diff options
Diffstat (limited to 'crates/typst-library/src')
79 files changed, 26417 insertions, 0 deletions
diff --git a/crates/typst-library/src/compute/calc.rs b/crates/typst-library/src/compute/calc.rs new file mode 100644 index 00000000..81715007 --- /dev/null +++ b/crates/typst-library/src/compute/calc.rs @@ -0,0 +1,1024 @@ +//! Calculations and processing of numeric values. + +use std::cmp; +use std::cmp::Ordering; +use std::ops::{Div, Rem}; + +use typst::eval::{Module, Scope}; + +use crate::prelude::*; + +/// A module with computational functions. +pub fn module() -> Module { + let mut scope = Scope::new(); + scope.define("abs", abs_func()); + scope.define("pow", pow_func()); + scope.define("exp", exp_func()); + scope.define("sqrt", sqrt_func()); + scope.define("sin", sin_func()); + scope.define("cos", cos_func()); + scope.define("tan", tan_func()); + scope.define("asin", asin_func()); + scope.define("acos", acos_func()); + scope.define("atan", atan_func()); + scope.define("atan2", atan2_func()); + scope.define("sinh", sinh_func()); + scope.define("cosh", cosh_func()); + scope.define("tanh", tanh_func()); + scope.define("log", log_func()); + scope.define("ln", ln_func()); + scope.define("fact", fact_func()); + scope.define("perm", perm_func()); + scope.define("binom", binom_func()); + scope.define("gcd", gcd_func()); + scope.define("lcm", lcm_func()); + scope.define("floor", floor_func()); + scope.define("ceil", ceil_func()); + scope.define("trunc", trunc_func()); + scope.define("fract", fract_func()); + scope.define("round", round_func()); + scope.define("clamp", clamp_func()); + scope.define("min", min_func()); + scope.define("max", max_func()); + scope.define("even", even_func()); + scope.define("odd", odd_func()); + scope.define("rem", rem_func()); + scope.define("quo", quo_func()); + scope.define("inf", f64::INFINITY); + scope.define("nan", f64::NAN); + scope.define("pi", std::f64::consts::PI); + scope.define("e", std::f64::consts::E); + Module::new("calc").with_scope(scope) +} + +/// Calculates the absolute value of a numeric value. +/// +/// ## Example { #example } +/// ```example +/// #calc.abs(-5) \ +/// #calc.abs(5pt - 2cm) \ +/// #calc.abs(2fr) +/// ``` +/// +/// Display: Absolute +/// Category: calculate +#[func] +pub fn abs( + /// The value whose absolute value to calculate. + value: ToAbs, +) -> Value { + value.0 +} + +/// A value of which the absolute value can be taken. +pub struct ToAbs(Value); + +cast! { + ToAbs, + v: i64 => Self(v.abs().into_value()), + v: f64 => Self(v.abs().into_value()), + v: Length => Self(Value::Length(v.try_abs() + .ok_or("cannot take absolute value of this length")?)), + v: Angle => Self(Value::Angle(v.abs())), + v: Ratio => Self(Value::Ratio(v.abs())), + v: Fr => Self(Value::Fraction(v.abs())), +} + +/// Raises a value to some exponent. +/// +/// ## Example { #example } +/// ```example +/// #calc.pow(2, 3) +/// ``` +/// +/// Display: Power +/// Category: calculate +#[func] +pub fn pow( + /// The base of the power. + base: Num, + /// The exponent of the power. + exponent: Spanned<Num>, + /// The callsite span. + span: Span, +) -> SourceResult<Num> { + match exponent.v { + _ if exponent.v.float() == 0.0 && base.float() == 0.0 => { + bail!(span, "zero to the power of zero is undefined") + } + Num::Int(i) if i32::try_from(i).is_err() => { + bail!(exponent.span, "exponent is too large") + } + Num::Float(f) if !f.is_normal() && f != 0.0 => { + bail!(exponent.span, "exponent may not be infinite, subnormal, or NaN") + } + _ => {} + }; + + let result = match (base, exponent.v) { + (Num::Int(a), Num::Int(b)) if b >= 0 => a + .checked_pow(b as u32) + .map(Num::Int) + .ok_or("the result is too large") + .at(span)?, + (a, b) => Num::Float(if a.float() == std::f64::consts::E { + b.float().exp() + } else if a.float() == 2.0 { + b.float().exp2() + } else if let Num::Int(b) = b { + a.float().powi(b as i32) + } else { + a.float().powf(b.float()) + }), + }; + + if result.float().is_nan() { + bail!(span, "the result is not a real number") + } + + Ok(result) +} + +/// Raises a value to some exponent of e. +/// +/// ## Example { #example } +/// ```example +/// #calc.exp(1) +/// ``` +/// +/// Display: Exponential +/// Category: calculate +#[func] +pub fn exp( + /// The exponent of the power. + exponent: Spanned<Num>, + /// The callsite span. + span: Span, +) -> SourceResult<f64> { + match exponent.v { + Num::Int(i) if i32::try_from(i).is_err() => { + bail!(exponent.span, "exponent is too large") + } + Num::Float(f) if !f.is_normal() && f != 0.0 => { + bail!(exponent.span, "exponent may not be infinite, subnormal, or NaN") + } + _ => {} + }; + + let result = exponent.v.float().exp(); + if result.is_nan() { + bail!(span, "the result is not a real number") + } + + Ok(result) +} + +/// Extracts the square root of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.sqrt(16) \ +/// #calc.sqrt(2.5) +/// ``` +/// +/// Display: Square Root +/// Category: calculate +#[func] +pub fn sqrt( + /// The number whose square root to calculate. Must be non-negative. + value: Spanned<Num>, +) -> SourceResult<f64> { + if value.v.float() < 0.0 { + bail!(value.span, "cannot take square root of negative number"); + } + Ok(value.v.float().sqrt()) +} + +/// Calculates the sine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as +/// radians. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.sin(90deg) == calc.sin(-270deg)) +/// #calc.sin(1.5) \ +/// #calc.sin(90deg) +/// ``` +/// +/// Display: Sine +/// Category: calculate +#[func] +pub fn sin( + /// The angle whose sine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.sin(), + AngleLike::Int(n) => (n as f64).sin(), + AngleLike::Float(n) => n.sin(), + } +} + +/// Calculates the cosine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as +/// radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.cos(90deg) \ +/// #calc.cos(1.5) \ +/// #calc.cos(90deg) +/// ``` +/// +/// Display: Cosine +/// Category: calculate +#[func] +pub fn cos( + /// The angle whose cosine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.cos(), + AngleLike::Int(n) => (n as f64).cos(), + AngleLike::Float(n) => n.cos(), + } +} + +/// Calculates the tangent of an angle. +/// +/// When called with an integer or a float, they will be interpreted as +/// radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.tan(1.5) \ +/// #calc.tan(90deg) +/// ``` +/// +/// Display: Tangent +/// Category: calculate +#[func] +pub fn tan( + /// The angle whose tangent to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.tan(), + AngleLike::Int(n) => (n as f64).tan(), + AngleLike::Float(n) => n.tan(), + } +} + +/// Calculates the arcsine of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.asin(0) \ +/// #calc.asin(1) +/// ``` +/// +/// Display: Arcsine +/// Category: calculate +#[func] +pub fn asin( + /// The number whose arcsine to calculate. Must be between -1 and 1. + value: Spanned<Num>, +) -> SourceResult<Angle> { + let val = value.v.float(); + if val < -1.0 || val > 1.0 { + bail!(value.span, "value must be between -1 and 1"); + } + Ok(Angle::rad(val.asin())) +} + +/// Calculates the arccosine of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.acos(0) \ +/// #calc.acos(1) +/// ``` +/// +/// Display: Arccosine +/// Category: calculate +#[func] +pub fn acos( + /// The number whose arcsine to calculate. Must be between -1 and 1. + value: Spanned<Num>, +) -> SourceResult<Angle> { + let val = value.v.float(); + if val < -1.0 || val > 1.0 { + bail!(value.span, "value must be between -1 and 1"); + } + Ok(Angle::rad(val.acos())) +} + +/// Calculates the arctangent of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.atan(0) \ +/// #calc.atan(1) +/// ``` +/// +/// Display: Arctangent +/// Category: calculate +#[func] +pub fn atan( + /// The number whose arctangent to calculate. + value: Num, +) -> Angle { + Angle::rad(value.float().atan()) +} + +/// Calculates the four-quadrant arctangent of a coordinate. +/// +/// The arguments are `(x, y)`, not `(y, x)`. +/// +/// ## Example { #example } +/// ```example +/// #calc.atan2(1, 1) \ +/// #calc.atan2(-2, -3) +/// ``` +/// +/// Display: Four-quadrant Arctangent +/// Category: calculate +#[func] +pub fn atan2( + /// The X coordinate. + x: Num, + /// The Y coordinate. + y: Num, +) -> Angle { + Angle::rad(f64::atan2(y.float(), x.float())) +} + +/// Calculates the hyperbolic sine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.sinh(0) \ +/// #calc.sinh(45deg) +/// ``` +/// +/// Display: Hyperbolic sine +/// Category: calculate +#[func] +pub fn sinh( + /// The angle whose hyperbolic sine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.to_rad().sinh(), + AngleLike::Int(n) => (n as f64).sinh(), + AngleLike::Float(n) => n.sinh(), + } +} + +/// Calculates the hyperbolic cosine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.cosh(0) \ +/// #calc.cosh(45deg) +/// ``` +/// +/// Display: Hyperbolic cosine +/// Category: calculate +#[func] +pub fn cosh( + /// The angle whose hyperbolic cosine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.to_rad().cosh(), + AngleLike::Int(n) => (n as f64).cosh(), + AngleLike::Float(n) => n.cosh(), + } +} + +/// Calculates the hyperbolic tangent of an angle. +/// +/// When called with an integer or a float, they will be interpreted as radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.tanh(0) \ +/// #calc.tanh(45deg) +/// ``` +/// +/// Display: Hyperbolic tangent +/// Category: calculate +#[func] +pub fn tanh( + /// The angle whose hyperbolic tangent to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.to_rad().tanh(), + AngleLike::Int(n) => (n as f64).tanh(), + AngleLike::Float(n) => n.tanh(), + } +} + +/// Calculates the logarithm of a number. +/// +/// If the base is not specified, the logarithm is calculated in base 10. +/// +/// ## Example { #example } +/// ```example +/// #calc.log(100) +/// ``` +/// +/// Display: Logarithm +/// Category: calculate +#[func] +pub fn log( + /// The number whose logarithm to calculate. Must be strictly positive. + value: Spanned<Num>, + /// The base of the logarithm. May not be zero. + #[named] + #[default(Spanned::new(10.0, Span::detached()))] + base: Spanned<f64>, + /// The callsite span. + span: Span, +) -> SourceResult<f64> { + let number = value.v.float(); + if number <= 0.0 { + bail!(value.span, "value must be strictly positive") + } + + if !base.v.is_normal() { + bail!(base.span, "base may not be zero, NaN, infinite, or subnormal") + } + + let result = if base.v == std::f64::consts::E { + number.ln() + } else if base.v == 2.0 { + number.log2() + } else if base.v == 10.0 { + number.log10() + } else { + number.log(base.v) + }; + + if result.is_infinite() || result.is_nan() { + bail!(span, "the result is not a real number") + } + + Ok(result) +} + +/// Calculates the natural logarithm of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.ln(calc.e) +/// ``` +/// +/// Display: Natural Logarithm +/// Category: calculate +#[func] +pub fn ln( + /// The number whose logarithm to calculate. Must be strictly positive. + value: Spanned<Num>, + /// The callsite span. + span: Span, +) -> SourceResult<f64> { + let number = value.v.float(); + if number <= 0.0 { + bail!(value.span, "value must be strictly positive") + } + + let result = number.ln(); + if result.is_infinite() { + bail!(span, "result close to -inf") + } + + Ok(result) +} + +/// Calculates the factorial of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.fact(5) +/// ``` +/// +/// Display: Factorial +/// Category: calculate +#[func] +pub fn fact( + /// The number whose factorial to calculate. Must be non-negative. + number: u64, +) -> StrResult<i64> { + Ok(fact_impl(1, number).ok_or("the result is too large")?) +} + +/// Calculates a permutation. +/// +/// ## Example { #example } +/// ```example +/// #calc.perm(10, 5) +/// ``` +/// +/// Display: Permutation +/// Category: calculate +#[func] +pub fn perm( + /// The base number. Must be non-negative. + base: u64, + /// The number of permutations. Must be non-negative. + numbers: u64, +) -> StrResult<i64> { + // By convention. + if base < numbers { + return Ok(0); + } + + Ok(fact_impl(base - numbers + 1, base).ok_or("the result is too large")?) +} + +/// Calculates the product of a range of numbers. Used to calculate +/// permutations. Returns None if the result is larger than `i64::MAX` +fn fact_impl(start: u64, end: u64) -> Option<i64> { + // By convention + if end + 1 < start { + return Some(0); + } + + let real_start: u64 = cmp::max(1, start); + let mut count: u64 = 1; + for i in real_start..=end { + count = count.checked_mul(i)?; + } + + count.try_into().ok() +} + +/// Calculates a binomial coefficient. +/// +/// ## Example { #example } +/// ```example +/// #calc.binom(10, 5) +/// ``` +/// +/// Display: Binomial +/// Category: calculate +#[func] +pub fn binom( + /// The upper coefficient. Must be non-negative. + n: u64, + /// The lower coefficient. Must be non-negative. + k: u64, +) -> StrResult<i64> { + Ok(binom_impl(n, k).ok_or("the result is too large")?) +} + +/// Calculates a binomial coefficient, with `n` the upper coefficient and `k` +/// the lower coefficient. Returns `None` if the result is larger than +/// `i64::MAX` +fn binom_impl(n: u64, k: u64) -> Option<i64> { + if k > n { + return Some(0); + } + + // By symmetry + let real_k = cmp::min(n - k, k); + if real_k == 0 { + return Some(1); + } + + let mut result: u64 = 1; + for i in 0..real_k { + result = result.checked_mul(n - i)?.checked_div(i + 1)?; + } + + result.try_into().ok() +} + +/// Calculates the greatest common divisor of two integers. +/// +/// ## Example { #example } +/// ```example +/// #calc.gcd(7, 42) +/// ``` +/// +/// Display: Greatest Common Divisor +/// Category: calculate +#[func] +pub fn gcd( + /// The first integer. + a: i64, + /// The second integer. + b: i64, +) -> i64 { + let (mut a, mut b) = (a, b); + while b != 0 { + let temp = b; + b = a % b; + a = temp; + } + + a.abs() +} + +/// Calculates the least common multiple of two integers. +/// +/// ## Example { #example } +/// ```example +/// #calc.lcm(96, 13) +/// ``` +/// +/// Display: Least Common Multiple +/// Category: calculate +#[func] +pub fn lcm( + /// The first integer. + a: i64, + /// The second integer. + b: i64, +) -> StrResult<i64> { + if a == b { + return Ok(a.abs()); + } + + Ok(a.checked_div(gcd(a, b)) + .and_then(|gcd| gcd.checked_mul(b)) + .map(|v| v.abs()) + .ok_or("the return value is too large")?) +} + +/// Rounds a number down to the nearest integer. +/// +/// If the number is already an integer, it is returned unchanged. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.floor(3.14) == 3) +/// #assert(calc.floor(3) == 3) +/// #calc.floor(500.1) +/// ``` +/// +/// Display: Round down +/// Category: calculate +#[func] +pub fn floor( + /// The number to round down. + value: Num, +) -> i64 { + match value { + Num::Int(n) => n, + Num::Float(n) => n.floor() as i64, + } +} + +/// Rounds a number up to the nearest integer. +/// +/// If the number is already an integer, it is returned unchanged. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.ceil(3.14) == 4) +/// #assert(calc.ceil(3) == 3) +/// #calc.ceil(500.1) +/// ``` +/// +/// Display: Round up +/// Category: calculate +#[func] +pub fn ceil( + /// The number to round up. + value: Num, +) -> i64 { + match value { + Num::Int(n) => n, + Num::Float(n) => n.ceil() as i64, + } +} + +/// Returns the integer part of a number. +/// +/// If the number is already an integer, it is returned unchanged. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.trunc(3) == 3) +/// #assert(calc.trunc(-3.7) == -3) +/// #assert(calc.trunc(15.9) == 15) +/// ``` +/// +/// Display: Truncate +/// Category: calculate +#[func] +pub fn trunc( + /// The number to truncate. + value: Num, +) -> i64 { + match value { + Num::Int(n) => n, + Num::Float(n) => n.trunc() as i64, + } +} + +/// Returns the fractional part of a number. +/// +/// If the number is an integer, returns `0`. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.fract(3) == 0) +/// #calc.fract(-3.1) +/// ``` +/// +/// Display: Fractional +/// Category: calculate +#[func] +pub fn fract( + /// The number to truncate. + value: Num, +) -> Num { + match value { + Num::Int(_) => Num::Int(0), + Num::Float(n) => Num::Float(n.fract()), + } +} + +/// Rounds a number to the nearest integer. +/// +/// Optionally, a number of decimal places can be specified. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.round(3.14) == 3) +/// #assert(calc.round(3.5) == 4) +/// #calc.round(3.1415, digits: 2) +/// ``` +/// +/// Display: Round +/// Category: calculate +#[func] +pub fn round( + /// The number to round. + value: Num, + /// The number of decimal places. + #[named] + #[default(0)] + digits: i64, +) -> Num { + match value { + Num::Int(n) if digits == 0 => Num::Int(n), + _ => { + let n = value.float(); + let factor = 10.0_f64.powi(digits as i32); + Num::Float((n * factor).round() / factor) + } + } +} + +/// Clamps a number between a minimum and maximum value. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.clamp(5, 0, 10) == 5) +/// #assert(calc.clamp(5, 6, 10) == 6) +/// #calc.clamp(5, 0, 4) +/// ``` +/// +/// Display: Clamp +/// Category: calculate +#[func] +pub fn clamp( + /// The number to clamp. + value: Num, + /// The inclusive minimum value. + min: Num, + /// The inclusive maximum value. + max: Spanned<Num>, +) -> SourceResult<Num> { + if max.v.float() < min.float() { + bail!(max.span, "max must be greater than or equal to min") + } + Ok(value.apply3(min, max.v, i64::clamp, f64::clamp)) +} + +/// Determines the minimum of a sequence of values. +/// +/// ## Example { #example } +/// ```example +/// #calc.min(1, -3, -5, 20, 3, 6) \ +/// #calc.min("typst", "in", "beta") +/// ``` +/// +/// Display: Minimum +/// Category: calculate +#[func] +pub fn min( + /// The sequence of values from which to extract the minimum. + /// Must not be empty. + #[variadic] + values: Vec<Spanned<Value>>, + /// The callsite span. + span: Span, +) -> SourceResult<Value> { + minmax(span, values, Ordering::Less) +} + +/// Determines the maximum of a sequence of values. +/// +/// ## Example { #example } +/// ```example +/// #calc.max(1, -3, -5, 20, 3, 6) \ +/// #calc.max("typst", "in", "beta") +/// ``` +/// +/// Display: Maximum +/// Category: calculate +#[func] +pub fn max( + /// The sequence of values from which to extract the maximum. + /// Must not be empty. + #[variadic] + values: Vec<Spanned<Value>>, + /// The callsite span. + span: Span, +) -> SourceResult<Value> { + minmax(span, values, Ordering::Greater) +} + +/// Find the minimum or maximum of a sequence of values. +fn minmax( + span: Span, + values: Vec<Spanned<Value>>, + goal: Ordering, +) -> SourceResult<Value> { + let mut iter = values.into_iter(); + let Some(Spanned { v: mut extremum, ..}) = iter.next() else { + bail!(span, "expected at least one value"); + }; + + for Spanned { v, span } in iter { + let ordering = typst::eval::ops::compare(&v, &extremum).at(span)?; + if ordering == goal { + extremum = v; + } + } + + Ok(extremum) +} + +/// Determines whether an integer is even. +/// +/// ## Example { #example } +/// ```example +/// #calc.even(4) \ +/// #calc.even(5) \ +/// #range(10).filter(calc.even) +/// ``` +/// +/// Display: Even +/// Category: calculate +#[func] +pub fn even( + /// The number to check for evenness. + value: i64, +) -> bool { + value % 2 == 0 +} + +/// Determines whether an integer is odd. +/// +/// ## Example { #example } +/// ```example +/// #calc.odd(4) \ +/// #calc.odd(5) \ +/// #range(10).filter(calc.odd) +/// ``` +/// +/// Display: Odd +/// Category: calculate +#[func] +pub fn odd( + /// The number to check for oddness. + value: i64, +) -> bool { + value % 2 != 0 +} + +/// Calculates the remainder of two numbers. +/// +/// ## Example { #example } +/// ```example +/// #calc.rem(20, 6) \ +/// #calc.rem(1.75, 0.5) +/// ``` +/// +/// Display: Remainder +/// Category: calculate +#[func] +pub fn rem( + /// The dividend of the remainder. + dividend: Num, + /// The divisor of the remainder. + divisor: Spanned<Num>, +) -> SourceResult<Num> { + if divisor.v.float() == 0.0 { + bail!(divisor.span, "divisor must not be zero"); + } + Ok(dividend.apply2(divisor.v, Rem::rem, Rem::rem)) +} + +/// Calculates the quotient of two numbers. +/// +/// ## Example { #example } +/// ```example +/// #calc.quo(14, 5) \ +/// #calc.quo(3.46, 0.5) +/// ``` +/// +/// Display: Quotient +/// Category: calculate +#[func] +pub fn quo( + /// The dividend of the quotient. + dividend: Num, + /// The divisor of the quotient. + divisor: Spanned<Num>, +) -> SourceResult<i64> { + if divisor.v.float() == 0.0 { + bail!(divisor.span, "divisor must not be zero"); + } + + Ok(floor(dividend.apply2(divisor.v, Div::div, Div::div))) +} + +/// A value which can be passed to functions that work with integers and floats. +#[derive(Debug, Copy, Clone)] +pub enum Num { + Int(i64), + Float(f64), +} + +impl Num { + pub fn apply2( + self, + other: Self, + int: impl FnOnce(i64, i64) -> i64, + float: impl FnOnce(f64, f64) -> f64, + ) -> Num { + match (self, other) { + (Self::Int(a), Self::Int(b)) => Num::Int(int(a, b)), + (a, b) => Num::Float(float(a.float(), b.float())), + } + } + + pub fn apply3( + self, + other: Self, + third: Self, + int: impl FnOnce(i64, i64, i64) -> i64, + float: impl FnOnce(f64, f64, f64) -> f64, + ) -> Num { + match (self, other, third) { + (Self::Int(a), Self::Int(b), Self::Int(c)) => Num::Int(int(a, b, c)), + (a, b, c) => Num::Float(float(a.float(), b.float(), c.float())), + } + } + + pub fn float(self) -> f64 { + match self { + Self::Int(v) => v as f64, + Self::Float(v) => v, + } + } +} + +cast! { + Num, + self => match self { + Self::Int(v) => v.into_value(), + Self::Float(v) => v.into_value(), + }, + v: i64 => Self::Int(v), + v: f64 => Self::Float(v), +} + +/// A value that can be passed to a trigonometric function. +pub enum AngleLike { + Int(i64), + Float(f64), + Angle(Angle), +} + +cast! { + AngleLike, + v: i64 => Self::Int(v), + v: f64 => Self::Float(v), + v: Angle => Self::Angle(v), +} diff --git a/crates/typst-library/src/compute/construct.rs b/crates/typst-library/src/compute/construct.rs new file mode 100644 index 00000000..956212ee --- /dev/null +++ b/crates/typst-library/src/compute/construct.rs @@ -0,0 +1,743 @@ +use std::num::NonZeroI64; +use std::str::FromStr; + +use time::{Month, PrimitiveDateTime}; + +use typst::eval::{Datetime, Regex}; + +use crate::prelude::*; + +/// Converts a value to an integer. +/// +/// - Booleans are converted to `0` or `1`. +/// - Floats are floored to the next 64-bit integer. +/// - Strings are parsed in base 10. +/// +/// ## Example { #example } +/// ```example +/// #int(false) \ +/// #int(true) \ +/// #int(2.7) \ +/// #{ int("27") + int("4") } +/// ``` +/// +/// Display: Integer +/// Category: construct +#[func] +pub fn int( + /// The value that should be converted to an integer. + value: ToInt, +) -> i64 { + value.0 +} + +/// A value that can be cast to an integer. +pub struct ToInt(i64); + +cast! { + ToInt, + v: bool => Self(v as i64), + v: i64 => Self(v), + v: f64 => Self(v as i64), + v: EcoString => Self(v.parse().map_err(|_| eco_format!("invalid integer: {}", v))?), +} + +/// Converts a value to a float. +/// +/// - Booleans are converted to `0.0` or `1.0`. +/// - Integers are converted to the closest 64-bit float. +/// - Ratios are divided by 100%. +/// - Strings are parsed in base 10 to the closest 64-bit float. +/// Exponential notation is supported. +/// +/// ## Example { #example } +/// ```example +/// #float(false) \ +/// #float(true) \ +/// #float(4) \ +/// #float(40%) \ +/// #float("2.7") \ +/// #float("1e5") +/// ``` +/// +/// Display: Float +/// Category: construct +#[func] +pub fn float( + /// The value that should be converted to a float. + value: ToFloat, +) -> f64 { + value.0 +} + +/// A value that can be cast to a float. +pub struct ToFloat(f64); + +cast! { + ToFloat, + v: bool => Self(v as i64 as f64), + v: i64 => Self(v as f64), + v: f64 => Self(v), + v: Ratio => Self(v.get()), + v: EcoString => Self(v.parse().map_err(|_| eco_format!("invalid float: {}", v))?), +} + +/// Creates a grayscale color. +/// +/// ## Example { #example } +/// ```example +/// #for x in range(250, step: 50) { +/// box(square(fill: luma(x))) +/// } +/// ``` +/// +/// Display: Luma +/// Category: construct +#[func] +pub fn luma( + /// The gray component. + gray: Component, +) -> Color { + LumaColor::new(gray.0).into() +} + +/// Creates an RGB(A) color. +/// +/// The color is specified in the sRGB color space. +/// +/// _Note:_ While you can specify transparent colors and Typst's preview will +/// render them correctly, the PDF export does not handle them properly at the +/// moment. This will be fixed in the future. +/// +/// ## Example { #example } +/// ```example +/// #square(fill: rgb("#b1f2eb")) +/// #square(fill: rgb(87, 127, 230)) +/// #square(fill: rgb(25%, 13%, 65%)) +/// ``` +/// +/// Display: RGB +/// Category: construct +#[func] +pub fn rgb( + /// The color in hexadecimal notation. + /// + /// Accepts three, four, six or eight hexadecimal digits and optionally + /// a leading hashtag. + /// + /// If this string is given, the individual components should not be given. + /// + /// ```example + /// #text(16pt, rgb("#239dad"))[ + /// *Typst* + /// ] + /// ``` + #[external] + hex: EcoString, + /// The red component. + #[external] + red: Component, + /// The green component. + #[external] + green: Component, + /// The blue component. + #[external] + blue: Component, + /// The alpha component. + #[external] + alpha: Component, + /// The arguments. + args: Args, +) -> SourceResult<Color> { + let mut args = args; + Ok(if let Some(string) = args.find::<Spanned<EcoString>>()? { + match RgbaColor::from_str(&string.v) { + Ok(color) => color.into(), + Err(msg) => bail!(string.span, "{msg}"), + } + } else { + let Component(r) = args.expect("red component")?; + let Component(g) = args.expect("green component")?; + let Component(b) = args.expect("blue component")?; + let Component(a) = args.eat()?.unwrap_or(Component(255)); + RgbaColor::new(r, g, b, a).into() + }) +} + +/// An integer or ratio component. +pub struct Component(u8); + +cast! { + Component, + v: i64 => match v { + 0 ..= 255 => Self(v as u8), + _ => bail!("number must be between 0 and 255"), + }, + v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) { + Self((v.get() * 255.0).round() as u8) + } else { + bail!("ratio must be between 0% and 100%"); + }, +} + +/// Creates a new datetime. +/// +/// You can specify the [datetime]($type/datetime) using a year, month, day, +/// hour, minute, and second. You can also get the current date with +/// [`datetime.today`]($func/datetime.today). +/// +/// ## Example +/// ```example +/// #let date = datetime( +/// year: 2012, +/// month: 8, +/// day: 3, +/// ) +/// +/// #date.display() \ +/// #date.display( +/// "[day].[month].[year]" +/// ) +/// ``` +/// +/// ## Format +/// _Note_: Depending on which components of the datetime you specify, Typst +/// will store it in one of the following three ways: +/// * If you specify year, month and day, Typst will store just a date. +/// * If you specify hour, minute and second, Typst will store just a time. +/// * If you specify all of year, month, day, hour, minute and second, Typst +/// will store a full datetime. +/// +/// Depending on how it is stored, the [`display`]($type/datetime.display) +/// method will choose a different formatting by default. +/// +/// Display: Datetime +/// Category: construct +#[func] +#[scope( + scope.define("today", datetime_today_func()); + scope +)] +pub fn datetime( + /// The year of the datetime. + #[named] + year: Option<YearComponent>, + /// The month of the datetime. + #[named] + month: Option<MonthComponent>, + /// The day of the datetime. + #[named] + day: Option<DayComponent>, + /// The hour of the datetime. + #[named] + hour: Option<HourComponent>, + /// The minute of the datetime. + #[named] + minute: Option<MinuteComponent>, + /// The second of the datetime. + #[named] + second: Option<SecondComponent>, +) -> StrResult<Datetime> { + let time = match (hour, minute, second) { + (Some(hour), Some(minute), Some(second)) => { + match time::Time::from_hms(hour.0, minute.0, second.0) { + Ok(time) => Some(time), + Err(_) => bail!("time is invalid"), + } + } + (None, None, None) => None, + _ => bail!("time is incomplete"), + }; + + let date = match (year, month, day) { + (Some(year), Some(month), Some(day)) => { + match time::Date::from_calendar_date(year.0, month.0, day.0) { + Ok(date) => Some(date), + Err(_) => bail!("date is invalid"), + } + } + (None, None, None) => None, + _ => bail!("date is incomplete"), + }; + + Ok(match (date, time) { + (Some(date), Some(time)) => { + Datetime::Datetime(PrimitiveDateTime::new(date, time)) + } + (Some(date), None) => Datetime::Date(date), + (None, Some(time)) => Datetime::Time(time), + (None, None) => { + bail!("at least one of date or time must be fully specified") + } + }) +} + +pub struct YearComponent(i32); +pub struct MonthComponent(Month); +pub struct DayComponent(u8); +pub struct HourComponent(u8); +pub struct MinuteComponent(u8); +pub struct SecondComponent(u8); + +cast! { + YearComponent, + v: i32 => Self(v), +} + +cast! { + MonthComponent, + v: u8 => Self(Month::try_from(v).map_err(|_| "month is invalid")?) +} + +cast! { + DayComponent, + v: u8 => Self(v), +} + +cast! { + HourComponent, + v: u8 => Self(v), +} + +cast! { + MinuteComponent, + v: u8 => Self(v), +} + +cast! { + SecondComponent, + v: u8 => Self(v), +} + +/// Returns the current date. +/// +/// Refer to the documentation of the [`display`]($type/datetime.display) method +/// for details on how to affect the formatting of the date. +/// +/// ## Example +/// ```example +/// Today's date is +/// #datetime.today().display(). +/// ``` +/// +/// Display: Today +/// Category: construct +#[func] +pub fn datetime_today( + /// An offset to apply to the current UTC date. If set to `{auto}`, the + /// offset will be the local offset. + #[named] + #[default] + offset: Smart<i64>, + /// The virtual machine. + vt: &mut Vt, +) -> StrResult<Datetime> { + Ok(vt + .world + .today(offset.as_custom()) + .ok_or("unable to get the current date")?) +} + +/// Creates a CMYK color. +/// +/// This is useful if you want to target a specific printer. The conversion +/// to RGB for display preview might differ from how your printer reproduces +/// the color. +/// +/// ## Example { #example } +/// ```example +/// #square( +/// fill: cmyk(27%, 0%, 3%, 5%) +/// ) +/// ```` +/// +/// Display: CMYK +/// Category: construct +#[func] +pub fn cmyk( + /// The cyan component. + cyan: RatioComponent, + /// The magenta component. + magenta: RatioComponent, + /// The yellow component. + yellow: RatioComponent, + /// The key component. + key: RatioComponent, +) -> Color { + CmykColor::new(cyan.0, magenta.0, yellow.0, key.0).into() +} + +/// A component that must be a ratio. +pub struct RatioComponent(u8); + +cast! { + RatioComponent, + v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) { + Self((v.get() * 255.0).round() as u8) + } else { + bail!("ratio must be between 0% and 100%"); + }, +} + +/// Creates a custom symbol with modifiers. +/// +/// ## Example { #example } +/// ```example +/// #let envelope = symbol( +/// "🖂", +/// ("stamped", "🖃"), +/// ("stamped.pen", "🖆"), +/// ("lightning", "🖄"), +/// ("fly", "🖅"), +/// ) +/// +/// #envelope +/// #envelope.stamped +/// #envelope.stamped.pen +/// #envelope.lightning +/// #envelope.fly +/// ``` +/// +/// Display: Symbol +/// Category: construct +#[func] +pub fn symbol( + /// The variants of the symbol. + /// + /// Can be a just a string consisting of a single character for the + /// modifierless variant or an array with two strings specifying the modifiers + /// and the symbol. Individual modifiers should be separated by dots. When + /// displaying a symbol, Typst selects the first from the variants that have + /// all attached modifiers and the minimum number of other modifiers. + #[variadic] + variants: Vec<Spanned<Variant>>, + /// The callsite span. + span: Span, +) -> SourceResult<Symbol> { + let mut list = Vec::new(); + if variants.is_empty() { + bail!(span, "expected at least one variant"); + } + for Spanned { v, span } in variants { + if list.iter().any(|(prev, _)| &v.0 == prev) { + bail!(span, "duplicate variant"); + } + list.push((v.0, v.1)); + } + Ok(Symbol::runtime(list.into_boxed_slice())) +} + +/// A value that can be cast to a symbol. +pub struct Variant(EcoString, char); + +cast! { + Variant, + c: char => Self(EcoString::new(), c), + array: Array => { + let mut iter = array.into_iter(); + match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => Self(a.cast()?, b.cast()?), + _ => bail!("point array must contain exactly two entries"), + } + }, +} + +/// Converts a value to a string. +/// +/// - Integers are formatted in base 10. This can be overridden with the +/// optional `base` parameter. +/// - Floats are formatted in base 10 and never in exponential notation. +/// - From labels the name is extracted. +/// +/// If you wish to convert from and to Unicode code points, see +/// [`str.to-unicode`]($func/str.to-unicode) and +/// [`str.from-unicode`]($func/str.from-unicode). +/// +/// ## Example { #example } +/// ```example +/// #str(10) \ +/// #str(4000, base: 16) \ +/// #str(2.7) \ +/// #str(1e8) \ +/// #str(<intro>) +/// ``` +/// +/// Display: String +/// Category: construct +#[func] +#[scope( + scope.define("to-unicode", str_to_unicode_func()); + scope.define("from-unicode", str_from_unicode_func()); + scope +)] +pub fn str( + /// The value that should be converted to a string. + value: ToStr, + /// The base (radix) to display integers in, between 2 and 36. + #[named] + #[default(Spanned::new(10, Span::detached()))] + base: Spanned<i64>, +) -> SourceResult<Str> { + Ok(match value { + ToStr::Str(s) => { + if base.v != 10 { + bail!(base.span, "base is only supported for integers"); + } + s + } + ToStr::Int(n) => { + if base.v < 2 || base.v > 36 { + bail!(base.span, "base must be between 2 and 36"); + } + int_to_base(n, base.v).into() + } + }) +} + +/// A value that can be cast to a string. +pub enum ToStr { + /// A string value ready to be used as-is. + Str(Str), + /// An integer about to be formatted in a given base. + Int(i64), +} + +cast! { + ToStr, + v: i64 => Self::Int(v), + v: f64 => Self::Str(format_str!("{}", v)), + v: Label => Self::Str(v.0.into()), + v: Str => Self::Str(v), +} + +/// Format an integer in a base. +fn int_to_base(mut n: i64, base: i64) -> EcoString { + if n == 0 { + return "0".into(); + } + + // In Rust, `format!("{:x}", -14i64)` is not `-e` but `fffffffffffffff2`. + // So we can only use the built-in for decimal, not bin/oct/hex. + if base == 10 { + return eco_format!("{n}"); + } + + // The largest output is `to_base(i64::MIN, 2)`, which is 65 chars long. + const SIZE: usize = 65; + let mut digits = [b'\0'; SIZE]; + let mut i = SIZE; + + // It's tempting to take the absolute value, but this will fail for i64::MIN. + // Instead, we turn n negative, as -i64::MAX is perfectly representable. + let negative = n < 0; + if n > 0 { + n = -n; + } + + while n != 0 { + let digit = char::from_digit(-(n % base) as u32, base as u32); + i -= 1; + digits[i] = digit.unwrap_or('?') as u8; + n /= base; + } + + if negative { + i -= 1; + digits[i] = b'-'; + } + + std::str::from_utf8(&digits[i..]).unwrap_or_default().into() +} + +/// Converts a character into its corresponding code point. +/// +/// ## Example +/// ```example +/// #str.to-unicode("a") \ +/// #"a\u{0300}".codepoints().map(str.to-unicode) +/// ``` +/// +/// Display: String To Unicode +/// Category: construct +#[func] +pub fn str_to_unicode( + /// The character that should be converted. + value: char, +) -> u32 { + value.into() +} + +/// Converts a Unicode code point into its corresponding string. +/// +/// ```example +/// #str.from-unicode(97) +/// ``` +/// +/// Display: String From Unicode +/// Category: construct +#[func] +pub fn str_from_unicode( + /// The code point that should be converted. + value: CodePoint, +) -> Str { + format_str!("{}", value.0) +} + +/// The numeric representation of a single unicode code point. +pub struct CodePoint(char); + +cast! { + CodePoint, + v: i64 => { + Self(v.try_into().ok().and_then(|v: u32| v.try_into().ok()).ok_or_else( + || eco_format!("{:#x} is not a valid codepoint", v), + )?) + }, +} + +/// Creates a label from a string. +/// +/// Inserting a label into content attaches it to the closest previous element +/// that is not a space. Then, the element can be [referenced]($func/ref) and +/// styled through the label. +/// +/// ## Example { #example } +/// ```example +/// #show <a>: set text(blue) +/// #show label("b"): set text(red) +/// +/// = Heading <a> +/// *Strong* #label("b") +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: You can create a label by enclosing +/// its name in angle brackets. This works both in markup and code. +/// +/// Display: Label +/// Category: construct +#[func] +pub fn label( + /// The name of the label. + name: EcoString, +) -> Label { + Label(name) +} + +/// Creates a regular expression from a string. +/// +/// The result can be used as a +/// [show rule selector]($styling/#show-rules) and with +/// [string methods]($type/string) like `find`, `split`, and `replace`. +/// +/// See [the specification of the supported syntax](https://docs.rs/regex/latest/regex/#syntax). +/// +/// ## Example { #example } +/// ```example +/// // Works with show rules. +/// #show regex("\d+"): set text(red) +/// +/// The numbers 1 to 10. +/// +/// // Works with string methods. +/// #("a,b;c" +/// .split(regex("[,;]"))) +/// ``` +/// +/// Display: Regex +/// Category: construct +#[func] +pub fn regex( + /// The regular expression as a string. + /// + /// Most regex escape sequences just work because they are not valid Typst + /// escape sequences. To produce regex escape sequences that are also valid in + /// Typst (e.g. `[\\]`), you need to escape twice. Thus, to match a verbatim + /// backslash, you would need to write `{regex("\\\\")}`. + /// + /// If you need many escape sequences, you can also create a raw element + /// and extract its text to use it for your regular expressions: + /// ```{regex(`\d+\.\d+\.\d+`.text)}```. + regex: Spanned<EcoString>, +) -> SourceResult<Regex> { + Regex::new(®ex.v).at(regex.span) +} + +/// Creates an array consisting of consecutive integers. +/// +/// If you pass just one positional parameter, it is interpreted as the `end` of +/// the range. If you pass two, they describe the `start` and `end` of the +/// range. +/// +/// ## Example { #example } +/// ```example +/// #range(5) \ +/// #range(2, 5) \ +/// #range(20, step: 4) \ +/// #range(21, step: 4) \ +/// #range(5, 2, step: -1) +/// ``` +/// +/// Display: Range +/// Category: construct +#[func] +pub fn range( + /// The start of the range (inclusive). + #[external] + #[default] + start: i64, + /// The end of the range (exclusive). + #[external] + end: i64, + /// The distance between the generated numbers. + #[named] + #[default(NonZeroI64::new(1).unwrap())] + step: NonZeroI64, + /// The arguments. + args: Args, +) -> SourceResult<Array> { + let mut args = args; + let first = args.expect::<i64>("end")?; + let (start, end) = match args.eat::<i64>()? { + Some(second) => (first, second), + None => (0, first), + }; + + let step = step.get(); + + let mut x = start; + let mut array = Array::new(); + + while x.cmp(&end) == 0.cmp(&step) { + array.push(Value::Int(x)); + x += step; + } + + Ok(array) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_base() { + assert_eq!(&int_to_base(0, 10), "0"); + assert_eq!(&int_to_base(0, 16), "0"); + assert_eq!(&int_to_base(0, 36), "0"); + assert_eq!( + &int_to_base(i64::MAX, 2), + "111111111111111111111111111111111111111111111111111111111111111" + ); + assert_eq!( + &int_to_base(i64::MIN, 2), + "-1000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!(&int_to_base(i64::MAX, 10), "9223372036854775807"); + assert_eq!(&int_to_base(i64::MIN, 10), "-9223372036854775808"); + assert_eq!(&int_to_base(i64::MAX, 16), "7fffffffffffffff"); + assert_eq!(&int_to_base(i64::MIN, 16), "-8000000000000000"); + assert_eq!(&int_to_base(i64::MAX, 36), "1y2p0ij32e8e7"); + assert_eq!(&int_to_base(i64::MIN, 36), "-1y2p0ij32e8e8"); + } +} diff --git a/crates/typst-library/src/compute/data.rs b/crates/typst-library/src/compute/data.rs new file mode 100644 index 00000000..6e3a298e --- /dev/null +++ b/crates/typst-library/src/compute/data.rs @@ -0,0 +1,492 @@ +use typst::diag::{format_xml_like_error, FileError}; +use typst::eval::Datetime; + +use crate::prelude::*; + +/// Reads plain text from a file. +/// +/// The file will be read and returned as a string. +/// +/// ## Example { #example } +/// ```example +/// #let text = read("data.html") +/// +/// An example for a HTML file:\ +/// #raw(text, lang: "html") +/// ``` +/// +/// Display: Read +/// Category: data-loading +#[func] +pub fn read( + /// Path to a file. + path: Spanned<EcoString>, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Str> { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let text = std::str::from_utf8(&data) + .map_err(|_| "file is not valid utf-8") + .at(span)?; + Ok(text.into()) +} + +/// Reads structured data from a CSV file. +/// +/// The CSV file will be read and parsed into a 2-dimensional array of strings: +/// Each row in the CSV file will be represented as an array of strings, and all +/// rows will be collected into a single array. Header rows will not be +/// stripped. +/// +/// ## Example { #example } +/// ```example +/// #let results = csv("data.csv") +/// +/// #table( +/// columns: 2, +/// [*Condition*], [*Result*], +/// ..results.flatten(), +/// ) +/// ``` +/// +/// Display: CSV +/// Category: data-loading +#[func] +pub fn csv( + /// Path to a CSV file. + path: Spanned<EcoString>, + /// The delimiter that separates columns in the CSV file. + /// Must be a single ASCII character. + #[named] + #[default] + delimiter: Delimiter, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Array> { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + + let mut builder = csv::ReaderBuilder::new(); + builder.has_headers(false); + builder.delimiter(delimiter.0 as u8); + + let mut reader = builder.from_reader(data.as_slice()); + let mut array = Array::new(); + + for (line, result) in reader.records().enumerate() { + // Original solution use line from error, but that is incorrect with + // `has_headers` set to `false`. See issue: + // https://github.com/BurntSushi/rust-csv/issues/184 + let line = line + 1; // Counting lines from 1 + let row = result.map_err(|err| format_csv_error(err, line)).at(span)?; + let sub = row.into_iter().map(|field| field.into_value()).collect(); + array.push(Value::Array(sub)) + } + + Ok(array) +} + +/// The delimiter to use when parsing CSV files. +pub struct Delimiter(char); + +impl Default for Delimiter { + fn default() -> Self { + Self(',') + } +} + +cast! { + Delimiter, + self => self.0.into_value(), + v: EcoString => { + let mut chars = v.chars(); + let first = chars.next().ok_or("delimiter must not be empty")?; + if chars.next().is_some() { + bail!("delimiter must be a single character"); + } + + if !first.is_ascii() { + bail!("delimiter must be an ASCII character"); + } + + Self(first) + }, +} + +/// Format the user-facing CSV error message. +fn format_csv_error(error: csv::Error, line: usize) -> EcoString { + match error.kind() { + csv::ErrorKind::Utf8 { .. } => "file is not valid utf-8".into(), + csv::ErrorKind::UnequalLengths { expected_len, len, .. } => { + eco_format!( + "failed to parse csv file: found {len} instead of {expected_len} fields in line {line}" + ) + } + _ => "failed to parse csv file".into(), + } +} + +/// Reads structured data from a JSON file. +/// +/// The file must contain a valid JSON object or array. JSON objects will be +/// converted into Typst dictionaries, and JSON arrays will be converted into +/// Typst arrays. Strings and booleans will be converted into the Typst +/// equivalents, `null` will be converted into `{none}`, and numbers will be +/// converted to floats or integers depending on whether they are whole numbers. +/// +/// The function returns a dictionary or an array, depending on the JSON file. +/// +/// The JSON files in the example contain objects with the keys `temperature`, +/// `unit`, and `weather`. +/// +/// ## Example { #example } +/// ```example +/// #let forecast(day) = block[ +/// #box(square( +/// width: 2cm, +/// inset: 8pt, +/// fill: if day.weather == "sunny" { +/// yellow +/// } else { +/// aqua +/// }, +/// align( +/// bottom + right, +/// strong(day.weather), +/// ), +/// )) +/// #h(6pt) +/// #set text(22pt, baseline: -8pt) +/// #day.temperature °#day.unit +/// ] +/// +/// #forecast(json("monday.json")) +/// #forecast(json("tuesday.json")) +/// ``` +/// +/// Display: JSON +/// Category: data-loading +#[func] +pub fn json( + /// Path to a JSON file. + path: Spanned<EcoString>, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Value> { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let value: serde_json::Value = + serde_json::from_slice(&data).map_err(format_json_error).at(span)?; + Ok(convert_json(value)) +} + +/// Convert a JSON value to a Typst value. +fn convert_json(value: serde_json::Value) -> Value { + match value { + serde_json::Value::Null => Value::None, + serde_json::Value::Bool(v) => v.into_value(), + serde_json::Value::Number(v) => match v.as_i64() { + Some(int) => int.into_value(), + None => v.as_f64().unwrap_or(f64::NAN).into_value(), + }, + serde_json::Value::String(v) => v.into_value(), + serde_json::Value::Array(v) => { + v.into_iter().map(convert_json).collect::<Array>().into_value() + } + serde_json::Value::Object(v) => v + .into_iter() + .map(|(key, value)| (key.into(), convert_json(value))) + .collect::<Dict>() + .into_value(), + } +} + +/// Format the user-facing JSON error message. +fn format_json_error(error: serde_json::Error) -> EcoString { + assert!(error.is_syntax() || error.is_eof()); + eco_format!("failed to parse json file: syntax error in line {}", error.line()) +} + +/// Reads structured data from a TOML file. +/// +/// The file must contain a valid TOML table. TOML tables will be +/// converted into Typst dictionaries, and TOML arrays will be converted into +/// Typst arrays. Strings and booleans will be converted into the Typst +/// equivalents and numbers will be converted to floats or integers depending on +/// whether they are whole numbers. For the time being, datetimes will be +/// converted to strings as Typst does not have a built-in datetime yet. +/// +/// The TOML file in the example consists of a table with the keys `title`, +/// `version`, and `authors`. +/// +/// ## Example { #example } +/// ```example +/// #let details = toml("details.toml") +/// +/// Title: #details.title \ +/// Version: #details.version \ +/// Authors: #(details.authors +/// .join(", ", last: " and ")) +/// ``` +/// +/// Display: TOML +/// Category: data-loading +#[func] +pub fn toml( + /// Path to a TOML file. + path: Spanned<EcoString>, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Value> { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + + let raw = std::str::from_utf8(&data) + .map_err(|_| "file is not valid utf-8") + .at(span)?; + + let value: toml::Value = toml::from_str(raw).map_err(format_toml_error).at(span)?; + Ok(convert_toml(value)) +} + +/// Convert a TOML value to a Typst value. +fn convert_toml(value: toml::Value) -> Value { + match value { + toml::Value::String(v) => v.into_value(), + toml::Value::Integer(v) => v.into_value(), + toml::Value::Float(v) => v.into_value(), + toml::Value::Boolean(v) => v.into_value(), + toml::Value::Array(v) => { + v.into_iter().map(convert_toml).collect::<Array>().into_value() + } + toml::Value::Table(v) => v + .into_iter() + .map(|(key, value)| (key.into(), convert_toml(value))) + .collect::<Dict>() + .into_value(), + toml::Value::Datetime(v) => match (v.date, v.time) { + (None, None) => Value::None, + (Some(date), None) => { + Datetime::from_ymd(date.year as i32, date.month, date.day).into_value() + } + (None, Some(time)) => { + Datetime::from_hms(time.hour, time.minute, time.second).into_value() + } + (Some(date), Some(time)) => Datetime::from_ymd_hms( + date.year as i32, + date.month, + date.day, + time.hour, + time.minute, + time.second, + ) + .into_value(), + }, + } +} + +/// Format the user-facing TOML error message. +fn format_toml_error(error: toml::de::Error) -> EcoString { + if let Some(range) = error.span() { + eco_format!( + "failed to parse toml file: {}, index {}-{}", + error.message(), + range.start, + range.end + ) + } else { + eco_format!("failed to parse toml file: {}", error.message()) + } +} + +/// Reads structured data from a YAML file. +/// +/// The file must contain a valid YAML object or array. YAML mappings will be +/// converted into Typst dictionaries, and YAML sequences will be converted into +/// Typst arrays. Strings and booleans will be converted into the Typst +/// equivalents, null-values (`null`, `~` or empty ``) will be converted into +/// `{none}`, and numbers will be converted to floats or integers depending on +/// whether they are whole numbers. +/// +/// Note that mapping keys that are not a string cause the entry to be +/// discarded. +/// +/// Custom YAML tags are ignored, though the loaded value will still be +/// present. +/// +/// The function returns a dictionary or value or an array, depending on +/// the YAML file. +/// +/// The YAML files in the example contain objects with authors as keys, +/// each with a sequence of their own submapping with the keys +/// "title" and "published" +/// +/// ## Example { #example } +/// ```example +/// #let bookshelf(contents) = { +/// for (author, works) in contents { +/// author +/// for work in works [ +/// - #work.title (#work.published) +/// ] +/// } +/// } +/// +/// #bookshelf( +/// yaml("scifi-authors.yaml") +/// ) +/// ``` +/// +/// Display: YAML +/// Category: data-loading +#[func] +pub fn yaml( + /// Path to a YAML file. + path: Spanned<EcoString>, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Value> { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let value: serde_yaml::Value = + serde_yaml::from_slice(&data).map_err(format_yaml_error).at(span)?; + Ok(convert_yaml(value)) +} + +/// Convert a YAML value to a Typst value. +fn convert_yaml(value: serde_yaml::Value) -> Value { + match value { + serde_yaml::Value::Null => Value::None, + serde_yaml::Value::Bool(v) => v.into_value(), + serde_yaml::Value::Number(v) => match v.as_i64() { + Some(int) => int.into_value(), + None => v.as_f64().unwrap_or(f64::NAN).into_value(), + }, + serde_yaml::Value::String(v) => v.into_value(), + serde_yaml::Value::Sequence(v) => { + v.into_iter().map(convert_yaml).collect::<Array>().into_value() + } + serde_yaml::Value::Mapping(v) => v + .into_iter() + .map(|(key, value)| (convert_yaml_key(key), convert_yaml(value))) + .filter_map(|(key, value)| key.map(|key| (key, value))) + .collect::<Dict>() + .into_value(), + } +} + +/// Converts an arbitrary YAML mapping key into a Typst Dict Key. +/// Currently it only does so for strings, everything else +/// returns None +fn convert_yaml_key(key: serde_yaml::Value) -> Option<Str> { + match key { + serde_yaml::Value::String(v) => Some(Str::from(v)), + _ => None, + } +} + +/// Format the user-facing YAML error message. +fn format_yaml_error(error: serde_yaml::Error) -> EcoString { + eco_format!("failed to parse yaml file: {}", error.to_string().trim()) +} + +/// Reads structured data from an XML file. +/// +/// The XML file is parsed into an array of dictionaries and strings. XML nodes +/// can be elements or strings. Elements are represented as dictionaries with +/// the the following keys: +/// +/// - `tag`: The name of the element as a string. +/// - `attrs`: A dictionary of the element's attributes as strings. +/// - `children`: An array of the element's child nodes. +/// +/// The XML file in the example contains a root `news` tag with multiple +/// `article` tags. Each article has a `title`, `author`, and `content` tag. The +/// `content` tag contains one or more paragraphs, which are represented as `p` +/// tags. +/// +/// ## Example { #example } +/// ```example +/// #let find-child(elem, tag) = { +/// elem.children +/// .find(e => "tag" in e and e.tag == tag) +/// } +/// +/// #let article(elem) = { +/// let title = find-child(elem, "title") +/// let author = find-child(elem, "author") +/// let pars = find-child(elem, "content") +/// +/// heading(title.children.first()) +/// text(10pt, weight: "medium")[ +/// Published by +/// #author.children.first() +/// ] +/// +/// for p in pars.children { +/// if (type(p) == "dictionary") { +/// parbreak() +/// p.children.first() +/// } +/// } +/// } +/// +/// #let data = xml("example.xml") +/// #for elem in data.first().children { +/// if (type(elem) == "dictionary") { +/// article(elem) +/// } +/// } +/// ``` +/// +/// Display: XML +/// Category: data-loading +#[func] +pub fn xml( + /// Path to an XML file. + path: Spanned<EcoString>, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Value> { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let text = std::str::from_utf8(&data).map_err(FileError::from).at(span)?; + let document = roxmltree::Document::parse(text).map_err(format_xml_error).at(span)?; + Ok(convert_xml(document.root())) +} + +/// Convert an XML node to a Typst value. +fn convert_xml(node: roxmltree::Node) -> Value { + if node.is_text() { + return node.text().unwrap_or_default().into_value(); + } + + let children: Array = node.children().map(convert_xml).collect(); + if node.is_root() { + return Value::Array(children); + } + + let tag: Str = node.tag_name().name().into(); + let attrs: Dict = node + .attributes() + .map(|attr| (attr.name().into(), attr.value().into_value())) + .collect(); + + Value::Dict(dict! { + "tag" => tag, + "attrs" => attrs, + "children" => children, + }) +} + +/// Format the user-facing XML error message. +fn format_xml_error(error: roxmltree::Error) -> EcoString { + format_xml_like_error("xml file", error) +} diff --git a/crates/typst-library/src/compute/foundations.rs b/crates/typst-library/src/compute/foundations.rs new file mode 100644 index 00000000..f83d71a0 --- /dev/null +++ b/crates/typst-library/src/compute/foundations.rs @@ -0,0 +1,215 @@ +use crate::prelude::*; + +/// Determines the type of a value. +/// +/// Returns the name of the value's type. +/// +/// ## Example { #example } +/// ```example +/// #type(12) \ +/// #type(14.7) \ +/// #type("hello") \ +/// #type(none) \ +/// #type([Hi]) \ +/// #type(x => x + 1) +/// ``` +/// +/// Display: Type +/// Category: foundations +#[func] +pub fn type_( + /// The value whose type's to determine. + value: Value, +) -> Str { + value.type_name().into() +} + +/// Returns the string representation of a value. +/// +/// When inserted into content, most values are displayed as this representation +/// in monospace with syntax-highlighting. The exceptions are `{none}`, +/// integers, floats, strings, content, and functions. +/// +/// **Note:** This function is for debugging purposes. Its output should not be +/// considered stable and may change at any time! +/// +/// ## Example { #example } +/// ```example +/// #none vs #repr(none) \ +/// #"hello" vs #repr("hello") \ +/// #(1, 2) vs #repr((1, 2)) \ +/// #[*Hi*] vs #repr([*Hi*]) +/// ``` +/// +/// Display: Representation +/// Category: foundations +#[func] +pub fn repr( + /// The value whose string representation to produce. + value: Value, +) -> Str { + value.repr() +} + +/// Fails with an error. +/// +/// ## Example { #example } +/// The code below produces the error `panicked with: "this is wrong"`. +/// ```typ +/// #panic("this is wrong") +/// ``` +/// +/// Display: Panic +/// Category: foundations +#[func] +pub fn panic( + /// The values to panic with. + #[variadic] + values: Vec<Value>, +) -> StrResult<Never> { + let mut msg = EcoString::from("panicked"); + if !values.is_empty() { + msg.push_str(" with: "); + for (i, value) in values.iter().enumerate() { + if i > 0 { + msg.push_str(", "); + } + msg.push_str(&value.repr()); + } + } + Err(msg) +} + +/// Ensures that a condition is fulfilled. +/// +/// Fails with an error if the condition is not fulfilled. Does not +/// produce any output in the document. +/// +/// If you wish to test equality between two values, see +/// [`assert.eq`]($func/assert.eq) and [`assert.ne`]($func/assert.ne). +/// +/// ## Example { #example } +/// ```typ +/// #assert(1 < 2, message: "math broke") +/// ``` +/// +/// Display: Assert +/// Category: foundations +#[func] +#[scope( + scope.define("eq", assert_eq_func()); + scope.define("ne", assert_ne_func()); + scope +)] +pub fn assert( + /// The condition that must be true for the assertion to pass. + condition: bool, + /// The error message when the assertion fails. + #[named] + message: Option<EcoString>, +) -> StrResult<NoneValue> { + if !condition { + if let Some(message) = message { + bail!("assertion failed: {message}"); + } else { + bail!("assertion failed"); + } + } + Ok(NoneValue) +} + +/// Ensures that two values are equal. +/// +/// Fails with an error if the first value is not equal to the second. Does not +/// produce any output in the document. +/// +/// ## Example { #example } +/// ```typ +/// #assert.eq(10, 10) +/// ``` +/// +/// Display: Assert Equals +/// Category: foundations +#[func] +pub fn assert_eq( + /// The first value to compare. + left: Value, + + /// The second value to compare. + right: Value, + + /// An optional message to display on error instead of the representations + /// of the compared values. + #[named] + message: Option<EcoString>, +) -> StrResult<NoneValue> { + if left != right { + if let Some(message) = message { + bail!("equality assertion failed: {message}"); + } else { + bail!("equality assertion failed: value {left:?} was not equal to {right:?}"); + } + } + Ok(NoneValue) +} + +/// Ensures that two values are not equal. +/// +/// Fails with an error if the first value is equal to the second. Does not +/// produce any output in the document. +/// +/// ## Example { #example } +/// ```typ +/// #assert.ne(3, 4) +/// ``` +/// +/// Display: Assert Not Equals +/// Category: foundations +#[func] +pub fn assert_ne( + /// The first value to compare. + left: Value, + + /// The second value to compare. + right: Value, + + /// An optional message to display on error instead of the representations + /// of the compared values. + #[named] + message: Option<EcoString>, +) -> StrResult<NoneValue> { + if left == right { + if let Some(message) = message { + bail!("inequality assertion failed: {message}"); + } else { + bail!("inequality assertion failed: value {left:?} was equal to {right:?}"); + } + } + Ok(NoneValue) +} + +/// Evaluates a string as Typst code. +/// +/// This function should only be used as a last resort. +/// +/// ## Example { #example } +/// ```example +/// #eval("1 + 1") \ +/// #eval("(1, 2, 3, 4)").len() \ +/// #eval("[*Strong text*]") +/// ``` +/// +/// Display: Evaluate +/// Category: foundations +#[func] +pub fn eval( + /// A string of Typst code to evaluate. + /// + /// The code in the string cannot interact with the file system. + source: Spanned<String>, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Value> { + let Spanned { v: text, span } = source; + typst::eval::eval_string(vm.world(), &text, span) +} diff --git a/crates/typst-library/src/compute/mod.rs b/crates/typst-library/src/compute/mod.rs new file mode 100644 index 00000000..e9e4870c --- /dev/null +++ b/crates/typst-library/src/compute/mod.rs @@ -0,0 +1,39 @@ +//! Computational functions. + +pub mod calc; +mod construct; +mod data; +mod foundations; + +pub use self::construct::*; +pub use self::data::*; +pub use self::foundations::*; + +use crate::prelude::*; + +/// Hook up all compute definitions. +pub(super) fn define(global: &mut Scope) { + global.define("type", type_func()); + global.define("repr", repr_func()); + global.define("panic", panic_func()); + global.define("assert", assert_func()); + global.define("eval", eval_func()); + global.define("int", int_func()); + global.define("float", float_func()); + global.define("luma", luma_func()); + global.define("rgb", rgb_func()); + global.define("cmyk", cmyk_func()); + global.define("datetime", datetime_func()); + global.define("symbol", symbol_func()); + global.define("str", str_func()); + global.define("label", label_func()); + global.define("regex", regex_func()); + global.define("range", range_func()); + global.define("read", read_func()); + global.define("csv", csv_func()); + global.define("json", json_func()); + global.define("toml", toml_func()); + global.define("yaml", yaml_func()); + global.define("xml", xml_func()); + global.define("calc", calc::module()); +} diff --git a/crates/typst-library/src/layout/align.rs b/crates/typst-library/src/layout/align.rs new file mode 100644 index 00000000..bbfe9f7e --- /dev/null +++ b/crates/typst-library/src/layout/align.rs @@ -0,0 +1,67 @@ +use crate::prelude::*; + +/// Aligns content horizontally and vertically. +/// +/// ## Example { #example } +/// ```example +/// #set align(center) +/// +/// Centered text, a sight to see \ +/// In perfect balance, visually \ +/// Not left nor right, it stands alone \ +/// A work of art, a visual throne +/// ``` +/// +/// Display: Align +/// Category: layout +#[element(Show)] +pub struct AlignElem { + /// The alignment along both axes. + /// + /// Possible values for horizontal alignments are: + /// - `start` + /// - `end` + /// - `left` + /// - `center` + /// - `right` + /// + /// The `start` and `end` alignments are relative to the current [text + /// direction]($func/text.dir). + /// + /// Possible values for vertical alignments are: + /// - `top` + /// - `horizon` + /// - `bottom` + /// + /// To align along both axes at the same time, add the two alignments using + /// the `+` operator to get a `2d alignment`. For example, `top + right` + /// aligns the content to the top right corner. + /// + /// ```example + /// #set page(height: 6cm) + /// #set text(lang: "ar") + /// + /// مثال + /// #align( + /// end + horizon, + /// rect(inset: 12pt)[ركن] + /// ) + /// ``` + #[positional] + #[fold] + #[default(Axes::new(GenAlign::Start, GenAlign::Specific(Align::Top)))] + pub alignment: Axes<Option<GenAlign>>, + + /// The content to align. + #[required] + pub body: Content, +} + +impl Show for AlignElem { + #[tracing::instrument(name = "AlignElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(self + .body() + .styled(Self::set_alignment(self.alignment(styles).map(Some)))) + } +} diff --git a/crates/typst-library/src/layout/columns.rs b/crates/typst-library/src/layout/columns.rs new file mode 100644 index 00000000..d2138491 --- /dev/null +++ b/crates/typst-library/src/layout/columns.rs @@ -0,0 +1,172 @@ +use crate::prelude::*; +use crate::text::TextElem; + +/// Separates a region into multiple equally sized columns. +/// +/// The `column` function allows to separate the interior of any container into +/// multiple columns. It will not equalize the height of the columns, instead, +/// the columns will take up the height of their container or the remaining +/// height on the page. The columns function can break across pages if +/// necessary. +/// +/// ## Example { #example } +/// ```example +/// = Towards Advanced Deep Learning +/// +/// #box(height: 68pt, +/// columns(2, gutter: 11pt)[ +/// #set par(justify: true) +/// This research was funded by the +/// National Academy of Sciences. +/// NAoS provided support for field +/// tests and interviews with a +/// grant of up to USD 40.000 for a +/// period of 6 months. +/// ] +/// ) +/// +/// In recent years, deep learning has +/// increasingly been used to solve a +/// variety of problems. +/// ``` +/// +/// Display: Columns +/// Category: layout +#[element(Layout)] +pub struct ColumnsElem { + /// The number of columns. + #[positional] + #[default(NonZeroUsize::new(2).unwrap())] + pub count: NonZeroUsize, + + /// The size of the gutter space between each column. + #[resolve] + #[default(Ratio::new(0.04).into())] + pub gutter: Rel<Length>, + + /// The content that should be layouted into the columns. + #[required] + pub body: Content, +} + +impl Layout for ColumnsElem { + #[tracing::instrument(name = "ColumnsElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let body = self.body(); + + // Separating the infinite space into infinite columns does not make + // much sense. + if !regions.size.x.is_finite() { + return body.layout(vt, styles, regions); + } + + // Determine the width of the gutter and each column. + let columns = self.count(styles).get(); + let gutter = self.gutter(styles).relative_to(regions.base().x); + let width = (regions.size.x - gutter * (columns - 1) as f64) / columns as f64; + + let backlog: Vec<_> = std::iter::once(®ions.size.y) + .chain(regions.backlog) + .flat_map(|&height| std::iter::repeat(height).take(columns)) + .skip(1) + .collect(); + + // Create the pod regions. + let pod = Regions { + size: Size::new(width, regions.size.y), + full: regions.full, + backlog: &backlog, + last: regions.last, + expand: Axes::new(true, regions.expand.y), + root: regions.root, + }; + + // Layout the children. + let mut frames = body.layout(vt, styles, pod)?.into_iter(); + let mut finished = vec![]; + + let dir = TextElem::dir_in(styles); + let total_regions = (frames.len() as f32 / columns as f32).ceil() as usize; + + // Stitch together the columns for each region. + for region in regions.iter().take(total_regions) { + // The height should be the parent height if we should expand. + // Otherwise its the maximum column height for the frame. In that + // case, the frame is first created with zero height and then + // resized. + let height = if regions.expand.y { region.y } else { Abs::zero() }; + let mut output = Frame::new(Size::new(regions.size.x, height)); + let mut cursor = Abs::zero(); + + for _ in 0..columns { + let Some(frame) = frames.next() else { break }; + if !regions.expand.y { + output.size_mut().y.set_max(frame.height()); + } + + let width = frame.width(); + let x = if dir == Dir::LTR { + cursor + } else { + regions.size.x - cursor - width + }; + + output.push_frame(Point::with_x(x), frame); + cursor += width + gutter; + } + + finished.push(output); + } + + Ok(Fragment::frames(finished)) + } +} + +/// Forces a column break. +/// +/// The function will behave like a [page break]($func/pagebreak) when used in a +/// single column layout or the last column on a page. Otherwise, content after +/// the column break will be placed in the next column. +/// +/// ## Example { #example } +/// ```example +/// #set page(columns: 2) +/// Preliminary findings from our +/// ongoing research project have +/// revealed a hitherto unknown +/// phenomenon of extraordinary +/// significance. +/// +/// #colbreak() +/// Through rigorous experimentation +/// and analysis, we have discovered +/// a hitherto uncharacterized process +/// that defies our current +/// understanding of the fundamental +/// laws of nature. +/// ``` +/// +/// Display: Column Break +/// Category: layout +#[element(Behave)] +pub struct ColbreakElem { + /// If `{true}`, the column break is skipped if the current column is + /// already empty. + #[default(false)] + pub weak: bool, +} + +impl Behave for ColbreakElem { + fn behaviour(&self) -> Behaviour { + if self.weak(StyleChain::default()) { + Behaviour::Weak(1) + } else { + Behaviour::Destructive + } + } +} diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs new file mode 100644 index 00000000..c79669d0 --- /dev/null +++ b/crates/typst-library/src/layout/container.rs @@ -0,0 +1,497 @@ +use typst::eval::AutoValue; + +use super::VElem; +use crate::layout::Spacing; +use crate::prelude::*; + +/// An inline-level container that sizes content. +/// +/// All elements except inline math, text, and boxes are block-level and cannot +/// occur inside of a paragraph. The box function can be used to integrate such +/// elements into a paragraph. Boxes take the size of their contents by default +/// but can also be sized explicitly. +/// +/// ## Example { #example } +/// ```example +/// Refer to the docs +/// #box( +/// height: 9pt, +/// image("docs.svg") +/// ) +/// for more information. +/// ``` +/// +/// Display: Box +/// Category: layout +#[element(Layout)] +pub struct BoxElem { + /// The width of the box. + /// + /// Boxes can have [fractional]($type/fraction) widths, as the example + /// below demonstrates. + /// + /// _Note:_ Currently, only boxes and only their widths might be fractionally + /// sized within paragraphs. Support for fractionally sized images, shapes, + /// and more might be added in the future. + /// + /// ```example + /// Line in #box(width: 1fr, line(length: 100%)) between. + /// ``` + pub width: Sizing, + + /// The height of the box. + pub height: Smart<Rel<Length>>, + + /// An amount to shift the box's baseline by. + /// + /// ```example + /// Image: #box(baseline: 40%, image("tiger.jpg", width: 2cm)). + /// ``` + #[resolve] + pub baseline: Rel<Length>, + + /// The box's background color. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// The box's border color. See the + /// [rectangle's documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Sides<Option<Option<PartialStroke>>>, + + /// How much to round the box's corners. See the [rectangle's + /// documentation]($func/rect.radius) for more details. + #[resolve] + #[fold] + pub radius: Corners<Option<Rel<Length>>>, + + /// How much to pad the box's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the box's size without affecting the layout. + /// + /// This is useful to prevent padding from affecting line layout. For a + /// generalized version of the example below, see the documentation for the + /// [raw text's block parameter]($func/raw.block). + /// + /// ```example + /// An inline + /// #box( + /// fill: luma(235), + /// inset: (x: 3pt, y: 0pt), + /// outset: (y: 3pt), + /// radius: 2pt, + /// )[rectangle]. + /// ``` + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// Whether to clip the content inside the box. + #[default(false)] + pub clip: bool, + + /// The contents of the box. + #[positional] + pub body: Option<Content>, +} + +impl Layout for BoxElem { + #[tracing::instrument(name = "BoxElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let width = match self.width(styles) { + Sizing::Auto => Smart::Auto, + Sizing::Rel(rel) => Smart::Custom(rel), + Sizing::Fr(_) => Smart::Custom(Ratio::one().into()), + }; + + // Resolve the sizing to a concrete size. + let sizing = Axes::new(width, self.height(styles)); + let expand = sizing.as_ref().map(Smart::is_custom); + let size = sizing + .resolve(styles) + .zip(regions.base()) + .map(|(s, b)| s.map(|v| v.relative_to(b))) + .unwrap_or(regions.base()); + + // Apply inset. + let mut body = self.body(styles).unwrap_or_default(); + let inset = self.inset(styles); + if inset.iter().any(|v| !v.is_zero()) { + body = body.padded(inset.map(|side| side.map(Length::from))); + } + + // Select the appropriate base and expansion for the child depending + // on whether it is automatically or relatively sized. + let pod = Regions::one(size, expand); + let mut frame = body.layout(vt, styles, pod)?.into_frame(); + + // Enforce correct size. + *frame.size_mut() = expand.select(size, frame.size()); + + // Apply baseline shift. + let shift = self.baseline(styles).relative_to(frame.height()); + if !shift.is_zero() { + frame.set_baseline(frame.baseline() - shift); + } + + // Clip the contents + if self.clip(styles) { + frame.clip(); + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = self.stroke(styles).map(|s| s.map(PartialStroke::unwrap_or_default)); + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + let outset = self.outset(styles); + let radius = self.radius(styles); + frame.fill_and_stroke(fill, stroke, outset, radius, self.span()); + } + + // Apply metadata. + frame.meta(styles, false); + + Ok(Fragment::frame(frame)) + } +} + +/// A block-level container. +/// +/// Such a container can be used to separate content, size it, and give it a +/// background or border. +/// +/// ## Examples { #examples } +/// With a block, you can give a background to content while still allowing it +/// to break across multiple pages. +/// ```example +/// #set page(height: 100pt) +/// #block( +/// fill: luma(230), +/// inset: 8pt, +/// radius: 4pt, +/// lorem(30), +/// ) +/// ``` +/// +/// Blocks are also useful to force elements that would otherwise be inline to +/// become block-level, especially when writing show rules. +/// ```example +/// #show heading: it => it.body +/// = Blockless +/// More text. +/// +/// #show heading: it => block(it.body) +/// = Blocky +/// More text. +/// ``` +/// +/// Display: Block +/// Category: layout +#[element(Layout)] +pub struct BlockElem { + /// The block's width. + /// + /// ```example + /// #set align(center) + /// #block( + /// width: 60%, + /// inset: 8pt, + /// fill: silver, + /// lorem(10), + /// ) + /// ``` + pub width: Smart<Rel<Length>>, + + /// The block's height. When the height is larger than the remaining space + /// on a page and [`breakable`]($func/block.breakable) is `{true}`, the + /// block will continue on the next page with the remaining height. + /// + /// ```example + /// #set page(height: 80pt) + /// #set align(center) + /// #block( + /// width: 80%, + /// height: 150%, + /// fill: aqua, + /// ) + /// ``` + pub height: Smart<Rel<Length>>, + + /// Whether the block can be broken and continue on the next page. + /// + /// ```example + /// #set page(height: 80pt) + /// The following block will + /// jump to its own page. + /// #block( + /// breakable: false, + /// lorem(15), + /// ) + /// ``` + #[default(true)] + pub breakable: bool, + + /// The block's background color. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// The block's border color. See the + /// [rectangle's documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Sides<Option<Option<PartialStroke>>>, + + /// How much to round the block's corners. See the [rectangle's + /// documentation]($func/rect.radius) for more details. + #[resolve] + #[fold] + pub radius: Corners<Option<Rel<Length>>>, + + /// How much to pad the block's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the block's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The spacing around this block. This is shorthand to set `above` and + /// `below` to the same value. + /// + /// ```example + /// #set align(center) + /// #show math.equation: set block(above: 8pt, below: 16pt) + /// + /// This sum of $x$ and $y$: + /// $ x + y = z $ + /// A second paragraph. + /// ``` + #[external] + #[default(Em::new(1.2).into())] + pub spacing: Spacing, + + /// The spacing between this block and its predecessor. Takes precedence + /// over `spacing`. Can be used in combination with a show rule to adjust + /// the spacing around arbitrary block-level elements. + #[external] + #[default(Em::new(1.2).into())] + pub above: Spacing, + #[internal] + #[parse( + let spacing = args.named("spacing")?; + args.named("above")? + .map(VElem::block_around) + .or_else(|| spacing.map(VElem::block_spacing)) + )] + #[default(VElem::block_spacing(Em::new(1.2).into()))] + pub above: VElem, + + /// The spacing between this block and its successor. Takes precedence + /// over `spacing`. + #[external] + #[default(Em::new(1.2).into())] + pub below: Spacing, + #[internal] + #[parse( + args.named("below")? + .map(VElem::block_around) + .or_else(|| spacing.map(VElem::block_spacing)) + )] + #[default(VElem::block_spacing(Em::new(1.2).into()))] + pub below: VElem, + + /// Whether to clip the content inside the block. + #[default(false)] + pub clip: bool, + + /// The contents of the block. + #[positional] + pub body: Option<Content>, + + /// Whether this block must stick to the following one. + /// + /// Use this to prevent page breaks between e.g. a heading and its body. + #[internal] + #[default(false)] + pub sticky: bool, +} + +impl Layout for BlockElem { + #[tracing::instrument(name = "BlockElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + // Apply inset. + let mut body = self.body(styles).unwrap_or_default(); + let inset = self.inset(styles); + if inset.iter().any(|v| !v.is_zero()) { + body = body.clone().padded(inset.map(|side| side.map(Length::from))); + } + + // Resolve the sizing to a concrete size. + let sizing = Axes::new(self.width(styles), self.height(styles)); + let mut expand = sizing.as_ref().map(Smart::is_custom); + let mut size = sizing + .resolve(styles) + .zip(regions.base()) + .map(|(s, b)| s.map(|v| v.relative_to(b))) + .unwrap_or(regions.base()); + + // Layout the child. + let mut frames = if self.breakable(styles) { + // Measure to ensure frames for all regions have the same width. + if sizing.x == Smart::Auto { + let pod = Regions::one(size, Axes::splat(false)); + let frame = body.measure(vt, styles, pod)?.into_frame(); + size.x = frame.width(); + expand.x = true; + } + + let mut pod = regions; + pod.size.x = size.x; + pod.expand = expand; + + if expand.y { + pod.full = size.y; + } + + // Generate backlog for fixed height. + let mut heights = vec![]; + if sizing.y.is_custom() { + let mut remaining = size.y; + for region in regions.iter() { + let limited = region.y.min(remaining); + heights.push(limited); + remaining -= limited; + if Abs::zero().fits(remaining) { + break; + } + } + + if let Some(last) = heights.last_mut() { + *last += remaining; + } + + pod.size.y = heights[0]; + pod.backlog = &heights[1..]; + pod.last = None; + } + + let mut frames = body.layout(vt, styles, pod)?.into_frames(); + for (frame, &height) in frames.iter_mut().zip(&heights) { + *frame.size_mut() = + expand.select(Size::new(size.x, height), frame.size()); + } + frames + } else { + let pod = Regions::one(size, expand); + let mut frames = body.layout(vt, styles, pod)?.into_frames(); + *frames[0].size_mut() = expand.select(size, frames[0].size()); + frames + }; + + // Clip the contents + if self.clip(styles) { + for frame in frames.iter_mut() { + frame.clip(); + } + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = self.stroke(styles).map(|s| s.map(PartialStroke::unwrap_or_default)); + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + let mut skip = false; + if let [first, rest @ ..] = frames.as_slice() { + skip = first.is_empty() && rest.iter().any(|frame| !frame.is_empty()); + } + + let outset = self.outset(styles); + let radius = self.radius(styles); + for frame in frames.iter_mut().skip(skip as usize) { + frame.fill_and_stroke( + fill.clone(), + stroke.clone(), + outset, + radius, + self.span(), + ); + } + } + + // Apply metadata. + for frame in &mut frames { + frame.meta(styles, false); + } + + Ok(Fragment::frames(frames)) + } +} + +/// Defines how to size a grid cell along an axis. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Sizing { + /// A track that fits its cell's contents. + Auto, + /// A track size specified in absolute terms and relative to the parent's + /// size. + Rel(Rel<Length>), + /// A track size specified as a fraction of the remaining free space in the + /// parent. + Fr(Fr), +} + +impl Sizing { + /// Whether this is fractional sizing. + pub fn is_fractional(self) -> bool { + matches!(self, Self::Fr(_)) + } +} + +impl Default for Sizing { + fn default() -> Self { + Self::Auto + } +} + +impl<T: Into<Spacing>> From<T> for Sizing { + fn from(spacing: T) -> Self { + match spacing.into() { + Spacing::Rel(rel) => Self::Rel(rel), + Spacing::Fr(fr) => Self::Fr(fr), + } + } +} + +cast! { + Sizing, + self => match self { + Self::Auto => Value::Auto, + Self::Rel(rel) => rel.into_value(), + Self::Fr(fr) => fr.into_value(), + }, + _: AutoValue => Self::Auto, + v: Rel<Length> => Self::Rel(v), + v: Fr => Self::Fr(v), +} diff --git a/crates/typst-library/src/layout/enum.rs b/crates/typst-library/src/layout/enum.rs new file mode 100644 index 00000000..d66477fc --- /dev/null +++ b/crates/typst-library/src/layout/enum.rs @@ -0,0 +1,318 @@ +use std::str::FromStr; + +use crate::layout::{BlockElem, ParElem, Sizing, Spacing}; +use crate::meta::{Numbering, NumberingPattern}; +use crate::prelude::*; +use crate::text::TextElem; + +use super::GridLayouter; + +/// A numbered list. +/// +/// Displays a sequence of items vertically and numbers them consecutively. +/// +/// ## Example { #example } +/// ```example +/// Automatically numbered: +/// + Preparations +/// + Analysis +/// + Conclusions +/// +/// Manually numbered: +/// 2. What is the first step? +/// 5. I am confused. +/// + Moving on ... +/// +/// Function call. +/// #enum[First][Second] +/// ``` +/// +/// You can easily switch all your enumerations to a different numbering style +/// with a set rule. +/// ```example +/// #set enum(numbering: "a)") +/// +/// + Starting off ... +/// + Don't forget step two +/// ``` +/// +/// You can also use [`enum.item`]($func/enum.item) to programmatically +/// customize the number of each item in the enumeration: +/// +/// ```example +/// #enum( +/// enum.item(1)[First step], +/// enum.item(5)[Fifth step], +/// enum.item(10)[Tenth step] +/// ) +/// ``` +/// +/// ## Syntax { #syntax } +/// This functions also has dedicated syntax: +/// +/// - Starting a line with a plus sign creates an automatically numbered +/// enumeration item. +/// - Starting a line with a number followed by a dot creates an explicitly +/// numbered enumeration item. +/// +/// Enumeration items can contain multiple paragraphs and other block-level +/// content. All content that is indented more than an item's plus sign or dot +/// becomes part of that item. +/// +/// Display: Numbered List +/// Category: layout +#[element(Layout)] +#[scope( + scope.define("item", EnumItem::func()); + scope +)] +pub struct EnumElem { + /// If this is `{false}`, the items are spaced apart with + /// [enum spacing]($func/enum.spacing). If it is `{true}`, they use normal + /// [leading]($func/par.leading) instead. This makes the enumeration more + /// compact, which can look better if the items are short. + /// + /// In markup mode, the value of this parameter is determined based on + /// whether items are separated with a blank line. If items directly follow + /// each other, this is set to `{true}`; if items are separated by a blank + /// line, this is set to `{false}`. + /// + /// ```example + /// + If an enum has a lot of text, and + /// maybe other inline content, it + /// should not be tight anymore. + /// + /// + To make an enum wide, simply + /// insert a blank line between the + /// items. + /// ``` + #[default(true)] + pub tight: bool, + + /// How to number the enumeration. Accepts a + /// [numbering pattern or function]($func/numbering). + /// + /// If the numbering pattern contains multiple counting symbols, they apply + /// to nested enums. If given a function, the function receives one argument + /// if `full` is `{false}` and multiple arguments if `full` is `{true}`. + /// + /// ```example + /// #set enum(numbering: "1.a)") + /// + Different + /// + Numbering + /// + Nested + /// + Items + /// + Style + /// + /// #set enum(numbering: n => super[#n]) + /// + Superscript + /// + Numbering! + /// ``` + #[default(Numbering::Pattern(NumberingPattern::from_str("1.").unwrap()))] + pub numbering: Numbering, + + /// Which number to start the enumeration with. + /// + /// ```example + /// #enum( + /// start: 3, + /// [Skipping], + /// [Ahead], + /// ) + /// ``` + #[default(1)] + pub start: usize, + + /// Whether to display the full numbering, including the numbers of + /// all parent enumerations. + /// + /// + /// ```example + /// #set enum(numbering: "1.a)", full: true) + /// + Cook + /// + Heat water + /// + Add integredients + /// + Eat + /// ``` + #[default(false)] + pub full: bool, + + /// The indentation of each item. + #[resolve] + pub indent: Length, + + /// The space between the numbering and the body of each item. + #[resolve] + #[default(Em::new(0.5).into())] + pub body_indent: Length, + + /// The spacing between the items of a wide (non-tight) enumeration. + /// + /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below). + pub spacing: Smart<Spacing>, + + /// The horizontal alignment that enum numbers should have. + /// + /// By default, this is set to `{end}`, which aligns enum numbers + /// towards end of the current text direction (in left-to-right script, + /// for example, this is the same as `{right}`). The choice of `{end}` + /// for horizontal alignment of enum numbers is usually preferred over + /// `{start}`, as numbers then grow away from the text instead of towards + /// it, avoiding certain visual issues. This option lets you override this + /// behavior, however. + /// + /// ````example + /// #set enum(number-align: start) + /// + /// Here are some powers of two: + /// 1. One + /// 2. Two + /// 4. Four + /// 8. Eight + /// 16. Sixteen + /// 32. Thirty two + /// ```` + #[default(HorizontalAlign(GenAlign::End))] + pub number_align: HorizontalAlign, + + /// The numbered list's items. + /// + /// When using the enum syntax, adjacent items are automatically collected + /// into enumerations, even through constructs like for loops. + /// + /// ```example + /// #for phase in ( + /// "Launch", + /// "Orbit", + /// "Descent", + /// ) [+ #phase] + /// ``` + #[variadic] + pub children: Vec<EnumItem>, + + /// The numbers of parent items. + #[internal] + #[fold] + parents: Parent, +} + +impl Layout for EnumElem { + #[tracing::instrument(name = "EnumElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let numbering = self.numbering(styles); + let indent = self.indent(styles); + let body_indent = self.body_indent(styles); + let gutter = if self.tight(styles) { + ParElem::leading_in(styles).into() + } else { + self.spacing(styles) + .unwrap_or_else(|| BlockElem::below_in(styles).amount()) + }; + + let mut cells = vec![]; + let mut number = self.start(styles); + let mut parents = self.parents(styles); + let full = self.full(styles); + + // Horizontally align based on the given respective parameter. + // Vertically align to the top to avoid inheriting 'horizon' or + // 'bottom' alignment from the context and having the number be + // displaced in relation to the item it refers to. + let number_align: Axes<Option<GenAlign>> = + Axes::new(self.number_align(styles).into(), Align::Top.into()).map(Some); + + for item in self.children() { + number = item.number(styles).unwrap_or(number); + + let resolved = if full { + parents.push(number); + let content = numbering.apply_vt(vt, &parents)?.display(); + parents.pop(); + content + } else { + match &numbering { + Numbering::Pattern(pattern) => { + TextElem::packed(pattern.apply_kth(parents.len(), number)) + } + other => other.apply_vt(vt, &[number])?.display(), + } + }; + + // Disable overhang as a workaround to end-aligned dots glitching + // and decreasing spacing between numbers and items. + let resolved = + resolved.aligned(number_align).styled(TextElem::set_overhang(false)); + + cells.push(Content::empty()); + cells.push(resolved); + cells.push(Content::empty()); + cells.push(item.body().styled(Self::set_parents(Parent(number)))); + number = number.saturating_add(1); + } + + let layouter = GridLayouter::new( + Axes::with_x(&[ + Sizing::Rel(indent.into()), + Sizing::Auto, + Sizing::Rel(body_indent.into()), + Sizing::Auto, + ]), + Axes::with_y(&[gutter.into()]), + &cells, + regions, + styles, + ); + + Ok(layouter.layout(vt)?.fragment) + } +} + +/// An enumeration item. +/// +/// Display: Numbered List Item +/// Category: layout +#[element] +pub struct EnumItem { + /// The item's number. + #[positional] + pub number: Option<usize>, + + /// The item's body. + #[required] + pub body: Content, +} + +cast! { + EnumItem, + array: Array => { + let mut iter = array.into_iter(); + let (number, body) = match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => (a.cast()?, b.cast()?), + _ => bail!("array must contain exactly two entries"), + }; + Self::new(body).with_number(number) + }, + v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::new(v.clone())), +} + +struct Parent(usize); + +cast! { + Parent, + self => self.0.into_value(), + v: usize => Self(v), +} + +impl Fold for Parent { + type Output = Vec<usize>; + + fn fold(self, mut outer: Self::Output) -> Self::Output { + outer.push(self.0); + outer + } +} diff --git a/crates/typst-library/src/layout/flow.rs b/crates/typst-library/src/layout/flow.rs new file mode 100644 index 00000000..accd092a --- /dev/null +++ b/crates/typst-library/src/layout/flow.rs @@ -0,0 +1,583 @@ +use std::mem; + +use super::{ + AlignElem, BlockElem, ColbreakElem, ColumnsElem, ParElem, PlaceElem, Spacing, VElem, +}; +use crate::meta::{FootnoteElem, FootnoteEntry}; +use crate::prelude::*; +use crate::visualize::{ + CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem, + SquareElem, +}; + +/// Arranges spacing, paragraphs and block-level elements into a flow. +/// +/// This element is responsible for layouting both the top-level content flow +/// and the contents of boxes. +/// +/// Display: Flow +/// Category: layout +#[element(Layout)] +pub struct FlowElem { + /// The children that will be arranges into a flow. + #[variadic] + pub children: Vec<Content>, +} + +impl Layout for FlowElem { + #[tracing::instrument(name = "FlowElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let mut layouter = FlowLayouter::new(regions, styles); + + for mut child in &self.children() { + let outer = styles; + let mut styles = styles; + if let Some((elem, map)) = child.to_styled() { + child = elem; + styles = outer.chain(map); + } + + if let Some(elem) = child.to::<VElem>() { + layouter.layout_spacing(vt, elem, styles)?; + } else if let Some(elem) = child.to::<ParElem>() { + layouter.layout_par(vt, elem, styles)?; + } else if child.is::<LineElem>() + || child.is::<RectElem>() + || child.is::<SquareElem>() + || child.is::<EllipseElem>() + || child.is::<CircleElem>() + || child.is::<ImageElem>() + || child.is::<PolygonElem>() + || child.is::<PathElem>() + { + let layoutable = child.with::<dyn Layout>().unwrap(); + layouter.layout_single(vt, layoutable, styles)?; + } else if child.is::<MetaElem>() { + let mut frame = Frame::new(Size::zero()); + frame.meta(styles, true); + layouter.items.push(FlowItem::Frame { + frame, + aligns: Axes::new(Align::Top, Align::Left), + sticky: true, + movable: false, + }); + } else if child.can::<dyn Layout>() { + layouter.layout_multiple(vt, child, styles)?; + } else if child.is::<ColbreakElem>() { + if !layouter.regions.backlog.is_empty() || layouter.regions.last.is_some() + { + layouter.finish_region()?; + } + } else { + bail!(child.span(), "unexpected flow child"); + } + } + + layouter.finish() + } +} + +/// Performs flow layout. +struct FlowLayouter<'a> { + /// Whether this is the root flow. + root: bool, + /// The regions to layout children into. + regions: Regions<'a>, + /// The shared styles. + styles: StyleChain<'a>, + /// Whether the flow should expand to fill the region. + expand: Axes<bool>, + /// The initial size of `regions.size` that was available before we started + /// subtracting. + initial: Size, + /// Whether the last block was a paragraph. + last_was_par: bool, + /// Spacing and layouted blocks for the current region. + items: Vec<FlowItem>, + /// Whether we have any footnotes in the current region. + has_footnotes: bool, + /// Footnote configuration. + footnote_config: FootnoteConfig, + /// Finished frames for previous regions. + finished: Vec<Frame>, +} + +/// Cached footnote configuration. +struct FootnoteConfig { + separator: Content, + clearance: Abs, + gap: Abs, +} + +/// A prepared item in a flow layout. +#[derive(Debug)] +enum FlowItem { + /// Spacing between other items and whether it is weak. + Absolute(Abs, bool), + /// Fractional spacing between other items. + Fractional(Fr), + /// A frame for a layouted block, how to align it, whether it sticks to the + /// item after it (for orphan prevention), and whether it is movable + /// (to keep it together with its footnotes). + Frame { frame: Frame, aligns: Axes<Align>, sticky: bool, movable: bool }, + /// An absolutely placed frame. + Placed(Frame), + /// A footnote frame (can also be the separator). + Footnote(Frame), +} + +impl FlowItem { + /// The inherent height of the item. + fn height(&self) -> Abs { + match self { + Self::Absolute(v, _) => *v, + Self::Fractional(_) | Self::Placed(_) => Abs::zero(), + Self::Frame { frame, .. } | Self::Footnote(frame) => frame.height(), + } + } +} + +impl<'a> FlowLayouter<'a> { + /// Create a new flow layouter. + fn new(mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self { + let expand = regions.expand; + + // Disable vertical expansion & root for children. + regions.expand.y = false; + let root = mem::replace(&mut regions.root, false); + + Self { + root, + regions, + styles, + expand, + initial: regions.size, + last_was_par: false, + items: vec![], + has_footnotes: false, + footnote_config: FootnoteConfig { + separator: FootnoteEntry::separator_in(styles), + clearance: FootnoteEntry::clearance_in(styles), + gap: FootnoteEntry::gap_in(styles), + }, + finished: vec![], + } + } + + /// Layout vertical spacing. + #[tracing::instrument(name = "FlowLayouter::layout_spacing", skip_all)] + fn layout_spacing( + &mut self, + vt: &mut Vt, + v: &VElem, + styles: StyleChain, + ) -> SourceResult<()> { + self.layout_item( + vt, + match v.amount() { + Spacing::Rel(rel) => FlowItem::Absolute( + rel.resolve(styles).relative_to(self.initial.y), + v.weakness(styles) > 0, + ), + Spacing::Fr(fr) => FlowItem::Fractional(fr), + }, + ) + } + + /// Layout a paragraph. + #[tracing::instrument(name = "FlowLayouter::layout_par", skip_all)] + fn layout_par( + &mut self, + vt: &mut Vt, + par: &ParElem, + styles: StyleChain, + ) -> SourceResult<()> { + let aligns = AlignElem::alignment_in(styles).resolve(styles); + let leading = ParElem::leading_in(styles); + let consecutive = self.last_was_par; + let lines = par + .layout(vt, styles, consecutive, self.regions.base(), self.regions.expand.x)? + .into_frames(); + + let mut sticky = self.items.len(); + for (i, item) in self.items.iter().enumerate().rev() { + match *item { + FlowItem::Absolute(_, _) => {} + FlowItem::Frame { sticky: true, .. } => sticky = i, + _ => break, + } + } + + if let Some(first) = lines.first() { + if !self.regions.size.y.fits(first.height()) && !self.regions.in_last() { + let carry: Vec<_> = self.items.drain(sticky..).collect(); + self.finish_region()?; + for item in carry { + self.layout_item(vt, item)?; + } + } + } + + for (i, frame) in lines.into_iter().enumerate() { + if i > 0 { + self.layout_item(vt, FlowItem::Absolute(leading, true))?; + } + + self.layout_item( + vt, + FlowItem::Frame { frame, aligns, sticky: false, movable: true }, + )?; + } + + self.last_was_par = true; + Ok(()) + } + + /// Layout into a single region. + #[tracing::instrument(name = "FlowLayouter::layout_single", skip_all)] + fn layout_single( + &mut self, + vt: &mut Vt, + content: &dyn Layout, + styles: StyleChain, + ) -> SourceResult<()> { + let aligns = AlignElem::alignment_in(styles).resolve(styles); + let sticky = BlockElem::sticky_in(styles); + let pod = Regions::one(self.regions.base(), Axes::splat(false)); + let frame = content.layout(vt, styles, pod)?.into_frame(); + self.layout_item(vt, FlowItem::Frame { frame, aligns, sticky, movable: true })?; + self.last_was_par = false; + Ok(()) + } + + /// Layout into multiple regions. + fn layout_multiple( + &mut self, + vt: &mut Vt, + block: &Content, + styles: StyleChain, + ) -> SourceResult<()> { + // Placed elements that are out of flow produce placed items which + // aren't aligned later. + if let Some(placed) = block.to::<PlaceElem>() { + if placed.out_of_flow(styles) { + let frame = block.layout(vt, styles, self.regions)?.into_frame(); + self.layout_item(vt, FlowItem::Placed(frame))?; + return Ok(()); + } + } else if self.regions.is_full() { + // Skip directly if region is already full. + self.finish_region()?; + } + + // How to align the block. + let aligns = if let Some(align) = block.to::<AlignElem>() { + align.alignment(styles) + } else if let Some((_, local)) = block.to_styled() { + AlignElem::alignment_in(styles.chain(local)) + } else { + AlignElem::alignment_in(styles) + } + .resolve(styles); + + // Temporarily delegerate rootness to the columns. + let is_root = self.root; + if is_root && block.is::<ColumnsElem>() { + self.root = false; + self.regions.root = true; + } + + // Layout the block itself. + let sticky = BlockElem::sticky_in(styles); + let fragment = block.layout(vt, styles, self.regions)?; + let mut notes = Vec::new(); + + for (i, frame) in fragment.into_iter().enumerate() { + // Find footnotes in the frame. + if self.root { + find_footnotes(&mut notes, &frame); + } + + if i > 0 { + self.finish_region()?; + } + + self.layout_item( + vt, + FlowItem::Frame { frame, aligns, sticky, movable: false }, + )?; + } + + if self.root && !self.handle_footnotes(vt, &mut notes, false, false)? { + self.finish_region()?; + self.handle_footnotes(vt, &mut notes, false, true)?; + } + + self.root = is_root; + self.regions.root = false; + self.last_was_par = false; + + Ok(()) + } + + /// Layout a finished frame. + #[tracing::instrument(name = "FlowLayouter::layout_item", skip_all)] + fn layout_item(&mut self, vt: &mut Vt, item: FlowItem) -> SourceResult<()> { + match item { + FlowItem::Absolute(v, weak) => { + if weak + && !self + .items + .iter() + .any(|item| matches!(item, FlowItem::Frame { .. })) + { + return Ok(()); + } + self.regions.size.y -= v + } + FlowItem::Fractional(_) => {} + FlowItem::Frame { ref frame, movable, .. } => { + let size = frame.size(); + if !self.regions.size.y.fits(size.y) && !self.regions.in_last() { + self.finish_region()?; + } + + self.regions.size.y -= size.y; + if self.root && movable { + let mut notes = Vec::new(); + find_footnotes(&mut notes, frame); + self.items.push(item); + if !self.handle_footnotes(vt, &mut notes, true, false)? { + let item = self.items.pop(); + self.finish_region()?; + self.items.extend(item); + self.regions.size.y -= size.y; + self.handle_footnotes(vt, &mut notes, true, true)?; + } + return Ok(()); + } + } + FlowItem::Placed(_) => {} + FlowItem::Footnote(_) => {} + } + + self.items.push(item); + Ok(()) + } + + /// Finish the frame for one region. + fn finish_region(&mut self) -> SourceResult<()> { + // Trim weak spacing. + while self + .items + .last() + .map_or(false, |item| matches!(item, FlowItem::Absolute(_, true))) + { + self.items.pop(); + } + + // Determine the used size. + let mut fr = Fr::zero(); + let mut used = Size::zero(); + let mut footnote_height = Abs::zero(); + let mut first_footnote = true; + for item in &self.items { + match item { + FlowItem::Absolute(v, _) => used.y += *v, + FlowItem::Fractional(v) => fr += *v, + FlowItem::Frame { frame, .. } => { + let size = frame.size(); + used.y += size.y; + used.x.set_max(size.x); + } + FlowItem::Placed(_) => {} + FlowItem::Footnote(frame) => { + let size = frame.size(); + footnote_height += size.y; + if !first_footnote { + footnote_height += self.footnote_config.gap; + } + first_footnote = false; + used.x.set_max(size.x); + } + } + } + used.y += footnote_height; + + // Determine the size of the flow in this region depending on whether + // the region expands. Also account for fractional spacing and + // footnotes. + let mut size = self.expand.select(self.initial, used).min(self.initial); + if (fr.get() > 0.0 || self.has_footnotes) && self.initial.y.is_finite() { + size.y = self.initial.y; + } + + let mut output = Frame::new(size); + let mut offset = Abs::zero(); + let mut ruler = Align::Top; + let mut footnote_offset = size.y - footnote_height; + + // Place all frames. + for item in self.items.drain(..) { + match item { + FlowItem::Absolute(v, _) => { + offset += v; + } + FlowItem::Fractional(v) => { + let remaining = self.initial.y - used.y; + offset += v.share(fr, remaining); + } + FlowItem::Frame { frame, aligns, .. } => { + ruler = ruler.max(aligns.y); + let x = aligns.x.position(size.x - frame.width()); + let y = offset + ruler.position(size.y - used.y); + let pos = Point::new(x, y); + offset += frame.height(); + output.push_frame(pos, frame); + } + FlowItem::Footnote(frame) => { + let pos = Point::with_y(footnote_offset); + footnote_offset += frame.height() + self.footnote_config.gap; + output.push_frame(pos, frame); + } + FlowItem::Placed(frame) => { + output.push_frame(Point::zero(), frame); + } + } + } + + // Advance to the next region. + self.finished.push(output); + self.regions.next(); + self.initial = self.regions.size; + self.has_footnotes = false; + Ok(()) + } + + /// Finish layouting and return the resulting fragment. + fn finish(mut self) -> SourceResult<Fragment> { + if self.expand.y { + while !self.regions.backlog.is_empty() { + self.finish_region()?; + } + } + + self.finish_region()?; + Ok(Fragment::frames(self.finished)) + } +} + +impl FlowLayouter<'_> { + /// Processes all footnotes in the frame. + #[tracing::instrument(skip_all)] + fn handle_footnotes( + &mut self, + vt: &mut Vt, + notes: &mut Vec<FootnoteElem>, + movable: bool, + force: bool, + ) -> SourceResult<bool> { + let items_len = self.items.len(); + let notes_len = notes.len(); + + // Process footnotes one at a time. + let mut k = 0; + while k < notes.len() { + if notes[k].is_ref() { + k += 1; + continue; + } + + if !self.has_footnotes { + self.layout_footnote_separator(vt)?; + } + + self.regions.size.y -= self.footnote_config.gap; + let frames = FootnoteEntry::new(notes[k].clone()) + .pack() + .layout(vt, self.styles, self.regions.with_root(false))? + .into_frames(); + + // If the entries didn't fit, abort (to keep footnote and entry + // together). + if !force + && (k == 0 || movable) + && frames.first().map_or(false, Frame::is_empty) + { + // Remove existing footnotes attempts because we need to + // move the item to the next page. + notes.truncate(notes_len); + + // Undo region modifications. + for item in self.items.drain(items_len..) { + self.regions.size.y -= item.height(); + } + + return Ok(false); + } + + let prev = notes.len(); + for (i, frame) in frames.into_iter().enumerate() { + find_footnotes(notes, &frame); + if i > 0 { + self.finish_region()?; + self.layout_footnote_separator(vt)?; + self.regions.size.y -= self.footnote_config.gap; + } + self.regions.size.y -= frame.height(); + self.items.push(FlowItem::Footnote(frame)); + } + + k += 1; + + // Process the nested notes before dealing with further top-level + // notes. + let nested = notes.len() - prev; + if nested > 0 { + notes[k..].rotate_right(nested); + } + } + + Ok(true) + } + + /// Layout and save the footnote separator, typically a line. + #[tracing::instrument(skip_all)] + fn layout_footnote_separator(&mut self, vt: &mut Vt) -> SourceResult<()> { + let expand = Axes::new(self.regions.expand.x, false); + let pod = Regions::one(self.regions.base(), expand); + let separator = &self.footnote_config.separator; + + let mut frame = separator.layout(vt, self.styles, pod)?.into_frame(); + frame.size_mut().y += self.footnote_config.clearance; + frame.translate(Point::with_y(self.footnote_config.clearance)); + + self.has_footnotes = true; + self.regions.size.y -= frame.height(); + self.items.push(FlowItem::Footnote(frame)); + + Ok(()) + } +} + +/// Finds all footnotes in the frame. +#[tracing::instrument(skip_all)] +fn find_footnotes(notes: &mut Vec<FootnoteElem>, frame: &Frame) { + for (_, item) in frame.items() { + match item { + FrameItem::Group(group) => find_footnotes(notes, &group.frame), + FrameItem::Meta(Meta::Elem(content), _) + if !notes.iter().any(|note| note.0.location() == content.location()) => + { + let Some(footnote) = content.to::<FootnoteElem>() else { continue }; + notes.push(footnote.clone()); + } + _ => {} + } + } +} diff --git a/crates/typst-library/src/layout/fragment.rs b/crates/typst-library/src/layout/fragment.rs new file mode 100644 index 00000000..3550df2a --- /dev/null +++ b/crates/typst-library/src/layout/fragment.rs @@ -0,0 +1,87 @@ +use crate::prelude::*; + +/// A partial layout result. +#[derive(Clone)] +pub struct Fragment(Vec<Frame>); + +impl Fragment { + /// Create a fragment from a single frame. + pub fn frame(frame: Frame) -> Self { + Self(vec![frame]) + } + + /// Create a fragment from multiple frames. + pub fn frames(frames: Vec<Frame>) -> Self { + Self(frames) + } + + /// Return `true` if the length is 0. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The number of frames in the fragment. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Extract the first and only frame. + /// + /// Panics if there are multiple frames. + #[track_caller] + pub fn into_frame(self) -> Frame { + assert_eq!(self.0.len(), 1, "expected exactly one frame"); + self.0.into_iter().next().unwrap() + } + + /// Extract the frames. + pub fn into_frames(self) -> Vec<Frame> { + self.0 + } + + /// Iterate over the contained frames. + pub fn iter(&self) -> std::slice::Iter<Frame> { + self.0.iter() + } + + /// Iterate over the contained frames. + pub fn iter_mut(&mut self) -> std::slice::IterMut<Frame> { + self.0.iter_mut() + } +} + +impl Debug for Fragment { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self.0.as_slice() { + [frame] => frame.fmt(f), + frames => frames.fmt(f), + } + } +} + +impl IntoIterator for Fragment { + type Item = Frame; + type IntoIter = std::vec::IntoIter<Frame>; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> IntoIterator for &'a Fragment { + type Item = &'a Frame; + type IntoIter = std::slice::Iter<'a, Frame>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl<'a> IntoIterator for &'a mut Fragment { + type Item = &'a mut Frame; + type IntoIter = std::slice::IterMut<'a, Frame>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut() + } +} diff --git a/crates/typst-library/src/layout/grid.rs b/crates/typst-library/src/layout/grid.rs new file mode 100644 index 00000000..4f5175e9 --- /dev/null +++ b/crates/typst-library/src/layout/grid.rs @@ -0,0 +1,708 @@ +use crate::prelude::*; +use crate::text::TextElem; + +use super::Sizing; + +/// Arranges content in a grid. +/// +/// The grid element allows you to arrange content in a grid. You can define the +/// number of rows and columns, as well as the size of the gutters between them. +/// There are multiple sizing modes for columns and rows that can be used to +/// create complex layouts. +/// +/// The sizing of the grid is determined by the track sizes specified in the +/// arguments. Because each of the sizing parameters accepts the same values, we +/// will explain them just once, here. Each sizing argument accepts an array of +/// individual track sizes. A track size is either: +/// +/// - `{auto}`: The track will be sized to fit its contents. It will be at most +/// as large as the remaining space. If there is more than one `{auto}` track +/// which, and together they claim more than the available space, the `{auto}` +/// tracks will fairly distribute the available space among themselves. +/// +/// - A fixed or relative length (e.g. `{10pt}` or `{20% - 1cm}`): The track +/// will be exactly of this size. +/// +/// - A fractional length (e.g. `{1fr}`): Once all other tracks have been sized, +/// the remaining space will be divided among the fractional tracks according +/// to their fractions. For example, if there are two fractional tracks, each +/// with a fraction of `{1fr}`, they will each take up half of the remaining +/// space. +/// +/// To specify a single track, the array can be omitted in favor of a single +/// value. To specify multiple `{auto}` tracks, enter the number of tracks +/// instead of an array. For example, `columns:` `{3}` is equivalent to +/// `columns:` `{(auto, auto, auto)}`. +/// +/// ## Example { #example } +/// ```example +/// #set text(10pt, style: "italic") +/// #let cell = rect.with( +/// inset: 8pt, +/// fill: rgb("e4e5ea"), +/// width: 100%, +/// radius: 6pt +/// ) +/// #grid( +/// columns: (60pt, 1fr, 60pt), +/// rows: (60pt, auto), +/// gutter: 3pt, +/// cell(height: 100%)[Easy to learn], +/// cell(height: 100%)[Great output], +/// cell(height: 100%)[Intuitive], +/// cell[Our best Typst yet], +/// cell[ +/// Responsive design in print +/// for everyone +/// ], +/// cell[One more thing...], +/// ) +/// ``` +/// +/// Display: Grid +/// Category: layout +#[element(Layout)] +pub struct GridElem { + /// The column sizes. + /// + /// Either specify a track size array or provide an integer to create a grid + /// with that many `{auto}`-sized columns. Note that opposed to rows and + /// gutters, providing a single track size will only ever create a single + /// column. + pub columns: TrackSizings, + + /// The row sizes. + /// + /// If there are more cells than fit the defined rows, the last row is + /// repeated until there are no more cells. + pub rows: TrackSizings, + + /// The gaps between rows & columns. + /// + /// If there are more gutters than defined sizes, the last gutter is repeated. + #[external] + pub gutter: TrackSizings, + + /// The gaps between columns. Takes precedence over `gutter`. + #[parse( + let gutter = args.named("gutter")?; + args.named("column-gutter")?.or_else(|| gutter.clone()) + )] + pub column_gutter: TrackSizings, + + /// The gaps between rows. Takes precedence over `gutter`. + #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))] + pub row_gutter: TrackSizings, + + /// The contents of the grid cells. + /// + /// The cells are populated in row-major order. + #[variadic] + pub children: Vec<Content>, +} + +impl Layout for GridElem { + #[tracing::instrument(name = "GridElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + // Prepare grid layout by unifying content and gutter tracks. + let cells = self.children(); + let layouter = GridLayouter::new( + Axes::new(&self.columns(styles).0, &self.rows(styles).0), + Axes::new(&self.column_gutter(styles).0, &self.row_gutter(styles).0), + &cells, + regions, + styles, + ); + + // Measure the columns and layout the grid row-by-row. + Ok(layouter.layout(vt)?.fragment) + } +} + +/// Track sizing definitions. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct TrackSizings(pub Vec<Sizing>); + +cast! { + TrackSizings, + self => self.0.into_value(), + sizing: Sizing => Self(vec![sizing]), + count: NonZeroUsize => Self(vec![Sizing::Auto; count.get()]), + values: Array => Self(values.into_iter().map(Value::cast).collect::<StrResult<_>>()?), +} + +/// Performs grid layout. +pub struct GridLayouter<'a> { + /// The grid cells. + cells: &'a [Content], + /// Whether this is an RTL grid. + is_rtl: bool, + /// Whether this grid has gutters. + has_gutter: bool, + /// The column tracks including gutter tracks. + cols: Vec<Sizing>, + /// The row tracks including gutter tracks. + rows: Vec<Sizing>, + /// The regions to layout children into. + regions: Regions<'a>, + /// The inherited styles. + styles: StyleChain<'a>, + /// Resolved column sizes. + rcols: Vec<Abs>, + /// The sum of `rcols`. + width: Abs, + /// Resolve row sizes, by region. + rrows: Vec<Vec<RowPiece>>, + /// Rows in the current region. + lrows: Vec<Row>, + /// The initial size of the current region before we started subtracting. + initial: Size, + /// Frames for finished regions. + finished: Vec<Frame>, +} + +/// The resulting sizes of columns and rows in a grid. +#[derive(Debug)] +pub struct GridLayout { + /// The fragment. + pub fragment: Fragment, + /// The column widths. + pub cols: Vec<Abs>, + /// The heights of the resulting rows segments, by region. + pub rows: Vec<Vec<RowPiece>>, +} + +/// Details about a resulting row piece. +#[derive(Debug)] +pub struct RowPiece { + /// The height of the segment. + pub height: Abs, + /// The index of the row. + pub y: usize, +} + +/// Produced by initial row layout, auto and relative rows are already finished, +/// fractional rows not yet. +enum Row { + /// Finished row frame of auto or relative row with y index. + Frame(Frame, usize), + /// Fractional row with y index. + Fr(Fr, usize), +} + +impl<'a> GridLayouter<'a> { + /// Create a new grid layouter. + /// + /// This prepares grid layout by unifying content and gutter tracks. + pub fn new( + tracks: Axes<&[Sizing]>, + gutter: Axes<&[Sizing]>, + cells: &'a [Content], + regions: Regions<'a>, + styles: StyleChain<'a>, + ) -> Self { + let mut cols = vec![]; + let mut rows = vec![]; + + // Number of content columns: Always at least one. + let c = tracks.x.len().max(1); + + // Number of content rows: At least as many as given, but also at least + // as many as needed to place each item. + let r = { + let len = cells.len(); + let given = tracks.y.len(); + let needed = len / c + (len % c).clamp(0, 1); + given.max(needed) + }; + + let has_gutter = gutter.any(|tracks| !tracks.is_empty()); + let auto = Sizing::Auto; + let zero = Sizing::Rel(Rel::zero()); + let get_or = |tracks: &[_], idx, default| { + tracks.get(idx).or(tracks.last()).copied().unwrap_or(default) + }; + + // Collect content and gutter columns. + for x in 0..c { + cols.push(get_or(tracks.x, x, auto)); + if has_gutter { + cols.push(get_or(gutter.x, x, zero)); + } + } + + // Collect content and gutter rows. + for y in 0..r { + rows.push(get_or(tracks.y, y, auto)); + if has_gutter { + rows.push(get_or(gutter.y, y, zero)); + } + } + + // Remove superfluous gutter tracks. + if has_gutter { + cols.pop(); + rows.pop(); + } + + // Reverse for RTL. + let is_rtl = TextElem::dir_in(styles) == Dir::RTL; + if is_rtl { + cols.reverse(); + } + + // We use these regions for auto row measurement. Since at that moment, + // columns are already sized, we can enable horizontal expansion. + let mut regions = regions; + regions.expand = Axes::new(true, false); + + Self { + cells, + is_rtl, + has_gutter, + rows, + regions, + styles, + rcols: vec![Abs::zero(); cols.len()], + cols, + width: Abs::zero(), + rrows: vec![], + lrows: vec![], + initial: regions.size, + finished: vec![], + } + } + + /// Determines the columns sizes and then layouts the grid row-by-row. + pub fn layout(mut self, vt: &mut Vt) -> SourceResult<GridLayout> { + self.measure_columns(vt)?; + + for y in 0..self.rows.len() { + // Skip to next region if current one is full, but only for content + // rows, not for gutter rows. + if self.regions.is_full() && (!self.has_gutter || y % 2 == 0) { + self.finish_region(vt)?; + } + + match self.rows[y] { + Sizing::Auto => self.layout_auto_row(vt, y)?, + Sizing::Rel(v) => self.layout_relative_row(vt, v, y)?, + Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)), + } + } + + self.finish_region(vt)?; + + Ok(GridLayout { + fragment: Fragment::frames(self.finished), + cols: self.rcols, + rows: self.rrows, + }) + } + + /// Determine all column sizes. + #[tracing::instrument(name = "GridLayouter::measure_columns", skip_all)] + fn measure_columns(&mut self, vt: &mut Vt) -> SourceResult<()> { + // Sum of sizes of resolved relative tracks. + let mut rel = Abs::zero(); + + // Sum of fractions of all fractional tracks. + let mut fr = Fr::zero(); + + // Resolve the size of all relative columns and compute the sum of all + // fractional tracks. + for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) { + match col { + Sizing::Auto => {} + Sizing::Rel(v) => { + let resolved = + v.resolve(self.styles).relative_to(self.regions.base().x); + *rcol = resolved; + rel += resolved; + } + Sizing::Fr(v) => fr += v, + } + } + + // Size that is not used by fixed-size columns. + let available = self.regions.size.x - rel; + if available >= Abs::zero() { + // Determine size of auto columns. + let (auto, count) = self.measure_auto_columns(vt, available)?; + + // If there is remaining space, distribute it to fractional columns, + // otherwise shrink auto columns. + let remaining = available - auto; + if remaining >= Abs::zero() { + self.grow_fractional_columns(remaining, fr); + } else { + self.shrink_auto_columns(available, count); + } + } + + // Sum up the resolved column sizes once here. + self.width = self.rcols.iter().sum(); + + Ok(()) + } + + /// Measure the size that is available to auto columns. + fn measure_auto_columns( + &mut self, + vt: &mut Vt, + available: Abs, + ) -> SourceResult<(Abs, usize)> { + let mut auto = Abs::zero(); + let mut count = 0; + + // Determine size of auto columns by laying out all cells in those + // columns, measuring them and finding the largest one. + for (x, &col) in self.cols.iter().enumerate() { + if col != Sizing::Auto { + continue; + } + + let mut resolved = Abs::zero(); + for y in 0..self.rows.len() { + if let Some(cell) = self.cell(x, y) { + // For relative rows, we can already resolve the correct + // base and for auto and fr we could only guess anyway. + let height = match self.rows[y] { + Sizing::Rel(v) => { + v.resolve(self.styles).relative_to(self.regions.base().y) + } + _ => self.regions.base().y, + }; + + let size = Size::new(available, height); + let pod = Regions::one(size, Axes::splat(false)); + let frame = cell.measure(vt, self.styles, pod)?.into_frame(); + resolved.set_max(frame.width()); + } + } + + self.rcols[x] = resolved; + auto += resolved; + count += 1; + } + + Ok((auto, count)) + } + + /// Distribute remaining space to fractional columns. + fn grow_fractional_columns(&mut self, remaining: Abs, fr: Fr) { + if fr.is_zero() { + return; + } + + for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) { + if let Sizing::Fr(v) = col { + *rcol = v.share(fr, remaining); + } + } + } + + /// Redistribute space to auto columns so that each gets a fair share. + fn shrink_auto_columns(&mut self, available: Abs, count: usize) { + let mut last; + let mut fair = -Abs::inf(); + let mut redistribute = available; + let mut overlarge = count; + let mut changed = true; + + // Iteratively remove columns that don't need to be shrunk. + while changed && overlarge > 0 { + changed = false; + last = fair; + fair = redistribute / (overlarge as f64); + + for (&col, &rcol) in self.cols.iter().zip(&self.rcols) { + // Remove an auto column if it is not overlarge (rcol <= fair), + // but also hasn't already been removed (rcol > last). + if col == Sizing::Auto && rcol <= fair && rcol > last { + redistribute -= rcol; + overlarge -= 1; + changed = true; + } + } + } + + // Redistribute space fairly among overlarge columns. + for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) { + if col == Sizing::Auto && *rcol > fair { + *rcol = fair; + } + } + } + + /// Layout a row with automatic height. Such a row may break across multiple + /// regions. + fn layout_auto_row(&mut self, vt: &mut Vt, y: usize) -> SourceResult<()> { + // Determine the size for each region of the row. If the first region + // ends up empty for some column, skip the region and remeasure. + let mut resolved = match self.measure_auto_row(vt, y, true)? { + Some(resolved) => resolved, + None => { + self.finish_region(vt)?; + self.measure_auto_row(vt, y, false)?.unwrap() + } + }; + + // Nothing to layout. + if resolved.is_empty() { + return Ok(()); + } + + // Layout into a single region. + if let &[first] = resolved.as_slice() { + let frame = self.layout_single_row(vt, first, y)?; + self.push_row(frame, y); + return Ok(()); + } + + // Expand all but the last region. + // Skip the first region if the space is eaten up by an fr row. + let len = resolved.len(); + for (region, target) in self + .regions + .iter() + .zip(&mut resolved[..len - 1]) + .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) + { + target.set_max(region.y); + } + + // Layout into multiple regions. + let fragment = self.layout_multi_row(vt, &resolved, y)?; + let len = fragment.len(); + for (i, frame) in fragment.into_iter().enumerate() { + self.push_row(frame, y); + if i + 1 < len { + self.finish_region(vt)?; + } + } + + Ok(()) + } + + /// Measure the regions sizes of an auto row. The option is always `Some(_)` + /// if `can_skip` is false. + fn measure_auto_row( + &mut self, + vt: &mut Vt, + y: usize, + can_skip: bool, + ) -> SourceResult<Option<Vec<Abs>>> { + let mut resolved: Vec<Abs> = vec![]; + + for (x, &rcol) in self.rcols.iter().enumerate() { + if let Some(cell) = self.cell(x, y) { + let mut pod = self.regions; + pod.size.x = rcol; + + let frames = cell.measure(vt, self.styles, pod)?.into_frames(); + + // Skip the first region if one cell in it is empty. Then, + // remeasure. + if let [first, rest @ ..] = frames.as_slice() { + if can_skip + && first.is_empty() + && rest.iter().any(|frame| !frame.is_empty()) + { + return Ok(None); + } + } + + let mut sizes = frames.iter().map(|frame| frame.height()); + for (target, size) in resolved.iter_mut().zip(&mut sizes) { + target.set_max(size); + } + + // New heights are maximal by virtue of being new. Note that + // this extend only uses the rest of the sizes iterator. + resolved.extend(sizes); + } + } + + Ok(Some(resolved)) + } + + /// Layout a row with relative height. Such a row cannot break across + /// multiple regions, but it may force a region break. + fn layout_relative_row( + &mut self, + vt: &mut Vt, + v: Rel<Length>, + y: usize, + ) -> SourceResult<()> { + let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); + let frame = self.layout_single_row(vt, resolved, y)?; + + // Skip to fitting region. + let height = frame.height(); + while !self.regions.size.y.fits(height) && !self.regions.in_last() { + self.finish_region(vt)?; + + // Don't skip multiple regions for gutter and don't push a row. + if self.has_gutter && y % 2 == 1 { + return Ok(()); + } + } + + self.push_row(frame, y); + + Ok(()) + } + + /// Layout a row with fixed height and return its frame. + fn layout_single_row( + &mut self, + vt: &mut Vt, + height: Abs, + y: usize, + ) -> SourceResult<Frame> { + let mut output = Frame::new(Size::new(self.width, height)); + let mut pos = Point::zero(); + + for (x, &rcol) in self.rcols.iter().enumerate() { + if let Some(cell) = self.cell(x, y) { + let size = Size::new(rcol, height); + let mut pod = Regions::one(size, Axes::splat(true)); + if self.rows[y] == Sizing::Auto { + pod.full = self.regions.full; + } + let frame = cell.layout(vt, self.styles, pod)?.into_frame(); + output.push_frame(pos, frame); + } + + pos.x += rcol; + } + + Ok(output) + } + + /// Layout a row spanning multiple regions. + fn layout_multi_row( + &mut self, + vt: &mut Vt, + heights: &[Abs], + y: usize, + ) -> SourceResult<Fragment> { + // Prepare frames. + let mut outputs: Vec<_> = heights + .iter() + .map(|&h| Frame::new(Size::new(self.width, h))) + .collect(); + + // Prepare regions. + let size = Size::new(self.width, heights[0]); + let mut pod = Regions::one(size, Axes::splat(true)); + pod.full = self.regions.full; + pod.backlog = &heights[1..]; + + // Layout the row. + let mut pos = Point::zero(); + for (x, &rcol) in self.rcols.iter().enumerate() { + if let Some(cell) = self.cell(x, y) { + pod.size.x = rcol; + + // Push the layouted frames into the individual output frames. + let fragment = cell.layout(vt, self.styles, pod)?; + for (output, frame) in outputs.iter_mut().zip(fragment) { + output.push_frame(pos, frame); + } + } + + pos.x += rcol; + } + + Ok(Fragment::frames(outputs)) + } + + /// Push a row frame into the current region. + fn push_row(&mut self, frame: Frame, y: usize) { + self.regions.size.y -= frame.height(); + self.lrows.push(Row::Frame(frame, y)); + } + + /// Finish rows for one region. + fn finish_region(&mut self, vt: &mut Vt) -> SourceResult<()> { + // Determine the height of existing rows in the region. + let mut used = Abs::zero(); + let mut fr = Fr::zero(); + for row in &self.lrows { + match row { + Row::Frame(frame, _) => used += frame.height(), + Row::Fr(v, _) => fr += *v, + } + } + + // Determine the size of the grid in this region, expanding fully if + // there are fr rows. + let mut size = Size::new(self.width, used).min(self.initial); + if fr.get() > 0.0 && self.initial.y.is_finite() { + size.y = self.initial.y; + } + + // The frame for the region. + let mut output = Frame::new(size); + let mut pos = Point::zero(); + let mut rrows = vec![]; + + // Place finished rows and layout fractional rows. + for row in std::mem::take(&mut self.lrows) { + let (frame, y) = match row { + Row::Frame(frame, y) => (frame, y), + Row::Fr(v, y) => { + let remaining = self.regions.full - used; + let height = v.share(fr, remaining); + (self.layout_single_row(vt, height, y)?, y) + } + }; + + let height = frame.height(); + output.push_frame(pos, frame); + rrows.push(RowPiece { height, y }); + pos.y += height; + } + + self.finished.push(output); + self.rrows.push(rrows); + self.regions.next(); + self.initial = self.regions.size; + + Ok(()) + } + + /// Get the content of the cell in column `x` and row `y`. + /// + /// Returns `None` if it's a gutter cell. + #[track_caller] + fn cell(&self, mut x: usize, y: usize) -> Option<&'a Content> { + assert!(x < self.cols.len()); + assert!(y < self.rows.len()); + + // Columns are reorder, but the cell slice is not. + if self.is_rtl { + x = self.cols.len() - 1 - x; + } + + if self.has_gutter { + // Even columns and rows are children, odd ones are gutter. + if x % 2 == 0 && y % 2 == 0 { + let c = 1 + self.cols.len() / 2; + self.cells.get((y / 2) * c + x / 2) + } else { + None + } + } else { + let c = self.cols.len(); + self.cells.get(y * c + x) + } + } +} diff --git a/crates/typst-library/src/layout/hide.rs b/crates/typst-library/src/layout/hide.rs new file mode 100644 index 00000000..c6e83e0c --- /dev/null +++ b/crates/typst-library/src/layout/hide.rs @@ -0,0 +1,30 @@ +use crate::prelude::*; + +/// Hides content without affecting layout. +/// +/// The `hide` function allows you to hide content while the layout still 'sees' +/// it. This is useful to create whitespace that is exactly as large as some +/// content. It may also be useful to redact content because its arguments are +/// not included in the output. +/// +/// ## Example { #example } +/// ```example +/// Hello Jane \ +/// #hide[Hello] Joe +/// ``` +/// +/// Display: Hide +/// Category: layout +#[element(Show)] +pub struct HideElem { + /// The content to hide. + #[required] + pub body: Content, +} + +impl Show for HideElem { + #[tracing::instrument(name = "HideElem::show", skip(self))] + fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> { + Ok(self.body().styled(MetaElem::set_data(vec![Meta::Hide]))) + } +} diff --git a/crates/typst-library/src/layout/list.rs b/crates/typst-library/src/layout/list.rs new file mode 100644 index 00000000..e39ec3f5 --- /dev/null +++ b/crates/typst-library/src/layout/list.rs @@ -0,0 +1,239 @@ +use crate::layout::{BlockElem, ParElem, Sizing, Spacing}; +use crate::prelude::*; +use crate::text::TextElem; + +use super::GridLayouter; + +/// A bullet list. +/// +/// Displays a sequence of items vertically, with each item introduced by a +/// marker. +/// +/// ## Example { #example } +/// ```example +/// - *Content* +/// - Text +/// - Math +/// - Layout +/// - Visualize +/// - Meta +/// - Symbols +/// +/// - *Compute* +/// #list( +/// [Foundations], +/// [Calculate], +/// [Construct], +/// [Data Loading], +/// ) +/// ``` +/// +/// ## Syntax { #syntax } +/// This functions also has dedicated syntax: Start a line with a hyphen, +/// followed by a space to create a list item. A list item can contain multiple +/// paragraphs and other block-level content. All content that is indented +/// more than an item's hyphen becomes part of that item. +/// +/// Display: Bullet List +/// Category: layout +#[element(Layout)] +#[scope( + scope.define("item", ListItem::func()); + scope +)] +pub struct ListElem { + /// If this is `{false}`, the items are spaced apart with [list + /// spacing]($func/list.spacing). If it is `{true}`, they use normal + /// [leading]($func/par.leading) instead. This makes the list more compact, + /// which can look better if the items are short. + /// + /// In markup mode, the value of this parameter is determined based on + /// whether items are separated with a blank line. If items directly follow + /// each other, this is set to `{true}`; if items are separated by a blank + /// line, this is set to `{false}`. + /// + /// ```example + /// - If a list has a lot of text, and + /// maybe other inline content, it + /// should not be tight anymore. + /// + /// - To make a list wide, simply insert + /// a blank line between the items. + /// ``` + #[default(true)] + pub tight: bool, + + /// The marker which introduces each item. + /// + /// Instead of plain content, you can also pass an array with multiple + /// markers that should be used for nested lists. If the list nesting depth + /// exceeds the number of markers, the last one is repeated. For total + /// control, you may pass a function that maps the list's nesting depth + /// (starting from `{0}`) to a desired marker. + /// + /// ```example + /// #set list(marker: [--]) + /// - A more classic list + /// - With en-dashes + /// + /// #set list(marker: ([•], [--])) + /// - Top-level + /// - Nested + /// - Items + /// - Items + /// ``` + #[default(ListMarker::Content(vec![TextElem::packed('•')]))] + pub marker: ListMarker, + + /// The indent of each item. + #[resolve] + pub indent: Length, + + /// The spacing between the marker and the body of each item. + #[resolve] + #[default(Em::new(0.5).into())] + pub body_indent: Length, + + /// The spacing between the items of a wide (non-tight) list. + /// + /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below). + pub spacing: Smart<Spacing>, + + /// The bullet list's children. + /// + /// When using the list syntax, adjacent items are automatically collected + /// into lists, even through constructs like for loops. + /// + /// ```example + /// #for letter in "ABC" [ + /// - Letter #letter + /// ] + /// ``` + #[variadic] + pub children: Vec<ListItem>, + + /// The nesting depth. + #[internal] + #[fold] + depth: Depth, +} + +impl Layout for ListElem { + #[tracing::instrument(name = "ListElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let indent = self.indent(styles); + let body_indent = self.body_indent(styles); + let gutter = if self.tight(styles) { + ParElem::leading_in(styles).into() + } else { + self.spacing(styles) + .unwrap_or_else(|| BlockElem::below_in(styles).amount()) + }; + + let depth = self.depth(styles); + let marker = self + .marker(styles) + .resolve(vt, depth)? + // avoid '#set align' interference with the list + .aligned(Align::LEFT_TOP.into()); + + let mut cells = vec![]; + for item in self.children() { + cells.push(Content::empty()); + cells.push(marker.clone()); + cells.push(Content::empty()); + cells.push(item.body().styled(Self::set_depth(Depth))); + } + + let layouter = GridLayouter::new( + Axes::with_x(&[ + Sizing::Rel(indent.into()), + Sizing::Auto, + Sizing::Rel(body_indent.into()), + Sizing::Auto, + ]), + Axes::with_y(&[gutter.into()]), + &cells, + regions, + styles, + ); + + Ok(layouter.layout(vt)?.fragment) + } +} + +/// A bullet list item. +/// +/// Display: Bullet List Item +/// Category: layout +#[element] +pub struct ListItem { + /// The item's body. + #[required] + pub body: Content, +} + +cast! { + ListItem, + v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::new(v.clone())), +} + +/// A list's marker. +#[derive(Debug, Clone, Hash)] +pub enum ListMarker { + Content(Vec<Content>), + Func(Func), +} + +impl ListMarker { + /// Resolve the marker for the given depth. + fn resolve(&self, vt: &mut Vt, depth: usize) -> SourceResult<Content> { + Ok(match self { + Self::Content(list) => { + list.get(depth).or(list.last()).cloned().unwrap_or_default() + } + Self::Func(func) => func.call_vt(vt, [depth])?.display(), + }) + } +} + +cast! { + ListMarker, + self => match self { + Self::Content(vec) => if vec.len() == 1 { + vec.into_iter().next().unwrap().into_value() + } else { + vec.into_value() + }, + Self::Func(func) => func.into_value(), + }, + v: Content => Self::Content(vec![v]), + array: Array => { + if array.is_empty() { + bail!("array must contain at least one marker"); + } + Self::Content(array.into_iter().map(Value::display).collect()) + }, + v: Func => Self::Func(v), +} + +struct Depth; + +cast! { + Depth, + self => Value::None, + _: Value => Self, +} + +impl Fold for Depth { + type Output = usize; + + fn fold(self, outer: Self::Output) -> Self::Output { + outer + 1 + } +} diff --git a/crates/typst-library/src/layout/measure.rs b/crates/typst-library/src/layout/measure.rs new file mode 100644 index 00000000..eb8e509e --- /dev/null +++ b/crates/typst-library/src/layout/measure.rs @@ -0,0 +1,56 @@ +use crate::prelude::*; + +/// Measures the layouted size of content. +/// +/// The `measure` function lets you determine the layouted size of content. +/// Note that an infinite space is assumed, therefore the measured height/width +/// may not necessarily match the final height/width of the measured content. +/// If you want to measure in the current layout dimensions, you can combined +/// `measure` and [`layout`]($func/layout). +/// +/// # Example { #example } +/// The same content can have a different size depending on the styles that +/// are active when it is layouted. For example, in the example below +/// `[#content]` is of course bigger when we increase the font size. +/// +/// ```example +/// #let content = [Hello!] +/// #content +/// #set text(14pt) +/// #content +/// ``` +/// +/// To do a meaningful measurement, you therefore first need to retrieve the +/// active styles with the [`style`]($func/style) function. You can then pass +/// them to the `measure` function. +/// +/// ```example +/// #let thing(body) = style(styles => { +/// let size = measure(body, styles) +/// [Width of "#body" is #size.width] +/// }) +/// +/// #thing[Hey] \ +/// #thing[Welcome] +/// ``` +/// +/// The measure function returns a dictionary with the entries `width` and +/// `height`, both of type [`length`]($type/length). +/// +/// Display: Measure +/// Category: layout +#[func] +pub fn measure( + /// The content whose size to measure. + content: Content, + /// The styles with which to layout the content. + styles: Styles, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Dict> { + let pod = Regions::one(Axes::splat(Abs::inf()), Axes::splat(false)); + let styles = StyleChain::new(&styles); + let frame = content.measure(&mut vm.vt, styles, pod)?.into_frame(); + let Size { x, y } = frame.size(); + Ok(dict! { "width" => x, "height" => y }) +} diff --git a/crates/typst-library/src/layout/mod.rs b/crates/typst-library/src/layout/mod.rs new file mode 100644 index 00000000..41490eb8 --- /dev/null +++ b/crates/typst-library/src/layout/mod.rs @@ -0,0 +1,709 @@ +//! Composable layouts. + +mod align; +mod columns; +mod container; +#[path = "enum.rs"] +mod enum_; +mod flow; +mod fragment; +mod grid; +mod hide; +mod list; +mod measure; +mod pad; +mod page; +mod par; +mod place; +mod regions; +mod repeat; +mod spacing; +mod stack; +mod table; +mod terms; +mod transform; + +pub use self::align::*; +pub use self::columns::*; +pub use self::container::*; +pub use self::enum_::*; +pub use self::flow::*; +pub use self::fragment::*; +pub use self::grid::*; +pub use self::hide::*; +pub use self::list::*; +pub use self::measure::*; +pub use self::pad::*; +pub use self::page::*; +pub use self::par::*; +pub use self::place::*; +pub use self::regions::*; +pub use self::repeat::*; +pub use self::spacing::*; +pub use self::stack::*; +pub use self::table::*; +pub use self::terms::*; +pub use self::transform::*; + +use std::mem; + +use typed_arena::Arena; +use typst::diag::SourceResult; +use typst::eval::Tracer; +use typst::model::DelayedErrors; +use typst::model::{applicable, realize, StyleVecBuilder}; + +use crate::math::{EquationElem, LayoutMath}; +use crate::meta::DocumentElem; +use crate::prelude::*; +use crate::shared::BehavedBuilder; +use crate::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; +use crate::visualize::{ + CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem, + SquareElem, +}; + +/// Hook up all layout definitions. +pub(super) fn define(global: &mut Scope) { + global.define("page", PageElem::func()); + global.define("pagebreak", PagebreakElem::func()); + global.define("v", VElem::func()); + global.define("par", ParElem::func()); + global.define("parbreak", ParbreakElem::func()); + global.define("h", HElem::func()); + global.define("box", BoxElem::func()); + global.define("block", BlockElem::func()); + global.define("list", ListElem::func()); + global.define("enum", EnumElem::func()); + global.define("terms", TermsElem::func()); + global.define("table", TableElem::func()); + global.define("stack", StackElem::func()); + global.define("grid", GridElem::func()); + global.define("columns", ColumnsElem::func()); + global.define("colbreak", ColbreakElem::func()); + global.define("place", PlaceElem::func()); + global.define("align", AlignElem::func()); + global.define("pad", PadElem::func()); + global.define("repeat", RepeatElem::func()); + global.define("move", MoveElem::func()); + global.define("scale", ScaleElem::func()); + global.define("rotate", RotateElem::func()); + global.define("hide", HideElem::func()); + global.define("measure", measure_func()); + global.define("ltr", Dir::LTR); + global.define("rtl", Dir::RTL); + global.define("ttb", Dir::TTB); + global.define("btt", Dir::BTT); + global.define("start", GenAlign::Start); + global.define("end", GenAlign::End); + global.define("left", GenAlign::Specific(Align::Left)); + global.define("center", GenAlign::Specific(Align::Center)); + global.define("right", GenAlign::Specific(Align::Right)); + global.define("top", GenAlign::Specific(Align::Top)); + global.define("horizon", GenAlign::Specific(Align::Horizon)); + global.define("bottom", GenAlign::Specific(Align::Bottom)); +} + +/// Root-level layout. +pub trait LayoutRoot { + /// Layout into one frame per page. + fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document>; +} + +impl LayoutRoot for Content { + #[tracing::instrument(name = "Content::layout_root", skip_all)] + fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document> { + #[comemo::memoize] + fn cached( + content: &Content, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + locator: Tracked<Locator>, + delayed: TrackedMut<DelayedErrors>, + tracer: TrackedMut<Tracer>, + styles: StyleChain, + ) -> SourceResult<Document> { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let scratch = Scratch::default(); + let (realized, styles) = realize_root(&mut vt, &scratch, content, styles)?; + realized + .with::<dyn LayoutRoot>() + .unwrap() + .layout_root(&mut vt, styles) + } + + tracing::info!("Starting layout"); + cached( + self, + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + styles, + ) + } +} + +/// Layout into regions. +pub trait Layout { + /// Layout into one frame per region. + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment>; + + /// Layout without side effects. + /// + /// This element must be layouted again in the same order for the results to + /// be valid. + #[tracing::instrument(name = "Layout::measure", skip_all)] + fn measure( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let mut locator = Locator::chained(vt.locator.track()); + let mut vt = Vt { + world: vt.world, + introspector: vt.introspector, + locator: &mut locator, + tracer: TrackedMut::reborrow_mut(&mut vt.tracer), + delayed: TrackedMut::reborrow_mut(&mut vt.delayed), + }; + self.layout(&mut vt, styles, regions) + } +} + +impl Layout for Content { + #[tracing::instrument(name = "Content::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + #[allow(clippy::too_many_arguments)] + #[comemo::memoize] + fn cached( + content: &Content, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + locator: Tracked<Locator>, + delayed: TrackedMut<DelayedErrors>, + tracer: TrackedMut<Tracer>, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let scratch = Scratch::default(); + let (realized, styles) = realize_block(&mut vt, &scratch, content, styles)?; + realized + .with::<dyn Layout>() + .unwrap() + .layout(&mut vt, styles, regions) + } + + tracing::info!("Layouting `Content`"); + + let fragment = cached( + self, + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + styles, + regions, + )?; + + vt.locator.visit_frames(&fragment); + Ok(fragment) + } +} + +/// Realize into an element that is capable of root-level layout. +#[tracing::instrument(skip_all)] +fn realize_root<'a>( + vt: &mut Vt, + scratch: &'a Scratch<'a>, + content: &'a Content, + styles: StyleChain<'a>, +) -> SourceResult<(Content, StyleChain<'a>)> { + if content.can::<dyn LayoutRoot>() && !applicable(content, styles) { + return Ok((content.clone(), styles)); + } + + let mut builder = Builder::new(vt, scratch, true); + builder.accept(content, styles)?; + builder.interrupt_page(Some(styles))?; + let (pages, shared) = builder.doc.unwrap().pages.finish(); + Ok((DocumentElem::new(pages.to_vec()).pack(), shared)) +} + +/// Realize into an element that is capable of block-level layout. +#[tracing::instrument(skip_all)] +fn realize_block<'a>( + vt: &mut Vt, + scratch: &'a Scratch<'a>, + content: &'a Content, + styles: StyleChain<'a>, +) -> SourceResult<(Content, StyleChain<'a>)> { + if content.can::<dyn Layout>() + && !content.is::<LineElem>() + && !content.is::<RectElem>() + && !content.is::<SquareElem>() + && !content.is::<EllipseElem>() + && !content.is::<CircleElem>() + && !content.is::<ImageElem>() + && !content.is::<PolygonElem>() + && !content.is::<PathElem>() + && !applicable(content, styles) + { + return Ok((content.clone(), styles)); + } + + let mut builder = Builder::new(vt, scratch, false); + builder.accept(content, styles)?; + builder.interrupt_par()?; + let (children, shared) = builder.flow.0.finish(); + Ok((FlowElem::new(children.to_vec()).pack(), shared)) +} + +/// Builds a document or a flow element from content. +struct Builder<'a, 'v, 't> { + /// The virtual typesetter. + vt: &'v mut Vt<'t>, + /// Scratch arenas for building. + scratch: &'a Scratch<'a>, + /// The current document building state. + doc: Option<DocBuilder<'a>>, + /// The current flow building state. + flow: FlowBuilder<'a>, + /// The current paragraph building state. + par: ParBuilder<'a>, + /// The current list building state. + list: ListBuilder<'a>, +} + +/// Temporary storage arenas for building. +#[derive(Default)] +struct Scratch<'a> { + /// An arena where intermediate style chains are stored. + styles: Arena<StyleChain<'a>>, + /// An arena where intermediate content resulting from show rules is stored. + content: Arena<Content>, +} + +impl<'a, 'v, 't> Builder<'a, 'v, 't> { + fn new(vt: &'v mut Vt<'t>, scratch: &'a Scratch<'a>, top: bool) -> Self { + Self { + vt, + scratch, + doc: top.then(DocBuilder::default), + flow: FlowBuilder::default(), + par: ParBuilder::default(), + list: ListBuilder::default(), + } + } + + fn accept( + &mut self, + mut content: &'a Content, + styles: StyleChain<'a>, + ) -> SourceResult<()> { + if content.can::<dyn LayoutMath>() && !content.is::<EquationElem>() { + content = + self.scratch.content.alloc(EquationElem::new(content.clone()).pack()); + } + + if let Some(realized) = realize(self.vt, content, styles)? { + let stored = self.scratch.content.alloc(realized); + return self.accept(stored, styles); + } + + if let Some((elem, local)) = content.to_styled() { + return self.styled(elem, local, styles); + } + + if let Some(children) = content.to_sequence() { + for elem in children { + self.accept(elem, styles)?; + } + return Ok(()); + } + + if self.list.accept(content, styles) { + return Ok(()); + } + + self.interrupt_list()?; + + if self.list.accept(content, styles) { + return Ok(()); + } + + if self.par.accept(content, styles) { + return Ok(()); + } + + self.interrupt_par()?; + + if self.flow.accept(content, styles) { + return Ok(()); + } + + let keep = content + .to::<PagebreakElem>() + .map_or(false, |pagebreak| !pagebreak.weak(styles)); + + self.interrupt_page(keep.then_some(styles))?; + + if let Some(doc) = &mut self.doc { + if doc.accept(content, styles) { + return Ok(()); + } + } + + if content.is::<PagebreakElem>() { + bail!(content.span(), "pagebreaks are not allowed inside of containers"); + } else { + bail!(content.span(), "{} is not allowed here", content.func().name()); + } + } + + fn styled( + &mut self, + elem: &'a Content, + map: &'a Styles, + styles: StyleChain<'a>, + ) -> SourceResult<()> { + let stored = self.scratch.styles.alloc(styles); + let styles = stored.chain(map); + self.interrupt_style(map, None)?; + self.accept(elem, styles)?; + self.interrupt_style(map, Some(styles))?; + Ok(()) + } + + fn interrupt_style( + &mut self, + local: &Styles, + outer: Option<StyleChain<'a>>, + ) -> SourceResult<()> { + if let Some(Some(span)) = local.interruption::<DocumentElem>() { + if self.doc.is_none() { + bail!(span, "document set rules are not allowed inside of containers"); + } + if outer.is_none() + && (!self.flow.0.is_empty() + || !self.par.0.is_empty() + || !self.list.items.is_empty()) + { + bail!(span, "document set rules must appear before any content"); + } + } else if let Some(Some(span)) = local.interruption::<PageElem>() { + if self.doc.is_none() { + bail!(span, "page configuration is not allowed inside of containers"); + } + self.interrupt_page(outer)?; + } else if local.interruption::<ParElem>().is_some() + || local.interruption::<AlignElem>().is_some() + { + self.interrupt_par()?; + } else if local.interruption::<ListElem>().is_some() + || local.interruption::<EnumElem>().is_some() + || local.interruption::<TermsElem>().is_some() + { + self.interrupt_list()?; + } + Ok(()) + } + + fn interrupt_list(&mut self) -> SourceResult<()> { + if !self.list.items.is_empty() { + let staged = mem::take(&mut self.list.staged); + let (list, styles) = mem::take(&mut self.list).finish(); + let stored = self.scratch.content.alloc(list); + self.accept(stored, styles)?; + for (content, styles) in staged { + self.accept(content, styles)?; + } + } + Ok(()) + } + + fn interrupt_par(&mut self) -> SourceResult<()> { + self.interrupt_list()?; + if !self.par.0.is_empty() { + let (par, styles) = mem::take(&mut self.par).finish(); + let stored = self.scratch.content.alloc(par); + self.accept(stored, styles)?; + } + + Ok(()) + } + + fn interrupt_page(&mut self, styles: Option<StyleChain<'a>>) -> SourceResult<()> { + self.interrupt_par()?; + let Some(doc) = &mut self.doc else { return Ok(()) }; + if !self.flow.0.is_empty() || (doc.keep_next && styles.is_some()) { + let (flow, shared) = mem::take(&mut self.flow).0.finish(); + let styles = if shared == StyleChain::default() { + styles.unwrap_or_default() + } else { + shared + }; + let page = PageElem::new(FlowElem::new(flow.to_vec()).pack()); + let stored = self.scratch.content.alloc(page.pack()); + self.accept(stored, styles)?; + } + Ok(()) + } +} + +/// Accepts pagebreaks and pages. +struct DocBuilder<'a> { + /// The page runs built so far. + pages: StyleVecBuilder<'a, Content>, + /// Whether to keep a following page even if it is empty. + keep_next: bool, + /// Whether the next page should be cleared to an even or odd number. + clear_next: Option<Parity>, +} + +impl<'a> DocBuilder<'a> { + fn accept(&mut self, content: &Content, styles: StyleChain<'a>) -> bool { + if let Some(pagebreak) = content.to::<PagebreakElem>() { + self.keep_next = !pagebreak.weak(styles); + self.clear_next = pagebreak.to(styles); + return true; + } + + if let Some(page) = content.to::<PageElem>() { + let elem = if let Some(clear_to) = self.clear_next.take() { + let mut page = page.clone(); + page.push_clear_to(Some(clear_to)); + page.pack() + } else { + content.clone() + }; + + self.pages.push(elem, styles); + self.keep_next = false; + return true; + } + + false + } +} + +impl Default for DocBuilder<'_> { + fn default() -> Self { + Self { + pages: StyleVecBuilder::new(), + keep_next: true, + clear_next: None, + } + } +} + +/// Accepts flow content. +#[derive(Default)] +struct FlowBuilder<'a>(BehavedBuilder<'a>, bool); + +impl<'a> FlowBuilder<'a> { + fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool { + if content.is::<ParbreakElem>() { + self.1 = true; + return true; + } + + let last_was_parbreak = self.1; + self.1 = false; + + if content.is::<VElem>() + || content.is::<ColbreakElem>() + || content.is::<MetaElem>() + { + self.0.push(content.clone(), styles); + return true; + } + + if content.can::<dyn Layout>() || content.is::<ParElem>() { + let is_tight_list = if let Some(elem) = content.to::<ListElem>() { + elem.tight(styles) + } else if let Some(elem) = content.to::<EnumElem>() { + elem.tight(styles) + } else if let Some(elem) = content.to::<TermsElem>() { + elem.tight(styles) + } else { + false + }; + + if !last_was_parbreak && is_tight_list { + let leading = ParElem::leading_in(styles); + let spacing = VElem::list_attach(leading.into()); + self.0.push(spacing.pack(), styles); + } + + let (above, below) = if let Some(block) = content.to::<BlockElem>() { + (block.above(styles), block.below(styles)) + } else { + (BlockElem::above_in(styles), BlockElem::below_in(styles)) + }; + + self.0.push(above.pack(), styles); + self.0.push(content.clone(), styles); + self.0.push(below.pack(), styles); + return true; + } + + false + } +} + +/// Accepts paragraph content. +#[derive(Default)] +struct ParBuilder<'a>(BehavedBuilder<'a>); + +impl<'a> ParBuilder<'a> { + fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool { + if content.is::<MetaElem>() { + if !self.0.is_basically_empty() { + self.0.push(content.clone(), styles); + return true; + } + } else if content.is::<SpaceElem>() + || content.is::<TextElem>() + || content.is::<HElem>() + || content.is::<LinebreakElem>() + || content.is::<SmartQuoteElem>() + || content.to::<EquationElem>().map_or(false, |elem| !elem.block(styles)) + || content.is::<BoxElem>() + { + self.0.push(content.clone(), styles); + return true; + } + + false + } + + fn finish(self) -> (Content, StyleChain<'a>) { + let (children, shared) = self.0.finish(); + (ParElem::new(children.to_vec()).pack(), shared) + } +} + +/// Accepts list / enum items, spaces, paragraph breaks. +struct ListBuilder<'a> { + /// The list items collected so far. + items: StyleVecBuilder<'a, Content>, + /// Whether the list contains no paragraph breaks. + tight: bool, + /// Trailing content for which it is unclear whether it is part of the list. + staged: Vec<(&'a Content, StyleChain<'a>)>, +} + +impl<'a> ListBuilder<'a> { + fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool { + if !self.items.is_empty() + && (content.is::<SpaceElem>() || content.is::<ParbreakElem>()) + { + self.staged.push((content, styles)); + return true; + } + + if (content.is::<ListItem>() + || content.is::<EnumItem>() + || content.is::<TermItem>()) + && self + .items + .elems() + .next() + .map_or(true, |first| first.func() == content.func()) + { + self.items.push(content.clone(), styles); + self.tight &= self.staged.drain(..).all(|(t, _)| !t.is::<ParbreakElem>()); + return true; + } + + false + } + + fn finish(self) -> (Content, StyleChain<'a>) { + let (items, shared) = self.items.finish(); + let item = items.items().next().unwrap(); + let output = if item.is::<ListItem>() { + ListElem::new( + items + .iter() + .map(|(item, local)| { + let item = item.to::<ListItem>().unwrap(); + item.clone().with_body(item.body().styled_with_map(local.clone())) + }) + .collect::<Vec<_>>(), + ) + .with_tight(self.tight) + .pack() + } else if item.is::<EnumItem>() { + EnumElem::new( + items + .iter() + .map(|(item, local)| { + let item = item.to::<EnumItem>().unwrap(); + item.clone().with_body(item.body().styled_with_map(local.clone())) + }) + .collect::<Vec<_>>(), + ) + .with_tight(self.tight) + .pack() + } else if item.is::<TermItem>() { + TermsElem::new( + items + .iter() + .map(|(item, local)| { + let item = item.to::<TermItem>().unwrap(); + item.clone() + .with_term(item.term().styled_with_map(local.clone())) + .with_description( + item.description().styled_with_map(local.clone()), + ) + }) + .collect::<Vec<_>>(), + ) + .with_tight(self.tight) + .pack() + } else { + unreachable!() + }; + (output, shared) + } +} + +impl Default for ListBuilder<'_> { + fn default() -> Self { + Self { + items: StyleVecBuilder::default(), + tight: true, + staged: vec![], + } + } +} diff --git a/crates/typst-library/src/layout/pad.rs b/crates/typst-library/src/layout/pad.rs new file mode 100644 index 00000000..a3d5646b --- /dev/null +++ b/crates/typst-library/src/layout/pad.rs @@ -0,0 +1,125 @@ +use crate::prelude::*; + +/// Adds spacing around content. +/// +/// The spacing can be specified for each side individually, or for all sides at +/// once by specifying a positional argument. +/// +/// ## Example { #example } +/// ```example +/// #set align(center) +/// +/// #pad(x: 16pt, image("typing.jpg")) +/// _Typing speeds can be +/// measured in words per minute._ +/// ``` +/// +/// Display: Padding +/// Category: layout +#[element(Layout)] +pub struct PadElem { + /// The padding at the left side. + #[parse( + let all = args.named("rest")?.or(args.find()?); + let x = args.named("x")?.or(all); + let y = args.named("y")?.or(all); + args.named("left")?.or(x) + )] + pub left: Rel<Length>, + + /// The padding at the top side. + #[parse(args.named("top")?.or(y))] + pub top: Rel<Length>, + + /// The padding at the right side. + #[parse(args.named("right")?.or(x))] + pub right: Rel<Length>, + + /// The padding at the bottom side. + #[parse(args.named("bottom")?.or(y))] + pub bottom: Rel<Length>, + + /// The horizontal padding. Both `left` and `right` take precedence over + /// this. + #[external] + pub x: Rel<Length>, + + /// The vertical padding. Both `top` and `bottom` take precedence over this. + #[external] + pub y: Rel<Length>, + + /// The padding for all sides. All other parameters take precedence over + /// this. + #[external] + pub rest: Rel<Length>, + + /// The content to pad at the sides. + #[required] + pub body: Content, +} + +impl Layout for PadElem { + #[tracing::instrument(name = "PadElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let sides = Sides::new( + self.left(styles), + self.top(styles), + self.right(styles), + self.bottom(styles), + ); + + // Layout child into padded regions. + let mut backlog = vec![]; + let padding = sides.resolve(styles); + let pod = regions.map(&mut backlog, |size| shrink(size, padding)); + let mut fragment = self.body().layout(vt, styles, pod)?; + + for frame in &mut fragment { + // Apply the padding inversely such that the grown size padded + // yields the frame's size. + let padded = grow(frame.size(), padding); + let padding = padding.relative_to(padded); + let offset = Point::new(padding.left, padding.top); + + // Grow the frame and translate everything in the frame inwards. + frame.set_size(padded); + frame.translate(offset); + } + + Ok(fragment) + } +} + +/// Shrink a size by padding relative to the size itself. +fn shrink(size: Size, padding: Sides<Rel<Abs>>) -> Size { + size - padding.relative_to(size).sum_by_axis() +} + +/// Grow a size by padding relative to the grown size. +/// This is the inverse operation to `shrink()`. +/// +/// For the horizontal axis the derivation looks as follows. +/// (Vertical axis is analogous.) +/// +/// Let w be the grown target width, +/// s be the given width, +/// l be the left padding, +/// r be the right padding, +/// p = l + r. +/// +/// We want that: w - l.resolve(w) - r.resolve(w) = s +/// +/// Thus: w - l.resolve(w) - r.resolve(w) = s +/// <=> w - p.resolve(w) = s +/// <=> w - p.rel * w - p.abs = s +/// <=> (1 - p.rel) * w = s + p.abs +/// <=> w = (s + p.abs) / (1 - p.rel) +fn grow(size: Size, padding: Sides<Rel<Abs>>) -> Size { + size.zip(padding.sum_by_axis()) + .map(|(s, p)| (s + p.abs).safe_div(1.0 - p.rel.get())) +} diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs new file mode 100644 index 00000000..3b81f92c --- /dev/null +++ b/crates/typst-library/src/layout/page.rs @@ -0,0 +1,898 @@ +use std::ptr; +use std::str::FromStr; + +use super::{AlignElem, ColumnsElem}; +use crate::meta::{Counter, CounterKey, Numbering}; +use crate::prelude::*; +use crate::text::TextElem; + +/// Layouts its child onto one or multiple pages. +/// +/// Although this function is primarily used in set rules to affect page +/// properties, it can also be used to explicitly render its argument onto +/// a set of pages of its own. +/// +/// Pages can be set to use `{auto}` as their width or height. In this case, +/// the pages will grow to fit their content on the respective axis. +/// +/// ## Example { #example } +/// ```example +/// >>> #set page(margin: auto) +/// #set page("us-letter") +/// +/// There you go, US friends! +/// ``` +/// +/// Display: Page +/// Category: layout +#[element] +pub struct PageElem { + /// A standard paper size to set width and height. + #[external] + #[default(Paper::A4)] + pub paper: Paper, + + /// The width of the page. + /// + /// ```example + /// #set page( + /// width: 3cm, + /// margin: (x: 0cm), + /// ) + /// + /// #for i in range(3) { + /// box(square(width: 1cm)) + /// } + /// ``` + #[resolve] + #[parse( + let paper = args.named_or_find::<Paper>("paper")?; + args.named("width")? + .or_else(|| paper.map(|paper| Smart::Custom(paper.width().into()))) + )] + #[default(Smart::Custom(Paper::A4.width().into()))] + pub width: Smart<Length>, + + /// The height of the page. + /// + /// If this is set to `{auto}`, page breaks can only be triggered manually + /// by inserting a [page break]($func/pagebreak). Most examples throughout + /// this documentation use `{auto}` for the height of the page to + /// dynamically grow and shrink to fit their content. + #[resolve] + #[parse( + args.named("height")? + .or_else(|| paper.map(|paper| Smart::Custom(paper.height().into()))) + )] + #[default(Smart::Custom(Paper::A4.height().into()))] + pub height: Smart<Length>, + + /// Whether the page is flipped into landscape orientation. + /// + /// ```example + /// #set page( + /// "us-business-card", + /// flipped: true, + /// fill: rgb("f2e5dd"), + /// ) + /// + /// #set align(bottom + end) + /// #text(14pt)[*Sam H. Richards*] \ + /// _Procurement Manager_ + /// + /// #set text(10pt) + /// 17 Main Street \ + /// New York, NY 10001 \ + /// +1 555 555 5555 + /// ``` + #[default(false)] + pub flipped: bool, + + /// The page's margins. + /// + /// - A single length: The same margin on all sides. + /// - `{auto}`: The margin is set to the default value for the page's size. + /// - A dictionary: With a dictionary, the margins can be set individually. + /// The dictionary can contain the following keys in order of precedence: + /// - `top`: The top margin. + /// - `right`: The right margin. + /// - `bottom`: The bottom margin. + /// - `left`: The left margin. + /// - `inside`: The margin at the inner side of the page (where the + /// [binding]($func/page.binding) is). + /// - `outside`: The margin at the outer side of the page (opposite to the + /// [binding]($func/page.binding)). + /// - `x`: The horizontal margins. + /// - `y`: The vertical margins. + /// - `rest`: The margins on all sides except those for which the + /// dictionary explicitly sets a size. + /// + /// The values for `left` and `right` are mutually exclusive with + /// the values for `inside` and `outside`. + /// + /// ```example + /// #set page( + /// width: 3cm, + /// height: 4cm, + /// margin: (x: 8pt, y: 4pt), + /// ) + /// + /// #rect( + /// width: 100%, + /// height: 100%, + /// fill: aqua, + /// ) + /// ``` + #[fold] + pub margin: Margin, + + /// On which side the pages will be bound. + /// + /// - `{auto}`: Equivalent to `left` if the [text direction]($func/text.dir) + /// is left-to-right and `right` if it is right-to-left. + /// - `left`: Bound on the left side. + /// - `right`: Bound on the right side. + /// + /// This affects the meaning of the `inside` and `outside` options for + /// margins. + pub binding: Smart<Binding>, + + /// How many columns the page has. + /// + /// ```example:single + /// #set page(columns: 2, height: 4.8cm) + /// Climate change is one of the most + /// pressing issues of our time, with + /// the potential to devastate + /// communities, ecosystems, and + /// economies around the world. It's + /// clear that we need to take urgent + /// action to reduce our carbon + /// emissions and mitigate the impacts + /// of a rapidly changing climate. + /// ``` + #[default(NonZeroUsize::ONE)] + pub columns: NonZeroUsize, + + /// The page's background color. + /// + /// This instructs the printer to color the complete page with the given + /// color. If you are considering larger production runs, it may be more + /// environmentally friendly and cost-effective to source pre-dyed pages and + /// not set this property. + /// + /// ```example + /// #set page(fill: rgb("444352")) + /// #set text(fill: rgb("fdfdfd")) + /// *Dark mode enabled.* + /// ``` + pub fill: Option<Paint>, + + /// How to [number]($func/numbering) the pages. + /// + /// If an explicit `footer` is given, the numbering is ignored. + /// + /// ```example + /// #set page( + /// height: 100pt, + /// margin: (top: 16pt, bottom: 24pt), + /// numbering: "1 / 1", + /// ) + /// + /// #lorem(48) + /// ``` + pub numbering: Option<Numbering>, + + /// The alignment of the page numbering. + /// + /// ```example + /// #set page( + /// margin: (top: 16pt, bottom: 24pt), + /// numbering: "1", + /// number-align: right, + /// ) + /// + /// #lorem(30) + /// ``` + #[default(Align::Center.into())] + pub number_align: Axes<Option<GenAlign>>, + + /// The page's header. Fills the top margin of each page. + /// + /// ```example + /// #set par(justify: true) + /// #set page( + /// margin: (top: 32pt, bottom: 20pt), + /// header: [ + /// #set text(8pt) + /// #smallcaps[Typst Academcy] + /// #h(1fr) _Exercise Sheet 3_ + /// ], + /// ) + /// + /// #lorem(19) + /// ``` + pub header: Option<Content>, + + /// The amount the header is raised into the top margin. + #[resolve] + #[default(Ratio::new(0.3).into())] + pub header_ascent: Rel<Length>, + + /// The page's footer. Fills the bottom margin of each page. + /// + /// For just a page number, the `numbering` property, typically suffices. If + /// you want to create a custom footer, but still display the page number, + /// you can directly access the [page counter]($func/counter). + /// + /// ```example + /// #set par(justify: true) + /// #set page( + /// height: 100pt, + /// margin: 20pt, + /// footer: [ + /// #set align(right) + /// #set text(8pt) + /// #counter(page).display( + /// "1 of I", + /// both: true, + /// ) + /// ] + /// ) + /// + /// #lorem(48) + /// ``` + pub footer: Option<Content>, + + /// The amount the footer is lowered into the bottom margin. + #[resolve] + #[default(Ratio::new(0.3).into())] + pub footer_descent: Rel<Length>, + + /// Content in the page's background. + /// + /// This content will be placed behind the page's body. It can be + /// used to place a background image or a watermark. + /// + /// ```example + /// #set page(background: rotate(24deg, + /// text(18pt, fill: rgb("FFCBC4"))[ + /// *CONFIDENTIAL* + /// ] + /// )) + /// + /// = Typst's secret plans + /// In the year 2023, we plan to take + /// over the world (of typesetting). + /// ``` + pub background: Option<Content>, + + /// Content in the page's foreground. + /// + /// This content will overlay the page's body. + /// + /// ```example + /// #set page(foreground: text(24pt)[🥸]) + /// + /// Reviewer 2 has marked our paper + /// "Weak Reject" because they did + /// not understand our approach... + /// ``` + pub foreground: Option<Content>, + + /// The contents of the page(s). + /// + /// Multiple pages will be created if the content does not fit on a single + /// page. A new page with the page properties prior to the function invocation + /// will be created after the body has been typeset. + #[required] + pub body: Content, + + /// Whether the page should be aligned to an even or odd page. + /// Not part of the public API for now. + #[internal] + pub clear_to: Option<Parity>, +} + +impl PageElem { + /// A document can consist of multiple `PageElem`s, one per run of pages + /// with equal properties (not one per actual output page!). The `number` is + /// the physical page number of the first page of this run. It is mutated + /// while we post-process the pages in this function. This function returns + /// a fragment consisting of multiple frames, one per output page of this + /// page run. + #[tracing::instrument(skip_all)] + pub fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + mut number: NonZeroUsize, + ) -> SourceResult<Fragment> { + tracing::info!("Page layout"); + + // When one of the lengths is infinite the page fits its content along + // that axis. + let width = self.width(styles).unwrap_or(Abs::inf()); + let height = self.height(styles).unwrap_or(Abs::inf()); + let mut size = Size::new(width, height); + if self.flipped(styles) { + std::mem::swap(&mut size.x, &mut size.y); + } + + let mut min = width.min(height); + if !min.is_finite() { + min = Paper::A4.width(); + } + + // Determine the margins. + let default = Rel::<Length>::from(0.1190 * min); + let margin = self.margin(styles); + let two_sided = margin.two_sided.unwrap_or(false); + let margin = margin + .sides + .map(|side| side.and_then(Smart::as_custom).unwrap_or(default)) + .resolve(styles) + .relative_to(size); + + // Determine the binding. + let binding = + self.binding(styles) + .unwrap_or_else(|| match TextElem::dir_in(styles) { + Dir::LTR => Binding::Left, + _ => Binding::Right, + }); + + // Realize columns. + let mut child = self.body(); + let columns = self.columns(styles); + if columns.get() > 1 { + child = ColumnsElem::new(child).with_count(columns).pack(); + } + + let area = size - margin.sum_by_axis(); + let mut regions = Regions::repeat(area, area.map(Abs::is_finite)); + regions.root = true; + + // Layout the child. + let mut frames = child.layout(vt, styles, regions)?.into_frames(); + + // Align the child to the pagebreak's parity. + if self.clear_to(styles).is_some_and(|p| !p.matches(number.get())) { + let size = area.map(Abs::is_finite).select(area, Size::zero()); + frames.insert(0, Frame::new(size)); + } + + let fill = self.fill(styles); + let foreground = self.foreground(styles); + let background = self.background(styles); + let header = self.header(styles); + let header_ascent = self.header_ascent(styles); + let footer = self.footer(styles).or_else(|| { + self.numbering(styles).map(|numbering| { + let both = match &numbering { + Numbering::Pattern(pattern) => pattern.pieces() >= 2, + Numbering::Func(_) => true, + }; + Counter::new(CounterKey::Page) + .display(Some(numbering), both) + .aligned(self.number_align(styles)) + }) + }); + let footer_descent = self.footer_descent(styles); + + let numbering_meta = FrameItem::Meta( + Meta::PageNumbering(self.numbering(styles).into_value()), + Size::zero(), + ); + + // Post-process pages. + for frame in frames.iter_mut() { + tracing::info!("Layouting page #{number}"); + + // The padded width of the page's content without margins. + let pw = frame.width(); + + // If two sided, left becomes inside and right becomes outside. + // Thus, for left-bound pages, we want to swap on even pages and + // for right-bound pages, we want to swap on odd pages. + let mut margin = margin; + if two_sided && binding.swap(number) { + std::mem::swap(&mut margin.left, &mut margin.right); + } + + // Realize margins. + frame.set_size(frame.size() + margin.sum_by_axis()); + frame.translate(Point::new(margin.left, margin.top)); + frame.push(Point::zero(), numbering_meta.clone()); + + // The page size with margins. + let size = frame.size(); + + // Realize overlays. + for (name, marginal) in [ + ("header", &header), + ("footer", &footer), + ("background", &background), + ("foreground", &foreground), + ] { + tracing::info!("Layouting {name}"); + + let Some(content) = marginal else { continue }; + + let (pos, area, align); + if ptr::eq(marginal, &header) { + let ascent = header_ascent.relative_to(margin.top); + pos = Point::with_x(margin.left); + area = Size::new(pw, margin.top - ascent); + align = Align::Bottom.into(); + } else if ptr::eq(marginal, &footer) { + let descent = footer_descent.relative_to(margin.bottom); + pos = Point::new(margin.left, size.y - margin.bottom + descent); + area = Size::new(pw, margin.bottom - descent); + align = Align::Top.into(); + } else { + pos = Point::zero(); + area = size; + align = Align::CENTER_HORIZON.into(); + }; + + let pod = Regions::one(area, Axes::splat(true)); + let sub = content + .clone() + .styled(AlignElem::set_alignment(align)) + .layout(vt, styles, pod)? + .into_frame(); + + if ptr::eq(marginal, &header) || ptr::eq(marginal, &background) { + frame.prepend_frame(pos, sub); + } else { + frame.push_frame(pos, sub); + } + } + + if let Some(fill) = &fill { + frame.fill(fill.clone()); + } + + number = number.saturating_add(1); + } + + Ok(Fragment::frames(frames)) + } +} + +/// Specification of the page's margins. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Margin { + /// The margins for each side. + pub sides: Sides<Option<Smart<Rel<Length>>>>, + /// Whether to swap `left` and `right` to make them `inside` and `outside` + /// (when to swap depends on the binding). + pub two_sided: Option<bool>, +} + +impl Margin { + /// Create an instance with four equal components. + pub fn splat(value: Option<Smart<Rel<Length>>>) -> Self { + Self { sides: Sides::splat(value), two_sided: None } + } +} + +impl Fold for Margin { + type Output = Margin; + + fn fold(self, outer: Self::Output) -> Self::Output { + let sides = + self.sides + .zip(outer.sides) + .map(|(inner, outer)| match (inner, outer) { + (Some(value), Some(outer)) => Some(value.fold(outer)), + _ => inner.or(outer), + }); + let two_sided = self.two_sided.or(outer.two_sided); + Margin { sides, two_sided } + } +} + +cast! { + Margin, + self => { + let mut dict = Dict::new(); + let mut handle = |key: &str, component: Value| { + let value = component.into_value(); + if value != Value::None { + dict.insert(key.into(), value); + } + }; + + handle("top", self.sides.top.into_value()); + handle("bottom", self.sides.bottom.into_value()); + if self.two_sided.unwrap_or(false) { + handle("inside", self.sides.left.into_value()); + handle("outside", self.sides.right.into_value()); + } else { + handle("left", self.sides.left.into_value()); + handle("right", self.sides.right.into_value()); + } + + Value::Dict(dict) + }, + _: AutoValue => Self::splat(Some(Smart::Auto)), + v: Rel<Length> => Self::splat(Some(Smart::Custom(v))), + mut dict: Dict => { + let mut take = |key| dict.take(key).ok().map(Value::cast).transpose(); + + let rest = take("rest")?; + let x = take("x")?.or(rest); + let y = take("y")?.or(rest); + let top = take("top")?.or(y); + let bottom = take("bottom")?.or(y); + let outside = take("outside")?; + let inside = take("inside")?; + let left = take("left")?; + let right = take("right")?; + + let implicitly_two_sided = outside.is_some() || inside.is_some(); + let implicitly_not_two_sided = left.is_some() || right.is_some(); + if implicitly_two_sided && implicitly_not_two_sided { + bail!("`inside` and `outside` are mutually exclusive with `left` and `right`"); + } + + // - If 'implicitly_two_sided' is false here, then + // 'implicitly_not_two_sided' will be guaranteed to be true + // due to the previous two 'if' conditions. + // - If both are false, this means that this margin change does not + // affect lateral margins, and thus shouldn't make a difference on + // the 'two_sided' attribute of this margin. + let two_sided = (implicitly_two_sided || implicitly_not_two_sided) + .then_some(implicitly_two_sided); + + dict.finish(&[ + "left", "top", "right", "bottom", "outside", "inside", "x", "y", "rest", + ])?; + + Margin { + sides: Sides { + left: inside.or(left).or(x), + top, + right: outside.or(right).or(x), + bottom, + }, + two_sided, + } + } +} + +/// Specification of the page's binding. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Binding { + /// Bound on the left, as customary in LTR languages. + Left, + /// Bound on the right, as customary in RTL languages. + Right, +} + +impl Binding { + /// Whether to swap left and right margin for the page with this number. + fn swap(self, number: NonZeroUsize) -> bool { + match self { + // Left-bound must swap on even pages + // (because it is correct on the first page). + Self::Left => number.get() % 2 == 0, + // Right-bound must swap on odd pages + // (because it is wrong on the first page). + Self::Right => number.get() % 2 == 1, + } + } +} + +cast! { + Binding, + self => match self { + Self::Left => GenAlign::Specific(Align::Left).into_value(), + Self::Right => GenAlign::Specific(Align::Right).into_value(), + }, + v: GenAlign => match v { + GenAlign::Specific(Align::Left) => Self::Left, + GenAlign::Specific(Align::Right) => Self::Right, + _ => bail!("must be `left` or `right`"), + }, +} + +/// A header, footer, foreground or background definition. +#[derive(Debug, Clone, Hash)] +pub enum Marginal { + /// Bare content. + Content(Content), + /// A closure mapping from a page number to content. + Func(Func), +} + +impl Marginal { + /// Resolve the marginal based on the page number. + pub fn resolve(&self, vt: &mut Vt, page: usize) -> SourceResult<Content> { + Ok(match self { + Self::Content(content) => content.clone(), + Self::Func(func) => func.call_vt(vt, [page])?.display(), + }) + } +} + +cast! { + Marginal, + self => match self { + Self::Content(v) => v.into_value(), + Self::Func(v) => v.into_value(), + }, + v: Content => Self::Content(v), + v: Func => Self::Func(v), +} + +/// A manual page break. +/// +/// Must not be used inside any containers. +/// +/// ## Example { #example } +/// ```example +/// The next page contains +/// more details on compound theory. +/// #pagebreak() +/// +/// == Compound Theory +/// In 1984, the first ... +/// ``` +/// +/// Display: Page Break +/// Category: layout +#[element] +pub struct PagebreakElem { + /// If `{true}`, the page break is skipped if the current page is already + /// empty. + #[default(false)] + pub weak: bool, + + /// If given, ensures that the next page will be an even/odd page, with an + /// empty page in between if necessary. + /// + /// ```example + /// #set page(height: 30pt) + /// + /// First. + /// #pagebreak(to: "odd") + /// Third. + /// ``` + pub to: Option<Parity>, +} + +/// Whether something should be even or odd. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum Parity { + /// Next page will be an even page. + Even, + /// Next page will be an odd page. + Odd, +} + +impl Parity { + /// Whether the given number matches the parity. + fn matches(self, number: usize) -> bool { + match self { + Self::Even => number % 2 == 0, + Self::Odd => number % 2 == 1, + } + } +} + +/// Specification of a paper. +#[derive(Debug, Copy, Clone, Hash)] +pub struct Paper { + /// The name of the paper. + name: &'static str, + /// The width of the paper in millimeters. + width: Scalar, + /// The height of the paper in millimeters. + height: Scalar, +} + +impl Paper { + /// The width of the paper. + pub fn width(self) -> Abs { + Abs::mm(self.width.0) + } + + /// The height of the paper. + pub fn height(self) -> Abs { + Abs::mm(self.height.0) + } +} + +/// Defines paper constants and a paper parsing implementation. +macro_rules! papers { + ($(($var:ident: $width:expr, $height: expr, $name:literal))*) => { + /// Predefined papers. + /// + /// Each paper is parsable from its name in kebab-case. + impl Paper { + $(pub const $var: Self = Self { + name: $name, + width: Scalar($width), + height: Scalar($height), + };)* + } + + impl FromStr for Paper { + type Err = &'static str; + + fn from_str(name: &str) -> Result<Self, Self::Err> { + match name.to_lowercase().as_str() { + $($name => Ok(Self::$var),)* + _ => Err("unknown paper size"), + } + } + } + + cast! { + Paper, + self => self.name.into_value(), + $( + /// Produces a paper of the respective size. + $name => Self::$var, + )* + } + }; +} + +// All paper sizes in mm. +// +// Resources: +// - https://papersizes.io/ +// - https://en.wikipedia.org/wiki/Paper_size +// - https://www.theedkins.co.uk/jo/units/oldunits/print.htm +// - https://vintagepaper.co/blogs/news/traditional-paper-sizes +papers! { + // ---------------------------------------------------------------------- // + // ISO 216 A Series + (A0: 841.0, 1189.0, "a0") + (A1: 594.0, 841.0, "a1") + (A2: 420.0, 594.0, "a2") + (A3: 297.0, 420.0, "a3") + (A4: 210.0, 297.0, "a4") + (A5: 148.0, 210.0, "a5") + (A6: 105.0, 148.0, "a6") + (A7: 74.0, 105.0, "a7") + (A8: 52.0, 74.0, "a8") + (A9: 37.0, 52.0, "a9") + (A10: 26.0, 37.0, "a10") + (A11: 18.0, 26.0, "a11") + + // ISO 216 B Series + (ISO_B1: 707.0, 1000.0, "iso-b1") + (ISO_B2: 500.0, 707.0, "iso-b2") + (ISO_B3: 353.0, 500.0, "iso-b3") + (ISO_B4: 250.0, 353.0, "iso-b4") + (ISO_B5: 176.0, 250.0, "iso-b5") + (ISO_B6: 125.0, 176.0, "iso-b6") + (ISO_B7: 88.0, 125.0, "iso-b7") + (ISO_B8: 62.0, 88.0, "iso-b8") + + // ISO 216 C Series + (ISO_C3: 324.0, 458.0, "iso-c3") + (ISO_C4: 229.0, 324.0, "iso-c4") + (ISO_C5: 162.0, 229.0, "iso-c5") + (ISO_C6: 114.0, 162.0, "iso-c6") + (ISO_C7: 81.0, 114.0, "iso-c7") + (ISO_C8: 57.0, 81.0, "iso-c8") + + // DIN D Series (extension to ISO) + (DIN_D3: 272.0, 385.0, "din-d3") + (DIN_D4: 192.0, 272.0, "din-d4") + (DIN_D5: 136.0, 192.0, "din-d5") + (DIN_D6: 96.0, 136.0, "din-d6") + (DIN_D7: 68.0, 96.0, "din-d7") + (DIN_D8: 48.0, 68.0, "din-d8") + + // SIS (used in academia) + (SIS_G5: 169.0, 239.0, "sis-g5") + (SIS_E5: 115.0, 220.0, "sis-e5") + + // ANSI Extensions + (ANSI_A: 216.0, 279.0, "ansi-a") + (ANSI_B: 279.0, 432.0, "ansi-b") + (ANSI_C: 432.0, 559.0, "ansi-c") + (ANSI_D: 559.0, 864.0, "ansi-d") + (ANSI_E: 864.0, 1118.0, "ansi-e") + + // ANSI Architectural Paper + (ARCH_A: 229.0, 305.0, "arch-a") + (ARCH_B: 305.0, 457.0, "arch-b") + (ARCH_C: 457.0, 610.0, "arch-c") + (ARCH_D: 610.0, 914.0, "arch-d") + (ARCH_E1: 762.0, 1067.0, "arch-e1") + (ARCH_E: 914.0, 1219.0, "arch-e") + + // JIS B Series + (JIS_B0: 1030.0, 1456.0, "jis-b0") + (JIS_B1: 728.0, 1030.0, "jis-b1") + (JIS_B2: 515.0, 728.0, "jis-b2") + (JIS_B3: 364.0, 515.0, "jis-b3") + (JIS_B4: 257.0, 364.0, "jis-b4") + (JIS_B5: 182.0, 257.0, "jis-b5") + (JIS_B6: 128.0, 182.0, "jis-b6") + (JIS_B7: 91.0, 128.0, "jis-b7") + (JIS_B8: 64.0, 91.0, "jis-b8") + (JIS_B9: 45.0, 64.0, "jis-b9") + (JIS_B10: 32.0, 45.0, "jis-b10") + (JIS_B11: 22.0, 32.0, "jis-b11") + + // SAC D Series + (SAC_D0: 764.0, 1064.0, "sac-d0") + (SAC_D1: 532.0, 760.0, "sac-d1") + (SAC_D2: 380.0, 528.0, "sac-d2") + (SAC_D3: 264.0, 376.0, "sac-d3") + (SAC_D4: 188.0, 260.0, "sac-d4") + (SAC_D5: 130.0, 184.0, "sac-d5") + (SAC_D6: 92.0, 126.0, "sac-d6") + + // ISO 7810 ID + (ISO_ID_1: 85.6, 53.98, "iso-id-1") + (ISO_ID_2: 74.0, 105.0, "iso-id-2") + (ISO_ID_3: 88.0, 125.0, "iso-id-3") + + // ---------------------------------------------------------------------- // + // Asia + (ASIA_F4: 210.0, 330.0, "asia-f4") + + // Japan + (JP_SHIROKU_BAN_4: 264.0, 379.0, "jp-shiroku-ban-4") + (JP_SHIROKU_BAN_5: 189.0, 262.0, "jp-shiroku-ban-5") + (JP_SHIROKU_BAN_6: 127.0, 188.0, "jp-shiroku-ban-6") + (JP_KIKU_4: 227.0, 306.0, "jp-kiku-4") + (JP_KIKU_5: 151.0, 227.0, "jp-kiku-5") + (JP_BUSINESS_CARD: 91.0, 55.0, "jp-business-card") + + // China + (CN_BUSINESS_CARD: 90.0, 54.0, "cn-business-card") + + // Europe + (EU_BUSINESS_CARD: 85.0, 55.0, "eu-business-card") + + // French Traditional (AFNOR) + (FR_TELLIERE: 340.0, 440.0, "fr-tellière") + (FR_COURONNE_ECRITURE: 360.0, 460.0, "fr-couronne-écriture") + (FR_COURONNE_EDITION: 370.0, 470.0, "fr-couronne-édition") + (FR_RAISIN: 500.0, 650.0, "fr-raisin") + (FR_CARRE: 450.0, 560.0, "fr-carré") + (FR_JESUS: 560.0, 760.0, "fr-jésus") + + // United Kingdom Imperial + (UK_BRIEF: 406.4, 342.9, "uk-brief") + (UK_DRAFT: 254.0, 406.4, "uk-draft") + (UK_FOOLSCAP: 203.2, 330.2, "uk-foolscap") + (UK_QUARTO: 203.2, 254.0, "uk-quarto") + (UK_CROWN: 508.0, 381.0, "uk-crown") + (UK_BOOK_A: 111.0, 178.0, "uk-book-a") + (UK_BOOK_B: 129.0, 198.0, "uk-book-b") + + // Unites States + (US_LETTER: 215.9, 279.4, "us-letter") + (US_LEGAL: 215.9, 355.6, "us-legal") + (US_TABLOID: 279.4, 431.8, "us-tabloid") + (US_EXECUTIVE: 84.15, 266.7, "us-executive") + (US_FOOLSCAP_FOLIO: 215.9, 342.9, "us-foolscap-folio") + (US_STATEMENT: 139.7, 215.9, "us-statement") + (US_LEDGER: 431.8, 279.4, "us-ledger") + (US_OFICIO: 215.9, 340.36, "us-oficio") + (US_GOV_LETTER: 203.2, 266.7, "us-gov-letter") + (US_GOV_LEGAL: 215.9, 330.2, "us-gov-legal") + (US_BUSINESS_CARD: 88.9, 50.8, "us-business-card") + (US_DIGEST: 139.7, 215.9, "us-digest") + (US_TRADE: 152.4, 228.6, "us-trade") + + // ---------------------------------------------------------------------- // + // Other + (NEWSPAPER_COMPACT: 280.0, 430.0, "newspaper-compact") + (NEWSPAPER_BERLINER: 315.0, 470.0, "newspaper-berliner") + (NEWSPAPER_BROADSHEET: 381.0, 578.0, "newspaper-broadsheet") + (PRESENTATION_16_9: 297.0, 167.0625, "presentation-16-9") + (PRESENTATION_4_3: 280.0, 210.0, "presentation-4-3") +} diff --git a/crates/typst-library/src/layout/par.rs b/crates/typst-library/src/layout/par.rs new file mode 100644 index 00000000..6b914e80 --- /dev/null +++ b/crates/typst-library/src/layout/par.rs @@ -0,0 +1,1566 @@ +use icu_properties::{maps::CodePointMapData, LineBreak}; +use icu_provider::AsDeserializingBufferProvider; +use icu_provider_adapters::fork::ForkByKeyProvider; +use icu_provider_blob::BlobDataProvider; +use icu_segmenter::{LineBreakIteratorUtf8, LineSegmenter}; +use once_cell::sync::Lazy; +use typst::eval::Tracer; +use typst::model::DelayedErrors; +use unicode_bidi::{BidiInfo, Level as BidiLevel}; +use unicode_script::{Script, UnicodeScript}; + +use super::{BoxElem, HElem, Sizing, Spacing}; +use crate::layout::AlignElem; +use crate::math::EquationElem; +use crate::prelude::*; +use crate::text::{ + is_gb_style, shape, LinebreakElem, Quoter, Quotes, ShapedText, SmartQuoteElem, + SpaceElem, TextElem, +}; + +/// Arranges text, spacing and inline-level elements into a paragraph. +/// +/// Although this function is primarily used in set rules to affect paragraph +/// properties, it can also be used to explicitly render its argument onto a +/// paragraph of its own. +/// +/// ## Example { #example } +/// ```example +/// #show par: set block(spacing: 0.65em) +/// #set par( +/// first-line-indent: 1em, +/// justify: true, +/// ) +/// +/// We proceed by contradiction. +/// Suppose that there exists a set +/// of positive integers $a$, $b$, and +/// $c$ that satisfies the equation +/// $a^n + b^n = c^n$ for some +/// integer value of $n > 2$. +/// +/// Without loss of generality, +/// let $a$ be the smallest of the +/// three integers. Then, we ... +/// ``` +/// +/// Display: Paragraph +/// Category: layout +#[element(Construct)] +pub struct ParElem { + /// The spacing between lines. + #[resolve] + #[default(Em::new(0.65).into())] + pub leading: Length, + + /// Whether to justify text in its line. + /// + /// Hyphenation will be enabled for justified paragraphs if the [text + /// property hyphenate]($func/text.hyphenate) is set to `{auto}` and the + /// current language is known. + /// + /// Note that the current [alignment]($func/align) still has an effect on + /// the placement of the last line except if it ends with a [justified line + /// break]($func/linebreak.justify). + #[default(false)] + pub justify: bool, + + /// How to determine line breaks. + /// + /// When this property is set to `{auto}`, its default value, optimized line + /// breaks will be used for justified paragraphs. Enabling optimized line + /// breaks for ragged paragraphs may also be worthwhile to improve the + /// appearance of the text. + /// + /// ```example + /// #set page(width: 190pt) + /// #set par(linebreaks: "simple") + /// Some texts are frustratingly + /// challenging to break in a + /// visually pleasing way. This + /// very aesthetic example is one + /// of them. + /// + /// #set par(linebreaks: "optimized") + /// Some texts are frustratingly + /// challenging to break in a + /// visually pleasing way. This + /// very aesthetic example is one + /// of them. + /// ``` + #[default] + pub linebreaks: Smart<Linebreaks>, + + /// The indent the first line of a paragraph should have. + /// + /// Only the first line of a consecutive paragraph will be indented (not + /// the first one in a block or on the page). + /// + /// By typographic convention, paragraph breaks are indicated either by some + /// space between paragraphs or by indented first lines. Consider reducing + /// the [paragraph spacing]($func/block.spacing) to the [`leading`] when + /// using this property (e.g. using + /// `[#show par: set block(spacing: 0.65em)]`). + pub first_line_indent: Length, + + /// The indent all but the first line of a paragraph should have. + #[resolve] + pub hanging_indent: Length, + + /// The contents of the paragraph. + #[external] + #[required] + pub body: Content, + + /// The paragraph's children. + #[internal] + #[variadic] + pub children: Vec<Content>, +} + +impl Construct for ParElem { + fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> { + // The paragraph constructor is special: It doesn't create a paragraph + // element. Instead, it just ensures that the passed content lives in a + // separate paragraph and styles it. + let styles = Self::set(args)?; + let body = args.expect::<Content>("body")?; + Ok(Content::sequence([ + ParbreakElem::new().pack(), + body.styled_with_map(styles), + ParbreakElem::new().pack(), + ])) + } +} + +impl ParElem { + /// Layout the paragraph into a collection of lines. + #[tracing::instrument(name = "ParElement::layout", skip_all)] + pub fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + consecutive: bool, + region: Size, + expand: bool, + ) -> SourceResult<Fragment> { + #[comemo::memoize] + #[allow(clippy::too_many_arguments)] + fn cached( + par: &ParElem, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + locator: Tracked<Locator>, + delayed: TrackedMut<DelayedErrors>, + tracer: TrackedMut<Tracer>, + styles: StyleChain, + consecutive: bool, + region: Size, + expand: bool, + ) -> SourceResult<Fragment> { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let children = par.children(); + + // Collect all text into one string for BiDi analysis. + let (text, segments, spans) = collect(&children, &styles, consecutive)?; + + // Perform BiDi analysis and then prepare paragraph layout by building a + // representation on which we can do line breaking without layouting + // each and every line from scratch. + let p = prepare(&mut vt, &children, &text, segments, spans, styles, region)?; + + // Break the paragraph into lines. + let lines = linebreak(&vt, &p, region.x - p.hang); + + // Stack the lines into one frame per region. + finalize(&mut vt, &p, &lines, region, expand) + } + + let fragment = cached( + self, + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + styles, + consecutive, + region, + expand, + )?; + + vt.locator.visit_frames(&fragment); + Ok(fragment) + } +} + +/// How to determine line breaks in a paragraph. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum Linebreaks { + /// Determine the line breaks in a simple first-fit style. + Simple, + /// Optimize the line breaks for the whole paragraph. + /// + /// Typst will try to produce more evenly filled lines of text by + /// considering the whole paragraph when calculating line breaks. + Optimized, +} + +/// A paragraph break. +/// +/// This starts a new paragraph. Especially useful when used within code like +/// [for loops]($scripting/#loops). Multiple consecutive +/// paragraph breaks collapse into a single one. +/// +/// ## Example { #example } +/// ```example +/// #for i in range(3) { +/// [Blind text #i: ] +/// lorem(5) +/// parbreak() +/// } +/// ``` +/// +/// ## Syntax { #syntax } +/// Instead of calling this function, you can insert a blank line into your +/// markup to create a paragraph break. +/// +/// Display: Paragraph Break +/// Category: layout +#[element(Unlabellable)] +pub struct ParbreakElem {} + +impl Unlabellable for ParbreakElem {} + +/// Range of a substring of text. +type Range = std::ops::Range<usize>; + +// The characters by which spacing, inline content and pins are replaced in the +// paragraph's full text. +const SPACING_REPLACE: char = ' '; // Space +const OBJ_REPLACE: char = '\u{FFFC}'; // Object Replacement Character + +/// A paragraph representation in which children are already layouted and text +/// is already preshaped. +/// +/// In many cases, we can directly reuse these results when constructing a line. +/// Only when a line break falls onto a text index that is not safe-to-break per +/// rustybuzz, we have to reshape that portion. +struct Preparation<'a> { + /// Bidirectional text embedding levels for the paragraph. + bidi: BidiInfo<'a>, + /// Text runs, spacing and layouted elements. + items: Vec<Item<'a>>, + /// The span mapper. + spans: SpanMapper, + /// The styles shared by all children. + styles: StyleChain<'a>, + /// Whether to hyphenate if it's the same for all children. + hyphenate: Option<bool>, + /// The text language if it's the same for all children. + lang: Option<Lang>, + /// The paragraph's resolved alignment. + align: Align, + /// Whether to justify the paragraph. + justify: bool, + /// The paragraph's hanging indent. + hang: Abs, +} + +impl<'a> Preparation<'a> { + /// Find the item that contains the given `text_offset`. + fn find(&self, text_offset: usize) -> Option<&Item<'a>> { + let mut cursor = 0; + for item in &self.items { + let end = cursor + item.len(); + if (cursor..end).contains(&text_offset) { + return Some(item); + } + cursor = end; + } + None + } + + /// Return the items that intersect the given `text_range`. + /// + /// Returns the expanded range around the items and the items. + fn slice(&self, text_range: Range) -> (Range, &[Item<'a>]) { + let mut cursor = 0; + let mut start = 0; + let mut end = 0; + let mut expanded = text_range.clone(); + + for (i, item) in self.items.iter().enumerate() { + if cursor <= text_range.start { + start = i; + expanded.start = cursor; + } + + let len = item.len(); + if cursor < text_range.end || cursor + len <= text_range.end { + end = i + 1; + expanded.end = cursor + len; + } else { + break; + } + + cursor += len; + } + + (expanded, &self.items[start..end]) + } +} + +/// A segment of one or multiple collapsed children. +#[derive(Debug, Copy, 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). + Text(usize), + /// Horizontal spacing between other segments. + Spacing(Spacing), + /// A mathematical equation. + Equation(&'a EquationElem), + /// A box with arbitrary content. + Box(&'a BoxElem, bool), + /// Metadata. + Meta, +} + +impl Segment<'_> { + /// The text length of the item. + fn len(&self) -> usize { + 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::Meta => 0, + } + } +} + +/// A prepared item in a paragraph layout. +#[derive(Debug)] +enum Item<'a> { + /// A shaped text run with consistent style and direction. + Text(ShapedText<'a>), + /// Absolute spacing between other items. + Absolute(Abs), + /// Fractional spacing between other items. + Fractional(Fr, Option<(&'a BoxElem, StyleChain<'a>)>), + /// Layouted inline-level content. + Frame(Frame), + /// Metadata. + Meta(Frame), +} + +impl<'a> Item<'a> { + /// If this a text item, return it. + fn text(&self) -> Option<&ShapedText<'a>> { + match self { + Self::Text(shaped) => Some(shaped), + _ => None, + } + } + + fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> { + match self { + Self::Text(shaped) => Some(shaped), + _ => None, + } + } + + /// The text length of the item. + fn len(&self) -> usize { + match self { + Self::Text(shaped) => shaped.text.len(), + Self::Absolute(_) | Self::Fractional(_, _) => SPACING_REPLACE.len_utf8(), + Self::Frame(_) => OBJ_REPLACE.len_utf8(), + Self::Meta(_) => 0, + } + } + + /// The natural layouted width of the item. + fn width(&self) -> Abs { + match self { + Self::Text(shaped) => shaped.width, + Self::Absolute(v) => *v, + Self::Frame(frame) => frame.width(), + Self::Fractional(_, _) | Self::Meta(_) => Abs::zero(), + } + } +} + +/// Maps byte offsets back to spans. +#[derive(Default)] +pub struct SpanMapper(Vec<(usize, Span)>); + +impl SpanMapper { + /// Create a new span mapper. + pub fn new() -> Self { + Self::default() + } + + /// Push a span for a segment with the given length. + pub fn push(&mut self, len: usize, span: Span) { + self.0.push((len, span)); + } + + /// Determine the span at the given byte offset. + /// + /// May return a detached span. + pub fn span_at(&self, offset: usize) -> (Span, u16) { + let mut cursor = 0; + for &(len, span) in &self.0 { + if (cursor..=cursor + len).contains(&offset) { + return (span, u16::try_from(offset - cursor).unwrap_or(0)); + } + cursor += len; + } + (Span::detached(), 0) + } +} + +/// A layouted line, consisting of a sequence of layouted paragraph items that +/// are mostly borrowed from the preparation phase. This type enables you to +/// measure the size of a line in a range before committing to building the +/// line's frame. +/// +/// At most two paragraph items must be created individually for this line: The +/// first and last one since they may be broken apart by the start or end of the +/// line, respectively. But even those can partially reuse previous results when +/// the break index is safe-to-break per rustybuzz. +struct Line<'a> { + /// Bidi information about the paragraph. + bidi: &'a BidiInfo<'a>, + /// The trimmed range the line spans in the paragraph. + trimmed: Range, + /// The untrimmed end where the line ends. + end: usize, + /// A reshaped text item if the line sliced up a text item at the start. + first: Option<Item<'a>>, + /// Inner items which don't need to be reprocessed. + inner: &'a [Item<'a>], + /// A reshaped text item if the line sliced up a text item at the end. If + /// there is only one text item, this takes precedence over `first`. + last: Option<Item<'a>>, + /// The width of the line. + width: Abs, + /// Whether the line should be justified. + justify: bool, + /// Whether the line ends with a hyphen or dash, either naturally or through + /// hyphenation. + dash: bool, +} + +impl<'a> Line<'a> { + /// Iterate over the line's items. + fn items(&self) -> impl Iterator<Item = &Item<'a>> { + self.first.iter().chain(self.inner).chain(&self.last) + } + + /// Return items that intersect the given `text_range`. + fn slice(&self, text_range: Range) -> impl Iterator<Item = &Item<'a>> { + let mut cursor = self.trimmed.start; + let mut start = 0; + let mut end = 0; + + for (i, item) in self.items().enumerate() { + if cursor <= text_range.start { + start = i; + } + + let len = item.len(); + if cursor < text_range.end || cursor + len <= text_range.end { + end = i + 1; + } else { + break; + } + + cursor += len; + } + + self.items().skip(start).take(end - start) + } + + /// How many glyphs are in the text where we can insert additional + /// space when encountering underfull lines. + fn justifiables(&self) -> usize { + let mut count = 0; + for shaped in self.items().filter_map(Item::text) { + count += shaped.justifiables(); + } + // CJK character at line end should not be adjusted. + if self + .items() + .last() + .and_then(Item::text) + .map(|s| s.cjk_justifiable_at_last()) + .unwrap_or(false) + { + count -= 1; + } + + count + } + + /// How much can the line stretch + fn stretchability(&self) -> Abs { + self.items().filter_map(Item::text).map(|s| s.stretchability()).sum() + } + + /// How much can the line shrink + fn shrinkability(&self) -> Abs { + self.items().filter_map(Item::text).map(|s| s.shrinkability()).sum() + } + + /// The sum of fractions in the line. + fn fr(&self) -> Fr { + self.items() + .filter_map(|item| match item { + Item::Fractional(fr, _) => Some(*fr), + _ => None, + }) + .sum() + } +} + +/// Collect all text of the paragraph into one string. This also performs +/// string-level preprocessing like case transformations. +#[allow(clippy::type_complexity)] +fn collect<'a>( + children: &'a [Content], + styles: &'a StyleChain<'a>, + consecutive: bool, +) -> SourceResult<(String, Vec<(Segment<'a>, StyleChain<'a>)>, SpanMapper)> { + let mut full = String::new(); + let mut quoter = Quoter::new(); + let mut segments = vec![]; + let mut spans = SpanMapper::new(); + let mut iter = children.iter().peekable(); + + let first_line_indent = ParElem::first_line_indent_in(*styles); + if !first_line_indent.is_zero() + && consecutive + && AlignElem::alignment_in(*styles).x.resolve(*styles) + == TextElem::dir_in(*styles).start().into() + { + full.push(SPACING_REPLACE); + segments.push((Segment::Spacing(first_line_indent.into()), *styles)); + } + + let hang = ParElem::hanging_indent_in(*styles); + if !hang.is_zero() { + full.push(SPACING_REPLACE); + segments.push((Segment::Spacing((-hang).into()), *styles)); + } + + while let Some(mut child) = iter.next() { + let outer = styles; + let mut styles = *styles; + if let Some((elem, local)) = child.to_styled() { + child = elem; + styles = outer.chain(local); + } + + let segment = if child.is::<SpaceElem>() { + full.push(' '); + Segment::Text(1) + } else if let Some(elem) = child.to::<TextElem>() { + let prev = full.len(); + if let Some(case) = TextElem::case_in(styles) { + full.push_str(&case.apply(&elem.text())); + } else { + full.push_str(&elem.text()); + } + Segment::Text(full.len() - prev) + } else if let Some(elem) = child.to::<HElem>() { + if elem.amount().is_zero() { + continue; + } + + full.push(SPACING_REPLACE); + Segment::Spacing(elem.amount()) + } else if let Some(elem) = child.to::<LinebreakElem>() { + let c = if elem.justify(styles) { '\u{2028}' } else { '\n' }; + full.push(c); + Segment::Text(c.len_utf8()) + } else if let Some(elem) = child.to::<SmartQuoteElem>() { + let prev = full.len(); + if SmartQuoteElem::enabled_in(styles) { + let lang = TextElem::lang_in(styles); + let region = TextElem::region_in(styles); + let quotes = Quotes::from_lang(lang, region); + let peeked = iter.peek().and_then(|child| { + let child = if let Some((child, _)) = child.to_styled() { + child + } else { + child + }; + if let Some(elem) = child.to::<TextElem>() { + elem.text().chars().next() + } else if child.is::<SmartQuoteElem>() { + Some('"') + } else if child.is::<SpaceElem>() + || child.is::<HElem>() + || child.is::<LinebreakElem>() + { + Some(SPACING_REPLACE) + } else { + Some(OBJ_REPLACE) + } + }); + + full.push_str(quoter.quote("es, elem.double(styles), peeked)); + } else { + full.push(if elem.double(styles) { '"' } else { '\'' }); + } + Segment::Text(full.len() - prev) + } else if let Some(elem) = child.to::<EquationElem>() { + full.push(OBJ_REPLACE); + Segment::Equation(elem) + } else if let Some(elem) = child.to::<BoxElem>() { + let frac = elem.width(styles).is_fractional(); + full.push(if frac { SPACING_REPLACE } else { OBJ_REPLACE }); + Segment::Box(elem, frac) + } else if child.is::<MetaElem>() { + Segment::Meta + } else { + bail!(child.span(), "unexpected paragraph child"); + }; + + if let Some(last) = full.chars().last() { + quoter.last(last); + } + + spans.push(segment.len(), child.span()); + + if let (Some((Segment::Text(last_len), last_styles)), Segment::Text(len)) = + (segments.last_mut(), segment) + { + if *last_styles == styles { + *last_len += len; + continue; + } + } + + segments.push((segment, styles)); + } + + Ok((full, segments, spans)) +} + +/// Prepare paragraph layout by shaping the whole paragraph and layouting all +/// contained inline-level content. +fn prepare<'a>( + vt: &mut Vt, + children: &'a [Content], + text: &'a str, + segments: Vec<(Segment<'a>, StyleChain<'a>)>, + spans: SpanMapper, + styles: StyleChain<'a>, + region: Size, +) -> SourceResult<Preparation<'a>> { + let bidi = BidiInfo::new( + text, + match TextElem::dir_in(styles) { + Dir::LTR => Some(BidiLevel::ltr()), + Dir::RTL => Some(BidiLevel::rtl()), + _ => None, + }, + ); + + let mut cursor = 0; + let mut items = vec![]; + + // Shape / layout the children and collect them into items. + for (segment, styles) in segments { + let end = cursor + segment.len(); + match segment { + Segment::Text(_) => { + shape_range(&mut items, vt, &bidi, cursor..end, &spans, styles); + } + Segment::Spacing(spacing) => match spacing { + Spacing::Rel(v) => { + let resolved = v.resolve(styles).relative_to(region.x); + items.push(Item::Absolute(resolved)); + } + Spacing::Fr(v) => { + items.push(Item::Fractional(v, None)); + } + }, + Segment::Equation(equation) => { + let pod = Regions::one(region, Axes::splat(false)); + let mut frame = equation.layout(vt, styles, pod)?.into_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) { + items.push(Item::Fractional(v, Some((elem, styles)))); + } else { + let pod = Regions::one(region, Axes::splat(false)); + let mut frame = elem.layout(vt, styles, pod)?.into_frame(); + frame.translate(Point::with_y(TextElem::baseline_in(styles))); + items.push(Item::Frame(frame)); + } + } + Segment::Meta => { + let mut frame = Frame::new(Size::zero()); + frame.meta(styles, true); + items.push(Item::Meta(frame)); + } + } + + cursor = end; + } + + Ok(Preparation { + bidi, + items, + spans, + styles, + hyphenate: shared_get(styles, children, TextElem::hyphenate_in), + lang: shared_get(styles, children, TextElem::lang_in), + align: AlignElem::alignment_in(styles).x.resolve(styles), + justify: ParElem::justify_in(styles), + hang: ParElem::hanging_indent_in(styles), + }) +} + +/// Group a range of text by BiDi level and script, shape the runs and generate +/// items for them. +fn shape_range<'a>( + items: &mut Vec<Item<'a>>, + vt: &Vt, + bidi: &BidiInfo<'a>, + range: Range, + spans: &SpanMapper, + styles: StyleChain<'a>, +) { + let lang = TextElem::lang_in(styles); + let region = TextElem::region_in(styles); + let mut process = |range: Range, level: BidiLevel| { + let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL }; + let shaped = + shape(vt, range.start, &bidi.text[range], spans, styles, dir, lang, region); + items.push(Item::Text(shaped)); + }; + + let mut prev_level = BidiLevel::ltr(); + let mut prev_script = Script::Unknown; + let mut cursor = range.start; + + // Group by embedding level and script. + for i in range.clone() { + if !bidi.text.is_char_boundary(i) { + continue; + } + + let level = bidi.levels[i]; + let script = + bidi.text[i..].chars().next().map_or(Script::Unknown, |c| c.script()); + + if level != prev_level || !is_compatible(script, prev_script) { + if cursor < i { + process(cursor..i, prev_level); + } + cursor = i; + prev_level = level; + prev_script = script; + } else if is_generic_script(prev_script) { + prev_script = script; + } + } + + process(cursor..range.end, prev_level); +} + +/// Whether this is not a specific script. +fn is_generic_script(script: Script) -> bool { + matches!(script, Script::Unknown | Script::Common | Script::Inherited) +} + +/// Whether these script can be part of the same shape run. +fn is_compatible(a: Script, b: Script) -> bool { + is_generic_script(a) || is_generic_script(b) || a == b +} + +/// Get a style property, but only if it is the same for all children of the +/// paragraph. +fn shared_get<T: PartialEq>( + styles: StyleChain<'_>, + children: &[Content], + getter: fn(StyleChain) -> T, +) -> Option<T> { + let value = getter(styles); + children + .iter() + .filter_map(|child| child.to_styled()) + .all(|(_, local)| getter(styles.chain(local)) == value) + .then_some(value) +} + +/// Find suitable linebreaks. +fn linebreak<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> { + let linebreaks = ParElem::linebreaks_in(p.styles).unwrap_or_else(|| { + if ParElem::justify_in(p.styles) { + Linebreaks::Optimized + } else { + Linebreaks::Simple + } + }); + + match linebreaks { + Linebreaks::Simple => linebreak_simple(vt, p, width), + Linebreaks::Optimized => linebreak_optimized(vt, p, width), + } +} + +/// Perform line breaking in simple first-fit style. This means that we build +/// lines greedily, always taking the longest possible line. This may lead to +/// very unbalanced line, but is fast and simple. +fn linebreak_simple<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> { + let mut lines = vec![]; + let mut start = 0; + let mut last = None; + + for (end, mandatory, hyphen) in breakpoints(p) { + // Compute the line and its size. + let mut attempt = line(vt, p, start..end, mandatory, hyphen); + + // If the line doesn't fit anymore, we push the last fitting attempt + // into the stack and rebuild the line from the attempt's end. The + // resulting line cannot be broken up further. + if !width.fits(attempt.width) { + if let Some((last_attempt, last_end)) = last.take() { + lines.push(last_attempt); + start = last_end; + attempt = line(vt, p, start..end, mandatory, hyphen); + } + } + + // Finish the current line if there is a mandatory line break (i.e. + // due to "\n") or if the line doesn't fit horizontally already + // since then no shorter line will be possible. + if mandatory || !width.fits(attempt.width) { + lines.push(attempt); + start = end; + last = None; + } else { + last = Some((attempt, end)); + } + } + + if let Some((line, _)) = last { + lines.push(line); + } + + lines +} + +/// Perform line breaking in optimized Knuth-Plass style. Here, we use more +/// context to determine the line breaks than in the simple first-fit style. For +/// example, we might choose to cut a line short even though there is still a +/// bit of space to improve the fit of one of the following lines. The +/// Knuth-Plass algorithm is based on the idea of "cost". A line which has a +/// very tight or very loose fit has a higher cost than one that is just right. +/// Ending a line with a hyphen incurs extra cost and endings two successive +/// lines with hyphens even more. +/// +/// To find the layout with the minimal total cost the algorithm uses dynamic +/// programming: For each possible breakpoint it determines the optimal +/// paragraph layout _up to that point_. It walks over all possible start points +/// for a line ending at that point and finds the one for which the cost of the +/// line plus the cost of the optimal paragraph up to the start point (already +/// computed and stored in dynamic programming table) is minimal. The final +/// result is simply the layout determined for the last breakpoint at the end of +/// text. +fn linebreak_optimized<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> { + /// The cost of a line or paragraph layout. + type Cost = f64; + + /// An entry in the dynamic programming table. + struct Entry<'a> { + pred: usize, + total: Cost, + line: Line<'a>, + } + + // Cost parameters. + const HYPH_COST: Cost = 0.5; + const CONSECUTIVE_DASH_COST: Cost = 300.0; + const MAX_COST: Cost = 1_000_000.0; + const MIN_RATIO: f64 = -1.0; + + // Dynamic programming table. + let mut active = 0; + let mut table = vec![Entry { + pred: 0, + total: 0.0, + line: line(vt, p, 0..0, false, false), + }]; + + let em = TextElem::size_in(p.styles); + + for (end, mandatory, hyphen) in breakpoints(p) { + let k = table.len(); + let eof = end == p.bidi.text.len(); + let mut best: Option<Entry> = None; + + // Find the optimal predecessor. + for (i, pred) in table.iter_mut().enumerate().skip(active) { + // Layout the line. + let start = pred.line.end; + let attempt = line(vt, p, start..end, mandatory, hyphen); + + // Determine how much the line's spaces would need to be stretched + // to make it the desired width. + let delta = width - attempt.width; + // Determine how much stretch are permitted. + let adjust = if delta >= Abs::zero() { + attempt.stretchability() + } else { + attempt.shrinkability() + }; + // Ideally, the ratio should between -1.0 and 1.0, but sometimes a value above 1.0 + // is possible, in which case the line is underfull. + let mut ratio = delta / adjust; + if ratio.is_nan() { + // The line is not stretchable, but it just fits. + // This often happens with monospace fonts and CJK texts. + ratio = 0.0; + } + if ratio > 1.0 { + // We should stretch the line above its stretchability. Now calculate the extra amount. + let extra_stretch = (delta - adjust) / attempt.justifiables() as f64; + // Normalize the amount by half Em size. + ratio = 1.0 + extra_stretch / (em / 2.0); + } + + // Determine the cost of the line. + let min_ratio = if p.justify { MIN_RATIO } else { 0.0 }; + let mut cost = if ratio < min_ratio { + // The line is overfull. This is the case if + // - justification is on, but we'd need to shrink too much + // - justification is off and the line just doesn't fit + // Since any longer line will also be overfull, we can deactivate + // this breakpoint. + active = i + 1; + MAX_COST + } else if mandatory || eof { + // This is a mandatory break and the line is not overfull, so + // all breakpoints before this one become inactive since no line + // can span above the mandatory break. + active = k; + // If ratio > 0, we need to stretch the line only when justify is needed. + // If ratio < 0, we always need to shrink the line. + if (ratio > 0.0 && attempt.justify) || ratio < 0.0 { + ratio.powi(3).abs() + } else { + 0.0 + } + } else { + // Normal line with cost of |ratio^3|. + ratio.powi(3).abs() + }; + + // Penalize hyphens. + if hyphen { + cost += HYPH_COST; + } + + // In Knuth paper, cost = (1 + 100|r|^3 + p)^2 + a, + // where r is the ratio, p=50 is penaty, and a=3000 is consecutive penaty. + // We divide the whole formula by 10, resulting (0.01 + |r|^3 + p)^2 + a, + // where p=0.5 and a=300 + cost = (0.01 + cost).powi(2); + + // Penalize two consecutive dashes (not necessarily hyphens) extra. + if attempt.dash && pred.line.dash { + cost += CONSECUTIVE_DASH_COST; + } + + // The total cost of this line and its chain of predecessors. + let total = pred.total + cost; + + // If this attempt is better than what we had before, take it! + if best.as_ref().map_or(true, |best| best.total >= total) { + best = Some(Entry { pred: i, total, line: attempt }); + } + } + + table.push(best.unwrap()); + } + + // Retrace the best path. + let mut lines = vec![]; + let mut idx = table.len() - 1; + while idx != 0 { + table.truncate(idx + 1); + let entry = table.pop().unwrap(); + lines.push(entry.line); + idx = entry.pred; + } + + lines.reverse(); + lines +} + +/// Generated by the following command: +/// +/// ```sh +/// icu4x-datagen --locales full --keys-for-bin target/debug/typst \ +/// --format blob --out library/assets/icudata.postcard --overwrite +/// ``` +/// +/// Install icu4x-datagen with `cargo install icu4x-datagen`. +static ICU_DATA: &[u8] = include_bytes!("../../assets/icudata.postcard"); + +/// Generated by the following command: +/// +/// ```sh +/// icu4x-datagen --locales zh ja --keys segmenter/line@1 --format blob \ +/// --out library/assets/cj_linebreak_data.postcard --overwrite +/// ``` +/// +/// The used icu4x-datagen should be patched by +/// https://github.com/peng1999/icu4x/commit/b9beb6cbf633d61fc3d7983e5baf7f4449fbfae5 +static CJ_LINEBREAK_DATA: &[u8] = + include_bytes!("../../assets/cj_linebreak_data.postcard"); + +/// The general line break segmenter. +static SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| { + let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap(); + LineSegmenter::try_new_lstm_with_buffer_provider(&provider).unwrap() +}); + +/// The Unicode line break properties for each code point. +static CJ_SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| { + let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap(); + let cj_blob = BlobDataProvider::try_new_from_static_blob(CJ_LINEBREAK_DATA).unwrap(); + let cj_provider = ForkByKeyProvider::new(cj_blob, provider); + LineSegmenter::try_new_lstm_with_buffer_provider(&cj_provider).unwrap() +}); + +/// The line break segmenter for Chinese/Jpanese text. +static LINEBREAK_DATA: Lazy<CodePointMapData<LineBreak>> = Lazy::new(|| { + let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap(); + let deser_provider = provider.as_deserializing(); + icu_properties::maps::load_line_break(&deser_provider).unwrap() +}); + +/// Determine all possible points in the text where lines can broken. +/// +/// Returns for each breakpoint the text index, whether the break is mandatory +/// (after `\n`) and whether a hyphen is required (when breaking inside of a +/// word). +fn breakpoints<'a>(p: &'a Preparation<'a>) -> Breakpoints<'a> { + let mut linebreaks = if matches!(p.lang, Some(Lang::CHINESE | Lang::JAPANESE)) { + CJ_SEGMENTER.segment_str(p.bidi.text) + } else { + SEGMENTER.segment_str(p.bidi.text) + }; + // The iterator always yields a breakpoint at index 0, we want to ignore it + linebreaks.next(); + Breakpoints { + p, + linebreaks, + syllables: None, + offset: 0, + suffix: 0, + end: 0, + mandatory: false, + } +} + +/// An iterator over the line break opportunities in a text. +struct Breakpoints<'a> { + /// The paragraph's items. + p: &'a Preparation<'a>, + /// The inner iterator over the unicode line break opportunities. + linebreaks: LineBreakIteratorUtf8<'a, 'a>, + /// Iterator over syllables of the current word. + syllables: Option<hypher::Syllables<'a>>, + /// The current text offset. + offset: usize, + /// The trimmed end of the current word. + suffix: usize, + /// The untrimmed end of the current word. + end: usize, + /// Whether the break after the current word is mandatory. + mandatory: bool, +} + +impl Iterator for Breakpoints<'_> { + type Item = (usize, bool, bool); + + fn next(&mut self) -> Option<Self::Item> { + // If we're currently in a hyphenated "word", process the next syllable. + if let Some(syllable) = self.syllables.as_mut().and_then(Iterator::next) { + self.offset += syllable.len(); + if self.offset == self.suffix { + self.offset = self.end; + } + + // Filter out hyphenation opportunities where hyphenation was + // actually disabled. + let hyphen = self.offset < self.end; + if hyphen && !self.hyphenate(self.offset) { + return self.next(); + } + + return Some((self.offset, self.mandatory && !hyphen, hyphen)); + } + + let lb = LINEBREAK_DATA.as_borrowed(); + + // Get the next "word". + self.end = self.linebreaks.next()?; + self.mandatory = + self.p.bidi.text[..self.end].chars().next_back().map_or(false, |c| { + matches!( + lb.get(c), + LineBreak::MandatoryBreak + | LineBreak::CarriageReturn + | LineBreak::LineFeed + | LineBreak::NextLine + ) || self.end == self.p.bidi.text.len() + }); + + // Hyphenate the next word. + if self.p.hyphenate != Some(false) { + if let Some(lang) = self.lang(self.offset) { + let word = &self.p.bidi.text[self.offset..self.end]; + let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic()); + if !trimmed.is_empty() { + self.suffix = self.offset + trimmed.len(); + self.syllables = Some(hypher::hyphenate(trimmed, lang)); + return self.next(); + } + } + } + + self.offset = self.end; + Some((self.end, self.mandatory, false)) + } +} + +impl Breakpoints<'_> { + /// Whether hyphenation is enabled at the given offset. + fn hyphenate(&self, offset: usize) -> bool { + self.p + .hyphenate + .or_else(|| { + let shaped = self.p.find(offset)?.text()?; + Some(TextElem::hyphenate_in(shaped.styles)) + }) + .unwrap_or(false) + } + + /// The text language at the given offset. + fn lang(&self, offset: usize) -> Option<hypher::Lang> { + let lang = self.p.lang.or_else(|| { + let shaped = self.p.find(offset)?.text()?; + Some(TextElem::lang_in(shaped.styles)) + })?; + + let bytes = lang.as_str().as_bytes().try_into().ok()?; + hypher::Lang::from_iso(bytes) + } +} + +/// Create a line which spans the given range. +fn line<'a>( + vt: &Vt, + p: &'a Preparation, + mut range: Range, + mandatory: bool, + hyphen: bool, +) -> Line<'a> { + let end = range.end; + let mut justify = p.justify && end < p.bidi.text.len() && !mandatory; + + if range.is_empty() { + return Line { + bidi: &p.bidi, + end, + trimmed: range, + first: None, + inner: &[], + last: None, + width: Abs::zero(), + justify, + dash: false, + }; + } + + // Slice out the relevant items. + let (expanded, mut inner) = p.slice(range.clone()); + let mut width = Abs::zero(); + + // Reshape the last item if it's split in half or hyphenated. + let mut last = None; + let mut dash = false; + if let Some((Item::Text(shaped), before)) = inner.split_last() { + // Compute the range we want to shape, trimming whitespace at the + // end of the line. + let base = expanded.end - shaped.text.len(); + let start = range.start.max(base); + let text = &p.bidi.text[start..range.end]; + // U+200B ZERO WIDTH SPACE is used to provide a line break opportunity, + // we want to trim it too. + let trimmed = text.trim_end().trim_end_matches('\u{200B}'); + range.end = start + trimmed.len(); + + // Deal with hyphens, dashes and justification. + let shy = trimmed.ends_with('\u{ad}'); + dash = hyphen || shy || trimmed.ends_with(['-', '–', '—']); + justify |= text.ends_with('\u{2028}'); + + // Deal with CJK punctuation at line ends. + let gb_style = is_gb_style(shaped.lang, shaped.region); + let end_cjk_punct = trimmed + .ends_with(['”', '’', ',', '。', '、', ':', ';', '》', ')', '』', '」']); + + // Usually, we don't want to shape an empty string because: + // - We don't want the height of trimmed whitespace in a different + // font to be considered for the line height. + // - Even if it's in the same font, its unnecessary. + // + // There is one exception though. When the whole line is empty, we + // need the shaped empty string to make the line the appropriate + // height. That is the case exactly if the string is empty and there + // are no other items in the line. + if hyphen || start + shaped.text.len() > range.end || end_cjk_punct { + if hyphen || start < range.end || before.is_empty() { + let mut reshaped = shaped.reshape(vt, &p.spans, start..range.end); + if hyphen || shy { + reshaped.push_hyphen(vt); + } + let punct = reshaped.glyphs.last(); + if let Some(punct) = punct { + if punct.is_cjk_left_aligned_punctuation(gb_style) { + let shrink_amount = punct.shrinkability().1; + let punct = reshaped.glyphs.to_mut().last_mut().unwrap(); + punct.shrink_right(shrink_amount); + reshaped.width -= shrink_amount.at(reshaped.size); + } + } + width += reshaped.width; + last = Some(Item::Text(reshaped)); + } + + inner = before; + } + } + + // Deal with CJK punctuation at line starts. + let text = &p.bidi.text[range.start..end]; + let start_cjk_punct = text.starts_with(['“', '‘', '《', '(', '『', '「']); + + // Reshape the start item if it's split in half. + let mut first = None; + if let Some((Item::Text(shaped), after)) = inner.split_first() { + // Compute the range we want to shape. + let base = expanded.start; + let end = range.end.min(base + shaped.text.len()); + + // Reshape if necessary. + if range.start + shaped.text.len() > end || start_cjk_punct { + if range.start < end || start_cjk_punct { + let reshaped = shaped.reshape(vt, &p.spans, range.start..end); + width += reshaped.width; + first = Some(Item::Text(reshaped)); + } + + inner = after; + } + } + + if start_cjk_punct { + let reshaped = first.as_mut().or(last.as_mut()).and_then(Item::text_mut); + if let Some(reshaped) = reshaped { + if let Some(punct) = reshaped.glyphs.first() { + if punct.is_cjk_right_aligned_punctuation() { + let shrink_amount = punct.shrinkability().0; + let punct = reshaped.glyphs.to_mut().first_mut().unwrap(); + punct.shrink_left(shrink_amount); + let amount_abs = shrink_amount.at(reshaped.size); + reshaped.width -= amount_abs; + width -= amount_abs; + } + } + } + } + + // Measure the inner items. + for item in inner { + width += item.width(); + } + + Line { + bidi: &p.bidi, + trimmed: range, + end, + first, + inner, + last, + width, + justify, + dash, + } +} + +/// Combine layouted lines into one frame per region. +fn finalize( + vt: &mut Vt, + p: &Preparation, + lines: &[Line], + region: Size, + expand: bool, +) -> SourceResult<Fragment> { + // Determine the paragraph's width: Full width of the region if we + // should expand or there's fractional spacing, fit-to-width otherwise. + let width = if !region.x.is_finite() + || (!expand && lines.iter().all(|line| line.fr().is_zero())) + { + p.hang + lines.iter().map(|line| line.width).max().unwrap_or_default() + } else { + region.x + }; + + // Stack the lines into one frame per region. + let mut frames: Vec<Frame> = lines + .iter() + .map(|line| commit(vt, p, line, width, region.y)) + .collect::<SourceResult<_>>()?; + + // Prevent orphans. + let leading = ParElem::leading_in(p.styles); + if frames.len() >= 2 && !frames[1].is_empty() { + let second = frames.remove(1); + let first = &mut frames[0]; + merge(first, second, leading); + } + + // Prevent widows. + let len = frames.len(); + if len >= 2 && !frames[len - 2].is_empty() { + let second = frames.pop().unwrap(); + let first = frames.last_mut().unwrap(); + merge(first, second, leading); + } + + Ok(Fragment::frames(frames)) +} + +/// Merge two line frames +fn merge(first: &mut Frame, second: Frame, leading: Abs) { + let offset = first.height() + leading; + let total = offset + second.height(); + first.push_frame(Point::with_y(offset), second); + first.size_mut().y = total; +} + +/// Commit to a line and build its frame. +fn commit( + vt: &mut Vt, + p: &Preparation, + line: &Line, + width: Abs, + full: Abs, +) -> SourceResult<Frame> { + let mut remaining = width - line.width - p.hang; + let mut offset = Abs::zero(); + + // Reorder the line from logical to visual order. + let (reordered, starts_rtl) = reorder(line); + if !starts_rtl { + offset += p.hang; + } + + // Handle hanging punctuation to the left. + if let Some(Item::Text(text)) = reordered.first() { + if let Some(glyph) = text.glyphs.first() { + if !text.dir.is_positive() + && TextElem::overhang_in(text.styles) + && (reordered.len() > 1 || text.glyphs.len() > 1) + { + let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + offset -= amount; + remaining += amount; + } + } + } + + // Handle hanging punctuation to the right. + if let Some(Item::Text(text)) = reordered.last() { + if let Some(glyph) = text.glyphs.last() { + if text.dir.is_positive() + && TextElem::overhang_in(text.styles) + && (reordered.len() > 1 || text.glyphs.len() > 1) + { + let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + remaining += amount; + } + } + } + + // Determine how much additional space is needed. + // The justicication_ratio is for the first step justification, + // extra_justification is for the last step. + // For more info on multi-step justification, see Procedures for Inter- + // Character Space Expansion in W3C document Chinese Layout Requirements. + let fr = line.fr(); + let mut justification_ratio = 0.0; + let mut extra_justification = Abs::zero(); + + let shrink = line.shrinkability(); + let stretch = line.stretchability(); + if remaining < Abs::zero() && shrink > Abs::zero() { + // Attempt to reduce the length of the line, using shrinkability. + justification_ratio = (remaining / shrink).max(-1.0); + remaining = (remaining + shrink).min(Abs::zero()); + } else if line.justify && fr.is_zero() { + // Attempt to increase the length of the line, using stretchability. + if stretch > Abs::zero() { + justification_ratio = (remaining / stretch).min(1.0); + remaining = (remaining - stretch).max(Abs::zero()); + } + + let justifiables = line.justifiables(); + if justifiables > 0 && remaining > Abs::zero() { + // Underfull line, distribute the extra space. + extra_justification = remaining / justifiables as f64; + remaining = Abs::zero(); + } + } + + let mut top = Abs::zero(); + let mut bottom = Abs::zero(); + + // Build the frames and determine the height and baseline. + let mut frames = vec![]; + for item in reordered { + let mut push = |offset: &mut Abs, frame: Frame| { + let width = frame.width(); + top.set_max(frame.baseline()); + bottom.set_max(frame.size().y - frame.baseline()); + frames.push((*offset, frame)); + *offset += width; + }; + + match item { + Item::Absolute(v) => { + offset += *v; + } + Item::Fractional(v, elem) => { + let amount = v.share(fr, remaining); + if let Some((elem, styles)) = elem { + let region = Size::new(amount, full); + let pod = Regions::one(region, Axes::new(true, false)); + let mut frame = elem.layout(vt, *styles, pod)?.into_frame(); + frame.translate(Point::with_y(TextElem::baseline_in(*styles))); + push(&mut offset, frame); + } else { + offset += amount; + } + } + Item::Text(shaped) => { + let frame = shaped.build(vt, justification_ratio, extra_justification); + push(&mut offset, frame); + } + Item::Frame(frame) | Item::Meta(frame) => { + push(&mut offset, frame.clone()); + } + } + } + + // Remaining space is distributed now. + if !fr.is_zero() { + remaining = Abs::zero(); + } + + let size = Size::new(width, top + bottom); + let mut output = Frame::new(size); + output.set_baseline(top); + + // Construct the line's frame. + for (offset, frame) in frames { + let x = offset + p.align.position(remaining); + let y = top - frame.baseline(); + output.push_frame(Point::new(x, y), frame); + } + + Ok(output) +} + +/// Return a line's items in visual order. +fn reorder<'a>(line: &'a Line<'a>) -> (Vec<&Item<'a>>, bool) { + let mut reordered = vec![]; + + // The bidi crate doesn't like empty lines. + if line.trimmed.is_empty() { + return (line.slice(line.trimmed.clone()).collect(), false); + } + + // Find the paragraph that contains the line. + let para = line + .bidi + .paragraphs + .iter() + .find(|para| para.range.contains(&line.trimmed.start)) + .unwrap(); + + // Compute the reordered ranges in visual order (left to right). + let (levels, runs) = line.bidi.visual_runs(para, line.trimmed.clone()); + let starts_rtl = levels.first().map_or(false, |level| level.is_rtl()); + + // Collect the reordered items. + for run in runs { + // Skip reset L1 runs because handling them would require reshaping + // again in some cases. + if line.bidi.levels[run.start] != levels[run.start] { + continue; + } + + let prev = reordered.len(); + reordered.extend(line.slice(run.clone())); + + if levels[run.start].is_rtl() { + reordered[prev..].reverse(); + } + } + + (reordered, starts_rtl) +} + +/// How much a character should hang into the end margin. +/// +/// For more discussion, see: +/// https://recoveringphysicist.com/21/ +fn overhang(c: char) -> f64 { + match c { + // Dashes. + '–' | '—' => 0.2, + '-' => 0.55, + + // Punctuation. + '.' | ',' => 0.8, + ':' | ';' => 0.3, + + // Arabic + '\u{60C}' | '\u{6D4}' => 0.4, + + _ => 0.0, + } +} diff --git a/crates/typst-library/src/layout/place.rs b/crates/typst-library/src/layout/place.rs new file mode 100644 index 00000000..6602948c --- /dev/null +++ b/crates/typst-library/src/layout/place.rs @@ -0,0 +1,103 @@ +use crate::prelude::*; + +/// Places content at an absolute position. +/// +/// Placed content will not affect the position of other content. Place is +/// always relative to its parent container and will be in the foreground of all +/// other content in the container. Page margins will be respected. +/// +/// +/// ## Example { #example } +/// ```example +/// #set page(height: 60pt) +/// Hello, world! +/// +/// #place( +/// top + right, +/// square( +/// width: 20pt, +/// stroke: 2pt + blue +/// ), +/// ) +/// ``` +/// +/// Display: Place +/// Category: layout +#[element(Layout, Behave)] +pub struct PlaceElem { + /// Relative to which position in the parent container to place the content. + /// + /// When an axis of the page is `{auto}` sized, all alignments relative to that + /// axis will be ignored, instead, the item will be placed in the origin of the + /// axis. + #[positional] + #[default(Axes::with_x(Some(GenAlign::Start)))] + pub alignment: Axes<Option<GenAlign>>, + + /// The horizontal displacement of the placed content. + /// + /// ```example + /// #set page(height: 100pt) + /// #for i in range(16) { + /// let amount = i * 4pt + /// place(center, dx: amount - 32pt, dy: amount)[A] + /// } + /// ``` + pub dx: Rel<Length>, + + /// The vertical displacement of the placed content. + pub dy: Rel<Length>, + + /// The content to place. + #[required] + pub body: Content, +} + +impl Layout for PlaceElem { + #[tracing::instrument(name = "PlaceElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let out_of_flow = self.out_of_flow(styles); + + // The pod is the base area of the region because for absolute + // placement we don't really care about the already used area. + let pod = { + let finite = regions.base().map(Abs::is_finite); + let expand = finite & (regions.expand | out_of_flow); + Regions::one(regions.base(), expand) + }; + + let child = self + .body() + .moved(Axes::new(self.dx(styles), self.dy(styles))) + .aligned(self.alignment(styles)); + + let mut frame = child.layout(vt, styles, pod)?.into_frame(); + + // If expansion is off, zero all sizes so that we don't take up any + // space in our parent. Otherwise, respect the expand settings. + let target = regions.expand.select(regions.size, Size::zero()); + frame.resize(target, Align::LEFT_TOP); + + Ok(Fragment::frame(frame)) + } +} + +impl PlaceElem { + /// Whether this element wants to be placed relative to its its parent's + /// base origin. Instead of relative to the parent's current flow/cursor + /// position. + pub fn out_of_flow(&self, styles: StyleChain) -> bool { + self.alignment(styles).y.is_some() + } +} + +impl Behave for PlaceElem { + fn behaviour(&self) -> Behaviour { + Behaviour::Ignorant + } +} diff --git a/crates/typst-library/src/layout/regions.rs b/crates/typst-library/src/layout/regions.rs new file mode 100644 index 00000000..6dd549b0 --- /dev/null +++ b/crates/typst-library/src/layout/regions.rs @@ -0,0 +1,144 @@ +use std::fmt::{self, Debug, Formatter}; + +use typst::geom::{Abs, Axes, Size}; + +/// A sequence of regions to layout into. +#[derive(Copy, Clone, Hash)] +pub struct Regions<'a> { + /// The remaining size of the first region. + pub size: Size, + /// The full height of the region for relative sizing. + pub full: Abs, + /// The height of followup regions. The width is the same for all regions. + pub backlog: &'a [Abs], + /// The height of the final region that is repeated once the backlog is + /// drained. The width is the same for all regions. + pub last: Option<Abs>, + /// Whether elements should expand to fill the regions instead of shrinking + /// to fit the content. + pub expand: Axes<bool>, + /// Whether these are the root regions or direct descendants. + /// + /// True for the padded page regions and columns directly in the page, + /// false otherwise. + pub root: bool, +} + +impl Regions<'_> { + /// Create a new region sequence with exactly one region. + pub fn one(size: Size, expand: Axes<bool>) -> Self { + Self { + size, + full: size.y, + backlog: &[], + last: None, + expand, + root: false, + } + } + + /// Create a new sequence of same-size regions that repeats indefinitely. + pub fn repeat(size: Size, expand: Axes<bool>) -> Self { + Self { + size, + full: size.y, + backlog: &[], + last: Some(size.y), + expand, + root: false, + } + } + + /// The base size, which doesn't take into account that the regions is + /// already partially used up. + /// + /// This is also used for relative sizing. + pub fn base(&self) -> Size { + Size::new(self.size.x, self.full) + } + + /// Create new regions where all sizes are mapped with `f`. + /// + /// Note that since all regions must have the same width, the width returned + /// by `f` is ignored for the backlog and the final region. + pub fn map<'v, F>(&self, backlog: &'v mut Vec<Abs>, mut f: F) -> Regions<'v> + where + F: FnMut(Size) -> Size, + { + let x = self.size.x; + backlog.clear(); + backlog.extend(self.backlog.iter().map(|&y| f(Size::new(x, y)).y)); + Regions { + size: f(self.size), + full: f(Size::new(x, self.full)).y, + backlog, + last: self.last.map(|y| f(Size::new(x, y)).y), + expand: self.expand, + root: false, + } + } + + /// Whether the first region is full and a region break is called for. + pub fn is_full(&self) -> bool { + Abs::zero().fits(self.size.y) && !self.in_last() + } + + /// Whether the first region is the last usable region. + /// + /// If this is true, calling `next()` will have no effect. + pub fn in_last(&self) -> bool { + self.backlog.is_empty() && self.last.map_or(true, |height| self.size.y == height) + } + + /// The same regions, but with different `root` configuration. + pub fn with_root(self, root: bool) -> Self { + Self { root, ..self } + } + + /// Advance to the next region if there is any. + pub fn next(&mut self) { + if let Some(height) = self + .backlog + .split_first() + .map(|(first, tail)| { + self.backlog = tail; + *first + }) + .or(self.last) + { + self.size.y = height; + self.full = height; + } + } + + /// An iterator that returns the sizes of the first and all following + /// regions, equivalently to what would be produced by calling + /// [`next()`](Self::next) repeatedly until all regions are exhausted. + /// This iterator may be infinite. + pub fn iter(&self) -> impl Iterator<Item = Size> + '_ { + let first = std::iter::once(self.size); + let backlog = self.backlog.iter(); + let last = self.last.iter().cycle(); + first.chain(backlog.chain(last).map(|&h| Size::new(self.size.x, h))) + } +} + +impl Debug for Regions<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str("Regions ")?; + let mut list = f.debug_list(); + let mut prev = self.size.y; + list.entry(&self.size); + for &height in self.backlog { + list.entry(&Size::new(self.size.x, height)); + prev = height; + } + if let Some(last) = self.last { + if last != prev { + list.entry(&Size::new(self.size.x, last)); + } + list.entry(&(..)); + } + list.finish() + } +} diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs new file mode 100644 index 00000000..646eb991 --- /dev/null +++ b/crates/typst-library/src/layout/repeat.rs @@ -0,0 +1,79 @@ +use crate::prelude::*; + +use super::AlignElem; + +/// Repeats content to the available space. +/// +/// This can be useful when implementing a custom index, reference, or outline. +/// +/// Space may be inserted between the instances of the body parameter, so be +/// sure to include negative space if you need the instances to overlap. +/// +/// Errors if there no bounds on the available space, as it would create +/// infinite content. +/// +/// ## Example { #example } +/// ```example +/// Sign on the dotted line: +/// #box(width: 1fr, repeat[.]) +/// +/// #set text(10pt) +/// #v(8pt, weak: true) +/// #align(right)[ +/// Berlin, the 22nd of December, 2022 +/// ] +/// ``` +/// +/// Display: Repeat +/// Category: layout +#[element(Layout)] +pub struct RepeatElem { + /// The content to repeat. + #[required] + pub body: Content, +} + +impl Layout for RepeatElem { + #[tracing::instrument(name = "RepeatElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let pod = Regions::one(regions.size, Axes::new(false, false)); + let piece = self.body().layout(vt, styles, pod)?.into_frame(); + let align = AlignElem::alignment_in(styles).x.resolve(styles); + + let fill = regions.size.x; + let width = piece.width(); + let count = (fill / width).floor(); + let remaining = fill % width; + let apart = remaining / (count - 1.0); + + let size = Size::new(regions.size.x, piece.height()); + + if !size.is_finite() { + bail!(self.span(), "repeat with no size restrictions"); + } + + let mut frame = Frame::new(size); + if piece.has_baseline() { + frame.set_baseline(piece.baseline()); + } + + let mut offset = Abs::zero(); + if count == 1.0 { + offset += align.position(remaining); + } + + if width > Abs::zero() { + for _ in 0..(count as usize).min(1000) { + frame.push_frame(Point::with_x(offset), piece.clone()); + offset += piece.width() + apart; + } + } + + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/layout/spacing.rs b/crates/typst-library/src/layout/spacing.rs new file mode 100644 index 00000000..e7dc24fb --- /dev/null +++ b/crates/typst-library/src/layout/spacing.rs @@ -0,0 +1,240 @@ +use std::cmp::Ordering; + +use crate::prelude::*; + +/// Inserts horizontal spacing into a paragraph. +/// +/// The spacing can be absolute, relative, or fractional. In the last case, the +/// remaining space on the line is distributed among all fractional spacings +/// according to their relative fractions. +/// +/// ## Example { #example } +/// ```example +/// First #h(1cm) Second \ +/// First #h(30%) Second \ +/// First #h(2fr) Second #h(1fr) Third +/// ``` +/// +/// ## Mathematical Spacing { #math-spacing } +/// In [mathematical formulas]($category/math), you can additionally use these +/// constants to add spacing between elements: `thin`, `med`, `thick`, `quad`. +/// +/// Display: Spacing (H) +/// Category: layout +#[element(Behave)] +pub struct HElem { + /// How much spacing to insert. + #[required] + pub amount: Spacing, + + /// If `{true}`, the spacing collapses at the start or end of a paragraph. + /// Moreover, from multiple adjacent weak spacings all but the largest one + /// collapse. + /// + /// ```example + /// #h(1cm, weak: true) + /// We identified a group of + /// _weak_ specimens that fail to + /// manifest in most cases. However, + /// when #h(8pt, weak: true) + /// supported + /// #h(8pt, weak: true) on both + /// sides, they do show up. + /// ``` + #[default(false)] + pub weak: bool, +} + +impl Behave for HElem { + fn behaviour(&self) -> Behaviour { + if self.amount().is_fractional() { + Behaviour::Destructive + } else if self.weak(StyleChain::default()) { + Behaviour::Weak(1) + } else { + Behaviour::Ignorant + } + } + + fn larger(&self, prev: &Content) -> bool { + let Some(prev) = prev.to::<Self>() else { return false }; + self.amount() > prev.amount() + } +} + +/// Inserts vertical spacing into a flow of blocks. +/// +/// The spacing can be absolute, relative, or fractional. In the last case, +/// the remaining space on the page is distributed among all fractional spacings +/// according to their relative fractions. +/// +/// ## Example { #example } +/// ```example +/// #grid( +/// rows: 3cm, +/// columns: 6, +/// gutter: 1fr, +/// [A #parbreak() B], +/// [A #v(0pt) B], +/// [A #v(10pt) B], +/// [A #v(0pt, weak: true) B], +/// [A #v(40%, weak: true) B], +/// [A #v(1fr) B], +/// ) +/// ``` +/// +/// Display: Spacing (V) +/// Category: layout +#[element(Behave)] +pub struct VElem { + /// How much spacing to insert. + #[required] + pub amount: Spacing, + + /// If `{true}`, the spacing collapses at the start or end of a flow. + /// Moreover, from multiple adjacent weak spacings all but the largest one + /// collapse. Weak spacings will always collapse adjacent paragraph spacing, + /// even if the paragraph spacing is larger. + /// + /// ```example + /// The following theorem is + /// foundational to the field: + /// #v(4pt, weak: true) + /// $ x^2 + y^2 = r^2 $ + /// #v(4pt, weak: true) + /// The proof is simple: + /// ``` + #[external] + pub weak: bool, + + /// The element's weakness level, see also [`Behaviour`]. + #[internal] + #[parse(args.named("weak")?.map(|v: bool| v as usize))] + pub weakness: usize, +} + +impl VElem { + /// Normal strong spacing. + pub fn strong(amount: Spacing) -> Self { + Self::new(amount).with_weakness(0) + } + + /// User-created weak spacing. + pub fn weak(amount: Spacing) -> Self { + Self::new(amount).with_weakness(1) + } + + /// Weak spacing with list attach weakness. + pub fn list_attach(amount: Spacing) -> Self { + Self::new(amount).with_weakness(2) + } + + /// Weak spacing with BlockElem::ABOVE/BELOW weakness. + pub fn block_around(amount: Spacing) -> Self { + Self::new(amount).with_weakness(3) + } + + /// Weak spacing with BlockElem::SPACING weakness. + pub fn block_spacing(amount: Spacing) -> Self { + Self::new(amount).with_weakness(4) + } +} + +impl Behave for VElem { + fn behaviour(&self) -> Behaviour { + if self.amount().is_fractional() { + Behaviour::Destructive + } else if self.weakness(StyleChain::default()) > 0 { + Behaviour::Weak(self.weakness(StyleChain::default())) + } else { + Behaviour::Ignorant + } + } + + fn larger(&self, prev: &Content) -> bool { + let Some(prev) = prev.to::<Self>() else { return false }; + self.amount() > prev.amount() + } +} + +cast! { + VElem, + v: Content => v.to::<Self>().cloned().ok_or("expected `v` element")?, +} + +/// Kinds of spacing. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Spacing { + /// Spacing specified in absolute terms and relative to the parent's size. + Rel(Rel<Length>), + /// Spacing specified as a fraction of the remaining free space in the + /// parent. + Fr(Fr), +} + +impl Spacing { + /// Whether this is fractional spacing. + pub fn is_fractional(self) -> bool { + matches!(self, Self::Fr(_)) + } + + /// Whether the spacing is actually no spacing. + pub fn is_zero(&self) -> bool { + match self { + Self::Rel(rel) => rel.is_zero(), + Self::Fr(fr) => fr.is_zero(), + } + } +} + +impl From<Abs> for Spacing { + fn from(abs: Abs) -> Self { + Self::Rel(abs.into()) + } +} + +impl From<Em> for Spacing { + fn from(em: Em) -> Self { + Self::Rel(Rel::new(Ratio::zero(), em.into())) + } +} + +impl From<Length> for Spacing { + fn from(length: Length) -> Self { + Self::Rel(length.into()) + } +} + +impl From<Fr> for Spacing { + fn from(fr: Fr) -> Self { + Self::Fr(fr) + } +} + +impl PartialOrd for Spacing { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + match (self, other) { + (Self::Rel(a), Self::Rel(b)) => a.partial_cmp(b), + (Self::Fr(a), Self::Fr(b)) => a.partial_cmp(b), + _ => None, + } + } +} + +cast! { + Spacing, + self => match self { + Self::Rel(rel) => { + if rel.rel.is_zero() { + rel.abs.into_value() + } else if rel.abs.is_zero() { + rel.rel.into_value() + } else { + rel.into_value() + } + } + Self::Fr(fr) => fr.into_value(), + }, + v: Rel<Length> => Self::Rel(v), + v: Fr => Self::Fr(v), +} diff --git a/crates/typst-library/src/layout/stack.rs b/crates/typst-library/src/layout/stack.rs new file mode 100644 index 00000000..97305ddf --- /dev/null +++ b/crates/typst-library/src/layout/stack.rs @@ -0,0 +1,339 @@ +use super::{AlignElem, Spacing}; +use crate::prelude::*; + +/// Arranges content and spacing horizontally or vertically. +/// +/// The stack places a list of items along an axis, with optional spacing +/// between each item. +/// +/// ## Example { #example } +/// ```example +/// #stack( +/// dir: ttb, +/// rect(width: 40pt), +/// rect(width: 120pt), +/// rect(width: 90pt), +/// ) +/// ``` +/// +/// Display: Stack +/// Category: layout +#[element(Layout)] +pub struct StackElem { + /// The direction along which the items are stacked. Possible values are: + /// + /// - `{ltr}`: Left to right. + /// - `{rtl}`: Right to left. + /// - `{ttb}`: Top to bottom. + /// - `{btt}`: Bottom to top. + #[default(Dir::TTB)] + pub dir: Dir, + + /// Spacing to insert between items where no explicit spacing was provided. + pub spacing: Option<Spacing>, + + /// The children to stack along the axis. + #[variadic] + pub children: Vec<StackChild>, +} + +impl Layout for StackElem { + #[tracing::instrument(name = "StackElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let mut layouter = StackLayouter::new(self.dir(styles), regions, styles); + + // Spacing to insert before the next block. + let spacing = self.spacing(styles); + let mut deferred = None; + + for child in self.children() { + match child { + StackChild::Spacing(kind) => { + layouter.layout_spacing(kind); + deferred = None; + } + StackChild::Block(block) => { + if let Some(kind) = deferred { + layouter.layout_spacing(kind); + } + + layouter.layout_block(vt, &block, styles)?; + deferred = spacing; + } + } + } + + Ok(layouter.finish()) + } +} + +/// A child of a stack element. +#[derive(Hash)] +pub enum StackChild { + /// Spacing between other children. + Spacing(Spacing), + /// Arbitrary block-level content. + Block(Content), +} + +impl Debug for StackChild { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Spacing(kind) => kind.fmt(f), + Self::Block(block) => block.fmt(f), + } + } +} + +cast! { + StackChild, + self => match self { + Self::Spacing(spacing) => spacing.into_value(), + Self::Block(content) => content.into_value(), + }, + v: Spacing => Self::Spacing(v), + v: Content => Self::Block(v), +} + +/// Performs stack layout. +struct StackLayouter<'a> { + /// The stacking direction. + dir: Dir, + /// The axis of the stacking direction. + axis: Axis, + /// The regions to layout children into. + regions: Regions<'a>, + /// The inherited styles. + styles: StyleChain<'a>, + /// Whether the stack itself should expand to fill the region. + expand: Axes<bool>, + /// The initial size of the current region before we started subtracting. + initial: Size, + /// The generic size used by the frames for the current region. + used: Gen<Abs>, + /// The sum of fractions in the current region. + fr: Fr, + /// Already layouted items whose exact positions are not yet known due to + /// fractional spacing. + items: Vec<StackItem>, + /// Finished frames for previous regions. + finished: Vec<Frame>, +} + +/// A prepared item in a stack layout. +enum StackItem { + /// Absolute spacing between other items. + Absolute(Abs), + /// Fractional spacing between other items. + Fractional(Fr), + /// A frame for a layouted block. + Frame(Frame, Axes<Align>), +} + +impl<'a> StackLayouter<'a> { + /// Create a new stack layouter. + fn new(dir: Dir, mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self { + let axis = dir.axis(); + let expand = regions.expand; + + // Disable expansion along the block axis for children. + regions.expand.set(axis, false); + + Self { + dir, + axis, + regions, + styles, + expand, + initial: regions.size, + used: Gen::zero(), + fr: Fr::zero(), + items: vec![], + finished: vec![], + } + } + + /// Add spacing along the spacing direction. + #[tracing::instrument(name = "StackLayouter::layout_spacing", skip_all)] + fn layout_spacing(&mut self, spacing: Spacing) { + match spacing { + Spacing::Rel(v) => { + // Resolve the spacing and limit it to the remaining space. + let resolved = v + .resolve(self.styles) + .relative_to(self.regions.base().get(self.axis)); + let remaining = self.regions.size.get_mut(self.axis); + let limited = resolved.min(*remaining); + if self.dir.axis() == Axis::Y { + *remaining -= limited; + } + self.used.main += limited; + self.items.push(StackItem::Absolute(resolved)); + } + Spacing::Fr(v) => { + self.fr += v; + self.items.push(StackItem::Fractional(v)); + } + } + } + + /// Layout an arbitrary block. + #[tracing::instrument(name = "StackLayouter::layout_block", skip_all)] + fn layout_block( + &mut self, + vt: &mut Vt, + block: &Content, + styles: StyleChain, + ) -> SourceResult<()> { + if self.regions.is_full() { + self.finish_region(); + } + + // Block-axis alignment of the `AlignElement` is respected by stacks. + let aligns = if let Some(align) = block.to::<AlignElem>() { + align.alignment(styles) + } else if let Some((_, local)) = block.to_styled() { + AlignElem::alignment_in(styles.chain(local)) + } else { + AlignElem::alignment_in(styles) + } + .resolve(styles); + + let fragment = block.layout(vt, styles, self.regions)?; + let len = fragment.len(); + for (i, frame) in fragment.into_iter().enumerate() { + // Grow our size, shrink the region and save the frame for later. + let size = frame.size(); + if self.dir.axis() == Axis::Y { + self.regions.size.y -= size.y; + } + + let gen = match self.axis { + Axis::X => Gen::new(size.y, size.x), + Axis::Y => Gen::new(size.x, size.y), + }; + + self.used.main += gen.main; + self.used.cross.set_max(gen.cross); + + self.items.push(StackItem::Frame(frame, aligns)); + + if i + 1 < len { + self.finish_region(); + } + } + + Ok(()) + } + + /// Advance to the next region. + fn finish_region(&mut self) { + // Determine the size of the stack in this region depending on whether + // the region expands. + let mut size = self + .expand + .select(self.initial, self.used.into_axes(self.axis)) + .min(self.initial); + + // Expand fully if there are fr spacings. + let full = self.initial.get(self.axis); + let remaining = full - self.used.main; + if self.fr.get() > 0.0 && full.is_finite() { + self.used.main = full; + size.set(self.axis, full); + } + + let mut output = Frame::new(size); + let mut cursor = Abs::zero(); + let mut ruler: Align = self.dir.start().into(); + + // Place all frames. + for item in self.items.drain(..) { + match item { + StackItem::Absolute(v) => cursor += v, + StackItem::Fractional(v) => cursor += v.share(self.fr, remaining), + StackItem::Frame(frame, aligns) => { + if self.dir.is_positive() { + ruler = ruler.max(aligns.get(self.axis)); + } else { + ruler = ruler.min(aligns.get(self.axis)); + } + + // Align along the main axis. + let parent = size.get(self.axis); + let child = frame.size().get(self.axis); + let main = ruler.position(parent - self.used.main) + + if self.dir.is_positive() { + cursor + } else { + self.used.main - child - cursor + }; + + // Align along the cross axis. + let other = self.axis.other(); + let cross = aligns + .get(other) + .position(size.get(other) - frame.size().get(other)); + + let pos = Gen::new(cross, main).to_point(self.axis); + cursor += child; + output.push_frame(pos, frame); + } + } + } + + // Advance to the next region. + self.regions.next(); + self.initial = self.regions.size; + self.used = Gen::zero(); + self.fr = Fr::zero(); + self.finished.push(output); + } + + /// Finish layouting and return the resulting frames. + fn finish(mut self) -> Fragment { + self.finish_region(); + Fragment::frames(self.finished) + } +} + +/// A container with a main and cross component. +#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] +struct Gen<T> { + /// The main component. + pub cross: T, + /// The cross component. + pub main: T, +} + +impl<T> Gen<T> { + /// Create a new instance from the two components. + const fn new(cross: T, main: T) -> Self { + Self { cross, main } + } + + /// Convert to the specific representation, given the current main axis. + fn into_axes(self, main: Axis) -> Axes<T> { + match main { + Axis::X => Axes::new(self.main, self.cross), + Axis::Y => Axes::new(self.cross, self.main), + } + } +} + +impl Gen<Abs> { + /// The zero value. + fn zero() -> Self { + Self { cross: Abs::zero(), main: Abs::zero() } + } + + /// Convert to a point. + fn to_point(self, main: Axis) -> Point { + self.into_axes(main).to_point() + } +} diff --git a/crates/typst-library/src/layout/table.rs b/crates/typst-library/src/layout/table.rs new file mode 100644 index 00000000..c2faedba --- /dev/null +++ b/crates/typst-library/src/layout/table.rs @@ -0,0 +1,334 @@ +use typst::eval::{CastInfo, Reflect}; + +use crate::layout::{AlignElem, GridLayouter, TrackSizings}; +use crate::meta::{Figurable, LocalName}; +use crate::prelude::*; + +/// A table of items. +/// +/// Tables are used to arrange content in cells. Cells can contain arbitrary +/// content, including multiple paragraphs and are specified in row-major order. +/// Because tables are just grids with configurable cell properties, refer to +/// the [grid documentation]($func/grid) for more information on how to size the +/// table tracks. +/// +/// To give a table a caption and make it [referenceable]($func/ref), put it +/// into a [figure]($func/figure). +/// +/// ## Example { #example } +/// ```example +/// #table( +/// columns: (1fr, auto, auto), +/// inset: 10pt, +/// align: horizon, +/// [], [*Area*], [*Parameters*], +/// image("cylinder.svg"), +/// $ pi h (D^2 - d^2) / 4 $, +/// [ +/// $h$: height \ +/// $D$: outer radius \ +/// $d$: inner radius +/// ], +/// image("tetrahedron.svg"), +/// $ sqrt(2) / 12 a^3 $, +/// [$a$: edge length] +/// ) +/// ``` +/// +/// Display: Table +/// Category: layout +#[element(Layout, LocalName, Figurable)] +pub struct TableElem { + /// The column sizes. See the [grid documentation]($func/grid) for more + /// information on track sizing. + pub columns: TrackSizings, + + /// The row sizes. See the [grid documentation]($func/grid) for more + /// information on track sizing. + pub rows: TrackSizings, + + /// The gaps between rows & columns. See the [grid + /// documentation]($func/grid) for more information on gutters. + #[external] + pub gutter: TrackSizings, + + /// The gaps between columns. Takes precedence over `gutter`. See the [grid + /// documentation]($func/grid) for more information on gutters. + #[parse( + let gutter = args.named("gutter")?; + args.named("column-gutter")?.or_else(|| gutter.clone()) + )] + pub column_gutter: TrackSizings, + + /// The gaps between rows. Takes precedence over `gutter`. See the [grid + /// documentation]($func/grid) for more information on gutters. + #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))] + pub row_gutter: TrackSizings, + + /// How to fill the cells. + /// + /// This can be a color or a function that returns a color. The function is + /// passed the cells' column and row index, starting at zero. This can be + /// used to implement striped tables. + /// + /// ```example + /// #table( + /// fill: (col, _) => if calc.odd(col) { luma(240) } else { white }, + /// align: (col, row) => + /// if row == 0 { center } + /// else if col == 0 { left } + /// else { right }, + /// columns: 4, + /// [], [*Q1*], [*Q2*], [*Q3*], + /// [Revenue:], [1000 €], [2000 €], [3000 €], + /// [Expenses:], [500 €], [1000 €], [1500 €], + /// [Profit:], [500 €], [1000 €], [1500 €], + /// ) + /// ``` + pub fill: Celled<Option<Paint>>, + + /// How to align the cells' content. + /// + /// This can either be a single alignment, an array of alignments + /// (corresponding to each column) or a function that returns an alignment. + /// The function is passed the cells' column and row index, starting at zero. + /// If set to `{auto}`, the outer alignment is used. + /// + /// ```example + /// #table( + /// columns: 3, + /// align: (x, y) => (left, center, right).at(x), + /// [Hello], [Hello], [Hello], + /// [A], [B], [C], + /// ) + /// ``` + pub align: Celled<Smart<Axes<Option<GenAlign>>>>, + + /// How to stroke the cells. + /// + /// See the [line's documentation]($func/line.stroke) for more details. + /// Strokes can be disabled by setting this to `{none}`. + /// + /// _Note:_ Richer stroke customization for individual cells is not yet + /// implemented, but will be in the future. In the meantime, you can use + /// the third-party [tablex library](https://github.com/PgBiel/typst-tablex/). + #[resolve] + #[fold] + #[default(Some(PartialStroke::default()))] + pub stroke: Option<PartialStroke>, + + /// How much to pad the cells' content. + #[default(Abs::pt(5.0).into())] + pub inset: Rel<Length>, + + /// The contents of the table cells. + #[variadic] + pub children: Vec<Content>, +} + +impl Layout for TableElem { + #[tracing::instrument(name = "TableElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let inset = self.inset(styles); + let align = self.align(styles); + + let tracks = Axes::new(self.columns(styles).0, self.rows(styles).0); + let gutter = Axes::new(self.column_gutter(styles).0, self.row_gutter(styles).0); + let cols = tracks.x.len().max(1); + let cells: Vec<_> = self + .children() + .into_iter() + .enumerate() + .map(|(i, child)| { + let mut child = child.padded(Sides::splat(inset)); + + let x = i % cols; + let y = i / cols; + if let Smart::Custom(alignment) = align.resolve(vt, x, y)? { + child = child.styled(AlignElem::set_alignment(alignment)); + } + + Ok(child) + }) + .collect::<SourceResult<_>>()?; + + let fill = self.fill(styles); + let stroke = self.stroke(styles).map(PartialStroke::unwrap_or_default); + + // Prepare grid layout by unifying content and gutter tracks. + let layouter = GridLayouter::new( + tracks.as_deref(), + gutter.as_deref(), + &cells, + regions, + styles, + ); + + // Measure the columns and layout the grid row-by-row. + let mut layout = layouter.layout(vt)?; + + // Add lines and backgrounds. + for (frame, rows) in layout.fragment.iter_mut().zip(&layout.rows) { + if layout.cols.is_empty() || rows.is_empty() { + continue; + } + + // Render table lines. + if let Some(stroke) = &stroke { + let thickness = stroke.thickness; + let half = thickness / 2.0; + + // Render horizontal lines. + for offset in points(rows.iter().map(|piece| piece.height)) { + let target = Point::with_x(frame.width() + thickness); + let hline = Geometry::Line(target).stroked(stroke.clone()); + frame.prepend( + Point::new(-half, offset), + FrameItem::Shape(hline, self.span()), + ); + } + + // Render vertical lines. + for offset in points(layout.cols.iter().copied()) { + let target = Point::with_y(frame.height() + thickness); + let vline = Geometry::Line(target).stroked(stroke.clone()); + frame.prepend( + Point::new(offset, -half), + FrameItem::Shape(vline, self.span()), + ); + } + } + + // Render cell backgrounds. + let mut dx = Abs::zero(); + for (x, &col) in layout.cols.iter().enumerate() { + let mut dy = Abs::zero(); + for row in rows { + if let Some(fill) = fill.resolve(vt, x, row.y)? { + let pos = Point::new(dx, dy); + let size = Size::new(col, row.height); + let rect = Geometry::Rect(size).filled(fill); + frame.prepend(pos, FrameItem::Shape(rect, self.span())); + } + dy += row.height; + } + dx += col; + } + } + + Ok(layout.fragment) + } +} + +/// Turn an iterator of extents into an iterator of offsets before, in between, +/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm]. +fn points(extents: impl IntoIterator<Item = Abs>) -> impl Iterator<Item = Abs> { + let mut offset = Abs::zero(); + std::iter::once(Abs::zero()) + .chain(extents.into_iter()) + .map(move |extent| { + offset += extent; + offset + }) +} + +/// A value that can be configured per cell. +#[derive(Debug, Clone, PartialEq, Hash)] +pub enum Celled<T> { + /// A bare value, the same for all cells. + Value(T), + /// A closure mapping from cell coordinates to a value. + Func(Func), + /// An array of alignment values corresponding to each column. + Array(Vec<T>), +} + +impl<T: Default + Clone + FromValue> Celled<T> { + /// Resolve the value based on the cell position. + pub fn resolve(&self, vt: &mut Vt, x: usize, y: usize) -> SourceResult<T> { + Ok(match self { + Self::Value(value) => value.clone(), + Self::Func(func) => func.call_vt(vt, [x, y])?.cast().at(func.span())?, + Self::Array(array) => x + .checked_rem(array.len()) + .and_then(|i| array.get(i)) + .cloned() + .unwrap_or_default(), + }) + } +} + +impl<T: Default> Default for Celled<T> { + fn default() -> Self { + Self::Value(T::default()) + } +} + +impl<T: Reflect> Reflect for Celled<T> { + fn describe() -> CastInfo { + T::describe() + Array::describe() + Func::describe() + } + + fn castable(value: &Value) -> bool { + Array::castable(value) || Func::castable(value) || T::castable(value) + } +} + +impl<T: IntoValue> IntoValue for Celled<T> { + fn into_value(self) -> Value { + match self { + Self::Value(value) => value.into_value(), + Self::Func(func) => func.into_value(), + Self::Array(arr) => arr.into_value(), + } + } +} + +impl<T: FromValue> FromValue for Celled<T> { + fn from_value(value: Value) -> StrResult<Self> { + match value { + Value::Func(v) => Ok(Self::Func(v)), + Value::Array(array) => Ok(Self::Array( + array.into_iter().map(T::from_value).collect::<StrResult<_>>()?, + )), + v if T::castable(&v) => Ok(Self::Value(T::from_value(v)?)), + v => Err(Self::error(&v)), + } + } +} + +impl LocalName for TableElem { + fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "Tabel", + Lang::ARABIC => "جدول", + Lang::BOKMÅL => "Tabell", + Lang::CHINESE => "表", + Lang::CZECH => "Tabulka", + Lang::DANISH => "Tabel", + Lang::DUTCH => "Tabel", + Lang::FILIPINO => "Talaan", + Lang::FRENCH => "Tableau", + Lang::GERMAN => "Tabelle", + Lang::ITALIAN => "Tabella", + Lang::NYNORSK => "Tabell", + Lang::POLISH => "Tabela", + Lang::PORTUGUESE => "Tabela", + Lang::RUSSIAN => "Таблица", + Lang::SLOVENIAN => "Tabela", + Lang::SPANISH => "Tabla", + Lang::SWEDISH => "Tabell", + Lang::TURKISH => "Tablo", + Lang::UKRAINIAN => "Таблиця", + Lang::VIETNAMESE => "Bảng", + Lang::ENGLISH | _ => "Table", + } + } +} + +impl Figurable for TableElem {} diff --git a/crates/typst-library/src/layout/terms.rs b/crates/typst-library/src/layout/terms.rs new file mode 100644 index 00000000..d693f100 --- /dev/null +++ b/crates/typst-library/src/layout/terms.rs @@ -0,0 +1,166 @@ +use super::{HElem, VElem}; +use crate::layout::{BlockElem, ParElem, Spacing}; +use crate::prelude::*; + +/// A list of terms and their descriptions. +/// +/// Displays a sequence of terms and their descriptions vertically. When the +/// descriptions span over multiple lines, they use hanging indent to +/// communicate the visual hierarchy. +/// +/// ## Example { #example } +/// ```example +/// / Ligature: A merged glyph. +/// / Kerning: A spacing adjustment +/// between two adjacent letters. +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: Starting a line with a slash, +/// followed by a term, a colon and a description creates a term list item. +/// +/// Display: Term List +/// Category: layout +#[element(Layout)] +#[scope( + scope.define("item", TermItem::func()); + scope +)] +pub struct TermsElem { + /// If this is `{false}`, the items are spaced apart with [term list + /// spacing]($func/terms.spacing). If it is `{true}`, they use normal + /// [leading]($func/par.leading) instead. This makes the term list more + /// compact, which can look better if the items are short. + /// + /// In markup mode, the value of this parameter is determined based on + /// whether items are separated with a blank line. If items directly follow + /// each other, this is set to `{true}`; if items are separated by a blank + /// line, this is set to `{false}`. + /// + /// ```example + /// / Fact: If a term list has a lot + /// of text, and maybe other inline + /// content, it should not be tight + /// anymore. + /// + /// / Tip: To make it wide, simply + /// insert a blank line between the + /// items. + /// ``` + #[default(true)] + pub tight: bool, + + /// The separator between the item and the description. + /// + /// If you want to just separate them with a certain amount of space, use + /// `{h(2cm, weak: true)}` as the separator and replace `{2cm}` with your + /// desired amount of space. + /// + /// ```example + /// #set terms(separator: [: ]) + /// + /// / Colon: A nice separator symbol. + /// ``` + #[default(HElem::new(Em::new(0.6).into()).with_weak(true).pack())] + pub separator: Content, + + /// The indentation of each item. + pub indent: Length, + + /// The hanging indent of the description. + /// + /// This is in addition to the whole item's `indent`. + /// + /// ```example + /// #set terms(hanging-indent: 0pt) + /// / Term: This term list does not + /// make use of hanging indents. + /// ``` + #[default(Em::new(2.0).into())] + pub hanging_indent: Length, + + /// The spacing between the items of a wide (non-tight) term list. + /// + /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below). + pub spacing: Smart<Spacing>, + + /// The term list's children. + /// + /// When using the term list syntax, adjacent items are automatically + /// collected into term lists, even through constructs like for loops. + /// + /// ```example + /// #for (year, product) in ( + /// "1978": "TeX", + /// "1984": "LaTeX", + /// "2019": "Typst", + /// ) [/ #product: Born in #year.] + /// ``` + #[variadic] + pub children: Vec<TermItem>, +} + +impl Layout for TermsElem { + #[tracing::instrument(name = "TermsElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let separator = self.separator(styles); + let indent = self.indent(styles); + let hanging_indent = self.hanging_indent(styles); + let gutter = if self.tight(styles) { + ParElem::leading_in(styles).into() + } else { + self.spacing(styles) + .unwrap_or_else(|| BlockElem::below_in(styles).amount()) + }; + + let mut seq = vec![]; + for (i, child) in self.children().into_iter().enumerate() { + if i > 0 { + seq.push(VElem::new(gutter).with_weakness(1).pack()); + } + if !indent.is_zero() { + seq.push(HElem::new(indent.into()).pack()); + } + seq.push(child.term().strong()); + seq.push(separator.clone()); + seq.push(child.description()); + } + + Content::sequence(seq) + .styled(ParElem::set_hanging_indent(hanging_indent + indent)) + .layout(vt, styles, regions) + } +} + +/// A term list item. +/// +/// Display: Term List Item +/// Category: layout +#[element] +pub struct TermItem { + /// The term described by the list item. + #[required] + pub term: Content, + + /// The description of the term. + #[required] + pub description: Content, +} + +cast! { + TermItem, + array: Array => { + let mut iter = array.into_iter(); + let (term, description) = match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => (a.cast()?, b.cast()?), + _ => bail!("array must contain exactly two entries"), + }; + Self::new(term, description) + }, + v: Content => v.to::<Self>().cloned().ok_or("expected term item or array")?, +} diff --git a/crates/typst-library/src/layout/transform.rs b/crates/typst-library/src/layout/transform.rs new file mode 100644 index 00000000..a57a5edc --- /dev/null +++ b/crates/typst-library/src/layout/transform.rs @@ -0,0 +1,194 @@ +use typst::geom::Transform; + +use crate::prelude::*; + +/// Moves content without affecting layout. +/// +/// The `move` function allows you to move content while the layout still 'sees' +/// it at the original positions. Containers will still be sized as if the +/// content was not moved. +/// +/// ## Example { #example } +/// ```example +/// #rect(inset: 0pt, move( +/// dx: 6pt, dy: 6pt, +/// rect( +/// inset: 8pt, +/// fill: white, +/// stroke: black, +/// [Abra cadabra] +/// ) +/// )) +/// ``` +/// +/// Display: Move +/// Category: layout +#[element(Layout)] +pub struct MoveElem { + /// The horizontal displacement of the content. + pub dx: Rel<Length>, + + /// The vertical displacement of the content. + pub dy: Rel<Length>, + + /// The content to move. + #[required] + pub body: Content, +} + +impl Layout for MoveElem { + #[tracing::instrument(name = "MoveElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let mut frame = self.body().layout(vt, styles, pod)?.into_frame(); + let delta = Axes::new(self.dx(styles), self.dy(styles)).resolve(styles); + let delta = delta.zip(regions.base()).map(|(d, s)| d.relative_to(s)); + frame.translate(delta.to_point()); + Ok(Fragment::frame(frame)) + } +} + +/// Rotates content without affecting layout. +/// +/// Rotates an element by a given angle. The layout will act as if the element +/// was not rotated. +/// +/// ## Example { #example } +/// ```example +/// #stack( +/// dir: ltr, +/// spacing: 1fr, +/// ..range(16) +/// .map(i => rotate(24deg * i)[X]), +/// ) +/// ``` +/// +/// Display: Rotate +/// Category: layout +#[element(Layout)] +pub struct RotateElem { + /// The amount of rotation. + /// + /// ```example + /// #rotate(-1.571rad)[Space!] + /// ``` + /// + #[positional] + pub angle: Angle, + + /// The origin of the rotation. + /// + /// If, for instance, you wanted the bottom left corner of the rotated + /// element to stay aligned with the baseline, you would set it to `bottom + + /// left` instead. + /// + /// ```example + /// #set text(spacing: 8pt) + /// #let square = square.with(width: 8pt) + /// + /// #box(square()) + /// #box(rotate(30deg, origin: center, square())) + /// #box(rotate(30deg, origin: top + left, square())) + /// #box(rotate(30deg, origin: bottom + right, square())) + /// ``` + #[resolve] + #[fold] + #[default(Align::CENTER_HORIZON)] + pub origin: Axes<Option<GenAlign>>, + + /// The content to rotate. + #[required] + pub body: Content, +} + +impl Layout for RotateElem { + #[tracing::instrument(name = "RotateElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let mut frame = self.body().layout(vt, styles, pod)?.into_frame(); + let Axes { x, y } = + self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s)); + let ts = Transform::translate(x, y) + .pre_concat(Transform::rotate(self.angle(styles))) + .pre_concat(Transform::translate(-x, -y)); + frame.transform(ts); + Ok(Fragment::frame(frame)) + } +} + +/// Scales content without affecting layout. +/// +/// Lets you mirror content by specifying a negative scale on a single axis. +/// +/// ## Example { #example } +/// ```example +/// #set align(center) +/// #scale(x: -100%)[This is mirrored.] +/// ``` +/// +/// Display: Scale +/// Category: layout +#[element(Layout)] +pub struct ScaleElem { + /// The horizontal scaling factor. + /// + /// The body will be mirrored horizontally if the parameter is negative. + #[parse( + let all = args.find()?; + args.named("x")?.or(all) + )] + #[default(Ratio::one())] + pub x: Ratio, + + /// The vertical scaling factor. + /// + /// The body will be mirrored vertically if the parameter is negative. + #[parse(args.named("y")?.or(all))] + #[default(Ratio::one())] + pub y: Ratio, + + /// The origin of the transformation. + /// + /// ```example + /// A#box(scale(75%)[A])A \ + /// B#box(scale(75%, origin: bottom + left)[B])B + /// ``` + #[resolve] + #[fold] + #[default(Align::CENTER_HORIZON)] + pub origin: Axes<Option<GenAlign>>, + + /// The content to scale. + #[required] + pub body: Content, +} + +impl Layout for ScaleElem { + #[tracing::instrument(name = "ScaleElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let mut frame = self.body().layout(vt, styles, pod)?.into_frame(); + let Axes { x, y } = + self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s)); + let transform = Transform::translate(x, y) + .pre_concat(Transform::scale(self.x(styles), self.y(styles))) + .pre_concat(Transform::translate(-x, -y)); + frame.transform(transform); + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs new file mode 100644 index 00000000..0bd88501 --- /dev/null +++ b/crates/typst-library/src/lib.rs @@ -0,0 +1,141 @@ +//! Typst's standard library. + +#![allow(clippy::wildcard_in_or_patterns)] +#![allow(clippy::manual_range_contains)] +#![allow(clippy::comparison_chain)] + +pub mod compute; +pub mod layout; +pub mod math; +pub mod meta; +pub mod prelude; +pub mod shared; +pub mod symbols; +pub mod text; +pub mod visualize; + +use typst::diag::At; +use typst::eval::{LangItems, Library, Module, Scope}; +use typst::geom::Smart; +use typst::model::{Element, Styles}; + +use self::layout::LayoutRoot; + +/// Construct the standard library. +pub fn build() -> Library { + let math = math::module(); + let global = global(math.clone()); + Library { global, math, styles: styles(), items: items() } +} + +/// Construct the module with global definitions. +#[tracing::instrument(skip_all)] +fn global(math: Module) -> Module { + let mut global = Scope::deduplicating(); + + // Categories. + text::define(&mut global); + layout::define(&mut global); + visualize::define(&mut global); + meta::define(&mut global); + compute::define(&mut global); + symbols::define(&mut global); + global.define("math", math); + + Module::new("global").with_scope(global) +} + +/// Construct the standard style map. +fn styles() -> Styles { + Styles::new() +} + +/// Construct the standard lang item mapping. +fn items() -> LangItems { + LangItems { + layout: |world, content, styles| content.layout_root(world, styles), + em: text::TextElem::size_in, + dir: text::TextElem::dir_in, + space: || text::SpaceElem::new().pack(), + linebreak: || text::LinebreakElem::new().pack(), + text: |text| text::TextElem::new(text).pack(), + text_func: text::TextElem::func(), + text_str: |content| Some(content.to::<text::TextElem>()?.text()), + smart_quote: |double| text::SmartQuoteElem::new().with_double(double).pack(), + parbreak: || layout::ParbreakElem::new().pack(), + strong: |body| text::StrongElem::new(body).pack(), + emph: |body| text::EmphElem::new(body).pack(), + raw: |text, lang, block| { + let mut elem = text::RawElem::new(text).with_block(block); + if let Some(lang) = lang { + elem.push_lang(Some(lang)); + } + elem.pack() + }, + raw_languages: text::RawElem::languages, + link: |url| meta::LinkElem::from_url(url).pack(), + reference: |target, supplement| { + let mut elem = meta::RefElem::new(target); + if let Some(supplement) = supplement { + elem.push_supplement(Smart::Custom(Some(meta::Supplement::Content( + supplement, + )))); + } + elem.pack() + }, + bibliography_keys: meta::BibliographyElem::keys, + heading: |level, title| meta::HeadingElem::new(title).with_level(level).pack(), + heading_func: meta::HeadingElem::func(), + list_item: |body| layout::ListItem::new(body).pack(), + enum_item: |number, body| { + let mut elem = layout::EnumItem::new(body); + if let Some(number) = number { + elem.push_number(Some(number)); + } + elem.pack() + }, + term_item: |term, description| layout::TermItem::new(term, description).pack(), + equation: |body, block| math::EquationElem::new(body).with_block(block).pack(), + math_align_point: || math::AlignPointElem::new().pack(), + math_delimited: |open, body, close| math::LrElem::new(open + body + close).pack(), + math_attach: |base, t, b, tl, bl, tr, br| { + let mut elem = math::AttachElem::new(base); + if let Some(t) = t { + elem.push_t(Some(t)); + } + if let Some(b) = b { + elem.push_b(Some(b)); + } + if let Some(tl) = tl { + elem.push_tl(Some(tl)); + } + if let Some(bl) = bl { + elem.push_bl(Some(bl)); + } + if let Some(tr) = tr { + elem.push_tr(Some(tr)); + } + if let Some(br) = br { + elem.push_br(Some(br)); + } + elem.pack() + }, + math_accent: |base, accent| { + math::AccentElem::new(base, math::Accent::new(accent)).pack() + }, + math_frac: |num, denom| math::FracElem::new(num, denom).pack(), + math_root: |index, radicand| { + math::RootElem::new(radicand).with_index(index).pack() + }, + library_method: |vm, dynamic, method, args, span| { + if let Some(counter) = dynamic.downcast::<meta::Counter>().cloned() { + counter.call_method(vm, method, args, span) + } else if let Some(state) = dynamic.downcast::<meta::State>().cloned() { + state.call_method(vm, method, args, span) + } else { + Err(format!("type {} has no method `{method}`", dynamic.type_name())) + .at(span) + } + }, + } +} diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs new file mode 100644 index 00000000..d1bee198 --- /dev/null +++ b/crates/typst-library/src/math/accent.rs @@ -0,0 +1,139 @@ +use super::*; + +/// How much the accent can be shorter than the base. +const ACCENT_SHORT_FALL: Em = Em::new(0.5); + +/// Attaches an accent to a base. +/// +/// ## Example { #example } +/// ```example +/// $grave(a) = accent(a, `)$ \ +/// $arrow(a) = accent(a, arrow)$ \ +/// $tilde(a) = accent(a, \u{0303})$ +/// ``` +/// +/// Display: Accent +/// Category: math +#[element(LayoutMath)] +pub struct AccentElem { + /// The base to which the accent is applied. + /// May consist of multiple letters. + /// + /// ```example + /// $arrow(A B C)$ + /// ``` + #[required] + pub base: Content, + + /// The accent to apply to the base. + /// + /// Supported accents include: + /// + /// | Accent | Name | Codepoint | + /// | ------------- | --------------- | --------- | + /// | Grave | `grave` | <code>`</code> | + /// | Acute | `acute` | `´` | + /// | Circumflex | `hat` | `^` | + /// | Tilde | `tilde` | `~` | + /// | Macron | `macron` | `¯` | + /// | Breve | `breve` | `˘` | + /// | Dot | `dot` | `.` | + /// | Double dot | `dot.double` | `¨` | + /// | Triple dot | `dot.triple` | <code>⃛</code> | + /// | Quadruple dot | `dot.quad` | <code>⃜</code> | + /// | Diaeresis | `diaer` | `¨` | + /// | Circle | `circle` | `∘` | + /// | Double acute | `acute.double` | `˝` | + /// | Caron | `caron` | `ˇ` | + /// | Right arrow | `arrow`, `->` | `→` | + /// | Left arrow | `arrow.l`, `<-` | `←` | + #[required] + pub accent: Accent, +} + +impl LayoutMath for AccentElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + ctx.style(ctx.style.with_cramped(true)); + let base = ctx.layout_fragment(&self.base())?; + ctx.unstyle(); + + // Preserve class to preserve automatic spacing. + let base_class = base.class().unwrap_or(MathClass::Normal); + let base_attach = match &base { + MathFragment::Glyph(base) => { + attachment(ctx, base.id, base.italics_correction) + } + _ => (base.width() + base.italics_correction()) / 2.0, + }; + + // Forcing the accent to be at least as large as the base makes it too + // wide in many case. + let Accent(c) = self.accent(); + let glyph = GlyphFragment::new(ctx, c, self.span()); + let short_fall = ACCENT_SHORT_FALL.scaled(ctx); + let variant = glyph.stretch_horizontal(ctx, base.width(), short_fall); + let accent = variant.frame; + let accent_attach = match variant.id { + Some(id) => attachment(ctx, id, variant.italics_correction), + None => accent.width() / 2.0, + }; + + // Descent is negative because the accent's ink bottom is above the + // baseline. Therefore, the default gap is the accent's negated descent + // minus the accent base height. Only if the base is very small, we need + // a larger gap so that the accent doesn't move too low. + let accent_base_height = scaled!(ctx, accent_base_height); + let gap = -accent.descent() - base.height().min(accent_base_height); + let size = Size::new(base.width(), accent.height() + gap + base.height()); + let accent_pos = Point::with_x(base_attach - accent_attach); + let base_pos = Point::with_y(accent.height() + gap); + let base_ascent = base.ascent(); + let baseline = base_pos.y + base.ascent(); + + let mut frame = Frame::new(size); + frame.set_baseline(baseline); + frame.push_frame(accent_pos, accent); + frame.push_frame(base_pos, base.into_frame()); + ctx.push( + FrameFragment::new(ctx, frame) + .with_class(base_class) + .with_base_ascent(base_ascent), + ); + + Ok(()) + } +} + +/// The horizontal attachment position for the given glyph. +fn attachment(ctx: &MathContext, id: GlyphId, italics_correction: Abs) -> Abs { + ctx.table + .glyph_info + .and_then(|info| info.top_accent_attachments) + .and_then(|attachments| attachments.get(id)) + .map(|record| record.value.scaled(ctx)) + .unwrap_or_else(|| { + let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); + (advance.scaled(ctx) + italics_correction) / 2.0 + }) +} + +/// An accent character. +pub struct Accent(char); + +impl Accent { + /// Normalize a character into an accent. + pub fn new(c: char) -> Self { + Self(Symbol::combining_accent(c).unwrap_or(c)) + } +} + +cast! { + Accent, + self => self.0.into_value(), + v: char => Self::new(v), + v: Content => match v.to::<TextElem>() { + Some(elem) => Value::Str(elem.text().into()).cast()?, + None => bail!("expected text"), + }, +} diff --git a/crates/typst-library/src/math/align.rs b/crates/typst-library/src/math/align.rs new file mode 100644 index 00000000..aee89a89 --- /dev/null +++ b/crates/typst-library/src/math/align.rs @@ -0,0 +1,63 @@ +use super::*; + +/// A math alignment point: `&`, `&&`. +/// +/// Display: Alignment Point +/// Category: math +#[element(LayoutMath)] +pub struct AlignPointElem {} + +impl LayoutMath for AlignPointElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + ctx.push(MathFragment::Align); + Ok(()) + } +} + +pub(super) struct AlignmentResult { + pub points: Vec<Abs>, + pub width: Abs, +} + +/// Determine the position of the alignment points. +pub(super) fn alignments(rows: &[MathRow]) -> AlignmentResult { + let mut widths = Vec::<Abs>::new(); + + let mut pending_width = Abs::zero(); + for row in rows { + let mut width = Abs::zero(); + let mut alignment_index = 0; + + for fragment in row.iter() { + if matches!(fragment, MathFragment::Align) { + if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + width = Abs::zero(); + alignment_index += 1; + } else { + width += fragment.width(); + } + } + if widths.is_empty() { + pending_width.set_max(width); + } else if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + } + + 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), + points, + } +} diff --git a/crates/typst-library/src/math/attach.rs b/crates/typst-library/src/math/attach.rs new file mode 100644 index 00000000..fedeb908 --- /dev/null +++ b/crates/typst-library/src/math/attach.rs @@ -0,0 +1,411 @@ +use super::*; + +/// A base with optional attachments. +/// +/// ## Example { #example } +/// ```example +/// // With syntax. +/// $ sum_(i=0)^n a_i = 2^(1+i) $ +/// +/// // With function call. +/// $ attach( +/// Pi, t: alpha, b: beta, +/// tl: 1, tr: 2+3, bl: 4+5, br: 6, +/// ) $ +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax for attachments after the base: Use +/// the underscore (`_`) to indicate a subscript i.e. bottom attachment and the +/// hat (`^`) to indicate a superscript i.e. top attachment. +/// +/// Display: Attachment +/// Category: math +#[element(LayoutMath)] +pub struct AttachElem { + /// The base to which things are attached. + #[required] + pub base: Content, + + /// The top attachment, smartly positioned at top-right or above the base. + /// + /// You can wrap the base in `{limits()}` or `{scripts()}` to override the + /// smart positioning. + pub t: Option<Content>, + + /// The bottom attachment, smartly positioned at the bottom-right or below + /// the base. + /// + /// You can wrap the base in `{limits()}` or `{scripts()}` to override the + /// smart positioning. + pub b: Option<Content>, + + /// The top-left attachment (before the base). + pub tl: Option<Content>, + + /// The bottom-left attachment (before base). + pub bl: Option<Content>, + + /// The top-right attachment (after the base). + pub tr: Option<Content>, + + /// The bottom-right attachment (after the base). + pub br: Option<Content>, +} + +impl LayoutMath for AttachElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + type GetAttachment = fn(&AttachElem, styles: StyleChain) -> Option<Content>; + let layout_attachment = |ctx: &mut MathContext, getter: GetAttachment| { + getter(self, ctx.styles()) + .map(|elem| ctx.layout_fragment(&elem)) + .transpose() + }; + + let base = ctx.layout_fragment(&self.base())?; + + ctx.style(ctx.style.for_superscript()); + let tl = layout_attachment(ctx, Self::tl)?; + let tr = layout_attachment(ctx, Self::tr)?; + let t = layout_attachment(ctx, Self::t)?; + ctx.unstyle(); + + ctx.style(ctx.style.for_subscript()); + let bl = layout_attachment(ctx, Self::bl)?; + let br = layout_attachment(ctx, Self::br)?; + let b = layout_attachment(ctx, Self::b)?; + ctx.unstyle(); + + let limits = base.limits().active(ctx); + let (t, tr) = if limits || tr.is_some() { (t, tr) } else { (None, t) }; + let (b, br) = if limits || br.is_some() { (b, br) } else { (None, b) }; + layout_attachments(ctx, base, [tl, t, tr, bl, b, br]) + } +} + +/// Forces a base to display attachments as scripts. +/// +/// ## Example { #example } +/// ```example +/// $ scripts(sum)_1^2 != sum_1^2 $ +/// ``` +/// +/// Display: Scripts +/// Category: math +#[element(LayoutMath)] +pub struct ScriptsElem { + /// The base to attach the scripts to. + #[required] + pub body: Content, +} + +impl LayoutMath for ScriptsElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut fragment = ctx.layout_fragment(&self.body())?; + fragment.set_limits(Limits::Never); + ctx.push(fragment); + Ok(()) + } +} + +/// Forces a base to display attachments as limits. +/// +/// ## Example { #example } +/// ```example +/// $ limits(A)_1^2 != A_1^2 $ +/// ``` +/// +/// Display: Limits +/// Category: math +#[element(LayoutMath)] +pub struct LimitsElem { + /// The base to attach the limits to. + #[required] + pub body: Content, + + /// Whether to also force limits in inline equations. + /// + /// When applying limits globally (e.g., through a show rule), it is + /// typically a good idea to disable this. + #[default(true)] + pub inline: bool, +} + +impl LayoutMath for LimitsElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut fragment = ctx.layout_fragment(&self.body())?; + fragment.set_limits(if self.inline(ctx.styles()) { + Limits::Always + } else { + Limits::Display + }); + ctx.push(fragment); + Ok(()) + } +} + +/// Describes in which situation a frame should use limits for attachments. +#[derive(Debug, Copy, Clone)] +pub enum Limits { + /// Always scripts. + Never, + /// Display limits only in `display` math. + Display, + /// Always limits. + Always, +} + +impl Limits { + /// The default limit configuration if the given character is the base. + pub fn for_char(c: char) -> Self { + if Self::DEFAULT_TO_LIMITS.contains(&c) { + Limits::Display + } else { + Limits::Never + } + } + + /// Whether limits should be displayed in this context + pub fn active(&self, ctx: &MathContext) -> bool { + match self { + Self::Always => true, + Self::Display => ctx.style.size == MathSize::Display, + Self::Never => false, + } + } + + /// Unicode codepoints that should show attachments as limits in display + /// mode. + #[rustfmt::skip] + const DEFAULT_TO_LIMITS: &[char] = &[ + /* ∏ */ '\u{220F}', /* ∐ */ '\u{2210}', /* ∑ */ '\u{2211}', + /* ⋀ */ '\u{22C0}', /* ⋁ */ '\u{22C1}', + /* ⋂ */ '\u{22C2}', /* ⋃ */ '\u{22C3}', + /* ⨀ */ '\u{2A00}', /* ⨁ */ '\u{2A01}', /* ⨂ */ '\u{2A02}', + /* ⨃ */ '\u{2A03}', /* ⨄ */ '\u{2A04}', + /* ⨅ */ '\u{2A05}', /* ⨆ */ '\u{2A06}', + ]; +} + +macro_rules! measure { + ($e: ident, $attr: ident) => { + $e.as_ref().map(|e| e.$attr()).unwrap_or_default() + }; +} + +/// Layout the attachments. +fn layout_attachments( + ctx: &mut MathContext, + base: MathFragment, + [tl, t, tr, bl, b, br]: [Option<MathFragment>; 6], +) -> SourceResult<()> { + let (shift_up, shift_down) = + compute_shifts_up_and_down(ctx, &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().unwrap_or(MathClass::Normal); + + let ascent = base_ascent + .max(shift_up + measure!(tr, ascent)) + .max(shift_up + measure!(tl, ascent)) + .max(shift_up + measure!(t, height)); + + let 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_max_width = + (sup_delta + measure!(tr, width)).max(sub_delta + measure!(br, width)); + + let (center_frame, base_offset) = attach_top_and_bottom(ctx, base, t, b); + let base_pos = + Point::new(sup_delta + pre_width_max, ascent - base_ascent - base_offset); + if [&tl, &bl, &tr, &br].iter().all(|&e| e.is_none()) { + ctx.push(FrameFragment::new(ctx, center_frame).with_class(base_class)); + return Ok(()); + } + + let mut frame = Frame::new(Size::new( + pre_width_max + base_width + post_max_width + scaled!(ctx, space_after_script), + ascent + descent, + )); + frame.set_baseline(ascent); + frame.push_frame(base_pos, center_frame); + + 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()); + } + + 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()); + } + + ctx.push(FrameFragment::new(ctx, frame).with_class(base_class)); + + Ok(()) +} + +fn attach_top_and_bottom( + ctx: &mut MathContext, + base: MathFragment, + t: Option<MathFragment>, + b: Option<MathFragment>, +) -> (Frame, Abs) { + let upper_gap_min = scaled!(ctx, upper_limit_gap_min); + let upper_rise_min = scaled!(ctx, upper_limit_baseline_rise_min); + let lower_gap_min = scaled!(ctx, lower_limit_gap_min); + let lower_drop_min = scaled!(ctx, lower_limit_baseline_drop_min); + + let mut base_offset = Abs::zero(); + let mut width = base.width(); + let mut height = base.height(); + + 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::new(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) +} + +fn compute_shifts_up_and_down( + ctx: &MathContext, + base: &MathFragment, + [tl, tr, bl, br]: [&Option<MathFragment>; 4], +) -> (Abs, Abs) { + let sup_shift_up = if ctx.style.cramped { + scaled!(ctx, superscript_shift_up_cramped) + } else { + scaled!(ctx, superscript_shift_up) + }; + + let sup_bottom_min = scaled!(ctx, superscript_bottom_min); + let sup_bottom_max_with_sub = scaled!(ctx, superscript_bottom_max_with_subscript); + let sup_drop_max = scaled!(ctx, superscript_baseline_drop_max); + let gap_min = scaled!(ctx, sub_superscript_gap_min); + let sub_shift_down = scaled!(ctx, subscript_shift_down); + let sub_top_max = scaled!(ctx, subscript_top_max); + let sub_drop_min = scaled!(ctx, subscript_baseline_drop_min); + + let mut shift_up = Abs::zero(); + let mut shift_down = Abs::zero(); + let is_char_box = is_character_box(base); + + if tl.is_some() || tr.is_some() { + let ascent = match &base { + MathFragment::Frame(frame) => frame.base_ascent, + _ => base.ascent(), + }; + shift_up = shift_up + .max(sup_shift_up) + .max(if is_char_box { Abs::zero() } else { ascent - sup_drop_max }) + .max(sup_bottom_min + measure!(tl, descent)) + .max(sup_bottom_min + measure!(tr, descent)); + } + + if bl.is_some() || br.is_some() { + shift_down = shift_down + .max(sub_shift_down) + .max(if is_char_box { Abs::zero() } else { base.descent() + sub_drop_min }) + .max(measure!(bl, ascent) - sub_top_max) + .max(measure!(br, ascent) - sub_top_max); + } + + for (sup, sub) in [(tl, bl), (tr, br)] { + if let (Some(sup), Some(sub)) = (&sup, &sub) { + let sup_bottom = shift_up - sup.descent(); + let sub_top = sub.ascent() - shift_down; + let gap = sup_bottom - sub_top; + if gap >= gap_min { + continue; + } + + let increase = gap_min - gap; + let sup_only = + (sup_bottom_max_with_sub - sup_bottom).clamp(Abs::zero(), increase); + let rest = (increase - sup_only) / 2.0; + shift_up += sup_only + rest; + shift_down += rest; + } + } + + (shift_up, shift_down) +} + +/// Whether the fragment consists of a single character or atomic piece of text. +fn is_character_box(fragment: &MathFragment) -> bool { + match fragment { + MathFragment::Glyph(_) | MathFragment::Variant(_) => { + fragment.class() != Some(MathClass::Large) + } + MathFragment::Frame(fragment) => is_atomic_text_frame(&fragment.frame), + _ => false, + } +} + +/// Handles e.g. "sin", "log", "exp", "CustomOperator". +fn is_atomic_text_frame(frame: &Frame) -> bool { + // Meta information isn't visible or renderable, so we exclude it. + let mut iter = frame + .items() + .map(|(_, item)| item) + .filter(|item| !matches!(item, FrameItem::Meta(_, _))); + matches!(iter.next(), Some(FrameItem::Text(_))) && iter.next().is_none() +} diff --git a/crates/typst-library/src/math/cancel.rs b/crates/typst-library/src/math/cancel.rs new file mode 100644 index 00000000..f576a727 --- /dev/null +++ b/crates/typst-library/src/math/cancel.rs @@ -0,0 +1,187 @@ +use super::*; + +/// Displays a diagonal line over a part of an equation. +/// +/// This is commonly used to show the elimination of a term. +/// +/// ## Example { #example } +/// ```example +/// >>> #set page(width: 140pt) +/// Here, we can simplify: +/// $ (a dot b dot cancel(x)) / +/// cancel(x) $ +/// ``` +/// +/// Display: Cancel +/// Category: math +#[element(LayoutMath)] +pub struct CancelElem { + /// The content over which the line should be placed. + #[required] + pub body: Content, + + /// The length of the line, relative to the length of the diagonal spanning + /// the whole element being "cancelled". A value of `{100%}` would then have + /// the line span precisely the element's diagonal. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ a + cancel(x, length: #200%) + /// - cancel(x, length: #200%) $ + /// ``` + #[default(Rel::new(Ratio::one(), Abs::pt(3.0).into()))] + pub length: Rel<Length>, + + /// If the cancel line should be inverted (pointing to the top left instead + /// of top right). + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ (a cancel((b + c), inverted: #true)) / + /// cancel(b + c, inverted: #true) $ + /// ``` + #[default(false)] + pub inverted: bool, + + /// If two opposing cancel lines should be drawn, forming a cross over the + /// element. Overrides `inverted`. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ cancel(Pi, cross: #true) $ + /// ``` + #[default(false)] + pub cross: bool, + + /// How to rotate the cancel line. See the [line's + /// documentation]($func/line.angle) for more details. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ cancel(Pi, rotation: #30deg) $ + /// ``` + #[default(Angle::zero())] + pub rotation: Angle, + + /// How to stroke the cancel line. See the + /// [line's documentation]($func/line.stroke) for more details. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ cancel( + /// sum x, + /// stroke: #( + /// paint: red, + /// thickness: 1.5pt, + /// dash: "dashed", + /// ), + /// ) $ + /// ``` + #[resolve] + #[fold] + #[default(PartialStroke { + // Default stroke has 0.5pt for better visuals. + thickness: Smart::Custom(Abs::pt(0.5)), + ..Default::default() + })] + pub stroke: PartialStroke, +} + +impl LayoutMath for CancelElem { + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let body = ctx.layout_fragment(&self.body())?; + // Use the same math class as the body, in order to preserve automatic spacing around it. + let body_class = body.class().unwrap_or(MathClass::Special); + let mut body = body.into_frame(); + + let styles = ctx.styles(); + let body_size = body.size(); + let span = self.span(); + let length = self.length(styles).resolve(styles); + + let stroke = self.stroke(styles).unwrap_or(Stroke { + paint: TextElem::fill_in(styles), + ..Default::default() + }); + + let invert = self.inverted(styles); + let cross = self.cross(styles); + let angle = self.rotation(styles); + + let invert_first_line = !cross && invert; + let first_line = draw_cancel_line( + length, + stroke.clone(), + invert_first_line, + angle, + body_size, + span, + ); + + // The origin of our line is the very middle of the element. + let center = body_size.to_point() / 2.0; + body.push_frame(center, first_line); + + if cross { + // Draw the second line. + let second_line = + draw_cancel_line(length, stroke, true, angle, body_size, span); + + body.push_frame(center, second_line); + } + + ctx.push(FrameFragment::new(ctx, body).with_class(body_class)); + + Ok(()) + } +} + +/// Draws a cancel line. +fn draw_cancel_line( + length: Rel<Abs>, + stroke: Stroke, + invert: bool, + angle: Angle, + body_size: Size, + span: Span, +) -> Frame { + // B + // /| + // diagonal / | height + // / | + // / | + // O ---- + // width + let diagonal = body_size.to_point().hypot(); + let length = length.relative_to(diagonal); + let (width, height) = (body_size.x, body_size.y); + let mid = body_size / 2.0; + + // Scale the amount needed such that the cancel line has the given 'length' + // (reference length, or 100%, is the whole diagonal). + // Scales from the center. + let scale = length.to_raw() / diagonal.to_raw(); + + // invert horizontally if 'invert' was given + let scale_x = scale * if invert { -1.0 } else { 1.0 }; + let scale_y = scale; + let scales = Axes::new(scale_x, scale_y); + + // Draw a line from bottom left to top right of the given element, where the + // origin represents the very middle of that element, that is, a line from + // (-width / 2, height / 2) with length components (width, -height) (sign is + // inverted in the y-axis). After applying the scale, the line will have the + // correct length and orientation (inverted if needed). + let start = Axes::new(-mid.x, mid.y).zip(scales).map(|(l, s)| l * s); + let delta = Axes::new(width, -height).zip(scales).map(|(l, s)| l * s); + + let mut frame = Frame::new(body_size); + frame.push( + start.to_point(), + FrameItem::Shape(Geometry::Line(delta.to_point()).stroked(stroke), span), + ); + + // Having the middle of the line at the origin is convenient here. + frame.transform(Transform::rotate(angle)); + frame +} diff --git a/crates/typst-library/src/math/ctx.rs b/crates/typst-library/src/math/ctx.rs new file mode 100644 index 00000000..a1dc6cf4 --- /dev/null +++ b/crates/typst-library/src/math/ctx.rs @@ -0,0 +1,268 @@ +use ttf_parser::math::MathValue; +use typst::font::{FontStyle, FontWeight}; +use typst::model::realize; +use unicode_segmentation::UnicodeSegmentation; + +use super::*; + +macro_rules! scaled { + ($ctx:expr, text: $text:ident, display: $display:ident $(,)?) => { + match $ctx.style.size { + MathSize::Display => scaled!($ctx, $display), + _ => scaled!($ctx, $text), + } + }; + ($ctx:expr, $name:ident) => { + $ctx.constants.$name().scaled($ctx) + }; +} + +macro_rules! percent { + ($ctx:expr, $name:ident) => { + $ctx.constants.$name() as f64 / 100.0 + }; +} + +/// The context for math layout. +pub struct MathContext<'a, 'b, 'v> { + pub vt: &'v mut Vt<'b>, + pub regions: Regions<'static>, + pub font: &'a Font, + pub ttf: &'a ttf_parser::Face<'a>, + pub table: ttf_parser::math::Table<'a>, + pub constants: ttf_parser::math::Constants<'a>, + pub ssty_table: Option<ttf_parser::gsub::AlternateSubstitution<'a>>, + pub space_width: Em, + pub fragments: Vec<MathFragment>, + pub local: Styles, + pub style: MathStyle, + pub size: Abs, + outer: StyleChain<'a>, + style_stack: Vec<(MathStyle, Abs)>, +} + +impl<'a, 'b, 'v> MathContext<'a, 'b, 'v> { + pub fn new( + vt: &'v mut Vt<'b>, + styles: StyleChain<'a>, + regions: Regions, + font: &'a Font, + block: bool, + ) -> Self { + let table = font.ttf().tables().math.unwrap(); + let constants = table.constants.unwrap(); + + let ssty_table = font + .ttf() + .tables() + .gsub + .and_then(|gsub| { + gsub.features + .find(ttf_parser::Tag::from_bytes(b"ssty")) + .and_then(|feature| feature.lookup_indices.get(0)) + .and_then(|index| gsub.lookups.get(index)) + }) + .and_then(|ssty| { + ssty.subtables.get::<ttf_parser::gsub::SubstitutionSubtable>(0) + }) + .and_then(|ssty| match ssty { + ttf_parser::gsub::SubstitutionSubtable::Alternate(alt_glyphs) => { + Some(alt_glyphs) + } + _ => None, + }); + + let size = TextElem::size_in(styles); + let ttf = font.ttf(); + let space_width = ttf + .glyph_index(' ') + .and_then(|id| ttf.glyph_hor_advance(id)) + .map(|advance| font.to_em(advance)) + .unwrap_or(THICK); + + let variant = variant(styles); + Self { + vt, + regions: Regions::one(regions.base(), Axes::splat(false)), + font, + ttf: font.ttf(), + table, + constants, + ssty_table, + space_width, + fragments: vec![], + local: Styles::new(), + style: MathStyle { + variant: MathVariant::Serif, + size: if block { MathSize::Display } else { MathSize::Text }, + cramped: false, + bold: variant.weight >= FontWeight::BOLD, + italic: match variant.style { + FontStyle::Normal => Smart::Auto, + FontStyle::Italic | FontStyle::Oblique => Smart::Custom(true), + }, + }, + size, + outer: styles, + style_stack: vec![], + } + } + + pub fn push(&mut self, fragment: impl Into<MathFragment>) { + self.fragments.push(fragment.into()); + } + + pub fn extend(&mut self, fragments: Vec<MathFragment>) { + self.fragments.extend(fragments); + } + + pub fn layout_fragment( + &mut self, + elem: &dyn LayoutMath, + ) -> SourceResult<MathFragment> { + let row = self.layout_fragments(elem)?; + Ok(MathRow::new(row).into_fragment(self)) + } + + pub fn layout_fragments( + &mut self, + elem: &dyn LayoutMath, + ) -> SourceResult<Vec<MathFragment>> { + let prev = std::mem::take(&mut self.fragments); + elem.layout_math(self)?; + Ok(std::mem::replace(&mut self.fragments, prev)) + } + + pub fn layout_row(&mut self, elem: &dyn LayoutMath) -> SourceResult<MathRow> { + let fragments = self.layout_fragments(elem)?; + Ok(MathRow::new(fragments)) + } + + pub fn layout_frame(&mut self, elem: &dyn LayoutMath) -> SourceResult<Frame> { + Ok(self.layout_fragment(elem)?.into_frame()) + } + + pub fn layout_content(&mut self, content: &Content) -> SourceResult<Frame> { + Ok(content + .layout(self.vt, self.outer.chain(&self.local), self.regions)? + .into_frame()) + } + + pub fn layout_text(&mut self, elem: &TextElem) -> SourceResult<MathFragment> { + let text = elem.text(); + let span = elem.span(); + let mut chars = text.chars(); + let fragment = if let Some(mut glyph) = chars + .next() + .filter(|_| chars.next().is_none()) + .map(|c| self.style.styled_char(c)) + .and_then(|c| GlyphFragment::try_new(self, c, span)) + { + // A single letter that is available in the math font. + match self.style.size { + MathSize::Display => { + if glyph.class == Some(MathClass::Large) { + let height = scaled!(self, display_operator_min_height); + glyph.stretch_vertical(self, height, Abs::zero()).into() + } else { + glyph.into() + } + } + MathSize::Script => { + glyph.make_scriptsize(self); + glyph.into() + } + MathSize::ScriptScript => { + glyph.make_scriptscriptsize(self); + glyph.into() + } + _ => glyph.into(), + } + } else if text.chars().all(|c| c.is_ascii_digit()) { + // Numbers aren't that difficult. + let mut fragments = vec![]; + for c in text.chars() { + let c = self.style.styled_char(c); + fragments.push(GlyphFragment::new(self, c, span).into()); + } + let frame = MathRow::new(fragments).into_frame(self); + FrameFragment::new(self, frame).into() + } else { + // Anything else is handled by Typst's standard text layout. + let spaced = text.graphemes(true).nth(1).is_some(); + let mut style = self.style; + if self.style.italic == Smart::Auto { + style = style.with_italic(false); + } + let text: EcoString = text.chars().map(|c| style.styled_char(c)).collect(); + let frame = self.layout_content(&TextElem::packed(text).spanned(span))?; + FrameFragment::new(self, frame) + .with_class(MathClass::Alphabetic) + .with_spaced(spaced) + .into() + }; + Ok(fragment) + } + + pub fn styles(&self) -> StyleChain { + self.outer.chain(&self.local) + } + + pub fn realize(&mut self, content: &Content) -> SourceResult<Option<Content>> { + realize(self.vt, content, self.outer.chain(&self.local)) + } + + pub fn style(&mut self, style: MathStyle) { + self.style_stack.push((self.style, self.size)); + let base_size = TextElem::size_in(self.styles()) / self.style.size.factor(self); + self.size = base_size * style.size.factor(self); + self.local.set(TextElem::set_size(TextSize(self.size.into()))); + self.local + .set(TextElem::set_style(if style.italic == Smart::Custom(true) { + FontStyle::Italic + } else { + FontStyle::Normal + })); + self.local.set(TextElem::set_weight(if style.bold { + FontWeight::BOLD + } else { + FontWeight::REGULAR + })); + self.style = style; + } + + pub fn unstyle(&mut self) { + (self.style, self.size) = self.style_stack.pop().unwrap(); + self.local.unset(); + self.local.unset(); + self.local.unset(); + } +} + +pub(super) trait Scaled { + fn scaled(self, ctx: &MathContext) -> Abs; +} + +impl Scaled for i16 { + fn scaled(self, ctx: &MathContext) -> Abs { + ctx.font.to_em(self).scaled(ctx) + } +} + +impl Scaled for u16 { + fn scaled(self, ctx: &MathContext) -> Abs { + ctx.font.to_em(self).scaled(ctx) + } +} + +impl Scaled for Em { + fn scaled(self, ctx: &MathContext) -> Abs { + self.at(ctx.size) + } +} + +impl Scaled for MathValue<'_> { + fn scaled(self, ctx: &MathContext) -> Abs { + self.value.scaled(ctx) + } +} diff --git a/crates/typst-library/src/math/delimited.rs b/crates/typst-library/src/math/delimited.rs new file mode 100644 index 00000000..99cd6c33 --- /dev/null +++ b/crates/typst-library/src/math/delimited.rs @@ -0,0 +1,200 @@ +use super::*; + +/// How much less high scaled delimiters can be than what they wrap. +pub(super) const DELIM_SHORT_FALL: Em = Em::new(0.1); + +/// Scales delimiters. +/// +/// While matched delimiters scale by default, this can be used to scale +/// unmatched delimiters and to control the delimiter scaling more precisely. +/// +/// ## Example { #example } +/// ```example +/// $ lr(]a, b/2]) $ +/// $ lr(]sum_(x=1)^n] x, size: #50%) $ +/// ``` +/// +/// Display: Left/Right +/// Category: math +#[element(LayoutMath)] +pub struct LrElem { + /// The size of the brackets, relative to the height of the wrapped content. + pub size: Smart<Rel<Length>>, + + /// The delimited content, including the delimiters. + #[required] + #[parse( + let mut body = Content::empty(); + for (i, arg) in args.all::<Content>()?.into_iter().enumerate() { + if i > 0 { + body += TextElem::packed(','); + } + body += arg; + } + body + )] + pub body: Content, +} + +impl LayoutMath for LrElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut body = self.body(); + if let Some(elem) = body.to::<LrElem>() { + if elem.size(ctx.styles()).is_auto() { + body = elem.body(); + } + } + + let mut fragments = ctx.layout_fragments(&body)?; + let axis = scaled!(ctx, axis_height); + let max_extent = fragments + .iter() + .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis)) + .max() + .unwrap_or_default(); + + let height = self + .size(ctx.styles()) + .unwrap_or(Rel::one()) + .resolve(ctx.styles()) + .relative_to(2.0 * max_extent); + + match fragments.as_mut_slice() { + [one] => scale(ctx, one, height, None), + [first, .., last] => { + scale(ctx, first, height, Some(MathClass::Opening)); + scale(ctx, last, height, Some(MathClass::Closing)); + } + _ => {} + } + + ctx.extend(fragments); + + Ok(()) + } +} + +/// Scale a math fragment to a height. +fn scale( + ctx: &mut MathContext, + fragment: &mut MathFragment, + height: Abs, + apply: Option<MathClass>, +) { + if matches!( + fragment.class(), + Some(MathClass::Opening | MathClass::Closing | MathClass::Fence) + ) { + let glyph = match fragment { + MathFragment::Glyph(glyph) => glyph.clone(), + MathFragment::Variant(variant) => { + GlyphFragment::new(ctx, variant.c, variant.span) + } + _ => return, + }; + + let short_fall = DELIM_SHORT_FALL.scaled(ctx); + *fragment = + MathFragment::Variant(glyph.stretch_vertical(ctx, height, short_fall)); + + if let Some(class) = apply { + fragment.set_class(class); + } + } +} + +/// Floors an expression. +/// +/// ## Example { #example } +/// ```example +/// $ floor(x/2) $ +/// ``` +/// +/// Display: Floor +/// Category: math +#[func] +pub fn floor( + /// The expression to floor. + body: Content, +) -> Content { + delimited(body, '⌊', '⌋') +} + +/// Ceils an expression. +/// +/// ## Example { #example } +/// ```example +/// $ ceil(x/2) $ +/// ``` +/// +/// Display: Ceil +/// Category: math +#[func] +pub fn ceil( + /// The expression to ceil. + body: Content, +) -> Content { + delimited(body, '⌈', '⌉') +} + +/// Rounds an expression. +/// +/// ## Example { #example } +/// ```example +/// $ round(x/2) $ +/// ``` +/// +/// Display: Round +/// Category: math +#[func] +pub fn round( + /// The expression to round. + body: Content, +) -> Content { + delimited(body, '⌊', '⌉') +} + +/// Takes the absolute value of an expression. +/// +/// ## Example { #example } +/// ```example +/// $ abs(x/2) $ +/// ``` +/// +/// +/// Display: Abs +/// Category: math +#[func] +pub fn abs( + /// The expression to take the absolute value of. + body: Content, +) -> Content { + delimited(body, '|', '|') +} + +/// Takes the norm of an expression. +/// +/// ## Example { #example } +/// ```example +/// $ norm(x/2) $ +/// ``` +/// +/// Display: Norm +/// Category: math +#[func] +pub fn norm( + /// The expression to take the norm of. + body: Content, +) -> Content { + delimited(body, '‖', '‖') +} + +fn delimited(body: Content, left: char, right: char) -> Content { + LrElem::new(Content::sequence([ + TextElem::packed(left), + body, + TextElem::packed(right), + ])) + .pack() +} diff --git a/crates/typst-library/src/math/frac.rs b/crates/typst-library/src/math/frac.rs new file mode 100644 index 00000000..0e1f78cc --- /dev/null +++ b/crates/typst-library/src/math/frac.rs @@ -0,0 +1,148 @@ +use super::*; + +const FRAC_AROUND: Em = Em::new(0.1); + +/// A mathematical fraction. +/// +/// ## Example { #example } +/// ```example +/// $ 1/2 < (x+1)/2 $ +/// $ ((x+1)) / 2 = frac(a, b) $ +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: Use a slash to turn neighbouring +/// expressions into a fraction. Multiple atoms can be grouped into a single +/// expression using round grouping parenthesis. Such parentheses are removed +/// from the output, but you can nest multiple to force them. +/// +/// Display: Fraction +/// Category: math +#[element(LayoutMath)] +pub struct FracElem { + /// The fraction's numerator. + #[required] + pub num: Content, + + /// The fraction's denominator. + #[required] + pub denom: Content, +} + +impl LayoutMath for FracElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout(ctx, &self.num(), &self.denom(), false, self.span()) + } +} + +/// A binomial expression. +/// +/// ## Example { #example } +/// ```example +/// $ binom(n, k) $ +/// ``` +/// +/// Display: Binomial +/// Category: math +#[element(LayoutMath)] +pub struct BinomElem { + /// The binomial's upper index. + #[required] + pub upper: Content, + + /// The binomial's lower index. + #[required] + pub lower: Content, +} + +impl LayoutMath for BinomElem { + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout(ctx, &self.upper(), &self.lower(), true, self.span()) + } +} + +/// Layout a fraction or binomial. +fn layout( + ctx: &mut MathContext, + num: &Content, + denom: &Content, + binom: bool, + span: Span, +) -> SourceResult<()> { + let short_fall = DELIM_SHORT_FALL.scaled(ctx); + let axis = scaled!(ctx, axis_height); + let thickness = scaled!(ctx, fraction_rule_thickness); + let shift_up = scaled!( + ctx, + text: fraction_numerator_shift_up, + display: fraction_numerator_display_style_shift_up, + ); + let shift_down = scaled!( + ctx, + text: fraction_denominator_shift_down, + display: fraction_denominator_display_style_shift_down, + ); + let num_min = scaled!( + ctx, + text: fraction_numerator_gap_min, + display: fraction_num_display_style_gap_min, + ); + let denom_min = scaled!( + ctx, + text: fraction_denominator_gap_min, + display: fraction_denom_display_style_gap_min, + ); + + ctx.style(ctx.style.for_numerator()); + let num = ctx.layout_frame(num)?; + ctx.unstyle(); + + ctx.style(ctx.style.for_denominator()); + let denom = ctx.layout_frame(denom)?; + ctx.unstyle(); + + let around = FRAC_AROUND.scaled(ctx); + let num_gap = (shift_up - axis - num.descent()).max(num_min + thickness / 2.0); + let denom_gap = (shift_down + axis - denom.ascent()).max(denom_min + thickness / 2.0); + + let line_width = num.width().max(denom.width()); + let width = line_width + 2.0 * around; + let height = num.height() + num_gap + thickness + denom_gap + denom.height(); + let size = Size::new(width, height); + let num_pos = Point::with_x((width - num.width()) / 2.0); + let line_pos = + Point::new((width - line_width) / 2.0, num.height() + num_gap + thickness / 2.0); + let denom_pos = Point::new((width - denom.width()) / 2.0, height - denom.height()); + let baseline = line_pos.y + axis; + + let mut frame = Frame::new(size); + frame.set_baseline(baseline); + frame.push_frame(num_pos, num); + frame.push_frame(denom_pos, denom); + + if binom { + ctx.push( + GlyphFragment::new(ctx, '(', span).stretch_vertical(ctx, height, short_fall), + ); + ctx.push(FrameFragment::new(ctx, frame)); + ctx.push( + GlyphFragment::new(ctx, ')', span).stretch_vertical(ctx, height, short_fall), + ); + } else { + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(line_width)).stroked(Stroke { + paint: TextElem::fill_in(ctx.styles()), + thickness, + ..Stroke::default() + }), + span, + ), + ); + ctx.push(FrameFragment::new(ctx, frame)); + } + + Ok(()) +} diff --git a/crates/typst-library/src/math/fragment.rs b/crates/typst-library/src/math/fragment.rs new file mode 100644 index 00000000..139ce07b --- /dev/null +++ b/crates/typst-library/src/math/fragment.rs @@ -0,0 +1,414 @@ +use super::*; +use ttf_parser::gsub::AlternateSet; + +#[derive(Debug, Clone)] +pub enum MathFragment { + Glyph(GlyphFragment), + Variant(VariantFragment), + Frame(FrameFragment), + Spacing(Abs), + Space(Abs), + Linebreak, + Align, +} + +impl MathFragment { + pub fn size(&self) -> Size { + Size::new(self.width(), self.height()) + } + + pub fn width(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.width, + Self::Variant(variant) => variant.frame.width(), + Self::Frame(fragment) => fragment.frame.width(), + Self::Spacing(amount) => *amount, + Self::Space(amount) => *amount, + _ => Abs::zero(), + } + } + + pub fn height(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.height(), + Self::Variant(variant) => variant.frame.height(), + Self::Frame(fragment) => fragment.frame.height(), + _ => Abs::zero(), + } + } + + pub fn ascent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.ascent, + Self::Variant(variant) => variant.frame.ascent(), + Self::Frame(fragment) => fragment.frame.baseline(), + _ => Abs::zero(), + } + } + + pub fn descent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.descent, + Self::Variant(variant) => variant.frame.descent(), + Self::Frame(fragment) => fragment.frame.descent(), + _ => Abs::zero(), + } + } + + pub fn class(&self) -> Option<MathClass> { + match self { + Self::Glyph(glyph) => glyph.class, + Self::Variant(variant) => variant.class, + Self::Frame(fragment) => Some(fragment.class), + _ => None, + } + } + + pub fn style(&self) -> Option<MathStyle> { + match self { + Self::Glyph(glyph) => Some(glyph.style), + Self::Variant(variant) => Some(variant.style), + Self::Frame(fragment) => Some(fragment.style), + _ => None, + } + } + + pub fn font_size(&self) -> Option<Abs> { + match self { + Self::Glyph(glyph) => Some(glyph.font_size), + Self::Variant(variant) => Some(variant.font_size), + Self::Frame(fragment) => Some(fragment.font_size), + _ => None, + } + } + + pub fn set_class(&mut self, class: MathClass) { + match self { + Self::Glyph(glyph) => glyph.class = Some(class), + Self::Variant(variant) => variant.class = Some(class), + Self::Frame(fragment) => fragment.class = class, + _ => {} + } + } + + pub fn set_limits(&mut self, limits: Limits) { + match self { + Self::Glyph(glyph) => glyph.limits = limits, + Self::Variant(variant) => variant.limits = limits, + Self::Frame(fragment) => fragment.limits = limits, + _ => {} + } + } + + pub fn is_spaced(&self) -> bool { + match self { + MathFragment::Frame(frame) => frame.spaced, + _ => self.class() == Some(MathClass::Fence), + } + } + + pub fn italics_correction(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.italics_correction, + Self::Variant(variant) => variant.italics_correction, + _ => Abs::zero(), + } + } + + pub fn into_frame(self) -> Frame { + match self { + Self::Glyph(glyph) => glyph.into_frame(), + Self::Variant(variant) => variant.frame, + Self::Frame(fragment) => fragment.frame, + _ => Frame::new(self.size()), + } + } + + pub fn limits(&self) -> Limits { + match self { + MathFragment::Glyph(glyph) => glyph.limits, + MathFragment::Variant(variant) => variant.limits, + MathFragment::Frame(fragment) => fragment.limits, + _ => Limits::Never, + } + } +} + +impl From<GlyphFragment> for MathFragment { + fn from(glyph: GlyphFragment) -> Self { + Self::Glyph(glyph) + } +} + +impl From<VariantFragment> for MathFragment { + fn from(variant: VariantFragment) -> Self { + Self::Variant(variant) + } +} + +impl From<FrameFragment> for MathFragment { + fn from(fragment: FrameFragment) -> Self { + Self::Frame(fragment) + } +} + +#[derive(Clone)] +pub struct GlyphFragment { + pub id: GlyphId, + pub c: char, + pub font: Font, + pub lang: Lang, + pub fill: Paint, + pub width: Abs, + pub ascent: Abs, + pub descent: Abs, + pub italics_correction: Abs, + pub style: MathStyle, + pub font_size: Abs, + pub class: Option<MathClass>, + pub span: Span, + pub meta: Vec<Meta>, + pub limits: Limits, +} + +impl GlyphFragment { + pub fn new(ctx: &MathContext, c: char, span: Span) -> Self { + let id = ctx.ttf.glyph_index(c).unwrap_or_default(); + Self::with_id(ctx, c, id, span) + } + + pub fn try_new(ctx: &MathContext, c: char, span: Span) -> Option<Self> { + let c = ctx.style.styled_char(c); + let id = ctx.ttf.glyph_index(c)?; + Some(Self::with_id(ctx, c, id, span)) + } + + pub fn with_id(ctx: &MathContext, c: char, id: GlyphId, span: Span) -> Self { + let class = match c { + ':' => Some(MathClass::Relation), + _ => unicode_math_class::class(c), + }; + let mut fragment = Self { + id, + c, + font: ctx.font.clone(), + lang: TextElem::lang_in(ctx.styles()), + fill: TextElem::fill_in(ctx.styles()), + style: ctx.style, + font_size: ctx.size, + width: Abs::zero(), + ascent: Abs::zero(), + descent: Abs::zero(), + limits: Limits::for_char(c), + italics_correction: Abs::zero(), + class, + span, + meta: MetaElem::data_in(ctx.styles()), + }; + fragment.set_id(ctx, id); + fragment + } + + /// Sets element id and boxes in appropriate way without changing other + /// styles. This is used to replace the glyph with a stretch variant. + pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) { + let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); + let italics = italics_correction(ctx, id).unwrap_or_default(); + let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect { + x_min: 0, + y_min: 0, + x_max: 0, + y_max: 0, + }); + + let mut width = advance.scaled(ctx); + if !is_extended_shape(ctx, id) { + width += italics; + } + + self.id = id; + self.width = width; + self.ascent = bbox.y_max.scaled(ctx); + self.descent = -bbox.y_min.scaled(ctx); + self.italics_correction = italics; + } + + pub fn height(&self) -> Abs { + self.ascent + self.descent + } + + pub fn into_variant(self) -> VariantFragment { + VariantFragment { + c: self.c, + id: Some(self.id), + style: self.style, + font_size: self.font_size, + italics_correction: self.italics_correction, + class: self.class, + span: self.span, + limits: self.limits, + frame: self.into_frame(), + } + } + + pub fn into_frame(self) -> Frame { + let item = TextItem { + font: self.font.clone(), + size: self.font_size, + fill: self.fill, + lang: self.lang, + text: self.c.into(), + glyphs: vec![Glyph { + id: self.id.0, + x_advance: Em::from_length(self.width, self.font_size), + x_offset: Em::zero(), + range: 0..self.c.len_utf8() as u16, + span: (self.span, 0), + }], + }; + let size = Size::new(self.width, self.ascent + self.descent); + let mut frame = Frame::new(size); + frame.set_baseline(self.ascent); + frame.push(Point::with_y(self.ascent), FrameItem::Text(item)); + frame.meta_iter(self.meta); + frame + } + + pub fn make_scriptsize(&mut self, ctx: &MathContext) { + let alt_id = + script_alternatives(ctx, self.id).and_then(|alts| alts.alternates.get(0)); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } + + pub fn make_scriptscriptsize(&mut self, ctx: &MathContext) { + let alts = script_alternatives(ctx, self.id); + let alt_id = alts + .and_then(|alts| alts.alternates.get(1).or_else(|| alts.alternates.get(0))); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } +} + +impl Debug for GlyphFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "GlyphFragment({:?})", self.c) + } +} + +#[derive(Clone)] +pub struct VariantFragment { + pub c: char, + pub id: Option<GlyphId>, + pub italics_correction: Abs, + pub frame: Frame, + pub style: MathStyle, + pub font_size: Abs, + pub class: Option<MathClass>, + pub span: Span, + pub limits: Limits, +} + +impl Debug for VariantFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "VariantFragment({:?})", self.c) + } +} + +#[derive(Debug, Clone)] +pub struct FrameFragment { + pub frame: Frame, + pub style: MathStyle, + pub font_size: Abs, + pub class: MathClass, + pub limits: Limits, + pub spaced: bool, + pub base_ascent: Abs, +} + +impl FrameFragment { + pub fn new(ctx: &MathContext, mut frame: Frame) -> Self { + let base_ascent = frame.ascent(); + frame.meta(ctx.styles(), false); + Self { + frame, + font_size: ctx.size, + style: ctx.style, + class: MathClass::Normal, + limits: Limits::Never, + spaced: false, + base_ascent, + } + } + + pub fn with_class(self, class: MathClass) -> Self { + Self { class, ..self } + } + + pub fn with_limits(self, limits: Limits) -> Self { + Self { limits, ..self } + } + + pub fn with_spaced(self, spaced: bool) -> Self { + Self { spaced, ..self } + } + + pub fn with_base_ascent(self, base_ascent: Abs) -> Self { + Self { base_ascent, ..self } + } +} + +/// Look up the italics correction for a glyph. +fn italics_correction(ctx: &MathContext, id: GlyphId) -> Option<Abs> { + Some(ctx.table.glyph_info?.italic_corrections?.get(id)?.scaled(ctx)) +} + +/// Look up the script/scriptscript alternates for a glyph +fn script_alternatives<'a>( + ctx: &MathContext<'a, '_, '_>, + id: GlyphId, +) -> Option<AlternateSet<'a>> { + ctx.ssty_table.and_then(|ssty| { + ssty.coverage.get(id).and_then(|index| ssty.alternate_sets.get(index)) + }) +} + +/// Look up the italics correction for a glyph. +fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool { + ctx.table + .glyph_info + .and_then(|info| info.extended_shapes) + .and_then(|info| info.get(id)) + .is_some() +} + +/// 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, + id: GlyphId, + corner: Corner, + height: Abs, +) -> Option<Abs> { + let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?; + let kern = match corner { + Corner::TopLeft => kerns.top_left, + Corner::TopRight => kerns.top_right, + Corner::BottomRight => kerns.bottom_right, + Corner::BottomLeft => kerns.bottom_left, + }?; + + let mut i = 0; + while i < kern.count() && height > kern.height(i)?.scaled(ctx) { + i += 1; + } + + Some(kern.kern(i)?.scaled(ctx)) +} diff --git a/crates/typst-library/src/math/matrix.rs b/crates/typst-library/src/math/matrix.rs new file mode 100644 index 00000000..aaccc332 --- /dev/null +++ b/crates/typst-library/src/math/matrix.rs @@ -0,0 +1,313 @@ +use super::*; + +const ROW_GAP: Em = Em::new(0.5); +const COL_GAP: Em = Em::new(0.5); +const VERTICAL_PADDING: Ratio = Ratio::new(0.1); + +/// A column vector. +/// +/// Content in the vector's elements can be aligned with the `&` symbol. +/// +/// ## Example { #example } +/// ```example +/// $ vec(a, b, c) dot vec(1, 2, 3) +/// = a + 2b + 3c $ +/// ``` +/// +/// Display: Vector +/// Category: math +#[element(LayoutMath)] +pub struct VecElem { + /// The delimiter to use. + /// + /// ```example + /// #set math.vec(delim: "[") + /// $ vec(1, 2) $ + /// ``` + #[default(Some(Delimiter::Paren))] + pub delim: Option<Delimiter>, + + /// The elements of the vector. + #[variadic] + pub children: Vec<Content>, +} + +impl LayoutMath for VecElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let delim = self.delim(ctx.styles()); + let frame = layout_vec_body(ctx, &self.children(), Align::Center)?; + layout_delimiters( + ctx, + frame, + delim.map(Delimiter::open), + delim.map(Delimiter::close), + self.span(), + ) + } +} + +/// A matrix. +/// +/// The elements of a row should be separated by commas, while the rows +/// themselves should be separated by semicolons. The semicolon syntax merges +/// preceding arguments separated by commas into an array. You can also use this +/// special syntax of math function calls to define custom functions that take +/// 2D data. +/// +/// Content in cells that are in the same row can be aligned with the `&` symbol. +/// +/// ## Example { #example } +/// ```example +/// $ mat( +/// 1, 2, ..., 10; +/// 2, 2, ..., 10; +/// dots.v, dots.v, dots.down, dots.v; +/// 10, 10, ..., 10; +/// ) $ +/// ``` +/// +/// Display: Matrix +/// Category: math +#[element(LayoutMath)] +pub struct MatElem { + /// The delimiter to use. + /// + /// ```example + /// #set math.mat(delim: "[") + /// $ mat(1, 2; 3, 4) $ + /// ``` + #[default(Some(Delimiter::Paren))] + pub delim: Option<Delimiter>, + + /// An array of arrays with the rows of the matrix. + /// + /// ```example + /// #let data = ((1, 2, 3), (4, 5, 6)) + /// #let matrix = math.mat(..data) + /// $ v := matrix $ + /// ``` + #[variadic] + #[parse( + let mut rows = vec![]; + let mut width = 0; + + let values = args.all::<Spanned<Value>>()?; + if values.iter().any(|spanned| matches!(spanned.v, Value::Array(_))) { + for Spanned { v, span } in values { + let array = v.cast::<Array>().at(span)?; + let row: Vec<_> = array.into_iter().map(Value::display).collect(); + width = width.max(row.len()); + rows.push(row); + } + } else { + rows = vec![values.into_iter().map(|spanned| spanned.v.display()).collect()]; + } + + for row in &mut rows { + if row.len() < width { + row.resize(width, Content::empty()); + } + } + + rows + )] + pub rows: Vec<Vec<Content>>, +} + +impl LayoutMath for MatElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let delim = self.delim(ctx.styles()); + let frame = layout_mat_body(ctx, &self.rows())?; + layout_delimiters( + ctx, + frame, + delim.map(Delimiter::open), + delim.map(Delimiter::close), + self.span(), + ) + } +} + +/// A case distinction. +/// +/// Content across different branches can be aligned with the `&` symbol. +/// +/// ## Example { #example } +/// ```example +/// $ f(x, y) := cases( +/// 1 "if" (x dot y)/2 <= 0, +/// 2 "if" x "is even", +/// 3 "if" x in NN, +/// 4 "else", +/// ) $ +/// ``` +/// +/// Display: Cases +/// Category: math +#[element(LayoutMath)] +pub struct CasesElem { + /// The delimiter to use. + /// + /// ```example + /// #set math.cases(delim: "[") + /// $ x = cases(1, 2) $ + /// ``` + #[default(Delimiter::Brace)] + pub delim: Delimiter, + + /// The branches of the case distinction. + #[variadic] + pub children: Vec<Content>, +} + +impl LayoutMath for CasesElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let delim = self.delim(ctx.styles()); + let frame = layout_vec_body(ctx, &self.children(), Align::Left)?; + layout_delimiters(ctx, frame, Some(delim.open()), None, self.span()) + } +} + +/// A vector / matrix delimiter. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum Delimiter { + /// Delimit with parentheses. + #[string("(")] + Paren, + /// Delimit with brackets. + #[string("[")] + Bracket, + /// Delimit with curly braces. + #[string("{")] + Brace, + /// Delimit with vertical bars. + #[string("|")] + Bar, + /// Delimit with double vertical bars. + #[string("||")] + DoubleBar, +} + +impl Delimiter { + /// The delimiter's opening character. + fn open(self) -> char { + match self { + Self::Paren => '(', + Self::Bracket => '[', + Self::Brace => '{', + Self::Bar => '|', + Self::DoubleBar => '‖', + } + } + + /// The delimiter's closing character. + fn close(self) -> char { + match self { + Self::Paren => ')', + Self::Bracket => ']', + Self::Brace => '}', + Self::Bar => '|', + Self::DoubleBar => '‖', + } + } +} + +/// Layout the inner contents of a vector. +fn layout_vec_body( + ctx: &mut MathContext, + column: &[Content], + align: Align, +) -> SourceResult<Frame> { + let gap = ROW_GAP.scaled(ctx); + ctx.style(ctx.style.for_denominator()); + let mut flat = vec![]; + for child in column { + flat.push(ctx.layout_row(child)?); + } + ctx.unstyle(); + Ok(stack(ctx, flat, align, gap, 0)) +} + +/// Layout the inner contents of a matrix. +fn layout_mat_body(ctx: &mut MathContext, rows: &[Vec<Content>]) -> SourceResult<Frame> { + let row_gap = ROW_GAP.scaled(ctx); + let col_gap = COL_GAP.scaled(ctx); + + let ncols = rows.first().map_or(0, |row| row.len()); + let nrows = rows.len(); + if ncols == 0 || nrows == 0 { + return Ok(Frame::new(Size::zero())); + } + + let mut heights = vec![(Abs::zero(), Abs::zero()); nrows]; + + ctx.style(ctx.style.for_denominator()); + let mut cols = vec![vec![]; ncols]; + for (row, (ascent, descent)) in rows.iter().zip(&mut heights) { + for (cell, col) in row.iter().zip(&mut cols) { + let cell = ctx.layout_row(cell)?; + ascent.set_max(cell.ascent()); + descent.set_max(cell.descent()); + col.push(cell); + } + } + ctx.unstyle(); + + let mut frame = Frame::new(Size::new( + Abs::zero(), + heights.iter().map(|&(a, b)| a + b).sum::<Abs>() + row_gap * (nrows - 1) as f64, + )); + let mut x = Abs::zero(); + for col in cols { + let AlignmentResult { points, width: rcol } = alignments(&col); + let mut y = Abs::zero(); + for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) { + let cell = cell.into_aligned_frame(ctx, &points, Align::Center); + let pos = Point::new( + if points.is_empty() { x + (rcol - cell.width()) / 2.0 } else { x }, + y + ascent - cell.ascent(), + ); + frame.push_frame(pos, cell); + y += ascent + descent + row_gap; + } + x += rcol + col_gap; + } + frame.size_mut().x = x - col_gap; + + Ok(frame) +} + +/// Layout the outer wrapper around a vector's or matrices' body. +fn layout_delimiters( + ctx: &mut MathContext, + mut frame: Frame, + left: Option<char>, + right: Option<char>, + span: Span, +) -> SourceResult<()> { + let axis = scaled!(ctx, axis_height); + let short_fall = DELIM_SHORT_FALL.scaled(ctx); + let height = frame.height(); + let target = height + VERTICAL_PADDING.of(height); + frame.set_baseline(height / 2.0 + axis); + + if let Some(left) = left { + ctx.push( + GlyphFragment::new(ctx, left, span).stretch_vertical(ctx, target, short_fall), + ); + } + + ctx.push(FrameFragment::new(ctx, frame)); + + if let Some(right) = right { + ctx.push( + GlyphFragment::new(ctx, right, span) + .stretch_vertical(ctx, target, short_fall), + ); + } + + Ok(()) +} diff --git a/crates/typst-library/src/math/mod.rs b/crates/typst-library/src/math/mod.rs new file mode 100644 index 00000000..0429265f --- /dev/null +++ b/crates/typst-library/src/math/mod.rs @@ -0,0 +1,480 @@ +//! Mathematical formulas. + +#[macro_use] +mod ctx; +mod accent; +mod align; +mod attach; +mod cancel; +mod delimited; +mod frac; +mod fragment; +mod matrix; +mod op; +mod root; +mod row; +mod spacing; +mod stretch; +mod style; +mod underover; + +pub use self::accent::*; +pub use self::align::*; +pub use self::attach::*; +pub use self::cancel::*; +pub use self::delimited::*; +pub use self::frac::*; +pub use self::matrix::*; +pub use self::op::*; +pub use self::root::*; +pub use self::style::*; +pub use self::underover::*; + +use ttf_parser::{GlyphId, Rect}; +use typst::eval::{Module, Scope}; +use typst::font::{Font, FontWeight}; +use typst::model::Guard; +use typst::util::option_eq; +use unicode_math_class::MathClass; + +use self::ctx::*; +use self::fragment::*; +use self::row::*; +use self::spacing::*; +use crate::layout::{HElem, ParElem, Spacing}; +use crate::meta::Supplement; +use crate::meta::{ + Count, Counter, CounterUpdate, LocalName, Numbering, Outlinable, Refable, +}; +use crate::prelude::*; +use crate::text::{ + families, variant, FontFamily, FontList, LinebreakElem, SpaceElem, TextElem, TextSize, +}; + +/// Create a module with all math definitions. +pub fn module() -> Module { + let mut math = Scope::deduplicating(); + math.define("equation", EquationElem::func()); + math.define("text", TextElem::func()); + + // Grouping. + math.define("lr", LrElem::func()); + math.define("abs", abs_func()); + math.define("norm", norm_func()); + math.define("floor", floor_func()); + math.define("ceil", ceil_func()); + math.define("round", round_func()); + + // Attachments and accents. + math.define("attach", AttachElem::func()); + math.define("scripts", ScriptsElem::func()); + math.define("limits", LimitsElem::func()); + math.define("accent", AccentElem::func()); + math.define("underline", UnderlineElem::func()); + math.define("overline", OverlineElem::func()); + math.define("underbrace", UnderbraceElem::func()); + math.define("overbrace", OverbraceElem::func()); + math.define("underbracket", UnderbracketElem::func()); + math.define("overbracket", OverbracketElem::func()); + math.define("cancel", CancelElem::func()); + + // Fractions and matrix-likes. + math.define("frac", FracElem::func()); + math.define("binom", BinomElem::func()); + math.define("vec", VecElem::func()); + math.define("mat", MatElem::func()); + math.define("cases", CasesElem::func()); + + // Roots. + math.define("sqrt", sqrt_func()); + math.define("root", RootElem::func()); + + // Styles. + math.define("upright", upright_func()); + math.define("bold", bold_func()); + math.define("italic", italic_func()); + math.define("serif", serif_func()); + math.define("sans", sans_func()); + math.define("cal", cal_func()); + math.define("frak", frak_func()); + math.define("mono", mono_func()); + math.define("bb", bb_func()); + + math.define("display", display_func()); + math.define("inline", inline_func()); + math.define("script", script_func()); + math.define("sscript", sscript_func()); + + // Text operators. + math.define("op", OpElem::func()); + op::define(&mut math); + + // Spacings. + spacing::define(&mut math); + + // Symbols. + for (name, symbol) in crate::symbols::SYM { + math.define(*name, symbol.clone()); + } + + Module::new("math").with_scope(math) +} + +/// A mathematical equation. +/// +/// Can be displayed inline with text or as a separate block. +/// +/// ## Example { #example } +/// ```example +/// #set text(font: "New Computer Modern") +/// +/// Let $a$, $b$, and $c$ be the side +/// lengths of right-angled triangle. +/// Then, we know that: +/// $ a^2 + b^2 = c^2 $ +/// +/// Prove by induction: +/// $ sum_(k=1)^n k = (n(n+1)) / 2 $ +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: Write mathematical markup within +/// dollar signs to create an equation. Starting and ending the equation with at +/// least one space lifts it into a separate block that is centered +/// horizontally. For more details about math syntax, see the +/// [main math page]($category/math). +/// +/// Display: Equation +/// Category: math +#[element( + Locatable, Synthesize, Show, Finalize, Layout, LayoutMath, Count, LocalName, Refable, + Outlinable +)] +pub struct EquationElem { + /// Whether the equation is displayed as a separate block. + #[default(false)] + pub block: bool, + + /// How to [number]($func/numbering) block-level equations. + /// + /// ```example + /// #set math.equation(numbering: "(1)") + /// + /// We define: + /// $ phi.alt := (1 + sqrt(5)) / 2 $ <ratio> + /// + /// With @ratio, we get: + /// $ F_n = floor(1 / sqrt(5) phi.alt^n) $ + /// ``` + pub numbering: Option<Numbering>, + + /// A supplement for the equation. + /// + /// For references to equations, this is added before the referenced number. + /// + /// If a function is specified, it is passed the referenced equation and + /// should return content. + /// + /// ```example + /// #set math.equation(numbering: "(1)", supplement: [Eq.]) + /// + /// We define: + /// $ phi.alt := (1 + sqrt(5)) / 2 $ <ratio> + /// + /// With @ratio, we get: + /// $ F_n = floor(1 / sqrt(5) phi.alt^n) $ + /// ``` + pub supplement: Smart<Option<Supplement>>, + + /// The contents of the equation. + #[required] + pub body: Content, +} + +impl Synthesize for EquationElem { + fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + // Resolve the supplement. + let supplement = match self.supplement(styles) { + Smart::Auto => TextElem::packed(self.local_name_in(styles)), + Smart::Custom(None) => Content::empty(), + Smart::Custom(Some(supplement)) => supplement.resolve(vt, [self.clone()])?, + }; + + self.push_block(self.block(styles)); + self.push_numbering(self.numbering(styles)); + self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement)))); + + Ok(()) + } +} + +impl Show for EquationElem { + #[tracing::instrument(name = "EquationElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let mut realized = self.clone().pack().guarded(Guard::Base(Self::func())); + if self.block(styles) { + realized = realized.aligned(Axes::with_x(Some(Align::Center.into()))) + } + Ok(realized) + } +} + +impl Finalize for EquationElem { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + realized + .styled(TextElem::set_weight(FontWeight::from_number(450))) + .styled(TextElem::set_font(FontList(vec![FontFamily::new( + "New Computer Modern Math", + )]))) + } +} + +impl Layout for EquationElem { + #[tracing::instrument(name = "EquationElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + const NUMBER_GUTTER: Em = Em::new(0.5); + + let block = self.block(styles); + + // Find a math font. + let variant = variant(styles); + let world = vt.world; + let Some(font) = families(styles) + .find_map(|family| { + let id = world.book().select(family.as_str(), 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 mut ctx = MathContext::new(vt, styles, regions, &font, block); + let mut frame = ctx.layout_frame(self)?; + + if block { + if let Some(numbering) = self.numbering(styles) { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let counter = Counter::of(Self::func()) + .display(Some(numbering), false) + .layout(vt, styles, pod)? + .into_frame(); + + let width = if regions.size.x.is_finite() { + regions.size.x + } else { + frame.width() + + 2.0 * (counter.width() + NUMBER_GUTTER.resolve(styles)) + }; + + let height = frame.height().max(counter.height()); + frame.resize(Size::new(width, height), Align::CENTER_HORIZON); + + let x = if TextElem::dir_in(styles).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 slack = ParElem::leading_in(styles) * 0.7; + let top_edge = TextElem::top_edge_in(styles).resolve(styles, font.metrics()); + let bottom_edge = + -TextElem::bottom_edge_in(styles).resolve(styles, font.metrics()); + + let ascent = top_edge.max(frame.ascent() - slack); + 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(Fragment::frame(frame)) + } +} + +impl Count for EquationElem { + fn update(&self) -> Option<CounterUpdate> { + (self.block(StyleChain::default()) + && self.numbering(StyleChain::default()).is_some()) + .then(|| CounterUpdate::Step(NonZeroUsize::ONE)) + } +} + +impl LocalName for EquationElem { + fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "Ekuacion", + Lang::ARABIC => "معادلة", + Lang::BOKMÅL => "Ligning", + Lang::CHINESE if option_eq(region, "TW") => "方程式", + Lang::CHINESE => "等式", + Lang::CZECH => "Rovnice", + Lang::DANISH => "Ligning", + Lang::DUTCH => "Vergelijking", + Lang::FILIPINO => "Ekwasyon", + Lang::FRENCH => "Équation", + Lang::GERMAN => "Gleichung", + Lang::ITALIAN => "Equazione", + Lang::NYNORSK => "Likning", + Lang::POLISH => "Równanie", + Lang::PORTUGUESE => "Equação", + Lang::RUSSIAN => "Уравнение", + Lang::SLOVENIAN => "Enačba", + Lang::SPANISH => "Ecuación", + Lang::SWEDISH => "Ekvation", + Lang::TURKISH => "Denklem", + Lang::UKRAINIAN => "Рівняння", + Lang::VIETNAMESE => "Phương trình", + Lang::ENGLISH | _ => "Equation", + } + } +} + +impl Refable for EquationElem { + fn supplement(&self) -> Content { + // After synthesis, this should always be custom content. + match self.supplement(StyleChain::default()) { + Smart::Custom(Some(Supplement::Content(content))) => content, + _ => Content::empty(), + } + } + + fn counter(&self) -> Counter { + Counter::of(Self::func()) + } + + fn numbering(&self) -> Option<Numbering> { + self.numbering(StyleChain::default()) + } +} + +impl Outlinable for EquationElem { + fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>> { + let Some(numbering) = self.numbering(StyleChain::default()) else { + return Ok(None); + }; + + // After synthesis, this should always be custom content. + let mut supplement = match self.supplement(StyleChain::default()) { + Smart::Custom(Some(Supplement::Content(content))) => content, + _ => Content::empty(), + }; + + if !supplement.is_empty() { + supplement += TextElem::packed("\u{a0}"); + } + + let numbers = self + .counter() + .at(vt, self.0.location().unwrap())? + .display(vt, &numbering)?; + + Ok(Some(supplement + numbers)) + } +} + +pub trait LayoutMath { + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()>; +} + +impl LayoutMath for EquationElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + self.body().layout_math(ctx) + } +} + +impl LayoutMath for Content { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + // Directly layout the body of nested equations instead of handling it + // like a normal equation so that things like this work: + // ``` + // #let my = $pi$ + // $ my r^2 $ + // ``` + if let Some(elem) = self.to::<EquationElem>() { + return elem.layout_math(ctx); + } + + if let Some(realized) = ctx.realize(self)? { + return realized.layout_math(ctx); + } + + if let Some(children) = self.to_sequence() { + for child in children { + child.layout_math(ctx)?; + } + return Ok(()); + } + + if let Some((elem, styles)) = self.to_styled() { + if TextElem::font_in(ctx.styles().chain(styles)) + != TextElem::font_in(ctx.styles()) + { + let frame = ctx.layout_content(self)?; + ctx.push(FrameFragment::new(ctx, frame).with_spaced(true)); + return Ok(()); + } + + let prev_map = std::mem::replace(&mut ctx.local, styles.clone()); + let prev_size = ctx.size; + ctx.local.apply(prev_map.clone()); + ctx.size = TextElem::size_in(ctx.styles()); + elem.layout_math(ctx)?; + ctx.size = prev_size; + ctx.local = prev_map; + return Ok(()); + } + + if self.is::<SpaceElem>() { + ctx.push(MathFragment::Space(ctx.space_width.scaled(ctx))); + return Ok(()); + } + + if self.is::<LinebreakElem>() { + ctx.push(MathFragment::Linebreak); + return Ok(()); + } + + if let Some(elem) = self.to::<HElem>() { + if let Spacing::Rel(rel) = elem.amount() { + if rel.rel.is_zero() { + ctx.push(MathFragment::Spacing(rel.abs.resolve(ctx.styles()))); + } + } + return Ok(()); + } + + if let Some(elem) = self.to::<TextElem>() { + let fragment = ctx.layout_text(elem)?; + ctx.push(fragment); + return Ok(()); + } + + if let Some(elem) = self.with::<dyn LayoutMath>() { + return elem.layout_math(ctx); + } + + let mut frame = ctx.layout_content(self)?; + if !frame.has_baseline() { + let axis = scaled!(ctx, axis_height); + frame.set_baseline(frame.height() / 2.0 + axis); + } + ctx.push(FrameFragment::new(ctx, frame).with_spaced(true)); + + Ok(()) + } +} diff --git a/crates/typst-library/src/math/op.rs b/crates/typst-library/src/math/op.rs new file mode 100644 index 00000000..8ad74c49 --- /dev/null +++ b/crates/typst-library/src/math/op.rs @@ -0,0 +1,113 @@ +use typst::eval::Scope; + +use super::*; + +/// A text operator in an equation. +/// +/// ## Example { #example } +/// ```example +/// $ tan x = (sin x)/(cos x) $ +/// $ op("custom", +/// limits: #true)_(n->oo) n $ +/// ``` +/// +/// ## Predefined Operators { #predefined } +/// Typst predefines the operators `arccos`, `arcsin`, `arctan`, `arg`, +/// `cos`, `cosh`, `cot`, `ctg`, `coth`, `csc`, `deg`, `det`, `dim`, +/// `exp`, `gcd`, `hom`, `mod`, `inf`, `ker`, `lg`, `lim`, `ln`, `log`, +/// `max`, `min`, `Pr`, `sec`, `sin`, `sinc`, `sinh`, `sup`, `tan`, `tg`, +/// `tanh`, `liminf`, and `limsup`. +/// +/// Display: Text Operator +/// Category: math +#[element(LayoutMath)] +pub struct OpElem { + /// The operator's text. + #[required] + pub text: EcoString, + + /// Whether the operator should show attachments as limits in display mode. + #[default(false)] + pub limits: bool, +} + +impl LayoutMath for OpElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let fragment = + ctx.layout_text(&TextElem::new(self.text()).spanned(self.span()))?; + ctx.push( + FrameFragment::new(ctx, fragment.into_frame()) + .with_class(MathClass::Large) + .with_limits(if self.limits(ctx.styles()) { + Limits::Display + } else { + Limits::Never + }), + ); + Ok(()) + } +} + +macro_rules! ops { + ($($name:ident $(: $value:literal)? $(($tts:tt))?),* $(,)?) => { + pub(super) fn define(math: &mut Scope) { + $(math.define( + stringify!($name), + OpElem::new(ops!(@name $name $(: $value)?).into()) + .with_limits(ops!(@limit $($tts)*)) + .pack() + );)* + + let dif = |d| { + HElem::new(THIN.into()).pack() + + MathStyleElem::new(TextElem::packed(d)).with_italic(Some(false)).pack() + }; + math.define("dif", dif('d')); + math.define("Dif", dif('D')); + } + }; + (@name $name:ident) => { stringify!($name) }; + (@name $name:ident: $value:literal) => { $value }; + (@limit limits) => { true }; + (@limit) => { false }; +} + +ops! { + arccos, + arcsin, + arctan, + arg, + cos, + cosh, + cot, + ctg, + coth, + csc, + deg, + det (limits), + dim, + exp, + gcd (limits), + hom, + mod, + inf (limits), + ker, + lg, + lim (limits), + ln, + log, + max (limits), + min (limits), + Pr (limits), + sec, + sin, + sinc, + sinh, + sup (limits), + tan, + tg, + tanh, + liminf: "lim inf" (limits), + limsup: "lim sup" (limits), +} diff --git a/crates/typst-library/src/math/root.rs b/crates/typst-library/src/math/root.rs new file mode 100644 index 00000000..d1c5f46a --- /dev/null +++ b/crates/typst-library/src/math/root.rs @@ -0,0 +1,156 @@ +use super::*; + +/// A square root. +/// +/// ## Example { #example } +/// ```example +/// $ sqrt(x^2) = x = sqrt(x)^2 $ +/// ``` +/// +/// Display: Square Root +/// Category: math +#[func] +pub fn sqrt( + /// The expression to take the square root of. + radicand: Content, +) -> Content { + RootElem::new(radicand).pack() +} + +/// A general root. +/// +/// ## Example { #example } +/// ```example +/// $ root(3, x) $ +/// ``` +/// +/// Display: Root +/// Category: math +#[element(LayoutMath)] +pub struct RootElem { + /// Which root of the radicand to take. + #[positional] + pub index: Option<Content>, + + /// The expression to take the root of. + #[required] + pub radicand: Content, +} + +impl LayoutMath for RootElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout(ctx, self.index(ctx.styles()).as_ref(), &self.radicand(), self.span()) + } +} + +/// Layout a root. +/// +/// https://www.w3.org/TR/mathml-core/#radicals-msqrt-mroot +fn layout( + ctx: &mut MathContext, + mut index: Option<&Content>, + radicand: &Content, + span: Span, +) -> SourceResult<()> { + let gap = scaled!( + ctx, + text: radical_vertical_gap, + display: radical_display_style_vertical_gap, + ); + let thickness = scaled!(ctx, radical_rule_thickness); + let extra_ascender = scaled!(ctx, radical_extra_ascender); + let kern_before = scaled!(ctx, radical_kern_before_degree); + let kern_after = scaled!(ctx, radical_kern_after_degree); + let raise_factor = percent!(ctx, radical_degree_bottom_raise_percent); + + // Layout radicand. + ctx.style(ctx.style.with_cramped(true)); + let radicand = ctx.layout_frame(radicand)?; + ctx.unstyle(); + + // Layout root symbol. + let target = radicand.height() + thickness + gap; + let sqrt = precomposed(ctx, index, target) + .map(|frame| { + index = None; + frame + }) + .unwrap_or_else(|| { + let glyph = GlyphFragment::new(ctx, '√', span); + glyph.stretch_vertical(ctx, target, Abs::zero()).frame + }); + + // Layout the index. + // Script-script style looks too small, we use Script style instead. + ctx.style(ctx.style.with_size(MathSize::Script)); + let index = index.map(|elem| ctx.layout_frame(elem)).transpose()?; + ctx.unstyle(); + + let gap = gap.max((sqrt.height() - radicand.height() - thickness) / 2.0); + let descent = radicand.descent() + gap; + let inner_ascent = extra_ascender + thickness + gap + radicand.ascent(); + + let mut sqrt_offset = Abs::zero(); + let mut shift_up = Abs::zero(); + let mut ascent = inner_ascent; + + if let Some(index) = &index { + sqrt_offset = kern_before + index.width() + kern_after; + shift_up = raise_factor * sqrt.height() - descent + index.descent(); + ascent.set_max(shift_up + index.ascent()); + } + + let radicant_offset = sqrt_offset + sqrt.width(); + let width = radicant_offset + radicand.width(); + let size = Size::new(width, ascent + descent); + + let sqrt_pos = Point::new(sqrt_offset, ascent - inner_ascent); + let line_pos = Point::new(radicant_offset, ascent - inner_ascent + thickness / 2.0); + let radicand_pos = Point::new(radicant_offset, ascent - radicand.ascent()); + + let mut frame = Frame::new(size); + frame.set_baseline(ascent); + + if let Some(index) = index { + let index_pos = Point::new(kern_before, ascent - shift_up - index.ascent()); + frame.push_frame(index_pos, index); + } + + frame.push_frame(sqrt_pos, sqrt); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(radicand.width())).stroked(Stroke { + paint: TextElem::fill_in(ctx.styles()), + thickness, + ..Stroke::default() + }), + span, + ), + ); + + frame.push_frame(radicand_pos, radicand); + ctx.push(FrameFragment::new(ctx, frame)); + + Ok(()) +} + +/// Select a precomposed radical, if the font has it. +fn precomposed(ctx: &MathContext, index: Option<&Content>, target: Abs) -> Option<Frame> { + let elem = index?.to::<TextElem>()?; + let c = match elem.text().as_str() { + "3" => '∛', + "4" => '∜', + _ => return None, + }; + + ctx.ttf.glyph_index(c)?; + let glyph = GlyphFragment::new(ctx, c, elem.span()); + let variant = glyph.stretch_vertical(ctx, target, Abs::zero()).frame; + if variant.height() < target { + return None; + } + + Some(variant) +} diff --git a/crates/typst-library/src/math/row.rs b/crates/typst-library/src/math/row.rs new file mode 100644 index 00000000..687f82b8 --- /dev/null +++ b/crates/typst-library/src/math/row.rs @@ -0,0 +1,258 @@ +use std::iter::once; + +use crate::layout::AlignElem; + +use super::*; + +pub const TIGHT_LEADING: Em = Em::new(0.25); + +#[derive(Debug, Default, Clone)] +pub struct MathRow(Vec<MathFragment>); + +impl MathRow { + pub fn new(fragments: Vec<MathFragment>) -> Self { + let iter = fragments.into_iter().peekable(); + let mut last: Option<usize> = None; + let mut space: Option<MathFragment> = None; + let mut resolved: Vec<MathFragment> = vec![]; + + for mut fragment in iter { + match fragment { + // Keep space only if supported by spaced fragments. + MathFragment::Space(_) => { + if last.is_some() { + space = Some(fragment); + } + continue; + } + + // Explicit spacing disables automatic spacing. + MathFragment::Spacing(_) => { + last = None; + space = None; + resolved.push(fragment); + continue; + } + + // Alignment points are resolved later. + MathFragment::Align => { + resolved.push(fragment); + continue; + } + + // New line, new things. + MathFragment::Linebreak => { + resolved.push(fragment); + space = None; + last = None; + continue; + } + + _ => {} + } + + // Convert variable operators into binary operators if something + // precedes them and they are not preceded by a operator or comparator. + if fragment.class() == Some(MathClass::Vary) + && matches!( + last.and_then(|i| resolved[i].class()), + Some( + MathClass::Normal + | MathClass::Alphabetic + | MathClass::Closing + | MathClass::Fence + ) + ) + { + fragment.set_class(MathClass::Binary); + } + + // Insert spacing between the last and this item. + if let Some(i) = last { + if let Some(s) = spacing(&resolved[i], space.take(), &fragment) { + resolved.insert(i + 1, s); + } + } + + last = Some(resolved.len()); + resolved.push(fragment); + } + + Self(resolved) + } + + pub fn iter(&self) -> std::slice::Iter<'_, MathFragment> { + self.0.iter() + } + + /// Extract the sublines of the row. + /// + /// It is very unintuitive, but in current state of things, a `MathRow` can + /// contain several actual rows. That function deconstructs it to "single" + /// rows. Hopefully this is only a temporary hack. + pub fn rows(&self) -> Vec<Self> { + self.0 + .split(|frag| matches!(frag, MathFragment::Linebreak)) + .map(|slice| Self(slice.to_vec())) + .collect() + } + + pub fn ascent(&self) -> Abs { + self.iter().map(MathFragment::ascent).max().unwrap_or_default() + } + + pub fn descent(&self) -> Abs { + self.iter().map(MathFragment::descent).max().unwrap_or_default() + } + + pub fn class(&self) -> MathClass { + // Predict the class of the output of 'into_fragment' + if self.0.len() == 1 { + self.0 + .first() + .and_then(|fragment| fragment.class()) + .unwrap_or(MathClass::Special) + } else { + // FrameFragment::new() (inside 'into_fragment' in this branch) defaults + // to MathClass::Normal for its class. + MathClass::Normal + } + } + + pub fn into_frame(self, ctx: &MathContext) -> Frame { + let styles = ctx.styles(); + let align = AlignElem::alignment_in(styles).x.resolve(styles); + self.into_aligned_frame(ctx, &[], align) + } + + pub fn into_fragment(self, ctx: &MathContext) -> MathFragment { + if self.0.len() == 1 { + self.0.into_iter().next().unwrap() + } else { + FrameFragment::new(ctx, self.into_frame(ctx)).into() + } + } + + pub fn into_aligned_frame( + self, + ctx: &MathContext, + points: &[Abs], + align: Align, + ) -> Frame { + if self.iter().any(|frag| matches!(frag, MathFragment::Linebreak)) { + let leading = if ctx.style.size >= MathSize::Text { + ParElem::leading_in(ctx.styles()) + } else { + TIGHT_LEADING.scaled(ctx) + }; + + let mut rows: Vec<_> = self.rows(); + + if matches!(rows.last(), Some(row) if row.0.is_empty()) { + rows.pop(); + } + + let AlignmentResult { points, width } = alignments(&rows); + let mut frame = Frame::new(Size::zero()); + + for (i, row) in rows.into_iter().enumerate() { + let sub = row.into_line_frame(&points, align); + let size = frame.size_mut(); + if i > 0 { + size.y += leading; + } + + let mut pos = Point::with_y(size.y); + if points.is_empty() { + pos.x = align.position(width - sub.width()); + } + size.y += sub.height(); + size.x.set_max(sub.width()); + frame.push_frame(pos, sub); + } + frame + } else { + self.into_line_frame(points, align) + } + } + + fn into_line_frame(self, points: &[Abs], align: Align) -> Frame { + let ascent = self.ascent(); + let mut frame = Frame::new(Size::new(Abs::zero(), ascent + self.descent())); + frame.set_baseline(ascent); + + let mut next_x = { + let mut widths = Vec::new(); + if !points.is_empty() && align != Align::Left { + let mut width = Abs::zero(); + for fragment in self.iter() { + if matches!(fragment, MathFragment::Align) { + widths.push(width); + width = Abs::zero(); + } else { + width += fragment.width(); + } + } + widths.push(width); + } + let widths = widths; + + let mut prev_points = once(Abs::zero()).chain(points.iter().copied()); + let mut point_widths = points.iter().copied().zip(widths); + let mut alternator = LeftRightAlternator::Right; + move || match align { + Align::Left => prev_points.next(), + Align::Right => point_widths.next().map(|(point, width)| point - width), + _ => point_widths + .next() + .zip(prev_points.next()) + .zip(alternator.next()) + .map(|(((point, width), prev_point), alternator)| match alternator { + LeftRightAlternator::Left => prev_point, + LeftRightAlternator::Right => point - width, + }), + } + }; + let mut x = next_x().unwrap_or_default(); + + for fragment in self.0.into_iter() { + if matches!(fragment, MathFragment::Align) { + x = next_x().unwrap_or(x); + continue; + } + + let y = ascent - fragment.ascent(); + let pos = Point::new(x, y); + x += fragment.width(); + frame.push_frame(pos, fragment.into_frame()); + } + + frame.size_mut().x = x; + frame + } +} + +impl<T: Into<MathFragment>> From<T> for MathRow { + fn from(fragment: T) -> Self { + Self(vec![fragment.into()]) + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum LeftRightAlternator { + Left, + Right, +} + +impl Iterator for LeftRightAlternator { + type Item = LeftRightAlternator; + + fn next(&mut self) -> Option<Self::Item> { + let r = Some(*self); + match self { + Self::Left => *self = Self::Right, + Self::Right => *self = Self::Left, + } + r + } +} diff --git a/crates/typst-library/src/math/spacing.rs b/crates/typst-library/src/math/spacing.rs new file mode 100644 index 00000000..848aca78 --- /dev/null +++ b/crates/typst-library/src/math/spacing.rs @@ -0,0 +1,60 @@ +use super::*; + +pub(super) const THIN: Em = Em::new(1.0 / 6.0); +pub(super) const MEDIUM: Em = Em::new(2.0 / 9.0); +pub(super) const THICK: Em = Em::new(5.0 / 18.0); +pub(super) const QUAD: Em = Em::new(1.0); + +/// Hook up all spacings. +pub(super) fn define(math: &mut Scope) { + math.define("thin", HElem::new(THIN.into()).pack()); + math.define("med", HElem::new(MEDIUM.into()).pack()); + math.define("thick", HElem::new(THICK.into()).pack()); + math.define("quad", HElem::new(QUAD.into()).pack()); +} + +/// Create the spacing between two fragments in a given style. +pub(super) fn spacing( + l: &MathFragment, + space: Option<MathFragment>, + r: &MathFragment, +) -> Option<MathFragment> { + use MathClass::*; + + let class = |f: &MathFragment| f.class().unwrap_or(Special); + let resolve = |v: Em, f: &MathFragment| { + Some(MathFragment::Spacing(f.font_size().map_or(Abs::zero(), |size| v.at(size)))) + }; + let script = + |f: &MathFragment| f.style().map_or(false, |s| s.size <= MathSize::Script); + + match (class(l), class(r)) { + // No spacing before punctuation; thin spacing after punctuation, unless + // in script size. + (_, Punctuation) => None, + (Punctuation, _) if !script(l) => resolve(THIN, l), + + // No spacing after opening delimiters and before closing delimiters. + (Opening, _) | (_, Closing) => None, + + // Thick spacing around relations, unless followed by a another relation + // or in script size. + (Relation, Relation) => None, + (Relation, _) if !script(l) => resolve(THICK, l), + (_, Relation) if !script(r) => resolve(THICK, r), + + // Medium spacing around binary operators, unless in script size. + (Binary, _) if !script(l) => resolve(MEDIUM, l), + (_, Binary) if !script(r) => resolve(MEDIUM, r), + + // Thin spacing around large operators, unless next to a delimiter. + (Large, Opening | Fence) | (Closing | Fence, Large) => None, + (Large, _) => resolve(THIN, l), + (_, Large) => resolve(THIN, r), + + // Spacing around spaced frames. + _ if (l.is_spaced() || r.is_spaced()) => space, + + _ => None, + } +} diff --git a/crates/typst-library/src/math/stretch.rs b/crates/typst-library/src/math/stretch.rs new file mode 100644 index 00000000..910f7a81 --- /dev/null +++ b/crates/typst-library/src/math/stretch.rs @@ -0,0 +1,199 @@ +use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; +use ttf_parser::LazyArray16; + +use super::*; + +/// Maximum number of times extenders can be repeated. +const MAX_REPEATS: usize = 1024; + +impl GlyphFragment { + /// Try to stretch a glyph to a desired height. + pub fn stretch_vertical( + self, + ctx: &MathContext, + height: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, height, short_fall, false) + } + + /// Try to stretch a glyph to a desired width. + pub fn stretch_horizontal( + self, + ctx: &MathContext, + width: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, width, short_fall, true) + } +} + +/// Try to stretch a glyph to a desired width or height. +/// +/// The resulting frame may not have the exact desired width. +fn stretch_glyph( + ctx: &MathContext, + mut base: GlyphFragment, + target: Abs, + short_fall: Abs, + horizontal: bool, +) -> VariantFragment { + let short_target = target - short_fall; + let mut min_overlap = Abs::zero(); + let construction = ctx + .table + .variants + .and_then(|variants| { + min_overlap = variants.min_connector_overlap.scaled(ctx); + if horizontal { + variants.horizontal_constructions + } else { + variants.vertical_constructions + } + .get(base.id) + }) + .unwrap_or(GlyphConstruction { assembly: None, variants: LazyArray16::new(&[]) }); + + // If the base glyph is good enough, use it. + let advance = if horizontal { base.width } else { base.height() }; + if short_target <= advance { + return base.into_variant(); + } + + // Search for a pre-made variant with a good advance. + let mut best_id = base.id; + let mut best_advance = base.width; + for variant in construction.variants { + best_id = variant.variant_glyph; + best_advance = base.font.to_em(variant.advance_measurement).at(base.font_size); + if short_target <= best_advance { + break; + } + } + + // This is either good or the best we've got. + if short_target <= best_advance || construction.assembly.is_none() { + base.set_id(ctx, best_id); + return base.into_variant(); + } + + // Assemble from parts. + let assembly = construction.assembly.unwrap(); + assemble(ctx, base, assembly, min_overlap, target, horizontal) +} + +/// Assemble a glyph from parts. +fn assemble( + ctx: &MathContext, + base: GlyphFragment, + assembly: GlyphAssembly, + min_overlap: Abs, + target: Abs, + horizontal: bool, +) -> VariantFragment { + // Determine the number of times the extenders need to be repeated as well + // as a ratio specifying how much to spread the parts apart + // (0 = maximal overlap, 1 = minimal overlap). + let mut full; + let mut ratio; + let mut repeat = 0; + loop { + full = Abs::zero(); + ratio = 0.0; + + let mut parts = parts(assembly, repeat).peekable(); + let mut growable = Abs::zero(); + + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx); + if let Some(next) = parts.peek() { + let max_overlap = part + .end_connector_length + .min(next.start_connector_length) + .scaled(ctx); + + advance -= max_overlap; + growable += max_overlap - min_overlap; + } + + full += advance; + } + + if full < target { + let delta = target - full; + ratio = (delta / growable).min(1.0); + full += ratio * growable; + } + + if target <= full || repeat >= MAX_REPEATS { + break; + } + + repeat += 1; + } + + let mut selected = vec![]; + let mut parts = parts(assembly, repeat).peekable(); + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx); + if let Some(next) = parts.peek() { + let max_overlap = + part.end_connector_length.min(next.start_connector_length).scaled(ctx); + advance -= max_overlap; + advance += ratio * (max_overlap - min_overlap); + } + + let mut fragment = base.clone(); + fragment.set_id(ctx, part.glyph_id); + selected.push((fragment, advance)); + } + + let size; + let baseline; + if horizontal { + let height = base.ascent + base.descent; + size = Size::new(full, height); + baseline = base.ascent; + } else { + let axis = scaled!(ctx, axis_height); + let width = selected.iter().map(|(f, _)| f.width).max().unwrap_or_default(); + size = Size::new(width, full); + baseline = full / 2.0 + axis; + } + + let mut frame = Frame::new(size); + let mut offset = Abs::zero(); + frame.set_baseline(baseline); + frame.meta_iter(base.meta); + + for (fragment, advance) in selected { + let pos = if horizontal { + Point::new(offset, frame.baseline() - fragment.ascent) + } else { + Point::with_y(full - offset - fragment.height()) + }; + frame.push_frame(pos, fragment.into_frame()); + offset += advance; + } + + VariantFragment { + c: base.c, + id: None, + frame, + style: base.style, + font_size: base.font_size, + italics_correction: Abs::zero(), + class: base.class, + span: base.span, + limits: base.limits, + } +} + +/// Return an iterator over the assembly's parts with extenders repeated the +/// specified number of times. +fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator<Item = GlyphPart> + '_ { + assembly.parts.into_iter().flat_map(move |part| { + let count = if part.part_flags.extender() { repeat } else { 1 }; + std::iter::repeat(part).take(count) + }) +} diff --git a/crates/typst-library/src/math/style.rs b/crates/typst-library/src/math/style.rs new file mode 100644 index 00000000..235770db --- /dev/null +++ b/crates/typst-library/src/math/style.rs @@ -0,0 +1,620 @@ +use super::*; + +/// Bold font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ bold(A) := B^+ $ +/// ``` +/// +/// Display: Bold +/// Category: math +#[func] +pub fn bold( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_bold(Some(true)).pack() +} + +/// Upright (non-italic) font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ upright(A) != A $ +/// ``` +/// +/// Display: Upright +/// Category: math +#[func] +pub fn upright( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_italic(Some(false)).pack() +} + +/// Italic font style in math. +/// +/// For roman letters and greek lowercase letters, this is already the default. +/// +/// Display: Italic +/// Category: math +#[func] +pub fn italic( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_italic(Some(true)).pack() +} +/// Serif (roman) font style in math. +/// +/// This is already the default. +/// +/// Display: Serif +/// Category: math +#[func] +pub fn serif( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Serif)).pack() +} + +/// Sans-serif font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ sans(A B C) $ +/// ``` +/// +/// Display: Sans-serif +/// Category: math +#[func] +pub fn sans( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Sans)).pack() +} + +/// Calligraphic font style in math. +/// +/// ## Example { #example } +/// ```example +/// Let $cal(P)$ be the set of ... +/// ``` +/// +/// Display: Calligraphic +/// Category: math +#[func] +pub fn cal( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Cal)).pack() +} + +/// Fraktur font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ frak(P) $ +/// ``` +/// +/// Display: Fraktur +/// Category: math +#[func] +pub fn frak( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Frak)).pack() +} + +/// Monospace font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ mono(x + y = z) $ +/// ``` +/// +/// Display: Monospace +/// Category: math +#[func] +pub fn mono( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Mono)).pack() +} + +/// Blackboard bold (double-struck) font style in math. +/// +/// For uppercase latin letters, blackboard bold is additionally available +/// through [symbols]($category/symbols/sym) of the form `NN` and `RR`. +/// +/// ## Example { #example } +/// ```example +/// $ bb(b) $ +/// $ bb(N) = NN $ +/// $ f: NN -> RR $ +/// ``` +/// +/// Display: Blackboard Bold +/// Category: math +#[func] +pub fn bb( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Bb)).pack() +} + +/// Forced display style in math. +/// +/// This is the normal size for block equations. +/// +/// ## Example { #example } +/// ```example +/// $sum_i x_i/2 = display(sum_i x_i/2)$ +/// ``` +/// +/// Display: Display Size +/// Category: math +#[func] +pub fn display( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(false)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::Display)) + .with_cramped(Some(cramped)) + .pack() +} + +/// Forced inline (text) style in math. +/// +/// This is the normal size for inline equations. +/// +/// ## Example { #example } +/// ```example +/// $ sum_i x_i/2 +/// = inline(sum_i x_i/2) $ +/// ``` +/// +/// Display: Inline Size +/// Category: math +#[func] +pub fn inline( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(false)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::Text)) + .with_cramped(Some(cramped)) + .pack() +} + +/// Forced script style in math. +/// +/// This is the smaller size used in powers or sub- or superscripts. +/// +/// ## Example { #example } +/// ```example +/// $sum_i x_i/2 = script(sum_i x_i/2)$ +/// ``` +/// +/// Display: Script Size +/// Category: math +#[func] +pub fn script( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(true)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::Script)) + .with_cramped(Some(cramped)) + .pack() +} + +/// Forced second script style in math. +/// +/// This is the smallest size, used in second-level sub- and superscripts +/// (script of the script). +/// +/// ## Example { #example } +/// ```example +/// $sum_i x_i/2 = sscript(sum_i x_i/2)$ +/// ``` +/// +/// Display: Script-Script Size +/// Category: math +#[func] +pub fn sscript( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(true)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::ScriptScript)) + .with_cramped(Some(cramped)) + .pack() +} + +/// A font variant in math. +/// +/// Display: Bold +/// Category: math +#[element(LayoutMath)] +pub struct MathStyleElem { + /// The content to style. + #[required] + pub body: Content, + + /// The variant to select. + pub variant: Option<MathVariant>, + + /// Whether to use bold glyphs. + pub bold: Option<bool>, + + /// Whether to use italic glyphs. + pub italic: Option<bool>, + + /// Whether to use forced size + pub size: Option<MathSize>, + + /// Whether to limit height of exponents + pub cramped: Option<bool>, +} + +impl LayoutMath for MathStyleElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut style = ctx.style; + if let Some(variant) = self.variant(StyleChain::default()) { + style = style.with_variant(variant); + } + if let Some(bold) = self.bold(StyleChain::default()) { + style = style.with_bold(bold); + } + if let Some(italic) = self.italic(StyleChain::default()) { + style = style.with_italic(italic); + } + if let Some(size) = self.size(StyleChain::default()) { + style = style.with_size(size); + } + if let Some(cramped) = self.cramped(StyleChain::default()) { + style = style.with_cramped(cramped); + } + ctx.style(style); + self.body().layout_math(ctx)?; + ctx.unstyle(); + Ok(()) + } +} + +/// Text properties in math. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct MathStyle { + /// The style variant to select. + pub variant: MathVariant, + /// The size of the glyphs. + pub size: MathSize, + /// Affects the height of exponents. + pub cramped: bool, + /// Whether to use bold glyphs. + pub bold: bool, + /// Whether to use italic glyphs. + pub italic: Smart<bool>, +} + +impl MathStyle { + /// This style, with the given `variant`. + pub fn with_variant(self, variant: MathVariant) -> Self { + Self { variant, ..self } + } + + /// This style, with the given `size`. + pub fn with_size(self, size: MathSize) -> Self { + Self { size, ..self } + } + + /// This style, with `cramped` set to the given value. + pub fn with_cramped(self, cramped: bool) -> Self { + Self { cramped, ..self } + } + + /// This style, with `bold` set to the given value. + pub fn with_bold(self, bold: bool) -> Self { + Self { bold, ..self } + } + + /// This style, with `italic` set to the given value. + pub fn with_italic(self, italic: bool) -> Self { + Self { italic: Smart::Custom(italic), ..self } + } + + /// The style for subscripts in the current style. + pub fn for_subscript(self) -> Self { + self.for_superscript().with_cramped(true) + } + + /// The style for superscripts in the current style. + pub fn for_superscript(self) -> Self { + self.with_size(match self.size { + MathSize::Display | MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + } + + /// The style for numerators in the current style. + pub fn for_numerator(self) -> Self { + self.with_size(match self.size { + MathSize::Display => MathSize::Text, + MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + } + + /// The style for denominators in the current style. + pub fn for_denominator(self) -> Self { + self.for_numerator().with_cramped(true) + } + + /// Apply the style to a character. + pub fn styled_char(self, c: char) -> char { + styled_char(self, c) + } +} + +/// The size of elements in an equation. +/// +/// See the TeXbook p. 141. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Cast)] +pub enum MathSize { + /// Second-level sub- and superscripts. + ScriptScript, + /// Sub- and superscripts. + Script, + /// Math in text. + Text, + /// Math on its own line. + Display, +} + +impl MathSize { + pub(super) fn factor(self, ctx: &MathContext) -> f64 { + match self { + Self::Display | Self::Text => 1.0, + Self::Script => percent!(ctx, script_percent_scale_down), + Self::ScriptScript => percent!(ctx, script_script_percent_scale_down), + } + } +} + +/// A mathematical style variant, as defined by Unicode. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Cast)] +pub enum MathVariant { + Serif, + Sans, + Cal, + Frak, + Mono, + Bb, +} + +impl Default for MathVariant { + fn default() -> Self { + Self::Serif + } +} + +/// Select the correct styled math letter. +/// +/// https://www.w3.org/TR/mathml-core/#new-text-transform-mappings +/// https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols +pub(super) fn styled_char(style: MathStyle, c: char) -> char { + use MathVariant::*; + + let MathStyle { variant, bold, .. } = style; + let italic = style.italic.unwrap_or(matches!( + c, + 'a'..='z' | 'ı' | 'ȷ' | 'A'..='Z' | 'α'..='ω' | + '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ' + )); + + if let Some(c) = basic_exception(c) { + return c; + } + + if let Some(c) = latin_exception(c, variant, bold, italic) { + return c; + } + + if let Some(c) = greek_exception(c, variant, bold, italic) { + return c; + } + + let base = match c { + 'A'..='Z' => 'A', + 'a'..='z' => 'a', + 'Α'..='Ω' => 'Α', + 'α'..='ω' => 'α', + '0'..='9' => '0', + _ => return c, + }; + + let tuple = (variant, bold, italic); + let start = match c { + // Latin upper. + 'A'..='Z' => match tuple { + (Serif, false, false) => 0x0041, + (Serif, true, false) => 0x1D400, + (Serif, false, true) => 0x1D434, + (Serif, true, true) => 0x1D468, + (Sans, false, false) => 0x1D5A0, + (Sans, true, false) => 0x1D5D4, + (Sans, false, true) => 0x1D608, + (Sans, true, true) => 0x1D63C, + (Cal, false, _) => 0x1D49C, + (Cal, true, _) => 0x1D4D0, + (Frak, false, _) => 0x1D504, + (Frak, true, _) => 0x1D56C, + (Mono, _, _) => 0x1D670, + (Bb, _, _) => 0x1D538, + }, + + // Latin lower. + 'a'..='z' => match tuple { + (Serif, false, false) => 0x0061, + (Serif, true, false) => 0x1D41A, + (Serif, false, true) => 0x1D44E, + (Serif, true, true) => 0x1D482, + (Sans, false, false) => 0x1D5BA, + (Sans, true, false) => 0x1D5EE, + (Sans, false, true) => 0x1D622, + (Sans, true, true) => 0x1D656, + (Cal, false, _) => 0x1D4B6, + (Cal, true, _) => 0x1D4EA, + (Frak, false, _) => 0x1D51E, + (Frak, true, _) => 0x1D586, + (Mono, _, _) => 0x1D68A, + (Bb, _, _) => 0x1D552, + }, + + // Greek upper. + 'Α'..='Ω' => match tuple { + (Serif, false, false) => 0x0391, + (Serif, true, false) => 0x1D6A8, + (Serif, false, true) => 0x1D6E2, + (Serif, true, true) => 0x1D71C, + (Sans, _, false) => 0x1D756, + (Sans, _, true) => 0x1D790, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Greek lower. + 'α'..='ω' => match tuple { + (Serif, false, false) => 0x03B1, + (Serif, true, false) => 0x1D6C2, + (Serif, false, true) => 0x1D6FC, + (Serif, true, true) => 0x1D736, + (Sans, _, false) => 0x1D770, + (Sans, _, true) => 0x1D7AA, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Numbers. + '0'..='9' => match tuple { + (Serif, false, _) => 0x0030, + (Serif, true, _) => 0x1D7CE, + (Bb, _, _) => 0x1D7D8, + (Sans, false, _) => 0x1D7E2, + (Sans, true, _) => 0x1D7EC, + (Mono, _, _) => 0x1D7F6, + (Cal | Frak, _, _) => return c, + }, + + _ => unreachable!(), + }; + + std::char::from_u32(start + (c as u32 - base as u32)).unwrap() +} + +fn basic_exception(c: char) -> Option<char> { + Some(match c { + '〈' => '⟨', + '〉' => '⟩', + '《' => '⟪', + '》' => '⟫', + _ => return None, + }) +} + +fn latin_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option<char> { + use MathVariant::*; + Some(match (c, variant, bold, italic) { + ('B', Cal, false, _) => 'ℬ', + ('E', Cal, false, _) => 'ℰ', + ('F', Cal, false, _) => 'ℱ', + ('H', Cal, false, _) => 'ℋ', + ('I', Cal, false, _) => 'ℐ', + ('L', Cal, false, _) => 'ℒ', + ('M', Cal, false, _) => 'ℳ', + ('R', Cal, false, _) => 'ℛ', + ('C', Frak, false, _) => 'ℭ', + ('H', Frak, false, _) => 'ℌ', + ('I', Frak, false, _) => 'ℑ', + ('R', Frak, false, _) => 'ℜ', + ('Z', Frak, false, _) => 'ℨ', + ('C', Bb, ..) => 'ℂ', + ('H', Bb, ..) => 'ℍ', + ('N', Bb, ..) => 'ℕ', + ('P', Bb, ..) => 'ℙ', + ('Q', Bb, ..) => 'ℚ', + ('R', Bb, ..) => 'ℝ', + ('Z', Bb, ..) => 'ℤ', + ('h', Serif, false, true) => 'ℎ', + ('e', Cal, false, _) => 'ℯ', + ('g', Cal, false, _) => 'ℊ', + ('o', Cal, false, _) => 'ℴ', + ('ı', Serif, .., true) => '𝚤', + ('ȷ', Serif, .., true) => '𝚥', + _ => return None, + }) +} + +fn greek_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option<char> { + use MathVariant::*; + let list = match c { + 'ϴ' => ['𝚹', '𝛳', '𝜭', '𝝧', '𝞡'], + '∇' => ['𝛁', '𝛻', '𝜵', '𝝯', '𝞩'], + '∂' => ['𝛛', '𝜕', '𝝏', '𝞉', '𝟃'], + 'ϵ' => ['𝛜', '𝜖', '𝝐', '𝞊', '𝟄'], + 'ϑ' => ['𝛝', '𝜗', '𝝑', '𝞋', '𝟅'], + 'ϰ' => ['𝛞', '𝜘', '𝝒', '𝞌', '𝟆'], + 'ϕ' => ['𝛟', '𝜙', '𝝓', '𝞍', '𝟇'], + 'ϱ' => ['𝛠', '𝜚', '𝝔', '𝞎', '𝟈'], + 'ϖ' => ['𝛡', '𝜛', '𝝕', '𝞏', '𝟉'], + _ => return None, + }; + + Some(match (variant, bold, italic) { + (Serif, true, false) => list[0], + (Serif, false, true) => list[1], + (Serif, true, true) => list[2], + (Sans, _, false) => list[3], + (Sans, _, true) => list[4], + _ => return None, + }) +} diff --git a/crates/typst-library/src/math/underover.rs b/crates/typst-library/src/math/underover.rs new file mode 100644 index 00000000..796c9ebc --- /dev/null +++ b/crates/typst-library/src/math/underover.rs @@ -0,0 +1,339 @@ +use super::*; + +const BRACE_GAP: Em = Em::new(0.25); +const BRACKET_GAP: Em = Em::new(0.25); + +/// A marker to distinguish under- vs. overlines. +enum LineKind { + Over, + Under, +} + +/// A horizontal line under content. +/// +/// ## Example { #example } +/// ```example +/// $ underline(1 + 2 + ... + 5) $ +/// ``` +/// +/// Display: Underline +/// Category: math +#[element(LayoutMath)] +pub struct UnderlineElem { + /// The content above the line. + #[required] + pub body: Content, +} + +impl LayoutMath for UnderlineElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverline(ctx, &self.body(), self.span(), LineKind::Under) + } +} + +/// A horizontal line over content. +/// +/// ## Example { #example } +/// ```example +/// $ overline(1 + 2 + ... + 5) $ +/// ``` +/// +/// Display: Overline +/// Category: math +#[element(LayoutMath)] +pub struct OverlineElem { + /// The content below the line. + #[required] + pub body: Content, +} + +impl LayoutMath for OverlineElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverline(ctx, &self.body(), self.span(), LineKind::Over) + } +} + +/// layout under- or overlined content +fn layout_underoverline( + ctx: &mut MathContext, + body: &Content, + span: Span, + line: LineKind, +) -> SourceResult<()> { + let (extra_height, content, line_pos, content_pos, baseline, bar_height); + match line { + LineKind::Under => { + let sep = scaled!(ctx, underbar_extra_descender); + bar_height = scaled!(ctx, underbar_rule_thickness); + let gap = scaled!(ctx, underbar_vertical_gap); + extra_height = sep + bar_height + gap; + + content = ctx.layout_fragment(body)?; + + line_pos = Point::with_y(content.height() + gap + bar_height / 2.0); + content_pos = Point::zero(); + baseline = content.ascent() + } + LineKind::Over => { + let sep = scaled!(ctx, overbar_extra_ascender); + bar_height = scaled!(ctx, overbar_rule_thickness); + let gap = scaled!(ctx, overbar_vertical_gap); + extra_height = sep + bar_height + gap; + + ctx.style(ctx.style.with_cramped(true)); + content = ctx.layout_fragment(body)?; + ctx.unstyle(); + + line_pos = Point::with_y(sep + bar_height / 2.0); + content_pos = Point::with_y(extra_height); + baseline = content.ascent() + extra_height; + } + } + + let width = content.width(); + let height = content.height() + extra_height; + let size = Size::new(width, height); + + let content_class = content.class().unwrap_or(MathClass::Normal); + let mut frame = Frame::new(size); + frame.set_baseline(baseline); + frame.push_frame(content_pos, content.into_frame()); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(width)).stroked(Stroke { + paint: TextElem::fill_in(ctx.styles()), + thickness: bar_height, + ..Stroke::default() + }), + span, + ), + ); + + ctx.push(FrameFragment::new(ctx, frame).with_class(content_class)); + + Ok(()) +} + +/// A horizontal brace under content, with an optional annotation below. +/// +/// ## Example { #example } +/// ```example +/// $ underbrace(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Underbrace +/// Category: math +#[element(LayoutMath)] +pub struct UnderbraceElem { + /// The content above the brace. + #[required] + pub body: Content, + + /// The optional content below the brace. + #[positional] + pub annotation: Option<Content>, +} + +impl LayoutMath for UnderbraceElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⏟', + BRACE_GAP, + false, + self.span(), + ) + } +} + +/// A horizontal brace over content, with an optional annotation above. +/// +/// ## Example { #example } +/// ```example +/// $ overbrace(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Overbrace +/// Category: math +#[element(LayoutMath)] +pub struct OverbraceElem { + /// The content below the brace. + #[required] + pub body: Content, + + /// The optional content above the brace. + #[positional] + pub annotation: Option<Content>, +} + +impl LayoutMath for OverbraceElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⏞', + BRACE_GAP, + true, + self.span(), + ) + } +} + +/// A horizontal bracket under content, with an optional annotation below. +/// +/// ## Example { #example } +/// ```example +/// $ underbracket(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Underbracket +/// Category: math +#[element(LayoutMath)] +pub struct UnderbracketElem { + /// The content above the bracket. + #[required] + pub body: Content, + + /// The optional content below the bracket. + #[positional] + pub annotation: Option<Content>, +} + +impl LayoutMath for UnderbracketElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⎵', + BRACKET_GAP, + false, + self.span(), + ) + } +} + +/// A horizontal bracket over content, with an optional annotation above. +/// +/// ## Example { #example } +/// ```example +/// $ overbracket(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Overbracket +/// Category: math +#[element(LayoutMath)] +pub struct OverbracketElem { + /// The content below the bracket. + #[required] + pub body: Content, + + /// The optional content above the bracket. + #[positional] + pub annotation: Option<Content>, +} + +impl LayoutMath for OverbracketElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⎴', + BRACKET_GAP, + true, + self.span(), + ) + } +} + +/// Layout an over- or underbrace-like object. +fn layout_underoverspreader( + ctx: &mut MathContext, + body: &Content, + annotation: &Option<Content>, + c: char, + gap: Em, + reverse: bool, + span: Span, +) -> SourceResult<()> { + let gap = gap.scaled(ctx); + let body = ctx.layout_row(body)?; + let body_class = body.class(); + let body = body.into_fragment(ctx); + let glyph = GlyphFragment::new(ctx, c, span); + let stretched = glyph.stretch_horizontal(ctx, body.width(), Abs::zero()); + + let mut rows = vec![MathRow::new(vec![body]), stretched.into()]; + ctx.style(if reverse { + ctx.style.for_subscript() + } else { + ctx.style.for_superscript() + }); + rows.extend( + annotation + .as_ref() + .map(|annotation| ctx.layout_row(annotation)) + .transpose()?, + ); + ctx.unstyle(); + + let mut baseline = 0; + if reverse { + rows.reverse(); + baseline = rows.len() - 1; + } + + let frame = stack(ctx, rows, Align::Center, gap, baseline); + ctx.push(FrameFragment::new(ctx, frame).with_class(body_class)); + + Ok(()) +} + +/// Stack rows on top of each other. +/// +/// Add a `gap` between each row and uses the baseline of the `baseline`th +/// row for the whole frame. +pub(super) fn stack( + ctx: &MathContext, + rows: Vec<MathRow>, + align: Align, + gap: Abs, + baseline: usize, +) -> Frame { + let rows: Vec<_> = rows.into_iter().flat_map(|r| r.rows()).collect(); + let AlignmentResult { points, width } = alignments(&rows); + let rows: Vec<_> = rows + .into_iter() + .map(|row| row.into_aligned_frame(ctx, &points, align)) + .collect(); + + let mut y = Abs::zero(); + let mut frame = Frame::new(Size::new( + width, + rows.iter().map(|row| row.height()).sum::<Abs>() + + rows.len().saturating_sub(1) as f64 * gap, + )); + + for (i, row) in rows.into_iter().enumerate() { + let x = align.position(width - row.width()); + let pos = Point::new(x, y); + if i == baseline { + frame.set_baseline(y + row.baseline()); + } + y += row.height() + gap; + frame.push_frame(pos, row); + } + + frame +} diff --git a/crates/typst-library/src/meta/bibliography.rs b/crates/typst-library/src/meta/bibliography.rs new file mode 100644 index 00000000..0531997d --- /dev/null +++ b/crates/typst-library/src/meta/bibliography.rs @@ -0,0 +1,724 @@ +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::Path; +use std::sync::Arc; + +use ecow::{eco_vec, EcoVec}; +use hayagriva::io::{BibLaTeXError, YamlBibliographyError}; +use hayagriva::style::{self, Brackets, Citation, Database, DisplayString, Formatting}; +use hayagriva::Entry; +use typst::diag::FileError; +use typst::util::{option_eq, Bytes}; + +use super::{LinkElem, LocalName, RefElem}; +use crate::layout::{BlockElem, GridElem, ParElem, Sizing, TrackSizings, VElem}; +use crate::meta::{FootnoteElem, HeadingElem}; +use crate::prelude::*; +use crate::text::TextElem; + +/// A bibliography / reference listing. +/// +/// You can create a new bibliography by calling this function with a path +/// to a bibliography file in either one of two formats: +/// +/// - A Hayagriva `.yml` file. Hayagriva is a new bibliography file format +/// designed for use with Typst. Visit its +/// [documentation](https://github.com/typst/hayagriva/blob/main/docs/file-format.md) +/// for more details. +/// - A BibLaTeX `.bib` file. +/// +/// As soon as you add a bibliography somewhere in your document, you can start +/// citing things with reference syntax (`[@key]`) or explicit calls to the +/// [citation]($func/cite) function (`[#cite("key")]`). The bibliography will +/// only show entries for works that were referenced in the document. +/// +/// # Example +/// ```example +/// This was already noted by +/// pirates long ago. @arrgh +/// +/// Multiple sources say ... +/// #cite("arrgh", "netwok"). +/// +/// #bibliography("works.bib") +/// ``` +/// +/// Display: Bibliography +/// Category: meta +#[element(Locatable, Synthesize, Show, Finalize, LocalName)] +pub struct BibliographyElem { + /// Path to a Hayagriva `.yml` or BibLaTeX `.bib` file. + #[required] + #[parse( + let Spanned { v: paths, span } = + args.expect::<Spanned<BibPaths>>("path to bibliography file")?; + + // Load bibliography files. + let data = paths.0 + .iter() + .map(|path| { + let id = vm.location().join(path).at(span)?; + vm.world().file(id).at(span) + }) + .collect::<SourceResult<Vec<Bytes>>>()?; + + // Check that parsing works. + let _ = load(&paths, &data).at(span)?; + + paths + )] + pub path: BibPaths, + + /// The raw file buffers. + #[internal] + #[required] + #[parse(data)] + pub data: Vec<Bytes>, + + /// The title of the bibliography. + /// + /// - When set to `{auto}`, an appropriate title for the [text + /// language]($func/text.lang) will be used. This is the default. + /// - When set to `{none}`, the bibliography will not have a title. + /// - A custom title can be set by passing content. + /// + /// The bibliography's heading will not be numbered by default, but you can + /// force it to be with a show-set rule: + /// `{show bibliography: set heading(numbering: "1.")}` + /// ``` + #[default(Some(Smart::Auto))] + pub title: Option<Smart<Content>>, + + /// The bibliography style. + #[default(BibliographyStyle::Ieee)] + pub style: BibliographyStyle, +} + +/// A list of bibliography file paths. +#[derive(Debug, Default, Clone, Hash)] +pub struct BibPaths(Vec<EcoString>); + +cast! { + BibPaths, + self => self.0.into_value(), + v: EcoString => Self(vec![v]), + v: Array => Self(v.into_iter().map(Value::cast).collect::<StrResult<_>>()?), +} + +impl BibliographyElem { + /// Find the document's bibliography. + pub fn find(introspector: Tracked<Introspector>) -> StrResult<Self> { + let mut iter = introspector.query(&Self::func().select()).into_iter(); + let Some(elem) = iter.next() else { + bail!("the document does not contain a bibliography"); + }; + + if iter.next().is_some() { + bail!("multiple bibliographies are not supported"); + } + + Ok(elem.to::<Self>().unwrap().clone()) + } + + /// Whether the bibliography contains the given key. + pub fn has(vt: &Vt, key: &str) -> bool { + vt.introspector + .query(&Self::func().select()) + .into_iter() + .flat_map(|elem| { + let elem = elem.to::<Self>().unwrap(); + load(&elem.path(), &elem.data()) + }) + .flatten() + .any(|entry| entry.key() == key) + } + + /// Find all bibliography keys. + pub fn keys( + introspector: Tracked<Introspector>, + ) -> Vec<(EcoString, Option<EcoString>)> { + Self::find(introspector) + .and_then(|elem| load(&elem.path(), &elem.data())) + .into_iter() + .flatten() + .map(|entry| { + let key = entry.key().into(); + let detail = + entry.title().map(|title| title.canonical.value.as_str().into()); + (key, detail) + }) + .collect() + } +} + +impl Synthesize for BibliographyElem { + fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + self.push_style(self.style(styles)); + Ok(()) + } +} + +impl Show for BibliographyElem { + #[tracing::instrument(name = "BibliographyElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + const COLUMN_GUTTER: Em = Em::new(0.65); + const INDENT: Em = Em::new(1.5); + + let mut seq = vec![]; + if let Some(title) = self.title(styles) { + let title = + title.unwrap_or_else(|| { + TextElem::packed(self.local_name( + TextElem::lang_in(styles), + TextElem::region_in(styles), + )) + .spanned(self.span()) + }); + + seq.push(HeadingElem::new(title).with_level(NonZeroUsize::ONE).pack()); + } + + Ok(vt.delayed(|vt| { + let works = Works::new(vt).at(self.span())?; + + let row_gutter = BlockElem::below_in(styles).amount(); + if works.references.iter().any(|(prefix, _)| prefix.is_some()) { + let mut cells = vec![]; + for (prefix, reference) in &works.references { + cells.push(prefix.clone().unwrap_or_default()); + cells.push(reference.clone()); + } + + seq.push(VElem::new(row_gutter).with_weakness(3).pack()); + seq.push( + GridElem::new(cells) + .with_columns(TrackSizings(vec![Sizing::Auto; 2])) + .with_column_gutter(TrackSizings(vec![COLUMN_GUTTER.into()])) + .with_row_gutter(TrackSizings(vec![row_gutter.into()])) + .pack(), + ); + } else { + let mut entries = vec![]; + for (_, reference) in &works.references { + entries.push(VElem::new(row_gutter).with_weakness(3).pack()); + entries.push(reference.clone()); + } + + seq.push( + Content::sequence(entries) + .styled(ParElem::set_hanging_indent(INDENT.into())), + ); + } + + Ok(Content::sequence(seq)) + })) + } +} + +impl Finalize for BibliographyElem { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + realized.styled(HeadingElem::set_numbering(None)) + } +} + +impl LocalName for BibliographyElem { + fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "Bibliografi", + Lang::ARABIC => "المراجع", + Lang::BOKMÅL => "Bibliografi", + Lang::CHINESE if option_eq(region, "TW") => "書目", + Lang::CHINESE => "参考文献", + Lang::CZECH => "Bibliografie", + Lang::DANISH => "Bibliografi", + Lang::DUTCH => "Bibliografie", + Lang::FILIPINO => "Bibliograpiya", + Lang::FRENCH => "Bibliographie", + Lang::GERMAN => "Bibliographie", + Lang::ITALIAN => "Bibliografia", + Lang::NYNORSK => "Bibliografi", + Lang::POLISH => "Bibliografia", + Lang::PORTUGUESE => "Bibliografia", + Lang::RUSSIAN => "Библиография", + Lang::SLOVENIAN => "Literatura", + Lang::SPANISH => "Bibliografía", + Lang::SWEDISH => "Bibliografi", + Lang::TURKISH => "Kaynakça", + Lang::UKRAINIAN => "Бібліографія", + Lang::VIETNAMESE => "Tài liệu tham khảo", + Lang::ENGLISH | _ => "Bibliography", + } + } +} + +/// A bibliography style. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum BibliographyStyle { + /// Follows guidance of the American Psychological Association. Based on the + /// 7th edition of the APA Publication Manual. + Apa, + /// The Chicago Author Date style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 15. + ChicagoAuthorDate, + /// The Chicago Notes style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 14. + ChicagoNotes, + /// The style of the Institute of Electrical and Electronics Engineers. + /// Based on the 2018 IEEE Reference Guide. + Ieee, + /// Follows guidance of the Modern Language Association. Based on the 8th + /// edition of the MLA Handbook. + Mla, +} + +impl BibliographyStyle { + /// The default citation style for this bibliography style. + pub fn default_citation_style(self) -> CitationStyle { + match self { + Self::Apa => CitationStyle::ChicagoAuthorDate, + Self::ChicagoAuthorDate => CitationStyle::ChicagoAuthorDate, + Self::ChicagoNotes => CitationStyle::ChicagoNotes, + Self::Ieee => CitationStyle::Numerical, + Self::Mla => CitationStyle::ChicagoAuthorDate, + } + } +} + +/// Cite a work from the bibliography. +/// +/// Before you starting citing, you need to add a +/// [bibliography]($func/bibliography) somewhere in your document. +/// +/// # Example +/// ```example +/// This was already noted by +/// pirates long ago. @arrgh +/// +/// Multiple sources say ... +/// #cite("arrgh", "netwok"). +/// +/// #bibliography("works.bib") +/// ``` +/// +/// # Syntax +/// This function indirectly has dedicated syntax. [References]($func/ref) +/// can be used to cite works from the bibliography. The label then +/// corresponds to the citation key. +/// +/// Display: Citation +/// Category: meta +#[element(Locatable, Synthesize, Show)] +pub struct CiteElem { + /// The citation keys that identify the elements that shall be cited in + /// the bibliography. + /// + /// Reference syntax supports only a single key. + #[variadic] + pub keys: Vec<EcoString>, + + /// A supplement for the citation such as page or chapter number. + /// + /// In reference syntax, the supplement can be added in square brackets: + /// + /// ```example + /// This has been proven over and + /// over again. @distress[p.~7] + /// + /// #bibliography("works.bib") + /// ``` + #[positional] + pub supplement: Option<Content>, + + /// Whether the citation should include brackets. + /// + /// ```example + /// #set cite(brackets: false) + /// + /// @netwok follow these methods + /// in their work ... + /// + /// #bibliography( + /// "works.bib", + /// style: "chicago-author-date", + /// ) + /// ``` + #[default(true)] + pub brackets: bool, + + /// The citation style. + /// + /// When set to `{auto}`, automatically picks the preferred citation style + /// for the bibliography's style. + /// + /// ```example + /// #set cite(style: "alphanumerical") + /// Alphanumerical references. + /// @netwok + /// + /// #bibliography("works.bib") + /// ``` + pub style: Smart<CitationStyle>, +} + +impl Synthesize for CiteElem { + fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + self.push_supplement(self.supplement(styles)); + self.push_brackets(self.brackets(styles)); + self.push_style(self.style(styles)); + Ok(()) + } +} + +impl Show for CiteElem { + #[tracing::instrument(name = "CiteElem::show", skip(self, vt))] + fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> { + Ok(vt.delayed(|vt| { + let works = Works::new(vt).at(self.span())?; + let location = self.0.location().unwrap(); + works + .citations + .get(&location) + .cloned() + .flatten() + .ok_or("bibliography does not contain this key") + .at(self.span()) + })) + } +} + +cast! { + CiteElem, + v: Content => v.to::<Self>().cloned().ok_or("expected citation")?, +} + +/// A citation style. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum CitationStyle { + /// IEEE-style numerical reference markers. + Numerical, + /// A simple alphanumerical style. For example, the output could be Rass97 + /// or MKG+21. + Alphanumerical, + /// Citations that just consist of the entry keys. + Keys, + /// The Chicago Author Date style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 15. + ChicagoAuthorDate, + /// The Chicago Notes style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 14. + ChicagoNotes, + /// A Chicago-like author-title format. Results could look like this: + /// Prokopov, “It Is Fast or It Is Wrong”. + ChicagoAuthorTitle, +} + +impl CitationStyle { + fn is_short(self) -> bool { + matches!(self, Self::Numerical | Self::Alphanumerical | Self::Keys) + } +} + +/// Fully formatted citations and references. +#[derive(Default)] +struct Works { + citations: HashMap<Location, Option<Content>>, + references: Vec<(Option<Content>, Content)>, +} + +impl Works { + /// Prepare all things need to cite a work or format a bibliography. + fn new(vt: &Vt) -> StrResult<Arc<Self>> { + let bibliography = BibliographyElem::find(vt.introspector)?; + let citations = vt + .introspector + .query(&Selector::Or(eco_vec![ + RefElem::func().select(), + CiteElem::func().select(), + ])) + .into_iter() + .map(|elem| match elem.to::<RefElem>() { + Some(reference) => reference.citation().unwrap(), + _ => elem.to::<CiteElem>().unwrap().clone(), + }) + .collect(); + Ok(create(bibliography, citations)) + } +} + +/// Generate all citations and the whole bibliography. +#[comemo::memoize] +fn create(bibliography: BibliographyElem, citations: Vec<CiteElem>) -> Arc<Works> { + let span = bibliography.span(); + let entries = load(&bibliography.path(), &bibliography.data()).unwrap(); + let style = bibliography.style(StyleChain::default()); + let bib_location = bibliography.0.location().unwrap(); + let ref_location = |target: &Entry| { + let i = entries + .iter() + .position(|entry| entry.key() == target.key()) + .unwrap_or_default(); + bib_location.variant(i) + }; + + let mut db = Database::new(); + let mut ids = HashMap::new(); + let mut preliminary = vec![]; + + for citation in citations { + let cite_id = citation.0.location().unwrap(); + let entries = citation + .keys() + .into_iter() + .map(|key| { + let entry = entries.iter().find(|entry| entry.key() == key)?; + ids.entry(entry.key()).or_insert(cite_id); + db.push(entry); + Some(entry) + }) + .collect::<Option<Vec<_>>>(); + preliminary.push((citation, entries)); + } + + let mut current = CitationStyle::Numerical; + let mut citation_style: Box<dyn style::CitationStyle> = + Box::new(style::Numerical::new()); + + let citations = preliminary + .into_iter() + .map(|(citation, cited)| { + let location = citation.0.location().unwrap(); + let Some(cited) = cited else { return (location, None) }; + + let mut supplement = citation.supplement(StyleChain::default()); + let brackets = citation.brackets(StyleChain::default()); + let style = citation + .style(StyleChain::default()) + .unwrap_or(style.default_citation_style()); + + if style != current { + current = style; + citation_style = match style { + CitationStyle::Numerical => Box::new(style::Numerical::new()), + CitationStyle::Alphanumerical => { + Box::new(style::Alphanumerical::new()) + } + CitationStyle::ChicagoAuthorDate => { + Box::new(style::ChicagoAuthorDate::new()) + } + CitationStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()), + CitationStyle::ChicagoAuthorTitle => { + Box::new(style::AuthorTitle::new()) + } + CitationStyle::Keys => Box::new(style::Keys::new()), + }; + } + + let len = cited.len(); + let mut content = Content::empty(); + for (i, entry) in cited.into_iter().enumerate() { + let supplement = if i + 1 == len { supplement.take() } else { None }; + let mut display = db + .citation( + &mut *citation_style, + &[Citation { + entry, + supplement: supplement.is_some().then_some(SUPPLEMENT), + }], + ) + .display; + + if style.is_short() { + display.value = display.value.replace(' ', "\u{a0}"); + } + + if brackets && len == 1 { + display = display.with_default_brackets(&*citation_style); + } + + if i > 0 { + content += TextElem::packed(",\u{a0}"); + } + + // Format and link to the reference entry. + content += format_display_string(&display, supplement, citation.span()) + .linked(Destination::Location(ref_location(entry))); + } + + if brackets && len > 1 { + content = match citation_style.brackets() { + Brackets::None => content, + Brackets::Round => { + TextElem::packed('(') + content + TextElem::packed(')') + } + Brackets::Square => { + TextElem::packed('[') + content + TextElem::packed(']') + } + }; + } + + if style == CitationStyle::ChicagoNotes { + content = FootnoteElem::with_content(content).pack(); + } + + (location, Some(content)) + }) + .collect(); + + let bibliography_style: Box<dyn style::BibliographyStyle> = match style { + BibliographyStyle::Apa => Box::new(style::Apa::new()), + BibliographyStyle::ChicagoAuthorDate => Box::new(style::ChicagoAuthorDate::new()), + BibliographyStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()), + BibliographyStyle::Ieee => Box::new(style::Ieee::new()), + BibliographyStyle::Mla => Box::new(style::Mla::new()), + }; + + let references = db + .bibliography(&*bibliography_style, None) + .into_iter() + .map(|reference| { + let backlink = ref_location(reference.entry); + let prefix = reference.prefix.map(|prefix| { + // Format and link to first citation. + let bracketed = prefix.with_default_brackets(&*citation_style); + format_display_string(&bracketed, None, span) + .linked(Destination::Location(ids[reference.entry.key()])) + .backlinked(backlink) + }); + + let mut reference = format_display_string(&reference.display, None, span); + if prefix.is_none() { + reference = reference.backlinked(backlink); + } + + (prefix, reference) + }) + .collect(); + + Arc::new(Works { citations, references }) +} + +/// Load bibliography entries from a path. +#[comemo::memoize] +fn load(paths: &BibPaths, data: &[Bytes]) -> StrResult<EcoVec<hayagriva::Entry>> { + let mut result = EcoVec::new(); + + // We might have multiple bib/yaml files + for (path, bytes) in paths.0.iter().zip(data) { + let src = std::str::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?; + let entries = parse_bib(path, src)?; + result.extend(entries); + } + + // Biblatex only checks for duplicate keys within files + // -> We have to do this between files again + let mut keys = result.iter().map(|r| r.key()).collect::<Vec<_>>(); + keys.sort_unstable(); + // Waiting for `slice_partition_dedup` #54279 + let mut duplicates = Vec::new(); + for pair in keys.windows(2) { + if pair[0] == pair[1] { + duplicates.push(pair[0]); + } + } + + if !duplicates.is_empty() { + Err(eco_format!("duplicate bibliography keys: {}", duplicates.join(", "))) + } else { + Ok(result) + } +} + +/// Parse a bibliography file (bib/yml/yaml) +fn parse_bib(path_str: &str, src: &str) -> StrResult<Vec<hayagriva::Entry>> { + let path = Path::new(path_str); + let ext = path.extension().and_then(OsStr::to_str).unwrap_or_default(); + match ext.to_lowercase().as_str() { + "yml" | "yaml" => { + hayagriva::io::from_yaml_str(src).map_err(format_hayagriva_error) + } + "bib" => hayagriva::io::from_biblatex_str(src).map_err(|err| { + err.into_iter() + .next() + .map(|error| format_biblatex_error(path_str, src, error)) + .unwrap_or_else(|| eco_format!("failed to parse {path_str}")) + }), + _ => bail!("unknown bibliography format (must be .yml/.yaml or .bib)"), + } +} + +/// Format a Hayagriva loading error. +fn format_hayagriva_error(error: YamlBibliographyError) -> EcoString { + eco_format!("{error}") +} + +/// Format a BibLaTeX loading error. +fn format_biblatex_error(path: &str, src: &str, error: BibLaTeXError) -> EcoString { + let (span, msg) = match error { + BibLaTeXError::Parse(error) => (error.span, error.kind.to_string()), + BibLaTeXError::Type(error) => (error.span, error.kind.to_string()), + }; + let line = src.get(..span.start).unwrap_or_default().lines().count(); + eco_format!("parsing failed at {path}:{line}: {msg}") +} + +/// Hayagriva only supports strings, but we have a content supplement. To deal +/// with this, we pass this string to hayagriva instead of our content, find it +/// in the output and replace it with the content. +const SUPPLEMENT: &str = "cdc579c45cf3d648905c142c7082683f"; + +/// Format a display string into content. +fn format_display_string( + string: &DisplayString, + mut supplement: Option<Content>, + span: Span, +) -> Content { + let mut stops: Vec<_> = string + .formatting + .iter() + .flat_map(|(range, _)| [range.start, range.end]) + .collect(); + + if let Some(i) = string.value.find(SUPPLEMENT) { + stops.push(i); + stops.push(i + SUPPLEMENT.len()); + } + + stops.sort(); + stops.dedup(); + stops.push(string.value.len()); + + let mut start = 0; + let mut seq = vec![]; + for stop in stops { + let segment = string.value.get(start..stop).unwrap_or_default(); + if segment.is_empty() { + continue; + } + + let mut content = if segment == SUPPLEMENT && supplement.is_some() { + supplement.take().unwrap_or_default() + } else { + TextElem::packed(segment).spanned(span) + }; + + for (range, fmt) in &string.formatting { + if !range.contains(&start) { + continue; + } + + content = match fmt { + Formatting::Bold => content.strong(), + Formatting::Italic => content.emph(), + Formatting::Link(link) => { + LinkElem::new(Destination::Url(link.as_str().into()).into(), content) + .pack() + } + }; + } + + seq.push(content); + start = stop; + } + + Content::sequence(seq) +} diff --git a/crates/typst-library/src/meta/context.rs b/crates/typst-library/src/meta/context.rs new file mode 100644 index 00000000..a42c6980 --- /dev/null +++ b/crates/typst-library/src/meta/context.rs @@ -0,0 +1,220 @@ +use crate::prelude::*; + +/// Provides access to the location of content. +/// +/// This is useful in combination with [queries]($func/query), +/// [counters]($func/counter), [state]($func/state), and [links]($func/link). +/// See their documentation for more details. +/// +/// ```example +/// #locate(loc => [ +/// My location: \ +/// #loc.position()! +/// ]) +/// ``` +/// +/// ## Methods +/// ### page() +/// Returns the page number for this location. +/// +/// Note that this does not return the value of the [page counter]($func/counter) +/// at this location, but the true page number (starting from one). +/// +/// If you want to know the value of the page counter, use +/// `{counter(page).at(loc)}` instead. +/// +/// - returns: integer +/// +/// ### position() +/// Returns a dictionary with the page number and the x, y position for this +/// location. The page number starts at one and the coordinates are measured +/// from the top-left of the page. +/// +/// If you only need the page number, use `page()` instead as it allows Typst +/// to skip unnecessary work. +/// +/// - returns: dictionary +/// +/// ### page-numbering() +/// Returns the page numbering pattern of the page at this location. This can be +/// used when displaying the page counter in order to obtain the local numbering. +/// This is useful if you are building custom indices or outlines. +/// +/// If the page numbering is set to `none` at that location, this function returns `none`. +/// +/// - returns: string or function or none +/// +/// Display: Locate +/// Category: meta +#[func] +pub fn locate( + /// A function that receives a `location`. Its return value is displayed + /// in the document. + /// + /// This function is called once for each time the content returned by + /// `locate` appears in the document. That makes it possible to generate + /// content that depends on its own location in the document. + func: Func, +) -> Content { + LocateElem::new(func).pack() +} + +/// Executes a `locate` call. +/// +/// Display: Locate +/// Category: special +#[element(Locatable, Show)] +struct LocateElem { + /// The function to call with the location. + #[required] + func: Func, +} + +impl Show for LocateElem { + #[tracing::instrument(name = "LocateElem::show", skip(self, vt))] + fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> { + Ok(vt.delayed(|vt| { + let location = self.0.location().unwrap(); + Ok(self.func().call_vt(vt, [location])?.display()) + })) + } +} + +/// Provides access to active styles. +/// +/// The styles are currently opaque and only useful in combination with the +/// [`measure`]($func/measure) function. See its documentation for more details. +/// In the future, the provided styles might also be directly accessed to look +/// up styles defined by [set rules]($styling/#set-rules). +/// +/// ```example +/// #let thing(body) = style(styles => { +/// let size = measure(body, styles) +/// [Width of "#body" is #size.width] +/// }) +/// +/// #thing[Hey] \ +/// #thing[Welcome] +/// ``` +/// +/// Display: Style +/// Category: meta +#[func] +pub fn style( + /// A function to call with the styles. Its return value is displayed + /// in the document. + /// + /// This function is called once for each time the content returned by + /// `style` appears in the document. That makes it possible to generate + /// content that depends on the style context it appears in. + func: Func, +) -> Content { + StyleElem::new(func).pack() +} + +/// Executes a style access. +/// +/// Display: Style +/// Category: special +#[element(Show)] +struct StyleElem { + /// The function to call with the styles. + #[required] + func: Func, +} + +impl Show for StyleElem { + #[tracing::instrument(name = "StyleElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(self.func().call_vt(vt, [styles.to_map()])?.display()) + } +} + +/// Provides access to the current outer container's (or page's, if none) size +/// (width and height). +/// +/// The given function must accept a single parameter, `size`, which is a +/// dictionary with keys `width` and `height`, both of type +/// [`length`]($type/length). +/// + +/// ```example +/// #let text = lorem(30) +/// #layout(size => style(styles => [ +/// #let (height,) = measure( +/// block(width: size.width, text), +/// styles, +/// ) +/// This text is #height high with +/// the current page width: \ +/// #text +/// ])) +/// ``` +/// +/// If the `layout` call is placed inside of a box width a width of `{800pt}` +/// and a height of `{400pt}`, then the specified function will be given the +/// parameter `{(width: 800pt, height: 400pt)}`. If it placed directly into the +/// page it receives the page's dimensions minus its margins. This is mostly +/// useful in combination with [measurement]($func/measure). +/// +/// You can also use this function to resolve [`ratio`]($type/ratio) to fixed +/// lengths. This might come in handy if you're building your own layout +/// abstractions. +/// +/// ```example +/// #layout(size => { +/// let half = 50% * size.width +/// [Half a page is #half wide.] +/// }) +/// ``` +/// +/// Note that this function will provide an infinite width or height if one of +/// the page width or height is `auto`, respectively. +/// +/// Display: Layout +/// Category: meta +#[func] +pub fn layout( + /// A function to call with the outer container's size. Its return value is + /// displayed in the document. + /// + /// The container's size is given as a [dictionary]($type/dictionary) with + /// the keys `width` and `height`. + /// + /// This function is called once for each time the content returned by + /// `layout` appears in the document. That makes it possible to generate + /// content that depends on the size of the container it is inside of. + func: Func, +) -> Content { + LayoutElem::new(func).pack() +} + +/// Executes a `layout` call. +/// +/// Display: Layout +/// Category: special +#[element(Layout)] +struct LayoutElem { + /// The function to call with the outer container's (or page's) size. + #[required] + func: Func, +} + +impl Layout for LayoutElem { + #[tracing::instrument(name = "LayoutElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + // Gets the current region's base size, which will be the size of the + // outer container, or of the page if there is no such container. + let Size { x, y } = regions.base(); + let result = self + .func() + .call_vt(vt, [dict! { "width" => x, "height" => y }])? + .display(); + result.layout(vt, styles, regions) + } +} diff --git a/crates/typst-library/src/meta/counter.rs b/crates/typst-library/src/meta/counter.rs new file mode 100644 index 00000000..9a223b32 --- /dev/null +++ b/crates/typst-library/src/meta/counter.rs @@ -0,0 +1,683 @@ +use std::fmt::{self, Debug, Formatter, Write}; +use std::str::FromStr; + +use ecow::{eco_vec, EcoVec}; +use smallvec::{smallvec, SmallVec}; +use typst::eval::Tracer; +use typst::model::DelayedErrors; + +use super::{FigureElem, HeadingElem, Numbering, NumberingPattern}; +use crate::layout::PageElem; +use crate::math::EquationElem; +use crate::prelude::*; + +/// Counts through pages, elements, and more. +/// +/// With the counter function, you can access and modify counters for pages, +/// headings, figures, and more. Moreover, you can define custom counters for +/// other things you want to count. +/// +/// ## Displaying a counter { #displaying } +/// To display the current value of the heading counter, you call the `counter` +/// function with the `key` set to `heading` and then call the `display` method +/// on the counter. To see any output, you also have to enable heading +/// [numbering]($func/heading.numbering). +/// +/// The `display` method optionally takes an argument telling it how to format +/// the counter. This can be a [numbering pattern or a +/// function]($func/numbering). +/// +/// ```example +/// #set heading(numbering: "1.") +/// +/// = Introduction +/// Some text here. +/// +/// = Background +/// The current value is: +/// #counter(heading).display() +/// +/// Or in roman numerals: +/// #counter(heading).display("I") +/// ``` +/// +/// ## Modifying a counter { #modifying } +/// To modify a counter, you can use the `step` and `update` methods: +/// +/// - The `step` method increases the value of the counter by one. Because +/// counters can have multiple levels (in the case of headings for sections, +/// subsections, and so on), the `step` method optionally takes a `level` +/// argument. If given, the counter steps at the given depth. +/// +/// - The `update` method allows you to arbitrarily modify the counter. In its +/// basic form, you give it an integer (or multiple for multiple levels). For +/// more flexibility, you can instead also give it a function that gets the +/// current value and returns a new value. +/// +/// The heading counter is stepped before the heading is displayed, so +/// `Analysis` gets the number seven even though the counter is at six after the +/// second update. +/// +/// ```example +/// #set heading(numbering: "1.") +/// +/// = Introduction +/// #counter(heading).step() +/// +/// = Background +/// #counter(heading).update(3) +/// #counter(heading).update(n => n * 2) +/// +/// = Analysis +/// Let's skip 7.1. +/// #counter(heading).step(level: 2) +/// +/// == Analysis +/// Still at #counter(heading).display(). +/// ``` +/// +/// ## Custom counters { #custom-counters } +/// To define your own counter, call the `counter` function with a string as a +/// key. This key identifies the counter globally. +/// +/// ```example +/// #let mine = counter("mycounter") +/// #mine.display() \ +/// #mine.step() +/// #mine.display() \ +/// #mine.update(c => c * 3) +/// #mine.display() \ +/// ``` +/// +/// ## How to step { #how-to-step } +/// When you define and use a custom counter, in general, you should first step +/// the counter and then display it. This way, the stepping behaviour of a +/// counter can depend on the element it is stepped for. If you were writing a +/// counter for, let's say, theorems, your theorem's definition would thus first +/// include the counter step and only then display the counter and the theorem's +/// contents. +/// +/// ```example +/// #let c = counter("theorem") +/// #let theorem(it) = block[ +/// #c.step() +/// *Theorem #c.display():* #it +/// ] +/// +/// #theorem[$1 = 1$] +/// #theorem[$2 < 3$] +/// ``` +/// +/// The rationale behind this is best explained on the example of the heading +/// counter: An update to the heading counter depends on the heading's level. +/// By stepping directly before the heading, we can correctly step from `1` to +/// `1.1` when encountering a level 2 heading. If we were to step after the +/// heading, we wouldn't know what to step to. +/// +/// Because counters should always be stepped before the elements they count, +/// they always start at zero. This way, they are at one for the first display +/// (which happens after the first step). +/// +/// ## Page counter { #page-counter } +/// The page counter is special. It is automatically stepped at each pagebreak. +/// But like other counters, you can also step it manually. For example, you +/// could have Roman page numbers for your preface, then switch to Arabic page +/// numbers for your main content and reset the page counter to one. +/// +/// ```example +/// >>> #set page( +/// >>> height: 100pt, +/// >>> margin: (bottom: 24pt, rest: 16pt), +/// >>> ) +/// #set page(numbering: "(i)") +/// +/// = Preface +/// The preface is numbered with +/// roman numerals. +/// +/// #set page(numbering: "1 / 1") +/// #counter(page).update(1) +/// +/// = Main text +/// Here, the counter is reset to one. +/// We also display both the current +/// page and total number of pages in +/// Arabic numbers. +/// ``` +/// +/// ## Time travel { #time-travel } +/// Counters can travel through time! You can find out the final value of the +/// counter before it is reached and even determine what the value was at any +/// particular location in the document. +/// +/// ```example +/// #let mine = counter("mycounter") +/// +/// = Values +/// #locate(loc => { +/// let start-val = mine.at(loc) +/// let elements = query(<intro>, loc) +/// let intro-val = mine.at( +/// elements.first().location() +/// ) +/// let final-val = mine.final(loc) +/// [Starts as: #start-val \ +/// Value at intro is: #intro-val \ +/// Final value is: #final-val \ ] +/// }) +/// +/// #mine.update(n => n + 3) +/// +/// = Introduction <intro> +/// #lorem(10) +/// +/// #mine.step() +/// #mine.step() +/// ``` +/// +/// Let's dissect what happens in the example above: +/// +/// - We call [`locate`]($func/locate) to get access to the current location in +/// the document. We then pass this location to our counter's `at` method to +/// get its value at the current location. The `at` method always returns an +/// array because counters can have multiple levels. As the counter starts at +/// one, the first value is thus `{(1,)}`. +/// +/// - We now [`query`]($func/query) the document for all elements with the +/// `{<intro>}` label. The result is an array from which we extract the first +/// (and only) element's [location]($type/content.location). We then look up +/// the value of the counter at that location. The first update to the counter +/// sets it to `{1 + 3 = 4}`. At the introduction heading, the value is thus +/// `{(4,)}`. +/// +/// - Last but not least, we call the `final` method on the counter. It tells us +/// what the counter's value will be at the end of the document. We also need +/// to give it a location to prove that we are inside of a `locate` call, but +/// which one doesn't matter. After the heading follow two calls to `step()`, +/// so the final value is `{(6,)}`. +/// +/// ## Other kinds of state { #other-state } +/// The `counter` function is closely related to [state]($func/state) function. +/// Read its documentation for more details on state management in Typst and +/// why it doesn't just use normal variables for counters. +/// +/// ## Methods +/// ### display() +/// Displays the value of the counter. +/// +/// - numbering: string or function (positional) +/// A [numbering pattern or a function]($func/numbering), which specifies how +/// to display the counter. If given a function, that function receives each +/// number of the counter as a separate argument. If the amount of numbers +/// varies, e.g. for the heading argument, you can use an +/// [argument sink]($type/arguments). +/// +/// If this is omitted, displays the counter with the numbering style for the +/// counted element or with the pattern `{"1.1"}` if no such style exists. +/// +/// - both: boolean (named) +/// If enabled, displays the current and final top-level count together. Both +/// can be styled through a single numbering pattern. This is used by the page +/// numbering property to display the current and total number of pages when a +/// pattern like `{"1 / 1"}` is given. +/// +/// - returns: content +/// +/// ### step() +/// Increases the value of the counter by one. +/// +/// The update will be in effect at the position where the returned content is +/// inserted into the document. If you don't put the output into the document, +/// nothing happens! This would be the case, for example, if you write +/// `{let _ = counter(page).step()}`. Counter updates are always applied in +/// layout order and in that case, Typst wouldn't know when to step the counter. +/// +/// - level: integer (named) +/// The depth at which to step the counter. Defaults to `{1}`. +/// +/// - returns: content +/// +/// ### update() +/// Updates the value of the counter. +/// +/// Just like with `step`, the update only occurs if you put the resulting +/// content into the document. +/// +/// - value: integer or array or function (positional, required) +/// If given an integer or array of integers, sets the counter to that value. +/// If given a function, that function receives the previous counter value +/// (with each number as a separate argument) and has to return the new +/// value (integer or array). +/// +/// - returns: content +/// +/// ### at() +/// Gets the value of the counter at the given location. Always returns an +/// array of integers, even if the counter has just one number. +/// +/// - location: location (positional, required) +/// The location at which the counter value should be retrieved. A suitable +/// location can be retrieved from [`locate`]($func/locate) or +/// [`query`]($func/query). +/// +/// - returns: array +/// +/// ### final() +/// Gets the value of the counter at the end of the document. Always returns an +/// array of integers, even if the counter has just one number. +/// +/// - location: location (positional, required) +/// Can be any location. Why is it required then? Typst has to evaluate parts +/// of your code multiple times to determine all counter values. By only +/// allowing this method within [`locate`]($func/locate) calls, the amount of +/// code that can depend on the method's result is reduced. If you could call +/// `final` directly at the top level of a module, the evaluation of the whole +/// module and its exports could depend on the counter's value. +/// +/// - returns: array +/// +/// Display: Counter +/// Category: meta +#[func] +pub fn counter( + /// The key that identifies this counter. + /// + /// - If it is a string, creates a custom counter that is only affected by + /// manual updates, + /// - If this is a `{<label>}`, counts through all elements with that label, + /// - If this is an element function or selector, counts through its elements, + /// - If this is the [`page`]($func/page) function, counts through pages. + key: CounterKey, +) -> Counter { + Counter::new(key) +} + +/// Counts through pages, elements, and more. +#[derive(Clone, PartialEq, Hash)] +pub struct Counter(CounterKey); + +impl Counter { + /// Create a new counter from a key. + pub fn new(key: CounterKey) -> Self { + Self(key) + } + + /// The counter for the given element. + pub fn of(func: ElemFunc) -> Self { + Self::new(CounterKey::Selector(Selector::Elem(func, None))) + } + + /// Call a method on counter. + #[tracing::instrument(skip(vm))] + pub fn call_method( + self, + vm: &mut Vm, + method: &str, + mut args: Args, + span: Span, + ) -> SourceResult<Value> { + let value = match method { + "display" => self + .display(args.eat()?, args.named("both")?.unwrap_or(false)) + .into_value(), + "step" => self + .update(CounterUpdate::Step( + args.named("level")?.unwrap_or(NonZeroUsize::ONE), + )) + .into_value(), + "update" => self.update(args.expect("value or function")?).into_value(), + "at" => self.at(&mut vm.vt, args.expect("location")?)?.into_value(), + "final" => self.final_(&mut vm.vt, args.expect("location")?)?.into_value(), + _ => bail!(span, "type counter has no method `{}`", method), + }; + args.finish()?; + Ok(value) + } + + /// Display the current value of the counter. + pub fn display(self, numbering: Option<Numbering>, both: bool) -> Content { + DisplayElem::new(self, numbering, both).pack() + } + + /// Get the value of the state at the given location. + pub fn at(&self, vt: &mut Vt, location: Location) -> SourceResult<CounterState> { + let sequence = self.sequence(vt)?; + let offset = vt.introspector.query(&self.selector().before(location, true)).len(); + let (mut state, page) = sequence[offset].clone(); + if self.is_page() { + let delta = vt.introspector.page(location).get().saturating_sub(page.get()); + state.step(NonZeroUsize::ONE, delta); + } + + Ok(state) + } + + /// Get the value of the state at the final location. + pub fn final_(&self, vt: &mut Vt, _: Location) -> SourceResult<CounterState> { + let sequence = self.sequence(vt)?; + let (mut state, page) = sequence.last().unwrap().clone(); + if self.is_page() { + let delta = vt.introspector.pages().get().saturating_sub(page.get()); + state.step(NonZeroUsize::ONE, delta); + } + Ok(state) + } + + /// Get the current and final value of the state combined in one state. + pub fn both(&self, vt: &mut Vt, location: Location) -> SourceResult<CounterState> { + let sequence = self.sequence(vt)?; + let offset = vt + .introspector + .query(&Selector::before(self.selector(), location, true)) + .len(); + let (mut at_state, at_page) = sequence[offset].clone(); + let (mut final_state, final_page) = sequence.last().unwrap().clone(); + if self.is_page() { + let at_delta = + vt.introspector.page(location).get().saturating_sub(at_page.get()); + at_state.step(NonZeroUsize::ONE, at_delta); + let final_delta = + vt.introspector.pages().get().saturating_sub(final_page.get()); + final_state.step(NonZeroUsize::ONE, final_delta); + } + Ok(CounterState(smallvec![at_state.first(), final_state.first()])) + } + + /// Produce content that performs a state update. + pub fn update(self, update: CounterUpdate) -> Content { + UpdateElem::new(self, update).pack() + } + + /// Produce the whole sequence of counter states. + /// + /// This has to happen just once for all counters, cutting down the number + /// of counter updates from quadratic to linear. + fn sequence( + &self, + vt: &mut Vt, + ) -> SourceResult<EcoVec<(CounterState, NonZeroUsize)>> { + self.sequence_impl( + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + ) + } + + /// Memoized implementation of `sequence`. + #[comemo::memoize] + fn sequence_impl( + &self, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + locator: Tracked<Locator>, + delayed: TrackedMut<DelayedErrors>, + tracer: TrackedMut<Tracer>, + ) -> SourceResult<EcoVec<(CounterState, NonZeroUsize)>> { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let mut state = CounterState(match &self.0 { + // special case, because pages always start at one. + CounterKey::Page => smallvec![1], + _ => smallvec![0], + }); + let mut page = NonZeroUsize::ONE; + let mut stops = eco_vec![(state.clone(), page)]; + + for elem in introspector.query(&self.selector()) { + if self.is_page() { + let prev = page; + page = introspector.page(elem.location().unwrap()); + + let delta = page.get() - prev.get(); + if delta > 0 { + state.step(NonZeroUsize::ONE, delta); + } + } + + if let Some(update) = match elem.to::<UpdateElem>() { + Some(elem) => Some(elem.update()), + None => match elem.with::<dyn Count>() { + Some(countable) => countable.update(), + None => Some(CounterUpdate::Step(NonZeroUsize::ONE)), + }, + } { + state.update(&mut vt, update)?; + } + + stops.push((state.clone(), page)); + } + + Ok(stops) + } + + /// The selector relevant for this counter's updates. + fn selector(&self) -> Selector { + let mut selector = + Selector::Elem(UpdateElem::func(), Some(dict! { "counter" => self.clone() })); + + if let CounterKey::Selector(key) = &self.0 { + selector = Selector::Or(eco_vec![selector, key.clone()]); + } + + selector + } + + /// Whether this is the page counter. + fn is_page(&self) -> bool { + self.0 == CounterKey::Page + } +} + +impl Debug for Counter { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.write_str("counter(")?; + self.0.fmt(f)?; + f.write_char(')') + } +} + +cast! { + type Counter: "counter", +} + +/// Identifies a counter. +#[derive(Clone, PartialEq, Hash)] +pub enum CounterKey { + /// The page counter. + Page, + /// Counts elements matching the given selectors. Only works for locatable + /// elements or labels. + Selector(Selector), + /// Counts through manual counters with the same key. + Str(Str), +} + +cast! { + CounterKey, + v: Str => Self::Str(v), + label: Label => Self::Selector(Selector::Label(label)), + v: ElemFunc => { + if v == PageElem::func() { + Self::Page + } else { + Self::Selector(LocatableSelector::from_value(v.into_value())?.0) + } + }, + selector: LocatableSelector => Self::Selector(selector.0), +} + +impl Debug for CounterKey { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Page => f.pad("page"), + Self::Selector(selector) => selector.fmt(f), + Self::Str(str) => str.fmt(f), + } + } +} + +/// An update to perform on a counter. +#[derive(Clone, PartialEq, Hash)] +pub enum CounterUpdate { + /// Set the counter to the specified state. + Set(CounterState), + /// Increase the number for the given level by one. + Step(NonZeroUsize), + /// Apply the given function to the counter's state. + Func(Func), +} + +impl Debug for CounterUpdate { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.pad("..") + } +} + +cast! { + type CounterUpdate: "counter update", + v: CounterState => Self::Set(v), + v: Func => Self::Func(v), +} + +/// Elements that have special counting behaviour. +pub trait Count { + /// Get the counter update for this element. + fn update(&self) -> Option<CounterUpdate>; +} + +/// Counts through elements with different levels. +#[derive(Debug, Clone, PartialEq, Hash)] +pub struct CounterState(pub SmallVec<[usize; 3]>); + +impl CounterState { + /// Advance the counter and return the numbers for the given heading. + pub fn update(&mut self, vt: &mut Vt, update: CounterUpdate) -> SourceResult<()> { + match update { + CounterUpdate::Set(state) => *self = state, + CounterUpdate::Step(level) => self.step(level, 1), + CounterUpdate::Func(func) => { + *self = func.call_vt(vt, self.0.iter().copied())?.cast().at(func.span())? + } + } + Ok(()) + } + + /// Advance the number of the given level by the specified amount. + pub fn step(&mut self, level: NonZeroUsize, by: usize) { + let level = level.get(); + + if self.0.len() >= level { + self.0[level - 1] = self.0[level - 1].saturating_add(by); + self.0.truncate(level); + } + + while self.0.len() < level { + self.0.push(1); + } + } + + /// Get the first number of the state. + pub fn first(&self) -> usize { + self.0.first().copied().unwrap_or(1) + } + + /// Display the counter state with a numbering. + pub fn display(&self, vt: &mut Vt, numbering: &Numbering) -> SourceResult<Content> { + Ok(numbering.apply_vt(vt, &self.0)?.display()) + } +} + +cast! { + CounterState, + self => Value::Array(self.0.into_iter().map(IntoValue::into_value).collect()), + num: usize => Self(smallvec![num]), + array: Array => Self(array + .into_iter() + .map(Value::cast) + .collect::<StrResult<_>>()?), +} + +/// Executes a display of a state. +/// +/// Display: State +/// Category: special +#[element(Locatable, Show)] +struct DisplayElem { + /// The counter. + #[required] + counter: Counter, + + /// The numbering to display the counter with. + #[required] + numbering: Option<Numbering>, + + /// Whether to display both the current and final value. + #[required] + both: bool, +} + +impl Show for DisplayElem { + #[tracing::instrument(name = "DisplayElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(vt.delayed(|vt| { + let location = self.0.location().unwrap(); + let counter = self.counter(); + let numbering = self + .numbering() + .or_else(|| { + let CounterKey::Selector(Selector::Elem(func, _)) = counter.0 else { + return None; + }; + + if func == HeadingElem::func() { + HeadingElem::numbering_in(styles) + } else if func == FigureElem::func() { + FigureElem::numbering_in(styles) + } else if func == EquationElem::func() { + EquationElem::numbering_in(styles) + } else { + None + } + }) + .unwrap_or_else(|| NumberingPattern::from_str("1.1").unwrap().into()); + + let state = if self.both() { + counter.both(vt, location)? + } else { + counter.at(vt, location)? + }; + + state.display(vt, &numbering) + })) + } +} + +/// Executes a display of a state. +/// +/// Display: State +/// Category: special +#[element(Locatable, Show)] +struct UpdateElem { + /// The counter. + #[required] + counter: Counter, + + /// The update to perform on the counter. + #[required] + update: CounterUpdate, +} + +impl Show for UpdateElem { + #[tracing::instrument(name = "UpdateElem::show", skip(self))] + fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> { + Ok(Content::empty()) + } +} diff --git a/crates/typst-library/src/meta/document.rs b/crates/typst-library/src/meta/document.rs new file mode 100644 index 00000000..db036e0a --- /dev/null +++ b/crates/typst-library/src/meta/document.rs @@ -0,0 +1,86 @@ +use crate::layout::{LayoutRoot, PageElem}; +use crate::prelude::*; + +/// The root element of a document and its metadata. +/// +/// All documents are automatically wrapped in a `document` element. You cannot +/// create a document element yourself. This function is only used with +/// [set rules]($styling/#set-rules) to specify document metadata. Such a set +/// rule must appear before any of the document's contents. +/// +/// ```example +/// #set document(title: "Hello") +/// +/// This has no visible output, but +/// embeds metadata into the PDF! +/// ``` +/// +/// Note that metadata set with this function is not rendered within the +/// document. Instead, it is embedded in the compiled PDF file. +/// +/// Display: Document +/// Category: meta +#[element(Construct, LayoutRoot)] +pub struct DocumentElem { + /// The document's title. This is often rendered as the title of the + /// PDF viewer window. + pub title: Option<EcoString>, + + /// The document's authors. + pub author: Author, + + /// The page runs. + #[internal] + #[variadic] + pub children: Vec<Content>, +} + +impl Construct for DocumentElem { + fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> { + bail!(args.span, "can only be used in set rules") + } +} + +impl LayoutRoot for DocumentElem { + /// Layout the document into a sequence of frames, one per page. + #[tracing::instrument(name = "DocumentElem::layout_root", skip_all)] + fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document> { + tracing::info!("Document layout"); + + let mut pages = vec![]; + + for mut child in &self.children() { + let outer = styles; + let mut styles = styles; + if let Some((elem, local)) = child.to_styled() { + styles = outer.chain(local); + child = elem; + } + + if let Some(page) = child.to::<PageElem>() { + let number = NonZeroUsize::ONE.saturating_add(pages.len()); + let fragment = page.layout(vt, styles, number)?; + pages.extend(fragment); + } else { + bail!(child.span(), "unexpected document child"); + } + } + + Ok(Document { + pages, + title: self.title(styles), + author: self.author(styles).0, + }) + } +} + +/// A list of authors. +#[derive(Debug, Default, Clone, Hash)] +pub struct Author(Vec<EcoString>); + +cast! { + Author, + self => self.0.into_value(), + v: EcoString => Self(vec![v]), + v: Array => Self(v.into_iter().map(Value::cast).collect::<StrResult<_>>()?), +} diff --git a/crates/typst-library/src/meta/figure.rs b/crates/typst-library/src/meta/figure.rs new file mode 100644 index 00000000..0d218770 --- /dev/null +++ b/crates/typst-library/src/meta/figure.rs @@ -0,0 +1,351 @@ +use std::str::FromStr; + +use super::{ + Count, Counter, CounterKey, CounterUpdate, LocalName, Numbering, NumberingPattern, +}; +use crate::layout::{BlockElem, VElem}; +use crate::meta::{Outlinable, Refable, Supplement}; +use crate::prelude::*; +use crate::text::TextElem; +use crate::visualize::ImageElem; + +/// A figure with an optional caption. +/// +/// Automatically detects its contents to select the correct counting track. +/// For example, figures containing images will be numbered separately from +/// figures containing tables. +/// +/// ## Examples { #examples } +/// The example below shows a basic figure with an image: +/// ```example +/// @glacier shows a glacier. Glaciers +/// are complex systems. +/// +/// #figure( +/// image("glacier.jpg", width: 80%), +/// caption: [A curious figure.], +/// ) <glacier> +/// ``` +/// +/// You can also insert [tables]($func/table) into figures to give them a +/// caption. The figure will detect this and automatically use a separate +/// counter. +/// +/// ```example +/// #figure( +/// table( +/// columns: 4, +/// [t], [1], [2], [3], +/// [y], [0.3s], [0.4s], [0.8s], +/// ), +/// caption: [Timing results], +/// ) +/// ``` +/// +/// This behaviour can be overridden by explicitly specifying the figure's +/// `kind`. All figures of the same kind share a common counter. +/// +/// ## Modifying the appearance { #modifying-appearance } +/// You can completely customize the look of your figures with a [show +/// rule]($styling/#show-rules). In the example below, we show the figure's +/// caption above its body and display its supplement and counter after the +/// caption. +/// +/// ```example +/// #show figure: it => align(center)[ +/// #it.caption | +/// #emph[ +/// #it.supplement +/// #it.counter.display(it.numbering) +/// ] +/// #v(10pt, weak: true) +/// #it.body +/// ] +/// +/// #figure( +/// image("molecular.jpg", width: 80%), +/// caption: [ +/// The molecular testing pipeline. +/// ], +/// ) +/// ``` +/// +/// If your figure is too large and its contents are breakable across pages +/// (e.g. if it contains a large table), then you can make the figure breakable +/// across pages as well by using `#show figure: set block(breakable: true)` +/// (see the [block]($func/block) documentation for more information). +/// +/// Display: Figure +/// Category: meta +#[element(Locatable, Synthesize, Count, Show, Finalize, Refable, Outlinable)] +pub struct FigureElem { + /// The content of the figure. Often, an [image]($func/image). + #[required] + pub body: Content, + + /// The figure's caption. + pub caption: Option<Content>, + + /// The kind of the figure this is. + /// + /// If set to `{auto}`, the figure will try to automatically determine its + /// kind. All figures of the same kind share a common counter. + /// + /// Setting this to something other than `{auto}` will override the + /// automatic detection. This can be useful if + /// - you wish to create a custom figure type that is not an + /// [image]($func/image), a [table]($func/table) or [code]($func/raw), + /// - you want to force the figure to use a specific counter regardless of + /// its content. + /// + /// You can set the kind to be an element function or a string. If you set + /// it to an element function that is not supported by the figure, you will + /// need to manually specify the figure's supplement. + /// + /// ```example + /// #figure( + /// circle(radius: 10pt), + /// caption: [A curious atom.], + /// kind: "atom", + /// supplement: [Atom], + /// ) + /// ``` + #[default(Smart::Auto)] + pub kind: Smart<FigureKind>, + + /// The figure's supplement. + /// + /// If set to `{auto}`, the figure will try to automatically determine the + /// correct supplement based on the `kind` and the active [text + /// language]($func/text.lang). If you are using a custom figure type, you + /// will need to manually specify the supplement. + /// + /// If a function is specified, it is passed the first descendant of the + /// specified `kind` (typically, the figure's body) and should return + /// content. + /// + /// ```example + /// #figure( + /// [The contents of my figure!], + /// caption: [My custom figure], + /// supplement: [Bar], + /// kind: "foo", + /// ) + /// ``` + pub supplement: Smart<Option<Supplement>>, + + /// How to number the figure. Accepts a + /// [numbering pattern or function]($func/numbering). + #[default(Some(NumberingPattern::from_str("1").unwrap().into()))] + pub numbering: Option<Numbering>, + + /// The vertical gap between the body and caption. + #[default(Em::new(0.65).into())] + pub gap: Length, + + /// Whether the figure should appear in an [`outline`]($func/outline) + /// of figures. + #[default(true)] + pub outlined: bool, + + /// Convenience field to get access to the counter for this figure. + /// + /// The counter only depends on the `kind`: + /// - For (tables)[$func/table]: `{counter(figure.where(kind: table))}` + /// - For (images)[$func/image]: `{counter(figure.where(kind: image))}` + /// - For a custom kind: `{counter(figure.where(kind: kind))}` + /// + /// These are the counters you'll need to modify if you want to skip a + /// number or reset the counter. + #[synthesized] + pub counter: Option<Counter>, +} + +impl Synthesize for FigureElem { + fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + let numbering = self.numbering(styles); + + // Determine the figure's kind. + let kind = self.kind(styles).unwrap_or_else(|| { + self.body() + .query_first(Selector::can::<dyn Figurable>()) + .cloned() + .map(|elem| FigureKind::Elem(elem.func())) + .unwrap_or_else(|| FigureKind::Elem(ImageElem::func())) + }); + + // Resolve the supplement. + let supplement = match self.supplement(styles) { + Smart::Auto => { + // Default to the local name for the kind, if available. + let name = match &kind { + FigureKind::Elem(func) => { + let empty = Content::new(*func); + empty.with::<dyn LocalName>().map(|c| { + TextElem::packed(c.local_name( + TextElem::lang_in(styles), + TextElem::region_in(styles), + )) + }) + } + FigureKind::Name(_) => None, + }; + + if numbering.is_some() && name.is_none() { + bail!(self.span(), "please specify the figure's supplement") + } + + name.unwrap_or_default() + } + Smart::Custom(None) => Content::empty(), + Smart::Custom(Some(supplement)) => { + // Resolve the supplement with the first descendant of the kind or + // just the body, if none was found. + let descendant = match kind { + FigureKind::Elem(func) => { + self.body().query_first(Selector::Elem(func, None)).cloned() + } + FigureKind::Name(_) => None, + }; + + let target = descendant.unwrap_or_else(|| self.body()); + supplement.resolve(vt, [target])? + } + }; + + // Construct the figure's counter. + let counter = Counter::new(CounterKey::Selector(Selector::Elem( + Self::func(), + Some(dict! { + "kind" => kind.clone(), + }), + ))); + + self.push_caption(self.caption(styles)); + self.push_kind(Smart::Custom(kind)); + self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement)))); + self.push_numbering(numbering); + self.push_outlined(self.outlined(styles)); + self.push_counter(Some(counter)); + + Ok(()) + } +} + +impl Show for FigureElem { + #[tracing::instrument(name = "FigureElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let mut realized = self.body(); + + // Build the caption, if any. + if let Some(caption) = self.full_caption(vt)? { + realized += VElem::weak(self.gap(styles).into()).pack(); + realized += caption; + } + + // Wrap the contents in a block. + Ok(BlockElem::new() + .with_body(Some(realized)) + .pack() + .aligned(Axes::with_x(Some(Align::Center.into())))) + } +} + +impl Finalize for FigureElem { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + // Allow breakable figures with `show figure: set block(breakable: true)`. + realized.styled(BlockElem::set_breakable(false)) + } +} + +impl Count for FigureElem { + fn update(&self) -> Option<CounterUpdate> { + // If the figure is numbered, step the counter by one. + // This steps the `counter(figure)` which is global to all numbered figures. + self.numbering(StyleChain::default()) + .is_some() + .then(|| CounterUpdate::Step(NonZeroUsize::ONE)) + } +} + +impl Refable for FigureElem { + fn supplement(&self) -> Content { + // After synthesis, this should always be custom content. + match self.supplement(StyleChain::default()) { + Smart::Custom(Some(Supplement::Content(content))) => content, + _ => Content::empty(), + } + } + + fn counter(&self) -> Counter { + self.counter().unwrap_or_else(|| Counter::of(Self::func())) + } + + fn numbering(&self) -> Option<Numbering> { + self.numbering(StyleChain::default()) + } +} + +impl Outlinable for FigureElem { + fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>> { + if !self.outlined(StyleChain::default()) { + return Ok(None); + } + + self.full_caption(vt) + } +} + +impl FigureElem { + /// Builds the full caption for the figure (with supplement and numbering). + pub fn full_caption(&self, vt: &mut Vt) -> SourceResult<Option<Content>> { + let Some(mut caption) = self.caption(StyleChain::default()) else { + return Ok(None); + }; + + if let ( + Smart::Custom(Some(Supplement::Content(mut supplement))), + Some(counter), + Some(numbering), + ) = ( + self.supplement(StyleChain::default()), + self.counter(), + self.numbering(StyleChain::default()), + ) { + let loc = self.0.location().unwrap(); + let numbers = counter.at(vt, loc)?.display(vt, &numbering)?; + + if !supplement.is_empty() { + supplement += TextElem::packed("\u{a0}"); + } + + caption = supplement + numbers + TextElem::packed(": ") + caption; + } + + Ok(Some(caption)) + } +} + +/// The `kind` parameter of a [`FigureElem`]. +#[derive(Debug, Clone)] +pub enum FigureKind { + /// The kind is an element function. + Elem(ElemFunc), + /// The kind is a name. + Name(EcoString), +} + +cast! { + FigureKind, + self => match self { + Self::Elem(v) => v.into_value(), + Self::Name(v) => v.into_value(), + }, + v: ElemFunc => Self::Elem(v), + v: EcoString => Self::Name(v), +} + +/// An element that can be auto-detected in a figure. +/// +/// This trait is used to determine the type of a figure. +pub trait Figurable: LocalName {} diff --git a/crates/typst-library/src/meta/footnote.rs b/crates/typst-library/src/meta/footnote.rs new file mode 100644 index 00000000..31ec9fe9 --- /dev/null +++ b/crates/typst-library/src/meta/footnote.rs @@ -0,0 +1,299 @@ +use comemo::Prehashed; +use std::str::FromStr; + +use super::{Counter, Numbering, NumberingPattern}; +use crate::layout::{HElem, ParElem}; +use crate::meta::{Count, CounterUpdate}; +use crate::prelude::*; +use crate::text::{SuperElem, TextElem, TextSize}; +use crate::visualize::LineElem; + +/// The body of a footnote can be either some content or a label referencing +/// another footnote. +#[derive(Debug)] +pub enum FootnoteBody { + Content(Content), + Reference(Label), +} + +cast! { + FootnoteBody, + self => match self { + Self::Content(v) => v.into_value(), + Self::Reference(v) => v.into_value(), + }, + v: Content => Self::Content(v), + v: Label => Self::Reference(v), +} + +/// A footnote. +/// +/// Includes additional remarks and references on the same page with footnotes. +/// A footnote will insert a superscript number that links to the note at the +/// bottom of the page. Notes are numbered sequentially throughout your document +/// and can break across multiple pages. +/// +/// To customize the appearance of the entry in the footnote listing, see +/// [`footnote.entry`]($func/footnote.entry). The footnote itself is realized as +/// a normal superscript, so you can use a set rule on the +/// [`super`]($func/super) function to customize it. +/// +/// ## Example { #example } +/// ```example +/// Check the docs for more details. +/// #footnote[https://typst.app/docs] +/// ``` +/// +/// The footnote automatically attaches itself to the preceding word, even if +/// there is a space before it in the markup. To force space, you can use the +/// string `[#" "]` or explicit [horizontal spacing]($func/h). +/// +/// By giving a label to a footnote, you can have multiple references to it. +/// +/// ```example +/// You can edit Typst documents online. +/// #footnote[https://typst.app/app] <fn> +/// Checkout Typst's website. @fn +/// And the online app. #footnote(<fn>) +/// ``` +/// +/// _Note:_ Set and show rules in the scope where `footnote` is called may not +/// apply to the footnote's content. See [here][issue] more information. +/// +/// [issue]: https://github.com/typst/typst/issues/1467#issuecomment-1588799440 +/// +/// Display: Footnote +/// Category: meta +#[element(Locatable, Synthesize, Show, Count)] +#[scope( + scope.define("entry", FootnoteEntry::func()); + scope +)] +pub struct FootnoteElem { + /// How to number footnotes. + /// + /// By default, the footnote numbering continues throughout your document. + /// If you prefer per-page footnote numbering, you can reset the footnote + /// [counter]($func/counter) in the page [header]($func/page.header). In the + /// future, there might be a simpler way to achieve this. + /// + /// ```example + /// #set footnote(numbering: "*") + /// + /// Footnotes: + /// #footnote[Star], + /// #footnote[Dagger] + /// ``` + #[default(Numbering::Pattern(NumberingPattern::from_str("1").unwrap()))] + pub numbering: Numbering, + + /// The content to put into the footnote. Can also be the label of another + /// footnote this one should point to. + #[required] + pub body: FootnoteBody, +} + +impl FootnoteElem { + /// Creates a new footnote that the passed content as its body. + pub fn with_content(content: Content) -> Self { + Self::new(FootnoteBody::Content(content)) + } + + /// Creates a new footnote referencing the footnote with the specified label. + pub fn with_label(label: Label) -> Self { + Self::new(FootnoteBody::Reference(label)) + } + + /// Tests if this footnote is a reference to another footnote. + pub fn is_ref(&self) -> bool { + matches!(self.body(), FootnoteBody::Reference(_)) + } + + /// Returns the content of the body of this footnote if it is not a ref. + pub fn body_content(&self) -> Option<Content> { + match self.body() { + FootnoteBody::Content(content) => Some(content), + _ => None, + } + } + + /// Returns the location of the definition of this footnote. + pub fn declaration_location(&self, vt: &Vt) -> StrResult<Location> { + match self.body() { + FootnoteBody::Reference(label) => { + let element: Prehashed<Content> = vt.introspector.query_label(&label)?; + let footnote = element + .to::<FootnoteElem>() + .ok_or("referenced element should be a footnote")?; + footnote.declaration_location(vt) + } + _ => Ok(self.0.location().unwrap()), + } + } +} + +impl Synthesize for FootnoteElem { + fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + self.push_numbering(self.numbering(styles)); + Ok(()) + } +} + +impl Show for FootnoteElem { + #[tracing::instrument(name = "FootnoteElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(vt.delayed(|vt| { + let loc = self.declaration_location(vt).at(self.span())?; + let numbering = self.numbering(styles); + let counter = Counter::of(Self::func()); + let num = counter.at(vt, loc)?.display(vt, &numbering)?; + let sup = SuperElem::new(num).pack(); + let hole = HElem::new(Abs::zero().into()).with_weak(true).pack(); + let loc = loc.variant(1); + Ok(hole + sup.linked(Destination::Location(loc))) + })) + } +} + +impl Count for FootnoteElem { + fn update(&self) -> Option<CounterUpdate> { + (!self.is_ref()).then(|| CounterUpdate::Step(NonZeroUsize::ONE)) + } +} + +/// An entry in a footnote list. +/// +/// This function is not intended to be called directly. Instead, it is used +/// in set and show rules to customize footnote listings. +/// +/// ## Example { #example } +/// ```example +/// #show footnote.entry: set text(red) +/// +/// My footnote listing +/// #footnote[It's down here] +/// has red text! +/// ``` +/// +/// Display: Footnote Entry +/// Category: meta +#[element(Show, Finalize)] +pub struct FootnoteEntry { + /// The footnote for this entry. It's location can be used to determine + /// the footnote counter state. + /// + /// ```example + /// #show footnote.entry: it => { + /// let loc = it.note.location() + /// numbering( + /// "1: ", + /// ..counter(footnote).at(loc), + /// ) + /// it.note.body + /// } + /// + /// Customized #footnote[Hello] + /// listing #footnote[World! 🌏] + /// ``` + #[required] + pub note: FootnoteElem, + + /// The separator between the document body and the footnote listing. + /// + /// ```example + /// #set footnote.entry( + /// separator: repeat[.] + /// ) + /// + /// Testing a different separator. + /// #footnote[ + /// Unconventional, but maybe + /// not that bad? + /// ] + /// ``` + #[default( + LineElem::new() + .with_length(Ratio::new(0.3).into()) + .with_stroke(PartialStroke { + thickness: Smart::Custom(Abs::pt(0.5).into()), + ..Default::default() + }) + .pack() + )] + pub separator: Content, + + /// The amount of clearance between the document body and the separator. + /// + /// ```example + /// #set footnote.entry(clearance: 3em) + /// + /// Footnotes also need ... + /// #footnote[ + /// ... some space to breathe. + /// ] + /// ``` + #[default(Em::new(1.0).into())] + #[resolve] + pub clearance: Length, + + /// The gap between footnote entries. + /// + /// ```example + /// #set footnote.entry(gap: 0.8em) + /// + /// Footnotes: + /// #footnote[Spaced], + /// #footnote[Apart] + /// ``` + #[default(Em::new(0.5).into())] + #[resolve] + pub gap: Length, + + /// The indent of each footnote entry. + /// + /// ```example + /// #set footnote.entry(indent: 0em) + /// + /// Footnotes: + /// #footnote[No], + /// #footnote[Indent] + /// ``` + #[default(Em::new(1.0).into())] + pub indent: Length, +} + +impl Show for FootnoteEntry { + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let note = self.note(); + let number_gap = Em::new(0.05); + let numbering = note.numbering(StyleChain::default()); + let counter = Counter::of(FootnoteElem::func()); + let loc = note.0.location().unwrap(); + let num = counter.at(vt, loc)?.display(vt, &numbering)?; + let sup = SuperElem::new(num) + .pack() + .linked(Destination::Location(loc)) + .backlinked(loc.variant(1)); + Ok(Content::sequence([ + HElem::new(self.indent(styles).into()).pack(), + sup, + HElem::new(number_gap.into()).with_weak(true).pack(), + note.body_content().unwrap(), + ])) + } +} + +impl Finalize for FootnoteEntry { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + let text_size = Em::new(0.85); + let leading = Em::new(0.5); + realized + .styled(ParElem::set_leading(leading.into())) + .styled(TextElem::set_size(TextSize(text_size.into()))) + } +} + +cast! { + FootnoteElem, + v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::with_content(v.clone())), +} diff --git a/crates/typst-library/src/meta/heading.rs b/crates/typst-library/src/meta/heading.rs new file mode 100644 index 00000000..d70bc0a9 --- /dev/null +++ b/crates/typst-library/src/meta/heading.rs @@ -0,0 +1,239 @@ +use typst::font::FontWeight; +use typst::util::option_eq; + +use super::{Counter, CounterUpdate, LocalName, Numbering, Outlinable, Refable}; +use crate::layout::{BlockElem, HElem, VElem}; +use crate::meta::{Count, Supplement}; +use crate::prelude::*; +use crate::text::{SpaceElem, TextElem, TextSize}; + +/// A section heading. +/// +/// With headings, you can structure your document into sections. Each heading +/// has a _level,_ which starts at one and is unbounded upwards. This level +/// indicates the logical role of the following content (section, subsection, +/// etc.) A top-level heading indicates a top-level section of the document +/// (not the document's title). +/// +/// Typst can automatically number your headings for you. To enable numbering, +/// specify how you want your headings to be numbered with a +/// [numbering pattern or function]($func/numbering). +/// +/// Independently from the numbering, Typst can also automatically generate an +/// [outline]($func/outline) of all headings for you. To exclude one or more +/// headings from this outline, you can set the `outlined` parameter to +/// `{false}`. +/// +/// ## Example { #example } +/// ```example +/// #set heading(numbering: "1.a)") +/// +/// = Introduction +/// In recent years, ... +/// +/// == Preliminaries +/// To start, ... +/// ``` +/// +/// ## Syntax { #syntax } +/// Headings have dedicated syntax: They can be created by starting a line with +/// one or multiple equals signs, followed by a space. The number of equals +/// signs determines the heading's logical nesting depth. +/// +/// Display: Heading +/// Category: meta +#[element(Locatable, Synthesize, Count, Show, Finalize, LocalName, Refable, Outlinable)] +pub struct HeadingElem { + /// The logical nesting depth of the heading, starting from one. + #[default(NonZeroUsize::ONE)] + pub level: NonZeroUsize, + + /// How to number the heading. Accepts a + /// [numbering pattern or function]($func/numbering). + /// + /// ```example + /// #set heading(numbering: "1.a.") + /// + /// = A section + /// == A subsection + /// === A sub-subsection + /// ``` + pub numbering: Option<Numbering>, + + /// A supplement for the heading. + /// + /// For references to headings, this is added before the referenced number. + /// + /// If a function is specified, it is passed the referenced heading and + /// should return content. + /// + /// ```example + /// #set heading(numbering: "1.", supplement: [Chapter]) + /// + /// = Introduction <intro> + /// In @intro, we see how to turn + /// Sections into Chapters. And + /// in @intro[Part], it is done + /// manually. + /// ``` + pub supplement: Smart<Option<Supplement>>, + + /// Whether the heading should appear in the outline. + /// + /// ```example + /// #outline() + /// + /// #heading[Normal] + /// This is a normal heading. + /// + /// #heading(outlined: false)[Hidden] + /// This heading does not appear + /// in the outline. + /// ``` + #[default(true)] + pub outlined: bool, + + /// The heading's title. + #[required] + pub body: Content, +} + +impl Synthesize for HeadingElem { + fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + // Resolve the supplement. + let supplement = match self.supplement(styles) { + Smart::Auto => TextElem::packed(self.local_name_in(styles)), + Smart::Custom(None) => Content::empty(), + Smart::Custom(Some(supplement)) => supplement.resolve(vt, [self.clone()])?, + }; + + self.push_level(self.level(styles)); + self.push_numbering(self.numbering(styles)); + self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement)))); + self.push_outlined(self.outlined(styles)); + + Ok(()) + } +} + +impl Show for HeadingElem { + #[tracing::instrument(name = "HeadingElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let mut realized = self.body(); + if let Some(numbering) = self.numbering(styles) { + realized = Counter::of(Self::func()) + .display(Some(numbering), false) + .spanned(self.span()) + + HElem::new(Em::new(0.3).into()).with_weak(true).pack() + + realized; + } + Ok(BlockElem::new().with_body(Some(realized)).pack()) + } +} + +impl Finalize for HeadingElem { + fn finalize(&self, realized: Content, styles: StyleChain) -> Content { + let level = self.level(styles).get(); + let scale = match level { + 1 => 1.4, + 2 => 1.2, + _ => 1.0, + }; + + let size = Em::new(scale); + let above = Em::new(if level == 1 { 1.8 } else { 1.44 }) / scale; + let below = Em::new(0.75) / scale; + + let mut styles = Styles::new(); + styles.set(TextElem::set_size(TextSize(size.into()))); + styles.set(TextElem::set_weight(FontWeight::BOLD)); + styles.set(BlockElem::set_above(VElem::block_around(above.into()))); + styles.set(BlockElem::set_below(VElem::block_around(below.into()))); + styles.set(BlockElem::set_sticky(true)); + realized.styled_with_map(styles) + } +} + +impl Count for HeadingElem { + fn update(&self) -> Option<CounterUpdate> { + self.numbering(StyleChain::default()) + .is_some() + .then(|| CounterUpdate::Step(self.level(StyleChain::default()))) + } +} + +cast! { + HeadingElem, + v: Content => v.to::<Self>().ok_or("expected heading")?.clone(), +} + +impl Refable for HeadingElem { + fn supplement(&self) -> Content { + // After synthesis, this should always be custom content. + match self.supplement(StyleChain::default()) { + Smart::Custom(Some(Supplement::Content(content))) => content, + _ => Content::empty(), + } + } + + fn counter(&self) -> Counter { + Counter::of(Self::func()) + } + + fn numbering(&self) -> Option<Numbering> { + self.numbering(StyleChain::default()) + } +} + +impl Outlinable for HeadingElem { + fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>> { + if !self.outlined(StyleChain::default()) { + return Ok(None); + } + + let mut content = self.body(); + if let Some(numbering) = self.numbering(StyleChain::default()) { + let numbers = Counter::of(Self::func()) + .at(vt, self.0.location().unwrap())? + .display(vt, &numbering)?; + content = numbers + SpaceElem::new().pack() + content; + }; + + Ok(Some(content)) + } + + fn level(&self) -> NonZeroUsize { + self.level(StyleChain::default()) + } +} + +impl LocalName for HeadingElem { + fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "Kapitull", + Lang::ARABIC => "الفصل", + Lang::BOKMÅL => "Kapittel", + Lang::CHINESE if option_eq(region, "TW") => "小節", + Lang::CHINESE => "小节", + Lang::CZECH => "Kapitola", + Lang::DANISH => "Afsnit", + Lang::DUTCH => "Hoofdstuk", + Lang::FILIPINO => "Seksyon", + Lang::FRENCH => "Chapitre", + Lang::GERMAN => "Abschnitt", + Lang::ITALIAN => "Sezione", + Lang::NYNORSK => "Kapittel", + Lang::POLISH => "Sekcja", + Lang::PORTUGUESE if option_eq(region, "PT") => "Secção", + Lang::PORTUGUESE => "Seção", + Lang::RUSSIAN => "Раздел", + Lang::SLOVENIAN => "Poglavje", + Lang::SPANISH => "Sección", + Lang::SWEDISH => "Kapitel", + Lang::TURKISH => "Bölüm", + Lang::UKRAINIAN => "Розділ", + Lang::VIETNAMESE => "Phần", // TODO: This may be wrong. + Lang::ENGLISH | _ => "Section", + } + } +} diff --git a/crates/typst-library/src/meta/link.rs b/crates/typst-library/src/meta/link.rs new file mode 100644 index 00000000..2a53b84f --- /dev/null +++ b/crates/typst-library/src/meta/link.rs @@ -0,0 +1,137 @@ +use crate::prelude::*; +use crate::text::{Hyphenate, TextElem}; + +/// Links to a URL or a location in the document. +/// +/// By default, links are not styled any different from normal text. However, +/// you can easily apply a style of your choice with a show rule. +/// +/// ## Example { #example } +/// ```example +/// #show link: underline +/// +/// https://example.com \ +/// +/// #link("https://example.com") \ +/// #link("https://example.com")[ +/// See example.com +/// ] +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: Text that starts with `http://` or +/// `https://` is automatically turned into a link. +/// +/// Display: Link +/// Category: meta +#[element(Show)] +pub struct LinkElem { + /// The destination the link points to. + /// + /// - To link to web pages, `dest` should be a valid URL string. If the URL + /// is in the `mailto:` or `tel:` scheme and the `body` parameter is + /// omitted, the email address or phone number will be the link's body, + /// without the scheme. + /// + /// - To link to another part of the document, `dest` can take one of three + /// forms: + /// - A [label]($func/label) attached to an element. If you also want + /// automatic text for the link based on the element, consider using + /// a [reference]($func/ref) instead. + /// + /// - A [location]($func/locate) resulting from a [`locate`]($func/locate) + /// call or [`query`]($func/query). + /// + /// - A dictionary with a `page` key of type [integer]($type/integer) and + /// `x` and `y` coordinates of type [length]($type/length). Pages are + /// counted from one, and the coordinates are relative to the page's top + /// left corner. + /// + /// ```example + /// = Introduction <intro> + /// #link("mailto:hello@typst.app") \ + /// #link(<intro>)[Go to intro] \ + /// #link((page: 1, x: 0pt, y: 0pt))[ + /// Go to top + /// ] + /// ``` + #[required] + #[parse( + let dest = args.expect::<LinkTarget>("destination")?; + dest.clone() + )] + pub dest: LinkTarget, + + /// The content that should become a link. + /// + /// If `dest` is an URL string, the parameter can be omitted. In this case, + /// the URL will be shown as the link. + #[required] + #[parse(match &dest { + LinkTarget::Dest(Destination::Url(url)) => match args.eat()? { + Some(body) => body, + None => body_from_url(url), + }, + _ => args.expect("body")?, + })] + pub body: Content, +} + +impl LinkElem { + /// Create a link element from a URL with its bare text. + pub fn from_url(url: EcoString) -> Self { + let body = body_from_url(&url); + Self::new(LinkTarget::Dest(Destination::Url(url)), body) + } +} + +impl Show for LinkElem { + #[tracing::instrument(name = "LinkElem::show", skip(self, vt))] + fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> { + let body = self.body(); + let linked = match self.dest() { + LinkTarget::Dest(dest) => body.linked(dest), + LinkTarget::Label(label) => vt + .delayed(|vt| { + let elem = vt.introspector.query_label(&label).at(self.span())?; + let dest = Destination::Location(elem.location().unwrap()); + Ok(Some(body.clone().linked(dest))) + }) + .unwrap_or(body), + }; + + Ok(linked.styled(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false))))) + } +} + +fn body_from_url(url: &EcoString) -> Content { + let mut text = url.as_str(); + for prefix in ["mailto:", "tel:"] { + text = text.trim_start_matches(prefix); + } + let shorter = text.len() < url.len(); + TextElem::packed(if shorter { text.into() } else { url.clone() }) +} + +/// A target where a link can go. +#[derive(Debug, Clone)] +pub enum LinkTarget { + Dest(Destination), + Label(Label), +} + +cast! { + LinkTarget, + self => match self { + Self::Dest(v) => v.into_value(), + Self::Label(v) => v.into_value(), + }, + v: Destination => Self::Dest(v), + v: Label => Self::Label(v), +} + +impl From<Destination> for LinkTarget { + fn from(dest: Destination) -> Self { + Self::Dest(dest) + } +} diff --git a/crates/typst-library/src/meta/mod.rs b/crates/typst-library/src/meta/mod.rs new file mode 100644 index 00000000..dcac6379 --- /dev/null +++ b/crates/typst-library/src/meta/mod.rs @@ -0,0 +1,64 @@ +//! Interaction between document parts. + +mod bibliography; +mod context; +mod counter; +mod document; +mod figure; +mod footnote; +mod heading; +mod link; +mod numbering; +mod outline; +mod query; +mod reference; +mod state; + +pub use self::bibliography::*; +pub use self::context::*; +pub use self::counter::*; +pub use self::document::*; +pub use self::figure::*; +pub use self::footnote::*; +pub use self::heading::*; +pub use self::link::*; +pub use self::numbering::*; +pub use self::outline::*; +pub use self::query::*; +pub use self::reference::*; +pub use self::state::*; + +use crate::prelude::*; +use crate::text::TextElem; + +/// Hook up all meta definitions. +pub(super) fn define(global: &mut Scope) { + global.define("document", DocumentElem::func()); + global.define("ref", RefElem::func()); + global.define("link", LinkElem::func()); + global.define("outline", OutlineElem::func()); + global.define("heading", HeadingElem::func()); + global.define("figure", FigureElem::func()); + global.define("footnote", FootnoteElem::func()); + global.define("cite", CiteElem::func()); + global.define("bibliography", BibliographyElem::func()); + global.define("locate", locate_func()); + global.define("style", style_func()); + global.define("layout", layout_func()); + global.define("counter", counter_func()); + global.define("numbering", numbering_func()); + global.define("state", state_func()); + global.define("query", query_func()); + global.define("selector", selector_func()); +} + +/// The named with which an element is referenced. +pub trait LocalName { + /// Get the name in the given language and (optionally) region. + fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str; + + /// Resolve the local name with a style chain. + fn local_name_in(&self, styles: StyleChain) -> &'static str { + self.local_name(TextElem::lang_in(styles), TextElem::region_in(styles)) + } +} diff --git a/crates/typst-library/src/meta/numbering.rs b/crates/typst-library/src/meta/numbering.rs new file mode 100644 index 00000000..8698f7b9 --- /dev/null +++ b/crates/typst-library/src/meta/numbering.rs @@ -0,0 +1,525 @@ +use std::str::FromStr; + +use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToChinese}; +use ecow::EcoVec; + +use crate::prelude::*; +use crate::text::Case; + +/// Applies a numbering to a sequence of numbers. +/// +/// A numbering defines how a sequence of numbers should be displayed as +/// content. It is defined either through a pattern string or an arbitrary +/// function. +/// +/// A numbering pattern consists of counting symbols, for which the actual +/// number is substituted, their prefixes, and one suffix. The prefixes and the +/// suffix are repeated as-is. +/// +/// ## Example { #example } +/// ```example +/// #numbering("1.1)", 1, 2, 3) \ +/// #numbering("1.a.i", 1, 2) \ +/// #numbering("I – 1", 12, 2) \ +/// #numbering( +/// (..nums) => nums +/// .pos() +/// .map(str) +/// .join(".") + ")", +/// 1, 2, 3, +/// ) +/// ``` +/// +/// Display: Numbering +/// Category: meta +#[func] +pub fn numbering( + /// Defines how the numbering works. + /// + /// **Counting symbols** are `1`, `a`, `A`, `i`, `I`, `い`, `イ`, `א`, `가`, + /// `ㄱ`, and `*`. They are replaced by the number in the sequence, in the + /// given case. + /// + /// The `*` character means that symbols should be used to count, in the + /// order of `*`, `†`, `‡`, `§`, `¶`, and `‖`. If there are more than six + /// items, the number is represented using multiple symbols. + /// + /// **Suffixes** are all characters after the last counting symbol. They are + /// repeated as-is at the end of any rendered number. + /// + /// **Prefixes** are all characters that are neither counting symbols nor + /// suffixes. They are repeated as-is at in front of their rendered + /// equivalent of their counting symbol. + /// + /// This parameter can also be an arbitrary function that gets each number + /// as an individual argument. When given a function, the `numbering` + /// function just forwards the arguments to that function. While this is not + /// particularly useful in itself, it means that you can just give arbitrary + /// numberings to the `numbering` function without caring whether they are + /// defined as a pattern or function. + numbering: Numbering, + /// The numbers to apply the numbering to. Must be positive. + /// + /// If `numbering` is a pattern and more numbers than counting symbols are + /// given, the last counting symbol with its prefix is repeated. + #[variadic] + numbers: Vec<usize>, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult<Value> { + numbering.apply_vm(vm, &numbers) +} + +/// How to number a sequence of things. +#[derive(Debug, Clone, PartialEq, Hash)] +pub enum Numbering { + /// A pattern with prefix, numbering, lower / upper case and suffix. + Pattern(NumberingPattern), + /// A closure mapping from an item's number to content. + Func(Func), +} + +impl Numbering { + /// Apply the pattern to the given numbers. + pub fn apply_vm(&self, vm: &mut Vm, numbers: &[usize]) -> SourceResult<Value> { + Ok(match self { + Self::Pattern(pattern) => Value::Str(pattern.apply(numbers).into()), + Self::Func(func) => { + let args = Args::new(func.span(), numbers.iter().copied()); + func.call_vm(vm, args)? + } + }) + } + + /// Apply the pattern to the given numbers. + pub fn apply_vt(&self, vt: &mut Vt, numbers: &[usize]) -> SourceResult<Value> { + Ok(match self { + Self::Pattern(pattern) => Value::Str(pattern.apply(numbers).into()), + Self::Func(func) => func.call_vt(vt, numbers.iter().copied())?, + }) + } + + /// Trim the prefix suffix if this is a pattern. + pub fn trimmed(mut self) -> Self { + if let Self::Pattern(pattern) = &mut self { + pattern.trimmed = true; + } + self + } +} + +impl From<NumberingPattern> for Numbering { + fn from(pattern: NumberingPattern) -> Self { + Self::Pattern(pattern) + } +} + +cast! { + Numbering, + self => match self { + Self::Pattern(pattern) => pattern.into_value(), + Self::Func(func) => func.into_value(), + }, + v: NumberingPattern => Self::Pattern(v), + v: Func => Self::Func(v), +} + +/// How to turn a number into text. +/// +/// A pattern consists of a prefix, followed by one of `1`, `a`, `A`, `i`, +/// `I`, `い`, `イ`, `א`, `가`, `ㄱ`, or `*`, and then a suffix. +/// +/// Examples of valid patterns: +/// - `1)` +/// - `a.` +/// - `(I)` +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct NumberingPattern { + pieces: EcoVec<(EcoString, NumberingKind, Case)>, + suffix: EcoString, + trimmed: bool, +} + +impl NumberingPattern { + /// Apply the pattern to the given number. + pub fn apply(&self, numbers: &[usize]) -> EcoString { + let mut fmt = EcoString::new(); + let mut numbers = numbers.iter(); + + for (i, ((prefix, kind, case), &n)) in + self.pieces.iter().zip(&mut numbers).enumerate() + { + if i > 0 || !self.trimmed { + fmt.push_str(prefix); + } + fmt.push_str(&kind.apply(n, *case)); + } + + for ((prefix, kind, case), &n) in + self.pieces.last().into_iter().cycle().zip(numbers) + { + if prefix.is_empty() { + fmt.push_str(&self.suffix); + } else { + fmt.push_str(prefix); + } + fmt.push_str(&kind.apply(n, *case)); + } + + if !self.trimmed { + fmt.push_str(&self.suffix); + } + + fmt + } + + /// Apply only the k-th segment of the pattern to a number. + pub fn apply_kth(&self, k: usize, number: usize) -> EcoString { + let mut fmt = EcoString::new(); + if let Some((prefix, _, _)) = self.pieces.first() { + fmt.push_str(prefix); + } + if let Some((_, kind, case)) = self + .pieces + .iter() + .chain(self.pieces.last().into_iter().cycle()) + .nth(k) + { + fmt.push_str(&kind.apply(number, *case)); + } + fmt.push_str(&self.suffix); + fmt + } + + /// How many counting symbols this pattern has. + pub fn pieces(&self) -> usize { + self.pieces.len() + } +} + +impl FromStr for NumberingPattern { + type Err = &'static str; + + fn from_str(pattern: &str) -> Result<Self, Self::Err> { + let mut pieces = EcoVec::new(); + let mut handled = 0; + + for (i, c) in pattern.char_indices() { + let Some(kind) = NumberingKind::from_char(c.to_ascii_lowercase()) else { + continue; + }; + + let prefix = pattern[handled..i].into(); + let case = + if c.is_uppercase() || c == '壹' { Case::Upper } else { Case::Lower }; + pieces.push((prefix, kind, case)); + handled = c.len_utf8() + i; + } + + let suffix = pattern[handled..].into(); + if pieces.is_empty() { + return Err("invalid numbering pattern"); + } + + Ok(Self { pieces, suffix, trimmed: false }) + } +} + +cast! { + NumberingPattern, + self => { + let mut pat = EcoString::new(); + for (prefix, kind, case) in &self.pieces { + pat.push_str(prefix); + let mut c = kind.to_char(); + if *case == Case::Upper { + c = c.to_ascii_uppercase(); + } + pat.push(c); + } + pat.push_str(&self.suffix); + pat.into_value() + }, + v: Str => v.parse()?, +} + +/// Different kinds of numberings. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +enum NumberingKind { + Arabic, + Letter, + Roman, + Symbol, + Hebrew, + SimplifiedChinese, + // TODO: Pick the numbering pattern based on languages choice. + // As the `1st` numbering character of Chinese (Simplified) and + // Chinese (Traditional) is same, we are unable to determine + // if the context is Simplified or Traditional by only this + // character. + #[allow(unused)] + TraditionalChinese, + HiraganaIroha, + KatakanaIroha, + KoreanJamo, + KoreanSyllable, +} + +impl NumberingKind { + /// Create a numbering kind from a lowercase character. + pub fn from_char(c: char) -> Option<Self> { + Some(match c { + '1' => NumberingKind::Arabic, + 'a' => NumberingKind::Letter, + 'i' => NumberingKind::Roman, + '*' => NumberingKind::Symbol, + 'א' => NumberingKind::Hebrew, + '一' | '壹' => NumberingKind::SimplifiedChinese, + 'い' => NumberingKind::HiraganaIroha, + 'イ' => NumberingKind::KatakanaIroha, + 'ㄱ' => NumberingKind::KoreanJamo, + '가' => NumberingKind::KoreanSyllable, + _ => return None, + }) + } + + /// The lowercase character for this numbering kind. + pub fn to_char(self) -> char { + match self { + Self::Arabic => '1', + Self::Letter => 'a', + Self::Roman => 'i', + Self::Symbol => '*', + Self::Hebrew => 'א', + Self::SimplifiedChinese => '一', + Self::TraditionalChinese => '一', + Self::HiraganaIroha => 'い', + Self::KatakanaIroha => 'イ', + Self::KoreanJamo => 'ㄱ', + Self::KoreanSyllable => '가', + } + } + + /// Apply the numbering to the given number. + pub fn apply(self, mut n: usize, case: Case) -> EcoString { + match self { + Self::Arabic => { + eco_format!("{n}") + } + Self::Letter => zeroless::<26>( + |x| match case { + Case::Lower => char::from(b'a' + x as u8), + Case::Upper => char::from(b'A' + x as u8), + }, + n, + ), + Self::HiraganaIroha => zeroless::<47>( + |x| { + [ + 'い', 'ろ', 'は', 'に', 'ほ', 'へ', 'と', 'ち', 'り', 'ぬ', 'る', + 'を', 'わ', 'か', 'よ', 'た', 'れ', 'そ', 'つ', 'ね', 'な', 'ら', + 'む', 'う', 'ゐ', 'の', 'お', 'く', 'や', 'ま', 'け', 'ふ', 'こ', + 'え', 'て', 'あ', 'さ', 'き', 'ゆ', 'め', 'み', 'し', 'ゑ', 'ひ', + 'も', 'せ', 'す', + ][x] + }, + n, + ), + Self::KatakanaIroha => zeroless::<47>( + |x| { + [ + 'イ', 'ロ', 'ハ', 'ニ', 'ホ', 'ヘ', 'ト', 'チ', 'リ', 'ヌ', 'ル', + 'ヲ', 'ワ', 'カ', 'ヨ', 'タ', 'レ', 'ソ', 'ツ', 'ネ', 'ナ', 'ラ', + 'ム', 'ウ', 'ヰ', 'ノ', 'オ', 'ク', 'ヤ', 'マ', 'ケ', 'フ', 'コ', + 'エ', 'テ', 'ア', 'サ', 'キ', 'ユ', 'メ', 'ミ', 'シ', 'ヱ', 'ヒ', + 'モ', 'セ', 'ス', + ][x] + }, + n, + ), + Self::Roman => { + if n == 0 { + return 'N'.into(); + } + + // Adapted from Yann Villessuzanne's roman.rs under the + // Unlicense, at https://github.com/linfir/roman.rs/ + let mut fmt = EcoString::new(); + for &(name, value) in &[ + ("M̅", 1000000), + ("D̅", 500000), + ("C̅", 100000), + ("L̅", 50000), + ("X̅", 10000), + ("V̅", 5000), + ("I̅V̅", 4000), + ("M", 1000), + ("CM", 900), + ("D", 500), + ("CD", 400), + ("C", 100), + ("XC", 90), + ("L", 50), + ("XL", 40), + ("X", 10), + ("IX", 9), + ("V", 5), + ("IV", 4), + ("I", 1), + ] { + while n >= value { + n -= value; + for c in name.chars() { + match case { + Case::Lower => fmt.extend(c.to_lowercase()), + Case::Upper => fmt.push(c), + } + } + } + } + + fmt + } + Self::Symbol => { + if n == 0 { + return '-'.into(); + } + + const SYMBOLS: &[char] = &['*', '†', '‡', '§', '¶', '‖']; + let symbol = SYMBOLS[(n - 1) % SYMBOLS.len()]; + let amount = ((n - 1) / SYMBOLS.len()) + 1; + std::iter::repeat(symbol).take(amount).collect() + } + Self::Hebrew => { + if n == 0 { + return '-'.into(); + } + + let mut fmt = EcoString::new(); + 'outer: for &(name, value) in &[ + ('ת', 400), + ('ש', 300), + ('ר', 200), + ('ק', 100), + ('צ', 90), + ('פ', 80), + ('ע', 70), + ('ס', 60), + ('נ', 50), + ('מ', 40), + ('ל', 30), + ('כ', 20), + ('י', 10), + ('ט', 9), + ('ח', 8), + ('ז', 7), + ('ו', 6), + ('ה', 5), + ('ד', 4), + ('ג', 3), + ('ב', 2), + ('א', 1), + ] { + while n >= value { + match n { + 15 => fmt.push_str("ט״ו"), + 16 => fmt.push_str("ט״ז"), + _ => { + let append_geresh = n == value && fmt.is_empty(); + if n == value && !fmt.is_empty() { + fmt.push('״'); + } + fmt.push(name); + if append_geresh { + fmt.push('׳'); + } + + n -= value; + continue; + } + } + break 'outer; + } + } + fmt + } + l @ (Self::SimplifiedChinese | Self::TraditionalChinese) => { + let chinese_case = match case { + Case::Lower => ChineseCase::Lower, + Case::Upper => ChineseCase::Upper, + }; + + match (n as u8).to_chinese( + match l { + Self::SimplifiedChinese => ChineseVariant::Simple, + Self::TraditionalChinese => ChineseVariant::Traditional, + _ => unreachable!(), + }, + chinese_case, + ChineseCountMethod::TenThousand, + ) { + Ok(num_str) => EcoString::from(num_str), + Err(_) => '-'.into(), + } + } + Self::KoreanJamo => zeroless::<14>( + |x| { + [ + 'ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', + 'ㅌ', 'ㅍ', 'ㅎ', + ][x] + }, + n, + ), + Self::KoreanSyllable => zeroless::<14>( + |x| { + [ + '가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', + '타', '파', '하', + ][x] + }, + n, + ), + } + } +} + +/// Stringify a number using a base-N counting system with no zero digit. +/// +/// This is best explained by example. Suppose our digits are 'A', 'B', and 'C'. +/// we would get the following: +/// +/// ```text +/// 1 => "A" +/// 2 => "B" +/// 3 => "C" +/// 4 => "AA" +/// 5 => "AB" +/// 6 => "AC" +/// 7 => "BA" +/// 8 => "BB" +/// 9 => "BC" +/// 10 => "CA" +/// 11 => "CB" +/// 12 => "CC" +/// 13 => "AAA" +/// etc. +/// ``` +/// +/// You might be familiar with this scheme from the way spreadsheet software +/// tends to label its columns. +fn zeroless<const N_DIGITS: usize>( + mk_digit: impl Fn(usize) -> char, + mut n: usize, +) -> EcoString { + if n == 0 { + return '-'.into(); + } + let mut cs = vec![]; + while n > 0 { + n -= 1; + cs.push(mk_digit(n % N_DIGITS)); + n /= N_DIGITS; + } + cs.into_iter().rev().collect() +} diff --git a/crates/typst-library/src/meta/outline.rs b/crates/typst-library/src/meta/outline.rs new file mode 100644 index 00000000..ba858a69 --- /dev/null +++ b/crates/typst-library/src/meta/outline.rs @@ -0,0 +1,528 @@ +use std::str::FromStr; + +use typst::util::option_eq; + +use super::{ + Counter, CounterKey, HeadingElem, LocalName, Numbering, NumberingPattern, Refable, +}; +use crate::layout::{BoxElem, HElem, HideElem, ParbreakElem, RepeatElem, Spacing}; +use crate::prelude::*; +use crate::text::{LinebreakElem, SpaceElem, TextElem}; + +/// A table of contents, figures, or other elements. +/// +/// This function generates a list of all occurrences of an element in the +/// document, up to a given depth. The element's numbering and page number will +/// be displayed in the outline alongside its title or caption. By default this +/// generates a table of contents. +/// +/// ## Example { #example } +/// ```example +/// #outline() +/// +/// = Introduction +/// #lorem(5) +/// +/// = Prior work +/// #lorem(10) +/// ``` +/// +/// ## Alternative outlines { #alternative-outlines } +/// By setting the `target` parameter, the outline can be used to generate a +/// list of other kinds of elements than headings. In the example below, we list +/// all figures containing images by setting `target` to `{figure.where(kind: +/// image)}`. We could have also set it to just `figure`, but then the list +/// would also include figures containing tables or other material. For more +/// details on the `where` selector, [see here]($type/content.where). +/// +/// ```example +/// #outline( +/// title: [List of Figures], +/// target: figure.where(kind: image), +/// ) +/// +/// #figure( +/// image("tiger.jpg"), +/// caption: [A nice figure!], +/// ) +/// ``` +/// +/// ## Styling the outline { #styling-the-outline } +/// The outline element has several options for customization, such as its +/// `title` and `indent` parameters. If desired, however, it is possible to +/// have more control over the outline's look and style through the +/// [`outline.entry`]($func/outline.entry) element. +/// +/// Display: Outline +/// Category: meta +/// Keywords: Table of Contents +#[element(Show, Finalize, LocalName)] +#[scope( + scope.define("entry", OutlineEntry::func()); + scope +)] +pub struct OutlineElem { + /// The title of the outline. + /// + /// - When set to `{auto}`, an appropriate title for the + /// [text language]($func/text.lang) will be used. This is the default. + /// - When set to `{none}`, the outline will not have a title. + /// - A custom title can be set by passing content. + /// + /// The outline's heading will not be numbered by default, but you can + /// force it to be with a show-set rule: + /// `{show outline: set heading(numbering: "1.")}` + /// ``` + #[default(Some(Smart::Auto))] + pub title: Option<Smart<Content>>, + + /// The type of element to include in the outline. + /// + /// To list figures containing a specific kind of element, like a table, you + /// can write `{figure.where(kind: table)}`. + /// + /// ```example + /// #outline( + /// title: [List of Tables], + /// target: figure.where(kind: table), + /// ) + /// + /// #figure( + /// table( + /// columns: 4, + /// [t], [1], [2], [3], + /// [y], [0.3], [0.7], [0.5], + /// ), + /// caption: [Experiment results], + /// ) + /// ``` + #[default(LocatableSelector(Selector::Elem( + HeadingElem::func(), + Some(dict! { "outlined" => true }) + )))] + pub target: LocatableSelector, + + /// The maximum level up to which elements are included in the outline. When + /// this argument is `{none}`, all elements are included. + /// + /// ```example + /// #set heading(numbering: "1.") + /// #outline(depth: 2) + /// + /// = Yes + /// Top-level section. + /// + /// == Still + /// Subsection. + /// + /// === Nope + /// Not included. + /// ``` + pub depth: Option<NonZeroUsize>, + + /// How to indent the outline's entries. + /// + /// - `{none}`: No indent + /// - `{auto}`: Indents the numbering of the nested entry with the title of + /// its parent entry. This only has an effect if the entries are numbered + /// (e.g., via [heading numbering]($func/heading.numbering)). + /// - [Relative length]($type/relative-length): Indents the item by this length + /// multiplied by its nesting level. Specifying `{2em}`, for instance, + /// would indent top-level headings (not nested) by `{0em}`, second level + /// headings by `{2em}` (nested once), third-level headings by `{4em}` + /// (nested twice) and so on. + /// - [Function]($type/function): You can completely customize this setting + /// with a function. That function receives the nesting level as a + /// parameter (starting at 0 for top-level headings/elements) and can + /// return a relative length or content making up the indent. For example, + /// `{n => n * 2em}` would be equivalent to just specifying `{2em}`, + /// while `{n => [→ ] * n}` would indent with one arrow per nesting + /// level. + /// + /// *Migration hints:* Specifying `{true}` (equivalent to `{auto}`) or + /// `{false}` (equivalent to `{none}`) for this option is deprecated and + /// will be removed in a future release. + /// + /// ```example + /// #set heading(numbering: "1.a.") + /// + /// #outline( + /// title: [Contents (Automatic)], + /// indent: auto, + /// ) + /// + /// #outline( + /// title: [Contents (Length)], + /// indent: 2em, + /// ) + /// + /// #outline( + /// title: [Contents (Function)], + /// indent: n => [→ ] * n, + /// ) + /// + /// = About ACME Corp. + /// == History + /// === Origins + /// #lorem(10) + /// + /// == Products + /// #lorem(10) + /// ``` + #[default(None)] + pub indent: Option<Smart<OutlineIndent>>, + + /// Content to fill the space between the title and the page number. Can be + /// set to `none` to disable filling. + /// + /// ```example + /// #outline(fill: line(length: 100%)) + /// + /// = A New Beginning + /// ``` + #[default(Some(RepeatElem::new(TextElem::packed(".")).pack()))] + pub fill: Option<Content>, +} + +impl Show for OutlineElem { + #[tracing::instrument(name = "OutlineElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let mut seq = vec![ParbreakElem::new().pack()]; + // Build the outline title. + if let Some(title) = self.title(styles) { + let title = + title.unwrap_or_else(|| { + TextElem::packed(self.local_name( + TextElem::lang_in(styles), + TextElem::region_in(styles), + )) + .spanned(self.span()) + }); + + seq.push(HeadingElem::new(title).with_level(NonZeroUsize::ONE).pack()); + } + + let indent = self.indent(styles); + let depth = self.depth(styles).unwrap_or(NonZeroUsize::new(usize::MAX).unwrap()); + + let mut ancestors: Vec<&Content> = vec![]; + let elems = vt.introspector.query(&self.target(styles).0); + + for elem in &elems { + let Some(entry) = OutlineEntry::from_outlinable( + vt, + self.span(), + elem.clone().into_inner(), + self.fill(styles), + )? else { + continue; + }; + + let level = entry.level(); + if depth < level { + continue; + } + + // Deals with the ancestors of the current element. + // This is only applicable for elements with a hierarchy/level. + while ancestors + .last() + .and_then(|ancestor| ancestor.with::<dyn Outlinable>()) + .map_or(false, |last| last.level() >= level) + { + ancestors.pop(); + } + + OutlineIndent::apply(&indent, vt, &ancestors, &mut seq, self.span())?; + + // Add the overridable outline entry, followed by a line break. + seq.push(entry.pack()); + seq.push(LinebreakElem::new().pack()); + + ancestors.push(elem); + } + + seq.push(ParbreakElem::new().pack()); + + Ok(Content::sequence(seq)) + } +} + +impl Finalize for OutlineElem { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + realized + .styled(HeadingElem::set_outlined(false)) + .styled(HeadingElem::set_numbering(None)) + } +} + +impl LocalName for OutlineElem { + fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "Përmbajtja", + Lang::ARABIC => "المحتويات", + Lang::BOKMÅL => "Innhold", + Lang::CHINESE if option_eq(region, "TW") => "目錄", + Lang::CHINESE => "目录", + Lang::CZECH => "Obsah", + Lang::DANISH => "Indhold", + Lang::DUTCH => "Inhoudsopgave", + Lang::FILIPINO => "Talaan ng mga Nilalaman", + Lang::FRENCH => "Table des matières", + Lang::GERMAN => "Inhaltsverzeichnis", + Lang::ITALIAN => "Indice", + Lang::NYNORSK => "Innhald", + Lang::POLISH => "Spis treści", + Lang::PORTUGUESE if option_eq(region, "PT") => "Índice", + Lang::PORTUGUESE => "Sumário", + Lang::RUSSIAN => "Содержание", + Lang::SLOVENIAN => "Kazalo", + Lang::SPANISH => "Índice", + Lang::SWEDISH => "Innehåll", + Lang::TURKISH => "İçindekiler", + Lang::UKRAINIAN => "Зміст", + Lang::VIETNAMESE => "Mục lục", + Lang::ENGLISH | _ => "Contents", + } + } +} + +/// Marks an element as being able to be outlined. This is used to implement the +/// `#outline()` element. +pub trait Outlinable: Refable { + /// Produce an outline item for this element. + fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>>; + + /// Returns the nesting level of this element. + fn level(&self) -> NonZeroUsize { + NonZeroUsize::ONE + } +} + +#[derive(Debug, Clone)] +pub enum OutlineIndent { + Bool(bool), + Rel(Rel<Length>), + Func(Func), +} + +impl OutlineIndent { + fn apply( + indent: &Option<Smart<Self>>, + vt: &mut Vt, + ancestors: &Vec<&Content>, + seq: &mut Vec<Content>, + span: Span, + ) -> SourceResult<()> { + match indent { + // 'none' | 'false' => no indenting + None | Some(Smart::Custom(OutlineIndent::Bool(false))) => {} + + // 'auto' | 'true' => use numbering alignment for indenting + Some(Smart::Auto | Smart::Custom(OutlineIndent::Bool(true))) => { + // Add hidden ancestors numberings to realize the indent. + let mut hidden = Content::empty(); + for ancestor in ancestors { + let ancestor_outlinable = ancestor.with::<dyn Outlinable>().unwrap(); + + if let Some(numbering) = ancestor_outlinable.numbering() { + let numbers = ancestor_outlinable + .counter() + .at(vt, ancestor.location().unwrap())? + .display(vt, &numbering)?; + + hidden += numbers + SpaceElem::new().pack(); + }; + } + + if !ancestors.is_empty() { + seq.push(HideElem::new(hidden).pack()); + seq.push(SpaceElem::new().pack()); + } + } + + // Length => indent with some fixed spacing per level + Some(Smart::Custom(OutlineIndent::Rel(length))) => { + seq.push( + HElem::new(Spacing::Rel(*length)).pack().repeat(ancestors.len()), + ); + } + + // Function => call function with the current depth and take + // the returned content + Some(Smart::Custom(OutlineIndent::Func(func))) => { + let depth = ancestors.len(); + let LengthOrContent(content) = + func.call_vt(vt, [depth])?.cast().at(span)?; + if !content.is_empty() { + seq.push(content); + } + } + }; + + Ok(()) + } +} + +cast! { + OutlineIndent, + self => match self { + Self::Bool(v) => v.into_value(), + Self::Rel(v) => v.into_value(), + Self::Func(v) => v.into_value() + }, + v: bool => OutlineIndent::Bool(v), + v: Rel<Length> => OutlineIndent::Rel(v), + v: Func => OutlineIndent::Func(v), +} + +struct LengthOrContent(Content); + +cast! { + LengthOrContent, + v: Rel<Length> => Self(HElem::new(Spacing::Rel(v)).pack()), + v: Content => Self(v), +} + +/// Represents each entry line in an outline, including the reference to the +/// outlined element, its page number, and the filler content between both. +/// +/// This element is intended for use with show rules to control the appearance +/// of outlines. +/// +/// ## Example { #example } +/// The example below shows how to style entries for top-level sections to make +/// them stand out. +/// +/// ```example +/// #set heading(numbering: "1.") +/// +/// #show outline.entry.where( +/// level: 1 +/// ): it => { +/// v(12pt, weak: true) +/// strong(it) +/// } +/// +/// #outline(indent: auto) +/// +/// = Introduction +/// = Background +/// == History +/// == State of the Art +/// = Analysis +/// == Setup +/// ``` +/// +/// To completely customize an entry's line, you can also build it from scratch +/// by accessing the `level`, `element`, `body`, `fill` and `page` fields on the entry. +/// +/// Display: Outline Entry +/// Category: meta +#[element(Show)] +pub struct OutlineEntry { + /// The nesting level of this outline entry. Starts at `{1}` for top-level + /// entries. + #[required] + pub level: NonZeroUsize, + + /// The element this entry refers to. Its location will be available + /// through the [`location`]($type/content.location) method on content + /// and can be [linked]($func/link) to. + #[required] + pub element: Content, + + /// The content which is displayed in place of the referred element at its + /// entry in the outline. For a heading, this would be its number followed + /// by the heading's title, for example. + #[required] + pub body: Content, + + /// The content used to fill the space between the element's outline and + /// its page number, as defined by the outline element this entry is + /// located in. When `{none}`, empty space is inserted in that gap instead. + /// + /// Note that, when using show rules to override outline entries, it is + /// recommended to wrap the filling content in a [`box`]($func/box) with + /// fractional width. For example, `{box(width: 1fr, repeat[-])}` would show + /// precisely as many `-` characters as necessary to fill a particular gap. + #[required] + pub fill: Option<Content>, + + /// The page number of the element this entry links to, formatted with the + /// numbering set for the referenced page. + #[required] + pub page: Content, +} + +impl OutlineEntry { + /// Generates an OutlineEntry from the given element, if possible (errors if + /// the element does not implement `Outlinable`). If the element should not + /// be outlined (e.g. heading with 'outlined: false'), does not generate an + /// entry instance (returns `Ok(None)`). + fn from_outlinable( + vt: &mut Vt, + span: Span, + elem: Content, + fill: Option<Content>, + ) -> SourceResult<Option<Self>> { + let Some(outlinable) = elem.with::<dyn Outlinable>() else { + bail!(span, "cannot outline {}", elem.func().name()); + }; + + let Some(body) = outlinable.outline(vt)? else { + return Ok(None); + }; + + let location = elem.location().unwrap(); + let page_numbering = vt + .introspector + .page_numbering(location) + .cast::<Option<Numbering>>() + .unwrap() + .unwrap_or_else(|| { + Numbering::Pattern(NumberingPattern::from_str("1").unwrap()) + }); + + let page = Counter::new(CounterKey::Page) + .at(vt, location)? + .display(vt, &page_numbering)?; + + Ok(Some(Self::new(outlinable.level(), elem, body, fill, page))) + } +} + +impl Show for OutlineEntry { + fn show(&self, _vt: &mut Vt, _: StyleChain) -> SourceResult<Content> { + let mut seq = vec![]; + let elem = self.element(); + + // In case a user constructs an outline entry with an arbitrary element. + let Some(location) = elem.location() else { + bail!(self.span(), "cannot outline {}", elem.func().name()) + }; + + // The body text remains overridable. + seq.push(self.body().linked(Destination::Location(location))); + + // Add filler symbols between the section name and page number. + if let Some(filler) = self.fill() { + seq.push(SpaceElem::new().pack()); + seq.push( + BoxElem::new() + .with_body(Some(filler)) + .with_width(Fr::one().into()) + .pack(), + ); + seq.push(SpaceElem::new().pack()); + } else { + seq.push(HElem::new(Fr::one().into()).pack()); + } + + // Add the page number. + let page = self.page().linked(Destination::Location(location)); + seq.push(page); + + Ok(Content::sequence(seq)) + } +} diff --git a/crates/typst-library/src/meta/query.rs b/crates/typst-library/src/meta/query.rs new file mode 100644 index 00000000..826b812e --- /dev/null +++ b/crates/typst-library/src/meta/query.rs @@ -0,0 +1,145 @@ +use crate::prelude::*; + +/// Finds elements in the document. +/// +/// The `query` functions lets you search your document for elements of a +/// particular type or with a particular label. +/// +/// To use it, you first need to retrieve the current document location with the +/// [`locate`]($func/locate) function. You can then decide whether you want to +/// find all elements, just the ones before that location, or just the ones +/// after it. +/// +/// ## Finding elements { #finding-elements } +/// In the example below, we create a custom page header that displays the text +/// "Typst Academy" in small capitals and the current section title. On the +/// first page, the section title is omitted because the header is before the +/// first section heading. +/// +/// To realize this layout, we call `locate` and then query for all headings +/// after the current location. The function we pass to locate is called twice +/// in this case: Once per page. +/// +/// - On the first page the query for all headings before the current location +/// yields an empty array: There are no previous headings. We check for this +/// case and and just display "Typst Academy". +/// +/// - For the second page, we retrieve the last element from the query's result. +/// This is the latest heading before the current position and as such, it is +/// the heading of the section we are currently in. We access its content +/// through the `body` field and display it alongside "Typst Academy". +/// +/// ```example +/// >>> #set page( +/// >>> width: 240pt, +/// >>> height: 180pt, +/// >>> margin: (top: 35pt, rest: 15pt), +/// >>> header-ascent: 12pt, +/// >>> ) +/// #set page(header: locate(loc => { +/// let elems = query( +/// selector(heading).before(loc), +/// loc, +/// ) +/// let academy = smallcaps[ +/// Typst Academy +/// ] +/// if elems == () { +/// align(right, academy) +/// } else { +/// let body = elems.last().body +/// academy + h(1fr) + emph(body) +/// } +/// })) +/// +/// = Introduction +/// #lorem(23) +/// +/// = Background +/// #lorem(30) +/// +/// = Analysis +/// #lorem(15) +/// ``` +/// +/// ## A word of caution { #caution } +/// To resolve all your queries, Typst evaluates and layouts parts of the +/// document multiple times. However, there is no guarantee that your queries +/// can actually be completely resolved. If you aren't careful a query can +/// affect itself—leading to a result that never stabilizes. +/// +/// In the example below, we query for all headings in the document. We then +/// generate as many headings. In the beginning, there's just one heading, +/// titled `Real`. Thus, `count` is `1` and one `Fake` heading is generated. +/// Typst sees that the query's result has changed and processes it again. This +/// time, `count` is `2` and two `Fake` headings are generated. This goes on and +/// on. As we can see, the output has five headings. This is because Typst +/// simply gives up after five attempts. +/// +/// In general, you should try not to write queries that affect themselves. +/// The same words of caution also apply to other introspection features like +/// [counters]($func/counter) and [state]($func/state). +/// +/// ```example +/// = Real +/// #locate(loc => { +/// let elems = query(heading, loc) +/// let count = elems.len() +/// count * [= Fake] +/// }) +/// ``` +/// +/// ## Migration Hints { #migration-hints } +/// The `before` and `after` arguments have been removed in version 0.3.0. You +/// can now use flexible selector combinator methods instead. For example, +/// `query(heading, before: loc)` becomes `query(heading.before(loc), loc)`. +/// Please refer to the [selector documentation]($type/selector) for more +/// details. +/// +/// Display: Query +/// Category: meta +#[func] +pub fn query( + /// Can be an element function like a `heading` or `figure`, a `{<label>}` + /// or a more complex selector like `{heading.where(level: 1)}`. + /// + /// Currently, only a subset of element functions is supported. Aside from + /// headings and figures, this includes equations, references and all + /// elements with an explicit label. As a result, you _can_ query for e.g. + /// [`strong`]($func/strong) elements, but you will find only those that + /// have an explicit label attached to them. This limitation will be + /// resolved in the future. + target: LocatableSelector, + /// Can be any location. Why is it required then? As noted before, Typst has + /// to evaluate parts of your code multiple times to determine the values of + /// all state. By only allowing this function within + /// [`locate`]($func/locate) calls, the amount of code that can depend on + /// the query's result is reduced. If you could call it directly at the top + /// level of a module, the evaluation of the whole module and its exports + /// could depend on the query's result. + location: Location, + /// The virtual machine. + vm: &mut Vm, +) -> Array { + let _ = location; + let vec = vm.vt.introspector.query(&target.0); + vec.into_iter() + .map(|elem| Value::Content(elem.into_inner())) + .collect() +} + +/// Turns a value into a selector. The following values are accepted: +/// - An element function like a `heading` or `figure`. +/// - A `{<label>}`. +/// - A more complex selector like `{heading.where(level: 1)}`. +/// +/// Display: Selector +/// Category: meta +#[func] +pub fn selector( + /// Can be an element function like a `heading` or `figure`, a `{<label>}` + /// or a more complex selector like `{heading.where(level: 1)}`. + target: Selector, +) -> Selector { + target +} diff --git a/crates/typst-library/src/meta/reference.rs b/crates/typst-library/src/meta/reference.rs new file mode 100644 index 00000000..5bd04431 --- /dev/null +++ b/crates/typst-library/src/meta/reference.rs @@ -0,0 +1,276 @@ +use super::{BibliographyElem, CiteElem, Counter, Figurable, Numbering}; +use crate::meta::FootnoteElem; +use crate::prelude::*; +use crate::text::TextElem; + +/// A reference to a label or bibliography. +/// +/// Produces a textual reference to a label. For example, a reference to a +/// heading will yield an appropriate string such as "Section 1" for a reference +/// to the first heading. The references are also links to the respective +/// element. Reference syntax can also be used to [cite]($func/cite) from a +/// bibliography. +/// +/// Referenceable elements include [headings]($func/heading), +/// [figures]($func/figure), [equations]($func/math.equation), and +/// [footnotes]($func/footnote). To create a custom referenceable element like a +/// theorem, you can create a figure of a custom [`kind`]($func/figure.kind) and +/// write a show rule for it. In the future, there might be a more direct way to +/// define a custom referenceable element. +/// +/// If you just want to link to a labelled element and not get an automatic +/// textual reference, consider using the [`link`]($func/link) function instead. +/// +/// ## Example { #example } +/// ```example +/// #set heading(numbering: "1.") +/// #set math.equation(numbering: "(1)") +/// +/// = Introduction <intro> +/// Recent developments in +/// typesetting software have +/// rekindled hope in previously +/// frustrated researchers. @distress +/// As shown in @results, we ... +/// +/// = Results <results> +/// We discuss our approach in +/// comparison with others. +/// +/// == Performance <perf> +/// @slow demonstrates what slow +/// software looks like. +/// $ O(n) = 2^n $ <slow> +/// +/// #bibliography("works.bib") +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: A reference to a label can be +/// created by typing an `@` followed by the name of the label (e.g. +/// `[= Introduction <intro>]` can be referenced by typing `[@intro]`). +/// +/// To customize the supplement, add content in square brackets after the +/// reference: `[@intro[Chapter]]`. +/// +/// ## Customization { #customization } +/// If you write a show rule for references, you can access the referenced +/// element through the `element` field of the reference. The `element` may +/// be `{none}` even if it exists if Typst hasn't discovered it yet, so you +/// always need to handle that case in your code. +/// +/// ```example +/// #set heading(numbering: "1.") +/// #set math.equation(numbering: "(1)") +/// +/// #show ref: it => { +/// let eq = math.equation +/// let el = it.element +/// if el != none and el.func() == eq { +/// // Override equation references. +/// numbering( +/// el.numbering, +/// ..counter(eq).at(el.location()) +/// ) +/// } else { +/// // Other references as usual. +/// it +/// } +/// } +/// +/// = Beginnings <beginning> +/// In @beginning we prove @pythagoras. +/// $ a^2 + b^2 = c^2 $ <pythagoras> +/// ``` +/// +/// Display: Reference +/// Category: meta +#[element(Synthesize, Locatable, Show)] +pub struct RefElem { + /// The target label that should be referenced. + #[required] + pub target: Label, + + /// A supplement for the reference. + /// + /// For references to headings or figures, this is added before the + /// referenced number. For citations, this can be used to add a page number. + /// + /// If a function is specified, it is passed the referenced element and + /// should return content. + /// + /// ```example + /// #set heading(numbering: "1.") + /// #set ref(supplement: it => { + /// if it.func() == heading { + /// "Chapter" + /// } else { + /// "Thing" + /// } + /// }) + /// + /// = Introduction <intro> + /// In @intro, we see how to turn + /// Sections into Chapters. And + /// in @intro[Part], it is done + /// manually. + /// ``` + pub supplement: Smart<Option<Supplement>>, + + /// A synthesized citation. + #[synthesized] + pub citation: Option<CiteElem>, + + /// The referenced element. + #[synthesized] + pub element: Option<Content>, +} + +impl Synthesize for RefElem { + fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + let citation = self.to_citation(vt, styles)?; + self.push_citation(Some(citation)); + self.push_element(None); + + let target = self.target(); + if !BibliographyElem::has(vt, &target.0) { + if let Ok(elem) = vt.introspector.query_label(&target) { + self.push_element(Some(elem.into_inner())); + return Ok(()); + } + } + + Ok(()) + } +} + +impl Show for RefElem { + #[tracing::instrument(name = "RefElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(vt.delayed(|vt| { + let target = self.target(); + let elem = vt.introspector.query_label(&self.target()); + let span = self.span(); + + if BibliographyElem::has(vt, &target.0) { + if elem.is_ok() { + bail!(span, "label occurs in the document and its bibliography"); + } + + return Ok(self.to_citation(vt, styles)?.pack().spanned(span)); + } + + let elem = elem.at(span)?; + + if elem.func() == FootnoteElem::func() { + return Ok(FootnoteElem::with_label(target).pack().spanned(span)); + } + + let refable = elem + .with::<dyn Refable>() + .ok_or_else(|| { + if elem.can::<dyn Figurable>() { + eco_format!( + "cannot reference {} directly, try putting it into a figure", + elem.func().name() + ) + } else { + eco_format!("cannot reference {}", elem.func().name()) + } + }) + .at(span)?; + + let numbering = refable + .numbering() + .ok_or_else(|| { + eco_format!( + "cannot reference {} without numbering", + elem.func().name() + ) + }) + .hint(eco_format!( + "you can enable heading numbering with `#set {}(numbering: \"1.\")`", + elem.func().name() + )) + .at(span)?; + + let numbers = refable + .counter() + .at(vt, elem.location().unwrap())? + .display(vt, &numbering.trimmed())?; + + let supplement = match self.supplement(styles) { + Smart::Auto => refable.supplement(), + Smart::Custom(None) => Content::empty(), + Smart::Custom(Some(supplement)) => { + supplement.resolve(vt, [(*elem).clone()])? + } + }; + + let mut content = numbers; + if !supplement.is_empty() { + content = supplement + TextElem::packed("\u{a0}") + content; + } + + Ok(content.linked(Destination::Location(elem.location().unwrap()))) + })) + } +} + +impl RefElem { + /// Turn the reference into a citation. + pub fn to_citation(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<CiteElem> { + let mut elem = CiteElem::new(vec![self.target().0]); + elem.0.set_location(self.0.location().unwrap()); + elem.synthesize(vt, styles)?; + elem.push_supplement(match self.supplement(styles) { + Smart::Custom(Some(Supplement::Content(content))) => Some(content), + _ => None, + }); + + Ok(elem) + } +} + +/// Additional content for a reference. +pub enum Supplement { + Content(Content), + Func(Func), +} + +impl Supplement { + /// Tries to resolve the supplement into its content. + pub fn resolve<T: IntoValue>( + &self, + vt: &mut Vt, + args: impl IntoIterator<Item = T>, + ) -> SourceResult<Content> { + Ok(match self { + Supplement::Content(content) => content.clone(), + Supplement::Func(func) => func.call_vt(vt, args)?.display(), + }) + } +} + +cast! { + Supplement, + self => match self { + Self::Content(v) => v.into_value(), + Self::Func(v) => v.into_value(), + }, + v: Content => Self::Content(v), + v: Func => Self::Func(v), +} + +/// Marks an element as being able to be referenced. This is used to implement +/// the `@ref` element. +pub trait Refable { + /// The supplement, if not overridden by the reference. + fn supplement(&self) -> Content; + + /// Returns the counter of this element. + fn counter(&self) -> Counter; + + /// Returns the numbering of this element. + fn numbering(&self) -> Option<Numbering>; +} diff --git a/crates/typst-library/src/meta/state.rs b/crates/typst-library/src/meta/state.rs new file mode 100644 index 00000000..aee53a29 --- /dev/null +++ b/crates/typst-library/src/meta/state.rs @@ -0,0 +1,440 @@ +use std::fmt::{self, Debug, Formatter, Write}; + +use ecow::{eco_vec, EcoVec}; +use typst::eval::Tracer; +use typst::model::DelayedErrors; + +use crate::prelude::*; + +/// Manages stateful parts of your document. +/// +/// Let's say you have some computations in your document and want to remember +/// the result of your last computation to use it in the next one. You might try +/// something similar to the code below and expect it to output 10, 13, 26, and +/// 21. However this **does not work** in Typst. If you test this code, you will +/// see that Typst complains with the following error message: _Variables from +/// outside the function are read-only and cannot be modified._ +/// +/// ```typ +/// #let x = 0 +/// #let compute(expr) = { +/// x = eval( +/// expr.replace("x", str(x)) +/// ) +/// [New value is #x. ] +/// } +/// +/// #compute("10") \ +/// #compute("x + 3") \ +/// #compute("x * 2") \ +/// #compute("x - 5") +/// ``` +/// +/// ## State and document markup { #state-and-markup } +/// Why does it do that? Because, in general, this kind of computation with side +/// effects is problematic in document markup and Typst is upfront about that. +/// For the results to make sense, the computation must proceed in the same +/// order in which the results will be laid out in the document. In our simple +/// example, that's the case, but in general it might not be. +/// +/// Let's look at a slightly different, but similar kind of state: The heading +/// numbering. We want to increase the heading counter at each heading. Easy +/// enough, right? Just add one. Well, it's not that simple. Consider the +/// following example: +/// +/// ```example +/// #set heading(numbering: "1.") +/// #let template(body) = [ +/// = Outline +/// ... +/// #body +/// ] +/// +/// #show: template +/// +/// = Introduction +/// ... +/// ``` +/// +/// Here, Typst first processes the body of the document after the show rule, +/// sees the `Introduction` heading, then passes the resulting content to the +/// `template` function and only then sees the `Outline`. Just counting up would +/// number the `Introduction` with `1` and the `Outline` with `2`. +/// +/// ## Managing state in Typst { #state-in-typst } +/// So what do we do instead? We use Typst's state management system. Calling +/// the `state` function with an identifying string key and an optional initial +/// value gives you a state value which exposes a few methods. The two most +/// important ones are `display` and `update`: +/// +/// - The `display` method shows the current value of the state. You can +/// optionally give it a function that receives the value and formats it in +/// some way. +/// +/// - The `update` method modifies the state. You can give it any value. If +/// given a non-function value, it sets the state to that value. If given a +/// function, that function receives the previous state and has to return the +/// new state. +/// +/// Our initial example would now look like this: +/// +/// ```example +/// #let s = state("x", 0) +/// #let compute(expr) = [ +/// #s.update(x => +/// eval(expr.replace("x", str(x))) +/// ) +/// New value is #s.display(). +/// ] +/// +/// #compute("10") \ +/// #compute("x + 3") \ +/// #compute("x * 2") \ +/// #compute("x - 5") +/// ``` +/// +/// State managed by Typst is always updated in layout order, not in evaluation +/// order. The `update` method returns content and its effect occurs at the +/// position where the returned content is inserted into the document. +/// +/// As a result, we can now also store some of the computations in +/// variables, but they still show the correct results: +/// +/// ```example +/// >>> #let s = state("x", 0) +/// >>> #let compute(expr) = [ +/// >>> #s.update(x => +/// >>> eval(expr.replace("x", str(x))) +/// >>> ) +/// >>> New value is #s.display(). +/// >>> ] +/// <<< ... +/// +/// #let more = [ +/// #compute("x * 2") \ +/// #compute("x - 5") +/// ] +/// +/// #compute("10") \ +/// #compute("x + 3") \ +/// #more +/// ``` +/// +/// This example is of course a bit silly, but in practice this is often exactly +/// what you want! A good example are heading counters, which is why Typst's +/// [counting system]($func/counter) is very similar to its state system. +/// +/// ## Time Travel { #time-travel } +/// By using Typst's state management system you also get time travel +/// capabilities! By combining the state system with [`locate`]($func/locate) +/// and [`query`]($func/query), we can find out what the value of the state will +/// be at any position in the document from anywhere else. In particular, the +/// `at` method gives us the value of the state at any location and the `final` +/// methods gives us the value of the state at the end of the document. +/// +/// ```example +/// >>> #let s = state("x", 0) +/// >>> #let compute(expr) = [ +/// >>> #s.update(x => { +/// >>> eval(expr.replace("x", str(x))) +/// >>> }) +/// >>> New value is #s.display(). +/// >>> ] +/// <<< ... +/// +/// Value at `<here>` is +/// #locate(loc => s.at( +/// query(<here>, loc) +/// .first() +/// .location() +/// )) +/// +/// #compute("10") \ +/// #compute("x + 3") \ +/// *Here.* <here> \ +/// #compute("x * 2") \ +/// #compute("x - 5") +/// ``` +/// +/// ## A word of caution { #caution } +/// To resolve the values of all states, Typst evaluates parts of your code +/// multiple times. However, there is no guarantee that your state manipulation +/// can actually be completely resolved. +/// +/// For instance, if you generate state updates depending on the final value of +/// a state, the results might never converge. The example below illustrates +/// this. We initialize our state with `1` and then update it to its own final +/// value plus 1. So it should be `2`, but then its final value is `2`, so it +/// should be `3`, and so on. This example display `4` because Typst simply +/// gives up after a few attempts. +/// +/// ```example +/// #let s = state("x", 1) +/// #locate(loc => { +/// s.update(s.final(loc) + 1) +/// }) +/// #s.display() +/// ``` +/// +/// In general, you should _typically_ not generate state updates from within +/// `locate` calls or `display` calls of state or counters. Instead, pass a +/// function to `update` that determines the value of the state based on its +/// previous value. +/// +/// ## Methods +/// ### display() +/// Displays the value of the state. +/// +/// - format: function (positional) +/// A function which receives the value of the state and can return arbitrary +/// content which is then displayed. If this is omitted, the value is directly +/// displayed. +/// +/// - returns: content +/// +/// ### update() +/// Updates the value of the state. +/// +/// The update will be in effect at the position where the returned content is +/// inserted into the document. If you don't put the output into the document, +/// nothing happens! This would be the case, for example, if you write +/// `{let _ = state("key").update(7)}`. State updates are always applied in +/// layout order and in that case, Typst wouldn't know when to update the state. +/// +/// - value: any or function (positional, required) +/// If given a non function-value, sets the state to that value. If given a +/// function, that function receives the previous state and has to return the +/// new state. +/// +/// - returns: content +/// +/// ### at() +/// Gets the value of the state at the given location. +/// +/// - location: location (positional, required) +/// The location at which the state's value should be retrieved. A suitable +/// location can be retrieved from [`locate`]($func/locate) or +/// [`query`]($func/query). +/// +/// - returns: any +/// +/// ### final() +/// Gets the value of the state at the end of the document. +/// +/// - location: location (positional, required) +/// Can be any location. Why is it required then? As noted before, Typst has +/// to evaluate parts of your code multiple times to determine the values of +/// all state. By only allowing this method within [`locate`]($func/locate) +/// calls, the amount of code that can depend on the method's result is +/// reduced. If you could call `final` directly at the top level of a module, +/// the evaluation of the whole module and its exports could depend on the +/// state's value. +/// +/// - returns: any +/// +/// Display: State +/// Category: meta +#[func] +pub fn state( + /// The key that identifies this state. + key: Str, + /// The initial value of the state. + #[default] + init: Value, +) -> State { + State { key, init } +} + +/// A state. +#[derive(Clone, PartialEq, Hash)] +pub struct State { + /// The key that identifies the state. + key: Str, + /// The initial value of the state. + init: Value, +} + +impl State { + /// Call a method on a state. + #[tracing::instrument(skip(vm))] + pub fn call_method( + self, + vm: &mut Vm, + method: &str, + mut args: Args, + span: Span, + ) -> SourceResult<Value> { + let value = match method { + "display" => self.display(args.eat()?).into_value(), + "at" => self.at(&mut vm.vt, args.expect("location")?)?, + "final" => self.final_(&mut vm.vt, args.expect("location")?)?, + "update" => self.update(args.expect("value or function")?).into_value(), + _ => bail!(span, "type state has no method `{}`", method), + }; + args.finish()?; + Ok(value) + } + + /// Display the current value of the state. + pub fn display(self, func: Option<Func>) -> Content { + DisplayElem::new(self, func).pack() + } + + /// Get the value of the state at the given location. + #[tracing::instrument(skip(self, vt))] + pub fn at(self, vt: &mut Vt, location: Location) -> SourceResult<Value> { + let sequence = self.sequence(vt)?; + let offset = vt.introspector.query(&self.selector().before(location, true)).len(); + Ok(sequence[offset].clone()) + } + + /// Get the value of the state at the final location. + #[tracing::instrument(skip(self, vt))] + pub fn final_(self, vt: &mut Vt, _: Location) -> SourceResult<Value> { + let sequence = self.sequence(vt)?; + Ok(sequence.last().unwrap().clone()) + } + + /// Produce content that performs a state update. + pub fn update(self, update: StateUpdate) -> Content { + UpdateElem::new(self, update).pack() + } + + /// Produce the whole sequence of states. + /// + /// This has to happen just once for all states, cutting down the number + /// of state updates from quadratic to linear. + fn sequence(&self, vt: &mut Vt) -> SourceResult<EcoVec<Value>> { + self.sequence_impl( + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + ) + } + + /// Memoized implementation of `sequence`. + #[comemo::memoize] + fn sequence_impl( + &self, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + locator: Tracked<Locator>, + delayed: TrackedMut<DelayedErrors>, + tracer: TrackedMut<Tracer>, + ) -> SourceResult<EcoVec<Value>> { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let mut state = self.init.clone(); + let mut stops = eco_vec![state.clone()]; + + for elem in introspector.query(&self.selector()) { + let elem = elem.to::<UpdateElem>().unwrap(); + match elem.update() { + StateUpdate::Set(value) => state = value, + StateUpdate::Func(func) => state = func.call_vt(&mut vt, [state])?, + } + stops.push(state.clone()); + } + + Ok(stops) + } + + /// The selector for this state's updates. + fn selector(&self) -> Selector { + Selector::Elem(UpdateElem::func(), Some(dict! { "state" => self.clone() })) + } +} + +impl Debug for State { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.write_str("state(")?; + self.key.fmt(f)?; + f.write_str(", ")?; + self.init.fmt(f)?; + f.write_char(')') + } +} + +cast! { + type State: "state", +} + +/// An update to perform on a state. +#[derive(Clone, PartialEq, Hash)] +pub enum StateUpdate { + /// Set the state to the specified value. + Set(Value), + /// Apply the given function to the state. + Func(Func), +} + +impl Debug for StateUpdate { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.pad("..") + } +} + +cast! { + type StateUpdate: "state update", + v: Func => Self::Func(v), + v: Value => Self::Set(v), +} + +/// Executes a display of a state. +/// +/// Display: State +/// Category: special +#[element(Locatable, Show)] +struct DisplayElem { + /// The state. + #[required] + state: State, + + /// The function to display the state with. + #[required] + func: Option<Func>, +} + +impl Show for DisplayElem { + #[tracing::instrument(name = "DisplayElem::show", skip(self, vt))] + fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> { + Ok(vt.delayed(|vt| { + let location = self.0.location().unwrap(); + let value = self.state().at(vt, location)?; + Ok(match self.func() { + Some(func) => func.call_vt(vt, [value])?.display(), + None => value.display(), + }) + })) + } +} + +/// Executes a display of a state. +/// +/// Display: State +/// Category: special +#[element(Locatable, Show)] +struct UpdateElem { + /// The state. + #[required] + state: State, + + /// The update to perform on the state. + #[required] + update: StateUpdate, +} + +impl Show for UpdateElem { + #[tracing::instrument(name = "UpdateElem::show")] + fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> { + Ok(Content::empty()) + } +} diff --git a/crates/typst-library/src/prelude.rs b/crates/typst-library/src/prelude.rs new file mode 100644 index 00000000..c720e770 --- /dev/null +++ b/crates/typst-library/src/prelude.rs @@ -0,0 +1,42 @@ +//! Helpful imports for creating library functionality. + +#[doc(no_inline)] +pub use std::fmt::{self, Debug, Formatter}; +#[doc(no_inline)] +pub use std::num::NonZeroUsize; + +#[doc(no_inline)] +pub use comemo::{Track, Tracked, TrackedMut}; +#[doc(no_inline)] +pub use ecow::{eco_format, EcoString}; +#[doc(no_inline)] +pub use typst::diag::{bail, error, At, Hint, SourceResult, StrResult}; +#[doc(no_inline)] +pub use typst::doc::*; +#[doc(no_inline)] +pub use typst::eval::{ + array, cast, dict, format_str, func, Args, Array, AutoValue, Cast, Dict, FromValue, + Func, IntoValue, Never, NoneValue, Scope, Str, Symbol, Type, Value, Vm, +}; +#[doc(no_inline)] +pub use typst::file::FileId; +#[doc(no_inline)] +pub use typst::geom::*; +#[doc(no_inline)] +pub use typst::model::{ + element, Behave, Behaviour, Construct, Content, ElemFunc, Element, Finalize, Fold, + Introspector, Label, Locatable, LocatableSelector, Location, Locator, MetaElem, + PlainText, Resolve, Selector, Set, Show, StyleChain, StyleVec, Styles, Synthesize, + Unlabellable, Vt, +}; +#[doc(no_inline)] +pub use typst::syntax::{Span, Spanned}; +#[doc(no_inline)] +pub use typst::util::NonZeroExt; +#[doc(no_inline)] +pub use typst::World; + +#[doc(no_inline)] +pub use crate::layout::{Fragment, Layout, Regions}; +#[doc(no_inline)] +pub use crate::shared::{ContentExt, StylesExt}; diff --git a/crates/typst-library/src/shared/behave.rs b/crates/typst-library/src/shared/behave.rs new file mode 100644 index 00000000..6a1aa127 --- /dev/null +++ b/crates/typst-library/src/shared/behave.rs @@ -0,0 +1,109 @@ +//! Element interaction. + +use typst::model::{Behave, Behaviour, Content, StyleChain, StyleVec, StyleVecBuilder}; + +/// A wrapper around a [`StyleVecBuilder`] that allows elements to interact. +#[derive(Debug)] +pub struct BehavedBuilder<'a> { + /// The internal builder. + builder: StyleVecBuilder<'a, Content>, + /// Staged weak and ignorant elements that we can't yet commit to the + /// builder. The option is `Some(_)` for weak elements and `None` for + /// ignorant elements. + staged: Vec<(Content, Behaviour, StyleChain<'a>)>, + /// What the last non-ignorant item was. + last: Behaviour, +} + +impl<'a> BehavedBuilder<'a> { + /// Create a new style-vec builder. + pub fn new() -> Self { + Self { + builder: StyleVecBuilder::new(), + staged: vec![], + last: Behaviour::Destructive, + } + } + + /// Whether the builder is totally empty. + pub fn is_empty(&self) -> bool { + self.builder.is_empty() && self.staged.is_empty() + } + + /// Whether the builder is empty except for some weak elements that will + /// probably collapse. + pub fn is_basically_empty(&self) -> bool { + self.builder.is_empty() + && self + .staged + .iter() + .all(|(_, behaviour, _)| matches!(behaviour, Behaviour::Weak(_))) + } + + /// Push an item into the sequence. + pub fn push(&mut self, elem: Content, styles: StyleChain<'a>) { + let interaction = elem + .with::<dyn Behave>() + .map_or(Behaviour::Supportive, Behave::behaviour); + + match interaction { + Behaviour::Weak(level) => { + if matches!(self.last, Behaviour::Weak(_)) { + let item = elem.with::<dyn Behave>().unwrap(); + let i = self.staged.iter().position(|prev| { + let Behaviour::Weak(prev_level) = prev.1 else { return false }; + level < prev_level + || (level == prev_level && item.larger(&prev.0)) + }); + let Some(i) = i else { return }; + self.staged.remove(i); + } + + if self.last != Behaviour::Destructive { + self.staged.push((elem, interaction, styles)); + self.last = interaction; + } + } + Behaviour::Supportive => { + self.flush(true); + self.builder.push(elem, styles); + self.last = interaction; + } + Behaviour::Destructive => { + self.flush(false); + self.builder.push(elem, styles); + self.last = interaction; + } + Behaviour::Ignorant => { + self.staged.push((elem, interaction, styles)); + } + } + } + + /// Iterate over the contained elements. + pub fn elems(&self) -> impl DoubleEndedIterator<Item = &Content> { + self.builder.elems().chain(self.staged.iter().map(|(item, ..)| item)) + } + + /// Return the finish style vec and the common prefix chain. + pub fn finish(mut self) -> (StyleVec<Content>, StyleChain<'a>) { + self.flush(false); + self.builder.finish() + } + + /// Push the staged elements, filtering out weak elements if `supportive` is + /// false. + fn flush(&mut self, supportive: bool) { + for (item, interaction, styles) in self.staged.drain(..) { + if supportive || interaction == Behaviour::Ignorant { + self.builder.push(item, styles); + } + } + } +} + +impl<'a> Default for BehavedBuilder<'a> { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/typst-library/src/shared/ext.rs b/crates/typst-library/src/shared/ext.rs new file mode 100644 index 00000000..d7c80a30 --- /dev/null +++ b/crates/typst-library/src/shared/ext.rs @@ -0,0 +1,92 @@ +//! Extension traits. + +use crate::layout::{AlignElem, MoveElem, PadElem}; +use crate::prelude::*; +use crate::text::{EmphElem, FontFamily, FontList, StrongElem, TextElem, UnderlineElem}; + +/// Additional methods on content. +pub trait ContentExt { + /// Make this content strong. + fn strong(self) -> Self; + + /// Make this content emphasized. + fn emph(self) -> Self; + + /// Underline this content. + fn underlined(self) -> Self; + + /// Link the content somewhere. + fn linked(self, dest: Destination) -> Self; + + /// Make the content linkable by `.linked(Destination::Location(loc))`. + /// + /// Should be used in combination with [`Location::variant`]. + fn backlinked(self, loc: Location) -> Self; + + /// Set alignments for this content. + fn aligned(self, aligns: Axes<Option<GenAlign>>) -> Self; + + /// Pad this content at the sides. + fn padded(self, padding: Sides<Rel<Length>>) -> Self; + + /// Transform this content's contents without affecting layout. + fn moved(self, delta: Axes<Rel<Length>>) -> Self; +} + +impl ContentExt for Content { + fn strong(self) -> Self { + StrongElem::new(self).pack() + } + + fn emph(self) -> Self { + EmphElem::new(self).pack() + } + + fn underlined(self) -> Self { + UnderlineElem::new(self).pack() + } + + fn linked(self, dest: Destination) -> Self { + self.styled(MetaElem::set_data(vec![Meta::Link(dest)])) + } + + fn backlinked(self, loc: Location) -> Self { + let mut backlink = Content::empty(); + backlink.set_location(loc); + self.styled(MetaElem::set_data(vec![Meta::Elem(backlink)])) + } + + fn aligned(self, aligns: Axes<Option<GenAlign>>) -> Self { + self.styled(AlignElem::set_alignment(aligns)) + } + + fn padded(self, padding: Sides<Rel<Length>>) -> Self { + PadElem::new(self) + .with_left(padding.left) + .with_top(padding.top) + .with_right(padding.right) + .with_bottom(padding.bottom) + .pack() + } + + fn moved(self, delta: Axes<Rel<Length>>) -> Self { + MoveElem::new(self).with_dx(delta.x).with_dy(delta.y).pack() + } +} + +/// Additional methods for style lists. +pub trait StylesExt { + /// Set a font family composed of a preferred family and existing families + /// from a style chain. + fn set_family(&mut self, preferred: FontFamily, existing: StyleChain); +} + +impl StylesExt for Styles { + fn set_family(&mut self, preferred: FontFamily, existing: StyleChain) { + self.set(TextElem::set_font(FontList( + std::iter::once(preferred) + .chain(TextElem::font_in(existing)) + .collect(), + ))); + } +} diff --git a/crates/typst-library/src/shared/mod.rs b/crates/typst-library/src/shared/mod.rs new file mode 100644 index 00000000..f54241cf --- /dev/null +++ b/crates/typst-library/src/shared/mod.rs @@ -0,0 +1,7 @@ +//! Shared definitions for the standard library. + +mod behave; +mod ext; + +pub use behave::*; +pub use ext::*; diff --git a/crates/typst-library/src/symbols/emoji.rs b/crates/typst-library/src/symbols/emoji.rs new file mode 100644 index 00000000..44bc3e14 --- /dev/null +++ b/crates/typst-library/src/symbols/emoji.rs @@ -0,0 +1,1356 @@ +use typst::eval::{symbols, Module, Scope, Symbol}; + +/// A module with all emoji. +pub fn emoji() -> Module { + let mut scope = Scope::new(); + for (name, symbol) in EMOJI { + scope.define(*name, symbol.clone()); + } + Module::new("emoji").with_scope(scope) +} + +/// A list of named emoji. +const EMOJI: &[(&str, Symbol)] = symbols! { + abacus: '🧮', + abc: '🔤', + abcd: '🔡', + ABCD: '🔠', + accordion: '🪗', + aesculapius: '⚕', + airplane: [ + '✈', + landing: '🛬', + small: '🛩', + takeoff: '🛫', + ], + alembic: '⚗', + alien: ['👽', monster: '👾'], + ambulance: '🚑', + amphora: '🏺', + anchor: '⚓', + anger: '💢', + ant: '🐜', + apple: [green: '🍏', red: '🍎'], + arm: [mech: '🦾', muscle: '💪', selfie: '🤳'], + arrow: [ + r.filled: '➡', + r.hook: '↪', + r.soon: '🔜', + l.filled: '⬅', + l.hook: '↩', + l.back: '🔙', + l.end: '🔚', + t.filled: '⬆', + t.curve: '⤴', + t.top: '🔝', + b.filled: '⬇', + b.curve: '⤵', + l.r: '↔', + l.r.on: '🔛', + t.b: '↕', + bl: '↙', + br: '↘', + tl: '↖', + tr: '↗', + ], + arrows: [cycle: '🔄'], + ast: ['*', box: '✳'], + atm: '🏧', + atom: '⚛', + aubergine: '🍆', + avocado: '🥑', + axe: '🪓', + baby: ['👶', angel: '👼', box: '🚼'], + babybottle: '🍼', + backpack: '🎒', + bacon: '🥓', + badger: '🦡', + badminton: '🏸', + bagel: '🥯', + baggageclaim: '🛄', + baguette: '🥖', + balloon: '🎈', + ballot: [check: '☑'], + ballotbox: '🗳', + banana: '🍌', + banjo: '🪕', + bank: '🏦', + barberpole: '💈', + baseball: '⚾', + basecap: '🧢', + basket: '🧺', + basketball: ['⛹', ball: '🏀'], + bat: '🦇', + bathtub: ['🛀', foam: '🛁'], + battery: ['🔋', low: '🪫'], + beach: [palm: '🏝', umbrella: '🏖'], + beads: '📿', + beans: '🫘', + bear: '🐻', + beaver: '🦫', + bed: ['🛏', person: '🛌'], + bee: '🐝', + beer: ['🍺', clink: '🍻'], + beetle: ['🪲', lady: '🐞'], + bell: ['🔔', ding: '🛎', not: '🔕'], + bento: '🍱', + bicyclist: ['🚴', mountain: '🚵'], + bike: ['🚲', not: '🚳'], + bikini: '👙', + billiards: '🎱', + bin: '🗑', + biohazard: '☣', + bird: '🐦', + bison: '🦬', + blood: '🩸', + blouse: '👚', + blowfish: '🐡', + blueberries: '🫐', + boar: '🐗', + boat: [ + sail: '⛵', + row: '🚣', + motor: '🛥', + speed: '🚤', + canoe: '🛶', + ], + bolt: '🔩', + bomb: '💣', + bone: '🦴', + book: [ + red: '📕', + blue: '📘', + green: '📗', + orange: '📙', + spiral: '📒', + open: '📖', + ], + bookmark: '🔖', + books: '📚', + boomerang: '🪃', + bordercontrol: '🛂', + bouquet: '💐', + bow: '🏹', + bowl: [spoon: '🥣', steam: '🍜'], + bowling: '🎳', + boxing: '🥊', + boy: '👦', + brain: '🧠', + bread: '🍞', + brick: '🧱', + bride: '👰', + bridge: [fog: '🌁', night: '🌉'], + briefcase: '💼', + briefs: '🩲', + brightness: [high: '🔆', low: '🔅'], + broccoli: '🥦', + broom: '🧹', + brush: '🖌', + bubble: [ + speech.r: '💬', + speech.l: '🗨', + thought: '💭', + anger.r: '🗯', + ], + bubbles: '🫧', + bubbletea: '🧋', + bucket: '🪣', + buffalo: [water: '🐃'], + bug: '🐛', + builder: '👷', + burger: '🍔', + burrito: '🌯', + bus: [ + '🚌', + front: '🚍', + small: '🚐', + stop: '🚏', + trolley: '🚎', + ], + butter: '🧈', + butterfly: '🦋', + button: ['🔲', alt: '🔳', radio: '🔘'], + cabinet: [file: '🗄'], + cablecar: ['🚠', small: '🚡'], + cactus: '🌵', + cake: [ + '🎂', + fish: '🍥', + moon: '🥮', + slice: '🍰', + ], + calendar: ['📅', spiral: '🗓', tearoff: '📆'], + camel: ['🐫', dromedar: '🐪'], + camera: [ + '📷', + flash: '📸', + movie: '🎥', + movie.box: '🎦', + video: '📹', + ], + camping: '🏕', + can: '🥫', + candle: '🕯', + candy: '🍬', + cane: '🦯', + car: [ + '🚗', + front: '🚘', + pickup: '🛻', + police: '🚓', + police.front: '🚔', + racing: '🏎', + rickshaw: '🛺', + suv: '🚙', + ], + card: [credit: '💳', id: '🪪'], + cardindex: '📇', + carrot: '🥕', + cart: '🛒', + cassette: '📼', + castle: [eu: '🏰', jp: '🏯'], + cat: [ + '🐈', + face: '🐱', + face.angry: '😾', + face.cry: '😿', + face.heart: '😻', + face.joy: '😹', + face.kiss: '😽', + face.laugh: '😸', + face.shock: '🙀', + face.smile: '😺', + face.smirk: '😼', + ], + chain: '🔗', + chains: '⛓', + chair: '🪑', + champagne: '🍾', + chart: [ + bar: '📊', + up: '📈', + down: '📉', + yen.up: '💹', + ], + checkmark: [heavy: '✔', box: '✅'], + cheese: '🧀', + cherries: '🍒', + chess: '♟', + chestnut: '🌰', + chicken: [ + '🐔', + baby: '🐥', + baby.egg: '🐣', + baby.head: '🐤', + leg: '🍗', + male: '🐓', + ], + child: '🧒', + chipmunk: '🐿', + chocolate: '🍫', + chopsticks: '🥢', + church: ['⛪', love: '💒'], + cigarette: ['🚬', not: '🚭'], + circle: [ + black: '⚫', + blue: '🔵', + brown: '🟤', + green: '🟢', + orange: '🟠', + purple: '🟣', + white: '⚪', + red: '🔴', + yellow: '🟡', + stroked: '⭕', + ], + circus: '🎪', + city: [ + '🏙', + dusk: '🌆', + night: '🌃', + sunset: '🌇', + ], + clamp: '🗜', + clapperboard: '🎬', + climbing: '🧗', + clip: '📎', + clipboard: '📋', + clips: '🖇', + clock: [ + one: '🕐', + one.thirty: '🕜', + two: '🕑', + two.thirty: '🕝', + three: '🕒', + three.thirty: '🕞', + four: '🕓', + four.thirty: '🕟', + five: '🕔', + five.thirty: '🕠', + six: '🕕', + six.thirty: '🕡', + seven: '🕖', + seven.thirty: '🕢', + eight: '🕗', + eight.thirty: '🕣', + nine: '🕘', + nine.thirty: '🕤', + ten: '🕙', + ten.thirty: '🕥', + eleven: '🕚', + eleven.thirty: '🕦', + twelve: '🕛', + twelve.thirty: '🕧', + alarm: '⏰', + old: '🕰', + timer: '⏲', + ], + cloud: [ + '☁', + dust: '💨', + rain: '🌧', + snow: '🌨', + storm: '⛈', + sun: '⛅', + sun.hidden: '🌥', + sun.rain: '🌦', + thunder: '🌩', + ], + coat: ['🧥', lab: '🥼'], + cockroach: '🪳', + cocktail: [martini: '🍸', tropical: '🍹'], + coconut: '🥥', + coffee: '☕', + coffin: '⚰', + coin: '🪙', + comet: '☄', + compass: '🧭', + computer: '🖥', + computermouse: '🖱', + confetti: '🎊', + construction: '🚧', + controller: '🎮', + cookie: ['🍪', fortune: '🥠'], + cooking: '🍳', + cool: '🆒', + copyright: '©', + coral: '🪸', + corn: '🌽', + couch: '🛋', + couple: '💑', + cow: ['🐄', face: '🐮'], + crab: '🦀', + crane: '🏗', + crayon: '🖍', + cricket: '🦗', + cricketbat: '🏏', + crocodile: '🐊', + croissant: '🥐', + crossmark: ['❌', box: '❎'], + crown: '👑', + crutch: '🩼', + crystal: '🔮', + cucumber: '🥒', + cup: [straw: '🥤'], + cupcake: '🧁', + curling: '🥌', + curry: '🍛', + custard: '🍮', + customs: '🛃', + cutlery: '🍴', + cyclone: '🌀', + dancing: [man: '🕺', woman: '💃', women.bunny: '👯'], + darts: '🎯', + dash: [wave.double: '〰'], + deer: '🦌', + desert: '🏜', + detective: '🕵', + diamond: [ + blue: '🔷', + blue.small: '🔹', + orange: '🔶', + orange.small: '🔸', + dot: '💠', + ], + die: '🎲', + dino: [pod: '🦕', rex: '🦖'], + disc: [cd: '💿', dvd: '📀', mini: '💽'], + discoball: '🪩', + diving: '🤿', + dodo: '🦤', + dog: [ + '🐕', + face: '🐶', + guide: '🦮', + poodle: '🐩', + ], + dollar: '💲', + dolphin: '🐬', + donut: '🍩', + door: '🚪', + dove: [peace: '🕊'], + dragon: ['🐉', face: '🐲'], + dress: ['👗', kimono: '👘', sari: '🥻'], + drop: '💧', + drops: '💦', + drum: ['🥁', big: '🪘'], + duck: '🦆', + dumpling: '🥟', + eagle: '🦅', + ear: ['👂', aid: '🦻'], + egg: '🥚', + eighteen: [not: '🔞'], + elephant: '🐘', + elevator: '🛗', + elf: '🧝', + email: '📧', + excl: [ + '❗', + white: '❕', + double: '‼', + quest: '⁉', + ], + explosion: '💥', + extinguisher: '🧯', + eye: '👁', + eyes: '👀', + face: [ + grin: '😀', + angry: '😠', + angry.red: '😡', + anguish: '😧', + astonish: '😲', + bandage: '🤕', + beam: '😁', + blank: '😶', + clown: '🤡', + cold: '🥶', + concern: '😦', + cool: '😎', + cover: '🤭', + cowboy: '🤠', + cry: '😭', + devil.smile: '😈', + devil.frown: '👿', + diagonal: '🫤', + disguise: '🥸', + dizzy: '😵', + dotted: '🫥', + down: '😞', + down.sweat: '😓', + drool: '🤤', + explode: '🤯', + eyeroll: '🙄', + friendly: '☺', + fear: '😨', + fear.sweat: '😰', + fever: '🤒', + flush: '😳', + frown: '☹', + frown.slight: '🙁', + frust: '😣', + goofy: '🤪', + halo: '😇', + happy: '😊', + heart: '😍', + hearts: '🥰', + heat: '🥵', + hug: '🤗', + inv: '🙃', + joy: '😂', + kiss: '😗', + kiss.smile: '😙', + kiss.heart: '😘', + kiss.blush: '😚', + lick: '😋', + lie: '🤥', + mask: '😷', + meh: '😒', + melt: '🫠', + money: '🤑', + monocle: '🧐', + nausea: '🤢', + nerd: '🤓', + neutral: '😐', + open: '😃', + party: '🥳', + peek: '🫣', + plead: '🥺', + relief: '😌', + rofl: '🤣', + sad: '😔', + salute: '🫡', + shock: '😱', + shush: '🤫', + skeptic: '🤨', + sleep: '😴', + sleepy: '😪', + smile: '😄', + smile.slight: '🙂', + smile.sweat: '😅', + smile.tear: '🥲', + smirk: '😏', + sneeze: '🤧', + speak.not: '🫢', + squint: '😆', + stars: '🤩', + straight: '😑', + suffer: '😖', + surprise: '😯', + symbols: '🤬', + tear: '😢', + tear.relief: '😥', + tear.withheld: '🥹', + teeth: '😬', + think: '🤔', + tired: '😫', + tongue: '😛', + tongue.squint: '😝', + tongue.wink: '😜', + triumph: '😤', + unhappy: '😕', + vomit: '🤮', + weary: '😩', + wink: '😉', + woozy: '🥴', + worry: '😟', + wow: '😮', + yawn: '🥱', + zip: '🤐', + ], + factory: '🏭', + fairy: '🧚', + faith: [ + christ: '✝', + dharma: '☸', + islam: '☪', + judaism: '✡', + menorah: '🕎', + om: '🕉', + orthodox: '☦', + peace: '☮', + star.dot: '🔯', + worship: '🛐', + yinyang: '☯', + ], + falafel: '🧆', + family: '👪', + fax: '📠', + feather: '🪶', + feeding: [breast: '🤱'], + fencing: '🤺', + ferriswheel: '🎡', + filebox: '🗃', + filedividers: '🗂', + film: '🎞', + finger: [ + r: '👉', + l: '👈', + t: '👆', + t.alt: '☝', + b: '👇', + front: '🫵', + m: '🖕', + ], + fingers: [cross: '🤞', pinch: '🤌', snap: '🫰'], + fire: '🔥', + firecracker: '🧨', + fireengine: '🚒', + fireworks: '🎆', + fish: ['🐟', tropical: '🐠'], + fishing: '🎣', + fist: [ + front: '👊', + r: '🤜', + l: '🤛', + raised: '✊', + ], + flag: [ + black: '🏴', + white: '🏳', + goal: '🏁', + golf: '⛳', + red: '🚩', + ], + flags: [jp.crossed: '🎌'], + flamingo: '🦩', + flashlight: '🔦', + flatbread: '🫓', + fleur: '⚜', + floppy: '💾', + flower: [ + hibiscus: '🌺', + lotus: '🪷', + pink: '🌸', + rose: '🌹', + sun: '🌻', + tulip: '🌷', + white: '💮', + wilted: '🥀', + yellow: '🌼', + ], + fly: '🪰', + fog: '🌫', + folder: ['📁', open: '📂'], + fondue: '🫕', + foot: '🦶', + football: ['⚽', am: '🏈'], + forex: '💱', + fountain: '⛲', + fox: '🦊', + free: '🆓', + fries: '🍟', + frisbee: '🥏', + frog: [face: '🐸'], + fuelpump: '⛽', + garlic: '🧄', + gear: '⚙', + gem: '💎', + genie: '🧞', + ghost: '👻', + giraffe: '🦒', + girl: '👧', + glass: [ + clink: '🥂', + milk: '🥛', + pour: '🫗', + tumbler: '🥃', + ], + glasses: ['👓', sun: '🕶'], + globe: [ + am: '🌎', + as.au: '🌏', + eu.af: '🌍', + meridian: '🌐', + ], + gloves: '🧤', + goal: '🥅', + goat: '🐐', + goggles: '🥽', + golfing: '🏌', + gorilla: '🦍', + grapes: '🍇', + guard: [man: '💂'], + guitar: '🎸', + gymnastics: '🤸', + haircut: '💇', + hammer: ['🔨', pick: '⚒', wrench: '🛠'], + hamsa: '🪬', + hamster: [face: '🐹'], + hand: [ + raised: '✋', + raised.alt: '🤚', + r: '🫱', + l: '🫲', + t: '🫴', + b: '🫳', + ok: '👌', + call: '🤙', + love: '🤟', + part: '🖖', + peace: '✌', + pinch: '🤏', + rock: '🤘', + splay: '🖐', + wave: '👋', + write: '✍', + ], + handbag: '👜', + handball: '🤾', + handholding: [man.man: '👬', woman.man: '👫', woman.woman: '👭'], + hands: [ + folded: '🙏', + palms: '🤲', + clap: '👏', + heart: '🫶', + open: '👐', + raised: '🙌', + shake: '🤝', + ], + hash: '#', + hat: [ribbon: '👒', top: '🎩'], + headphone: '🎧', + heart: [ + '❤', + arrow: '💘', + beat: '💓', + black: '🖤', + blue: '💙', + box: '💟', + broken: '💔', + brown: '🤎', + double: '💕', + excl: '❣', + green: '💚', + grow: '💗', + orange: '🧡', + purple: '💜', + real: '🫀', + revolve: '💞', + ribbon: '💝', + spark: '💖', + white: '🤍', + yellow: '💛', + ], + hedgehog: '🦔', + helicopter: '🚁', + helix: '🧬', + helmet: [cross: '⛑', military: '🪖'], + hippo: '🦛', + hockey: '🏑', + hole: '🕳', + honey: '🍯', + hongbao: '🧧', + hook: '🪝', + horn: [postal: '📯'], + horse: [ + '🐎', + carousel: '🎠', + face: '🐴', + race: '🏇', + ], + hospital: '🏥', + hotdog: '🌭', + hotel: ['🏨', love: '🏩'], + hotspring: '♨', + hourglass: ['⌛', flow: '⏳'], + house: [ + '🏠', + derelict: '🏚', + garden: '🏡', + multiple: '🏘', + ], + hundred: '💯', + hut: '🛖', + ice: '🧊', + icecream: ['🍨', shaved: '🍧', soft: '🍦'], + icehockey: '🏒', + id: '🆔', + info: 'ℹ', + izakaya: '🏮', + jar: '🫙', + jeans: '👖', + jigsaw: '🧩', + joystick: '🕹', + juggling: '🤹', + juice: '🧃', + kaaba: '🕋', + kadomatsu: '🎍', + kangaroo: '🦘', + gachi: '🈷', + go: '🈴', + hi: '㊙', + ka: '🉑', + kachi: '🈹', + kara: '🈳', + kon: '🈲', + man: '🈵', + muryo: '🈚', + shin: '🈸', + shuku: '㊗', + toku: '🉐', + yo: '🈺', + yubi: '🈯', + yuryo: '🈶', + koko: '🈁', + sa: '🈂', + kebab: '🥙', + key: ['🔑', old: '🗝'], + keyboard: '⌨', + kiss: '💏', + kissmark: '💋', + kite: '🪁', + kiwi: '🥝', + knife: ['🔪', dagger: '🗡'], + knot: '🪢', + koala: '🐨', + koinobori: '🎏', + label: '🏷', + lacrosse: '🥍', + ladder: '🪜', + lamp: [diya: '🪔'], + laptop: '💻', + a: '🅰', + ab: '🆎', + b: '🅱', + cl: '🆑', + o: '🅾', + leaf: [ + clover.three: '☘', + clover.four: '🍀', + fall: '🍂', + herb: '🌿', + maple: '🍁', + wind: '🍃', + ], + leftluggage: '🛅', + leg: ['🦵', mech: '🦿'], + lemon: '🍋', + leopard: '🐆', + letter: [love: '💌'], + liberty: '🗽', + lightbulb: '💡', + lightning: '⚡', + lion: '🦁', + lipstick: '💄', + litter: ['🚮', not: '🚯'], + lizard: '🦎', + llama: '🦙', + lobster: '🦞', + lock: [ + '🔒', + key: '🔐', + open: '🔓', + pen: '🔏', + ], + lollipop: '🍭', + lotion: '🧴', + luggage: '🧳', + lungs: '🫁', + mage: '🧙', + magnet: '🧲', + magnify: [r: '🔎', l: '🔍'], + mahjong: [dragon.red: '🀄'], + mail: ['✉', arrow: '📩'], + mailbox: [ + closed.empty: '📪', + closed.full: '📫', + open.empty: '📭', + open.full: '📬', + ], + mammoth: '🦣', + man: [ + '👨', + box: '🚹', + crown: '🤴', + guapimao: '👲', + levitate: '🕴', + old: '👴', + pregnant: '🫃', + turban: '👳', + tuxedo: '🤵', + ], + mango: '🥭', + map: [world: '🗺', jp: '🗾'], + martialarts: '🥋', + masks: '🎭', + mate: '🧉', + matryoshka: '🪆', + meat: ['🥩', bone: '🍖'], + medal: [ + first: '🥇', + second: '🥈', + third: '🥉', + sports: '🏅', + military: '🎖', + ], + megaphone: ['📢', simple: '📣'], + melon: '🍈', + merperson: '🧜', + metro: 'Ⓜ', + microbe: '🦠', + microphone: ['🎤', studio: '🎙'], + microscope: '🔬', + milkyway: '🌌', + mirror: '🪞', + mixer: '🎛', + money: [ + bag: '💰', + dollar: '💵', + euro: '💶', + pound: '💷', + yen: '💴', + wings: '💸', + ], + monkey: [ + '🐒', + face: '🐵', + hear.not: '🙉', + see.not: '🙈', + speak.not: '🙊', + ], + moon: [ + crescent: '🌙', + full: '🌕', + full.face: '🌝', + new: '🌑', + new.face: '🌚', + wane.one: '🌖', + wane.two: '🌗', + wane.three.face: '🌜', + wane.three: '🌘', + wax.one: '🌒', + wax.two: '🌓', + wax.two.face: '🌛', + wax.three: '🌔', + ], + mortarboard: '🎓', + mosque: '🕌', + mosquito: '🦟', + motorcycle: '🏍', + motorway: '🛣', + mountain: [ + '⛰', + fuji: '🗻', + snow: '🏔', + sunrise: '🌄', + ], + mouse: ['🐁', face: '🐭'], + mousetrap: '🪤', + mouth: ['👄', bite: '🫦'], + moyai: '🗿', + museum: '🏛', + mushroom: '🍄', + musicalscore: '🎼', + nails: [polish: '💅'], + namebadge: '📛', + nazar: '🧿', + necktie: '👔', + needle: '🪡', + nest: [empty: '🪹', eggs: '🪺'], + new: '🆕', + newspaper: ['📰', rolled: '🗞'], + ng: '🆖', + ningyo: '🎎', + ninja: '🥷', + noentry: '⛔', + nose: '👃', + notebook: ['📓', deco: '📔'], + notepad: '🗒', + notes: ['🎵', triple: '🎶'], + numbers: '🔢', + octopus: '🐙', + office: '🏢', + oil: '🛢', + ok: '🆗', + olive: '🫒', + oni: '👹', + onion: '🧅', + orangutan: '🦧', + otter: '🦦', + owl: '🦉', + ox: '🐂', + oyster: '🦪', + package: '📦', + paella: '🥘', + page: ['📄', curl: '📃', pencil: '📝'], + pager: '📟', + pages: [tabs: '📑'], + painting: '🖼', + palette: '🎨', + pancakes: '🥞', + panda: '🐼', + parachute: '🪂', + park: '🏞', + parking: '🅿', + parrot: '🦜', + partalteration: '〽', + party: '🎉', + peach: '🍑', + peacock: '🦚', + peanuts: '🥜', + pear: '🍐', + pedestrian: ['🚶', not: '🚷'], + pen: [ball: '🖊', fountain: '🖋'], + pencil: '✏', + penguin: '🐧', + pepper: ['🫑', hot: '🌶'], + person: [ + '🧑', + angry: '🙎', + beard: '🧔', + blonde: '👱', + bow: '🙇', + crown: '🫅', + deaf: '🧏', + facepalm: '🤦', + frown: '🙍', + hijab: '🧕', + kneel: '🧎', + lotus: '🧘', + massage: '💆', + no: '🙅', + ok: '🙆', + old: '🧓', + pregnant: '🫄', + raise: '🙋', + sassy: '💁', + shrug: '🤷', + stand: '🧍', + steam: '🧖', + ], + petri: '🧫', + phone: [ + '📱', + arrow: '📲', + classic: '☎', + not: '📵', + off: '📴', + receiver: '📞', + signal: '📶', + vibrate: '📳', + ], + piano: '🎹', + pick: '⛏', + pie: '🥧', + pig: ['🐖', face: '🐷', nose: '🐽'], + pill: '💊', + pin: ['📌', round: '📍'], + pinata: '🪅', + pineapple: '🍍', + pingpong: '🏓', + pistol: '🔫', + pizza: '🍕', + placard: '🪧', + planet: '🪐', + plant: '🪴', + plaster: '🩹', + plate: [cutlery: '🍽'], + playback: [ + down: '⏬', + eject: '⏏', + forward: '⏩', + pause: '⏸', + record: '⏺', + repeat: '🔁', + repeat.once: '🔂', + repeat.v: '🔃', + restart: '⏮', + rewind: '⏪', + shuffle: '🔀', + skip: '⏭', + stop: '⏹', + toggle: '⏯', + up: '⏫', + ], + playingcard: [flower: '🎴', joker: '🃏'], + plunger: '🪠', + policeofficer: '👮', + poo: '💩', + popcorn: '🍿', + post: [eu: '🏤', jp: '🏣'], + postbox: '📮', + potato: ['🥔', sweet: '🍠'], + pouch: '👝', + powerplug: '🔌', + present: '🎁', + pretzel: '🥨', + printer: '🖨', + prints: [foot: '👣', paw: '🐾'], + prohibited: '🚫', + projector: '📽', + pumpkin: [lantern: '🎃'], + purse: '👛', + quest: ['❓', white: '❔'], + rabbit: ['🐇', face: '🐰'], + raccoon: '🦝', + radio: '📻', + radioactive: '☢', + railway: '🛤', + rainbow: '🌈', + ram: '🐏', + rat: '🐀', + razor: '🪒', + receipt: '🧾', + recycling: '♻', + reg: '®', + restroom: '🚻', + rhino: '🦏', + ribbon: ['🎀', remind: '🎗'], + rice: [ + '🍚', + cracker: '🍘', + ear: '🌾', + onigiri: '🍙', + ], + ring: '💍', + ringbuoy: '🛟', + robot: '🤖', + rock: '🪨', + rocket: '🚀', + rollercoaster: '🎢', + rosette: '🏵', + rugby: '🏉', + ruler: ['📏', triangle: '📐'], + running: '🏃', + safetypin: '🧷', + safetyvest: '🦺', + sake: '🍶', + salad: '🥗', + salt: '🧂', + sandwich: '🥪', + santa: [man: '🎅', woman: '🤶'], + satdish: '📡', + satellite: '🛰', + saw: '🪚', + saxophone: '🎷', + scales: '⚖', + scarf: '🧣', + school: '🏫', + scissors: '✂', + scooter: ['🛴', motor: '🛵'], + scorpion: '🦂', + screwdriver: '🪛', + scroll: '📜', + seal: '🦭', + seat: '💺', + seedling: '🌱', + shark: '🦈', + sheep: '🐑', + shell: [spiral: '🐚'], + shield: '🛡', + ship: ['🚢', cruise: '🛳', ferry: '⛴'], + shirt: [sports: '🎽', t: '👕'], + shoe: [ + '👞', + ballet: '🩰', + flat: '🥿', + heel: '👠', + hike: '🥾', + ice: '⛸', + roller: '🛼', + sandal.heel: '👡', + ski: '🎿', + sneaker: '👟', + tall: '👢', + thong: '🩴', + ], + shopping: '🛍', + shorts: '🩳', + shoshinsha: '🔰', + shower: '🚿', + shrimp: ['🦐', fried: '🍤'], + shrine: '⛩', + sign: [crossing: '🚸', stop: '🛑'], + silhouette: [ + '👤', + double: '👥', + hug: '🫂', + speak: '🗣', + ], + siren: '🚨', + skateboard: '🛹', + skewer: [dango: '🍡', oden: '🍢'], + skiing: '⛷', + skull: ['💀', bones: '☠'], + skunk: '🦨', + sled: '🛷', + slide: '🛝', + slider: '🎚', + sloth: '🦥', + slots: '🎰', + snail: '🐌', + snake: '🐍', + snowboarding: '🏂', + snowflake: '❄', + snowman: ['⛄', snow: '☃'], + soap: '🧼', + socks: '🧦', + softball: '🥎', + sos: '🆘', + soup: '🍲', + spaghetti: '🍝', + sparkle: [box: '❇'], + sparkler: '🎇', + sparkles: '✨', + speaker: [ + '🔈', + not: '🔇', + wave: '🔉', + waves: '🔊', + ], + spider: '🕷', + spiderweb: '🕸', + spinach: '🥬', + sponge: '🧽', + spoon: '🥄', + square: [ + black: '⬛', + black.tiny: '▪', + black.small: '◾', + black.medium: '◼', + white: '⬜', + white.tiny: '▫', + white.small: '◽', + white.medium: '◻', + blue: '🟦', + brown: '🟫', + green: '🟩', + orange: '🟧', + purple: '🟪', + red: '🟥', + yellow: '🟨', + ], + squid: '🦑', + stadium: '🏟', + star: [ + '⭐', + arc: '💫', + box: '✴', + glow: '🌟', + shoot: '🌠', + ], + stethoscope: '🩺', + store: [big: '🏬', small: '🏪'], + strawberry: '🍓', + suit: [ + club: '♣', + diamond: '♦', + heart: '♥', + spade: '♠', + ], + sun: ['☀', cloud: '🌤', face: '🌞'], + sunrise: '🌅', + superhero: '🦸', + supervillain: '🦹', + surfing: '🏄', + sushi: '🍣', + swan: '🦢', + swimming: '🏊', + swimsuit: '🩱', + swords: '⚔', + symbols: '🔣', + synagogue: '🕍', + syringe: '💉', + taco: '🌮', + takeout: '🥡', + tamale: '🫔', + tanabata: '🎋', + tangerine: '🍊', + tap: ['🚰', not: '🚱'], + taxi: ['🚕', front: '🚖'], + teacup: '🍵', + teapot: '🫖', + teddy: '🧸', + telescope: '🔭', + temple: '🛕', + ten: '🔟', + tengu: '👺', + tennis: '🎾', + tent: '⛺', + testtube: '🧪', + thermometer: '🌡', + thread: '🧵', + thumb: [up: '👍', down: '👎'], + ticket: [event: '🎟', travel: '🎫'], + tiger: ['🐅', face: '🐯'], + tm: '™', + toilet: '🚽', + toiletpaper: '🧻', + tomato: '🍅', + tombstone: '🪦', + tongue: '👅', + toolbox: '🧰', + tooth: '🦷', + toothbrush: '🪥', + tornado: '🌪', + tower: [tokyo: '🗼'], + trackball: '🖲', + tractor: '🚜', + trafficlight: [v: '🚦', h: '🚥'], + train: [ + '🚆', + car: '🚃', + light: '🚈', + metro: '🚇', + mono: '🚝', + mountain: '🚞', + speed: '🚄', + speed.bullet: '🚅', + steam: '🚂', + stop: '🚉', + suspend: '🚟', + tram: '🚊', + tram.car: '🚋', + ], + transgender: '⚧', + tray: [inbox: '📥', mail: '📨', outbox: '📤'], + tree: [ + deciduous: '🌳', + evergreen: '🌲', + palm: '🌴', + xmas: '🎄', + ], + triangle: [ + r: '▶', + l: '◀', + t: '🔼', + b: '🔽', + t.red: '🔺', + b.red: '🔻', + ], + trident: '🔱', + troll: '🧌', + trophy: '🏆', + truck: ['🚚', trailer: '🚛'], + trumpet: '🎺', + tsukimi: '🎑', + turkey: '🦃', + turtle: '🐢', + tv: '📺', + ufo: '🛸', + umbrella: [ + open: '☂', + closed: '🌂', + rain: '☔', + sun: '⛱', + ], + unicorn: '🦄', + unknown: '🦳', + up: '🆙', + urn: '⚱', + vampire: '🧛', + violin: '🎻', + volcano: '🌋', + volleyball: '🏐', + vs: '🆚', + waffle: '🧇', + wand: '🪄', + warning: '⚠', + watch: ['⌚', stop: '⏱'], + watermelon: '🍉', + waterpolo: '🤽', + wave: '🌊', + wc: '🚾', + weightlifting: '🏋', + whale: ['🐋', spout: '🐳'], + wheel: '🛞', + wheelchair: ['🦽', box: '♿', motor: '🦼'], + wind: '🌬', + windchime: '🎐', + window: '🪟', + wine: '🍷', + wolf: '🐺', + woman: [ + '👩', + box: '🚺', + crown: '👸', + old: '👵', + pregnant: '🤰', + ], + wood: '🪵', + worm: '🪱', + wrench: '🔧', + wrestling: '🤼', + xray: '🩻', + yarn: '🧶', + yoyo: '🪀', + zebra: '🦓', + zodiac: [ + aquarius: '♒', + aries: '♈', + cancer: '♋', + capri: '♑', + gemini: '♊', + leo: '♌', + libra: '♎', + ophi: '⛎', + pisces: '♓', + sagit: '♐', + scorpio: '♏', + taurus: '♉', + virgo: '♍', + ], + zombie: '🧟', + zzz: '💤', +}; diff --git a/crates/typst-library/src/symbols/mod.rs b/crates/typst-library/src/symbols/mod.rs new file mode 100644 index 00000000..5036aa11 --- /dev/null +++ b/crates/typst-library/src/symbols/mod.rs @@ -0,0 +1,15 @@ +//! Modifiable symbols. + +mod emoji; +mod sym; + +pub use emoji::*; +pub use sym::*; + +use crate::prelude::*; + +/// Hook up all symbol definitions. +pub(super) fn define(global: &mut Scope) { + global.define("sym", sym()); + global.define("emoji", emoji()); +} diff --git a/crates/typst-library/src/symbols/sym.rs b/crates/typst-library/src/symbols/sym.rs new file mode 100644 index 00000000..8b1b0b39 --- /dev/null +++ b/crates/typst-library/src/symbols/sym.rs @@ -0,0 +1,842 @@ +use typst::eval::{symbols, Module, Scope, Symbol}; + +/// A module with all general symbols. +pub fn sym() -> Module { + let mut scope = Scope::new(); + for (name, symbol) in SYM { + scope.define(*name, symbol.clone()); + } + Module::new("sym").with_scope(scope) +} + +/// The list of general symbols. +pub(crate) const SYM: &[(&str, Symbol)] = symbols! { + // Control. + wj: '\u{2060}', + zwj: '\u{200D}', + zwnj: '\u{200C}', + zws: '\u{200B}', + + // Spaces. + space: [ + ' ', + nobreak: '\u{A0}', + en: '\u{2002}', + quad: '\u{2003}', + third: '\u{2004}', + quarter: '\u{2005}', + sixth: '\u{2006}', + med: '\u{205F}', + fig: '\u{2007}', + punct: '\u{2008}', + thin: '\u{2009}', + hair: '\u{200A}', + ], + + // Delimiters. + paren: [l: '(', r: ')', t: '⏜', b: '⏝'], + brace: [l: '{', r: '}', t: '⏞', b: '⏟'], + bracket: [l: '[', l.double: '⟦', r: ']', r.double: '⟧', t: '⎴', b: '⎵'], + turtle: [l: '〔', r: '〕', t: '⏠', b: '⏡'], + bar: [v: '|', v.double: '‖', v.triple: '⦀', v.broken: '¦', v.circle: '⦶', h: '―'], + fence: [l: '⧘', l.double: '⧚', r: '⧙', r.double: '⧛', dotted: '⦙'], + angle: [ + '∠', + l: '⟨', + r: '⟩', + l.double: '《', + r.double: '》', + acute: '⦟', + arc: '∡', + arc.rev: '⦛', + rev: '⦣', + right: '∟', + right.rev: '⯾', + right.arc: '⊾', + right.dot: '⦝', + right.sq: '⦜', + spatial: '⟀', + spheric: '∢', + spheric.rev: '⦠', + spheric.top: '⦡', + ], + + // Punctuation. + amp: ['&', inv: '⅋'], + ast: [ + op: '∗', + basic: '*', + low: '⁎', + double: '⁑', + triple: '⁂', + small: '﹡', + circle: '⊛', + sq: '⧆', + ], + at: '@', + backslash: ['\\', circle: '⦸', not: '⧷'], + co: '℅', + colon: [':', eq: '≔', double.eq: '⩴'], + comma: ',', + dagger: ['†', double: '‡'], + dash: [ + en: '–', + em: '—', + fig: '‒', + wave: '〜', + colon: '∹', + circle: '⊝', + wave.double: '〰', + ], + dot: [ + op: '⋅', + basic: '.', + c: '·', + circle: '⊙', + circle.big: '⨀', + square: '⊡', + double: '¨', + triple: '\u{20db}', + quad: '\u{20dc}', + ], + excl: ['!', double: '‼', inv: '¡', quest: '⁉'], + quest: ['?', double: '⁇', excl: '⁈', inv: '¿'], + interrobang: '‽', + hash: '#', + hyph: ['‐', minus: '\u{2D}', nobreak: '\u{2011}', point: '‧', soft: '\u{ad}'], + percent: '%', + copyright: ['©', sound: '℗'], + permille: '‰', + pilcrow: ['¶', rev: '⁋'], + section: '§', + semi: [';', rev: '⁏'], + slash: ['/', double: '⫽', triple: '⫻', big: '\u{29f8}'], + dots: [h.c: '⋯', h: '…', v: '⋮', down: '⋱', up: '⋰'], + tilde: [ + op: '∼', + basic: '~', + eq: '≃', + eq.not: '≄', + eq.rev: '⋍', + eqq: '≅', + eqq.not: '≇', + neqq: '≆', + not: '≁', + rev: '∽', + rev.eqq: '≌', + triple: '≋', + ], + + // Accents, quotes, and primes. + acute: ['´', double: '˝'], + breve: '˘', + caret: '‸', + caron: 'ˇ', + hat: '^', + diaer: '¨', + grave: '`', + macron: '¯', + quote: [ + double: '"', + single: '\'', + l.double: '“', + l.single: '‘', + r.double: '”', + r.single: '’', + angle.l.double: '«', + angle.l.single: '‹', + angle.r.double: '»', + angle.r.single: '›', + high.double: '‟', + high.single: '‛', + low.double: '„', + low.single: '‚', + ], + prime: [ + '′', + rev: '‵', + double: '″', + double.rev: '‶', + triple: '‴', + triple.rev: '‷', + quad: '⁗', + ], + + // https://en.wikipedia.org/wiki/List_of_mathematical_symbols_by_subject + // Arithmetic. + plus: [ + '+', + circle: '⊕', + circle.arrow: '⟴', + circle.big: '⨁', + dot: '∔', + minus: '±', + small: '﹢', + square: '⊞', + triangle: '⨹', + ], + minus: [ + '−', + circle: '⊖', + dot: '∸', + plus: '∓', + square: '⊟', + tilde: '≂', + triangle: '⨺', + ], + div: ['÷', circle: '⨸'], + times: [ + '×', + big: '⨉', + circle: '⊗', + circle.big: '⨂', + div: '⋇', + three.l: '⋋', + three.r: '⋌', + l: '⋉', + r: '⋊', + square: '⊠', + triangle: '⨻', + ], + ratio: '∶', + + // Relations. + eq: [ + '=', + star: '≛', + circle: '⊜', + colon: '≕', + def: '≝', + delta: '≜', + equi: '≚', + est: '≙', + gt: '⋝', + lt: '⋜', + m: '≞', + not: '≠', + prec: '⋞', + quest: '≟', + small: '﹦', + succ: '⋟', + ], + gt: [ + '>', + circle: '⧁', + dot: '⋗', + double: '≫', + eq: '≥', + eq.slant: '⩾', + eq.lt: '⋛', + eq.not: '≱', + eqq: '≧', + lt: '≷', + lt.not: '≹', + neqq: '≩', + not: '≯', + ntilde: '⋧', + small: '﹥', + tilde: '≳', + tilde.not: '≵', + tri: '⊳', + tri.eq: '⊵', + tri.eq.not: '⋭', + tri.not: '⋫', + triple: '⋙', + triple.nested: '⫸', + ], + lt: [ + '<', + circle: '⧀', + dot: '⋖', + double: '≪', + eq: '≤', + eq.slant: '⩽' , + eq.gt: '⋚', + eq.not: '≰', + eqq: '≦', + gt: '≶', + gt.not: '≸', + neqq: '≨', + not: '≮', + ntilde: '⋦', + small: '﹤', + tilde: '≲', + tilde.not: '≴', + tri: '⊲', + tri.eq: '⊴', + tri.eq.not: '⋬', + tri.not: '⋪', + triple: '⋘', + triple.nested: '⫷', + ], + approx: ['≈', eq: '≊', not: '≉'], + prec: [ + '≺', + approx: '⪷', + double: '⪻', + eq: '≼', + eq.not: '⋠', + eqq: '⪳', + napprox: '⪹', + neqq: '⪵', + not: '⊀', + ntilde: '⋨', + tilde: '≾', + ], + succ: [ + '≻', + approx: '⪸', + double: '⪼', + eq: '≽', + eq.not: '⋡', + eqq: '⪴', + napprox: '⪺', + neqq: '⪶', + not: '⊁', + ntilde: '⋩', + tilde: '≿', + ], + ident: ['≡', not: '≢', strict: '≣'], + prop: '∝', + + // Set theory. + nothing: ['∅', rev: '⦰'], + without: '∖', + complement: '∁', + in: [ + '∈', + not: '∉', + rev: '∋', + rev.not: '∌', + rev.small: '∍', + small: '∊', + ], + subset: [ + '⊂', + dot: '⪽', + double: '⋐', + eq: '⊆', + eq.not: '⊈', + eq.sq: '⊑', + eq.sq.not: '⋢', + neq: '⊊', + not: '⊄', + sq: '⊏', + sq.neq: '⋤', + ], + supset: [ + '⊃', + dot: '⪾', + double: '⋑', + eq: '⊇', + eq.not: '⊉', + eq.sq: '⊒', + eq.sq.not: '⋣', + neq: '⊋', + not: '⊅', + sq: '⊐', + sq.neq: '⋥', + ], + union: [ + '∪', + arrow: '⊌', + big: '⋃', + dot: '⊍', + dot.big: '⨃', + double: '⋓', + minus: '⩁', + or: '⩅', + plus: '⊎', + plus.big: '⨄', + sq: '⊔', + sq.big: '⨆', + sq.double: '⩏', + ], + sect: [ + '∩', + and: '⩄', + big: '⋂', + dot: '⩀', + double: '⋒', + sq: '⊓', + sq.big: '⨅', + sq.double: '⩎', + ], + + // Calculus. + infinity: '∞', + oo: '∞', + diff: '∂', + nabla: '∇', + sum: ['∑', integral: '⨋'], + product: ['∏', co: '∐'], + integral: [ + '∫', + arrow.hook: '⨗', + ccw: '⨑', + cont: '∮', + cont.ccw: '∳', + cont.cw: '∲', + cw: '∱', + double: '∬', + quad: '⨌', + sect: '⨙', + sq: '⨖', + surf: '∯', + times: '⨘', + triple: '∭', + union: '⨚', + vol: '∰', + ], + laplace: '∆', + + // Logic. + forall: '∀', + exists: ['∃', not: '∄'], + top: '⊤', + bot: '⊥', + not: '¬', + and: ['∧', big: '⋀', curly: '⋏', dot: '⟑', double: '⩓'], + or: ['∨', big: '⋁', curly: '⋎', dot: '⟇', double: '⩔'], + xor: ['⊕', big: '⨁'], + models: '⊧', + therefore: '∴', + because: '∵', + qed: '∎', + + // Function and category theory. + compose: '∘', + convolve: '∗', + multimap: '⊸', + + // Number theory. + divides: ['∣', not: '∤'], + perp: ['⟂', circle: '⦹'], + + // Algebra. + wreath: '≀', + + // Geometry. + parallel: ['∥', circle: '⦷', not: '∦'], + + // Miscellaneous Technical. + diameter: '⌀', + join: ['⨝', r: '⟖', l: '⟕', l.r: '⟗'], + degree: ['°', c: '℃', f: '℉'], + smash: '⨳', + + // Currency. + bitcoin: '₿', + dollar: '$', + euro: '€', + franc: '₣', + lira: '₺', + peso: '₱', + pound: '£', + ruble: '₽', + rupee: '₹', + won: '₩', + yen: '¥', + + // Miscellaneous. + ballot: ['☐', x: '☒'], + checkmark: ['✓', light: '🗸'], + floral: ['❦', l: '☙', r: '❧'], + notes: [up: '🎜', down: '🎝'], + refmark: '※', + servicemark: '℠', + maltese: '✠', + suit: [club: '♣', diamond: '♦', heart: '♥', spade: '♠'], + + // Shapes. + bullet: '•', + circle: [ + stroked: '○', + stroked.tiny: '∘', + stroked.small: '⚬', + stroked.big: '◯', + filled: '●', + filled.tiny: '⦁', + filled.small: '∙', + filled.big: '⬤', + dotted: '◌', + nested: '⊚', + ], + ellipse: [ + stroked.h: '⬭', + stroked.v: '⬯', + filled.h: '⬬', + filled.v: '⬮', + ], + triangle: [ + stroked.r: '▷', + stroked.l: '◁', + stroked.t: '△', + stroked.b: '▽', + stroked.bl: '◺', + stroked.br: '◿', + stroked.tl: '◸', + stroked.tr: '◹', + stroked.small.r: '▹', + stroked.small.b: '▿', + stroked.small.l: '◃', + stroked.small.t: '▵', + stroked.rounded: '🛆', + stroked.nested: '⟁', + stroked.dot: '◬', + filled.r: '▶', + filled.l: '◀', + filled.t: '▲', + filled.b: '▼', + filled.bl: '◣', + filled.br: '◢', + filled.tl: '◤', + filled.tr: '◥', + filled.small.r: '▸', + filled.small.b: '▾', + filled.small.l: '◂', + filled.small.t: '▴', + ], + square: [ + stroked: '□', + stroked.tiny: '▫', + stroked.small: '◽', + stroked.medium: '◻', + stroked.big: '⬜', + stroked.dotted: '⬚', + stroked.rounded: '▢', + filled: '■', + filled.tiny: '▪', + filled.small: '◾', + filled.medium: '◼', + filled.big: '⬛', + ], + rect: [ + stroked.h: '▭', + stroked.v: '▯', + filled.h: '▬', + filled.v: '▮', + ], + penta: [stroked: '⬠', filled: '⬟'], + hexa: [stroked: '⬡', filled: '⬢'], + diamond: [ + stroked: '◇', + stroked.small: '⋄', + stroked.medium: '⬦', + stroked.dot: '⟐', + filled: '◆', + filled.medium: '⬥', + filled.small: '⬩', + ], + lozenge: [ + stroked: '◊', + stroked.small: '⬫', + stroked.medium: '⬨', + filled: '⧫', + filled.small: '⬪', + filled.medium: '⬧', + ], + star: [op: '⋆', stroked: '★', filled: '★'], + + // Arrows, harpoons, and tacks. + arrow: [ + r: '→', + r.long.bar: '⟼', + r.bar: '↦', + r.curve: '⤷', + r.dashed: '⇢', + r.dotted: '⤑', + r.double: '⇒', + r.double.bar: '⤇', + r.double.long: '⟹', + r.double.long.bar: '⟾', + r.double.not: '⇏', + r.filled: '➡', + r.hook: '↪', + r.long: '⟶', + r.long.squiggly: '⟿', + r.loop: '↬', + r.not: '↛', + r.quad: '⭆', + r.squiggly: '⇝', + r.stop: '⇥', + r.stroked: '⇨', + r.tail: '↣', + r.triple: '⇛', + r.twohead.bar: '⤅', + r.twohead: '↠', + r.wave: '↝', + l: '←', + l.bar: '↤', + l.curve: '⤶', + l.dashed: '⇠', + l.dotted: '⬸', + l.double: '⇐', + l.double.bar: '⤆', + l.double.long: '⟸', + l.double.long.bar: '⟽', + l.double.not: '⇍', + l.filled: '⬅', + l.hook: '↩', + l.long: '⟵', + l.long.bar: '⟻', + l.long.squiggly: '⬳', + l.loop: '↫', + l.not: '↚', + l.quad: '⭅', + l.squiggly: '⇜', + l.stop: '⇤', + l.stroked: '⇦', + l.tail: '↢', + l.triple: '⇚', + l.twohead.bar: '⬶', + l.twohead: '↞', + l.wave: '↜', + t: '↑', + t.bar: '↥', + t.curve: '⤴', + t.dashed: '⇡', + t.double: '⇑', + t.filled: '⬆', + t.quad: '⟰', + t.stop: '⤒', + t.stroked: '⇧', + t.triple: '⤊', + t.twohead: '↟', + b: '↓', + b.bar: '↧', + b.curve: '⤵', + b.dashed: '⇣', + b.double: '⇓', + b.filled: '⬇', + b.quad: '⟱', + b.stop: '⤓', + b.stroked: '⇩', + b.triple: '⤋', + b.twohead: '↡', + l.r: '↔', + l.r.double: '⇔', + l.r.double.long: '⟺', + l.r.double.not: '⇎', + l.r.filled: '⬌', + l.r.long: '⟷', + l.r.not: '↮', + l.r.stroked: '⬄', + l.r.wave: '↭', + t.b: '↕', + t.b.double: '⇕', + t.b.filled: '⬍', + t.b.stroked: '⇳', + tr: '↗', + tr.double: '⇗', + tr.filled: '⬈', + tr.hook: '⤤', + tr.stroked: '⬀', + br: '↘', + br.double: '⇘', + br.filled: '⬊', + br.hook: '⤥', + br.stroked: '⬂', + tl: '↖', + tl.double: '⇖', + tl.filled: '⬉', + tl.hook: '⤣', + tl.stroked: '⬁', + bl: '↙', + bl.double: '⇙', + bl.filled: '⬋', + bl.hook: '⤦', + bl.stroked: '⬃', + tl.br: '⤡', + tr.bl: '⤢', + ccw: '↺', + ccw.half: '↶', + cw: '↻', + cw.half: '↷', + zigzag: '↯', + ], + arrows: [ + rr: '⇉', + ll: '⇇', + tt: '⇈', + bb: '⇊', + lr: '⇆', + lr.stop: '↹', + rl: '⇄', + tb: '⇅', + bt: '⇵', + rrr: '⇶', + lll: '⬱', + ], + arrowhead: [ + t: '⌃', + b: '⌄', + ], + harpoon: [ + rt: '⇀', + rt.bar: '⥛', + rt.stop: '⥓', + rb: '⇁', + rb.bar: '⥟', + rb.stop: '⥗', + lt: '↼', + lt.bar: '⥚', + lt.stop: '⥒', + lb: '↽', + lb.bar: '⥞', + lb.stop: '⥖', + tl: '↿', + tl.bar: '⥠', + tl.stop: '⥘', + tr: '↾', + tr.bar: '⥜', + tr.stop: '⥔', + bl: '⇃', + bl.bar: '⥡', + bl.stop: '⥙', + br: '⇂', + br.bar: '⥝', + br.stop: '⥕', + lt.rt: '⥎', + lb.rb: '⥐', + lb.rt: '⥋', + lt.rb: '⥊', + tl.bl: '⥑', + tr.br: '⥏', + tl.br: '⥍', + tr.bl: '⥌', + ], + harpoons: [ + rtrb: '⥤', + blbr: '⥥', + bltr: '⥯', + lbrb: '⥧', + ltlb: '⥢', + ltrb: '⇋', + ltrt: '⥦', + rblb: '⥩', + rtlb: '⇌', + rtlt: '⥨', + tlbr: '⥮', + tltr: '⥣', + ], + tack: [ + r: '⊢', + r.not: '⊬', + r.long: '⟝', + r.short: '⊦', + r.double: '⊨', + r.double.not: '⊭', + l: '⊣', + l.long: '⟞', + l.short: '⫞', + l.double: '⫤', + t: '⊥', + t.big: '⟘', + t.double: '⫫', + t.short: '⫠', + b: '⊤', + b.big: '⟙', + b.double: '⫪', + b.short: '⫟', + l.r: '⟛', + ], + + // Lowercase Greek. + alpha: 'α', + beta: ['β', alt: 'ϐ'], + chi: 'χ', + delta: 'δ', + epsilon: ['ε', alt: 'ϵ'], + eta: 'η', + gamma: 'γ', + iota: 'ι', + kai: 'ϗ', + kappa: ['κ', alt: 'ϰ'], + lambda: 'λ', + mu: 'μ', + nu: 'ν', + ohm: ['Ω', inv: '℧'], + omega: 'ω', + omicron: 'ο', + phi: ['φ', alt: 'ϕ'], + pi: ['π', alt: 'ϖ'], + psi: 'ψ', + rho: ['ρ', alt: 'ϱ'], + sigma: ['σ', alt: 'ς'], + tau: 'τ', + theta: ['θ', alt: 'ϑ'], + upsilon: 'υ', + xi: 'ξ', + zeta: 'ζ', + + // Uppercase Greek. + Alpha: 'Α', + Beta: 'Β', + Chi: 'Χ', + Delta: 'Δ', + Epsilon: 'Ε', + Eta: 'Η', + Gamma: 'Γ', + Iota: 'Ι', + Kai: 'Ϗ', + Kappa: 'Κ', + Lambda: 'Λ', + Mu: 'Μ', + Nu: 'Ν', + Omega: 'Ω', + Omicron: 'Ο', + Phi: 'Φ', + Pi: 'Π', + Psi: 'Ψ', + Rho: 'Ρ', + Sigma: 'Σ', + Tau: 'Τ', + Theta: 'Θ', + Upsilon: 'Υ', + Xi: 'Ξ', + Zeta: 'Ζ', + + // Hebrew. + alef: 'א', + bet: 'ב', + gimel: 'ג', + shin: 'ש', + + // Double-struck. + AA: '𝔸', + BB: '𝔹', + CC: 'ℂ', + DD: '𝔻', + EE: '𝔼', + FF: '𝔽', + GG: '𝔾', + HH: 'ℍ', + II: '𝕀', + JJ: '𝕁', + KK: '𝕂', + LL: '𝕃', + MM: '𝕄', + NN: 'ℕ', + OO: '𝕆', + PP: 'ℙ', + QQ: 'ℚ', + RR: 'ℝ', + SS: '𝕊', + TT: '𝕋', + UU: '𝕌', + VV: '𝕍', + WW: '𝕎', + XX: '𝕏', + YY: '𝕐', + ZZ: 'ℤ', + + // Miscellaneous letter-likes. + ell: 'ℓ', + planck: ['ℎ', reduce: 'ℏ'], + angstrom: 'Å', + kelvin: 'K', + Re: 'ℜ', + Im: 'ℑ', + dotless: [i: '𝚤', j: '𝚥'], +}; diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs new file mode 100644 index 00000000..9ec4ca32 --- /dev/null +++ b/crates/typst-library/src/text/deco.rs @@ -0,0 +1,420 @@ +use kurbo::{BezPath, Line, ParamCurve}; +use ttf_parser::{GlyphId, OutlineBuilder}; + +use super::TextElem; +use crate::prelude::*; + +/// Underlines text. +/// +/// ## Example { #example } +/// ```example +/// This is #underline[important]. +/// ``` +/// +/// Display: Underline +/// Category: text +#[element(Show)] +pub struct UnderlineElem { + /// How to stroke the line. + /// + /// See the [line's documentation]($func/line.stroke) for more details. If + /// set to `{auto}`, takes on the text's color and a thickness defined in + /// the current font. + /// + /// ```example + /// Take #underline( + /// stroke: 1.5pt + red, + /// offset: 2pt, + /// [care], + /// ) + /// ``` + #[resolve] + #[fold] + pub stroke: Smart<PartialStroke>, + + /// The position of the line relative to the baseline, read from the font + /// tables if `{auto}`. + /// + /// ```example + /// #underline(offset: 5pt)[ + /// The Tale Of A Faraway Line I + /// ] + /// ``` + #[resolve] + pub offset: Smart<Length>, + + /// The amount by which to extend the line beyond (or within if negative) + /// the content. + /// + /// ```example + /// #align(center, + /// underline(extent: 2pt)[Chapter 1] + /// ) + /// ``` + #[resolve] + pub extent: Length, + + /// Whether the line skips sections in which it would collide with the + /// glyphs. + /// + /// ```example + /// This #underline(evade: true)[is great]. + /// This #underline(evade: false)[is less great]. + /// ``` + #[default(true)] + pub evade: bool, + + /// The content to underline. + #[required] + pub body: Content, +} + +impl Show for UnderlineElem { + #[tracing::instrument(name = "UnderlineElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(self.body().styled(TextElem::set_deco(Decoration { + line: DecoLine::Underline, + stroke: self.stroke(styles).unwrap_or_default(), + offset: self.offset(styles), + extent: self.extent(styles), + evade: self.evade(styles), + }))) + } +} + +/// Adds a line over text. +/// +/// ## Example { #example } +/// ```example +/// #overline[A line over text.] +/// ``` +/// +/// Display: Overline +/// Category: text +#[element(Show)] +pub struct OverlineElem { + /// How to stroke the line. + /// + /// See the [line's documentation]($func/line.stroke) for more details. If + /// set to `{auto}`, takes on the text's color and a thickness defined in + /// the current font. + /// + /// ```example + /// #set text(fill: olive) + /// #overline( + /// stroke: green.darken(20%), + /// offset: -12pt, + /// [The Forest Theme], + /// ) + /// ``` + #[resolve] + #[fold] + pub stroke: Smart<PartialStroke>, + + /// The position of the line relative to the baseline. Read from the font + /// tables if `{auto}`. + /// + /// ```example + /// #overline(offset: -1.2em)[ + /// The Tale Of A Faraway Line II + /// ] + /// ``` + #[resolve] + pub offset: Smart<Length>, + + /// The amount by which to extend the line beyond (or within if negative) + /// the content. + /// + /// ```example + /// #set overline(extent: 4pt) + /// #set underline(extent: 4pt) + /// #overline(underline[Typography Today]) + /// ``` + #[resolve] + pub extent: Length, + + /// Whether the line skips sections in which it would collide with the + /// glyphs. + /// + /// ```example + /// #overline( + /// evade: false, + /// offset: -7.5pt, + /// stroke: 1pt, + /// extent: 3pt, + /// [Temple], + /// ) + /// ``` + #[default(true)] + pub evade: bool, + + /// The content to add a line over. + #[required] + pub body: Content, +} + +impl Show for OverlineElem { + #[tracing::instrument(name = "OverlineElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(self.body().styled(TextElem::set_deco(Decoration { + line: DecoLine::Overline, + stroke: self.stroke(styles).unwrap_or_default(), + offset: self.offset(styles), + extent: self.extent(styles), + evade: self.evade(styles), + }))) + } +} + +/// Strikes through text. +/// +/// ## Example { #example } +/// ```example +/// This is #strike[not] relevant. +/// ``` +/// +/// Display: Strikethrough +/// Category: text +#[element(Show)] +pub struct StrikeElem { + /// How to stroke the line. + /// + /// See the [line's documentation]($func/line.stroke) for more details. If + /// set to `{auto}`, takes on the text's color and a thickness defined in + /// the current font. + /// + /// _Note:_ Please don't use this for real redaction as you can still + /// copy paste the text. + /// + /// ```example + /// This is #strike(stroke: 1.5pt + red)[very stricken through]. \ + /// This is #strike(stroke: 10pt)[redacted]. + /// ``` + #[resolve] + #[fold] + pub stroke: Smart<PartialStroke>, + + /// The position of the line relative to the baseline. Read from the font + /// tables if `{auto}`. + /// + /// This is useful if you are unhappy with the offset your font provides. + /// + /// ```example + /// #set text(font: "Inria Serif") + /// This is #strike(offset: auto)[low-ish]. \ + /// This is #strike(offset: -3.5pt)[on-top]. + /// ``` + #[resolve] + pub offset: Smart<Length>, + + /// The amount by which to extend the line beyond (or within if negative) + /// the content. + /// + /// ```example + /// This #strike(extent: -2pt)[skips] parts of the word. + /// This #strike(extent: 2pt)[extends] beyond the word. + /// ``` + #[resolve] + pub extent: Length, + + /// The content to strike through. + #[required] + pub body: Content, +} + +impl Show for StrikeElem { + #[tracing::instrument(name = "StrikeElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(self.body().styled(TextElem::set_deco(Decoration { + line: DecoLine::Strikethrough, + stroke: self.stroke(styles).unwrap_or_default(), + offset: self.offset(styles), + extent: self.extent(styles), + evade: false, + }))) + } +} + +/// Defines a line that is positioned over, under or on top of text. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct Decoration { + pub line: DecoLine, + pub stroke: PartialStroke<Abs>, + pub offset: Smart<Abs>, + pub extent: Abs, + pub evade: bool, +} + +impl Fold for Decoration { + type Output = Vec<Self>; + + fn fold(self, mut outer: Self::Output) -> Self::Output { + outer.insert(0, self); + outer + } +} + +cast! { + type Decoration: "decoration", +} + +/// A kind of decorative line. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum DecoLine { + Underline, + Strikethrough, + Overline, +} + +/// Add line decorations to a single run of shaped text. +pub(super) fn decorate( + frame: &mut Frame, + deco: &Decoration, + text: &TextItem, + shift: Abs, + pos: Point, + width: Abs, +) { + let font_metrics = text.font.metrics(); + let metrics = match deco.line { + DecoLine::Strikethrough => font_metrics.strikethrough, + DecoLine::Overline => font_metrics.overline, + DecoLine::Underline => font_metrics.underline, + }; + + let offset = deco.offset.unwrap_or(-metrics.position.at(text.size)) - shift; + let stroke = deco.stroke.clone().unwrap_or(Stroke { + paint: text.fill.clone(), + thickness: metrics.thickness.at(text.size), + ..Stroke::default() + }); + + let gap_padding = 0.08 * text.size; + let min_width = 0.162 * text.size; + + let start = pos.x - deco.extent; + let end = pos.x + (width + 2.0 * deco.extent); + + let mut push_segment = |from: Abs, to: Abs| { + let origin = Point::new(from, pos.y + offset); + let target = Point::new(to - from, Abs::zero()); + + if target.x >= min_width || !deco.evade { + let shape = Geometry::Line(target).stroked(stroke.clone()); + frame.push(origin, FrameItem::Shape(shape, Span::detached())); + } + }; + + if !deco.evade { + push_segment(start, end); + return; + } + + let line = Line::new( + kurbo::Point::new(pos.x.to_raw(), offset.to_raw()), + kurbo::Point::new((pos.x + width).to_raw(), offset.to_raw()), + ); + + let mut x = pos.x; + let mut intersections = vec![]; + + for glyph in text.glyphs.iter() { + let dx = glyph.x_offset.at(text.size) + x; + let mut builder = + BezPathBuilder::new(font_metrics.units_per_em, text.size, dx.to_raw()); + + let bbox = text.font.ttf().outline_glyph(GlyphId(glyph.id), &mut builder); + let path = builder.finish(); + + x += glyph.x_advance.at(text.size); + + // Only do the costly segments intersection test if the line + // intersects the bounding box. + let intersect = bbox.map_or(false, |bbox| { + let y_min = -text.font.to_em(bbox.y_max).at(text.size); + let y_max = -text.font.to_em(bbox.y_min).at(text.size); + offset >= y_min && offset <= y_max + }); + + if intersect { + // Find all intersections of segments with the line. + intersections.extend( + path.segments() + .flat_map(|seg| seg.intersect_line(line)) + .map(|is| Abs::raw(line.eval(is.line_t).x)), + ); + } + } + + // Add start and end points, taking padding into account. + intersections.push(start - gap_padding); + intersections.push(end + gap_padding); + // When emitting the decorative line segments, we move from left to + // right. The intersections are not necessarily in this order, yet. + intersections.sort(); + + for edge in intersections.windows(2) { + let l = edge[0]; + let r = edge[1]; + + // If we are too close, don't draw the segment + if r - l < gap_padding { + continue; + } else { + push_segment(l + gap_padding, r - gap_padding); + } + } +} + +/// Builds a kurbo [`BezPath`] for a glyph. +struct BezPathBuilder { + path: BezPath, + units_per_em: f64, + font_size: Abs, + x_offset: f64, +} + +impl BezPathBuilder { + fn new(units_per_em: f64, font_size: Abs, x_offset: f64) -> Self { + Self { + path: BezPath::new(), + units_per_em, + font_size, + x_offset, + } + } + + fn finish(self) -> BezPath { + self.path + } + + fn p(&self, x: f32, y: f32) -> kurbo::Point { + kurbo::Point::new(self.s(x) + self.x_offset, -self.s(y)) + } + + fn s(&self, v: f32) -> f64 { + Em::from_units(v, self.units_per_em).at(self.font_size).to_raw() + } +} + +impl OutlineBuilder for BezPathBuilder { + fn move_to(&mut self, x: f32, y: f32) { + self.path.move_to(self.p(x, y)); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to(self.p(x, y)); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.path.quad_to(self.p(x1, y1), self.p(x, y)); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.path.curve_to(self.p(x1, y1), self.p(x2, y2), self.p(x, y)); + } + + fn close(&mut self) { + self.path.close_path(); + } +} diff --git a/crates/typst-library/src/text/misc.rs b/crates/typst-library/src/text/misc.rs new file mode 100644 index 00000000..811b027e --- /dev/null +++ b/crates/typst-library/src/text/misc.rs @@ -0,0 +1,330 @@ +use super::TextElem; +use crate::prelude::*; + +/// A text space. +/// +/// Display: Space +/// Category: text +#[element(Behave, Unlabellable, PlainText)] +pub struct SpaceElem {} + +impl Behave for SpaceElem { + fn behaviour(&self) -> Behaviour { + Behaviour::Weak(2) + } +} + +impl Unlabellable for SpaceElem {} + +impl PlainText for SpaceElem { + fn plain_text(&self, text: &mut EcoString) { + text.push(' '); + } +} + +/// Inserts a line break. +/// +/// Advances the paragraph to the next line. A single trailing line break at the +/// end of a paragraph is ignored, but more than one creates additional empty +/// lines. +/// +/// ## Example { #example } +/// ```example +/// *Date:* 26.12.2022 \ +/// *Topic:* Infrastructure Test \ +/// *Severity:* High \ +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: To insert a line break, simply write +/// a backslash followed by whitespace. This always creates an unjustified +/// break. +/// +/// Display: Line Break +/// Category: text +#[element(Behave)] +pub struct LinebreakElem { + /// Whether to justify the line before the break. + /// + /// This is useful if you found a better line break opportunity in your + /// justified text than Typst did. + /// + /// ```example + /// #set par(justify: true) + /// #let jb = linebreak(justify: true) + /// + /// I have manually tuned the #jb + /// line breaks in this paragraph #jb + /// for an _interesting_ result. #jb + /// ``` + #[default(false)] + pub justify: bool, +} + +impl Behave for LinebreakElem { + fn behaviour(&self) -> Behaviour { + Behaviour::Destructive + } +} + +/// Strongly emphasizes content by increasing the font weight. +/// +/// Increases the current font weight by a given `delta`. +/// +/// ## Example { #example } +/// ```example +/// This is *strong.* \ +/// This is #strong[too.] \ +/// +/// #show strong: set text(red) +/// And this is *evermore.* +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: To strongly emphasize content, +/// simply enclose it in stars/asterisks (`*`). Note that this only works at +/// word boundaries. To strongly emphasize part of a word, you have to use the +/// function. +/// +/// Display: Strong Emphasis +/// Category: text +#[element(Show)] +pub struct StrongElem { + /// The delta to apply on the font weight. + /// + /// ```example + /// #set strong(delta: 0) + /// No *effect!* + /// ``` + #[default(300)] + pub delta: i64, + + /// The content to strongly emphasize. + #[required] + pub body: Content, +} + +impl Show for StrongElem { + #[tracing::instrument(name = "StrongElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + Ok(self.body().styled(TextElem::set_delta(Delta(self.delta(styles))))) + } +} + +/// A delta that is summed up when folded. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Delta(pub i64); + +cast! { + Delta, + self => self.0.into_value(), + v: i64 => Self(v), +} + +impl Fold for Delta { + type Output = i64; + + fn fold(self, outer: Self::Output) -> Self::Output { + outer + self.0 + } +} + +/// Emphasizes content by setting it in italics. +/// +/// - If the current [text style]($func/text.style) is `{"normal"}`, +/// this turns it into `{"italic"}`. +/// - If it is already `{"italic"}` or `{"oblique"}`, +/// it turns it back to `{"normal"}`. +/// +/// ## Example { #example } +/// ```example +/// This is _emphasized._ \ +/// This is #emph[too.] +/// +/// #show emph: it => { +/// text(blue, it.body) +/// } +/// +/// This is _emphasized_ differently. +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: To emphasize content, simply +/// enclose it in underscores (`_`). Note that this only works at word +/// boundaries. To emphasize part of a word, you have to use the function. +/// +/// Display: Emphasis +/// Category: text +#[element(Show)] +pub struct EmphElem { + /// The content to emphasize. + #[required] + pub body: Content, +} + +impl Show for EmphElem { + #[tracing::instrument(name = "EmphElem::show", skip(self))] + fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> { + Ok(self.body().styled(TextElem::set_emph(Toggle))) + } +} + +/// A toggle that turns on and off alternatingly if folded. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Toggle; + +cast! { + Toggle, + self => Value::None, + _: Value => Self, +} + +impl Fold for Toggle { + type Output = bool; + + fn fold(self, outer: Self::Output) -> Self::Output { + !outer + } +} + +/// Converts text or content to lowercase. +/// +/// ## Example { #example } +/// ```example +/// #lower("ABC") \ +/// #lower[*My Text*] \ +/// #lower[already low] +/// ``` +/// +/// Display: Lowercase +/// Category: text +#[func] +pub fn lower( + /// The text to convert to lowercase. + text: Caseable, +) -> Caseable { + case(text, Case::Lower) +} + +/// Converts text or content to uppercase. +/// +/// ## Example { #example } +/// ```example +/// #upper("abc") \ +/// #upper[*my text*] \ +/// #upper[ALREADY HIGH] +/// ``` +/// +/// Display: Uppercase +/// Category: text +#[func] +pub fn upper( + /// The text to convert to uppercase. + text: Caseable, +) -> Caseable { + case(text, Case::Upper) +} + +/// Change the case of text. +fn case(text: Caseable, case: Case) -> Caseable { + match text { + Caseable::Str(v) => Caseable::Str(case.apply(&v).into()), + Caseable::Content(v) => { + Caseable::Content(v.styled(TextElem::set_case(Some(case)))) + } + } +} + +/// A value whose case can be changed. +pub enum Caseable { + Str(Str), + Content(Content), +} + +cast! { + Caseable, + self => match self { + Self::Str(v) => v.into_value(), + Self::Content(v) => v.into_value(), + }, + v: Str => Self::Str(v), + v: Content => Self::Content(v), +} + +/// A case transformation on text. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum Case { + /// Everything is lowercased. + Lower, + /// Everything is uppercased. + Upper, +} + +impl Case { + /// Apply the case to a string. + pub fn apply(self, text: &str) -> String { + match self { + Self::Lower => text.to_lowercase(), + Self::Upper => text.to_uppercase(), + } + } +} + +/// Displays text in small capitals. +/// +/// _Note:_ This enables the OpenType `smcp` feature for the font. Not all fonts +/// support this feature. Sometimes smallcaps are part of a dedicated font and +/// sometimes they are not available at all. In the future, this function will +/// support selecting a dedicated smallcaps font as well as synthesizing +/// smallcaps from normal letters, but this is not yet implemented. +/// +/// ## Example { #example } +/// ```example +/// #set par(justify: true) +/// #set heading(numbering: "I.") +/// +/// #show heading: it => { +/// set block(below: 10pt) +/// set text(weight: "regular") +/// align(center, smallcaps(it)) +/// } +/// +/// = Introduction +/// #lorem(40) +/// ``` +/// +/// Display: Small Capitals +/// Category: text +#[func] +pub fn smallcaps( + /// The text to display to small capitals. + body: Content, +) -> Content { + body.styled(TextElem::set_smallcaps(true)) +} + +/// Creates blind text. +/// +/// This function yields a Latin-like _Lorem Ipsum_ blind text with the given +/// number of words. The sequence of words generated by the function is always +/// the same but randomly chosen. As usual for blind texts, it does not make any +/// sense. Use it as a placeholder to try layouts. +/// +/// ## Example { #example } +/// ```example +/// = Blind Text +/// #lorem(30) +/// +/// = More Blind Text +/// #lorem(15) +/// ``` +/// +/// Display: Blind Text +/// Category: text +#[func] +pub fn lorem( + /// The length of the blind text in words. + words: usize, +) -> Str { + lipsum::lipsum(words).replace("--", "–").into() +} diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs new file mode 100644 index 00000000..ff8cbad8 --- /dev/null +++ b/crates/typst-library/src/text/mod.rs @@ -0,0 +1,769 @@ +//! Text handling. + +mod deco; +mod misc; +mod quotes; +mod raw; +mod shaping; +mod shift; + +pub use self::deco::*; +pub use self::misc::*; +pub use self::quotes::*; +pub use self::raw::*; +pub use self::shaping::*; +pub use self::shift::*; + +use rustybuzz::Tag; +use typst::font::{FontMetrics, FontStretch, FontStyle, FontWeight, VerticalFontMetric}; + +use crate::layout::ParElem; +use crate::prelude::*; + +/// Hook up all text definitions. +pub(super) fn define(global: &mut Scope) { + global.define("text", TextElem::func()); + global.define("linebreak", LinebreakElem::func()); + global.define("smartquote", SmartQuoteElem::func()); + global.define("strong", StrongElem::func()); + global.define("emph", EmphElem::func()); + global.define("lower", lower_func()); + global.define("upper", upper_func()); + global.define("smallcaps", smallcaps_func()); + global.define("sub", SubElem::func()); + global.define("super", SuperElem::func()); + global.define("underline", UnderlineElem::func()); + global.define("strike", StrikeElem::func()); + global.define("overline", OverlineElem::func()); + global.define("raw", RawElem::func()); + global.define("lorem", lorem_func()); +} + +/// Customizes the look and layout of text in a variety of ways. +/// +/// This function is used frequently, both with set rules and directly. While +/// the set rule is often the simpler choice, calling the `text` function +/// directly can be useful when passing text as an argument to another function. +/// +/// ## Example { #example } +/// ```example +/// #set text(18pt) +/// With a set rule. +/// +/// #emph(text(blue)[ +/// With a function call. +/// ]) +/// ``` +/// +/// Display: Text +/// Category: text +#[element(Construct, PlainText)] +pub struct TextElem { + /// A prioritized sequence of font families. + /// + /// When processing text, Typst tries all specified font families in order + /// until it finds a font that has the necessary glyphs. In the example + /// below, the font `Inria Serif` is preferred, but since it does not + /// contain Arabic glyphs, the arabic text uses `Noto Sans Arabic` instead. + /// + /// ```example + /// #set text(font: ( + /// "Inria Serif", + /// "Noto Sans Arabic", + /// )) + /// + /// This is Latin. \ + /// هذا عربي. + /// + /// ``` + #[default(FontList(vec![FontFamily::new("Linux Libertine")]))] + pub font: FontList, + + /// Whether to allow last resort font fallback when the primary font list + /// contains no match. This lets Typst search through all available fonts + /// for the most similar one that has the necessary glyphs. + /// + /// _Note:_ Currently, there are no warnings when fallback is disabled and + /// no glyphs are found. Instead, your text shows up in the form of "tofus": + /// Small boxes that indicate the lack of an appropriate glyph. In the + /// future, you will be able to instruct Typst to issue warnings so you know + /// something is up. + /// + /// ```example + /// #set text(font: "Inria Serif") + /// هذا عربي + /// + /// #set text(fallback: false) + /// هذا عربي + /// ``` + #[default(true)] + pub fallback: bool, + + /// The desired font style. + /// + /// When an italic style is requested and only an oblique one is available, + /// it is used. Similarly, the other way around, an italic style can stand + /// in for an oblique one. When neither an italic nor an oblique style is + /// available, Typst selects the normal style. Since most fonts are only + /// available either in an italic or oblique style, the difference between + /// italic and oblique style is rarely observable. + /// + /// If you want to emphasize your text, you should do so using the + /// [emph]($func/emph) function instead. This makes it easy to adapt the + /// style later if you change your mind about how to signify the emphasis. + /// + /// ```example + /// #text(font: "Linux Libertine", style: "italic")[Italic] + /// #text(font: "DejaVu Sans", style: "oblique")[Oblique] + /// ``` + pub style: FontStyle, + + /// The desired thickness of the font's glyphs. Accepts an integer between + /// `{100}` and `{900}` or one of the predefined weight names. When the + /// desired weight is not available, Typst selects the font from the family + /// that is closest in weight. + /// + /// If you want to strongly emphasize your text, you should do so using the + /// [strong]($func/strong) function instead. This makes it easy to adapt the + /// style later if you change your mind about how to signify the strong + /// emphasis. + /// + /// ```example + /// #set text(font: "IBM Plex Sans") + /// + /// #text(weight: "light")[Light] \ + /// #text(weight: "regular")[Regular] \ + /// #text(weight: "medium")[Medium] \ + /// #text(weight: 500)[Medium] \ + /// #text(weight: "bold")[Bold] + /// ``` + pub weight: FontWeight, + + /// The desired width of the glyphs. Accepts a ratio between `{50%}` and + /// `{200%}`. When the desired weight is not available, Typst selects the + /// font from the family that is closest in stretch. This will only stretch + /// the text if a condensed or expanded version of the font is available. + /// + /// If you want to adjust the amount of space between characters instead of + /// stretching the glyphs itself, use the [`tracking`]($func/text.tracking) + /// property instead. + /// + /// ```example + /// #text(stretch: 75%)[Condensed] \ + /// #text(stretch: 100%)[Normal] + /// ``` + pub stretch: FontStretch, + + /// The size of the glyphs. This value forms the basis of the `em` unit: + /// `{1em}` is equivalent to the font size. + /// + /// You can also give the font size itself in `em` units. Then, it is + /// relative to the previous font size. + /// + /// ```example + /// #set text(size: 20pt) + /// very #text(1.5em)[big] text + /// ``` + #[parse(args.named_or_find("size")?)] + #[fold] + #[default(Abs::pt(11.0))] + pub size: TextSize, + + /// The glyph fill color. + /// + /// ```example + /// #set text(fill: red) + /// This text is red. + /// ``` + #[parse(args.named_or_find("fill")?)] + #[default(Color::BLACK.into())] + pub fill: Paint, + + /// The amount of space that should be added between characters. + /// + /// ```example + /// #set text(tracking: 1.5pt) + /// Distant text. + /// ``` + #[resolve] + pub tracking: Length, + + /// The amount of space between words. + /// + /// Can be given as an absolute length, but also relative to the width of + /// the space character in the font. + /// + /// If you want to adjust the amount of space between characters rather than + /// words, use the [`tracking`]($func/text.tracking) property instead. + /// + /// ```example + /// #set text(spacing: 200%) + /// Text with distant words. + /// ``` + #[resolve] + #[default(Rel::one())] + pub spacing: Rel<Length>, + + /// An amount to shift the text baseline by. + /// + /// ```example + /// A #text(baseline: 3pt)[lowered] + /// word. + /// ``` + #[resolve] + pub baseline: Length, + + /// Whether certain glyphs can hang over into the margin in justified text. + /// This can make justification visually more pleasing. + /// + /// ```example + /// #set par(justify: true) + /// This justified text has a hyphen in + /// the paragraph's first line. Hanging + /// the hyphen slightly into the margin + /// results in a clearer paragraph edge. + /// + /// #set text(overhang: false) + /// This justified text has a hyphen in + /// the paragraph's first line. Hanging + /// the hyphen slightly into the margin + /// results in a clearer paragraph edge. + /// ``` + #[default(true)] + pub overhang: bool, + + /// The top end of the conceptual frame around the text used for layout and + /// positioning. This affects the size of containers that hold text. + /// + /// ```example + /// #set rect(inset: 0pt) + /// #set text(size: 20pt) + /// + /// #set text(top-edge: "ascender") + /// #rect(fill: aqua)[Typst] + /// + /// #set text(top-edge: "cap-height") + /// #rect(fill: aqua)[Typst] + /// ``` + #[default(TextEdge::Metric(VerticalFontMetric::CapHeight))] + pub top_edge: TextEdge, + + /// The bottom end of the conceptual frame around the text used for layout + /// and positioning. This affects the size of containers that hold text. + /// + /// ```example + /// #set rect(inset: 0pt) + /// #set text(size: 20pt) + /// + /// #set text(bottom-edge: "baseline") + /// #rect(fill: aqua)[Typst] + /// + /// #set text(bottom-edge: "descender") + /// #rect(fill: aqua)[Typst] + /// ``` + #[default(TextEdge::Metric(VerticalFontMetric::Baseline))] + pub bottom_edge: TextEdge, + + /// An [ISO 639-1/2/3 language code.](https://en.wikipedia.org/wiki/ISO_639) + /// + /// Setting the correct language affects various parts of Typst: + /// + /// - The text processing pipeline can make more informed choices. + /// - Hyphenation will use the correct patterns for the language. + /// - [Smart quotes]($func/smartquote) turns into the correct quotes for the + /// language. + /// - And all other things which are language-aware. + /// + /// ```example + /// #set text(lang: "de") + /// #outline() + /// + /// = Einleitung + /// In diesem Dokument, ... + /// ``` + #[default(Lang::ENGLISH)] + pub lang: Lang, + + /// An [ISO 3166-1 alpha-2 region code.](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) + /// + /// This lets the text processing pipeline make more informed choices. + pub region: Option<Region>, + + /// The dominant direction for text and inline objects. Possible values are: + /// + /// - `{auto}`: Automatically infer the direction from the `lang` property. + /// - `{ltr}`: Layout text from left to right. + /// - `{rtl}`: Layout text from right to left. + /// + /// When writing in right-to-left scripts like Arabic or Hebrew, you should + /// set the [text language]($func/text.lang) or direction. While individual + /// runs of text are automatically layouted in the correct direction, + /// setting the dominant direction gives the bidirectional reordering + /// algorithm the necessary information to correctly place punctuation and + /// inline objects. Furthermore, setting the direction affects the alignment + /// values `start` and `end`, which are equivalent to `left` and `right` in + /// `ltr` text and the other way around in `rtl` text. + /// + /// If you set this to `rtl` and experience bugs or in some way bad looking + /// output, please do get in touch with us through the + /// [contact form](https://typst.app/contact) or our + /// [Discord server]($community/#discord)! + /// + /// ```example + /// #set text(dir: rtl) + /// هذا عربي. + /// ``` + #[resolve] + pub dir: TextDir, + + /// Whether to hyphenate text to improve line breaking. When `{auto}`, text + /// will be hyphenated if and only if justification is enabled. + /// + /// Setting the [text language]($func/text.lang) ensures that the correct + /// hyphenation patterns are used. + /// + /// ```example + /// #set page(width: 200pt) + /// + /// #set par(justify: true) + /// This text illustrates how + /// enabling hyphenation can + /// improve justification. + /// + /// #set text(hyphenate: false) + /// This text illustrates how + /// enabling hyphenation can + /// improve justification. + /// ``` + #[resolve] + pub hyphenate: Hyphenate, + + /// Whether to apply kerning. + /// + /// When enabled, specific letter pairings move closer together or further + /// apart for a more visually pleasing result. The example below + /// demonstrates how decreasing the gap between the "T" and "o" results in a + /// more natural look. Setting this to `{false}` disables kerning by turning + /// off the OpenType `kern` font feature. + /// + /// ```example + /// #set text(size: 25pt) + /// Totally + /// + /// #set text(kerning: false) + /// Totally + /// ``` + #[default(true)] + pub kerning: bool, + + /// Whether to apply stylistic alternates. + /// + /// Sometimes fonts contain alternative glyphs for the same codepoint. + /// Setting this to `{true}` switches to these by enabling the OpenType + /// `salt` font feature. + /// + /// ```example + /// #set text( + /// font: "IBM Plex Sans", + /// size: 20pt, + /// ) + /// + /// 0, a, g, ß + /// + /// #set text(alternates: true) + /// 0, a, g, ß + /// ``` + #[default(false)] + pub alternates: bool, + + /// Which stylistic set to apply. Font designers can categorize alternative + /// glyphs forms into stylistic sets. As this value is highly font-specific, + /// you need to consult your font to know which sets are available. When set + /// to an integer between `{1}` and `{20}`, enables the corresponding + /// OpenType font feature from `ss01`, ..., `ss20`. + pub stylistic_set: Option<StylisticSet>, + + /// Whether standard ligatures are active. + /// + /// Certain letter combinations like "fi" are often displayed as a single + /// merged glyph called a _ligature._ Setting this to `{false}` disables + /// these ligatures by turning off the OpenType `liga` and `clig` font + /// features. + /// + /// ```example + /// #set text(size: 20pt) + /// A fine ligature. + /// + /// #set text(ligatures: false) + /// A fine ligature. + /// ``` + #[default(true)] + pub ligatures: bool, + + /// Whether ligatures that should be used sparingly are active. Setting this + /// to `{true}` enables the OpenType `dlig` font feature. + #[default(false)] + pub discretionary_ligatures: bool, + + /// Whether historical ligatures are active. Setting this to `{true}` + /// enables the OpenType `hlig` font feature. + #[default(false)] + pub historical_ligatures: bool, + + /// Which kind of numbers / figures to select. When set to `{auto}`, the + /// default numbers for the font are used. + /// + /// ```example + /// #set text(font: "Noto Sans", 20pt) + /// #set text(number-type: "lining") + /// Number 9. + /// + /// #set text(number-type: "old-style") + /// Number 9. + /// ``` + pub number_type: Smart<NumberType>, + + /// The width of numbers / figures. When set to `{auto}`, the default + /// numbers for the font are used. + /// + /// ```example + /// #set text(font: "Noto Sans", 20pt) + /// #set text(number-width: "proportional") + /// A 12 B 34. \ + /// A 56 B 78. + /// + /// #set text(number-width: "tabular") + /// A 12 B 34. \ + /// A 56 B 78. + /// ``` + pub number_width: Smart<NumberWidth>, + + /// Whether to have a slash through the zero glyph. Setting this to `{true}` + /// enables the OpenType `zero` font feature. + /// + /// ```example + /// 0, #text(slashed-zero: true)[0] + /// ``` + #[default(false)] + pub slashed_zero: bool, + + /// Whether to turn numbers into fractions. Setting this to `{true}` + /// enables the OpenType `frac` font feature. + /// + /// It is not advisable to enable this property globally as it will mess + /// with all appearances of numbers after a slash (e.g., in URLs). Instead, + /// enable it locally when you want a fraction. + /// + /// ```example + /// 1/2 \ + /// #text(fractions: true)[1/2] + /// ``` + #[default(false)] + pub fractions: bool, + + /// Raw OpenType features to apply. + /// + /// - If given an array of strings, sets the features identified by the + /// strings to `{1}`. + /// - If given a dictionary mapping to numbers, sets the features + /// identified by the keys to the values. + /// + /// ```example + /// // Enable the `frac` feature manually. + /// #set text(features: ("frac",)) + /// 1/2 + /// ``` + #[fold] + pub features: FontFeatures, + + /// Content in which all text is styled according to the other arguments. + #[external] + #[required] + pub body: Content, + + /// The text. + #[internal] + #[required] + pub text: EcoString, + + /// A delta to apply on the font weight. + #[internal] + #[fold] + pub delta: Delta, + + /// Whether the font style should be inverted. + #[internal] + #[fold] + #[default(false)] + pub emph: Toggle, + + /// Decorative lines. + #[internal] + #[fold] + pub deco: Decoration, + + /// A case transformation that should be applied to the text. + #[internal] + pub case: Option<Case>, + + /// Whether small capital glyphs should be used. ("smcp") + #[internal] + #[default(false)] + pub smallcaps: bool, +} + +impl TextElem { + /// Create a new packed text element. + pub fn packed(text: impl Into<EcoString>) -> Content { + Self::new(text.into()).pack() + } +} + +impl Construct for TextElem { + fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> { + // The text constructor is special: It doesn't create a text element. + // Instead, it leaves the passed argument structurally unchanged, but + // styles all text in it. + let styles = Self::set(args)?; + let body = args.expect::<Content>("body")?; + Ok(body.styled_with_map(styles)) + } +} + +impl PlainText for TextElem { + fn plain_text(&self, text: &mut EcoString) { + text.push_str(&self.text()); + } +} + +/// A lowercased font family like "arial". +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct FontFamily(EcoString); + +impl FontFamily { + /// Create a named font family variant. + pub fn new(string: &str) -> Self { + Self(string.to_lowercase().into()) + } + + /// The lowercased family name. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Debug for FontFamily { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + self.0.fmt(f) + } +} + +cast! { + FontFamily, + self => self.0.into_value(), + string: EcoString => Self::new(&string), +} + +/// Font family fallback list. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct FontList(pub Vec<FontFamily>); + +impl IntoIterator for FontList { + type IntoIter = std::vec::IntoIter<FontFamily>; + type Item = FontFamily; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +cast! { + FontList, + self => if self.0.len() == 1 { + self.0.into_iter().next().unwrap().0.into_value() + } else { + self.0.into_value() + }, + family: FontFamily => Self(vec![family]), + values: Array => Self(values.into_iter().map(|v| v.cast()).collect::<StrResult<_>>()?), +} + +/// The size of text. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct TextSize(pub Length); + +impl Fold for TextSize { + type Output = Abs; + + fn fold(self, outer: Self::Output) -> Self::Output { + self.0.em.at(outer) + self.0.abs + } +} + +cast! { + TextSize, + self => self.0.into_value(), + v: Length => Self(v), +} + +/// Specifies the bottom or top edge of text. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TextEdge { + /// An edge specified using one of the well-known font metrics. + Metric(VerticalFontMetric), + /// An edge specified as a length. + Length(Length), +} + +impl TextEdge { + /// Resolve the value of the text edge given a font's metrics. + pub fn resolve(self, styles: StyleChain, metrics: &FontMetrics) -> Abs { + match self { + Self::Metric(metric) => metrics.vertical(metric).resolve(styles), + Self::Length(length) => length.resolve(styles), + } + } +} + +cast! { + TextEdge, + self => match self { + Self::Metric(metric) => metric.into_value(), + Self::Length(length) => length.into_value(), + }, + v: VerticalFontMetric => Self::Metric(v), + v: Length => Self::Length(v), +} + +/// The direction of text and inline objects in their line. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct TextDir(pub Smart<Dir>); + +cast! { + TextDir, + self => self.0.into_value(), + v: Smart<Dir> => { + if v.map_or(false, |dir| dir.axis() == Axis::Y) { + bail!("text direction must be horizontal"); + } + Self(v) + }, +} + +impl Resolve for TextDir { + type Output = Dir; + + fn resolve(self, styles: StyleChain) -> Self::Output { + match self.0 { + Smart::Auto => TextElem::lang_in(styles).dir(), + Smart::Custom(dir) => dir, + } + } +} + +/// Whether to hyphenate text. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Hyphenate(pub Smart<bool>); + +cast! { + Hyphenate, + self => self.0.into_value(), + v: Smart<bool> => Self(v), +} + +impl Resolve for Hyphenate { + type Output = bool; + + fn resolve(self, styles: StyleChain) -> Self::Output { + match self.0 { + Smart::Auto => ParElem::justify_in(styles), + Smart::Custom(v) => v, + } + } +} + +/// A stylistic set in a font. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct StylisticSet(u8); + +impl StylisticSet { + /// Create a new set, clamping to 1-20. + pub fn new(index: u8) -> Self { + Self(index.clamp(1, 20)) + } + + /// Get the value, guaranteed to be 1-20. + pub fn get(self) -> u8 { + self.0 + } +} + +cast! { + StylisticSet, + self => self.0.into_value(), + v: i64 => match v { + 1 ..= 20 => Self::new(v as u8), + _ => bail!("stylistic set must be between 1 and 20"), + }, +} + +/// Which kind of numbers / figures to select. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum NumberType { + /// Numbers that fit well with capital text (the OpenType `lnum` + /// font feature). + Lining, + /// Numbers that fit well into a flow of upper- and lowercase text (the + /// OpenType `onum` font feature). + OldStyle, +} + +/// The width of numbers / figures. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum NumberWidth { + /// Numbers with glyph-specific widths (the OpenType `pnum` font feature). + Proportional, + /// Numbers of equal width (the OpenType `tnum` font feature). + Tabular, +} + +/// OpenType font features settings. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct FontFeatures(pub Vec<(Tag, u32)>); + +cast! { + FontFeatures, + self => self.0 + .into_iter() + .map(|(tag, num)| { + let bytes = tag.to_bytes(); + let key = std::str::from_utf8(&bytes).unwrap_or_default(); + (key.into(), num.into_value()) + }) + .collect::<Dict>() + .into_value(), + values: Array => Self(values + .into_iter() + .map(|v| { + let tag = v.cast::<EcoString>()?; + Ok((Tag::from_bytes_lossy(tag.as_bytes()), 1)) + }) + .collect::<StrResult<_>>()?), + values: Dict => Self(values + .into_iter() + .map(|(k, v)| { + let num = v.cast::<u32>()?; + let tag = Tag::from_bytes_lossy(k.as_bytes()); + Ok((tag, num)) + }) + .collect::<StrResult<_>>()?), +} + +impl Fold for FontFeatures { + type Output = Self; + + fn fold(mut self, outer: Self::Output) -> Self::Output { + self.0.extend(outer.0); + self + } +} diff --git a/crates/typst-library/src/text/quotes.rs b/crates/typst-library/src/text/quotes.rs new file mode 100644 index 00000000..d0798064 --- /dev/null +++ b/crates/typst-library/src/text/quotes.rs @@ -0,0 +1,209 @@ +use typst::syntax::is_newline; + +use crate::prelude::*; + +/// A language-aware quote that reacts to its context. +/// +/// Automatically turns into an appropriate opening or closing quote based on +/// the active [text language]($func/text.lang). +/// +/// ## Example { #example } +/// ```example +/// "This is in quotes." +/// +/// #set text(lang: "de") +/// "Das ist in Anführungszeichen." +/// +/// #set text(lang: "fr") +/// "C'est entre guillemets." +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: The normal quote characters +/// (`'` and `"`). Typst automatically makes your quotes smart. +/// +/// Display: Smart Quote +/// Category: text +#[element] +pub struct SmartQuoteElem { + /// Whether this should be a double quote. + #[default(true)] + pub double: bool, + + /// Whether smart quotes are enabled. + /// + /// To disable smartness for a single quote, you can also escape it with a + /// backslash. + /// + /// ```example + /// #set smartquote(enabled: false) + /// + /// These are "dumb" quotes. + /// ``` + #[default(true)] + pub enabled: bool, +} + +/// State machine for smart quote substitution. +#[derive(Debug, Clone)] +pub struct Quoter { + /// How many quotes have been opened. + quote_depth: usize, + /// Whether an opening quote might follow. + expect_opening: bool, + /// Whether the last character was numeric. + last_num: bool, +} + +impl Quoter { + /// Start quoting. + pub fn new() -> Self { + Self { + quote_depth: 0, + expect_opening: true, + last_num: false, + } + } + + /// Process the last seen character. + pub fn last(&mut self, c: char) { + self.expect_opening = is_ignorable(c) || is_opening_bracket(c); + self.last_num = c.is_numeric(); + } + + /// Process and substitute a quote. + pub fn quote<'a>( + &mut self, + quotes: &Quotes<'a>, + double: bool, + peeked: Option<char>, + ) -> &'a str { + let peeked = peeked.unwrap_or(' '); + if self.expect_opening { + self.quote_depth += 1; + quotes.open(double) + } else if self.quote_depth > 0 + && (peeked.is_ascii_punctuation() || is_ignorable(peeked)) + { + self.quote_depth -= 1; + quotes.close(double) + } else if self.last_num { + quotes.prime(double) + } else { + quotes.fallback(double) + } + } +} + +impl Default for Quoter { + fn default() -> Self { + Self::new() + } +} + +fn is_ignorable(c: char) -> bool { + c.is_whitespace() || is_newline(c) +} + +fn is_opening_bracket(c: char) -> bool { + matches!(c, '(' | '{' | '[') +} + +/// Decides which quotes to substitute smart quotes with. +pub struct Quotes<'s> { + /// The opening single quote. + pub single_open: &'s str, + /// The closing single quote. + pub single_close: &'s str, + /// The opening double quote. + pub double_open: &'s str, + /// The closing double quote. + pub double_close: &'s str, +} + +impl<'s> Quotes<'s> { + /// Create a new `Quotes` struct with the defaults for a language and + /// region. + /// + /// The language should be specified as an all-lowercase ISO 639-1 code, the + /// region as an all-uppercase ISO 3166-alpha2 code. + /// + /// Currently, the supported languages are: English, Czech, Danish, German, + /// Swiss / Liechtensteinian German, Estonian, Icelandic, Lithuanian, + /// Latvian, Slovak, Slovenian, Spanish, Bosnian, Finnish, Swedish, French, + /// Hungarian, Polish, Romanian, Japanese, Traditional Chinese, Russian, and + /// Norwegian. + /// + /// For unknown languages, the English quotes are used. + pub fn from_lang(lang: Lang, region: Option<Region>) -> Self { + let region = region.as_ref().map(Region::as_str); + let (single_open, single_close, double_open, double_close) = match lang.as_str() { + "de" if matches!(region, Some("CH" | "LI")) => ("‹", "›", "«", "»"), + "cs" | "da" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => { + ("‚", "‘", "„", "“") + } + "fr" => ("‹\u{00A0}", "\u{00A0}›", "«\u{00A0}", "\u{00A0}»"), + "bs" | "fi" | "sv" => ("’", "’", "”", "”"), + "es" if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"), + "hu" | "pl" | "ro" => ("’", "’", "„", "”"), + "ru" | "no" | "nb" | "nn" | "ua" => ("’", "’", "«", "»"), + _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"), + _ => return Self::default(), + }; + + Self { + single_open, + single_close, + double_open, + double_close, + } + } + + /// The opening quote. + fn open(&self, double: bool) -> &'s str { + if double { + self.double_open + } else { + self.single_open + } + } + + /// The closing quote. + fn close(&self, double: bool) -> &'s str { + if double { + self.double_close + } else { + self.single_close + } + } + + /// Which character should be used as a prime. + fn prime(&self, double: bool) -> &'static str { + if double { + "″" + } else { + "′" + } + } + + /// Which character should be used as a fallback quote. + fn fallback(&self, double: bool) -> &'static str { + if double { + "\"" + } else { + "’" + } + } +} + +impl Default for Quotes<'_> { + /// Returns the english quotes as default. + fn default() -> Self { + Self { + single_open: "‘", + single_close: "’", + double_open: "“", + double_close: "”", + } + } +} diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs new file mode 100644 index 00000000..24f39bb6 --- /dev/null +++ b/crates/typst-library/src/text/raw.rs @@ -0,0 +1,398 @@ +use once_cell::sync::Lazy; +use syntect::highlighting as synt; +use typst::syntax::{self, LinkedNode}; + +use super::{ + FontFamily, FontList, Hyphenate, LinebreakElem, SmartQuoteElem, TextElem, TextSize, +}; +use crate::layout::BlockElem; +use crate::meta::{Figurable, LocalName}; +use crate::prelude::*; + +/// Raw text with optional syntax highlighting. +/// +/// Displays the text verbatim and in a monospace font. This is typically used +/// to embed computer code into your document. +/// +/// ## Example { #example } +/// ````example +/// Adding `rbx` to `rcx` gives +/// the desired result. +/// +/// ```rust +/// fn main() { +/// println!("Hello World!"); +/// } +/// ``` +/// ```` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax. You can enclose text in 1 or 3+ +/// backticks (`` ` ``) to make it raw. Two backticks produce empty raw text. +/// When you use three or more backticks, you can additionally specify a +/// language tag for syntax highlighting directly after the opening backticks. +/// Within raw blocks, everything is rendered as is, in particular, there are no +/// escape sequences. +/// +/// Display: Raw Text / Code +/// Category: text +#[element(Synthesize, Show, Finalize, LocalName, Figurable, PlainText)] +pub struct RawElem { + /// The raw text. + /// + /// You can also use raw blocks creatively to create custom syntaxes for + /// your automations. + /// + /// ````example + /// // Parse numbers in raw blocks with the + /// // `mydsl` tag and sum them up. + /// #show raw.where(lang: "mydsl"): it => { + /// let sum = 0 + /// for part in it.text.split("+") { + /// sum += int(part.trim()) + /// } + /// sum + /// } + /// + /// ```mydsl + /// 1 + 2 + 3 + 4 + 5 + /// ``` + /// ```` + #[required] + pub text: EcoString, + + /// Whether the raw text is displayed as a separate block. + /// + /// In markup mode, using one-backtick notation makes this `{false}`, + /// whereas using three-backtick notation makes it `{true}`. + /// + /// ````example + /// // Display inline code in a small box + /// // that retains the correct baseline. + /// #show raw.where(block: false): box.with( + /// fill: luma(240), + /// inset: (x: 3pt, y: 0pt), + /// outset: (y: 3pt), + /// radius: 2pt, + /// ) + /// + /// // Display block code in a larger block + /// // with more padding. + /// #show raw.where(block: true): block.with( + /// fill: luma(240), + /// inset: 10pt, + /// radius: 4pt, + /// ) + /// + /// With `rg`, you can search through your files quickly. + /// + /// ```bash + /// rg "Hello World" + /// ``` + /// ```` + #[default(false)] + pub block: bool, + + /// The language to syntax-highlight in. + /// + /// Apart from typical language tags known from Markdown, this supports the + /// `{"typ"}` and `{"typc"}` tags for Typst markup and Typst code, + /// respectively. + /// + /// ````example + /// ```typ + /// This is *Typst!* + /// ``` + /// ```` + pub lang: Option<EcoString>, + + /// The horizontal alignment that each line in a raw block should have. + /// This option is ignored if this is not a raw block (if specified + /// `block: false` or single backticks were used in markup mode). + /// + /// By default, this is set to `{start}`, meaning that raw text is + /// aligned towards the start of the text direction inside the block + /// by default, regardless of the current context's alignment (allowing + /// you to center the raw block itself without centering the text inside + /// it, for example). + /// + /// ````example + /// #set raw(align: center) + /// + /// ```typc + /// let f(x) = x + /// code = "centered" + /// ``` + /// ```` + #[default(HorizontalAlign(GenAlign::Start))] + pub align: HorizontalAlign, +} + +impl RawElem { + /// The supported language names and tags. + pub fn languages() -> Vec<(&'static str, Vec<&'static str>)> { + SYNTAXES + .syntaxes() + .iter() + .map(|syntax| { + ( + syntax.name.as_str(), + syntax.file_extensions.iter().map(|s| s.as_str()).collect(), + ) + }) + .chain([("Typst", vec!["typ"]), ("Typst (code)", vec!["typc"])]) + .collect() + } +} + +impl Synthesize for RawElem { + fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + self.push_lang(self.lang(styles)); + Ok(()) + } +} + +impl Show for RawElem { + #[tracing::instrument(name = "RawElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let text = self.text(); + let lang = self.lang(styles).as_ref().map(|s| s.to_lowercase()); + let foreground = THEME + .settings + .foreground + .map(to_typst) + .map_or(Color::BLACK, Color::from); + + let mut realized = if matches!(lang.as_deref(), Some("typ" | "typst" | "typc")) { + let root = match lang.as_deref() { + Some("typc") => syntax::parse_code(&text), + _ => syntax::parse(&text), + }; + + let mut seq = vec![]; + let highlighter = synt::Highlighter::new(&THEME); + highlight_themed( + &LinkedNode::new(&root), + vec![], + &highlighter, + &mut |node, style| { + seq.push(styled(&text[node.range()], foreground.into(), style)); + }, + ); + + Content::sequence(seq) + } else if let Some(syntax) = + lang.and_then(|token| SYNTAXES.find_syntax_by_token(&token)) + { + let mut seq = vec![]; + let mut highlighter = syntect::easy::HighlightLines::new(syntax, &THEME); + for (i, line) in text.lines().enumerate() { + if i != 0 { + seq.push(LinebreakElem::new().pack()); + } + + for (style, piece) in + highlighter.highlight_line(line, &SYNTAXES).into_iter().flatten() + { + seq.push(styled(piece, foreground.into(), style)); + } + } + + Content::sequence(seq) + } else { + TextElem::packed(text) + }; + + if self.block(styles) { + // Align the text before inserting it into the block. + realized = realized.aligned(Axes::with_x(Some(self.align(styles).into()))); + realized = BlockElem::new().with_body(Some(realized)).pack(); + } + + Ok(realized) + } +} + +impl Finalize for RawElem { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + let mut styles = Styles::new(); + styles.set(TextElem::set_overhang(false)); + styles.set(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))); + styles.set(TextElem::set_size(TextSize(Em::new(0.8).into()))); + styles + .set(TextElem::set_font(FontList(vec![FontFamily::new("DejaVu Sans Mono")]))); + styles.set(SmartQuoteElem::set_enabled(false)); + realized.styled_with_map(styles) + } +} + +impl LocalName for RawElem { + fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "List", + Lang::ARABIC => "قائمة", + Lang::BOKMÅL => "Utskrift", + Lang::CHINESE => "代码", + Lang::CZECH => "Seznam", + Lang::DANISH => "Liste", + Lang::DUTCH => "Listing", + Lang::FILIPINO => "Listahan", + Lang::FRENCH => "Liste", + Lang::GERMAN => "Listing", + Lang::ITALIAN => "Codice", + Lang::NYNORSK => "Utskrift", + Lang::POLISH => "Program", + Lang::RUSSIAN => "Листинг", + Lang::SLOVENIAN => "Program", + Lang::SWEDISH => "Listing", + Lang::TURKISH => "Liste", + Lang::UKRAINIAN => "Лістинг", + Lang::VIETNAMESE => "Chương trình", // TODO: This may be wrong. + Lang::ENGLISH | _ => "Listing", + } + } +} + +impl Figurable for RawElem {} + +impl PlainText for RawElem { + fn plain_text(&self, text: &mut EcoString) { + text.push_str(&self.text()); + } +} + +/// Highlight a syntax node in a theme by calling `f` with ranges and their +/// styles. +fn highlight_themed<F>( + node: &LinkedNode, + scopes: Vec<syntect::parsing::Scope>, + highlighter: &synt::Highlighter, + f: &mut F, +) where + F: FnMut(&LinkedNode, synt::Style), +{ + if node.children().len() == 0 { + let style = highlighter.style_for_stack(&scopes); + f(node, style); + return; + } + + for child in node.children() { + let mut scopes = scopes.clone(); + if let Some(tag) = typst::ide::highlight(&child) { + scopes.push(syntect::parsing::Scope::new(tag.tm_scope()).unwrap()) + } + highlight_themed(&child, scopes, highlighter, f); + } +} + +/// Style a piece of text with a syntect style. +fn styled(piece: &str, foreground: Paint, style: synt::Style) -> Content { + let mut body = TextElem::packed(piece); + + let paint = to_typst(style.foreground).into(); + if paint != foreground { + body = body.styled(TextElem::set_fill(paint)); + } + + if style.font_style.contains(synt::FontStyle::BOLD) { + body = body.strong(); + } + + if style.font_style.contains(synt::FontStyle::ITALIC) { + body = body.emph(); + } + + if style.font_style.contains(synt::FontStyle::UNDERLINE) { + body = body.underlined(); + } + + body +} + +fn to_typst(synt::Color { r, g, b, a }: synt::Color) -> RgbaColor { + RgbaColor { r, g, b, a } +} + +fn to_syn(RgbaColor { r, g, b, a }: RgbaColor) -> synt::Color { + synt::Color { r, g, b, a } +} + +/// The syntect syntax definitions. +/// +/// Code for syntax set generation is below. The `syntaxes` directory is from +/// <https://github.com/sharkdp/bat/tree/master/assets/syntaxes> +/// +/// ```ignore +/// fn main() { +/// let mut builder = syntect::parsing::SyntaxSet::load_defaults_nonewlines().into_builder(); +/// builder.add_from_folder("syntaxes/02_Extra", false).unwrap(); +/// syntect::dumps::dump_to_file(&builder.build(), "syntect.bin").unwrap(); +/// } +/// ``` +/// +/// The following syntaxes are disabled due to compatibility issues: +/// ```text +/// syntaxes/02_Extra/Assembly (ARM).sublime-syntax +/// syntaxes/02_Extra/Elixir/Regular Expressions (Elixir).sublime-syntax +/// syntaxes/02_Extra/JavaScript (Babel).sublime-syntax +/// syntaxes/02_Extra/LiveScript.sublime-syntax +/// syntaxes/02_Extra/PowerShell.sublime-syntax +/// syntaxes/02_Extra/SCSS_Sass/Syntaxes/Sass.sublime-syntax +/// syntaxes/02_Extra/SLS/SLS.sublime-syntax +/// syntaxes/02_Extra/VimHelp.sublime-syntax +/// syntaxes/02_Extra/cmd-help/syntaxes/cmd-help.sublime-syntax +/// ``` +pub static SYNTAXES: Lazy<syntect::parsing::SyntaxSet> = + Lazy::new(|| syntect::dumps::from_binary(include_bytes!("../../assets/syntect.bin"))); + +/// The default theme used for syntax highlighting. +pub static THEME: Lazy<synt::Theme> = Lazy::new(|| synt::Theme { + name: Some("Typst Light".into()), + author: Some("The Typst Project Developers".into()), + settings: synt::ThemeSettings::default(), + scopes: vec![ + item("comment", Some("#8a8a8a"), None), + item("constant.character.escape", Some("#1d6c76"), None), + item("markup.bold", None, Some(synt::FontStyle::BOLD)), + item("markup.italic", None, Some(synt::FontStyle::ITALIC)), + item("markup.underline", None, Some(synt::FontStyle::UNDERLINE)), + item("markup.raw", Some("#818181"), None), + item("string.other.math.typst", None, None), + item("punctuation.definition.math", Some("#298e0d"), None), + item("keyword.operator.math", Some("#1d6c76"), None), + item("markup.heading, entity.name.section", None, Some(synt::FontStyle::BOLD)), + item( + "markup.heading.typst", + None, + Some(synt::FontStyle::BOLD | synt::FontStyle::UNDERLINE), + ), + item("punctuation.definition.list", Some("#8b41b1"), None), + item("markup.list.term", None, Some(synt::FontStyle::BOLD)), + item("entity.name.label, markup.other.reference", Some("#1d6c76"), None), + item("keyword, constant.language, variable.language", Some("#d73a49"), None), + item("storage.type, storage.modifier", Some("#d73a49"), None), + item("constant", Some("#b60157"), None), + item("string", Some("#298e0d"), None), + item("entity.name, variable.function, support", Some("#4b69c6"), None), + item("support.macro", Some("#16718d"), None), + item("meta.annotation", Some("#301414"), None), + item("entity.other, meta.interpolation", Some("#8b41b1"), None), + ], +}); + +/// Create a syntect theme item. +fn item( + scope: &str, + color: Option<&str>, + font_style: Option<synt::FontStyle>, +) -> synt::ThemeItem { + synt::ThemeItem { + scope: scope.parse().unwrap(), + style: synt::StyleModifier { + foreground: color.map(|s| to_syn(s.parse::<RgbaColor>().unwrap())), + background: None, + font_style, + }, + } +} diff --git a/crates/typst-library/src/text/shaping.rs b/crates/typst-library/src/text/shaping.rs new file mode 100644 index 00000000..ec8812fe --- /dev/null +++ b/crates/typst-library/src/text/shaping.rs @@ -0,0 +1,973 @@ +use std::borrow::Cow; +use std::ops::Range; +use std::str::FromStr; + +use az::SaturatingAs; +use rustybuzz::{Feature, Tag, UnicodeBuffer}; +use typst::font::{Font, FontStyle, FontVariant}; +use typst::util::SliceExt; +use unicode_script::{Script, UnicodeScript}; + +use super::{decorate, FontFamily, NumberType, NumberWidth, TextElem}; +use crate::layout::SpanMapper; +use crate::prelude::*; + +/// The result of shaping text. +/// +/// This type contains owned or borrowed shaped text runs, which can be +/// measured, used to reshape substrings more quickly and converted into a +/// frame. +pub struct ShapedText<'a> { + /// The start of the text in the full paragraph. + pub base: usize, + /// The text that was shaped. + pub text: &'a str, + /// The text direction. + pub dir: Dir, + /// The text language. + pub lang: Lang, + /// The text region. + pub region: Option<Region>, + /// The text's style properties. + pub styles: StyleChain<'a>, + /// The font variant. + pub variant: FontVariant, + /// The font size. + pub size: Abs, + /// The width of the text's bounding box. + pub width: Abs, + /// The shaped glyphs. + pub glyphs: Cow<'a, [ShapedGlyph]>, +} + +/// A single glyph resulting from shaping. +#[derive(Debug, Clone)] +pub struct ShapedGlyph { + /// The font the glyph is contained in. + pub font: Font, + /// The glyph's index in the font. + pub glyph_id: u16, + /// The advance width of the glyph. + pub x_advance: Em, + /// The horizontal offset of the glyph. + pub x_offset: Em, + /// The vertical offset of the glyph. + pub y_offset: Em, + /// The adjustability of the glyph. + pub adjustability: Adjustability, + /// The byte range of this glyph's cluster in the full paragraph. A cluster + /// is a sequence of one or multiple glyphs that cannot be separated and + /// must always be treated as a union. + /// + /// The range values of the glyphs in a [`ShapedText`] should not + /// overlap with each other, and they should be monotonically + /// increasing (for left-to-right or top-to-bottom text) or + /// monotonically decreasing (for right-to-left or bottom-to-top + /// text). + pub range: Range<usize>, + /// Whether splitting the shaping result before this glyph would yield the + /// same results as shaping the parts to both sides of `text_index` + /// separately. + pub safe_to_break: bool, + /// The first char in this glyph's cluster. + pub c: char, + /// The source code location of the glyph and its byte offset within it. + pub span: (Span, u16), +} + +#[derive(Debug, Clone, Default)] +pub struct Adjustability { + /// The left and right strechability + pub stretchability: (Em, Em), + /// The left and right shrinkability + pub shrinkability: (Em, Em), +} + +impl ShapedGlyph { + /// Whether the glyph is a space. + pub fn is_space(&self) -> bool { + matches!(self.c, ' ' | '\u{00A0}' | ' ') + } + + /// Whether the glyph is justifiable. + pub fn is_justifiable(&self) -> bool { + // GB style is not relevant here. + self.is_space() + || self.is_cjk_script() + || self.is_cjk_left_aligned_punctuation(true) + || self.is_cjk_right_aligned_punctuation() + || self.is_cjk_center_aligned_punctuation(true) + } + + pub fn is_cjk_script(&self) -> bool { + use Script::*; + // U+30FC: Katakana-Hiragana Prolonged Sound Mark + matches!(self.c.script(), Hiragana | Katakana | Han) || self.c == '\u{30FC}' + } + + pub fn is_cjk_punctuation(&self) -> bool { + self.is_cjk_left_aligned_punctuation(true) + || self.is_cjk_right_aligned_punctuation() + || self.is_cjk_center_aligned_punctuation(true) + } + + /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> + pub fn is_cjk_left_aligned_punctuation(&self, gb_style: bool) -> bool { + // CJK quotation marks shares codepoints with latin quotation marks. + // But only the CJK ones have full width. + if matches!(self.c, '”' | '’') + && self.x_advance + self.stretchability().1 == Em::one() + { + return true; + } + + if gb_style && matches!(self.c, ',' | '。' | '、' | ':' | ';') { + return true; + } + + matches!(self.c, '》' | ')' | '』' | '」') + } + + /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> + pub fn is_cjk_right_aligned_punctuation(&self) -> bool { + // CJK quotation marks shares codepoints with latin quotation marks. + // But only the CJK ones have full width. + if matches!(self.c, '“' | '‘') + && self.x_advance + self.stretchability().0 == Em::one() + { + return true; + } + + matches!(self.c, '《' | '(' | '『' | '「') + } + + /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> + pub fn is_cjk_center_aligned_punctuation(&self, gb_style: bool) -> bool { + if !gb_style && matches!(self.c, ',' | '。' | '、' | ':' | ';') { + return true; + } + + // U+30FB: Katakana Middle Dot + matches!(self.c, '\u{30FB}') + } + + pub fn base_adjustability(&self, gb_style: bool) -> Adjustability { + let width = self.x_advance; + if self.is_space() { + Adjustability { + // The number for spaces is from Knuth-Plass' paper + stretchability: (Em::zero(), width / 2.0), + shrinkability: (Em::zero(), width / 3.0), + } + } else if self.is_cjk_left_aligned_punctuation(gb_style) { + Adjustability { + stretchability: (Em::zero(), Em::zero()), + shrinkability: (Em::zero(), width / 2.0), + } + } else if self.is_cjk_right_aligned_punctuation() { + Adjustability { + stretchability: (Em::zero(), Em::zero()), + shrinkability: (width / 2.0, Em::zero()), + } + } else if self.is_cjk_center_aligned_punctuation(gb_style) { + Adjustability { + stretchability: (Em::zero(), Em::zero()), + shrinkability: (width / 4.0, width / 4.0), + } + } else { + Adjustability::default() + } + } + + /// The stretchability of the character. + pub fn stretchability(&self) -> (Em, Em) { + self.adjustability.stretchability + } + + /// The shrinkability of the character. + pub fn shrinkability(&self) -> (Em, Em) { + self.adjustability.shrinkability + } + + /// Shrink the width of glyph on the left side. + pub fn shrink_left(&mut self, amount: Em) { + self.x_offset -= amount; + self.x_advance -= amount; + self.adjustability.shrinkability.0 -= amount; + self.adjustability.stretchability.0 += amount; + } + + /// Shrink the width of glyph on the right side. + pub fn shrink_right(&mut self, amount: Em) { + self.x_advance -= amount; + self.adjustability.shrinkability.1 -= amount; + self.adjustability.stretchability.1 += amount; + } +} + +/// A side you can go toward. +enum Side { + /// To the left-hand side. + Left, + /// To the right-hand side. + Right, +} + +impl<'a> ShapedText<'a> { + /// Build the shaped text's frame. + /// + /// The `justification` defines how much extra advance width each + /// [justifiable glyph](ShapedGlyph::is_justifiable) will get. + pub fn build( + &self, + vt: &Vt, + justification_ratio: f64, + extra_justification: Abs, + ) -> Frame { + let (top, bottom) = self.measure(vt); + let size = Size::new(self.width, top + bottom); + + let mut offset = Abs::zero(); + let mut frame = Frame::new(size); + frame.set_baseline(top); + + let shift = TextElem::baseline_in(self.styles); + let lang = TextElem::lang_in(self.styles); + let decos = TextElem::deco_in(self.styles); + let fill = TextElem::fill_in(self.styles); + + for ((font, y_offset), group) in + self.glyphs.as_ref().group_by_key(|g| (g.font.clone(), g.y_offset)) + { + let mut range = group[0].range.clone(); + for glyph in group { + range.start = range.start.min(glyph.range.start); + range.end = range.end.max(glyph.range.end); + } + + let pos = Point::new(offset, top + shift - y_offset.at(self.size)); + let glyphs = group + .iter() + .map(|glyph| { + let adjustability_left = if justification_ratio < 0.0 { + glyph.shrinkability().0 + } else { + glyph.stretchability().0 + }; + let adjustability_right = if justification_ratio < 0.0 { + glyph.shrinkability().1 + } else { + glyph.stretchability().1 + }; + + let justification_left = adjustability_left * justification_ratio; + let mut justification_right = + adjustability_right * justification_ratio; + if glyph.is_justifiable() { + justification_right += + Em::from_length(extra_justification, self.size) + } + + frame.size_mut().x += justification_left.at(self.size) + + justification_right.at(self.size); + + Glyph { + id: glyph.glyph_id, + x_advance: glyph.x_advance + + justification_left + + justification_right, + x_offset: glyph.x_offset + justification_left, + range: (glyph.range.start - range.start).saturating_as() + ..(glyph.range.end - range.start).saturating_as(), + span: glyph.span, + } + }) + .collect(); + + let item = TextItem { + font, + size: self.size, + lang, + fill: fill.clone(), + text: self.text[range.start - self.base..range.end - self.base].into(), + glyphs, + }; + + let layer = frame.layer(); + let width = item.width(); + + // Apply line decorations. + for deco in &decos { + decorate(&mut frame, deco, &item, shift, pos, width); + } + + frame.insert(layer, pos, FrameItem::Text(item)); + offset += width; + } + + // Apply metadata. + frame.meta(self.styles, false); + + frame + } + + /// Measure the top and bottom extent of this text. + fn measure(&self, vt: &Vt) -> (Abs, Abs) { + let mut top = Abs::zero(); + let mut bottom = Abs::zero(); + + let top_edge = TextElem::top_edge_in(self.styles); + let bottom_edge = TextElem::bottom_edge_in(self.styles); + + // Expand top and bottom by reading the font's vertical metrics. + let mut expand = |font: &Font| { + let metrics = font.metrics(); + top.set_max(top_edge.resolve(self.styles, metrics)); + bottom.set_max(-bottom_edge.resolve(self.styles, metrics)); + }; + + if self.glyphs.is_empty() { + // When there are no glyphs, we just use the vertical metrics of the + // first available font. + let world = vt.world; + for family in families(self.styles) { + if let Some(font) = world + .book() + .select(family.as_str(), self.variant) + .and_then(|id| world.font(id)) + { + expand(&font); + break; + } + } + } else { + for g in self.glyphs.iter() { + expand(&g.font); + } + } + + (top, bottom) + } + + /// How many glyphs are in the text where we can insert additional + /// space when encountering underfull lines. + pub fn justifiables(&self) -> usize { + self.glyphs.iter().filter(|g| g.is_justifiable()).count() + } + + /// Whether the last glyph is a CJK character which should not be justified + /// on line end. + pub fn cjk_justifiable_at_last(&self) -> bool { + self.glyphs + .last() + .map(|g| g.is_cjk_script() || g.is_cjk_punctuation()) + .unwrap_or(false) + } + + /// The stretchability of the text. + pub fn stretchability(&self) -> Abs { + self.glyphs + .iter() + .map(|g| g.stretchability().0 + g.stretchability().1) + .sum::<Em>() + .at(self.size) + } + + /// The shrinkability of the text + pub fn shrinkability(&self) -> Abs { + self.glyphs + .iter() + .map(|g| g.shrinkability().0 + g.shrinkability().1) + .sum::<Em>() + .at(self.size) + } + + /// Reshape a range of the shaped text, reusing information from this + /// shaping process if possible. + /// + /// The text `range` is relative to the whole paragraph. + pub fn reshape( + &'a self, + vt: &Vt, + spans: &SpanMapper, + text_range: Range<usize>, + ) -> ShapedText<'a> { + let text = &self.text[text_range.start - self.base..text_range.end - self.base]; + if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) { + #[cfg(debug_assertions)] + assert_all_glyphs_in_range(glyphs, text, text_range.clone()); + Self { + base: text_range.start, + text, + dir: self.dir, + lang: self.lang, + region: self.region, + styles: self.styles, + size: self.size, + variant: self.variant, + width: glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(self.size), + glyphs: Cow::Borrowed(glyphs), + } + } else { + shape( + vt, + text_range.start, + text, + spans, + self.styles, + self.dir, + self.lang, + self.region, + ) + } + } + + /// Push a hyphen to end of the text. + pub fn push_hyphen(&mut self, vt: &Vt) { + families(self.styles).find_map(|family| { + let world = vt.world; + let font = world + .book() + .select(family.as_str(), self.variant) + .and_then(|id| world.font(id))?; + let ttf = font.ttf(); + let glyph_id = ttf.glyph_index('-')?; + let x_advance = font.to_em(ttf.glyph_hor_advance(glyph_id)?); + let range = self + .glyphs + .last() + .map(|g| g.range.end..g.range.end) + .unwrap_or_default(); + self.width += x_advance.at(self.size); + self.glyphs.to_mut().push(ShapedGlyph { + font, + glyph_id: glyph_id.0, + x_advance, + x_offset: Em::zero(), + y_offset: Em::zero(), + adjustability: Adjustability::default(), + range, + safe_to_break: true, + c: '-', + span: (Span::detached(), 0), + }); + Some(()) + }); + } + + /// Find the subslice of glyphs that represent the given text range if both + /// sides are safe to break. + fn slice_safe_to_break(&self, text_range: Range<usize>) -> Option<&[ShapedGlyph]> { + let Range { mut start, mut end } = text_range; + if !self.dir.is_positive() { + std::mem::swap(&mut start, &mut end); + } + + let left = self.find_safe_to_break(start, Side::Left)?; + let right = self.find_safe_to_break(end, Side::Right)?; + Some(&self.glyphs[left..right]) + } + + /// Find the glyph offset matching the text index that is most towards the + /// given side and safe-to-break. + fn find_safe_to_break(&self, text_index: usize, towards: Side) -> Option<usize> { + let ltr = self.dir.is_positive(); + + // Handle edge cases. + let len = self.glyphs.len(); + if text_index == self.base { + return Some(if ltr { 0 } else { len }); + } else if text_index == self.base + self.text.len() { + return Some(if ltr { len } else { 0 }); + } + + // Find any glyph with the text index. + let mut idx = self + .glyphs + .binary_search_by(|g| { + let ordering = g.range.start.cmp(&text_index); + if ltr { + ordering + } else { + ordering.reverse() + } + }) + .ok()?; + + let next = match towards { + Side::Left => usize::checked_sub, + Side::Right => usize::checked_add, + }; + + // Search for the outermost glyph with the text index. + while let Some(next) = next(idx, 1) { + if self.glyphs.get(next).map_or(true, |g| g.range.start != text_index) { + break; + } + idx = next; + } + + // RTL needs offset one because the left side of the range should be + // exclusive and the right side inclusive, contrary to the normal + // behaviour of ranges. + self.glyphs[idx].safe_to_break.then_some(idx + usize::from(!ltr)) + } +} + +impl Debug for ShapedText<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + self.text.fmt(f) + } +} + +/// Holds shaping results and metadata common to all shaped segments. +struct ShapingContext<'a, 'v> { + vt: &'a Vt<'v>, + spans: &'a SpanMapper, + glyphs: Vec<ShapedGlyph>, + used: Vec<Font>, + styles: StyleChain<'a>, + size: Abs, + variant: FontVariant, + tags: Vec<rustybuzz::Feature>, + fallback: bool, + dir: Dir, +} + +/// Shape text into [`ShapedText`]. +#[allow(clippy::too_many_arguments)] +pub fn shape<'a>( + vt: &Vt, + base: usize, + text: &'a str, + spans: &SpanMapper, + styles: StyleChain<'a>, + dir: Dir, + lang: Lang, + region: Option<Region>, +) -> ShapedText<'a> { + let size = TextElem::size_in(styles); + let mut ctx = ShapingContext { + vt, + spans, + size, + glyphs: vec![], + used: vec![], + styles, + variant: variant(styles), + tags: tags(styles), + fallback: TextElem::fallback_in(styles), + dir, + }; + + if !text.is_empty() { + shape_segment(&mut ctx, base, text, families(styles)); + } + + track_and_space(&mut ctx); + calculate_adjustability(&mut ctx, lang, region); + + #[cfg(debug_assertions)] + assert_all_glyphs_in_range(&ctx.glyphs, text, base..(base + text.len())); + #[cfg(debug_assertions)] + assert_glyph_ranges_in_order(&ctx.glyphs, dir); + + ShapedText { + base, + text, + dir, + lang, + region, + styles, + variant: ctx.variant, + size, + width: ctx.glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(size), + glyphs: Cow::Owned(ctx.glyphs), + } +} + +/// Shape text with font fallback using the `families` iterator. +fn shape_segment( + ctx: &mut ShapingContext, + base: usize, + text: &str, + mut families: impl Iterator<Item = FontFamily> + Clone, +) { + // Fonts dont have newlines and tabs. + if text.chars().all(|c| c == '\n' || c == '\t') { + return; + } + + // Find the next available family. + let world = ctx.vt.world; + let book = world.book(); + let mut selection = families.find_map(|family| { + book.select(family.as_str(), ctx.variant) + .and_then(|id| world.font(id)) + .filter(|font| !ctx.used.contains(font)) + }); + + // Do font fallback if the families are exhausted and fallback is enabled. + if selection.is_none() && ctx.fallback { + let first = ctx.used.first().map(Font::info); + selection = book + .select_fallback(first, ctx.variant, text) + .and_then(|id| world.font(id)) + .filter(|font| !ctx.used.contains(font)); + } + + // Extract the font id or shape notdef glyphs if we couldn't find any font. + let Some(font) = selection else { + if let Some(font) = ctx.used.first().cloned() { + shape_tofus(ctx, base, text, font); + } + return; + }; + + ctx.used.push(font.clone()); + + // Fill the buffer with our text. + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.set_language(language(ctx.styles)); + buffer.set_direction(match ctx.dir { + Dir::LTR => rustybuzz::Direction::LeftToRight, + Dir::RTL => rustybuzz::Direction::RightToLeft, + _ => unimplemented!("vertical text layout"), + }); + + // Shape! + let buffer = rustybuzz::shape(font.rusty(), &ctx.tags, buffer); + let infos = buffer.glyph_infos(); + let pos = buffer.glyph_positions(); + let ltr = ctx.dir.is_positive(); + + // Collect the shaped glyphs, doing fallback and shaping parts again with + // the next font if necessary. + let mut i = 0; + while i < infos.len() { + let info = &infos[i]; + let cluster = info.cluster as usize; + + // Add the glyph to the shaped output. + if info.glyph_id != 0 { + // Determine the text range of the glyph. + let start = base + cluster; + let end = base + + if ltr { i.checked_add(1) } else { i.checked_sub(1) } + .and_then(|last| infos.get(last)) + .map_or(text.len(), |info| info.cluster as usize); + + ctx.glyphs.push(ShapedGlyph { + font: font.clone(), + glyph_id: info.glyph_id as u16, + // TODO: Don't ignore y_advance. + x_advance: font.to_em(pos[i].x_advance), + x_offset: font.to_em(pos[i].x_offset), + y_offset: font.to_em(pos[i].y_offset), + adjustability: Adjustability::default(), + range: start..end, + safe_to_break: !info.unsafe_to_break(), + c: text[cluster..].chars().next().unwrap(), + span: ctx.spans.span_at(start), + }); + } else { + // First, search for the end of the tofu sequence. + let k = i; + while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) { + i += 1; + } + + // Then, determine the start and end text index for the tofu + // sequence. + // + // Examples: + // Everything is shown in visual order. Tofus are written as "_". + // We want to find out that the tofus span the text `2..6`. + // Note that the clusters are longer than 1 char. + // + // Left-to-right: + // Text: h a l i h a l l o + // Glyphs: A _ _ C E + // Clusters: 0 2 4 6 8 + // k=1 i=2 + // + // Right-to-left: + // Text: O L L A H I L A H + // Glyphs: E C _ _ A + // Clusters: 8 6 4 2 0 + // k=2 i=3 + let start = infos[if ltr { k } else { i }].cluster as usize; + let end = if ltr { i.checked_add(1) } else { k.checked_sub(1) } + .and_then(|last| infos.get(last)) + .map_or(text.len(), |info| info.cluster as usize); + + // Trim half-baked cluster. + let remove = base + start..base + end; + while ctx.glyphs.last().map_or(false, |g| remove.contains(&g.range.start)) { + ctx.glyphs.pop(); + } + + // Recursively shape the tofu sequence with the next family. + shape_segment(ctx, base + start, &text[start..end], families.clone()); + } + + i += 1; + } + + ctx.used.pop(); +} + +/// Shape the text with tofus from the given font. +fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) { + let x_advance = font.advance(0).unwrap_or_default(); + let add_glyph = |(cluster, c): (usize, char)| { + let start = base + cluster; + let end = start + c.len_utf8(); + ctx.glyphs.push(ShapedGlyph { + font: font.clone(), + glyph_id: 0, + x_advance, + x_offset: Em::zero(), + y_offset: Em::zero(), + adjustability: Adjustability::default(), + range: start..end, + safe_to_break: true, + c, + span: ctx.spans.span_at(start), + }); + }; + if ctx.dir.is_positive() { + text.char_indices().for_each(add_glyph); + } else { + text.char_indices().rev().for_each(add_glyph); + } +} + +/// Apply tracking and spacing to the shaped glyphs. +fn track_and_space(ctx: &mut ShapingContext) { + let tracking = Em::from_length(TextElem::tracking_in(ctx.styles), ctx.size); + let spacing = + TextElem::spacing_in(ctx.styles).map(|abs| Em::from_length(abs, ctx.size)); + + let mut glyphs = ctx.glyphs.iter_mut().peekable(); + while let Some(glyph) = glyphs.next() { + // Make non-breaking space same width as normal space. + if glyph.c == '\u{00A0}' { + glyph.x_advance -= nbsp_delta(&glyph.font).unwrap_or_default(); + } + + if glyph.is_space() { + glyph.x_advance = spacing.relative_to(glyph.x_advance); + } + + if glyphs + .peek() + .map_or(false, |next| glyph.range.start != next.range.start) + { + glyph.x_advance += tracking; + } + } +} + +pub fn is_gb_style(lang: Lang, region: Option<Region>) -> bool { + // Most CJK variants, including zh-CN, ja-JP, zh-SG, zh-MY use GB-style punctuation, + // while zh-HK and zh-TW use alternative style. We default to use GB-style. + !(lang == Lang::CHINESE + && matches!(region.as_ref().map(Region::as_str), Some("TW" | "HK"))) +} + +/// Calculate stretchability and shrinkability of each glyph, +/// and CJK punctuation adjustments according to Chinese Layout Requirements. +fn calculate_adjustability(ctx: &mut ShapingContext, lang: Lang, region: Option<Region>) { + let gb_style = is_gb_style(lang, region); + + for glyph in &mut ctx.glyphs { + glyph.adjustability = glyph.base_adjustability(gb_style); + } + + let mut glyphs = ctx.glyphs.iter_mut().peekable(); + while let Some(glyph) = glyphs.next() { + // Only GB style needs further adjustment. + if glyph.is_cjk_punctuation() && !gb_style { + continue; + } + + // Now we apply consecutive punctuation adjustment, specified in Chinese Layout + // Requirements, section 3.1.6.1 Punctuation Adjustment Space, and Japanese Layout + // Requirements, section 3.1 Line Composition Rules for Punctuation Marks + let Some(next) = glyphs.peek_mut() else { continue }; + let width = glyph.x_advance; + let delta = width / 2.0; + if glyph.is_cjk_punctuation() + && next.is_cjk_punctuation() + && (glyph.shrinkability().1 + next.shrinkability().0) >= delta + { + let left_delta = glyph.shrinkability().1.min(delta); + glyph.shrink_right(left_delta); + next.shrink_left(delta - left_delta); + } + } +} + +/// Difference between non-breaking and normal space. +fn nbsp_delta(font: &Font) -> Option<Em> { + let space = font.ttf().glyph_index(' ')?.0; + let nbsp = font.ttf().glyph_index('\u{00A0}')?.0; + Some(font.advance(nbsp)? - font.advance(space)?) +} + +/// Resolve the font variant. +pub fn variant(styles: StyleChain) -> FontVariant { + let mut variant = FontVariant::new( + TextElem::style_in(styles), + TextElem::weight_in(styles), + TextElem::stretch_in(styles), + ); + + let delta = TextElem::delta_in(styles); + variant.weight = variant + .weight + .thicken(delta.clamp(i16::MIN as i64, i16::MAX as i64) as i16); + + if TextElem::emph_in(styles) { + variant.style = match variant.style { + FontStyle::Normal => FontStyle::Italic, + FontStyle::Italic => FontStyle::Normal, + FontStyle::Oblique => FontStyle::Normal, + } + } + + variant +} + +/// Resolve a prioritized iterator over the font families. +pub fn families(styles: StyleChain) -> impl Iterator<Item = FontFamily> + Clone { + const FALLBACKS: &[&str] = &[ + "linux libertine", + "twitter color emoji", + "noto color emoji", + "apple color emoji", + "segoe ui emoji", + ]; + + let tail = if TextElem::fallback_in(styles) { FALLBACKS } else { &[] }; + TextElem::font_in(styles) + .into_iter() + .chain(tail.iter().copied().map(FontFamily::new)) +} + +/// Collect the tags of the OpenType features to apply. +fn tags(styles: StyleChain) -> Vec<Feature> { + let mut tags = vec![]; + let mut feat = |tag, value| { + tags.push(Feature::new(Tag::from_bytes(tag), value, ..)); + }; + + // Features that are on by default in Harfbuzz are only added if disabled. + if !TextElem::kerning_in(styles) { + feat(b"kern", 0); + } + + // Features that are off by default in Harfbuzz are only added if enabled. + if TextElem::smallcaps_in(styles) { + feat(b"smcp", 1); + } + + if TextElem::alternates_in(styles) { + feat(b"salt", 1); + } + + let storage; + if let Some(set) = TextElem::stylistic_set_in(styles) { + storage = [b's', b's', b'0' + set.get() / 10, b'0' + set.get() % 10]; + feat(&storage, 1); + } + + if !TextElem::ligatures_in(styles) { + feat(b"liga", 0); + feat(b"clig", 0); + } + + if TextElem::discretionary_ligatures_in(styles) { + feat(b"dlig", 1); + } + + if TextElem::historical_ligatures_in(styles) { + feat(b"hilg", 1); + } + + match TextElem::number_type_in(styles) { + Smart::Auto => {} + Smart::Custom(NumberType::Lining) => feat(b"lnum", 1), + Smart::Custom(NumberType::OldStyle) => feat(b"onum", 1), + } + + match TextElem::number_width_in(styles) { + Smart::Auto => {} + Smart::Custom(NumberWidth::Proportional) => feat(b"pnum", 1), + Smart::Custom(NumberWidth::Tabular) => feat(b"tnum", 1), + } + + if TextElem::slashed_zero_in(styles) { + feat(b"zero", 1); + } + + if TextElem::fractions_in(styles) { + feat(b"frac", 1); + } + + for (tag, value) in TextElem::features_in(styles).0 { + tags.push(Feature::new(tag, value, ..)) + } + + tags +} + +/// Process the language and and region of a style chain into a +/// rustybuzz-compatible BCP 47 language. +fn language(styles: StyleChain) -> rustybuzz::Language { + let mut bcp: EcoString = TextElem::lang_in(styles).as_str().into(); + if let Some(region) = TextElem::region_in(styles) { + bcp.push('-'); + bcp.push_str(region.as_str()); + } + rustybuzz::Language::from_str(&bcp).unwrap() +} + +/// Returns true if all glyphs in `glyphs` have ranges within the range `range`. +#[cfg(debug_assertions)] +fn assert_all_glyphs_in_range(glyphs: &[ShapedGlyph], text: &str, range: Range<usize>) { + if glyphs + .iter() + .any(|g| g.range.start < range.start || g.range.end > range.end) + { + panic!("one or more glyphs in {text:?} fell out of range"); + } +} + +/// Asserts that the ranges of `glyphs` is in the proper order according to `dir`. +/// +/// This asserts instead of returning a bool in order to provide a more informative message when the invariant is violated. +#[cfg(debug_assertions)] +fn assert_glyph_ranges_in_order(glyphs: &[ShapedGlyph], dir: Dir) { + if glyphs.is_empty() { + return; + } + + // Iterator::is_sorted and friends are unstable as of Rust 1.70.0 + for i in 0..(glyphs.len() - 1) { + let a = &glyphs[i]; + let b = &glyphs[i + 1]; + let ord = a.range.start.cmp(&b.range.start); + let ord = if dir.is_positive() { ord } else { ord.reverse() }; + if ord == std::cmp::Ordering::Greater { + panic!( + "glyph ranges should be monotonically {}, \ + but found glyphs out of order:\n\n\ + first: {a:#?}\nsecond: {b:#?}", + if dir.is_positive() { "increasing" } else { "decreasing" }, + ); + } + } +} diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs new file mode 100644 index 00000000..65e309e1 --- /dev/null +++ b/crates/typst-library/src/text/shift.rs @@ -0,0 +1,229 @@ +use super::{variant, SpaceElem, TextElem, TextSize}; +use crate::prelude::*; + +/// Renders text in subscript. +/// +/// The text is rendered smaller and its baseline is lowered. +/// +/// ## Example { #example } +/// ```example +/// Revenue#sub[yearly] +/// ``` +/// +/// Display: Subscript +/// Category: text +#[element(Show)] +pub struct SubElem { + /// Whether to prefer the dedicated subscript characters of the font. + /// + /// If this is enabled, Typst first tries to transform the text to subscript + /// codepoints. If that fails, it falls back to rendering lowered and shrunk + /// normal letters. + /// + /// ```example + /// N#sub(typographic: true)[1] + /// N#sub(typographic: false)[1] + /// ``` + #[default(true)] + pub typographic: bool, + + /// The baseline shift for synthetic subscripts. Does not apply if + /// `typographic` is true and the font has subscript codepoints for the + /// given `body`. + #[default(Em::new(0.2).into())] + pub baseline: Length, + + /// The font size for synthetic subscripts. Does not apply if + /// `typographic` is true and the font has subscript codepoints for the + /// given `body`. + #[default(TextSize(Em::new(0.6).into()))] + pub size: TextSize, + + /// The text to display in subscript. + #[required] + pub body: Content, +} + +impl Show for SubElem { + #[tracing::instrument(name = "SubElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let body = self.body(); + let mut transformed = None; + if self.typographic(styles) { + if let Some(text) = search_text(&body, true) { + if is_shapable(vt, &text, styles) { + transformed = Some(TextElem::packed(text)); + } + } + }; + + Ok(transformed.unwrap_or_else(|| { + body.styled(TextElem::set_baseline(self.baseline(styles))) + .styled(TextElem::set_size(self.size(styles))) + })) + } +} + +/// Renders text in superscript. +/// +/// The text is rendered smaller and its baseline is raised. +/// +/// ## Example { #example } +/// ```example +/// 1#super[st] try! +/// ``` +/// +/// Display: Superscript +/// Category: text +#[element(Show)] +pub struct SuperElem { + /// Whether to prefer the dedicated superscript characters of the font. + /// + /// If this is enabled, Typst first tries to transform the text to + /// superscript codepoints. If that fails, it falls back to rendering + /// raised and shrunk normal letters. + /// + /// ```example + /// N#super(typographic: true)[1] + /// N#super(typographic: false)[1] + /// ``` + #[default(true)] + pub typographic: bool, + + /// The baseline shift for synthetic superscripts. Does not apply if + /// `typographic` is true and the font has superscript codepoints for the + /// given `body`. + #[default(Em::new(-0.5).into())] + pub baseline: Length, + + /// The font size for synthetic superscripts. Does not apply if + /// `typographic` is true and the font has superscript codepoints for the + /// given `body`. + #[default(TextSize(Em::new(0.6).into()))] + pub size: TextSize, + + /// The text to display in superscript. + #[required] + pub body: Content, +} + +impl Show for SuperElem { + #[tracing::instrument(name = "SuperElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> { + let body = self.body(); + let mut transformed = None; + if self.typographic(styles) { + if let Some(text) = search_text(&body, false) { + if is_shapable(vt, &text, styles) { + transformed = Some(TextElem::packed(text)); + } + } + }; + + Ok(transformed.unwrap_or_else(|| { + body.styled(TextElem::set_baseline(self.baseline(styles))) + .styled(TextElem::set_size(self.size(styles))) + })) + } +} + +/// Find and transform the text contained in `content` to the given script kind +/// if and only if it only consists of `Text`, `Space`, and `Empty` leafs. +fn search_text(content: &Content, sub: bool) -> Option<EcoString> { + if content.is::<SpaceElem>() { + Some(' '.into()) + } else if let Some(elem) = content.to::<TextElem>() { + convert_script(&elem.text(), sub) + } else if let Some(children) = content.to_sequence() { + let mut full = EcoString::new(); + for item in children { + match search_text(item, sub) { + Some(text) => full.push_str(&text), + None => return None, + } + } + Some(full) + } else { + None + } +} + +/// Checks whether the first retrievable family contains all code points of the +/// given string. +fn is_shapable(vt: &Vt, text: &str, styles: StyleChain) -> bool { + let world = vt.world; + for family in TextElem::font_in(styles) { + if let Some(font) = world + .book() + .select(family.as_str(), variant(styles)) + .and_then(|id| world.font(id)) + { + return text.chars().all(|c| font.ttf().glyph_index(c).is_some()); + } + } + + false +} + +/// Convert a string to sub- or superscript codepoints if all characters +/// can be mapped to such a codepoint. +fn convert_script(text: &str, sub: bool) -> Option<EcoString> { + let mut result = EcoString::with_capacity(text.len()); + let converter = if sub { to_subscript_codepoint } else { to_superscript_codepoint }; + + for c in text.chars() { + match converter(c) { + Some(c) => result.push(c), + None => return None, + } + } + + Some(result) +} + +/// Convert a character to its corresponding Unicode superscript. +fn to_superscript_codepoint(c: char) -> Option<char> { + char::from_u32(match c { + '0' => 0x2070, + '1' => 0x00B9, + '2' => 0x00B2, + '3' => 0x00B3, + '4'..='9' => 0x2070 + (c as u32 + 4 - '4' as u32), + '+' => 0x207A, + '-' => 0x207B, + '=' => 0x207C, + '(' => 0x207D, + ')' => 0x207E, + 'n' => 0x207F, + 'i' => 0x2071, + ' ' => 0x0020, + _ => return None, + }) +} + +/// Convert a character to its corresponding Unicode subscript. +fn to_subscript_codepoint(c: char) -> Option<char> { + char::from_u32(match c { + '0' => 0x2080, + '1'..='9' => 0x2080 + (c as u32 - '0' as u32), + '+' => 0x208A, + '-' => 0x208B, + '=' => 0x208C, + '(' => 0x208D, + ')' => 0x208E, + 'a' => 0x2090, + 'e' => 0x2091, + 'o' => 0x2092, + 'x' => 0x2093, + 'h' => 0x2095, + 'k' => 0x2096, + 'l' => 0x2097, + 'm' => 0x2098, + 'n' => 0x2099, + 'p' => 0x209A, + 's' => 0x209B, + 't' => 0x209C, + ' ' => 0x0020, + _ => return None, + }) +} diff --git a/crates/typst-library/src/visualize/image.rs b/crates/typst-library/src/visualize/image.rs new file mode 100644 index 00000000..b169cc4d --- /dev/null +++ b/crates/typst-library/src/visualize/image.rs @@ -0,0 +1,197 @@ +use std::ffi::OsStr; +use std::path::Path; + +use typst::image::{Image, ImageFormat, RasterFormat, VectorFormat}; +use typst::util::Bytes; + +use crate::meta::{Figurable, LocalName}; +use crate::prelude::*; +use crate::text::families; + +/// A raster or vector graphic. +/// +/// Supported formats are PNG, JPEG, GIF and SVG. +/// +/// _Note:_ Work on SVG export is ongoing and there might be visual inaccuracies +/// in the resulting PDF. Make sure to double-check embedded SVG images. If you +/// have an issue, also feel free to report it on [GitHub][gh-svg]. +/// +/// ## Example { #example } +/// ```example +/// #figure( +/// image("molecular.jpg", width: 80%), +/// caption: [ +/// A step in the molecular testing +/// pipeline of our lab. +/// ], +/// ) +/// ``` +/// +/// [gh-svg]: https://github.com/typst/typst/issues?q=is%3Aopen+is%3Aissue+label%3Asvg +/// +/// Display: Image +/// Category: visualize +#[element(Layout, LocalName, Figurable)] +pub struct ImageElem { + /// Path to an image file. + #[required] + #[parse( + let Spanned { v: path, span } = + args.expect::<Spanned<EcoString>>("path to image file")?; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + path + )] + pub path: EcoString, + + /// The raw file data. + #[internal] + #[required] + #[parse(data)] + pub data: Bytes, + + /// The width of the image. + pub width: Smart<Rel<Length>>, + + /// The height of the image. + pub height: Smart<Rel<Length>>, + + /// A text describing the image. + pub alt: Option<EcoString>, + + /// How the image should adjust itself to a given area. + #[default(ImageFit::Cover)] + pub fit: ImageFit, +} + +impl Layout for ImageElem { + #[tracing::instrument(name = "ImageElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let ext = Path::new(self.path().as_str()) + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_lowercase(); + + let format = match ext.as_str() { + "png" => ImageFormat::Raster(RasterFormat::Png), + "jpg" | "jpeg" => ImageFormat::Raster(RasterFormat::Jpg), + "gif" => ImageFormat::Raster(RasterFormat::Gif), + "svg" | "svgz" => ImageFormat::Vector(VectorFormat::Svg), + _ => bail!(self.span(), "unknown image format"), + }; + + let image = Image::with_fonts( + self.data(), + format, + vt.world, + families(styles).next().as_ref().map(|f| f.as_str()), + self.alt(styles), + ) + .at(self.span())?; + + let sizing = Axes::new(self.width(styles), self.height(styles)); + let region = sizing + .zip(regions.base()) + .map(|(s, r)| s.map(|v| v.resolve(styles).relative_to(r))) + .unwrap_or(regions.base()); + + let expand = sizing.as_ref().map(Smart::is_custom) | regions.expand; + let region_ratio = region.x / region.y; + + // Find out whether the image is wider or taller than the target size. + let pxw = image.width() as f64; + let pxh = image.height() as f64; + let px_ratio = pxw / pxh; + let wide = px_ratio > region_ratio; + + // The space into which the image will be placed according to its fit. + let target = if expand.x && expand.y { + region + } else if expand.x || (!expand.y && wide && region.x.is_finite()) { + Size::new(region.x, region.y.min(region.x.safe_div(px_ratio))) + } else if region.y.is_finite() { + Size::new(region.x.min(region.y * px_ratio), region.y) + } else { + Size::new(Abs::pt(pxw), Abs::pt(pxh)) + }; + + // Compute the actual size of the fitted image. + let fit = self.fit(styles); + let fitted = match fit { + ImageFit::Cover | ImageFit::Contain => { + if wide == (fit == ImageFit::Contain) { + Size::new(target.x, target.x / px_ratio) + } else { + Size::new(target.y * px_ratio, target.y) + } + } + ImageFit::Stretch => target, + }; + + // First, place the image in a frame of exactly its size and then resize + // the frame to the target size, center aligning the image in the + // process. + let mut frame = Frame::new(fitted); + frame.push(Point::zero(), FrameItem::Image(image, fitted, self.span())); + frame.resize(target, Align::CENTER_HORIZON); + + // Create a clipping group if only part of the image should be visible. + if fit == ImageFit::Cover && !target.fits(fitted) { + frame.clip(); + } + + // Apply metadata. + frame.meta(styles, false); + + Ok(Fragment::frame(frame)) + } +} + +impl LocalName for ImageElem { + fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "Figurë", + Lang::ARABIC => "شكل", + Lang::BOKMÅL => "Figur", + Lang::CHINESE => "图", + Lang::CZECH => "Obrázek", + Lang::DANISH => "Figur", + Lang::DUTCH => "Figuur", + Lang::FILIPINO => "Pigura", + Lang::FRENCH => "Figure", + Lang::GERMAN => "Abbildung", + Lang::ITALIAN => "Figura", + Lang::NYNORSK => "Figur", + Lang::POLISH => "Rysunek", + Lang::PORTUGUESE => "Figura", + Lang::RUSSIAN => "Рисунок", + Lang::SLOVENIAN => "Slika", + Lang::SPANISH => "Figura", + Lang::SWEDISH => "Figur", + Lang::TURKISH => "Şekil", + Lang::UKRAINIAN => "Рисунок", + Lang::VIETNAMESE => "Hình", + Lang::ENGLISH | _ => "Figure", + } + } +} + +impl Figurable for ImageElem {} + +/// How an image should adjust itself to a given area. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum ImageFit { + /// The image should completely cover the area. This is the default. + Cover, + /// The image should be fully contained in the area. + Contain, + /// The image should be stretched so that it exactly fills the area, even if + /// this means that the image will be distorted. + Stretch, +} diff --git a/crates/typst-library/src/visualize/line.rs b/crates/typst-library/src/visualize/line.rs new file mode 100644 index 00000000..62a381a9 --- /dev/null +++ b/crates/typst-library/src/visualize/line.rs @@ -0,0 +1,118 @@ +use crate::prelude::*; + +/// A line from one point to another. +/// +/// ## Example { #example } +/// ```example +/// #set page(height: 100pt) +/// +/// #line(length: 100%) +/// #line(end: (50%, 50%)) +/// #line( +/// length: 4cm, +/// stroke: 2pt + maroon, +/// ) +/// ``` +/// +/// Display: Line +/// Category: visualize +#[element(Layout)] +pub struct LineElem { + /// The start point of the line. + /// + /// Must be an array of exactly two relative lengths. + #[resolve] + pub start: Axes<Rel<Length>>, + + /// The offset from `start` where the line ends. + #[resolve] + pub end: Option<Axes<Rel<Length>>>, + + /// The line's length. This is only respected if `end` is `none`. + #[resolve] + #[default(Abs::pt(30.0).into())] + pub length: Rel<Length>, + + /// The angle at which the line points away from the origin. This is only + /// respected if `end` is `none`. + pub angle: Angle, + + /// How to stroke the line. This can be: + /// + /// - A length specifying the stroke's thickness. The color is inherited, + /// defaulting to black. + /// - A color to use for the stroke. The thickness is inherited, defaulting + /// to `{1pt}`. + /// - A stroke combined from color and thickness using the `+` operator as + /// in `{2pt + red}`. + /// - A stroke described by a dictionary with any of the following keys: + /// - `paint`: The [color]($type/color) to use for the stroke. + /// - `thickness`: The stroke's thickness as a [length]($type/length). + /// - `cap`: How the line terminates. One of `{"butt"}`, `{"round"}`, or + /// `{"square"}`. + /// - `join`: How sharp turns of a contour are rendered. One of + /// `{"miter"}`, `{"round"}`, or `{"bevel"}`. Not applicable to lines + /// but to [polygons]($func/polygon) or [paths]($func/path). + /// - `miter-limit`: Number at which protruding sharp angles are rendered + /// with a bevel instead. The higher the number, the sharper an angle + /// can be before it is bevelled. Only applicable if `join` is + /// `{"miter"}`. Defaults to `{4.0}`. + /// - `dash`: The dash pattern to use. Can be any of the following: + /// - One of the predefined patterns `{"solid"}`, `{"dotted"}`, + /// `{"densely-dotted"}`, `{"loosely-dotted"}`, `{"dashed"}`, + /// `{"densely-dashed"}`, `{"loosely-dashed"}`, `{"dash-dotted"}`, + /// `{"densely-dash-dotted"}` or `{"loosely-dash-dotted"}` + /// - An [array]($type/array) with alternating lengths for dashes and + /// gaps. You can also use the string `{"dot"}` for a length equal to + /// the line thickness. + /// - A [dictionary]($type/dictionary) with the keys `array` (same as + /// the array above), and `phase` (of type [length]($type/length)), + /// which defines where in the pattern to start drawing. + /// + /// ```example + /// #set line(length: 100%) + /// #stack( + /// spacing: 1em, + /// line(stroke: 2pt + red), + /// line(stroke: (paint: blue, thickness: 4pt, cap: "round")), + /// line(stroke: (paint: blue, thickness: 1pt, dash: "dashed")), + /// line(stroke: (paint: blue, thickness: 1pt, dash: ("dot", 2pt, 4pt, 2pt))), + /// ) + /// ``` + #[resolve] + #[fold] + pub stroke: PartialStroke, +} + +impl Layout for LineElem { + #[tracing::instrument(name = "LineElem::layout", skip_all)] + fn layout( + &self, + _: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let resolve = |axes: Axes<Rel<Abs>>| { + axes.zip(regions.base()).map(|(l, b)| l.relative_to(b)) + }; + + let start = resolve(self.start(styles)); + let delta = + self.end(styles).map(|end| resolve(end) - start).unwrap_or_else(|| { + let length = self.length(styles); + let angle = self.angle(styles); + let x = angle.cos() * length; + let y = angle.sin() * length; + resolve(Axes::new(x, y)) + }); + + let stroke = self.stroke(styles).unwrap_or_default(); + let size = start.max(start + delta).max(Size::zero()); + let target = regions.expand.select(regions.size, size); + + let mut frame = Frame::new(target); + let shape = Geometry::Line(delta.to_point()).stroked(stroke); + frame.push(start.to_point(), FrameItem::Shape(shape, self.span())); + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/visualize/mod.rs b/crates/typst-library/src/visualize/mod.rs new file mode 100644 index 00000000..ea873f44 --- /dev/null +++ b/crates/typst-library/src/visualize/mod.rs @@ -0,0 +1,45 @@ +//! Drawing and visualization. + +mod image; +mod line; +mod path; +mod polygon; +mod shape; + +pub use self::image::*; +pub use self::line::*; +pub use self::path::*; +pub use self::polygon::*; +pub use self::shape::*; + +use crate::prelude::*; + +/// Hook up all visualize definitions. +pub(super) fn define(global: &mut Scope) { + global.define("image", ImageElem::func()); + global.define("line", LineElem::func()); + global.define("rect", RectElem::func()); + global.define("square", SquareElem::func()); + global.define("ellipse", EllipseElem::func()); + global.define("circle", CircleElem::func()); + global.define("polygon", PolygonElem::func()); + global.define("path", PathElem::func()); + global.define("black", Color::BLACK); + global.define("gray", Color::GRAY); + global.define("silver", Color::SILVER); + global.define("white", Color::WHITE); + global.define("navy", Color::NAVY); + global.define("blue", Color::BLUE); + global.define("aqua", Color::AQUA); + global.define("teal", Color::TEAL); + global.define("eastern", Color::EASTERN); + global.define("purple", Color::PURPLE); + global.define("fuchsia", Color::FUCHSIA); + global.define("maroon", Color::MAROON); + global.define("red", Color::RED); + global.define("orange", Color::ORANGE); + global.define("yellow", Color::YELLOW); + global.define("olive", Color::OLIVE); + global.define("green", Color::GREEN); + global.define("lime", Color::LIME); +} diff --git a/crates/typst-library/src/visualize/path.rs b/crates/typst-library/src/visualize/path.rs new file mode 100644 index 00000000..d78abce1 --- /dev/null +++ b/crates/typst-library/src/visualize/path.rs @@ -0,0 +1,211 @@ +use kurbo::{CubicBez, ParamCurveExtrema}; +use typst::eval::Reflect; + +use crate::prelude::*; + +use PathVertex::{AllControlPoints, MirroredControlPoint, Vertex}; + +/// A path through a list of points, connected by Bezier curves. +/// +/// ## Example { #example } +/// ```example +/// #path( +/// fill: blue.lighten(80%), +/// stroke: blue, +/// closed: true, +/// (0pt, 50pt), +/// (100%, 50pt), +/// ((50%, 0pt), (40pt, 0pt)), +/// ) +/// ``` +/// +/// Display: Path +/// Category: visualize +#[element(Layout)] +pub struct PathElem { + /// How to fill the path. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + /// + /// Currently all paths are filled according to the + /// [non-zero winding rule](https://en.wikipedia.org/wiki/Nonzero-rule). + pub fill: Option<Paint>, + + /// How to stroke the path. This can be: + /// + /// See the [line's documentation]($func/line.stroke) for more details. Can + /// be set to `{none}` to disable the stroke or to `{auto}` for a stroke of + /// `{1pt}` black if and if only if no fill is given. + #[resolve] + #[fold] + pub stroke: Smart<Option<PartialStroke>>, + + /// Whether to close this path with one last bezier curve. This curve will + /// takes into account the adjacent control points. If you want to close + /// with a straight line, simply add one last point that's the same as the + /// start point. + #[default(false)] + pub closed: bool, + + /// The vertices of the path. + /// + /// Each vertex can be defined in 3 ways: + /// + /// - A regular point, as given to the [`line`]($func/line) or + /// [`polygon`]($func/polygon) function. + /// - An array of two points, the first being the vertex and the second + /// being the control point. The control point is expressed relative to + /// the vertex and is mirrored to get the second control point. The given + /// control point is the one that affects the curve coming _into_ this + /// vertex (even for the first point). The mirrored control point affects + /// the curve going out of this vertex. + /// - An array of three points, the first being the vertex and the next + /// being the control points (control point for curves coming in and out, + /// respectively) + #[variadic] + pub vertices: Vec<PathVertex>, +} + +impl Layout for PathElem { + #[tracing::instrument(name = "PathElem::layout", skip_all)] + fn layout( + &self, + _: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let resolve = |axes: Axes<Rel<Length>>| { + axes.resolve(styles) + .zip(regions.base()) + .map(|(l, b)| l.relative_to(b)) + .to_point() + }; + + let vertices: Vec<PathVertex> = self.vertices(); + let points: Vec<Point> = vertices.iter().map(|c| resolve(c.vertex())).collect(); + + let mut size = Size::zero(); + if points.is_empty() { + return Ok(Fragment::frame(Frame::new(size))); + } + + // Only create a path if there are more than zero points. + // Construct a closed path given all points. + let mut path = Path::new(); + path.move_to(points[0]); + + let mut add_cubic = + |from_point: Point, to_point: Point, from: PathVertex, to: PathVertex| { + let from_control_point = resolve(from.control_point_from()) + from_point; + let to_control_point = resolve(to.control_point_to()) + to_point; + path.cubic_to(from_control_point, to_control_point, to_point); + + let p0 = kurbo::Point::new(from_point.x.to_raw(), from_point.y.to_raw()); + let p1 = kurbo::Point::new( + from_control_point.x.to_raw(), + from_control_point.y.to_raw(), + ); + let p2 = kurbo::Point::new( + to_control_point.x.to_raw(), + to_control_point.y.to_raw(), + ); + let p3 = kurbo::Point::new(to_point.x.to_raw(), to_point.y.to_raw()); + let extrema = CubicBez::new(p0, p1, p2, p3).bounding_box(); + size.x.set_max(Abs::raw(extrema.x1)); + size.y.set_max(Abs::raw(extrema.y1)); + }; + + for (vertex_window, point_window) in vertices.windows(2).zip(points.windows(2)) { + let from = vertex_window[0]; + let to = vertex_window[1]; + let from_point = point_window[0]; + let to_point = point_window[1]; + + add_cubic(from_point, to_point, from, to); + } + + if self.closed(styles) { + let from = *vertices.last().unwrap(); // We checked that we have at least one element. + let to = vertices[0]; + let from_point = *points.last().unwrap(); + let to_point = points[0]; + + add_cubic(from_point, to_point, from, to); + path.close_path(); + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = match self.stroke(styles) { + Smart::Auto if fill.is_none() => Some(Stroke::default()), + Smart::Auto => None, + Smart::Custom(stroke) => stroke.map(PartialStroke::unwrap_or_default), + }; + + let mut frame = Frame::new(size); + let shape = Shape { geometry: Geometry::Path(path), stroke, fill }; + frame.push(Point::zero(), FrameItem::Shape(shape, self.span())); + + Ok(Fragment::frame(frame)) + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum PathVertex { + Vertex(Axes<Rel<Length>>), + MirroredControlPoint(Axes<Rel<Length>>, Axes<Rel<Length>>), + AllControlPoints(Axes<Rel<Length>>, Axes<Rel<Length>>, Axes<Rel<Length>>), +} + +impl PathVertex { + pub fn vertex(&self) -> Axes<Rel<Length>> { + match self { + Vertex(x) => *x, + MirroredControlPoint(x, _) => *x, + AllControlPoints(x, _, _) => *x, + } + } + + pub fn control_point_from(&self) -> Axes<Rel<Length>> { + match self { + Vertex(_) => Axes::new(Rel::zero(), Rel::zero()), + MirroredControlPoint(_, a) => a.map(|x| -x), + AllControlPoints(_, _, b) => *b, + } + } + + pub fn control_point_to(&self) -> Axes<Rel<Length>> { + match self { + Vertex(_) => Axes::new(Rel::zero(), Rel::zero()), + MirroredControlPoint(_, a) => *a, + AllControlPoints(_, a, _) => *a, + } + } +} + +cast! { + PathVertex, + self => match self { + Vertex(x) => x.into_value(), + MirroredControlPoint(x, c) => array![x, c].into_value(), + AllControlPoints(x, c1, c2) => array![x, c1, c2].into_value(), + }, + array: Array => { + let mut iter = array.into_iter(); + match (iter.next(), iter.next(), iter.next(), iter.next()) { + (Some(a), None, None, None) => { + Vertex(a.cast()?) + }, + (Some(a), Some(b), None, None) => { + if Axes::<Rel<Length>>::castable(&a) { + MirroredControlPoint(a.cast()?, b.cast()?) + } else { + Vertex(Axes::new(a.cast()?, b.cast()?)) + } + }, + (Some(a), Some(b), Some(c), None) => { + AllControlPoints(a.cast()?, b.cast()?, c.cast()?) + }, + _ => bail!("path vertex must have 1, 2, or 3 points"), + } + }, +} diff --git a/crates/typst-library/src/visualize/polygon.rs b/crates/typst-library/src/visualize/polygon.rs new file mode 100644 index 00000000..d2d3132a --- /dev/null +++ b/crates/typst-library/src/visualize/polygon.rs @@ -0,0 +1,93 @@ +use crate::prelude::*; + +/// A closed polygon. +/// +/// The polygon is defined by its corner points and is closed automatically. +/// +/// ## Example { #example } +/// ```example +/// #polygon( +/// fill: blue.lighten(80%), +/// stroke: blue, +/// (20%, 0pt), +/// (60%, 0pt), +/// (80%, 2cm), +/// (0%, 2cm), +/// ) +/// ``` +/// +/// Display: Polygon +/// Category: visualize +#[element(Layout)] +pub struct PolygonElem { + /// How to fill the polygon. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + /// + /// Currently all polygons are filled according to the + /// [non-zero winding rule](https://en.wikipedia.org/wiki/Nonzero-rule). + pub fill: Option<Paint>, + + /// How to stroke the polygon. This can be: + /// + /// See the [line's documentation]($func/line.stroke) for more details. Can + /// be set to `{none}` to disable the stroke or to `{auto}` for a stroke of + /// `{1pt}` black if and if only if no fill is given. + #[resolve] + #[fold] + pub stroke: Smart<Option<PartialStroke>>, + + /// The vertices of the polygon. Each point is specified as an array of two + /// [relative lengths]($type/relative-length). + #[variadic] + pub vertices: Vec<Axes<Rel<Length>>>, +} + +impl Layout for PolygonElem { + #[tracing::instrument(name = "PolygonElem::layout", skip_all)] + fn layout( + &self, + _: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let points: Vec<Point> = self + .vertices() + .iter() + .map(|c| { + c.resolve(styles) + .zip(regions.base()) + .map(|(l, b)| l.relative_to(b)) + .to_point() + }) + .collect(); + + let size = points.iter().fold(Point::zero(), |max, c| c.max(max)).to_size(); + let mut frame = Frame::new(size); + + // Only create a path if there are more than zero points. + if points.is_empty() { + return Ok(Fragment::frame(frame)); + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = match self.stroke(styles) { + Smart::Auto if fill.is_none() => Some(Stroke::default()), + Smart::Auto => None, + Smart::Custom(stroke) => stroke.map(PartialStroke::unwrap_or_default), + }; + + // Construct a closed path given all points. + let mut path = Path::new(); + path.move_to(points[0]); + for &point in &points[1..] { + path.line_to(point); + } + path.close_path(); + + let shape = Shape { geometry: Geometry::Path(path), stroke, fill }; + frame.push(Point::zero(), FrameItem::Shape(shape, self.span())); + + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs new file mode 100644 index 00000000..6129b70b --- /dev/null +++ b/crates/typst-library/src/visualize/shape.rs @@ -0,0 +1,569 @@ +use std::f64::consts::SQRT_2; + +use crate::prelude::*; + +/// A rectangle with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #rect(width: 35%, height: 30pt) +/// +/// // With content. +/// #rect[ +/// Automatically sized \ +/// to fit the content. +/// ] +/// ``` +/// +/// Display: Rectangle +/// Category: visualize +#[element(Layout)] +pub struct RectElem { + /// The rectangle's width, relative to its parent container. + pub width: Smart<Rel<Length>>, + + /// The rectangle's height, relative to its parent container. + pub height: Smart<Rel<Length>>, + + /// How to fill the rectangle. + /// + /// When setting a fill, the default stroke disappears. To create a + /// rectangle with both fill and stroke, you have to configure both. + /// + /// ```example + /// #rect(fill: blue) + /// ``` + pub fill: Option<Paint>, + + /// How to stroke the rectangle. This can be: + /// + /// - `{none}` to disable stroking + /// - `{auto}` for a stroke of `{1pt + black}` if and if only if no fill is + /// given. + /// - Any kind of stroke that can also be used for + /// [lines]($func/line.stroke). + /// - A dictionary describing the stroke for each side inidvidually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top`: The top stroke. + /// - `right`: The right stroke. + /// - `bottom`: The bottom stroke. + /// - `left`: The left stroke. + /// - `x`: The horizontal stroke. + /// - `y`: The vertical stroke. + /// - `rest`: The stroke on all sides except those for which the + /// dictionary explicitly sets a size. + /// + /// ```example + /// #stack( + /// dir: ltr, + /// spacing: 1fr, + /// rect(stroke: red), + /// rect(stroke: 2pt), + /// rect(stroke: 2pt + red), + /// ) + /// ``` + #[resolve] + #[fold] + pub stroke: Smart<Sides<Option<Option<PartialStroke>>>>, + + /// How much to round the rectangle's corners, relative to the minimum of + /// the width and height divided by two. This can be: + /// + /// - A relative length for a uniform corner radius. + /// - A dictionary: With a dictionary, the stroke for each side can be set + /// individually. The dictionary can contain the following keys in order + /// of precedence: + /// - `top-left`: The top-left corner radius. + /// - `top-right`: The top-right corner radius. + /// - `bottom-right`: The bottom-right corner radius. + /// - `bottom-left`: The bottom-left corner radius. + /// - `left`: The top-left and bottom-left corner radii. + /// - `top`: The top-left and top-right corner radii. + /// - `right`: The top-right and bottom-right corner radii. + /// - `bottom`: The bottom-left and bottom-right corner radii. + /// - `rest`: The radii for all corners except those for which the + /// dictionary explicitly sets a size. + /// + /// ```example + /// #set rect(stroke: 4pt) + /// #rect( + /// radius: ( + /// left: 5pt, + /// top-right: 20pt, + /// bottom-right: 10pt, + /// ), + /// stroke: ( + /// left: red, + /// top: yellow, + /// right: green, + /// bottom: blue, + /// ), + /// ) + /// ``` + #[resolve] + #[fold] + pub radius: Corners<Option<Rel<Length>>>, + + /// How much to pad the rectangle's content. + /// + /// _Note:_ When the rectangle contains text, its exact size depends on the + /// current [text edges]($func/text.top-edge). + /// + /// ```example + /// #rect(inset: 0pt)[Tight] + /// ``` + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the rectangle's size without affecting the layout. + /// See the [box's documentation]($func/box.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the rectangle. + /// + /// When this is omitted, the rectangle takes on a default size of at most + /// `{45pt}` by `{30pt}`. + #[positional] + pub body: Option<Content>, +} + +impl Layout for RectElem { + #[tracing::instrument(name = "RectElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Rect, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles), + self.inset(styles), + self.outset(styles), + self.radius(styles), + self.span(), + ) + } +} + +/// A square with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #square(size: 40pt) +/// +/// // With content. +/// #square[ +/// Automatically \ +/// sized to fit. +/// ] +/// ``` +/// +/// Display: Square +/// Category: visualize +#[element(Layout)] +pub struct SquareElem { + /// The square's side length. This is mutually exclusive with `width` and + /// `height`. + #[external] + pub size: Smart<Length>, + + /// The square's width. This is mutually exclusive with `size` and `height`. + /// + /// In contrast to `size`, this can be relative to the parent container's + /// width. + #[parse( + let size = args.named::<Smart<Length>>("size")?.map(|s| s.map(Rel::from)); + match size { + None => args.named("width")?, + size => size, + } + )] + pub width: Smart<Rel<Length>>, + + /// The square's height. This is mutually exclusive with `size` and `width`. + /// + /// In contrast to `size`, this can be relative to the parent container's + /// height. + #[parse(match size { + None => args.named("height")?, + size => size, + })] + pub height: Smart<Rel<Length>>, + + /// How to fill the square. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// How to stroke the square. See the [rectangle's + /// documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Smart<Sides<Option<Option<PartialStroke>>>>, + + /// How much to round the square's corners. See the [rectangle's + /// documentation]($func/rect.radius) for more details. + #[resolve] + #[fold] + pub radius: Corners<Option<Rel<Length>>>, + + /// How much to pad the square's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the square's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the square. The square expands to fit this + /// content, keeping the 1-1 aspect ratio. + /// + /// When this is omitted, the square takes on a default size of at most + /// `{30pt}`. + #[positional] + pub body: Option<Content>, +} + +impl Layout for SquareElem { + #[tracing::instrument(name = "SquareElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Square, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles), + self.inset(styles), + self.outset(styles), + self.radius(styles), + self.span(), + ) + } +} + +/// An ellipse with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #ellipse(width: 35%, height: 30pt) +/// +/// // With content. +/// #ellipse[ +/// #set align(center) +/// Automatically sized \ +/// to fit the content. +/// ] +/// ``` +/// +/// Display: Ellipse +/// Category: visualize +#[element(Layout)] +pub struct EllipseElem { + /// The ellipse's width, relative to its parent container. + pub width: Smart<Rel<Length>>, + + /// The ellipse's height, relative to its parent container. + pub height: Smart<Rel<Length>>, + + /// How to fill the ellipse. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// How to stroke the ellipse. See the [rectangle's + /// documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Smart<Option<PartialStroke>>, + + /// How much to pad the ellipse's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the ellipse's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the ellipse. + /// + /// When this is omitted, the ellipse takes on a default size of at most + /// `{45pt}` by `{30pt}`. + #[positional] + pub body: Option<Content>, +} + +impl Layout for EllipseElem { + #[tracing::instrument(name = "EllipseElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Ellipse, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles).map(Sides::splat), + self.inset(styles), + self.outset(styles), + Corners::splat(Rel::zero()), + self.span(), + ) + } +} + +/// A circle with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #circle(radius: 25pt) +/// +/// // With content. +/// #circle[ +/// #set align(center + horizon) +/// Automatically \ +/// sized to fit. +/// ] +/// ``` +/// +/// Display: Circle +/// Category: visualize +#[element(Layout)] +pub struct CircleElem { + /// The circle's radius. This is mutually exclusive with `width` and + /// `height`. + #[external] + pub radius: Length, + + /// The circle's width. This is mutually exclusive with `radius` and + /// `height`. + /// + /// In contrast to `radius`, this can be relative to the parent container's + /// width. + #[parse( + let size = args + .named::<Smart<Length>>("radius")? + .map(|s| s.map(|r| 2.0 * Rel::from(r))); + match size { + None => args.named("width")?, + size => size, + } + )] + pub width: Smart<Rel<Length>>, + + /// The circle's height.This is mutually exclusive with `radius` and + /// `width`. + /// + /// In contrast to `radius`, this can be relative to the parent container's + /// height. + #[parse(match size { + None => args.named("height")?, + size => size, + })] + pub height: Smart<Rel<Length>>, + + /// How to fill the circle. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// How to stroke the circle. See the [rectangle's + /// documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + #[default(Smart::Auto)] + pub stroke: Smart<Option<PartialStroke>>, + + /// How much to pad the circle's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the circle's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the circle. The circle expands to fit this + /// content, keeping the 1-1 aspect ratio. + #[positional] + pub body: Option<Content>, +} + +impl Layout for CircleElem { + #[tracing::instrument(name = "CircleElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Circle, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles).map(Sides::splat), + self.inset(styles), + self.outset(styles), + Corners::splat(Rel::zero()), + self.span(), + ) + } +} + +/// Layout a shape. +#[tracing::instrument(name = "shape::layout", skip_all)] +#[allow(clippy::too_many_arguments)] +fn layout( + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + kind: ShapeKind, + body: &Option<Content>, + sizing: Axes<Smart<Rel<Length>>>, + fill: Option<Paint>, + stroke: Smart<Sides<Option<PartialStroke<Abs>>>>, + mut inset: Sides<Rel<Abs>>, + outset: Sides<Rel<Abs>>, + radius: Corners<Rel<Abs>>, + span: Span, +) -> SourceResult<Fragment> { + let resolved = sizing + .zip(regions.base()) + .map(|(s, r)| s.map(|v| v.resolve(styles).relative_to(r))); + + let mut frame; + if let Some(child) = body { + let region = resolved.unwrap_or(regions.base()); + if kind.is_round() { + inset = inset.map(|side| side + Ratio::new(0.5 - SQRT_2 / 4.0)); + } + + // Pad the child. + let child = child.clone().padded(inset.map(|side| side.map(Length::from))); + let expand = sizing.as_ref().map(Smart::is_custom); + let pod = Regions::one(region, expand); + frame = child.layout(vt, styles, pod)?.into_frame(); + + // Enforce correct size. + *frame.size_mut() = expand.select(region, frame.size()); + + // Relayout with full expansion into square region to make sure + // the result is really a square or circle. + if kind.is_quadratic() { + frame.set_size(Size::splat(frame.size().max_by_side())); + let length = frame.size().max_by_side().min(region.min_by_side()); + let pod = Regions::one(Size::splat(length), Axes::splat(true)); + frame = child.layout(vt, styles, pod)?.into_frame(); + } + + // Enforce correct size again. + *frame.size_mut() = expand.select(region, frame.size()); + if kind.is_quadratic() { + frame.set_size(Size::splat(frame.size().max_by_side())); + } + } else { + // The default size that a shape takes on if it has no child and + // enough space. + let default = Size::new(Abs::pt(45.0), Abs::pt(30.0)); + let mut size = resolved.unwrap_or(default.min(regions.base())); + if kind.is_quadratic() { + size = Size::splat(size.min_by_side()); + } + frame = Frame::new(size); + } + + // Prepare stroke. + let stroke = match stroke { + Smart::Auto if fill.is_none() => Sides::splat(Some(Stroke::default())), + Smart::Auto => Sides::splat(None), + Smart::Custom(strokes) => { + strokes.map(|s| s.map(PartialStroke::unwrap_or_default)) + } + }; + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + if kind.is_round() { + let outset = outset.relative_to(frame.size()); + let size = frame.size() + outset.sum_by_axis(); + let pos = Point::new(-outset.left, -outset.top); + let shape = ellipse(size, fill, stroke.left); + frame.prepend(pos, FrameItem::Shape(shape, span)); + } else { + frame.fill_and_stroke(fill, stroke, outset, radius, span); + } + } + + // Apply metadata. + frame.meta(styles, false); + + Ok(Fragment::frame(frame)) +} + +/// A category of shape. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum ShapeKind { + /// A rectangle with equal side lengths. + Square, + /// A quadrilateral with four right angles. + Rect, + /// An ellipse with coinciding foci. + Circle, + /// A curve around two focal points. + Ellipse, +} + +impl ShapeKind { + /// Whether this shape kind is curvy. + fn is_round(self) -> bool { + matches!(self, Self::Circle | Self::Ellipse) + } + + /// Whether this shape kind has equal side length. + fn is_quadratic(self) -> bool { + matches!(self, Self::Square | Self::Circle) + } +} |
