summaryrefslogtreecommitdiff
path: root/crates/typst-library/src
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2023-07-02 19:59:52 +0200
committerLaurenz <laurmaedje@gmail.com>2023-07-02 20:07:43 +0200
commitebfdb1dafa430786db10dad2ef7d5467c1bdbed1 (patch)
tree2bbc24ddb4124c4bb14dec0e536129d4de37b056 /crates/typst-library/src
parent3ab19185093d7709f824b95b979060ce125389d8 (diff)
Move everything into `crates/` directory
Diffstat (limited to 'crates/typst-library/src')
-rw-r--r--crates/typst-library/src/compute/calc.rs1024
-rw-r--r--crates/typst-library/src/compute/construct.rs743
-rw-r--r--crates/typst-library/src/compute/data.rs492
-rw-r--r--crates/typst-library/src/compute/foundations.rs215
-rw-r--r--crates/typst-library/src/compute/mod.rs39
-rw-r--r--crates/typst-library/src/layout/align.rs67
-rw-r--r--crates/typst-library/src/layout/columns.rs172
-rw-r--r--crates/typst-library/src/layout/container.rs497
-rw-r--r--crates/typst-library/src/layout/enum.rs318
-rw-r--r--crates/typst-library/src/layout/flow.rs583
-rw-r--r--crates/typst-library/src/layout/fragment.rs87
-rw-r--r--crates/typst-library/src/layout/grid.rs708
-rw-r--r--crates/typst-library/src/layout/hide.rs30
-rw-r--r--crates/typst-library/src/layout/list.rs239
-rw-r--r--crates/typst-library/src/layout/measure.rs56
-rw-r--r--crates/typst-library/src/layout/mod.rs709
-rw-r--r--crates/typst-library/src/layout/pad.rs125
-rw-r--r--crates/typst-library/src/layout/page.rs898
-rw-r--r--crates/typst-library/src/layout/par.rs1566
-rw-r--r--crates/typst-library/src/layout/place.rs103
-rw-r--r--crates/typst-library/src/layout/regions.rs144
-rw-r--r--crates/typst-library/src/layout/repeat.rs79
-rw-r--r--crates/typst-library/src/layout/spacing.rs240
-rw-r--r--crates/typst-library/src/layout/stack.rs339
-rw-r--r--crates/typst-library/src/layout/table.rs334
-rw-r--r--crates/typst-library/src/layout/terms.rs166
-rw-r--r--crates/typst-library/src/layout/transform.rs194
-rw-r--r--crates/typst-library/src/lib.rs141
-rw-r--r--crates/typst-library/src/math/accent.rs139
-rw-r--r--crates/typst-library/src/math/align.rs63
-rw-r--r--crates/typst-library/src/math/attach.rs411
-rw-r--r--crates/typst-library/src/math/cancel.rs187
-rw-r--r--crates/typst-library/src/math/ctx.rs268
-rw-r--r--crates/typst-library/src/math/delimited.rs200
-rw-r--r--crates/typst-library/src/math/frac.rs148
-rw-r--r--crates/typst-library/src/math/fragment.rs414
-rw-r--r--crates/typst-library/src/math/matrix.rs313
-rw-r--r--crates/typst-library/src/math/mod.rs480
-rw-r--r--crates/typst-library/src/math/op.rs113
-rw-r--r--crates/typst-library/src/math/root.rs156
-rw-r--r--crates/typst-library/src/math/row.rs258
-rw-r--r--crates/typst-library/src/math/spacing.rs60
-rw-r--r--crates/typst-library/src/math/stretch.rs199
-rw-r--r--crates/typst-library/src/math/style.rs620
-rw-r--r--crates/typst-library/src/math/underover.rs339
-rw-r--r--crates/typst-library/src/meta/bibliography.rs724
-rw-r--r--crates/typst-library/src/meta/context.rs220
-rw-r--r--crates/typst-library/src/meta/counter.rs683
-rw-r--r--crates/typst-library/src/meta/document.rs86
-rw-r--r--crates/typst-library/src/meta/figure.rs351
-rw-r--r--crates/typst-library/src/meta/footnote.rs299
-rw-r--r--crates/typst-library/src/meta/heading.rs239
-rw-r--r--crates/typst-library/src/meta/link.rs137
-rw-r--r--crates/typst-library/src/meta/mod.rs64
-rw-r--r--crates/typst-library/src/meta/numbering.rs525
-rw-r--r--crates/typst-library/src/meta/outline.rs528
-rw-r--r--crates/typst-library/src/meta/query.rs145
-rw-r--r--crates/typst-library/src/meta/reference.rs276
-rw-r--r--crates/typst-library/src/meta/state.rs440
-rw-r--r--crates/typst-library/src/prelude.rs42
-rw-r--r--crates/typst-library/src/shared/behave.rs109
-rw-r--r--crates/typst-library/src/shared/ext.rs92
-rw-r--r--crates/typst-library/src/shared/mod.rs7
-rw-r--r--crates/typst-library/src/symbols/emoji.rs1356
-rw-r--r--crates/typst-library/src/symbols/mod.rs15
-rw-r--r--crates/typst-library/src/symbols/sym.rs842
-rw-r--r--crates/typst-library/src/text/deco.rs420
-rw-r--r--crates/typst-library/src/text/misc.rs330
-rw-r--r--crates/typst-library/src/text/mod.rs769
-rw-r--r--crates/typst-library/src/text/quotes.rs209
-rw-r--r--crates/typst-library/src/text/raw.rs398
-rw-r--r--crates/typst-library/src/text/shaping.rs973
-rw-r--r--crates/typst-library/src/text/shift.rs229
-rw-r--r--crates/typst-library/src/visualize/image.rs197
-rw-r--r--crates/typst-library/src/visualize/line.rs118
-rw-r--r--crates/typst-library/src/visualize/mod.rs45
-rw-r--r--crates/typst-library/src/visualize/path.rs211
-rw-r--r--crates/typst-library/src/visualize/polygon.rs93
-rw-r--r--crates/typst-library/src/visualize/shape.rs569
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(&regex.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(&regions.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(&quotes, 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>&DiacriticalGrave;</code> |
+ /// | Acute | `acute` | `´` |
+ /// | Circumflex | `hat` | `^` |
+ /// | Tilde | `tilde` | `~` |
+ /// | Macron | `macron` | `¯` |
+ /// | Breve | `breve` | `˘` |
+ /// | Dot | `dot` | `.` |
+ /// | Double dot | `dot.double` | `¨` |
+ /// | Triple dot | `dot.triple` | <code>&tdot;</code> |
+ /// | Quadruple dot | `dot.quad` | <code>&DotDot;</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)
+ }
+}