summaryrefslogtreecommitdiff
path: root/crates/typst-library/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src')
-rw-r--r--crates/typst-library/src/diag.rs580
-rw-r--r--crates/typst-library/src/engine.rs387
-rw-r--r--crates/typst-library/src/foundations/args.rs453
-rw-r--r--crates/typst-library/src/foundations/array.rs1133
-rw-r--r--crates/typst-library/src/foundations/auto.rs267
-rw-r--r--crates/typst-library/src/foundations/bool.rs26
-rw-r--r--crates/typst-library/src/foundations/bytes.rs262
-rw-r--r--crates/typst-library/src/foundations/calc.rs1211
-rw-r--r--crates/typst-library/src/foundations/cast.rs499
-rw-r--r--crates/typst-library/src/foundations/content.rs1007
-rw-r--r--crates/typst-library/src/foundations/context.rs85
-rw-r--r--crates/typst-library/src/foundations/datetime.rs580
-rw-r--r--crates/typst-library/src/foundations/decimal.rs499
-rw-r--r--crates/typst-library/src/foundations/dict.rs395
-rw-r--r--crates/typst-library/src/foundations/duration.rs215
-rw-r--r--crates/typst-library/src/foundations/element.rs333
-rw-r--r--crates/typst-library/src/foundations/fields.rs91
-rw-r--r--crates/typst-library/src/foundations/float.rs190
-rw-r--r--crates/typst-library/src/foundations/func.rs541
-rw-r--r--crates/typst-library/src/foundations/int.rs478
-rw-r--r--crates/typst-library/src/foundations/label.rs93
-rw-r--r--crates/typst-library/src/foundations/mod.rs302
-rw-r--r--crates/typst-library/src/foundations/module.rs142
-rw-r--r--crates/typst-library/src/foundations/none.rs114
-rw-r--r--crates/typst-library/src/foundations/ops.rs583
-rw-r--r--crates/typst-library/src/foundations/plugin.rs365
-rw-r--r--crates/typst-library/src/foundations/repr.rs235
-rw-r--r--crates/typst-library/src/foundations/scope.rs416
-rw-r--r--crates/typst-library/src/foundations/selector.rs449
-rw-r--r--crates/typst-library/src/foundations/str.rs969
-rw-r--r--crates/typst-library/src/foundations/styles.rs1034
-rw-r--r--crates/typst-library/src/foundations/symbol.rs356
-rw-r--r--crates/typst-library/src/foundations/sys.rs18
-rw-r--r--crates/typst-library/src/foundations/ty.rs223
-rw-r--r--crates/typst-library/src/foundations/value.rs761
-rw-r--r--crates/typst-library/src/foundations/version.rs202
-rw-r--r--crates/typst-library/src/introspection/counter.rs856
-rw-r--r--crates/typst-library/src/introspection/here.rs52
-rw-r--r--crates/typst-library/src/introspection/introspector.rs453
-rw-r--r--crates/typst-library/src/introspection/locate.rs119
-rw-r--r--crates/typst-library/src/introspection/location.rs113
-rw-r--r--crates/typst-library/src/introspection/locator.rs350
-rw-r--r--crates/typst-library/src/introspection/metadata.rs38
-rw-r--r--crates/typst-library/src/introspection/mod.rs53
-rw-r--r--crates/typst-library/src/introspection/query.rs171
-rw-r--r--crates/typst-library/src/introspection/state.rs476
-rw-r--r--crates/typst-library/src/introspection/tag.rs73
-rw-r--r--crates/typst-library/src/layout/abs.rs279
-rw-r--r--crates/typst-library/src/layout/align.rs748
-rw-r--r--crates/typst-library/src/layout/angle.rs244
-rw-r--r--crates/typst-library/src/layout/axes.rs320
-rw-r--r--crates/typst-library/src/layout/columns.rs98
-rw-r--r--crates/typst-library/src/layout/container.rs563
-rw-r--r--crates/typst-library/src/layout/corners.rs315
-rw-r--r--crates/typst-library/src/layout/dir.rs132
-rw-r--r--crates/typst-library/src/layout/em.rs168
-rw-r--r--crates/typst-library/src/layout/fr.rs145
-rw-r--r--crates/typst-library/src/layout/fragment.rs94
-rw-r--r--crates/typst-library/src/layout/frame.rs606
-rw-r--r--crates/typst-library/src/layout/grid.rs924
-rw-r--r--crates/typst-library/src/layout/hide.rs34
-rw-r--r--crates/typst-library/src/layout/layout.rs107
-rw-r--r--crates/typst-library/src/layout/length.rs276
-rw-r--r--crates/typst-library/src/layout/measure.rs115
-rw-r--r--crates/typst-library/src/layout/mod.rs106
-rw-r--r--crates/typst-library/src/layout/pad.rs65
-rw-r--r--crates/typst-library/src/layout/page.rs924
-rw-r--r--crates/typst-library/src/layout/place.rs208
-rw-r--r--crates/typst-library/src/layout/point.rs168
-rw-r--r--crates/typst-library/src/layout/ratio.rs153
-rw-r--r--crates/typst-library/src/layout/regions.rs159
-rw-r--r--crates/typst-library/src/layout/rel.rs276
-rw-r--r--crates/typst-library/src/layout/repeat.rs49
-rw-r--r--crates/typst-library/src/layout/sides.rs343
-rw-r--r--crates/typst-library/src/layout/size.rs87
-rw-r--r--crates/typst-library/src/layout/spacing.rs193
-rw-r--r--crates/typst-library/src/layout/stack.rs84
-rw-r--r--crates/typst-library/src/layout/transform.rs408
-rw-r--r--crates/typst-library/src/lib.rs264
-rw-r--r--crates/typst-library/src/loading/cbor.rs62
-rw-r--r--crates/typst-library/src/loading/csv.rs191
-rw-r--r--crates/typst-library/src/loading/json.rs101
-rw-r--r--crates/typst-library/src/loading/mod.rs89
-rw-r--r--crates/typst-library/src/loading/read.rs59
-rw-r--r--crates/typst-library/src/loading/toml.rs92
-rw-r--r--crates/typst-library/src/loading/xml.rs123
-rw-r--r--crates/typst-library/src/loading/yaml.rs83
-rw-r--r--crates/typst-library/src/math/accent.rs147
-rw-r--r--crates/typst-library/src/math/attach.rs156
-rw-r--r--crates/typst-library/src/math/cancel.rs116
-rw-r--r--crates/typst-library/src/math/equation.rs256
-rw-r--r--crates/typst-library/src/math/frac.rs56
-rw-r--r--crates/typst-library/src/math/lr.rs135
-rw-r--r--crates/typst-library/src/math/matrix.rs448
-rw-r--r--crates/typst-library/src/math/mod.rs257
-rw-r--r--crates/typst-library/src/math/op.rs103
-rw-r--r--crates/typst-library/src/math/root.rs35
-rw-r--r--crates/typst-library/src/math/style.rs254
-rw-r--r--crates/typst-library/src/math/underover.rs156
-rw-r--r--crates/typst-library/src/model/bibliography.rs1101
-rw-r--r--crates/typst-library/src/model/cite.rs175
-rw-r--r--crates/typst-library/src/model/document.rs145
-rw-r--r--crates/typst-library/src/model/emph.rs41
-rw-r--r--crates/typst-library/src/model/enum.rs271
-rw-r--r--crates/typst-library/src/model/figure.rs641
-rw-r--r--crates/typst-library/src/model/footnote.rs326
-rw-r--r--crates/typst-library/src/model/heading.rs351
-rw-r--r--crates/typst-library/src/model/link.rs212
-rw-r--r--crates/typst-library/src/model/list.rs246
-rw-r--r--crates/typst-library/src/model/mod.rs75
-rw-r--r--crates/typst-library/src/model/numbering.rs658
-rw-r--r--crates/typst-library/src/model/outline.rs547
-rw-r--r--crates/typst-library/src/model/par.rs431
-rw-r--r--crates/typst-library/src/model/quote.rs237
-rw-r--r--crates/typst-library/src/model/reference.rs307
-rw-r--r--crates/typst-library/src/model/strong.rs48
-rw-r--r--crates/typst-library/src/model/table.rs724
-rw-r--r--crates/typst-library/src/model/terms.rs204
-rw-r--r--crates/typst-library/src/routines.rs368
-rw-r--r--crates/typst-library/src/symbols/emoji.rs1363
-rw-r--r--crates/typst-library/src/symbols/mod.rs24
-rw-r--r--crates/typst-library/src/symbols/sym.rs995
-rw-r--r--crates/typst-library/src/text/case.rs79
-rw-r--r--crates/typst-library/src/text/deco.rs401
-rw-r--r--crates/typst-library/src/text/font/book.rs546
-rw-r--r--crates/typst-library/src/text/font/color.rs627
-rw-r--r--crates/typst-library/src/text/font/exceptions.rs337
-rw-r--r--crates/typst-library/src/text/font/mod.rs306
-rw-r--r--crates/typst-library/src/text/font/variant.rs320
-rw-r--r--crates/typst-library/src/text/item.rs118
-rw-r--r--crates/typst-library/src/text/lang.rs317
-rw-r--r--crates/typst-library/src/text/linebreak.rs46
-rw-r--r--crates/typst-library/src/text/lorem.rs24
-rw-r--r--crates/typst-library/src/text/mod.rs1318
-rw-r--r--crates/typst-library/src/text/raw.rs926
-rw-r--r--crates/typst-library/src/text/shift.rs210
-rw-r--r--crates/typst-library/src/text/smallcaps.rs58
-rw-r--r--crates/typst-library/src/text/smartquote.rs387
-rw-r--r--crates/typst-library/src/text/space.rs31
-rw-r--r--crates/typst-library/src/visualize/color.rs2013
-rw-r--r--crates/typst-library/src/visualize/gradient.rs1260
-rw-r--r--crates/typst-library/src/visualize/image/mod.rs360
-rw-r--r--crates/typst-library/src/visualize/image/raster.rs286
-rw-r--r--crates/typst-library/src/visualize/image/svg.rs289
-rw-r--r--crates/typst-library/src/visualize/line.rs64
-rw-r--r--crates/typst-library/src/visualize/mod.rs50
-rw-r--r--crates/typst-library/src/visualize/paint.rs102
-rw-r--r--crates/typst-library/src/visualize/path.rs276
-rw-r--r--crates/typst-library/src/visualize/pattern.rs285
-rw-r--r--crates/typst-library/src/visualize/polygon.rs135
-rw-r--r--crates/typst-library/src/visualize/shape.rs448
-rw-r--r--crates/typst-library/src/visualize/stroke.rs617
152 files changed, 51548 insertions, 0 deletions
diff --git a/crates/typst-library/src/diag.rs b/crates/typst-library/src/diag.rs
new file mode 100644
index 00000000..bd4c90a1
--- /dev/null
+++ b/crates/typst-library/src/diag.rs
@@ -0,0 +1,580 @@
+//! Diagnostics.
+
+use std::fmt::{self, Display, Formatter};
+use std::io;
+use std::path::{Path, PathBuf};
+use std::str::Utf8Error;
+use std::string::FromUtf8Error;
+
+use comemo::Tracked;
+use ecow::{eco_vec, EcoVec};
+use typst_syntax::package::{PackageSpec, PackageVersion};
+use typst_syntax::{Span, Spanned, SyntaxError};
+
+use crate::{World, WorldExt};
+
+/// Early-return with a [`StrResult`] or [`SourceResult`].
+///
+/// If called with just a string and format args, returns with a
+/// `StrResult`. If called with a span, a string and format args, returns
+/// a `SourceResult`.
+///
+/// You can also emit hints with the `; hint: "..."` syntax.
+///
+/// ```ignore
+/// bail!("bailing with a {}", "string result");
+/// bail!(span, "bailing with a {}", "source result");
+/// bail!(
+/// span, "bailing with a {}", "source result";
+/// hint: "hint 1"
+/// );
+/// bail!(
+/// span, "bailing with a {}", "source result";
+/// hint: "hint 1";
+/// hint: "hint 2";
+/// );
+/// ```
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __bail {
+ // For bail!("just a {}", "string")
+ (
+ $fmt:literal $(, $arg:expr)*
+ $(; hint: $hint:literal $(, $hint_arg:expr)*)*
+ $(,)?
+ ) => {
+ return Err($crate::diag::error!(
+ $fmt $(, $arg)*
+ $(; hint: $hint $(, $hint_arg)*)*
+ ))
+ };
+
+ // For bail!(error!(..))
+ ($error:expr) => {
+ return Err(::ecow::eco_vec![$error])
+ };
+
+ // For bail(span, ...)
+ ($($tts:tt)*) => {
+ return Err(::ecow::eco_vec![$crate::diag::error!($($tts)*)])
+ };
+}
+
+/// Construct an [`EcoString`], [`HintedString`] or [`SourceDiagnostic`] with
+/// severity `Error`.
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __error {
+ // For bail!("just a {}", "string").
+ ($fmt:literal $(, $arg:expr)* $(,)?) => {
+ $crate::diag::eco_format!($fmt, $($arg),*).into()
+ };
+
+ // For bail!("a hinted {}", "string"; hint: "some hint"; hint: "...")
+ (
+ $fmt:literal $(, $arg:expr)*
+ $(; hint: $hint:literal $(, $hint_arg:expr)*)*
+ $(,)?
+ ) => {
+ $crate::diag::HintedString::new(
+ $crate::diag::eco_format!($fmt, $($arg),*)
+ ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))*
+ };
+
+ // For bail!(span, ...)
+ (
+ $span:expr, $fmt:literal $(, $arg:expr)*
+ $(; hint: $hint:literal $(, $hint_arg:expr)*)*
+ $(,)?
+ ) => {
+ $crate::diag::SourceDiagnostic::error(
+ $span,
+ $crate::diag::eco_format!($fmt, $($arg),*),
+ ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))*
+ };
+}
+
+/// Construct a [`SourceDiagnostic`] with severity `Warning`.
+///
+/// You can also emit hints with the `; hint: "..."` syntax.
+///
+/// ```ignore
+/// warning!(span, "warning with a {}", "source result");
+/// warning!(
+/// span, "warning with a {}", "source result";
+/// hint: "hint 1"
+/// );
+/// warning!(
+/// span, "warning with a {}", "source result";
+/// hint: "hint 1";
+/// hint: "hint 2";
+/// );
+/// ```
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __warning {
+ (
+ $span:expr,
+ $fmt:literal $(, $arg:expr)*
+ $(; hint: $hint:literal $(, $hint_arg:expr)*)*
+ $(,)?
+ ) => {
+ $crate::diag::SourceDiagnostic::warning(
+ $span,
+ $crate::diag::eco_format!($fmt, $($arg),*),
+ ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))*
+ };
+}
+
+#[rustfmt::skip]
+#[doc(inline)]
+pub use {
+ crate::__bail as bail,
+ crate::__error as error,
+ crate::__warning as warning,
+ ecow::{eco_format, EcoString},
+};
+
+/// A result that can carry multiple source errors.
+pub type SourceResult<T> = Result<T, EcoVec<SourceDiagnostic>>;
+
+/// An output alongside warnings generated while producing it.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Warned<T> {
+ /// The produced output.
+ pub output: T,
+ /// Warnings generated while producing the output.
+ pub warnings: EcoVec<SourceDiagnostic>,
+}
+
+/// An error or warning in a source file.
+///
+/// The contained spans will only be detached if any of the input source files
+/// were detached.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct SourceDiagnostic {
+ /// Whether the diagnostic is an error or a warning.
+ pub severity: Severity,
+ /// The span of the relevant node in the source code.
+ pub span: Span,
+ /// A diagnostic message describing the problem.
+ pub message: EcoString,
+ /// The trace of function calls leading to the problem.
+ pub trace: EcoVec<Spanned<Tracepoint>>,
+ /// Additional hints to the user, indicating how this problem could be avoided
+ /// or worked around.
+ pub hints: EcoVec<EcoString>,
+}
+
+/// The severity of a [`SourceDiagnostic`].
+#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
+pub enum Severity {
+ /// A fatal error.
+ Error,
+ /// A non-fatal warning.
+ Warning,
+}
+
+impl SourceDiagnostic {
+ /// Create a new, bare error.
+ pub fn error(span: Span, message: impl Into<EcoString>) -> Self {
+ Self {
+ severity: Severity::Error,
+ span,
+ trace: eco_vec![],
+ message: message.into(),
+ hints: eco_vec![],
+ }
+ }
+
+ /// Create a new, bare warning.
+ pub fn warning(span: Span, message: impl Into<EcoString>) -> Self {
+ Self {
+ severity: Severity::Warning,
+ span,
+ trace: eco_vec![],
+ message: message.into(),
+ hints: eco_vec![],
+ }
+ }
+
+ /// Adds a single hint to the diagnostic.
+ pub fn hint(&mut self, hint: impl Into<EcoString>) {
+ self.hints.push(hint.into());
+ }
+
+ /// Adds a single hint to the diagnostic.
+ pub fn with_hint(mut self, hint: impl Into<EcoString>) -> Self {
+ self.hint(hint);
+ self
+ }
+
+ /// Adds user-facing hints to the diagnostic.
+ pub fn with_hints(mut self, hints: impl IntoIterator<Item = EcoString>) -> Self {
+ self.hints.extend(hints);
+ self
+ }
+}
+
+impl From<SyntaxError> for SourceDiagnostic {
+ fn from(error: SyntaxError) -> Self {
+ Self {
+ severity: Severity::Error,
+ span: error.span,
+ message: error.message,
+ trace: eco_vec![],
+ hints: error.hints,
+ }
+ }
+}
+
+/// A part of a diagnostic's [trace](SourceDiagnostic::trace).
+#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum Tracepoint {
+ /// A function call.
+ Call(Option<EcoString>),
+ /// A show rule application.
+ Show(EcoString),
+ /// A module import.
+ Import,
+}
+
+impl Display for Tracepoint {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Tracepoint::Call(Some(name)) => {
+ write!(f, "error occurred in this call of function `{name}`")
+ }
+ Tracepoint::Call(None) => {
+ write!(f, "error occurred in this function call")
+ }
+ Tracepoint::Show(name) => {
+ write!(f, "error occurred while applying show rule to this {name}")
+ }
+ Tracepoint::Import => {
+ write!(f, "error occurred while importing this module")
+ }
+ }
+ }
+}
+
+/// Enrich a [`SourceResult`] with a tracepoint.
+pub trait Trace<T> {
+ /// Add the tracepoint to all errors that lie outside the `span`.
+ fn trace<F>(self, world: Tracked<dyn World + '_>, make_point: F, span: Span) -> Self
+ where
+ F: Fn() -> Tracepoint;
+}
+
+impl<T> Trace<T> for SourceResult<T> {
+ fn trace<F>(self, world: Tracked<dyn World + '_>, make_point: F, span: Span) -> Self
+ where
+ F: Fn() -> Tracepoint,
+ {
+ self.map_err(|mut errors| {
+ let Some(trace_range) = world.range(span) else { return errors };
+ for error in errors.make_mut().iter_mut() {
+ // Skip traces that surround the error.
+ if let Some(error_range) = world.range(error.span) {
+ if error.span.id() == span.id()
+ && trace_range.start <= error_range.start
+ && trace_range.end >= error_range.end
+ {
+ continue;
+ }
+ }
+
+ error.trace.push(Spanned::new(make_point(), span));
+ }
+ errors
+ })
+ }
+}
+
+/// A result type with a string error message.
+pub type StrResult<T> = Result<T, EcoString>;
+
+/// Convert a [`StrResult`] or [`HintedStrResult`] to a [`SourceResult`] by
+/// adding span information.
+pub trait At<T> {
+ /// Add the span information.
+ fn at(self, span: Span) -> SourceResult<T>;
+}
+
+impl<T, S> At<T> for Result<T, S>
+where
+ S: Into<EcoString>,
+{
+ fn at(self, span: Span) -> SourceResult<T> {
+ self.map_err(|message| {
+ let mut diagnostic = SourceDiagnostic::error(span, message);
+ if diagnostic.message.contains("(access denied)") {
+ diagnostic.hint("cannot read file outside of project root");
+ diagnostic
+ .hint("you can adjust the project root with the --root argument");
+ }
+ eco_vec![diagnostic]
+ })
+ }
+}
+
+/// A result type with a string error message and hints.
+pub type HintedStrResult<T> = Result<T, HintedString>;
+
+/// A string message with hints.
+///
+/// This is internally represented by a vector of strings.
+/// The first element of the vector contains the message.
+/// The remaining elements are the hints.
+/// This is done to reduce the size of a HintedString.
+/// The vector is guaranteed to not be empty.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct HintedString(EcoVec<EcoString>);
+
+impl HintedString {
+ /// Creates a new hinted string with the given message.
+ pub fn new(message: EcoString) -> Self {
+ Self(eco_vec![message])
+ }
+
+ /// A diagnostic message describing the problem.
+ pub fn message(&self) -> &EcoString {
+ self.0.first().unwrap()
+ }
+
+ /// Additional hints to the user, indicating how this error could be avoided
+ /// or worked around.
+ pub fn hints(&self) -> &[EcoString] {
+ self.0.get(1..).unwrap_or(&[])
+ }
+
+ /// Adds a single hint to the hinted string.
+ pub fn hint(&mut self, hint: impl Into<EcoString>) {
+ self.0.push(hint.into());
+ }
+
+ /// Adds a single hint to the hinted string.
+ pub fn with_hint(mut self, hint: impl Into<EcoString>) -> Self {
+ self.hint(hint);
+ self
+ }
+
+ /// Adds user-facing hints to the hinted string.
+ pub fn with_hints(mut self, hints: impl IntoIterator<Item = EcoString>) -> Self {
+ self.0.extend(hints);
+ self
+ }
+}
+
+impl<S> From<S> for HintedString
+where
+ S: Into<EcoString>,
+{
+ fn from(value: S) -> Self {
+ Self::new(value.into())
+ }
+}
+
+impl<T> At<T> for HintedStrResult<T> {
+ fn at(self, span: Span) -> SourceResult<T> {
+ self.map_err(|err| {
+ let mut components = err.0.into_iter();
+ let message = components.next().unwrap();
+ let diag = SourceDiagnostic::error(span, message).with_hints(components);
+ eco_vec![diag]
+ })
+ }
+}
+
+/// Enrich a [`StrResult`] or [`HintedStrResult`] with a hint.
+pub trait Hint<T> {
+ /// Add the hint.
+ fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T>;
+}
+
+impl<T, S> Hint<T> for Result<T, S>
+where
+ S: Into<EcoString>,
+{
+ fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T> {
+ self.map_err(|message| HintedString::new(message.into()).with_hint(hint))
+ }
+}
+
+impl<T> Hint<T> for HintedStrResult<T> {
+ fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T> {
+ self.map_err(|mut error| {
+ error.hint(hint.into());
+ error
+ })
+ }
+}
+
+/// A result type with a file-related error.
+pub type FileResult<T> = Result<T, FileError>;
+
+/// An error that occurred while trying to load of a file.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum FileError {
+ /// A file was not found at this path.
+ NotFound(PathBuf),
+ /// A file could not be accessed.
+ AccessDenied,
+ /// A directory was found, but a file was expected.
+ IsDirectory,
+ /// The file is not a Typst source file, but should have been.
+ NotSource,
+ /// The file was not valid UTF-8, but should have been.
+ InvalidUtf8,
+ /// The package the file is part of could not be loaded.
+ Package(PackageError),
+ /// Another error.
+ ///
+ /// The optional string can give more details, if available.
+ Other(Option<EcoString>),
+}
+
+impl FileError {
+ /// Create a file error from an I/O error.
+ pub fn from_io(err: io::Error, path: &Path) -> Self {
+ match err.kind() {
+ io::ErrorKind::NotFound => Self::NotFound(path.into()),
+ io::ErrorKind::PermissionDenied => Self::AccessDenied,
+ io::ErrorKind::InvalidData
+ if err.to_string().contains("stream did not contain valid UTF-8") =>
+ {
+ Self::InvalidUtf8
+ }
+ _ => Self::Other(Some(eco_format!("{err}"))),
+ }
+ }
+}
+
+impl std::error::Error for FileError {}
+
+impl Display for FileError {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::NotFound(path) => {
+ write!(f, "file not found (searched at {})", path.display())
+ }
+ Self::AccessDenied => f.pad("failed to load file (access denied)"),
+ Self::IsDirectory => f.pad("failed to load file (is a directory)"),
+ Self::NotSource => f.pad("not a typst source file"),
+ Self::InvalidUtf8 => f.pad("file is not valid utf-8"),
+ Self::Package(error) => error.fmt(f),
+ Self::Other(Some(err)) => write!(f, "failed to load file ({err})"),
+ Self::Other(None) => f.pad("failed to load file"),
+ }
+ }
+}
+
+impl From<Utf8Error> for FileError {
+ fn from(_: Utf8Error) -> Self {
+ Self::InvalidUtf8
+ }
+}
+
+impl From<FromUtf8Error> for FileError {
+ fn from(_: FromUtf8Error) -> Self {
+ Self::InvalidUtf8
+ }
+}
+
+impl From<PackageError> for FileError {
+ fn from(err: PackageError) -> Self {
+ Self::Package(err)
+ }
+}
+
+impl From<FileError> for EcoString {
+ fn from(err: FileError) -> Self {
+ eco_format!("{err}")
+ }
+}
+
+/// A result type with a package-related error.
+pub type PackageResult<T> = Result<T, PackageError>;
+
+/// An error that occurred while trying to load a package.
+///
+/// Some variants have an optional string can give more details, if available.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum PackageError {
+ /// The specified package does not exist.
+ NotFound(PackageSpec),
+ /// The specified package found, but the version does not exist.
+ VersionNotFound(PackageSpec, PackageVersion),
+ /// Failed to retrieve the package through the network.
+ NetworkFailed(Option<EcoString>),
+ /// The package archive was malformed.
+ MalformedArchive(Option<EcoString>),
+ /// Another error.
+ Other(Option<EcoString>),
+}
+
+impl std::error::Error for PackageError {}
+
+impl Display for PackageError {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::NotFound(spec) => {
+ write!(f, "package not found (searched for {spec})",)
+ }
+ Self::VersionNotFound(spec, latest) => {
+ write!(
+ f,
+ "package found, but version {} does not exist (latest is {})",
+ spec.version, latest,
+ )
+ }
+ Self::NetworkFailed(Some(err)) => {
+ write!(f, "failed to download package ({err})")
+ }
+ Self::NetworkFailed(None) => f.pad("failed to download package"),
+ Self::MalformedArchive(Some(err)) => {
+ write!(f, "failed to decompress package ({err})")
+ }
+ Self::MalformedArchive(None) => {
+ f.pad("failed to decompress package (archive malformed)")
+ }
+ Self::Other(Some(err)) => write!(f, "failed to load package ({err})"),
+ Self::Other(None) => f.pad("failed to load package"),
+ }
+ }
+}
+
+impl From<PackageError> for EcoString {
+ fn from(err: PackageError) -> Self {
+ eco_format!("{err}")
+ }
+}
+
+/// Format a user-facing error message for an XML-like file format.
+pub fn format_xml_like_error(format: &str, error: roxmltree::Error) -> EcoString {
+ match error {
+ roxmltree::Error::UnexpectedCloseTag(expected, actual, pos) => {
+ eco_format!(
+ "failed to parse {format} (found closing tag '{actual}' \
+ instead of '{expected}' in line {})",
+ pos.row
+ )
+ }
+ roxmltree::Error::UnknownEntityReference(entity, pos) => {
+ eco_format!(
+ "failed to parse {format} (unknown entity '{entity}' in line {})",
+ pos.row
+ )
+ }
+ roxmltree::Error::DuplicatedAttribute(attr, pos) => {
+ eco_format!(
+ "failed to parse {format} (duplicate attribute '{attr}' in line {})",
+ pos.row
+ )
+ }
+ roxmltree::Error::NoRootNode => {
+ eco_format!("failed to parse {format} (missing root node)")
+ }
+ err => eco_format!("failed to parse {format} ({err})"),
+ }
+}
diff --git a/crates/typst-library/src/engine.rs b/crates/typst-library/src/engine.rs
new file mode 100644
index 00000000..e532172e
--- /dev/null
+++ b/crates/typst-library/src/engine.rs
@@ -0,0 +1,387 @@
+//! Definition of the central compilation context.
+
+use std::collections::HashSet;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use comemo::{Track, Tracked, TrackedMut, Validate};
+use ecow::EcoVec;
+use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};
+use typst_syntax::{FileId, Span};
+
+use crate::diag::{bail, HintedStrResult, SourceDiagnostic, SourceResult, StrResult};
+use crate::foundations::{Styles, Value};
+use crate::introspection::Introspector;
+use crate::routines::Routines;
+use crate::World;
+
+/// Holds all data needed during compilation.
+pub struct Engine<'a> {
+ /// Defines implementation of various Typst compiler routines as a table of
+ /// function pointers.
+ pub routines: &'a Routines,
+ /// The compilation environment.
+ pub world: Tracked<'a, dyn World + 'a>,
+ /// Provides access to information about the document.
+ pub introspector: Tracked<'a, Introspector>,
+ /// May hold a span that is currently under inspection.
+ pub traced: Tracked<'a, Traced>,
+ /// A pure sink for warnings, delayed errors, and spans under inspection.
+ pub sink: TrackedMut<'a, Sink>,
+ /// The route the engine took during compilation. This is used to detect
+ /// cyclic imports and excessive nesting.
+ pub route: Route<'a>,
+}
+
+impl Engine<'_> {
+ /// Handles a result without immediately terminating execution. Instead, it
+ /// produces a delayed error that is only promoted to a fatal one if it
+ /// remains by the end of the introspection loop.
+ pub fn delay<T: Default>(&mut self, result: SourceResult<T>) -> T {
+ match result {
+ Ok(value) => value,
+ Err(errors) => {
+ self.sink.delay(errors);
+ T::default()
+ }
+ }
+ }
+
+ /// Runs tasks on the engine in parallel.
+ pub fn parallelize<P, I, T, U, F>(&mut self, iter: P, f: F) -> impl Iterator<Item = U>
+ where
+ P: IntoIterator<IntoIter = I>,
+ I: Iterator<Item = T>,
+ T: Send,
+ U: Send,
+ F: Fn(&mut Engine, T) -> U + Send + Sync,
+ {
+ let Engine {
+ world, introspector, traced, ref route, routines, ..
+ } = *self;
+
+ // We collect into a vector and then call `into_par_iter` instead of
+ // using `par_bridge` because it does not retain the ordering.
+ let work: Vec<T> = iter.into_iter().collect();
+
+ // Work in parallel.
+ let mut pairs: Vec<(U, Sink)> = Vec::with_capacity(work.len());
+ work.into_par_iter()
+ .map(|value| {
+ let mut sink = Sink::new();
+ let mut engine = Engine {
+ world,
+ introspector,
+ traced,
+ sink: sink.track_mut(),
+ route: route.clone(),
+ routines,
+ };
+ (f(&mut engine, value), sink)
+ })
+ .collect_into_vec(&mut pairs);
+
+ // Apply the subsinks to the outer sink.
+ for (_, sink) in &mut pairs {
+ let sink = std::mem::take(sink);
+ self.sink.extend(sink.delayed, sink.warnings, sink.values);
+ }
+
+ pairs.into_iter().map(|(output, _)| output)
+ }
+}
+
+/// May hold a span that is currently under inspection.
+#[derive(Default)]
+pub struct Traced(Option<Span>);
+
+impl Traced {
+ /// Wraps a to-be-traced `Span`.
+ ///
+ /// Call `Traced::default()` to trace nothing.
+ pub fn new(traced: Span) -> Self {
+ Self(Some(traced))
+ }
+}
+
+#[comemo::track]
+impl Traced {
+ /// Returns the traced span _if_ it is part of the given source file or
+ /// `None` otherwise.
+ ///
+ /// We hide the span if it isn't in the given file so that only results for
+ /// the file with the traced span are invalidated.
+ pub fn get(&self, id: FileId) -> Option<Span> {
+ if self.0.and_then(Span::id) == Some(id) {
+ self.0
+ } else {
+ None
+ }
+ }
+}
+
+/// A push-only sink for delayed errors, warnings, and traced values.
+///
+/// All tracked methods of this type are of the form `(&mut self, ..) -> ()`, so
+/// in principle they do not need validation (though that optimization is not
+/// yet implemented in comemo).
+#[derive(Default, Clone)]
+pub struct Sink {
+ /// Delayed errors: Those are errors that we can ignore until the last
+ /// iteration. For instance, show rules may throw during earlier iterations
+ /// because the introspector is not yet ready. We first ignore that and
+ /// proceed with empty content and only if the error remains by the end
+ /// of the last iteration, we promote it.
+ delayed: EcoVec<SourceDiagnostic>,
+ /// Warnings emitted during iteration.
+ warnings: EcoVec<SourceDiagnostic>,
+ /// Hashes of all warning's spans and messages for warning deduplication.
+ warnings_set: HashSet<u128>,
+ /// A sequence of traced values for a span.
+ values: EcoVec<(Value, Option<Styles>)>,
+}
+
+impl Sink {
+ /// The maximum number of traced values.
+ pub const MAX_VALUES: usize = 10;
+
+ /// Create a new empty sink.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Get the stored delayed errors.
+ pub fn delayed(&mut self) -> EcoVec<SourceDiagnostic> {
+ std::mem::take(&mut self.delayed)
+ }
+
+ /// Get the stored warnings.
+ pub fn warnings(self) -> EcoVec<SourceDiagnostic> {
+ self.warnings
+ }
+
+ /// Get the values for the traced span.
+ pub fn values(self) -> EcoVec<(Value, Option<Styles>)> {
+ self.values
+ }
+
+ /// Extend from another sink.
+ pub fn extend_from_sink(&mut self, other: Sink) {
+ self.extend(other.delayed, other.warnings, other.values);
+ }
+}
+
+#[comemo::track]
+impl Sink {
+ /// Push delayed errors.
+ pub fn delay(&mut self, errors: EcoVec<SourceDiagnostic>) {
+ self.delayed.extend(errors);
+ }
+
+ /// Add a warning.
+ pub fn warn(&mut self, warning: SourceDiagnostic) {
+ // Check if warning is a duplicate.
+ let hash = typst_utils::hash128(&(&warning.span, &warning.message));
+ if self.warnings_set.insert(hash) {
+ self.warnings.push(warning);
+ }
+ }
+
+ /// Trace a value and optionally styles for the traced span.
+ pub fn value(&mut self, value: Value, styles: Option<Styles>) {
+ if self.values.len() < Self::MAX_VALUES {
+ self.values.push((value, styles));
+ }
+ }
+
+ /// Extend from parts of another sink.
+ fn extend(
+ &mut self,
+ delayed: EcoVec<SourceDiagnostic>,
+ warnings: EcoVec<SourceDiagnostic>,
+ values: EcoVec<(Value, Option<Styles>)>,
+ ) {
+ self.delayed.extend(delayed);
+ for warning in warnings {
+ self.warn(warning);
+ }
+ if let Some(remaining) = Self::MAX_VALUES.checked_sub(self.values.len()) {
+ self.values.extend(values.into_iter().take(remaining));
+ }
+ }
+}
+
+/// The route the engine took during compilation. This is used to detect
+/// cyclic imports and excessive nesting.
+pub struct Route<'a> {
+ /// The parent route segment, if present.
+ ///
+ /// This is used when an engine is created from another engine.
+ // We need to override the constraint's lifetime here so that `Tracked` is
+ // covariant over the constraint. If it becomes invariant, we're in for a
+ // world of lifetime pain.
+ outer: Option<Tracked<'a, Self, <Route<'static> as Validate>::Constraint>>,
+ /// This is set if this route segment was inserted through the start of a
+ /// module evaluation.
+ id: Option<FileId>,
+ /// This is set whenever we enter a function, nested layout, or are applying
+ /// a show rule. The length of this segment plus the lengths of all `outer`
+ /// route segments make up the length of the route. If the length of the
+ /// route exceeds `MAX_DEPTH`, then we throw a "maximum ... depth exceeded"
+ /// error.
+ len: usize,
+ /// The upper bound we've established for the parent chain length.
+ ///
+ /// We don't know the exact length (that would defeat the whole purpose
+ /// because it would prevent cache reuse of some computation at different,
+ /// non-exceeding depths).
+ upper: AtomicUsize,
+}
+
+impl<'a> Route<'a> {
+ /// Create a new, empty route.
+ pub fn root() -> Self {
+ Self {
+ id: None,
+ outer: None,
+ len: 0,
+ upper: AtomicUsize::new(0),
+ }
+ }
+
+ /// Extend the route with another segment with a default length of 1.
+ pub fn extend(outer: Tracked<'a, Self>) -> Self {
+ Route {
+ outer: Some(outer),
+ id: None,
+ len: 1,
+ upper: AtomicUsize::new(usize::MAX),
+ }
+ }
+
+ /// Attach a file id to the route segment.
+ pub fn with_id(self, id: FileId) -> Self {
+ Self { id: Some(id), ..self }
+ }
+
+ /// Set the length of the route segment to zero.
+ pub fn unnested(self) -> Self {
+ Self { len: 0, ..self }
+ }
+
+ /// Start tracking this route.
+ ///
+ /// In comparison to [`Track::track`], this method skips this chain link
+ /// if it does not contribute anything.
+ pub fn track(&self) -> Tracked<'_, Self> {
+ match self.outer {
+ Some(outer) if self.id.is_none() && self.len == 0 => outer,
+ _ => Track::track(self),
+ }
+ }
+
+ /// Increase the nesting depth for this route segment.
+ pub fn increase(&mut self) {
+ self.len += 1;
+ }
+
+ /// Decrease the nesting depth for this route segment.
+ pub fn decrease(&mut self) {
+ self.len -= 1;
+ }
+}
+
+/// The maximum nesting depths. They are different so that even if show rule and
+/// call checks are interleaved, for show rule problems we always get the show
+/// rule error. The lower the max depth for a kind of error, the higher its
+/// precedence compared to the others.
+impl Route<'_> {
+ /// The maximum stack nesting depth.
+ const MAX_SHOW_RULE_DEPTH: usize = 64;
+
+ /// The maximum layout nesting depth.
+ const MAX_LAYOUT_DEPTH: usize = 72;
+
+ /// The maximum function call nesting depth.
+ const MAX_CALL_DEPTH: usize = 80;
+
+ /// Ensures that we are within the maximum show rule depth.
+ pub fn check_show_depth(&self) -> HintedStrResult<()> {
+ if !self.within(Route::MAX_SHOW_RULE_DEPTH) {
+ bail!(
+ "maximum show rule depth exceeded";
+ hint: "check whether the show rule matches its own output"
+ );
+ }
+ Ok(())
+ }
+
+ /// Ensures that we are within the maximum layout depth.
+ pub fn check_layout_depth(&self) -> HintedStrResult<()> {
+ if !self.within(Route::MAX_LAYOUT_DEPTH) {
+ bail!(
+ "maximum layout depth exceeded";
+ hint: "try to reduce the amount of nesting in your layout",
+ );
+ }
+ Ok(())
+ }
+
+ /// Ensures that we are within the maximum function call depth.
+ pub fn check_call_depth(&self) -> StrResult<()> {
+ if !self.within(Route::MAX_CALL_DEPTH) {
+ bail!("maximum function call depth exceeded");
+ }
+ Ok(())
+ }
+}
+
+#[comemo::track]
+impl<'a> Route<'a> {
+ /// Whether the given id is part of the route.
+ pub fn contains(&self, id: FileId) -> bool {
+ self.id == Some(id) || self.outer.is_some_and(|outer| outer.contains(id))
+ }
+
+ /// Whether the route's depth is less than or equal to the given depth.
+ pub fn within(&self, depth: usize) -> bool {
+ // We only need atomicity and no synchronization of other operations, so
+ // `Relaxed` is fine.
+ use Ordering::Relaxed;
+
+ let upper = self.upper.load(Relaxed);
+ if upper.saturating_add(self.len) <= depth {
+ return true;
+ }
+
+ match self.outer {
+ Some(_) if depth < self.len => false,
+ Some(outer) => {
+ let within = outer.within(depth - self.len);
+ if within && depth < upper {
+ // We don't want to accidentally increase the upper bound,
+ // hence the compare-exchange.
+ self.upper.compare_exchange(upper, depth, Relaxed, Relaxed).ok();
+ }
+ within
+ }
+ None => true,
+ }
+ }
+}
+
+impl Default for Route<'_> {
+ fn default() -> Self {
+ Self::root()
+ }
+}
+
+impl Clone for Route<'_> {
+ fn clone(&self) -> Self {
+ Self {
+ outer: self.outer,
+ id: self.id,
+ len: self.len,
+ upper: AtomicUsize::new(self.upper.load(Ordering::Relaxed)),
+ }
+ }
+}
diff --git a/crates/typst-library/src/foundations/args.rs b/crates/typst-library/src/foundations/args.rs
new file mode 100644
index 00000000..ee282a87
--- /dev/null
+++ b/crates/typst-library/src/foundations/args.rs
@@ -0,0 +1,453 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::{eco_format, eco_vec, EcoString, EcoVec};
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, error, At, SourceDiagnostic, SourceResult, StrResult};
+use crate::foundations::{
+ cast, func, repr, scope, ty, Array, Dict, FromValue, IntoValue, Repr, Str, Value,
+};
+
+/// Captured arguments to a function.
+///
+/// # Argument Sinks
+/// Like built-in functions, custom functions can also take a variable number of
+/// arguments. You can specify an _argument sink_ which collects all excess
+/// arguments as `..sink`. The resulting `sink` value is of the `arguments`
+/// type. It exposes methods to access the positional and named arguments.
+///
+/// ```example
+/// #let format(title, ..authors) = {
+/// let by = authors
+/// .pos()
+/// .join(", ", last: " and ")
+///
+/// [*#title* \ _Written by #by;_]
+/// }
+///
+/// #format("ArtosFlow", "Jane", "Joe")
+/// ```
+///
+/// # Spreading
+/// Inversely to an argument sink, you can _spread_ arguments, arrays and
+/// dictionaries into a function call with the `..spread` operator:
+///
+/// ```example
+/// #let array = (2, 3, 5)
+/// #calc.min(..array)
+/// #let dict = (fill: blue)
+/// #text(..dict)[Hello]
+/// ```
+#[ty(scope, cast, name = "arguments")]
+#[derive(Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Args {
+ /// The callsite span for the function. This is not the span of the argument
+ /// list itself, but of the whole function call.
+ pub span: Span,
+ /// The positional and named arguments.
+ pub items: EcoVec<Arg>,
+}
+
+impl Args {
+ /// Create positional arguments from a span and values.
+ pub fn new<T: IntoValue>(span: Span, values: impl IntoIterator<Item = T>) -> Self {
+ let items = values
+ .into_iter()
+ .map(|value| Arg {
+ span,
+ name: None,
+ value: Spanned::new(value.into_value(), span),
+ })
+ .collect();
+ Self { span, items }
+ }
+
+ /// Attach a span to these arguments if they don't already have one.
+ pub fn spanned(mut self, span: Span) -> Self {
+ if self.span.is_detached() {
+ self.span = span;
+ }
+ self
+ }
+
+ /// Returns the number of remaining positional arguments.
+ pub fn remaining(&self) -> usize {
+ self.items.iter().filter(|slot| slot.name.is_none()).count()
+ }
+
+ /// Insert a positional argument at a specific index.
+ pub fn insert(&mut self, index: usize, span: Span, value: Value) {
+ self.items.insert(
+ index,
+ Arg {
+ span: self.span,
+ name: None,
+ value: Spanned::new(value, span),
+ },
+ )
+ }
+
+ /// Push a positional argument.
+ pub fn push(&mut self, span: Span, value: Value) {
+ self.items.push(Arg {
+ span: self.span,
+ name: None,
+ value: Spanned::new(value, span),
+ })
+ }
+
+ /// Consume and cast the first positional argument if there is one.
+ pub fn eat<T>(&mut self) -> SourceResult<Option<T>>
+ where
+ T: FromValue<Spanned<Value>>,
+ {
+ for (i, slot) in self.items.iter().enumerate() {
+ if slot.name.is_none() {
+ let value = self.items.remove(i).value;
+ let span = value.span;
+ return T::from_value(value).at(span).map(Some);
+ }
+ }
+ Ok(None)
+ }
+
+ /// Consume n positional arguments if possible.
+ pub fn consume(&mut self, n: usize) -> SourceResult<Vec<Arg>> {
+ let mut list = vec![];
+
+ let mut i = 0;
+ while i < self.items.len() && list.len() < n {
+ if self.items[i].name.is_none() {
+ list.push(self.items.remove(i));
+ } else {
+ i += 1;
+ }
+ }
+
+ if list.len() < n {
+ bail!(self.span, "not enough arguments");
+ }
+
+ Ok(list)
+ }
+
+ /// Consume and cast the first positional argument.
+ ///
+ /// Returns a `missing argument: {what}` error if no positional argument is
+ /// left.
+ pub fn expect<T>(&mut self, what: &str) -> SourceResult<T>
+ where
+ T: FromValue<Spanned<Value>>,
+ {
+ match self.eat()? {
+ Some(v) => Ok(v),
+ None => bail!(self.missing_argument(what)),
+ }
+ }
+
+ /// The error message for missing arguments.
+ fn missing_argument(&self, what: &str) -> SourceDiagnostic {
+ for item in &self.items {
+ let Some(name) = item.name.as_deref() else { continue };
+ if name == what {
+ return error!(
+ item.span,
+ "the argument `{what}` is positional";
+ hint: "try removing `{}:`", name,
+ );
+ }
+ }
+
+ error!(self.span, "missing argument: {what}")
+ }
+
+ /// Find and consume the first castable positional argument.
+ pub fn find<T>(&mut self) -> SourceResult<Option<T>>
+ where
+ T: FromValue<Spanned<Value>>,
+ {
+ for (i, slot) in self.items.iter().enumerate() {
+ if slot.name.is_none() && T::castable(&slot.value.v) {
+ let value = self.items.remove(i).value;
+ let span = value.span;
+ return T::from_value(value).at(span).map(Some);
+ }
+ }
+ Ok(None)
+ }
+
+ /// Find and consume all castable positional arguments.
+ pub fn all<T>(&mut self) -> SourceResult<Vec<T>>
+ where
+ T: FromValue<Spanned<Value>>,
+ {
+ let mut list = vec![];
+ let mut errors = eco_vec![];
+ self.items.retain(|item| {
+ if item.name.is_some() {
+ return true;
+ };
+ let span = item.value.span;
+ let spanned = Spanned::new(std::mem::take(&mut item.value.v), span);
+ match T::from_value(spanned).at(span) {
+ Ok(val) => list.push(val),
+ Err(diags) => errors.extend(diags),
+ }
+ false
+ });
+ if !errors.is_empty() {
+ return Err(errors);
+ }
+ Ok(list)
+ }
+
+ /// Cast and remove the value for the given named argument, returning an
+ /// error if the conversion fails.
+ pub fn named<T>(&mut self, name: &str) -> SourceResult<Option<T>>
+ where
+ T: FromValue<Spanned<Value>>,
+ {
+ // We don't quit once we have a match because when multiple matches
+ // exist, we want to remove all of them and use the last one.
+ let mut i = 0;
+ let mut found = None;
+ while i < self.items.len() {
+ if self.items[i].name.as_deref() == Some(name) {
+ let value = self.items.remove(i).value;
+ let span = value.span;
+ found = Some(T::from_value(value).at(span)?);
+ } else {
+ i += 1;
+ }
+ }
+ Ok(found)
+ }
+
+ /// Same as named, but with fallback to find.
+ pub fn named_or_find<T>(&mut self, name: &str) -> SourceResult<Option<T>>
+ where
+ T: FromValue<Spanned<Value>>,
+ {
+ match self.named(name)? {
+ Some(value) => Ok(Some(value)),
+ None => self.find(),
+ }
+ }
+
+ /// Take out all arguments into a new instance.
+ pub fn take(&mut self) -> Self {
+ Self {
+ span: self.span,
+ items: std::mem::take(&mut self.items),
+ }
+ }
+
+ /// Return an "unexpected argument" error if there is any remaining
+ /// argument.
+ pub fn finish(self) -> SourceResult<()> {
+ if let Some(arg) = self.items.first() {
+ match &arg.name {
+ Some(name) => bail!(arg.span, "unexpected argument: {name}"),
+ _ => bail!(arg.span, "unexpected argument"),
+ }
+ }
+ Ok(())
+ }
+}
+
+/// A key that can be used to get an argument: either the index of a positional
+/// argument, or the name of a named argument.
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum ArgumentKey {
+ Index(i64),
+ Name(Str),
+}
+
+cast! {
+ ArgumentKey,
+ v: i64 => Self::Index(v),
+ v: Str => Self::Name(v),
+}
+
+impl Args {
+ fn get(&self, key: &ArgumentKey) -> Option<&Value> {
+ let item = match key {
+ &ArgumentKey::Index(index) => {
+ let mut iter = self.items.iter().filter(|item| item.name.is_none());
+ if index < 0 {
+ let index = (-(index + 1)).try_into().ok()?;
+ iter.nth_back(index)
+ } else {
+ let index = index.try_into().ok()?;
+ iter.nth(index)
+ }
+ }
+ // Accept the last argument with the right name.
+ ArgumentKey::Name(name) => {
+ self.items.iter().rfind(|item| item.name.as_ref() == Some(name))
+ }
+ };
+ item.map(|item| &item.value.v)
+ }
+}
+
+#[scope]
+impl Args {
+ /// Construct spreadable arguments in place.
+ ///
+ /// This function behaves like `{let args(..sink) = sink}`.
+ ///
+ /// ```example
+ /// #let args = arguments(stroke: red, inset: 1em, [Body])
+ /// #box(..args)
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The real arguments (the other argument is just for the docs).
+ /// The docs argument cannot be called `args`.
+ args: &mut Args,
+ /// The arguments to construct.
+ #[external]
+ #[variadic]
+ arguments: Vec<Value>,
+ ) -> Args {
+ args.take()
+ }
+
+ /// Returns the positional argument at the specified index, or the named
+ /// argument with the specified name.
+ ///
+ /// If the key is an [integer]($int), this is equivalent to first calling
+ /// [`pos`]($arguments.pos) and then [`array.at`]. If it is a [string]($str),
+ /// this is equivalent to first calling [`named`]($arguments.named) and then
+ /// [`dictionary.at`].
+ #[func]
+ pub fn at(
+ &self,
+ /// The index or name of the argument to get.
+ key: ArgumentKey,
+ /// A default value to return if the key is invalid.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ self.get(&key)
+ .cloned()
+ .or(default)
+ .ok_or_else(|| missing_key_no_default(key))
+ }
+
+ /// Returns the captured positional arguments as an array.
+ #[func(name = "pos", title = "Positional")]
+ pub fn to_pos(&self) -> Array {
+ self.items
+ .iter()
+ .filter(|item| item.name.is_none())
+ .map(|item| item.value.v.clone())
+ .collect()
+ }
+
+ /// Returns the captured named arguments as a dictionary.
+ #[func(name = "named")]
+ pub fn to_named(&self) -> Dict {
+ self.items
+ .iter()
+ .filter_map(|item| item.name.clone().map(|name| (name, item.value.v.clone())))
+ .collect()
+ }
+}
+
+impl Debug for Args {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.debug_list().entries(&self.items).finish()
+ }
+}
+
+impl Repr for Args {
+ fn repr(&self) -> EcoString {
+ let pieces = self.items.iter().map(Arg::repr).collect::<Vec<_>>();
+ repr::pretty_array_like(&pieces, false).into()
+ }
+}
+
+impl PartialEq for Args {
+ fn eq(&self, other: &Self) -> bool {
+ self.to_pos() == other.to_pos() && self.to_named() == other.to_named()
+ }
+}
+
+/// An argument to a function call: `12` or `draw: false`.
+#[derive(Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Arg {
+ /// The span of the whole argument.
+ pub span: Span,
+ /// The name of the argument (`None` for positional arguments).
+ pub name: Option<Str>,
+ /// The value of the argument.
+ pub value: Spanned<Value>,
+}
+
+impl Debug for Arg {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if let Some(name) = &self.name {
+ name.fmt(f)?;
+ f.write_str(": ")?;
+ self.value.v.fmt(f)
+ } else {
+ self.value.v.fmt(f)
+ }
+ }
+}
+
+impl Repr for Arg {
+ fn repr(&self) -> EcoString {
+ if let Some(name) = &self.name {
+ eco_format!("{}: {}", name, self.value.v.repr())
+ } else {
+ self.value.v.repr()
+ }
+ }
+}
+
+impl PartialEq for Arg {
+ fn eq(&self, other: &Self) -> bool {
+ self.name == other.name && self.value.v == other.value.v
+ }
+}
+
+/// Things that can be used as arguments.
+pub trait IntoArgs {
+ /// Convert into arguments, attaching the `fallback` span in case `Self`
+ /// doesn't have a span.
+ fn into_args(self, fallback: Span) -> Args;
+}
+
+impl IntoArgs for Args {
+ fn into_args(self, fallback: Span) -> Args {
+ self.spanned(fallback)
+ }
+}
+
+impl<I, T> IntoArgs for I
+where
+ I: IntoIterator<Item = T>,
+ T: IntoValue,
+{
+ fn into_args(self, fallback: Span) -> Args {
+ Args::new(fallback, self)
+ }
+}
+
+/// The missing key access error message when no default was given.
+#[cold]
+fn missing_key_no_default(key: ArgumentKey) -> EcoString {
+ eco_format!(
+ "arguments do not contain key {} \
+ and no default value was specified",
+ match key {
+ ArgumentKey::Index(i) => i.repr(),
+ ArgumentKey::Name(name) => name.repr(),
+ }
+ )
+}
diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs
new file mode 100644
index 00000000..9c8aecac
--- /dev/null
+++ b/crates/typst-library/src/foundations/array.rs
@@ -0,0 +1,1133 @@
+use std::cmp::Ordering;
+use std::fmt::{Debug, Formatter};
+use std::num::{NonZeroI64, NonZeroUsize};
+use std::ops::{Add, AddAssign};
+
+use comemo::Tracked;
+use ecow::{eco_format, EcoString, EcoVec};
+use serde::{Deserialize, Serialize};
+use smallvec::SmallVec;
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, At, HintedStrResult, SourceDiagnostic, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, func, ops, repr, scope, ty, Args, Bytes, CastInfo, Context, Dict, FromValue,
+ Func, IntoValue, Reflect, Repr, Str, Value, Version,
+};
+
+/// Create a new [`Array`] from values.
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __array {
+ ($value:expr; $count:expr) => {
+ $crate::foundations::Array::from($crate::foundations::eco_vec![
+ $crate::foundations::IntoValue::into_value($value);
+ $count
+ ])
+ };
+
+ ($($value:expr),* $(,)?) => {
+ $crate::foundations::Array::from($crate::foundations::eco_vec![$(
+ $crate::foundations::IntoValue::into_value($value)
+ ),*])
+ };
+}
+
+#[doc(inline)]
+pub use crate::__array as array;
+
+/// A sequence of values.
+///
+/// You can construct an array by enclosing a comma-separated sequence of values
+/// in parentheses. The values do not have to be of the same type.
+///
+/// You can access and update array items with the `.at()` method. Indices are
+/// zero-based and negative indices wrap around to the end of the array. You can
+/// iterate over an array using a [for loop]($scripting/#loops). Arrays can be
+/// added together with the `+` operator, [joined together]($scripting/#blocks)
+/// and multiplied with integers.
+///
+/// **Note:** An array of length one needs a trailing comma, as in `{(1,)}`.
+/// This is to disambiguate from a simple parenthesized expressions like `{(1 +
+/// 2) * 3}`. An empty array is written as `{()}`.
+///
+/// # Example
+/// ```example
+/// #let values = (1, 7, 4, -3, 2)
+///
+/// #values.at(0) \
+/// #(values.at(0) = 3)
+/// #values.at(-1) \
+/// #values.find(calc.even) \
+/// #values.filter(calc.odd) \
+/// #values.map(calc.abs) \
+/// #values.rev() \
+/// #(1, (2, 3)).flatten() \
+/// #(("A", "B", "C")
+/// .join(", ", last: " and "))
+/// ```
+#[ty(scope, cast)]
+#[derive(Default, Clone, PartialEq, Hash, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct Array(EcoVec<Value>);
+
+impl Array {
+ /// Create a new, empty array.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Creates a new vec, with a known capacity.
+ pub fn with_capacity(capacity: usize) -> Self {
+ Self(EcoVec::with_capacity(capacity))
+ }
+
+ /// Return `true` if the length is 0.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// Extract a slice of the whole array.
+ pub fn as_slice(&self) -> &[Value] {
+ self.0.as_slice()
+ }
+
+ /// Iterate over references to the contained values.
+ pub fn iter(&self) -> std::slice::Iter<Value> {
+ self.0.iter()
+ }
+
+ /// Mutably borrow the first value in the array.
+ pub fn first_mut(&mut self) -> StrResult<&mut Value> {
+ self.0.make_mut().first_mut().ok_or_else(array_is_empty)
+ }
+
+ /// Mutably borrow the last value in the array.
+ pub fn last_mut(&mut self) -> StrResult<&mut Value> {
+ self.0.make_mut().last_mut().ok_or_else(array_is_empty)
+ }
+
+ /// Mutably borrow the value at the given index.
+ pub fn at_mut(&mut self, index: i64) -> StrResult<&mut Value> {
+ let len = self.len();
+ self.locate_opt(index, false)
+ .and_then(move |i| self.0.make_mut().get_mut(i))
+ .ok_or_else(|| out_of_bounds(index, len))
+ }
+
+ /// Resolve an index or throw an out of bounds error.
+ fn locate(&self, index: i64, end_ok: bool) -> StrResult<usize> {
+ self.locate_opt(index, end_ok)
+ .ok_or_else(|| out_of_bounds(index, self.len()))
+ }
+
+ /// Resolve an index, if it is within bounds.
+ ///
+ /// `index == len` is considered in bounds if and only if `end_ok` is true.
+ fn locate_opt(&self, index: i64, end_ok: bool) -> Option<usize> {
+ let wrapped =
+ if index >= 0 { Some(index) } else { (self.len() as i64).checked_add(index) };
+
+ wrapped
+ .and_then(|v| usize::try_from(v).ok())
+ .filter(|&v| v < self.0.len() + end_ok as usize)
+ }
+
+ /// Repeat this array `n` times.
+ pub fn repeat(&self, n: usize) -> StrResult<Self> {
+ let count = self
+ .len()
+ .checked_mul(n)
+ .ok_or_else(|| format!("cannot repeat this array {n} times"))?;
+
+ Ok(self.iter().cloned().cycle().take(count).collect())
+ }
+}
+
+#[scope]
+impl Array {
+ /// Converts a value to an array.
+ ///
+ /// Note that this function is only intended for conversion of a collection-like
+ /// value to an array, not for creation of an array from individual items. Use
+ /// the array syntax `(1, 2, 3)` (or `(1,)` for a single-element array) instead.
+ ///
+ /// ```example
+ /// #let hi = "Hello 😃"
+ /// #array(bytes(hi))
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The value that should be converted to an array.
+ value: ToArray,
+ ) -> Array {
+ value.0
+ }
+
+ /// The number of values in the array.
+ #[func(title = "Length")]
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Returns the first item in the array. May be used on the left-hand side
+ /// of an assignment. Fails with an error if the array is empty.
+ #[func]
+ pub fn first(&self) -> StrResult<Value> {
+ self.0.first().cloned().ok_or_else(array_is_empty)
+ }
+
+ /// Returns the last item in the array. May be used on the left-hand side of
+ /// an assignment. Fails with an error if the array is empty.
+ #[func]
+ pub fn last(&self) -> StrResult<Value> {
+ self.0.last().cloned().ok_or_else(array_is_empty)
+ }
+
+ /// Returns the item at the specified index in the array. May be used on the
+ /// left-hand side of an assignment. Returns the default value if the index
+ /// is out of bounds or fails with an error if no default value was
+ /// specified.
+ #[func]
+ pub fn at(
+ &self,
+ /// The index at which to retrieve the item. If negative, indexes from
+ /// the back.
+ index: i64,
+ /// A default value to return if the index is out of bounds.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ self.locate_opt(index, false)
+ .and_then(|i| self.0.get(i).cloned())
+ .or(default)
+ .ok_or_else(|| out_of_bounds_no_default(index, self.len()))
+ }
+
+ /// Adds a value to the end of the array.
+ #[func]
+ pub fn push(
+ &mut self,
+ /// The value to insert at the end of the array.
+ value: Value,
+ ) {
+ self.0.push(value);
+ }
+
+ /// Removes the last item from the array and returns it. Fails with an error
+ /// if the array is empty.
+ #[func]
+ pub fn pop(&mut self) -> StrResult<Value> {
+ self.0.pop().ok_or_else(array_is_empty)
+ }
+
+ /// Inserts a value into the array at the specified index, shifting all
+ /// subsequent elements to the right. Fails with an error if the index is
+ /// out of bounds.
+ ///
+ /// To replace an element of an array, use [`at`]($array.at).
+ #[func]
+ pub fn insert(
+ &mut self,
+ /// The index at which to insert the item. If negative, indexes from
+ /// the back.
+ index: i64,
+ /// The value to insert into the array.
+ value: Value,
+ ) -> StrResult<()> {
+ let i = self.locate(index, true)?;
+ self.0.insert(i, value);
+ Ok(())
+ }
+
+ /// Removes the value at the specified index from the array and return it.
+ #[func]
+ pub fn remove(
+ &mut self,
+ /// The index at which to remove the item. If negative, indexes from
+ /// the back.
+ index: i64,
+ /// A default value to return if the index is out of bounds.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ self.locate_opt(index, false)
+ .map(|i| self.0.remove(i))
+ .or(default)
+ .ok_or_else(|| out_of_bounds_no_default(index, self.len()))
+ }
+
+ /// Extracts a subslice of the array. Fails with an error if the start or end
+ /// index is out of bounds.
+ #[func]
+ pub fn slice(
+ &self,
+ /// The start index (inclusive). If negative, indexes from the back.
+ start: i64,
+ /// The end index (exclusive). If omitted, the whole slice until the end
+ /// of the array is extracted. If negative, indexes from the back.
+ #[default]
+ end: Option<i64>,
+ /// The number of items to extract. This is equivalent to passing
+ /// `start + count` as the `end` position. Mutually exclusive with `end`.
+ #[named]
+ count: Option<i64>,
+ ) -> StrResult<Array> {
+ let mut end = end;
+ if end.is_none() {
+ end = count.map(|c: i64| start + c);
+ }
+ let start = self.locate(start, true)?;
+ let end = self.locate(end.unwrap_or(self.len() as i64), true)?.max(start);
+ Ok(self.0[start..end].into())
+ }
+
+ /// Whether the array contains the specified value.
+ ///
+ /// This method also has dedicated syntax: You can write `{2 in (1, 2, 3)}`
+ /// instead of `{(1, 2, 3).contains(2)}`.
+ #[func]
+ pub fn contains(
+ &self,
+ /// The value to search for.
+ value: Value,
+ ) -> bool {
+ self.0.contains(&value)
+ }
+
+ /// Searches for an item for which the given function returns `{true}` and
+ /// returns the first match or `{none}` if there is no match.
+ #[func]
+ pub fn find(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The function to apply to each item. Must return a boolean.
+ searcher: Func,
+ ) -> SourceResult<Option<Value>> {
+ for item in self.iter() {
+ if searcher
+ .call(engine, context, [item.clone()])?
+ .cast::<bool>()
+ .at(searcher.span())?
+ {
+ return Ok(Some(item.clone()));
+ }
+ }
+ Ok(None)
+ }
+
+ /// Searches for an item for which the given function returns `{true}` and
+ /// returns the index of the first match or `{none}` if there is no match.
+ #[func]
+ pub fn position(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The function to apply to each item. Must return a boolean.
+ searcher: Func,
+ ) -> SourceResult<Option<i64>> {
+ for (i, item) in self.iter().enumerate() {
+ if searcher
+ .call(engine, context, [item.clone()])?
+ .cast::<bool>()
+ .at(searcher.span())?
+ {
+ return Ok(Some(i as i64));
+ }
+ }
+
+ Ok(None)
+ }
+
+ /// Create an array consisting of a sequence of numbers.
+ ///
+ /// 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.
+ ///
+ /// This function is available both in the array function's scope and
+ /// globally.
+ ///
+ /// ```example
+ /// #range(5) \
+ /// #range(2, 5) \
+ /// #range(20, step: 4) \
+ /// #range(21, step: 4) \
+ /// #range(5, 2, step: -1)
+ /// ```
+ #[func]
+ pub fn range(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// 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,
+ ) -> SourceResult<Array> {
+ 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 = Self::new();
+
+ while x.cmp(&end) == 0.cmp(&step) {
+ array.push(x.into_value());
+ x += step;
+ }
+
+ Ok(array)
+ }
+
+ /// Produces a new array with only the items from the original one for which
+ /// the given function returns true.
+ #[func]
+ pub fn filter(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The function to apply to each item. Must return a boolean.
+ test: Func,
+ ) -> SourceResult<Array> {
+ let mut kept = EcoVec::new();
+ for item in self.iter() {
+ if test
+ .call(engine, context, [item.clone()])?
+ .cast::<bool>()
+ .at(test.span())?
+ {
+ kept.push(item.clone())
+ }
+ }
+ Ok(kept.into())
+ }
+
+ /// Produces a new array in which all items from the original one were
+ /// transformed with the given function.
+ #[func]
+ pub fn map(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The function to apply to each item.
+ mapper: Func,
+ ) -> SourceResult<Array> {
+ self.into_iter()
+ .map(|item| mapper.call(engine, context, [item]))
+ .collect()
+ }
+
+ /// Returns a new array with the values alongside their indices.
+ ///
+ /// The returned array consists of `(index, value)` pairs in the form of
+ /// length-2 arrays. These can be [destructured]($scripting/#bindings) with
+ /// a let binding or for loop.
+ #[func]
+ pub fn enumerate(
+ self,
+ /// The index returned for the first pair of the returned list.
+ #[named]
+ #[default(0)]
+ start: i64,
+ ) -> StrResult<Array> {
+ self.into_iter()
+ .enumerate()
+ .map(|(i, value)| {
+ Ok(array![
+ start
+ .checked_add_unsigned(i as u64)
+ .ok_or("array index is too large")?,
+ value
+ ]
+ .into_value())
+ })
+ .collect()
+ }
+
+ /// Zips the array with other arrays.
+ ///
+ /// Returns an array of arrays, where the `i`th inner array contains all the
+ /// `i`th elements from each original array.
+ ///
+ /// If the arrays to be zipped have different lengths, they are zipped up to
+ /// the last element of the shortest array and all remaining elements are
+ /// ignored.
+ ///
+ /// This function is variadic, meaning that you can zip multiple arrays
+ /// together at once: `{(1, 2).zip(("A", "B"), (10, 20))}` yields
+ /// `{((1, "A", 10), (2, "B", 20))}`.
+ #[func]
+ pub fn zip(
+ self,
+ /// The real arguments (the `others` arguments are just for the docs, this
+ /// function is a bit involved, so we parse the positional arguments manually).
+ args: &mut Args,
+ /// Whether all arrays have to have the same length.
+ /// For example, `{(1, 2).zip((1, 2, 3), exact: true)}` produces an
+ /// error.
+ #[named]
+ #[default(false)]
+ exact: bool,
+ /// The arrays to zip with.
+ #[external]
+ #[variadic]
+ others: Vec<Array>,
+ ) -> SourceResult<Array> {
+ let remaining = args.remaining();
+
+ // Fast path for one array.
+ if remaining == 0 {
+ return Ok(self.into_iter().map(|item| array![item].into_value()).collect());
+ }
+
+ // Fast path for just two arrays.
+ if remaining == 1 {
+ let Spanned { v: other, span: other_span } =
+ args.expect::<Spanned<Array>>("others")?;
+ if exact && self.len() != other.len() {
+ bail!(
+ other_span,
+ "second array has different length ({}) from first array ({})",
+ other.len(),
+ self.len()
+ );
+ }
+ return Ok(self
+ .into_iter()
+ .zip(other)
+ .map(|(first, second)| array![first, second].into_value())
+ .collect());
+ }
+
+ // If there is more than one array, we use the manual method.
+ let mut out = Self::with_capacity(self.len());
+ let arrays = args.all::<Spanned<Array>>()?;
+ if exact {
+ let errs = arrays
+ .iter()
+ .filter(|sp| sp.v.len() != self.len())
+ .map(|Spanned { v, span }| {
+ SourceDiagnostic::error(
+ *span,
+ eco_format!(
+ "array has different length ({}) from first array ({})",
+ v.len(),
+ self.len()
+ ),
+ )
+ })
+ .collect::<EcoVec<_>>();
+ if !errs.is_empty() {
+ return Err(errs);
+ }
+ }
+
+ let mut iterators =
+ arrays.into_iter().map(|i| i.v.into_iter()).collect::<Vec<_>>();
+
+ for this in self {
+ let mut row = Self::with_capacity(1 + iterators.len());
+ row.push(this.clone());
+
+ for iterator in &mut iterators {
+ let Some(item) = iterator.next() else {
+ return Ok(out);
+ };
+
+ row.push(item);
+ }
+
+ out.push(row.into_value());
+ }
+
+ Ok(out)
+ }
+
+ /// Folds all items into a single value using an accumulator function.
+ #[func]
+ pub fn fold(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The initial value to start with.
+ init: Value,
+ /// The folding function. Must have two parameters: One for the
+ /// accumulated value and one for an item.
+ folder: Func,
+ ) -> SourceResult<Value> {
+ let mut acc = init;
+ for item in self {
+ acc = folder.call(engine, context, [acc, item])?;
+ }
+ Ok(acc)
+ }
+
+ /// Sums all items (works for all types that can be added).
+ #[func]
+ pub fn sum(
+ self,
+ /// What to return if the array is empty. Must be set if the array can
+ /// be empty.
+ #[named]
+ default: Option<Value>,
+ ) -> HintedStrResult<Value> {
+ let mut iter = self.into_iter();
+ let mut acc = iter
+ .next()
+ .or(default)
+ .ok_or("cannot calculate sum of empty array with no default")?;
+ for item in iter {
+ acc = ops::add(acc, item)?;
+ }
+ Ok(acc)
+ }
+
+ /// Calculates the product all items (works for all types that can be
+ /// multiplied).
+ #[func]
+ pub fn product(
+ self,
+ /// What to return if the array is empty. Must be set if the array can
+ /// be empty.
+ #[named]
+ default: Option<Value>,
+ ) -> HintedStrResult<Value> {
+ let mut iter = self.into_iter();
+ let mut acc = iter
+ .next()
+ .or(default)
+ .ok_or("cannot calculate product of empty array with no default")?;
+ for item in iter {
+ acc = ops::mul(acc, item)?;
+ }
+ Ok(acc)
+ }
+
+ /// Whether the given function returns `{true}` for any item in the array.
+ #[func]
+ pub fn any(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The function to apply to each item. Must return a boolean.
+ test: Func,
+ ) -> SourceResult<bool> {
+ for item in self {
+ if test.call(engine, context, [item])?.cast::<bool>().at(test.span())? {
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+ }
+
+ /// Whether the given function returns `{true}` for all items in the array.
+ #[func]
+ pub fn all(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The function to apply to each item. Must return a boolean.
+ test: Func,
+ ) -> SourceResult<bool> {
+ for item in self {
+ if !test.call(engine, context, [item])?.cast::<bool>().at(test.span())? {
+ return Ok(false);
+ }
+ }
+
+ Ok(true)
+ }
+
+ /// Combine all nested arrays into a single flat one.
+ #[func]
+ pub fn flatten(self) -> Array {
+ let mut flat = EcoVec::with_capacity(self.0.len());
+ for item in self {
+ if let Value::Array(nested) = item {
+ flat.extend(nested.flatten());
+ } else {
+ flat.push(item);
+ }
+ }
+ flat.into()
+ }
+
+ /// Return a new array with the same items, but in reverse order.
+ #[func(title = "Reverse")]
+ pub fn rev(self) -> Array {
+ self.into_iter().rev().collect()
+ }
+
+ /// Split the array at occurrences of the specified value.
+ #[func]
+ pub fn split(
+ &self,
+ /// The value to split at.
+ at: Value,
+ ) -> Array {
+ self.as_slice()
+ .split(|value| *value == at)
+ .map(|subslice| Value::Array(subslice.iter().cloned().collect()))
+ .collect()
+ }
+
+ /// Combine all items in the array into one.
+ #[func]
+ pub fn join(
+ self,
+ /// A value to insert between each item of the array.
+ #[default]
+ separator: Option<Value>,
+ /// An alternative separator between the last two items.
+ #[named]
+ last: Option<Value>,
+ ) -> StrResult<Value> {
+ let len = self.0.len();
+ let separator = separator.unwrap_or(Value::None);
+
+ let mut last = last;
+ let mut result = Value::None;
+ for (i, value) in self.into_iter().enumerate() {
+ if i > 0 {
+ if i + 1 == len && last.is_some() {
+ result = ops::join(result, last.take().unwrap())?;
+ } else {
+ result = ops::join(result, separator.clone())?;
+ }
+ }
+
+ result = ops::join(result, value)?;
+ }
+
+ Ok(result)
+ }
+
+ /// Returns an array with a copy of the separator value placed between
+ /// adjacent elements.
+ #[func]
+ pub fn intersperse(
+ self,
+ /// The value that will be placed between each adjacent element.
+ separator: Value,
+ ) -> Array {
+ // TODO: Use once stabilized:
+ // https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.intersperse
+ let size = match self.len() {
+ 0 => return Array::new(),
+ n => (2 * n) - 1,
+ };
+ let mut vec = EcoVec::with_capacity(size);
+ let mut iter = self.into_iter();
+
+ if let Some(first) = iter.next() {
+ vec.push(first);
+ }
+
+ for value in iter {
+ vec.push(separator.clone());
+ vec.push(value);
+ }
+
+ Array(vec)
+ }
+
+ /// Splits an array into non-overlapping chunks, starting at the beginning,
+ /// ending with a single remainder chunk.
+ ///
+ /// All chunks but the last have `chunk-size` elements.
+ /// If `exact` is set to `{true}`, the remainder is dropped if it
+ /// contains less than `chunk-size` elements.
+ ///
+ /// ```example
+ /// #let array = (1, 2, 3, 4, 5, 6, 7, 8)
+ /// #array.chunks(3)
+ /// #array.chunks(3, exact: true)
+ /// ```
+ #[func]
+ pub fn chunks(
+ self,
+ /// How many elements each chunk may at most contain.
+ chunk_size: NonZeroUsize,
+ /// Whether to keep the remainder if its size is less than `chunk-size`.
+ #[named]
+ #[default(false)]
+ exact: bool,
+ ) -> Array {
+ let to_array = |chunk| Array::from(chunk).into_value();
+ if exact {
+ self.0.chunks_exact(chunk_size.get()).map(to_array).collect()
+ } else {
+ self.0.chunks(chunk_size.get()).map(to_array).collect()
+ }
+ }
+
+ /// Returns sliding windows of `window-size` elements over an array.
+ ///
+ /// If the array length is less than `window-size`, this will return an empty array.
+ ///
+ /// ```example
+ /// #let array = (1, 2, 3, 4, 5, 6, 7, 8)
+ /// #array.windows(5)
+ /// ```
+ #[func]
+ pub fn windows(
+ self,
+ /// How many elements each window will contain.
+ window_size: NonZeroUsize,
+ ) -> Array {
+ self.0
+ .windows(window_size.get())
+ .map(|window| Array::from(window).into_value())
+ .collect()
+ }
+
+ /// Return a sorted version of this array, optionally by a given key
+ /// function. The sorting algorithm used is stable.
+ ///
+ /// Returns an error if two values could not be compared or if the key
+ /// function (if given) yields an error.
+ #[func]
+ pub fn sorted(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ /// If given, applies this function to the elements in the array to
+ /// determine the keys to sort by.
+ #[named]
+ key: Option<Func>,
+ ) -> SourceResult<Array> {
+ let mut result = Ok(());
+ let mut vec = self.0;
+ let mut key_of = |x: Value| match &key {
+ // NOTE: We are relying on `comemo`'s memoization of function
+ // evaluation to not excessively reevaluate the `key`.
+ Some(f) => f.call(engine, context, [x]),
+ None => Ok(x),
+ };
+ vec.make_mut().sort_by(|a, b| {
+ // Until we get `try` blocks :)
+ match (key_of(a.clone()), key_of(b.clone())) {
+ (Ok(a), Ok(b)) => ops::compare(&a, &b).unwrap_or_else(|err| {
+ if result.is_ok() {
+ result = Err(err).at(span);
+ }
+ Ordering::Equal
+ }),
+ (Err(e), _) | (_, Err(e)) => {
+ if result.is_ok() {
+ result = Err(e);
+ }
+ Ordering::Equal
+ }
+ }
+ });
+ result.map(|_| vec.into())
+ }
+
+ /// Deduplicates all items in the array.
+ ///
+ /// Returns a new array with all duplicate items removed. Only the first
+ /// element of each duplicate is kept.
+ ///
+ /// ```example
+ /// #(1, 1, 2, 3, 1).dedup()
+ /// ```
+ #[func(title = "Deduplicate")]
+ pub fn dedup(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// If given, applies this function to the elements in the array to
+ /// determine the keys to deduplicate by.
+ #[named]
+ key: Option<Func>,
+ ) -> SourceResult<Array> {
+ let mut out = EcoVec::with_capacity(self.0.len());
+ let mut key_of = |x: Value| match &key {
+ // NOTE: We are relying on `comemo`'s memoization of function
+ // evaluation to not excessively reevaluate the `key`.
+ Some(f) => f.call(engine, context, [x]),
+ None => Ok(x),
+ };
+
+ // This algorithm is O(N^2) because we cannot rely on `HashSet` since:
+ // 1. We would like to preserve the order of the elements.
+ // 2. We cannot hash arbitrary `Value`.
+ 'outer: for value in self {
+ let key = key_of(value.clone())?;
+ if out.is_empty() {
+ out.push(value);
+ continue;
+ }
+
+ for second in out.iter() {
+ if ops::equal(&key, &key_of(second.clone())?) {
+ continue 'outer;
+ }
+ }
+
+ out.push(value);
+ }
+
+ Ok(Self(out))
+ }
+
+ /// Converts an array of pairs into a dictionary.
+ /// The first value of each pair is the key, the second the value.
+ ///
+ /// If the same key occurs multiple times, the last value is selected.
+ ///
+ /// ```example
+ /// #(
+ /// ("apples", 2),
+ /// ("peaches", 3),
+ /// ("apples", 5),
+ /// ).to-dict()
+ /// ```
+ #[func]
+ pub fn to_dict(self) -> StrResult<Dict> {
+ self.into_iter()
+ .map(|value| {
+ let value_ty = value.ty();
+ let pair = value.cast::<Array>().map_err(|_| {
+ eco_format!("expected (str, any) pairs, found {}", value_ty)
+ })?;
+ if let [key, value] = pair.as_slice() {
+ let key = key.clone().cast::<Str>().map_err(|_| {
+ eco_format!("expected key of type str, found {}", value.ty())
+ })?;
+ Ok((key, value.clone()))
+ } else {
+ bail!("expected pairs of length 2, found length {}", pair.len());
+ }
+ })
+ .collect()
+ }
+
+ /// Reduces the elements to a single one, by repeatedly applying a reducing
+ /// operation.
+ ///
+ /// If the array is empty, returns `{none}`, otherwise, returns the result
+ /// of the reduction.
+ ///
+ /// The reducing function is a closure with two arguments: an "accumulator",
+ /// and an element.
+ ///
+ /// For arrays with at least one element, this is the same as [`array.fold`]
+ /// with the first element of the array as the initial accumulator value,
+ /// folding every subsequent element into it.
+ #[func]
+ pub fn reduce(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The reducing function. Must have two parameters: One for the
+ /// accumulated value and one for an item.
+ reducer: Func,
+ ) -> SourceResult<Value> {
+ let mut iter = self.into_iter();
+ let mut acc = iter.next().unwrap_or_default();
+ for item in iter {
+ acc = reducer.call(engine, context, [acc, item])?;
+ }
+ Ok(acc)
+ }
+}
+
+/// A value that can be cast to bytes.
+pub struct ToArray(Array);
+
+cast! {
+ ToArray,
+ v: Array => Self(v),
+ v: Bytes => Self(v.iter().map(|&b| Value::Int(b.into())).collect()),
+ v: Version => Self(v.values().iter().map(|&v| Value::Int(v as i64)).collect())
+}
+
+impl Debug for Array {
+ fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
+ f.debug_list().entries(&self.0).finish()
+ }
+}
+
+impl Repr for Array {
+ fn repr(&self) -> EcoString {
+ let max = 40;
+ let mut pieces: Vec<_> = self
+ .iter()
+ .take(max)
+ .map(|value| eco_format!("{}", value.repr()))
+ .collect();
+ if self.len() > max {
+ pieces.push(eco_format!(".. ({} items omitted)", self.len() - max));
+ }
+ repr::pretty_array_like(&pieces, self.len() == 1).into()
+ }
+}
+
+impl Add for Array {
+ type Output = Self;
+
+ fn add(mut self, rhs: Array) -> Self::Output {
+ self += rhs;
+ self
+ }
+}
+
+impl AddAssign for Array {
+ fn add_assign(&mut self, rhs: Self) {
+ self.0.extend(rhs.0);
+ }
+}
+
+impl Extend<Value> for Array {
+ fn extend<T: IntoIterator<Item = Value>>(&mut self, iter: T) {
+ self.0.extend(iter);
+ }
+}
+
+impl FromIterator<Value> for Array {
+ fn from_iter<T: IntoIterator<Item = Value>>(iter: T) -> Self {
+ Self(iter.into_iter().collect())
+ }
+}
+
+impl IntoIterator for Array {
+ type Item = Value;
+ type IntoIter = ecow::vec::IntoIter<Value>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a Array {
+ type Item = &'a Value;
+ type IntoIter = std::slice::Iter<'a, Value>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.iter()
+ }
+}
+
+impl From<EcoVec<Value>> for Array {
+ fn from(v: EcoVec<Value>) -> Self {
+ Array(v)
+ }
+}
+
+impl From<&[Value]> for Array {
+ fn from(v: &[Value]) -> Self {
+ Array(v.into())
+ }
+}
+
+impl<T> Reflect for Vec<T> {
+ fn input() -> CastInfo {
+ Array::input()
+ }
+
+ fn output() -> CastInfo {
+ Array::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Array::castable(value)
+ }
+}
+
+impl<T: Reflect, const N: usize> Reflect for SmallVec<[T; N]> {
+ fn input() -> CastInfo {
+ Array::input()
+ }
+
+ fn output() -> CastInfo {
+ Array::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Array::castable(value)
+ }
+}
+
+impl<T: IntoValue> IntoValue for Vec<T> {
+ fn into_value(self) -> Value {
+ Value::Array(self.into_iter().map(IntoValue::into_value).collect())
+ }
+}
+
+impl<T: IntoValue, const N: usize> IntoValue for SmallVec<[T; N]> {
+ fn into_value(self) -> Value {
+ Value::Array(self.into_iter().map(IntoValue::into_value).collect())
+ }
+}
+
+impl<T: FromValue> FromValue for Vec<T> {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ value.cast::<Array>()?.into_iter().map(Value::cast).collect()
+ }
+}
+
+impl<T: FromValue, const N: usize> FromValue for SmallVec<[T; N]> {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ value.cast::<Array>()?.into_iter().map(Value::cast).collect()
+ }
+}
+
+/// The error message when the array is empty.
+#[cold]
+fn array_is_empty() -> EcoString {
+ "array is empty".into()
+}
+
+/// The out of bounds access error message.
+#[cold]
+fn out_of_bounds(index: i64, len: usize) -> EcoString {
+ eco_format!("array index out of bounds (index: {index}, len: {len})")
+}
+
+/// The out of bounds access error message when no default value was given.
+#[cold]
+fn out_of_bounds_no_default(index: i64, len: usize) -> EcoString {
+ eco_format!(
+ "array index out of bounds (index: {index}, len: {len}) \
+ and no default value was specified",
+ )
+}
diff --git a/crates/typst-library/src/foundations/auto.rs b/crates/typst-library/src/foundations/auto.rs
new file mode 100644
index 00000000..8237b450
--- /dev/null
+++ b/crates/typst-library/src/foundations/auto.rs
@@ -0,0 +1,267 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::EcoString;
+
+use crate::diag::HintedStrResult;
+use crate::foundations::{
+ ty, CastInfo, Fold, FromValue, IntoValue, Reflect, Repr, Resolve, StyleChain, Type,
+ Value,
+};
+
+/// A value that indicates a smart default.
+///
+/// The auto type has exactly one value: `{auto}`.
+///
+/// Parameters that support the `{auto}` value have some smart default or
+/// contextual behaviour. A good example is the [text direction]($text.dir)
+/// parameter. Setting it to `{auto}` lets Typst automatically determine the
+/// direction from the [text language]($text.lang).
+#[ty(cast, name = "auto")]
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct AutoValue;
+
+impl IntoValue for AutoValue {
+ fn into_value(self) -> Value {
+ Value::Auto
+ }
+}
+
+impl FromValue for AutoValue {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ match value {
+ Value::Auto => Ok(Self),
+ _ => Err(Self::error(&value)),
+ }
+ }
+}
+
+impl Reflect for AutoValue {
+ fn input() -> CastInfo {
+ CastInfo::Type(Type::of::<Self>())
+ }
+
+ fn output() -> CastInfo {
+ CastInfo::Type(Type::of::<Self>())
+ }
+
+ fn castable(value: &Value) -> bool {
+ matches!(value, Value::Auto)
+ }
+}
+
+impl Debug for AutoValue {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Auto")
+ }
+}
+
+impl Repr for AutoValue {
+ fn repr(&self) -> EcoString {
+ "auto".into()
+ }
+}
+
+/// A value that can be automatically determined.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum Smart<T> {
+ /// The value should be determined smartly based on the circumstances.
+ Auto,
+ /// A specific value.
+ Custom(T),
+}
+
+impl<T> Smart<T> {
+ /// Whether the value is `Auto`.
+ pub fn is_auto(&self) -> bool {
+ matches!(self, Self::Auto)
+ }
+
+ /// Whether this holds a custom value.
+ pub fn is_custom(&self) -> bool {
+ matches!(self, Self::Custom(_))
+ }
+
+ /// Whether this is a `Smart::Custom(x)` and `f(x)` is true.
+ pub fn is_custom_and<F>(self, f: F) -> bool
+ where
+ F: Fn(T) -> bool,
+ {
+ match self {
+ Self::Auto => false,
+ Self::Custom(x) => f(x),
+ }
+ }
+
+ /// Returns a `Smart<&T>` borrowing the inner `T`.
+ pub fn as_ref(&self) -> Smart<&T> {
+ match self {
+ Smart::Auto => Smart::Auto,
+ Smart::Custom(v) => Smart::Custom(v),
+ }
+ }
+
+ /// Returns the contained custom value.
+ ///
+ /// If the value is [`Smart::Auto`], returns `None`.
+ ///
+ /// Equivalently, this just converts `Smart` to `Option`.
+ pub fn custom(self) -> Option<T> {
+ match self {
+ Self::Auto => None,
+ Self::Custom(x) => Some(x),
+ }
+ }
+
+ /// Map the contained custom value with `f`.
+ pub fn map<F, U>(self, f: F) -> Smart<U>
+ where
+ F: FnOnce(T) -> U,
+ {
+ match self {
+ Self::Auto => Smart::Auto,
+ Self::Custom(x) => Smart::Custom(f(x)),
+ }
+ }
+
+ /// Map the contained custom value with `f` if it contains a custom value,
+ /// otherwise returns `default`.
+ pub fn map_or<F, U>(self, default: U, f: F) -> U
+ where
+ F: FnOnce(T) -> U,
+ {
+ match self {
+ Self::Auto => default,
+ Self::Custom(x) => f(x),
+ }
+ }
+
+ /// Keeps `self` if it contains a custom value, otherwise returns `other`.
+ pub fn or(self, other: Smart<T>) -> Self {
+ match self {
+ Self::Custom(x) => Self::Custom(x),
+ Self::Auto => other,
+ }
+ }
+
+ /// Keeps `self` if it contains a custom value, otherwise returns the
+ /// output of the given function.
+ pub fn or_else<F>(self, f: F) -> Self
+ where
+ F: FnOnce() -> Self,
+ {
+ match self {
+ Self::Custom(x) => Self::Custom(x),
+ Self::Auto => f(),
+ }
+ }
+
+ /// Returns `Auto` if `self` is `Auto`, otherwise calls the provided
+ /// function on the contained value and returns the result.
+ pub fn and_then<F, U>(self, f: F) -> Smart<U>
+ where
+ F: FnOnce(T) -> Smart<U>,
+ {
+ match self {
+ Smart::Auto => Smart::Auto,
+ Smart::Custom(x) => f(x),
+ }
+ }
+
+ /// Returns the contained custom value or a provided default value.
+ pub fn unwrap_or(self, default: T) -> T {
+ match self {
+ Self::Auto => default,
+ Self::Custom(x) => x,
+ }
+ }
+
+ /// Returns the contained custom value or computes a default value.
+ pub fn unwrap_or_else<F>(self, f: F) -> T
+ where
+ F: FnOnce() -> T,
+ {
+ match self {
+ Self::Auto => f(),
+ Self::Custom(x) => x,
+ }
+ }
+
+ /// Returns the contained custom value or the default value.
+ pub fn unwrap_or_default(self) -> T
+ where
+ T: Default,
+ {
+ // we want to do this; the Clippy lint is not type-aware
+ #[allow(clippy::unwrap_or_default)]
+ self.unwrap_or_else(T::default)
+ }
+}
+
+impl<T> Smart<Smart<T>> {
+ /// Removes a single level of nesting, returns `Auto` if the inner or outer value is `Auto`.
+ pub fn flatten(self) -> Smart<T> {
+ match self {
+ Smart::Custom(Smart::Auto) | Smart::Auto => Smart::Auto,
+ Smart::Custom(Smart::Custom(v)) => Smart::Custom(v),
+ }
+ }
+}
+
+impl<T> Default for Smart<T> {
+ fn default() -> Self {
+ Self::Auto
+ }
+}
+
+impl<T: Reflect> Reflect for Smart<T> {
+ fn input() -> CastInfo {
+ T::input() + AutoValue::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output() + AutoValue::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ AutoValue::castable(value) || T::castable(value)
+ }
+}
+
+impl<T: IntoValue> IntoValue for Smart<T> {
+ fn into_value(self) -> Value {
+ match self {
+ Smart::Custom(v) => v.into_value(),
+ Smart::Auto => Value::Auto,
+ }
+ }
+}
+
+impl<T: FromValue> FromValue for Smart<T> {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ match value {
+ Value::Auto => Ok(Self::Auto),
+ v if T::castable(&v) => Ok(Self::Custom(T::from_value(v)?)),
+ _ => Err(Self::error(&value)),
+ }
+ }
+}
+
+impl<T: Resolve> Resolve for Smart<T> {
+ type Output = Smart<T::Output>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.map(|v| v.resolve(styles))
+ }
+}
+
+impl<T: Fold> Fold for Smart<T> {
+ fn fold(self, outer: Self) -> Self {
+ use Smart::Custom;
+ match (self, outer) {
+ (Custom(inner), Custom(outer)) => Custom(inner.fold(outer)),
+ // An explicit `auto` should be respected, thus we don't do
+ // `inner.or(outer)`.
+ (inner, _) => inner,
+ }
+ }
+}
diff --git a/crates/typst-library/src/foundations/bool.rs b/crates/typst-library/src/foundations/bool.rs
new file mode 100644
index 00000000..e88c8c6f
--- /dev/null
+++ b/crates/typst-library/src/foundations/bool.rs
@@ -0,0 +1,26 @@
+use ecow::EcoString;
+
+use crate::foundations::{ty, Repr};
+
+/// A type with two states.
+///
+/// The boolean type has two values: `{true}` and `{false}`. It denotes whether
+/// something is active or enabled.
+///
+/// # Example
+/// ```example
+/// #false \
+/// #true \
+/// #(1 < 2)
+/// ```
+#[ty(cast, title = "Boolean")]
+type bool;
+
+impl Repr for bool {
+ fn repr(&self) -> EcoString {
+ match self {
+ true => "true".into(),
+ false => "false".into(),
+ }
+ }
+}
diff --git a/crates/typst-library/src/foundations/bytes.rs b/crates/typst-library/src/foundations/bytes.rs
new file mode 100644
index 00000000..05fe4763
--- /dev/null
+++ b/crates/typst-library/src/foundations/bytes.rs
@@ -0,0 +1,262 @@
+use std::borrow::Cow;
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Add, AddAssign, Deref};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use serde::{Serialize, Serializer};
+use typst_utils::LazyHash;
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{cast, func, scope, ty, Array, Reflect, Repr, Str, Value};
+
+/// A sequence of bytes.
+///
+/// This is conceptually similar to an array of [integers]($int) between `{0}`
+/// and `{255}`, but represented much more efficiently. You can iterate over it
+/// using a [for loop]($scripting/#loops).
+///
+/// You can convert
+/// - a [string]($str) or an [array] of integers to bytes with the [`bytes`]
+/// constructor
+/// - bytes to a string with the [`str`] constructor, with UTF-8 encoding
+/// - bytes to an array of integers with the [`array`] constructor
+///
+/// When [reading]($read) data from a file, you can decide whether to load it
+/// as a string or as raw bytes.
+///
+/// ```example
+/// #bytes((123, 160, 22, 0)) \
+/// #bytes("Hello 😃")
+///
+/// #let data = read(
+/// "rhino.png",
+/// encoding: none,
+/// )
+///
+/// // Magic bytes.
+/// #array(data.slice(0, 4)) \
+/// #str(data.slice(1, 4))
+/// ```
+#[ty(scope, cast)]
+#[derive(Clone, Hash, Eq, PartialEq)]
+pub struct Bytes(Arc<LazyHash<Cow<'static, [u8]>>>);
+
+impl Bytes {
+ /// Create a buffer from a static byte slice.
+ pub fn from_static(slice: &'static [u8]) -> Self {
+ Self(Arc::new(LazyHash::new(Cow::Borrowed(slice))))
+ }
+
+ /// Return `true` if the length is 0.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// Return a view into the buffer.
+ pub fn as_slice(&self) -> &[u8] {
+ self
+ }
+
+ /// Return a copy of the buffer as a vector.
+ pub fn to_vec(&self) -> Vec<u8> {
+ self.0.to_vec()
+ }
+
+ /// Resolve an index or throw an out of bounds error.
+ fn locate(&self, index: i64) -> StrResult<usize> {
+ self.locate_opt(index).ok_or_else(|| out_of_bounds(index, self.len()))
+ }
+
+ /// Resolve an index, if it is within bounds.
+ ///
+ /// `index == len` is considered in bounds.
+ fn locate_opt(&self, index: i64) -> Option<usize> {
+ let wrapped =
+ if index >= 0 { Some(index) } else { (self.len() as i64).checked_add(index) };
+
+ wrapped
+ .and_then(|v| usize::try_from(v).ok())
+ .filter(|&v| v <= self.0.len())
+ }
+}
+
+#[scope]
+impl Bytes {
+ /// Converts a value to bytes.
+ ///
+ /// - Strings are encoded in UTF-8.
+ /// - Arrays of integers between `{0}` and `{255}` are converted directly. The
+ /// dedicated byte representation is much more efficient than the array
+ /// representation and thus typically used for large byte buffers (e.g. image
+ /// data).
+ ///
+ /// ```example
+ /// #bytes("Hello 😃") \
+ /// #bytes((123, 160, 22, 0))
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The value that should be converted to bytes.
+ value: ToBytes,
+ ) -> Bytes {
+ value.0
+ }
+
+ /// The length in bytes.
+ #[func(title = "Length")]
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Returns the byte at the specified index. Returns the default value if
+ /// the index is out of bounds or fails with an error if no default value
+ /// was specified.
+ #[func]
+ pub fn at(
+ &self,
+ /// The index at which to retrieve the byte.
+ index: i64,
+ /// A default value to return if the index is out of bounds.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ self.locate_opt(index)
+ .and_then(|i| self.0.get(i).map(|&b| Value::Int(b.into())))
+ .or(default)
+ .ok_or_else(|| out_of_bounds_no_default(index, self.len()))
+ }
+
+ /// Extracts a subslice of the bytes. Fails with an error if the start or end
+ /// index is out of bounds.
+ #[func]
+ pub fn slice(
+ &self,
+ /// The start index (inclusive).
+ start: i64,
+ /// The end index (exclusive). If omitted, the whole slice until the end
+ /// is extracted.
+ #[default]
+ end: Option<i64>,
+ /// The number of items to extract. This is equivalent to passing
+ /// `start + count` as the `end` position. Mutually exclusive with
+ /// `end`.
+ #[named]
+ count: Option<i64>,
+ ) -> StrResult<Bytes> {
+ let mut end = end;
+ if end.is_none() {
+ end = count.map(|c: i64| start + c);
+ }
+ let start = self.locate(start)?;
+ let end = self.locate(end.unwrap_or(self.len() as i64))?.max(start);
+ Ok(self.0[start..end].into())
+ }
+}
+
+impl Debug for Bytes {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Bytes({})", self.len())
+ }
+}
+
+impl Repr for Bytes {
+ fn repr(&self) -> EcoString {
+ eco_format!("bytes({})", self.len())
+ }
+}
+
+impl Deref for Bytes {
+ type Target = [u8];
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl AsRef<[u8]> for Bytes {
+ fn as_ref(&self) -> &[u8] {
+ self
+ }
+}
+
+impl From<&[u8]> for Bytes {
+ fn from(slice: &[u8]) -> Self {
+ Self(Arc::new(LazyHash::new(slice.to_vec().into())))
+ }
+}
+
+impl From<Vec<u8>> for Bytes {
+ fn from(vec: Vec<u8>) -> Self {
+ Self(Arc::new(LazyHash::new(vec.into())))
+ }
+}
+
+impl Add for Bytes {
+ type Output = Self;
+
+ fn add(mut self, rhs: Self) -> Self::Output {
+ self += rhs;
+ self
+ }
+}
+
+impl AddAssign for Bytes {
+ fn add_assign(&mut self, rhs: Self) {
+ if rhs.is_empty() {
+ // Nothing to do
+ } else if self.is_empty() {
+ *self = rhs;
+ } else if Arc::strong_count(&self.0) == 1 && matches!(**self.0, Cow::Owned(_)) {
+ Arc::make_mut(&mut self.0).to_mut().extend_from_slice(&rhs);
+ } else {
+ *self = Self::from([self.as_slice(), rhs.as_slice()].concat());
+ }
+ }
+}
+
+impl Serialize for Bytes {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ if serializer.is_human_readable() {
+ serializer.serialize_str(&eco_format!("{self:?}"))
+ } else {
+ serializer.serialize_bytes(self)
+ }
+ }
+}
+
+/// A value that can be cast to bytes.
+pub struct ToBytes(Bytes);
+
+cast! {
+ ToBytes,
+ v: Str => Self(v.as_bytes().into()),
+ v: Array => Self(v.iter()
+ .map(|item| match item {
+ Value::Int(byte @ 0..=255) => Ok(*byte as u8),
+ Value::Int(_) => bail!("number must be between 0 and 255"),
+ value => Err(<u8 as Reflect>::error(value)),
+ })
+ .collect::<Result<Vec<u8>, _>>()?
+ .into()
+ ),
+ v: Bytes => Self(v),
+}
+
+/// The out of bounds access error message.
+#[cold]
+fn out_of_bounds(index: i64, len: usize) -> EcoString {
+ eco_format!("byte index out of bounds (index: {index}, len: {len})")
+}
+
+/// The out of bounds access error message when no default value was given.
+#[cold]
+fn out_of_bounds_no_default(index: i64, len: usize) -> EcoString {
+ eco_format!(
+ "byte index out of bounds (index: {index}, len: {len}) \
+ and no default value was specified",
+ )
+}
diff --git a/crates/typst-library/src/foundations/calc.rs b/crates/typst-library/src/foundations/calc.rs
new file mode 100644
index 00000000..f12ca74c
--- /dev/null
+++ b/crates/typst-library/src/foundations/calc.rs
@@ -0,0 +1,1211 @@
+//! Calculations and processing of numeric values.
+
+use std::cmp;
+use std::cmp::Ordering;
+
+use az::SaturatingAs;
+use typst_syntax::{Span, Spanned};
+use typst_utils::{round_int_with_precision, round_with_precision};
+
+use crate::diag::{bail, At, HintedString, SourceResult, StrResult};
+use crate::foundations::{cast, func, ops, Decimal, IntoValue, Module, Scope, Value};
+use crate::layout::{Angle, Fr, Length, Ratio};
+
+/// A module with calculation definitions.
+pub fn module() -> Module {
+ let mut scope = Scope::new();
+ scope.define_func::<abs>();
+ scope.define_func::<pow>();
+ scope.define_func::<exp>();
+ scope.define_func::<sqrt>();
+ scope.define_func::<root>();
+ scope.define_func::<sin>();
+ scope.define_func::<cos>();
+ scope.define_func::<tan>();
+ scope.define_func::<asin>();
+ scope.define_func::<acos>();
+ scope.define_func::<atan>();
+ scope.define_func::<atan2>();
+ scope.define_func::<sinh>();
+ scope.define_func::<cosh>();
+ scope.define_func::<tanh>();
+ scope.define_func::<log>();
+ scope.define_func::<ln>();
+ scope.define_func::<fact>();
+ scope.define_func::<perm>();
+ scope.define_func::<binom>();
+ scope.define_func::<gcd>();
+ scope.define_func::<lcm>();
+ scope.define_func::<floor>();
+ scope.define_func::<ceil>();
+ scope.define_func::<trunc>();
+ scope.define_func::<fract>();
+ scope.define_func::<round>();
+ scope.define_func::<clamp>();
+ scope.define_func::<min>();
+ scope.define_func::<max>();
+ scope.define_func::<even>();
+ scope.define_func::<odd>();
+ scope.define_func::<rem>();
+ scope.define_func::<div_euclid>();
+ scope.define_func::<rem_euclid>();
+ scope.define_func::<quo>();
+ scope.define("inf", f64::INFINITY);
+ scope.define("pi", std::f64::consts::PI);
+ scope.define("tau", std::f64::consts::TAU);
+ scope.define("e", std::f64::consts::E);
+ Module::new("calc", scope)
+}
+
+/// Calculates the absolute value of a numeric value.
+///
+/// ```example
+/// #calc.abs(-5) \
+/// #calc.abs(5pt - 2cm) \
+/// #calc.abs(2fr) \
+/// #calc.abs(decimal("-342.440"))
+/// ```
+#[func(title = "Absolute")]
+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())),
+ v: Decimal => Self(Value::Decimal(v.abs()))
+}
+
+/// Raises a value to some exponent.
+///
+/// ```example
+/// #calc.pow(2, 3) \
+/// #calc.pow(decimal("2.5"), 2)
+/// ```
+#[func(title = "Power")]
+pub fn pow(
+ /// The callsite span.
+ span: Span,
+ /// The base of the power.
+ ///
+ /// If this is a [`decimal`], the exponent can only be an [integer]($int).
+ base: DecNum,
+ /// The exponent of the power.
+ exponent: Spanned<Num>,
+) -> SourceResult<DecNum> {
+ match exponent.v {
+ _ if exponent.v.float() == 0.0 && base.is_zero() => {
+ 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")
+ }
+ _ => {}
+ };
+
+ match (base, exponent.v) {
+ (DecNum::Int(a), Num::Int(b)) if b >= 0 => a
+ .checked_pow(b as u32)
+ .map(DecNum::Int)
+ .ok_or_else(too_large)
+ .at(span),
+ (DecNum::Decimal(a), Num::Int(b)) => {
+ a.checked_powi(b).map(DecNum::Decimal).ok_or_else(too_large).at(span)
+ }
+ (a, b) => {
+ let Some(a) = a.float() else {
+ return Err(cant_apply_to_decimal_and_float()).at(span);
+ };
+
+ let result = if a == std::f64::consts::E {
+ b.float().exp()
+ } else if a == 2.0 {
+ b.float().exp2()
+ } else if let Num::Int(b) = b {
+ a.powi(b as i32)
+ } else {
+ a.powf(b.float())
+ };
+
+ if result.is_nan() {
+ bail!(span, "the result is not a real number")
+ }
+
+ Ok(DecNum::Float(result))
+ }
+ }
+}
+
+/// Raises a value to some exponent of e.
+///
+/// ```example
+/// #calc.exp(1)
+/// ```
+#[func(title = "Exponential")]
+pub fn exp(
+ /// The callsite span.
+ span: Span,
+ /// The exponent of the power.
+ exponent: Spanned<Num>,
+) -> 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)
+}
+
+/// Calculates the square root of a number.
+///
+/// ```example
+/// #calc.sqrt(16) \
+/// #calc.sqrt(2.5)
+/// ```
+#[func(title = "Square Root")]
+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 real nth root of a number.
+///
+/// If the number is negative, then n must be odd.
+///
+/// ```example
+/// #calc.root(16.0, 4) \
+/// #calc.root(27.0, 3)
+/// ```
+#[func]
+pub fn root(
+ /// The expression to take the root of
+ radicand: f64,
+ /// Which root of the radicand to take
+ index: Spanned<i64>,
+) -> SourceResult<f64> {
+ if index.v == 0 {
+ bail!(index.span, "cannot take the 0th root of a number");
+ } else if radicand < 0.0 {
+ if index.v % 2 == 0 {
+ bail!(
+ index.span,
+ "negative numbers do not have a real nth root when n is even"
+ );
+ } else {
+ Ok(-(-radicand).powf(1.0 / index.v as f64))
+ }
+ } else {
+ Ok(radicand.powf(1.0 / index.v as f64))
+ }
+}
+
+/// Calculates the sine of an angle.
+///
+/// When called with an integer or a float, they will be interpreted as
+/// radians.
+///
+/// ```example
+/// #calc.sin(1.5) \
+/// #calc.sin(90deg)
+/// ```
+#[func(title = "Sine")]
+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
+/// #calc.cos(1.5) \
+/// #calc.cos(90deg)
+/// ```
+#[func(title = "Cosine")]
+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
+/// #calc.tan(1.5) \
+/// #calc.tan(90deg)
+/// ```
+#[func(title = "Tangent")]
+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
+/// #calc.asin(0) \
+/// #calc.asin(1)
+/// ```
+#[func(title = "Arcsine")]
+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
+/// #calc.acos(0) \
+/// #calc.acos(1)
+/// ```
+#[func(title = "Arccosine")]
+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
+/// #calc.atan(0) \
+/// #calc.atan(1)
+/// ```
+#[func(title = "Arctangent")]
+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
+/// #calc.atan2(1, 1) \
+/// #calc.atan2(-2, -3)
+/// ```
+#[func(title = "Four-quadrant Arctangent")]
+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 a hyperbolic angle.
+///
+/// ```example
+/// #calc.sinh(0) \
+/// #calc.sinh(1.5)
+/// ```
+#[func(title = "Hyperbolic Sine")]
+pub fn sinh(
+ /// The hyperbolic angle whose hyperbolic sine to calculate.
+ value: f64,
+) -> f64 {
+ value.sinh()
+}
+
+/// Calculates the hyperbolic cosine of a hyperbolic angle.
+///
+/// ```example
+/// #calc.cosh(0) \
+/// #calc.cosh(1.5)
+/// ```
+#[func(title = "Hyperbolic Cosine")]
+pub fn cosh(
+ /// The hyperbolic angle whose hyperbolic cosine to calculate.
+ value: f64,
+) -> f64 {
+ value.cosh()
+}
+
+/// Calculates the hyperbolic tangent of an hyperbolic angle.
+///
+/// ```example
+/// #calc.tanh(0) \
+/// #calc.tanh(1.5)
+/// ```
+#[func(title = "Hyperbolic Tangent")]
+pub fn tanh(
+ /// The hyperbolic angle whose hyperbolic tangent to calculate.
+ value: f64,
+) -> f64 {
+ value.tanh()
+}
+
+/// Calculates the logarithm of a number.
+///
+/// If the base is not specified, the logarithm is calculated in base 10.
+///
+/// ```example
+/// #calc.log(100)
+/// ```
+#[func(title = "Logarithm")]
+pub fn log(
+ /// The callsite span.
+ span: Span,
+ /// 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>,
+) -> 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
+/// #calc.ln(calc.e)
+/// ```
+#[func(title = "Natural Logarithm")]
+pub fn ln(
+ /// The callsite span.
+ span: Span,
+ /// The number whose logarithm to calculate. Must be strictly positive.
+ value: Spanned<Num>,
+) -> 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
+/// #calc.fact(5)
+/// ```
+#[func(title = "Factorial")]
+pub fn fact(
+ /// The number whose factorial to calculate. Must be non-negative.
+ number: u64,
+) -> StrResult<i64> {
+ Ok(fact_impl(1, number).ok_or_else(too_large)?)
+}
+
+/// Calculates a permutation.
+///
+/// Returns the `k`-permutation of `n`, or the number of ways to choose `k`
+/// items from a set of `n` with regard to order.
+///
+/// ```example
+/// $ "perm"(n, k) &= n!/((n - k)!) \
+/// "perm"(5, 3) &= #calc.perm(5, 3) $
+/// ```
+#[func(title = "Permutation")]
+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_else(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.
+///
+/// Returns the `k`-combination of `n`, or the number of ways to choose `k`
+/// items from a set of `n` without regard to order.
+///
+/// ```example
+/// #calc.binom(10, 5)
+/// ```
+#[func(title = "Binomial")]
+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_else(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
+/// #calc.gcd(7, 42)
+/// ```
+#[func(title = "Greatest Common Divisor")]
+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
+/// #calc.lcm(96, 13)
+/// ```
+#[func(title = "Least Common Multiple")]
+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_else(too_large)?)
+}
+
+/// Rounds a number down to the nearest integer.
+///
+/// If the number is already an integer, it is returned unchanged.
+///
+/// Note that this function will always return an [integer]($int), and will
+/// error if the resulting [`float`] or [`decimal`] is larger than the maximum
+/// 64-bit signed integer or smaller than the minimum for that type.
+///
+/// ```example
+/// #calc.floor(500.1)
+/// #assert(calc.floor(3) == 3)
+/// #assert(calc.floor(3.14) == 3)
+/// #assert(calc.floor(decimal("-3.14")) == -4)
+/// ```
+#[func]
+pub fn floor(
+ /// The number to round down.
+ value: DecNum,
+) -> StrResult<i64> {
+ match value {
+ DecNum::Int(n) => Ok(n),
+ DecNum::Float(n) => Ok(crate::foundations::convert_float_to_int(n.floor())
+ .map_err(|_| too_large())?),
+ DecNum::Decimal(n) => Ok(i64::try_from(n.floor()).map_err(|_| too_large())?),
+ }
+}
+
+/// Rounds a number up to the nearest integer.
+///
+/// If the number is already an integer, it is returned unchanged.
+///
+/// Note that this function will always return an [integer]($int), and will
+/// error if the resulting [`float`] or [`decimal`] is larger than the maximum
+/// 64-bit signed integer or smaller than the minimum for that type.
+///
+/// ```example
+/// #calc.ceil(500.1)
+/// #assert(calc.ceil(3) == 3)
+/// #assert(calc.ceil(3.14) == 4)
+/// #assert(calc.ceil(decimal("-3.14")) == -3)
+/// ```
+#[func]
+pub fn ceil(
+ /// The number to round up.
+ value: DecNum,
+) -> StrResult<i64> {
+ match value {
+ DecNum::Int(n) => Ok(n),
+ DecNum::Float(n) => Ok(crate::foundations::convert_float_to_int(n.ceil())
+ .map_err(|_| too_large())?),
+ DecNum::Decimal(n) => Ok(i64::try_from(n.ceil()).map_err(|_| too_large())?),
+ }
+}
+
+/// Returns the integer part of a number.
+///
+/// If the number is already an integer, it is returned unchanged.
+///
+/// Note that this function will always return an [integer]($int), and will
+/// error if the resulting [`float`] or [`decimal`] is larger than the maximum
+/// 64-bit signed integer or smaller than the minimum for that type.
+///
+/// ```example
+/// #calc.trunc(15.9)
+/// #assert(calc.trunc(3) == 3)
+/// #assert(calc.trunc(-3.7) == -3)
+/// #assert(calc.trunc(decimal("8493.12949582390")) == 8493)
+/// ```
+#[func(title = "Truncate")]
+pub fn trunc(
+ /// The number to truncate.
+ value: DecNum,
+) -> StrResult<i64> {
+ match value {
+ DecNum::Int(n) => Ok(n),
+ DecNum::Float(n) => Ok(crate::foundations::convert_float_to_int(n.trunc())
+ .map_err(|_| too_large())?),
+ DecNum::Decimal(n) => Ok(i64::try_from(n.trunc()).map_err(|_| too_large())?),
+ }
+}
+
+/// Returns the fractional part of a number.
+///
+/// If the number is an integer, returns `0`.
+///
+/// ```example
+/// #calc.fract(-3.1)
+/// #assert(calc.fract(3) == 0)
+/// #assert(calc.fract(decimal("234.23949211")) == decimal("0.23949211"))
+/// ```
+#[func(title = "Fractional")]
+pub fn fract(
+ /// The number to truncate.
+ value: DecNum,
+) -> DecNum {
+ match value {
+ DecNum::Int(_) => DecNum::Int(0),
+ DecNum::Float(n) => DecNum::Float(n.fract()),
+ DecNum::Decimal(n) => DecNum::Decimal(n.fract()),
+ }
+}
+
+/// Rounds a number to the nearest integer away from zero.
+///
+/// Optionally, a number of decimal places can be specified.
+///
+/// If the number of digits is negative, its absolute value will indicate the
+/// amount of significant integer digits to remove before the decimal point.
+///
+/// Note that this function will return the same type as the operand. That is,
+/// applying `round` to a [`float`] will return a `float`, and to a [`decimal`],
+/// another `decimal`. You may explicitly convert the output of this function to
+/// an integer with [`int`], but note that such a conversion will error if the
+/// `float` or `decimal` is larger than the maximum 64-bit signed integer or
+/// smaller than the minimum integer.
+///
+/// In addition, this function can error if there is an attempt to round beyond
+/// the maximum or minimum integer or `decimal`. If the number is a `float`,
+/// such an attempt will cause `{float.inf}` or `{-float.inf}` to be returned
+/// for maximum and minimum respectively.
+///
+/// ```example
+/// #calc.round(3.1415, digits: 2)
+/// #assert(calc.round(3) == 3)
+/// #assert(calc.round(3.14) == 3)
+/// #assert(calc.round(3.5) == 4.0)
+/// #assert(calc.round(3333.45, digits: -2) == 3300.0)
+/// #assert(calc.round(-48953.45, digits: -3) == -49000.0)
+/// #assert(calc.round(3333, digits: -2) == 3300)
+/// #assert(calc.round(-48953, digits: -3) == -49000)
+/// #assert(calc.round(decimal("-6.5")) == decimal("-7"))
+/// #assert(calc.round(decimal("7.123456789"), digits: 6) == decimal("7.123457"))
+/// #assert(calc.round(decimal("3333.45"), digits: -2) == decimal("3300"))
+/// #assert(calc.round(decimal("-48953.45"), digits: -3) == decimal("-49000"))
+/// ```
+#[func]
+pub fn round(
+ /// The number to round.
+ value: DecNum,
+ /// If positive, the number of decimal places.
+ ///
+ /// If negative, the number of significant integer digits that should be
+ /// removed before the decimal point.
+ #[named]
+ #[default(0)]
+ digits: i64,
+) -> StrResult<DecNum> {
+ match value {
+ DecNum::Int(n) => Ok(DecNum::Int(
+ round_int_with_precision(n, digits.saturating_as::<i16>())
+ .ok_or_else(too_large)?,
+ )),
+ DecNum::Float(n) => {
+ Ok(DecNum::Float(round_with_precision(n, digits.saturating_as::<i16>())))
+ }
+ DecNum::Decimal(n) => Ok(DecNum::Decimal(
+ n.round(digits.saturating_as::<i32>()).ok_or_else(too_large)?,
+ )),
+ }
+}
+
+/// Clamps a number between a minimum and maximum value.
+///
+/// ```example
+/// #calc.clamp(5, 0, 4)
+/// #assert(calc.clamp(5, 0, 10) == 5)
+/// #assert(calc.clamp(5, 6, 10) == 6)
+/// #assert(calc.clamp(decimal("5.45"), 2, decimal("45.9")) == decimal("5.45"))
+/// #assert(calc.clamp(decimal("5.45"), decimal("6.75"), 12) == decimal("6.75"))
+/// ```
+#[func]
+pub fn clamp(
+ /// The callsite span.
+ span: Span,
+ /// The number to clamp.
+ value: DecNum,
+ /// The inclusive minimum value.
+ min: DecNum,
+ /// The inclusive maximum value.
+ max: Spanned<DecNum>,
+) -> SourceResult<DecNum> {
+ // Ignore if there are incompatible types (decimal and float) since that
+ // will cause `apply3` below to error before calling clamp, avoiding a
+ // panic.
+ if min
+ .apply2(max.v, |min, max| max < min, |min, max| max < min, |min, max| max < min)
+ .unwrap_or(false)
+ {
+ bail!(max.span, "max must be greater than or equal to min")
+ }
+
+ value
+ .apply3(min, max.v, i64::clamp, f64::clamp, Decimal::clamp)
+ .ok_or_else(cant_apply_to_decimal_and_float)
+ .at(span)
+}
+
+/// Determines the minimum of a sequence of values.
+///
+/// ```example
+/// #calc.min(1, -3, -5, 20, 3, 6) \
+/// #calc.min("typst", "is", "cool")
+/// ```
+#[func(title = "Minimum")]
+pub fn min(
+ /// The callsite span.
+ span: Span,
+ /// The sequence of values from which to extract the minimum.
+ /// Must not be empty.
+ #[variadic]
+ values: Vec<Spanned<Value>>,
+) -> SourceResult<Value> {
+ minmax(span, values, Ordering::Less)
+}
+
+/// Determines the maximum of a sequence of values.
+///
+/// ```example
+/// #calc.max(1, -3, -5, 20, 3, 6) \
+/// #calc.max("typst", "is", "cool")
+/// ```
+#[func(title = "Maximum")]
+pub fn max(
+ /// The callsite span.
+ span: Span,
+ /// The sequence of values from which to extract the maximum.
+ /// Must not be empty.
+ #[variadic]
+ values: Vec<Spanned<Value>>,
+) -> 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 = ops::compare(&v, &extremum).at(span)?;
+ if ordering == goal {
+ extremum = v;
+ }
+ }
+
+ Ok(extremum)
+}
+
+/// Determines whether an integer is even.
+///
+/// ```example
+/// #calc.even(4) \
+/// #calc.even(5) \
+/// #range(10).filter(calc.even)
+/// ```
+#[func]
+pub fn even(
+ /// The number to check for evenness.
+ value: i64,
+) -> bool {
+ value % 2 == 0
+}
+
+/// Determines whether an integer is odd.
+///
+/// ```example
+/// #calc.odd(4) \
+/// #calc.odd(5) \
+/// #range(10).filter(calc.odd)
+/// ```
+#[func]
+pub fn odd(
+ /// The number to check for oddness.
+ value: i64,
+) -> bool {
+ value % 2 != 0
+}
+
+/// Calculates the remainder of two numbers.
+///
+/// The value `calc.rem(x, y)` always has the same sign as `x`, and is smaller
+/// in magnitude than `y`.
+///
+/// This can error if given a [`decimal`] input and the dividend is too small in
+/// magnitude compared to the divisor.
+///
+/// ```example
+/// #calc.rem(7, 3) \
+/// #calc.rem(7, -3) \
+/// #calc.rem(-7, 3) \
+/// #calc.rem(-7, -3) \
+/// #calc.rem(1.75, 0.5)
+/// ```
+#[func(title = "Remainder")]
+pub fn rem(
+ /// The span of the function call.
+ span: Span,
+ /// The dividend of the remainder.
+ dividend: DecNum,
+ /// The divisor of the remainder.
+ divisor: Spanned<DecNum>,
+) -> SourceResult<DecNum> {
+ if divisor.v.is_zero() {
+ bail!(divisor.span, "divisor must not be zero");
+ }
+
+ dividend
+ .apply2(
+ divisor.v,
+ |a, b| Some(DecNum::Int(a % b)),
+ |a, b| Some(DecNum::Float(a % b)),
+ |a, b| a.checked_rem(b).map(DecNum::Decimal),
+ )
+ .ok_or_else(cant_apply_to_decimal_and_float)
+ .at(span)?
+ .ok_or("dividend too small compared to divisor")
+ .at(span)
+}
+
+/// Performs euclidean division of two numbers.
+///
+/// The result of this computation is that of a division rounded to the integer
+/// `{n}` such that the dividend is greater than or equal to `{n}` times the divisor.
+///
+/// ```example
+/// #calc.div-euclid(7, 3) \
+/// #calc.div-euclid(7, -3) \
+/// #calc.div-euclid(-7, 3) \
+/// #calc.div-euclid(-7, -3) \
+/// #calc.div-euclid(1.75, 0.5) \
+/// #calc.div-euclid(decimal("1.75"), decimal("0.5"))
+/// ```
+#[func(title = "Euclidean Division")]
+pub fn div_euclid(
+ /// The callsite span.
+ span: Span,
+ /// The dividend of the division.
+ dividend: DecNum,
+ /// The divisor of the division.
+ divisor: Spanned<DecNum>,
+) -> SourceResult<DecNum> {
+ if divisor.v.is_zero() {
+ bail!(divisor.span, "divisor must not be zero");
+ }
+
+ dividend
+ .apply2(
+ divisor.v,
+ |a, b| Some(DecNum::Int(a.div_euclid(b))),
+ |a, b| Some(DecNum::Float(a.div_euclid(b))),
+ |a, b| a.checked_div_euclid(b).map(DecNum::Decimal),
+ )
+ .ok_or_else(cant_apply_to_decimal_and_float)
+ .at(span)?
+ .ok_or_else(too_large)
+ .at(span)
+}
+
+/// This calculates the least nonnegative remainder of a division.
+///
+/// Warning: Due to a floating point round-off error, the remainder may equal
+/// the absolute value of the divisor if the dividend is much smaller in
+/// magnitude than the divisor and the dividend is negative. This only applies
+/// for floating point inputs.
+///
+/// In addition, this can error if given a [`decimal`] input and the dividend is
+/// too small in magnitude compared to the divisor.
+///
+/// ```example
+/// #calc.rem-euclid(7, 3) \
+/// #calc.rem-euclid(7, -3) \
+/// #calc.rem-euclid(-7, 3) \
+/// #calc.rem-euclid(-7, -3) \
+/// #calc.rem-euclid(1.75, 0.5) \
+/// #calc.rem-euclid(decimal("1.75"), decimal("0.5"))
+/// ```
+#[func(title = "Euclidean Remainder")]
+pub fn rem_euclid(
+ /// The callsite span.
+ span: Span,
+ /// The dividend of the remainder.
+ dividend: DecNum,
+ /// The divisor of the remainder.
+ divisor: Spanned<DecNum>,
+) -> SourceResult<DecNum> {
+ if divisor.v.is_zero() {
+ bail!(divisor.span, "divisor must not be zero");
+ }
+
+ dividend
+ .apply2(
+ divisor.v,
+ |a, b| Some(DecNum::Int(a.rem_euclid(b))),
+ |a, b| Some(DecNum::Float(a.rem_euclid(b))),
+ |a, b| a.checked_rem_euclid(b).map(DecNum::Decimal),
+ )
+ .ok_or_else(cant_apply_to_decimal_and_float)
+ .at(span)?
+ .ok_or("dividend too small compared to divisor")
+ .at(span)
+}
+
+/// Calculates the quotient (floored division) of two numbers.
+///
+/// Note that this function will always return an [integer]($int), and will
+/// error if the resulting [`float`] or [`decimal`] is larger than the maximum
+/// 64-bit signed integer or smaller than the minimum for that type.
+///
+/// ```example
+/// $ "quo"(a, b) &= floor(a/b) \
+/// "quo"(14, 5) &= #calc.quo(14, 5) \
+/// "quo"(3.46, 0.5) &= #calc.quo(3.46, 0.5) $
+/// ```
+#[func(title = "Quotient")]
+pub fn quo(
+ /// The span of the function call.
+ span: Span,
+ /// The dividend of the quotient.
+ dividend: DecNum,
+ /// The divisor of the quotient.
+ divisor: Spanned<DecNum>,
+) -> SourceResult<i64> {
+ if divisor.v.is_zero() {
+ bail!(divisor.span, "divisor must not be zero");
+ }
+
+ let divided = dividend
+ .apply2(
+ divisor.v,
+ |a, b| Some(DecNum::Int(a / b)),
+ |a, b| Some(DecNum::Float(a / b)),
+ |a, b| a.checked_div(b).map(DecNum::Decimal),
+ )
+ .ok_or_else(cant_apply_to_decimal_and_float)
+ .at(span)?
+ .ok_or_else(too_large)
+ .at(span)?;
+
+ floor(divided).at(span)
+}
+
+/// 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 {
+ 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 which can be passed to functions that work with integers, floats,
+/// and decimals.
+#[derive(Debug, Copy, Clone)]
+pub enum DecNum {
+ Int(i64),
+ Float(f64),
+ Decimal(Decimal),
+}
+
+impl DecNum {
+ /// Checks if this number is equivalent to zero.
+ fn is_zero(self) -> bool {
+ match self {
+ Self::Int(i) => i == 0,
+ Self::Float(f) => f == 0.0,
+ Self::Decimal(d) => d.is_zero(),
+ }
+ }
+
+ /// If this `DecNum` holds an integer or float, returns a float.
+ /// Otherwise, returns `None`.
+ fn float(self) -> Option<f64> {
+ match self {
+ Self::Int(i) => Some(i as f64),
+ Self::Float(f) => Some(f),
+ Self::Decimal(_) => None,
+ }
+ }
+
+ /// If this `DecNum` holds an integer or decimal, returns a decimal.
+ /// Otherwise, returns `None`.
+ fn decimal(self) -> Option<Decimal> {
+ match self {
+ Self::Int(i) => Some(Decimal::from(i)),
+ Self::Float(_) => None,
+ Self::Decimal(d) => Some(d),
+ }
+ }
+
+ /// Tries to apply a function to two decimal or numeric arguments.
+ ///
+ /// Fails with `None` if one is a float and the other is a decimal.
+ fn apply2<T>(
+ self,
+ other: Self,
+ int: impl FnOnce(i64, i64) -> T,
+ float: impl FnOnce(f64, f64) -> T,
+ decimal: impl FnOnce(Decimal, Decimal) -> T,
+ ) -> Option<T> {
+ match (self, other) {
+ (Self::Int(a), Self::Int(b)) => Some(int(a, b)),
+ (Self::Decimal(a), Self::Decimal(b)) => Some(decimal(a, b)),
+ (Self::Decimal(a), Self::Int(b)) => Some(decimal(a, Decimal::from(b))),
+ (Self::Int(a), Self::Decimal(b)) => Some(decimal(Decimal::from(a), b)),
+ (a, b) => Some(float(a.float()?, b.float()?)),
+ }
+ }
+
+ /// Tries to apply a function to three decimal or numeric arguments.
+ ///
+ /// Fails with `None` if one is a float and the other is a decimal.
+ fn apply3(
+ self,
+ other: Self,
+ third: Self,
+ int: impl FnOnce(i64, i64, i64) -> i64,
+ float: impl FnOnce(f64, f64, f64) -> f64,
+ decimal: impl FnOnce(Decimal, Decimal, Decimal) -> Decimal,
+ ) -> Option<Self> {
+ match (self, other, third) {
+ (Self::Int(a), Self::Int(b), Self::Int(c)) => Some(Self::Int(int(a, b, c))),
+ (Self::Decimal(a), b, c) => {
+ Some(Self::Decimal(decimal(a, b.decimal()?, c.decimal()?)))
+ }
+ (a, Self::Decimal(b), c) => {
+ Some(Self::Decimal(decimal(a.decimal()?, b, c.decimal()?)))
+ }
+ (a, b, Self::Decimal(c)) => {
+ Some(Self::Decimal(decimal(a.decimal()?, b.decimal()?, c)))
+ }
+ (a, b, c) => Some(Self::Float(float(a.float()?, b.float()?, c.float()?))),
+ }
+ }
+}
+
+cast! {
+ DecNum,
+ self => match self {
+ Self::Int(v) => v.into_value(),
+ Self::Float(v) => v.into_value(),
+ Self::Decimal(v) => v.into_value(),
+ },
+ v: i64 => Self::Int(v),
+ v: f64 => Self::Float(v),
+ v: Decimal => Self::Decimal(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),
+}
+
+/// The error message when the result is too large to be represented.
+#[cold]
+fn too_large() -> &'static str {
+ "the result is too large"
+}
+
+/// The hinted error message when trying to apply an operation to decimal and
+/// float operands.
+#[cold]
+fn cant_apply_to_decimal_and_float() -> HintedString {
+ HintedString::new("cannot apply this operation to a decimal and a float".into())
+ .with_hint(
+ "if loss of precision is acceptable, explicitly cast the \
+ decimal to a float with `float(value)`",
+ )
+}
diff --git a/crates/typst-library/src/foundations/cast.rs b/crates/typst-library/src/foundations/cast.rs
new file mode 100644
index 00000000..84f38f36
--- /dev/null
+++ b/crates/typst-library/src/foundations/cast.rs
@@ -0,0 +1,499 @@
+#[rustfmt::skip]
+#[doc(inline)]
+pub use typst_macros::{cast, Cast};
+
+use std::borrow::Cow;
+use std::fmt::Write;
+use std::hash::Hash;
+use std::ops::Add;
+
+use ecow::eco_format;
+use smallvec::SmallVec;
+use typst_syntax::{Span, Spanned};
+use unicode_math_class::MathClass;
+
+use crate::diag::{At, HintedStrResult, HintedString, SourceResult, StrResult};
+use crate::foundations::{array, repr, NativeElement, Packed, Repr, Str, Type, Value};
+
+/// Determine details of a type.
+///
+/// Type casting works as follows:
+/// - [`Reflect for T`](Reflect) describes the possible Typst values for `T`
+/// (for documentation and autocomplete).
+/// - [`IntoValue for T`](IntoValue) is for conversion from `T -> Value`
+/// (infallible)
+/// - [`FromValue for T`](FromValue) is for conversion from `Value -> T`
+/// (fallible).
+///
+/// We can't use `TryFrom<Value>` due to conflicting impls. We could use
+/// `From<T> for Value`, but that inverses the impl and leads to tons of
+/// `.into()` all over the place that become hard to decipher.
+pub trait Reflect {
+ /// Describe what can be cast into this value.
+ fn input() -> CastInfo;
+
+ /// Describe what this value can be cast into.
+ fn output() -> CastInfo;
+
+ /// Whether the given value can be converted to `T`.
+ ///
+ /// This exists for performance. The check could also be done through the
+ /// [`CastInfo`], but it would be much more expensive (heap allocation +
+ /// dynamic checks instead of optimized machine code for each type).
+ fn castable(value: &Value) -> bool;
+
+ /// Produce an error message for an unacceptable value type.
+ ///
+ /// ```ignore
+ /// assert_eq!(
+ /// <i64 as Reflect>::error(&Value::None),
+ /// "expected integer, found none",
+ /// );
+ /// ```
+ fn error(found: &Value) -> HintedString {
+ Self::input().error(found)
+ }
+}
+
+impl Reflect for Value {
+ fn input() -> CastInfo {
+ CastInfo::Any
+ }
+
+ fn output() -> CastInfo {
+ CastInfo::Any
+ }
+
+ fn castable(_: &Value) -> bool {
+ true
+ }
+}
+
+impl<T: Reflect> Reflect for Spanned<T> {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: NativeElement + Reflect> Reflect for Packed<T> {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for StrResult<T> {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for HintedStrResult<T> {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for SourceResult<T> {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for &T {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for &mut T {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+/// Cast a Rust type into a Typst [`Value`].
+///
+/// See also: [`Reflect`].
+pub trait IntoValue {
+ /// Cast this type into a value.
+ fn into_value(self) -> Value;
+}
+
+impl IntoValue for Value {
+ fn into_value(self) -> Value {
+ self
+ }
+}
+
+impl IntoValue for (&Str, &Value) {
+ fn into_value(self) -> Value {
+ Value::Array(array![self.0.clone(), self.1.clone()])
+ }
+}
+
+impl<T: IntoValue + Clone> IntoValue for Cow<'_, T> {
+ fn into_value(self) -> Value {
+ self.into_owned().into_value()
+ }
+}
+
+impl<T: NativeElement + IntoValue> IntoValue for Packed<T> {
+ fn into_value(self) -> Value {
+ Value::Content(self.pack())
+ }
+}
+
+impl<T: IntoValue> IntoValue for Spanned<T> {
+ fn into_value(self) -> Value {
+ self.v.into_value()
+ }
+}
+
+/// Cast a Rust type or result into a [`SourceResult<Value>`].
+///
+/// Converts `T`, [`StrResult<T>`], or [`SourceResult<T>`] into
+/// [`SourceResult<Value>`] by `Ok`-wrapping or adding span information.
+pub trait IntoResult {
+ /// Cast this type into a value.
+ fn into_result(self, span: Span) -> SourceResult<Value>;
+}
+
+impl<T: IntoValue> IntoResult for T {
+ fn into_result(self, _: Span) -> SourceResult<Value> {
+ Ok(self.into_value())
+ }
+}
+
+impl<T: IntoValue> IntoResult for StrResult<T> {
+ fn into_result(self, span: Span) -> SourceResult<Value> {
+ self.map(IntoValue::into_value).at(span)
+ }
+}
+
+impl<T: IntoValue> IntoResult for HintedStrResult<T> {
+ fn into_result(self, span: Span) -> SourceResult<Value> {
+ self.map(IntoValue::into_value).at(span)
+ }
+}
+
+impl<T: IntoValue> IntoResult for SourceResult<T> {
+ fn into_result(self, _: Span) -> SourceResult<Value> {
+ self.map(IntoValue::into_value)
+ }
+}
+
+impl<T: IntoValue> IntoValue for fn() -> T {
+ fn into_value(self) -> Value {
+ self().into_value()
+ }
+}
+
+/// Try to cast a Typst [`Value`] into a Rust type.
+///
+/// See also: [`Reflect`].
+pub trait FromValue<V = Value>: Sized + Reflect {
+ /// Try to cast the value into an instance of `Self`.
+ fn from_value(value: V) -> HintedStrResult<Self>;
+}
+
+impl FromValue for Value {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ Ok(value)
+ }
+}
+
+impl<T: NativeElement + FromValue> FromValue for Packed<T> {
+ fn from_value(mut value: Value) -> HintedStrResult<Self> {
+ if let Value::Content(content) = value {
+ match content.into_packed::<T>() {
+ Ok(packed) => return Ok(packed),
+ Err(content) => value = Value::Content(content),
+ }
+ }
+ let val = T::from_value(value)?;
+ Ok(Packed::new(val))
+ }
+}
+
+impl<T: FromValue> FromValue<Spanned<Value>> for T {
+ fn from_value(value: Spanned<Value>) -> HintedStrResult<Self> {
+ T::from_value(value.v)
+ }
+}
+
+impl<T: FromValue> FromValue<Spanned<Value>> for Spanned<T> {
+ fn from_value(value: Spanned<Value>) -> HintedStrResult<Self> {
+ let span = value.span;
+ T::from_value(value.v).map(|t| Spanned::new(t, span))
+ }
+}
+
+/// Describes a possible value for a cast.
+#[derive(Debug, Clone, PartialEq, Hash, PartialOrd)]
+pub enum CastInfo {
+ /// Any value is okay.
+ Any,
+ /// A specific value, plus short documentation for that value.
+ Value(Value, &'static str),
+ /// Any value of a type.
+ Type(Type),
+ /// Multiple alternatives.
+ Union(Vec<Self>),
+}
+
+impl CastInfo {
+ /// Produce an error message describing what was expected and what was
+ /// found.
+ pub fn error(&self, found: &Value) -> HintedString {
+ let mut matching_type = false;
+ let mut parts = vec![];
+
+ self.walk(|info| match info {
+ CastInfo::Any => parts.push("anything".into()),
+ CastInfo::Value(value, _) => {
+ parts.push(value.repr());
+ if value.ty() == found.ty() {
+ matching_type = true;
+ }
+ }
+ CastInfo::Type(ty) => parts.push(eco_format!("{ty}")),
+ CastInfo::Union(_) => {}
+ });
+
+ let mut msg = String::from("expected ");
+ if parts.is_empty() {
+ msg.push_str(" nothing");
+ }
+
+ msg.push_str(&repr::separated_list(&parts, "or"));
+
+ if !matching_type {
+ msg.push_str(", found ");
+ write!(msg, "{}", found.ty()).unwrap();
+ }
+
+ let mut msg: HintedString = msg.into();
+
+ if let Value::Int(i) = found {
+ if !matching_type && parts.iter().any(|p| p == "length") {
+ msg.hint(eco_format!("a length needs a unit - did you mean {i}pt?"));
+ }
+ } else if let Value::Str(s) = found {
+ if !matching_type && parts.iter().any(|p| p == "label") {
+ if typst_syntax::is_valid_label_literal_id(s) {
+ msg.hint(eco_format!(
+ "use `<{s}>` or `label({})` to create a label",
+ s.repr()
+ ));
+ } else {
+ msg.hint(eco_format!("use `label({})` to create a label", s.repr()));
+ }
+ }
+ } else if let Value::Decimal(_) = found {
+ if !matching_type && parts.iter().any(|p| p == "float") {
+ msg.hint(eco_format!(
+ "if loss of precision is acceptable, explicitly cast the \
+ decimal to a float with `float(value)`"
+ ));
+ }
+ }
+
+ msg
+ }
+
+ /// Walk all contained non-union infos.
+ pub fn walk<F>(&self, mut f: F)
+ where
+ F: FnMut(&Self),
+ {
+ fn inner<F>(info: &CastInfo, f: &mut F)
+ where
+ F: FnMut(&CastInfo),
+ {
+ if let CastInfo::Union(infos) = info {
+ for child in infos {
+ inner(child, f);
+ }
+ } else {
+ f(info);
+ }
+ }
+
+ inner(self, &mut f)
+ }
+}
+
+impl Add for CastInfo {
+ type Output = Self;
+
+ fn add(self, rhs: Self) -> Self {
+ Self::Union(match (self, rhs) {
+ (Self::Union(mut lhs), Self::Union(rhs)) => {
+ for cast in rhs {
+ if !lhs.contains(&cast) {
+ lhs.push(cast);
+ }
+ }
+ lhs
+ }
+ (Self::Union(mut lhs), rhs) => {
+ if !lhs.contains(&rhs) {
+ lhs.push(rhs);
+ }
+ lhs
+ }
+ (lhs, Self::Union(mut rhs)) => {
+ if !rhs.contains(&lhs) {
+ rhs.insert(0, lhs);
+ }
+ rhs
+ }
+ (lhs, rhs) => vec![lhs, rhs],
+ })
+ }
+}
+
+/// A container for an argument.
+pub trait Container {
+ /// The contained type.
+ type Inner;
+}
+
+impl<T> Container for Option<T> {
+ type Inner = T;
+}
+
+impl<T> Container for Vec<T> {
+ type Inner = T;
+}
+
+impl<T, const N: usize> Container for SmallVec<[T; N]> {
+ type Inner = T;
+}
+
+/// An uninhabitable type.
+#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
+pub enum Never {}
+
+impl Reflect for Never {
+ fn input() -> CastInfo {
+ CastInfo::Union(vec![])
+ }
+
+ fn output() -> CastInfo {
+ CastInfo::Union(vec![])
+ }
+
+ fn castable(_: &Value) -> bool {
+ false
+ }
+}
+
+impl IntoValue for Never {
+ fn into_value(self) -> Value {
+ match self {}
+ }
+}
+
+impl FromValue for Never {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ Err(Self::error(&value))
+ }
+}
+
+cast! {
+ MathClass,
+ self => IntoValue::into_value(match self {
+ MathClass::Normal => "normal",
+ MathClass::Alphabetic => "alphabetic",
+ MathClass::Binary => "binary",
+ MathClass::Closing => "closing",
+ MathClass::Diacritic => "diacritic",
+ MathClass::Fence => "fence",
+ MathClass::GlyphPart => "glyph-part",
+ MathClass::Large => "large",
+ MathClass::Opening => "opening",
+ MathClass::Punctuation => "punctuation",
+ MathClass::Relation => "relation",
+ MathClass::Space => "space",
+ MathClass::Unary => "unary",
+ MathClass::Vary => "vary",
+ MathClass::Special => "special",
+ }),
+ /// The default class for non-special things.
+ "normal" => MathClass::Normal,
+ /// Punctuation, e.g. a comma.
+ "punctuation" => MathClass::Punctuation,
+ /// An opening delimiter, e.g. `(`.
+ "opening" => MathClass::Opening,
+ /// A closing delimiter, e.g. `)`.
+ "closing" => MathClass::Closing,
+ /// A delimiter that is the same on both sides, e.g. `|`.
+ "fence" => MathClass::Fence,
+ /// A large operator like `sum`.
+ "large" => MathClass::Large,
+ /// A relation like `=` or `prec`.
+ "relation" => MathClass::Relation,
+ /// A unary operator like `not`.
+ "unary" => MathClass::Unary,
+ /// A binary operator like `times`.
+ "binary" => MathClass::Binary,
+ /// An operator that can be both unary or binary like `+`.
+ "vary" => MathClass::Vary,
+}
diff --git a/crates/typst-library/src/foundations/content.rs b/crates/typst-library/src/foundations/content.rs
new file mode 100644
index 00000000..a274b8bf
--- /dev/null
+++ b/crates/typst-library/src/foundations/content.rs
@@ -0,0 +1,1007 @@
+use std::any::TypeId;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::iter::{self, Sum};
+use std::marker::PhantomData;
+use std::ops::{Add, AddAssign, Deref, DerefMut};
+use std::sync::Arc;
+
+use comemo::Tracked;
+use ecow::{eco_format, EcoString};
+use serde::{Serialize, Serializer};
+use smallvec::smallvec;
+use typst_syntax::Span;
+use typst_utils::{fat, singleton, LazyHash, SmallBitSet};
+
+use crate::diag::{SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, func, scope, ty, Context, Dict, Element, Fields, IntoValue, Label,
+ NativeElement, Recipe, RecipeIndex, Repr, Selector, Str, Style, StyleChain, Styles,
+ Value,
+};
+use crate::introspection::Location;
+use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides};
+use crate::model::{Destination, EmphElem, LinkElem, StrongElem};
+use crate::text::UnderlineElem;
+
+/// A piece of document content.
+///
+/// This type is at the heart of Typst. All markup you write and most
+/// [functions]($function) you call produce content values. You can create a
+/// content value by enclosing markup in square brackets. This is also how you
+/// pass content to functions.
+///
+/// # Example
+/// ```example
+/// Type of *Hello!* is
+/// #type([*Hello!*])
+/// ```
+///
+/// Content can be added with the `+` operator,
+/// [joined together]($scripting/#blocks) and multiplied with integers. Wherever
+/// content is expected, you can also pass a [string]($str) or `{none}`.
+///
+/// # Representation
+/// Content consists of elements with fields. When constructing an element with
+/// its _element function,_ you provide these fields as arguments and when you
+/// have a content value, you can access its fields with [field access
+/// syntax]($scripting/#field-access).
+///
+/// Some fields are required: These must be provided when constructing an
+/// element and as a consequence, they are always available through field access
+/// on content of that type. Required fields are marked as such in the
+/// documentation.
+///
+/// Most fields are optional: Like required fields, they can be passed to the
+/// element function to configure them for a single element. However, these can
+/// also be configured with [set rules]($styling/#set-rules) to apply them to
+/// all elements within a scope. Optional fields are only available with field
+/// access syntax when they were explicitly passed to the element function, not
+/// when they result from a set rule.
+///
+/// Each element has a default appearance. However, you can also completely
+/// customize its appearance with a [show rule]($styling/#show-rules). The show
+/// rule is passed the element. It can access the element's field and produce
+/// arbitrary content from it.
+///
+/// In the web app, you can hover over a content variable to see exactly which
+/// elements the content is composed of and what fields they have.
+/// Alternatively, you can inspect the output of the [`repr`] function.
+#[ty(scope, cast)]
+#[derive(Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Content {
+ /// The partially element-dependent inner data.
+ inner: Arc<Inner<dyn Bounds>>,
+ /// The element's source code location.
+ span: Span,
+}
+
+/// The inner representation behind the `Arc`.
+#[derive(Hash)]
+struct Inner<T: ?Sized + 'static> {
+ /// An optional label attached to the element.
+ label: Option<Label>,
+ /// The element's location which identifies it in the layouted output.
+ location: Option<Location>,
+ /// Manages the element during realization.
+ /// - If bit 0 is set, the element is prepared.
+ /// - If bit n is set, the element is guarded against the n-th show rule
+ /// recipe from the top of the style chain (counting from 1).
+ lifecycle: SmallBitSet,
+ /// The element's raw data.
+ elem: LazyHash<T>,
+}
+
+impl Content {
+ /// Creates a new content from an element.
+ pub fn new<T: NativeElement>(elem: T) -> Self {
+ Self {
+ inner: Arc::new(Inner {
+ label: None,
+ location: None,
+ lifecycle: SmallBitSet::new(),
+ elem: elem.into(),
+ }),
+ span: Span::detached(),
+ }
+ }
+
+ /// Creates a empty sequence content.
+ pub fn empty() -> Self {
+ singleton!(Content, SequenceElem::default().pack()).clone()
+ }
+
+ /// Get the element of this content.
+ pub fn elem(&self) -> Element {
+ self.inner.elem.dyn_elem()
+ }
+
+ /// Get the span of the content.
+ pub fn span(&self) -> Span {
+ self.span
+ }
+
+ /// Set the span of the content.
+ pub fn spanned(mut self, span: Span) -> Self {
+ if self.span.is_detached() {
+ self.span = span;
+ }
+ self
+ }
+
+ /// Get the label of the content.
+ pub fn label(&self) -> Option<Label> {
+ self.inner.label
+ }
+
+ /// Attach a label to the content.
+ pub fn labelled(mut self, label: Label) -> Self {
+ self.set_label(label);
+ self
+ }
+
+ /// Set the label of the content.
+ pub fn set_label(&mut self, label: Label) {
+ self.make_mut().label = Some(label);
+ }
+
+ /// Assigns a location to the content.
+ ///
+ /// This identifies the content and e.g. makes it linkable by
+ /// `.linked(Destination::Location(loc))`.
+ ///
+ /// Useful in combination with [`Location::variant`].
+ pub fn located(mut self, loc: Location) -> Self {
+ self.set_location(loc);
+ self
+ }
+
+ /// Set the location of the content.
+ pub fn set_location(&mut self, location: Location) {
+ self.make_mut().location = Some(location);
+ }
+
+ /// Check whether a show rule recipe is disabled.
+ pub fn is_guarded(&self, index: RecipeIndex) -> bool {
+ self.inner.lifecycle.contains(index.0)
+ }
+
+ /// Disable a show rule recipe.
+ pub fn guarded(mut self, index: RecipeIndex) -> Self {
+ self.make_mut().lifecycle.insert(index.0);
+ self
+ }
+
+ /// Whether this content has already been prepared.
+ pub fn is_prepared(&self) -> bool {
+ self.inner.lifecycle.contains(0)
+ }
+
+ /// Mark this content as prepared.
+ pub fn mark_prepared(&mut self) {
+ self.make_mut().lifecycle.insert(0);
+ }
+
+ /// Get a field by ID.
+ ///
+ /// This is the preferred way to access fields. However, you can only use it
+ /// if you have set the field IDs yourself or are using the field IDs
+ /// generated by the `#[elem]` macro.
+ pub fn get(
+ &self,
+ id: u8,
+ styles: Option<StyleChain>,
+ ) -> Result<Value, FieldAccessError> {
+ if id == 255 {
+ if let Some(label) = self.label() {
+ return Ok(label.into_value());
+ }
+ }
+ match styles {
+ Some(styles) => self.inner.elem.field_with_styles(id, styles),
+ None => self.inner.elem.field(id),
+ }
+ }
+
+ /// Get a field by name.
+ ///
+ /// If you have access to the field IDs of the element, use [`Self::get`]
+ /// instead.
+ pub fn get_by_name(&self, name: &str) -> Result<Value, FieldAccessError> {
+ if name == "label" {
+ if let Some(label) = self.label() {
+ return Ok(label.into_value());
+ }
+ }
+ let id = self.elem().field_id(name).ok_or(FieldAccessError::Unknown)?;
+ self.get(id, None)
+ }
+
+ /// Get a field by ID, returning a missing field error if it does not exist.
+ ///
+ /// This is the preferred way to access fields. However, you can only use it
+ /// if you have set the field IDs yourself or are using the field IDs
+ /// generated by the `#[elem]` macro.
+ pub fn field(&self, id: u8) -> StrResult<Value> {
+ self.get(id, None)
+ .map_err(|e| e.message(self, self.elem().field_name(id).unwrap()))
+ }
+
+ /// Get a field by name, returning a missing field error if it does not
+ /// exist.
+ ///
+ /// If you have access to the field IDs of the element, use [`Self::field`]
+ /// instead.
+ pub fn field_by_name(&self, name: &str) -> StrResult<Value> {
+ self.get_by_name(name).map_err(|e| e.message(self, name))
+ }
+
+ /// Resolve all fields with the styles and save them in-place.
+ pub fn materialize(&mut self, styles: StyleChain) {
+ self.make_mut().elem.materialize(styles);
+ }
+
+ /// Create a new sequence element from multiples elements.
+ pub fn sequence(iter: impl IntoIterator<Item = Self>) -> Self {
+ let vec: Vec<_> = iter.into_iter().collect();
+ if vec.is_empty() {
+ Self::empty()
+ } else if vec.len() == 1 {
+ vec.into_iter().next().unwrap()
+ } else {
+ SequenceElem::new(vec).into()
+ }
+ }
+
+ /// Whether the contained element is of type `T`.
+ pub fn is<T: NativeElement>(&self) -> bool {
+ self.inner.elem.dyn_type_id() == TypeId::of::<T>()
+ }
+
+ /// Downcasts the element to a packed value.
+ pub fn to_packed<T: NativeElement>(&self) -> Option<&Packed<T>> {
+ Packed::from_ref(self)
+ }
+
+ /// Downcasts the element to a mutable packed value.
+ pub fn to_packed_mut<T: NativeElement>(&mut self) -> Option<&mut Packed<T>> {
+ Packed::from_mut(self)
+ }
+
+ /// Downcasts the element into an owned packed value.
+ pub fn into_packed<T: NativeElement>(self) -> Result<Packed<T>, Self> {
+ Packed::from_owned(self)
+ }
+
+ /// Extract the raw underlying element.
+ pub fn unpack<T: NativeElement>(self) -> Result<T, Self> {
+ self.into_packed::<T>().map(Packed::unpack)
+ }
+
+ /// Makes sure the content is not shared and returns a mutable reference to
+ /// the inner data.
+ fn make_mut(&mut self) -> &mut Inner<dyn Bounds> {
+ let arc = &mut self.inner;
+ if Arc::strong_count(arc) > 1 || Arc::weak_count(arc) > 0 {
+ *self = arc.elem.dyn_clone(arc, self.span);
+ }
+ Arc::get_mut(&mut self.inner).unwrap()
+ }
+
+ /// Whether the contained element has the given capability.
+ pub fn can<C>(&self) -> bool
+ where
+ C: ?Sized + 'static,
+ {
+ self.elem().can::<C>()
+ }
+
+ /// Cast to a trait object if the contained element has the given
+ /// capability.
+ pub fn with<C>(&self) -> Option<&C>
+ where
+ C: ?Sized + 'static,
+ {
+ // Safety: The vtable comes from the `Capable` implementation which
+ // guarantees to return a matching vtable for `Packed<T>` and `C`.
+ // Since any `Packed<T>` is a repr(transparent) `Content`, we can also
+ // use a `*const Content` pointer.
+ let vtable = self.elem().vtable()(TypeId::of::<C>())?;
+ let data = self as *const Content as *const ();
+ Some(unsafe { &*fat::from_raw_parts(data, vtable.as_ptr()) })
+ }
+
+ /// Cast to a mutable trait object if the contained element has the given
+ /// capability.
+ pub fn with_mut<C>(&mut self) -> Option<&mut C>
+ where
+ C: ?Sized + 'static,
+ {
+ // Safety: The vtable comes from the `Capable` implementation which
+ // guarantees to return a matching vtable for `Packed<T>` and `C`.
+ // Since any `Packed<T>` is a repr(transparent) `Content`, we can also
+ // use a `*const Content` pointer.
+ //
+ // The resulting trait object contains an `&mut Packed<T>`. We do _not_
+ // need to ensure that we hold the only reference to the `Arc` here
+ // because `Packed<T>`'s DerefMut impl will take care of that if
+ // mutable access is required.
+ let vtable = self.elem().vtable()(TypeId::of::<C>())?;
+ let data = self as *mut Content as *mut ();
+ Some(unsafe { &mut *fat::from_raw_parts_mut(data, vtable.as_ptr()) })
+ }
+
+ /// Whether the content is an empty sequence.
+ pub fn is_empty(&self) -> bool {
+ let Some(sequence) = self.to_packed::<SequenceElem>() else {
+ return false;
+ };
+
+ sequence.children.is_empty()
+ }
+
+ /// Also auto expands sequence of sequences into flat sequence
+ pub fn sequence_recursive_for_each<'a>(&'a self, f: &mut impl FnMut(&'a Self)) {
+ if let Some(sequence) = self.to_packed::<SequenceElem>() {
+ for child in &sequence.children {
+ child.sequence_recursive_for_each(f);
+ }
+ } else {
+ f(self);
+ }
+ }
+
+ /// Style this content with a recipe, eagerly applying it if possible.
+ pub fn styled_with_recipe(
+ self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ recipe: Recipe,
+ ) -> SourceResult<Self> {
+ if recipe.selector().is_none() {
+ recipe.apply(engine, context, self)
+ } else {
+ Ok(self.styled(recipe))
+ }
+ }
+
+ /// Repeat this content `count` times.
+ pub fn repeat(&self, count: usize) -> Self {
+ Self::sequence(std::iter::repeat_with(|| self.clone()).take(count))
+ }
+
+ /// Style this content with a style entry.
+ pub fn styled(mut self, style: impl Into<Style>) -> Self {
+ if let Some(style_elem) = self.to_packed_mut::<StyledElem>() {
+ style_elem.styles.apply_one(style.into());
+ self
+ } else {
+ self.styled_with_map(style.into().into())
+ }
+ }
+
+ /// Style this content with a full style map.
+ pub fn styled_with_map(mut self, styles: Styles) -> Self {
+ if styles.is_empty() {
+ return self;
+ }
+
+ if let Some(style_elem) = self.to_packed_mut::<StyledElem>() {
+ style_elem.styles.apply(styles);
+ self
+ } else {
+ StyledElem::new(self, styles).into()
+ }
+ }
+
+ /// Style this content with a full style map in-place.
+ pub fn style_in_place(&mut self, styles: Styles) {
+ if styles.is_empty() {
+ return;
+ }
+
+ if let Some(style_elem) = self.to_packed_mut::<StyledElem>() {
+ style_elem.styles.apply(styles);
+ } else {
+ *self = StyledElem::new(std::mem::take(self), styles).into();
+ }
+ }
+
+ /// Queries the content tree for all elements that match the given selector.
+ ///
+ /// Elements produced in `show` rules will not be included in the results.
+ pub fn query(&self, selector: Selector) -> Vec<Content> {
+ let mut results = Vec::new();
+ self.traverse(&mut |element| {
+ if selector.matches(&element, None) {
+ results.push(element);
+ }
+ });
+ results
+ }
+
+ /// Queries the content tree for the first element that match the given
+ /// selector.
+ ///
+ /// Elements produced in `show` rules will not be included in the results.
+ pub fn query_first(&self, selector: Selector) -> Option<Content> {
+ let mut result = None;
+ self.traverse(&mut |element| {
+ if result.is_none() && selector.matches(&element, None) {
+ result = Some(element);
+ }
+ });
+ result
+ }
+
+ /// Extracts the plain text of this content.
+ pub fn plain_text(&self) -> EcoString {
+ let mut text = EcoString::new();
+ self.traverse(&mut |element| {
+ if let Some(textable) = element.with::<dyn PlainText>() {
+ textable.plain_text(&mut text);
+ }
+ });
+ text
+ }
+
+ /// Traverse this content.
+ fn traverse<F>(&self, f: &mut F)
+ where
+ F: FnMut(Content),
+ {
+ f(self.clone());
+
+ self.inner
+ .elem
+ .fields()
+ .into_iter()
+ .for_each(|(_, value)| walk_value(value, f));
+
+ /// Walks a given value to find any content that matches the selector.
+ fn walk_value<F>(value: Value, f: &mut F)
+ where
+ F: FnMut(Content),
+ {
+ match value {
+ Value::Content(content) => content.traverse(f),
+ Value::Array(array) => {
+ for value in array {
+ walk_value(value, f);
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+}
+
+impl Content {
+ /// Strongly emphasize this content.
+ pub fn strong(self) -> Self {
+ StrongElem::new(self).pack()
+ }
+
+ /// Emphasize this content.
+ pub fn emph(self) -> Self {
+ EmphElem::new(self).pack()
+ }
+
+ /// Underline this content.
+ pub fn underlined(self) -> Self {
+ UnderlineElem::new(self).pack()
+ }
+
+ /// Link the content somewhere.
+ pub fn linked(self, dest: Destination) -> Self {
+ self.styled(LinkElem::set_dests(smallvec![dest]))
+ }
+
+ /// Set alignments for this content.
+ pub fn aligned(self, align: Alignment) -> Self {
+ self.styled(AlignElem::set_alignment(align))
+ }
+
+ /// Pad this content at the sides.
+ pub 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()
+ }
+
+ /// Transform this content's contents without affecting layout.
+ pub fn moved(self, delta: Axes<Rel<Length>>) -> Self {
+ MoveElem::new(self).with_dx(delta.x).with_dy(delta.y).pack()
+ }
+}
+
+#[scope]
+impl Content {
+ /// The content's element function. This function can be used to create the element
+ /// contained in this content. It can be used in set and show rules for the
+ /// element. Can be compared with global functions to check whether you have
+ /// a specific
+ /// kind of element.
+ #[func]
+ pub fn func(&self) -> Element {
+ self.elem()
+ }
+
+ /// Whether the content has the specified field.
+ #[func]
+ pub fn has(
+ &self,
+ /// The field to look for.
+ field: Str,
+ ) -> bool {
+ if field.as_str() == "label" {
+ return self.label().is_some();
+ }
+
+ let Some(id) = self.elem().field_id(&field) else {
+ return false;
+ };
+
+ self.inner.elem.has(id)
+ }
+
+ /// Access the specified field on the content. Returns the default value if
+ /// the field does not exist or fails with an error if no default value was
+ /// specified.
+ #[func]
+ pub fn at(
+ &self,
+ /// The field to access.
+ field: Str,
+ /// A default value to return if the field does not exist.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ self.get_by_name(&field)
+ .or_else(|e| default.ok_or(e))
+ .map_err(|e| e.message_no_default(self, &field))
+ }
+
+ /// Returns the fields of this content.
+ ///
+ /// ```example
+ /// #rect(
+ /// width: 10cm,
+ /// height: 10cm,
+ /// ).fields()
+ /// ```
+ #[func]
+ pub fn fields(&self) -> Dict {
+ let mut dict = self.inner.elem.fields();
+ if let Some(label) = self.label() {
+ dict.insert("label".into(), label.into_value());
+ }
+ dict
+ }
+
+ /// The location of the content. This is only available on content returned
+ /// by [query] or provided by a [show rule]($reference/styling/#show-rules),
+ /// for other content it will be `{none}`. The resulting location can be
+ /// used with [counters]($counter), [state] and [queries]($query).
+ #[func]
+ pub fn location(&self) -> Option<Location> {
+ self.inner.location
+ }
+}
+
+impl Default for Content {
+ fn default() -> Self {
+ Self::empty()
+ }
+}
+
+impl Debug for Content {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.inner.elem.fmt(f)
+ }
+}
+
+impl<T: NativeElement> From<T> for Content {
+ fn from(value: T) -> Self {
+ Self::new(value)
+ }
+}
+
+impl PartialEq for Content {
+ fn eq(&self, other: &Self) -> bool {
+ // Additional short circuit for different elements.
+ self.elem() == other.elem() && self.inner.elem.dyn_eq(other)
+ }
+}
+
+impl Repr for Content {
+ fn repr(&self) -> EcoString {
+ self.inner.elem.repr()
+ }
+}
+
+impl Add for Content {
+ type Output = Self;
+
+ fn add(self, mut rhs: Self) -> Self::Output {
+ let mut lhs = self;
+ match (lhs.to_packed_mut::<SequenceElem>(), rhs.to_packed_mut::<SequenceElem>()) {
+ (Some(seq_lhs), Some(rhs)) => {
+ seq_lhs.children.extend(rhs.children.iter().cloned());
+ lhs
+ }
+ (Some(seq_lhs), None) => {
+ seq_lhs.children.push(rhs);
+ lhs
+ }
+ (None, Some(rhs_seq)) => {
+ rhs_seq.children.insert(0, lhs);
+ rhs
+ }
+ (None, None) => Self::sequence([lhs, rhs]),
+ }
+ }
+}
+
+impl<'a> Add<&'a Self> for Content {
+ type Output = Self;
+
+ fn add(self, rhs: &'a Self) -> Self::Output {
+ let mut lhs = self;
+ match (lhs.to_packed_mut::<SequenceElem>(), rhs.to_packed::<SequenceElem>()) {
+ (Some(seq_lhs), Some(rhs)) => {
+ seq_lhs.children.extend(rhs.children.iter().cloned());
+ lhs
+ }
+ (Some(seq_lhs), None) => {
+ seq_lhs.children.push(rhs.clone());
+ lhs
+ }
+ (None, Some(_)) => {
+ let mut rhs = rhs.clone();
+ rhs.to_packed_mut::<SequenceElem>().unwrap().children.insert(0, lhs);
+ rhs
+ }
+ (None, None) => Self::sequence([lhs, rhs.clone()]),
+ }
+ }
+}
+
+impl AddAssign for Content {
+ fn add_assign(&mut self, rhs: Self) {
+ *self = std::mem::take(self) + rhs;
+ }
+}
+
+impl AddAssign<&Self> for Content {
+ fn add_assign(&mut self, rhs: &Self) {
+ *self = std::mem::take(self) + rhs;
+ }
+}
+
+impl Sum for Content {
+ fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
+ Self::sequence(iter)
+ }
+}
+
+impl Serialize for Content {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.collect_map(
+ iter::once(("func".into(), self.func().name().into_value()))
+ .chain(self.fields()),
+ )
+ }
+}
+
+/// The trait that combines all the other traits into a trait object.
+trait Bounds: Debug + Repr + Fields + Send + Sync + 'static {
+ fn dyn_type_id(&self) -> TypeId;
+ fn dyn_elem(&self) -> Element;
+ fn dyn_clone(&self, inner: &Inner<dyn Bounds>, span: Span) -> Content;
+ fn dyn_hash(&self, hasher: &mut dyn Hasher);
+ fn dyn_eq(&self, other: &Content) -> bool;
+}
+
+impl<T: NativeElement> Bounds for T {
+ fn dyn_type_id(&self) -> TypeId {
+ TypeId::of::<Self>()
+ }
+
+ fn dyn_elem(&self) -> Element {
+ Self::elem()
+ }
+
+ fn dyn_clone(&self, inner: &Inner<dyn Bounds>, span: Span) -> Content {
+ Content {
+ inner: Arc::new(Inner {
+ label: inner.label,
+ location: inner.location,
+ lifecycle: inner.lifecycle.clone(),
+ elem: LazyHash::reuse(self.clone(), &inner.elem),
+ }),
+ span,
+ }
+ }
+
+ fn dyn_hash(&self, mut state: &mut dyn Hasher) {
+ TypeId::of::<Self>().hash(&mut state);
+ self.hash(&mut state);
+ }
+
+ fn dyn_eq(&self, other: &Content) -> bool {
+ let Some(other) = other.to_packed::<Self>() else {
+ return false;
+ };
+ *self == **other
+ }
+}
+
+impl Hash for dyn Bounds {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.dyn_hash(state);
+ }
+}
+
+/// A packed element of a static type.
+#[derive(Clone, PartialEq, Hash)]
+#[repr(transparent)]
+pub struct Packed<T: NativeElement>(
+ /// Invariant: Must be of type `T`.
+ Content,
+ PhantomData<T>,
+);
+
+impl<T: NativeElement> Packed<T> {
+ /// Pack element while retaining its static type.
+ pub fn new(element: T) -> Self {
+ // Safety: The element is known to be of type `T`.
+ Packed(element.pack(), PhantomData)
+ }
+
+ /// Try to cast type-erased content into a statically known packed element.
+ pub fn from_ref(content: &Content) -> Option<&Self> {
+ if content.is::<T>() {
+ // Safety:
+ // - We have checked the type.
+ // - Packed<T> is repr(transparent).
+ return Some(unsafe { std::mem::transmute::<&Content, &Packed<T>>(content) });
+ }
+ None
+ }
+
+ /// Try to cast type-erased content into a statically known packed element.
+ pub fn from_mut(content: &mut Content) -> Option<&mut Self> {
+ if content.is::<T>() {
+ // Safety:
+ // - We have checked the type.
+ // - Packed<T> is repr(transparent).
+ return Some(unsafe {
+ std::mem::transmute::<&mut Content, &mut Packed<T>>(content)
+ });
+ }
+ None
+ }
+
+ /// Try to cast type-erased content into a statically known packed element.
+ pub fn from_owned(content: Content) -> Result<Self, Content> {
+ if content.is::<T>() {
+ // Safety:
+ // - We have checked the type.
+ // - Packed<T> is repr(transparent).
+ return Ok(unsafe { std::mem::transmute::<Content, Packed<T>>(content) });
+ }
+ Err(content)
+ }
+
+ /// Pack back into content.
+ pub fn pack(self) -> Content {
+ self.0
+ }
+
+ /// Extract the raw underlying element.
+ pub fn unpack(self) -> T {
+ // This function doesn't yet need owned self, but might in the future.
+ (*self).clone()
+ }
+
+ /// The element's span.
+ pub fn span(&self) -> Span {
+ self.0.span()
+ }
+
+ /// Set the span of the element.
+ pub fn spanned(self, span: Span) -> Self {
+ Self(self.0.spanned(span), PhantomData)
+ }
+
+ /// Accesses the label of the element.
+ pub fn label(&self) -> Option<Label> {
+ self.0.label()
+ }
+
+ /// Accesses the location of the element.
+ pub fn location(&self) -> Option<Location> {
+ self.0.location()
+ }
+
+ /// Sets the location of the element.
+ pub fn set_location(&mut self, location: Location) {
+ self.0.set_location(location);
+ }
+}
+
+impl<T: NativeElement> AsRef<T> for Packed<T> {
+ fn as_ref(&self) -> &T {
+ self
+ }
+}
+
+impl<T: NativeElement> AsMut<T> for Packed<T> {
+ fn as_mut(&mut self) -> &mut T {
+ self
+ }
+}
+
+impl<T: NativeElement> Deref for Packed<T> {
+ type Target = T;
+
+ fn deref(&self) -> &Self::Target {
+ // Safety:
+ // - Packed<T> guarantees that the content trait object wraps
+ // an element of type `T`.
+ // - This downcast works the same way as dyn Any's does. We can't reuse
+ // that one because we don't want to pay the cost for every deref.
+ let elem = &*self.0.inner.elem;
+ unsafe { &*(elem as *const dyn Bounds as *const T) }
+ }
+}
+
+impl<T: NativeElement> DerefMut for Packed<T> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ // Safety:
+ // - Packed<T> guarantees that the content trait object wraps
+ // an element of type `T`.
+ // - We have guaranteed unique access thanks to `make_mut`.
+ // - This downcast works the same way as dyn Any's does. We can't reuse
+ // that one because we don't want to pay the cost for every deref.
+ let elem = &mut *self.0.make_mut().elem;
+ unsafe { &mut *(elem as *mut dyn Bounds as *mut T) }
+ }
+}
+
+impl<T: NativeElement + Debug> Debug for Packed<T> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+/// A sequence of content.
+#[elem(Debug, Repr, PartialEq)]
+pub struct SequenceElem {
+ /// The elements.
+ #[required]
+ pub children: Vec<Content>,
+}
+
+impl Debug for SequenceElem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Sequence ")?;
+ f.debug_list().entries(&self.children).finish()
+ }
+}
+
+// Derive is currently incompatible with `elem` macro.
+#[allow(clippy::derivable_impls)]
+impl Default for SequenceElem {
+ fn default() -> Self {
+ Self { children: Default::default() }
+ }
+}
+
+impl PartialEq for SequenceElem {
+ fn eq(&self, other: &Self) -> bool {
+ self.children.iter().eq(other.children.iter())
+ }
+}
+
+impl Repr for SequenceElem {
+ fn repr(&self) -> EcoString {
+ if self.children.is_empty() {
+ "[]".into()
+ } else {
+ let elements = crate::foundations::repr::pretty_array_like(
+ &self.children.iter().map(|c| c.inner.elem.repr()).collect::<Vec<_>>(),
+ false,
+ );
+ eco_format!("sequence{}", elements)
+ }
+ }
+}
+
+/// Content alongside styles.
+#[elem(Debug, Repr, PartialEq)]
+pub struct StyledElem {
+ /// The content.
+ #[required]
+ pub child: Content,
+ /// The styles.
+ #[required]
+ pub styles: Styles,
+}
+
+impl Debug for StyledElem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ for style in self.styles.iter() {
+ writeln!(f, "#{style:?}")?;
+ }
+ self.child.fmt(f)
+ }
+}
+
+impl PartialEq for StyledElem {
+ fn eq(&self, other: &Self) -> bool {
+ self.child == other.child
+ }
+}
+
+impl Repr for StyledElem {
+ fn repr(&self) -> EcoString {
+ eco_format!("styled(child: {}, ..)", self.child.repr())
+ }
+}
+
+/// Tries to extract the plain-text representation of the element.
+pub trait PlainText {
+ /// Write this element's plain text into the given buffer.
+ fn plain_text(&self, text: &mut EcoString);
+}
+
+/// An error arising when trying to access a field of content.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum FieldAccessError {
+ Unknown,
+ Unset,
+ Internal,
+}
+
+impl FieldAccessError {
+ /// Formats the error message given the content and the field name.
+ #[cold]
+ pub fn message(self, content: &Content, field: &str) -> EcoString {
+ let elem_name = content.elem().name();
+ match self {
+ FieldAccessError::Unknown => {
+ eco_format!("{elem_name} does not have field {}", field.repr())
+ }
+ FieldAccessError::Unset => {
+ eco_format!(
+ "field {} in {elem_name} is not known at this point",
+ field.repr()
+ )
+ }
+ FieldAccessError::Internal => {
+ eco_format!(
+ "internal error when accessing field {} in {elem_name} – this is a bug",
+ field.repr()
+ )
+ }
+ }
+ }
+
+ /// Formats the error message for an `at` calls without a default value.
+ #[cold]
+ pub fn message_no_default(self, content: &Content, field: &str) -> EcoString {
+ let mut msg = self.message(content, field);
+ msg.push_str(" and no default was specified");
+ msg
+ }
+}
diff --git a/crates/typst-library/src/foundations/context.rs b/crates/typst-library/src/foundations/context.rs
new file mode 100644
index 00000000..bf4bdcd2
--- /dev/null
+++ b/crates/typst-library/src/foundations/context.rs
@@ -0,0 +1,85 @@
+use comemo::Track;
+
+use crate::diag::{bail, Hint, HintedStrResult, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, Args, Construct, Content, Func, Packed, Show, StyleChain, Value,
+};
+use crate::introspection::{Locatable, Location};
+
+/// Data that is contextually made available to code.
+///
+/// _Contextual_ functions and expressions require the presence of certain
+/// pieces of context to be evaluated. This includes things like `text.lang`,
+/// `measure`, or `counter(heading).get()`.
+#[derive(Debug, Default, Clone, Hash)]
+pub struct Context<'a> {
+ /// The location in the document.
+ pub location: Option<Location>,
+ /// The active styles.
+ pub styles: Option<StyleChain<'a>>,
+}
+
+impl<'a> Context<'a> {
+ /// An empty context.
+ pub fn none() -> Self {
+ Self::default()
+ }
+
+ /// Create a new context from its parts.
+ pub fn new(location: Option<Location>, styles: Option<StyleChain<'a>>) -> Self {
+ Self { location, styles }
+ }
+}
+
+#[comemo::track]
+impl<'a> Context<'a> {
+ /// Try to extract the location.
+ pub fn location(&self) -> HintedStrResult<Location> {
+ require(self.location)
+ }
+
+ /// Try to extract the styles.
+ pub fn styles(&self) -> HintedStrResult<StyleChain<'a>> {
+ require(self.styles)
+ }
+
+ /// Guard access to the introspector by requiring at least some piece of context.
+ pub fn introspect(&self) -> HintedStrResult<()> {
+ require(self.location.map(|_| ()).or(self.styles.map(|_| ())))
+ }
+}
+
+/// Extracts an optional piece of context, yielding an error with hints if
+/// it isn't available.
+fn require<T>(val: Option<T>) -> HintedStrResult<T> {
+ val.ok_or("can only be used when context is known")
+ .hint("try wrapping this in a `context` expression")
+ .hint(
+ "the `context` expression should wrap everything that depends on this function",
+ )
+}
+
+/// Executes a `context` block.
+#[elem(Construct, Locatable, Show)]
+pub struct ContextElem {
+ /// The function to call with the context.
+ #[required]
+ #[internal]
+ func: Func,
+}
+
+impl Construct for ContextElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl Show for Packed<ContextElem> {
+ #[typst_macros::time(name = "context", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let loc = self.location().unwrap();
+ let context = Context::new(Some(loc), Some(styles));
+ Ok(self.func.call::<[Value; 0]>(engine, context.track(), [])?.display())
+ }
+}
diff --git a/crates/typst-library/src/foundations/datetime.rs b/crates/typst-library/src/foundations/datetime.rs
new file mode 100644
index 00000000..d15cd417
--- /dev/null
+++ b/crates/typst-library/src/foundations/datetime.rs
@@ -0,0 +1,580 @@
+use std::cmp::Ordering;
+use std::hash::Hash;
+use std::ops::{Add, Sub};
+
+use ecow::{eco_format, EcoString, EcoVec};
+use time::error::{Format, InvalidFormatDescription};
+use time::macros::format_description;
+use time::{format_description, Month, PrimitiveDateTime};
+
+use crate::diag::{bail, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, func, repr, scope, ty, Dict, Duration, Repr, Smart, Str, Value,
+};
+use crate::World;
+
+/// Represents a date, a time, or a combination of both.
+///
+/// Can be created by either specifying a custom datetime using this type's
+/// constructor function or getting the current date with
+/// [`datetime.today`]($datetime.today).
+///
+/// # Example
+/// ```example
+/// #let date = datetime(
+/// year: 2020,
+/// month: 10,
+/// day: 4,
+/// )
+///
+/// #date.display() \
+/// #date.display(
+/// "y:[year repr:last_two]"
+/// )
+///
+/// #let time = datetime(
+/// hour: 18,
+/// minute: 2,
+/// second: 23,
+/// )
+///
+/// #time.display() \
+/// #time.display(
+/// "h:[hour repr:12][period]"
+/// )
+/// ```
+///
+/// # Datetime and Duration
+/// You can get a [duration] by subtracting two datetime:
+/// ```example
+/// #let first-of-march = datetime(day: 1, month: 3, year: 2024)
+/// #let first-of-jan = datetime(day: 1, month: 1, year: 2024)
+/// #let distance = first-of-march - first-of-jan
+/// #distance.hours()
+/// ```
+///
+/// You can also add/subtract a datetime and a duration to retrieve a new,
+/// offset datetime:
+/// ```example
+/// #let date = datetime(day: 1, month: 3, year: 2024)
+/// #let two-days = duration(days: 2)
+/// #let two-days-earlier = date - two-days
+/// #let two-days-later = date + two-days
+///
+/// #date.display() \
+/// #two-days-earlier.display() \
+/// #two-days-later.display()
+/// ```
+///
+/// # Format
+/// You can specify a customized formatting using the
+/// [`display`]($datetime.display) method. The format of a datetime is
+/// specified by providing _components_ with a specified number of _modifiers_.
+/// A component represents a certain part of the datetime that you want to
+/// display, and with the help of modifiers you can define how you want to
+/// display that component. In order to display a component, you wrap the name
+/// of the component in square brackets (e.g. `[[year]]` will display the year).
+/// In order to add modifiers, you add a space after the component name followed
+/// by the name of the modifier, a colon and the value of the modifier (e.g.
+/// `[[month repr:short]]` will display the short representation of the month).
+///
+/// The possible combination of components and their respective modifiers is as
+/// follows:
+///
+/// - `year`: Displays the year of the datetime.
+/// - `padding`: Can be either `zero`, `space` or `none`. Specifies how the
+/// year is padded.
+/// - `repr` Can be either `full` in which case the full year is displayed or
+/// `last_two` in which case only the last two digits are displayed.
+/// - `sign`: Can be either `automatic` or `mandatory`. Specifies when the
+/// sign should be displayed.
+/// - `month`: Displays the month of the datetime.
+/// - `padding`: Can be either `zero`, `space` or `none`. Specifies how the
+/// month is padded.
+/// - `repr`: Can be either `numerical`, `long` or `short`. Specifies if the
+/// month should be displayed as a number or a word. Unfortunately, when
+/// choosing the word representation, it can currently only display the
+/// English version. In the future, it is planned to support localization.
+/// - `day`: Displays the day of the datetime.
+/// - `padding`: Can be either `zero`, `space` or `none`. Specifies how the
+/// day is padded.
+/// - `week_number`: Displays the week number of the datetime.
+/// - `padding`: Can be either `zero`, `space` or `none`. Specifies how the
+/// week number is padded.
+/// - `repr`: Can be either `ISO`, `sunday` or `monday`. In the case of `ISO`,
+/// week numbers are between 1 and 53, while the other ones are between 0
+/// and 53.
+/// - `weekday`: Displays the weekday of the date.
+/// - `repr` Can be either `long`, `short`, `sunday` or `monday`. In the case
+/// of `long` and `short`, the corresponding English name will be displayed
+/// (same as for the month, other languages are currently not supported). In
+/// the case of `sunday` and `monday`, the numerical value will be displayed
+/// (assuming Sunday and Monday as the first day of the week, respectively).
+/// - `one_indexed`: Can be either `true` or `false`. Defines whether the
+/// numerical representation of the week starts with 0 or 1.
+/// - `hour`: Displays the hour of the date.
+/// - `padding`: Can be either `zero`, `space` or `none`. Specifies how the
+/// hour is padded.
+/// - `repr`: Can be either `24` or `12`. Changes whether the hour is
+/// displayed in the 24-hour or 12-hour format.
+/// - `period`: The AM/PM part of the hour
+/// - `case`: Can be `lower` to display it in lower case and `upper` to
+/// display it in upper case.
+/// - `minute`: Displays the minute of the date.
+/// - `padding`: Can be either `zero`, `space` or `none`. Specifies how the
+/// minute is padded.
+/// - `second`: Displays the second of the date.
+/// - `padding`: Can be either `zero`, `space` or `none`. Specifies how the
+/// second is padded.
+///
+/// Keep in mind that not always all components can be used. For example, if you
+/// create a new datetime with `{datetime(year: 2023, month: 10, day: 13)}`, it
+/// will be stored as a plain date internally, meaning that you cannot use
+/// components such as `hour` or `minute`, which would only work on datetimes
+/// that have a specified time.
+#[ty(scope, cast)]
+#[derive(Debug, Clone, Copy, PartialEq, Hash)]
+pub enum Datetime {
+ /// Representation as a date.
+ Date(time::Date),
+ /// Representation as a time.
+ Time(time::Time),
+ /// Representation as a combination of date and time.
+ Datetime(time::PrimitiveDateTime),
+}
+
+impl Datetime {
+ /// Create a datetime from year, month, and day.
+ pub fn from_ymd(year: i32, month: u8, day: u8) -> Option<Self> {
+ Some(Datetime::Date(
+ time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day)
+ .ok()?,
+ ))
+ }
+
+ /// Create a datetime from hour, minute, and second.
+ pub fn from_hms(hour: u8, minute: u8, second: u8) -> Option<Self> {
+ Some(Datetime::Time(time::Time::from_hms(hour, minute, second).ok()?))
+ }
+
+ /// Create a datetime from day and time.
+ pub fn from_ymd_hms(
+ year: i32,
+ month: u8,
+ day: u8,
+ hour: u8,
+ minute: u8,
+ second: u8,
+ ) -> Option<Self> {
+ let date =
+ time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day)
+ .ok()?;
+ let time = time::Time::from_hms(hour, minute, second).ok()?;
+ Some(Datetime::Datetime(PrimitiveDateTime::new(date, time)))
+ }
+
+ /// Try to parse a dictionary as a TOML date.
+ pub fn from_toml_dict(dict: &Dict) -> Option<Self> {
+ if dict.len() != 1 {
+ return None;
+ }
+
+ let Ok(Value::Str(string)) = dict.get("$__toml_private_datetime") else {
+ return None;
+ };
+
+ if let Ok(d) = time::PrimitiveDateTime::parse(
+ string,
+ &format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z"),
+ ) {
+ Self::from_ymd_hms(
+ d.year(),
+ d.month() as u8,
+ d.day(),
+ d.hour(),
+ d.minute(),
+ d.second(),
+ )
+ } else if let Ok(d) = time::PrimitiveDateTime::parse(
+ string,
+ &format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]"),
+ ) {
+ Self::from_ymd_hms(
+ d.year(),
+ d.month() as u8,
+ d.day(),
+ d.hour(),
+ d.minute(),
+ d.second(),
+ )
+ } else if let Ok(d) =
+ time::Date::parse(string, &format_description!("[year]-[month]-[day]"))
+ {
+ Self::from_ymd(d.year(), d.month() as u8, d.day())
+ } else if let Ok(d) =
+ time::Time::parse(string, &format_description!("[hour]:[minute]:[second]"))
+ {
+ Self::from_hms(d.hour(), d.minute(), d.second())
+ } else {
+ None
+ }
+ }
+
+ /// Which kind of variant this datetime stores.
+ pub fn kind(&self) -> &'static str {
+ match self {
+ Datetime::Datetime(_) => "datetime",
+ Datetime::Date(_) => "date",
+ Datetime::Time(_) => "time",
+ }
+ }
+}
+
+#[scope]
+impl Datetime {
+ /// Creates a new datetime.
+ ///
+ /// You can specify the [datetime] using a year, month, day, hour, minute,
+ /// and second.
+ ///
+ /// _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`]($datetime.display) method
+ /// will choose a different formatting by default.
+ ///
+ /// ```example
+ /// #datetime(
+ /// year: 2012,
+ /// month: 8,
+ /// day: 3,
+ /// ).display()
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The year of the datetime.
+ #[named]
+ year: Option<i32>,
+ /// The month of the datetime.
+ #[named]
+ month: Option<Month>,
+ /// The day of the datetime.
+ #[named]
+ day: Option<u8>,
+ /// The hour of the datetime.
+ #[named]
+ hour: Option<u8>,
+ /// The minute of the datetime.
+ #[named]
+ minute: Option<u8>,
+ /// The second of the datetime.
+ #[named]
+ second: Option<u8>,
+ ) -> StrResult<Datetime> {
+ let time = match (hour, minute, second) {
+ (Some(hour), Some(minute), Some(second)) => {
+ match time::Time::from_hms(hour, minute, second) {
+ 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, month, day) {
+ 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")
+ }
+ })
+ }
+
+ /// Returns the current date.
+ ///
+ /// ```example
+ /// Today's date is
+ /// #datetime.today().display().
+ /// ```
+ #[func]
+ pub fn today(
+ /// The engine.
+ engine: &mut Engine,
+ /// 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>,
+ ) -> StrResult<Datetime> {
+ Ok(engine
+ .world
+ .today(offset.custom())
+ .ok_or("unable to get the current date")?)
+ }
+
+ /// Displays the datetime in a specified format.
+ ///
+ /// Depending on whether you have defined just a date, a time or both, the
+ /// default format will be different. If you specified a date, it will be
+ /// `[[year]-[month]-[day]]`. If you specified a time, it will be
+ /// `[[hour]:[minute]:[second]]`. In the case of a datetime, it will be
+ /// `[[year]-[month]-[day] [hour]:[minute]:[second]]`.
+ ///
+ /// See the [format syntax]($datetime/#format) for more information.
+ #[func]
+ pub fn display(
+ &self,
+ /// The format used to display the datetime.
+ #[default]
+ pattern: Smart<DisplayPattern>,
+ ) -> StrResult<EcoString> {
+ let pat = |s| format_description::parse_borrowed::<2>(s).unwrap();
+ let result = match pattern {
+ Smart::Auto => match self {
+ Self::Date(date) => date.format(&pat("[year]-[month]-[day]")),
+ Self::Time(time) => time.format(&pat("[hour]:[minute]:[second]")),
+ Self::Datetime(datetime) => {
+ datetime.format(&pat("[year]-[month]-[day] [hour]:[minute]:[second]"))
+ }
+ },
+
+ Smart::Custom(DisplayPattern(_, format)) => match self {
+ Self::Date(date) => date.format(&format),
+ Self::Time(time) => time.format(&format),
+ Self::Datetime(datetime) => datetime.format(&format),
+ },
+ };
+ result.map(EcoString::from).map_err(format_time_format_error)
+ }
+
+ /// The year if it was specified, or `{none}` for times without a date.
+ #[func]
+ pub fn year(&self) -> Option<i32> {
+ match self {
+ Self::Date(date) => Some(date.year()),
+ Self::Time(_) => None,
+ Self::Datetime(datetime) => Some(datetime.year()),
+ }
+ }
+
+ /// The month if it was specified, or `{none}` for times without a date.
+ #[func]
+ pub fn month(&self) -> Option<u8> {
+ match self {
+ Self::Date(date) => Some(date.month().into()),
+ Self::Time(_) => None,
+ Self::Datetime(datetime) => Some(datetime.month().into()),
+ }
+ }
+
+ /// The weekday (counting Monday as 1) or `{none}` for times without a date.
+ #[func]
+ pub fn weekday(&self) -> Option<u8> {
+ match self {
+ Self::Date(date) => Some(date.weekday().number_from_monday()),
+ Self::Time(_) => None,
+ Self::Datetime(datetime) => Some(datetime.weekday().number_from_monday()),
+ }
+ }
+
+ /// The day if it was specified, or `{none}` for times without a date.
+ #[func]
+ pub fn day(&self) -> Option<u8> {
+ match self {
+ Self::Date(date) => Some(date.day()),
+ Self::Time(_) => None,
+ Self::Datetime(datetime) => Some(datetime.day()),
+ }
+ }
+
+ /// The hour if it was specified, or `{none}` for dates without a time.
+ #[func]
+ pub fn hour(&self) -> Option<u8> {
+ match self {
+ Self::Date(_) => None,
+ Self::Time(time) => Some(time.hour()),
+ Self::Datetime(datetime) => Some(datetime.hour()),
+ }
+ }
+
+ /// The minute if it was specified, or `{none}` for dates without a time.
+ #[func]
+ pub fn minute(&self) -> Option<u8> {
+ match self {
+ Self::Date(_) => None,
+ Self::Time(time) => Some(time.minute()),
+ Self::Datetime(datetime) => Some(datetime.minute()),
+ }
+ }
+
+ /// The second if it was specified, or `{none}` for dates without a time.
+ #[func]
+ pub fn second(&self) -> Option<u8> {
+ match self {
+ Self::Date(_) => None,
+ Self::Time(time) => Some(time.second()),
+ Self::Datetime(datetime) => Some(datetime.second()),
+ }
+ }
+
+ /// The ordinal (day of the year), or `{none}` for times without a date.
+ #[func]
+ pub fn ordinal(&self) -> Option<u16> {
+ match self {
+ Self::Datetime(datetime) => Some(datetime.ordinal()),
+ Self::Date(date) => Some(date.ordinal()),
+ Self::Time(_) => None,
+ }
+ }
+}
+
+impl Repr for Datetime {
+ fn repr(&self) -> EcoString {
+ let year = self.year().map(|y| eco_format!("year: {}", (y as i64).repr()));
+ let month = self.month().map(|m| eco_format!("month: {}", (m as i64).repr()));
+ let day = self.day().map(|d| eco_format!("day: {}", (d as i64).repr()));
+ let hour = self.hour().map(|h| eco_format!("hour: {}", (h as i64).repr()));
+ let minute = self.minute().map(|m| eco_format!("minute: {}", (m as i64).repr()));
+ let second = self.second().map(|s| eco_format!("second: {}", (s as i64).repr()));
+ let filtered = [year, month, day, hour, minute, second]
+ .into_iter()
+ .flatten()
+ .collect::<EcoVec<_>>();
+
+ eco_format!("datetime{}", &repr::pretty_array_like(&filtered, false))
+ }
+}
+
+impl PartialOrd for Datetime {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ match (self, other) {
+ (Self::Datetime(a), Self::Datetime(b)) => a.partial_cmp(b),
+ (Self::Date(a), Self::Date(b)) => a.partial_cmp(b),
+ (Self::Time(a), Self::Time(b)) => a.partial_cmp(b),
+ _ => None,
+ }
+ }
+}
+
+impl Add<Duration> for Datetime {
+ type Output = Self;
+
+ fn add(self, rhs: Duration) -> Self::Output {
+ let rhs: time::Duration = rhs.into();
+ match self {
+ Self::Datetime(datetime) => Self::Datetime(datetime + rhs),
+ Self::Date(date) => Self::Date(date + rhs),
+ Self::Time(time) => Self::Time(time + rhs),
+ }
+ }
+}
+
+impl Sub<Duration> for Datetime {
+ type Output = Self;
+
+ fn sub(self, rhs: Duration) -> Self::Output {
+ let rhs: time::Duration = rhs.into();
+ match self {
+ Self::Datetime(datetime) => Self::Datetime(datetime - rhs),
+ Self::Date(date) => Self::Date(date - rhs),
+ Self::Time(time) => Self::Time(time - rhs),
+ }
+ }
+}
+
+impl Sub for Datetime {
+ type Output = StrResult<Duration>;
+
+ fn sub(self, rhs: Self) -> Self::Output {
+ match (self, rhs) {
+ (Self::Datetime(a), Self::Datetime(b)) => Ok((a - b).into()),
+ (Self::Date(a), Self::Date(b)) => Ok((a - b).into()),
+ (Self::Time(a), Self::Time(b)) => Ok((a - b).into()),
+ (a, b) => bail!("cannot subtract {} from {}", b.kind(), a.kind()),
+ }
+ }
+}
+
+/// A format in which a datetime can be displayed.
+pub struct DisplayPattern(Str, format_description::OwnedFormatItem);
+
+cast! {
+ DisplayPattern,
+ self => self.0.into_value(),
+ v: Str => {
+ let item = format_description::parse_owned::<2>(&v)
+ .map_err(format_time_invalid_format_description_error)?;
+ Self(v, item)
+ }
+}
+
+cast! {
+ Month,
+ v: u8 => Self::try_from(v).map_err(|_| "month is invalid")?
+}
+
+/// Format the `Format` error of the time crate in an appropriate way.
+fn format_time_format_error(error: Format) -> EcoString {
+ match error {
+ Format::InvalidComponent(name) => eco_format!("invalid component '{}'", name),
+ Format::InsufficientTypeInformation { .. } => {
+ "failed to format datetime (insufficient information)".into()
+ }
+ err => eco_format!("failed to format datetime in the requested format ({err})"),
+ }
+}
+
+/// Format the `InvalidFormatDescription` error of the time crate in an
+/// appropriate way.
+fn format_time_invalid_format_description_error(
+ error: InvalidFormatDescription,
+) -> EcoString {
+ match error {
+ InvalidFormatDescription::UnclosedOpeningBracket { index, .. } => {
+ eco_format!("missing closing bracket for bracket at index {}", index)
+ }
+ InvalidFormatDescription::InvalidComponentName { name, index, .. } => {
+ eco_format!("invalid component name '{}' at index {}", name, index)
+ }
+ InvalidFormatDescription::InvalidModifier { value, index, .. } => {
+ eco_format!("invalid modifier '{}' at index {}", value, index)
+ }
+ InvalidFormatDescription::Expected { what, index, .. } => {
+ eco_format!("expected {} at index {}", what, index)
+ }
+ InvalidFormatDescription::MissingComponentName { index, .. } => {
+ eco_format!("expected component name at index {}", index)
+ }
+ InvalidFormatDescription::MissingRequiredModifier { name, index, .. } => {
+ eco_format!(
+ "missing required modifier {} for component at index {}",
+ name,
+ index
+ )
+ }
+ InvalidFormatDescription::NotSupported { context, what, index, .. } => {
+ eco_format!("{} is not supported in {} at index {}", what, context, index)
+ }
+ err => eco_format!("failed to parse datetime format ({err})"),
+ }
+}
diff --git a/crates/typst-library/src/foundations/decimal.rs b/crates/typst-library/src/foundations/decimal.rs
new file mode 100644
index 00000000..cf11e1dd
--- /dev/null
+++ b/crates/typst-library/src/foundations/decimal.rs
@@ -0,0 +1,499 @@
+use std::fmt::{self, Display, Formatter};
+use std::hash::{Hash, Hasher};
+use std::ops::Neg;
+use std::str::FromStr;
+
+use ecow::{eco_format, EcoString};
+use rust_decimal::MathematicalOps;
+use typst_syntax::{ast, Span, Spanned};
+
+use crate::diag::{warning, At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{cast, func, repr, scope, ty, Repr, Str};
+use crate::World;
+
+/// A fixed-point decimal number type.
+///
+/// This type should be used for precise arithmetic operations on numbers
+/// represented in base 10. A typical use case is representing currency.
+///
+/// # Example
+/// ```example
+/// Decimal: #(decimal("0.1") + decimal("0.2")) \
+/// Float: #(0.1 + 0.2)
+/// ```
+///
+/// # Construction and casts
+/// To create a decimal number, use the `{decimal(string)}` constructor, such as
+/// in `{decimal("3.141592653")}` **(note the double quotes!)**. This
+/// constructor preserves all given fractional digits, provided they are
+/// representable as per the limits specified below (otherwise, an error is
+/// raised).
+///
+/// You can also convert any [integer]($int) to a decimal with the
+/// `{decimal(int)}` constructor, e.g. `{decimal(59)}`. However, note that
+/// constructing a decimal from a [floating-point number]($float), while
+/// supported, **is an imprecise conversion and therefore discouraged.** A
+/// warning will be raised if Typst detects that there was an accidental `float`
+/// to `decimal` cast through its constructor, e.g. if writing `{decimal(3.14)}`
+/// (note the lack of double quotes, indicating this is an accidental `float`
+/// cast and therefore imprecise). It is recommended to use strings for
+/// constant decimal values instead (e.g. `{decimal("3.14")}`).
+///
+/// The precision of a `float` to `decimal` cast can be slightly improved by
+/// rounding the result to 15 digits with [`calc.round`]($calc.round), but there
+/// are still no precision guarantees for that kind of conversion.
+///
+/// # Operations
+/// Basic arithmetic operations are supported on two decimals and on pairs of
+/// decimals and integers.
+///
+/// Built-in operations between `float` and `decimal` are not supported in order
+/// to guard against accidental loss of precision. They will raise an error
+/// instead.
+///
+/// Certain `calc` functions, such as trigonometric functions and power between
+/// two real numbers, are also only supported for `float` (although raising
+/// `decimal` to integer exponents is supported). You can opt into potentially
+/// imprecise operations with the `{float(decimal)}` constructor, which casts
+/// the `decimal` number into a `float`, allowing for operations without
+/// precision guarantees.
+///
+/// # Displaying decimals
+/// To display a decimal, simply insert the value into the document. To only
+/// display a certain number of digits, [round]($calc.round) the decimal first.
+/// Localized formatting of decimals and other numbers is not yet supported, but
+/// planned for the future.
+///
+/// You can convert decimals to strings using the [`str`] constructor. This way,
+/// you can post-process the displayed representation, e.g. to replace the
+/// period with a comma (as a stand-in for proper built-in localization to
+/// languages that use the comma).
+///
+/// # Precision and limits
+/// A `decimal` number has a limit of 28 to 29 significant base-10 digits. This
+/// includes the sum of digits before and after the decimal point. As such,
+/// numbers with more fractional digits have a smaller range. The maximum and
+/// minimum `decimal` numbers have a value of `{79228162514264337593543950335}`
+/// and `{-79228162514264337593543950335}` respectively. In contrast with
+/// [`float`], this type does not support infinity or NaN, so overflowing or
+/// underflowing operations will raise an error.
+///
+/// Typical operations between `decimal` numbers, such as addition,
+/// multiplication, and [power]($calc.pow) to an integer, will be highly precise
+/// due to their fixed-point representation. Note, however, that multiplication
+/// and division may not preserve all digits in some edge cases: while they are
+/// considered precise, digits past the limits specified above are rounded off
+/// and lost, so some loss of precision beyond the maximum representable digits
+/// is possible. Note that this behavior can be observed not only when dividing,
+/// but also when multiplying by numbers between 0 and 1, as both operations can
+/// push a number's fractional digits beyond the limits described above, leading
+/// to rounding. When those two operations do not surpass the digit limits, they
+/// are fully precise.
+#[ty(scope, cast)]
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Decimal(rust_decimal::Decimal);
+
+impl Decimal {
+ pub const ZERO: Self = Self(rust_decimal::Decimal::ZERO);
+ pub const ONE: Self = Self(rust_decimal::Decimal::ONE);
+ pub const MIN: Self = Self(rust_decimal::Decimal::MIN);
+ pub const MAX: Self = Self(rust_decimal::Decimal::MAX);
+
+ /// Whether this decimal value is zero.
+ pub const fn is_zero(self) -> bool {
+ self.0.is_zero()
+ }
+
+ /// Whether this decimal value is negative.
+ pub const fn is_negative(self) -> bool {
+ self.0.is_sign_negative()
+ }
+
+ /// Whether this decimal has fractional part equal to zero (is an integer).
+ pub fn is_integer(self) -> bool {
+ self.0.is_integer()
+ }
+
+ /// Computes the absolute value of this decimal.
+ pub fn abs(self) -> Self {
+ Self(self.0.abs())
+ }
+
+ /// Computes the largest integer less than or equal to this decimal.
+ ///
+ /// A decimal is returned as this may not be within `i64`'s range of
+ /// values.
+ pub fn floor(self) -> Self {
+ Self(self.0.floor())
+ }
+
+ /// Computes the smallest integer greater than or equal to this decimal.
+ ///
+ /// A decimal is returned as this may not be within `i64`'s range of
+ /// values.
+ pub fn ceil(self) -> Self {
+ Self(self.0.ceil())
+ }
+
+ /// Returns the integer part of this decimal.
+ pub fn trunc(self) -> Self {
+ Self(self.0.trunc())
+ }
+
+ /// Returns the fractional part of this decimal (with the integer part set
+ /// to zero).
+ pub fn fract(self) -> Self {
+ Self(self.0.fract())
+ }
+
+ /// Rounds this decimal up to the specified amount of digits with the
+ /// traditional rounding rules, using the "midpoint away from zero"
+ /// strategy (6.5 -> 7, -6.5 -> -7).
+ ///
+ /// If given a negative amount of digits, rounds to integer digits instead
+ /// with the same rounding strategy. For example, rounding to -3 digits
+ /// will turn 34567.89 into 35000.00 and -34567.89 into -35000.00.
+ ///
+ /// Note that this can return `None` when using negative digits where the
+ /// rounded number would overflow the available range for decimals.
+ pub fn round(self, digits: i32) -> Option<Self> {
+ // Positive digits can be handled by just rounding with rust_decimal.
+ if let Ok(positive_digits) = u32::try_from(digits) {
+ return Some(Self(self.0.round_dp_with_strategy(
+ positive_digits,
+ rust_decimal::RoundingStrategy::MidpointAwayFromZero,
+ )));
+ }
+
+ // We received negative digits, so we round to integer digits.
+ let mut num = self.0;
+ let old_scale = num.scale();
+ let digits = -digits as u32;
+
+ let (Ok(_), Some(ten_to_digits)) = (
+ // Same as dividing by 10^digits.
+ num.set_scale(old_scale + digits),
+ rust_decimal::Decimal::TEN.checked_powi(digits as i64),
+ ) else {
+ // Scaling more than any possible amount of integer digits.
+ let mut zero = rust_decimal::Decimal::ZERO;
+ zero.set_sign_negative(self.is_negative());
+ return Some(Self(zero));
+ };
+
+ // Round to this integer digit.
+ num = num.round_dp_with_strategy(
+ 0,
+ rust_decimal::RoundingStrategy::MidpointAwayFromZero,
+ );
+
+ // Multiply by 10^digits again, which can overflow and fail.
+ num.checked_mul(ten_to_digits).map(Self)
+ }
+
+ /// Attempts to add two decimals.
+ ///
+ /// Returns `None` on overflow or underflow.
+ pub fn checked_add(self, other: Self) -> Option<Self> {
+ self.0.checked_add(other.0).map(Self)
+ }
+
+ /// Attempts to subtract a decimal from another.
+ ///
+ /// Returns `None` on overflow or underflow.
+ pub fn checked_sub(self, other: Self) -> Option<Self> {
+ self.0.checked_sub(other.0).map(Self)
+ }
+
+ /// Attempts to multiply two decimals.
+ ///
+ /// Returns `None` on overflow or underflow.
+ pub fn checked_mul(self, other: Self) -> Option<Self> {
+ self.0.checked_mul(other.0).map(Self)
+ }
+
+ /// Attempts to divide two decimals.
+ ///
+ /// Returns `None` if `other` is zero, as well as on overflow or underflow.
+ pub fn checked_div(self, other: Self) -> Option<Self> {
+ self.0.checked_div(other.0).map(Self)
+ }
+
+ /// Attempts to obtain the quotient of Euclidean division between two
+ /// decimals. Implemented similarly to [`f64::div_euclid`].
+ ///
+ /// The returned quotient is truncated and adjusted if the remainder was
+ /// negative.
+ ///
+ /// Returns `None` if `other` is zero, as well as on overflow or underflow.
+ pub fn checked_div_euclid(self, other: Self) -> Option<Self> {
+ let q = self.0.checked_div(other.0)?.trunc();
+ if self
+ .0
+ .checked_rem(other.0)
+ .as_ref()
+ .is_some_and(rust_decimal::Decimal::is_sign_negative)
+ {
+ return if other.0.is_sign_positive() {
+ q.checked_sub(rust_decimal::Decimal::ONE).map(Self)
+ } else {
+ q.checked_add(rust_decimal::Decimal::ONE).map(Self)
+ };
+ }
+ Some(Self(q))
+ }
+
+ /// Attempts to obtain the remainder of Euclidean division between two
+ /// decimals. Implemented similarly to [`f64::rem_euclid`].
+ ///
+ /// The returned decimal `r` is non-negative within the range
+ /// `0.0 <= r < other.abs()`.
+ ///
+ /// Returns `None` if `other` is zero, as well as on overflow or underflow.
+ pub fn checked_rem_euclid(self, other: Self) -> Option<Self> {
+ let r = self.0.checked_rem(other.0)?;
+ Some(Self(if r.is_sign_negative() { r.checked_add(other.0.abs())? } else { r }))
+ }
+
+ /// Attempts to calculate the remainder of the division of two decimals.
+ ///
+ /// Returns `None` if `other` is zero, as well as on overflow or underflow.
+ pub fn checked_rem(self, other: Self) -> Option<Self> {
+ self.0.checked_rem(other.0).map(Self)
+ }
+
+ /// Attempts to take one decimal to the power of an integer.
+ ///
+ /// Returns `None` for invalid operands, as well as on overflow or
+ /// underflow.
+ pub fn checked_powi(self, other: i64) -> Option<Self> {
+ self.0.checked_powi(other).map(Self)
+ }
+}
+
+#[scope]
+impl Decimal {
+ /// Converts a value to a `decimal`.
+ ///
+ /// It is recommended to use a string to construct the decimal number, or an
+ /// [integer]($int) (if desired). The string must contain a number in the
+ /// format `{"3.14159"}` (or `{"-3.141519"}` for negative numbers). The
+ /// fractional digits are fully preserved; if that's not possible due to the
+ /// limit of significant digits (around 28 to 29) having been reached, an
+ /// error is raised as the given decimal number wouldn't be representable.
+ ///
+ /// While this constructor can be used with [floating-point numbers]($float)
+ /// to cast them to `decimal`, doing so is **discouraged** as **this cast is
+ /// inherently imprecise.** It is easy to accidentally perform this cast by
+ /// writing `{decimal(1.234)}` (note the lack of double quotes), which is
+ /// why Typst will emit a warning in that case. Please write
+ /// `{decimal("1.234")}` instead for that particular case (initialization of
+ /// a constant decimal). Also note that floats that are NaN or infinite
+ /// cannot be cast to decimals and will raise an error.
+ ///
+ /// ```example
+ /// #decimal("1.222222222222222")
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ engine: &mut Engine,
+ /// The value that should be converted to a decimal.
+ value: Spanned<ToDecimal>,
+ ) -> SourceResult<Decimal> {
+ match value.v {
+ ToDecimal::Str(str) => Self::from_str(&str.replace(repr::MINUS_SIGN, "-"))
+ .map_err(|_| eco_format!("invalid decimal: {str}"))
+ .at(value.span),
+ ToDecimal::Int(int) => Ok(Self::from(int)),
+ ToDecimal::Float(float) => {
+ warn_on_float_literal(engine, value.span);
+ Self::try_from(float)
+ .map_err(|_| {
+ eco_format!(
+ "float is not a valid decimal: {}",
+ repr::format_float(float, None, true, "")
+ )
+ })
+ .at(value.span)
+ }
+ }
+ }
+}
+
+/// Emits a warning when a decimal is constructed from a float literal.
+fn warn_on_float_literal(engine: &mut Engine, span: Span) -> Option<()> {
+ let id = span.id()?;
+ let source = engine.world.source(id).ok()?;
+ let node = source.find(span)?;
+ if node.is::<ast::Float>() {
+ engine.sink.warn(warning!(
+ span,
+ "creating a decimal using imprecise float literal";
+ hint: "use a string in the decimal constructor to avoid loss \
+ of precision: `decimal({})`",
+ node.text().repr()
+ ));
+ }
+ Some(())
+}
+
+impl FromStr for Decimal {
+ type Err = rust_decimal::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ rust_decimal::Decimal::from_str_exact(s).map(Self)
+ }
+}
+
+impl From<i64> for Decimal {
+ fn from(value: i64) -> Self {
+ Self(rust_decimal::Decimal::from(value))
+ }
+}
+
+impl TryFrom<f64> for Decimal {
+ type Error = ();
+
+ /// Attempts to convert a Decimal to a float.
+ ///
+ /// This can fail if the float is infinite or NaN, or otherwise cannot be
+ /// represented by a decimal number.
+ fn try_from(value: f64) -> Result<Self, Self::Error> {
+ rust_decimal::Decimal::from_f64_retain(value).map(Self).ok_or(())
+ }
+}
+
+impl TryFrom<Decimal> for f64 {
+ type Error = rust_decimal::Error;
+
+ /// Attempts to convert a Decimal to a float.
+ ///
+ /// This should in principle be infallible according to the implementation,
+ /// but we mirror the decimal implementation's API either way.
+ fn try_from(value: Decimal) -> Result<Self, Self::Error> {
+ value.0.try_into()
+ }
+}
+
+impl TryFrom<Decimal> for i64 {
+ type Error = rust_decimal::Error;
+
+ /// Attempts to convert a Decimal to an integer.
+ ///
+ /// Returns an error if the decimal has a fractional part, or if there
+ /// would be overflow or underflow.
+ fn try_from(value: Decimal) -> Result<Self, Self::Error> {
+ value.0.try_into()
+ }
+}
+
+impl Display for Decimal {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ if self.0.is_sign_negative() {
+ f.write_str(repr::MINUS_SIGN)?;
+ }
+ self.0.abs().fmt(f)
+ }
+}
+
+impl Repr for Decimal {
+ fn repr(&self) -> EcoString {
+ eco_format!("decimal({})", eco_format!("{}", self.0).repr())
+ }
+}
+
+impl Neg for Decimal {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self(-self.0)
+ }
+}
+
+impl Hash for Decimal {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ // `rust_decimal`'s Hash implementation normalizes decimals before
+ // hashing them. This means decimals with different scales but
+ // equivalent value not only compare equal but also hash equally. Here,
+ // we hash all bytes explicitly to ensure the scale is also considered.
+ // This means that 123.314 == 123.31400, but 123.314.hash() !=
+ // 123.31400.hash().
+ //
+ // Note that this implies that equal decimals can have different hashes,
+ // which might generate problems with certain data structures, such as
+ // HashSet and HashMap.
+ self.0.serialize().hash(state);
+ }
+}
+
+/// A value that can be cast to a decimal.
+pub enum ToDecimal {
+ /// A string with the decimal's representation.
+ Str(EcoString),
+ /// An integer to be converted to the equivalent decimal.
+ Int(i64),
+ /// A float to be converted to the equivalent decimal.
+ Float(f64),
+}
+
+cast! {
+ ToDecimal,
+ v: i64 => Self::Int(v),
+ v: f64 => Self::Float(v),
+ v: Str => Self::Str(EcoString::from(v)),
+}
+
+#[cfg(test)]
+mod tests {
+ use std::str::FromStr;
+
+ use typst_utils::hash128;
+
+ use super::Decimal;
+
+ #[test]
+ fn test_decimals_with_equal_scales_hash_identically() {
+ let a = Decimal::from_str("3.14").unwrap();
+ let b = Decimal::from_str("3.14").unwrap();
+ assert_eq!(a, b);
+ assert_eq!(hash128(&a), hash128(&b));
+ }
+
+ #[test]
+ fn test_decimals_with_different_scales_hash_differently() {
+ let a = Decimal::from_str("3.140").unwrap();
+ let b = Decimal::from_str("3.14000").unwrap();
+ assert_eq!(a, b);
+ assert_ne!(hash128(&a), hash128(&b));
+ }
+
+ #[track_caller]
+ fn test_round(value: &str, digits: i32, expected: &str) {
+ assert_eq!(
+ Decimal::from_str(value).unwrap().round(digits),
+ Some(Decimal::from_str(expected).unwrap()),
+ );
+ }
+
+ #[test]
+ fn test_decimal_positive_round() {
+ test_round("312.55553", 0, "313.00000");
+ test_round("312.55553", 3, "312.556");
+ test_round("312.5555300000", 3, "312.556");
+ test_round("-312.55553", 3, "-312.556");
+ test_round("312.55553", 28, "312.55553");
+ test_round("312.55553", 2341, "312.55553");
+ test_round("-312.55553", 2341, "-312.55553");
+ }
+
+ #[test]
+ fn test_decimal_negative_round() {
+ test_round("4596.55553", -1, "4600");
+ test_round("4596.555530000000", -1, "4600");
+ test_round("-4596.55553", -3, "-5000");
+ test_round("4596.55553", -28, "0");
+ test_round("-4596.55553", -2341, "0");
+ assert_eq!(Decimal::MAX.round(-1), None);
+ }
+}
diff --git a/crates/typst-library/src/foundations/dict.rs b/crates/typst-library/src/foundations/dict.rs
new file mode 100644
index 00000000..e4ab54e7
--- /dev/null
+++ b/crates/typst-library/src/foundations/dict.rs
@@ -0,0 +1,395 @@
+use std::fmt::{Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::ops::{Add, AddAssign};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use indexmap::IndexMap;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use typst_syntax::is_ident;
+use typst_utils::ArcExt;
+
+use crate::diag::{Hint, HintedStrResult, StrResult};
+use crate::foundations::{
+ array, cast, func, repr, scope, ty, Array, Module, Repr, Str, Value,
+};
+
+/// Create a new [`Dict`] from key-value pairs.
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __dict {
+ ($($key:expr => $value:expr),* $(,)?) => {{
+ #[allow(unused_mut)]
+ let mut map = $crate::foundations::IndexMap::new();
+ $(map.insert($key.into(), $crate::foundations::IntoValue::into_value($value));)*
+ $crate::foundations::Dict::from(map)
+ }};
+}
+
+#[doc(inline)]
+pub use crate::__dict as dict;
+
+/// A map from string keys to values.
+///
+/// You can construct a dictionary by enclosing comma-separated `key: value`
+/// pairs in parentheses. The values do not have to be of the same type. Since
+/// empty parentheses already yield an empty array, you have to use the special
+/// `(:)` syntax to create an empty dictionary.
+///
+/// A dictionary is conceptually similar to an array, but it is indexed by
+/// strings instead of integers. You can access and create dictionary entries
+/// with the `.at()` method. If you know the key statically, you can
+/// alternatively use [field access notation]($scripting/#fields) (`.key`) to
+/// access the value. Dictionaries can be added with the `+` operator and
+/// [joined together]($scripting/#blocks). To check whether a key is present in
+/// the dictionary, use the `in` keyword.
+///
+/// You can iterate over the pairs in a dictionary using a [for
+/// loop]($scripting/#loops). This will iterate in the order the pairs were
+/// inserted / declared.
+///
+/// # Example
+/// ```example
+/// #let dict = (
+/// name: "Typst",
+/// born: 2019,
+/// )
+///
+/// #dict.name \
+/// #(dict.launch = 20)
+/// #dict.len() \
+/// #dict.keys() \
+/// #dict.values() \
+/// #dict.at("born") \
+/// #dict.insert("city", "Berlin ")
+/// #("name" in dict)
+/// ```
+#[ty(scope, cast, name = "dictionary")]
+#[derive(Default, Clone, PartialEq)]
+pub struct Dict(Arc<IndexMap<Str, Value>>);
+
+impl Dict {
+ /// Create a new, empty dictionary.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Whether the dictionary is empty.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// Borrow the value at the given key.
+ pub fn get(&self, key: &str) -> StrResult<&Value> {
+ self.0.get(key).ok_or_else(|| missing_key(key))
+ }
+
+ /// Mutably borrow the value the given `key` maps to.
+ pub fn at_mut(&mut self, key: &str) -> HintedStrResult<&mut Value> {
+ Arc::make_mut(&mut self.0)
+ .get_mut(key)
+ .ok_or_else(|| missing_key(key))
+ .hint("use `insert` to add or update values")
+ }
+
+ /// Remove the value if the dictionary contains the given key.
+ pub fn take(&mut self, key: &str) -> StrResult<Value> {
+ Arc::make_mut(&mut self.0)
+ .shift_remove(key)
+ .ok_or_else(|| missing_key(key))
+ }
+
+ /// Whether the dictionary contains a specific key.
+ pub fn contains(&self, key: &str) -> bool {
+ self.0.contains_key(key)
+ }
+
+ /// Clear the dictionary.
+ pub fn clear(&mut self) {
+ if Arc::strong_count(&self.0) == 1 {
+ Arc::make_mut(&mut self.0).clear();
+ } else {
+ *self = Self::new();
+ }
+ }
+
+ /// Iterate over pairs of references to the contained keys and values.
+ pub fn iter(&self) -> indexmap::map::Iter<Str, Value> {
+ self.0.iter()
+ }
+
+ /// Check if there is any remaining pair, and if so return an
+ /// "unexpected key" error.
+ pub fn finish(&self, expected: &[&str]) -> StrResult<()> {
+ let mut iter = self.iter().peekable();
+ if iter.peek().is_none() {
+ return Ok(());
+ }
+ let unexpected: Vec<&str> = iter.map(|kv| kv.0.as_str()).collect();
+
+ Err(Self::unexpected_keys(unexpected, Some(expected)))
+ }
+
+ // Return an "unexpected key" error string.
+ pub fn unexpected_keys(
+ unexpected: Vec<&str>,
+ hint_expected: Option<&[&str]>,
+ ) -> EcoString {
+ let format_as_list = |arr: &[&str]| {
+ repr::separated_list(
+ &arr.iter().map(|s| eco_format!("\"{s}\"")).collect::<Vec<_>>(),
+ "and",
+ )
+ };
+
+ let mut msg = String::from(match unexpected.len() {
+ 1 => "unexpected key ",
+ _ => "unexpected keys ",
+ });
+
+ msg.push_str(&format_as_list(&unexpected[..]));
+
+ if let Some(expected) = hint_expected {
+ msg.push_str(", valid keys are ");
+ msg.push_str(&format_as_list(expected));
+ }
+
+ msg.into()
+ }
+}
+
+#[scope]
+impl Dict {
+ /// Converts a value into a dictionary.
+ ///
+ /// Note that this function is only intended for conversion of a
+ /// dictionary-like value to a dictionary, not for creation of a dictionary
+ /// from individual pairs. Use the dictionary syntax `(key: value)` instead.
+ ///
+ /// ```example
+ /// #dictionary(sys).at("version")
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The value that should be converted to a dictionary.
+ value: ToDict,
+ ) -> Dict {
+ value.0
+ }
+
+ /// The number of pairs in the dictionary.
+ #[func(title = "Length")]
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Returns the value associated with the specified key in the dictionary.
+ /// May be used on the left-hand side of an assignment if the key is already
+ /// present in the dictionary. Returns the default value if the key is not
+ /// part of the dictionary or fails with an error if no default value was
+ /// specified.
+ #[func]
+ pub fn at(
+ &self,
+ /// The key at which to retrieve the item.
+ key: Str,
+ /// A default value to return if the key is not part of the dictionary.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ self.0
+ .get(&key)
+ .cloned()
+ .or(default)
+ .ok_or_else(|| missing_key_no_default(&key))
+ }
+
+ /// Inserts a new pair into the dictionary. If the dictionary already
+ /// contains this key, the value is updated.
+ #[func]
+ pub fn insert(
+ &mut self,
+ /// The key of the pair that should be inserted.
+ key: Str,
+ /// The value of the pair that should be inserted.
+ value: Value,
+ ) {
+ Arc::make_mut(&mut self.0).insert(key, value);
+ }
+
+ /// Removes a pair from the dictionary by key and return the value.
+ #[func]
+ pub fn remove(
+ &mut self,
+ /// The key of the pair to remove.
+ key: Str,
+ /// A default value to return if the key does not exist.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ Arc::make_mut(&mut self.0)
+ .shift_remove(&key)
+ .or(default)
+ .ok_or_else(|| missing_key(&key))
+ }
+
+ /// Returns the keys of the dictionary as an array in insertion order.
+ #[func]
+ pub fn keys(&self) -> Array {
+ self.0.keys().cloned().map(Value::Str).collect()
+ }
+
+ /// Returns the values of the dictionary as an array in insertion order.
+ #[func]
+ pub fn values(&self) -> Array {
+ self.0.values().cloned().collect()
+ }
+
+ /// Returns the keys and values of the dictionary as an array of pairs. Each
+ /// pair is represented as an array of length two.
+ #[func]
+ pub fn pairs(&self) -> Array {
+ self.0
+ .iter()
+ .map(|(k, v)| Value::Array(array![k.clone(), v.clone()]))
+ .collect()
+ }
+}
+
+/// A value that can be cast to dictionary.
+pub struct ToDict(Dict);
+
+cast! {
+ ToDict,
+ v: Module => Self(v.scope().iter().map(|(k, v, _)| (Str::from(k.clone()), v.clone())).collect()),
+}
+
+impl Debug for Dict {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ f.debug_map().entries(self.0.iter()).finish()
+ }
+}
+
+impl Repr for Dict {
+ fn repr(&self) -> EcoString {
+ if self.is_empty() {
+ return "(:)".into();
+ }
+
+ let max = 40;
+ let mut pieces: Vec<_> = self
+ .iter()
+ .take(max)
+ .map(|(key, value)| {
+ if is_ident(key) {
+ eco_format!("{key}: {}", value.repr())
+ } else {
+ eco_format!("{}: {}", key.repr(), value.repr())
+ }
+ })
+ .collect();
+
+ if self.len() > max {
+ pieces.push(eco_format!(".. ({} pairs omitted)", self.len() - max));
+ }
+
+ repr::pretty_array_like(&pieces, false).into()
+ }
+}
+
+impl Add for Dict {
+ type Output = Self;
+
+ fn add(mut self, rhs: Dict) -> Self::Output {
+ self += rhs;
+ self
+ }
+}
+
+impl AddAssign for Dict {
+ fn add_assign(&mut self, rhs: Dict) {
+ match Arc::try_unwrap(rhs.0) {
+ Ok(map) => self.extend(map),
+ Err(rc) => self.extend(rc.iter().map(|(k, v)| (k.clone(), v.clone()))),
+ }
+ }
+}
+
+impl Hash for Dict {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ state.write_usize(self.0.len());
+ for item in self {
+ item.hash(state);
+ }
+ }
+}
+
+impl Serialize for Dict {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ self.0.serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for Dict {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Ok(IndexMap::<Str, Value>::deserialize(deserializer)?.into())
+ }
+}
+
+impl Extend<(Str, Value)> for Dict {
+ fn extend<T: IntoIterator<Item = (Str, Value)>>(&mut self, iter: T) {
+ Arc::make_mut(&mut self.0).extend(iter);
+ }
+}
+
+impl FromIterator<(Str, Value)> for Dict {
+ fn from_iter<T: IntoIterator<Item = (Str, Value)>>(iter: T) -> Self {
+ Self(Arc::new(iter.into_iter().collect()))
+ }
+}
+
+impl IntoIterator for Dict {
+ type Item = (Str, Value);
+ type IntoIter = indexmap::map::IntoIter<Str, Value>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ Arc::take(self.0).into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a Dict {
+ type Item = (&'a Str, &'a Value);
+ type IntoIter = indexmap::map::Iter<'a, Str, Value>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.iter()
+ }
+}
+
+impl From<IndexMap<Str, Value>> for Dict {
+ fn from(map: IndexMap<Str, Value>) -> Self {
+ Self(Arc::new(map))
+ }
+}
+
+/// The missing key access error message.
+#[cold]
+fn missing_key(key: &str) -> EcoString {
+ eco_format!("dictionary does not contain key {}", key.repr())
+}
+
+/// The missing key access error message when no default was given.
+#[cold]
+fn missing_key_no_default(key: &str) -> EcoString {
+ eco_format!(
+ "dictionary does not contain key {} \
+ and no default value was specified",
+ key.repr()
+ )
+}
diff --git a/crates/typst-library/src/foundations/duration.rs b/crates/typst-library/src/foundations/duration.rs
new file mode 100644
index 00000000..94d44fb2
--- /dev/null
+++ b/crates/typst-library/src/foundations/duration.rs
@@ -0,0 +1,215 @@
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Add, Div, Mul, Neg, Sub};
+
+use ecow::{eco_format, EcoString};
+use time::ext::NumericalDuration;
+
+use crate::foundations::{func, repr, scope, ty, Repr};
+
+/// Represents a positive or negative span of time.
+#[ty(scope, cast)]
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Duration(time::Duration);
+
+impl Duration {
+ /// Whether the duration is empty / zero.
+ pub fn is_zero(&self) -> bool {
+ self.0.is_zero()
+ }
+}
+
+#[scope]
+impl Duration {
+ /// Creates a new duration.
+ ///
+ /// You can specify the [duration] using weeks, days, hours, minutes and
+ /// seconds. You can also get a duration by subtracting two
+ /// [datetimes]($datetime).
+ ///
+ /// ```example
+ /// #duration(
+ /// days: 3,
+ /// hours: 12,
+ /// ).hours()
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The number of seconds.
+ #[named]
+ #[default(0)]
+ seconds: i64,
+ /// The number of minutes.
+ #[named]
+ #[default(0)]
+ minutes: i64,
+ /// The number of hours.
+ #[named]
+ #[default(0)]
+ hours: i64,
+ /// The number of days.
+ #[named]
+ #[default(0)]
+ days: i64,
+ /// The number of weeks.
+ #[named]
+ #[default(0)]
+ weeks: i64,
+ ) -> Duration {
+ Duration::from(
+ time::Duration::seconds(seconds)
+ + time::Duration::minutes(minutes)
+ + time::Duration::hours(hours)
+ + time::Duration::days(days)
+ + time::Duration::weeks(weeks),
+ )
+ }
+
+ /// The duration expressed in seconds.
+ ///
+ /// This function returns the total duration represented in seconds as a
+ /// floating-point number rather than the second component of the duration.
+ #[func]
+ pub fn seconds(&self) -> f64 {
+ self.0.as_seconds_f64()
+ }
+
+ /// The duration expressed in minutes.
+ ///
+ /// This function returns the total duration represented in minutes as a
+ /// floating-point number rather than the second component of the duration.
+ #[func]
+ pub fn minutes(&self) -> f64 {
+ self.seconds() / 60.0
+ }
+
+ /// The duration expressed in hours.
+ ///
+ /// This function returns the total duration represented in hours as a
+ /// floating-point number rather than the second component of the duration.
+ #[func]
+ pub fn hours(&self) -> f64 {
+ self.seconds() / 3_600.0
+ }
+
+ /// The duration expressed in days.
+ ///
+ /// This function returns the total duration represented in days as a
+ /// floating-point number rather than the second component of the duration.
+ #[func]
+ pub fn days(&self) -> f64 {
+ self.seconds() / 86_400.0
+ }
+
+ /// The duration expressed in weeks.
+ ///
+ /// This function returns the total duration represented in weeks as a
+ /// floating-point number rather than the second component of the duration.
+ #[func]
+ pub fn weeks(&self) -> f64 {
+ self.seconds() / 604_800.0
+ }
+}
+
+impl Debug for Duration {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl Repr for Duration {
+ fn repr(&self) -> EcoString {
+ let mut tmp = self.0;
+ let mut vec = Vec::with_capacity(5);
+
+ let weeks = tmp.whole_seconds() / 604_800.0 as i64;
+ if weeks != 0 {
+ vec.push(eco_format!("weeks: {}", weeks.repr()));
+ }
+ tmp -= weeks.weeks();
+
+ let days = tmp.whole_days();
+ if days != 0 {
+ vec.push(eco_format!("days: {}", days.repr()));
+ }
+ tmp -= days.days();
+
+ let hours = tmp.whole_hours();
+ if hours != 0 {
+ vec.push(eco_format!("hours: {}", hours.repr()));
+ }
+ tmp -= hours.hours();
+
+ let minutes = tmp.whole_minutes();
+ if minutes != 0 {
+ vec.push(eco_format!("minutes: {}", minutes.repr()));
+ }
+ tmp -= minutes.minutes();
+
+ let seconds = tmp.whole_seconds();
+ if seconds != 0 {
+ vec.push(eco_format!("seconds: {}", seconds.repr()));
+ }
+
+ eco_format!("duration{}", &repr::pretty_array_like(&vec, false))
+ }
+}
+
+impl From<time::Duration> for Duration {
+ fn from(value: time::Duration) -> Self {
+ Self(value)
+ }
+}
+
+impl From<Duration> for time::Duration {
+ fn from(value: Duration) -> Self {
+ value.0
+ }
+}
+
+impl Add for Duration {
+ type Output = Duration;
+
+ fn add(self, rhs: Self) -> Self::Output {
+ Duration(self.0 + rhs.0)
+ }
+}
+
+impl Sub for Duration {
+ type Output = Duration;
+
+ fn sub(self, rhs: Self) -> Self::Output {
+ Duration(self.0 - rhs.0)
+ }
+}
+
+impl Neg for Duration {
+ type Output = Duration;
+
+ fn neg(self) -> Self::Output {
+ Duration(-self.0)
+ }
+}
+
+impl Mul<f64> for Duration {
+ type Output = Duration;
+
+ fn mul(self, rhs: f64) -> Self::Output {
+ Duration(self.0 * rhs)
+ }
+}
+
+impl Div<f64> for Duration {
+ type Output = Duration;
+
+ fn div(self, rhs: f64) -> Self::Output {
+ Duration(self.0 / rhs)
+ }
+}
+
+impl Div for Duration {
+ type Output = f64;
+
+ fn div(self, rhs: Self) -> Self::Output {
+ self.0 / rhs.0
+ }
+}
diff --git a/crates/typst-library/src/foundations/element.rs b/crates/typst-library/src/foundations/element.rs
new file mode 100644
index 00000000..8da71965
--- /dev/null
+++ b/crates/typst-library/src/foundations/element.rs
@@ -0,0 +1,333 @@
+use std::any::TypeId;
+use std::cmp::Ordering;
+use std::fmt::{self, Debug};
+use std::hash::Hash;
+use std::ptr::NonNull;
+
+use ecow::EcoString;
+use once_cell::sync::Lazy;
+use smallvec::SmallVec;
+#[doc(inline)]
+pub use typst_macros::elem;
+use typst_utils::Static;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, Args, Content, Dict, FieldAccessError, Func, ParamInfo, Repr, Scope, Selector,
+ StyleChain, Styles, Value,
+};
+use crate::text::{Lang, Region};
+
+/// A document element.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Element(Static<NativeElementData>);
+
+impl Element {
+ /// Get the element for `T`.
+ pub fn of<T: NativeElement>() -> Self {
+ T::elem()
+ }
+
+ /// The element's normal name (e.g. `enum`).
+ pub fn name(self) -> &'static str {
+ self.0.name
+ }
+
+ /// The element's title case name, for use in documentation
+ /// (e.g. `Numbered List`).
+ pub fn title(&self) -> &'static str {
+ self.0.title
+ }
+
+ /// Documentation for the element (as Markdown).
+ pub fn docs(&self) -> &'static str {
+ self.0.docs
+ }
+
+ /// Search keywords for the element.
+ pub fn keywords(&self) -> &'static [&'static str] {
+ self.0.keywords
+ }
+
+ /// Construct an instance of this element.
+ pub fn construct(
+ self,
+ engine: &mut Engine,
+ args: &mut Args,
+ ) -> SourceResult<Content> {
+ (self.0.construct)(engine, args)
+ }
+
+ /// Execute the set rule for the element and return the resulting style map.
+ pub fn set(self, engine: &mut Engine, mut args: Args) -> SourceResult<Styles> {
+ let styles = (self.0.set)(engine, &mut args)?;
+ args.finish()?;
+ Ok(styles)
+ }
+
+ /// Whether the element has the given capability.
+ pub fn can<C>(self) -> bool
+ where
+ C: ?Sized + 'static,
+ {
+ self.can_type_id(TypeId::of::<C>())
+ }
+
+ /// Whether the element has the given capability where the capability is
+ /// given by a `TypeId`.
+ pub fn can_type_id(self, type_id: TypeId) -> bool {
+ (self.0.vtable)(type_id).is_some()
+ }
+
+ /// The VTable for capabilities dispatch.
+ pub fn vtable(self) -> fn(of: TypeId) -> Option<NonNull<()>> {
+ self.0.vtable
+ }
+
+ /// Create a selector for this element.
+ pub fn select(self) -> Selector {
+ Selector::Elem(self, None)
+ }
+
+ /// Create a selector for this element, filtering for those that
+ /// [fields](crate::foundations::Content::field) match the given argument.
+ pub fn where_(self, fields: SmallVec<[(u8, Value); 1]>) -> Selector {
+ Selector::Elem(self, Some(fields))
+ }
+
+ /// The element's associated scope of sub-definition.
+ pub fn scope(&self) -> &'static Scope {
+ &(self.0).0.scope
+ }
+
+ /// Details about the element's fields.
+ pub fn params(&self) -> &'static [ParamInfo] {
+ &(self.0).0.params
+ }
+
+ /// Extract the field ID for the given field name.
+ pub fn field_id(&self, name: &str) -> Option<u8> {
+ if name == "label" {
+ return Some(255);
+ }
+ (self.0.field_id)(name)
+ }
+
+ /// Extract the field name for the given field ID.
+ pub fn field_name(&self, id: u8) -> Option<&'static str> {
+ if id == 255 {
+ return Some("label");
+ }
+ (self.0.field_name)(id)
+ }
+
+ /// Extract the value of the field for the given field ID and style chain.
+ pub fn field_from_styles(
+ &self,
+ id: u8,
+ styles: StyleChain,
+ ) -> Result<Value, FieldAccessError> {
+ (self.0.field_from_styles)(id, styles)
+ }
+
+ /// The element's local name, if any.
+ pub fn local_name(&self, lang: Lang, region: Option<Region>) -> Option<&'static str> {
+ (self.0).0.local_name.map(|f| f(lang, region))
+ }
+}
+
+impl Debug for Element {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "Element({})", self.name())
+ }
+}
+
+impl Repr for Element {
+ fn repr(&self) -> EcoString {
+ self.name().into()
+ }
+}
+
+impl Ord for Element {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.name().cmp(other.name())
+ }
+}
+
+impl PartialOrd for Element {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+cast! {
+ Element,
+ self => Value::Func(self.into()),
+ v: Func => v.element().ok_or("expected element")?,
+}
+
+/// A Typst element that is defined by a native Rust type.
+pub trait NativeElement:
+ Debug
+ + Clone
+ + PartialEq
+ + Hash
+ + Construct
+ + Set
+ + Capable
+ + Fields
+ + Repr
+ + Send
+ + Sync
+ + 'static
+{
+ /// Get the element for the native Rust element.
+ fn elem() -> Element
+ where
+ Self: Sized,
+ {
+ Element::from(Self::data())
+ }
+
+ /// Pack the element into type-erased content.
+ fn pack(self) -> Content
+ where
+ Self: Sized,
+ {
+ Content::new(self)
+ }
+
+ /// Get the element data for the native Rust element.
+ fn data() -> &'static NativeElementData
+ where
+ Self: Sized;
+}
+
+/// Used to cast an element to a trait object for a trait it implements.
+///
+/// # Safety
+/// If the `vtable` function returns `Some(p)`, then `p` must be a valid pointer
+/// to a vtable of `Packed<Self>` w.r.t to the trait `C` where `capability` is
+/// `TypeId::of::<dyn C>()`.
+pub unsafe trait Capable {
+ /// Get the pointer to the vtable for the given capability / trait.
+ fn vtable(capability: TypeId) -> Option<NonNull<()>>;
+}
+
+/// Defines how fields of an element are accessed.
+pub trait Fields {
+ /// An enum with the fields of the element.
+ type Enum
+ where
+ Self: Sized;
+
+ /// Whether the element has the given field set.
+ fn has(&self, id: u8) -> bool;
+
+ /// Get the field with the given field ID.
+ fn field(&self, id: u8) -> Result<Value, FieldAccessError>;
+
+ /// Get the field with the given ID in the presence of styles.
+ fn field_with_styles(
+ &self,
+ id: u8,
+ styles: StyleChain,
+ ) -> Result<Value, FieldAccessError>;
+
+ /// Get the field with the given ID from the styles.
+ fn field_from_styles(id: u8, styles: StyleChain) -> Result<Value, FieldAccessError>
+ where
+ Self: Sized;
+
+ /// Resolve all fields with the styles and save them in-place.
+ fn materialize(&mut self, styles: StyleChain);
+
+ /// Get the fields of the element.
+ fn fields(&self) -> Dict;
+}
+
+/// An element's constructor function.
+pub trait Construct {
+ /// Construct an element from the arguments.
+ ///
+ /// This is passed only the arguments that remain after execution of the
+ /// element's set rule.
+ fn construct(engine: &mut Engine, args: &mut Args) -> SourceResult<Content>
+ where
+ Self: Sized;
+}
+
+/// An element's set rule.
+pub trait Set {
+ /// Parse relevant arguments into style properties for this element.
+ fn set(engine: &mut Engine, args: &mut Args) -> SourceResult<Styles>
+ where
+ Self: Sized;
+}
+
+/// Defines a native element.
+#[derive(Debug)]
+pub struct NativeElementData {
+ /// The element's normal name (e.g. `align`), as exposed to Typst.
+ pub name: &'static str,
+ /// The element's title case name (e.g. `Align`).
+ pub title: &'static str,
+ /// The documentation for this element as a string.
+ pub docs: &'static str,
+ /// A list of alternate search terms for this element.
+ pub keywords: &'static [&'static str],
+ /// The constructor for this element (see [`Construct`]).
+ pub construct: fn(&mut Engine, &mut Args) -> SourceResult<Content>,
+ /// Executes this element's set rule (see [`Set`]).
+ pub set: fn(&mut Engine, &mut Args) -> SourceResult<Styles>,
+ /// Gets the vtable for one of this element's capabilities
+ /// (see [`Capable`]).
+ pub vtable: fn(capability: TypeId) -> Option<NonNull<()>>,
+ /// Gets the numeric index of this field by its name.
+ pub field_id: fn(name: &str) -> Option<u8>,
+ /// Gets the name of a field by its numeric index.
+ pub field_name: fn(u8) -> Option<&'static str>,
+ /// Get the field with the given ID in the presence of styles (see [`Fields`]).
+ pub field_from_styles: fn(u8, StyleChain) -> Result<Value, FieldAccessError>,
+ /// Gets the localized name for this element (see [`LocalName`][crate::text::LocalName]).
+ pub local_name: Option<fn(Lang, Option<Region>) -> &'static str>,
+ pub scope: Lazy<Scope>,
+ /// A list of parameter information for each field.
+ pub params: Lazy<Vec<ParamInfo>>,
+}
+
+impl From<&'static NativeElementData> for Element {
+ fn from(data: &'static NativeElementData) -> Self {
+ Self(Static(data))
+ }
+}
+
+cast! {
+ &'static NativeElementData,
+ self => Element::from(self).into_value(),
+}
+
+/// Synthesize fields on an element. This happens before execution of any show
+/// rule.
+pub trait Synthesize {
+ /// Prepare the element for show rule application.
+ fn synthesize(&mut self, engine: &mut Engine, styles: StyleChain)
+ -> SourceResult<()>;
+}
+
+/// Defines a built-in show rule for an element.
+pub trait Show {
+ /// Execute the base recipe for this element.
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content>;
+}
+
+/// Defines built-in show set rules for an element.
+///
+/// This is a bit more powerful than a user-defined show-set because it can
+/// access the element's fields.
+pub trait ShowSet {
+ /// Finalize the fully realized form of the element. Use this for effects
+ /// that should work even in the face of a user-defined show rule.
+ fn show_set(&self, styles: StyleChain) -> Styles;
+}
diff --git a/crates/typst-library/src/foundations/fields.rs b/crates/typst-library/src/foundations/fields.rs
new file mode 100644
index 00000000..422f30b8
--- /dev/null
+++ b/crates/typst-library/src/foundations/fields.rs
@@ -0,0 +1,91 @@
+//! Fields on values.
+
+use ecow::{eco_format, EcoString};
+
+use crate::diag::StrResult;
+use crate::foundations::{IntoValue, Type, Value, Version};
+use crate::layout::{Alignment, Length, Rel};
+use crate::visualize::Stroke;
+
+/// Try to access a field on a value.
+///
+/// This function is exclusively for types which have predefined fields, such as
+/// stroke and length.
+pub(crate) fn field(value: &Value, field: &str) -> StrResult<Value> {
+ let ty = value.ty();
+ let nope = || Err(no_fields(ty));
+ let missing = || Err(missing_field(ty, field));
+
+ // Special cases, such as module and dict, are handled by Value itself
+ let result = match value {
+ Value::Version(version) => match version.component(field) {
+ Ok(i) => i.into_value(),
+ Err(_) => return missing(),
+ },
+ Value::Length(length) => match field {
+ "em" => length.em.get().into_value(),
+ "abs" => length.abs.into_value(),
+ _ => return missing(),
+ },
+ Value::Relative(rel) => match field {
+ "ratio" => rel.rel.into_value(),
+ "length" => rel.abs.into_value(),
+ _ => return missing(),
+ },
+ Value::Dyn(dynamic) => {
+ if let Some(stroke) = dynamic.downcast::<Stroke>() {
+ match field {
+ "paint" => stroke.paint.clone().into_value(),
+ "thickness" => stroke.thickness.into_value(),
+ "cap" => stroke.cap.into_value(),
+ "join" => stroke.join.into_value(),
+ "dash" => stroke.dash.clone().into_value(),
+ "miter-limit" => {
+ stroke.miter_limit.map(|limit| limit.get()).into_value()
+ }
+ _ => return missing(),
+ }
+ } else if let Some(align) = dynamic.downcast::<Alignment>() {
+ match field {
+ "x" => align.x().into_value(),
+ "y" => align.y().into_value(),
+ _ => return missing(),
+ }
+ } else {
+ return nope();
+ }
+ }
+ _ => return nope(),
+ };
+
+ Ok(result)
+}
+
+/// The error message for a type not supporting field access.
+#[cold]
+fn no_fields(ty: Type) -> EcoString {
+ eco_format!("cannot access fields on type {ty}")
+}
+
+/// The missing field error message.
+#[cold]
+fn missing_field(ty: Type, field: &str) -> EcoString {
+ eco_format!("{ty} does not contain field \"{field}\"")
+}
+
+/// List the available fields for a type.
+pub fn fields_on(ty: Type) -> &'static [&'static str] {
+ if ty == Type::of::<Version>() {
+ &Version::COMPONENTS
+ } else if ty == Type::of::<Length>() {
+ &["em", "abs"]
+ } else if ty == Type::of::<Rel>() {
+ &["ratio", "length"]
+ } else if ty == Type::of::<Stroke>() {
+ &["paint", "thickness", "cap", "join", "dash", "miter-limit"]
+ } else if ty == Type::of::<Alignment>() {
+ &["x", "y"]
+ } else {
+ &[]
+ }
+}
diff --git a/crates/typst-library/src/foundations/float.rs b/crates/typst-library/src/foundations/float.rs
new file mode 100644
index 00000000..bb3232ee
--- /dev/null
+++ b/crates/typst-library/src/foundations/float.rs
@@ -0,0 +1,190 @@
+use std::num::ParseFloatError;
+
+use ecow::{eco_format, EcoString};
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{
+ cast, func, repr, scope, ty, Bytes, Decimal, Endianness, Repr, Str,
+};
+use crate::layout::Ratio;
+
+/// A floating-point number.
+///
+/// A limited-precision representation of a real number. Typst uses 64 bits to
+/// store floats. Wherever a float is expected, you can also pass an
+/// [integer]($int).
+///
+/// You can convert a value to a float with this type's constructor.
+///
+/// NaN and positive infinity are available as `{float.nan}` and `{float.inf}`
+/// respectively.
+///
+/// # Example
+/// ```example
+/// #3.14 \
+/// #1e4 \
+/// #(10 / 4)
+/// ```
+#[ty(scope, cast, name = "float")]
+type f64;
+
+#[scope]
+impl f64 {
+ /// Positive infinity.
+ const INF: f64 = f64::INFINITY;
+
+ /// A NaN value, as defined by the
+ /// [IEEE 754 standard](https://en.wikipedia.org/wiki/IEEE_754).
+ const NAN: f64 = f64::NAN;
+
+ /// Converts a value to a float.
+ ///
+ /// - Booleans are converted to `0.0` or `1.0`.
+ /// - Integers are converted to the closest 64-bit float. For integers with
+ /// absolute value less than `{calc.pow(2, 53)}`, this conversion is
+ /// exact.
+ /// - Ratios are divided by 100%.
+ /// - Strings are parsed in base 10 to the closest 64-bit float. Exponential
+ /// notation is supported.
+ ///
+ /// ```example
+ /// #float(false) \
+ /// #float(true) \
+ /// #float(4) \
+ /// #float(40%) \
+ /// #float("2.7") \
+ /// #float("1e5")
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The value that should be converted to a float.
+ value: ToFloat,
+ ) -> f64 {
+ value.0
+ }
+
+ /// Checks if a float is not a number.
+ ///
+ /// In IEEE 754, more than one bit pattern represents a NaN. This function
+ /// returns `true` if the float is any of those bit patterns.
+ ///
+ /// ```example
+ /// #float.is-nan(0) \
+ /// #float.is-nan(1) \
+ /// #float.is-nan(float.nan)
+ /// ```
+ #[func]
+ pub fn is_nan(self) -> bool {
+ f64::is_nan(self)
+ }
+
+ /// Checks if a float is infinite.
+ ///
+ /// Floats can represent positive infinity and negative infinity. This
+ /// function returns `{true}` if the float is an infinity.
+ ///
+ /// ```example
+ /// #float.is-infinite(0) \
+ /// #float.is-infinite(1) \
+ /// #float.is-infinite(float.inf)
+ /// ```
+ #[func]
+ pub fn is_infinite(self) -> bool {
+ f64::is_infinite(self)
+ }
+
+ /// Calculates the sign of a floating point number.
+ ///
+ /// - If the number is positive (including `{+0.0}`), returns `{1.0}`.
+ /// - If the number is negative (including `{-0.0}`), returns `{-1.0}`.
+ /// - If the number is NaN, returns `{float.nan}`.
+ ///
+ /// ```example
+ /// #(5.0).signum() \
+ /// #(-5.0).signum() \
+ /// #(0.0).signum() \
+ /// #float.nan.signum()
+ /// ```
+ #[func]
+ pub fn signum(self) -> f64 {
+ f64::signum(self)
+ }
+
+ /// Converts bytes to a float.
+ ///
+ /// ```example
+ /// #float.from-bytes(bytes((0, 0, 0, 0, 0, 0, 240, 63))) \
+ /// #float.from-bytes(bytes((63, 240, 0, 0, 0, 0, 0, 0)), endian: "big")
+ /// ```
+ #[func]
+ pub fn from_bytes(
+ /// The bytes that should be converted to a float.
+ ///
+ /// Must be of length exactly 8 so that the result fits into a 64-bit
+ /// float.
+ bytes: Bytes,
+ /// The endianness of the conversion.
+ #[named]
+ #[default(Endianness::Little)]
+ endian: Endianness,
+ ) -> StrResult<f64> {
+ // Convert slice to an array of length 8.
+ let buf: [u8; 8] = match bytes.as_ref().try_into() {
+ Ok(buffer) => buffer,
+ Err(_) => bail!("bytes must have a length of exactly 8"),
+ };
+
+ Ok(match endian {
+ Endianness::Little => f64::from_le_bytes(buf),
+ Endianness::Big => f64::from_be_bytes(buf),
+ })
+ }
+
+ /// Converts a float to bytes.
+ ///
+ /// ```example
+ /// #array(1.0.to-bytes(endian: "big")) \
+ /// #array(1.0.to-bytes())
+ /// ```
+ #[func]
+ pub fn to_bytes(
+ self,
+ /// The endianness of the conversion.
+ #[named]
+ #[default(Endianness::Little)]
+ endian: Endianness,
+ ) -> Bytes {
+ match endian {
+ Endianness::Little => self.to_le_bytes(),
+ Endianness::Big => self.to_be_bytes(),
+ }
+ .as_slice()
+ .into()
+ }
+}
+
+impl Repr for f64 {
+ fn repr(&self) -> EcoString {
+ repr::format_float(*self, None, true, "")
+ }
+}
+
+/// A value that can be cast to a float.
+pub struct ToFloat(f64);
+
+cast! {
+ ToFloat,
+ v: f64 => Self(v),
+ v: bool => Self(v as i64 as f64),
+ v: i64 => Self(v as f64),
+ v: Decimal => Self(f64::try_from(v).map_err(|_| eco_format!("invalid float: {}", v))?),
+ v: Ratio => Self(v.get()),
+ v: Str => Self(
+ parse_float(v.clone().into())
+ .map_err(|_| eco_format!("invalid float: {}", v))?
+ ),
+}
+
+fn parse_float(s: EcoString) -> Result<f64, ParseFloatError> {
+ s.replace(repr::MINUS_SIGN, "-").parse()
+}
diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs
new file mode 100644
index 00000000..1b40714b
--- /dev/null
+++ b/crates/typst-library/src/foundations/func.rs
@@ -0,0 +1,541 @@
+#[doc(inline)]
+pub use typst_macros::func;
+
+use std::fmt::{self, Debug, Formatter};
+use std::sync::Arc;
+
+use comemo::{Tracked, TrackedMut};
+use ecow::{eco_format, EcoString};
+use once_cell::sync::Lazy;
+use typst_syntax::{ast, Span, SyntaxNode};
+use typst_utils::{singleton, LazyHash, Static};
+
+use crate::diag::{bail, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, repr, scope, ty, Args, CastInfo, Content, Context, Element, IntoArgs, Scope,
+ Selector, Type, Value,
+};
+
+/// A mapping from argument values to a return value.
+///
+/// You can call a function by writing a comma-separated list of function
+/// _arguments_ enclosed in parentheses directly after the function name.
+/// Additionally, you can pass any number of trailing content blocks arguments
+/// to a function _after_ the normal argument list. If the normal argument list
+/// would become empty, it can be omitted. Typst supports positional and named
+/// arguments. The former are identified by position and type, while the latter
+/// are written as `name: value`.
+///
+/// Within math mode, function calls have special behaviour. See the
+/// [math documentation]($category/math) for more details.
+///
+/// # Example
+/// ```example
+/// // Call a function.
+/// #list([A], [B])
+///
+/// // Named arguments and trailing
+/// // content blocks.
+/// #enum(start: 2)[A][B]
+///
+/// // Version without parentheses.
+/// #list[A][B]
+/// ```
+///
+/// Functions are a fundamental building block of Typst. Typst provides
+/// functions for a variety of typesetting tasks. Moreover, the markup you write
+/// is backed by functions and all styling happens through functions. This
+/// reference lists all available functions and how you can use them. Please
+/// also refer to the documentation about [set]($styling/#set-rules) and
+/// [show]($styling/#show-rules) rules to learn about additional ways you can
+/// work with functions in Typst.
+///
+/// # Element functions
+/// Some functions are associated with _elements_ like [headings]($heading) or
+/// [tables]($table). When called, these create an element of their respective
+/// kind. In contrast to normal functions, they can further be used in [set
+/// rules]($styling/#set-rules), [show rules]($styling/#show-rules), and
+/// [selectors]($selector).
+///
+/// # Function scopes
+/// Functions can hold related definitions in their own scope, similar to a
+/// [module]($scripting/#modules). Examples of this are
+/// [`assert.eq`]($assert.eq) or [`list.item`]($list.item). However, this
+/// feature is currently only available for built-in functions.
+///
+/// # Defining functions
+/// You can define your own function with a [let binding]($scripting/#bindings)
+/// that has a parameter list after the binding's name. The parameter list can
+/// contain mandatory positional parameters, named parameters with default
+/// values and [argument sinks]($arguments).
+///
+/// The right-hand side of a function binding is the function body, which can be
+/// a block or any other expression. It defines the function's return value and
+/// can depend on the parameters. If the function body is a [code
+/// block]($scripting/#blocks), the return value is the result of joining the
+/// values of each expression in the block.
+///
+/// Within a function body, the `return` keyword can be used to exit early and
+/// optionally specify a return value. If no explicit return value is given, the
+/// body evaluates to the result of joining all expressions preceding the
+/// `return`.
+///
+/// Functions that don't return any meaningful value return [`none`] instead.
+/// The return type of such functions is not explicitly specified in the
+/// documentation. (An example of this is [`array.push`]).
+///
+/// ```example
+/// #let alert(body, fill: red) = {
+/// set text(white)
+/// set align(center)
+/// rect(
+/// fill: fill,
+/// inset: 8pt,
+/// radius: 4pt,
+/// [*Warning:\ #body*],
+/// )
+/// }
+///
+/// #alert[
+/// Danger is imminent!
+/// ]
+///
+/// #alert(fill: blue)[
+/// KEEP OFF TRACKS
+/// ]
+/// ```
+///
+/// # Importing functions
+/// Functions can be imported from one file ([`module`]($scripting/#modules)) into
+/// another using `{import}`. For example, assume that we have defined the `alert`
+/// function from the previous example in a file called `foo.typ`. We can import
+/// it into another file by writing `{import "foo.typ": alert}`.
+///
+/// # Unnamed functions { #unnamed }
+/// You can also created an unnamed function without creating a binding by
+/// specifying a parameter list followed by `=>` and the function body. If your
+/// function has just one parameter, the parentheses around the parameter list
+/// are optional. Unnamed functions are mainly useful for show rules, but also
+/// for settable properties that take functions like the page function's
+/// [`footer`]($page.footer) property.
+///
+/// ```example
+/// #show "once?": it => [#it #it]
+/// once?
+/// ```
+///
+/// # Note on function purity
+/// In Typst, all functions are _pure._ This means that for the same
+/// arguments, they always return the same result. They cannot "remember" things to
+/// produce another value when they are called a second time.
+///
+/// The only exception are built-in methods like
+/// [`array.push(value)`]($array.push). These can modify the values they are
+/// called on.
+#[ty(scope, cast, name = "function")]
+#[derive(Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Func {
+ /// The internal representation.
+ repr: Repr,
+ /// The span with which errors are reported when this function is called.
+ span: Span,
+}
+
+/// The different kinds of function representations.
+#[derive(Clone, PartialEq, Hash)]
+enum Repr {
+ /// A native Rust function.
+ Native(Static<NativeFuncData>),
+ /// A function for an element.
+ Element(Element),
+ /// A user-defined closure.
+ Closure(Arc<LazyHash<Closure>>),
+ /// A nested function with pre-applied arguments.
+ With(Arc<(Func, Args)>),
+}
+
+impl Func {
+ /// The function's name (e.g. `min`).
+ ///
+ /// Returns `None` if this is an anonymous closure.
+ pub fn name(&self) -> Option<&str> {
+ match &self.repr {
+ Repr::Native(native) => Some(native.name),
+ Repr::Element(elem) => Some(elem.name()),
+ Repr::Closure(closure) => closure.name(),
+ Repr::With(with) => with.0.name(),
+ }
+ }
+
+ /// The function's title case name, for use in documentation (e.g. `Minimum`).
+ ///
+ /// Returns `None` if this is a closure.
+ pub fn title(&self) -> Option<&'static str> {
+ match &self.repr {
+ Repr::Native(native) => Some(native.title),
+ Repr::Element(elem) => Some(elem.title()),
+ Repr::Closure(_) => None,
+ Repr::With(with) => with.0.title(),
+ }
+ }
+
+ /// Documentation for the function (as Markdown).
+ pub fn docs(&self) -> Option<&'static str> {
+ match &self.repr {
+ Repr::Native(native) => Some(native.docs),
+ Repr::Element(elem) => Some(elem.docs()),
+ Repr::Closure(_) => None,
+ Repr::With(with) => with.0.docs(),
+ }
+ }
+
+ /// Whether the function is known to be contextual.
+ pub fn contextual(&self) -> Option<bool> {
+ match &self.repr {
+ Repr::Native(native) => Some(native.contextual),
+ _ => None,
+ }
+ }
+
+ /// Get details about this function's parameters if available.
+ pub fn params(&self) -> Option<&'static [ParamInfo]> {
+ match &self.repr {
+ Repr::Native(native) => Some(&native.0.params),
+ Repr::Element(elem) => Some(elem.params()),
+ Repr::Closure(_) => None,
+ Repr::With(with) => with.0.params(),
+ }
+ }
+
+ /// Get the parameter info for a parameter with the given name if it exist.
+ pub fn param(&self, name: &str) -> Option<&'static ParamInfo> {
+ self.params()?.iter().find(|param| param.name == name)
+ }
+
+ /// Get details about the function's return type.
+ pub fn returns(&self) -> Option<&'static CastInfo> {
+ match &self.repr {
+ Repr::Native(native) => Some(&native.0.returns),
+ Repr::Element(_) => {
+ Some(singleton!(CastInfo, CastInfo::Type(Type::of::<Content>())))
+ }
+ Repr::Closure(_) => None,
+ Repr::With(with) => with.0.returns(),
+ }
+ }
+
+ /// Search keywords for the function.
+ pub fn keywords(&self) -> &'static [&'static str] {
+ match &self.repr {
+ Repr::Native(native) => native.keywords,
+ Repr::Element(elem) => elem.keywords(),
+ Repr::Closure(_) => &[],
+ Repr::With(with) => with.0.keywords(),
+ }
+ }
+
+ /// The function's associated scope of sub-definition.
+ pub fn scope(&self) -> Option<&'static Scope> {
+ match &self.repr {
+ Repr::Native(native) => Some(&native.0.scope),
+ Repr::Element(elem) => Some(elem.scope()),
+ Repr::Closure(_) => None,
+ Repr::With(with) => with.0.scope(),
+ }
+ }
+
+ /// Get a field from this function's scope, if possible.
+ pub fn field(&self, field: &str) -> StrResult<&'static Value> {
+ let scope =
+ self.scope().ok_or("cannot access fields on user-defined functions")?;
+ match scope.get(field) {
+ Some(field) => Ok(field),
+ None => match self.name() {
+ Some(name) => bail!("function `{name}` does not contain field `{field}`"),
+ None => bail!("function does not contain field `{field}`"),
+ },
+ }
+ }
+
+ /// Extract the element function, if it is one.
+ pub fn element(&self) -> Option<Element> {
+ match self.repr {
+ Repr::Element(func) => Some(func),
+ _ => None,
+ }
+ }
+
+ /// Call the function with the given context and arguments.
+ pub fn call<A: IntoArgs>(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ args: A,
+ ) -> SourceResult<Value> {
+ self.call_impl(engine, context, args.into_args(self.span))
+ }
+
+ /// Non-generic implementation of `call`.
+ #[typst_macros::time(name = "func call", span = self.span())]
+ fn call_impl(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ mut args: Args,
+ ) -> SourceResult<Value> {
+ match &self.repr {
+ Repr::Native(native) => {
+ let value = (native.function)(engine, context, &mut args)?;
+ args.finish()?;
+ Ok(value)
+ }
+ Repr::Element(func) => {
+ let value = func.construct(engine, &mut args)?;
+ args.finish()?;
+ Ok(Value::Content(value))
+ }
+ Repr::Closure(closure) => (engine.routines.eval_closure)(
+ self,
+ closure,
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ context,
+ args,
+ ),
+ Repr::With(with) => {
+ args.items = with.1.items.iter().cloned().chain(args.items).collect();
+ with.0.call(engine, context, args)
+ }
+ }
+ }
+
+ /// The function's span.
+ pub fn span(&self) -> Span {
+ self.span
+ }
+
+ /// Attach a span to this function if it doesn't already have one.
+ pub fn spanned(mut self, span: Span) -> Self {
+ if self.span.is_detached() {
+ self.span = span;
+ }
+ self
+ }
+}
+
+#[scope]
+impl Func {
+ /// Returns a new function that has the given arguments pre-applied.
+ #[func]
+ pub fn with(
+ self,
+ /// The real arguments (the other argument is just for the docs).
+ /// The docs argument cannot be called `args`.
+ args: &mut Args,
+ /// The arguments to apply to the function.
+ #[external]
+ #[variadic]
+ arguments: Vec<Value>,
+ ) -> Func {
+ let span = self.span;
+ Self {
+ repr: Repr::With(Arc::new((self, args.take()))),
+ span,
+ }
+ }
+
+ /// Returns a selector that filters for elements belonging to this function
+ /// whose fields have the values of the given arguments.
+ ///
+ /// ```example
+ /// #show heading.where(level: 2): set text(blue)
+ /// = Section
+ /// == Subsection
+ /// === Sub-subsection
+ /// ```
+ #[func]
+ pub fn where_(
+ self,
+ /// The real arguments (the other argument is just for the docs).
+ /// The docs argument cannot be called `args`.
+ args: &mut Args,
+ /// The fields to filter for.
+ #[variadic]
+ #[external]
+ fields: Vec<Value>,
+ ) -> StrResult<Selector> {
+ let fields = args.to_named();
+ args.items.retain(|arg| arg.name.is_none());
+
+ let element = self
+ .element()
+ .ok_or("`where()` can only be called on element functions")?;
+
+ let fields = fields
+ .into_iter()
+ .map(|(key, value)| {
+ element.field_id(&key).map(|id| (id, value)).ok_or_else(|| {
+ eco_format!(
+ "element `{}` does not have field `{}`",
+ element.name(),
+ key
+ )
+ })
+ })
+ .collect::<StrResult<smallvec::SmallVec<_>>>()?;
+
+ Ok(element.where_(fields))
+ }
+}
+
+impl Debug for Func {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Func({})", self.name().unwrap_or(".."))
+ }
+}
+
+impl repr::Repr for Func {
+ fn repr(&self) -> EcoString {
+ match self.name() {
+ Some(name) => name.into(),
+ None => "(..) => ..".into(),
+ }
+ }
+}
+
+impl PartialEq for Func {
+ fn eq(&self, other: &Self) -> bool {
+ self.repr == other.repr
+ }
+}
+
+impl PartialEq<&NativeFuncData> for Func {
+ fn eq(&self, other: &&NativeFuncData) -> bool {
+ match &self.repr {
+ Repr::Native(native) => native.function == other.function,
+ _ => false,
+ }
+ }
+}
+
+impl From<Repr> for Func {
+ fn from(repr: Repr) -> Self {
+ Self { repr, span: Span::detached() }
+ }
+}
+
+impl From<Element> for Func {
+ fn from(func: Element) -> Self {
+ Repr::Element(func).into()
+ }
+}
+
+/// A Typst function that is defined by a native Rust type that shadows a
+/// native Rust function.
+pub trait NativeFunc {
+ /// Get the function for the native Rust type.
+ fn func() -> Func {
+ Func::from(Self::data())
+ }
+
+ /// Get the function data for the native Rust type.
+ fn data() -> &'static NativeFuncData;
+}
+
+/// Defines a native function.
+#[derive(Debug)]
+pub struct NativeFuncData {
+ /// Invokes the function from Typst.
+ pub function: fn(&mut Engine, Tracked<Context>, &mut Args) -> SourceResult<Value>,
+ /// The function's normal name (e.g. `align`), as exposed to Typst.
+ pub name: &'static str,
+ /// The function's title case name (e.g. `Align`).
+ pub title: &'static str,
+ /// The documentation for this function as a string.
+ pub docs: &'static str,
+ /// A list of alternate search terms for this function.
+ pub keywords: &'static [&'static str],
+ /// Whether this function makes use of context.
+ pub contextual: bool,
+ pub scope: Lazy<Scope>,
+ /// A list of parameter information for each parameter.
+ pub params: Lazy<Vec<ParamInfo>>,
+ /// Information about the return value of this function.
+ pub returns: Lazy<CastInfo>,
+}
+
+impl From<&'static NativeFuncData> for Func {
+ fn from(data: &'static NativeFuncData) -> Self {
+ Repr::Native(Static(data)).into()
+ }
+}
+
+cast! {
+ &'static NativeFuncData,
+ self => Func::from(self).into_value(),
+}
+
+/// Describes a function parameter.
+#[derive(Debug, Clone)]
+pub struct ParamInfo {
+ /// The parameter's name.
+ pub name: &'static str,
+ /// Documentation for the parameter.
+ pub docs: &'static str,
+ /// Describe what values this parameter accepts.
+ pub input: CastInfo,
+ /// Creates an instance of the parameter's default value.
+ pub default: Option<fn() -> Value>,
+ /// Is the parameter positional?
+ pub positional: bool,
+ /// Is the parameter named?
+ ///
+ /// Can be true even if `positional` is true if the parameter can be given
+ /// in both variants.
+ pub named: bool,
+ /// Can the parameter be given any number of times?
+ pub variadic: bool,
+ /// Is the parameter required?
+ pub required: bool,
+ /// Is the parameter settable with a set rule?
+ pub settable: bool,
+}
+
+/// A user-defined closure.
+#[derive(Debug, Hash)]
+pub struct Closure {
+ /// The closure's syntax node. Must be either castable to `ast::Closure` or
+ /// `ast::Expr`. In the latter case, this is a synthesized closure without
+ /// any parameters (used by `context` expressions).
+ pub node: SyntaxNode,
+ /// Default values of named parameters.
+ pub defaults: Vec<Value>,
+ /// Captured values from outer scopes.
+ pub captured: Scope,
+ /// The number of positional parameters in the closure.
+ pub num_pos_params: usize,
+}
+
+impl Closure {
+ /// The name of the closure.
+ pub fn name(&self) -> Option<&str> {
+ self.node.cast::<ast::Closure>()?.name().map(|ident| ident.as_str())
+ }
+}
+
+impl From<Closure> for Func {
+ fn from(closure: Closure) -> Self {
+ Repr::Closure(Arc::new(LazyHash::new(closure))).into()
+ }
+}
+
+cast! {
+ Closure,
+ self => Value::Func(self.into()),
+}
diff --git a/crates/typst-library/src/foundations/int.rs b/crates/typst-library/src/foundations/int.rs
new file mode 100644
index 00000000..e936353c
--- /dev/null
+++ b/crates/typst-library/src/foundations/int.rs
@@ -0,0 +1,478 @@
+use std::num::{NonZeroI64, NonZeroIsize, NonZeroU64, NonZeroUsize, ParseIntError};
+
+use ecow::{eco_format, EcoString};
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{
+ cast, func, repr, scope, ty, Bytes, Cast, Decimal, Repr, Str, Value,
+};
+
+/// A whole number.
+///
+/// The number can be negative, zero, or positive. As Typst uses 64 bits to
+/// store integers, integers cannot be smaller than `{-9223372036854775808}` or
+/// larger than `{9223372036854775807}`.
+///
+/// The number can also be specified as hexadecimal, octal, or binary by
+/// starting it with a zero followed by either `x`, `o`, or `b`.
+///
+/// You can convert a value to an integer with this type's constructor.
+///
+/// # Example
+/// ```example
+/// #(1 + 2) \
+/// #(2 - 5) \
+/// #(3 + 4 < 8)
+///
+/// #0xff \
+/// #0o10 \
+/// #0b1001
+/// ```
+#[ty(scope, cast, name = "int", title = "Integer")]
+type i64;
+
+#[scope]
+impl i64 {
+ /// Converts a value to an integer. Raises an error if there is an attempt
+ /// to produce an integer larger than the maximum 64-bit signed integer
+ /// or smaller than the minimum 64-bit signed integer.
+ ///
+ /// - Booleans are converted to `0` or `1`.
+ /// - Floats and decimals are truncated to the next 64-bit integer.
+ /// - Strings are parsed in base 10.
+ ///
+ /// ```example
+ /// #int(false) \
+ /// #int(true) \
+ /// #int(2.7) \
+ /// #int(decimal("3.8")) \
+ /// #(int("27") + int("4"))
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The value that should be converted to an integer.
+ value: ToInt,
+ ) -> i64 {
+ value.0
+ }
+
+ /// Calculates the sign of an integer.
+ ///
+ /// - If the number is positive, returns `{1}`.
+ /// - If the number is negative, returns `{-1}`.
+ /// - If the number is zero, returns `{0}`.
+ ///
+ /// ```example
+ /// #(5).signum() \
+ /// #(-5).signum() \
+ /// #(0).signum()
+ /// ```
+ #[func]
+ pub fn signum(self) -> i64 {
+ i64::signum(self)
+ }
+
+ /// Calculates the bitwise NOT of an integer.
+ ///
+ /// For the purposes of this function, the operand is treated as a signed
+ /// integer of 64 bits.
+ ///
+ /// ```example
+ /// #4.bit-not() \
+ /// #(-1).bit-not()
+ /// ```
+ #[func(title = "Bitwise NOT")]
+ pub fn bit_not(self) -> i64 {
+ !self
+ }
+
+ /// Calculates the bitwise AND between two integers.
+ ///
+ /// For the purposes of this function, the operands are treated as signed
+ /// integers of 64 bits.
+ ///
+ /// ```example
+ /// #128.bit-and(192)
+ /// ```
+ #[func(title = "Bitwise AND")]
+ pub fn bit_and(
+ self,
+ /// The right-hand operand of the bitwise AND.
+ rhs: i64,
+ ) -> i64 {
+ self & rhs
+ }
+
+ /// Calculates the bitwise OR between two integers.
+ ///
+ /// For the purposes of this function, the operands are treated as signed
+ /// integers of 64 bits.
+ ///
+ /// ```example
+ /// #64.bit-or(32)
+ /// ```
+ #[func(title = "Bitwise OR")]
+ pub fn bit_or(
+ self,
+ /// The right-hand operand of the bitwise OR.
+ rhs: i64,
+ ) -> i64 {
+ self | rhs
+ }
+
+ /// Calculates the bitwise XOR between two integers.
+ ///
+ /// For the purposes of this function, the operands are treated as signed
+ /// integers of 64 bits.
+ ///
+ /// ```example
+ /// #64.bit-xor(96)
+ /// ```
+ #[func(title = "Bitwise XOR")]
+ pub fn bit_xor(
+ self,
+ /// The right-hand operand of the bitwise XOR.
+ rhs: i64,
+ ) -> i64 {
+ self ^ rhs
+ }
+
+ /// Shifts the operand's bits to the left by the specified amount.
+ ///
+ /// For the purposes of this function, the operand is treated as a signed
+ /// integer of 64 bits. An error will occur if the result is too large to
+ /// fit in a 64-bit integer.
+ ///
+ /// ```example
+ /// #33.bit-lshift(2) \
+ /// #(-1).bit-lshift(3)
+ /// ```
+ #[func(title = "Bitwise Left Shift")]
+ pub fn bit_lshift(
+ self,
+ /// The amount of bits to shift. Must not be negative.
+ shift: u32,
+ ) -> StrResult<i64> {
+ Ok(self.checked_shl(shift).ok_or("the result is too large")?)
+ }
+
+ /// Shifts the operand's bits to the right by the specified amount.
+ /// Performs an arithmetic shift by default (extends the sign bit to the left,
+ /// such that negative numbers stay negative), but that can be changed by the
+ /// `logical` parameter.
+ ///
+ /// For the purposes of this function, the operand is treated as a signed
+ /// integer of 64 bits.
+ ///
+ /// ```example
+ /// #64.bit-rshift(2) \
+ /// #(-8).bit-rshift(2) \
+ /// #(-8).bit-rshift(2, logical: true)
+ /// ```
+ #[func(title = "Bitwise Right Shift")]
+ pub fn bit_rshift(
+ self,
+ /// The amount of bits to shift. Must not be negative.
+ ///
+ /// Shifts larger than 63 are allowed and will cause the return value to
+ /// saturate. For non-negative numbers, the return value saturates at
+ /// `{0}`, while, for negative numbers, it saturates at `{-1}` if
+ /// `logical` is set to `{false}`, or `{0}` if it is `{true}`. This
+ /// behavior is consistent with just applying this operation multiple
+ /// times. Therefore, the shift will always succeed.
+ shift: u32,
+ /// Toggles whether a logical (unsigned) right shift should be performed
+ /// instead of arithmetic right shift.
+ /// If this is `{true}`, negative operands will not preserve their sign
+ /// bit, and bits which appear to the left after the shift will be
+ /// `{0}`. This parameter has no effect on non-negative operands.
+ #[named]
+ #[default(false)]
+ logical: bool,
+ ) -> i64 {
+ if logical {
+ if shift >= u64::BITS {
+ // Excessive logical right shift would be equivalent to setting
+ // all bits to zero. Using `.min(63)` is not enough for logical
+ // right shift, since `-1 >> 63` returns 1, whereas
+ // `calc.bit-rshift(-1, 64)` should return the same as
+ // `(-1 >> 63) >> 1`, which is zero.
+ 0
+ } else {
+ // Here we reinterpret the signed integer's bits as unsigned to
+ // perform logical right shift, and then reinterpret back as signed.
+ // This is valid as, according to the Rust reference, casting between
+ // two integers of same size (i64 <-> u64) is a no-op (two's complement
+ // is used).
+ // Reference:
+ // https://doc.rust-lang.org/stable/reference/expressions/operator-expr.html#numeric-cast
+ ((self as u64) >> shift) as i64
+ }
+ } else {
+ // Saturate at -1 (negative) or 0 (otherwise) on excessive arithmetic
+ // right shift. Shifting those numbers any further does not change
+ // them, so it is consistent.
+ let shift = shift.min(i64::BITS - 1);
+ self >> shift
+ }
+ }
+
+ /// Converts bytes to an integer.
+ ///
+ /// ```example
+ /// #int.from-bytes(bytes((0, 0, 0, 0, 0, 0, 0, 1))) \
+ /// #int.from-bytes(bytes((1, 0, 0, 0, 0, 0, 0, 0)), endian: "big")
+ /// ```
+ #[func]
+ pub fn from_bytes(
+ /// The bytes that should be converted to an integer.
+ ///
+ /// Must be of length at most 8 so that the result fits into a 64-bit
+ /// signed integer.
+ bytes: Bytes,
+ /// The endianness of the conversion.
+ #[named]
+ #[default(Endianness::Little)]
+ endian: Endianness,
+ /// Whether the bytes should be treated as a signed integer. If this is
+ /// `{true}` and the most significant bit is set, the resulting number
+ /// will negative.
+ #[named]
+ #[default(true)]
+ signed: bool,
+ ) -> StrResult<i64> {
+ let len = bytes.len();
+ if len == 0 {
+ return Ok(0);
+ } else if len > 8 {
+ bail!("too many bytes to convert to a 64 bit number");
+ }
+
+ // `decimal` will hold the part of the buffer that should be filled with
+ // the input bytes, `rest` will remain as is or be filled with 0xFF for
+ // negative numbers if signed is true.
+ //
+ // – big-endian: `decimal` will be the rightmost bytes of the buffer.
+ // - little-endian: `decimal` will be the leftmost bytes of the buffer.
+ let mut buf = [0u8; 8];
+ let (rest, decimal) = match endian {
+ Endianness::Big => buf.split_at_mut(8 - len),
+ Endianness::Little => {
+ let (first, second) = buf.split_at_mut(len);
+ (second, first)
+ }
+ };
+
+ decimal.copy_from_slice(bytes.as_ref());
+
+ // Perform sign-extension if necessary.
+ if signed {
+ let most_significant_byte = match endian {
+ Endianness::Big => decimal[0],
+ Endianness::Little => decimal[len - 1],
+ };
+
+ if most_significant_byte & 0b1000_0000 != 0 {
+ rest.fill(0xFF);
+ }
+ }
+
+ Ok(match endian {
+ Endianness::Big => i64::from_be_bytes(buf),
+ Endianness::Little => i64::from_le_bytes(buf),
+ })
+ }
+
+ /// Converts an integer to bytes.
+ ///
+ /// ```example
+ /// #array(10000.to-bytes(endian: "big")) \
+ /// #array(10000.to-bytes(size: 4))
+ /// ```
+ #[func]
+ pub fn to_bytes(
+ self,
+ /// The endianness of the conversion.
+ #[named]
+ #[default(Endianness::Little)]
+ endian: Endianness,
+ /// The size in bytes of the resulting bytes (must be at least zero). If
+ /// the integer is too large to fit in the specified size, the
+ /// conversion will truncate the remaining bytes based on the
+ /// endianness. To keep the same resulting value, if the endianness is
+ /// big-endian, the truncation will happen at the rightmost bytes.
+ /// Otherwise, if the endianness is little-endian, the truncation will
+ /// happen at the leftmost bytes.
+ ///
+ /// Be aware that if the integer is negative and the size is not enough
+ /// to make the number fit, when passing the resulting bytes to
+ /// `int.from-bytes`, the resulting number might be positive, as the
+ /// most significant bit might not be set to 1.
+ #[named]
+ #[default(8)]
+ size: usize,
+ ) -> Bytes {
+ let array = match endian {
+ Endianness::Big => self.to_be_bytes(),
+ Endianness::Little => self.to_le_bytes(),
+ };
+
+ let mut buf = vec![0u8; size];
+ match endian {
+ Endianness::Big => {
+ // Copy the bytes from the array to the buffer, starting from
+ // the end of the buffer.
+ let buf_start = size.saturating_sub(8);
+ let array_start = 8usize.saturating_sub(size);
+ buf[buf_start..].copy_from_slice(&array[array_start..])
+ }
+ Endianness::Little => {
+ // Copy the bytes from the array to the buffer, starting from
+ // the beginning of the buffer.
+ let end = size.min(8);
+ buf[..end].copy_from_slice(&array[..end])
+ }
+ }
+
+ Bytes::from(buf)
+ }
+}
+
+impl Repr for i64 {
+ fn repr(&self) -> EcoString {
+ eco_format!("{:?}", self)
+ }
+}
+
+/// Represents the byte order used for converting integers and floats to bytes
+/// and vice versa.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Endianness {
+ /// Big-endian byte order: The highest-value byte is at the beginning of the
+ /// bytes.
+ Big,
+ /// Little-endian byte order: The lowest-value byte is at the beginning of
+ /// the bytes.
+ Little,
+}
+
+/// A value that can be cast to an integer.
+pub struct ToInt(i64);
+
+cast! {
+ ToInt,
+ v: i64 => Self(v),
+ v: bool => Self(v as i64),
+ v: f64 => Self(convert_float_to_int(v)?),
+ v: Decimal => Self(i64::try_from(v).map_err(|_| eco_format!("number too large"))?),
+ v: Str => Self(parse_int(&v).map_err(|_| eco_format!("invalid integer: {}", v))?),
+}
+
+pub fn convert_float_to_int(f: f64) -> StrResult<i64> {
+ if f <= i64::MIN as f64 - 1.0 || f >= i64::MAX as f64 + 1.0 {
+ Err(eco_format!("number too large"))
+ } else {
+ Ok(f as i64)
+ }
+}
+
+fn parse_int(mut s: &str) -> Result<i64, ParseIntError> {
+ let mut sign = 1;
+ if let Some(rest) = s.strip_prefix('-').or_else(|| s.strip_prefix(repr::MINUS_SIGN)) {
+ sign = -1;
+ s = rest;
+ }
+ if sign == -1 && s == "9223372036854775808" {
+ return Ok(i64::MIN);
+ }
+ Ok(sign * s.parse::<i64>()?)
+}
+
+macro_rules! signed_int {
+ ($($ty:ty)*) => {
+ $(cast! {
+ $ty,
+ self => Value::Int(self as _),
+ v: i64 => v.try_into().map_err(|_| "number too large")?,
+ })*
+ }
+}
+
+macro_rules! unsigned_int {
+ ($($ty:ty)*) => {
+ $(cast! {
+ $ty,
+ self => {
+ #[allow(irrefutable_let_patterns)]
+ if let Ok(int) = i64::try_from(self) {
+ Value::Int(int)
+ } else {
+ // Some u64 are too large to be cast as i64
+ // In that case, we accept that there may be a
+ // precision loss, and use a floating point number
+ Value::Float(self as _)
+ }
+ },
+ v: i64 => v.try_into().map_err(|_| {
+ if v < 0 {
+ "number must be at least zero"
+ } else {
+ "number too large"
+ }
+ })?,
+ })*
+ }
+}
+
+signed_int! { i8 i16 i32 isize }
+unsigned_int! { u8 u16 u32 u64 usize }
+
+cast! {
+ NonZeroI64,
+ self => Value::Int(self.get() as _),
+ v: i64 => v.try_into()
+ .map_err(|_| if v == 0 {
+ "number must not be zero"
+ } else {
+ "number too large"
+ })?,
+}
+
+cast! {
+ NonZeroIsize,
+ self => Value::Int(self.get() as _),
+ v: i64 => v
+ .try_into()
+ .and_then(|v: isize| v.try_into())
+ .map_err(|_| if v == 0 {
+ "number must not be zero"
+ } else {
+ "number too large"
+ })?,
+}
+
+cast! {
+ NonZeroU64,
+ self => Value::Int(self.get() as _),
+ v: i64 => v
+ .try_into()
+ .and_then(|v: u64| v.try_into())
+ .map_err(|_| if v <= 0 {
+ "number must be positive"
+ } else {
+ "number too large"
+ })?,
+}
+
+cast! {
+ NonZeroUsize,
+ self => Value::Int(self.get() as _),
+ v: i64 => v
+ .try_into()
+ .and_then(|v: usize| v.try_into())
+ .map_err(|_| if v <= 0 {
+ "number must be positive"
+ } else {
+ "number too large"
+ })?,
+}
diff --git a/crates/typst-library/src/foundations/label.rs b/crates/typst-library/src/foundations/label.rs
new file mode 100644
index 00000000..726958df
--- /dev/null
+++ b/crates/typst-library/src/foundations/label.rs
@@ -0,0 +1,93 @@
+use ecow::{eco_format, EcoString};
+use typst_utils::PicoStr;
+
+use crate::foundations::{func, scope, ty, Repr};
+
+/// A label for an element.
+///
+/// Inserting a label into content attaches it to the closest preceding element
+/// that is not a space. The preceding element must be in the same scope as the
+/// label, which means that `[Hello #[<label>]]`, for instance, wouldn't work.
+///
+/// A labelled element can be [referenced]($ref), [queried]($query) for, and
+/// [styled]($styling) through its label.
+///
+/// Once constructed, you can get the name of a label using
+/// [`str`]($str/#constructor).
+///
+/// # Example
+/// ```example
+/// #show <a>: set text(blue)
+/// #show label("b"): set text(red)
+///
+/// = Heading <a>
+/// *Strong* #label("b")
+/// ```
+///
+/// # 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. A label's
+/// name can contain letters, numbers, `_`, `-`, `:`, and `.`.
+///
+/// Note that there is a syntactical difference when using the dedicated syntax
+/// for this function. In the code below, the `[<a>]` terminates the heading and
+/// thus attaches to the heading itself, whereas the `[#label("b")]` is part of
+/// the heading and thus attaches to the heading's text.
+///
+/// ```typ
+/// // Equivalent to `#heading[Introduction] <a>`.
+/// = Introduction <a>
+///
+/// // Equivalent to `#heading[Conclusion #label("b")]`.
+/// = Conclusion #label("b")
+/// ```
+///
+/// Currently, labels can only be attached to elements in markup mode, not in
+/// code mode. This might change in the future.
+#[ty(scope, cast)]
+#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Label(PicoStr);
+
+impl Label {
+ /// Creates a label from a string, interning it.
+ pub fn new(name: impl Into<PicoStr>) -> Self {
+ Self(name.into())
+ }
+
+ /// Resolves the label to a string.
+ pub fn as_str(&self) -> &'static str {
+ self.0.resolve()
+ }
+
+ /// Turns this label into its inner interned string.
+ pub fn into_inner(self) -> PicoStr {
+ self.0
+ }
+}
+
+#[scope]
+impl Label {
+ /// Creates a label from a string.
+ #[func(constructor)]
+ pub fn construct(
+ /// The name of the label.
+ name: PicoStr,
+ ) -> Label {
+ Self(name)
+ }
+}
+
+impl Repr for Label {
+ fn repr(&self) -> EcoString {
+ eco_format!("<{}>", self.as_str())
+ }
+}
+
+impl From<Label> for PicoStr {
+ fn from(value: Label) -> Self {
+ value.into_inner()
+ }
+}
+
+/// Indicates that an element cannot be labelled.
+pub trait Unlabellable {}
diff --git a/crates/typst-library/src/foundations/mod.rs b/crates/typst-library/src/foundations/mod.rs
new file mode 100644
index 00000000..a6d6c253
--- /dev/null
+++ b/crates/typst-library/src/foundations/mod.rs
@@ -0,0 +1,302 @@
+//! Foundational types and functions.
+
+pub mod calc;
+pub mod ops;
+pub mod repr;
+pub mod sys;
+
+mod args;
+mod array;
+mod auto;
+mod bool;
+mod bytes;
+mod cast;
+mod content;
+mod context;
+mod datetime;
+mod decimal;
+mod dict;
+mod duration;
+mod element;
+mod fields;
+mod float;
+mod func;
+mod int;
+mod label;
+mod module;
+mod none;
+mod plugin;
+mod scope;
+mod selector;
+mod str;
+mod styles;
+mod symbol;
+mod ty;
+mod value;
+mod version;
+
+pub use self::args::*;
+pub use self::array::*;
+pub use self::auto::*;
+pub use self::bytes::*;
+pub use self::cast::*;
+pub use self::content::*;
+pub use self::context::*;
+pub use self::datetime::*;
+pub use self::decimal::*;
+pub use self::dict::*;
+pub use self::duration::*;
+pub use self::element::*;
+pub use self::fields::*;
+pub use self::float::*;
+pub use self::func::*;
+pub use self::int::*;
+pub use self::label::*;
+pub use self::module::*;
+pub use self::none::*;
+pub use self::plugin::*;
+pub use self::repr::Repr;
+pub use self::scope::*;
+pub use self::selector::*;
+pub use self::str::*;
+pub use self::styles::*;
+pub use self::symbol::*;
+pub use self::ty::*;
+pub use self::value::*;
+pub use self::version::*;
+pub use typst_macros::{scope, ty};
+
+#[rustfmt::skip]
+#[doc(hidden)]
+pub use {
+ ecow::{eco_format, eco_vec},
+ indexmap::IndexMap,
+ once_cell::sync::Lazy,
+};
+
+use ecow::EcoString;
+use typst_syntax::Spanned;
+
+use crate::diag::{bail, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::routines::EvalMode;
+
+/// Foundational types and functions.
+///
+/// Here, you'll find documentation for basic data types like [integers]($int)
+/// and [strings]($str) as well as details about core computational functions.
+#[category]
+pub static FOUNDATIONS: Category;
+
+/// Hook up all `foundations` definitions.
+pub(super) fn define(global: &mut Scope, inputs: Dict) {
+ global.category(FOUNDATIONS);
+ global.define_type::<bool>();
+ global.define_type::<i64>();
+ global.define_type::<f64>();
+ global.define_type::<Str>();
+ global.define_type::<Label>();
+ global.define_type::<Bytes>();
+ global.define_type::<Content>();
+ global.define_type::<Array>();
+ global.define_type::<Dict>();
+ global.define_type::<Func>();
+ global.define_type::<Args>();
+ global.define_type::<Type>();
+ global.define_type::<Module>();
+ global.define_type::<Regex>();
+ global.define_type::<Selector>();
+ global.define_type::<Datetime>();
+ global.define_type::<Decimal>();
+ global.define_type::<Symbol>();
+ global.define_type::<Duration>();
+ global.define_type::<Version>();
+ global.define_type::<Plugin>();
+ global.define_func::<repr::repr>();
+ global.define_func::<panic>();
+ global.define_func::<assert>();
+ global.define_func::<eval>();
+ global.define_func::<style>();
+ global.define_module(calc::module());
+ global.define_module(sys::module(inputs));
+}
+
+/// Fails with an error.
+///
+/// Arguments are displayed to the user (not rendered in the document) as
+/// strings, converting with `repr` if necessary.
+///
+/// # Example
+/// The code below produces the error `panicked with: "this is wrong"`.
+/// ```typ
+/// #panic("this is wrong")
+/// ```
+#[func(keywords = ["error"])]
+pub fn panic(
+ /// The values to panic with and display to the user.
+ #[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`]($assert.eq) and [`assert.ne`]($assert.ne).
+///
+/// # Example
+/// ```typ
+/// #assert(1 < 2, message: "math broke")
+/// ```
+#[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)
+}
+
+#[scope]
+impl assert {
+ /// 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.
+ ///
+ /// ```typ
+ /// #assert.eq(10, 10)
+ /// ```
+ #[func(title = "Assert Equal")]
+ pub fn 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 {} was not equal to {}",
+ left.repr(),
+ right.repr()
+ );
+ }
+ }
+ 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.
+ ///
+ /// ```typ
+ /// #assert.ne(3, 4)
+ /// ```
+ #[func(title = "Assert Not Equal")]
+ pub fn 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 {} was equal to {}",
+ left.repr(),
+ right.repr()
+ );
+ }
+ }
+ Ok(NoneValue)
+ }
+}
+
+/// Evaluates a string as Typst code.
+///
+/// This function should only be used as a last resort.
+///
+/// # Example
+/// ```example
+/// #eval("1 + 1") \
+/// #eval("(1, 2, 3, 4)").len() \
+/// #eval("*Markup!*", mode: "markup") \
+/// ```
+#[func(title = "Evaluate")]
+pub fn eval(
+ /// The engine.
+ engine: &mut Engine,
+ /// A string of Typst code to evaluate.
+ source: Spanned<String>,
+ /// The [syntactical mode]($reference/syntax/#modes) in which the string is
+ /// parsed.
+ ///
+ /// ```example
+ /// #eval("= Heading", mode: "markup")
+ /// #eval("1_2^3", mode: "math")
+ /// ```
+ #[named]
+ #[default(EvalMode::Code)]
+ mode: EvalMode,
+ /// A scope of definitions that are made available.
+ ///
+ /// ```example
+ /// #eval("x + 1", scope: (x: 2)) \
+ /// #eval(
+ /// "abc/xyz",
+ /// mode: "math",
+ /// scope: (
+ /// abc: $a + b + c$,
+ /// xyz: $x + y + z$,
+ /// ),
+ /// )
+ /// ```
+ #[named]
+ #[default]
+ scope: Dict,
+) -> SourceResult<Value> {
+ let Spanned { v: text, span } = source;
+ let dict = scope;
+ let mut scope = Scope::new();
+ for (key, value) in dict {
+ scope.define_spanned(key, value, span);
+ }
+ (engine.routines.eval_string)(engine.routines, engine.world, &text, span, mode, scope)
+}
diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs
new file mode 100644
index 00000000..a476d6af
--- /dev/null
+++ b/crates/typst-library/src/foundations/module.rs
@@ -0,0 +1,142 @@
+use std::fmt::{self, Debug, Formatter};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use typst_syntax::FileId;
+
+use crate::diag::StrResult;
+use crate::foundations::{repr, ty, Content, Scope, Value};
+
+/// An evaluated module, either built-in or resulting from a file.
+///
+/// You can access definitions from the module using
+/// [field access notation]($scripting/#fields) and interact with it using the
+/// [import and include syntaxes]($scripting/#modules). Alternatively, it is
+/// possible to convert a module to a dictionary, and therefore access its
+/// contents dynamically, using the
+/// [dictionary constructor]($dictionary/#constructor).
+///
+/// # Example
+/// ```example
+/// <<< #import "utils.typ"
+/// <<< #utils.add(2, 5)
+///
+/// <<< #import utils: sub
+/// <<< #sub(1, 4)
+/// >>> #7
+/// >>>
+/// >>> #(-3)
+/// ```
+#[ty(cast)]
+#[derive(Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Module {
+ /// The module's name.
+ name: EcoString,
+ /// The reference-counted inner fields.
+ inner: Arc<Repr>,
+}
+
+/// The internal representation.
+#[derive(Debug, Clone, Hash)]
+struct Repr {
+ /// The top-level definitions that were bound in this module.
+ scope: Scope,
+ /// The module's layoutable contents.
+ content: Content,
+ /// The id of the file which defines the module, if any.
+ file_id: Option<FileId>,
+}
+
+impl Module {
+ /// Create a new module.
+ pub fn new(name: impl Into<EcoString>, scope: Scope) -> Self {
+ Self {
+ name: name.into(),
+ inner: Arc::new(Repr { scope, content: Content::empty(), file_id: None }),
+ }
+ }
+
+ /// Update the module's name.
+ pub fn with_name(mut self, name: impl Into<EcoString>) -> Self {
+ self.name = name.into();
+ self
+ }
+
+ /// Update the module's scope.
+ pub fn with_scope(mut self, scope: Scope) -> Self {
+ Arc::make_mut(&mut self.inner).scope = scope;
+ self
+ }
+
+ /// Update the module's content.
+ pub fn with_content(mut self, content: Content) -> Self {
+ Arc::make_mut(&mut self.inner).content = content;
+ self
+ }
+
+ /// Update the module's file id.
+ pub fn with_file_id(mut self, file_id: FileId) -> Self {
+ Arc::make_mut(&mut self.inner).file_id = Some(file_id);
+ self
+ }
+
+ /// Get the module's name.
+ pub fn name(&self) -> &EcoString {
+ &self.name
+ }
+
+ /// Access the module's scope.
+ pub fn scope(&self) -> &Scope {
+ &self.inner.scope
+ }
+
+ /// Access the module's file id.
+ ///
+ /// Some modules are not associated with a file, like the built-in modules.
+ pub fn file_id(&self) -> Option<FileId> {
+ self.inner.file_id
+ }
+
+ /// Access the module's scope, mutably.
+ pub fn scope_mut(&mut self) -> &mut Scope {
+ &mut Arc::make_mut(&mut self.inner).scope
+ }
+
+ /// Try to access a definition in the module.
+ pub fn field(&self, name: &str) -> StrResult<&Value> {
+ self.scope().get(name).ok_or_else(|| {
+ eco_format!("module `{}` does not contain `{name}`", self.name())
+ })
+ }
+
+ /// Extract the module's content.
+ pub fn content(self) -> Content {
+ match Arc::try_unwrap(self.inner) {
+ Ok(repr) => repr.content,
+ Err(arc) => arc.content.clone(),
+ }
+ }
+}
+
+impl Debug for Module {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.debug_struct("Module")
+ .field("name", &self.name)
+ .field("scope", &self.inner.scope)
+ .field("content", &self.inner.content)
+ .finish()
+ }
+}
+
+impl repr::Repr for Module {
+ fn repr(&self) -> EcoString {
+ eco_format!("<module {}>", self.name())
+ }
+}
+
+impl PartialEq for Module {
+ fn eq(&self, other: &Self) -> bool {
+ self.name == other.name && Arc::ptr_eq(&self.inner, &other.inner)
+ }
+}
diff --git a/crates/typst-library/src/foundations/none.rs b/crates/typst-library/src/foundations/none.rs
new file mode 100644
index 00000000..d376c0c5
--- /dev/null
+++ b/crates/typst-library/src/foundations/none.rs
@@ -0,0 +1,114 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::EcoString;
+use serde::{Serialize, Serializer};
+
+use crate::diag::HintedStrResult;
+use crate::foundations::{
+ cast, ty, CastInfo, FromValue, IntoValue, Reflect, Repr, Type, Value,
+};
+
+/// A value that indicates the absence of any other value.
+///
+/// The none type has exactly one value: `{none}`.
+///
+/// When inserted into the document, it is not visible. This is also the value
+/// that is produced by empty code blocks. It can be
+/// [joined]($scripting/#blocks) with any value, yielding the other value.
+///
+/// # Example
+/// ```example
+/// Not visible: #none
+/// ```
+#[ty(cast, name = "none")]
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct NoneValue;
+
+impl Reflect for NoneValue {
+ fn input() -> CastInfo {
+ CastInfo::Type(Type::of::<Self>())
+ }
+
+ fn output() -> CastInfo {
+ CastInfo::Type(Type::of::<Self>())
+ }
+
+ fn castable(value: &Value) -> bool {
+ matches!(value, Value::None)
+ }
+}
+
+impl IntoValue for NoneValue {
+ fn into_value(self) -> Value {
+ Value::None
+ }
+}
+
+impl FromValue for NoneValue {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ match value {
+ Value::None => Ok(Self),
+ _ => Err(Self::error(&value)),
+ }
+ }
+}
+
+impl Debug for NoneValue {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("None")
+ }
+}
+
+impl Repr for NoneValue {
+ fn repr(&self) -> EcoString {
+ "none".into()
+ }
+}
+
+impl Serialize for NoneValue {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_none()
+ }
+}
+
+cast! {
+ (),
+ self => Value::None,
+ _: NoneValue => (),
+}
+
+impl<T: Reflect> Reflect for Option<T> {
+ fn input() -> CastInfo {
+ T::input() + NoneValue::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output() + NoneValue::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ NoneValue::castable(value) || T::castable(value)
+ }
+}
+
+impl<T: IntoValue> IntoValue for Option<T> {
+ fn into_value(self) -> Value {
+ match self {
+ Some(v) => v.into_value(),
+ None => Value::None,
+ }
+ }
+}
+
+impl<T: FromValue> FromValue for Option<T> {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ match value {
+ Value::None => Ok(None),
+ v if T::castable(&v) => Ok(Some(T::from_value(v)?)),
+ _ => Err(Self::error(&value)),
+ }
+ }
+}
diff --git a/crates/typst-library/src/foundations/ops.rs b/crates/typst-library/src/foundations/ops.rs
new file mode 100644
index 00000000..ba36137f
--- /dev/null
+++ b/crates/typst-library/src/foundations/ops.rs
@@ -0,0 +1,583 @@
+//! Operations on values.
+
+use std::cmp::Ordering;
+
+use ecow::eco_format;
+use typst_utils::Numeric;
+
+use crate::diag::{bail, HintedStrResult, StrResult};
+use crate::foundations::{format_str, Datetime, IntoValue, Regex, Repr, Value};
+use crate::layout::{Alignment, Length, Rel};
+use crate::text::TextElem;
+use crate::visualize::Stroke;
+
+/// Bail with a type mismatch error.
+macro_rules! mismatch {
+ ($fmt:expr, $($value:expr),* $(,)?) => {
+ return Err(eco_format!($fmt, $($value.ty()),*).into())
+ };
+}
+
+/// Join a value with another value.
+pub fn join(lhs: Value, rhs: Value) -> StrResult<Value> {
+ use Value::*;
+ Ok(match (lhs, rhs) {
+ (a, None) => a,
+ (None, b) => b,
+ (Symbol(a), Symbol(b)) => Str(format_str!("{a}{b}")),
+ (Str(a), Str(b)) => Str(a + b),
+ (Str(a), Symbol(b)) => Str(format_str!("{a}{b}")),
+ (Symbol(a), Str(b)) => Str(format_str!("{a}{b}")),
+ (Bytes(a), Bytes(b)) => Bytes(a + b),
+ (Content(a), Content(b)) => Content(a + b),
+ (Content(a), Symbol(b)) => Content(a + TextElem::packed(b.get())),
+ (Content(a), Str(b)) => Content(a + TextElem::packed(b)),
+ (Str(a), Content(b)) => Content(TextElem::packed(a) + b),
+ (Symbol(a), Content(b)) => Content(TextElem::packed(a.get()) + b),
+ (Array(a), Array(b)) => Array(a + b),
+ (Dict(a), Dict(b)) => Dict(a + b),
+
+ // Type compatibility.
+ (Type(a), Str(b)) => Str(format_str!("{a}{b}")),
+ (Str(a), Type(b)) => Str(format_str!("{a}{b}")),
+
+ (a, b) => mismatch!("cannot join {} with {}", a, b),
+ })
+}
+
+/// Apply the unary plus operator to a value.
+pub fn pos(value: Value) -> HintedStrResult<Value> {
+ use Value::*;
+ Ok(match value {
+ Int(v) => Int(v),
+ Float(v) => Float(v),
+ Decimal(v) => Decimal(v),
+ Length(v) => Length(v),
+ Angle(v) => Angle(v),
+ Ratio(v) => Ratio(v),
+ Relative(v) => Relative(v),
+ Fraction(v) => Fraction(v),
+ Symbol(_) | Str(_) | Bytes(_) | Content(_) | Array(_) | Dict(_) | Datetime(_) => {
+ mismatch!("cannot apply unary '+' to {}", value)
+ }
+ Dyn(d) => {
+ if d.is::<Alignment>() {
+ mismatch!("cannot apply unary '+' to {}", d)
+ } else {
+ mismatch!("cannot apply '+' to {}", d)
+ }
+ }
+ v => mismatch!("cannot apply '+' to {}", v),
+ })
+}
+
+/// Compute the negation of a value.
+pub fn neg(value: Value) -> HintedStrResult<Value> {
+ use Value::*;
+ Ok(match value {
+ Int(v) => Int(v.checked_neg().ok_or_else(too_large)?),
+ Float(v) => Float(-v),
+ Decimal(v) => Decimal(-v),
+ Length(v) => Length(-v),
+ Angle(v) => Angle(-v),
+ Ratio(v) => Ratio(-v),
+ Relative(v) => Relative(-v),
+ Fraction(v) => Fraction(-v),
+ Duration(v) => Duration(-v),
+ Datetime(_) => mismatch!("cannot apply unary '-' to {}", value),
+ v => mismatch!("cannot apply '-' to {}", v),
+ })
+}
+
+/// Compute the sum of two values.
+pub fn add(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ use Value::*;
+ Ok(match (lhs, rhs) {
+ (a, None) => a,
+ (None, b) => b,
+
+ (Int(a), Int(b)) => Int(a.checked_add(b).ok_or_else(too_large)?),
+ (Int(a), Float(b)) => Float(a as f64 + b),
+ (Float(a), Int(b)) => Float(a + b as f64),
+ (Float(a), Float(b)) => Float(a + b),
+
+ (Decimal(a), Decimal(b)) => Decimal(a.checked_add(b).ok_or_else(too_large)?),
+ (Decimal(a), Int(b)) => Decimal(
+ a.checked_add(crate::foundations::Decimal::from(b))
+ .ok_or_else(too_large)?,
+ ),
+ (Int(a), Decimal(b)) => Decimal(
+ crate::foundations::Decimal::from(a)
+ .checked_add(b)
+ .ok_or_else(too_large)?,
+ ),
+
+ (Angle(a), Angle(b)) => Angle(a + b),
+
+ (Length(a), Length(b)) => Length(a + b),
+ (Length(a), Ratio(b)) => Relative(b + a),
+ (Length(a), Relative(b)) => Relative(b + a),
+
+ (Ratio(a), Length(b)) => Relative(a + b),
+ (Ratio(a), Ratio(b)) => Ratio(a + b),
+ (Ratio(a), Relative(b)) => Relative(b + a),
+
+ (Relative(a), Length(b)) => Relative(a + b),
+ (Relative(a), Ratio(b)) => Relative(a + b),
+ (Relative(a), Relative(b)) => Relative(a + b),
+
+ (Fraction(a), Fraction(b)) => Fraction(a + b),
+
+ (Symbol(a), Symbol(b)) => Str(format_str!("{a}{b}")),
+ (Str(a), Str(b)) => Str(a + b),
+ (Str(a), Symbol(b)) => Str(format_str!("{a}{b}")),
+ (Symbol(a), Str(b)) => Str(format_str!("{a}{b}")),
+ (Bytes(a), Bytes(b)) => Bytes(a + b),
+ (Content(a), Content(b)) => Content(a + b),
+ (Content(a), Symbol(b)) => Content(a + TextElem::packed(b.get())),
+ (Content(a), Str(b)) => Content(a + TextElem::packed(b)),
+ (Str(a), Content(b)) => Content(TextElem::packed(a) + b),
+ (Symbol(a), Content(b)) => Content(TextElem::packed(a.get()) + b),
+
+ (Array(a), Array(b)) => Array(a + b),
+ (Dict(a), Dict(b)) => Dict(a + b),
+
+ (Color(color), Length(thickness)) | (Length(thickness), Color(color)) => {
+ Stroke::from_pair(color, thickness).into_value()
+ }
+ (Gradient(gradient), Length(thickness))
+ | (Length(thickness), Gradient(gradient)) => {
+ Stroke::from_pair(gradient, thickness).into_value()
+ }
+ (Pattern(pattern), Length(thickness)) | (Length(thickness), Pattern(pattern)) => {
+ Stroke::from_pair(pattern, thickness).into_value()
+ }
+
+ (Duration(a), Duration(b)) => Duration(a + b),
+ (Datetime(a), Duration(b)) => Datetime(a + b),
+ (Duration(a), Datetime(b)) => Datetime(b + a),
+
+ // Type compatibility.
+ (Type(a), Str(b)) => Str(format_str!("{a}{b}")),
+ (Str(a), Type(b)) => Str(format_str!("{a}{b}")),
+
+ (Dyn(a), Dyn(b)) => {
+ // Alignments can be summed.
+ if let (Some(&a), Some(&b)) =
+ (a.downcast::<Alignment>(), b.downcast::<Alignment>())
+ {
+ return Ok((a + b)?.into_value());
+ }
+
+ mismatch!("cannot add {} and {}", a, b);
+ }
+
+ (a, b) => mismatch!("cannot add {} and {}", a, b),
+ })
+}
+
+/// Compute the difference of two values.
+pub fn sub(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ use Value::*;
+ Ok(match (lhs, rhs) {
+ (Int(a), Int(b)) => Int(a.checked_sub(b).ok_or_else(too_large)?),
+ (Int(a), Float(b)) => Float(a as f64 - b),
+ (Float(a), Int(b)) => Float(a - b as f64),
+ (Float(a), Float(b)) => Float(a - b),
+
+ (Decimal(a), Decimal(b)) => Decimal(a.checked_sub(b).ok_or_else(too_large)?),
+ (Decimal(a), Int(b)) => Decimal(
+ a.checked_sub(crate::foundations::Decimal::from(b))
+ .ok_or_else(too_large)?,
+ ),
+ (Int(a), Decimal(b)) => Decimal(
+ crate::foundations::Decimal::from(a)
+ .checked_sub(b)
+ .ok_or_else(too_large)?,
+ ),
+
+ (Angle(a), Angle(b)) => Angle(a - b),
+
+ (Length(a), Length(b)) => Length(a - b),
+ (Length(a), Ratio(b)) => Relative(-b + a),
+ (Length(a), Relative(b)) => Relative(-b + a),
+
+ (Ratio(a), Length(b)) => Relative(a + -b),
+ (Ratio(a), Ratio(b)) => Ratio(a - b),
+ (Ratio(a), Relative(b)) => Relative(-b + a),
+
+ (Relative(a), Length(b)) => Relative(a + -b),
+ (Relative(a), Ratio(b)) => Relative(a + -b),
+ (Relative(a), Relative(b)) => Relative(a - b),
+
+ (Fraction(a), Fraction(b)) => Fraction(a - b),
+
+ (Duration(a), Duration(b)) => Duration(a - b),
+ (Datetime(a), Duration(b)) => Datetime(a - b),
+ (Datetime(a), Datetime(b)) => Duration((a - b)?),
+
+ (a, b) => mismatch!("cannot subtract {1} from {0}", a, b),
+ })
+}
+
+/// Compute the product of two values.
+pub fn mul(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ use Value::*;
+ Ok(match (lhs, rhs) {
+ (Int(a), Int(b)) => Int(a.checked_mul(b).ok_or_else(too_large)?),
+ (Int(a), Float(b)) => Float(a as f64 * b),
+ (Float(a), Int(b)) => Float(a * b as f64),
+ (Float(a), Float(b)) => Float(a * b),
+
+ (Decimal(a), Decimal(b)) => Decimal(a.checked_mul(b).ok_or_else(too_large)?),
+ (Decimal(a), Int(b)) => Decimal(
+ a.checked_mul(crate::foundations::Decimal::from(b))
+ .ok_or_else(too_large)?,
+ ),
+ (Int(a), Decimal(b)) => Decimal(
+ crate::foundations::Decimal::from(a)
+ .checked_mul(b)
+ .ok_or_else(too_large)?,
+ ),
+
+ (Length(a), Int(b)) => Length(a * b as f64),
+ (Length(a), Float(b)) => Length(a * b),
+ (Length(a), Ratio(b)) => Length(a * b.get()),
+ (Int(a), Length(b)) => Length(b * a as f64),
+ (Float(a), Length(b)) => Length(b * a),
+ (Ratio(a), Length(b)) => Length(b * a.get()),
+
+ (Angle(a), Int(b)) => Angle(a * b as f64),
+ (Angle(a), Float(b)) => Angle(a * b),
+ (Angle(a), Ratio(b)) => Angle(a * b.get()),
+ (Int(a), Angle(b)) => Angle(a as f64 * b),
+ (Float(a), Angle(b)) => Angle(a * b),
+ (Ratio(a), Angle(b)) => Angle(a.get() * b),
+
+ (Ratio(a), Ratio(b)) => Ratio(a * b),
+ (Ratio(a), Int(b)) => Ratio(a * b as f64),
+ (Ratio(a), Float(b)) => Ratio(a * b),
+ (Int(a), Ratio(b)) => Ratio(a as f64 * b),
+ (Float(a), Ratio(b)) => Ratio(a * b),
+
+ (Relative(a), Int(b)) => Relative(a * b as f64),
+ (Relative(a), Float(b)) => Relative(a * b),
+ (Relative(a), Ratio(b)) => Relative(a * b.get()),
+ (Int(a), Relative(b)) => Relative(a as f64 * b),
+ (Float(a), Relative(b)) => Relative(a * b),
+ (Ratio(a), Relative(b)) => Relative(a.get() * b),
+
+ (Fraction(a), Int(b)) => Fraction(a * b as f64),
+ (Fraction(a), Float(b)) => Fraction(a * b),
+ (Fraction(a), Ratio(b)) => Fraction(a * b.get()),
+ (Int(a), Fraction(b)) => Fraction(a as f64 * b),
+ (Float(a), Fraction(b)) => Fraction(a * b),
+ (Ratio(a), Fraction(b)) => Fraction(a.get() * b),
+
+ (Str(a), Int(b)) => Str(a.repeat(Value::Int(b).cast()?)?),
+ (Int(a), Str(b)) => Str(b.repeat(Value::Int(a).cast()?)?),
+ (Array(a), Int(b)) => Array(a.repeat(Value::Int(b).cast()?)?),
+ (Int(a), Array(b)) => Array(b.repeat(Value::Int(a).cast()?)?),
+ (Content(a), b @ Int(_)) => Content(a.repeat(b.cast()?)),
+ (a @ Int(_), Content(b)) => Content(b.repeat(a.cast()?)),
+
+ (Int(a), Duration(b)) => Duration(b * (a as f64)),
+ (Float(a), Duration(b)) => Duration(b * a),
+ (Duration(a), Int(b)) => Duration(a * (b as f64)),
+ (Duration(a), Float(b)) => Duration(a * b),
+
+ (a, b) => mismatch!("cannot multiply {} with {}", a, b),
+ })
+}
+
+/// Compute the quotient of two values.
+pub fn div(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ use Value::*;
+ if is_zero(&rhs) {
+ bail!("cannot divide by zero");
+ }
+
+ Ok(match (lhs, rhs) {
+ (Int(a), Int(b)) => Float(a as f64 / b as f64),
+ (Int(a), Float(b)) => Float(a as f64 / b),
+ (Float(a), Int(b)) => Float(a / b as f64),
+ (Float(a), Float(b)) => Float(a / b),
+
+ (Decimal(a), Decimal(b)) => Decimal(a.checked_div(b).ok_or_else(too_large)?),
+ (Decimal(a), Int(b)) => Decimal(
+ a.checked_div(crate::foundations::Decimal::from(b))
+ .ok_or_else(too_large)?,
+ ),
+ (Int(a), Decimal(b)) => Decimal(
+ crate::foundations::Decimal::from(a)
+ .checked_div(b)
+ .ok_or_else(too_large)?,
+ ),
+
+ (Length(a), Int(b)) => Length(a / b as f64),
+ (Length(a), Float(b)) => Length(a / b),
+ (Length(a), Length(b)) => Float(try_div_length(a, b)?),
+ (Length(a), Relative(b)) if b.rel.is_zero() => Float(try_div_length(a, b.abs)?),
+
+ (Angle(a), Int(b)) => Angle(a / b as f64),
+ (Angle(a), Float(b)) => Angle(a / b),
+ (Angle(a), Angle(b)) => Float(a / b),
+
+ (Ratio(a), Int(b)) => Ratio(a / b as f64),
+ (Ratio(a), Float(b)) => Ratio(a / b),
+ (Ratio(a), Ratio(b)) => Float(a / b),
+ (Ratio(a), Relative(b)) if b.abs.is_zero() => Float(a / b.rel),
+
+ (Relative(a), Int(b)) => Relative(a / b as f64),
+ (Relative(a), Float(b)) => Relative(a / b),
+ (Relative(a), Length(b)) if a.rel.is_zero() => Float(try_div_length(a.abs, b)?),
+ (Relative(a), Ratio(b)) if a.abs.is_zero() => Float(a.rel / b),
+ (Relative(a), Relative(b)) => Float(try_div_relative(a, b)?),
+
+ (Fraction(a), Int(b)) => Fraction(a / b as f64),
+ (Fraction(a), Float(b)) => Fraction(a / b),
+ (Fraction(a), Fraction(b)) => Float(a / b),
+
+ (Duration(a), Int(b)) => Duration(a / (b as f64)),
+ (Duration(a), Float(b)) => Duration(a / b),
+ (Duration(a), Duration(b)) => Float(a / b),
+
+ (a, b) => mismatch!("cannot divide {} by {}", a, b),
+ })
+}
+
+/// Whether a value is a numeric zero.
+fn is_zero(v: &Value) -> bool {
+ use Value::*;
+ match *v {
+ Int(v) => v == 0,
+ Float(v) => v == 0.0,
+ Decimal(v) => v.is_zero(),
+ Length(v) => v.is_zero(),
+ Angle(v) => v.is_zero(),
+ Ratio(v) => v.is_zero(),
+ Relative(v) => v.is_zero(),
+ Fraction(v) => v.is_zero(),
+ Duration(v) => v.is_zero(),
+ _ => false,
+ }
+}
+
+/// Try to divide two lengths.
+fn try_div_length(a: Length, b: Length) -> StrResult<f64> {
+ a.try_div(b).ok_or_else(|| "cannot divide these two lengths".into())
+}
+
+/// Try to divide two relative lengths.
+fn try_div_relative(a: Rel<Length>, b: Rel<Length>) -> StrResult<f64> {
+ a.try_div(b)
+ .ok_or_else(|| "cannot divide these two relative lengths".into())
+}
+
+/// Compute the logical "not" of a value.
+pub fn not(value: Value) -> HintedStrResult<Value> {
+ match value {
+ Value::Bool(b) => Ok(Value::Bool(!b)),
+ v => mismatch!("cannot apply 'not' to {}", v),
+ }
+}
+
+/// Compute the logical "and" of two values.
+pub fn and(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ match (lhs, rhs) {
+ (Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(a && b)),
+ (a, b) => mismatch!("cannot apply 'and' to {} and {}", a, b),
+ }
+}
+
+/// Compute the logical "or" of two values.
+pub fn or(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ match (lhs, rhs) {
+ (Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(a || b)),
+ (a, b) => mismatch!("cannot apply 'or' to {} and {}", a, b),
+ }
+}
+
+/// Compute whether two values are equal.
+pub fn eq(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ Ok(Value::Bool(equal(&lhs, &rhs)))
+}
+
+/// Compute whether two values are unequal.
+pub fn neq(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ Ok(Value::Bool(!equal(&lhs, &rhs)))
+}
+
+macro_rules! comparison {
+ ($name:ident, $op:tt, $($pat:tt)*) => {
+ /// Compute how a value compares with another value.
+ pub fn $name(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ let ordering = compare(&lhs, &rhs)?;
+ Ok(Value::Bool(matches!(ordering, $($pat)*)))
+ }
+ };
+}
+
+comparison!(lt, "<", Ordering::Less);
+comparison!(leq, "<=", Ordering::Less | Ordering::Equal);
+comparison!(gt, ">", Ordering::Greater);
+comparison!(geq, ">=", Ordering::Greater | Ordering::Equal);
+
+/// Determine whether two values are equal.
+pub fn equal(lhs: &Value, rhs: &Value) -> bool {
+ use Value::*;
+ match (lhs, rhs) {
+ // Compare reflexively.
+ (None, None) => true,
+ (Auto, Auto) => true,
+ (Bool(a), Bool(b)) => a == b,
+ (Int(a), Int(b)) => a == b,
+ (Float(a), Float(b)) => a == b,
+ (Decimal(a), Decimal(b)) => a == b,
+ (Length(a), Length(b)) => a == b,
+ (Angle(a), Angle(b)) => a == b,
+ (Ratio(a), Ratio(b)) => a == b,
+ (Relative(a), Relative(b)) => a == b,
+ (Fraction(a), Fraction(b)) => a == b,
+ (Color(a), Color(b)) => a == b,
+ (Symbol(a), Symbol(b)) => a == b,
+ (Version(a), Version(b)) => a == b,
+ (Str(a), Str(b)) => a == b,
+ (Bytes(a), Bytes(b)) => a == b,
+ (Label(a), Label(b)) => a == b,
+ (Content(a), Content(b)) => a == b,
+ (Array(a), Array(b)) => a == b,
+ (Dict(a), Dict(b)) => a == b,
+ (Func(a), Func(b)) => a == b,
+ (Args(a), Args(b)) => a == b,
+ (Type(a), Type(b)) => a == b,
+ (Module(a), Module(b)) => a == b,
+ (Plugin(a), Plugin(b)) => a == b,
+ (Datetime(a), Datetime(b)) => a == b,
+ (Duration(a), Duration(b)) => a == b,
+ (Dyn(a), Dyn(b)) => a == b,
+
+ // Some technically different things should compare equal.
+ (&Int(i), &Float(f)) | (&Float(f), &Int(i)) => i as f64 == f,
+ (&Int(i), &Decimal(d)) | (&Decimal(d), &Int(i)) => {
+ crate::foundations::Decimal::from(i) == d
+ }
+ (&Length(len), &Relative(rel)) | (&Relative(rel), &Length(len)) => {
+ len == rel.abs && rel.rel.is_zero()
+ }
+ (&Ratio(rat), &Relative(rel)) | (&Relative(rel), &Ratio(rat)) => {
+ rat == rel.rel && rel.abs.is_zero()
+ }
+
+ // Type compatibility.
+ (Type(ty), Str(str)) | (Str(str), Type(ty)) => ty.compat_name() == str.as_str(),
+
+ _ => false,
+ }
+}
+
+/// Compare two values.
+pub fn compare(lhs: &Value, rhs: &Value) -> StrResult<Ordering> {
+ use Value::*;
+ Ok(match (lhs, rhs) {
+ (Bool(a), Bool(b)) => a.cmp(b),
+ (Int(a), Int(b)) => a.cmp(b),
+ (Float(a), Float(b)) => try_cmp_values(a, b)?,
+ (Decimal(a), Decimal(b)) => a.cmp(b),
+ (Length(a), Length(b)) => try_cmp_values(a, b)?,
+ (Angle(a), Angle(b)) => a.cmp(b),
+ (Ratio(a), Ratio(b)) => a.cmp(b),
+ (Relative(a), Relative(b)) => try_cmp_values(a, b)?,
+ (Fraction(a), Fraction(b)) => a.cmp(b),
+ (Version(a), Version(b)) => a.cmp(b),
+ (Str(a), Str(b)) => a.cmp(b),
+
+ // Some technically different things should be comparable.
+ (Int(a), Float(b)) => try_cmp_values(&(*a as f64), b)?,
+ (Float(a), Int(b)) => try_cmp_values(a, &(*b as f64))?,
+ (Int(a), Decimal(b)) => crate::foundations::Decimal::from(*a).cmp(b),
+ (Decimal(a), Int(b)) => a.cmp(&crate::foundations::Decimal::from(*b)),
+ (Length(a), Relative(b)) if b.rel.is_zero() => try_cmp_values(a, &b.abs)?,
+ (Ratio(a), Relative(b)) if b.abs.is_zero() => a.cmp(&b.rel),
+ (Relative(a), Length(b)) if a.rel.is_zero() => try_cmp_values(&a.abs, b)?,
+ (Relative(a), Ratio(b)) if a.abs.is_zero() => a.rel.cmp(b),
+
+ (Duration(a), Duration(b)) => a.cmp(b),
+ (Datetime(a), Datetime(b)) => try_cmp_datetimes(a, b)?,
+ (Array(a), Array(b)) => try_cmp_arrays(a.as_slice(), b.as_slice())?,
+
+ _ => mismatch!("cannot compare {} and {}", lhs, rhs),
+ })
+}
+
+/// Try to compare two values.
+fn try_cmp_values<T: PartialOrd + Repr>(a: &T, b: &T) -> StrResult<Ordering> {
+ a.partial_cmp(b)
+ .ok_or_else(|| eco_format!("cannot compare {} with {}", a.repr(), b.repr()))
+}
+
+/// Try to compare two datetimes.
+fn try_cmp_datetimes(a: &Datetime, b: &Datetime) -> StrResult<Ordering> {
+ a.partial_cmp(b)
+ .ok_or_else(|| eco_format!("cannot compare {} and {}", a.kind(), b.kind()))
+}
+
+/// Try to compare arrays of values lexicographically.
+fn try_cmp_arrays(a: &[Value], b: &[Value]) -> StrResult<Ordering> {
+ a.iter()
+ .zip(b.iter())
+ .find_map(|(first, second)| {
+ match compare(first, second) {
+ // Keep searching for a pair of elements that isn't equal.
+ Ok(Ordering::Equal) => None,
+ // Found a pair which either is not equal or not comparable, so
+ // we stop searching.
+ result => Some(result),
+ }
+ })
+ .unwrap_or_else(|| {
+ // The two arrays are equal up to the shortest array's extent,
+ // so compare their lengths instead.
+ Ok(a.len().cmp(&b.len()))
+ })
+}
+
+/// Test whether one value is "in" another one.
+pub fn in_(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ if let Some(b) = contains(&lhs, &rhs) {
+ Ok(Value::Bool(b))
+ } else {
+ mismatch!("cannot apply 'in' to {} and {}", lhs, rhs)
+ }
+}
+
+/// Test whether one value is "not in" another one.
+pub fn not_in(lhs: Value, rhs: Value) -> HintedStrResult<Value> {
+ if let Some(b) = contains(&lhs, &rhs) {
+ Ok(Value::Bool(!b))
+ } else {
+ mismatch!("cannot apply 'not in' to {} and {}", lhs, rhs)
+ }
+}
+
+/// Test for containment.
+pub fn contains(lhs: &Value, rhs: &Value) -> Option<bool> {
+ use Value::*;
+ match (lhs, rhs) {
+ (Str(a), Str(b)) => Some(b.as_str().contains(a.as_str())),
+ (Dyn(a), Str(b)) => a.downcast::<Regex>().map(|regex| regex.is_match(b)),
+ (Str(a), Dict(b)) => Some(b.contains(a)),
+ (a, Array(b)) => Some(b.contains(a.clone())),
+
+ // Type compatibility.
+ (Type(a), Str(b)) => Some(b.as_str().contains(a.compat_name())),
+ (Type(a), Dict(b)) => Some(b.contains(a.compat_name())),
+
+ _ => Option::None,
+ }
+}
+
+#[cold]
+fn too_large() -> &'static str {
+ "value is too large"
+}
diff --git a/crates/typst-library/src/foundations/plugin.rs b/crates/typst-library/src/foundations/plugin.rs
new file mode 100644
index 00000000..31107dc3
--- /dev/null
+++ b/crates/typst-library/src/foundations/plugin.rs
@@ -0,0 +1,365 @@
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::{Arc, Mutex};
+
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+use wasmi::{AsContext, AsContextMut};
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{func, repr, scope, ty, Bytes};
+use crate::World;
+
+/// A WebAssembly plugin.
+///
+/// Typst is capable of interfacing with plugins compiled to WebAssembly. Plugin
+/// functions may accept multiple [byte buffers]($bytes) as arguments and return
+/// a single byte buffer. They should typically be wrapped in idiomatic Typst
+/// functions that perform the necessary conversions between native Typst types
+/// and bytes.
+///
+/// Plugins run in isolation from your system, which means that printing,
+/// reading files, or anything like that will not be supported for security
+/// reasons. To run as a plugin, a program needs to be compiled to a 32-bit
+/// shared WebAssembly library. Many compilers will use the
+/// [WASI ABI](https://wasi.dev/) by default or as their only option (e.g.
+/// emscripten), which allows printing, reading files, etc. This ABI will not
+/// directly work with Typst. You will either need to compile to a different
+/// target or [stub all functions](https://github.com/astrale-sharp/wasm-minimal-protocol/blob/master/wasi-stub).
+///
+/// # Plugins and Packages
+/// Plugins are distributed as packages. A package can make use of a plugin
+/// simply by including a WebAssembly file and loading it. Because the
+/// byte-based plugin interface is quite low-level, plugins are typically
+/// exposed through wrapper functions, that also live in the same package.
+///
+/// # Purity
+/// Plugin functions must be pure: Given the same arguments, they must always
+/// return the same value. The reason for this is that Typst functions must be
+/// pure (which is quite fundamental to the language design) and, since Typst
+/// function can call plugin functions, this requirement is inherited. In
+/// particular, if a plugin function is called twice with the same arguments,
+/// Typst might cache the results and call your function only once.
+///
+/// # Example
+/// ```example
+/// #let myplugin = plugin("hello.wasm")
+/// #let concat(a, b) = str(
+/// myplugin.concatenate(
+/// bytes(a),
+/// bytes(b),
+/// )
+/// )
+///
+/// #concat("hello", "world")
+/// ```
+///
+/// # Protocol
+/// To be used as a plugin, a WebAssembly module must conform to the following
+/// protocol:
+///
+/// ## Exports
+/// A plugin module can export functions to make them callable from Typst. To
+/// conform to the protocol, an exported function should:
+///
+/// - Take `n` 32-bit integer arguments `a_1`, `a_2`, ..., `a_n` (interpreted as
+/// lengths, so `usize/size_t` may be preferable), and return one 32-bit
+/// integer.
+///
+/// - The function should first allocate a buffer `buf` of length
+/// `a_1 + a_2 + ... + a_n`, and then call
+/// `wasm_minimal_protocol_write_args_to_buffer(buf.ptr)`.
+///
+/// - The `a_1` first bytes of the buffer now constitute the first argument, the
+/// `a_2` next bytes the second argument, and so on.
+///
+/// - The function can now do its job with the arguments and produce an output
+/// buffer. Before returning, it should call
+/// `wasm_minimal_protocol_send_result_to_host` to send its result back to the
+/// host.
+///
+/// - To signal success, the function should return `0`.
+///
+/// - To signal an error, the function should return `1`. The written buffer is
+/// then interpreted as an UTF-8 encoded error message.
+///
+/// ## Imports
+/// Plugin modules need to import two functions that are provided by the runtime.
+/// (Types and functions are described using WAT syntax.)
+///
+/// - `(import "typst_env" "wasm_minimal_protocol_write_args_to_buffer" (func (param i32)))`
+///
+/// Writes the arguments for the current function into a plugin-allocated
+/// buffer. When a plugin function is called, it
+/// [receives the lengths](#exports) of its input buffers as arguments. It
+/// should then allocate a buffer whose capacity is at least the sum of these
+/// lengths. It should then call this function with a `ptr` to the buffer to
+/// fill it with the arguments, one after another.
+///
+/// - `(import "typst_env" "wasm_minimal_protocol_send_result_to_host" (func (param i32 i32)))`
+///
+/// Sends the output of the current function to the host (Typst). The first
+/// parameter shall be a pointer to a buffer (`ptr`), while the second is the
+/// length of that buffer (`len`). The memory pointed at by `ptr` can be freed
+/// immediately after this function returns. If the message should be
+/// interpreted as an error message, it should be encoded as UTF-8.
+///
+/// # Resources
+/// For more resources, check out the
+/// [wasm-minimal-protocol repository](https://github.com/astrale-sharp/wasm-minimal-protocol).
+/// It contains:
+///
+/// - A list of example plugin implementations and a test runner for these
+/// examples
+/// - Wrappers to help you write your plugin in Rust (Zig wrapper in
+/// development)
+/// - A stubber for WASI
+#[ty(scope, cast)]
+#[derive(Clone)]
+pub struct Plugin(Arc<Repr>);
+
+/// The internal representation of a plugin.
+struct Repr {
+ /// The raw WebAssembly bytes.
+ bytes: Bytes,
+ /// The function defined by the WebAssembly module.
+ functions: Vec<(EcoString, wasmi::Func)>,
+ /// Owns all data associated with the WebAssembly module.
+ store: Mutex<Store>,
+}
+
+/// Owns all data associated with the WebAssembly module.
+type Store = wasmi::Store<StoreData>;
+
+/// If there was an error reading/writing memory, keep the offset + length to
+/// display an error message.
+struct MemoryError {
+ offset: u32,
+ length: u32,
+ write: bool,
+}
+/// The persistent store data used for communication between store and host.
+#[derive(Default)]
+struct StoreData {
+ args: Vec<Bytes>,
+ output: Vec<u8>,
+ memory_error: Option<MemoryError>,
+}
+
+#[scope]
+impl Plugin {
+ /// Creates a new plugin from a WebAssembly file.
+ #[func(constructor)]
+ pub fn construct(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a WebAssembly file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+ ) -> SourceResult<Plugin> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ Plugin::new(data).at(span)
+ }
+}
+
+impl Plugin {
+ /// Create a new plugin from raw WebAssembly bytes.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load plugin")]
+ pub fn new(bytes: Bytes) -> StrResult<Plugin> {
+ let engine = wasmi::Engine::default();
+ let module = wasmi::Module::new(&engine, bytes.as_slice())
+ .map_err(|err| format!("failed to load WebAssembly module ({err})"))?;
+
+ let mut linker = wasmi::Linker::new(&engine);
+ linker
+ .func_wrap(
+ "typst_env",
+ "wasm_minimal_protocol_send_result_to_host",
+ wasm_minimal_protocol_send_result_to_host,
+ )
+ .unwrap();
+ linker
+ .func_wrap(
+ "typst_env",
+ "wasm_minimal_protocol_write_args_to_buffer",
+ wasm_minimal_protocol_write_args_to_buffer,
+ )
+ .unwrap();
+
+ let mut store = Store::new(&engine, StoreData::default());
+ let instance = linker
+ .instantiate(&mut store, &module)
+ .and_then(|pre_instance| pre_instance.start(&mut store))
+ .map_err(|e| eco_format!("{e}"))?;
+
+ // Ensure that the plugin exports its memory.
+ if !matches!(
+ instance.get_export(&store, "memory"),
+ Some(wasmi::Extern::Memory(_))
+ ) {
+ bail!("plugin does not export its memory");
+ }
+
+ // Collect exported functions.
+ let functions = instance
+ .exports(&store)
+ .filter_map(|export| {
+ let name = export.name().into();
+ export.into_func().map(|func| (name, func))
+ })
+ .collect();
+
+ Ok(Plugin(Arc::new(Repr { bytes, functions, store: Mutex::new(store) })))
+ }
+
+ /// Call the plugin function with the given `name`.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "call plugin")]
+ pub fn call(&self, name: &str, args: Vec<Bytes>) -> StrResult<Bytes> {
+ // Find the function with the given name.
+ let func = self
+ .0
+ .functions
+ .iter()
+ .find(|(v, _)| v == name)
+ .map(|&(_, func)| func)
+ .ok_or_else(|| {
+ eco_format!("plugin does not contain a function called {name}")
+ })?;
+
+ let mut store = self.0.store.lock().unwrap();
+ let ty = func.ty(store.as_context());
+
+ // Check function signature.
+ if ty.params().iter().any(|&v| v != wasmi::core::ValType::I32) {
+ bail!(
+ "plugin function `{name}` has a parameter that is not a 32-bit integer"
+ );
+ }
+ if ty.results() != [wasmi::core::ValType::I32] {
+ bail!("plugin function `{name}` does not return exactly one 32-bit integer");
+ }
+
+ // Check inputs.
+ let expected = ty.params().len();
+ let given = args.len();
+ if expected != given {
+ bail!(
+ "plugin function takes {expected} argument{}, but {given} {} given",
+ if expected == 1 { "" } else { "s" },
+ if given == 1 { "was" } else { "were" },
+ );
+ }
+
+ // Collect the lengths of the argument buffers.
+ let lengths = args
+ .iter()
+ .map(|a| wasmi::Val::I32(a.len() as i32))
+ .collect::<Vec<_>>();
+
+ // Store the input data.
+ store.data_mut().args = args;
+
+ // Call the function.
+ let mut code = wasmi::Val::I32(-1);
+ func.call(store.as_context_mut(), &lengths, std::slice::from_mut(&mut code))
+ .map_err(|err| eco_format!("plugin panicked: {err}"))?;
+ if let Some(MemoryError { offset, length, write }) =
+ store.data_mut().memory_error.take()
+ {
+ return Err(eco_format!(
+ "plugin tried to {kind} out of bounds: pointer {offset:#x} is out of bounds for {kind} of length {length}",
+ kind = if write { "write" } else { "read" }
+ ));
+ }
+
+ // Extract the returned data.
+ let output = std::mem::take(&mut store.data_mut().output);
+
+ // Parse the functions return value.
+ match code {
+ wasmi::Val::I32(0) => {}
+ wasmi::Val::I32(1) => match std::str::from_utf8(&output) {
+ Ok(message) => bail!("plugin errored with: {message}"),
+ Err(_) => {
+ bail!("plugin errored, but did not return a valid error message")
+ }
+ },
+ _ => bail!("plugin did not respect the protocol"),
+ };
+
+ Ok(output.into())
+ }
+
+ /// An iterator over all the function names defined by the plugin.
+ pub fn iter(&self) -> impl Iterator<Item = &EcoString> {
+ self.0.functions.as_slice().iter().map(|(func_name, _)| func_name)
+ }
+}
+
+impl Debug for Plugin {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("Plugin(..)")
+ }
+}
+
+impl repr::Repr for Plugin {
+ fn repr(&self) -> EcoString {
+ "plugin(..)".into()
+ }
+}
+
+impl PartialEq for Plugin {
+ fn eq(&self, other: &Self) -> bool {
+ self.0.bytes == other.0.bytes
+ }
+}
+
+impl Hash for Plugin {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.0.bytes.hash(state);
+ }
+}
+
+/// Write the arguments to the plugin function into the plugin's memory.
+fn wasm_minimal_protocol_write_args_to_buffer(
+ mut caller: wasmi::Caller<StoreData>,
+ ptr: u32,
+) {
+ let memory = caller.get_export("memory").unwrap().into_memory().unwrap();
+ let arguments = std::mem::take(&mut caller.data_mut().args);
+ let mut offset = ptr as usize;
+ for arg in arguments {
+ if memory.write(&mut caller, offset, arg.as_slice()).is_err() {
+ caller.data_mut().memory_error = Some(MemoryError {
+ offset: offset as u32,
+ length: arg.len() as u32,
+ write: true,
+ });
+ return;
+ }
+ offset += arg.len();
+ }
+}
+
+/// Extracts the output of the plugin function from the plugin's memory.
+fn wasm_minimal_protocol_send_result_to_host(
+ mut caller: wasmi::Caller<StoreData>,
+ ptr: u32,
+ len: u32,
+) {
+ let memory = caller.get_export("memory").unwrap().into_memory().unwrap();
+ let mut buffer = std::mem::take(&mut caller.data_mut().output);
+ buffer.resize(len as usize, 0);
+ if memory.read(&caller, ptr as _, &mut buffer).is_err() {
+ caller.data_mut().memory_error =
+ Some(MemoryError { offset: ptr, length: len, write: false });
+ return;
+ }
+ caller.data_mut().output = buffer;
+}
diff --git a/crates/typst-library/src/foundations/repr.rs b/crates/typst-library/src/foundations/repr.rs
new file mode 100644
index 00000000..e219b0f1
--- /dev/null
+++ b/crates/typst-library/src/foundations/repr.rs
@@ -0,0 +1,235 @@
+//! Debug representation of values.
+
+use ecow::{eco_format, EcoString};
+use typst_utils::round_with_precision;
+
+use crate::foundations::{func, Str, Value};
+
+/// The Unicode minus sign.
+pub const MINUS_SIGN: &str = "\u{2212}";
+
+/// 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
+/// #none vs #repr(none) \
+/// #"hello" vs #repr("hello") \
+/// #(1, 2) vs #repr((1, 2)) \
+/// #[*Hi*] vs #repr([*Hi*])
+/// ```
+#[func(title = "Representation")]
+pub fn repr(
+ /// The value whose string representation to produce.
+ value: Value,
+) -> Str {
+ value.repr().into()
+}
+
+/// A trait that defines the `repr` of a Typst value.
+pub trait Repr {
+ /// Return the debug representation of the value.
+ fn repr(&self) -> EcoString;
+}
+
+/// Format an integer in a base.
+pub fn format_int_with_base(mut n: i64, base: i64) -> EcoString {
+ if n == 0 {
+ return "0".into();
+ }
+
+ // The largest output is `to_base(i64::MIN, 2)`, which is 64 bytes long,
+ // plus the length of the minus sign.
+ const SIZE: usize = 64 + MINUS_SIGN.len();
+ 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 {
+ let prev = i;
+ i -= MINUS_SIGN.len();
+ digits[i..prev].copy_from_slice(MINUS_SIGN.as_bytes());
+ }
+
+ std::str::from_utf8(&digits[i..]).unwrap_or_default().into()
+}
+
+/// Converts a float to a string representation with a specific precision and a
+/// unit, all with a single allocation.
+///
+/// The returned string is always valid Typst code. As such, it might not be a
+/// float literal. For example, it may return `"float.inf"`.
+pub fn format_float(
+ mut value: f64,
+ precision: Option<u8>,
+ force_separator: bool,
+ unit: &str,
+) -> EcoString {
+ if let Some(p) = precision {
+ value = round_with_precision(value, p as i16);
+ }
+ // Debug for f64 always prints a decimal separator, while Display only does
+ // when necessary.
+ let unit_multiplication = if unit.is_empty() { "" } else { " * 1" };
+ if value.is_nan() {
+ eco_format!("float.nan{unit_multiplication}{unit}")
+ } else if value.is_infinite() {
+ let sign = if value < 0.0 { "-" } else { "" };
+ eco_format!("{sign}float.inf{unit_multiplication}{unit}")
+ } else if force_separator {
+ eco_format!("{value:?}{unit}")
+ } else {
+ eco_format!("{value}{unit}")
+ }
+}
+
+/// Converts a float to a string representation with a precision of three
+/// decimal places. This is intended to be used as part of a larger structure
+/// containing multiple float components, such as colors.
+pub fn format_float_component(value: f64) -> EcoString {
+ format_float(value, Some(3), false, "")
+}
+
+/// Converts a float to a string representation with a precision of two decimal
+/// places, followed by a unit.
+pub fn format_float_with_unit(value: f64, unit: &str) -> EcoString {
+ format_float(value, Some(2), false, unit)
+}
+
+/// Converts a float to a string that can be used to display the float as text.
+pub fn display_float(value: f64) -> EcoString {
+ if value.is_nan() {
+ "NaN".into()
+ } else if value.is_infinite() {
+ let sign = if value < 0.0 { MINUS_SIGN } else { "" };
+ eco_format!("{sign}∞")
+ } else if value < 0.0 {
+ eco_format!("{}{}", MINUS_SIGN, value.abs())
+ } else {
+ eco_format!("{}", value.abs())
+ }
+}
+
+/// Formats pieces separated with commas and a final "and" or "or".
+pub fn separated_list(pieces: &[impl AsRef<str>], last: &str) -> String {
+ let mut buf = String::new();
+ for (i, part) in pieces.iter().enumerate() {
+ match i {
+ 0 => {}
+ 1 if pieces.len() == 2 => {
+ buf.push(' ');
+ buf.push_str(last);
+ buf.push(' ');
+ }
+ i if i + 1 == pieces.len() => {
+ buf.push_str(", ");
+ buf.push_str(last);
+ buf.push(' ');
+ }
+ _ => buf.push_str(", "),
+ }
+ buf.push_str(part.as_ref());
+ }
+ buf
+}
+
+/// Formats a comma-separated list.
+///
+/// Tries to format horizontally, but falls back to vertical formatting if the
+/// pieces are too long.
+pub fn pretty_comma_list(pieces: &[impl AsRef<str>], trailing_comma: bool) -> String {
+ const MAX_WIDTH: usize = 50;
+
+ let mut buf = String::new();
+ let len = pieces.iter().map(|s| s.as_ref().len()).sum::<usize>()
+ + 2 * pieces.len().saturating_sub(1);
+
+ if len <= MAX_WIDTH {
+ for (i, piece) in pieces.iter().enumerate() {
+ if i > 0 {
+ buf.push_str(", ");
+ }
+ buf.push_str(piece.as_ref());
+ }
+ if trailing_comma {
+ buf.push(',');
+ }
+ } else {
+ for piece in pieces {
+ buf.push_str(piece.as_ref().trim());
+ buf.push_str(",\n");
+ }
+ }
+
+ buf
+}
+
+/// Formats an array-like construct.
+///
+/// Tries to format horizontally, but falls back to vertical formatting if the
+/// pieces are too long.
+pub fn pretty_array_like(parts: &[impl AsRef<str>], trailing_comma: bool) -> String {
+ let list = pretty_comma_list(parts, trailing_comma);
+ let mut buf = String::new();
+ buf.push('(');
+ if list.contains('\n') {
+ buf.push('\n');
+ for (i, line) in list.lines().enumerate() {
+ if i > 0 {
+ buf.push('\n');
+ }
+ buf.push_str(" ");
+ buf.push_str(line);
+ }
+ buf.push('\n');
+ } else {
+ buf.push_str(&list);
+ }
+ buf.push(')');
+ buf
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_to_base() {
+ assert_eq!(&format_int_with_base(0, 10), "0");
+ assert_eq!(&format_int_with_base(0, 16), "0");
+ assert_eq!(&format_int_with_base(0, 36), "0");
+ assert_eq!(
+ &format_int_with_base(i64::MAX, 2),
+ "111111111111111111111111111111111111111111111111111111111111111"
+ );
+ assert_eq!(
+ &format_int_with_base(i64::MIN, 2),
+ "\u{2212}1000000000000000000000000000000000000000000000000000000000000000"
+ );
+ assert_eq!(&format_int_with_base(i64::MAX, 10), "9223372036854775807");
+ assert_eq!(&format_int_with_base(i64::MIN, 10), "\u{2212}9223372036854775808");
+ assert_eq!(&format_int_with_base(i64::MAX, 16), "7fffffffffffffff");
+ assert_eq!(&format_int_with_base(i64::MIN, 16), "\u{2212}8000000000000000");
+ assert_eq!(&format_int_with_base(i64::MAX, 36), "1y2p0ij32e8e7");
+ assert_eq!(&format_int_with_base(i64::MIN, 36), "\u{2212}1y2p0ij32e8e8");
+ }
+}
diff --git a/crates/typst-library/src/foundations/scope.rs b/crates/typst-library/src/foundations/scope.rs
new file mode 100644
index 00000000..b51f8caa
--- /dev/null
+++ b/crates/typst-library/src/foundations/scope.rs
@@ -0,0 +1,416 @@
+#[doc(inline)]
+pub use typst_macros::category;
+
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+
+use ecow::{eco_format, EcoString};
+use indexmap::IndexMap;
+use typst_syntax::ast::{self, AstNode};
+use typst_syntax::Span;
+use typst_utils::Static;
+
+use crate::diag::{bail, HintedStrResult, HintedString, StrResult};
+use crate::foundations::{
+ Element, Func, IntoValue, Module, NativeElement, NativeFunc, NativeFuncData,
+ NativeType, Type, Value,
+};
+use crate::Library;
+
+/// A stack of scopes.
+#[derive(Debug, Default, Clone)]
+pub struct Scopes<'a> {
+ /// The active scope.
+ pub top: Scope,
+ /// The stack of lower scopes.
+ pub scopes: Vec<Scope>,
+ /// The standard library.
+ pub base: Option<&'a Library>,
+}
+
+impl<'a> Scopes<'a> {
+ /// Create a new, empty hierarchy of scopes.
+ pub fn new(base: Option<&'a Library>) -> Self {
+ Self { top: Scope::new(), scopes: vec![], base }
+ }
+
+ /// Enter a new scope.
+ pub fn enter(&mut self) {
+ self.scopes.push(std::mem::take(&mut self.top));
+ }
+
+ /// Exit the topmost scope.
+ ///
+ /// This panics if no scope was entered.
+ pub fn exit(&mut self) {
+ self.top = self.scopes.pop().expect("no pushed scope");
+ }
+
+ /// Try to access a variable immutably.
+ pub fn get(&self, var: &str) -> HintedStrResult<&Value> {
+ std::iter::once(&self.top)
+ .chain(self.scopes.iter().rev())
+ .find_map(|scope| scope.get(var))
+ .or_else(|| {
+ self.base.and_then(|base| match base.global.scope().get(var) {
+ Some(value) => Some(value),
+ None if var == "std" => Some(&base.std),
+ None => None,
+ })
+ })
+ .ok_or_else(|| unknown_variable(var))
+ }
+
+ /// Try to access a variable immutably in math.
+ pub fn get_in_math(&self, var: &str) -> HintedStrResult<&Value> {
+ std::iter::once(&self.top)
+ .chain(self.scopes.iter().rev())
+ .find_map(|scope| scope.get(var))
+ .or_else(|| {
+ self.base.and_then(|base| match base.math.scope().get(var) {
+ Some(value) => Some(value),
+ None if var == "std" => Some(&base.std),
+ None => None,
+ })
+ })
+ .ok_or_else(|| {
+ unknown_variable_math(
+ var,
+ self.base.is_some_and(|base| base.global.scope().get(var).is_some()),
+ )
+ })
+ }
+
+ /// Try to access a variable mutably.
+ pub fn get_mut(&mut self, var: &str) -> HintedStrResult<&mut Value> {
+ std::iter::once(&mut self.top)
+ .chain(&mut self.scopes.iter_mut().rev())
+ .find_map(|scope| scope.get_mut(var))
+ .ok_or_else(|| {
+ match self.base.and_then(|base| base.global.scope().get(var)) {
+ Some(_) => cannot_mutate_constant(var),
+ _ if var == "std" => cannot_mutate_constant(var),
+ _ => unknown_variable(var),
+ }
+ })?
+ }
+
+ /// Check if an std variable is shadowed.
+ pub fn check_std_shadowed(&self, var: &str) -> bool {
+ self.base.is_some_and(|base| base.global.scope().get(var).is_some())
+ && std::iter::once(&self.top)
+ .chain(self.scopes.iter().rev())
+ .any(|scope| scope.get(var).is_some())
+ }
+}
+
+#[cold]
+fn cannot_mutate_constant(var: &str) -> HintedString {
+ eco_format!("cannot mutate a constant: {}", var).into()
+}
+
+/// The error message when a variable is not found.
+#[cold]
+fn unknown_variable(var: &str) -> HintedString {
+ let mut res = HintedString::new(eco_format!("unknown variable: {}", var));
+
+ if var.contains('-') {
+ res.hint(eco_format!(
+ "if you meant to use subtraction, try adding spaces around the minus sign{}: `{}`",
+ if var.matches('-').count() > 1 { "s" } else { "" },
+ var.replace('-', " - ")
+ ));
+ }
+
+ res
+}
+
+#[cold]
+fn unknown_variable_math(var: &str, in_global: bool) -> HintedString {
+ let mut res = HintedString::new(eco_format!("unknown variable: {}", var));
+
+ if matches!(var, "none" | "auto" | "false" | "true") {
+ res.hint(eco_format!(
+ "if you meant to use a literal, try adding a hash before it: `#{var}`",
+ ));
+ } else if in_global {
+ res.hint(eco_format!(
+ "`{var}` is not available directly in math, try adding a hash before it: `#{var}`",
+ ));
+ } else {
+ res.hint(eco_format!(
+ "if you meant to display multiple letters as is, try adding spaces between each letter: `{}`",
+ var.chars()
+ .flat_map(|c| [' ', c])
+ .skip(1)
+ .collect::<EcoString>()
+ ));
+ res.hint(eco_format!(
+ "or if you meant to display this as text, try placing it in quotes: `\"{var}\"`"
+ ));
+ }
+
+ res
+}
+
+/// A map from binding names to values.
+#[derive(Default, Clone)]
+pub struct Scope {
+ map: IndexMap<EcoString, Slot>,
+ deduplicate: bool,
+ category: Option<Category>,
+}
+
+impl Scope {
+ /// Create a new empty scope.
+ pub fn new() -> Self {
+ Default::default()
+ }
+
+ /// Create a new scope with duplication prevention.
+ pub fn deduplicating() -> Self {
+ Self { deduplicate: true, ..Default::default() }
+ }
+
+ /// Enter a new category.
+ pub fn category(&mut self, category: Category) {
+ self.category = Some(category);
+ }
+
+ /// Reset the category.
+ pub fn reset_category(&mut self) {
+ self.category = None;
+ }
+
+ /// Bind a value to a name.
+ #[track_caller]
+ pub fn define(&mut self, name: impl Into<EcoString>, value: impl IntoValue) {
+ self.define_spanned(name, value, Span::detached())
+ }
+
+ /// Bind a value to a name defined by an identifier.
+ #[track_caller]
+ pub fn define_ident(&mut self, ident: ast::Ident, value: impl IntoValue) {
+ self.define_spanned(ident.get().clone(), value, ident.span())
+ }
+
+ /// Bind a value to a name.
+ #[track_caller]
+ pub fn define_spanned(
+ &mut self,
+ name: impl Into<EcoString>,
+ value: impl IntoValue,
+ span: Span,
+ ) {
+ let name = name.into();
+
+ #[cfg(debug_assertions)]
+ if self.deduplicate && self.map.contains_key(&name) {
+ panic!("duplicate definition: {name}");
+ }
+
+ self.map.insert(
+ name,
+ Slot::new(value.into_value(), span, Kind::Normal, self.category),
+ );
+ }
+
+ /// Define a captured, immutable binding.
+ pub fn define_captured(
+ &mut self,
+ name: EcoString,
+ value: Value,
+ capturer: Capturer,
+ span: Span,
+ ) {
+ self.map.insert(
+ name,
+ Slot::new(value.into_value(), span, Kind::Captured(capturer), self.category),
+ );
+ }
+
+ /// Define a native function through a Rust type that shadows the function.
+ pub fn define_func<T: NativeFunc>(&mut self) {
+ let data = T::data();
+ self.define(data.name, Func::from(data));
+ }
+
+ /// Define a native function with raw function data.
+ pub fn define_func_with_data(&mut self, data: &'static NativeFuncData) {
+ self.define(data.name, Func::from(data));
+ }
+
+ /// Define a native type.
+ pub fn define_type<T: NativeType>(&mut self) {
+ let data = T::data();
+ self.define(data.name, Type::from(data));
+ }
+
+ /// Define a native element.
+ pub fn define_elem<T: NativeElement>(&mut self) {
+ let data = T::data();
+ self.define(data.name, Element::from(data));
+ }
+
+ /// Define a module.
+ pub fn define_module(&mut self, module: Module) {
+ self.define(module.name().clone(), module);
+ }
+
+ /// Try to access a variable immutably.
+ pub fn get(&self, var: &str) -> Option<&Value> {
+ self.map.get(var).map(Slot::read)
+ }
+
+ /// Try to access a variable mutably.
+ pub fn get_mut(&mut self, var: &str) -> Option<HintedStrResult<&mut Value>> {
+ self.map
+ .get_mut(var)
+ .map(Slot::write)
+ .map(|res| res.map_err(HintedString::from))
+ }
+
+ /// Get the span of a definition.
+ pub fn get_span(&self, var: &str) -> Option<Span> {
+ Some(self.map.get(var)?.span)
+ }
+
+ /// Get the category of a definition.
+ pub fn get_category(&self, var: &str) -> Option<Category> {
+ self.map.get(var)?.category
+ }
+
+ /// Iterate over all definitions.
+ pub fn iter(&self) -> impl Iterator<Item = (&EcoString, &Value, Span)> {
+ self.map.iter().map(|(k, v)| (k, v.read(), v.span))
+ }
+}
+
+impl Debug for Scope {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Scope ")?;
+ f.debug_map()
+ .entries(self.map.iter().map(|(k, v)| (k, v.read())))
+ .finish()
+ }
+}
+
+impl Hash for Scope {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ state.write_usize(self.map.len());
+ for item in &self.map {
+ item.hash(state);
+ }
+ self.deduplicate.hash(state);
+ self.category.hash(state);
+ }
+}
+
+/// Defines the associated scope of a Rust type.
+pub trait NativeScope {
+ /// The constructor function for the type, if any.
+ fn constructor() -> Option<&'static NativeFuncData>;
+
+ /// Get the associated scope for the type.
+ fn scope() -> Scope;
+}
+
+/// A slot where a value is stored.
+#[derive(Clone, Hash)]
+struct Slot {
+ /// The stored value.
+ value: Value,
+ /// The kind of slot, determines how the value can be accessed.
+ kind: Kind,
+ /// A span associated with the stored value.
+ span: Span,
+ /// The category of the slot.
+ category: Option<Category>,
+}
+
+/// The different kinds of slots.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+enum Kind {
+ /// A normal, mutable binding.
+ Normal,
+ /// A captured copy of another variable.
+ Captured(Capturer),
+}
+
+/// What the variable was captured by.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Capturer {
+ /// Captured by a function / closure.
+ Function,
+ /// Captured by a context expression.
+ Context,
+}
+
+impl Slot {
+ /// Create a new slot.
+ fn new(value: Value, span: Span, kind: Kind, category: Option<Category>) -> Self {
+ Self { value, span, kind, category }
+ }
+
+ /// Read the value.
+ fn read(&self) -> &Value {
+ &self.value
+ }
+
+ /// Try to write to the value.
+ fn write(&mut self) -> StrResult<&mut Value> {
+ match self.kind {
+ Kind::Normal => Ok(&mut self.value),
+ Kind::Captured(capturer) => {
+ bail!(
+ "variables from outside the {} are \
+ read-only and cannot be modified",
+ match capturer {
+ Capturer::Function => "function",
+ Capturer::Context => "context expression",
+ }
+ )
+ }
+ }
+ }
+}
+
+/// A group of related definitions.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Category(Static<CategoryData>);
+
+impl Category {
+ /// Create a new category from raw data.
+ pub const fn from_data(data: &'static CategoryData) -> Self {
+ Self(Static(data))
+ }
+
+ /// The category's name.
+ pub fn name(&self) -> &'static str {
+ self.0.name
+ }
+
+ /// The type's title case name, for use in documentation (e.g. `String`).
+ pub fn title(&self) -> &'static str {
+ self.0.title
+ }
+
+ /// Documentation for the category.
+ pub fn docs(&self) -> &'static str {
+ self.0.docs
+ }
+}
+
+impl Debug for Category {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Category({})", self.name())
+ }
+}
+
+/// Defines a category.
+#[derive(Debug)]
+pub struct CategoryData {
+ pub name: &'static str,
+ pub title: &'static str,
+ pub docs: &'static str,
+}
diff --git a/crates/typst-library/src/foundations/selector.rs b/crates/typst-library/src/foundations/selector.rs
new file mode 100644
index 00000000..bf5449d9
--- /dev/null
+++ b/crates/typst-library/src/foundations/selector.rs
@@ -0,0 +1,449 @@
+use std::any::{Any, TypeId};
+use std::sync::Arc;
+
+use comemo::Tracked;
+use ecow::{eco_format, EcoString, EcoVec};
+use smallvec::SmallVec;
+
+use crate::diag::{bail, HintedStrResult, StrResult};
+use crate::foundations::{
+ cast, func, repr, scope, ty, CastInfo, Content, Context, Dict, Element, FromValue,
+ Func, Label, Reflect, Regex, Repr, Str, StyleChain, Symbol, Type, Value,
+};
+use crate::introspection::{Introspector, Locatable, Location, Unqueriable};
+
+/// A helper macro to create a field selector used in [`Selector::Elem`]
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __select_where {
+ ($ty:ty $(, $field:ident => $value:expr)* $(,)?) => {{
+ #[allow(unused_mut)]
+ let mut fields = ::smallvec::SmallVec::new();
+ $(
+ fields.push((
+ <$ty as $crate::foundations::Fields>::Enum::$field as u8,
+ $crate::foundations::IntoValue::into_value($value),
+ ));
+ )*
+ $crate::foundations::Selector::Elem(
+ <$ty as $crate::foundations::NativeElement>::elem(),
+ Some(fields),
+ )
+ }};
+}
+
+#[doc(inline)]
+pub use crate::__select_where as select_where;
+
+/// A filter for selecting elements within the document.
+///
+/// You can construct a selector in the following ways:
+/// - you can use an element [function]
+/// - you can filter for an element function with
+/// [specific fields]($function.where)
+/// - you can use a [string]($str) or [regular expression]($regex)
+/// - you can use a [`{<label>}`]($label)
+/// - you can use a [`location`]
+/// - call the [`selector`] constructor to convert any of the above types into a
+/// selector value and use the methods below to refine it
+///
+/// Selectors are used to [apply styling rules]($styling/#show-rules) to
+/// elements. You can also use selectors to [query] the document for certain
+/// types of elements.
+///
+/// Furthermore, you can pass a selector to several of Typst's built-in
+/// functions to configure their behaviour. One such example is the [outline]
+/// where it can be used to change which elements are listed within the outline.
+///
+/// Multiple selectors can be combined using the methods shown below. However,
+/// not all kinds of selectors are supported in all places, at the moment.
+///
+/// # Example
+/// ```example
+/// #context query(
+/// heading.where(level: 1)
+/// .or(heading.where(level: 2))
+/// )
+///
+/// = This will be found
+/// == So will this
+/// === But this will not.
+/// ```
+#[ty(scope, cast)]
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Selector {
+ /// Matches a specific type of element.
+ ///
+ /// If there is a dictionary, only elements with the fields from the
+ /// dictionary match.
+ Elem(Element, Option<SmallVec<[(u8, Value); 1]>>),
+ /// Matches the element at the specified location.
+ Location(Location),
+ /// Matches elements with a specific label.
+ Label(Label),
+ /// Matches text elements through a regular expression.
+ Regex(Regex),
+ /// Matches elements with a specific capability.
+ Can(TypeId),
+ /// Matches if any of the subselectors match.
+ Or(EcoVec<Self>),
+ /// Matches if all of the subselectors match.
+ And(EcoVec<Self>),
+ /// Matches all matches of `selector` before `end`.
+ Before { selector: Arc<Self>, end: Arc<Self>, inclusive: bool },
+ /// Matches all matches of `selector` after `start`.
+ After { selector: Arc<Self>, start: Arc<Self>, inclusive: bool },
+}
+
+impl Selector {
+ /// Define a simple text selector.
+ pub fn text(text: &str) -> StrResult<Self> {
+ if text.is_empty() {
+ bail!("text selector is empty");
+ }
+ Ok(Self::Regex(Regex::new(&regex::escape(text)).unwrap()))
+ }
+
+ /// Define a regex selector.
+ pub fn regex(regex: Regex) -> StrResult<Self> {
+ if regex.as_str().is_empty() {
+ bail!("regex selector is empty");
+ }
+ if regex.is_match("") {
+ bail!("regex matches empty text");
+ }
+ Ok(Self::Regex(regex))
+ }
+
+ /// Define a simple [`Selector::Can`] selector.
+ pub fn can<T: ?Sized + Any>() -> Self {
+ Self::Can(TypeId::of::<T>())
+ }
+
+ /// Whether the selector matches for the target.
+ pub fn matches(&self, target: &Content, styles: Option<StyleChain>) -> bool {
+ match self {
+ Self::Elem(element, dict) => {
+ target.elem() == *element
+ && dict.iter().flat_map(|dict| dict.iter()).all(|(id, value)| {
+ target.get(*id, styles).as_ref().ok() == Some(value)
+ })
+ }
+ Self::Label(label) => target.label() == Some(*label),
+ Self::Can(cap) => target.func().can_type_id(*cap),
+ Self::Or(selectors) => {
+ selectors.iter().any(move |sel| sel.matches(target, styles))
+ }
+ Self::And(selectors) => {
+ selectors.iter().all(move |sel| sel.matches(target, styles))
+ }
+ Self::Location(location) => target.location() == Some(*location),
+ // Not supported here.
+ Self::Regex(_) | Self::Before { .. } | Self::After { .. } => false,
+ }
+ }
+}
+
+#[scope]
+impl Selector {
+ /// 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)}`.
+ #[func(constructor)]
+ pub fn construct(
+ /// 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
+ }
+
+ /// Selects all elements that match this or any of the other selectors.
+ #[func]
+ pub fn or(
+ self,
+ /// The other selectors to match on.
+ #[variadic]
+ others: Vec<Selector>,
+ ) -> Selector {
+ Self::Or(others.into_iter().chain(Some(self)).collect())
+ }
+
+ /// Selects all elements that match this and all of the other selectors.
+ #[func]
+ pub fn and(
+ self,
+ /// The other selectors to match on.
+ #[variadic]
+ others: Vec<Selector>,
+ ) -> Selector {
+ Self::And(others.into_iter().chain(Some(self)).collect())
+ }
+
+ /// Returns a modified selector that will only match elements that occur
+ /// before the first match of `end`.
+ #[func]
+ pub fn before(
+ self,
+ /// The original selection will end at the first match of `end`.
+ end: LocatableSelector,
+ /// Whether `end` itself should match or not. This is only relevant if
+ /// both selectors match the same type of element. Defaults to `{true}`.
+ #[named]
+ #[default(true)]
+ inclusive: bool,
+ ) -> Selector {
+ Self::Before {
+ selector: Arc::new(self),
+ end: Arc::new(end.0),
+ inclusive,
+ }
+ }
+
+ /// Returns a modified selector that will only match elements that occur
+ /// after the first match of `start`.
+ #[func]
+ pub fn after(
+ self,
+ /// The original selection will start at the first match of `start`.
+ start: LocatableSelector,
+ /// Whether `start` itself should match or not. This is only relevant
+ /// if both selectors match the same type of element. Defaults to
+ /// `{true}`.
+ #[named]
+ #[default(true)]
+ inclusive: bool,
+ ) -> Selector {
+ Self::After {
+ selector: Arc::new(self),
+ start: Arc::new(start.0),
+ inclusive,
+ }
+ }
+}
+
+impl From<Location> for Selector {
+ fn from(value: Location) -> Self {
+ Self::Location(value)
+ }
+}
+
+impl Repr for Selector {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Elem(elem, dict) => {
+ if let Some(dict) = dict {
+ let dict = dict
+ .iter()
+ .map(|(id, value)| (elem.field_name(*id).unwrap(), value.clone()))
+ .map(|(name, value)| (EcoString::from(name).into(), value))
+ .collect::<Dict>();
+ eco_format!("{}.where{}", elem.name(), dict.repr())
+ } else {
+ elem.name().into()
+ }
+ }
+ Self::Label(label) => label.repr(),
+ Self::Regex(regex) => regex.repr(),
+ Self::Can(cap) => eco_format!("{cap:?}"),
+ Self::Or(selectors) | Self::And(selectors) => {
+ let function = if matches!(self, Self::Or(_)) { "or" } else { "and" };
+ let pieces: Vec<_> = selectors.iter().map(Selector::repr).collect();
+ eco_format!("{}{}", function, repr::pretty_array_like(&pieces, false))
+ }
+ Self::Location(loc) => loc.repr(),
+ Self::Before { selector, end: split, inclusive }
+ | Self::After { selector, start: split, inclusive } => {
+ let method =
+ if matches!(self, Self::Before { .. }) { "before" } else { "after" };
+ let inclusive_arg = if !*inclusive { ", inclusive: false" } else { "" };
+ eco_format!(
+ "{}.{}({}{})",
+ selector.repr(),
+ method,
+ split.repr(),
+ inclusive_arg
+ )
+ }
+ }
+ }
+}
+
+cast! {
+ type Selector,
+ text: EcoString => Self::text(&text)?,
+ func: Func => func
+ .element()
+ .ok_or("only element functions can be used as selectors")?
+ .select(),
+ label: Label => Self::Label(label),
+ regex: Regex => Self::regex(regex)?,
+ location: Location => Self::Location(location),
+}
+
+/// A selector that can be used with `query`.
+///
+/// Hopefully, this is made obsolete by a more powerful query mechanism in the
+/// future.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub struct LocatableSelector(pub Selector);
+
+impl LocatableSelector {
+ /// Resolve this selector into a location that is guaranteed to be unique.
+ pub fn resolve_unique(
+ &self,
+ introspector: Tracked<Introspector>,
+ context: Tracked<Context>,
+ ) -> HintedStrResult<Location> {
+ match &self.0 {
+ Selector::Location(loc) => Ok(*loc),
+ other => {
+ context.introspect()?;
+ Ok(introspector.query_unique(other).map(|c| c.location().unwrap())?)
+ }
+ }
+ }
+}
+
+impl Reflect for LocatableSelector {
+ fn input() -> CastInfo {
+ CastInfo::Union(vec![
+ CastInfo::Type(Type::of::<Label>()),
+ CastInfo::Type(Type::of::<Func>()),
+ CastInfo::Type(Type::of::<Location>()),
+ CastInfo::Type(Type::of::<Selector>()),
+ ])
+ }
+
+ fn output() -> CastInfo {
+ CastInfo::Type(Type::of::<Selector>())
+ }
+
+ fn castable(value: &Value) -> bool {
+ Label::castable(value)
+ || Func::castable(value)
+ || Location::castable(value)
+ || Selector::castable(value)
+ }
+}
+
+cast! {
+ LocatableSelector,
+ self => self.0.into_value(),
+}
+
+impl FromValue for LocatableSelector {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ fn validate(selector: &Selector) -> StrResult<()> {
+ match selector {
+ Selector::Elem(elem, _) => {
+ if !elem.can::<dyn Locatable>() || elem.can::<dyn Unqueriable>() {
+ Err(eco_format!("{} is not locatable", elem.name()))?
+ }
+ }
+ Selector::Location(_) => {}
+ Selector::Label(_) => {}
+ Selector::Regex(_) => bail!("text is not locatable"),
+ Selector::Can(_) => bail!("capability is not locatable"),
+ Selector::Or(list) | Selector::And(list) => {
+ for selector in list {
+ validate(selector)?;
+ }
+ }
+ Selector::Before { selector, end: split, .. }
+ | Selector::After { selector, start: split, .. } => {
+ for selector in [selector, split] {
+ validate(selector)?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ if !Self::castable(&value) {
+ return Err(Self::error(&value));
+ }
+
+ let selector = Selector::from_value(value)?;
+ validate(&selector)?;
+ Ok(Self(selector))
+ }
+}
+
+impl From<Location> for LocatableSelector {
+ fn from(loc: Location) -> Self {
+ Self(Selector::Location(loc))
+ }
+}
+
+/// A selector that can be used with show rules.
+///
+/// Hopefully, this is made obsolete by a more powerful showing mechanism in the
+/// future.
+#[derive(Clone, PartialEq, Hash)]
+pub struct ShowableSelector(pub Selector);
+
+impl Reflect for ShowableSelector {
+ fn input() -> CastInfo {
+ CastInfo::Union(vec![
+ CastInfo::Type(Type::of::<Symbol>()),
+ CastInfo::Type(Type::of::<Str>()),
+ CastInfo::Type(Type::of::<Label>()),
+ CastInfo::Type(Type::of::<Func>()),
+ CastInfo::Type(Type::of::<Regex>()),
+ CastInfo::Type(Type::of::<Selector>()),
+ ])
+ }
+
+ fn output() -> CastInfo {
+ CastInfo::Type(Type::of::<Selector>())
+ }
+
+ fn castable(value: &Value) -> bool {
+ Symbol::castable(value)
+ || Str::castable(value)
+ || Label::castable(value)
+ || Func::castable(value)
+ || Regex::castable(value)
+ || Selector::castable(value)
+ }
+}
+
+cast! {
+ ShowableSelector,
+ self => self.0.into_value(),
+}
+
+impl FromValue for ShowableSelector {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ fn validate(selector: &Selector, nested: bool) -> HintedStrResult<()> {
+ match selector {
+ Selector::Elem(_, _) => {}
+ Selector::Label(_) => {}
+ Selector::Regex(_) if !nested => {}
+ Selector::Or(list) | Selector::And(list) => {
+ for selector in list {
+ validate(selector, true)?;
+ }
+ }
+ Selector::Regex(_)
+ | Selector::Location(_)
+ | Selector::Can(_)
+ | Selector::Before { .. }
+ | Selector::After { .. } => {
+ bail!("this selector cannot be used with show")
+ }
+ }
+ Ok(())
+ }
+
+ if !Self::castable(&value) {
+ return Err(Self::error(&value));
+ }
+
+ let selector = Selector::from_value(value)?;
+ validate(&selector, false)?;
+ Ok(Self(selector))
+ }
+}
diff --git a/crates/typst-library/src/foundations/str.rs b/crates/typst-library/src/foundations/str.rs
new file mode 100644
index 00000000..fc221c49
--- /dev/null
+++ b/crates/typst-library/src/foundations/str.rs
@@ -0,0 +1,969 @@
+use std::borrow::{Borrow, Cow};
+use std::fmt::{self, Debug, Display, Formatter};
+use std::hash::{Hash, Hasher};
+use std::ops::{Add, AddAssign, Deref, Range};
+
+use comemo::Tracked;
+use ecow::EcoString;
+use serde::{Deserialize, Serialize};
+use typst_syntax::{Span, Spanned};
+use typst_utils::PicoStr;
+use unicode_segmentation::UnicodeSegmentation;
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, dict, func, repr, scope, ty, Array, Bytes, Context, Decimal, Dict, Func,
+ IntoValue, Label, Repr, Type, Value, Version,
+};
+use crate::layout::Alignment;
+
+/// Create a new [`Str`] from a format string.
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __format_str {
+ ($($tts:tt)*) => {{
+ $crate::foundations::Str::from($crate::foundations::eco_format!($($tts)*))
+ }};
+}
+
+#[doc(hidden)]
+pub use ecow::eco_format;
+
+#[doc(inline)]
+pub use crate::__format_str as format_str;
+
+/// A sequence of Unicode codepoints.
+///
+/// You can iterate over the grapheme clusters of the string using a [for
+/// loop]($scripting/#loops). Grapheme clusters are basically characters but
+/// keep together things that belong together, e.g. multiple codepoints that
+/// together form a flag emoji. Strings can be added with the `+` operator,
+/// [joined together]($scripting/#blocks) and multiplied with integers.
+///
+/// Typst provides utility methods for string manipulation. Many of these
+/// methods (e.g., `split`, `trim` and `replace`) operate on _patterns:_ A
+/// pattern can be either a string or a [regular expression]($regex). This makes
+/// the methods quite versatile.
+///
+/// All lengths and indices are expressed in terms of UTF-8 bytes. Indices are
+/// zero-based and negative indices wrap around to the end of the string.
+///
+/// You can convert a value to a string with this type's constructor.
+///
+/// # Example
+/// ```example
+/// #"hello world!" \
+/// #"\"hello\n world\"!" \
+/// #"1 2 3".split() \
+/// #"1,2;3".split(regex("[,;]")) \
+/// #(regex("\d+") in "ten euros") \
+/// #(regex("\d+") in "10 euros")
+/// ```
+///
+/// # Escape sequences { #escapes }
+/// Just like in markup, you can escape a few symbols in strings:
+/// - `[\\]` for a backslash
+/// - `[\"]` for a quote
+/// - `[\n]` for a newline
+/// - `[\r]` for a carriage return
+/// - `[\t]` for a tab
+/// - `[\u{1f600}]` for a hexadecimal Unicode escape sequence
+#[ty(scope, cast, title = "String")]
+#[derive(Default, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct Str(EcoString);
+
+impl Str {
+ /// Create a new, empty string.
+ pub fn new() -> Self {
+ Self(EcoString::new())
+ }
+
+ /// Return `true` if the length is 0.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// Repeat the string a number of times.
+ pub fn repeat(&self, n: usize) -> StrResult<Self> {
+ if self.0.len().checked_mul(n).is_none() {
+ return Err(eco_format!("cannot repeat this string {n} times"));
+ }
+ Ok(Self(self.0.repeat(n)))
+ }
+
+ /// A string slice containing the entire string.
+ pub fn as_str(&self) -> &str {
+ self
+ }
+
+ /// Resolve an index or throw an out of bounds error.
+ fn locate(&self, index: i64) -> StrResult<usize> {
+ self.locate_opt(index)?
+ .ok_or_else(|| out_of_bounds(index, self.len()))
+ }
+
+ /// Resolve an index, if it is within bounds and on a valid char boundary.
+ ///
+ /// `index == len` is considered in bounds.
+ fn locate_opt(&self, index: i64) -> StrResult<Option<usize>> {
+ let wrapped =
+ if index >= 0 { Some(index) } else { (self.len() as i64).checked_add(index) };
+
+ let resolved = wrapped
+ .and_then(|v| usize::try_from(v).ok())
+ .filter(|&v| v <= self.0.len());
+
+ if resolved.is_some_and(|i| !self.0.is_char_boundary(i)) {
+ return Err(not_a_char_boundary(index));
+ }
+
+ Ok(resolved)
+ }
+}
+
+#[scope]
+impl Str {
+ /// 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.
+ /// - Bytes are decoded as UTF-8.
+ ///
+ /// If you wish to convert from and to Unicode code points, see the
+ /// [`to-unicode`]($str.to-unicode) and [`from-unicode`]($str.from-unicode)
+ /// functions.
+ ///
+ /// ```example
+ /// #str(10) \
+ /// #str(4000, base: 16) \
+ /// #str(2.7) \
+ /// #str(1e8) \
+ /// #str(<intro>)
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// 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");
+ }
+ repr::format_int_with_base(n, base.v).into()
+ }
+ })
+ }
+
+ /// The length of the string in UTF-8 encoded bytes.
+ #[func(title = "Length")]
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Extracts the first grapheme cluster of the string.
+ /// Fails with an error if the string is empty.
+ #[func]
+ pub fn first(&self) -> StrResult<Str> {
+ self.0
+ .graphemes(true)
+ .next()
+ .map(Into::into)
+ .ok_or_else(string_is_empty)
+ }
+
+ /// Extracts the last grapheme cluster of the string.
+ /// Fails with an error if the string is empty.
+ #[func]
+ pub fn last(&self) -> StrResult<Str> {
+ self.0
+ .graphemes(true)
+ .next_back()
+ .map(Into::into)
+ .ok_or_else(string_is_empty)
+ }
+
+ /// Extracts the first grapheme cluster after the specified index. Returns
+ /// the default value if the index is out of bounds or fails with an error
+ /// if no default value was specified.
+ #[func]
+ pub fn at(
+ &self,
+ /// The byte index. If negative, indexes from the back.
+ index: i64,
+ /// A default value to return if the index is out of bounds.
+ #[named]
+ default: Option<Value>,
+ ) -> StrResult<Value> {
+ let len = self.len();
+ self.locate_opt(index)?
+ .and_then(|i| self.0[i..].graphemes(true).next().map(|s| s.into_value()))
+ .or(default)
+ .ok_or_else(|| no_default_and_out_of_bounds(index, len))
+ }
+
+ /// Extracts a substring of the string.
+ /// Fails with an error if the start or end index is out of bounds.
+ #[func]
+ pub fn slice(
+ &self,
+ /// The start byte index (inclusive). If negative, indexes from the
+ /// back.
+ start: i64,
+ /// The end byte index (exclusive). If omitted, the whole slice until
+ /// the end of the string is extracted. If negative, indexes from the
+ /// back.
+ #[default]
+ end: Option<i64>,
+ /// The number of bytes to extract. This is equivalent to passing
+ /// `start + count` as the `end` position. Mutually exclusive with `end`.
+ #[named]
+ count: Option<i64>,
+ ) -> StrResult<Str> {
+ let end = end.or(count.map(|c| start + c)).unwrap_or(self.len() as i64);
+ let start = self.locate(start)?;
+ let end = self.locate(end)?.max(start);
+ Ok(self.0[start..end].into())
+ }
+
+ /// Returns the grapheme clusters of the string as an array of substrings.
+ #[func]
+ pub fn clusters(&self) -> Array {
+ self.as_str().graphemes(true).map(|s| Value::Str(s.into())).collect()
+ }
+
+ /// Returns the Unicode codepoints of the string as an array of substrings.
+ #[func]
+ pub fn codepoints(&self) -> Array {
+ self.chars().map(|c| Value::Str(c.into())).collect()
+ }
+
+ /// Converts a character into its corresponding code point.
+ ///
+ /// ```example
+ /// #"a".to-unicode() \
+ /// #("a\u{0300}"
+ /// .codepoints()
+ /// .map(str.to-unicode))
+ /// ```
+ #[func]
+ pub fn to_unicode(
+ /// The character that should be converted.
+ character: char,
+ ) -> u32 {
+ character as u32
+ }
+
+ /// Converts a unicode code point into its corresponding string.
+ ///
+ /// ```example
+ /// #str.from-unicode(97)
+ /// ```
+ #[func]
+ pub fn from_unicode(
+ /// The code point that should be converted.
+ value: u32,
+ ) -> StrResult<Str> {
+ let c: char = value
+ .try_into()
+ .map_err(|_| eco_format!("{value:#x} is not a valid codepoint"))?;
+ Ok(c.into())
+ }
+
+ /// Whether the string contains the specified pattern.
+ ///
+ /// This method also has dedicated syntax: You can write `{"bc" in "abcd"}`
+ /// instead of `{"abcd".contains("bc")}`.
+ #[func]
+ pub fn contains(
+ &self,
+ /// The pattern to search for.
+ pattern: StrPattern,
+ ) -> bool {
+ match pattern {
+ StrPattern::Str(pat) => self.0.contains(pat.as_str()),
+ StrPattern::Regex(re) => re.is_match(self),
+ }
+ }
+
+ /// Whether the string starts with the specified pattern.
+ #[func]
+ pub fn starts_with(
+ &self,
+ /// The pattern the string might start with.
+ pattern: StrPattern,
+ ) -> bool {
+ match pattern {
+ StrPattern::Str(pat) => self.0.starts_with(pat.as_str()),
+ StrPattern::Regex(re) => re.find(self).is_some_and(|m| m.start() == 0),
+ }
+ }
+
+ /// Whether the string ends with the specified pattern.
+ #[func]
+ pub fn ends_with(
+ &self,
+ /// The pattern the string might end with.
+ pattern: StrPattern,
+ ) -> bool {
+ match pattern {
+ StrPattern::Str(pat) => self.0.ends_with(pat.as_str()),
+ StrPattern::Regex(re) => {
+ let mut start_byte = 0;
+ while let Some(mat) = re.find_at(self, start_byte) {
+ if mat.end() == self.0.len() {
+ return true;
+ }
+
+ // There might still be a match overlapping this one, so
+ // restart at the next code point.
+ let Some(c) = self[mat.start()..].chars().next() else { break };
+ start_byte = mat.start() + c.len_utf8();
+ }
+ false
+ }
+ }
+ }
+
+ /// Searches for the specified pattern in the string and returns the first
+ /// match as a string or `{none}` if there is no match.
+ #[func]
+ pub fn find(
+ &self,
+ /// The pattern to search for.
+ pattern: StrPattern,
+ ) -> Option<Str> {
+ match pattern {
+ StrPattern::Str(pat) => self.0.contains(pat.as_str()).then_some(pat),
+ StrPattern::Regex(re) => re.find(self).map(|m| m.as_str().into()),
+ }
+ }
+
+ /// Searches for the specified pattern in the string and returns the index
+ /// of the first match as an integer or `{none}` if there is no match.
+ #[func]
+ pub fn position(
+ &self,
+ /// The pattern to search for.
+ pattern: StrPattern,
+ ) -> Option<usize> {
+ match pattern {
+ StrPattern::Str(pat) => self.0.find(pat.as_str()),
+ StrPattern::Regex(re) => re.find(self).map(|m| m.start()),
+ }
+ }
+
+ /// Searches for the specified pattern in the string and returns a
+ /// dictionary with details about the first match or `{none}` if there is no
+ /// match.
+ ///
+ /// The returned dictionary has the following keys:
+ /// - `start`: The start offset of the match
+ /// - `end`: The end offset of the match
+ /// - `text`: The text that matched.
+ /// - `captures`: An array containing a string for each matched capturing
+ /// group. The first item of the array contains the first matched
+ /// capturing, not the whole match! This is empty unless the `pattern` was
+ /// a regex with capturing groups.
+ #[func]
+ pub fn match_(
+ &self,
+ /// The pattern to search for.
+ pattern: StrPattern,
+ ) -> Option<Dict> {
+ match pattern {
+ StrPattern::Str(pat) => {
+ self.0.match_indices(pat.as_str()).next().map(match_to_dict)
+ }
+ StrPattern::Regex(re) => re.captures(self).map(captures_to_dict),
+ }
+ }
+
+ /// Searches for the specified pattern in the string and returns an array of
+ /// dictionaries with details about all matches. For details about the
+ /// returned dictionaries, see above.
+ #[func]
+ pub fn matches(
+ &self,
+ /// The pattern to search for.
+ pattern: StrPattern,
+ ) -> Array {
+ match pattern {
+ StrPattern::Str(pat) => self
+ .0
+ .match_indices(pat.as_str())
+ .map(match_to_dict)
+ .map(Value::Dict)
+ .collect(),
+ StrPattern::Regex(re) => re
+ .captures_iter(self)
+ .map(captures_to_dict)
+ .map(Value::Dict)
+ .collect(),
+ }
+ }
+
+ /// Replace at most `count` occurrences of the given pattern with a
+ /// replacement string or function (beginning from the start). If no count
+ /// is given, all occurrences are replaced.
+ #[func]
+ pub fn replace(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The pattern to search for.
+ pattern: StrPattern,
+ /// The string to replace the matches with or a function that gets a
+ /// dictionary for each match and can return individual replacement
+ /// strings.
+ replacement: Replacement,
+ /// If given, only the first `count` matches of the pattern are placed.
+ #[named]
+ count: Option<usize>,
+ ) -> SourceResult<Str> {
+ // Heuristic: Assume the new string is about the same length as
+ // the current string.
+ let mut output = EcoString::with_capacity(self.as_str().len());
+
+ // Replace one match of a pattern with the replacement.
+ let mut last_match = 0;
+ let mut handle_match = |range: Range<usize>, dict: Dict| -> SourceResult<()> {
+ // Push everything until the match.
+ output.push_str(&self[last_match..range.start]);
+ last_match = range.end;
+
+ // Determine and push the replacement.
+ match &replacement {
+ Replacement::Str(s) => output.push_str(s),
+ Replacement::Func(func) => {
+ let piece = func
+ .call(engine, context, [dict])?
+ .cast::<Str>()
+ .at(func.span())?;
+ output.push_str(&piece);
+ }
+ }
+
+ Ok(())
+ };
+
+ // Iterate over the matches of the `pattern`.
+ let count = count.unwrap_or(usize::MAX);
+ match &pattern {
+ StrPattern::Str(pat) => {
+ for m in self.match_indices(pat.as_str()).take(count) {
+ let (start, text) = m;
+ handle_match(start..start + text.len(), match_to_dict(m))?;
+ }
+ }
+ StrPattern::Regex(re) => {
+ for caps in re.captures_iter(self).take(count) {
+ // Extract the entire match over all capture groups.
+ let m = caps.get(0).unwrap();
+ handle_match(m.start()..m.end(), captures_to_dict(caps))?;
+ }
+ }
+ }
+
+ // Push the remainder.
+ output.push_str(&self[last_match..]);
+ Ok(output.into())
+ }
+
+ /// Removes matches of a pattern from one or both sides of the string, once or
+ /// repeatedly and returns the resulting string.
+ #[func]
+ pub fn trim(
+ &self,
+ /// The pattern to search for. If `{none}`, trims white spaces.
+ #[default]
+ pattern: Option<StrPattern>,
+ /// Can be `{start}` or `{end}` to only trim the start or end of the
+ /// string. If omitted, both sides are trimmed.
+ #[named]
+ at: Option<StrSide>,
+ /// Whether to repeatedly removes matches of the pattern or just once.
+ /// Defaults to `{true}`.
+ #[named]
+ #[default(true)]
+ repeat: bool,
+ ) -> Str {
+ let mut start = matches!(at, Some(StrSide::Start) | None);
+ let end = matches!(at, Some(StrSide::End) | None);
+
+ let trimmed = match pattern {
+ None => match at {
+ None => self.0.trim(),
+ Some(StrSide::Start) => self.0.trim_start(),
+ Some(StrSide::End) => self.0.trim_end(),
+ },
+ Some(StrPattern::Str(pat)) => {
+ let pat = pat.as_str();
+ let mut s = self.as_str();
+ if repeat {
+ if start {
+ s = s.trim_start_matches(pat);
+ }
+ if end {
+ s = s.trim_end_matches(pat);
+ }
+ } else {
+ if start {
+ s = s.strip_prefix(pat).unwrap_or(s);
+ }
+ if end {
+ s = s.strip_suffix(pat).unwrap_or(s);
+ }
+ }
+ s
+ }
+ Some(StrPattern::Regex(re)) => {
+ let s = self.as_str();
+ let mut last = None;
+ let mut range = 0..s.len();
+
+ for m in re.find_iter(s) {
+ // Does this match follow directly after the last one?
+ let consecutive = last == Some(m.start());
+
+ // As long as we're at the beginning or in a consecutive run
+ // of matches, and we're still trimming at the start, trim.
+ start &= m.start() == 0 || consecutive;
+ if start {
+ range.start = m.end();
+ start &= repeat;
+ }
+
+ // Reset end trim if we aren't consecutive anymore or aren't
+ // repeating.
+ if end && (!consecutive || !repeat) {
+ range.end = m.start();
+ }
+
+ last = Some(m.end());
+ }
+
+ // Is the last match directly at the end?
+ if last.is_some_and(|last| last < s.len()) {
+ range.end = s.len();
+ }
+
+ &s[range.start..range.start.max(range.end)]
+ }
+ };
+
+ trimmed.into()
+ }
+
+ /// Splits a string at matches of a specified pattern and returns an array
+ /// of the resulting parts.
+ #[func]
+ pub fn split(
+ &self,
+ /// The pattern to split at. Defaults to whitespace.
+ #[default]
+ pattern: Option<StrPattern>,
+ ) -> Array {
+ let s = self.as_str();
+ match pattern {
+ None => s.split_whitespace().map(|v| Value::Str(v.into())).collect(),
+ Some(StrPattern::Str(pat)) => {
+ s.split(pat.as_str()).map(|v| Value::Str(v.into())).collect()
+ }
+ Some(StrPattern::Regex(re)) => {
+ re.split(s).map(|v| Value::Str(v.into())).collect()
+ }
+ }
+ }
+
+ /// Reverse the string.
+ #[func(title = "Reverse")]
+ pub fn rev(&self) -> Str {
+ let mut s = EcoString::with_capacity(self.0.len());
+ for grapheme in self.as_str().graphemes(true).rev() {
+ s.push_str(grapheme);
+ }
+ s.into()
+ }
+}
+
+impl Deref for Str {
+ type Target = str;
+
+ fn deref(&self) -> &str {
+ &self.0
+ }
+}
+
+impl Debug for Str {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ Debug::fmt(self.as_str(), f)
+ }
+}
+
+impl Display for Str {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ Display::fmt(self.as_str(), f)
+ }
+}
+
+impl Repr for Str {
+ fn repr(&self) -> EcoString {
+ self.as_ref().repr()
+ }
+}
+
+impl Repr for EcoString {
+ fn repr(&self) -> EcoString {
+ self.as_ref().repr()
+ }
+}
+
+impl Repr for str {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::with_capacity(self.len() + 2);
+ r.push('"');
+ for c in self.chars() {
+ match c {
+ '\0' => r.push_str(r"\u{0}"),
+ '\'' => r.push('\''),
+ '"' => r.push_str(r#"\""#),
+ _ => c.escape_debug().for_each(|c| r.push(c)),
+ }
+ }
+ r.push('"');
+ r
+ }
+}
+
+impl Add for Str {
+ type Output = Self;
+
+ fn add(mut self, rhs: Self) -> Self::Output {
+ self += rhs;
+ self
+ }
+}
+
+impl AddAssign for Str {
+ fn add_assign(&mut self, rhs: Self) {
+ self.0.push_str(rhs.as_str());
+ }
+}
+
+impl AsRef<str> for Str {
+ fn as_ref(&self) -> &str {
+ self
+ }
+}
+
+impl Borrow<str> for Str {
+ fn borrow(&self) -> &str {
+ self
+ }
+}
+
+impl From<char> for Str {
+ fn from(c: char) -> Self {
+ Self(c.into())
+ }
+}
+
+impl From<&str> for Str {
+ fn from(s: &str) -> Self {
+ Self(s.into())
+ }
+}
+
+impl From<EcoString> for Str {
+ fn from(s: EcoString) -> Self {
+ Self(s)
+ }
+}
+
+impl From<String> for Str {
+ fn from(s: String) -> Self {
+ Self(s.into())
+ }
+}
+
+impl From<Cow<'_, str>> for Str {
+ fn from(s: Cow<str>) -> Self {
+ Self(s.into())
+ }
+}
+
+impl FromIterator<char> for Str {
+ fn from_iter<T: IntoIterator<Item = char>>(iter: T) -> Self {
+ Self(iter.into_iter().collect())
+ }
+}
+
+impl From<Str> for EcoString {
+ fn from(str: Str) -> Self {
+ str.0
+ }
+}
+
+impl From<Str> for String {
+ fn from(s: Str) -> Self {
+ s.0.into()
+ }
+}
+
+cast! {
+ char,
+ self => Value::Str(self.into()),
+ string: Str => {
+ let mut chars = string.chars();
+ match (chars.next(), chars.next()) {
+ (Some(c), None) => c,
+ _ => bail!("expected exactly one character"),
+ }
+ },
+}
+
+cast! {
+ &str,
+ self => Value::Str(self.into()),
+}
+
+cast! {
+ EcoString,
+ self => Value::Str(self.into()),
+ v: Str => v.into(),
+}
+
+cast! {
+ PicoStr,
+ self => Value::Str(self.resolve().into()),
+ v: Str => v.as_str().into(),
+}
+
+cast! {
+ String,
+ self => Value::Str(self.into()),
+ v: Str => 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(repr::display_float(v).into()),
+ v: Decimal => Self::Str(format_str!("{}", v)),
+ v: Version => Self::Str(format_str!("{}", v)),
+ v: Bytes => Self::Str(
+ std::str::from_utf8(&v)
+ .map_err(|_| "bytes are not valid utf-8")?
+ .into()
+ ),
+ v: Label => Self::Str(v.as_str().into()),
+ v: Type => Self::Str(v.long_name().into()),
+ v: Str => Self::Str(v),
+}
+
+/// Convert an item of std's `match_indices` to a dictionary.
+fn match_to_dict((start, text): (usize, &str)) -> Dict {
+ dict! {
+ "start" => start,
+ "end" => start + text.len(),
+ "text" => text,
+ "captures" => Array::new(),
+ }
+}
+
+/// Convert regex captures to a dictionary.
+fn captures_to_dict(cap: regex::Captures) -> Dict {
+ let m = cap.get(0).expect("missing first match");
+ dict! {
+ "start" => m.start(),
+ "end" => m.end(),
+ "text" => m.as_str(),
+ "captures" => cap.iter()
+ .skip(1)
+ .map(|opt| opt.map_or(Value::None, |m| m.as_str().into_value()))
+ .collect::<Array>(),
+ }
+}
+
+/// The out of bounds access error message.
+#[cold]
+fn out_of_bounds(index: i64, len: usize) -> EcoString {
+ eco_format!("string index out of bounds (index: {}, len: {})", index, len)
+}
+
+/// The out of bounds access error message when no default value was given.
+#[cold]
+fn no_default_and_out_of_bounds(index: i64, len: usize) -> EcoString {
+ eco_format!("no default value was specified and string index out of bounds (index: {}, len: {})", index, len)
+}
+
+/// The char boundary access error message.
+#[cold]
+fn not_a_char_boundary(index: i64) -> EcoString {
+ eco_format!("string index {} is not a character boundary", index)
+}
+
+/// The error message when the string is empty.
+#[cold]
+fn string_is_empty() -> EcoString {
+ "string is empty".into()
+}
+
+/// A regular expression.
+///
+/// Can be used as a [show rule selector]($styling/#show-rules) and with
+/// [string methods]($str) like `find`, `split`, and `replace`.
+///
+/// [See here](https://docs.rs/regex/latest/regex/#syntax) for a specification
+/// of the supported syntax.
+///
+/// # Example
+/// ```example
+/// // Works with string methods.
+/// #"a,b;c".split(regex("[,;]"))
+///
+/// // Works with show rules.
+/// #show regex("\d+"): set text(red)
+///
+/// The numbers 1 to 10.
+/// ```
+#[ty(scope)]
+#[derive(Debug, Clone)]
+pub struct Regex(regex::Regex);
+
+impl Regex {
+ /// Create a new regular expression.
+ pub fn new(re: &str) -> StrResult<Self> {
+ regex::Regex::new(re).map(Self).map_err(|err| eco_format!("{err}"))
+ }
+}
+
+#[scope]
+impl Regex {
+ /// Create a regular expression from a string.
+ #[func(constructor)]
+ pub fn construct(
+ /// 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<Str>,
+ ) -> SourceResult<Regex> {
+ Self::new(&regex.v).at(regex.span)
+ }
+}
+
+impl Deref for Regex {
+ type Target = regex::Regex;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl Repr for Regex {
+ fn repr(&self) -> EcoString {
+ eco_format!("regex({})", self.0.as_str().repr())
+ }
+}
+
+impl PartialEq for Regex {
+ fn eq(&self, other: &Self) -> bool {
+ self.0.as_str() == other.0.as_str()
+ }
+}
+
+impl Hash for Regex {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.0.as_str().hash(state);
+ }
+}
+
+/// A pattern which can be searched for in a string.
+#[derive(Debug, Clone)]
+pub enum StrPattern {
+ /// Just a string.
+ Str(Str),
+ /// A regular expression.
+ Regex(Regex),
+}
+
+cast! {
+ StrPattern,
+ self => match self {
+ Self::Str(v) => v.into_value(),
+ Self::Regex(v) => v.into_value(),
+ },
+ v: Str => Self::Str(v),
+ v: Regex => Self::Regex(v),
+}
+
+/// A side of a string.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
+pub enum StrSide {
+ /// The logical start of the string, may be left or right depending on the
+ /// language.
+ Start,
+ /// The logical end of the string.
+ End,
+}
+
+cast! {
+ StrSide,
+ v: Alignment => match v {
+ Alignment::START => Self::Start,
+ Alignment::END => Self::End,
+ _ => bail!("expected either `start` or `end`"),
+ },
+}
+
+/// A replacement for a matched [`Str`]
+pub enum Replacement {
+ /// A string a match is replaced with.
+ Str(Str),
+ /// Function of type Dict -> Str (see `captures_to_dict` or `match_to_dict`)
+ /// whose output is inserted for the match.
+ Func(Func),
+}
+
+cast! {
+ Replacement,
+ self => match self {
+ Self::Str(v) => v.into_value(),
+ Self::Func(v) => v.into_value(),
+ },
+ v: Str => Self::Str(v),
+ v: Func => Self::Func(v)
+}
diff --git a/crates/typst-library/src/foundations/styles.rs b/crates/typst-library/src/foundations/styles.rs
new file mode 100644
index 00000000..af4909e5
--- /dev/null
+++ b/crates/typst-library/src/foundations/styles.rs
@@ -0,0 +1,1034 @@
+use std::any::{Any, TypeId};
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::{mem, ptr};
+
+use comemo::{Track, Tracked};
+use ecow::{eco_vec, EcoString, EcoVec};
+use smallvec::SmallVec;
+use typst_syntax::Span;
+use typst_utils::LazyHash;
+
+use crate::diag::{warning, SourceResult, Trace, Tracepoint};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, func, ty, Content, Context, Element, Func, NativeElement, Packed, Repr,
+ Selector, Show,
+};
+use crate::introspection::Locatable;
+use crate::text::{FontFamily, FontList, TextElem};
+
+/// Provides access to active styles.
+///
+/// **Deprecation planned.** Use [context] instead.
+///
+/// ```example
+/// #let thing(body) = style(styles => {
+/// let size = measure(body, styles)
+/// [Width of "#body" is #size.width]
+/// })
+///
+/// #thing[Hey] \
+/// #thing[Welcome]
+/// ```
+#[func]
+pub fn style(
+ /// The engine.
+ engine: &mut Engine,
+ /// The call site span.
+ span: Span,
+ /// 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 {
+ engine.sink.warn(warning!(
+ span, "`style` is deprecated";
+ hint: "use a `context` expression instead"
+ ));
+
+ StyleElem::new(func).pack().spanned(span)
+}
+
+/// Executes a style access.
+#[elem(Locatable, Show)]
+struct StyleElem {
+ /// The function to call with the styles.
+ #[required]
+ func: Func,
+}
+
+impl Show for Packed<StyleElem> {
+ #[typst_macros::time(name = "style", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let context = Context::new(self.location(), Some(styles));
+ Ok(self
+ .func()
+ .call(engine, context.track(), [styles.to_map()])?
+ .display())
+ }
+}
+
+/// A list of style properties.
+#[ty(cast)]
+#[derive(Default, PartialEq, Clone, Hash)]
+pub struct Styles(EcoVec<LazyHash<Style>>);
+
+impl Styles {
+ /// Create a new, empty style list.
+ pub const fn new() -> Self {
+ Self(EcoVec::new())
+ }
+
+ /// Whether this contains no styles.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// Iterate over the contained styles.
+ pub fn iter(&self) -> impl Iterator<Item = &Style> {
+ self.0.iter().map(|style| &**style)
+ }
+
+ /// Iterate over the contained styles.
+ pub fn as_slice(&self) -> &[LazyHash<Style>] {
+ self.0.as_slice()
+ }
+
+ /// Set an inner value for a style property.
+ ///
+ /// If the property needs folding and the value is already contained in the
+ /// style map, `self` contributes the outer values and `value` is the inner
+ /// one.
+ pub fn set(&mut self, style: impl Into<Style>) {
+ self.0.push(LazyHash::new(style.into()));
+ }
+
+ /// Remove the style that was last set.
+ pub fn unset(&mut self) {
+ self.0.pop();
+ }
+
+ /// Apply outer styles. Like [`chain`](StyleChain::chain), but in-place.
+ pub fn apply(&mut self, mut outer: Self) {
+ outer.0.extend(mem::take(self).0);
+ *self = outer;
+ }
+
+ /// Apply one outer styles.
+ pub fn apply_one(&mut self, outer: Style) {
+ self.0.insert(0, LazyHash::new(outer));
+ }
+
+ /// Add an origin span to all contained properties.
+ pub fn spanned(mut self, span: Span) -> Self {
+ for entry in self.0.make_mut() {
+ if let Style::Property(property) = &mut **entry {
+ property.span = span;
+ }
+ }
+ self
+ }
+
+ /// Marks the styles as having been applied outside of any show rule.
+ pub fn outside(mut self) -> Self {
+ for entry in self.0.make_mut() {
+ match &mut **entry {
+ Style::Property(property) => property.outside = true,
+ Style::Recipe(recipe) => recipe.outside = true,
+ _ => {}
+ }
+ }
+ self
+ }
+
+ /// Marks the styles as being allowed to be lifted up to the page level.
+ pub fn liftable(mut self) -> Self {
+ for entry in self.0.make_mut() {
+ if let Style::Property(property) = &mut **entry {
+ property.liftable = true;
+ }
+ }
+ self
+ }
+
+ /// Whether there is a style for the given field of the given element.
+ pub fn has<T: NativeElement>(&self, field: u8) -> bool {
+ let elem = T::elem();
+ self.0
+ .iter()
+ .filter_map(|style| style.property())
+ .any(|property| property.is_of(elem) && property.id == field)
+ }
+
+ /// Set a font family composed of a preferred family and existing families
+ /// from a style chain.
+ pub fn set_family(&mut self, preferred: FontFamily, existing: StyleChain) {
+ self.set(TextElem::set_font(FontList(
+ std::iter::once(preferred)
+ .chain(TextElem::font_in(existing).into_iter().cloned())
+ .collect(),
+ )));
+ }
+}
+
+impl From<LazyHash<Style>> for Styles {
+ fn from(style: LazyHash<Style>) -> Self {
+ Self(eco_vec![style])
+ }
+}
+
+impl From<Style> for Styles {
+ fn from(style: Style) -> Self {
+ Self(eco_vec![LazyHash::new(style)])
+ }
+}
+
+impl IntoIterator for Styles {
+ type Item = LazyHash<Style>;
+ type IntoIter = ecow::vec::IntoIter<Self::Item>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl FromIterator<LazyHash<Style>> for Styles {
+ fn from_iter<T: IntoIterator<Item = LazyHash<Style>>>(iter: T) -> Self {
+ Self(iter.into_iter().collect())
+ }
+}
+
+impl Debug for Styles {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Styles ")?;
+ f.debug_list().entries(&self.0).finish()
+ }
+}
+
+impl Repr for Styles {
+ fn repr(&self) -> EcoString {
+ "..".into()
+ }
+}
+
+/// A single style property or recipe.
+#[derive(Clone, Hash)]
+pub enum Style {
+ /// A style property originating from a set rule or constructor.
+ Property(Property),
+ /// A show rule recipe.
+ Recipe(Recipe),
+ /// Disables a specific show rule recipe.
+ ///
+ /// Note: This currently only works for regex recipes since it's the only
+ /// place we need it for the moment. Normal show rules use guards directly
+ /// on elements instead.
+ Revocation(RecipeIndex),
+}
+
+impl Style {
+ /// If this is a property, return it.
+ pub fn property(&self) -> Option<&Property> {
+ match self {
+ Self::Property(property) => Some(property),
+ _ => None,
+ }
+ }
+
+ /// If this is a recipe, return it.
+ pub fn recipe(&self) -> Option<&Recipe> {
+ match self {
+ Self::Recipe(recipe) => Some(recipe),
+ _ => None,
+ }
+ }
+
+ /// The style's span, if any.
+ pub fn span(&self) -> Span {
+ match self {
+ Self::Property(property) => property.span,
+ Self::Recipe(recipe) => recipe.span,
+ Self::Revocation(_) => Span::detached(),
+ }
+ }
+
+ /// Returns `Some(_)` with an optional span if this style is for
+ /// the given element.
+ pub fn element(&self) -> Option<Element> {
+ match self {
+ Style::Property(property) => Some(property.elem),
+ Style::Recipe(recipe) => match recipe.selector {
+ Some(Selector::Elem(elem, _)) => Some(elem),
+ _ => None,
+ },
+ Style::Revocation(_) => None,
+ }
+ }
+
+ /// Whether the style is allowed to be lifted up to the page level. Only
+ /// true for styles originating from set rules.
+ pub fn liftable(&self) -> bool {
+ match self {
+ Self::Property(property) => property.liftable,
+ Self::Recipe(_) => true,
+ Self::Revocation(_) => false,
+ }
+ }
+
+ /// Whether the style was applied outside of any show rule. This is set
+ /// during realization.
+ pub fn outside(&self) -> bool {
+ match self {
+ Self::Property(property) => property.outside,
+ Self::Recipe(recipe) => recipe.outside,
+ Self::Revocation(_) => false,
+ }
+ }
+
+ /// Turn this style into prehashed style.
+ pub fn wrap(self) -> LazyHash<Style> {
+ LazyHash::new(self)
+ }
+}
+
+impl Debug for Style {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Property(property) => property.fmt(f),
+ Self::Recipe(recipe) => recipe.fmt(f),
+ Self::Revocation(guard) => guard.fmt(f),
+ }
+ }
+}
+
+impl From<Property> for Style {
+ fn from(property: Property) -> Self {
+ Self::Property(property)
+ }
+}
+
+impl From<Recipe> for Style {
+ fn from(recipe: Recipe) -> Self {
+ Self::Recipe(recipe)
+ }
+}
+
+/// A style property originating from a set rule or constructor.
+#[derive(Clone, Hash)]
+pub struct Property {
+ /// The element the property belongs to.
+ elem: Element,
+ /// The property's ID.
+ id: u8,
+ /// The property's value.
+ value: Block,
+ /// The span of the set rule the property stems from.
+ span: Span,
+ /// Whether the property is allowed to be lifted up to the page level.
+ liftable: bool,
+ /// Whether the property was applied outside of any show rule.
+ outside: bool,
+}
+
+impl Property {
+ /// Create a new property from a key-value pair.
+ pub fn new<E, T>(id: u8, value: T) -> Self
+ where
+ E: NativeElement,
+ T: Debug + Clone + Hash + Send + Sync + 'static,
+ {
+ Self {
+ elem: E::elem(),
+ id,
+ value: Block::new(value),
+ span: Span::detached(),
+ liftable: false,
+ outside: false,
+ }
+ }
+
+ /// Whether this property is the given one.
+ pub fn is(&self, elem: Element, id: u8) -> bool {
+ self.elem == elem && self.id == id
+ }
+
+ /// Whether this property belongs to the given element.
+ pub fn is_of(&self, elem: Element) -> bool {
+ self.elem == elem
+ }
+
+ /// Turn this property into prehashed style.
+ pub fn wrap(self) -> LazyHash<Style> {
+ Style::Property(self).wrap()
+ }
+}
+
+impl Debug for Property {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(
+ f,
+ "Set({}.{}: ",
+ self.elem.name(),
+ self.elem.field_name(self.id).unwrap()
+ )?;
+ self.value.fmt(f)?;
+ write!(f, ")")
+ }
+}
+
+/// A block storage for storing style values.
+///
+/// We're using a `Box` since values will either be contained in an `Arc` and
+/// therefore already on the heap or they will be small enough that we can just
+/// clone them.
+#[derive(Hash)]
+struct Block(Box<dyn Blockable>);
+
+impl Block {
+ /// Creates a new block.
+ fn new<T: Blockable>(value: T) -> Self {
+ Self(Box::new(value))
+ }
+
+ /// Downcasts the block to the specified type.
+ fn downcast<T: 'static>(&self) -> Option<&T> {
+ self.0.as_any().downcast_ref()
+ }
+}
+
+impl Debug for Block {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl Clone for Block {
+ fn clone(&self) -> Self {
+ self.0.dyn_clone()
+ }
+}
+
+/// A value that can be stored in a block.
+///
+/// Auto derived for all types that implement [`Any`], [`Clone`], [`Hash`],
+/// [`Debug`], [`Send`] and [`Sync`].
+trait Blockable: Debug + Send + Sync + 'static {
+ /// Equivalent to `downcast_ref` for the block.
+ fn as_any(&self) -> &dyn Any;
+
+ /// Equivalent to [`Hash`] for the block.
+ fn dyn_hash(&self, state: &mut dyn Hasher);
+
+ /// Equivalent to [`Clone`] for the block.
+ fn dyn_clone(&self) -> Block;
+}
+
+impl<T: Debug + Clone + Hash + Send + Sync + 'static> Blockable for T {
+ fn as_any(&self) -> &dyn Any {
+ self
+ }
+
+ fn dyn_hash(&self, mut state: &mut dyn Hasher) {
+ // Also hash the TypeId since values with different types but
+ // equal data should be different.
+ TypeId::of::<Self>().hash(&mut state);
+ self.hash(&mut state);
+ }
+
+ fn dyn_clone(&self) -> Block {
+ Block(Box::new(self.clone()))
+ }
+}
+
+impl Hash for dyn Blockable {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.dyn_hash(state);
+ }
+}
+
+/// A show rule recipe.
+#[derive(Clone, PartialEq, Hash)]
+pub struct Recipe {
+ /// Determines whether the recipe applies to an element.
+ ///
+ /// If this is `None`, then this recipe is from a show rule with
+ /// no selector (`show: rest => ...`), which is [eagerly applied][Content::styled_with_recipe]
+ /// to the rest of the content in the scope.
+ selector: Option<Selector>,
+ /// The transformation to perform on the match.
+ transform: Transformation,
+ /// The span that errors are reported with.
+ span: Span,
+ /// Relevant properties of the kind of construct the style originated from
+ /// and where it was applied.
+ outside: bool,
+}
+
+impl Recipe {
+ /// Create a new recipe from a key-value pair.
+ pub fn new(
+ selector: Option<Selector>,
+ transform: Transformation,
+ span: Span,
+ ) -> Self {
+ Self { selector, transform, span, outside: false }
+ }
+
+ /// The recipe's selector.
+ pub fn selector(&self) -> Option<&Selector> {
+ self.selector.as_ref()
+ }
+
+ /// The recipe's transformation.
+ pub fn transform(&self) -> &Transformation {
+ &self.transform
+ }
+
+ /// The recipe's span.
+ pub fn span(&self) -> Span {
+ self.span
+ }
+
+ /// Apply the recipe to the given content.
+ pub fn apply(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ content: Content,
+ ) -> SourceResult<Content> {
+ let mut content = match &self.transform {
+ Transformation::Content(content) => content.clone(),
+ Transformation::Func(func) => {
+ let mut result = func.call(engine, context, [content.clone()]);
+ if self.selector.is_some() {
+ let point = || Tracepoint::Show(content.func().name().into());
+ result = result.trace(engine.world, point, content.span());
+ }
+ result?.display()
+ }
+ Transformation::Style(styles) => content.styled_with_map(styles.clone()),
+ };
+ if content.span().is_detached() {
+ content = content.spanned(self.span);
+ }
+ Ok(content)
+ }
+}
+
+impl Debug for Recipe {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Show(")?;
+ if let Some(selector) = &self.selector {
+ selector.fmt(f)?;
+ f.write_str(", ")?;
+ }
+ self.transform.fmt(f)
+ }
+}
+
+/// Identifies a show rule recipe from the top of the chain.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct RecipeIndex(pub usize);
+
+/// A show rule transformation that can be applied to a match.
+#[derive(Clone, PartialEq, Hash)]
+pub enum Transformation {
+ /// Replacement content.
+ Content(Content),
+ /// A function to apply to the match.
+ Func(Func),
+ /// Apply styles to the content.
+ Style(Styles),
+}
+
+impl Debug for Transformation {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Content(content) => content.fmt(f),
+ Self::Func(func) => func.fmt(f),
+ Self::Style(styles) => styles.fmt(f),
+ }
+ }
+}
+
+cast! {
+ Transformation,
+ content: Content => Self::Content(content),
+ func: Func => Self::Func(func),
+}
+
+/// A chain of styles, similar to a linked list.
+///
+/// A style chain allows to combine properties from multiple style lists in a
+/// element hierarchy in a non-allocating way. Rather than eagerly merging the
+/// lists, each access walks the hierarchy from the innermost to the outermost
+/// map, trying to find a match and then folding it with matches further up the
+/// chain.
+#[derive(Default, Clone, Copy, Hash)]
+pub struct StyleChain<'a> {
+ /// The first link of this chain.
+ head: &'a [LazyHash<Style>],
+ /// The remaining links in the chain.
+ tail: Option<&'a Self>,
+}
+
+impl<'a> StyleChain<'a> {
+ /// Start a new style chain with root styles.
+ pub fn new(root: &'a Styles) -> Self {
+ Self { head: &root.0, tail: None }
+ }
+
+ /// Make the given chainable the first link of this chain.
+ ///
+ /// The resulting style chain contains styles from `local` as well as
+ /// `self`. The ones from `local` take precedence over the ones from
+ /// `self`. For folded properties `local` contributes the inner value.
+ pub fn chain<'b, C>(&'b self, local: &'b C) -> StyleChain<'b>
+ where
+ C: Chainable + ?Sized,
+ {
+ Chainable::chain(local, self)
+ }
+
+ /// Cast the first value for the given property in the chain.
+ pub fn get<T: Clone + 'static>(
+ self,
+ func: Element,
+ id: u8,
+ inherent: Option<&T>,
+ default: impl Fn() -> T,
+ ) -> T {
+ self.properties::<T>(func, id, inherent)
+ .next()
+ .cloned()
+ .unwrap_or_else(default)
+ }
+
+ /// Cast the first value for the given property in the chain,
+ /// returning a borrowed value.
+ pub fn get_ref<T: 'static>(
+ self,
+ func: Element,
+ id: u8,
+ inherent: Option<&'a T>,
+ default: impl Fn() -> &'a T,
+ ) -> &'a T {
+ self.properties::<T>(func, id, inherent)
+ .next()
+ .unwrap_or_else(default)
+ }
+
+ /// Cast the first value for the given property in the chain, taking
+ /// `Fold` implementations into account.
+ pub fn get_folded<T: Fold + Clone + 'static>(
+ self,
+ func: Element,
+ id: u8,
+ inherent: Option<&T>,
+ default: impl Fn() -> T,
+ ) -> T {
+ fn next<T: Fold>(
+ mut values: impl Iterator<Item = T>,
+ default: &impl Fn() -> T,
+ ) -> T {
+ values
+ .next()
+ .map(|value| value.fold(next(values, default)))
+ .unwrap_or_else(default)
+ }
+ next(self.properties::<T>(func, id, inherent).cloned(), &default)
+ }
+
+ /// Iterate over all values for the given property in the chain.
+ fn properties<T: 'static>(
+ self,
+ func: Element,
+ id: u8,
+ inherent: Option<&'a T>,
+ ) -> impl Iterator<Item = &'a T> {
+ inherent.into_iter().chain(
+ self.entries()
+ .filter_map(|style| style.property())
+ .filter(move |property| property.is(func, id))
+ .map(|property| &property.value)
+ .map(move |value| {
+ value.downcast().unwrap_or_else(|| {
+ panic!(
+ "attempted to read a value of a different type than was written {}.{}: {:?}",
+ func.name(),
+ func.field_name(id).unwrap(),
+ value
+ )
+ })
+ }),
+ )
+ }
+
+ /// Iterate over the entries of the chain.
+ pub fn entries(self) -> Entries<'a> {
+ Entries { inner: [].as_slice().iter(), links: self.links() }
+ }
+
+ /// Iterate over the recipes in the chain.
+ pub fn recipes(self) -> impl Iterator<Item = &'a Recipe> {
+ self.entries().filter_map(|style| style.recipe())
+ }
+
+ /// Iterate over the links of the chain.
+ pub fn links(self) -> Links<'a> {
+ Links(Some(self))
+ }
+
+ /// Convert to a style map.
+ pub fn to_map(self) -> Styles {
+ let mut styles: EcoVec<_> = self.entries().cloned().collect();
+ styles.make_mut().reverse();
+ Styles(styles)
+ }
+
+ /// Build owned styles from the suffix (all links beyond the `len`) of the
+ /// chain.
+ pub fn suffix(self, len: usize) -> Styles {
+ let mut styles = EcoVec::new();
+ let take = self.links().count().saturating_sub(len);
+ for link in self.links().take(take) {
+ styles.extend(link.iter().cloned().rev());
+ }
+ styles.make_mut().reverse();
+ Styles(styles)
+ }
+
+ /// Remove the last link from the chain.
+ pub fn pop(&mut self) {
+ *self = self.tail.copied().unwrap_or_default();
+ }
+
+ /// Determine the shared trunk of a collection of style chains.
+ pub fn trunk(iter: impl IntoIterator<Item = Self>) -> Option<Self> {
+ // Determine shared style depth and first span.
+ let mut iter = iter.into_iter();
+ let mut trunk = iter.next()?;
+ let mut depth = trunk.links().count();
+
+ for mut chain in iter {
+ let len = chain.links().count();
+ if len < depth {
+ for _ in 0..depth - len {
+ trunk.pop();
+ }
+ depth = len;
+ } else if len > depth {
+ for _ in 0..len - depth {
+ chain.pop();
+ }
+ }
+
+ while depth > 0 && chain != trunk {
+ trunk.pop();
+ chain.pop();
+ depth -= 1;
+ }
+ }
+
+ Some(trunk)
+ }
+}
+
+impl Debug for StyleChain<'_> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("StyleChain ")?;
+ f.debug_list()
+ .entries(self.entries().collect::<Vec<_>>().into_iter().rev())
+ .finish()
+ }
+}
+
+impl PartialEq for StyleChain<'_> {
+ fn eq(&self, other: &Self) -> bool {
+ ptr::eq(self.head, other.head)
+ && match (self.tail, other.tail) {
+ (Some(a), Some(b)) => ptr::eq(a, b),
+ (None, None) => true,
+ _ => false,
+ }
+ }
+}
+
+/// Things that can be attached to a style chain.
+pub trait Chainable {
+ /// Attach `self` as the first link of the chain.
+ fn chain<'a>(&'a self, outer: &'a StyleChain<'_>) -> StyleChain<'a>;
+}
+
+impl Chainable for LazyHash<Style> {
+ fn chain<'a>(&'a self, outer: &'a StyleChain<'_>) -> StyleChain<'a> {
+ StyleChain {
+ head: std::slice::from_ref(self),
+ tail: Some(outer),
+ }
+ }
+}
+
+impl Chainable for [LazyHash<Style>] {
+ fn chain<'a>(&'a self, outer: &'a StyleChain<'_>) -> StyleChain<'a> {
+ if self.is_empty() {
+ *outer
+ } else {
+ StyleChain { head: self, tail: Some(outer) }
+ }
+ }
+}
+
+impl<const N: usize> Chainable for [LazyHash<Style>; N] {
+ fn chain<'a>(&'a self, outer: &'a StyleChain<'_>) -> StyleChain<'a> {
+ Chainable::chain(self.as_slice(), outer)
+ }
+}
+
+impl Chainable for Styles {
+ fn chain<'a>(&'a self, outer: &'a StyleChain<'_>) -> StyleChain<'a> {
+ Chainable::chain(self.0.as_slice(), outer)
+ }
+}
+
+/// An iterator over the entries in a style chain.
+pub struct Entries<'a> {
+ inner: std::slice::Iter<'a, LazyHash<Style>>,
+ links: Links<'a>,
+}
+
+impl<'a> Iterator for Entries<'a> {
+ type Item = &'a LazyHash<Style>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ loop {
+ if let Some(entry) = self.inner.next_back() {
+ return Some(entry);
+ }
+
+ match self.links.next() {
+ Some(next) => self.inner = next.iter(),
+ None => return None,
+ }
+ }
+ }
+}
+
+/// An iterator over the links of a style chain.
+pub struct Links<'a>(Option<StyleChain<'a>>);
+
+impl<'a> Iterator for Links<'a> {
+ type Item = &'a [LazyHash<Style>];
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let StyleChain { head, tail } = self.0?;
+ self.0 = tail.copied();
+ Some(head)
+ }
+}
+
+/// A sequence of elements with associated styles.
+#[derive(Clone, PartialEq, Hash)]
+pub struct StyleVec {
+ /// The elements themselves.
+ elements: EcoVec<Content>,
+ /// A run-length encoded list of style lists.
+ ///
+ /// Each element is a (styles, count) pair. Any elements whose
+ /// style falls after the end of this list is considered to
+ /// have an empty style list.
+ styles: EcoVec<(Styles, usize)>,
+}
+
+impl StyleVec {
+ /// Create a style vector from an unstyled vector content.
+ pub fn wrap(elements: EcoVec<Content>) -> Self {
+ Self { elements, styles: EcoVec::new() }
+ }
+
+ /// Create a `StyleVec` from a list of content with style chains.
+ pub fn create<'a>(buf: &[(&'a Content, StyleChain<'a>)]) -> (Self, StyleChain<'a>) {
+ let trunk = StyleChain::trunk(buf.iter().map(|&(_, s)| s)).unwrap_or_default();
+ let depth = trunk.links().count();
+
+ let mut elements = EcoVec::with_capacity(buf.len());
+ let mut styles = EcoVec::<(Styles, usize)>::new();
+ let mut last: Option<(StyleChain<'a>, usize)> = None;
+
+ for &(element, chain) in buf {
+ elements.push(element.clone());
+
+ if let Some((prev, run)) = &mut last {
+ if chain == *prev {
+ *run += 1;
+ } else {
+ styles.push((prev.suffix(depth), *run));
+ last = Some((chain, 1));
+ }
+ } else {
+ last = Some((chain, 1));
+ }
+ }
+
+ if let Some((last, run)) = last {
+ let skippable = styles.is_empty() && last == trunk;
+ if !skippable {
+ styles.push((last.suffix(depth), run));
+ }
+ }
+
+ (StyleVec { elements, styles }, trunk)
+ }
+
+ /// Whether there are no elements.
+ pub fn is_empty(&self) -> bool {
+ self.elements.is_empty()
+ }
+
+ /// The number of elements.
+ pub fn len(&self) -> usize {
+ self.elements.len()
+ }
+
+ /// Iterate over the contained content and style chains.
+ pub fn iter<'a>(
+ &'a self,
+ outer: &'a StyleChain<'_>,
+ ) -> impl Iterator<Item = (&'a Content, StyleChain<'a>)> {
+ static EMPTY: Styles = Styles::new();
+ self.elements
+ .iter()
+ .zip(
+ self.styles
+ .iter()
+ .flat_map(|(local, count)| std::iter::repeat(local).take(*count))
+ .chain(std::iter::repeat(&EMPTY)),
+ )
+ .map(|(element, local)| (element, outer.chain(local)))
+ }
+
+ /// Get a style property, but only if it is the same for all children of the
+ /// style vector.
+ pub fn shared_get<T: PartialEq>(
+ &self,
+ styles: StyleChain<'_>,
+ getter: fn(StyleChain) -> T,
+ ) -> Option<T> {
+ let value = getter(styles);
+ self.styles
+ .iter()
+ .all(|(local, _)| getter(styles.chain(local)) == value)
+ .then_some(value)
+ }
+}
+
+impl Debug for StyleVec {
+ fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
+ f.debug_list().entries(&self.elements).finish()
+ }
+}
+
+/// A property that is resolved with other properties from the style chain.
+pub trait Resolve {
+ /// The type of the resolved output.
+ type Output;
+
+ /// Resolve the value using the style chain.
+ fn resolve(self, styles: StyleChain) -> Self::Output;
+}
+
+impl<T: Resolve> Resolve for Option<T> {
+ type Output = Option<T::Output>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.map(|v| v.resolve(styles))
+ }
+}
+
+/// A property that is folded to determine its final value.
+///
+/// In the example below, the chain of stroke values is folded into a single
+/// value: `4pt + red`.
+///
+/// ```example
+/// #set rect(stroke: red)
+/// #set rect(stroke: 4pt)
+/// #rect()
+/// ```
+pub trait Fold {
+ /// Fold this inner value with an outer folded value.
+ fn fold(self, outer: Self) -> Self;
+}
+
+impl Fold for bool {
+ fn fold(self, _: Self) -> Self {
+ self
+ }
+}
+
+impl<T: Fold> Fold for Option<T> {
+ fn fold(self, outer: Self) -> Self {
+ match (self, outer) {
+ (Some(inner), Some(outer)) => Some(inner.fold(outer)),
+ // An explicit `None` should be respected, thus we don't do
+ // `inner.or(outer)`.
+ (inner, _) => inner,
+ }
+ }
+}
+
+impl<T> Fold for Vec<T> {
+ fn fold(self, mut outer: Self) -> Self {
+ outer.extend(self);
+ outer
+ }
+}
+
+impl<T, const N: usize> Fold for SmallVec<[T; N]> {
+ fn fold(self, mut outer: Self) -> Self {
+ outer.extend(self);
+ outer
+ }
+}
+
+/// A variant of fold for foldable optional (`Option<T>`) values where an inner
+/// `None` value isn't respected (contrary to `Option`'s usual `Fold`
+/// implementation, with which folding with an inner `None` always returns
+/// `None`). Instead, when either of the `Option` objects is `None`, the other
+/// one is necessarily returned by `fold_or`. Normal folding still occurs when
+/// both values are `Some`, using `T`'s `Fold` implementation.
+///
+/// This is useful when `None` in a particular context means "unspecified"
+/// rather than "absent", in which case a specified value (`Some`) is chosen
+/// over an unspecified one (`None`), while two specified values are folded
+/// together.
+pub trait AlternativeFold {
+ /// Attempts to fold this inner value with an outer value. However, if
+ /// either value is `None`, returns the other one instead of folding.
+ fn fold_or(self, outer: Self) -> Self;
+}
+
+impl<T: Fold> AlternativeFold for Option<T> {
+ fn fold_or(self, outer: Self) -> Self {
+ match (self, outer) {
+ (Some(inner), Some(outer)) => Some(inner.fold(outer)),
+ // If one of values is `None`, return the other one instead of
+ // folding.
+ (inner, outer) => inner.or(outer),
+ }
+ }
+}
+
+/// A type that accumulates depth when folded.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
+pub struct Depth(pub usize);
+
+impl Fold for Depth {
+ fn fold(self, outer: Self) -> Self {
+ Self(outer.0 + self.0)
+ }
+}
diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs
new file mode 100644
index 00000000..86676fa2
--- /dev/null
+++ b/crates/typst-library/src/foundations/symbol.rs
@@ -0,0 +1,356 @@
+#[doc(inline)]
+pub use typst_macros::symbols;
+
+use std::cmp::Reverse;
+use std::collections::BTreeSet;
+use std::fmt::{self, Debug, Display, Formatter, Write};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use serde::{Serialize, Serializer};
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, SourceResult, StrResult};
+use crate::foundations::{cast, func, scope, ty, Array, Func};
+
+/// A Unicode symbol.
+///
+/// Typst defines common symbols so that they can easily be written with
+/// standard keyboards. The symbols are defined in modules, from which they can
+/// be accessed using [field access notation]($scripting/#fields):
+///
+/// - General symbols are defined in the [`sym` module]($category/symbols/sym)
+/// - Emoji are defined in the [`emoji` module]($category/symbols/emoji)
+///
+/// Moreover, you can define custom symbols with this type's constructor
+/// function.
+///
+/// ```example
+/// #sym.arrow.r \
+/// #sym.gt.eq.not \
+/// $gt.eq.not$ \
+/// #emoji.face.halo
+/// ```
+///
+/// Many symbols have different variants, which can be selected by appending the
+/// modifiers with dot notation. The order of the modifiers is not relevant.
+/// Visit the documentation pages of the symbol modules and click on a symbol to
+/// see its available variants.
+///
+/// ```example
+/// $arrow.l$ \
+/// $arrow.r$ \
+/// $arrow.t.quad$
+/// ```
+#[ty(scope, cast)]
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Symbol(Repr);
+
+/// The character of a symbol, possibly with a function.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct SymChar(char, Option<fn() -> Func>);
+
+/// The internal representation.
+#[derive(Clone, Eq, PartialEq, Hash)]
+enum Repr {
+ Single(SymChar),
+ Const(&'static [(&'static str, SymChar)]),
+ Multi(Arc<(List, EcoString)>),
+}
+
+/// A collection of symbols.
+#[derive(Clone, Eq, PartialEq, Hash)]
+enum List {
+ Static(&'static [(&'static str, SymChar)]),
+ Runtime(Box<[(EcoString, SymChar)]>),
+}
+
+impl Symbol {
+ /// Create a new symbol from a single character.
+ pub const fn single(c: SymChar) -> Self {
+ Self(Repr::Single(c))
+ }
+
+ /// Create a symbol with a static variant list.
+ #[track_caller]
+ pub const fn list(list: &'static [(&'static str, SymChar)]) -> Self {
+ debug_assert!(!list.is_empty());
+ Self(Repr::Const(list))
+ }
+
+ /// Create a symbol with a runtime variant list.
+ #[track_caller]
+ pub fn runtime(list: Box<[(EcoString, SymChar)]>) -> Self {
+ debug_assert!(!list.is_empty());
+ Self(Repr::Multi(Arc::new((List::Runtime(list), EcoString::new()))))
+ }
+
+ /// Get the symbol's char.
+ pub fn get(&self) -> char {
+ self.sym().char()
+ }
+
+ /// Resolve the symbol's `SymChar`.
+ pub fn sym(&self) -> SymChar {
+ match &self.0 {
+ Repr::Single(c) => *c,
+ Repr::Const(_) => find(self.variants(), "").unwrap(),
+ Repr::Multi(arc) => find(self.variants(), &arc.1).unwrap(),
+ }
+ }
+
+ /// Try to get the function associated with the symbol, if any.
+ pub fn func(&self) -> StrResult<Func> {
+ self.sym()
+ .func()
+ .ok_or_else(|| eco_format!("symbol {self} is not callable"))
+ }
+
+ /// Apply a modifier to the symbol.
+ pub fn modified(mut self, modifier: &str) -> StrResult<Self> {
+ if let Repr::Const(list) = self.0 {
+ self.0 = Repr::Multi(Arc::new((List::Static(list), EcoString::new())));
+ }
+
+ if let Repr::Multi(arc) = &mut self.0 {
+ let (list, modifiers) = Arc::make_mut(arc);
+ if !modifiers.is_empty() {
+ modifiers.push('.');
+ }
+ modifiers.push_str(modifier);
+ if find(list.variants(), modifiers).is_some() {
+ return Ok(self);
+ }
+ }
+
+ bail!("unknown symbol modifier")
+ }
+
+ /// The characters that are covered by this symbol.
+ pub fn variants(&self) -> impl Iterator<Item = (&str, SymChar)> {
+ match &self.0 {
+ Repr::Single(c) => Variants::Single(Some(*c).into_iter()),
+ Repr::Const(list) => Variants::Static(list.iter()),
+ Repr::Multi(arc) => arc.0.variants(),
+ }
+ }
+
+ /// Possible modifiers.
+ pub fn modifiers(&self) -> impl Iterator<Item = &str> + '_ {
+ let mut set = BTreeSet::new();
+ let modifiers = match &self.0 {
+ Repr::Multi(arc) => arc.1.as_str(),
+ _ => "",
+ };
+ for modifier in self.variants().flat_map(|(name, _)| name.split('.')) {
+ if !modifier.is_empty() && !contained(modifiers, modifier) {
+ set.insert(modifier);
+ }
+ }
+ set.into_iter()
+ }
+}
+
+#[scope]
+impl Symbol {
+ /// Create a custom symbol with modifiers.
+ ///
+ /// ```example
+ /// #let envelope = symbol(
+ /// "🖂",
+ /// ("stamped", "🖃"),
+ /// ("stamped.pen", "🖆"),
+ /// ("lightning", "🖄"),
+ /// ("fly", "🖅"),
+ /// )
+ ///
+ /// #envelope
+ /// #envelope.stamped
+ /// #envelope.stamped.pen
+ /// #envelope.lightning
+ /// #envelope.fly
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The callsite span.
+ span: Span,
+ /// 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<SymbolVariant>>,
+ ) -> 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, SymChar::pure(v.1)));
+ }
+ Ok(Symbol::runtime(list.into_boxed_slice()))
+ }
+}
+
+impl Display for Symbol {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_char(self.get())
+ }
+}
+
+impl SymChar {
+ /// Create a symbol character without a function.
+ pub const fn pure(c: char) -> Self {
+ Self(c, None)
+ }
+
+ /// Create a symbol character with a function.
+ pub const fn with_func(c: char, func: fn() -> Func) -> Self {
+ Self(c, Some(func))
+ }
+
+ /// Get the character of the symbol.
+ pub const fn char(&self) -> char {
+ self.0
+ }
+
+ /// Get the function associated with the symbol.
+ pub fn func(&self) -> Option<Func> {
+ self.1.map(|f| f())
+ }
+}
+
+impl From<char> for SymChar {
+ fn from(c: char) -> Self {
+ SymChar(c, None)
+ }
+}
+
+impl Debug for Repr {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Single(c) => Debug::fmt(c, f),
+ Self::Const(list) => list.fmt(f),
+ Self::Multi(lists) => lists.fmt(f),
+ }
+ }
+}
+
+impl Debug for List {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Static(list) => list.fmt(f),
+ Self::Runtime(list) => list.fmt(f),
+ }
+ }
+}
+
+impl crate::foundations::Repr for Symbol {
+ fn repr(&self) -> EcoString {
+ eco_format!("\"{}\"", self.get())
+ }
+}
+
+impl Serialize for Symbol {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_char(self.get())
+ }
+}
+
+impl List {
+ /// The characters that are covered by this list.
+ fn variants(&self) -> Variants<'_> {
+ match self {
+ List::Static(list) => Variants::Static(list.iter()),
+ List::Runtime(list) => Variants::Runtime(list.iter()),
+ }
+ }
+}
+
+/// A value that can be cast to a symbol.
+pub struct SymbolVariant(EcoString, char);
+
+cast! {
+ SymbolVariant,
+ 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()?),
+ _ => Err("point array must contain exactly two entries")?,
+ }
+ },
+}
+
+/// Iterator over variants.
+enum Variants<'a> {
+ Single(std::option::IntoIter<SymChar>),
+ Static(std::slice::Iter<'static, (&'static str, SymChar)>),
+ Runtime(std::slice::Iter<'a, (EcoString, SymChar)>),
+}
+
+impl<'a> Iterator for Variants<'a> {
+ type Item = (&'a str, SymChar);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ match self {
+ Self::Single(iter) => Some(("", iter.next()?)),
+ Self::Static(list) => list.next().copied(),
+ Self::Runtime(list) => list.next().map(|(s, c)| (s.as_str(), *c)),
+ }
+ }
+}
+
+/// Find the best symbol from the list.
+fn find<'a>(
+ variants: impl Iterator<Item = (&'a str, SymChar)>,
+ modifiers: &str,
+) -> Option<SymChar> {
+ let mut best = None;
+ let mut best_score = None;
+
+ // Find the best table entry with this name.
+ 'outer: for candidate in variants {
+ for modifier in parts(modifiers) {
+ if !contained(candidate.0, modifier) {
+ continue 'outer;
+ }
+ }
+
+ let mut matching = 0;
+ let mut total = 0;
+ for modifier in parts(candidate.0) {
+ if contained(modifiers, modifier) {
+ matching += 1;
+ }
+ total += 1;
+ }
+
+ let score = (matching, Reverse(total));
+ if best_score.map_or(true, |b| score > b) {
+ best = Some(candidate.1);
+ best_score = Some(score);
+ }
+ }
+
+ best
+}
+
+/// Split a modifier list into its parts.
+fn parts(modifiers: &str) -> impl Iterator<Item = &str> {
+ modifiers.split('.').filter(|s| !s.is_empty())
+}
+
+/// Whether the modifier string contains the modifier `m`.
+fn contained(modifiers: &str, m: &str) -> bool {
+ parts(modifiers).any(|part| part == m)
+}
diff --git a/crates/typst-library/src/foundations/sys.rs b/crates/typst-library/src/foundations/sys.rs
new file mode 100644
index 00000000..7c128104
--- /dev/null
+++ b/crates/typst-library/src/foundations/sys.rs
@@ -0,0 +1,18 @@
+//! System-related things.
+
+use crate::foundations::{Dict, Module, Scope, Version};
+
+/// A module with system-related things.
+pub fn module(inputs: Dict) -> Module {
+ let mut scope = Scope::deduplicating();
+ scope.define(
+ "version",
+ Version::from_iter([
+ env!("CARGO_PKG_VERSION_MAJOR").parse::<u32>().unwrap(),
+ env!("CARGO_PKG_VERSION_MINOR").parse::<u32>().unwrap(),
+ env!("CARGO_PKG_VERSION_PATCH").parse::<u32>().unwrap(),
+ ]),
+ );
+ scope.define("inputs", inputs);
+ Module::new("sys", scope)
+}
diff --git a/crates/typst-library/src/foundations/ty.rs b/crates/typst-library/src/foundations/ty.rs
new file mode 100644
index 00000000..70845dd2
--- /dev/null
+++ b/crates/typst-library/src/foundations/ty.rs
@@ -0,0 +1,223 @@
+#[doc(inline)]
+pub use typst_macros::{scope, ty};
+
+use std::cmp::Ordering;
+use std::fmt::{self, Debug, Display, Formatter};
+
+use ecow::{eco_format, EcoString};
+use once_cell::sync::Lazy;
+use typst_utils::Static;
+
+use crate::diag::StrResult;
+use crate::foundations::{
+ cast, func, AutoValue, Func, NativeFuncData, NoneValue, Repr, Scope, Value,
+};
+
+/// Describes a kind of value.
+///
+/// To style your document, you need to work with values of different kinds:
+/// Lengths specifying the size of your elements, colors for your text and
+/// shapes, and more. Typst categorizes these into clearly defined _types_ and
+/// tells you where it expects which type of value.
+///
+/// Apart from basic types for numeric values and [typical]($int)
+/// [types]($float) [known]($str) [from]($array) [programming]($dictionary)
+/// languages, Typst provides a special type for [_content._]($content) A value
+/// of this type can hold anything that you can enter into your document: Text,
+/// elements like headings and shapes, and style information.
+///
+/// # Example
+/// ```example
+/// #let x = 10
+/// #if type(x) == int [
+/// #x is an integer!
+/// ] else [
+/// #x is another value...
+/// ]
+///
+/// An image is of type
+/// #type(image("glacier.jpg")).
+/// ```
+///
+/// The type of `10` is `int`. Now, what is the type of `int` or even `type`?
+/// ```example
+/// #type(int) \
+/// #type(type)
+/// ```
+///
+/// # Compatibility
+/// In Typst 0.7 and lower, the `type` function returned a string instead of a
+/// type. Compatibility with the old way will remain for a while to give package
+/// authors time to upgrade, but it will be removed at some point.
+///
+/// - Checks like `{int == "integer"}` evaluate to `{true}`
+/// - Adding/joining a type and string will yield a string
+/// - The `{in}` operator on a type and a dictionary will evaluate to `{true}`
+/// if the dictionary has a string key matching the type's name
+#[ty(scope, cast)]
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Type(Static<NativeTypeData>);
+
+impl Type {
+ /// Get the type for `T`.
+ pub fn of<T: NativeType>() -> Self {
+ T::ty()
+ }
+
+ /// The type's short name, how it is used in code (e.g. `str`).
+ pub fn short_name(&self) -> &'static str {
+ self.0.name
+ }
+
+ /// The type's long name, for use in diagnostics (e.g. `string`).
+ pub fn long_name(&self) -> &'static str {
+ self.0.long_name
+ }
+
+ /// The type's title case name, for use in documentation (e.g. `String`).
+ pub fn title(&self) -> &'static str {
+ self.0.title
+ }
+
+ /// Documentation for the type (as Markdown).
+ pub fn docs(&self) -> &'static str {
+ self.0.docs
+ }
+
+ /// Search keywords for the type.
+ pub fn keywords(&self) -> &'static [&'static str] {
+ self.0.keywords
+ }
+
+ /// This type's constructor function.
+ pub fn constructor(&self) -> StrResult<Func> {
+ self.0
+ .constructor
+ .as_ref()
+ .map(|lazy| Func::from(*lazy))
+ .ok_or_else(|| eco_format!("type {self} does not have a constructor"))
+ }
+
+ /// The type's associated scope that holds sub-definitions.
+ pub fn scope(&self) -> &'static Scope {
+ &(self.0).0.scope
+ }
+
+ /// Get a field from this type's scope, if possible.
+ pub fn field(&self, field: &str) -> StrResult<&'static Value> {
+ self.scope()
+ .get(field)
+ .ok_or_else(|| eco_format!("type {self} does not contain field `{field}`"))
+ }
+}
+
+// Type compatibility.
+impl Type {
+ /// The type's backward-compatible name.
+ pub fn compat_name(&self) -> &str {
+ self.long_name()
+ }
+}
+
+#[scope]
+impl Type {
+ /// Determines a value's type.
+ ///
+ /// ```example
+ /// #type(12) \
+ /// #type(14.7) \
+ /// #type("hello") \
+ /// #type(<glacier>) \
+ /// #type([Hi]) \
+ /// #type(x => x + 1) \
+ /// #type(type)
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The value whose type's to determine.
+ value: Value,
+ ) -> Type {
+ value.ty()
+ }
+}
+
+impl Debug for Type {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Type({})", self.long_name())
+ }
+}
+
+impl Repr for Type {
+ fn repr(&self) -> EcoString {
+ if *self == Type::of::<AutoValue>() {
+ "type(auto)"
+ } else if *self == Type::of::<NoneValue>() {
+ "type(none)"
+ } else {
+ self.long_name()
+ }
+ .into()
+ }
+}
+
+impl Display for Type {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(self.long_name())
+ }
+}
+
+impl Ord for Type {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.long_name().cmp(other.long_name())
+ }
+}
+
+impl PartialOrd for Type {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+/// A Typst type that is defined by a native Rust type.
+pub trait NativeType {
+ /// The type's name.
+ ///
+ /// In contrast to `data()`, this is usable in const contexts.
+ const NAME: &'static str;
+
+ /// Get the type for the native Rust type.
+ fn ty() -> Type {
+ Type::from(Self::data())
+ }
+
+ // Get the type data for the native Rust type.
+ fn data() -> &'static NativeTypeData;
+}
+
+/// Defines a native type.
+#[derive(Debug)]
+pub struct NativeTypeData {
+ /// The type's normal name (e.g. `str`), as exposed to Typst.
+ pub name: &'static str,
+ pub long_name: &'static str,
+ /// The function's title case name (e.g. `String`).
+ pub title: &'static str,
+ /// The documentation for this type as a string.
+ pub docs: &'static str,
+ /// A list of alternate search terms for this type.
+ pub keywords: &'static [&'static str],
+ /// The constructor for this type.
+ pub constructor: Lazy<Option<&'static NativeFuncData>>,
+ pub scope: Lazy<Scope>,
+}
+
+impl From<&'static NativeTypeData> for Type {
+ fn from(data: &'static NativeTypeData) -> Self {
+ Self(Static(data))
+ }
+}
+
+cast! {
+ &'static NativeTypeData,
+ self => Type::from(self).into_value(),
+}
diff --git a/crates/typst-library/src/foundations/value.rs b/crates/typst-library/src/foundations/value.rs
new file mode 100644
index 00000000..fbfa5a0e
--- /dev/null
+++ b/crates/typst-library/src/foundations/value.rs
@@ -0,0 +1,761 @@
+use std::any::{Any, TypeId};
+use std::cmp::Ordering;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
+use serde::de::{Error, MapAccess, SeqAccess, Visitor};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use typst_syntax::{ast, Span};
+use typst_utils::ArcExt;
+
+use crate::diag::{HintedStrResult, HintedString, StrResult};
+use crate::foundations::{
+ fields, ops, repr, Args, Array, AutoValue, Bytes, CastInfo, Content, Datetime,
+ Decimal, Dict, Duration, Fold, FromValue, Func, IntoValue, Label, Module,
+ NativeElement, NativeType, NoneValue, Plugin, Reflect, Repr, Resolve, Scope, Str,
+ Styles, Symbol, Type, Version,
+};
+use crate::layout::{Abs, Angle, Em, Fr, Length, Ratio, Rel};
+use crate::text::{RawContent, RawElem, TextElem};
+use crate::visualize::{Color, Gradient, Pattern};
+
+/// A computational value.
+#[derive(Default, Clone)]
+pub enum Value {
+ /// The value that indicates the absence of a meaningful value.
+ #[default]
+ None,
+ /// A value that indicates some smart default behaviour.
+ Auto,
+ /// A boolean: `true, false`.
+ Bool(bool),
+ /// An integer: `120`.
+ Int(i64),
+ /// A floating-point number: `1.2`, `10e-4`.
+ Float(f64),
+ /// A length: `12pt`, `3cm`, `1.5em`, `1em - 2pt`.
+ Length(Length),
+ /// An angle: `1.5rad`, `90deg`.
+ Angle(Angle),
+ /// A ratio: `50%`.
+ Ratio(Ratio),
+ /// A relative length, combination of a ratio and a length: `20% + 5cm`.
+ Relative(Rel<Length>),
+ /// A fraction: `1fr`.
+ Fraction(Fr),
+ /// A color value: `#f79143ff`.
+ Color(Color),
+ /// A gradient value: `gradient.linear(...)`.
+ Gradient(Gradient),
+ /// A pattern fill: `pattern(...)`.
+ Pattern(Pattern),
+ /// A symbol: `arrow.l`.
+ Symbol(Symbol),
+ /// A version.
+ Version(Version),
+ /// A string: `"string"`.
+ Str(Str),
+ /// Raw bytes.
+ Bytes(Bytes),
+ /// A label: `<intro>`.
+ Label(Label),
+ /// A datetime
+ Datetime(Datetime),
+ /// A decimal value: `decimal("123.4500")`
+ Decimal(Decimal),
+ /// A duration
+ Duration(Duration),
+ /// A content value: `[*Hi* there]`.
+ Content(Content),
+ // Content styles.
+ Styles(Styles),
+ /// An array of values: `(1, "hi", 12cm)`.
+ Array(Array),
+ /// A dictionary value: `(a: 1, b: "hi")`.
+ Dict(Dict),
+ /// An executable function.
+ Func(Func),
+ /// Captured arguments to a function.
+ Args(Args),
+ /// A type.
+ Type(Type),
+ /// A module.
+ Module(Module),
+ /// A WebAssembly plugin.
+ Plugin(Plugin),
+ /// A dynamic value.
+ Dyn(Dynamic),
+}
+
+impl Value {
+ /// Create a new dynamic value.
+ pub fn dynamic<T>(any: T) -> Self
+ where
+ T: Debug + Repr + NativeType + PartialEq + Hash + Sync + Send + 'static,
+ {
+ Self::Dyn(Dynamic::new(any))
+ }
+
+ /// Create a numeric value from a number with a unit.
+ pub fn numeric(pair: (f64, ast::Unit)) -> Self {
+ let (v, unit) = pair;
+ match unit {
+ ast::Unit::Pt => Abs::pt(v).into_value(),
+ ast::Unit::Mm => Abs::mm(v).into_value(),
+ ast::Unit::Cm => Abs::cm(v).into_value(),
+ ast::Unit::In => Abs::inches(v).into_value(),
+ ast::Unit::Rad => Angle::rad(v).into_value(),
+ ast::Unit::Deg => Angle::deg(v).into_value(),
+ ast::Unit::Em => Em::new(v).into_value(),
+ ast::Unit::Fr => Fr::new(v).into_value(),
+ ast::Unit::Percent => Ratio::new(v / 100.0).into_value(),
+ }
+ }
+
+ /// The type of this value.
+ pub fn ty(&self) -> Type {
+ match self {
+ Self::None => Type::of::<NoneValue>(),
+ Self::Auto => Type::of::<AutoValue>(),
+ Self::Bool(_) => Type::of::<bool>(),
+ Self::Int(_) => Type::of::<i64>(),
+ Self::Float(_) => Type::of::<f64>(),
+ Self::Length(_) => Type::of::<Length>(),
+ Self::Angle(_) => Type::of::<Angle>(),
+ Self::Ratio(_) => Type::of::<Ratio>(),
+ Self::Relative(_) => Type::of::<Rel<Length>>(),
+ Self::Fraction(_) => Type::of::<Fr>(),
+ Self::Color(_) => Type::of::<Color>(),
+ Self::Gradient(_) => Type::of::<Gradient>(),
+ Self::Pattern(_) => Type::of::<Pattern>(),
+ Self::Symbol(_) => Type::of::<Symbol>(),
+ Self::Version(_) => Type::of::<Version>(),
+ Self::Str(_) => Type::of::<Str>(),
+ Self::Bytes(_) => Type::of::<Bytes>(),
+ Self::Label(_) => Type::of::<Label>(),
+ Self::Datetime(_) => Type::of::<Datetime>(),
+ Self::Decimal(_) => Type::of::<Decimal>(),
+ Self::Duration(_) => Type::of::<Duration>(),
+ Self::Content(_) => Type::of::<Content>(),
+ Self::Styles(_) => Type::of::<Styles>(),
+ Self::Array(_) => Type::of::<Array>(),
+ Self::Dict(_) => Type::of::<Dict>(),
+ Self::Func(_) => Type::of::<Func>(),
+ Self::Args(_) => Type::of::<Args>(),
+ Self::Type(_) => Type::of::<Type>(),
+ Self::Module(_) => Type::of::<Module>(),
+ Self::Plugin(_) => Type::of::<Module>(),
+ Self::Dyn(v) => v.ty(),
+ }
+ }
+
+ /// Try to cast the value into a specific type.
+ pub fn cast<T: FromValue>(self) -> HintedStrResult<T> {
+ T::from_value(self)
+ }
+
+ /// Try to access a field on the value.
+ pub fn field(&self, field: &str) -> StrResult<Value> {
+ match self {
+ Self::Symbol(symbol) => symbol.clone().modified(field).map(Self::Symbol),
+ Self::Version(version) => version.component(field).map(Self::Int),
+ Self::Dict(dict) => dict.get(field).cloned(),
+ Self::Content(content) => content.field_by_name(field),
+ Self::Type(ty) => ty.field(field).cloned(),
+ Self::Func(func) => func.field(field).cloned(),
+ Self::Module(module) => module.field(field).cloned(),
+ _ => fields::field(self, field),
+ }
+ }
+
+ /// The associated scope, if this is a function, type, or module.
+ pub fn scope(&self) -> Option<&Scope> {
+ match self {
+ Self::Func(func) => func.scope(),
+ Self::Type(ty) => Some(ty.scope()),
+ Self::Module(module) => Some(module.scope()),
+ _ => None,
+ }
+ }
+
+ /// The name, if this is a function, type, or module.
+ pub fn name(&self) -> Option<&str> {
+ match self {
+ Self::Func(func) => func.name(),
+ Self::Type(ty) => Some(ty.short_name()),
+ Self::Module(module) => Some(module.name()),
+ _ => None,
+ }
+ }
+
+ /// Try to extract documentation for the value.
+ pub fn docs(&self) -> Option<&'static str> {
+ match self {
+ Self::Func(func) => func.docs(),
+ Self::Type(ty) => Some(ty.docs()),
+ _ => None,
+ }
+ }
+
+ /// Return the display representation of the value.
+ pub fn display(self) -> Content {
+ match self {
+ Self::None => Content::empty(),
+ Self::Int(v) => TextElem::packed(repr::format_int_with_base(v, 10)),
+ Self::Float(v) => TextElem::packed(repr::display_float(v)),
+ Self::Decimal(v) => TextElem::packed(eco_format!("{v}")),
+ Self::Str(v) => TextElem::packed(v),
+ Self::Version(v) => TextElem::packed(eco_format!("{v}")),
+ Self::Symbol(v) => TextElem::packed(v.get()),
+ Self::Content(v) => v,
+ Self::Module(module) => module.content(),
+ _ => RawElem::new(RawContent::Text(self.repr()))
+ .with_lang(Some("typc".into()))
+ .with_block(false)
+ .pack(),
+ }
+ }
+
+ /// Attach a span to the value, if possible.
+ pub fn spanned(self, span: Span) -> Self {
+ match self {
+ Value::Content(v) => Value::Content(v.spanned(span)),
+ Value::Func(v) => Value::Func(v.spanned(span)),
+ v => v,
+ }
+ }
+}
+
+impl Debug for Value {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::None => Debug::fmt(&NoneValue, f),
+ Self::Auto => Debug::fmt(&AutoValue, f),
+ Self::Bool(v) => Debug::fmt(v, f),
+ Self::Int(v) => Debug::fmt(v, f),
+ Self::Float(v) => Debug::fmt(v, f),
+ Self::Length(v) => Debug::fmt(v, f),
+ Self::Angle(v) => Debug::fmt(v, f),
+ Self::Ratio(v) => Debug::fmt(v, f),
+ Self::Relative(v) => Debug::fmt(v, f),
+ Self::Fraction(v) => Debug::fmt(v, f),
+ Self::Color(v) => Debug::fmt(v, f),
+ Self::Gradient(v) => Debug::fmt(v, f),
+ Self::Pattern(v) => Debug::fmt(v, f),
+ Self::Symbol(v) => Debug::fmt(v, f),
+ Self::Version(v) => Debug::fmt(v, f),
+ Self::Str(v) => Debug::fmt(v, f),
+ Self::Bytes(v) => Debug::fmt(v, f),
+ Self::Label(v) => Debug::fmt(v, f),
+ Self::Datetime(v) => Debug::fmt(v, f),
+ Self::Decimal(v) => Debug::fmt(v, f),
+ Self::Duration(v) => Debug::fmt(v, f),
+ Self::Content(v) => Debug::fmt(v, f),
+ Self::Styles(v) => Debug::fmt(v, f),
+ Self::Array(v) => Debug::fmt(v, f),
+ Self::Dict(v) => Debug::fmt(v, f),
+ Self::Func(v) => Debug::fmt(v, f),
+ Self::Args(v) => Debug::fmt(v, f),
+ Self::Type(v) => Debug::fmt(v, f),
+ Self::Module(v) => Debug::fmt(v, f),
+ Self::Plugin(v) => Debug::fmt(v, f),
+ Self::Dyn(v) => Debug::fmt(v, f),
+ }
+ }
+}
+
+impl Repr for Value {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::None => NoneValue.repr(),
+ Self::Auto => AutoValue.repr(),
+ Self::Bool(v) => v.repr(),
+ Self::Int(v) => v.repr(),
+ Self::Float(v) => v.repr(),
+ Self::Length(v) => v.repr(),
+ Self::Angle(v) => v.repr(),
+ Self::Ratio(v) => v.repr(),
+ Self::Relative(v) => v.repr(),
+ Self::Fraction(v) => v.repr(),
+ Self::Color(v) => v.repr(),
+ Self::Gradient(v) => v.repr(),
+ Self::Pattern(v) => v.repr(),
+ Self::Symbol(v) => v.repr(),
+ Self::Version(v) => v.repr(),
+ Self::Str(v) => v.repr(),
+ Self::Bytes(v) => v.repr(),
+ Self::Label(v) => v.repr(),
+ Self::Datetime(v) => v.repr(),
+ Self::Decimal(v) => v.repr(),
+ Self::Duration(v) => v.repr(),
+ Self::Content(v) => v.repr(),
+ Self::Styles(v) => v.repr(),
+ Self::Array(v) => v.repr(),
+ Self::Dict(v) => v.repr(),
+ Self::Func(v) => v.repr(),
+ Self::Args(v) => v.repr(),
+ Self::Type(v) => v.repr(),
+ Self::Module(v) => v.repr(),
+ Self::Plugin(v) => v.repr(),
+ Self::Dyn(v) => v.repr(),
+ }
+ }
+}
+
+impl PartialEq for Value {
+ fn eq(&self, other: &Self) -> bool {
+ ops::equal(self, other)
+ }
+}
+
+impl PartialOrd for Value {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ ops::compare(self, other).ok()
+ }
+}
+
+impl Hash for Value {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ std::mem::discriminant(self).hash(state);
+ match self {
+ Self::None => {}
+ Self::Auto => {}
+ Self::Bool(v) => v.hash(state),
+ Self::Int(v) => v.hash(state),
+ Self::Float(v) => v.to_bits().hash(state),
+ Self::Length(v) => v.hash(state),
+ Self::Angle(v) => v.hash(state),
+ Self::Ratio(v) => v.hash(state),
+ Self::Relative(v) => v.hash(state),
+ Self::Fraction(v) => v.hash(state),
+ Self::Color(v) => v.hash(state),
+ Self::Gradient(v) => v.hash(state),
+ Self::Pattern(v) => v.hash(state),
+ Self::Symbol(v) => v.hash(state),
+ Self::Version(v) => v.hash(state),
+ Self::Str(v) => v.hash(state),
+ Self::Bytes(v) => v.hash(state),
+ Self::Label(v) => v.hash(state),
+ Self::Content(v) => v.hash(state),
+ Self::Styles(v) => v.hash(state),
+ Self::Datetime(v) => v.hash(state),
+ Self::Decimal(v) => v.hash(state),
+ Self::Duration(v) => v.hash(state),
+ Self::Array(v) => v.hash(state),
+ Self::Dict(v) => v.hash(state),
+ Self::Func(v) => v.hash(state),
+ Self::Args(v) => v.hash(state),
+ Self::Type(v) => v.hash(state),
+ Self::Module(v) => v.hash(state),
+ Self::Plugin(v) => v.hash(state),
+ Self::Dyn(v) => v.hash(state),
+ }
+ }
+}
+
+impl Serialize for Value {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ match self {
+ Self::None => NoneValue.serialize(serializer),
+ Self::Bool(v) => v.serialize(serializer),
+ Self::Int(v) => v.serialize(serializer),
+ Self::Float(v) => v.serialize(serializer),
+ Self::Str(v) => v.serialize(serializer),
+ Self::Bytes(v) => v.serialize(serializer),
+ Self::Symbol(v) => v.serialize(serializer),
+ Self::Content(v) => v.serialize(serializer),
+ Self::Array(v) => v.serialize(serializer),
+ Self::Dict(v) => v.serialize(serializer),
+
+ // Fall back to repr() for other things.
+ other => serializer.serialize_str(&other.repr()),
+ }
+ }
+}
+
+impl<'de> Deserialize<'de> for Value {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ deserializer.deserialize_any(ValueVisitor)
+ }
+}
+
+/// Visitor for value deserialization.
+struct ValueVisitor;
+
+impl<'de> Visitor<'de> for ValueVisitor {
+ type Value = Value;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("a typst value")
+ }
+
+ fn visit_bool<E: Error>(self, v: bool) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_i8<E: Error>(self, v: i8) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_i16<E: Error>(self, v: i16) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_i32<E: Error>(self, v: i32) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_i64<E: Error>(self, v: i64) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_u8<E: Error>(self, v: u8) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_u16<E: Error>(self, v: u16) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_u32<E: Error>(self, v: u32) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_u64<E: Error>(self, v: u64) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_f32<E: Error>(self, v: f32) -> Result<Self::Value, E> {
+ Ok((v as f64).into_value())
+ }
+
+ fn visit_f64<E: Error>(self, v: f64) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_char<E: Error>(self, v: char) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_borrowed_str<E: Error>(self, v: &'de str) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_string<E: Error>(self, v: String) -> Result<Self::Value, E> {
+ Ok(v.into_value())
+ }
+
+ fn visit_bytes<E: Error>(self, v: &[u8]) -> Result<Self::Value, E> {
+ Ok(Bytes::from(v).into_value())
+ }
+
+ fn visit_borrowed_bytes<E: Error>(self, v: &'de [u8]) -> Result<Self::Value, E> {
+ Ok(Bytes::from(v).into_value())
+ }
+
+ fn visit_byte_buf<E: Error>(self, v: Vec<u8>) -> Result<Self::Value, E> {
+ Ok(Bytes::from(v).into_value())
+ }
+
+ fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
+ Ok(Value::None)
+ }
+
+ fn visit_some<D: Deserializer<'de>>(
+ self,
+ deserializer: D,
+ ) -> Result<Self::Value, D::Error> {
+ Value::deserialize(deserializer)
+ }
+
+ fn visit_unit<E: Error>(self) -> Result<Self::Value, E> {
+ Ok(Value::None)
+ }
+
+ fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
+ Ok(Array::deserialize(SeqAccessDeserializer::new(seq))?.into_value())
+ }
+
+ fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
+ let dict = Dict::deserialize(MapAccessDeserializer::new(map))?;
+ Ok(match Datetime::from_toml_dict(&dict) {
+ None => dict.into_value(),
+ Some(datetime) => datetime.into_value(),
+ })
+ }
+}
+
+/// A value that is not part of the built-in enum.
+#[derive(Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Dynamic(Arc<dyn Bounds>);
+
+impl Dynamic {
+ /// Create a new instance from any value that satisfies the required bounds.
+ pub fn new<T>(any: T) -> Self
+ where
+ T: Debug + Repr + NativeType + PartialEq + Hash + Sync + Send + 'static,
+ {
+ Self(Arc::new(any))
+ }
+
+ /// Whether the wrapped type is `T`.
+ pub fn is<T: 'static>(&self) -> bool {
+ (*self.0).as_any().is::<T>()
+ }
+
+ /// Try to downcast to a reference to a specific type.
+ pub fn downcast<T: 'static>(&self) -> Option<&T> {
+ (*self.0).as_any().downcast_ref()
+ }
+
+ /// The name of the stored value's type.
+ pub fn ty(&self) -> Type {
+ self.0.dyn_ty()
+ }
+}
+
+impl Debug for Dynamic {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl Repr for Dynamic {
+ fn repr(&self) -> EcoString {
+ self.0.repr()
+ }
+}
+
+impl PartialEq for Dynamic {
+ fn eq(&self, other: &Self) -> bool {
+ self.0.dyn_eq(other)
+ }
+}
+
+trait Bounds: Debug + Repr + Sync + Send + 'static {
+ fn as_any(&self) -> &dyn Any;
+ fn dyn_eq(&self, other: &Dynamic) -> bool;
+ fn dyn_ty(&self) -> Type;
+ fn dyn_hash(&self, state: &mut dyn Hasher);
+}
+
+impl<T> Bounds for T
+where
+ T: Debug + Repr + NativeType + PartialEq + Hash + Sync + Send + 'static,
+{
+ fn as_any(&self) -> &dyn Any {
+ self
+ }
+
+ fn dyn_eq(&self, other: &Dynamic) -> bool {
+ let Some(other) = other.downcast::<Self>() else { return false };
+ self == other
+ }
+
+ fn dyn_ty(&self) -> Type {
+ Type::of::<T>()
+ }
+
+ fn dyn_hash(&self, mut state: &mut dyn Hasher) {
+ // Also hash the TypeId since values with different types but
+ // equal data should be different.
+ TypeId::of::<Self>().hash(&mut state);
+ self.hash(&mut state);
+ }
+}
+
+impl Hash for dyn Bounds {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.dyn_hash(state);
+ }
+}
+
+/// Implements traits for primitives (Value enum variants).
+macro_rules! primitive {
+ (
+ $ty:ty: $name:literal, $variant:ident
+ $(, $other:ident$(($binding:ident))? => $out:expr)*
+ ) => {
+ impl Reflect for $ty {
+ fn input() -> CastInfo {
+ CastInfo::Type(Type::of::<Self>())
+ }
+
+ fn output() -> CastInfo {
+ CastInfo::Type(Type::of::<Self>())
+ }
+
+ fn castable(value: &Value) -> bool {
+ matches!(value, Value::$variant(_)
+ $(| primitive!(@$other $(($binding))?))*)
+ }
+ }
+
+ impl IntoValue for $ty {
+ fn into_value(self) -> Value {
+ Value::$variant(self)
+ }
+ }
+
+ impl FromValue for $ty {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ match value {
+ Value::$variant(v) => Ok(v),
+ $(Value::$other$(($binding))? => Ok($out),)*
+ v => Err(<Self as Reflect>::error(&v)),
+ }
+ }
+ }
+ };
+
+ (@$other:ident($binding:ident)) => { Value::$other(_) };
+ (@$other:ident) => { Value::$other };
+}
+
+primitive! { bool: "boolean", Bool }
+primitive! { i64: "integer", Int }
+primitive! { f64: "float", Float, Int(v) => v as f64 }
+primitive! { Length: "length", Length }
+primitive! { Angle: "angle", Angle }
+primitive! { Ratio: "ratio", Ratio }
+primitive! { Rel<Length>: "relative length",
+ Relative,
+ Length(v) => v.into(),
+ Ratio(v) => v.into()
+}
+primitive! { Fr: "fraction", Fraction }
+primitive! { Color: "color", Color }
+primitive! { Gradient: "gradient", Gradient }
+primitive! { Pattern: "pattern", Pattern }
+primitive! { Symbol: "symbol", Symbol }
+primitive! { Version: "version", Version }
+primitive! {
+ Str: "string",
+ Str,
+ Symbol(symbol) => symbol.get().into()
+}
+primitive! { Bytes: "bytes", Bytes }
+primitive! { Label: "label", Label }
+primitive! { Datetime: "datetime", Datetime }
+primitive! { Decimal: "decimal", Decimal }
+primitive! { Duration: "duration", Duration }
+primitive! { Content: "content",
+ Content,
+ None => Content::empty(),
+ Symbol(v) => TextElem::packed(v.get()),
+ Str(v) => TextElem::packed(v)
+}
+primitive! { Styles: "styles", Styles }
+primitive! { Array: "array", Array }
+primitive! { Dict: "dictionary", Dict }
+primitive! {
+ Func: "function",
+ Func,
+ Type(ty) => ty.constructor()?.clone(),
+ Symbol(symbol) => symbol.func()?
+}
+primitive! { Args: "arguments", Args }
+primitive! { Type: "type", Type }
+primitive! { Module: "module", Module }
+primitive! { Plugin: "plugin", Plugin }
+
+impl<T: Reflect> Reflect for Arc<T> {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+
+ fn error(found: &Value) -> HintedString {
+ T::error(found)
+ }
+}
+
+impl<T: Clone + IntoValue> IntoValue for Arc<T> {
+ fn into_value(self) -> Value {
+ Arc::take(self).into_value()
+ }
+}
+
+impl<T: FromValue> FromValue for Arc<T> {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ match value {
+ v if T::castable(&v) => Ok(Arc::new(T::from_value(v)?)),
+ _ => Err(Self::error(&value)),
+ }
+ }
+}
+
+impl<T: Clone + Resolve> Resolve for Arc<T> {
+ type Output = Arc<T::Output>;
+
+ fn resolve(self, styles: super::StyleChain) -> Self::Output {
+ Arc::new(Arc::take(self).resolve(styles))
+ }
+}
+
+impl<T: Clone + Fold> Fold for Arc<T> {
+ fn fold(self, outer: Self) -> Self {
+ Arc::new(Arc::take(self).fold(Arc::take(outer)))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::foundations::{array, dict};
+
+ #[track_caller]
+ fn test(value: impl IntoValue, exp: &str) {
+ assert_eq!(value.into_value().repr(), exp);
+ }
+
+ #[test]
+ fn test_value_debug() {
+ // Primitives.
+ test(Value::None, "none");
+ test(Value::Auto, "auto");
+ test(Value::None.ty(), "type(none)");
+ test(Value::Auto.ty(), "type(auto)");
+ test(false, "false");
+ test(12i64, "12");
+ test(3.24, "3.24");
+ test(Abs::pt(5.5), "5.5pt");
+ test(Angle::deg(90.0), "90deg");
+ test(Ratio::one() / 2.0, "50%");
+ test(Ratio::new(0.3) + Length::from(Abs::cm(2.0)), "30% + 56.69pt");
+ test(Fr::one() * 7.55, "7.55fr");
+
+ // Collections.
+ test("hello", r#""hello""#);
+ test("\n", r#""\n""#);
+ test("\\", r#""\\""#);
+ test("\"", r#""\"""#);
+ test(array![], "()");
+ test(array![Value::None], "(none,)");
+ test(array![1, 2], "(1, 2)");
+ test(dict![], "(:)");
+ test(dict!["one" => 1], "(one: 1)");
+ test(dict!["two" => false, "one" => 1], "(two: false, one: 1)");
+ }
+}
diff --git a/crates/typst-library/src/foundations/version.rs b/crates/typst-library/src/foundations/version.rs
new file mode 100644
index 00000000..62c02917
--- /dev/null
+++ b/crates/typst-library/src/foundations/version.rs
@@ -0,0 +1,202 @@
+use std::cmp::Ordering;
+use std::fmt::{self, Display, Formatter, Write};
+use std::hash::Hash;
+use std::iter::repeat;
+
+use ecow::{eco_format, EcoString, EcoVec};
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{cast, func, repr, scope, ty, Repr};
+
+/// A version with an arbitrary number of components.
+///
+/// The first three components have names that can be used as fields: `major`,
+/// `minor`, `patch`. All following components do not have names.
+///
+/// The list of components is semantically extended by an infinite list of
+/// zeros. This means that, for example, `0.8` is the same as `0.8.0`. As a
+/// special case, the empty version (that has no components at all) is the same
+/// as `0`, `0.0`, `0.0.0`, and so on.
+///
+/// The current version of the Typst compiler is available as `sys.version`.
+///
+/// You can convert a version to an array of explicitly given components using
+/// the [`array`] constructor.
+#[ty(scope, cast)]
+#[derive(Debug, Default, Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Version(EcoVec<u32>);
+
+impl Version {
+ /// The names for the first components of a version.
+ pub const COMPONENTS: [&'static str; 3] = ["major", "minor", "patch"];
+
+ /// Create a new (empty) version.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Get a named component of a version.
+ ///
+ /// Always non-negative. Returns `0` if the version isn't specified to the
+ /// necessary length.
+ pub fn component(&self, name: &str) -> StrResult<i64> {
+ self.0
+ .iter()
+ .zip(Self::COMPONENTS)
+ .find_map(|(&i, s)| (s == name).then_some(i as i64))
+ .ok_or_else(|| "unknown version component".into())
+ }
+
+ /// Push a component to the end of this version.
+ pub fn push(&mut self, component: u32) {
+ self.0.push(component);
+ }
+
+ /// The values of the version
+ pub fn values(&self) -> &[u32] {
+ &self.0
+ }
+}
+
+#[scope]
+impl Version {
+ /// Creates a new version.
+ ///
+ /// It can have any number of components (even zero).
+ ///
+ /// ```example
+ /// #version() \
+ /// #version(1) \
+ /// #version(1, 2, 3, 4) \
+ /// #version((1, 2, 3, 4)) \
+ /// #version((1, 2), 3)
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The components of the version (array arguments are flattened)
+ #[variadic]
+ components: Vec<VersionComponents>,
+ ) -> Version {
+ let mut version = Version::new();
+ for c in components {
+ match c {
+ VersionComponents::Single(v) => version.push(v),
+ VersionComponents::Multiple(values) => {
+ for v in values {
+ version.push(v);
+ }
+ }
+ }
+ }
+ version
+ }
+
+ /// Retrieves a component of a version.
+ ///
+ /// The returned integer is always non-negative. Returns `0` if the version
+ /// isn't specified to the necessary length.
+ #[func]
+ pub fn at(
+ &self,
+ /// The index at which to retrieve the component. If negative, indexes
+ /// from the back of the explicitly given components.
+ index: i64,
+ ) -> StrResult<i64> {
+ let mut index = index;
+ if index < 0 {
+ match (self.0.len() as i64).checked_add(index) {
+ Some(pos_index) if pos_index >= 0 => index = pos_index,
+ _ => bail!(
+ "component index out of bounds (index: {index}, len: {})",
+ self.0.len()
+ ),
+ }
+ }
+ Ok(usize::try_from(index)
+ .ok()
+ .and_then(|i| self.0.get(i).copied())
+ .unwrap_or_default() as i64)
+ }
+}
+
+impl FromIterator<u32> for Version {
+ fn from_iter<T: IntoIterator<Item = u32>>(iter: T) -> Self {
+ Self(EcoVec::from_iter(iter))
+ }
+}
+
+impl IntoIterator for Version {
+ type Item = u32;
+ type IntoIter = ecow::vec::IntoIter<u32>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl Ord for Version {
+ fn cmp(&self, other: &Self) -> Ordering {
+ let max_len = self.0.len().max(other.0.len());
+ let tail = repeat(&0);
+
+ let self_iter = self.0.iter().chain(tail.clone());
+ let other_iter = other.0.iter().chain(tail);
+
+ for (l, r) in self_iter.zip(other_iter).take(max_len) {
+ match l.cmp(r) {
+ Ordering::Equal => (),
+ ord => return ord,
+ }
+ }
+
+ Ordering::Equal
+ }
+}
+
+impl PartialOrd for Version {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl Eq for Version {}
+
+impl PartialEq for Version {
+ fn eq(&self, other: &Self) -> bool {
+ matches!(self.cmp(other), Ordering::Equal)
+ }
+}
+
+impl Display for Version {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let mut first = true;
+ for &v in &self.0 {
+ if !first {
+ f.write_char('.')?;
+ }
+ write!(f, "{v}")?;
+ first = false;
+ }
+ Ok(())
+ }
+}
+
+impl Repr for Version {
+ fn repr(&self) -> EcoString {
+ let parts: Vec<_> = self.0.iter().map(|v| eco_format!("{v}")).collect();
+ eco_format!("version{}", &repr::pretty_array_like(&parts, false))
+ }
+}
+
+/// One or multiple version components.
+pub enum VersionComponents {
+ Single(u32),
+ Multiple(Vec<u32>),
+}
+
+cast! {
+ VersionComponents,
+ v: u32 => Self::Single(v),
+ v: Vec<u32> => Self::Multiple(v)
+}
diff --git a/crates/typst-library/src/introspection/counter.rs b/crates/typst-library/src/introspection/counter.rs
new file mode 100644
index 00000000..2e7180c6
--- /dev/null
+++ b/crates/typst-library/src/introspection/counter.rs
@@ -0,0 +1,856 @@
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use comemo::{Track, Tracked, TrackedMut};
+use ecow::{eco_format, eco_vec, EcoString, EcoVec};
+use smallvec::{smallvec, SmallVec};
+use typst_syntax::Span;
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, warning, At, HintedStrResult, SourceResult};
+use crate::engine::{Engine, Route, Sink, Traced};
+use crate::foundations::{
+ cast, elem, func, scope, select_where, ty, Args, Array, Construct, Content, Context,
+ Element, Func, IntoValue, Label, LocatableSelector, NativeElement, Packed, Repr,
+ Selector, Show, Smart, Str, StyleChain, Value,
+};
+use crate::introspection::{Introspector, Locatable, Location, Tag};
+use crate::layout::{Frame, FrameItem, PageElem};
+use crate::math::EquationElem;
+use crate::model::{FigureElem, FootnoteElem, HeadingElem, Numbering, NumberingPattern};
+use crate::routines::Routines;
+use crate::World;
+
+/// 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.
+///
+/// Since counters change throughout the course of the document, their current
+/// value is _contextual._ It is recommended to read the chapter on [context]
+/// before continuing here.
+///
+/// # Accessing a counter { #accessing }
+/// To access the raw value of a counter, we can use the [`get`]($counter.get)
+/// function. This function returns an [array]: Counters can have multiple
+/// levels (in the case of headings for sections, subsections, and so on), and
+/// each item in the array corresponds to one level.
+///
+/// ```example
+/// #set heading(numbering: "1.")
+///
+/// = Introduction
+/// Raw value of heading counter is
+/// #context counter(heading).get()
+/// ```
+///
+/// # Displaying a counter { #displaying }
+/// Often, we want to display the value of a counter in a more human-readable
+/// way. To do that, we can call the [`display`]($counter.display) function on
+/// the counter. This function retrieves the current counter value and formats
+/// it either with a provided or with an automatically inferred [numbering].
+///
+/// ```example
+/// #set heading(numbering: "1.")
+///
+/// = Introduction
+/// Some text here.
+///
+/// = Background
+/// The current value is: #context {
+/// counter(heading).display()
+/// }
+///
+/// Or in roman numerals: #context {
+/// 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 , it 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 an array for multiple levels). For
+/// more flexibility, you can instead also give it a function that receives
+/// 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 #context {
+/// counter(heading).display()
+/// }
+/// ```
+///
+/// # 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.
+/// ```
+///
+/// # 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")
+/// #context mine.display() \
+/// #mine.step()
+/// #context mine.display() \
+/// #mine.update(c => c * 3)
+/// #context mine.display()
+/// ```
+///
+/// # 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 #context 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).
+///
+/// # 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
+/// #context [
+/// Value here: #mine.get() \
+/// At intro: #mine.at(<intro>) \
+/// Final value: #mine.final()
+/// ]
+///
+/// #mine.update(n => n + 3)
+///
+/// = Introduction <intro>
+/// #lorem(10)
+///
+/// #mine.step()
+/// #mine.step()
+/// ```
+///
+/// # Other kinds of state { #other-state }
+/// The `counter` type is closely related to [state] type. Read its
+/// documentation for more details on state management in Typst and why it
+/// doesn't just use normal variables for counters.
+#[ty(scope)]
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub struct Counter(CounterKey);
+
+impl Counter {
+ /// Create a new counter identified by a key.
+ pub fn new(key: CounterKey) -> Counter {
+ Self(key)
+ }
+
+ /// The counter for the given element.
+ pub fn of(func: Element) -> Self {
+ Self::new(CounterKey::Selector(Selector::Elem(func, None)))
+ }
+
+ /// Gets the current and final value of the state combined in one state.
+ pub fn both(
+ &self,
+ engine: &mut Engine,
+ location: Location,
+ ) -> SourceResult<CounterState> {
+ let sequence = self.sequence(engine)?;
+ let offset = engine.introspector.query_count_before(&self.selector(), location);
+ 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 =
+ engine.introspector.page(location).get().saturating_sub(at_page.get());
+ at_state.step(NonZeroUsize::ONE, at_delta);
+ let final_delta =
+ engine.introspector.pages().get().saturating_sub(final_page.get());
+ final_state.step(NonZeroUsize::ONE, final_delta);
+ }
+ Ok(CounterState(smallvec![at_state.first(), final_state.first()]))
+ }
+
+ /// Gets the value of the counter at the given location. Always returns an
+ /// array of integers, even if the counter has just one number.
+ pub fn at_loc(
+ &self,
+ engine: &mut Engine,
+ location: Location,
+ ) -> SourceResult<CounterState> {
+ let sequence = self.sequence(engine)?;
+ let offset = engine.introspector.query_count_before(&self.selector(), location);
+ let (mut state, page) = sequence[offset].clone();
+ if self.is_page() {
+ let delta =
+ engine.introspector.page(location).get().saturating_sub(page.get());
+ state.step(NonZeroUsize::ONE, delta);
+ }
+ Ok(state)
+ }
+
+ /// Displays the value of the counter at the given location.
+ pub fn display_at_loc(
+ &self,
+ engine: &mut Engine,
+ loc: Location,
+ styles: StyleChain,
+ numbering: &Numbering,
+ ) -> SourceResult<Content> {
+ let context = Context::new(Some(loc), Some(styles));
+ Ok(self
+ .at_loc(engine, loc)?
+ .display(engine, context.track(), numbering)?
+ .display())
+ }
+
+ /// 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,
+ engine: &mut Engine,
+ ) -> SourceResult<EcoVec<(CounterState, NonZeroUsize)>> {
+ self.sequence_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ )
+ }
+
+ /// Memoized implementation of `sequence`.
+ #[comemo::memoize]
+ fn sequence_impl(
+ &self,
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ ) -> SourceResult<EcoVec<(CounterState, NonZeroUsize)>> {
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route).unnested(),
+ };
+
+ let mut state = CounterState::init(matches!(self.0, CounterKey::Page));
+ 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.with::<dyn Count>() {
+ Some(countable) => countable.update(),
+ None => Some(CounterUpdate::Step(NonZeroUsize::ONE)),
+ } {
+ state.update(&mut engine, update)?;
+ }
+
+ stops.push((state.clone(), page));
+ }
+
+ Ok(stops)
+ }
+
+ /// The selector relevant for this counter's updates.
+ fn selector(&self) -> Selector {
+ let mut selector = select_where!(CounterUpdateElem, Key => self.0.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
+ }
+
+ /// Shared implementation of displaying between `counter.display` and
+ /// `DisplayElem`, which will be deprecated.
+ fn display_impl(
+ &self,
+ engine: &mut Engine,
+ location: Location,
+ numbering: Smart<Numbering>,
+ both: bool,
+ styles: Option<StyleChain>,
+ ) -> SourceResult<Value> {
+ let numbering = numbering
+ .custom()
+ .or_else(|| {
+ let styles = styles?;
+ let CounterKey::Selector(Selector::Elem(func, _)) = self.0 else {
+ return None;
+ };
+
+ if func == HeadingElem::elem() {
+ HeadingElem::numbering_in(styles).clone()
+ } else if func == FigureElem::elem() {
+ FigureElem::numbering_in(styles).clone()
+ } else if func == EquationElem::elem() {
+ EquationElem::numbering_in(styles).clone()
+ } else if func == FootnoteElem::elem() {
+ Some(FootnoteElem::numbering_in(styles).clone())
+ } else {
+ None
+ }
+ })
+ .unwrap_or_else(|| NumberingPattern::from_str("1.1").unwrap().into());
+
+ let state = if both {
+ self.both(engine, location)?
+ } else {
+ self.at_loc(engine, location)?
+ };
+
+ let context = Context::new(Some(location), styles);
+ state.display(engine, context.track(), &numbering)
+ }
+}
+
+#[scope]
+impl Counter {
+ /// Create a new counter identified by a key.
+ #[func(constructor)]
+ pub fn construct(
+ /// The key that identifies this counter.
+ ///
+ /// - If it is a string, creates a custom counter that is only affected
+ /// by manual updates,
+ /// - If it is the [`page`] function, counts through pages,
+ /// - If it is a [selector], counts through elements that matches with the
+ /// selector. For example,
+ /// - provide an element function: counts elements of that type,
+ /// - provide a [`{<label>}`]($label): counts elements with that label.
+ key: CounterKey,
+ ) -> Counter {
+ Self::new(key)
+ }
+
+ /// Retrieves the value of the counter at the current location. Always
+ /// returns an array of integers, even if the counter has just one number.
+ ///
+ /// This is equivalent to `{counter.at(here())}`.
+ #[func(contextual)]
+ pub fn get(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ ) -> SourceResult<CounterState> {
+ let loc = context.location().at(span)?;
+ self.at_loc(engine, loc)
+ }
+
+ /// Displays the current value of the counter with a numbering and returns
+ /// the formatted output.
+ ///
+ /// _Compatibility:_ For compatibility with Typst 0.10 and lower, this
+ /// function also works without an established context. Then, it will create
+ /// opaque contextual content rather than directly returning the output of
+ /// the numbering. This behaviour will be removed in a future release.
+ #[func(contextual)]
+ pub fn display(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The call span of the display.
+ span: Span,
+ /// A [numbering pattern or a function]($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]($arguments).
+ ///
+ /// If this is omitted or set to `{auto}`, displays the counter with the
+ /// numbering style for the counted element or with the pattern
+ /// `{"1.1"}` if no such style exists.
+ #[default]
+ numbering: Smart<Numbering>,
+ /// 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.
+ #[named]
+ #[default(false)]
+ both: bool,
+ ) -> SourceResult<Value> {
+ if let Ok(loc) = context.location() {
+ self.display_impl(engine, loc, numbering, both, context.styles().ok())
+ } else {
+ engine.sink.warn(warning!(
+ span, "`counter.display` without context is deprecated";
+ hint: "use it in a `context` expression instead"
+ ));
+
+ Ok(CounterDisplayElem::new(self, numbering, both)
+ .pack()
+ .spanned(span)
+ .into_value())
+ }
+ }
+
+ /// Retrieves the value of the counter at the given location. Always returns
+ /// an array of integers, even if the counter has just one number.
+ ///
+ /// The `selector` must match exactly one element in the document. The most
+ /// useful kinds of selectors for this are [labels]($label) and
+ /// [locations]($location).
+ ///
+ /// _Compatibility:_ For compatibility with Typst 0.10 and lower, this
+ /// function also works without a known context if the `selector` is a
+ /// location. This behaviour will be removed in a future release.
+ #[func(contextual)]
+ pub fn at(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ /// The place at which the counter's value should be retrieved.
+ selector: LocatableSelector,
+ ) -> SourceResult<CounterState> {
+ let loc = selector.resolve_unique(engine.introspector, context).at(span)?;
+ self.at_loc(engine, loc)
+ }
+
+ /// Retrieves 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.
+ #[func(contextual)]
+ pub fn final_(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ /// _Compatibility:_ This argument is deprecated. It only exists for
+ /// compatibility with Typst 0.10 and lower and shouldn't be used
+ /// anymore.
+ #[default]
+ location: Option<Location>,
+ ) -> SourceResult<CounterState> {
+ if location.is_none() {
+ context.location().at(span)?;
+ } else {
+ engine.sink.warn(warning!(
+ span, "calling `counter.final` with a location is deprecated";
+ hint: "try removing the location argument"
+ ));
+ }
+
+ let sequence = self.sequence(engine)?;
+ let (mut state, page) = sequence.last().unwrap().clone();
+ if self.is_page() {
+ let delta = engine.introspector.pages().get().saturating_sub(page.get());
+ state.step(NonZeroUsize::ONE, delta);
+ }
+ Ok(state)
+ }
+
+ /// 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.
+ #[func]
+ pub fn step(
+ self,
+ /// The call span of the update.
+ span: Span,
+ /// The depth at which to step the counter. Defaults to `{1}`.
+ #[named]
+ #[default(NonZeroUsize::ONE)]
+ level: NonZeroUsize,
+ ) -> Content {
+ self.update(span, CounterUpdate::Step(level))
+ }
+
+ /// Updates the value of the counter.
+ ///
+ /// Just like with `step`, the update only occurs if you put the resulting
+ /// content into the document.
+ #[func]
+ pub fn update(
+ self,
+ /// The call span of the update.
+ span: Span,
+ /// 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).
+ update: CounterUpdate,
+ ) -> Content {
+ CounterUpdateElem::new(self.0, update).pack().spanned(span)
+ }
+}
+
+impl Repr for Counter {
+ fn repr(&self) -> EcoString {
+ eco_format!("counter({})", self.0.repr())
+ }
+}
+
+/// Identifies a counter.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum CounterKey {
+ /// The page counter.
+ Page,
+ /// Counts elements matching the given selectors. Only works for
+ /// [locatable]($location/#locatable)
+ /// elements or labels.
+ Selector(Selector),
+ /// Counts through manual counters with the same key.
+ Str(Str),
+}
+
+cast! {
+ CounterKey,
+ self => match self {
+ Self::Page => PageElem::elem().into_value(),
+ Self::Selector(v) => v.into_value(),
+ Self::Str(v) => v.into_value(),
+ },
+ v: Str => Self::Str(v),
+ v: Label => Self::Selector(Selector::Label(v)),
+ v: Element => {
+ if v == PageElem::elem() {
+ Self::Page
+ } else {
+ Self::Selector(LocatableSelector::from_value(v.into_value())?.0)
+ }
+ },
+ v: LocatableSelector => Self::Selector(v.0),
+}
+
+impl Repr for CounterKey {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Page => "page".into(),
+ Self::Selector(selector) => selector.repr(),
+ Self::Str(str) => str.repr(),
+ }
+ }
+}
+
+/// An update to perform on a counter.
+#[derive(Debug, 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),
+}
+
+cast! {
+ CounterUpdate,
+ 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 {
+ /// Get the initial counter state for the key.
+ pub fn init(page: bool) -> Self {
+ // Special case, because pages always start at one.
+ Self(smallvec![usize::from(page)])
+ }
+
+ /// Advance the counter and return the numbers for the given heading.
+ pub fn update(
+ &mut self,
+ engine: &mut Engine,
+ update: CounterUpdate,
+ ) -> SourceResult<()> {
+ match update {
+ CounterUpdate::Set(state) => *self = state,
+ CounterUpdate::Step(level) => self.step(level, 1),
+ CounterUpdate::Func(func) => {
+ *self = func
+ .call(engine, Context::none().track(), 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();
+
+ while self.0.len() < level {
+ self.0.push(0);
+ }
+
+ self.0[level - 1] = self.0[level - 1].saturating_add(by);
+ self.0.truncate(level);
+ }
+
+ /// 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,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ numbering: &Numbering,
+ ) -> SourceResult<Value> {
+ numbering.apply(engine, context, &self.0)
+ }
+}
+
+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::<HintedStrResult<_>>()?),
+}
+
+/// Executes an update of a counter.
+#[elem(Construct, Locatable, Show, Count)]
+struct CounterUpdateElem {
+ /// The key that identifies the counter.
+ #[required]
+ key: CounterKey,
+
+ /// The update to perform on the counter.
+ #[required]
+ #[internal]
+ update: CounterUpdate,
+}
+
+impl Construct for CounterUpdateElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl Show for Packed<CounterUpdateElem> {
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(Content::empty())
+ }
+}
+
+impl Count for Packed<CounterUpdateElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ Some(self.update.clone())
+ }
+}
+
+/// Executes a display of a counter.
+///
+/// **Deprecation planned.**
+#[elem(Construct, Locatable, Show)]
+pub struct CounterDisplayElem {
+ /// The counter.
+ #[required]
+ #[internal]
+ counter: Counter,
+
+ /// The numbering to display the counter with.
+ #[required]
+ #[internal]
+ numbering: Smart<Numbering>,
+
+ /// Whether to display both the current and final value.
+ #[required]
+ #[internal]
+ both: bool,
+}
+
+impl Construct for CounterDisplayElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl Show for Packed<CounterDisplayElem> {
+ #[typst_macros::time(name = "counter.display", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self
+ .counter
+ .display_impl(
+ engine,
+ self.location().unwrap(),
+ self.numbering.clone(),
+ self.both,
+ Some(styles),
+ )?
+ .display())
+ }
+}
+
+/// An specialized handler of the page counter that tracks both the physical
+/// and the logical page counter.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct ManualPageCounter {
+ physical: NonZeroUsize,
+ logical: usize,
+}
+
+impl ManualPageCounter {
+ /// Create a new fast page counter, starting at 1.
+ pub fn new() -> Self {
+ Self { physical: NonZeroUsize::ONE, logical: 1 }
+ }
+
+ /// Get the current physical page counter state.
+ pub fn physical(&self) -> NonZeroUsize {
+ self.physical
+ }
+
+ /// Get the current logical page counter state.
+ pub fn logical(&self) -> usize {
+ self.logical
+ }
+
+ /// Advance past a page.
+ pub fn visit(&mut self, engine: &mut Engine, page: &Frame) -> SourceResult<()> {
+ for (_, item) in page.items() {
+ match item {
+ FrameItem::Group(group) => self.visit(engine, &group.frame)?,
+ FrameItem::Tag(Tag::Start(elem)) => {
+ let Some(elem) = elem.to_packed::<CounterUpdateElem>() else {
+ continue;
+ };
+ if *elem.key() == CounterKey::Page {
+ let mut state = CounterState(smallvec![self.logical]);
+ state.update(engine, elem.update.clone())?;
+ self.logical = state.first();
+ }
+ }
+ _ => {}
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Step past a page _boundary._
+ pub fn step(&mut self) {
+ self.physical = self.physical.saturating_add(1);
+ self.logical += 1;
+ }
+}
+
+impl Default for ManualPageCounter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/crates/typst-library/src/introspection/here.rs b/crates/typst-library/src/introspection/here.rs
new file mode 100644
index 00000000..9d613381
--- /dev/null
+++ b/crates/typst-library/src/introspection/here.rs
@@ -0,0 +1,52 @@
+use comemo::Tracked;
+
+use crate::diag::HintedStrResult;
+use crate::foundations::{func, Context};
+use crate::introspection::Location;
+
+/// Provides the current location in the document.
+///
+/// You can think of `here` as a low-level building block that directly extracts
+/// the current location from the active [context]. Some other functions use it
+/// internally: For instance, `{counter.get()}` is equivalent to
+/// `{counter.at(here())}`.
+///
+/// Within show rules on [locatable]($location/#locatable) elements, `{here()}`
+/// will match the location of the shown element.
+///
+/// If you want to display the current page number, refer to the documentation
+/// of the [`counter`] type. While `here` can be used to determine the physical
+/// page number, typically you want the logical page number that may, for
+/// instance, have been reset after a preface.
+///
+/// # Examples
+/// Determining the current position in the document in combination with the
+/// [`position`]($location.position) method:
+/// ```example
+/// #context [
+/// I am located at
+/// #here().position()
+/// ]
+/// ```
+///
+/// Running a [query] for elements before the current position:
+/// ```example
+/// = Introduction
+/// = Background
+///
+/// There are
+/// #context query(
+/// selector(heading).before(here())
+/// ).len()
+/// headings before me.
+///
+/// = Conclusion
+/// ```
+/// Refer to the [`selector`] type for more details on before/after selectors.
+#[func(contextual)]
+pub fn here(
+ /// The callsite context.
+ context: Tracked<Context>,
+) -> HintedStrResult<Location> {
+ context.location()
+}
diff --git a/crates/typst-library/src/introspection/introspector.rs b/crates/typst-library/src/introspection/introspector.rs
new file mode 100644
index 00000000..f4eaea30
--- /dev/null
+++ b/crates/typst-library/src/introspection/introspector.rs
@@ -0,0 +1,453 @@
+use std::collections::{BTreeSet, HashMap, HashSet};
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+use std::num::NonZeroUsize;
+use std::sync::RwLock;
+
+use ecow::EcoVec;
+use smallvec::SmallVec;
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{Content, Label, Repr, Selector};
+use crate::introspection::{Location, Tag};
+use crate::layout::{Frame, FrameItem, Page, Point, Position, Transform};
+use crate::model::Numbering;
+
+/// Can be queried for elements and their positions.
+#[derive(Default, Clone)]
+pub struct Introspector {
+ /// The number of pages in the document.
+ pages: usize,
+ /// The page numberings, indexed by page number minus 1.
+ page_numberings: Vec<Option<Numbering>>,
+
+ /// All introspectable elements.
+ elems: Vec<Pair>,
+ /// Lists all elements with a specific hash key. This is used for
+ /// introspector-assisted location assignment during measurement.
+ keys: MultiMap<u128, Location>,
+
+ /// Accelerates lookup of elements by location.
+ locations: HashMap<Location, usize>,
+ /// Accelerates lookup of elements by label.
+ labels: MultiMap<Label, usize>,
+
+ /// Caches queries done on the introspector. This is important because
+ /// even if all top-level queries are distinct, they often have shared
+ /// subqueries. Example: Individual counter queries with `before` that
+ /// all depend on a global counter query.
+ queries: QueryCache,
+}
+
+/// A pair of content and its position.
+type Pair = (Content, Position);
+
+impl Introspector {
+ /// Creates an introspector for a page list.
+ #[typst_macros::time(name = "introspect")]
+ pub fn new(pages: &[Page]) -> Self {
+ IntrospectorBuilder::new().build(pages)
+ }
+
+ /// Iterates over all locatable elements.
+ pub fn all(&self) -> impl Iterator<Item = &Content> + '_ {
+ self.elems.iter().map(|(c, _)| c)
+ }
+
+ /// Retrieves the element with the given index.
+ #[track_caller]
+ fn get_by_idx(&self, idx: usize) -> &Content {
+ &self.elems[idx].0
+ }
+
+ /// Retrieves the position of the element with the given index.
+ #[track_caller]
+ fn get_pos_by_idx(&self, idx: usize) -> Position {
+ self.elems[idx].1
+ }
+
+ /// Retrieves an element by its location.
+ fn get_by_loc(&self, location: &Location) -> Option<&Content> {
+ self.locations.get(location).map(|&idx| self.get_by_idx(idx))
+ }
+
+ /// Retrieves the position of the element with the given index.
+ fn get_pos_by_loc(&self, location: &Location) -> Option<Position> {
+ self.locations.get(location).map(|&idx| self.get_pos_by_idx(idx))
+ }
+
+ /// Performs a binary search for `elem` among the `list`.
+ fn binary_search(&self, list: &[Content], elem: &Content) -> Result<usize, usize> {
+ list.binary_search_by_key(&self.elem_index(elem), |elem| self.elem_index(elem))
+ }
+
+ /// Gets the index of this element.
+ fn elem_index(&self, elem: &Content) -> usize {
+ self.loc_index(&elem.location().unwrap())
+ }
+
+ /// Gets the index of the element with this location among all.
+ fn loc_index(&self, location: &Location) -> usize {
+ self.locations.get(location).copied().unwrap_or(usize::MAX)
+ }
+}
+
+#[comemo::track]
+impl Introspector {
+ /// Query for all matching elements.
+ pub fn query(&self, selector: &Selector) -> EcoVec<Content> {
+ let hash = typst_utils::hash128(selector);
+ if let Some(output) = self.queries.get(hash) {
+ return output;
+ }
+
+ let output = match selector {
+ Selector::Elem(..) => self
+ .all()
+ .filter(|elem| selector.matches(elem, None))
+ .cloned()
+ .collect(),
+ Selector::Location(location) => {
+ self.get_by_loc(location).cloned().into_iter().collect()
+ }
+ Selector::Label(label) => self
+ .labels
+ .get(label)
+ .iter()
+ .map(|&idx| self.get_by_idx(idx).clone())
+ .collect(),
+ Selector::Or(selectors) => selectors
+ .iter()
+ .flat_map(|sel| self.query(sel))
+ .map(|elem| self.elem_index(&elem))
+ .collect::<BTreeSet<usize>>()
+ .into_iter()
+ .map(|idx| self.get_by_idx(idx).clone())
+ .collect(),
+ Selector::And(selectors) => {
+ let mut results: Vec<_> =
+ selectors.iter().map(|sel| self.query(sel)).collect();
+
+ // Extract the smallest result list and then keep only those
+ // elements in the smallest list that are also in all other
+ // lists.
+ results
+ .iter()
+ .enumerate()
+ .min_by_key(|(_, vec)| vec.len())
+ .map(|(i, _)| i)
+ .map(|i| results.swap_remove(i))
+ .iter()
+ .flatten()
+ .filter(|candidate| {
+ results
+ .iter()
+ .all(|other| self.binary_search(other, candidate).is_ok())
+ })
+ .cloned()
+ .collect()
+ }
+ Selector::Before { selector, end, inclusive } => {
+ let mut list = self.query(selector);
+ if let Some(end) = self.query_first(end) {
+ // Determine which elements are before `end`.
+ let split = match self.binary_search(&list, &end) {
+ // Element itself is contained.
+ Ok(i) => i + *inclusive as usize,
+ // Element itself is not contained.
+ Err(i) => i,
+ };
+ list = list[..split].into();
+ }
+ list
+ }
+ Selector::After { selector, start, inclusive } => {
+ let mut list = self.query(selector);
+ if let Some(start) = self.query_first(start) {
+ // Determine which elements are after `start`.
+ let split = match self.binary_search(&list, &start) {
+ // Element itself is contained.
+ Ok(i) => i + !*inclusive as usize,
+ // Element itself is not contained.
+ Err(i) => i,
+ };
+ list = list[split..].into();
+ }
+ list
+ }
+ // Not supported here.
+ Selector::Can(_) | Selector::Regex(_) => EcoVec::new(),
+ };
+
+ self.queries.insert(hash, output.clone());
+ output
+ }
+
+ /// Query for the first element that matches the selector.
+ pub fn query_first(&self, selector: &Selector) -> Option<Content> {
+ match selector {
+ Selector::Location(location) => self.get_by_loc(location).cloned(),
+ Selector::Label(label) => self
+ .labels
+ .get(label)
+ .first()
+ .map(|&idx| self.get_by_idx(idx).clone()),
+ _ => self.query(selector).first().cloned(),
+ }
+ }
+
+ /// Query for the first element that matches the selector.
+ pub fn query_unique(&self, selector: &Selector) -> StrResult<Content> {
+ match selector {
+ Selector::Location(location) => self
+ .get_by_loc(location)
+ .cloned()
+ .ok_or_else(|| "element does not exist in the document".into()),
+ Selector::Label(label) => self.query_label(*label).cloned(),
+ _ => {
+ let elems = self.query(selector);
+ if elems.len() > 1 {
+ bail!("selector matches multiple elements",);
+ }
+ elems
+ .into_iter()
+ .next()
+ .ok_or_else(|| "selector does not match any element".into())
+ }
+ }
+ }
+
+ /// Query for a unique element with the label.
+ pub fn query_label(&self, label: Label) -> StrResult<&Content> {
+ match *self.labels.get(&label) {
+ [idx] => Ok(self.get_by_idx(idx)),
+ [] => bail!("label `{}` does not exist in the document", label.repr()),
+ _ => bail!("label `{}` occurs multiple times in the document", label.repr()),
+ }
+ }
+
+ /// This is an optimized version of
+ /// `query(selector.before(end, true).len()` used by counters and state.
+ pub fn query_count_before(&self, selector: &Selector, end: Location) -> usize {
+ // See `query()` for details.
+ let list = self.query(selector);
+ if let Some(end) = self.get_by_loc(&end) {
+ match self.binary_search(&list, end) {
+ Ok(i) => i + 1,
+ Err(i) => i,
+ }
+ } else {
+ list.len()
+ }
+ }
+
+ /// The total number pages.
+ pub fn pages(&self) -> NonZeroUsize {
+ NonZeroUsize::new(self.pages).unwrap_or(NonZeroUsize::ONE)
+ }
+
+ /// Find the page number for the given location.
+ pub fn page(&self, location: Location) -> NonZeroUsize {
+ self.position(location).page
+ }
+
+ /// Find the position for the given location.
+ pub fn position(&self, location: Location) -> Position {
+ self.get_pos_by_loc(&location)
+ .unwrap_or(Position { page: NonZeroUsize::ONE, point: Point::zero() })
+ }
+
+ /// Gets the page numbering for the given location, if any.
+ pub fn page_numbering(&self, location: Location) -> Option<&Numbering> {
+ let page = self.page(location);
+ self.page_numberings
+ .get(page.get() - 1)
+ .and_then(|slot| slot.as_ref())
+ }
+
+ /// Try to find a location for an element with the given `key` hash
+ /// that is closest after the `anchor`.
+ ///
+ /// This is used for introspector-assisted location assignment during
+ /// measurement. See the "Dealing with Measurement" section of the
+ /// [`Locator`](crate::introspection::Locator) docs for more details.
+ pub fn locator(&self, key: u128, anchor: Location) -> Option<Location> {
+ let anchor = self.loc_index(&anchor);
+ self.keys
+ .get(&key)
+ .iter()
+ .copied()
+ .min_by_key(|loc| self.loc_index(loc).wrapping_sub(anchor))
+ }
+}
+
+impl Debug for Introspector {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("Introspector(..)")
+ }
+}
+
+/// A map from one keys to multiple elements.
+#[derive(Clone)]
+struct MultiMap<K, V>(HashMap<K, SmallVec<[V; 1]>>);
+
+impl<K, V> MultiMap<K, V>
+where
+ K: Hash + Eq,
+{
+ fn get(&self, key: &K) -> &[V] {
+ self.0.get(key).map_or(&[], |vec| vec.as_slice())
+ }
+
+ fn insert(&mut self, key: K, value: V) {
+ self.0.entry(key).or_default().push(value);
+ }
+
+ fn take(&mut self, key: &K) -> Option<impl Iterator<Item = V>> {
+ self.0.remove(key).map(|vec| vec.into_iter())
+ }
+}
+
+impl<K, V> Default for MultiMap<K, V> {
+ fn default() -> Self {
+ Self(HashMap::new())
+ }
+}
+
+/// Caches queries.
+#[derive(Default)]
+struct QueryCache(RwLock<HashMap<u128, EcoVec<Content>>>);
+
+impl QueryCache {
+ fn get(&self, hash: u128) -> Option<EcoVec<Content>> {
+ self.0.read().unwrap().get(&hash).cloned()
+ }
+
+ fn insert(&self, hash: u128, output: EcoVec<Content>) {
+ self.0.write().unwrap().insert(hash, output);
+ }
+}
+
+impl Clone for QueryCache {
+ fn clone(&self) -> Self {
+ Self(RwLock::new(self.0.read().unwrap().clone()))
+ }
+}
+
+/// Builds the introspector.
+#[derive(Default)]
+struct IntrospectorBuilder {
+ page_numberings: Vec<Option<Numbering>>,
+ seen: HashSet<Location>,
+ insertions: MultiMap<Location, Vec<Pair>>,
+ keys: MultiMap<u128, Location>,
+ locations: HashMap<Location, usize>,
+ labels: MultiMap<Label, usize>,
+}
+
+impl IntrospectorBuilder {
+ /// Create an empty builder.
+ fn new() -> Self {
+ Self::default()
+ }
+
+ /// Build the introspector.
+ fn build(mut self, pages: &[Page]) -> Introspector {
+ self.page_numberings.reserve(pages.len());
+
+ // Discover all elements.
+ let mut root = Vec::new();
+ for (i, page) in pages.iter().enumerate() {
+ self.page_numberings.push(page.numbering.clone());
+ self.discover(
+ &mut root,
+ &page.frame,
+ NonZeroUsize::new(1 + i).unwrap(),
+ Transform::identity(),
+ );
+ }
+
+ self.locations.reserve(self.seen.len());
+
+ // Save all pairs and their descendants in the correct order.
+ let mut elems = Vec::with_capacity(self.seen.len());
+ for pair in root {
+ self.visit(&mut elems, pair);
+ }
+
+ Introspector {
+ pages: pages.len(),
+ page_numberings: self.page_numberings,
+ elems,
+ keys: self.keys,
+ locations: self.locations,
+ labels: self.labels,
+ queries: QueryCache::default(),
+ }
+ }
+
+ /// Processes the tags in the frame.
+ fn discover(
+ &mut self,
+ sink: &mut Vec<Pair>,
+ frame: &Frame,
+ page: NonZeroUsize,
+ ts: Transform,
+ ) {
+ for (pos, item) in frame.items() {
+ match item {
+ FrameItem::Group(group) => {
+ let ts = ts
+ .pre_concat(Transform::translate(pos.x, pos.y))
+ .pre_concat(group.transform);
+
+ if let Some(parent) = group.parent {
+ let mut nested = vec![];
+ self.discover(&mut nested, &group.frame, page, ts);
+ self.insertions.insert(parent, nested);
+ } else {
+ self.discover(sink, &group.frame, page, ts);
+ }
+ }
+ FrameItem::Tag(Tag::Start(elem)) => {
+ let loc = elem.location().unwrap();
+ if self.seen.insert(loc) {
+ let point = pos.transform(ts);
+ sink.push((elem.clone(), Position { page, point }));
+ }
+ }
+ FrameItem::Tag(Tag::End(loc, key)) => {
+ self.keys.insert(*key, *loc);
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Saves a pair and all its descendants into `elems` and populates the
+ /// acceleration structures.
+ fn visit(&mut self, elems: &mut Vec<Pair>, pair: Pair) {
+ let elem = &pair.0;
+ let loc = elem.location().unwrap();
+ let idx = elems.len();
+
+ // Populate the location acceleration map.
+ self.locations.insert(loc, idx);
+
+ // Populate the label acceleration map.
+ if let Some(label) = elem.label() {
+ self.labels.insert(label, idx);
+ }
+
+ // Save the element.
+ elems.push(pair);
+
+ // Process potential descendants.
+ if let Some(insertions) = self.insertions.take(&loc) {
+ for pair in insertions.flatten() {
+ self.visit(elems, pair);
+ }
+ }
+ }
+}
diff --git a/crates/typst-library/src/introspection/locate.rs b/crates/typst-library/src/introspection/locate.rs
new file mode 100644
index 00000000..9a0e28e2
--- /dev/null
+++ b/crates/typst-library/src/introspection/locate.rs
@@ -0,0 +1,119 @@
+use comemo::{Track, Tracked};
+use typst_syntax::Span;
+
+use crate::diag::{warning, HintedStrResult, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, func, Content, Context, Func, LocatableSelector, NativeElement, Packed,
+ Show, StyleChain, Value,
+};
+use crate::introspection::{Locatable, Location};
+
+/// Determines the location of an element in the document.
+///
+/// Takes a selector that must match exactly one element and returns that
+/// element's [`location`]. This location can, in particular, be used to
+/// retrieve the physical [`page`]($location.page) number and
+/// [`position`]($location.position) (page, x, y) for that element.
+///
+/// # Examples
+/// Locating a specific element:
+/// ```example
+/// #context [
+/// Introduction is at: \
+/// #locate(<intro>).position()
+/// ]
+///
+/// = Introduction <intro>
+/// ```
+///
+/// # Compatibility
+/// In Typst 0.10 and lower, the `locate` function took a closure that made the
+/// current location in the document available (like [`here`] does now). This
+/// usage pattern is deprecated. Compatibility with the old way will remain for
+/// a while to give package authors time to upgrade. To that effect, `locate`
+/// detects whether it received a selector or a user-defined function and
+/// adjusts its semantics accordingly. This behaviour will be removed in the
+/// future.
+#[func(contextual)]
+pub fn locate(
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The span of the `locate` call.
+ span: Span,
+ /// A selector that should match exactly one element. This element will be
+ /// located.
+ ///
+ /// Especially useful in combination with
+ /// - [`here`] to locate the current context,
+ /// - a [`location`] retrieved from some queried element via the
+ /// [`location()`]($content.location) method on content.
+ selector: LocateInput,
+) -> HintedStrResult<LocateOutput> {
+ Ok(match selector {
+ LocateInput::Selector(selector) => {
+ LocateOutput::Location(selector.resolve_unique(engine.introspector, context)?)
+ }
+ LocateInput::Func(func) => {
+ engine.sink.warn(warning!(
+ span, "`locate` with callback function is deprecated";
+ hint: "use a `context` expression instead"
+ ));
+
+ LocateOutput::Content(LocateElem::new(func).pack().spanned(span))
+ }
+ })
+}
+
+/// Compatible input type.
+pub enum LocateInput {
+ Selector(LocatableSelector),
+ Func(Func),
+}
+
+cast! {
+ LocateInput,
+ v: Func => {
+ if v.element().is_some() {
+ Self::Selector(Value::Func(v).cast()?)
+ } else {
+ Self::Func(v)
+ }
+ },
+ v: LocatableSelector => Self::Selector(v),
+}
+
+/// Compatible output type.
+pub enum LocateOutput {
+ Location(Location),
+ Content(Content),
+}
+
+cast! {
+ LocateOutput,
+ self => match self {
+ Self::Location(v) => v.into_value(),
+ Self::Content(v) => v.into_value(),
+ },
+ v: Location => Self::Location(v),
+ v: Content => Self::Content(v),
+}
+
+/// Executes a `locate` call.
+#[elem(Locatable, Show)]
+struct LocateElem {
+ /// The function to call with the location.
+ #[required]
+ func: Func,
+}
+
+impl Show for Packed<LocateElem> {
+ #[typst_macros::time(name = "locate", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let location = self.location().unwrap();
+ let context = Context::new(Some(location), Some(styles));
+ Ok(self.func().call(engine, context.track(), [location])?.display())
+ }
+}
diff --git a/crates/typst-library/src/introspection/location.rs b/crates/typst-library/src/introspection/location.rs
new file mode 100644
index 00000000..8f4caaec
--- /dev/null
+++ b/crates/typst-library/src/introspection/location.rs
@@ -0,0 +1,113 @@
+use std::fmt::{self, Debug, Formatter};
+use std::num::NonZeroUsize;
+
+use ecow::EcoString;
+
+use crate::engine::Engine;
+use crate::foundations::{func, scope, ty, Repr};
+use crate::layout::Position;
+use crate::model::Numbering;
+
+/// Identifies an element in the document.
+///
+/// A location uniquely identifies an element in the document and lets you
+/// access its absolute position on the pages. You can retrieve the current
+/// location with the [`here`] function and the location of a queried or shown
+/// element with the [`location()`]($content.location) method on content.
+///
+/// # Locatable elements { #locatable }
+/// Currently, only a subset of element functions is locatable. Aside from
+/// headings and figures, this includes equations, references, quotes and all
+/// elements with an explicit label. As a result, you _can_ query for e.g.
+/// [`strong`] elements, but you will find only those that have an explicit
+/// label attached to them. This limitation will be resolved in the future.
+#[ty(scope)]
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Location(u128);
+
+impl Location {
+ /// Create a new location from a unique hash.
+ pub fn new(hash: u128) -> Self {
+ Self(hash)
+ }
+
+ /// Extract the raw hash.
+ pub fn hash(self) -> u128 {
+ self.0
+ }
+
+ /// Produces a well-known variant of this location.
+ ///
+ /// This is a synthetic location created from another one and is used, for
+ /// example, in bibliography management to create individual linkable
+ /// locations for reference entries from the bibliography's location.
+ pub fn variant(self, n: usize) -> Self {
+ Self(typst_utils::hash128(&(self.0, n)))
+ }
+}
+
+#[scope]
+impl Location {
+ /// Returns the page number for this location.
+ ///
+ /// Note that this does not return the value of the [page counter]($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.
+ ///
+ /// Can be used with [`here`] to retrieve the physical page position
+ /// of the current context:
+ /// ```example
+ /// #context [
+ /// I am located on
+ /// page #here().page()
+ /// ]
+ /// ```
+ #[func]
+ pub fn page(self, engine: &mut Engine) -> NonZeroUsize {
+ engine.introspector.page(self)
+ }
+
+ /// 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.
+ #[func]
+ pub fn position(self, engine: &mut Engine) -> Position {
+ engine.introspector.position(self)
+ }
+
+ /// 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}`.
+ #[func]
+ pub fn page_numbering(self, engine: &mut Engine) -> Option<Numbering> {
+ engine.introspector.page_numbering(self).cloned()
+ }
+}
+
+impl Debug for Location {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Location({})", self.0)
+ }
+}
+
+impl Repr for Location {
+ fn repr(&self) -> EcoString {
+ "..".into()
+ }
+}
+
+/// Makes this element as locatable through the introspector.
+pub trait Locatable {}
+
+/// Marks this element as not being queryable even though it is locatable for
+/// internal reasons.
+pub trait Unqueriable {}
diff --git a/crates/typst-library/src/introspection/locator.rs b/crates/typst-library/src/introspection/locator.rs
new file mode 100644
index 00000000..4045aa9c
--- /dev/null
+++ b/crates/typst-library/src/introspection/locator.rs
@@ -0,0 +1,350 @@
+use std::collections::HashMap;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+use std::sync::OnceLock;
+
+use comemo::{Tracked, Validate};
+
+use crate::introspection::{Introspector, Location};
+
+/// Provides locations for elements in the document.
+///
+/// A [`Location`] is a unique ID for an element generated during realization.
+///
+/// # How to use this
+/// The same content may yield different results when laid out in different
+/// parts of the document. To reflect this, every layout operation receives a
+/// locator and every layout operation requires a locator. In code:
+///
+/// - all layouters receive an owned `Locator`
+/// - all layout functions take an owned `Locator`
+///
+/// When a layouter only requires a single sublayout call, it can simply pass on
+/// its locator. When a layouter needs to call multiple sublayouters, we need to
+/// make an explicit decision:
+///
+/// - Split: When we're layouting multiple distinct children (or other pieces of
+/// content), we need to split up the locator with [`Locator::split`]. This
+/// allows us to produce multiple new `Locator`s for the sublayouts. When we
+/// split the locator, each sublocator will be a distinct entity and using it
+/// to e.g. layout the same piece of figure content will yield distinctly
+/// numbered figures.
+///
+/// - Relayout: When we're layouting the same content multiple times (e.g. when
+/// measuring something), we can call [`Locator::relayout`] to use the same
+/// locator multiple times. This indicates to the compiler that it's actually
+/// the same content. Using it to e.g. layout the same piece of figure content
+/// will yield the same figure number both times. Typically, when we layout
+/// something multiple times using `relayout`, only one of the outputs
+/// actually ends up in the document, while the other outputs are only used
+/// for measurement and then discarded.
+///
+/// The `Locator` intentionally does not implement `Copy` and `Clone` so that it
+/// can only be used once. This ensures that whenever we are layouting multiple
+/// things, we make an explicit decision whether we want to split or relayout.
+///
+/// # How it works
+/// There are two primary considerations for the assignment of locations:
+///
+/// 1. Locations should match up over multiple layout iterations, so that
+/// elements can be identified as being the same: That's the whole point of
+/// them.
+///
+/// 2. Locations should be as stable as possible across document edits, so that
+/// incremental compilation is effective.
+///
+/// 3. We want to assign them with as little long-lived state as possible to
+/// enable parallelization of the layout process.
+///
+/// Let's look at a few different assignment strategies to get a feeling for
+/// these requirements:
+///
+/// - A very simple way to generate unique IDs would be to just increase a
+/// counter for each element. In this setup, (1) is somewhat satisfied: In
+/// principle, the counter will line up across iterations, but things start to
+/// break down once we generate content dependent on introspection since the
+/// IDs generated for that new content will shift the IDs for all following
+/// elements in the document. (2) is not satisfied since an edit in the middle
+/// of the document shifts all later IDs. (3) is obviously not satisfied.
+/// Conclusion: Not great.
+///
+/// - To make things more robust, we can incorporate some stable knowledge about
+/// the element into the ID. For this, we can use the element's span since it
+/// is already mostly unique: Elements resulting from different source code
+/// locations are guaranteed to have different spans. However, we can also
+/// have multiple distinct elements generated from the same source location:
+/// e.g. `#for _ in range(5) { figure(..) }`. To handle this case, we can then
+/// disambiguate elements with the same span with an increasing counter. In
+/// this setup, (1) is mostly satisfied: Unless we do stuff like generating
+/// colliding counter updates dependent on introspection, things will line up.
+/// (2) is also reasonably well satisfied, as typical edits will only affect
+/// the single element at the currently edited span. Only if we edit inside of
+/// a function, loop, or similar construct, we will affect multiple elements.
+/// (3) is still a problem though, since we count up.
+///
+/// - What's left is to get rid of the mutable state. Note that layout is a
+/// recursive process and has a tree-shaped execution graph. Thus, we can try
+/// to determine an element's ID based on the path of execution taken in this
+/// graph. Something like "3rd element in layer 1, 7th element in layer 2,
+/// ..". This is basically the first approach, but on a per-layer basis. Thus,
+/// we can again apply our trick from the second approach, and use the span +
+/// disambiguation strategy on a per-layer basis: "1st element with span X in
+/// layer 1, 3rd element with span Y in layer 2". The chance for a collision
+/// is now pretty low and our state is wholly local to each level. So, if we
+/// want to parallelize layout within a layer, we can generate the IDs for
+/// that layer upfront and then start forking out. The final remaining
+/// question is how we can compactly encode this information: For this, as
+/// always, we use hashing! We incorporate the ID information from each layer
+/// into a single hash and thanks to the collision resistance of 128-bit
+/// SipHash, we get almost guaranteed unique locations. We don't even store
+/// the full layer information at all, but rather hash _hierarchically:_ Let
+/// `k_x` be our local per-layer ID for layer `x` and `h_x` be the full
+/// combined hash for layer `x`. We compute `h_n = hash(h_(n-1), k_n)`.
+///
+/// So that's what's going on conceptually in this type. For efficient
+/// memoization, we do all of this in a tracked fashion, such that we only
+/// observe the hash for all the layers above us, if we actually need to
+/// generate a [`Location`]. Thus, if we have a piece of content that does not
+/// contain any locatable elements, we can cache its layout even if it occurs in
+/// different places.
+///
+/// # Dealing with measurement
+/// As explained above, any kind of measurement the compiler performs requires a
+/// locator that matches the one used during real layout. This ensures that the
+/// locations assigned during measurement match up exactly with the locations of
+/// real document elements. Without this guarantee, many introspection-driven
+/// features (like counters, state, and citations) don't work correctly (since
+/// they perform queries dependent on concrete locations).
+///
+/// This is all fine and good, but things get really tricky when the _user_
+/// measures such introspecting content since the user isn't kindly managing
+/// locators for us. Our standard `Locator` workflow assigns locations that
+/// depend a lot on the exact placement in the hierarchy of elements. For this
+/// reason, something that is measured, but then placed into something like a
+/// grid will get a location influenced by the grid. Without a locator, we can't
+/// make the connection between the measured content and the real content, so we
+/// can't ensure that the locations match up.
+///
+/// One possible way to deal with this is to force the user to uniquely identify
+/// content before being measured after all. This would mean that the user needs
+/// to come up with an identifier that is unique within the surrounding context
+/// block and attach it to the content in some way. However, after careful
+/// consideration, I have concluded that this is simply too big of an ask from
+/// users: Understanding why this is even necessary is pretty complicated and
+/// how to best come up with a unique ID is even more so.
+///
+/// For this reason, I chose an alternative best-effort approach: The locator
+/// has a custom "measurement mode" (entered through [`LocatorLink::measure`]),
+/// in which it does its best to assign locations that match up. Specifically,
+/// it uses the key hashes of the individual locatable elements in the measured
+/// content (which may not be unique if content is reused) and combines them
+/// with the context's location to find the most likely matching real element.
+/// This approach works correctly almost all of the time (especially for
+/// "normal" hand-written content where the key hashes rarely collide, as
+/// opposed to code-heavy things where they do).
+///
+/// Support for enhancing this with user-provided uniqueness can still be added
+/// in the future. It will most likely anyway be added simply because it's
+/// automatically included when we add a way to "freeze" content for things like
+/// slidehows. But it will be opt-in because it's just too much complication.
+pub struct Locator<'a> {
+ /// A local hash that incorporates all layers since the last memoization
+ /// boundary.
+ local: u128,
+ /// A pointer to an outer cached locator, which contributes the information
+ /// for all the layers beyond the memoization boundary on-demand.
+ outer: Option<&'a LocatorLink<'a>>,
+}
+
+impl<'a> Locator<'a> {
+ /// Create a new root-level locator.
+ ///
+ /// Should typically only be created at the document level, though there
+ /// are a few places where we use it as well that just don't support
+ /// introspection (e.g. drawable patterns).
+ pub fn root() -> Self {
+ Self { local: 0, outer: None }
+ }
+
+ /// Creates a new synthetic locator.
+ ///
+ /// This can be used to create a new dependent layout based on an element.
+ /// This is used for layouting footnote entries based on the location
+ /// of the associated footnote.
+ pub fn synthesize(location: Location) -> Self {
+ Self { local: location.hash(), outer: None }
+ }
+
+ /// Creates a new locator that points to the given link.
+ pub fn link(link: &'a LocatorLink<'a>) -> Self {
+ Self { local: 0, outer: Some(link) }
+ }
+}
+
+impl<'a> Locator<'a> {
+ /// Returns a type that can be used to generate `Locator`s for multiple
+ /// child elements. See the type-level docs for more details.
+ pub fn split(self) -> SplitLocator<'a> {
+ SplitLocator {
+ local: self.local,
+ outer: self.outer,
+ disambiguators: HashMap::new(),
+ }
+ }
+
+ /// Creates a copy of this locator for measurement or relayout of the same
+ /// content. See the type-level docs for more details.
+ ///
+ /// This is effectively just `Clone`, but the `Locator` doesn't implement
+ /// `Clone` to make this operation explicit.
+ pub fn relayout(&self) -> Self {
+ Self { local: self.local, outer: self.outer }
+ }
+}
+
+#[comemo::track]
+impl<'a> Locator<'a> {
+ /// Resolves the locator based on its local and the outer information.
+ fn resolve(&self) -> Resolved {
+ match self.outer {
+ None => Resolved::Hash(self.local),
+ Some(outer) => match outer.resolve() {
+ Resolved::Hash(outer) => {
+ Resolved::Hash(typst_utils::hash128(&(self.local, outer)))
+ }
+ Resolved::Measure(anchor) => Resolved::Measure(anchor),
+ },
+ }
+ }
+}
+
+impl Debug for Locator<'_> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Locator({:?})", self.resolve())
+ }
+}
+
+/// The fully resolved value of a locator.
+#[derive(Debug, Copy, Clone, Hash)]
+enum Resolved {
+ /// The full hash, incorporating the local and all outer information.
+ Hash(u128),
+ /// Indicates that the locator is in measurement mode, with the given anchor
+ /// location.
+ Measure(Location),
+}
+
+/// A type that generates unique sublocators.
+pub struct SplitLocator<'a> {
+ /// A local hash that incorporates all layers since the last memoization
+ /// boundary.
+ local: u128,
+ /// A pointer to an outer cached locator, which contributes the information
+ /// for all the layers beyond the memoization boundary on-demand.
+ outer: Option<&'a LocatorLink<'a>>,
+ /// Simply counts up the number of times we've seen each local hash.
+ disambiguators: HashMap<u128, usize>,
+}
+
+impl<'a> SplitLocator<'a> {
+ /// Produces a sublocator for a subtree keyed by `key`. The keys do *not*
+ /// need to be unique among the `next()` calls on this split locator. (They
+ /// can even all be `&()`.)
+ ///
+ /// However, stable & mostly unique keys lead to more stable locations
+ /// throughout edits, improving incremental compilation performance.
+ ///
+ /// A common choice for a key is the span of the content that will be
+ /// layouted with this locator.
+ pub fn next<K: Hash>(&mut self, key: &K) -> Locator<'a> {
+ self.next_inner(typst_utils::hash128(key))
+ }
+
+ /// Produces a sublocator for a subtree.
+ pub fn next_inner(&mut self, key: u128) -> Locator<'a> {
+ // Produce a locator disambiguator, for elements with the same key
+ // within this `SplitLocator`.
+ let disambiguator = {
+ let slot = self.disambiguators.entry(key).or_default();
+ std::mem::replace(slot, *slot + 1)
+ };
+
+ // Combine the key, disambiguator and local hash into a sub-local hash.
+ // The outer information is not yet merged into this, it is added
+ // on-demand in `Locator::resolve`.
+ let local = typst_utils::hash128(&(key, disambiguator, self.local));
+
+ Locator { outer: self.outer, local }
+ }
+
+ /// Produces a unique location for an element.
+ pub fn next_location(
+ &mut self,
+ introspector: Tracked<Introspector>,
+ key: u128,
+ ) -> Location {
+ match self.next_inner(key).resolve() {
+ Resolved::Hash(hash) => Location::new(hash),
+ Resolved::Measure(anchor) => {
+ // If we aren't able to find a matching element in the document,
+ // default to the anchor, so that it's at least remotely in
+ // the right area (so that counters can be resolved).
+ introspector.locator(key, anchor).unwrap_or(anchor)
+ }
+ }
+ }
+}
+
+/// A locator can be linked to this type to only access information across the
+/// memoization boundary on-demand, improving the cache hit chance.
+pub struct LocatorLink<'a> {
+ /// The link itself.
+ kind: LinkKind<'a>,
+ /// The cached resolved link.
+ resolved: OnceLock<Resolved>,
+}
+
+/// The different kinds of locator links.
+enum LinkKind<'a> {
+ /// An outer `Locator`, which we can resolved if necessary.
+ ///
+ /// We need to override the constraint's lifetime here so that `Tracked` is
+ /// covariant over the constraint. If it becomes invariant, we're in for a
+ /// world of lifetime pain.
+ Outer(Tracked<'a, Locator<'a>, <Locator<'static> as Validate>::Constraint>),
+ /// A link which indicates that we are in measurement mode.
+ Measure(Location),
+}
+
+impl<'a> LocatorLink<'a> {
+ /// Create a locator link.
+ pub fn new(outer: Tracked<'a, Locator<'a>>) -> Self {
+ LocatorLink {
+ kind: LinkKind::Outer(outer),
+ resolved: OnceLock::new(),
+ }
+ }
+
+ /// Creates a link that puts any linked downstream locator into measurement
+ /// mode.
+ ///
+ /// Read the "Dealing with measurement" section of the [`Locator`] docs for
+ /// more details.
+ pub fn measure(anchor: Location) -> Self {
+ LocatorLink {
+ kind: LinkKind::Measure(anchor),
+ resolved: OnceLock::new(),
+ }
+ }
+
+ /// Resolve the link.
+ ///
+ /// The result is cached in this link, so that we don't traverse the link
+ /// chain over and over again.
+ fn resolve(&self) -> Resolved {
+ *self.resolved.get_or_init(|| match self.kind {
+ LinkKind::Outer(outer) => outer.resolve(),
+ LinkKind::Measure(anchor) => Resolved::Measure(anchor),
+ })
+ }
+}
diff --git a/crates/typst-library/src/introspection/metadata.rs b/crates/typst-library/src/introspection/metadata.rs
new file mode 100644
index 00000000..06000174
--- /dev/null
+++ b/crates/typst-library/src/introspection/metadata.rs
@@ -0,0 +1,38 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, StyleChain, Value};
+use crate::introspection::Locatable;
+
+/// Exposes a value to the query system without producing visible content.
+///
+/// This element can be retrieved with the [`query`] function and from the
+/// command line with
+/// [`typst query`]($reference/introspection/query/#command-line-queries). Its
+/// purpose is to expose an arbitrary value to the introspection system. To
+/// identify a metadata value among others, you can attach a [`label`] to it and
+/// query for that label.
+///
+/// The `metadata` element is especially useful for command line queries because
+/// it allows you to expose arbitrary values to the outside world.
+///
+/// ```example
+/// // Put metadata somewhere.
+/// #metadata("This is a note") <note>
+///
+/// // And find it from anywhere else.
+/// #context {
+/// query(<note>).first().value
+/// }
+/// ```
+#[elem(Show, Locatable)]
+pub struct MetadataElem {
+ /// The value to embed into the document.
+ #[required]
+ pub value: Value,
+}
+
+impl Show for Packed<MetadataElem> {
+ fn show(&self, _: &mut Engine, _styles: StyleChain) -> SourceResult<Content> {
+ Ok(Content::empty())
+ }
+}
diff --git a/crates/typst-library/src/introspection/mod.rs b/crates/typst-library/src/introspection/mod.rs
new file mode 100644
index 00000000..b1ff2e08
--- /dev/null
+++ b/crates/typst-library/src/introspection/mod.rs
@@ -0,0 +1,53 @@
+//! Interaction between document parts.
+
+mod counter;
+#[path = "here.rs"]
+mod here_;
+mod introspector;
+#[path = "locate.rs"]
+mod locate_;
+mod location;
+mod locator;
+mod metadata;
+#[path = "query.rs"]
+mod query_;
+mod state;
+mod tag;
+
+pub use self::counter::*;
+pub use self::here_::*;
+pub use self::introspector::*;
+pub use self::locate_::*;
+pub use self::location::*;
+pub use self::locator::*;
+pub use self::metadata::*;
+pub use self::query_::*;
+pub use self::state::*;
+pub use self::tag::*;
+
+use crate::foundations::{category, Category, Scope};
+
+/// Interactions between document parts.
+///
+/// This category is home to Typst's introspection capabilities: With the
+/// `counter` function, you can access and manipulate page, section, figure, and
+/// equation counters or create custom ones. Meanwhile, the `query` function
+/// lets you search for elements in the document to construct things like a list
+/// of figures or headers which show the current chapter title.
+///
+/// Most of the functions are _contextual._ It is recommended to read the chapter
+/// on [context] before continuing here.
+#[category]
+pub static INTROSPECTION: Category;
+
+/// Hook up all `introspection` definitions.
+pub fn define(global: &mut Scope) {
+ global.category(INTROSPECTION);
+ global.define_type::<Location>();
+ global.define_type::<Counter>();
+ global.define_type::<State>();
+ global.define_elem::<MetadataElem>();
+ global.define_func::<here>();
+ global.define_func::<query>();
+ global.define_func::<locate>();
+}
diff --git a/crates/typst-library/src/introspection/query.rs b/crates/typst-library/src/introspection/query.rs
new file mode 100644
index 00000000..7b106bf0
--- /dev/null
+++ b/crates/typst-library/src/introspection/query.rs
@@ -0,0 +1,171 @@
+use comemo::Tracked;
+use typst_syntax::Span;
+
+use crate::diag::{warning, HintedStrResult};
+use crate::engine::Engine;
+use crate::foundations::{func, Array, Context, LocatableSelector, Value};
+use crate::introspection::Location;
+
+/// 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
+/// ensure that [context] is available.
+///
+
+/// # Finding elements
+/// In the example below, we manually create a table of contents instead of
+/// using the [`outline`] function.
+///
+/// To do this, we first query for all headings in the document at level 1 and
+/// where `outlined` is true. Querying only for headings at level 1 ensures
+/// that, for the purpose of this example, sub-headings are not included in the
+/// table of contents. The `outlined` field is used to exclude the "Table of
+/// Contents" heading itself.
+///
+/// Note that we open a `context` to be able to use the `query` function.
+///
+/// ```example
+/// >>> #set page(
+/// >>> width: 240pt,
+/// >>> height: 180pt,
+/// >>> margin: (top: 20pt, bottom: 35pt)
+/// >>> )
+/// #set page(numbering: "1")
+///
+/// #heading(outlined: false)[
+/// Table of Contents
+/// ]
+/// #context {
+/// let chapters = query(
+/// heading.where(
+/// level: 1,
+/// outlined: true,
+/// )
+/// )
+/// for chapter in chapters {
+/// let loc = chapter.location()
+/// let nr = numbering(
+/// loc.page-numbering(),
+/// ..counter(page).at(loc),
+/// )
+/// [#chapter.body #h(1fr) #nr \ ]
+/// }
+/// }
+///
+/// = Introduction
+/// #lorem(10)
+/// #pagebreak()
+///
+/// == Sub-Heading
+/// #lorem(8)
+///
+/// = Discussion
+/// #lorem(18)
+/// ```
+///
+/// To get the page numbers, we first get the location of the elements returned
+/// by `query` with [`location`]($content.location). We then also retrieve the
+/// [page numbering]($location.page-numbering) and [page
+/// counter]($counter/#page-counter) at that location and apply the numbering to
+/// the counter.
+///
+/// # 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 a finite amount of headings. This is
+/// because Typst simply gives up after a few 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]($counter) and [state].
+///
+/// ```example
+/// = Real
+/// #context {
+/// let elems = query(heading)
+/// let count = elems.len()
+/// count * [= Fake]
+/// }
+/// ```
+///
+/// # Command line queries
+/// You can also perform queries from the command line with the `typst query`
+/// command. This command executes an arbitrary query on the document and
+/// returns the resulting elements in serialized form. Consider the following
+/// `example.typ` file which contains some invisible [metadata]:
+///
+/// ```typ
+/// #metadata("This is a note") <note>
+/// ```
+///
+/// You can execute a query on it as follows using Typst's CLI:
+/// ```sh
+/// $ typst query example.typ "<note>"
+/// [
+/// {
+/// "func": "metadata",
+/// "value": "This is a note",
+/// "label": "<note>"
+/// }
+/// ]
+/// ```
+///
+/// Frequently, you're interested in only one specific field of the resulting
+/// elements. In the case of the `metadata` element, the `value` field is the
+/// interesting one. You can extract just this field with the `--field`
+/// argument.
+///
+/// ```sh
+/// $ typst query example.typ "<note>" --field value
+/// ["This is a note"]
+/// ```
+///
+/// If you are interested in just a single element, you can use the `--one`
+/// flag to extract just it.
+///
+/// ```sh
+/// $ typst query example.typ "<note>" --field value --one
+/// "This is a note"
+/// ```
+#[func(contextual)]
+pub fn query(
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The span of the `query` call.
+ span: Span,
+ /// Can be
+ /// - an element function like a `heading` or `figure`,
+ /// - a `{<label>}`,
+ /// - a more complex selector like `{heading.where(level: 1)}`,
+ /// - or `{selector(heading).before(here())}`.
+ ///
+ /// Only [locatable]($location/#locatable) element functions are supported.
+ target: LocatableSelector,
+ /// _Compatibility:_ This argument is deprecated. It only exists for
+ /// compatibility with Typst 0.10 and lower and shouldn't be used anymore.
+ #[default]
+ location: Option<Location>,
+) -> HintedStrResult<Array> {
+ if location.is_none() {
+ context.introspect()?;
+ } else {
+ engine.sink.warn(warning!(
+ span, "calling `query` with a location is deprecated";
+ hint: "try removing the location argument"
+ ));
+ }
+
+ let vec = engine.introspector.query(&target.0);
+ Ok(vec.into_iter().map(Value::Content).collect())
+}
diff --git a/crates/typst-library/src/introspection/state.rs b/crates/typst-library/src/introspection/state.rs
new file mode 100644
index 00000000..13c96b50
--- /dev/null
+++ b/crates/typst-library/src/introspection/state.rs
@@ -0,0 +1,476 @@
+use comemo::{Track, Tracked, TrackedMut};
+use ecow::{eco_format, eco_vec, EcoString, EcoVec};
+use typst_syntax::Span;
+
+use crate::diag::{bail, warning, At, SourceResult};
+use crate::engine::{Engine, Route, Sink, Traced};
+use crate::foundations::{
+ cast, elem, func, scope, select_where, ty, Args, Construct, Content, Context, Func,
+ LocatableSelector, NativeElement, Packed, Repr, Selector, Show, Str, StyleChain,
+ Value,
+};
+use crate::introspection::{Introspector, Locatable, Location};
+use crate::routines::Routines;
+use crate::World;
+
+/// 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
+/// // This doesn't work!
+/// #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 functions. The two most
+/// important ones are `get` and `update`:
+///
+/// - The [`get`]($state.get) function retrieves the current value of the state.
+/// Because the value can vary over the course of the document, it is a
+/// _contextual_ function that can only be used when [context]($context) is
+/// available.
+///
+/// - The [`update`]($state.update) function 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 #context s.get().
+/// ]
+///
+/// #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 #context s.get().
+/// >>> ]
+/// <<< ...
+///
+/// #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]($counter) is very similar to its state system.
+///
+/// # Time Travel
+/// By using Typst's state management system you also get time travel
+/// capabilities! 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 particular 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 #context s.get().
+/// >>> ]
+/// <<< ...
+///
+/// Value at `<here>` is
+/// #context s.at(<here>)
+///
+/// #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 displays a finite value because Typst
+/// simply gives up after a few attempts.
+///
+/// ```example
+/// // This is bad!
+/// #let s = state("x", 1)
+/// #context s.update(s.final() + 1)
+/// #context s.get()
+/// ```
+///
+/// In general, you should try not to generate state updates from within context
+/// expressions. If possible, try to express your updates as non-contextual
+/// values or functions that compute the new value from the previous value.
+/// Sometimes, it cannot be helped, but in those cases it is up to you to ensure
+/// that the result converges.
+#[ty(scope)]
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub struct State {
+ /// The key that identifies the state.
+ key: Str,
+ /// The initial value of the state.
+ init: Value,
+}
+
+impl State {
+ /// Create a new state identified by a key.
+ pub fn new(key: Str, init: Value) -> State {
+ Self { key, init }
+ }
+
+ /// Get the value of the state at the given location.
+ pub fn at_loc(&self, engine: &mut Engine, loc: Location) -> SourceResult<Value> {
+ let sequence = self.sequence(engine)?;
+ let offset = engine.introspector.query_count_before(&self.selector(), loc);
+ Ok(sequence[offset].clone())
+ }
+
+ /// 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, engine: &mut Engine) -> SourceResult<EcoVec<Value>> {
+ self.sequence_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ )
+ }
+
+ /// Memoized implementation of `sequence`.
+ #[comemo::memoize]
+ fn sequence_impl(
+ &self,
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ ) -> SourceResult<EcoVec<Value>> {
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route).unnested(),
+ };
+ let mut state = self.init.clone();
+ let mut stops = eco_vec![state.clone()];
+
+ for elem in introspector.query(&self.selector()) {
+ let elem = elem.to_packed::<StateUpdateElem>().unwrap();
+ match elem.update() {
+ StateUpdate::Set(value) => state = value.clone(),
+ StateUpdate::Func(func) => {
+ state = func.call(&mut engine, Context::none().track(), [state])?
+ }
+ }
+ stops.push(state.clone());
+ }
+
+ Ok(stops)
+ }
+
+ /// The selector for this state's updates.
+ fn selector(&self) -> Selector {
+ select_where!(StateUpdateElem, Key => self.key.clone())
+ }
+}
+
+#[scope]
+impl State {
+ /// Create a new state identified by a key.
+ #[func(constructor)]
+ pub fn construct(
+ /// The key that identifies this state.
+ key: Str,
+ /// The initial value of the state.
+ #[default]
+ init: Value,
+ ) -> State {
+ Self::new(key, init)
+ }
+
+ /// Retrieves the value of the state at the current location.
+ ///
+ /// This is equivalent to `{state.at(here())}`.
+ #[func(contextual)]
+ pub fn get(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ ) -> SourceResult<Value> {
+ let loc = context.location().at(span)?;
+ self.at_loc(engine, loc)
+ }
+
+ /// Retrieves the value of the state at the given selector's unique match.
+ ///
+ /// The `selector` must match exactly one element in the document. The most
+ /// useful kinds of selectors for this are [labels]($label) and
+ /// [locations]($location).
+ ///
+ /// _Compatibility:_ For compatibility with Typst 0.10 and lower, this
+ /// function also works without a known context if the `selector` is a
+ /// location. This behaviour will be removed in a future release.
+ #[func(contextual)]
+ pub fn at(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ /// The place at which the state's value should be retrieved.
+ selector: LocatableSelector,
+ ) -> SourceResult<Value> {
+ let loc = selector.resolve_unique(engine.introspector, context).at(span)?;
+ self.at_loc(engine, loc)
+ }
+
+ /// Retrieves the value of the state at the end of the document.
+ #[func(contextual)]
+ pub fn final_(
+ &self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ /// _Compatibility:_ This argument is deprecated. It only exists for
+ /// compatibility with Typst 0.10 and lower and shouldn't be used
+ /// anymore.
+ #[default]
+ location: Option<Location>,
+ ) -> SourceResult<Value> {
+ if location.is_none() {
+ context.location().at(span)?;
+ } else {
+ engine.sink.warn(warning!(
+ span, "calling `state.final` with a location is deprecated";
+ hint: "try removing the location argument"
+ ));
+ }
+
+ let sequence = self.sequence(engine)?;
+ Ok(sequence.last().unwrap().clone())
+ }
+
+ /// Update 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.
+ #[func]
+ pub fn update(
+ self,
+ /// The span of the `update` call.
+ span: Span,
+ /// 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.
+ update: StateUpdate,
+ ) -> Content {
+ StateUpdateElem::new(self.key, update).pack().spanned(span)
+ }
+
+ /// Displays the current value of the state.
+ ///
+ /// **Deprecation planned:** Use [`get`]($state.get) instead.
+ #[func]
+ pub fn display(
+ self,
+ /// The engine.
+ engine: &mut Engine,
+ /// The span of the `display` call.
+ span: Span,
+ /// 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.
+ #[default]
+ func: Option<Func>,
+ ) -> Content {
+ engine.sink.warn(warning!(
+ span, "`state.display` is deprecated";
+ hint: "use `state.get` in a `context` expression instead"
+ ));
+
+ StateDisplayElem::new(self, func).pack().spanned(span)
+ }
+}
+
+impl Repr for State {
+ fn repr(&self) -> EcoString {
+ eco_format!("state({}, {})", self.key.repr(), self.init.repr())
+ }
+}
+
+/// An update to perform on a state.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum StateUpdate {
+ /// Set the state to the specified value.
+ Set(Value),
+ /// Apply the given function to the state.
+ Func(Func),
+}
+
+cast! {
+ StateUpdate,
+ v: Func => Self::Func(v),
+ v: Value => Self::Set(v),
+}
+
+/// Executes a display of a state.
+#[elem(Construct, Locatable, Show)]
+struct StateUpdateElem {
+ /// The key that identifies the state.
+ #[required]
+ key: Str,
+
+ /// The update to perform on the state.
+ #[required]
+ #[internal]
+ update: StateUpdate,
+}
+
+impl Construct for StateUpdateElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl Show for Packed<StateUpdateElem> {
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(Content::empty())
+ }
+}
+
+/// Executes a display of a state.
+///
+/// **Deprecation planned.**
+#[elem(Construct, Locatable, Show)]
+struct StateDisplayElem {
+ /// The state.
+ #[required]
+ #[internal]
+ state: State,
+
+ /// The function to display the state with.
+ #[required]
+ #[internal]
+ func: Option<Func>,
+}
+
+impl Show for Packed<StateDisplayElem> {
+ #[typst_macros::time(name = "state.display", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let location = self.location().unwrap();
+ let context = Context::new(Some(location), Some(styles));
+ let value = self.state().at_loc(engine, location)?;
+ Ok(match self.func() {
+ Some(func) => func.call(engine, context.track(), [value])?.display(),
+ None => value.display(),
+ })
+ }
+}
+
+impl Construct for StateDisplayElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
diff --git a/crates/typst-library/src/introspection/tag.rs b/crates/typst-library/src/introspection/tag.rs
new file mode 100644
index 00000000..b2bae28e
--- /dev/null
+++ b/crates/typst-library/src/introspection/tag.rs
@@ -0,0 +1,73 @@
+use std::fmt::{self, Debug, Formatter};
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, Args, Construct, Content, NativeElement, Packed, Unlabellable,
+};
+use crate::introspection::Location;
+
+/// Marks the start or end of a locatable element.
+#[derive(Clone, PartialEq, Hash)]
+pub enum Tag {
+ /// The stored element starts here.
+ ///
+ /// Content placed in a tag **must** have a [`Location`] or there will be
+ /// panics.
+ Start(Content),
+ /// The element with the given location and key hash ends here.
+ ///
+ /// Note: The key hash is stored here instead of in `Start` simply to make
+ /// the two enum variants more balanced in size, keeping a `Tag`'s memory
+ /// size down. There are no semantic reasons for this.
+ End(Location, u128),
+}
+
+impl Tag {
+ /// Access the location of the tag.
+ pub fn location(&self) -> Location {
+ match self {
+ Tag::Start(elem) => elem.location().unwrap(),
+ Tag::End(loc, _) => *loc,
+ }
+ }
+}
+
+impl Debug for Tag {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Tag::Start(elem) => write!(f, "Start({:?})", elem.elem().name()),
+ Tag::End(..) => f.pad("End"),
+ }
+ }
+}
+
+/// Holds a tag for a locatable element that was realized.
+///
+/// The `TagElem` is handled by all layouters. The held element becomes
+/// available for introspection in the next compiler iteration.
+#[elem(Construct, Unlabellable)]
+pub struct TagElem {
+ /// The introspectable element.
+ #[required]
+ #[internal]
+ pub tag: Tag,
+}
+
+impl TagElem {
+ /// Create a packed tag element.
+ pub fn packed(tag: Tag) -> Content {
+ let mut content = Self::new(tag).pack();
+ // We can skip preparation for the `TagElem`.
+ content.mark_prepared();
+ content
+ }
+}
+
+impl Construct for TagElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually")
+ }
+}
+
+impl Unlabellable for Packed<TagElem> {}
diff --git a/crates/typst-library/src/layout/abs.rs b/crates/typst-library/src/layout/abs.rs
new file mode 100644
index 00000000..e19b037a
--- /dev/null
+++ b/crates/typst-library/src/layout/abs.rs
@@ -0,0 +1,279 @@
+use std::fmt::{self, Debug, Formatter};
+use std::iter::Sum;
+use std::ops::{Add, Div, Mul, Neg, Rem};
+
+use ecow::EcoString;
+use typst_utils::{Numeric, Scalar};
+
+use crate::foundations::{cast, repr, Fold, Repr, Value};
+
+/// An absolute length.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Abs(Scalar);
+
+impl Abs {
+ /// The zero length.
+ pub const fn zero() -> Self {
+ Self(Scalar::ZERO)
+ }
+
+ /// The infinite length.
+ pub const fn inf() -> Self {
+ Self(Scalar::INFINITY)
+ }
+
+ /// Create an absolute length from a number of raw units.
+ pub const fn raw(raw: f64) -> Self {
+ Self(Scalar::new(raw))
+ }
+
+ /// Create an absolute length from a value in a unit.
+ pub fn with_unit(val: f64, unit: AbsUnit) -> Self {
+ Self(Scalar::new(val * unit.raw_scale()))
+ }
+
+ /// Create an absolute length from a number of points.
+ pub fn pt(pt: f64) -> Self {
+ Self::with_unit(pt, AbsUnit::Pt)
+ }
+
+ /// Create an absolute length from a number of millimeters.
+ pub fn mm(mm: f64) -> Self {
+ Self::with_unit(mm, AbsUnit::Mm)
+ }
+
+ /// Create an absolute length from a number of centimeters.
+ pub fn cm(cm: f64) -> Self {
+ Self::with_unit(cm, AbsUnit::Cm)
+ }
+
+ /// Create an absolute length from a number of inches.
+ pub fn inches(inches: f64) -> Self {
+ Self::with_unit(inches, AbsUnit::In)
+ }
+
+ /// Get the value of this absolute length in raw units.
+ pub const fn to_raw(self) -> f64 {
+ self.0.get()
+ }
+
+ /// Get the value of this absolute length in a unit.
+ pub fn to_unit(self, unit: AbsUnit) -> f64 {
+ self.to_raw() / unit.raw_scale()
+ }
+
+ /// Convert this to a number of points.
+ pub fn to_pt(self) -> f64 {
+ self.to_unit(AbsUnit::Pt)
+ }
+
+ /// Convert this to a number of millimeters.
+ pub fn to_mm(self) -> f64 {
+ self.to_unit(AbsUnit::Mm)
+ }
+
+ /// Convert this to a number of centimeters.
+ pub fn to_cm(self) -> f64 {
+ self.to_unit(AbsUnit::Cm)
+ }
+
+ /// Convert this to a number of inches.
+ pub fn to_inches(self) -> f64 {
+ self.to_unit(AbsUnit::In)
+ }
+
+ /// The absolute value of this length.
+ pub fn abs(self) -> Self {
+ Self::raw(self.to_raw().abs())
+ }
+
+ /// The minimum of this and another absolute length.
+ pub fn min(self, other: Self) -> Self {
+ Self(self.0.min(other.0))
+ }
+
+ /// Set to the minimum of this and another absolute length.
+ pub fn set_min(&mut self, other: Self) {
+ *self = (*self).min(other);
+ }
+
+ /// The maximum of this and another absolute length.
+ pub fn max(self, other: Self) -> Self {
+ Self(self.0.max(other.0))
+ }
+
+ /// Set to the maximum of this and another absolute length.
+ pub fn set_max(&mut self, other: Self) {
+ *self = (*self).max(other);
+ }
+
+ /// Whether the other absolute length fits into this one (i.e. is smaller).
+ /// Allows for a bit of slack.
+ pub fn fits(self, other: Self) -> bool {
+ self.0 + AbsUnit::EPS >= other.0
+ }
+
+ /// Compares two absolute lengths for whether they are approximately equal.
+ pub fn approx_eq(self, other: Self) -> bool {
+ self == other || (self - other).to_raw().abs() < AbsUnit::EPS
+ }
+
+ /// Whether the size is close to zero or negative.
+ pub fn approx_empty(self) -> bool {
+ self.to_raw() <= AbsUnit::EPS
+ }
+
+ /// Returns a number that represent the sign of this length
+ pub fn signum(self) -> f64 {
+ self.0.get().signum()
+ }
+}
+
+impl Numeric for Abs {
+ fn zero() -> Self {
+ Self::zero()
+ }
+
+ fn is_finite(self) -> bool {
+ self.0.is_finite()
+ }
+}
+
+impl Debug for Abs {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}pt", self.to_pt())
+ }
+}
+
+impl Repr for Abs {
+ fn repr(&self) -> EcoString {
+ repr::format_float_with_unit(self.to_pt(), "pt")
+ }
+}
+
+impl Neg for Abs {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self(-self.0)
+ }
+}
+
+impl Add for Abs {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self(self.0 + other.0)
+ }
+}
+
+typst_utils::sub_impl!(Abs - Abs -> Abs);
+
+impl Mul<f64> for Abs {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self {
+ Self(self.0 * other)
+ }
+}
+
+impl Mul<Abs> for f64 {
+ type Output = Abs;
+
+ fn mul(self, other: Abs) -> Abs {
+ other * self
+ }
+}
+
+impl Div<f64> for Abs {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self(self.0 / other)
+ }
+}
+
+impl Div for Abs {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.to_raw() / other.to_raw()
+ }
+}
+
+typst_utils::assign_impl!(Abs += Abs);
+typst_utils::assign_impl!(Abs -= Abs);
+typst_utils::assign_impl!(Abs *= f64);
+typst_utils::assign_impl!(Abs /= f64);
+
+impl Rem for Abs {
+ type Output = Self;
+
+ fn rem(self, other: Self) -> Self::Output {
+ Self(self.0 % other.0)
+ }
+}
+
+impl Sum for Abs {
+ fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
+ Self(iter.map(|s| s.0).sum())
+ }
+}
+
+impl<'a> Sum<&'a Self> for Abs {
+ fn sum<I: Iterator<Item = &'a Self>>(iter: I) -> Self {
+ Self(iter.map(|s| s.0).sum())
+ }
+}
+
+impl Fold for Abs {
+ fn fold(self, _: Self) -> Self {
+ self
+ }
+}
+
+cast! {
+ Abs,
+ self => Value::Length(self.into()),
+}
+
+/// Different units of absolute measurement.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum AbsUnit {
+ /// Points.
+ Pt,
+ /// Millimeters.
+ Mm,
+ /// Centimeters.
+ Cm,
+ /// Inches.
+ In,
+}
+
+impl AbsUnit {
+ /// The epsilon for approximate length comparisons.
+ const EPS: f64 = 1e-4;
+
+ /// How many raw units correspond to a value of `1.0` in this unit.
+ const fn raw_scale(self) -> f64 {
+ // We choose a raw scale which has an integer conversion value to all
+ // four units of interest, so that whole numbers in all units can be
+ // represented accurately.
+ match self {
+ AbsUnit::Pt => 127.0,
+ AbsUnit::Mm => 360.0,
+ AbsUnit::Cm => 3600.0,
+ AbsUnit::In => 9144.0,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_length_unit_conversion() {
+ assert!((Abs::mm(150.0).to_cm() - 15.0) < 1e-4);
+ }
+}
diff --git a/crates/typst-library/src/layout/align.rs b/crates/typst-library/src/layout/align.rs
new file mode 100644
index 00000000..e8ba4d7c
--- /dev/null
+++ b/crates/typst-library/src/layout/align.rs
@@ -0,0 +1,748 @@
+use std::ops::Add;
+
+use ecow::{eco_format, EcoString};
+
+use crate::diag::{bail, HintedStrResult, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, func, scope, ty, CastInfo, Content, Fold, FromValue, IntoValue, Packed,
+ Reflect, Repr, Resolve, Show, StyleChain, Value,
+};
+use crate::layout::{Abs, Axes, Axis, Dir, Side};
+use crate::text::TextElem;
+
+/// Aligns content horizontally and vertically.
+///
+/// # Example
+/// Let's start with centering our content horizontally:
+/// ```example
+/// #set page(height: 120pt)
+/// #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
+/// ```
+///
+/// To center something vertically, use _horizon_ alignment:
+/// ```example
+/// #set page(height: 120pt)
+/// #set align(horizon)
+///
+/// Vertically centered, \
+/// the stage had entered, \
+/// a new paragraph.
+/// ```
+///
+/// # Combining alignments
+/// You can combine two alignments with the `+` operator. Let's also only apply
+/// this to one piece of content by using the function form instead of a set
+/// rule:
+/// ```example
+/// #set page(height: 120pt)
+/// Though left in the beginning ...
+///
+/// #align(right + bottom)[
+/// ... they were right in the end, \
+/// and with addition had gotten, \
+/// the paragraph to the bottom!
+/// ]
+/// ```
+///
+/// # Nested alignment
+/// You can use varying alignments for layout containers and the elements within
+/// them. This way, you can create intricate layouts:
+///
+/// ```example
+/// #align(center, block[
+/// #set align(left)
+/// Though centered together \
+/// alone \
+/// we \
+/// are \
+/// left.
+/// ])
+/// ```
+///
+/// # Alignment within the same line
+/// The `align` function performs block-level alignment and thus always
+/// interrupts the current paragraph. To have different alignment for parts
+/// of the same line, you should use [fractional spacing]($h) instead:
+///
+/// ```example
+/// Start #h(1fr) End
+/// ```
+#[elem(Show)]
+pub struct AlignElem {
+ /// The [alignment] along both axes.
+ ///
+ /// ```example
+ /// #set page(height: 6cm)
+ /// #set text(lang: "ar")
+ ///
+ /// مثال
+ /// #align(
+ /// end + horizon,
+ /// rect(inset: 12pt)[ركن]
+ /// )
+ /// ```
+ #[positional]
+ #[fold]
+ #[default]
+ pub alignment: Alignment,
+
+ /// The content to align.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<AlignElem> {
+ #[typst_macros::time(name = "align", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().aligned(self.alignment(styles)))
+ }
+}
+
+/// Where to [align] something along an axis.
+///
+/// Possible values are:
+/// - `start`: Aligns at the [start]($direction.start) of the [text
+/// direction]($text.dir).
+/// - `end`: Aligns at the [end]($direction.end) of the [text
+/// direction]($text.dir).
+/// - `left`: Align at the left.
+/// - `center`: Aligns in the middle, horizontally.
+/// - `right`: Aligns at the right.
+/// - `top`: Aligns at the top.
+/// - `horizon`: Aligns in the middle, vertically.
+/// - `bottom`: Align at the bottom.
+///
+/// These values are available globally and also in the alignment type's scope,
+/// so you can write either of the following two:
+///
+/// ```example
+/// #align(center)[Hi]
+/// #align(alignment.center)[Hi]
+/// ```
+///
+/// # 2D alignments
+/// To align along both axes at the same time, add the two alignments using the
+/// `+` operator. For example, `top + right` aligns the content to the top right
+/// corner.
+///
+/// ```example
+/// #set page(height: 3cm)
+/// #align(center + bottom)[Hi]
+/// ```
+///
+/// # Fields
+/// The `x` and `y` fields hold the alignment's horizontal and vertical
+/// components, respectively (as yet another `alignment`). They may be `{none}`.
+///
+/// ```example
+/// #(top + right).x \
+/// #left.x \
+/// #left.y (none)
+/// ```
+#[ty(scope)]
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Alignment {
+ H(HAlignment),
+ V(VAlignment),
+ Both(HAlignment, VAlignment),
+}
+
+impl Alignment {
+ /// The horizontal component.
+ pub const fn x(self) -> Option<HAlignment> {
+ match self {
+ Self::H(h) | Self::Both(h, _) => Some(h),
+ Self::V(_) => None,
+ }
+ }
+
+ /// The vertical component.
+ pub const fn y(self) -> Option<VAlignment> {
+ match self {
+ Self::V(v) | Self::Both(_, v) => Some(v),
+ Self::H(_) => None,
+ }
+ }
+
+ /// Normalize the alignment to a LTR-TTB space.
+ pub fn fix(self, text_dir: Dir) -> Axes<FixedAlignment> {
+ Axes::new(
+ self.x().unwrap_or_default().fix(text_dir),
+ self.y().unwrap_or_default().fix(text_dir),
+ )
+ }
+}
+
+#[scope]
+impl Alignment {
+ pub const START: Self = Alignment::H(HAlignment::Start);
+ pub const LEFT: Self = Alignment::H(HAlignment::Left);
+ pub const CENTER: Self = Alignment::H(HAlignment::Center);
+ pub const RIGHT: Self = Alignment::H(HAlignment::Right);
+ pub const END: Self = Alignment::H(HAlignment::End);
+ pub const TOP: Self = Alignment::V(VAlignment::Top);
+ pub const HORIZON: Self = Alignment::V(VAlignment::Horizon);
+ pub const BOTTOM: Self = Alignment::V(VAlignment::Bottom);
+
+ /// The axis this alignment belongs to.
+ /// - `{"horizontal"}` for `start`, `left`, `center`, `right`, and `end`
+ /// - `{"vertical"}` for `top`, `horizon`, and `bottom`
+ /// - `{none}` for 2-dimensional alignments
+ ///
+ /// ```example
+ /// #left.axis() \
+ /// #bottom.axis()
+ /// ```
+ #[func]
+ pub const fn axis(self) -> Option<Axis> {
+ match self {
+ Self::H(_) => Some(Axis::X),
+ Self::V(_) => Some(Axis::Y),
+ Self::Both(..) => None,
+ }
+ }
+
+ /// The inverse alignment.
+ ///
+ /// ```example
+ /// #top.inv() \
+ /// #left.inv() \
+ /// #center.inv() \
+ /// #(left + bottom).inv()
+ /// ```
+ #[func(title = "Inverse")]
+ pub const fn inv(self) -> Alignment {
+ match self {
+ Self::H(h) => Self::H(h.inv()),
+ Self::V(v) => Self::V(v.inv()),
+ Self::Both(h, v) => Self::Both(h.inv(), v.inv()),
+ }
+ }
+}
+
+impl Default for Alignment {
+ fn default() -> Self {
+ HAlignment::default() + VAlignment::default()
+ }
+}
+
+impl Add for Alignment {
+ type Output = StrResult<Self>;
+
+ fn add(self, rhs: Self) -> Self::Output {
+ match (self, rhs) {
+ (Self::H(h), Self::V(v)) | (Self::V(v), Self::H(h)) => Ok(h + v),
+ (Self::H(_), Self::H(_)) => bail!("cannot add two horizontal alignments"),
+ (Self::V(_), Self::V(_)) => bail!("cannot add two vertical alignments"),
+ (Self::H(_), Self::Both(..)) | (Self::Both(..), Self::H(_)) => {
+ bail!("cannot add a horizontal and a 2D alignment")
+ }
+ (Self::V(_), Self::Both(..)) | (Self::Both(..), Self::V(_)) => {
+ bail!("cannot add a vertical and a 2D alignment")
+ }
+ (Self::Both(..), Self::Both(..)) => {
+ bail!("cannot add two 2D alignments")
+ }
+ }
+ }
+}
+
+impl Repr for Alignment {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::H(h) => h.repr(),
+ Self::V(v) => v.repr(),
+ Self::Both(h, v) => eco_format!("{} + {}", h.repr(), v.repr()),
+ }
+ }
+}
+
+impl Fold for Alignment {
+ fn fold(self, outer: Self) -> Self {
+ match (self, outer) {
+ (Self::H(h), Self::V(v) | Self::Both(_, v)) => Self::Both(h, v),
+ (Self::V(v), Self::H(h) | Self::Both(h, _)) => Self::Both(h, v),
+ _ => self,
+ }
+ }
+}
+
+impl Resolve for Alignment {
+ type Output = Axes<FixedAlignment>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.fix(TextElem::dir_in(styles))
+ }
+}
+
+impl From<Side> for Alignment {
+ fn from(side: Side) -> Self {
+ match side {
+ Side::Left => Self::LEFT,
+ Side::Top => Self::TOP,
+ Side::Right => Self::RIGHT,
+ Side::Bottom => Self::BOTTOM,
+ }
+ }
+}
+
+/// Alignment on this axis can be fixed to an absolute direction.
+pub trait FixAlignment {
+ /// Resolve to the absolute alignment.
+ fn fix(self, dir: Dir) -> FixedAlignment;
+}
+
+/// Where to align something horizontally.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum HAlignment {
+ #[default]
+ Start,
+ Left,
+ Center,
+ Right,
+ End,
+}
+
+impl HAlignment {
+ /// The inverse horizontal alignment.
+ pub const fn inv(self) -> Self {
+ match self {
+ Self::Start => Self::End,
+ Self::Left => Self::Right,
+ Self::Center => Self::Center,
+ Self::Right => Self::Left,
+ Self::End => Self::Start,
+ }
+ }
+}
+
+impl FixAlignment for HAlignment {
+ fn fix(self, dir: Dir) -> FixedAlignment {
+ match (self, dir.is_positive()) {
+ (Self::Start, true) | (Self::End, false) => FixedAlignment::Start,
+ (Self::Left, _) => FixedAlignment::Start,
+ (Self::Center, _) => FixedAlignment::Center,
+ (Self::Right, _) => FixedAlignment::End,
+ (Self::End, true) | (Self::Start, false) => FixedAlignment::End,
+ }
+ }
+}
+
+impl Repr for HAlignment {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Start => "start".into(),
+ Self::Left => "left".into(),
+ Self::Center => "center".into(),
+ Self::Right => "right".into(),
+ Self::End => "end".into(),
+ }
+ }
+}
+
+impl Add<VAlignment> for HAlignment {
+ type Output = Alignment;
+
+ fn add(self, rhs: VAlignment) -> Self::Output {
+ Alignment::Both(self, rhs)
+ }
+}
+
+impl From<HAlignment> for Alignment {
+ fn from(align: HAlignment) -> Self {
+ Self::H(align)
+ }
+}
+
+impl TryFrom<Alignment> for HAlignment {
+ type Error = EcoString;
+
+ fn try_from(value: Alignment) -> StrResult<Self> {
+ match value {
+ Alignment::H(h) => Ok(h),
+ v => bail!(
+ "expected `start`, `left`, `center`, `right`, or `end`, found {}",
+ v.repr()
+ ),
+ }
+ }
+}
+
+impl Resolve for HAlignment {
+ type Output = FixedAlignment;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.fix(TextElem::dir_in(styles))
+ }
+}
+
+cast! {
+ HAlignment,
+ self => Alignment::H(self).into_value(),
+ align: Alignment => Self::try_from(align)?,
+}
+
+/// A horizontal alignment which only allows `left`/`right` and `start`/`end`,
+/// thus excluding `center`.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum OuterHAlignment {
+ #[default]
+ Start,
+ Left,
+ Right,
+ End,
+}
+
+impl FixAlignment for OuterHAlignment {
+ fn fix(self, dir: Dir) -> FixedAlignment {
+ match (self, dir.is_positive()) {
+ (Self::Start, true) | (Self::End, false) => FixedAlignment::Start,
+ (Self::Left, _) => FixedAlignment::Start,
+ (Self::Right, _) => FixedAlignment::End,
+ (Self::End, true) | (Self::Start, false) => FixedAlignment::End,
+ }
+ }
+}
+
+impl Resolve for OuterHAlignment {
+ type Output = FixedAlignment;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.fix(TextElem::dir_in(styles))
+ }
+}
+
+impl From<OuterHAlignment> for HAlignment {
+ fn from(value: OuterHAlignment) -> Self {
+ match value {
+ OuterHAlignment::Start => Self::Start,
+ OuterHAlignment::Left => Self::Left,
+ OuterHAlignment::Right => Self::Right,
+ OuterHAlignment::End => Self::End,
+ }
+ }
+}
+
+impl TryFrom<Alignment> for OuterHAlignment {
+ type Error = EcoString;
+
+ fn try_from(value: Alignment) -> StrResult<Self> {
+ match value {
+ Alignment::H(HAlignment::Start) => Ok(Self::Start),
+ Alignment::H(HAlignment::Left) => Ok(Self::Left),
+ Alignment::H(HAlignment::Right) => Ok(Self::Right),
+ Alignment::H(HAlignment::End) => Ok(Self::End),
+ v => bail!("expected `start`, `left`, `right`, or `end`, found {}", v.repr()),
+ }
+ }
+}
+
+cast! {
+ OuterHAlignment,
+ self => HAlignment::from(self).into_value(),
+ align: Alignment => Self::try_from(align)?,
+}
+
+/// Where to align something vertically.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum VAlignment {
+ #[default]
+ Top,
+ Horizon,
+ Bottom,
+}
+
+impl VAlignment {
+ /// The inverse vertical alignment.
+ pub const fn inv(self) -> Self {
+ match self {
+ Self::Top => Self::Bottom,
+ Self::Horizon => Self::Horizon,
+ Self::Bottom => Self::Top,
+ }
+ }
+
+ /// Returns the position of this alignment in a container with the given
+ /// extent.
+ pub fn position(self, extent: Abs) -> Abs {
+ match self {
+ Self::Top => Abs::zero(),
+ Self::Horizon => extent / 2.0,
+ Self::Bottom => extent,
+ }
+ }
+}
+
+impl FixAlignment for VAlignment {
+ fn fix(self, _: Dir) -> FixedAlignment {
+ // The vertical alignment does not depend on text direction.
+ match self {
+ Self::Top => FixedAlignment::Start,
+ Self::Horizon => FixedAlignment::Center,
+ Self::Bottom => FixedAlignment::End,
+ }
+ }
+}
+
+impl Repr for VAlignment {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Top => "top".into(),
+ Self::Horizon => "horizon".into(),
+ Self::Bottom => "bottom".into(),
+ }
+ }
+}
+
+impl Add<HAlignment> for VAlignment {
+ type Output = Alignment;
+
+ fn add(self, rhs: HAlignment) -> Self::Output {
+ Alignment::Both(rhs, self)
+ }
+}
+
+impl Resolve for VAlignment {
+ type Output = FixedAlignment;
+
+ fn resolve(self, _: StyleChain) -> Self::Output {
+ self.fix(Dir::TTB)
+ }
+}
+
+impl From<VAlignment> for Alignment {
+ fn from(align: VAlignment) -> Self {
+ Self::V(align)
+ }
+}
+
+impl TryFrom<Alignment> for VAlignment {
+ type Error = EcoString;
+
+ fn try_from(value: Alignment) -> StrResult<Self> {
+ match value {
+ Alignment::V(v) => Ok(v),
+ v => bail!("expected `top`, `horizon`, or `bottom`, found {}", v.repr()),
+ }
+ }
+}
+
+cast! {
+ VAlignment,
+ self => Alignment::V(self).into_value(),
+ align: Alignment => Self::try_from(align)?,
+}
+
+/// A vertical alignment which only allows `top` and `bottom`, thus excluding
+/// `horizon`.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum OuterVAlignment {
+ #[default]
+ Top,
+ Bottom,
+}
+
+impl FixAlignment for OuterVAlignment {
+ fn fix(self, _: Dir) -> FixedAlignment {
+ // The vertical alignment does not depend on text direction.
+ match self {
+ Self::Top => FixedAlignment::Start,
+ Self::Bottom => FixedAlignment::End,
+ }
+ }
+}
+
+impl From<OuterVAlignment> for VAlignment {
+ fn from(value: OuterVAlignment) -> Self {
+ match value {
+ OuterVAlignment::Top => Self::Top,
+ OuterVAlignment::Bottom => Self::Bottom,
+ }
+ }
+}
+
+impl TryFrom<Alignment> for OuterVAlignment {
+ type Error = EcoString;
+
+ fn try_from(value: Alignment) -> StrResult<Self> {
+ match value {
+ Alignment::V(VAlignment::Top) => Ok(Self::Top),
+ Alignment::V(VAlignment::Bottom) => Ok(Self::Bottom),
+ v => bail!("expected `top` or `bottom`, found {}", v.repr()),
+ }
+ }
+}
+
+cast! {
+ OuterVAlignment,
+ self => VAlignment::from(self).into_value(),
+ align: Alignment => Self::try_from(align)?,
+}
+
+/// An internal representation that combines horizontal or vertical alignments. The
+/// allowed alignment positions are designated by the type parameter `H` and `V`.
+///
+/// This is not user-visible, but an internal type to impose type safety. For example,
+/// `SpecificAlignment<HAlignment, OuterVAlignment>` does not allow vertical alignment
+/// position "center", because `V = OuterVAlignment` doesn't have it.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum SpecificAlignment<H, V> {
+ H(H),
+ V(V),
+ Both(H, V),
+}
+
+impl<H, V> SpecificAlignment<H, V>
+where
+ H: Default + Copy + FixAlignment,
+ V: Default + Copy + FixAlignment,
+{
+ /// The horizontal component.
+ pub const fn x(self) -> Option<H> {
+ match self {
+ Self::H(h) | Self::Both(h, _) => Some(h),
+ Self::V(_) => None,
+ }
+ }
+
+ /// The vertical component.
+ pub const fn y(self) -> Option<V> {
+ match self {
+ Self::V(v) | Self::Both(_, v) => Some(v),
+ Self::H(_) => None,
+ }
+ }
+
+ /// Normalize the alignment to a LTR-TTB space.
+ pub fn fix(self, text_dir: Dir) -> Axes<FixedAlignment> {
+ Axes::new(
+ self.x().unwrap_or_default().fix(text_dir),
+ self.y().unwrap_or_default().fix(text_dir),
+ )
+ }
+}
+
+impl<H, V> Resolve for SpecificAlignment<H, V>
+where
+ H: Default + Copy + FixAlignment,
+ V: Default + Copy + FixAlignment,
+{
+ type Output = Axes<FixedAlignment>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.fix(TextElem::dir_in(styles))
+ }
+}
+
+impl<H, V> From<SpecificAlignment<H, V>> for Alignment
+where
+ HAlignment: From<H>,
+ VAlignment: From<V>,
+{
+ fn from(value: SpecificAlignment<H, V>) -> Self {
+ type FromType<H, V> = SpecificAlignment<H, V>;
+ match value {
+ FromType::H(h) => Self::H(HAlignment::from(h)),
+ FromType::V(v) => Self::V(VAlignment::from(v)),
+ FromType::Both(h, v) => Self::Both(HAlignment::from(h), VAlignment::from(v)),
+ }
+ }
+}
+
+impl<H, V> Reflect for SpecificAlignment<H, V>
+where
+ H: Reflect,
+ V: Reflect,
+{
+ fn input() -> CastInfo {
+ Alignment::input()
+ }
+
+ fn output() -> CastInfo {
+ Alignment::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ H::castable(value) || V::castable(value)
+ }
+}
+
+impl<H, V> IntoValue for SpecificAlignment<H, V>
+where
+ HAlignment: From<H>,
+ VAlignment: From<V>,
+{
+ fn into_value(self) -> Value {
+ Alignment::from(self).into_value()
+ }
+}
+
+impl<H, V> FromValue for SpecificAlignment<H, V>
+where
+ H: Reflect + TryFrom<Alignment, Error = EcoString>,
+ V: Reflect + TryFrom<Alignment, Error = EcoString>,
+{
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ if Alignment::castable(&value) {
+ let align = Alignment::from_value(value)?;
+ let result = match align {
+ Alignment::H(_) => Self::H(H::try_from(align)?),
+ Alignment::V(_) => Self::V(V::try_from(align)?),
+ Alignment::Both(h, v) => {
+ Self::Both(H::try_from(h.into())?, V::try_from(v.into())?)
+ }
+ };
+ return Ok(result);
+ }
+ Err(Self::error(&value))
+ }
+}
+
+/// A fixed alignment in the global coordinate space.
+///
+/// For horizontal alignment, start is globally left and for vertical alignment
+/// it is globally top.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum FixedAlignment {
+ Start,
+ Center,
+ End,
+}
+
+impl FixedAlignment {
+ /// Returns the position of this alignment in a container with the given
+ /// extent.
+ pub fn position(self, extent: Abs) -> Abs {
+ match self {
+ Self::Start => Abs::zero(),
+ Self::Center => extent / 2.0,
+ Self::End => extent,
+ }
+ }
+
+ /// The inverse alignment.
+ pub const fn inv(self) -> Self {
+ match self {
+ Self::Start => Self::End,
+ Self::Center => Self::Center,
+ Self::End => Self::Start,
+ }
+ }
+}
+
+impl From<Side> for FixedAlignment {
+ fn from(side: Side) -> Self {
+ match side {
+ Side::Left => Self::Start,
+ Side::Top => Self::Start,
+ Side::Right => Self::End,
+ Side::Bottom => Self::End,
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/angle.rs b/crates/typst-library/src/layout/angle.rs
new file mode 100644
index 00000000..d1410786
--- /dev/null
+++ b/crates/typst-library/src/layout/angle.rs
@@ -0,0 +1,244 @@
+use std::f64::consts::PI;
+use std::fmt::{self, Debug, Formatter};
+use std::iter::Sum;
+use std::ops::{Add, Div, Mul, Neg};
+
+use ecow::EcoString;
+use typst_utils::{Numeric, Scalar};
+
+use crate::foundations::{func, repr, scope, ty, Repr};
+
+/// An angle describing a rotation.
+///
+/// Typst supports the following angular units:
+///
+/// - Degrees: `{180deg}`
+/// - Radians: `{3.14rad}`
+///
+/// # Example
+/// ```example
+/// #rotate(10deg)[Hello there!]
+/// ```
+#[ty(scope, cast)]
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Angle(Scalar);
+
+impl Angle {
+ /// The zero angle.
+ pub const fn zero() -> Self {
+ Self(Scalar::ZERO)
+ }
+
+ /// Create an angle from a number of raw units.
+ pub const fn raw(raw: f64) -> Self {
+ Self(Scalar::new(raw))
+ }
+
+ /// Create an angle from a value in a unit.
+ pub fn with_unit(val: f64, unit: AngleUnit) -> Self {
+ Self(Scalar::new(val * unit.raw_scale()))
+ }
+
+ /// Create an angle from a number of radians.
+ pub fn rad(rad: f64) -> Self {
+ Self::with_unit(rad, AngleUnit::Rad)
+ }
+
+ /// Create an angle from a number of degrees.
+ pub fn deg(deg: f64) -> Self {
+ Self::with_unit(deg, AngleUnit::Deg)
+ }
+
+ /// Get the value of this angle in raw units.
+ pub const fn to_raw(self) -> f64 {
+ (self.0).get()
+ }
+
+ /// Get the value of this angle in a unit.
+ pub fn to_unit(self, unit: AngleUnit) -> f64 {
+ self.to_raw() / unit.raw_scale()
+ }
+
+ /// The absolute value of the this angle.
+ pub fn abs(self) -> Self {
+ Self::raw(self.to_raw().abs())
+ }
+
+ /// Get the sine of this angle in radians.
+ pub fn sin(self) -> f64 {
+ self.to_rad().sin()
+ }
+
+ /// Get the cosine of this angle in radians.
+ pub fn cos(self) -> f64 {
+ self.to_rad().cos()
+ }
+
+ /// Get the tangent of this angle in radians.
+ pub fn tan(self) -> f64 {
+ self.to_rad().tan()
+ }
+
+ /// Get the quadrant of the Cartesian plane that this angle lies in.
+ ///
+ /// The angle is automatically normalized to the range `0deg..=360deg`.
+ ///
+ /// The quadrants are defined as follows:
+ /// - First: `0deg..=90deg` (top-right)
+ /// - Second: `90deg..=180deg` (top-left)
+ /// - Third: `180deg..=270deg` (bottom-left)
+ /// - Fourth: `270deg..=360deg` (bottom-right)
+ pub fn quadrant(self) -> Quadrant {
+ let angle = self.to_deg().rem_euclid(360.0);
+ if angle <= 90.0 {
+ Quadrant::First
+ } else if angle <= 180.0 {
+ Quadrant::Second
+ } else if angle <= 270.0 {
+ Quadrant::Third
+ } else {
+ Quadrant::Fourth
+ }
+ }
+}
+
+#[scope]
+impl Angle {
+ /// Converts this angle to radians.
+ #[func(name = "rad", title = "Radians")]
+ pub fn to_rad(self) -> f64 {
+ self.to_unit(AngleUnit::Rad)
+ }
+
+ /// Converts this angle to degrees.
+ #[func(name = "deg", title = "Degrees")]
+ pub fn to_deg(self) -> f64 {
+ self.to_unit(AngleUnit::Deg)
+ }
+}
+
+impl Numeric for Angle {
+ fn zero() -> Self {
+ Self::zero()
+ }
+
+ fn is_finite(self) -> bool {
+ self.0.is_finite()
+ }
+}
+
+impl Debug for Angle {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}deg", self.to_deg())
+ }
+}
+
+impl Repr for Angle {
+ fn repr(&self) -> EcoString {
+ repr::format_float_with_unit(self.to_deg(), "deg")
+ }
+}
+
+impl Neg for Angle {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self(-self.0)
+ }
+}
+
+impl Add for Angle {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self(self.0 + other.0)
+ }
+}
+
+typst_utils::sub_impl!(Angle - Angle -> Angle);
+
+impl Mul<f64> for Angle {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self {
+ Self(self.0 * other)
+ }
+}
+
+impl Mul<Angle> for f64 {
+ type Output = Angle;
+
+ fn mul(self, other: Angle) -> Angle {
+ other * self
+ }
+}
+
+impl Div for Angle {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.to_raw() / other.to_raw()
+ }
+}
+
+impl Div<f64> for Angle {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self(self.0 / other)
+ }
+}
+
+typst_utils::assign_impl!(Angle += Angle);
+typst_utils::assign_impl!(Angle -= Angle);
+typst_utils::assign_impl!(Angle *= f64);
+typst_utils::assign_impl!(Angle /= f64);
+
+impl Sum for Angle {
+ fn sum<I: Iterator<Item = Angle>>(iter: I) -> Self {
+ Self(iter.map(|s| s.0).sum())
+ }
+}
+
+/// Different units of angular measurement.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum AngleUnit {
+ /// Radians.
+ Rad,
+ /// Degrees.
+ Deg,
+}
+
+impl AngleUnit {
+ /// How many raw units correspond to a value of `1.0` in this unit.
+ fn raw_scale(self) -> f64 {
+ match self {
+ Self::Rad => 1.0,
+ Self::Deg => PI / 180.0,
+ }
+ }
+}
+
+/// A quadrant of the Cartesian plane.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum Quadrant {
+ /// The first quadrant, containing positive x and y values.
+ First,
+ /// The second quadrant, containing negative x and positive y values.
+ Second,
+ /// The third quadrant, containing negative x and y values.
+ Third,
+ /// The fourth quadrant, containing positive x and negative y values.
+ Fourth,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_angle_unit_conversion() {
+ assert!((Angle::rad(2.0 * PI).to_deg() - 360.0) < 1e-4);
+ assert!((Angle::deg(45.0).to_rad() - std::f64::consts::FRAC_PI_4) < 1e-4);
+ }
+}
diff --git a/crates/typst-library/src/layout/axes.rs b/crates/typst-library/src/layout/axes.rs
new file mode 100644
index 00000000..7a73ba79
--- /dev/null
+++ b/crates/typst-library/src/layout/axes.rs
@@ -0,0 +1,320 @@
+use std::any::Any;
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Deref, Not};
+
+use typst_utils::Get;
+
+use crate::diag::bail;
+use crate::foundations::{array, cast, Array, Resolve, Smart, StyleChain};
+use crate::layout::{Abs, Dir, Length, Ratio, Rel, Size};
+
+/// A container with a horizontal and vertical component.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Axes<T> {
+ /// The horizontal component.
+ pub x: T,
+ /// The vertical component.
+ pub y: T,
+}
+
+impl<T> Axes<T> {
+ /// Create a new instance from the two components.
+ pub const fn new(x: T, y: T) -> Self {
+ Self { x, y }
+ }
+
+ /// Create a new instance with two equal components.
+ pub fn splat(v: T) -> Self
+ where
+ T: Clone,
+ {
+ Self { x: v.clone(), y: v }
+ }
+
+ /// Map the individual fields with `f`.
+ pub fn map<F, U>(self, mut f: F) -> Axes<U>
+ where
+ F: FnMut(T) -> U,
+ {
+ Axes { x: f(self.x), y: f(self.y) }
+ }
+
+ /// Convert from `&Axes<T>` to `Axes<&T>`.
+ pub fn as_ref(&self) -> Axes<&T> {
+ Axes { x: &self.x, y: &self.y }
+ }
+
+ /// Convert from `&Axes<T>` to `Axes<&<T as Deref>::Target>`.
+ pub fn as_deref(&self) -> Axes<&T::Target>
+ where
+ T: Deref,
+ {
+ Axes { x: &self.x, y: &self.y }
+ }
+
+ /// Convert from `&mut Axes<T>` to `Axes<&mut T>`.
+ pub fn as_mut(&mut self) -> Axes<&mut T> {
+ Axes { x: &mut self.x, y: &mut self.y }
+ }
+
+ /// Zip two instances into an instance over a tuple.
+ pub fn zip<U>(self, other: Axes<U>) -> Axes<(T, U)> {
+ Axes { x: (self.x, other.x), y: (self.y, other.y) }
+ }
+
+ /// Apply a function to this and another-instance componentwise.
+ pub fn zip_map<F, V, U>(self, other: Axes<V>, mut f: F) -> Axes<U>
+ where
+ F: FnMut(T, V) -> U,
+ {
+ Axes { x: f(self.x, other.x), y: f(self.y, other.y) }
+ }
+
+ /// Whether a condition is true for at least one of fields.
+ pub fn any<F>(self, mut f: F) -> bool
+ where
+ F: FnMut(&T) -> bool,
+ {
+ f(&self.x) || f(&self.y)
+ }
+
+ /// Whether a condition is true for both fields.
+ pub fn all<F>(self, mut f: F) -> bool
+ where
+ F: FnMut(&T) -> bool,
+ {
+ f(&self.x) && f(&self.y)
+ }
+}
+
+impl<T: Default> Axes<T> {
+ /// Create a new instance with y set to its default value.
+ pub fn with_x(x: T) -> Self {
+ Self { x, y: T::default() }
+ }
+
+ /// Create a new instance with x set to its default value.
+ pub fn with_y(y: T) -> Self {
+ Self { x: T::default(), y }
+ }
+}
+
+impl<T: Ord> Axes<T> {
+ /// The component-wise minimum of this and another instance.
+ pub fn min(self, other: Self) -> Self {
+ Self { x: self.x.min(other.x), y: self.y.min(other.y) }
+ }
+
+ /// The component-wise minimum of this and another instance.
+ pub fn max(self, other: Self) -> Self {
+ Self { x: self.x.max(other.x), y: self.y.max(other.y) }
+ }
+
+ /// The minimum of width and height.
+ pub fn min_by_side(self) -> T {
+ self.x.min(self.y)
+ }
+
+ /// The minimum of width and height.
+ pub fn max_by_side(self) -> T {
+ self.x.max(self.y)
+ }
+}
+
+impl Axes<Rel<Abs>> {
+ /// Evaluate the axes relative to the given `size`.
+ pub fn relative_to(&self, size: Size) -> Size {
+ Size {
+ x: self.x.relative_to(size.x),
+ y: self.y.relative_to(size.y),
+ }
+ }
+}
+
+impl<T> Get<Axis> for Axes<T> {
+ type Component = T;
+
+ fn get_ref(&self, axis: Axis) -> &T {
+ match axis {
+ Axis::X => &self.x,
+ Axis::Y => &self.y,
+ }
+ }
+
+ fn get_mut(&mut self, axis: Axis) -> &mut T {
+ match axis {
+ Axis::X => &mut self.x,
+ Axis::Y => &mut self.y,
+ }
+ }
+}
+
+impl<T> Debug for Axes<T>
+where
+ T: Debug + 'static,
+{
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if (&self.x as &dyn Any).is::<Abs>() {
+ write!(f, "Size({:?}, {:?})", self.x, self.y)
+ } else {
+ write!(f, "Axes({:?}, {:?})", self.x, self.y)
+ }
+ }
+}
+
+/// The two layouting axes.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Axis {
+ /// The horizontal axis.
+ X,
+ /// The vertical axis.
+ Y,
+}
+
+impl Axis {
+ /// The direction with the given positivity for this axis.
+ pub fn dir(self, positive: bool) -> Dir {
+ match (self, positive) {
+ (Self::X, true) => Dir::LTR,
+ (Self::X, false) => Dir::RTL,
+ (Self::Y, true) => Dir::TTB,
+ (Self::Y, false) => Dir::BTT,
+ }
+ }
+
+ /// The other axis.
+ pub fn other(self) -> Self {
+ match self {
+ Self::X => Self::Y,
+ Self::Y => Self::X,
+ }
+ }
+}
+
+cast! {
+ Axis,
+ self => match self {
+ Self::X => "horizontal".into_value(),
+ Self::Y => "vertical".into_value(),
+ },
+ "horizontal" => Self::X,
+ "vertical" => Self::Y,
+}
+
+impl<T> Axes<Smart<T>> {
+ /// Unwrap the individual fields.
+ pub fn unwrap_or(self, other: Axes<T>) -> Axes<T> {
+ Axes {
+ x: self.x.unwrap_or(other.x),
+ y: self.y.unwrap_or(other.y),
+ }
+ }
+}
+
+impl Axes<bool> {
+ /// Select `t.x` if `self.x` is true and `f.x` otherwise and same for `y`.
+ pub fn select<T>(self, t: Axes<T>, f: Axes<T>) -> Axes<T> {
+ Axes {
+ x: if self.x { t.x } else { f.x },
+ y: if self.y { t.y } else { f.y },
+ }
+ }
+}
+
+impl Not for Axes<bool> {
+ type Output = Self;
+
+ fn not(self) -> Self::Output {
+ Self { x: !self.x, y: !self.y }
+ }
+}
+
+impl BitOr for Axes<bool> {
+ type Output = Self;
+
+ fn bitor(self, rhs: Self) -> Self::Output {
+ Self { x: self.x | rhs.x, y: self.y | rhs.y }
+ }
+}
+
+impl BitOr<bool> for Axes<bool> {
+ type Output = Self;
+
+ fn bitor(self, rhs: bool) -> Self::Output {
+ Self { x: self.x | rhs, y: self.y | rhs }
+ }
+}
+
+impl BitAnd for Axes<bool> {
+ type Output = Self;
+
+ fn bitand(self, rhs: Self) -> Self::Output {
+ Self { x: self.x & rhs.x, y: self.y & rhs.y }
+ }
+}
+
+impl BitAnd<bool> for Axes<bool> {
+ type Output = Self;
+
+ fn bitand(self, rhs: bool) -> Self::Output {
+ Self { x: self.x & rhs, y: self.y & rhs }
+ }
+}
+
+impl BitOrAssign for Axes<bool> {
+ fn bitor_assign(&mut self, rhs: Self) {
+ self.x |= rhs.x;
+ self.y |= rhs.y;
+ }
+}
+
+impl BitAndAssign for Axes<bool> {
+ fn bitand_assign(&mut self, rhs: Self) {
+ self.x &= rhs.x;
+ self.y &= rhs.y;
+ }
+}
+
+cast! {
+ Axes<Rel<Length>>,
+ self => array![self.x, self.y].into_value(),
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?),
+ _ => bail!("point array must contain exactly two entries"),
+ }
+ },
+}
+
+cast! {
+ Axes<Ratio>,
+ self => array![self.x, self.y].into_value(),
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?),
+ _ => bail!("ratio array must contain exactly two entries"),
+ }
+ },
+}
+
+cast! {
+ Axes<Length>,
+ self => array![self.x, self.y].into_value(),
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?),
+ _ => bail!("length array must contain exactly two entries"),
+ }
+ },
+}
+
+impl<T: Resolve> Resolve for Axes<T> {
+ type Output = Axes<T::Output>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.map(|v| v.resolve(styles))
+ }
+}
diff --git a/crates/typst-library/src/layout/columns.rs b/crates/typst-library/src/layout/columns.rs
new file mode 100644
index 00000000..f2f36441
--- /dev/null
+++ b/crates/typst-library/src/layout/columns.rs
@@ -0,0 +1,98 @@
+use std::num::NonZeroUsize;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, NativeElement, Packed, Show, StyleChain};
+use crate::layout::{BlockElem, Length, Ratio, Rel};
+
+/// Separates a region into multiple equally sized columns.
+///
+/// The `column` function lets you separate the interior of any container into
+/// multiple columns. It will currently not balance the height of the columns.
+/// Instead, the columns will take up the height of their container or the
+/// remaining height on the page. Support for balanced columns is planned for
+/// the future.
+///
+/// # Page-level columns { #page-level }
+/// If you need to insert columns across your whole document, use the `{page}`
+/// function's [`columns` parameter]($page.columns) instead. This will create
+/// the columns directly at the page-level rather than wrapping all of your
+/// content in a layout container. As a result, things like
+/// [pagebreaks]($pagebreak), [footnotes]($footnote), and [line
+/// numbers]($par.line) will continue to work as expected. For more information,
+/// also read the [relevant part of the page setup
+/// guide]($guides/page-setup-guide/#columns).
+///
+/// # Breaking out of columns { #breaking-out }
+/// To temporarily break out of columns (e.g. for a paper's title), use
+/// parent-scoped floating placement:
+///
+/// ```example:single
+/// #set page(columns: 2, height: 150pt)
+///
+/// #place(
+/// top + center,
+/// scope: "parent",
+/// float: true,
+/// text(1.4em, weight: "bold")[
+/// My document
+/// ],
+/// )
+///
+/// #lorem(40)
+/// ```
+#[elem(Show)]
+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 Show for Packed<ColumnsElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::multi_layouter(self.clone(), engine.routines.layout_columns)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// Forces a column break.
+///
+/// The function will behave like a [page break]($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
+/// #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.
+/// ```
+#[elem(title = "Column Break")]
+pub struct ColbreakElem {
+ /// If `{true}`, the column break is skipped if the current column is
+ /// already empty.
+ #[default(false)]
+ pub weak: bool,
+}
diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs
new file mode 100644
index 00000000..266d1d88
--- /dev/null
+++ b/crates/typst-library/src/layout/container.rs
@@ -0,0 +1,563 @@
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Args, AutoValue, Construct, Content, NativeElement, Packed, Smart,
+ StyleChain, Value,
+};
+use crate::introspection::Locator;
+use crate::layout::{
+ Abs, Corners, Em, Fr, Fragment, Frame, Length, Region, Regions, Rel, Sides, Size,
+ Spacing,
+};
+use crate::visualize::{Paint, Stroke};
+
+/// 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
+/// Refer to the docs
+/// #box(
+/// height: 9pt,
+/// image("docs.svg")
+/// )
+/// for more information.
+/// ```
+#[elem]
+pub struct BoxElem {
+ /// The width of the box.
+ ///
+ /// Boxes can have [fractional]($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]($rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// The box's border color. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Stroke>>>,
+
+ /// How much to round the box's corners. See the
+ /// [rectangle's documentation]($rect.radius) for more details.
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the box's content.
+ ///
+ /// _Note:_ When the box contains text, its exact size depends on the
+ /// current [text edges]($text.top-edge).
+ ///
+ /// ```example
+ /// #rect(inset: 0pt)[Tight]
+ /// ```
+ #[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]($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.
+ ///
+ /// Clipping is useful when the box's content is larger than the box itself,
+ /// as any content that exceeds the box's bounds will be hidden.
+ ///
+ /// ```example
+ /// #box(
+ /// width: 50pt,
+ /// height: 50pt,
+ /// clip: true,
+ /// image("tiger.jpg", width: 100pt, height: 100pt)
+ /// )
+ /// ```
+ #[default(false)]
+ pub clip: bool,
+
+ /// The contents of the box.
+ #[positional]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+/// An inline-level container that can produce arbitrary items that can break
+/// across lines.
+#[elem(Construct)]
+pub struct InlineElem {
+ /// A callback that is invoked with the regions to produce arbitrary
+ /// inline items.
+ #[required]
+ #[internal]
+ body: callbacks::InlineCallback,
+}
+
+impl Construct for InlineElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl InlineElem {
+ /// Create an inline-level item with a custom layouter.
+ #[allow(clippy::type_complexity)]
+ pub fn layouter<T: NativeElement>(
+ captured: Packed<T>,
+ callback: fn(
+ content: &Packed<T>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Size,
+ ) -> SourceResult<Vec<InlineItem>>,
+ ) -> Self {
+ Self::new(callbacks::InlineCallback::new(captured, callback))
+ }
+}
+
+impl Packed<InlineElem> {
+ /// Layout the element.
+ pub fn layout(
+ &self,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Size,
+ ) -> SourceResult<Vec<InlineItem>> {
+ self.body().call(engine, locator, styles, region)
+ }
+}
+
+/// Layouted items suitable for placing in a paragraph.
+#[derive(Debug, Clone)]
+pub enum InlineItem {
+ /// Absolute spacing between other items, and whether it is weak.
+ Space(Abs, bool),
+ /// Layouted inline-level content.
+ 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
+/// 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.
+/// ```
+#[elem]
+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`]($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: Sizing,
+
+ /// 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]($rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// The block's border color. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Stroke>>>,
+
+ /// How much to round the block's corners. See the
+ /// [rectangle's documentation]($rect.radius) for more details.
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the block's content. See the
+ /// [box's documentation]($box.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 [box's documentation]($box.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The spacing around the block. When `{auto}`, inherits the paragraph
+ /// [`spacing`]($par.spacing).
+ ///
+ /// For two adjacent blocks, the larger of the first block's `above` and the
+ /// second block's `below` spacing wins. Moreover, block spacing takes
+ /// precedence over paragraph [`spacing`]($par.spacing).
+ ///
+ /// Note that this is only a shorthand to set `above` and `below` to the
+ /// same value. Since the values for `above` and `below` might differ, a
+ /// [context] block only provides access to `{block.above}` and
+ /// `{block.below}`, not to `{block.spacing}` directly.
+ ///
+ /// This property can be used in combination with a show rule to adjust the
+ /// spacing around arbitrary block-level elements.
+ ///
+ /// ```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.
+ #[parse(
+ let spacing = args.named("spacing")?;
+ args.named("above")?.or(spacing)
+ )]
+ pub above: Smart<Spacing>,
+
+ /// The spacing between this block and its successor.
+ #[parse(args.named("below")?.or(spacing))]
+ pub below: Smart<Spacing>,
+
+ /// Whether to clip the content inside the block.
+ ///
+ /// Clipping is useful when the block's content is larger than the block itself,
+ /// as any content that exceeds the block's bounds will be hidden.
+ ///
+ /// ```example
+ /// #block(
+ /// width: 50pt,
+ /// height: 50pt,
+ /// clip: true,
+ /// image("tiger.jpg", width: 100pt, height: 100pt)
+ /// )
+ /// ```
+ #[default(false)]
+ pub clip: bool,
+
+ /// Whether this block must stick to the following one, with no break in
+ /// between.
+ ///
+ /// This is, by default, set on heading blocks to prevent orphaned headings
+ /// at the bottom of the page.
+ ///
+ /// ```example
+ /// >>> #set page(height: 140pt)
+ /// // Disable stickiness of headings.
+ /// #show heading: set block(sticky: false)
+ /// #lorem(20)
+ ///
+ /// = Chapter
+ /// #lorem(10)
+ /// ```
+ #[default(false)]
+ pub sticky: bool,
+
+ /// The contents of the block.
+ #[positional]
+ #[borrowed]
+ pub body: Option<BlockBody>,
+}
+
+impl BlockElem {
+ /// Create a block with a custom single-region layouter.
+ ///
+ /// Such a block must have `breakable: false` (which is set by this
+ /// constructor).
+ pub fn single_layouter<T: NativeElement>(
+ captured: Packed<T>,
+ f: fn(
+ content: &Packed<T>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>,
+ ) -> Self {
+ Self::new()
+ .with_breakable(false)
+ .with_body(Some(BlockBody::SingleLayouter(
+ callbacks::BlockSingleCallback::new(captured, f),
+ )))
+ }
+
+ /// Create a block with a custom multi-region layouter.
+ pub fn multi_layouter<T: NativeElement>(
+ captured: Packed<T>,
+ f: fn(
+ content: &Packed<T>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>,
+ ) -> Self {
+ Self::new().with_body(Some(BlockBody::MultiLayouter(
+ callbacks::BlockMultiCallback::new(captured, f),
+ )))
+ }
+}
+
+/// The contents of a block.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum BlockBody {
+ /// The block contains normal content.
+ Content(Content),
+ /// The block contains a layout callback that needs access to just one
+ /// base region.
+ SingleLayouter(callbacks::BlockSingleCallback),
+ /// The block contains a layout callback that needs access to the exact
+ /// regions.
+ MultiLayouter(callbacks::BlockMultiCallback),
+}
+
+impl Default for BlockBody {
+ fn default() -> Self {
+ Self::Content(Content::default())
+ }
+}
+
+cast! {
+ BlockBody,
+ self => match self {
+ Self::Content(content) => content.into_value(),
+ _ => Value::Auto,
+ },
+ v: Content => Self::Content(v),
+}
+
+/// Defines how to size something along an axis.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Sizing {
+ /// A track that fits its item's contents.
+ Auto,
+ /// A size specified in absolute terms and relative to the parent's size.
+ Rel(Rel),
+ /// A size specified as a fraction of the remaining free space in the
+ /// parent.
+ Fr(Fr),
+}
+
+impl Sizing {
+ /// Whether this is an automatic sizing.
+ pub fn is_auto(self) -> bool {
+ matches!(self, Self::Auto)
+ }
+
+ /// 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 From<Smart<Rel>> for Sizing {
+ fn from(smart: Smart<Rel>) -> Self {
+ match smart {
+ Smart::Auto => Self::Auto,
+ Smart::Custom(rel) => Self::Rel(rel),
+ }
+ }
+}
+
+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),
+}
+
+/// Manual closure implementations for layout callbacks.
+///
+/// Normal closures are not `Hash`, so we can't use them.
+mod callbacks {
+ use super::*;
+
+ macro_rules! callback {
+ ($name:ident = ($($param:ident: $param_ty:ty),* $(,)?) -> $ret:ty) => {
+ #[derive(Debug, Clone, PartialEq, Hash)]
+ pub struct $name {
+ captured: Content,
+ f: fn(&Content, $($param_ty),*) -> $ret,
+ }
+
+ impl $name {
+ pub fn new<T: NativeElement>(
+ captured: Packed<T>,
+ f: fn(&Packed<T>, $($param_ty),*) -> $ret,
+ ) -> Self {
+ Self {
+ // Type-erased the content.
+ captured: captured.pack(),
+ // Safety: The only difference between the two function
+ // pointer types is the type of the first parameter,
+ // which changes from `&Packed<T>` to `&Content`. This
+ // is safe because:
+ // - `Packed<T>` is a transparent wrapper around
+ // `Content`, so for any `T` it has the same memory
+ // representation as `Content`.
+ // - While `Packed<T>` imposes the additional constraint
+ // that the content is of type `T`, this constraint is
+ // upheld: It is initially the case because we store a
+ // `Packed<T>` above. It keeps being the case over the
+ // lifetime of the closure because `capture` is a
+ // private field and `Content`'s `Clone` impl is
+ // guaranteed to retain the type (if it didn't,
+ // literally everything would break).
+ #[allow(clippy::missing_transmute_annotations)]
+ f: unsafe { std::mem::transmute(f) },
+ }
+ }
+
+ pub fn call(&self, $($param: $param_ty),*) -> $ret {
+ (self.f)(&self.captured, $($param),*)
+ }
+ }
+ };
+ }
+
+ callback! {
+ InlineCallback = (
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Size,
+ ) -> SourceResult<Vec<InlineItem>>
+ }
+
+ callback! {
+ BlockSingleCallback = (
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+ }
+
+ callback! {
+ BlockMultiCallback = (
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+ }
+}
diff --git a/crates/typst-library/src/layout/corners.rs b/crates/typst-library/src/layout/corners.rs
new file mode 100644
index 00000000..fe69126a
--- /dev/null
+++ b/crates/typst-library/src/layout/corners.rs
@@ -0,0 +1,315 @@
+use std::fmt::{self, Debug, Formatter};
+
+use typst_utils::Get;
+
+use crate::diag::HintedStrResult;
+use crate::foundations::{
+ AlternativeFold, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve,
+ StyleChain, Value,
+};
+use crate::layout::Side;
+
+/// A container with components for the four corners of a rectangle.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Corners<T> {
+ /// The value for the top left corner.
+ pub top_left: T,
+ /// The value for the top right corner.
+ pub top_right: T,
+ /// The value for the bottom right corner.
+ pub bottom_right: T,
+ /// The value for the bottom left corner.
+ pub bottom_left: T,
+}
+
+impl<T> Corners<T> {
+ /// Create a new instance from the four components.
+ pub const fn new(top_left: T, top_right: T, bottom_right: T, bottom_left: T) -> Self {
+ Self { top_left, top_right, bottom_right, bottom_left }
+ }
+
+ /// Create an instance with four equal components.
+ pub fn splat(value: T) -> Self
+ where
+ T: Clone,
+ {
+ Self {
+ top_left: value.clone(),
+ top_right: value.clone(),
+ bottom_right: value.clone(),
+ bottom_left: value,
+ }
+ }
+
+ /// Map the individual fields with `f`.
+ pub fn map<F, U>(self, mut f: F) -> Corners<U>
+ where
+ F: FnMut(T) -> U,
+ {
+ Corners {
+ top_left: f(self.top_left),
+ top_right: f(self.top_right),
+ bottom_right: f(self.bottom_right),
+ bottom_left: f(self.bottom_left),
+ }
+ }
+
+ /// Zip two instances into one.
+ pub fn zip<U>(self, other: Corners<U>) -> Corners<(T, U)> {
+ Corners {
+ top_left: (self.top_left, other.top_left),
+ top_right: (self.top_right, other.top_right),
+ bottom_right: (self.bottom_right, other.bottom_right),
+ bottom_left: (self.bottom_left, other.bottom_left),
+ }
+ }
+
+ /// An iterator over the corners, starting with the top left corner,
+ /// clockwise.
+ pub fn iter(&self) -> impl Iterator<Item = &T> {
+ [&self.top_left, &self.top_right, &self.bottom_right, &self.bottom_left]
+ .into_iter()
+ }
+
+ /// Whether all sides are equal.
+ pub fn is_uniform(&self) -> bool
+ where
+ T: PartialEq,
+ {
+ self.top_left == self.top_right
+ && self.top_right == self.bottom_right
+ && self.bottom_right == self.bottom_left
+ }
+}
+
+impl<T> Corners<Option<T>> {
+ /// Unwrap-or-default the individual corners.
+ pub fn unwrap_or_default(self) -> Corners<T>
+ where
+ T: Default,
+ {
+ self.map(Option::unwrap_or_default)
+ }
+}
+
+impl<T> Get<Corner> for Corners<T> {
+ type Component = T;
+
+ fn get_ref(&self, corner: Corner) -> &T {
+ match corner {
+ Corner::TopLeft => &self.top_left,
+ Corner::TopRight => &self.top_right,
+ Corner::BottomRight => &self.bottom_right,
+ Corner::BottomLeft => &self.bottom_left,
+ }
+ }
+
+ fn get_mut(&mut self, corner: Corner) -> &mut T {
+ match corner {
+ Corner::TopLeft => &mut self.top_left,
+ Corner::TopRight => &mut self.top_right,
+ Corner::BottomRight => &mut self.bottom_right,
+ Corner::BottomLeft => &mut self.bottom_left,
+ }
+ }
+}
+
+impl<T: Debug + PartialEq> Debug for Corners<T> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if self.is_uniform() {
+ f.write_str("Corners::splat(")?;
+ self.top_left.fmt(f)?;
+ f.write_str(")")
+ } else {
+ f.debug_struct("Corners")
+ .field("top_left", &self.top_left)
+ .field("top_right", &self.top_right)
+ .field("bottom_right", &self.bottom_right)
+ .field("bottom_left", &self.bottom_left)
+ .finish()
+ }
+ }
+}
+
+impl<T: Reflect> Reflect for Corners<Option<T>> {
+ fn input() -> CastInfo {
+ T::input() + Dict::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output() + Dict::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Dict::castable(value) || T::castable(value)
+ }
+}
+
+impl<T> IntoValue for Corners<Option<T>>
+where
+ T: PartialEq + IntoValue,
+{
+ fn into_value(self) -> Value {
+ if self.is_uniform() {
+ if let Some(top_left) = self.top_left {
+ return top_left.into_value();
+ }
+ }
+
+ let mut dict = Dict::new();
+ let mut handle = |key: &str, component: Option<T>| {
+ if let Some(c) = component {
+ dict.insert(key.into(), c.into_value());
+ }
+ };
+
+ handle("top-left", self.top_left);
+ handle("top-right", self.top_right);
+ handle("bottom-right", self.bottom_right);
+ handle("bottom-left", self.bottom_left);
+
+ Value::Dict(dict)
+ }
+}
+
+impl<T> FromValue for Corners<Option<T>>
+where
+ T: FromValue + Clone,
+{
+ fn from_value(mut value: Value) -> HintedStrResult<Self> {
+ let expected_keys = [
+ "top-left",
+ "top-right",
+ "bottom-right",
+ "bottom-left",
+ "left",
+ "top",
+ "right",
+ "bottom",
+ "rest",
+ ];
+
+ if let Value::Dict(dict) = &mut value {
+ if dict.is_empty() {
+ return Ok(Self::splat(None));
+ } else if dict.iter().any(|(key, _)| expected_keys.contains(&key.as_str())) {
+ let mut take = |key| dict.take(key).ok().map(T::from_value).transpose();
+ let rest = take("rest")?;
+ let left = take("left")?.or_else(|| rest.clone());
+ let top = take("top")?.or_else(|| rest.clone());
+ let right = take("right")?.or_else(|| rest.clone());
+ let bottom = take("bottom")?.or_else(|| rest.clone());
+ let corners = Corners {
+ top_left: take("top-left")?
+ .or_else(|| top.clone())
+ .or_else(|| left.clone()),
+ top_right: take("top-right")?
+ .or_else(|| top.clone())
+ .or_else(|| right.clone()),
+ bottom_right: take("bottom-right")?
+ .or_else(|| bottom.clone())
+ .or_else(|| right.clone()),
+ bottom_left: take("bottom-left")?
+ .or_else(|| bottom.clone())
+ .or_else(|| left.clone()),
+ };
+
+ dict.finish(&expected_keys)?;
+ return Ok(corners);
+ }
+ }
+
+ if T::castable(&value) {
+ Ok(Self::splat(Some(T::from_value(value)?)))
+ } else if let Value::Dict(dict) = &value {
+ let keys = dict.iter().map(|kv| kv.0.as_str()).collect();
+ // Do not hint at expected_keys, because T may be castable from Dict
+ // objects with other sets of expected keys.
+ Err(Dict::unexpected_keys(keys, None).into())
+ } else {
+ Err(Self::error(&value))
+ }
+ }
+}
+
+impl<T: Resolve> Resolve for Corners<T> {
+ type Output = Corners<T::Output>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.map(|v| v.resolve(styles))
+ }
+}
+
+impl<T: Fold> Fold for Corners<Option<T>> {
+ fn fold(self, outer: Self) -> Self {
+ // Usually, folding an inner `None` with an `outer` prefers the
+ // explicit `None`. However, here `None` means unspecified and thus
+ // we want `outer`, so we use `fold_or` to opt into such behavior.
+ self.zip(outer).map(|(inner, outer)| inner.fold_or(outer))
+ }
+}
+
+/// The four corners of a rectangle.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Corner {
+ /// The top left corner.
+ TopLeft,
+ /// The top right corner.
+ TopRight,
+ /// The bottom right corner.
+ BottomRight,
+ /// The bottom left corner.
+ BottomLeft,
+}
+
+impl Corner {
+ /// The opposite corner.
+ pub fn inv(self) -> Self {
+ match self {
+ Self::TopLeft => Self::BottomRight,
+ Self::TopRight => Self::BottomLeft,
+ Self::BottomRight => Self::TopLeft,
+ Self::BottomLeft => Self::TopRight,
+ }
+ }
+
+ /// The next corner, clockwise.
+ pub fn next_cw(self) -> Self {
+ match self {
+ Self::TopLeft => Self::TopRight,
+ Self::TopRight => Self::BottomRight,
+ Self::BottomRight => Self::BottomLeft,
+ Self::BottomLeft => Self::TopLeft,
+ }
+ }
+
+ /// The next corner, counter-clockwise.
+ pub fn next_ccw(self) -> Self {
+ match self {
+ Self::TopLeft => Self::BottomLeft,
+ Self::TopRight => Self::TopLeft,
+ Self::BottomRight => Self::TopRight,
+ Self::BottomLeft => Self::BottomRight,
+ }
+ }
+
+ /// The next side, clockwise.
+ pub fn side_cw(self) -> Side {
+ match self {
+ Self::TopLeft => Side::Top,
+ Self::TopRight => Side::Right,
+ Self::BottomRight => Side::Bottom,
+ Self::BottomLeft => Side::Left,
+ }
+ }
+
+ /// The next side, counter-clockwise.
+ pub fn side_ccw(self) -> Side {
+ match self {
+ Self::TopLeft => Side::Left,
+ Self::TopRight => Side::Top,
+ Self::BottomRight => Side::Right,
+ Self::BottomLeft => Side::Bottom,
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/dir.rs b/crates/typst-library/src/layout/dir.rs
new file mode 100644
index 00000000..9a2e7710
--- /dev/null
+++ b/crates/typst-library/src/layout/dir.rs
@@ -0,0 +1,132 @@
+use ecow::EcoString;
+
+use crate::foundations::{func, scope, ty, Repr};
+use crate::layout::{Axis, Side};
+
+/// The four directions into which content can be laid out.
+///
+/// Possible values are:
+/// - `{ltr}`: Left to right.
+/// - `{rtl}`: Right to left.
+/// - `{ttb}`: Top to bottom.
+/// - `{btt}`: Bottom to top.
+///
+/// These values are available globally and
+/// also in the direction type's scope, so you can write either of the following
+/// two:
+/// ```example
+/// #stack(dir: rtl)[A][B][C]
+/// #stack(dir: direction.rtl)[A][B][C]
+/// ```
+#[ty(scope, name = "direction")]
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Dir {
+ /// Left to right.
+ LTR,
+ /// Right to left.
+ RTL,
+ /// Top to bottom.
+ TTB,
+ /// Bottom to top.
+ BTT,
+}
+
+impl Dir {
+ /// Whether this direction points into the positive coordinate direction.
+ ///
+ /// The positive directions are left-to-right and top-to-bottom.
+ pub const fn is_positive(self) -> bool {
+ match self {
+ Self::LTR | Self::TTB => true,
+ Self::RTL | Self::BTT => false,
+ }
+ }
+}
+
+#[scope]
+impl Dir {
+ pub const LTR: Self = Self::LTR;
+ pub const RTL: Self = Self::RTL;
+ pub const TTB: Self = Self::TTB;
+ pub const BTT: Self = Self::BTT;
+
+ /// The axis this direction belongs to, either `{"horizontal"}` or
+ /// `{"vertical"}`.
+ ///
+ /// ```example
+ /// #ltr.axis() \
+ /// #ttb.axis()
+ /// ```
+ #[func]
+ pub const fn axis(self) -> Axis {
+ match self {
+ Self::LTR | Self::RTL => Axis::X,
+ Self::TTB | Self::BTT => Axis::Y,
+ }
+ }
+
+ /// The start point of this direction, as an alignment.
+ ///
+ /// ```example
+ /// #ltr.start() \
+ /// #rtl.start() \
+ /// #ttb.start() \
+ /// #btt.start()
+ /// ```
+ #[func]
+ pub const fn start(self) -> Side {
+ match self {
+ Self::LTR => Side::Left,
+ Self::RTL => Side::Right,
+ Self::TTB => Side::Top,
+ Self::BTT => Side::Bottom,
+ }
+ }
+
+ /// The end point of this direction, as an alignment.
+ ///
+ /// ```example
+ /// #ltr.end() \
+ /// #rtl.end() \
+ /// #ttb.end() \
+ /// #btt.end()
+ /// ```
+ #[func]
+ pub const fn end(self) -> Side {
+ match self {
+ Self::LTR => Side::Right,
+ Self::RTL => Side::Left,
+ Self::TTB => Side::Bottom,
+ Self::BTT => Side::Top,
+ }
+ }
+
+ /// The inverse direction.
+ ///
+ /// ```example
+ /// #ltr.inv() \
+ /// #rtl.inv() \
+ /// #ttb.inv() \
+ /// #btt.inv()
+ /// ```
+ #[func(title = "Inverse")]
+ pub const fn inv(self) -> Dir {
+ match self {
+ Self::LTR => Self::RTL,
+ Self::RTL => Self::LTR,
+ Self::TTB => Self::BTT,
+ Self::BTT => Self::TTB,
+ }
+ }
+}
+
+impl Repr for Dir {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::LTR => "ltr".into(),
+ Self::RTL => "rtl".into(),
+ Self::TTB => "ttb".into(),
+ Self::BTT => "btt".into(),
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/em.rs b/crates/typst-library/src/layout/em.rs
new file mode 100644
index 00000000..e2d8b120
--- /dev/null
+++ b/crates/typst-library/src/layout/em.rs
@@ -0,0 +1,168 @@
+use std::fmt::{self, Debug, Formatter};
+use std::iter::Sum;
+use std::ops::{Add, Div, Mul, Neg};
+
+use ecow::EcoString;
+use typst_utils::{Numeric, Scalar};
+
+use crate::foundations::{cast, repr, Repr, Resolve, StyleChain, Value};
+use crate::layout::Abs;
+use crate::text::TextElem;
+
+/// A length that is relative to the font size.
+///
+/// `1em` is the same as the font size.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Em(Scalar);
+
+impl Em {
+ /// The zero em length.
+ pub const fn zero() -> Self {
+ Self(Scalar::ZERO)
+ }
+
+ /// The font size.
+ pub const fn one() -> Self {
+ Self(Scalar::ONE)
+ }
+
+ /// Create a font-relative length.
+ pub const fn new(em: f64) -> Self {
+ Self(Scalar::new(em))
+ }
+
+ /// Create an em length from font units at the given units per em.
+ pub fn from_units(units: impl Into<f64>, units_per_em: f64) -> Self {
+ Self(Scalar::new(units.into() / units_per_em))
+ }
+
+ /// Create an em length from a length at the given font size.
+ pub fn from_length(length: Abs, font_size: Abs) -> Self {
+ let result = length / font_size;
+ if result.is_finite() {
+ Self(Scalar::new(result))
+ } else {
+ Self::zero()
+ }
+ }
+
+ /// The number of em units.
+ pub const fn get(self) -> f64 {
+ (self.0).get()
+ }
+
+ /// The absolute value of this em length.
+ pub fn abs(self) -> Self {
+ Self::new(self.get().abs())
+ }
+
+ /// Convert to an absolute length at the given font size.
+ pub fn at(self, font_size: Abs) -> Abs {
+ let resolved = font_size * self.get();
+ if resolved.is_finite() {
+ resolved
+ } else {
+ Abs::zero()
+ }
+ }
+}
+
+impl Numeric for Em {
+ fn zero() -> Self {
+ Self::zero()
+ }
+
+ fn is_finite(self) -> bool {
+ self.0.is_finite()
+ }
+}
+
+impl Debug for Em {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}em", self.get())
+ }
+}
+
+impl Repr for Em {
+ fn repr(&self) -> EcoString {
+ repr::format_float_with_unit(self.get(), "em")
+ }
+}
+
+impl Neg for Em {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self(-self.0)
+ }
+}
+
+impl Add for Em {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self(self.0 + other.0)
+ }
+}
+
+typst_utils::sub_impl!(Em - Em -> Em);
+
+impl Mul<f64> for Em {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self {
+ Self(self.0 * other)
+ }
+}
+
+impl Mul<Em> for f64 {
+ type Output = Em;
+
+ fn mul(self, other: Em) -> Em {
+ other * self
+ }
+}
+
+impl Div<f64> for Em {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self(self.0 / other)
+ }
+}
+
+impl Div for Em {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.get() / other.get()
+ }
+}
+
+typst_utils::assign_impl!(Em += Em);
+typst_utils::assign_impl!(Em -= Em);
+typst_utils::assign_impl!(Em *= f64);
+typst_utils::assign_impl!(Em /= f64);
+
+impl Sum for Em {
+ fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
+ Self(iter.map(|s| s.0).sum())
+ }
+}
+
+cast! {
+ Em,
+ self => Value::Length(self.into()),
+}
+
+impl Resolve for Em {
+ type Output = Abs;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ if self.is_zero() {
+ Abs::zero()
+ } else {
+ self.at(TextElem::size_in(styles))
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/fr.rs b/crates/typst-library/src/layout/fr.rs
new file mode 100644
index 00000000..ff8a6e6b
--- /dev/null
+++ b/crates/typst-library/src/layout/fr.rs
@@ -0,0 +1,145 @@
+use std::fmt::{self, Debug, Formatter};
+use std::iter::Sum;
+use std::ops::{Add, Div, Mul, Neg};
+
+use ecow::EcoString;
+use typst_utils::{Numeric, Scalar};
+
+use crate::foundations::{repr, ty, Repr};
+use crate::layout::Abs;
+
+/// Defines how the remaining space in a layout is distributed.
+///
+/// Each fractionally sized element gets space based on the ratio of its
+/// fraction to the sum of all fractions.
+///
+/// For more details, also see the [h] and [v] functions and the
+/// [grid function]($grid).
+///
+/// # Example
+/// ```example
+/// Left #h(1fr) Left-ish #h(2fr) Right
+/// ```
+#[ty(cast, name = "fraction")]
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Fr(Scalar);
+
+impl Fr {
+ /// Takes up zero space: `0fr`.
+ pub const fn zero() -> Self {
+ Self(Scalar::ZERO)
+ }
+
+ /// Takes up as much space as all other items with this fraction: `1fr`.
+ pub const fn one() -> Self {
+ Self(Scalar::ONE)
+ }
+
+ /// Create a new fraction.
+ pub const fn new(ratio: f64) -> Self {
+ Self(Scalar::new(ratio))
+ }
+
+ /// Get the underlying number.
+ pub const fn get(self) -> f64 {
+ (self.0).get()
+ }
+
+ /// The absolute value of this fraction.
+ pub fn abs(self) -> Self {
+ Self::new(self.get().abs())
+ }
+
+ /// Determine this fraction's share in the remaining space.
+ pub fn share(self, total: Self, remaining: Abs) -> Abs {
+ let ratio = self / total;
+ if ratio.is_finite() && remaining.is_finite() {
+ (ratio * remaining).max(Abs::zero())
+ } else {
+ Abs::zero()
+ }
+ }
+}
+
+impl Numeric for Fr {
+ fn zero() -> Self {
+ Self::zero()
+ }
+
+ fn is_finite(self) -> bool {
+ self.0.is_finite()
+ }
+}
+
+impl Debug for Fr {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}fr", self.get())
+ }
+}
+
+impl Repr for Fr {
+ fn repr(&self) -> EcoString {
+ repr::format_float_with_unit(self.get(), "fr")
+ }
+}
+
+impl Neg for Fr {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self(-self.0)
+ }
+}
+
+impl Add for Fr {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self(self.0 + other.0)
+ }
+}
+
+typst_utils::sub_impl!(Fr - Fr -> Fr);
+
+impl Mul<f64> for Fr {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self {
+ Self(self.0 * other)
+ }
+}
+
+impl Mul<Fr> for f64 {
+ type Output = Fr;
+
+ fn mul(self, other: Fr) -> Fr {
+ other * self
+ }
+}
+
+impl Div for Fr {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.get() / other.get()
+ }
+}
+
+impl Div<f64> for Fr {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self(self.0 / other)
+ }
+}
+
+typst_utils::assign_impl!(Fr += Fr);
+typst_utils::assign_impl!(Fr -= Fr);
+typst_utils::assign_impl!(Fr *= f64);
+typst_utils::assign_impl!(Fr /= f64);
+
+impl Sum for Fr {
+ fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
+ Self(iter.map(|s| s.0).sum())
+ }
+}
diff --git a/crates/typst-library/src/layout/fragment.rs b/crates/typst-library/src/layout/fragment.rs
new file mode 100644
index 00000000..c2666115
--- /dev/null
+++ b/crates/typst-library/src/layout/fragment.rs
@@ -0,0 +1,94 @@
+use std::fmt::{self, Debug, Formatter};
+
+use crate::layout::Frame;
+
+/// 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
+ }
+
+ /// Extract a slice with the contained frames.
+ pub fn as_slice(&self) -> &[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/frame.rs b/crates/typst-library/src/layout/frame.rs
new file mode 100644
index 00000000..204584fc
--- /dev/null
+++ b/crates/typst-library/src/layout/frame.rs
@@ -0,0 +1,606 @@
+//! Finished documents.
+
+use std::fmt::{self, Debug, Formatter};
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use smallvec::SmallVec;
+use typst_syntax::Span;
+use typst_utils::{LazyHash, Numeric};
+
+use crate::foundations::{cast, dict, Dict, Label, StyleChain, Value};
+use crate::introspection::{Location, Tag};
+use crate::layout::{
+ Abs, Axes, FixedAlignment, HideElem, Length, Point, Size, Transform,
+};
+use crate::model::{Destination, LinkElem};
+use crate::text::TextItem;
+use crate::visualize::{Color, FixedStroke, Geometry, Image, Paint, Path, Shape};
+
+/// A finished layout with items at fixed positions.
+#[derive(Default, Clone, Hash)]
+pub struct Frame {
+ /// The size of the frame.
+ size: Size,
+ /// The baseline of the frame measured from the top. If this is `None`, the
+ /// frame's implicit baseline is at the bottom.
+ baseline: Option<Abs>,
+ /// The items composing this layout.
+ items: Arc<LazyHash<Vec<(Point, FrameItem)>>>,
+ /// The hardness of this frame.
+ ///
+ /// Determines whether it is a boundary for gradient drawing.
+ kind: FrameKind,
+}
+
+/// Constructor, accessors and setters.
+impl Frame {
+ /// Create a new, empty frame.
+ ///
+ /// Panics the size is not finite.
+ #[track_caller]
+ pub fn new(size: Size, kind: FrameKind) -> Self {
+ assert!(size.is_finite());
+ Self {
+ size,
+ baseline: None,
+ items: Arc::new(LazyHash::new(vec![])),
+ kind,
+ }
+ }
+
+ /// Create a new, empty soft frame.
+ ///
+ /// Panics if the size is not finite.
+ #[track_caller]
+ pub fn soft(size: Size) -> Self {
+ Self::new(size, FrameKind::Soft)
+ }
+
+ /// Create a new, empty hard frame.
+ ///
+ /// Panics if the size is not finite.
+ #[track_caller]
+ pub fn hard(size: Size) -> Self {
+ Self::new(size, FrameKind::Hard)
+ }
+
+ /// Sets the frame's hardness.
+ pub fn set_kind(&mut self, kind: FrameKind) {
+ self.kind = kind;
+ }
+
+ /// Sets the frame's hardness builder-style.
+ pub fn with_kind(mut self, kind: FrameKind) -> Self {
+ self.kind = kind;
+ self
+ }
+
+ /// Whether the frame is hard or soft.
+ pub fn kind(&self) -> FrameKind {
+ self.kind
+ }
+
+ /// Whether the frame contains no items.
+ pub fn is_empty(&self) -> bool {
+ self.items.is_empty()
+ }
+
+ /// The size of the frame.
+ pub fn size(&self) -> Size {
+ self.size
+ }
+
+ /// The size of the frame, mutably.
+ pub fn size_mut(&mut self) -> &mut Size {
+ &mut self.size
+ }
+
+ /// Set the size of the frame.
+ pub fn set_size(&mut self, size: Size) {
+ self.size = size;
+ }
+
+ /// The width of the frame.
+ pub fn width(&self) -> Abs {
+ self.size.x
+ }
+
+ /// The height of the frame.
+ pub fn height(&self) -> Abs {
+ self.size.y
+ }
+
+ /// The vertical position of the frame's baseline.
+ pub fn baseline(&self) -> Abs {
+ self.baseline.unwrap_or(self.size.y)
+ }
+
+ /// Whether the frame has a non-default baseline.
+ pub fn has_baseline(&self) -> bool {
+ self.baseline.is_some()
+ }
+
+ /// Set the frame's baseline from the top.
+ pub fn set_baseline(&mut self, baseline: Abs) {
+ self.baseline = Some(baseline);
+ }
+
+ /// The distance from the baseline to the top of the frame.
+ ///
+ /// This is the same as `baseline()`, but more in line with the terminology
+ /// used in math layout.
+ pub fn ascent(&self) -> Abs {
+ self.baseline()
+ }
+
+ /// The distance from the baseline to the bottom of the frame.
+ pub fn descent(&self) -> Abs {
+ self.size.y - self.baseline()
+ }
+
+ /// An iterator over the items inside this frame alongside their positions
+ /// relative to the top-left of the frame.
+ pub fn items(&self) -> std::slice::Iter<'_, (Point, FrameItem)> {
+ self.items.iter()
+ }
+}
+
+/// Insert items and subframes.
+impl Frame {
+ /// The layer the next item will be added on. This corresponds to the number
+ /// of items in the frame.
+ pub fn layer(&self) -> usize {
+ self.items.len()
+ }
+
+ /// Add an item at a position in the foreground.
+ pub fn push(&mut self, pos: Point, item: FrameItem) {
+ Arc::make_mut(&mut self.items).push((pos, item));
+ }
+
+ /// Add multiple items at a position in the foreground.
+ ///
+ /// The first item in the iterator will be the one that is most in the
+ /// background.
+ pub fn push_multiple<I>(&mut self, items: I)
+ where
+ I: IntoIterator<Item = (Point, FrameItem)>,
+ {
+ Arc::make_mut(&mut self.items).extend(items);
+ }
+
+ /// Add a frame at a position in the foreground.
+ ///
+ /// Automatically decides whether to inline the frame or to include it as a
+ /// group based on the number of items in it.
+ pub fn push_frame(&mut self, pos: Point, frame: Frame) {
+ if self.should_inline(&frame) {
+ self.inline(self.layer(), pos, frame);
+ } else {
+ self.push(pos, FrameItem::Group(GroupItem::new(frame)));
+ }
+ }
+
+ /// Insert an item at the given layer in the frame.
+ ///
+ /// This panics if the layer is greater than the number of layers present.
+ #[track_caller]
+ pub fn insert(&mut self, layer: usize, pos: Point, item: FrameItem) {
+ Arc::make_mut(&mut self.items).insert(layer, (pos, item));
+ }
+
+ /// Add an item at a position in the background.
+ pub fn prepend(&mut self, pos: Point, item: FrameItem) {
+ self.insert(0, pos, item);
+ }
+
+ /// Add multiple items at a position in the background.
+ ///
+ /// The first item in the iterator will be the one that is most in the
+ /// background.
+ pub fn prepend_multiple<I>(&mut self, items: I)
+ where
+ I: IntoIterator<Item = (Point, FrameItem)>,
+ {
+ Arc::make_mut(&mut self.items).splice(0..0, items);
+ }
+
+ /// Add a frame at a position in the background.
+ pub fn prepend_frame(&mut self, pos: Point, frame: Frame) {
+ if self.should_inline(&frame) {
+ self.inline(0, pos, frame);
+ } else {
+ self.prepend(pos, FrameItem::Group(GroupItem::new(frame)));
+ }
+ }
+
+ /// Whether the given frame should be inlined.
+ fn should_inline(&self, frame: &Frame) -> bool {
+ // We do not inline big frames and hard frames.
+ frame.kind().is_soft() && (self.items.is_empty() || frame.items.len() <= 5)
+ }
+
+ /// Inline a frame at the given layer.
+ fn inline(&mut self, layer: usize, pos: Point, frame: Frame) {
+ // Skip work if there's nothing to do.
+ if frame.items.is_empty() {
+ return;
+ }
+
+ // Try to just reuse the items.
+ if pos.is_zero() && self.items.is_empty() {
+ self.items = frame.items;
+ return;
+ }
+
+ // Try to transfer the items without adjusting the position.
+ // Also try to reuse the items if the Arc isn't shared.
+ let range = layer..layer;
+ if pos.is_zero() {
+ let sink = Arc::make_mut(&mut self.items);
+ match Arc::try_unwrap(frame.items) {
+ Ok(items) => {
+ sink.splice(range, items.into_inner());
+ }
+ Err(arc) => {
+ sink.splice(range, arc.iter().cloned());
+ }
+ }
+ return;
+ }
+
+ // We have to adjust the item positions.
+ // But still try to reuse the items if the Arc isn't shared.
+ let sink = Arc::make_mut(&mut self.items);
+ match Arc::try_unwrap(frame.items) {
+ Ok(items) => {
+ sink.splice(
+ range,
+ items.into_inner().into_iter().map(|(p, e)| (p + pos, e)),
+ );
+ }
+ Err(arc) => {
+ sink.splice(range, arc.iter().cloned().map(|(p, e)| (p + pos, e)));
+ }
+ }
+ }
+}
+
+/// Modify the frame.
+impl Frame {
+ /// Remove all items from the frame.
+ pub fn clear(&mut self) {
+ if Arc::strong_count(&self.items) == 1 {
+ Arc::make_mut(&mut self.items).clear();
+ } else {
+ self.items = Arc::new(LazyHash::new(vec![]));
+ }
+ }
+
+ /// Adjust the frame's size, translate the original content by an offset
+ /// computed according to the given alignments, and return the amount of
+ /// offset.
+ pub fn resize(&mut self, target: Size, align: Axes<FixedAlignment>) -> Point {
+ if self.size == target {
+ return Point::zero();
+ }
+ let offset =
+ align.zip_map(target - self.size, FixedAlignment::position).to_point();
+ self.size = target;
+ self.translate(offset);
+ offset
+ }
+
+ /// Move the baseline and contents of the frame by an offset.
+ pub fn translate(&mut self, offset: Point) {
+ if !offset.is_zero() {
+ if let Some(baseline) = &mut self.baseline {
+ *baseline += offset.y;
+ }
+ for (point, _) in Arc::make_mut(&mut self.items).iter_mut() {
+ *point += offset;
+ }
+ }
+ }
+
+ /// Apply late-stage properties from the style chain to this frame. This
+ /// includes:
+ /// - `HideElem::hidden`
+ /// - `LinkElem::dests`
+ ///
+ /// This must be called on all frames produced by elements
+ /// that manually handle styles (because their children can have varying
+ /// styles). This currently includes flow, par, and equation.
+ ///
+ /// Other elements don't manually need to handle it because their parents
+ /// that result from realization will take care of it and the styles can
+ /// only apply to them as a whole, not part of it (because they don't manage
+ /// styles).
+ pub fn post_processed(mut self, styles: StyleChain) -> Self {
+ self.post_process(styles);
+ self
+ }
+
+ /// Post process in place.
+ pub fn post_process(&mut self, styles: StyleChain) {
+ if !self.is_empty() {
+ self.post_process_raw(
+ LinkElem::dests_in(styles),
+ HideElem::hidden_in(styles),
+ );
+ }
+ }
+
+ /// Apply raw late-stage properties from the raw data.
+ pub fn post_process_raw(&mut self, dests: SmallVec<[Destination; 1]>, hide: bool) {
+ if !self.is_empty() {
+ let size = self.size;
+ self.push_multiple(
+ dests
+ .into_iter()
+ .map(|dest| (Point::zero(), FrameItem::Link(dest, size))),
+ );
+ if hide {
+ self.hide();
+ }
+ }
+ }
+
+ /// Hide all content in the frame, but keep metadata.
+ pub fn hide(&mut self) {
+ Arc::make_mut(&mut self.items).retain_mut(|(_, item)| match item {
+ FrameItem::Group(group) => {
+ group.frame.hide();
+ !group.frame.is_empty()
+ }
+ FrameItem::Tag(_) => true,
+ _ => false,
+ });
+ }
+
+ /// Add a background fill.
+ pub fn fill(&mut self, fill: impl Into<Paint>) {
+ self.prepend(
+ Point::zero(),
+ FrameItem::Shape(Geometry::Rect(self.size()).filled(fill), Span::detached()),
+ );
+ }
+
+ /// Arbitrarily transform the contents of the frame.
+ pub fn transform(&mut self, transform: Transform) {
+ if !self.is_empty() {
+ self.group(|g| g.transform = transform);
+ }
+ }
+
+ /// Clip the contents of a frame to a clip path.
+ ///
+ /// The clip path can be the size of the frame in the case of a
+ /// rectangular frame. In the case of a frame with rounded corner,
+ /// this should be a path that matches the frame's outline.
+ pub fn clip(&mut self, clip_path: Path) {
+ if !self.is_empty() {
+ self.group(|g| g.clip_path = Some(clip_path));
+ }
+ }
+
+ /// Add a label to the frame.
+ pub fn label(&mut self, label: Label) {
+ self.group(|g| g.label = Some(label));
+ }
+
+ /// Set a parent for the frame. As a result, all elements in the frame
+ /// become logically ordered immediately after the given location.
+ pub fn set_parent(&mut self, parent: Location) {
+ if !self.is_empty() {
+ self.group(|g| g.parent = Some(parent));
+ }
+ }
+
+ /// Wrap the frame's contents in a group and modify that group with `f`.
+ fn group<F>(&mut self, f: F)
+ where
+ F: FnOnce(&mut GroupItem),
+ {
+ let mut wrapper = Frame::soft(self.size);
+ wrapper.baseline = self.baseline;
+ let mut group = GroupItem::new(std::mem::take(self));
+ f(&mut group);
+ wrapper.push(Point::zero(), FrameItem::Group(group));
+ *self = wrapper;
+ }
+}
+
+/// Tools for debugging.
+impl Frame {
+ /// Add a full size aqua background and a red baseline for debugging.
+ pub fn mark_box(mut self) -> Self {
+ self.mark_box_in_place();
+ self
+ }
+
+ /// Debug in place. Add a full size aqua background and a red baseline for debugging.
+ pub fn mark_box_in_place(&mut self) {
+ self.insert(
+ 0,
+ Point::zero(),
+ FrameItem::Shape(
+ Geometry::Rect(self.size).filled(Color::TEAL.with_alpha(0.5)),
+ Span::detached(),
+ ),
+ );
+ self.insert(
+ 1,
+ Point::with_y(self.baseline()),
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(self.size.x))
+ .stroked(FixedStroke::from_pair(Color::RED, Abs::pt(1.0))),
+ Span::detached(),
+ ),
+ );
+ }
+
+ /// Add a green marker at a position for debugging.
+ pub fn mark_point(&mut self, pos: Point) {
+ let radius = Abs::pt(2.0);
+ self.push(
+ pos - Point::splat(radius),
+ FrameItem::Shape(
+ Geometry::Path(Path::ellipse(Size::splat(2.0 * radius)))
+ .filled(Color::GREEN),
+ Span::detached(),
+ ),
+ );
+ }
+
+ /// Add a green marker line at a position for debugging.
+ pub fn mark_line(&mut self, y: Abs) {
+ self.push(
+ Point::with_y(y),
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(self.size.x))
+ .stroked(FixedStroke::from_pair(Color::GREEN, Abs::pt(1.0))),
+ Span::detached(),
+ ),
+ );
+ }
+}
+
+impl Debug for Frame {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Frame ")?;
+ f.debug_list()
+ .entries(self.items.iter().map(|(_, item)| item))
+ .finish()
+ }
+}
+
+/// The hardness of a frame.
+///
+/// This corresponds to whether or not the frame is considered to be the
+/// innermost parent of its contents. This is used to determine the coordinate
+/// reference system for gradients.
+#[derive(Default, Clone, Copy, PartialEq, Eq, Hash, Debug)]
+pub enum FrameKind {
+ /// A container which follows its parent's size.
+ ///
+ /// Soft frames are the default since they do not impact the layout of
+ /// a gradient set on one of its children.
+ #[default]
+ Soft,
+ /// A container which uses its own size.
+ ///
+ /// This is used for pages, blocks, and boxes.
+ Hard,
+}
+
+impl FrameKind {
+ /// Returns `true` if the frame is soft.
+ pub fn is_soft(self) -> bool {
+ matches!(self, Self::Soft)
+ }
+
+ /// Returns `true` if the frame is hard.
+ pub fn is_hard(self) -> bool {
+ matches!(self, Self::Hard)
+ }
+}
+
+/// The building block frames are composed of.
+#[derive(Clone, Hash)]
+pub enum FrameItem {
+ /// A subframe with optional transformation and clipping.
+ Group(GroupItem),
+ /// A run of shaped text.
+ Text(TextItem),
+ /// A geometric shape with optional fill and stroke.
+ Shape(Shape, Span),
+ /// An image and its size.
+ Image(Image, Size, Span),
+ /// An internal or external link to a destination.
+ Link(Destination, Size),
+ /// An introspectable element that produced something within this frame
+ /// alongside its key.
+ Tag(Tag),
+}
+
+impl Debug for FrameItem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Group(group) => group.fmt(f),
+ Self::Text(text) => write!(f, "{text:?}"),
+ Self::Shape(shape, _) => write!(f, "{shape:?}"),
+ Self::Image(image, _, _) => write!(f, "{image:?}"),
+ Self::Link(dest, _) => write!(f, "Link({dest:?})"),
+ Self::Tag(tag) => write!(f, "{tag:?}"),
+ }
+ }
+}
+
+/// A subframe with optional transformation and clipping.
+#[derive(Clone, Hash)]
+pub struct GroupItem {
+ /// The group's frame.
+ pub frame: Frame,
+ /// A transformation to apply to the group.
+ pub transform: Transform,
+ /// Whether the frame should be a clipping boundary.
+ pub clip_path: Option<Path>,
+ /// The group's label.
+ pub label: Option<Label>,
+ /// The group's logical parent. All elements in this group are logically
+ /// ordered immediately after the parent's start location.
+ pub parent: Option<Location>,
+}
+
+impl GroupItem {
+ /// Create a new group with default settings.
+ pub fn new(frame: Frame) -> Self {
+ Self {
+ frame,
+ transform: Transform::identity(),
+ clip_path: None,
+ label: None,
+ parent: None,
+ }
+ }
+}
+
+impl Debug for GroupItem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Group ")?;
+ self.frame.fmt(f)
+ }
+}
+
+/// A physical position in a document.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Position {
+ /// The page, starting at 1.
+ pub page: NonZeroUsize,
+ /// The exact coordinates on the page (from the top left, as usual).
+ pub point: Point,
+}
+
+cast! {
+ Position,
+ self => Value::Dict(self.into()),
+ mut dict: Dict => {
+ let page = dict.take("page")?.cast()?;
+ let x: Length = dict.take("x")?.cast()?;
+ let y: Length = dict.take("y")?.cast()?;
+ dict.finish(&["page", "x", "y"])?;
+ Self { page, point: Point::new(x.abs, y.abs) }
+ },
+}
+
+impl From<Position> for Dict {
+ fn from(pos: Position) -> Self {
+ dict! {
+ "page" => pos.page,
+ "x" => pos.point.x,
+ "y" => pos.point.y,
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/grid.rs b/crates/typst-library/src/layout/grid.rs
new file mode 100644
index 00000000..2e1e9abc
--- /dev/null
+++ b/crates/typst-library/src/layout/grid.rs
@@ -0,0 +1,924 @@
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use comemo::Track;
+use smallvec::{smallvec, SmallVec};
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, At, HintedStrResult, HintedString, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Array, CastInfo, Content, Context, Fold, FromValue, Func,
+ IntoValue, NativeElement, Packed, Reflect, Resolve, Show, Smart, StyleChain, Value,
+};
+use crate::layout::{
+ Alignment, BlockElem, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, Sizing,
+};
+use crate::model::{TableCell, TableFooter, TableHLine, TableHeader, TableVLine};
+use crate::visualize::{Paint, Stroke};
+
+/// 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.
+///
+/// While the grid and table elements work very similarly, they are intended for
+/// different use cases and carry different semantics. The grid element is
+/// intended for presentational and layout purposes, while the
+/// [`{table}`]($table) element is intended for, in broad terms, presenting
+/// multiple related data points. In the future, Typst will annotate its output
+/// such that screenreaders will announce content in `table` as tabular while a
+/// grid's content will be announced no different than multiple content blocks
+/// in the document flow. Set and show rules on one of these elements do not
+/// affect the other.
+///
+/// A grid's sizing 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
+/// width, 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)}`.
+///
+/// # Examples
+/// The example below demonstrates the different track sizing options. It also
+/// shows how you can use [`grid.cell`]($grid.cell) to make an individual cell
+/// span two grid tracks.
+///
+/// ```example
+/// // We use `rect` to emphasize the
+/// // area of cells.
+/// #set rect(
+/// inset: 8pt,
+/// fill: rgb("e4e5ea"),
+/// width: 100%,
+/// )
+///
+/// #grid(
+/// columns: (60pt, 1fr, 2fr),
+/// rows: (auto, 60pt),
+/// gutter: 3pt,
+/// rect[Fixed width, auto height],
+/// rect[1/3 of the remains],
+/// rect[2/3 of the remains],
+/// rect(height: 100%)[Fixed height],
+/// grid.cell(
+/// colspan: 2,
+/// image("tiger.jpg", width: 100%),
+/// ),
+/// )
+/// ```
+///
+/// You can also [spread]($arguments/#spreading) an array of strings or content
+/// into a grid to populate its cells.
+///
+/// ```example
+/// #grid(
+/// columns: 5,
+/// gutter: 5pt,
+/// ..range(25).map(str)
+/// )
+/// ```
+///
+/// # Styling the grid
+/// The grid's appearance can be customized through different parameters. These
+/// are the most important ones:
+///
+/// - [`fill`]($grid.fill) to give all cells a background
+/// - [`align`]($grid.align) to change how cells are aligned
+/// - [`inset`]($grid.inset) to optionally add internal padding to each cell
+/// - [`stroke`]($grid.stroke) to optionally enable grid lines with a certain
+/// stroke
+///
+/// If you need to override one of the above options for a single cell, you can
+/// use the [`grid.cell`]($grid.cell) element. Likewise, you can override
+/// individual grid lines with the [`grid.hline`]($grid.hline) and
+/// [`grid.vline`]($grid.vline) elements.
+///
+/// Alternatively, if you need the appearance options to depend on a cell's
+/// position (column and row), you may specify a function to `fill` or `align`
+/// of the form `(column, row) => value`. You may also use a show rule on
+/// [`grid.cell`]($grid.cell) - see that element's examples or the examples
+/// below for more information.
+///
+/// Locating most of your styling in set and show rules is recommended, as it
+/// keeps the grid's or table's actual usages clean and easy to read. It also
+/// allows you to easily change the grid's appearance in one place.
+///
+/// ## Stroke styling precedence
+/// There are three ways to set the stroke of a grid cell: through
+/// [`{grid.cell}`'s `stroke` field]($grid.cell.stroke), by using
+/// [`{grid.hline}`]($grid.hline) and [`{grid.vline}`]($grid.vline), or by
+/// setting the [`{grid}`'s `stroke` field]($grid.stroke). When multiple of
+/// these settings are present and conflict, the `hline` and `vline` settings
+/// take the highest precedence, followed by the `cell` settings, and finally
+/// the `grid` settings.
+///
+/// Furthermore, strokes of a repeated grid header or footer will take
+/// precedence over regular cell strokes.
+#[elem(scope, Show)]
+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.
+ #[borrowed]
+ 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.
+ #[borrowed]
+ pub rows: TrackSizings,
+
+ /// The gaps between rows and columns.
+ ///
+ /// If there are more gutters than defined sizes, the last gutter is
+ /// repeated.
+ ///
+ /// This is a shorthand to set `column-gutter` and `row-gutter` to the same
+ /// value.
+ #[external]
+ pub gutter: TrackSizings,
+
+ /// The gaps between columns.
+ #[parse(
+ let gutter = args.named("gutter")?;
+ args.named("column-gutter")?.or_else(|| gutter.clone())
+ )]
+ #[borrowed]
+ pub column_gutter: TrackSizings,
+
+ /// The gaps between rows.
+ #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))]
+ #[borrowed]
+ pub row_gutter: TrackSizings,
+
+ /// How to fill the cells.
+ ///
+ /// This can be a color or a function that returns a color. The function
+ /// receives the cells' column and row indices, starting from zero. This can
+ /// be used to implement striped grids.
+ ///
+ /// ```example
+ /// #grid(
+ /// fill: (x, y) =>
+ /// if calc.even(x + y) { luma(230) }
+ /// else { white },
+ /// align: center + horizon,
+ /// columns: 4,
+ /// inset: 2pt,
+ /// [X], [O], [X], [O],
+ /// [O], [X], [O], [X],
+ /// [X], [O], [X], [O],
+ /// [O], [X], [O], [X],
+ /// )
+ /// ```
+ #[borrowed]
+ 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 receives the cells' column and row indices, starting from
+ /// zero. If set to `{auto}`, the outer alignment is used.
+ ///
+ /// You can find an example for this argument at the
+ /// [`table.align`]($table.align) parameter.
+ #[borrowed]
+ pub align: Celled<Smart<Alignment>>,
+
+ /// How to [stroke]($stroke) the cells.
+ ///
+ /// Grids have no strokes by default, which can be changed by setting this
+ /// option to the desired stroke.
+ ///
+ /// If it is necessary to place lines which can cross spacing between cells
+ /// produced by the `gutter` option, or to override the stroke between
+ /// multiple specific cells, consider specifying one or more of
+ /// [`grid.hline`]($grid.hline) and [`grid.vline`]($grid.vline) alongside
+ /// your grid cells.
+ ///
+ /// ```example
+ /// #set page(height: 13em, width: 26em)
+ ///
+ /// #let cv(..jobs) = grid(
+ /// columns: 2,
+ /// inset: 5pt,
+ /// stroke: (x, y) => if x == 0 and y > 0 {
+ /// (right: (
+ /// paint: luma(180),
+ /// thickness: 1.5pt,
+ /// dash: "dotted"
+ /// ))
+ /// },
+ /// grid.header(grid.cell(colspan: 2)[
+ /// *Professional Experience*
+ /// #box(width: 1fr, line(length: 100%, stroke: luma(180)))
+ /// ]),
+ /// ..{
+ /// let last = none
+ /// for job in jobs.pos() {
+ /// (
+ /// if job.year != last [*#job.year*],
+ /// [
+ /// *#job.company* - #job.role _(#job.timeframe)_ \
+ /// #job.details
+ /// ]
+ /// )
+ /// last = job.year
+ /// }
+ /// }
+ /// )
+ ///
+ /// #cv(
+ /// (
+ /// year: 2012,
+ /// company: [Pear Seed & Co.],
+ /// role: [Lead Engineer],
+ /// timeframe: [Jul - Dec],
+ /// details: [
+ /// - Raised engineers from 3x to 10x
+ /// - Did a great job
+ /// ],
+ /// ),
+ /// (
+ /// year: 2012,
+ /// company: [Mega Corp.],
+ /// role: [VP of Sales],
+ /// timeframe: [Mar - Jun],
+ /// details: [- Closed tons of customers],
+ /// ),
+ /// (
+ /// year: 2013,
+ /// company: [Tiny Co.],
+ /// role: [CEO],
+ /// timeframe: [Jan - Dec],
+ /// details: [- Delivered 4x more shareholder value],
+ /// ),
+ /// (
+ /// year: 2014,
+ /// company: [Glorbocorp Ltd],
+ /// role: [CTO],
+ /// timeframe: [Jan - Mar],
+ /// details: [- Drove containerization forward],
+ /// ),
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Celled<Sides<Option<Option<Arc<Stroke>>>>>,
+
+ /// How much to pad the cells' content.
+ ///
+ /// You can find an example for this argument at the
+ /// [`table.inset`]($table.inset) parameter.
+ #[fold]
+ pub inset: Celled<Sides<Option<Rel<Length>>>>,
+
+ /// The contents of the grid cells, plus any extra grid lines specified
+ /// with the [`grid.hline`]($grid.hline) and [`grid.vline`]($grid.vline)
+ /// elements.
+ ///
+ /// The cells are populated in row-major order.
+ #[variadic]
+ pub children: Vec<GridChild>,
+}
+
+#[scope]
+impl GridElem {
+ #[elem]
+ type GridCell;
+
+ #[elem]
+ type GridHLine;
+
+ #[elem]
+ type GridVLine;
+
+ #[elem]
+ type GridHeader;
+
+ #[elem]
+ type GridFooter;
+}
+
+impl Show for Packed<GridElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::multi_layouter(self.clone(), engine.routines.layout_grid)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// Track sizing definitions.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct TrackSizings(pub SmallVec<[Sizing; 4]>);
+
+cast! {
+ TrackSizings,
+ self => self.0.into_value(),
+ sizing: Sizing => Self(smallvec![sizing]),
+ count: NonZeroUsize => Self(smallvec![Sizing::Auto; count.get()]),
+ values: Array => Self(values.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
+
+/// Any child of a grid element.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum GridChild {
+ Header(Packed<GridHeader>),
+ Footer(Packed<GridFooter>),
+ Item(GridItem),
+}
+
+cast! {
+ GridChild,
+ self => match self {
+ Self::Header(header) => header.into_value(),
+ Self::Footer(footer) => footer.into_value(),
+ Self::Item(item) => item.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for GridChild {
+ type Error = HintedString;
+ fn try_from(value: Content) -> HintedStrResult<Self> {
+ if value.is::<TableHeader>() {
+ bail!(
+ "cannot use `table.header` as a grid header";
+ hint: "use `grid.header` instead"
+ )
+ }
+ if value.is::<TableFooter>() {
+ bail!(
+ "cannot use `table.footer` as a grid footer";
+ hint: "use `grid.footer` instead"
+ )
+ }
+
+ value
+ .into_packed::<GridHeader>()
+ .map(Self::Header)
+ .or_else(|value| value.into_packed::<GridFooter>().map(Self::Footer))
+ .or_else(|value| GridItem::try_from(value).map(Self::Item))
+ }
+}
+
+/// A grid item, which is the basic unit of grid specification.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum GridItem {
+ HLine(Packed<GridHLine>),
+ VLine(Packed<GridVLine>),
+ Cell(Packed<GridCell>),
+}
+
+cast! {
+ GridItem,
+ self => match self {
+ Self::HLine(hline) => hline.into_value(),
+ Self::VLine(vline) => vline.into_value(),
+ Self::Cell(cell) => cell.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ }
+}
+
+impl TryFrom<Content> for GridItem {
+ type Error = HintedString;
+ fn try_from(value: Content) -> HintedStrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!("cannot place a grid header within another header or footer");
+ }
+ if value.is::<TableHeader>() {
+ bail!("cannot place a table header within another header or footer");
+ }
+ if value.is::<GridFooter>() {
+ bail!("cannot place a grid footer within another footer or header");
+ }
+ if value.is::<TableFooter>() {
+ bail!("cannot place a table footer within another footer or header");
+ }
+ if value.is::<TableCell>() {
+ bail!(
+ "cannot use `table.cell` as a grid cell";
+ hint: "use `grid.cell` instead"
+ );
+ }
+ if value.is::<TableHLine>() {
+ bail!(
+ "cannot use `table.hline` as a grid line";
+ hint: "use `grid.hline` instead"
+ );
+ }
+ if value.is::<TableVLine>() {
+ bail!(
+ "cannot use `table.vline` as a grid line";
+ hint: "use `grid.vline` instead"
+ );
+ }
+
+ Ok(value
+ .into_packed::<GridHLine>()
+ .map(Self::HLine)
+ .or_else(|value| value.into_packed::<GridVLine>().map(Self::VLine))
+ .or_else(|value| value.into_packed::<GridCell>().map(Self::Cell))
+ .unwrap_or_else(|value| {
+ let span = value.span();
+ Self::Cell(Packed::new(GridCell::new(value)).spanned(span))
+ }))
+ }
+}
+
+/// A repeatable grid header.
+///
+/// If `repeat` is set to `true`, the header will be repeated across pages. For
+/// an example, refer to the [`table.header`]($table.header) element and the
+/// [`grid.stroke`]($grid.stroke) parameter.
+#[elem(name = "header", title = "Grid Header")]
+pub struct GridHeader {
+ /// Whether this header should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the header.
+ #[variadic]
+ pub children: Vec<GridItem>,
+}
+
+/// A repeatable grid footer.
+///
+/// Just like the [`grid.header`]($grid.header) element, the footer can repeat
+/// itself on every page of the table.
+///
+/// No other grid cells may be placed after the footer.
+#[elem(name = "footer", title = "Grid Footer")]
+pub struct GridFooter {
+ /// Whether this footer should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the footer.
+ #[variadic]
+ pub children: Vec<GridItem>,
+}
+
+/// A horizontal line in the grid.
+///
+/// Overrides any per-cell stroke, including stroke specified through the grid's
+/// `stroke` field. Can cross spacing between cells created through the grid's
+/// `column-gutter` option.
+///
+/// An example for this function can be found at the
+/// [`table.hline`]($table.hline) element.
+#[elem(name = "hline", title = "Grid Horizontal Line")]
+pub struct GridHLine {
+ /// The row above which the horizontal line is placed (zero-indexed).
+ /// If the `position` field is set to `{bottom}`, the line is placed below
+ /// the row with the given index instead (see that field's docs for
+ /// details).
+ ///
+ /// Specifying `{auto}` causes the line to be placed at the row below the
+ /// last automatically positioned cell (that is, cell without coordinate
+ /// overrides) before the line among the grid's children. If there is no
+ /// such cell before the line, it is placed at the top of the grid (row 0).
+ /// Note that specifying for this option exactly the total amount of rows
+ /// in the grid causes this horizontal line to override the bottom border
+ /// of the grid, while a value of 0 overrides the top border.
+ pub y: Smart<usize>,
+
+ /// The column at which the horizontal line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The column before which the horizontal line ends (zero-indexed,
+ /// exclusive).
+ /// Therefore, the horizontal line will be drawn up to and across column
+ /// `end - 1`.
+ ///
+ /// A value equal to `{none}` or to the amount of columns causes it to
+ /// extend all the way towards the end of the grid.
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` removes any lines previously placed across this
+ /// line's range, including hlines or per-cell stroke below it.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its row (`y`) - either
+ /// `{top}` to draw above it or `{bottom}` to draw below it.
+ ///
+ /// This setting is only relevant when row gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `y` field by
+ /// one instead), since then the position below a row becomes different
+ /// from the position above the next row due to the spacing between both.
+ #[default(OuterVAlignment::Top)]
+ pub position: OuterVAlignment,
+}
+
+/// A vertical line in the grid.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// grid's `stroke` field. Can cross spacing between cells created through
+/// the grid's `row-gutter` option.
+#[elem(name = "vline", title = "Grid Vertical Line")]
+pub struct GridVLine {
+ /// The column before which the horizontal line is placed (zero-indexed).
+ /// If the `position` field is set to `{end}`, the line is placed after the
+ /// column with the given index instead (see that field's docs for
+ /// details).
+ ///
+ /// Specifying `{auto}` causes the line to be placed at the column after
+ /// the last automatically positioned cell (that is, cell without
+ /// coordinate overrides) before the line among the grid's children. If
+ /// there is no such cell before the line, it is placed before the grid's
+ /// first column (column 0).
+ /// Note that specifying for this option exactly the total amount of
+ /// columns in the grid causes this vertical line to override the end
+ /// border of the grid (right in LTR, left in RTL), while a value of 0
+ /// overrides the start border (left in LTR, right in RTL).
+ pub x: Smart<usize>,
+
+ /// The row at which the vertical line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The row on top of which the vertical line ends (zero-indexed,
+ /// exclusive).
+ /// Therefore, the vertical line will be drawn up to and across row
+ /// `end - 1`.
+ ///
+ /// A value equal to `{none}` or to the amount of rows causes it to extend
+ /// all the way towards the bottom of the grid.
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` removes any lines previously placed across this
+ /// line's range, including vlines or per-cell stroke below it.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its column (`x`) -
+ /// either `{start}` to draw before it or `{end}` to draw after it.
+ ///
+ /// The values `{left}` and `{right}` are also accepted, but discouraged as
+ /// they cause your grid to be inconsistent between left-to-right and
+ /// right-to-left documents.
+ ///
+ /// This setting is only relevant when column gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `x` field by
+ /// one instead), since then the position after a column becomes different
+ /// from the position before the next column due to the spacing between
+ /// both.
+ #[default(OuterHAlignment::Start)]
+ pub position: OuterHAlignment,
+}
+
+/// A cell in the grid. You can use this function in the argument list of a grid
+/// to override grid style properties for an individual cell or manually
+/// positioning it within the grid. You can also use this function in show rules
+/// to apply certain styles to multiple cells at once.
+///
+/// For example, you can override the position and stroke for a single cell:
+///
+/// ```example
+/// >>> #set page(width: auto)
+/// >>> #set text(15pt, font: "Noto Sans Symbols 2", bottom-edge: -.2em)
+/// <<< #set text(15pt, font: "Noto Sans Symbols 2")
+/// #show regex("[♚-♟︎]"): set text(fill: rgb("21212A"))
+/// #show regex("[♔-♙]"): set text(fill: rgb("111015"))
+///
+/// #grid(
+/// fill: (x, y) => rgb(
+/// if calc.odd(x + y) { "7F8396" }
+/// else { "EFF0F3" }
+/// ),
+/// columns: (1em,) * 8,
+/// rows: 1em,
+/// align: center + horizon,
+///
+/// [♖], [♘], [♗], [♕], [♔], [♗], [♘], [♖],
+/// [♙], [♙], [♙], [♙], [], [♙], [♙], [♙],
+/// grid.cell(
+/// x: 4, y: 3,
+/// stroke: blue.transparentize(60%)
+/// )[♙],
+///
+/// ..(grid.cell(y: 6)[♟],) * 8,
+/// ..([♜], [♞], [♝], [♛], [♚], [♝], [♞], [♜])
+/// .map(grid.cell.with(y: 7)),
+/// )
+/// ```
+///
+/// You may also apply a show rule on `grid.cell` to style all cells at once,
+/// which allows you, for example, to apply styles based on a cell's position.
+/// Refer to the examples of the [`table.cell`]($table.cell) element to learn
+/// more about this.
+#[elem(name = "cell", title = "Grid Cell", Show)]
+pub struct GridCell {
+ /// The cell's body.
+ #[required]
+ pub body: Content,
+
+ /// The cell's column (zero-indexed).
+ /// This field may be used in show rules to style a cell depending on its
+ /// column.
+ ///
+ /// You may override this field to pick in which column the cell must
+ /// be placed. If no row (`y`) is chosen, the cell will be placed in the
+ /// first row (starting at row 0) with that column available (or a new row
+ /// if none). If both `x` and `y` are chosen, however, the cell will be
+ /// placed in that exact position. An error is raised if that position is
+ /// not available (thus, it is usually wise to specify cells with a custom
+ /// position before cells with automatic positions).
+ ///
+ /// ```example
+ /// #let circ(c) = circle(
+ /// fill: c, width: 5mm
+ /// )
+ ///
+ /// #grid(
+ /// columns: 4,
+ /// rows: 7mm,
+ /// stroke: .5pt + blue,
+ /// align: center + horizon,
+ /// inset: 1mm,
+ ///
+ /// grid.cell(x: 2, y: 2, circ(aqua)),
+ /// circ(yellow),
+ /// grid.cell(x: 3, circ(green)),
+ /// circ(black),
+ /// )
+ /// ```
+ pub x: Smart<usize>,
+
+ /// The cell's row (zero-indexed).
+ /// This field may be used in show rules to style a cell depending on its
+ /// row.
+ ///
+ /// You may override this field to pick in which row the cell must be
+ /// placed. If no column (`x`) is chosen, the cell will be placed in the
+ /// first column (starting at column 0) available in the chosen row. If all
+ /// columns in the chosen row are already occupied, an error is raised.
+ ///
+ /// ```example
+ /// #let tri(c) = polygon.regular(
+ /// fill: c,
+ /// size: 5mm,
+ /// vertices: 3,
+ /// )
+ ///
+ /// #grid(
+ /// columns: 2,
+ /// stroke: blue,
+ /// inset: 1mm,
+ ///
+ /// tri(black),
+ /// grid.cell(y: 1, tri(teal)),
+ /// grid.cell(y: 1, tri(red)),
+ /// grid.cell(y: 2, tri(orange))
+ /// )
+ /// ```
+ pub y: Smart<usize>,
+
+ /// The amount of columns spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ pub colspan: NonZeroUsize,
+
+ /// The amount of rows spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ pub rowspan: NonZeroUsize,
+
+ /// The cell's [fill]($grid.fill) override.
+ pub fill: Smart<Option<Paint>>,
+
+ /// The cell's [alignment]($grid.align) override.
+ pub align: Smart<Alignment>,
+
+ /// The cell's [inset]($grid.inset) override.
+ pub inset: Smart<Sides<Option<Rel<Length>>>>,
+
+ /// The cell's [stroke]($grid.stroke) override.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
+
+ /// Whether rows spanned by this cell can be placed in different pages.
+ /// When equal to `{auto}`, a cell spanning only fixed-size rows is
+ /// unbreakable, while a cell spanning at least one `{auto}`-sized row is
+ /// breakable.
+ pub breakable: Smart<bool>,
+}
+
+cast! {
+ GridCell,
+ v: Content => v.into(),
+}
+
+impl Show for Packed<GridCell> {
+ fn show(&self, _engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ show_grid_cell(self.body().clone(), self.inset(styles), self.align(styles))
+ }
+}
+
+impl Default for Packed<GridCell> {
+ fn default() -> Self {
+ Packed::new(GridCell::new(Content::default()))
+ }
+}
+
+impl From<Content> for GridCell {
+ fn from(value: Content) -> Self {
+ #[allow(clippy::unwrap_or_default)]
+ value.unpack::<Self>().unwrap_or_else(Self::new)
+ }
+}
+
+/// Function with common code to display a grid cell or table cell.
+pub(crate) fn show_grid_cell(
+ mut body: Content,
+ inset: Smart<Sides<Option<Rel<Length>>>>,
+ align: Smart<Alignment>,
+) -> SourceResult<Content> {
+ let inset = inset.unwrap_or_default().map(Option::unwrap_or_default);
+
+ if inset != Sides::default() {
+ // Only pad if some inset is not 0pt.
+ // Avoids a bug where using .padded() in any way inside Show causes
+ // alignment in align(...) to break.
+ body = body.padded(inset);
+ }
+
+ if let Smart::Custom(alignment) = align {
+ body = body.aligned(alignment);
+ }
+
+ Ok(body)
+}
+
+/// 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,
+ engine: &mut Engine,
+ styles: StyleChain,
+ x: usize,
+ y: usize,
+ ) -> SourceResult<T> {
+ Ok(match self {
+ Self::Value(value) => value.clone(),
+ Self::Func(func) => func
+ .call(engine, Context::new(None, Some(styles)).track(), [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 input() -> CastInfo {
+ T::input() + Array::input() + Func::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output() + Array::output() + Func::output()
+ }
+
+ 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) -> HintedStrResult<Self> {
+ match value {
+ Value::Func(v) => Ok(Self::Func(v)),
+ Value::Array(array) => Ok(Self::Array(
+ array.into_iter().map(T::from_value).collect::<HintedStrResult<_>>()?,
+ )),
+ v if T::castable(&v) => Ok(Self::Value(T::from_value(v)?)),
+ v => Err(Self::error(&v)),
+ }
+ }
+}
+
+impl<T: Fold> Fold for Celled<T> {
+ fn fold(self, outer: Self) -> Self {
+ match (self, outer) {
+ (Self::Value(inner), Self::Value(outer)) => Self::Value(inner.fold(outer)),
+ (self_, _) => self_,
+ }
+ }
+}
+
+impl<T: Resolve> Resolve for Celled<T> {
+ type Output = ResolvedCelled<T>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ match self {
+ Self::Value(value) => ResolvedCelled(Celled::Value(value.resolve(styles))),
+ Self::Func(func) => ResolvedCelled(Celled::Func(func)),
+ Self::Array(values) => ResolvedCelled(Celled::Array(
+ values.into_iter().map(|value| value.resolve(styles)).collect(),
+ )),
+ }
+ }
+}
+
+/// The result of resolving a Celled's value according to styles.
+/// Holds resolved values which depend on each grid cell's position.
+/// When it is a closure, however, it is only resolved when the closure is
+/// called.
+#[derive(Default, Clone)]
+pub struct ResolvedCelled<T: Resolve>(Celled<T::Output>);
+
+impl<T> ResolvedCelled<T>
+where
+ T: FromValue + Resolve,
+ <T as Resolve>::Output: Default + Clone,
+{
+ /// Resolve the value based on the cell position.
+ pub fn resolve(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ x: usize,
+ y: usize,
+ ) -> SourceResult<T::Output> {
+ Ok(match &self.0 {
+ Celled::Value(value) => value.clone(),
+ Celled::Func(func) => func
+ .call(engine, Context::new(None, Some(styles)).track(), [x, y])?
+ .cast::<T>()
+ .at(func.span())?
+ .resolve(styles),
+ Celled::Array(array) => x
+ .checked_rem(array.len())
+ .and_then(|i| array.get(i))
+ .cloned()
+ .unwrap_or_default(),
+ })
+ }
+}
diff --git a/crates/typst-library/src/layout/hide.rs b/crates/typst-library/src/layout/hide.rs
new file mode 100644
index 00000000..1b8b9bd5
--- /dev/null
+++ b/crates/typst-library/src/layout/hide.rs
@@ -0,0 +1,34 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, StyleChain};
+
+/// 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
+/// Hello Jane \
+/// #hide[Hello] Joe
+/// ```
+#[elem(Show)]
+pub struct HideElem {
+ /// The content to hide.
+ #[required]
+ pub body: Content,
+
+ /// This style is set on the content contained in the `hide` element.
+ #[internal]
+ #[ghost]
+ pub hidden: bool,
+}
+
+impl Show for Packed<HideElem> {
+ #[typst_macros::time(name = "hide", span = self.span())]
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(HideElem::set_hidden(true)))
+ }
+}
diff --git a/crates/typst-library/src/layout/layout.rs b/crates/typst-library/src/layout/layout.rs
new file mode 100644
index 00000000..c3d112e1
--- /dev/null
+++ b/crates/typst-library/src/layout/layout.rs
@@ -0,0 +1,107 @@
+use comemo::Track;
+use typst_syntax::Span;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ dict, elem, func, Content, Context, Func, NativeElement, Packed, Show, StyleChain,
+};
+use crate::introspection::Locatable;
+use crate::layout::{BlockElem, Size};
+
+/// Provides access to the current outer container's (or page's, if none)
+/// dimensions (width and height).
+///
+/// Accepts a function that receives a single parameter, which is a dictionary
+/// with keys `width` and `height`, both of type [`length`]. The function is
+/// provided [context], meaning you don't need to use it in combination with the
+/// `context` keyword. This is why [`measure`] can be called in the example
+/// below.
+///
+/// ```example
+/// #let text = lorem(30)
+/// #layout(size => [
+/// #let (height,) = measure(
+/// block(width: size.width, text),
+/// )
+/// This text is #height high with
+/// the current page width: \
+/// #text
+/// ])
+/// ```
+///
+/// Note that the `layout` function forces its contents into a [block]-level
+/// container, so placement relative to the page or pagebreaks are not possible
+/// within it.
+///
+/// If the `layout` call is placed inside a box with a width of `{800pt}` and a
+/// height of `{400pt}`, then the specified function will be given the argument
+/// `{(width: 800pt, height: 400pt)}`. If it is placed directly into the page, it
+/// receives the page's dimensions minus its margins. This is mostly useful in
+/// combination with [measurement]($measure).
+///
+/// You can also use this function to resolve [`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 the width or height provided by `layout` will be infinite if the
+/// corresponding page dimension is set to `{auto}`.
+#[func]
+pub fn layout(
+ /// The call span of this function.
+ span: Span,
+ /// 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] with the keys `width`
+ /// and `height`.
+ ///
+ /// This function is called once for each time the content returned by
+ /// `layout` appears in the document. This makes it possible to generate
+ /// content that depends on the dimensions of its container.
+ func: Func,
+) -> Content {
+ LayoutElem::new(func).pack().spanned(span)
+}
+
+/// Executes a `layout` call.
+#[elem(Locatable, Show)]
+struct LayoutElem {
+ /// The function to call with the outer container's (or page's) size.
+ #[required]
+ func: Func,
+}
+
+impl Show for Packed<LayoutElem> {
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::multi_layouter(
+ self.clone(),
+ |elem, engine, locator, styles, regions| {
+ // 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 loc = elem.location().unwrap();
+ let context = Context::new(Some(loc), Some(styles));
+ let result = elem
+ .func()
+ .call(
+ engine,
+ context.track(),
+ [dict! { "width" => x, "height" => y }],
+ )?
+ .display();
+ (engine.routines.layout_fragment)(
+ engine, &result, locator, styles, regions,
+ )
+ },
+ )
+ .pack()
+ .spanned(self.span()))
+ }
+}
diff --git a/crates/typst-library/src/layout/length.rs b/crates/typst-library/src/layout/length.rs
new file mode 100644
index 00000000..800140c4
--- /dev/null
+++ b/crates/typst-library/src/layout/length.rs
@@ -0,0 +1,276 @@
+use std::cmp::Ordering;
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Add, Div, Mul, Neg};
+
+use comemo::Tracked;
+use ecow::{eco_format, EcoString};
+use typst_syntax::Span;
+use typst_utils::Numeric;
+
+use crate::diag::{bail, HintedStrResult, SourceResult};
+use crate::foundations::{func, scope, ty, Context, Fold, Repr, Resolve, StyleChain};
+use crate::layout::{Abs, Em};
+
+/// A size or distance, possibly expressed with contextual units.
+///
+/// Typst supports the following length units:
+///
+/// - Points: `{72pt}`
+/// - Millimeters: `{254mm}`
+/// - Centimeters: `{2.54cm}`
+/// - Inches: `{1in}`
+/// - Relative to font size: `{2.5em}`
+///
+/// You can multiply lengths with and divide them by integers and floats.
+///
+/// # Example
+/// ```example
+/// #rect(width: 20pt)
+/// #rect(width: 2em)
+/// #rect(width: 1in)
+///
+/// #(3em + 5pt).em \
+/// #(20pt).em \
+/// #(40em + 2pt).abs \
+/// #(5em).abs
+/// ```
+///
+/// # Fields
+/// - `abs`: A length with just the absolute component of the current length
+/// (that is, excluding the `em` component).
+/// - `em`: The amount of `em` units in this length, as a [float].
+#[ty(scope, cast)]
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Length {
+ /// The absolute part.
+ pub abs: Abs,
+ /// The font-relative part.
+ pub em: Em,
+}
+
+impl Length {
+ /// The zero length.
+ pub const fn zero() -> Self {
+ Self { abs: Abs::zero(), em: Em::zero() }
+ }
+
+ /// Try to compute the absolute value of the length.
+ pub fn try_abs(self) -> Option<Self> {
+ (self.abs.is_zero() || self.em.is_zero())
+ .then(|| Self { abs: self.abs.abs(), em: self.em.abs() })
+ }
+
+ /// Try to divide two lengths.
+ pub fn try_div(self, other: Self) -> Option<f64> {
+ if self.abs.is_zero() && other.abs.is_zero() {
+ Some(self.em / other.em)
+ } else if self.em.is_zero() && other.em.is_zero() {
+ Some(self.abs / other.abs)
+ } else {
+ None
+ }
+ }
+
+ /// Convert to an absolute length at the given font size.
+ pub fn at(self, font_size: Abs) -> Abs {
+ self.abs + self.em.at(font_size)
+ }
+
+ /// Fails with an error if the length has a non-zero font-relative part.
+ fn ensure_that_em_is_zero(&self, span: Span, unit: &str) -> SourceResult<()> {
+ if self.em == Em::zero() {
+ return Ok(());
+ }
+
+ bail!(
+ span,
+ "cannot convert a length with non-zero em units (`{}`) to {unit}",
+ self.repr();
+ hint: "use `length.to-absolute()` to resolve its em component \
+ (requires context)";
+ hint: "or use `length.abs.{unit}()` instead to ignore its em component"
+ )
+ }
+}
+
+#[scope]
+impl Length {
+ /// Converts this length to points.
+ ///
+ /// Fails with an error if this length has non-zero `em` units (such as
+ /// `5em + 2pt` instead of just `2pt`). Use the `abs` field (such as in
+ /// `(5em + 2pt).abs.pt()`) to ignore the `em` component of the length (thus
+ /// converting only its absolute component).
+ #[func(name = "pt", title = "Points")]
+ pub fn to_pt(&self, span: Span) -> SourceResult<f64> {
+ self.ensure_that_em_is_zero(span, "pt")?;
+ Ok(self.abs.to_pt())
+ }
+
+ /// Converts this length to millimeters.
+ ///
+ /// Fails with an error if this length has non-zero `em` units. See the
+ /// [`pt`]($length.pt) method for more details.
+ #[func(name = "mm", title = "Millimeters")]
+ pub fn to_mm(&self, span: Span) -> SourceResult<f64> {
+ self.ensure_that_em_is_zero(span, "mm")?;
+ Ok(self.abs.to_mm())
+ }
+
+ /// Converts this length to centimeters.
+ ///
+ /// Fails with an error if this length has non-zero `em` units. See the
+ /// [`pt`]($length.pt) method for more details.
+ #[func(name = "cm", title = "Centimeters")]
+ pub fn to_cm(&self, span: Span) -> SourceResult<f64> {
+ self.ensure_that_em_is_zero(span, "cm")?;
+ Ok(self.abs.to_cm())
+ }
+
+ /// Converts this length to inches.
+ ///
+ /// Fails with an error if this length has non-zero `em` units. See the
+ /// [`pt`]($length.pt) method for more details.
+ #[func(name = "inches")]
+ pub fn to_inches(&self, span: Span) -> SourceResult<f64> {
+ self.ensure_that_em_is_zero(span, "inches")?;
+ Ok(self.abs.to_inches())
+ }
+
+ /// Resolve this length to an absolute length.
+ ///
+ /// ```example
+ /// #set text(size: 12pt)
+ /// #context [
+ /// #(6pt).to-absolute() \
+ /// #(6pt + 10em).to-absolute() \
+ /// #(10em).to-absolute()
+ /// ]
+ ///
+ /// #set text(size: 6pt)
+ /// #context [
+ /// #(6pt).to-absolute() \
+ /// #(6pt + 10em).to-absolute() \
+ /// #(10em).to-absolute()
+ /// ]
+ /// ```
+ #[func]
+ pub fn to_absolute(&self, context: Tracked<Context>) -> HintedStrResult<Length> {
+ Ok(self.resolve(context.styles()?).into())
+ }
+}
+
+impl Debug for Length {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match (self.abs.is_zero(), self.em.is_zero()) {
+ (false, false) => write!(f, "{:?} + {:?}", self.abs, self.em),
+ (true, false) => self.em.fmt(f),
+ (_, true) => self.abs.fmt(f),
+ }
+ }
+}
+
+impl Repr for Length {
+ fn repr(&self) -> EcoString {
+ match (self.abs.is_zero(), self.em.is_zero()) {
+ (false, false) => eco_format!("{} + {}", self.abs.repr(), self.em.repr()),
+ (true, false) => self.em.repr(),
+ (_, true) => self.abs.repr(),
+ }
+ }
+}
+
+impl Numeric for Length {
+ fn zero() -> Self {
+ Self::zero()
+ }
+
+ fn is_finite(self) -> bool {
+ self.abs.is_finite() && self.em.is_finite()
+ }
+}
+
+impl PartialOrd for Length {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ if self.em.is_zero() && other.em.is_zero() {
+ self.abs.partial_cmp(&other.abs)
+ } else if self.abs.is_zero() && other.abs.is_zero() {
+ self.em.partial_cmp(&other.em)
+ } else {
+ None
+ }
+ }
+}
+
+impl From<Abs> for Length {
+ fn from(abs: Abs) -> Self {
+ Self { abs, em: Em::zero() }
+ }
+}
+
+impl From<Em> for Length {
+ fn from(em: Em) -> Self {
+ Self { abs: Abs::zero(), em }
+ }
+}
+
+impl Neg for Length {
+ type Output = Self;
+
+ fn neg(self) -> Self::Output {
+ Self { abs: -self.abs, em: -self.em }
+ }
+}
+
+impl Add for Length {
+ type Output = Self;
+
+ fn add(self, rhs: Self) -> Self::Output {
+ Self { abs: self.abs + rhs.abs, em: self.em + rhs.em }
+ }
+}
+
+typst_utils::sub_impl!(Length - Length -> Length);
+
+impl Mul<f64> for Length {
+ type Output = Self;
+
+ fn mul(self, rhs: f64) -> Self::Output {
+ Self { abs: self.abs * rhs, em: self.em * rhs }
+ }
+}
+
+impl Mul<Length> for f64 {
+ type Output = Length;
+
+ fn mul(self, rhs: Length) -> Self::Output {
+ rhs * self
+ }
+}
+
+impl Div<f64> for Length {
+ type Output = Self;
+
+ fn div(self, rhs: f64) -> Self::Output {
+ Self { abs: self.abs / rhs, em: self.em / rhs }
+ }
+}
+
+typst_utils::assign_impl!(Length += Length);
+typst_utils::assign_impl!(Length -= Length);
+typst_utils::assign_impl!(Length *= f64);
+typst_utils::assign_impl!(Length /= f64);
+
+impl Resolve for Length {
+ type Output = Abs;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.abs + self.em.resolve(styles)
+ }
+}
+
+impl Fold for Length {
+ fn fold(self, _: Self) -> Self {
+ self
+ }
+}
diff --git a/crates/typst-library/src/layout/measure.rs b/crates/typst-library/src/layout/measure.rs
new file mode 100644
index 00000000..2fa51b2d
--- /dev/null
+++ b/crates/typst-library/src/layout/measure.rs
@@ -0,0 +1,115 @@
+use comemo::Tracked;
+use typst_syntax::Span;
+
+use crate::diag::{warning, At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ dict, func, Content, Context, Dict, Resolve, Smart, StyleChain, Styles,
+};
+use crate::introspection::{Locator, LocatorLink};
+use crate::layout::{Abs, Axes, Length, Region, Size};
+
+/// Measures the layouted size of content.
+///
+/// The `measure` function lets you determine the layouted size of content.
+/// By default an infinite space is assumed, so the measured dimensions may
+/// not necessarily match the final dimensions of the content.
+/// If you want to measure in the current layout dimensions, you can combine
+/// `measure` and [`layout`].
+///
+/// # Example
+/// The same content can have a different size depending on the [context] that
+/// it is placed into. In the example below, the `[#content]` is of course
+/// bigger when we increase the font size.
+///
+/// ```example
+/// #let content = [Hello!]
+/// #content
+/// #set text(14pt)
+/// #content
+/// ```
+///
+/// For this reason, you can only measure when context is available.
+///
+/// ```example
+/// #let thing(body) = context {
+/// let size = measure(body)
+/// [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`].
+#[func(contextual)]
+pub fn measure(
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// The callsite span.
+ span: Span,
+ /// The width available to layout the content.
+ ///
+ /// Setting this to `{auto}` indicates infinite available width.
+ ///
+ /// Note that using the `width` and `height` parameters of this function is
+ /// different from measuring a sized [`block`] containing the content. In
+ /// the following example, the former will get the dimensions of the inner
+ /// content instead of the dimensions of the block.
+ ///
+ /// ```example
+ /// #context measure(lorem(100), width: 400pt)
+ ///
+ /// #context measure(block(lorem(100), width: 400pt))
+ /// ```
+ #[named]
+ #[default(Smart::Auto)]
+ width: Smart<Length>,
+ /// The height available to layout the content.
+ ///
+ /// Setting this to `{auto}` indicates infinite available height.
+ #[named]
+ #[default(Smart::Auto)]
+ height: Smart<Length>,
+ /// The content whose size to measure.
+ content: Content,
+ /// _Compatibility:_ This argument is deprecated. It only exists for
+ /// compatibility with Typst 0.10 and lower and shouldn't be used anymore.
+ #[default]
+ styles: Option<Styles>,
+) -> SourceResult<Dict> {
+ let styles = match &styles {
+ Some(styles) => {
+ engine.sink.warn(warning!(
+ span, "calling `measure` with a styles argument is deprecated";
+ hint: "try removing the styles argument"
+ ));
+ StyleChain::new(styles)
+ }
+ None => context.styles().at(span)?,
+ };
+
+ // Create a pod region with the available space.
+ let pod = Region::new(
+ Axes::new(
+ width.resolve(styles).unwrap_or(Abs::inf()),
+ height.resolve(styles).unwrap_or(Abs::inf()),
+ ),
+ Axes::splat(false),
+ );
+
+ // We put the locator into a special "measurement mode" to ensure that
+ // introspection-driven features within the content continue to work. Read
+ // the "Dealing with measurement" section of the [`Locator`] docs for more
+ // details.
+ let here = context.location().at(span)?;
+ let link = LocatorLink::measure(here);
+ let locator = Locator::link(&link);
+
+ let frame = (engine.routines.layout_frame)(engine, &content, locator, styles, pod)?;
+ 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..b54d6906
--- /dev/null
+++ b/crates/typst-library/src/layout/mod.rs
@@ -0,0 +1,106 @@
+//! Composable layouts.
+
+mod abs;
+mod align;
+mod angle;
+mod axes;
+mod columns;
+mod container;
+mod corners;
+mod dir;
+mod em;
+mod fr;
+mod fragment;
+mod frame;
+mod grid;
+mod hide;
+#[path = "layout.rs"]
+mod layout_;
+mod length;
+#[path = "measure.rs"]
+mod measure_;
+mod pad;
+mod page;
+mod place;
+mod point;
+mod ratio;
+mod regions;
+mod rel;
+mod repeat;
+mod sides;
+mod size;
+mod spacing;
+mod stack;
+mod transform;
+
+pub use self::abs::*;
+pub use self::align::*;
+pub use self::angle::*;
+pub use self::axes::*;
+pub use self::columns::*;
+pub use self::container::*;
+pub use self::corners::*;
+pub use self::dir::*;
+pub use self::em::*;
+pub use self::fr::*;
+pub use self::fragment::*;
+pub use self::frame::*;
+pub use self::grid::*;
+pub use self::hide::*;
+pub use self::layout_::*;
+pub use self::length::*;
+pub use self::measure_::*;
+pub use self::pad::*;
+pub use self::page::*;
+pub use self::place::*;
+pub use self::point::*;
+pub use self::ratio::*;
+pub use self::regions::*;
+pub use self::rel::*;
+pub use self::repeat::*;
+pub use self::sides::*;
+pub use self::size::*;
+pub use self::spacing::*;
+pub use self::stack::*;
+pub use self::transform::*;
+
+use crate::foundations::{category, Category, Scope};
+
+/// Arranging elements on the page in different ways.
+///
+/// By combining layout functions, you can create complex and automatic layouts.
+#[category]
+pub static LAYOUT: Category;
+
+/// Hook up all `layout` definitions.
+pub fn define(global: &mut Scope) {
+ global.category(LAYOUT);
+ global.define_type::<Length>();
+ global.define_type::<Angle>();
+ global.define_type::<Ratio>();
+ global.define_type::<Rel<Length>>();
+ global.define_type::<Fr>();
+ global.define_type::<Dir>();
+ global.define_type::<Alignment>();
+ global.define_elem::<PageElem>();
+ global.define_elem::<PagebreakElem>();
+ global.define_elem::<VElem>();
+ global.define_elem::<HElem>();
+ global.define_elem::<BoxElem>();
+ global.define_elem::<BlockElem>();
+ global.define_elem::<StackElem>();
+ global.define_elem::<GridElem>();
+ global.define_elem::<ColumnsElem>();
+ global.define_elem::<ColbreakElem>();
+ global.define_elem::<PlaceElem>();
+ global.define_elem::<AlignElem>();
+ global.define_elem::<PadElem>();
+ global.define_elem::<RepeatElem>();
+ global.define_elem::<MoveElem>();
+ global.define_elem::<ScaleElem>();
+ global.define_elem::<RotateElem>();
+ global.define_elem::<SkewElem>();
+ global.define_elem::<HideElem>();
+ global.define_func::<measure>();
+ global.define_func::<layout>();
+}
diff --git a/crates/typst-library/src/layout/pad.rs b/crates/typst-library/src/layout/pad.rs
new file mode 100644
index 00000000..1dc6d131
--- /dev/null
+++ b/crates/typst-library/src/layout/pad.rs
@@ -0,0 +1,65 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, NativeElement, Packed, Show, StyleChain};
+use crate::layout::{BlockElem, Length, Rel};
+
+/// 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
+/// #set align(center)
+///
+/// #pad(x: 16pt, image("typing.jpg"))
+/// _Typing speeds can be
+/// measured in words per minute._
+/// ```
+#[elem(title = "Padding", Show)]
+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>,
+
+ /// A shorthand to set `left` and `right` to the same value.
+ #[external]
+ pub x: Rel<Length>,
+
+ /// A shorthand to set `top` and `bottom` to the same value.
+ #[external]
+ pub y: Rel<Length>,
+
+ /// A shorthand to set all four sides to the same value.
+ #[external]
+ pub rest: Rel<Length>,
+
+ /// The content to pad at the sides.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<PadElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::multi_layouter(self.clone(), engine.routines.layout_pad)
+ .pack()
+ .spanned(self.span()))
+ }
+}
diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs
new file mode 100644
index 00000000..de278cb6
--- /dev/null
+++ b/crates/typst-library/src/layout/page.rs
@@ -0,0 +1,924 @@
+use std::borrow::Cow;
+use std::num::NonZeroUsize;
+use std::ops::RangeInclusive;
+use std::str::FromStr;
+
+use comemo::Track;
+use typst_utils::{singleton, NonZeroExt, Scalar};
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Args, AutoValue, Cast, Construct, Content, Context, Dict, Fold, Func,
+ NativeElement, Set, Smart, StyleChain, Value,
+};
+use crate::layout::{
+ Abs, Alignment, FlushElem, Frame, HAlignment, Length, OuterVAlignment, Ratio, Rel,
+ Sides, SpecificAlignment,
+};
+use crate::model::Numbering;
+use crate::visualize::{Color, Paint};
+
+/// 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.
+///
+/// The [Guide for Page Setup]($guides/page-setup-guide) explains how to use
+/// this and related functions to set up a document with many examples.
+///
+/// # Example
+/// ```example
+/// >>> #set page(margin: auto)
+/// #set page("us-letter")
+///
+/// There you go, US friends!
+/// ```
+#[elem(Construct)]
+pub struct PageElem {
+ /// A standard paper size to set width and height.
+ ///
+ /// This is just a shorthand for setting `width` and `height` and, as such,
+ /// cannot be retrieved in a context expression.
+ #[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()))]
+ #[ghost]
+ 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]($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()))]
+ #[ghost]
+ 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)]
+ #[ghost]
+ pub flipped: bool,
+
+ /// The page's margins.
+ ///
+ /// - `{auto}`: The margins are set automatically to 2.5/21 times the smaller
+ /// dimension of the page. This results in 2.5cm margins for an A4 page.
+ /// - A single length: The same margin on all sides.
+ /// - 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]($page.binding) is).
+ /// - `outside`: The margin at the outer side of the page (opposite to the
+ /// [binding]($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]
+ #[ghost]
+ pub margin: Margin,
+
+ /// On which side the pages will be bound.
+ ///
+ /// - `{auto}`: Equivalent to `left` if the [text direction]($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.
+ #[ghost]
+ pub binding: Smart<Binding>,
+
+ /// How many columns the page has.
+ ///
+ /// If you need to insert columns into a page or other container, you can
+ /// also use the [`columns` function]($columns).
+ ///
+ /// ```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)]
+ #[ghost]
+ pub columns: NonZeroUsize,
+
+ /// The page's background fill.
+ ///
+ /// Setting this to something non-transparent instructs the printer to color
+ /// the complete page. 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.
+ ///
+ /// When set to `{none}`, the background becomes transparent. Note that PDF
+ /// pages will still appear with a (usually white) background in viewers,
+ /// but they are actually transparent. (If you print them, no color is used
+ /// for the background.)
+ ///
+ /// The default of `{auto}` results in `{none}` for PDF output, and
+ /// `{white}` for PNG and SVG.
+ ///
+ /// ```example
+ /// #set page(fill: rgb("444352"))
+ /// #set text(fill: rgb("fdfdfd"))
+ /// *Dark mode enabled.*
+ /// ```
+ #[borrowed]
+ #[ghost]
+ pub fill: Smart<Option<Paint>>,
+
+ /// How to [number]($numbering) the pages.
+ ///
+ /// If an explicit `footer` (or `header` for top-aligned numbering) is
+ /// given, the numbering is ignored.
+ ///
+ /// ```example
+ /// #set page(
+ /// height: 100pt,
+ /// margin: (top: 16pt, bottom: 24pt),
+ /// numbering: "1 / 1",
+ /// )
+ ///
+ /// #lorem(48)
+ /// ```
+ #[borrowed]
+ #[ghost]
+ pub numbering: Option<Numbering>,
+
+ /// The alignment of the page numbering.
+ ///
+ /// If the vertical component is `top`, the numbering is placed into the
+ /// header and if it is `bottom`, it is placed in the footer. Horizon
+ /// alignment is forbidden. If an explicit matching `header` or `footer` is
+ /// given, the numbering is ignored.
+ ///
+ /// ```example
+ /// #set page(
+ /// margin: (top: 16pt, bottom: 24pt),
+ /// numbering: "1",
+ /// number-align: right,
+ /// )
+ ///
+ /// #lorem(30)
+ /// ```
+ #[default(SpecificAlignment::Both(HAlignment::Center, OuterVAlignment::Bottom))]
+ #[ghost]
+ pub number_align: SpecificAlignment<HAlignment, OuterVAlignment>,
+
+ /// The page's header. Fills the top margin of each page.
+ ///
+ /// - Content: Shows the content as the header.
+ /// - `{auto}`: Shows the page number if a `numbering` is set and
+ /// `number-align` is `top`.
+ /// - `{none}`: Suppresses the header.
+ ///
+ /// ```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)
+ /// ```
+ #[borrowed]
+ #[ghost]
+ pub header: Smart<Option<Content>>,
+
+ /// The amount the header is raised into the top margin.
+ #[resolve]
+ #[default(Ratio::new(0.3).into())]
+ #[ghost]
+ pub header_ascent: Rel<Length>,
+
+ /// The page's footer. Fills the bottom margin of each page.
+ ///
+ /// - Content: Shows the content as the footer.
+ /// - `{auto}`: Shows the page number if a `numbering` is set and
+ /// `number-align` is `bottom`.
+ /// - `{none}`: Suppresses the footer.
+ ///
+ /// 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]($counter).
+ ///
+ /// ```example
+ /// #set par(justify: true)
+ /// #set page(
+ /// height: 100pt,
+ /// margin: 20pt,
+ /// footer: context [
+ /// #set align(right)
+ /// #set text(8pt)
+ /// #counter(page).display(
+ /// "1 of I",
+ /// both: true,
+ /// )
+ /// ]
+ /// )
+ ///
+ /// #lorem(48)
+ /// ```
+ #[borrowed]
+ #[ghost]
+ pub footer: Smart<Option<Content>>,
+
+ /// The amount the footer is lowered into the bottom margin.
+ #[resolve]
+ #[default(Ratio::new(0.3).into())]
+ #[ghost]
+ 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).
+ /// ```
+ #[borrowed]
+ #[ghost]
+ 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...
+ /// ```
+ #[borrowed]
+ #[ghost]
+ 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.
+ #[external]
+ #[required]
+ pub body: Content,
+}
+
+impl Construct for PageElem {
+ fn construct(engine: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ // The page constructor is special: It doesn't create a page element.
+ // Instead, it just ensures that the passed content lives in a separate
+ // page and styles it.
+ let styles = Self::set(engine, args)?;
+ let body = args.expect::<Content>("body")?;
+ Ok(Content::sequence([
+ PagebreakElem::shared_weak().clone(),
+ // We put an effectless, invisible non-tag element on the page.
+ // This has two desirable consequences:
+ // - The page is kept even if the body is empty
+ // - The page doesn't inherit shared styles from the body
+ FlushElem::new().pack(),
+ body,
+ PagebreakElem::shared_boundary().clone(),
+ ])
+ .styled_with_map(styles))
+ }
+}
+
+/// A manual page break.
+///
+/// Must not be used inside any containers.
+///
+/// # Example
+/// ```example
+/// The next page contains
+/// more details on compound theory.
+/// #pagebreak()
+///
+/// == Compound Theory
+/// In 1984, the first ...
+/// ```
+#[elem(title = "Page Break")]
+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 this pagebreak designates an end boundary of a page run. This is
+ /// an even weaker version of pagebreak `weak` because it not only doesn't
+ /// force an empty page, but also doesn't force its initial styles onto a
+ /// staged empty page.
+ #[internal]
+ #[parse(None)]
+ #[default(false)]
+ pub boundary: bool,
+}
+
+impl PagebreakElem {
+ /// Get the globally shared weak pagebreak element.
+ pub fn shared_weak() -> &'static Content {
+ singleton!(Content, PagebreakElem::new().with_weak(true).pack())
+ }
+
+ /// Get the globally shared boundary pagebreak element.
+ pub fn shared_boundary() -> &'static Content {
+ singleton!(
+ Content,
+ PagebreakElem::new().with_weak(true).with_boundary(true).pack()
+ )
+ }
+}
+
+/// A finished page.
+#[derive(Debug, Clone)]
+pub struct Page {
+ /// The frame that defines the page.
+ pub frame: Frame,
+ /// How the page is filled.
+ ///
+ /// - When `None`, the background is transparent.
+ /// - When `Auto`, the background is transparent for PDF and white
+ /// for raster and SVG targets.
+ ///
+ /// Exporters should access the resolved value of this property through
+ /// `fill_or_transparent()` or `fill_or_white()`.
+ pub fill: Smart<Option<Paint>>,
+ /// The page's numbering.
+ pub numbering: Option<Numbering>,
+ /// The logical page number (controlled by `counter(page)` and may thus not
+ /// match the physical number).
+ pub number: usize,
+}
+
+impl Page {
+ /// Get the configured background or `None` if it is `Auto`.
+ ///
+ /// This is used in PDF export.
+ pub fn fill_or_transparent(&self) -> Option<Paint> {
+ self.fill.clone().unwrap_or(None)
+ }
+
+ /// Get the configured background or white if it is `Auto`.
+ ///
+ /// This is used in raster and SVG export.
+ pub fn fill_or_white(&self) -> Option<Paint> {
+ self.fill.clone().unwrap_or_else(|| Some(Color::WHITE.into()))
+ }
+}
+
+/// Specification of the page's margins.
+#[derive(Debug, 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 Default for Margin {
+ fn default() -> Self {
+ Self {
+ sides: Sides::splat(Some(Smart::Auto)),
+ two_sided: None,
+ }
+ }
+}
+
+impl Fold for Margin {
+ fn fold(self, outer: Self) -> Self {
+ Margin {
+ sides: self.sides.fold(outer.sides),
+ two_sided: self.two_sided.fold(outer.two_sided),
+ }
+ }
+}
+
+cast! {
+ Margin,
+ self => {
+ let two_sided = self.two_sided.unwrap_or(false);
+ if !two_sided && self.sides.is_uniform() {
+ if let Some(left) = self.sides.left {
+ return left.into_value();
+ }
+ }
+
+ let mut dict = Dict::new();
+ let mut handle = |key: &str, component: Option<Smart<Rel<Length>>>| {
+ if let Some(c) = component {
+ dict.insert(key.into(), c.into_value());
+ }
+ };
+
+ handle("top", self.sides.top);
+ handle("bottom", self.sides.bottom);
+ if two_sided {
+ handle("inside", self.sides.left);
+ handle("outside", self.sides.right);
+ } else {
+ handle("left", self.sides.left);
+ handle("right", self.sides.right);
+ }
+
+ 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.
+ pub 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 => Alignment::LEFT.into_value(),
+ Self::Right => Alignment::RIGHT.into_value(),
+ },
+ v: Alignment => match v {
+ Alignment::LEFT => Self::Left,
+ Alignment::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,
+ engine: &mut Engine,
+ styles: StyleChain,
+ page: usize,
+ ) -> SourceResult<Cow<'_, Content>> {
+ Ok(match self {
+ Self::Content(content) => Cow::Borrowed(content),
+ Self::Func(func) => Cow::Owned(
+ func.call(engine, Context::new(None, Some(styles)).track(), [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 list of page ranges to be exported.
+#[derive(Debug, Clone)]
+pub struct PageRanges(Vec<PageRange>);
+
+/// A range of pages to export.
+///
+/// The range is one-indexed. For example, `1..=3` indicates the first, second
+/// and third pages should be exported.
+pub type PageRange = RangeInclusive<Option<NonZeroUsize>>;
+
+impl PageRanges {
+ /// Create new page ranges.
+ pub fn new(ranges: Vec<PageRange>) -> Self {
+ Self(ranges)
+ }
+
+ /// Check if a page, given its number, should be included when exporting the
+ /// document while restricting the exported pages to these page ranges.
+ /// This is the one-indexed version of 'includes_page_index'.
+ pub fn includes_page(&self, page: NonZeroUsize) -> bool {
+ self.includes_page_index(page.get() - 1)
+ }
+
+ /// Check if a page, given its index, should be included when exporting the
+ /// document while restricting the exported pages to these page ranges.
+ /// This is the zero-indexed version of 'includes_page'.
+ pub fn includes_page_index(&self, page: usize) -> bool {
+ let page = NonZeroUsize::try_from(page + 1).unwrap();
+ self.0.iter().any(|range| match (range.start(), range.end()) {
+ (Some(start), Some(end)) => (start..=end).contains(&&page),
+ (Some(start), None) => (start..).contains(&&page),
+ (None, Some(end)) => (..=end).contains(&&page),
+ (None, None) => true,
+ })
+ }
+}
+
+/// 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.
+ pub 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.get())
+ }
+
+ /// The height of the paper.
+ pub fn height(self) -> Abs {
+ Abs::mm(self.height.get())
+ }
+}
+
+/// 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::new($width),
+ height: Scalar::new($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/place.rs b/crates/typst-library/src/layout/place.rs
new file mode 100644
index 00000000..bedeb507
--- /dev/null
+++ b/crates/typst-library/src/layout/place.rs
@@ -0,0 +1,208 @@
+use crate::foundations::{elem, scope, Cast, Content, Packed, Smart};
+use crate::introspection::{Locatable, Unqueriable};
+use crate::layout::{Alignment, Em, Length, Rel};
+
+/// Places content relatively to its parent container.
+///
+/// Placed content can be either overlaid (the default) or floating. Overlaid
+/// content is aligned with the parent container according to the given
+/// [`alignment`]($place.alignment), and shown over any other content added so
+/// far in the container. Floating content is placed at the top or bottom of
+/// the container, displacing other content down or up respectively. In both
+/// cases, the content position can be adjusted with [`dx`]($place.dx) and
+/// [`dy`]($place.dy) offsets without affecting the layout.
+///
+/// The parent can be any container such as a [`block`], [`box`],
+/// [`rect`], etc. A top level `place` call will place content directly
+/// in the text area of the current page. This can be used for absolute
+/// positioning on the page: with a `top + left`
+/// [`alignment`]($place.alignment), the offsets `dx` and `dy` will set the
+/// position of the element's top left corner relatively to the top left corner
+/// of the text area. For absolute positioning on the full page including
+/// margins, you can use `place` in [`page.foreground`]($page.foreground) or
+/// [`page.background`]($page.background).
+///
+/// # Examples
+/// ```example
+/// #set page(height: 120pt)
+/// Hello, world!
+///
+/// #rect(
+/// width: 100%,
+/// height: 2cm,
+/// place(horizon + right, square()),
+/// )
+///
+/// #place(
+/// top + left,
+/// dx: -5pt,
+/// square(size: 5pt, fill: red),
+/// )
+/// ```
+///
+/// # Effect on the position of other elements { #effect-on-other-elements }
+/// Overlaid elements don't take space in the flow of content, but a `place`
+/// call inserts an invisible block-level element in the flow. This can
+/// affect the layout by breaking the current paragraph. To avoid this,
+/// you can wrap the `place` call in a [`box`] when the call is made
+/// in the middle of a paragraph. The alignment and offsets will then be
+/// relative to this zero-size box. To make sure it doesn't interfere with
+/// spacing, the box should be attached to a word using a word joiner.
+///
+/// For example, the following defines a function for attaching an annotation
+/// to the following word:
+///
+/// ```example
+/// >>> #set page(height: 70pt)
+/// #let annotate(..args) = {
+/// box(place(..args))
+/// sym.wj
+/// h(0pt, weak: true)
+/// }
+///
+/// A placed #annotate(square(), dy: 2pt)
+/// square in my text.
+/// ```
+///
+/// The zero-width weak spacing serves to discard spaces between the function
+/// call and the next word.
+#[elem(scope, Locatable, Unqueriable)]
+pub struct PlaceElem {
+ /// Relative to which position in the parent container to place the content.
+ ///
+ /// - If `float` is `{false}`, then this can be any alignment other than `{auto}`.
+ /// - If `float` is `{true}`, then this must be `{auto}`, `{top}`, or `{bottom}`.
+ ///
+ /// When `float` is `{false}` and no vertical alignment is specified, the
+ /// content is placed at the current position on the vertical axis.
+ #[positional]
+ #[default(Smart::Custom(Alignment::START))]
+ pub alignment: Smart<Alignment>,
+
+ /// Relative to which containing scope something is placed.
+ ///
+ /// The parent scope is primarily used with figures and, for
+ /// this reason, the figure function has a mirrored [`scope`
+ /// parameter]($figure.scope). Nonetheless, it can also be more generally
+ /// useful to break out of the columns. A typical example would be to
+ /// [create a single-column title section]($guides/page-setup-guide/#columns)
+ /// in a two-column document.
+ ///
+ /// Note that parent-scoped placement is currently only supported if `float`
+ /// is `{true}`. This may change in the future.
+ ///
+ /// ```example
+ /// #set page(height: 150pt, columns: 2)
+ /// #place(
+ /// top + center,
+ /// scope: "parent",
+ /// float: true,
+ /// rect(width: 80%, fill: aqua),
+ /// )
+ ///
+ /// #lorem(25)
+ /// ```
+ pub scope: PlacementScope,
+
+ /// Whether the placed element has floating layout.
+ ///
+ /// Floating elements are positioned at the top or bottom of the parent
+ /// container, displacing in-flow content. They are always placed in the
+ /// in-flow order relative to each other, as well as before any content
+ /// following a later [`place.flush`] element.
+ ///
+ /// ```example
+ /// #set page(height: 150pt)
+ /// #let note(where, body) = place(
+ /// center + where,
+ /// float: true,
+ /// clearance: 6pt,
+ /// rect(body),
+ /// )
+ ///
+ /// #lorem(10)
+ /// #note(bottom)[Bottom 1]
+ /// #note(bottom)[Bottom 2]
+ /// #lorem(40)
+ /// #note(top)[Top]
+ /// #lorem(10)
+ /// ```
+ pub float: bool,
+
+ /// The spacing between the placed element and other elements in a floating
+ /// layout.
+ ///
+ /// Has no effect if `float` is `{false}`.
+ #[default(Em::new(1.5).into())]
+ #[resolve]
+ pub clearance: Length,
+
+ /// 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]
+ /// }
+ /// ```
+ ///
+ /// This does not affect the layout of in-flow content.
+ /// In other words, the placed content is treated as if it
+ /// were wrapped in a [`move`] element.
+ pub dx: Rel<Length>,
+
+ /// The vertical displacement of the placed content.
+ ///
+ /// This does not affect the layout of in-flow content.
+ /// In other words, the placed content is treated as if it
+ /// were wrapped in a [`move`] element.
+ pub dy: Rel<Length>,
+
+ /// The content to place.
+ #[required]
+ pub body: Content,
+}
+
+/// `PlaceElem` must be locatable to support logical ordering of floats, but I
+/// do not want to expose `query(place)` for now.
+impl Unqueriable for Packed<PlaceElem> {}
+
+#[scope]
+impl PlaceElem {
+ #[elem]
+ type FlushElem;
+}
+
+/// Relative to which containing scope something shall be placed.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum PlacementScope {
+ /// Place into the current column.
+ #[default]
+ Column,
+ /// Place relative to the parent, letting the content span over all columns.
+ Parent,
+}
+
+/// Asks the layout algorithm to place pending floating elements before
+/// continuing with the content.
+///
+/// This is useful for preventing floating figures from spilling
+/// into the next section.
+///
+/// ```example
+/// >>> #set page(height: 160pt, width: 150pt)
+/// #lorem(15)
+///
+/// #figure(
+/// rect(width: 100%, height: 50pt),
+/// placement: auto,
+/// caption: [A rectangle],
+/// )
+///
+/// #place.flush()
+///
+/// This text appears after the figure.
+/// ```
+#[elem]
+pub struct FlushElem {}
diff --git a/crates/typst-library/src/layout/point.rs b/crates/typst-library/src/layout/point.rs
new file mode 100644
index 00000000..c3d1594e
--- /dev/null
+++ b/crates/typst-library/src/layout/point.rs
@@ -0,0 +1,168 @@
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Add, Div, Mul, Neg};
+
+use typst_utils::{Get, Numeric};
+
+use crate::layout::{Abs, Axis, Size, Transform};
+
+/// A point in 2D.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Point {
+ /// The x coordinate.
+ pub x: Abs,
+ /// The y coordinate.
+ pub y: Abs,
+}
+
+impl Point {
+ /// The origin point.
+ pub const fn zero() -> Self {
+ Self { x: Abs::zero(), y: Abs::zero() }
+ }
+
+ /// Create a new point from x and y coordinates.
+ pub const fn new(x: Abs, y: Abs) -> Self {
+ Self { x, y }
+ }
+
+ /// Create an instance with two equal components.
+ pub const fn splat(value: Abs) -> Self {
+ Self { x: value, y: value }
+ }
+
+ /// Create a new point with y set to zero.
+ pub const fn with_x(x: Abs) -> Self {
+ Self { x, y: Abs::zero() }
+ }
+
+ /// Create a new point with x set to zero.
+ pub const fn with_y(y: Abs) -> Self {
+ Self { x: Abs::zero(), y }
+ }
+
+ /// The component-wise minimum of this and another point.
+ pub fn min(self, other: Self) -> Self {
+ Self { x: self.x.min(other.x), y: self.y.min(other.y) }
+ }
+
+ /// The component-wise minimum of this and another point.
+ pub fn max(self, other: Self) -> Self {
+ Self { x: self.x.max(other.x), y: self.y.max(other.y) }
+ }
+
+ /// Maps the point with the given function.
+ pub fn map(self, f: impl Fn(Abs) -> Abs) -> Self {
+ Self { x: f(self.x), y: f(self.y) }
+ }
+
+ /// The distance between this point and the origin.
+ pub fn hypot(self) -> Abs {
+ Abs::raw(self.x.to_raw().hypot(self.y.to_raw()))
+ }
+
+ /// Transform the point with the given transformation.
+ ///
+ /// In the event that one of the coordinates is infinite, the result will
+ /// be zero.
+ pub fn transform(self, ts: Transform) -> Self {
+ Self::new(
+ ts.sx.of(self.x) + ts.kx.of(self.y) + ts.tx,
+ ts.ky.of(self.x) + ts.sy.of(self.y) + ts.ty,
+ )
+ }
+
+ /// Transforms the point with the given transformation, without accounting
+ /// for infinite values.
+ pub fn transform_inf(self, ts: Transform) -> Self {
+ Self::new(
+ ts.sx.get() * self.x + ts.kx.get() * self.y + ts.tx,
+ ts.ky.get() * self.x + ts.sy.get() * self.y + ts.ty,
+ )
+ }
+
+ /// Convert to a size.
+ pub fn to_size(self) -> Size {
+ Size::new(self.x, self.y)
+ }
+}
+
+impl Numeric for Point {
+ fn zero() -> Self {
+ Self::zero()
+ }
+
+ fn is_finite(self) -> bool {
+ self.x.is_finite() && self.y.is_finite()
+ }
+}
+
+impl Get<Axis> for Point {
+ type Component = Abs;
+
+ fn get_ref(&self, axis: Axis) -> &Abs {
+ match axis {
+ Axis::X => &self.x,
+ Axis::Y => &self.y,
+ }
+ }
+
+ fn get_mut(&mut self, axis: Axis) -> &mut Abs {
+ match axis {
+ Axis::X => &mut self.x,
+ Axis::Y => &mut self.y,
+ }
+ }
+}
+
+impl Debug for Point {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Point({:?}, {:?})", self.x, self.y)
+ }
+}
+
+impl Neg for Point {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self { x: -self.x, y: -self.y }
+ }
+}
+
+impl Add for Point {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self { x: self.x + other.x, y: self.y + other.y }
+ }
+}
+
+typst_utils::sub_impl!(Point - Point -> Point);
+
+impl Mul<f64> for Point {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self {
+ Self { x: self.x * other, y: self.y * other }
+ }
+}
+
+impl Mul<Point> for f64 {
+ type Output = Point;
+
+ fn mul(self, other: Point) -> Point {
+ other * self
+ }
+}
+
+impl Div<f64> for Point {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self { x: self.x / other, y: self.y / other }
+ }
+}
+
+typst_utils::assign_impl!(Point += Point);
+typst_utils::assign_impl!(Point -= Point);
+typst_utils::assign_impl!(Point *= f64);
+typst_utils::assign_impl!(Point /= f64);
diff --git a/crates/typst-library/src/layout/ratio.rs b/crates/typst-library/src/layout/ratio.rs
new file mode 100644
index 00000000..1c0dcd29
--- /dev/null
+++ b/crates/typst-library/src/layout/ratio.rs
@@ -0,0 +1,153 @@
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Add, Div, Mul, Neg};
+
+use ecow::EcoString;
+use typst_utils::{Numeric, Scalar};
+
+use crate::foundations::{repr, ty, Repr};
+
+/// A ratio of a whole.
+///
+/// Written as a number, followed by a percent sign.
+///
+/// # Example
+/// ```example
+/// #set align(center)
+/// #scale(x: 150%)[
+/// Scaled apart.
+/// ]
+/// ```
+#[ty(cast)]
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Ratio(Scalar);
+
+impl Ratio {
+ /// A ratio of `0%` represented as `0.0`.
+ pub const fn zero() -> Self {
+ Self(Scalar::ZERO)
+ }
+
+ /// A ratio of `100%` represented as `1.0`.
+ pub const fn one() -> Self {
+ Self(Scalar::ONE)
+ }
+
+ /// Create a new ratio from a value, where `1.0` means `100%`.
+ pub const fn new(ratio: f64) -> Self {
+ Self(Scalar::new(ratio))
+ }
+
+ /// Get the underlying ratio.
+ pub const fn get(self) -> f64 {
+ (self.0).get()
+ }
+
+ /// Whether the ratio is zero.
+ pub fn is_zero(self) -> bool {
+ self.0 == 0.0
+ }
+
+ /// Whether the ratio is one.
+ pub fn is_one(self) -> bool {
+ self.0 == 1.0
+ }
+
+ /// The absolute value of this ratio.
+ pub fn abs(self) -> Self {
+ Self::new(self.get().abs())
+ }
+
+ /// Return the ratio of the given `whole`.
+ pub fn of<T: Numeric>(self, whole: T) -> T {
+ let resolved = whole * self.get();
+ if resolved.is_finite() {
+ resolved
+ } else {
+ T::zero()
+ }
+ }
+}
+
+impl Debug for Ratio {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}%", self.get() * 100.0)
+ }
+}
+
+impl Repr for Ratio {
+ fn repr(&self) -> EcoString {
+ repr::format_float_with_unit(self.get() * 100.0, "%")
+ }
+}
+
+impl Neg for Ratio {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self(-self.0)
+ }
+}
+
+impl Add for Ratio {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self(self.0 + other.0)
+ }
+}
+
+typst_utils::sub_impl!(Ratio - Ratio -> Ratio);
+
+impl Mul for Ratio {
+ type Output = Self;
+
+ fn mul(self, other: Self) -> Self {
+ Self(self.0 * other.0)
+ }
+}
+
+impl Mul<f64> for Ratio {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self {
+ Self(self.0 * other)
+ }
+}
+
+impl Mul<Ratio> for f64 {
+ type Output = Ratio;
+
+ fn mul(self, other: Ratio) -> Ratio {
+ other * self
+ }
+}
+
+impl Div for Ratio {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.get() / other.get()
+ }
+}
+
+impl Div<f64> for Ratio {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self(self.0 / other)
+ }
+}
+
+impl Div<Ratio> for f64 {
+ type Output = Self;
+
+ fn div(self, other: Ratio) -> Self {
+ self / other.get()
+ }
+}
+
+typst_utils::assign_impl!(Ratio += Ratio);
+typst_utils::assign_impl!(Ratio -= Ratio);
+typst_utils::assign_impl!(Ratio *= Ratio);
+typst_utils::assign_impl!(Ratio *= f64);
+typst_utils::assign_impl!(Ratio /= f64);
diff --git a/crates/typst-library/src/layout/regions.rs b/crates/typst-library/src/layout/regions.rs
new file mode 100644
index 00000000..385664bb
--- /dev/null
+++ b/crates/typst-library/src/layout/regions.rs
@@ -0,0 +1,159 @@
+use std::fmt::{self, Debug, Formatter};
+
+use crate::layout::{Abs, Axes, Size};
+
+/// A single region to layout into.
+#[derive(Debug, Copy, Clone, Hash)]
+pub struct Region {
+ /// The size of the region.
+ pub size: Size,
+ /// Whether elements should expand to fill the regions instead of shrinking
+ /// to fit the content.
+ pub expand: Axes<bool>,
+}
+
+impl Region {
+ /// Create a new region.
+ pub fn new(size: Size, expand: Axes<bool>) -> Self {
+ Self { size, expand }
+ }
+}
+
+impl From<Region> for Regions<'_> {
+ fn from(region: Region) -> Self {
+ Regions {
+ size: region.size,
+ expand: region.expand,
+ full: region.size.y,
+ backlog: &[],
+ last: None,
+ }
+ }
+}
+
+/// A sequence of regions to layout into.
+///
+/// A *region* is a contiguous rectangular space in which elements
+/// can be laid out. All regions within a `Regions` object have the
+/// same width, namely `self.size.x`. This means that it is not
+/// currently possible to, for instance, have content wrap to the
+/// side of a floating element.
+#[derive(Copy, Clone, Hash)]
+pub struct Regions<'a> {
+ /// The remaining size of the first region.
+ pub size: Size,
+ /// Whether elements should expand to fill the regions instead of shrinking
+ /// to fit the content.
+ pub expand: Axes<bool>,
+ /// 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>,
+}
+
+impl Regions<'_> {
+ /// 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,
+ }
+ }
+
+ /// 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,
+ }
+ }
+
+ /// 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.may_progress()
+ }
+
+ /// Whether a region break is permitted.
+ pub fn may_break(&self) -> bool {
+ !self.backlog.is_empty() || self.last.is_some()
+ }
+
+ /// Whether calling `next()` may improve a situation where there is a lack
+ /// of space.
+ pub fn may_progress(&self) -> bool {
+ !self.backlog.is_empty() || self.last.is_some_and(|height| self.size.y != height)
+ }
+
+ /// 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/rel.rs b/crates/typst-library/src/layout/rel.rs
new file mode 100644
index 00000000..30441a42
--- /dev/null
+++ b/crates/typst-library/src/layout/rel.rs
@@ -0,0 +1,276 @@
+use std::cmp::Ordering;
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign};
+
+use ecow::{eco_format, EcoString};
+use typst_utils::Numeric;
+
+use crate::foundations::{cast, ty, Fold, Repr, Resolve, StyleChain};
+use crate::layout::{Abs, Em, Length, Ratio};
+
+/// A length in relation to some known length.
+///
+/// This type is a combination of a [length] with a [ratio]. It results from
+/// addition and subtraction of a length and a ratio. Wherever a relative length
+/// is expected, you can also use a bare length or ratio.
+///
+/// # Example
+/// ```example
+/// #rect(width: 100% - 50pt)
+///
+/// #(100% - 50pt).length \
+/// #(100% - 50pt).ratio
+/// ```
+///
+/// A relative length has the following fields:
+/// - `length`: Its length component.
+/// - `ratio`: Its ratio component.
+#[ty(cast, name = "relative", title = "Relative Length")]
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Rel<T: Numeric = Length> {
+ /// The relative part.
+ pub rel: Ratio,
+ /// The absolute part.
+ pub abs: T,
+}
+
+impl<T: Numeric> Rel<T> {
+ /// The zero relative.
+ pub fn zero() -> Self {
+ Self { rel: Ratio::zero(), abs: T::zero() }
+ }
+
+ /// A relative with a ratio of `100%` and no absolute part.
+ pub fn one() -> Self {
+ Self { rel: Ratio::one(), abs: T::zero() }
+ }
+
+ /// Create a new relative from its parts.
+ pub fn new(rel: Ratio, abs: T) -> Self {
+ Self { rel, abs }
+ }
+
+ /// Whether both parts are zero.
+ pub fn is_zero(self) -> bool {
+ self.rel.is_zero() && self.abs == T::zero()
+ }
+
+ /// Whether the relative part is one and the absolute part is zero.
+ pub fn is_one(self) -> bool {
+ self.rel.is_one() && self.abs == T::zero()
+ }
+
+ /// Evaluate this relative to the given `whole`.
+ pub fn relative_to(self, whole: T) -> T {
+ self.rel.of(whole) + self.abs
+ }
+
+ /// Map the absolute part with `f`.
+ pub fn map<F, U>(self, f: F) -> Rel<U>
+ where
+ F: FnOnce(T) -> U,
+ U: Numeric,
+ {
+ Rel { rel: self.rel, abs: f(self.abs) }
+ }
+}
+
+impl Rel<Length> {
+ /// Try to divide two relative lengths.
+ pub fn try_div(self, other: Self) -> Option<f64> {
+ if self.rel.is_zero() && other.rel.is_zero() {
+ self.abs.try_div(other.abs)
+ } else if self.abs.is_zero() && other.abs.is_zero() {
+ Some(self.rel / other.rel)
+ } else {
+ None
+ }
+ }
+
+ /// Convert to a relative length with the absolute part resolved at the
+ /// given font size.
+ pub fn at(self, font_size: Abs) -> Rel<Abs> {
+ self.map(|abs| abs.at(font_size))
+ }
+}
+
+impl<T: Numeric + Debug> Debug for Rel<T> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match (self.rel.is_zero(), self.abs.is_zero()) {
+ (false, false) => write!(f, "{:?} + {:?}", self.rel, self.abs),
+ (false, true) => self.rel.fmt(f),
+ (true, _) => self.abs.fmt(f),
+ }
+ }
+}
+
+impl<T: Numeric + Repr> Repr for Rel<T> {
+ fn repr(&self) -> EcoString {
+ eco_format!("{} + {}", self.rel.repr(), self.abs.repr())
+ }
+}
+
+impl From<Abs> for Rel<Length> {
+ fn from(abs: Abs) -> Self {
+ Rel::from(Length::from(abs))
+ }
+}
+
+impl From<Em> for Rel<Length> {
+ fn from(em: Em) -> Self {
+ Rel::from(Length::from(em))
+ }
+}
+
+impl<T: Numeric> From<T> for Rel<T> {
+ fn from(abs: T) -> Self {
+ Self { rel: Ratio::zero(), abs }
+ }
+}
+
+impl<T: Numeric> From<Ratio> for Rel<T> {
+ fn from(rel: Ratio) -> Self {
+ Self { rel, abs: T::zero() }
+ }
+}
+
+impl<T: Numeric + PartialOrd> PartialOrd for Rel<T> {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ if self.rel.is_zero() && other.rel.is_zero() {
+ self.abs.partial_cmp(&other.abs)
+ } else if self.abs.is_zero() && other.abs.is_zero() {
+ self.rel.partial_cmp(&other.rel)
+ } else {
+ None
+ }
+ }
+}
+
+impl<T: Numeric> Neg for Rel<T> {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self { rel: -self.rel, abs: -self.abs }
+ }
+}
+
+impl<T: Numeric> Add for Rel<T> {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self::Output {
+ Self {
+ rel: self.rel + other.rel,
+ abs: self.abs + other.abs,
+ }
+ }
+}
+
+impl<T: Numeric> Sub for Rel<T> {
+ type Output = Self;
+
+ fn sub(self, other: Self) -> Self::Output {
+ self + -other
+ }
+}
+
+impl<T: Numeric> Mul<f64> for Rel<T> {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self::Output {
+ Self { rel: self.rel * other, abs: self.abs * other }
+ }
+}
+
+impl<T: Numeric> Mul<Rel<T>> for f64 {
+ type Output = Rel<T>;
+
+ fn mul(self, other: Rel<T>) -> Self::Output {
+ other * self
+ }
+}
+
+impl<T: Numeric> Div<f64> for Rel<T> {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self::Output {
+ Self { rel: self.rel / other, abs: self.abs / other }
+ }
+}
+
+impl<T: Numeric + AddAssign> AddAssign for Rel<T> {
+ fn add_assign(&mut self, other: Self) {
+ self.rel += other.rel;
+ self.abs += other.abs;
+ }
+}
+
+impl<T: Numeric + SubAssign> SubAssign for Rel<T> {
+ fn sub_assign(&mut self, other: Self) {
+ self.rel -= other.rel;
+ self.abs -= other.abs;
+ }
+}
+
+impl<T: Numeric + MulAssign<f64>> MulAssign<f64> for Rel<T> {
+ fn mul_assign(&mut self, other: f64) {
+ self.rel *= other;
+ self.abs *= other;
+ }
+}
+
+impl<T: Numeric + DivAssign<f64>> DivAssign<f64> for Rel<T> {
+ fn div_assign(&mut self, other: f64) {
+ self.rel /= other;
+ self.abs /= other;
+ }
+}
+
+impl<T: Numeric> Add<T> for Ratio {
+ type Output = Rel<T>;
+
+ fn add(self, other: T) -> Self::Output {
+ Rel::from(self) + Rel::from(other)
+ }
+}
+
+impl<T: Numeric> Add<T> for Rel<T> {
+ type Output = Self;
+
+ fn add(self, other: T) -> Self::Output {
+ self + Rel::from(other)
+ }
+}
+
+impl<T: Numeric> Add<Ratio> for Rel<T> {
+ type Output = Self;
+
+ fn add(self, other: Ratio) -> Self::Output {
+ self + Rel::from(other)
+ }
+}
+
+impl<T> Resolve for Rel<T>
+where
+ T: Resolve + Numeric,
+ <T as Resolve>::Output: Numeric,
+{
+ type Output = Rel<<T as Resolve>::Output>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.map(|abs| abs.resolve(styles))
+ }
+}
+
+impl<T> Fold for Rel<T>
+where
+ T: Numeric + Fold,
+{
+ fn fold(self, outer: Self) -> Self {
+ Self { rel: self.rel, abs: self.abs.fold(outer.abs) }
+ }
+}
+
+cast! {
+ Rel<Abs>,
+ self => self.map(Length::from).into_value(),
+}
diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs
new file mode 100644
index 00000000..e423410a
--- /dev/null
+++ b/crates/typst-library/src/layout/repeat.rs
@@ -0,0 +1,49 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, NativeElement, Packed, Show, StyleChain};
+use crate::layout::{BlockElem, Length};
+
+/// 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 adjust the [`justify`]($repeat.justify) parameter accordingly.
+///
+/// Errors if there no bounds on the available space, as it would create
+/// infinite content.
+///
+/// # 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
+/// ]
+/// ```
+#[elem(Show)]
+pub struct RepeatElem {
+ /// The content to repeat.
+ #[required]
+ pub body: Content,
+
+ /// The gap between each instance of the body.
+ #[default]
+ pub gap: Length,
+
+ /// Whether to increase the gap between instances to completely fill the
+ /// available space.
+ #[default(true)]
+ pub justify: bool,
+}
+
+impl Show for Packed<RepeatElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_repeat)
+ .pack()
+ .spanned(self.span()))
+ }
+}
diff --git a/crates/typst-library/src/layout/sides.rs b/crates/typst-library/src/layout/sides.rs
new file mode 100644
index 00000000..e04b63d9
--- /dev/null
+++ b/crates/typst-library/src/layout/sides.rs
@@ -0,0 +1,343 @@
+use std::fmt::{self, Debug, Formatter};
+use std::ops::Add;
+
+use typst_utils::Get;
+
+use crate::diag::{bail, HintedStrResult};
+use crate::foundations::{
+ cast, AlternativeFold, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve,
+ StyleChain, Value,
+};
+use crate::layout::{Abs, Alignment, Axes, Axis, Corner, Rel, Size};
+
+/// A container with left, top, right and bottom components.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Sides<T> {
+ /// The value for the left side.
+ pub left: T,
+ /// The value for the top side.
+ pub top: T,
+ /// The value for the right side.
+ pub right: T,
+ /// The value for the bottom side.
+ pub bottom: T,
+}
+
+impl<T> Sides<T> {
+ /// Create a new instance from the four components.
+ pub const fn new(left: T, top: T, right: T, bottom: T) -> Self {
+ Self { left, top, right, bottom }
+ }
+
+ /// Create an instance with four equal components.
+ pub fn splat(value: T) -> Self
+ where
+ T: Clone,
+ {
+ Self {
+ left: value.clone(),
+ top: value.clone(),
+ right: value.clone(),
+ bottom: value,
+ }
+ }
+
+ /// Map the individual fields with `f`.
+ pub fn map<F, U>(self, mut f: F) -> Sides<U>
+ where
+ F: FnMut(T) -> U,
+ {
+ Sides {
+ left: f(self.left),
+ top: f(self.top),
+ right: f(self.right),
+ bottom: f(self.bottom),
+ }
+ }
+
+ /// Convert from `&Sides<T>` to `Sides<&T>`.
+ pub fn as_ref(&self) -> Sides<&T> {
+ Sides {
+ left: &self.left,
+ top: &self.top,
+ right: &self.right,
+ bottom: &self.bottom,
+ }
+ }
+
+ /// Zip two instances into one.
+ pub fn zip<U>(self, other: Sides<U>) -> Sides<(T, U)> {
+ Sides {
+ left: (self.left, other.left),
+ top: (self.top, other.top),
+ right: (self.right, other.right),
+ bottom: (self.bottom, other.bottom),
+ }
+ }
+
+ /// An iterator over the sides, starting with the left side, clockwise.
+ pub fn iter(&self) -> impl Iterator<Item = &T> {
+ [&self.left, &self.top, &self.right, &self.bottom].into_iter()
+ }
+
+ /// Whether all sides are equal.
+ pub fn is_uniform(&self) -> bool
+ where
+ T: PartialEq,
+ {
+ self.left == self.top && self.top == self.right && self.right == self.bottom
+ }
+}
+
+impl<T: Add> Sides<T> {
+ /// Sums up `left` and `right` into `x`, and `top` and `bottom` into `y`.
+ pub fn sum_by_axis(self) -> Axes<T::Output> {
+ Axes::new(self.left + self.right, self.top + self.bottom)
+ }
+}
+
+impl<T> Sides<Option<T>> {
+ /// Unwrap-or-default the individual sides.
+ pub fn unwrap_or_default(self) -> Sides<T>
+ where
+ T: Default,
+ {
+ self.map(Option::unwrap_or_default)
+ }
+}
+
+impl Sides<Rel<Abs>> {
+ /// Evaluate the sides relative to the given `size`.
+ pub fn relative_to(&self, size: Size) -> Sides<Abs> {
+ Sides {
+ left: self.left.relative_to(size.x),
+ top: self.top.relative_to(size.y),
+ right: self.right.relative_to(size.x),
+ bottom: self.bottom.relative_to(size.y),
+ }
+ }
+
+ /// Whether all sides are zero.
+ pub fn is_zero(&self) -> bool {
+ self.left.is_zero()
+ && self.top.is_zero()
+ && self.right.is_zero()
+ && self.bottom.is_zero()
+ }
+}
+
+impl<T> Get<Side> for Sides<T> {
+ type Component = T;
+
+ fn get_ref(&self, side: Side) -> &T {
+ match side {
+ Side::Left => &self.left,
+ Side::Top => &self.top,
+ Side::Right => &self.right,
+ Side::Bottom => &self.bottom,
+ }
+ }
+
+ fn get_mut(&mut self, side: Side) -> &mut T {
+ match side {
+ Side::Left => &mut self.left,
+ Side::Top => &mut self.top,
+ Side::Right => &mut self.right,
+ Side::Bottom => &mut self.bottom,
+ }
+ }
+}
+
+impl<T: Debug + PartialEq> Debug for Sides<T> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if self.is_uniform() {
+ f.write_str("Sides::splat(")?;
+ self.left.fmt(f)?;
+ f.write_str(")")
+ } else {
+ f.debug_struct("Sides")
+ .field("left", &self.left)
+ .field("top", &self.top)
+ .field("right", &self.right)
+ .field("bottom", &self.bottom)
+ .finish()
+ }
+ }
+}
+
+impl<T: Reflect> Reflect for Sides<Option<T>> {
+ fn input() -> CastInfo {
+ T::input() + Dict::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output() + Dict::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Dict::castable(value) || T::castable(value)
+ }
+}
+
+impl<T> IntoValue for Sides<Option<T>>
+where
+ T: PartialEq + IntoValue,
+{
+ fn into_value(self) -> Value {
+ if self.is_uniform() {
+ if let Some(left) = self.left {
+ return left.into_value();
+ }
+ }
+
+ let mut dict = Dict::new();
+ let mut handle = |key: &str, component: Option<T>| {
+ if let Some(c) = component {
+ dict.insert(key.into(), c.into_value());
+ }
+ };
+
+ handle("left", self.left);
+ handle("top", self.top);
+ handle("right", self.right);
+ handle("bottom", self.bottom);
+
+ Value::Dict(dict)
+ }
+}
+
+impl<T> FromValue for Sides<Option<T>>
+where
+ T: Default + FromValue + Clone,
+{
+ fn from_value(mut value: Value) -> HintedStrResult<Self> {
+ let expected_keys = ["left", "top", "right", "bottom", "x", "y", "rest"];
+ if let Value::Dict(dict) = &mut value {
+ if dict.is_empty() {
+ return Ok(Self::splat(None));
+ } else if dict.iter().any(|(key, _)| expected_keys.contains(&key.as_str())) {
+ let mut take = |key| dict.take(key).ok().map(T::from_value).transpose();
+ let rest = take("rest")?;
+ let x = take("x")?.or_else(|| rest.clone());
+ let y = take("y")?.or_else(|| rest.clone());
+ let sides = Sides {
+ left: take("left")?.or_else(|| x.clone()),
+ top: take("top")?.or_else(|| y.clone()),
+ right: take("right")?.or_else(|| x.clone()),
+ bottom: take("bottom")?.or_else(|| y.clone()),
+ };
+
+ dict.finish(&expected_keys)?;
+ return Ok(sides);
+ }
+ }
+
+ if T::castable(&value) {
+ Ok(Self::splat(Some(T::from_value(value)?)))
+ } else if let Value::Dict(dict) = &value {
+ let keys = dict.iter().map(|kv| kv.0.as_str()).collect();
+ // Do not hint at expected_keys, because T may be castable from Dict
+ // objects with other sets of expected keys.
+ Err(Dict::unexpected_keys(keys, None).into())
+ } else {
+ Err(Self::error(&value))
+ }
+ }
+}
+
+impl<T: Resolve> Resolve for Sides<T> {
+ type Output = Sides<T::Output>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.map(|v| v.resolve(styles))
+ }
+}
+
+impl<T: Fold> Fold for Sides<Option<T>> {
+ fn fold(self, outer: Self) -> Self {
+ // Usually, folding an inner `None` with an `outer` prefers the
+ // explicit `None`. However, here `None` means unspecified and thus
+ // we want `outer`, so we use `fold_or` to opt into such behavior.
+ self.zip(outer).map(|(inner, outer)| inner.fold_or(outer))
+ }
+}
+
+/// The four sides of objects.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Side {
+ /// The left side.
+ Left,
+ /// The top side.
+ Top,
+ /// The right side.
+ Right,
+ /// The bottom side.
+ Bottom,
+}
+
+impl Side {
+ /// The opposite side.
+ pub fn inv(self) -> Self {
+ match self {
+ Self::Left => Self::Right,
+ Self::Top => Self::Bottom,
+ Self::Right => Self::Left,
+ Self::Bottom => Self::Top,
+ }
+ }
+
+ /// The next side, clockwise.
+ pub fn next_cw(self) -> Self {
+ match self {
+ Self::Left => Self::Top,
+ Self::Top => Self::Right,
+ Self::Right => Self::Bottom,
+ Self::Bottom => Self::Left,
+ }
+ }
+
+ /// The next side, counter-clockwise.
+ pub fn next_ccw(self) -> Self {
+ match self {
+ Self::Left => Self::Bottom,
+ Self::Top => Self::Left,
+ Self::Right => Self::Top,
+ Self::Bottom => Self::Right,
+ }
+ }
+
+ /// The first corner of the side in clockwise order.
+ pub fn start_corner(self) -> Corner {
+ match self {
+ Self::Left => Corner::BottomLeft,
+ Self::Top => Corner::TopLeft,
+ Self::Right => Corner::TopRight,
+ Self::Bottom => Corner::BottomRight,
+ }
+ }
+
+ /// The second corner of the side in clockwise order.
+ pub fn end_corner(self) -> Corner {
+ self.next_cw().start_corner()
+ }
+
+ /// Return the corresponding axis.
+ pub fn axis(self) -> Axis {
+ match self {
+ Self::Left | Self::Right => Axis::Y,
+ Self::Top | Self::Bottom => Axis::X,
+ }
+ }
+}
+
+cast! {
+ Side,
+ self => Alignment::from(self).into_value(),
+ align: Alignment => match align {
+ Alignment::LEFT => Self::Left,
+ Alignment::RIGHT => Self::Right,
+ Alignment::TOP => Self::Top,
+ Alignment::BOTTOM => Self::Bottom,
+ _ => bail!("cannot convert this alignment to a side"),
+ },
+}
diff --git a/crates/typst-library/src/layout/size.rs b/crates/typst-library/src/layout/size.rs
new file mode 100644
index 00000000..294eec66
--- /dev/null
+++ b/crates/typst-library/src/layout/size.rs
@@ -0,0 +1,87 @@
+use std::ops::{Add, Div, Mul, Neg};
+
+use typst_utils::Numeric;
+
+use crate::layout::{Abs, Axes, Point, Ratio};
+
+/// A size in 2D.
+pub type Size = Axes<Abs>;
+
+impl Size {
+ /// The zero value.
+ pub const fn zero() -> Self {
+ Self { x: Abs::zero(), y: Abs::zero() }
+ }
+
+ /// Whether the other size fits into this one (smaller width and height).
+ pub fn fits(self, other: Self) -> bool {
+ self.x.fits(other.x) && self.y.fits(other.y)
+ }
+
+ /// Convert to a point.
+ pub fn to_point(self) -> Point {
+ Point::new(self.x, self.y)
+ }
+
+ /// Converts to a ratio of width to height.
+ pub fn aspect_ratio(self) -> Ratio {
+ Ratio::new(self.x / self.y)
+ }
+}
+
+impl Numeric for Size {
+ fn zero() -> Self {
+ Self::zero()
+ }
+
+ fn is_finite(self) -> bool {
+ self.x.is_finite() && self.y.is_finite()
+ }
+}
+
+impl Neg for Size {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self { x: -self.x, y: -self.y }
+ }
+}
+
+impl Add for Size {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self { x: self.x + other.x, y: self.y + other.y }
+ }
+}
+
+typst_utils::sub_impl!(Size - Size -> Size);
+
+impl Mul<f64> for Size {
+ type Output = Self;
+
+ fn mul(self, other: f64) -> Self {
+ Self { x: self.x * other, y: self.y * other }
+ }
+}
+
+impl Mul<Size> for f64 {
+ type Output = Size;
+
+ fn mul(self, other: Size) -> Size {
+ other * self
+ }
+}
+
+impl Div<f64> for Size {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self { x: self.x / other, y: self.y / other }
+ }
+}
+
+typst_utils::assign_impl!(Size -= Size);
+typst_utils::assign_impl!(Size += Size);
+typst_utils::assign_impl!(Size *= f64);
+typst_utils::assign_impl!(Size /= f64);
diff --git a/crates/typst-library/src/layout/spacing.rs b/crates/typst-library/src/layout/spacing.rs
new file mode 100644
index 00000000..b3ca1e81
--- /dev/null
+++ b/crates/typst-library/src/layout/spacing.rs
@@ -0,0 +1,193 @@
+use typst_utils::Numeric;
+
+use crate::foundations::{cast, elem, Content};
+use crate::layout::{Abs, Em, Fr, Length, Ratio, Rel};
+
+/// 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
+/// First #h(1cm) Second \
+/// First #h(30%) Second
+/// ```
+///
+/// # Fractional spacing
+/// With fractional spacing, you can align things within a line without forcing
+/// a paragraph break (like [`align`] would). Each fractionally sized element
+/// gets space based on the ratio of its fraction to the sum of all fractions.
+///
+/// ```example
+/// First #h(1fr) Second \
+/// First #h(1fr) Second #h(1fr) Third \
+/// 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` (1/6 em), `med` (2/9 em),
+/// `thick` (5/18 em), `quad` (1 em), `wide` (2 em).
+#[elem(title = "Spacing (H)")]
+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.
+ ///
+ /// Weak spacing in markup also causes all adjacent markup spaces to be
+ /// removed, regardless of the amount of spacing inserted. To force a space
+ /// next to weak spacing, you can explicitly write `[#" "]` (for a normal
+ /// space) or `[~]` (for a non-breaking space). The latter can be useful to
+ /// create a construct that always attaches to the preceding word with one
+ /// non-breaking space, independently of whether a markup space existed in
+ /// front or not.
+ ///
+ /// ```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.
+ ///
+ /// Further #h(0pt, weak: true) more,
+ /// even the smallest of them swallow
+ /// adjacent markup spaces.
+ /// ```
+ #[default(false)]
+ pub weak: bool,
+}
+
+impl HElem {
+ /// Zero-width horizontal weak spacing that eats surrounding spaces.
+ pub fn hole() -> Self {
+ Self::new(Abs::zero().into()).with_weak(true)
+ }
+}
+
+/// 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
+/// #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],
+/// )
+/// ```
+#[elem(title = "Spacing (V)")]
+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:
+ /// ```
+ pub weak: bool,
+
+ /// Whether the spacing collapses if not immediately preceded by a
+ /// paragraph.
+ #[internal]
+ #[parse(Some(false))]
+ pub attach: bool,
+}
+
+cast! {
+ VElem,
+ v: Content => v.unpack::<Self>().map_err(|_| "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)
+ }
+}
+
+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..5fc78480
--- /dev/null
+++ b/crates/typst-library/src/layout/stack.rs
@@ -0,0 +1,84 @@
+use std::fmt::{self, Debug, Formatter};
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{cast, elem, Content, NativeElement, Packed, Show, StyleChain};
+use crate::layout::{BlockElem, Dir, Spacing};
+
+/// 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
+/// #stack(
+/// dir: ttb,
+/// rect(width: 40pt),
+/// rect(width: 120pt),
+/// rect(width: 90pt),
+/// )
+/// ```
+#[elem(Show)]
+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.
+ ///
+ /// You can use the `start` and `end` methods to obtain the initial and
+ /// final points (respectively) of a direction, as `alignment`. You can also
+ /// use the `axis` method to determine whether a direction is
+ /// `{"horizontal"}` or `{"vertical"}`. The `inv` method returns a
+ /// direction's inverse direction.
+ ///
+ /// For example, `{ttb.start()}` is `top`, `{ttb.end()}` is `bottom`,
+ /// `{ttb.axis()}` is `{"vertical"}` and `{ttb.inv()}` is equal to `btt`.
+ #[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 Show for Packed<StackElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::multi_layouter(self.clone(), engine.routines.layout_stack)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A child of a stack element.
+#[derive(Clone, PartialEq, 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),
+}
diff --git a/crates/typst-library/src/layout/transform.rs b/crates/typst-library/src/layout/transform.rs
new file mode 100644
index 00000000..183df609
--- /dev/null
+++ b/crates/typst-library/src/layout/transform.rs
@@ -0,0 +1,408 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, NativeElement, Packed, Show, Smart, StyleChain,
+};
+use crate::layout::{
+ Abs, Alignment, Angle, BlockElem, HAlignment, Length, Ratio, Rel, VAlignment,
+};
+
+/// 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
+/// #rect(inset: 0pt, move(
+/// dx: 6pt, dy: 6pt,
+/// rect(
+/// inset: 8pt,
+/// fill: white,
+/// stroke: black,
+/// [Abra cadabra]
+/// )
+/// ))
+/// ```
+#[elem(Show)]
+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 Show for Packed<MoveElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_move)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// Rotates content without affecting layout.
+///
+/// Rotates an element by a given angle. The layout will act as if the element
+/// was not rotated unless you specify `{reflow: true}`.
+///
+/// # Example
+/// ```example
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// ..range(16)
+/// .map(i => rotate(24deg * i)[X]),
+/// )
+/// ```
+#[elem(Show)]
+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()))
+ /// ```
+ #[fold]
+ #[default(HAlignment::Center + VAlignment::Horizon)]
+ pub origin: Alignment,
+
+ /// Whether the rotation impacts the layout.
+ ///
+ /// If set to `{false}`, the rotated content will retain the bounding box of
+ /// the original content. If set to `{true}`, the bounding box will take the
+ /// rotation of the content into account and adjust the layout accordingly.
+ ///
+ /// ```example
+ /// Hello #rotate(90deg, reflow: true)[World]!
+ /// ```
+ #[default(false)]
+ pub reflow: bool,
+
+ /// The content to rotate.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<RotateElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_rotate)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// Scales content without affecting layout.
+///
+/// Lets you mirror content by specifying a negative scale on a single axis.
+///
+/// # Example
+/// ```example
+/// #set align(center)
+/// #scale(x: -100%)[This is mirrored.]
+/// #scale(x: -100%, reflow: true)[This is mirrored.]
+/// ```
+#[elem(Show)]
+pub struct ScaleElem {
+ /// The scaling factor for both axes, as a positional argument. This is just
+ /// an optional shorthand notation for setting `x` and `y` to the same
+ /// value.
+ #[external]
+ #[positional]
+ #[default(Smart::Custom(ScaleAmount::Ratio(Ratio::one())))]
+ pub factor: Smart<ScaleAmount>,
+
+ /// 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(Smart::Custom(ScaleAmount::Ratio(Ratio::one())))]
+ pub x: Smart<ScaleAmount>,
+
+ /// The vertical scaling factor.
+ ///
+ /// The body will be mirrored vertically if the parameter is negative.
+ #[parse(args.named("y")?.or(all))]
+ #[default(Smart::Custom(ScaleAmount::Ratio(Ratio::one())))]
+ pub y: Smart<ScaleAmount>,
+
+ /// The origin of the transformation.
+ ///
+ /// ```example
+ /// A#box(scale(75%)[A])A \
+ /// B#box(scale(75%, origin: bottom + left)[B])B
+ /// ```
+ #[fold]
+ #[default(HAlignment::Center + VAlignment::Horizon)]
+ pub origin: Alignment,
+
+ /// Whether the scaling impacts the layout.
+ ///
+ /// If set to `{false}`, the scaled content will be allowed to overlap
+ /// other content. If set to `{true}`, it will compute the new size of
+ /// the scaled content and adjust the layout accordingly.
+ ///
+ /// ```example
+ /// Hello #scale(x: 20%, y: 40%, reflow: true)[World]!
+ /// ```
+ #[default(false)]
+ pub reflow: bool,
+
+ /// The content to scale.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<ScaleElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_scale)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// To what size something shall be scaled.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ScaleAmount {
+ Ratio(Ratio),
+ Length(Length),
+}
+
+cast! {
+ ScaleAmount,
+ self => match self {
+ ScaleAmount::Ratio(ratio) => ratio.into_value(),
+ ScaleAmount::Length(length) => length.into_value(),
+ },
+ ratio: Ratio => ScaleAmount::Ratio(ratio),
+ length: Length => ScaleAmount::Length(length),
+}
+
+/// Skews content.
+///
+/// Skews an element in horizontal and/or vertical direction. The layout will
+/// act as if the element was not skewed unless you specify `{reflow: true}`.
+///
+/// # Example
+/// ```example
+/// #skew(ax: -12deg)[
+/// This is some fake italic text.
+/// ]
+/// ```
+#[elem(Show)]
+pub struct SkewElem {
+ /// The horizontal skewing angle.
+ ///
+ /// ```example
+ /// #skew(ax: 30deg)[Skewed]
+ /// ```
+ ///
+ #[default(Angle::zero())]
+ pub ax: Angle,
+
+ /// The vertical skewing angle.
+ ///
+ /// ```example
+ /// #skew(ay: 30deg)[Skewed]
+ /// ```
+ ///
+ #[default(Angle::zero())]
+ pub ay: Angle,
+
+ /// The origin of the skew transformation.
+ ///
+ /// The origin will stay fixed during the operation.
+ ///
+ /// ```example
+ /// X #box(skew(ax: -30deg, origin: center + horizon)[X]) X \
+ /// X #box(skew(ax: -30deg, origin: bottom + left)[X]) X \
+ /// X #box(skew(ax: -30deg, origin: top + right)[X]) X
+ /// ```
+ #[fold]
+ #[default(HAlignment::Center + VAlignment::Horizon)]
+ pub origin: Alignment,
+
+ /// Whether the skew transformation impacts the layout.
+ ///
+ /// If set to `{false}`, the skewed content will retain the bounding box of
+ /// the original content. If set to `{true}`, the bounding box will take the
+ /// transformation of the content into account and adjust the layout accordingly.
+ ///
+ /// ```example
+ /// Hello #skew(ay: 30deg, reflow: true, "World")!
+ /// ```
+ #[default(false)]
+ pub reflow: bool,
+
+ /// The content to skew.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<SkewElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_skew)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A scale-skew-translate transformation.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Transform {
+ pub sx: Ratio,
+ pub ky: Ratio,
+ pub kx: Ratio,
+ pub sy: Ratio,
+ pub tx: Abs,
+ pub ty: Abs,
+}
+
+impl Transform {
+ /// The identity transformation.
+ pub const fn identity() -> Self {
+ Self {
+ sx: Ratio::one(),
+ ky: Ratio::zero(),
+ kx: Ratio::zero(),
+ sy: Ratio::one(),
+ tx: Abs::zero(),
+ ty: Abs::zero(),
+ }
+ }
+
+ /// A translate transform.
+ pub const fn translate(tx: Abs, ty: Abs) -> Self {
+ Self { tx, ty, ..Self::identity() }
+ }
+
+ /// A scale transform.
+ pub const fn scale(sx: Ratio, sy: Ratio) -> Self {
+ Self { sx, sy, ..Self::identity() }
+ }
+
+ /// A rotate transform.
+ pub fn rotate(angle: Angle) -> Self {
+ let cos = Ratio::new(angle.cos());
+ let sin = Ratio::new(angle.sin());
+ Self {
+ sx: cos,
+ ky: sin,
+ kx: -sin,
+ sy: cos,
+ ..Self::default()
+ }
+ }
+
+ /// A skew transform.
+ pub fn skew(ax: Angle, ay: Angle) -> Self {
+ Self {
+ kx: Ratio::new(ax.tan()),
+ ky: Ratio::new(ay.tan()),
+ ..Self::identity()
+ }
+ }
+
+ /// Whether this is the identity transformation.
+ pub fn is_identity(self) -> bool {
+ self == Self::identity()
+ }
+
+ /// Pre-concatenate another transformation.
+ pub fn pre_concat(self, prev: Self) -> Self {
+ Transform {
+ sx: self.sx * prev.sx + self.kx * prev.ky,
+ ky: self.ky * prev.sx + self.sy * prev.ky,
+ kx: self.sx * prev.kx + self.kx * prev.sy,
+ sy: self.ky * prev.kx + self.sy * prev.sy,
+ tx: self.sx.of(prev.tx) + self.kx.of(prev.ty) + self.tx,
+ ty: self.ky.of(prev.tx) + self.sy.of(prev.ty) + self.ty,
+ }
+ }
+
+ /// Post-concatenate another transformation.
+ pub fn post_concat(self, next: Self) -> Self {
+ next.pre_concat(self)
+ }
+
+ /// Inverts the transformation.
+ ///
+ /// Returns `None` if the determinant of the matrix is zero.
+ pub fn invert(self) -> Option<Self> {
+ // Allow the trivial case to be inlined.
+ if self.is_identity() {
+ return Some(self);
+ }
+
+ // Fast path for scale-translate-only transforms.
+ if self.kx.is_zero() && self.ky.is_zero() {
+ if self.sx.is_zero() || self.sy.is_zero() {
+ return Some(Self::translate(-self.tx, -self.ty));
+ }
+
+ let inv_x = 1.0 / self.sx;
+ let inv_y = 1.0 / self.sy;
+ return Some(Self {
+ sx: Ratio::new(inv_x),
+ ky: Ratio::zero(),
+ kx: Ratio::zero(),
+ sy: Ratio::new(inv_y),
+ tx: -self.tx * inv_x,
+ ty: -self.ty * inv_y,
+ });
+ }
+
+ let det = self.sx * self.sy - self.kx * self.ky;
+ if det.get().abs() < 1e-12 {
+ return None;
+ }
+
+ let inv_det = 1.0 / det;
+ Some(Self {
+ sx: (self.sy * inv_det),
+ ky: (-self.ky * inv_det),
+ kx: (-self.kx * inv_det),
+ sy: (self.sx * inv_det),
+ tx: Abs::pt(
+ (self.kx.get() * self.ty.to_pt() - self.sy.get() * self.tx.to_pt())
+ * inv_det,
+ ),
+ ty: Abs::pt(
+ (self.ky.get() * self.tx.to_pt() - self.sx.get() * self.ty.to_pt())
+ * inv_det,
+ ),
+ })
+ }
+}
+
+impl Default for Transform {
+ fn default() -> Self {
+ Self::identity()
+ }
+}
diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs
new file mode 100644
index 00000000..34c2e526
--- /dev/null
+++ b/crates/typst-library/src/lib.rs
@@ -0,0 +1,264 @@
+//! Typst's standard library.
+//!
+//! This crate also contains all of the compiler's central type definitions as
+//! these are interwoven with the standard library types.
+//!
+//! In contrast to the _types,_ most of the compilation _behaviour_ is split out
+//! into separate crates (`typst-eval`, `typst-realize`, `typst-layout`, etc.)
+//!
+//! Note that, unless you are working on the compiler itself, you will rarely
+//! need to interact with this crate, as it is fully reexported by the `typst`
+//! crate.
+
+extern crate self as typst_library;
+
+pub mod diag;
+pub mod engine;
+pub mod foundations;
+pub mod introspection;
+pub mod layout;
+pub mod loading;
+pub mod math;
+pub mod model;
+pub mod routines;
+pub mod symbols;
+pub mod text;
+pub mod visualize;
+
+use std::ops::{Deref, Range};
+
+use ecow::EcoString;
+use typst_syntax::package::PackageSpec;
+use typst_syntax::{FileId, Source, Span};
+use typst_utils::LazyHash;
+
+use crate::diag::FileResult;
+use crate::foundations::{Array, Bytes, Datetime, Dict, Module, Scope, Styles, Value};
+use crate::layout::{Alignment, Dir};
+use crate::text::{Font, FontBook};
+use crate::visualize::Color;
+
+/// The environment in which typesetting occurs.
+///
+/// All loading functions (`main`, `source`, `file`, `font`) should perform
+/// internal caching so that they are relatively cheap on repeated invocations
+/// with the same argument. [`Source`], [`Bytes`], and [`Font`] are
+/// all reference-counted and thus cheap to clone.
+///
+/// The compiler doesn't do the caching itself because the world has much more
+/// information on when something can change. For example, fonts typically don't
+/// change and can thus even be cached across multiple compilations (for
+/// long-running applications like `typst watch`). Source files on the other
+/// hand can change and should thus be cleared after each compilation. Advanced
+/// clients like language servers can also retain the source files and
+/// [edit](Source::edit) them in-place to benefit from better incremental
+/// performance.
+#[comemo::track]
+pub trait World: Send + Sync {
+ /// The standard library.
+ ///
+ /// Can be created through `Library::build()`.
+ fn library(&self) -> &LazyHash<Library>;
+
+ /// Metadata about all known fonts.
+ fn book(&self) -> &LazyHash<FontBook>;
+
+ /// Get the file id of the main source file.
+ fn main(&self) -> FileId;
+
+ /// Try to access the specified source file.
+ fn source(&self, id: FileId) -> FileResult<Source>;
+
+ /// Try to access the specified file.
+ fn file(&self, id: FileId) -> FileResult<Bytes>;
+
+ /// Try to access the font with the given index in the font book.
+ fn font(&self, index: usize) -> Option<Font>;
+
+ /// Get the current date.
+ ///
+ /// If no offset is specified, the local date should be chosen. Otherwise,
+ /// the UTC date should be chosen with the corresponding offset in hours.
+ ///
+ /// If this function returns `None`, Typst's `datetime` function will
+ /// return an error.
+ fn today(&self, offset: Option<i64>) -> Option<Datetime>;
+
+ /// A list of all available packages and optionally descriptions for them.
+ ///
+ /// This function is optional to implement. It enhances the user experience
+ /// by enabling autocompletion for packages. Details about packages from the
+ /// `@preview` namespace are available from
+ /// `https://packages.typst.org/preview/index.json`.
+ fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] {
+ &[]
+ }
+}
+
+macro_rules! world_impl {
+ ($W:ident for $ptr:ty) => {
+ impl<$W: World> World for $ptr {
+ fn library(&self) -> &LazyHash<Library> {
+ self.deref().library()
+ }
+
+ fn book(&self) -> &LazyHash<FontBook> {
+ self.deref().book()
+ }
+
+ fn main(&self) -> FileId {
+ self.deref().main()
+ }
+
+ fn source(&self, id: FileId) -> FileResult<Source> {
+ self.deref().source(id)
+ }
+
+ fn file(&self, id: FileId) -> FileResult<Bytes> {
+ self.deref().file(id)
+ }
+
+ fn font(&self, index: usize) -> Option<Font> {
+ self.deref().font(index)
+ }
+
+ fn today(&self, offset: Option<i64>) -> Option<Datetime> {
+ self.deref().today(offset)
+ }
+
+ fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] {
+ self.deref().packages()
+ }
+ }
+ };
+}
+
+world_impl!(W for std::boxed::Box<W>);
+world_impl!(W for std::sync::Arc<W>);
+world_impl!(W for &W);
+
+/// Helper methods on [`World`] implementations.
+pub trait WorldExt {
+ /// Get the byte range for a span.
+ ///
+ /// Returns `None` if the `Span` does not point into any source file.
+ fn range(&self, span: Span) -> Option<Range<usize>>;
+}
+
+impl<T: World> WorldExt for T {
+ fn range(&self, span: Span) -> Option<Range<usize>> {
+ self.source(span.id()?).ok()?.range(span)
+ }
+}
+
+/// Definition of Typst's standard library.
+#[derive(Debug, Clone, Hash)]
+pub struct Library {
+ /// The module that contains the definitions that are available everywhere.
+ pub global: Module,
+ /// The module that contains the definitions available in math mode.
+ pub math: Module,
+ /// The default style properties (for page size, font selection, and
+ /// everything else configurable via set and show rules).
+ pub styles: Styles,
+ /// The standard library as a value.
+ /// Used to provide the `std` variable.
+ pub std: Value,
+}
+
+impl Library {
+ /// Create a new builder for a library.
+ pub fn builder() -> LibraryBuilder {
+ LibraryBuilder::default()
+ }
+}
+
+impl Default for Library {
+ /// Constructs the standard library with the default configuration.
+ fn default() -> Self {
+ Self::builder().build()
+ }
+}
+
+/// Configurable builder for the standard library.
+///
+/// This struct is created by [`Library::builder`].
+#[derive(Debug, Clone, Default)]
+pub struct LibraryBuilder {
+ inputs: Option<Dict>,
+}
+
+impl LibraryBuilder {
+ /// Configure the inputs visible through `sys.inputs`.
+ pub fn with_inputs(mut self, inputs: Dict) -> Self {
+ self.inputs = Some(inputs);
+ self
+ }
+
+ /// Consumes the builder and returns a `Library`.
+ pub fn build(self) -> Library {
+ let math = math::module();
+ let inputs = self.inputs.unwrap_or_default();
+ let global = global(math.clone(), inputs);
+ let std = Value::Module(global.clone());
+ Library { global, math, styles: Styles::new(), std }
+ }
+}
+
+/// Construct the module with global definitions.
+fn global(math: Module, inputs: Dict) -> Module {
+ let mut global = Scope::deduplicating();
+ self::foundations::define(&mut global, inputs);
+ self::model::define(&mut global);
+ self::text::define(&mut global);
+ global.reset_category();
+ global.define_module(math);
+ self::layout::define(&mut global);
+ self::visualize::define(&mut global);
+ self::introspection::define(&mut global);
+ self::loading::define(&mut global);
+ self::symbols::define(&mut global);
+ prelude(&mut global);
+ Module::new("global", global)
+}
+
+/// Defines scoped values that are globally available, too.
+fn prelude(global: &mut Scope) {
+ global.reset_category();
+ 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);
+ global.define("luma", Color::luma_data());
+ global.define("oklab", Color::oklab_data());
+ global.define("oklch", Color::oklch_data());
+ global.define("rgb", Color::rgb_data());
+ global.define("cmyk", Color::cmyk_data());
+ global.define("range", Array::range_data());
+ global.define("ltr", Dir::LTR);
+ global.define("rtl", Dir::RTL);
+ global.define("ttb", Dir::TTB);
+ global.define("btt", Dir::BTT);
+ global.define("start", Alignment::START);
+ global.define("left", Alignment::LEFT);
+ global.define("center", Alignment::CENTER);
+ global.define("right", Alignment::RIGHT);
+ global.define("end", Alignment::END);
+ global.define("top", Alignment::TOP);
+ global.define("horizon", Alignment::HORIZON);
+ global.define("bottom", Alignment::BOTTOM);
+}
diff --git a/crates/typst-library/src/loading/cbor.rs b/crates/typst-library/src/loading/cbor.rs
new file mode 100644
index 00000000..977059c3
--- /dev/null
+++ b/crates/typst-library/src/loading/cbor.rs
@@ -0,0 +1,62 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Bytes, Value};
+use crate::World;
+
+/// Reads structured data from a CBOR file.
+///
+/// The file must contain a valid CBOR serialization. Mappings will be
+/// converted into Typst dictionaries, and 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.
+///
+/// Be aware that integers larger than 2<sup>63</sup>-1 will be converted to
+/// floating point numbers, which may result in an approximative value.
+#[func(scope, title = "CBOR")]
+pub fn cbor(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a CBOR file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ cbor::decode(Spanned::new(data, span))
+}
+
+#[scope]
+impl cbor {
+ /// Reads structured data from CBOR bytes.
+ #[func(title = "Decode CBOR")]
+ pub fn decode(
+ /// cbor data.
+ data: Spanned<Bytes>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ ciborium::from_reader(data.as_slice())
+ .map_err(|err| eco_format!("failed to parse CBOR ({err})"))
+ .at(span)
+ }
+
+ /// Encode structured data into CBOR bytes.
+ #[func(title = "Encode CBOR")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ ) -> SourceResult<Bytes> {
+ let Spanned { v: value, span } = value;
+ let mut res = Vec::new();
+ ciborium::into_writer(&value, &mut res)
+ .map(|_| res.into())
+ .map_err(|err| eco_format!("failed to encode value as CBOR ({err})"))
+ .at(span)
+ }
+}
diff --git a/crates/typst-library/src/loading/csv.rs b/crates/typst-library/src/loading/csv.rs
new file mode 100644
index 00000000..6822505d
--- /dev/null
+++ b/crates/typst-library/src/loading/csv.rs
@@ -0,0 +1,191 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{bail, At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{cast, func, scope, Array, Dict, IntoValue, Type, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// 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
+/// #let results = csv("example.csv")
+///
+/// #table(
+/// columns: 2,
+/// [*Condition*], [*Result*],
+/// ..results.flatten(),
+/// )
+/// ```
+#[func(scope, title = "CSV")]
+pub fn csv(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a CSV file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+ /// The delimiter that separates columns in the CSV file.
+ /// Must be a single ASCII character.
+ #[named]
+ #[default]
+ delimiter: Delimiter,
+ /// How to represent the file's rows.
+ ///
+ /// - If set to `array`, each row is represented as a plain array of
+ /// strings.
+ /// - If set to `dictionary`, each row is represented as a dictionary
+ /// mapping from header keys to strings. This option only makes sense when
+ /// a header row is present in the CSV file.
+ #[named]
+ #[default(RowType::Array)]
+ row_type: RowType,
+) -> SourceResult<Array> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ self::csv::decode(Spanned::new(Readable::Bytes(data), span), delimiter, row_type)
+}
+
+#[scope]
+impl csv {
+ /// Reads structured data from a CSV string/bytes.
+ #[func(title = "Decode CSV")]
+ pub fn decode(
+ /// CSV data.
+ data: Spanned<Readable>,
+ /// The delimiter that separates columns in the CSV file.
+ /// Must be a single ASCII character.
+ #[named]
+ #[default]
+ delimiter: Delimiter,
+ /// How to represent the file's rows.
+ ///
+ /// - If set to `array`, each row is represented as a plain array of
+ /// strings.
+ /// - If set to `dictionary`, each row is represented as a dictionary
+ /// mapping from header keys to strings. This option only makes sense
+ /// when a header row is present in the CSV file.
+ #[named]
+ #[default(RowType::Array)]
+ row_type: RowType,
+ ) -> SourceResult<Array> {
+ let Spanned { v: data, span } = data;
+ let has_headers = row_type == RowType::Dict;
+
+ let mut builder = ::csv::ReaderBuilder::new();
+ builder.has_headers(has_headers);
+ builder.delimiter(delimiter.0 as u8);
+
+ // Counting lines from 1 by default.
+ let mut line_offset: usize = 1;
+ let mut reader = builder.from_reader(data.as_slice());
+ let mut headers: Option<::csv::StringRecord> = None;
+
+ if has_headers {
+ // Counting lines from 2 because we have a header.
+ line_offset += 1;
+ headers = Some(
+ reader
+ .headers()
+ .map_err(|err| format_csv_error(err, 1))
+ .at(span)?
+ .clone(),
+ );
+ }
+
+ let mut array = Array::new();
+ for (line, result) in reader.records().enumerate() {
+ // Original solution was to 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 + line_offset;
+ let row = result.map_err(|err| format_csv_error(err, line)).at(span)?;
+ let item = if let Some(headers) = &headers {
+ let mut dict = Dict::new();
+ for (field, value) in headers.iter().zip(&row) {
+ dict.insert(field.into(), value.into_value());
+ }
+ dict.into_value()
+ } else {
+ let sub = row.into_iter().map(|field| field.into_value()).collect();
+ Value::Array(sub)
+ };
+ array.push(item);
+ }
+
+ 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)
+ },
+}
+
+/// The type of parsed rows.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum RowType {
+ Array,
+ Dict,
+}
+
+cast! {
+ RowType,
+ self => match self {
+ Self::Array => Type::of::<Array>(),
+ Self::Dict => Type::of::<Dict>(),
+ }.into_value(),
+ ty: Type => {
+ if ty == Type::of::<Array>() {
+ Self::Array
+ } else if ty == Type::of::<Dict>() {
+ Self::Dict
+ } else {
+ bail!("expected `array` or `dictionary`");
+ }
+ },
+}
+
+/// Format the user-facing CSV error message.
+fn format_csv_error(err: ::csv::Error, line: usize) -> EcoString {
+ match err.kind() {
+ ::csv::ErrorKind::Utf8 { .. } => "file is not valid utf-8".into(),
+ ::csv::ErrorKind::UnequalLengths { expected_len, len, .. } => {
+ eco_format!(
+ "failed to parse CSV (found {len} instead of \
+ {expected_len} fields in line {line})"
+ )
+ }
+ _ => eco_format!("failed to parse CSV ({err})"),
+ }
+}
diff --git a/crates/typst-library/src/loading/json.rs b/crates/typst-library/src/loading/json.rs
new file mode 100644
index 00000000..597cf4cc
--- /dev/null
+++ b/crates/typst-library/src/loading/json.rs
@@ -0,0 +1,101 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads structured data from a JSON file.
+///
+/// The file must contain a valid JSON value, such as 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.
+///
+/// Be aware that integers larger than 2<sup>63</sup>-1 will be converted to
+/// floating point numbers, which may result in an approximative value.
+///
+/// The function returns a dictionary, an array or, depending on the JSON file,
+/// another JSON data type.
+///
+/// The JSON files in the example contain objects with the keys `temperature`,
+/// `unit`, and `weather`.
+///
+/// # 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"))
+/// ```
+#[func(scope, title = "JSON")]
+pub fn json(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a JSON file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ json::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl json {
+ /// Reads structured data from a JSON string/bytes.
+ #[func(title = "Decode JSON")]
+ pub fn decode(
+ /// JSON data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ serde_json::from_slice(data.as_slice())
+ .map_err(|err| eco_format!("failed to parse JSON ({err})"))
+ .at(span)
+ }
+
+ /// Encodes structured data into a JSON string.
+ #[func(title = "Encode JSON")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ /// Whether to pretty print the JSON with newlines and indentation.
+ #[named]
+ #[default(true)]
+ pretty: bool,
+ ) -> SourceResult<Str> {
+ let Spanned { v: value, span } = value;
+ if pretty {
+ serde_json::to_string_pretty(&value)
+ } else {
+ serde_json::to_string(&value)
+ }
+ .map(|v| v.into())
+ .map_err(|err| eco_format!("failed to encode value as JSON ({err})"))
+ .at(span)
+ }
+}
diff --git a/crates/typst-library/src/loading/mod.rs b/crates/typst-library/src/loading/mod.rs
new file mode 100644
index 00000000..ae74df86
--- /dev/null
+++ b/crates/typst-library/src/loading/mod.rs
@@ -0,0 +1,89 @@
+//! Data loading.
+
+#[path = "cbor.rs"]
+mod cbor_;
+#[path = "csv.rs"]
+mod csv_;
+#[path = "json.rs"]
+mod json_;
+#[path = "read.rs"]
+mod read_;
+#[path = "toml.rs"]
+mod toml_;
+#[path = "xml.rs"]
+mod xml_;
+#[path = "yaml.rs"]
+mod yaml_;
+
+pub use self::cbor_::*;
+pub use self::csv_::*;
+pub use self::json_::*;
+pub use self::read_::*;
+pub use self::toml_::*;
+pub use self::xml_::*;
+pub use self::yaml_::*;
+
+use crate::foundations::{cast, category, Bytes, Category, Scope, Str};
+
+/// Data loading from external files.
+///
+/// These functions help you with loading and embedding data, for example from
+/// the results of an experiment.
+#[category]
+pub static DATA_LOADING: Category;
+
+/// Hook up all `data-loading` definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.category(DATA_LOADING);
+ global.define_func::<read>();
+ global.define_func::<csv>();
+ global.define_func::<json>();
+ global.define_func::<toml>();
+ global.define_func::<yaml>();
+ global.define_func::<cbor>();
+ global.define_func::<xml>();
+}
+
+/// A value that can be read from a file.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Readable {
+ /// A decoded string.
+ Str(Str),
+ /// Raw bytes.
+ Bytes(Bytes),
+}
+
+impl Readable {
+ pub fn as_slice(&self) -> &[u8] {
+ match self {
+ Readable::Bytes(v) => v,
+ Readable::Str(v) => v.as_bytes(),
+ }
+ }
+
+ pub fn as_str(&self) -> Option<&str> {
+ match self {
+ Readable::Str(v) => Some(v.as_str()),
+ Readable::Bytes(v) => std::str::from_utf8(v).ok(),
+ }
+ }
+}
+
+cast! {
+ Readable,
+ self => match self {
+ Self::Str(v) => v.into_value(),
+ Self::Bytes(v) => v.into_value(),
+ },
+ v: Str => Self::Str(v),
+ v: Bytes => Self::Bytes(v),
+}
+
+impl From<Readable> for Bytes {
+ fn from(value: Readable) -> Self {
+ match value {
+ Readable::Bytes(v) => v,
+ Readable::Str(v) => v.as_bytes().into(),
+ }
+ }
+}
diff --git a/crates/typst-library/src/loading/read.rs b/crates/typst-library/src/loading/read.rs
new file mode 100644
index 00000000..23e6e27e
--- /dev/null
+++ b/crates/typst-library/src/loading/read.rs
@@ -0,0 +1,59 @@
+use ecow::EcoString;
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, Cast};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads plain text or data from a file.
+///
+/// By default, the file will be read as UTF-8 and returned as a [string]($str).
+///
+/// If you specify `{encoding: none}`, this returns raw [bytes] instead.
+///
+/// # Example
+/// ```example
+/// An example for a HTML file: \
+/// #let text = read("example.html")
+/// #raw(text, lang: "html")
+///
+/// Raw bytes:
+/// #read("tiger.jpg", encoding: none)
+/// ```
+#[func]
+pub fn read(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+ /// The encoding to read the file with.
+ ///
+ /// If set to `{none}`, this function returns raw bytes.
+ #[named]
+ #[default(Some(Encoding::Utf8))]
+ encoding: Option<Encoding>,
+) -> SourceResult<Readable> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ Ok(match encoding {
+ None => Readable::Bytes(data),
+ Some(Encoding::Utf8) => Readable::Str(
+ std::str::from_utf8(&data)
+ .map_err(|_| "file is not valid utf-8")
+ .at(span)?
+ .into(),
+ ),
+ })
+}
+
+/// An encoding of a file.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Encoding {
+ /// The Unicode UTF-8 encoding.
+ Utf8,
+}
diff --git a/crates/typst-library/src/loading/toml.rs b/crates/typst-library/src/loading/toml.rs
new file mode 100644
index 00000000..5167703e
--- /dev/null
+++ b/crates/typst-library/src/loading/toml.rs
@@ -0,0 +1,92 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::{is_newline, Spanned};
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// 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, booleans and datetimes will be converted into the Typst equivalents
+/// and numbers will be converted to floats or integers depending on whether
+/// they are whole numbers.
+///
+/// The TOML file in the example consists of a table with the keys `title`,
+/// `version`, and `authors`.
+///
+/// # Example
+/// ```example
+/// #let details = toml("details.toml")
+///
+/// Title: #details.title \
+/// Version: #details.version \
+/// Authors: #(details.authors
+/// .join(", ", last: " and "))
+/// ```
+#[func(scope, title = "TOML")]
+pub fn toml(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a TOML file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ toml::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl toml {
+ /// Reads structured data from a TOML string/bytes.
+ #[func(title = "Decode TOML")]
+ pub fn decode(
+ /// TOML data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ let raw = std::str::from_utf8(data.as_slice())
+ .map_err(|_| "file is not valid utf-8")
+ .at(span)?;
+ ::toml::from_str(raw)
+ .map_err(|err| format_toml_error(err, raw))
+ .at(span)
+ }
+
+ /// Encodes structured data into a TOML string.
+ #[func(title = "Encode TOML")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ /// Whether to pretty-print the resulting TOML.
+ #[named]
+ #[default(true)]
+ pretty: bool,
+ ) -> SourceResult<Str> {
+ let Spanned { v: value, span } = value;
+ if pretty { ::toml::to_string_pretty(&value) } else { ::toml::to_string(&value) }
+ .map(|v| v.into())
+ .map_err(|err| eco_format!("failed to encode value as TOML ({err})"))
+ .at(span)
+ }
+}
+
+/// Format the user-facing TOML error message.
+fn format_toml_error(error: ::toml::de::Error, raw: &str) -> EcoString {
+ if let Some(head) = error.span().and_then(|range| raw.get(..range.start)) {
+ let line = head.lines().count();
+ let column = 1 + head.chars().rev().take_while(|&c| !is_newline(c)).count();
+ eco_format!(
+ "failed to parse TOML ({} at line {line} column {column})",
+ error.message(),
+ )
+ } else {
+ eco_format!("failed to parse TOML ({})", error.message())
+ }
+}
diff --git a/crates/typst-library/src/loading/xml.rs b/crates/typst-library/src/loading/xml.rs
new file mode 100644
index 00000000..3b1a9674
--- /dev/null
+++ b/crates/typst-library/src/loading/xml.rs
@@ -0,0 +1,123 @@
+use ecow::EcoString;
+use roxmltree::ParsingOptions;
+use typst_syntax::Spanned;
+
+use crate::diag::{format_xml_like_error, At, FileError, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{dict, func, scope, Array, Dict, IntoValue, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// 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 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
+/// #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)
+/// }
+/// }
+/// ```
+#[func(scope, title = "XML")]
+pub fn xml(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to an XML file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ xml::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl xml {
+ /// Reads structured data from an XML string/bytes.
+ #[func(title = "Decode XML")]
+ pub fn decode(
+ /// XML data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ let text = std::str::from_utf8(data.as_slice())
+ .map_err(FileError::from)
+ .at(span)?;
+ let document = roxmltree::Document::parse_with_options(
+ text,
+ ParsingOptions { allow_dtd: true, ..Default::default() },
+ )
+ .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", error)
+}
diff --git a/crates/typst-library/src/loading/yaml.rs b/crates/typst-library/src/loading/yaml.rs
new file mode 100644
index 00000000..0e8ca3fb
--- /dev/null
+++ b/crates/typst-library/src/loading/yaml.rs
@@ -0,0 +1,83 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// 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. Custom YAML tags are ignored, though the
+/// loaded value will still be present.
+///
+/// Be aware that integers larger than 2<sup>63</sup>-1 will be converted to
+/// floating point numbers, which may give an approximative value.
+///
+/// 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
+/// #let bookshelf(contents) = {
+/// for (author, works) in contents {
+/// author
+/// for work in works [
+/// - #work.title (#work.published)
+/// ]
+/// }
+/// }
+///
+/// #bookshelf(
+/// yaml("scifi-authors.yaml")
+/// )
+/// ```
+#[func(scope, title = "YAML")]
+pub fn yaml(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a YAML file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ yaml::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl yaml {
+ /// Reads structured data from a YAML string/bytes.
+ #[func(title = "Decode YAML")]
+ pub fn decode(
+ /// YAML data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ serde_yaml::from_slice(data.as_slice())
+ .map_err(|err| eco_format!("failed to parse YAML ({err})"))
+ .at(span)
+ }
+
+ /// Encode structured data into a YAML string.
+ #[func(title = "Encode YAML")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ ) -> SourceResult<Str> {
+ let Spanned { v: value, span } = value;
+ serde_yaml::to_string(&value)
+ .map(|v| v.into())
+ .map_err(|err| eco_format!("failed to encode value as YAML ({err})"))
+ .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..a1c70b4a
--- /dev/null
+++ b/crates/typst-library/src/math/accent.rs
@@ -0,0 +1,147 @@
+use crate::diag::bail;
+use crate::foundations::{cast, elem, func, Content, NativeElement, Smart, Value};
+use crate::layout::{Length, Rel};
+use crate::math::Mathy;
+use crate::text::TextElem;
+
+/// Attaches an accent to a base.
+///
+/// # Example
+/// ```example
+/// $grave(a) = accent(a, `)$ \
+/// $arrow(a) = accent(a, arrow)$ \
+/// $tilde(a) = accent(a, \u{0303})$
+/// ```
+#[elem(Mathy)]
+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` | `¯` |
+ /// | Dash | `dash` | `‾` |
+ /// | Breve | `breve` | `˘` |
+ /// | Dot | `dot` | `.` |
+ /// | Double dot, Diaeresis | `dot.double`, `diaer` | `¨` |
+ /// | Triple dot | `dot.triple` | <code>&tdot;</code> |
+ /// | Quadruple dot | `dot.quad` | <code>&DotDot;</code> |
+ /// | Circle | `circle` | `∘` |
+ /// | Double acute | `acute.double` | `˝` |
+ /// | Caron | `caron` | `ˇ` |
+ /// | Right arrow | `arrow`, `->` | `→` |
+ /// | Left arrow | `arrow.l`, `<-` | `←` |
+ /// | Left/Right arrow | `arrow.l.r` | `↔` |
+ /// | Right harpoon | `harpoon` | `⇀` |
+ /// | Left harpoon | `harpoon.lt` | `↼` |
+ #[required]
+ pub accent: Accent,
+
+ /// The size of the accent, relative to the width of the base.
+ pub size: Smart<Rel<Length>>,
+}
+
+/// An accent character.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Accent(pub char);
+
+impl Accent {
+ /// Normalize a character into an accent.
+ pub fn new(c: char) -> Self {
+ Self(Self::combine(c).unwrap_or(c))
+ }
+}
+
+/// This macro generates accent-related functions.
+///
+/// ```ignore
+/// accents! {
+/// '\u{0300}' | '`' => grave,
+/// // ^^^^^^^^^ ^^^ ^^^^^
+/// // | | |
+/// // | | +-- The name of the function.
+/// // | +--------- The alternative characters that represent the accent.
+/// // +---------------------- The primary character that represents the accent.
+/// }
+/// ```
+///
+/// When combined with the `Accent::combine` function, accent characters can be normalized
+/// to the primary character.
+macro_rules! accents {
+ ($($primary:literal $(| $alt:literal)* => $name:ident),* $(,)?) => {
+ impl Accent {
+ /// Normalize an accent to a combining one.
+ pub fn combine(c: char) -> Option<char> {
+ Some(match c {
+ $($primary $(| $alt)* => $primary,)*
+ _ => return None,
+ })
+ }
+ }
+
+ $(
+ /// The accent function for callable symbol definitions.
+ #[func]
+ pub fn $name(
+ /// The base to which the accent is applied.
+ base: Content,
+ /// The size of the accent, relative to the width of the base.
+ #[named]
+ size: Option<Smart<Rel<Length>>>,
+ ) -> Content {
+ let mut accent = AccentElem::new(base, Accent::new($primary));
+ if let Some(size) = size {
+ accent = accent.with_size(size);
+ }
+ accent.pack()
+ }
+ )+
+ };
+}
+
+// Keep it synced with the documenting table above.
+accents! {
+ '\u{0300}' | '`' => grave,
+ '\u{0301}' | '´' => acute,
+ '\u{0302}' | '^' | 'ˆ' => hat,
+ '\u{0303}' | '~' | '∼' | '˜' => tilde,
+ '\u{0304}' | '¯' => macron,
+ '\u{0305}' | '-' | '‾' | '−' => dash,
+ '\u{0306}' | '˘' => breve,
+ '\u{0307}' | '.' | '˙' | '⋅' => dot,
+ '\u{0308}' | '¨' => dot_double,
+ '\u{20db}' => dot_triple,
+ '\u{20dc}' => dot_quad,
+ '\u{030a}' | '∘' | '○' => circle,
+ '\u{030b}' | '˝' => acute_double,
+ '\u{030c}' | 'ˇ' => caron,
+ '\u{20d6}' | '←' => arrow_l,
+ '\u{20d7}' | '→' | '⟶' => arrow,
+ '\u{20e1}' | '↔' | '⟷' => arrow_l_r,
+ '\u{20d0}' | '↼' => harpoon_lt,
+ '\u{20d1}' | '⇀' => harpoon,
+}
+
+cast! {
+ Accent,
+ self => self.0.into_value(),
+ v: char => Self::new(v),
+ v: Content => match v.to_packed::<TextElem>() {
+ Some(elem) => Value::Str(elem.text().clone().into()).cast()?,
+ None => bail!("expected text"),
+ },
+}
diff --git a/crates/typst-library/src/math/attach.rs b/crates/typst-library/src/math/attach.rs
new file mode 100644
index 00000000..74eabd10
--- /dev/null
+++ b/crates/typst-library/src/math/attach.rs
@@ -0,0 +1,156 @@
+use crate::foundations::{elem, Content, Packed, Smart};
+use crate::layout::{Length, Rel};
+use crate::math::{EquationElem, Mathy};
+
+/// A base with optional attachments.
+///
+/// ```example
+/// $ attach(
+/// Pi, t: alpha, b: beta,
+/// tl: 1, tr: 2+3, bl: 4+5, br: 6,
+/// ) $
+/// ```
+#[elem(Mathy)]
+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 Packed<AttachElem> {
+ /// If an AttachElem's base is also an AttachElem, merge attachments into the
+ /// base AttachElem where possible.
+ pub fn merge_base(&self) -> Option<Self> {
+ // Extract from an EquationElem.
+ let mut base = self.base();
+ if let Some(equation) = base.to_packed::<EquationElem>() {
+ base = equation.body();
+ }
+
+ // Move attachments from elem into base where possible.
+ if let Some(base) = base.to_packed::<AttachElem>() {
+ let mut elem = self.clone();
+ let mut base = base.clone();
+
+ macro_rules! merge {
+ ($content:ident) => {
+ if base.$content.is_none() && elem.$content.is_some() {
+ base.$content = elem.$content.clone();
+ elem.$content = None;
+ }
+ };
+ }
+
+ merge!(t);
+ merge!(b);
+ merge!(tl);
+ merge!(tr);
+ merge!(bl);
+ merge!(br);
+
+ elem.base = base.pack();
+ return Some(elem);
+ }
+
+ None
+ }
+}
+
+/// Grouped primes.
+///
+/// ```example
+/// $ a'''_b = a^'''_b $
+/// ```
+///
+/// # Syntax
+/// This function has dedicated syntax: use apostrophes instead of primes. They
+/// will automatically attach to the previous element, moving superscripts to
+/// the next level.
+#[elem(Mathy)]
+pub struct PrimesElem {
+ /// The number of grouped primes.
+ #[required]
+ pub count: usize,
+}
+
+/// Forces a base to display attachments as scripts.
+///
+/// ```example
+/// $ scripts(sum)_1^2 != sum_1^2 $
+/// ```
+#[elem(Mathy)]
+pub struct ScriptsElem {
+ /// The base to attach the scripts to.
+ #[required]
+ pub body: Content,
+}
+
+/// Forces a base to display attachments as limits.
+///
+/// ```example
+/// $ limits(A)_1^2 != A_1^2 $
+/// ```
+#[elem(Mathy)]
+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,
+}
+
+/// Stretches a glyph.
+///
+/// This function can also be used to automatically stretch the base of an
+/// attachment, so that it fits the top and bottom attachments.
+///
+/// Note that only some glyphs can be stretched, and which ones can depend on
+/// the math font being used. However, most math fonts are the same in this
+/// regard.
+///
+/// ```example
+/// $ H stretch(=)^"define" U + p V $
+/// $ f : X stretch(->>, size: #150%)_"surjective" Y $
+/// $ x stretch(harpoons.ltrb, size: #3em) y
+/// stretch(\[, size: #150%) z $
+/// ```
+#[elem(Mathy)]
+pub struct StretchElem {
+ /// The glyph to stretch.
+ #[required]
+ pub body: Content,
+
+ /// The size to stretch to, relative to the maximum size of the glyph and
+ /// its attachments.
+ pub size: Smart<Rel<Length>>,
+}
diff --git a/crates/typst-library/src/math/cancel.rs b/crates/typst-library/src/math/cancel.rs
new file mode 100644
index 00000000..a72505c0
--- /dev/null
+++ b/crates/typst-library/src/math/cancel.rs
@@ -0,0 +1,116 @@
+use crate::foundations::{cast, elem, Content, Func, Smart};
+use crate::layout::{Abs, Angle, Length, Ratio, Rel};
+use crate::math::Mathy;
+use crate::visualize::Stroke;
+
+/// Displays a diagonal line over a part of an equation.
+///
+/// This is commonly used to show the elimination of a term.
+///
+/// # Example
+/// ```example
+/// >>> #set page(width: 140pt)
+/// Here, we can simplify:
+/// $ (a dot b dot cancel(x)) /
+/// cancel(x) $
+/// ```
+#[elem(Mathy)]
+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>,
+
+ /// Whether the cancel line should be inverted (flipped along the y-axis).
+ /// For the default angle setting, inverted means the cancel line
+ /// points 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,
+
+ /// Whether 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 much to rotate the cancel line.
+ ///
+ /// - If given an angle, the line is rotated by that angle clockwise with
+ /// respect to the y-axis.
+ /// - If `{auto}`, the line assumes the default angle; that is, along the
+ /// rising diagonal of the content box.
+ /// - If given a function `angle => angle`, the line is rotated, with
+ /// respect to the y-axis, by the angle returned by that function. The
+ /// function receives the default angle as its input.
+ ///
+ /// ```example
+ /// >>> #set page(width: 140pt)
+ /// $ cancel(Pi)
+ /// cancel(Pi, angle: #0deg)
+ /// cancel(Pi, angle: #45deg)
+ /// cancel(Pi, angle: #90deg)
+ /// cancel(1/(1+x), angle: #(a => a + 45deg))
+ /// cancel(1/(1+x), angle: #(a => a + 90deg)) $
+ /// ```
+ pub angle: Smart<CancelAngle>,
+
+ /// How to [stroke]($stroke) the cancel line.
+ ///
+ /// ```example
+ /// >>> #set page(width: 140pt)
+ /// $ cancel(
+ /// sum x,
+ /// stroke: #(
+ /// paint: red,
+ /// thickness: 1.5pt,
+ /// dash: "dashed",
+ /// ),
+ /// ) $
+ /// ```
+ #[resolve]
+ #[fold]
+ #[default(Stroke {
+ // Default stroke has 0.5pt for better visuals.
+ thickness: Smart::Custom(Abs::pt(0.5).into()),
+ ..Default::default()
+ })]
+ pub stroke: Stroke,
+}
+
+/// Defines the cancel line.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum CancelAngle {
+ Angle(Angle),
+ Func(Func),
+}
+
+cast! {
+ CancelAngle,
+ self => match self {
+ Self::Angle(v) => v.into_value(),
+ Self::Func(v) => v.into_value()
+ },
+ v: Angle => CancelAngle::Angle(v),
+ v: Func => CancelAngle::Func(v),
+}
diff --git a/crates/typst-library/src/math/equation.rs b/crates/typst-library/src/math/equation.rs
new file mode 100644
index 00000000..4edafe5e
--- /dev/null
+++ b/crates/typst-library/src/math/equation.rs
@@ -0,0 +1,256 @@
+use std::num::NonZeroUsize;
+
+use typst_utils::NonZeroExt;
+use unicode_math_class::MathClass;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, Content, NativeElement, Packed, Show, ShowSet, Smart, StyleChain, Styles,
+ Synthesize,
+};
+use crate::introspection::{Count, Counter, CounterUpdate, Locatable};
+use crate::layout::{
+ AlignElem, Alignment, BlockElem, InlineElem, OuterHAlignment, SpecificAlignment,
+ VAlignment,
+};
+use crate::math::{MathSize, MathVariant};
+use crate::model::{Numbering, Outlinable, ParLine, Refable, Supplement};
+use crate::text::{FontFamily, FontList, FontWeight, LocalName, TextElem};
+
+/// A mathematical equation.
+///
+/// Can be displayed inline with text or as a separate block.
+///
+/// # 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 $
+/// ```
+///
+/// By default, block-level equations will not break across pages. This can be
+/// changed through `{show math.equation: set block(breakable: true)}`.
+///
+/// # 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).
+#[elem(Locatable, Synthesize, Show, ShowSet, Count, LocalName, Refable, Outlinable)]
+pub struct EquationElem {
+ /// Whether the equation is displayed as a separate block.
+ #[default(false)]
+ pub block: bool,
+
+ /// How to [number]($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) $
+ /// ```
+ #[borrowed]
+ pub numbering: Option<Numbering>,
+
+ /// The alignment of the equation numbering.
+ ///
+ /// By default, the alignment is `{end + horizon}`. For the horizontal
+ /// component, you can use `{right}`, `{left}`, or `{start}` and `{end}`
+ /// of the text direction; for the vertical component, you can use
+ /// `{top}`, `{horizon}`, or `{bottom}`.
+ ///
+ /// ```example
+ /// #set math.equation(numbering: "(1)", number-align: bottom)
+ ///
+ /// We can calculate:
+ /// $ E &= sqrt(m_0^2 + p^2) \
+ /// &approx 125 "GeV" $
+ /// ```
+ #[default(SpecificAlignment::Both(OuterHAlignment::End, VAlignment::Horizon))]
+ pub number_align: SpecificAlignment<OuterHAlignment, VAlignment>,
+
+ /// 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,
+
+ /// The size of the glyphs.
+ #[internal]
+ #[default(MathSize::Text)]
+ #[ghost]
+ pub size: MathSize,
+
+ /// The style variant to select.
+ #[internal]
+ #[ghost]
+ pub variant: MathVariant,
+
+ /// Affects the height of exponents.
+ #[internal]
+ #[default(false)]
+ #[ghost]
+ pub cramped: bool,
+
+ /// Whether to use bold glyphs.
+ #[internal]
+ #[default(false)]
+ #[ghost]
+ pub bold: bool,
+
+ /// Whether to use italic glyphs.
+ #[internal]
+ #[ghost]
+ pub italic: Smart<bool>,
+
+ /// A forced class to use for all fragment.
+ #[internal]
+ #[ghost]
+ pub class: Option<MathClass>,
+}
+
+impl Synthesize for Packed<EquationElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let supplement = match self.as_ref().supplement(styles) {
+ Smart::Auto => TextElem::packed(Self::local_name_in(styles)),
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => {
+ supplement.resolve(engine, styles, [self.clone().pack()])?
+ }
+ };
+
+ self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
+ Ok(())
+ }
+}
+
+impl Show for Packed<EquationElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ if self.block(styles) {
+ Ok(BlockElem::multi_layouter(
+ self.clone(),
+ engine.routines.layout_equation_block,
+ )
+ .pack()
+ .spanned(self.span()))
+ } else {
+ Ok(InlineElem::layouter(self.clone(), engine.routines.layout_equation_inline)
+ .pack()
+ .spanned(self.span()))
+ }
+ }
+}
+
+impl ShowSet for Packed<EquationElem> {
+ fn show_set(&self, styles: StyleChain) -> Styles {
+ let mut out = Styles::new();
+ if self.block(styles) {
+ out.set(AlignElem::set_alignment(Alignment::CENTER));
+ out.set(BlockElem::set_breakable(false));
+ out.set(ParLine::set_numbering(None));
+ out.set(EquationElem::set_size(MathSize::Display));
+ } else {
+ out.set(EquationElem::set_size(MathSize::Text));
+ }
+ out.set(TextElem::set_weight(FontWeight::from_number(450)));
+ out.set(TextElem::set_font(FontList(vec![FontFamily::new(
+ "New Computer Modern Math",
+ )])));
+ out
+ }
+}
+
+impl Count for Packed<EquationElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ (self.block(StyleChain::default()) && self.numbering().is_some())
+ .then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+impl LocalName for Packed<EquationElem> {
+ const KEY: &'static str = "equation";
+}
+
+impl Refable for Packed<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(EquationElem::elem())
+ }
+
+ fn numbering(&self) -> Option<&Numbering> {
+ (**self).numbering(StyleChain::default()).as_ref()
+ }
+}
+
+impl Outlinable for Packed<EquationElem> {
+ fn outline(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<Option<Content>> {
+ if !self.block(StyleChain::default()) {
+ return Ok(None);
+ }
+ let Some(numbering) = self.numbering() 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().display_at_loc(
+ engine,
+ self.location().unwrap(),
+ styles,
+ numbering,
+ )?;
+
+ Ok(Some(supplement + numbers))
+ }
+}
diff --git a/crates/typst-library/src/math/frac.rs b/crates/typst-library/src/math/frac.rs
new file mode 100644
index 00000000..f5c4514d
--- /dev/null
+++ b/crates/typst-library/src/math/frac.rs
@@ -0,0 +1,56 @@
+use typst_syntax::Spanned;
+
+use crate::diag::bail;
+use crate::foundations::{elem, Content, Value};
+use crate::math::Mathy;
+
+/// A mathematical fraction.
+///
+/// # Example
+/// ```example
+/// $ 1/2 < (x+1)/2 $
+/// $ ((x+1)) / 2 = frac(a, b) $
+/// ```
+///
+/// # 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.
+#[elem(title = "Fraction", Mathy)]
+pub struct FracElem {
+ /// The fraction's numerator.
+ #[required]
+ pub num: Content,
+
+ /// The fraction's denominator.
+ #[required]
+ pub denom: Content,
+}
+
+/// A binomial expression.
+///
+/// # Example
+/// ```example
+/// $ binom(n, k) $
+/// $ binom(n, k_1, k_2, k_3, ..., k_m) $
+/// ```
+#[elem(title = "Binomial", Mathy)]
+pub struct BinomElem {
+ /// The binomial's upper index.
+ #[required]
+ pub upper: Content,
+
+ /// The binomial's lower index.
+ #[required]
+ #[variadic]
+ #[parse(
+ let values = args.all::<Spanned<Value>>()?;
+ if values.is_empty() {
+ // Prevents one element binomials
+ bail!(args.span, "missing argument: lower");
+ }
+ values.into_iter().map(|spanned| spanned.v.display()).collect()
+ )]
+ pub lower: Vec<Content>,
+}
diff --git a/crates/typst-library/src/math/lr.rs b/crates/typst-library/src/math/lr.rs
new file mode 100644
index 00000000..07ab0dd4
--- /dev/null
+++ b/crates/typst-library/src/math/lr.rs
@@ -0,0 +1,135 @@
+use crate::foundations::{elem, func, Content, NativeElement, Smart};
+use crate::layout::{Length, Rel};
+use crate::math::Mathy;
+use crate::text::TextElem;
+
+/// Scales delimiters.
+///
+/// While matched delimiters scale by default, this can be used to scale
+/// unmatched delimiters and to control the delimiter scaling more precisely.
+#[elem(title = "Left/Right", Mathy)]
+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 arguments = args.all::<Content>()?.into_iter();
+ let mut body = arguments.next().unwrap_or_default();
+ arguments.for_each(|arg| body += TextElem::packed(',') + arg);
+ body
+ )]
+ pub body: Content,
+}
+
+/// Scales delimiters vertically to the nearest surrounding `{lr()}` group.
+///
+/// ```example
+/// $ { x mid(|) sum_(i=1)^n w_i|f_i (x)| < 1 } $
+/// ```
+#[elem(Mathy)]
+pub struct MidElem {
+ /// The content to be scaled.
+ #[required]
+ pub body: Content,
+}
+
+/// Floors an expression.
+///
+/// ```example
+/// $ floor(x/2) $
+/// ```
+#[func]
+pub fn floor(
+ /// The size of the brackets, relative to the height of the wrapped content.
+ #[named]
+ size: Option<Smart<Rel<Length>>>,
+ /// The expression to floor.
+ body: Content,
+) -> Content {
+ delimited(body, '⌊', '⌋', size)
+}
+
+/// Ceils an expression.
+///
+/// ```example
+/// $ ceil(x/2) $
+/// ```
+#[func]
+pub fn ceil(
+ /// The size of the brackets, relative to the height of the wrapped content.
+ #[named]
+ size: Option<Smart<Rel<Length>>>,
+ /// The expression to ceil.
+ body: Content,
+) -> Content {
+ delimited(body, '⌈', '⌉', size)
+}
+
+/// Rounds an expression.
+///
+/// ```example
+/// $ round(x/2) $
+/// ```
+#[func]
+pub fn round(
+ /// The size of the brackets, relative to the height of the wrapped content.
+ #[named]
+ size: Option<Smart<Rel<Length>>>,
+ /// The expression to round.
+ body: Content,
+) -> Content {
+ delimited(body, '⌊', '⌉', size)
+}
+
+/// Takes the absolute value of an expression.
+///
+/// ```example
+/// $ abs(x/2) $
+/// ```
+#[func]
+pub fn abs(
+ /// The size of the brackets, relative to the height of the wrapped content.
+ #[named]
+ size: Option<Smart<Rel<Length>>>,
+ /// The expression to take the absolute value of.
+ body: Content,
+) -> Content {
+ delimited(body, '|', '|', size)
+}
+
+/// Takes the norm of an expression.
+///
+/// ```example
+/// $ norm(x/2) $
+/// ```
+#[func]
+pub fn norm(
+ /// The size of the brackets, relative to the height of the wrapped content.
+ #[named]
+ size: Option<Smart<Rel<Length>>>,
+ /// The expression to take the norm of.
+ body: Content,
+) -> Content {
+ delimited(body, '‖', '‖', size)
+}
+
+fn delimited(
+ body: Content,
+ left: char,
+ right: char,
+ size: Option<Smart<Rel<Length>>>,
+) -> Content {
+ let span = body.span();
+ let mut elem = LrElem::new(Content::sequence([
+ TextElem::packed(left),
+ body,
+ TextElem::packed(right),
+ ]));
+ // Push size only if size is provided
+ if let Some(size) = size {
+ elem.push_size(size);
+ }
+ elem.pack().spanned(span)
+}
diff --git a/crates/typst-library/src/math/matrix.rs b/crates/typst-library/src/math/matrix.rs
new file mode 100644
index 00000000..1c788995
--- /dev/null
+++ b/crates/typst-library/src/math/matrix.rs
@@ -0,0 +1,448 @@
+use smallvec::{smallvec, SmallVec};
+use typst_syntax::Spanned;
+use typst_utils::Numeric;
+use unicode_math_class::MathClass;
+
+use crate::diag::{bail, At, HintedStrResult, StrResult};
+use crate::foundations::{
+ array, cast, dict, elem, Array, Content, Dict, Fold, NoneValue, Resolve, Smart,
+ StyleChain, Symbol, Value,
+};
+use crate::layout::{Abs, Em, HAlignment, Length, Rel};
+use crate::math::Mathy;
+use crate::visualize::Stroke;
+
+const DEFAULT_ROW_GAP: Em = Em::new(0.2);
+const DEFAULT_COL_GAP: Em = Em::new(0.5);
+
+/// A column vector.
+///
+/// Content in the vector's elements can be aligned with the
+/// [`align`]($math.vec.align) parameter, or the `&` symbol.
+///
+/// # Example
+/// ```example
+/// $ vec(a, b, c) dot vec(1, 2, 3)
+/// = a + 2b + 3c $
+/// ```
+#[elem(title = "Vector", Mathy)]
+pub struct VecElem {
+ /// The delimiter to use.
+ ///
+ /// Can be a single character specifying the left delimiter, in which case
+ /// the right delimiter is inferred. Otherwise, can be an array containing a
+ /// left and a right delimiter.
+ ///
+ /// ```example
+ /// #set math.vec(delim: "[")
+ /// $ vec(1, 2) $
+ /// ```
+ #[default(DelimiterPair::PAREN)]
+ pub delim: DelimiterPair,
+
+ /// The horizontal alignment that each element should have.
+ ///
+ /// ```example
+ /// #set math.vec(align: right)
+ /// $ vec(-1, 1, -1) $
+ /// ```
+ #[resolve]
+ #[default(HAlignment::Center)]
+ pub align: HAlignment,
+
+ /// The gap between elements.
+ ///
+ /// ```example
+ /// #set math.vec(gap: 1em)
+ /// $ vec(1, 2) $
+ /// ```
+ #[default(DEFAULT_ROW_GAP.into())]
+ pub gap: Rel<Length>,
+
+ /// The elements of the vector.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+/// 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 can be aligned with the [`align`]($math.mat.align)
+/// parameter, or content in cells that are in the same row can be aligned with
+/// the `&` symbol.
+///
+/// # Example
+/// ```example
+/// $ mat(
+/// 1, 2, ..., 10;
+/// 2, 2, ..., 10;
+/// dots.v, dots.v, dots.down, dots.v;
+/// 10, 10, ..., 10;
+/// ) $
+/// ```
+#[elem(title = "Matrix", Mathy)]
+pub struct MatElem {
+ /// The delimiter to use.
+ ///
+ /// Can be a single character specifying the left delimiter, in which case
+ /// the right delimiter is inferred. Otherwise, can be an array containing a
+ /// left and a right delimiter.
+ ///
+ /// ```example
+ /// #set math.mat(delim: "[")
+ /// $ mat(1, 2; 3, 4) $
+ /// ```
+ #[default(DelimiterPair::PAREN)]
+ pub delim: DelimiterPair,
+
+ /// The horizontal alignment that each cell should have.
+ ///
+ /// ```example
+ /// #set math.mat(align: right)
+ /// $ mat(-1, 1, 1; 1, -1, 1; 1, 1, -1) $
+ /// ```
+ #[resolve]
+ #[default(HAlignment::Center)]
+ pub align: HAlignment,
+
+ /// Draws augmentation lines in a matrix.
+ ///
+ /// - `{none}`: No lines are drawn.
+ /// - A single number: A vertical augmentation line is drawn
+ /// after the specified column number. Negative numbers start from the end.
+ /// - A dictionary: With a dictionary, multiple augmentation lines can be
+ /// drawn both horizontally and vertically. Additionally, the style of the
+ /// lines can be set. The dictionary can contain the following keys:
+ /// - `hline`: The offsets at which horizontal lines should be drawn.
+ /// For example, an offset of `2` would result in a horizontal line
+ /// being drawn after the second row of the matrix. Accepts either an
+ /// integer for a single line, or an array of integers
+ /// for multiple lines. Like for a single number, negative numbers start from the end.
+ /// - `vline`: The offsets at which vertical lines should be drawn.
+ /// For example, an offset of `2` would result in a vertical line being
+ /// drawn after the second column of the matrix. Accepts either an
+ /// integer for a single line, or an array of integers
+ /// for multiple lines. Like for a single number, negative numbers start from the end.
+ /// - `stroke`: How to [stroke]($stroke) the line. If set to `{auto}`,
+ /// takes on a thickness of 0.05em and square line caps.
+ ///
+ /// ```example
+ /// $ mat(1, 0, 1; 0, 1, 2; augment: #2) $
+ /// // Equivalent to:
+ /// $ mat(1, 0, 1; 0, 1, 2; augment: #(-1)) $
+ /// ```
+ ///
+ /// ```example
+ /// $ mat(0, 0, 0; 1, 1, 1; augment: #(hline: 1, stroke: 2pt + green)) $
+ /// ```
+ #[resolve]
+ #[fold]
+ pub augment: Option<Augment>,
+
+ /// The gap between rows and columns.
+ ///
+ /// This is a shorthand to set `row-gap` and `column-gap` to the same value.
+ ///
+ /// ```example
+ /// #set math.mat(gap: 1em)
+ /// $ mat(1, 2; 3, 4) $
+ /// ```
+ #[external]
+ pub gap: Rel<Length>,
+
+ /// The gap between rows.
+ ///
+ /// ```example
+ /// #set math.mat(row-gap: 1em)
+ /// $ mat(1, 2; 3, 4) $
+ /// ```
+ #[parse(
+ let gap = args.named("gap")?;
+ args.named("row-gap")?.or(gap)
+ )]
+ #[default(DEFAULT_ROW_GAP.into())]
+ pub row_gap: Rel<Length>,
+
+ /// The gap between columns.
+ ///
+ /// ```example
+ /// #set math.mat(column-gap: 1em)
+ /// $ mat(1, 2; 3, 4) $
+ /// ```
+ #[parse(args.named("column-gap")?.or(gap))]
+ #[default(DEFAULT_COL_GAP.into())]
+ pub column_gap: Rel<Length>,
+
+ /// 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>>,
+}
+
+/// A case distinction.
+///
+/// Content across different branches can be aligned with the `&` symbol.
+///
+/// # 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",
+/// ) $
+/// ```
+#[elem(Mathy)]
+pub struct CasesElem {
+ /// The delimiter to use.
+ ///
+ /// Can be a single character specifying the left delimiter, in which case
+ /// the right delimiter is inferred. Otherwise, can be an array containing a
+ /// left and a right delimiter.
+ ///
+ /// ```example
+ /// #set math.cases(delim: "[")
+ /// $ x = cases(1, 2) $
+ /// ```
+ #[default(DelimiterPair::BRACE)]
+ pub delim: DelimiterPair,
+
+ /// Whether the direction of cases should be reversed.
+ ///
+ /// ```example
+ /// #set math.cases(reverse: true)
+ /// $ cases(1, 2) = x $
+ /// ```
+ #[default(false)]
+ pub reverse: bool,
+
+ /// The gap between branches.
+ ///
+ /// ```example
+ /// #set math.cases(gap: 1em)
+ /// $ x = cases(1, 2) $
+ /// ```
+ #[default(DEFAULT_ROW_GAP.into())]
+ pub gap: Rel<Length>,
+
+ /// The branches of the case distinction.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+/// A delimiter is a single character that is used to delimit a matrix, vector
+/// or cases. The character has to be a Unicode codepoint tagged as a math
+/// "opening", "closing" or "fence".
+///
+/// Typically, the delimiter is stretched to fit the height of whatever it
+/// delimits.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Delimiter(Option<char>);
+
+cast! {
+ Delimiter,
+ self => self.0.into_value(),
+ _: NoneValue => Self::none(),
+ v: Symbol => Self::char(v.get())?,
+ v: char => Self::char(v)?,
+}
+
+impl Delimiter {
+ pub fn none() -> Self {
+ Self(None)
+ }
+
+ pub fn char(c: char) -> StrResult<Self> {
+ if !matches!(
+ unicode_math_class::class(c),
+ Some(MathClass::Opening | MathClass::Closing | MathClass::Fence),
+ ) {
+ bail!("invalid delimiter: \"{}\"", c)
+ }
+ Ok(Self(Some(c)))
+ }
+
+ pub fn get(self) -> Option<char> {
+ self.0
+ }
+
+ pub fn find_matching(self) -> Self {
+ match self.0 {
+ None => Self::none(),
+ Some('[') => Self(Some(']')),
+ Some(']') => Self(Some('[')),
+ Some('{') => Self(Some('}')),
+ Some('}') => Self(Some('{')),
+ Some(c) => match unicode_math_class::class(c) {
+ Some(MathClass::Opening) => Self(char::from_u32(c as u32 + 1)),
+ Some(MathClass::Closing) => Self(char::from_u32(c as u32 - 1)),
+ _ => Self(Some(c)),
+ },
+ }
+ }
+}
+
+/// A pair of delimiters (one closing, one opening) used for matrices, vectors
+/// and cases.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct DelimiterPair {
+ open: Delimiter,
+ close: Delimiter,
+}
+
+cast! {
+ DelimiterPair,
+
+ self => array![self.open, self.close].into_value(),
+
+ v: Array => match v.as_slice() {
+ [open, close] => Self {
+ open: open.clone().cast()?,
+ close: close.clone().cast()?,
+ },
+ _ => bail!("expected 2 delimiters, found {}", v.len())
+ },
+ v: Delimiter => Self { open: v, close: v.find_matching() }
+}
+
+impl DelimiterPair {
+ const PAREN: Self = Self {
+ open: Delimiter(Some('(')),
+ close: Delimiter(Some(')')),
+ };
+ const BRACE: Self = Self {
+ open: Delimiter(Some('{')),
+ close: Delimiter(Some('}')),
+ };
+
+ /// The delimiter's opening character.
+ pub fn open(self) -> Option<char> {
+ self.open.get()
+ }
+
+ /// The delimiter's closing character.
+ pub fn close(self) -> Option<char> {
+ self.close.get()
+ }
+}
+
+/// Parameters specifying how augmentation lines
+/// should be drawn on a matrix.
+#[derive(Debug, Default, Clone, PartialEq, Hash)]
+pub struct Augment<T: Numeric = Length> {
+ pub hline: AugmentOffsets,
+ pub vline: AugmentOffsets,
+ pub stroke: Smart<Stroke<T>>,
+}
+
+impl<T: Numeric + Fold> Fold for Augment<T> {
+ fn fold(self, outer: Self) -> Self {
+ Self {
+ stroke: match (self.stroke, outer.stroke) {
+ (Smart::Custom(inner), Smart::Custom(outer)) => {
+ Smart::Custom(inner.fold(outer))
+ }
+ // Usually, folding an inner `auto` with an `outer` prefers
+ // the explicit `auto`. However, here `auto` means unspecified
+ // and thus we want `outer`.
+ (inner, outer) => inner.or(outer),
+ },
+ ..self
+ }
+ }
+}
+
+impl Resolve for Augment {
+ type Output = Augment<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ Augment {
+ hline: self.hline,
+ vline: self.vline,
+ stroke: self.stroke.resolve(styles),
+ }
+ }
+}
+
+cast! {
+ Augment,
+ self => {
+ // if the stroke is auto and there is only one vertical line,
+ if self.stroke.is_auto() && self.hline.0.is_empty() && self.vline.0.len() == 1 {
+ return self.vline.0[0].into_value();
+ }
+
+ dict! {
+ "hline" => self.hline,
+ "vline" => self.vline,
+ "stroke" => self.stroke,
+ }.into_value()
+ },
+ v: isize => Augment {
+ hline: AugmentOffsets::default(),
+ vline: AugmentOffsets(smallvec![v]),
+ stroke: Smart::Auto,
+ },
+ mut dict: Dict => {
+ let mut take = |key| dict.take(key).ok().map(AugmentOffsets::from_value).transpose();
+ let hline = take("hline")?.unwrap_or_default();
+ let vline = take("vline")?.unwrap_or_default();
+ let stroke = dict.take("stroke")
+ .ok()
+ .map(Stroke::from_value)
+ .transpose()?
+ .map(Smart::Custom)
+ .unwrap_or(Smart::Auto);
+ Augment { hline, vline, stroke }
+ },
+}
+
+cast! {
+ Augment<Abs>,
+ self => self.into_value(),
+}
+
+/// The offsets at which augmentation lines should be drawn on a matrix.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct AugmentOffsets(pub SmallVec<[isize; 1]>);
+
+cast! {
+ AugmentOffsets,
+ self => self.0.into_value(),
+ v: isize => Self(smallvec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
diff --git a/crates/typst-library/src/math/mod.rs b/crates/typst-library/src/math/mod.rs
new file mode 100644
index 00000000..53b1f072
--- /dev/null
+++ b/crates/typst-library/src/math/mod.rs
@@ -0,0 +1,257 @@
+//! Mathematical formulas.
+
+pub mod accent;
+mod attach;
+mod cancel;
+mod equation;
+mod frac;
+mod lr;
+mod matrix;
+mod op;
+mod root;
+mod style;
+mod underover;
+
+pub use self::accent::{Accent, AccentElem};
+pub use self::attach::*;
+pub use self::cancel::*;
+pub use self::equation::*;
+pub use self::frac::*;
+pub use self::lr::*;
+pub use self::matrix::*;
+pub use self::op::*;
+pub use self::root::*;
+pub use self::style::*;
+pub use self::underover::*;
+
+use typst_utils::singleton;
+use unicode_math_class::MathClass;
+
+use crate::foundations::{
+ category, elem, Category, Content, Module, NativeElement, Scope,
+};
+use crate::layout::{Em, HElem};
+use crate::text::TextElem;
+
+/// Typst has special [syntax]($syntax/#math) and library functions to typeset
+/// mathematical formulas. Math formulas can be displayed inline with text or as
+/// separate blocks. They will be typeset into their own block if they start and
+/// end with at least one space (e.g. `[$ x^2 $]`).
+///
+/// # Variables
+/// In math, single letters are always displayed as is. Multiple letters,
+/// however, are interpreted as variables and functions. To display multiple
+/// letters verbatim, you can place them into quotes and to access single letter
+/// variables, you can use the [hash syntax]($scripting/#expressions).
+///
+/// ```example
+/// $ A = pi r^2 $
+/// $ "area" = pi dot "radius"^2 $
+/// $ cal(A) :=
+/// { x in RR | x "is natural" } $
+/// #let x = 5
+/// $ #x < 17 $
+/// ```
+///
+/// # Symbols
+/// Math mode makes a wide selection of [symbols]($category/symbols/sym) like
+/// `pi`, `dot`, or `RR` available. Many mathematical symbols are available in
+/// different variants. You can select between different variants by applying
+/// [modifiers]($symbol) to the symbol. Typst further recognizes a number of
+/// shorthand sequences like `=>` that approximate a symbol. When such a
+/// shorthand exists, the symbol's documentation lists it.
+///
+/// ```example
+/// $ x < y => x gt.eq.not y $
+/// ```
+///
+/// # Line Breaks
+/// Formulas can also contain line breaks. Each line can contain one or multiple
+/// _alignment points_ (`&`) which are then aligned.
+///
+/// ```example
+/// $ sum_(k=0)^n k
+/// &= 1 + ... + n \
+/// &= (n(n+1)) / 2 $
+/// ```
+///
+/// # Function calls
+/// Math mode supports special function calls without the hash prefix. In these
+/// "math calls", the argument list works a little differently than in code:
+///
+/// - Within them, Typst is still in "math mode". Thus, you can write math
+/// directly into them, but need to use hash syntax to pass code expressions
+/// (except for strings, which are available in the math syntax).
+/// - They support positional and named arguments, but don't support trailing
+/// content blocks and argument spreading.
+/// - They provide additional syntax for 2-dimensional argument lists. The
+/// semicolon (`;`) merges preceding arguments separated by commas into an
+/// array argument.
+///
+/// ```example
+/// $ frac(a^2, 2) $
+/// $ vec(1, 2, delim: "[") $
+/// $ mat(1, 2; 3, 4) $
+/// $ lim_x =
+/// op("lim", limits: #true)_x $
+/// ```
+///
+/// To write a verbatim comma or semicolon in a math call, escape it with a
+/// backslash. The colon on the other hand is only recognized in a special way
+/// if directly preceded by an identifier, so to display it verbatim in those
+/// cases, you can just insert a space before it.
+///
+/// Functions calls preceded by a hash are normal code function calls and not
+/// affected by these rules.
+///
+/// # Alignment
+/// When equations include multiple _alignment points_ (`&`), this creates
+/// blocks of alternatingly right- and left-aligned columns. In the example
+/// below, the expression `(3x + y) / 7` is right-aligned and `= 9` is
+/// left-aligned. The word "given" is also left-aligned because `&&` creates two
+/// alignment points in a row, alternating the alignment twice. `& &` and `&&`
+/// behave exactly the same way. Meanwhile, "multiply by 7" is right-aligned
+/// because just one `&` precedes it. Each alignment point simply alternates
+/// between right-aligned/left-aligned.
+///
+/// ```example
+/// $ (3x + y) / 7 &= 9 && "given" \
+/// 3x + y &= 63 & "multiply by 7" \
+/// 3x &= 63 - y && "subtract y" \
+/// x &= 21 - y/3 & "divide by 3" $
+/// ```
+///
+/// # Math fonts
+/// You can set the math font by with a [show-set rule]($styling/#show-rules) as
+/// demonstrated below. Note that only special OpenType math fonts are suitable
+/// for typesetting maths.
+///
+/// ```example
+/// #show math.equation: set text(font: "Fira Math")
+/// $ sum_(i in NN) 1 + i $
+/// ```
+///
+/// # Math module
+/// All math functions are part of the `math` [module]($scripting/#modules),
+/// which is available by default in equations. Outside of equations, they can
+/// be accessed with the `math.` prefix.
+#[category]
+pub static MATH: Category;
+
+// Spacings.
+pub const THIN: Em = Em::new(1.0 / 6.0);
+pub const MEDIUM: Em = Em::new(2.0 / 9.0);
+pub const THICK: Em = Em::new(5.0 / 18.0);
+pub const QUAD: Em = Em::new(1.0);
+pub const WIDE: Em = Em::new(2.0);
+
+/// Create a module with all math definitions.
+pub fn module() -> Module {
+ let mut math = Scope::deduplicating();
+ math.category(MATH);
+ math.define_elem::<EquationElem>();
+ math.define_elem::<TextElem>();
+ math.define_elem::<LrElem>();
+ math.define_elem::<MidElem>();
+ math.define_elem::<AttachElem>();
+ math.define_elem::<StretchElem>();
+ math.define_elem::<ScriptsElem>();
+ math.define_elem::<LimitsElem>();
+ math.define_elem::<AccentElem>();
+ math.define_elem::<UnderlineElem>();
+ math.define_elem::<OverlineElem>();
+ math.define_elem::<UnderbraceElem>();
+ math.define_elem::<OverbraceElem>();
+ math.define_elem::<UnderbracketElem>();
+ math.define_elem::<OverbracketElem>();
+ math.define_elem::<UnderparenElem>();
+ math.define_elem::<OverparenElem>();
+ math.define_elem::<UndershellElem>();
+ math.define_elem::<OvershellElem>();
+ math.define_elem::<CancelElem>();
+ math.define_elem::<FracElem>();
+ math.define_elem::<BinomElem>();
+ math.define_elem::<VecElem>();
+ math.define_elem::<MatElem>();
+ math.define_elem::<CasesElem>();
+ math.define_elem::<RootElem>();
+ math.define_elem::<ClassElem>();
+ math.define_elem::<OpElem>();
+ math.define_elem::<PrimesElem>();
+ math.define_func::<abs>();
+ math.define_func::<norm>();
+ math.define_func::<round>();
+ math.define_func::<sqrt>();
+ math.define_func::<upright>();
+ math.define_func::<bold>();
+ math.define_func::<italic>();
+ math.define_func::<serif>();
+ math.define_func::<sans>();
+ math.define_func::<cal>();
+ math.define_func::<frak>();
+ math.define_func::<mono>();
+ math.define_func::<bb>();
+ math.define_func::<display>();
+ math.define_func::<inline>();
+ math.define_func::<script>();
+ math.define_func::<sscript>();
+
+ // Text operators.
+ op::define(&mut math);
+
+ // Spacings.
+ 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());
+ math.define("wide", HElem::new(WIDE.into()).pack());
+
+ // Symbols.
+ for (name, symbol) in crate::symbols::SYM {
+ math.define(*name, symbol.clone());
+ }
+
+ Module::new("math", math)
+}
+
+/// Trait for recognizing math elements and auto-wrapping them in equations.
+pub trait Mathy {}
+
+/// A math alignment point: `&`, `&&`.
+#[elem(title = "Alignment Point", Mathy)]
+pub struct AlignPointElem {}
+
+impl AlignPointElem {
+ /// Get the globally shared alignment point element.
+ pub fn shared() -> &'static Content {
+ singleton!(Content, AlignPointElem::new().pack())
+ }
+}
+
+/// Forced use of a certain math class.
+///
+/// This is useful to treat certain symbols as if they were of a different
+/// class, e.g. to make a symbol behave like a relation. The class of a symbol
+/// defines the way it is laid out, including spacing around it, and how its
+/// scripts are attached by default. Note that the latter can always be
+/// overridden using [`{limits}`](math.limits) and [`{scripts}`](math.scripts).
+///
+/// # Example
+/// ```example
+/// #let loves = math.class(
+/// "relation",
+/// sym.suit.heart,
+/// )
+///
+/// $x loves y and y loves 5$
+/// ```
+#[elem(Mathy)]
+pub struct ClassElem {
+ /// The class to apply to the content.
+ #[required]
+ pub class: MathClass,
+
+ /// The content to which the class is applied.
+ #[required]
+ pub body: Content,
+}
diff --git a/crates/typst-library/src/math/op.rs b/crates/typst-library/src/math/op.rs
new file mode 100644
index 00000000..ef24705a
--- /dev/null
+++ b/crates/typst-library/src/math/op.rs
@@ -0,0 +1,103 @@
+use ecow::EcoString;
+
+use crate::foundations::{elem, Content, NativeElement, Scope};
+use crate::layout::HElem;
+use crate::math::{upright, Mathy, THIN};
+use crate::text::TextElem;
+
+/// A text operator in an equation.
+///
+/// # 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`, `coth`, `csc`, `csch`, `ctg`, `deg`, `det`, `dim`, `exp`,
+/// `gcd`, `hom`, `id`, `im`, `inf`, `ker`, `lg`, `lim`, `liminf`, `limsup`,
+/// `ln`, `log`, `max`, `min`, `mod`, `Pr`, `sec`, `sech`, `sin`, `sinc`,
+/// `sinh`, `sup`, `tan`, `tanh`, `tg` and `tr`.
+#[elem(title = "Text Operator", Mathy)]
+pub struct OpElem {
+ /// The operator's text.
+ #[required]
+ pub text: Content,
+
+ /// Whether the operator should show attachments as limits in display mode.
+ #[default(false)]
+ pub limits: bool,
+}
+
+macro_rules! ops {
+ ($($name:ident $(: $value:literal)? $(($tts:tt))?),* $(,)?) => {
+ pub(super) fn define(math: &mut Scope) {
+ $({
+ let operator = EcoString::from(ops!(@name $name $(: $value)?));
+ math.define(
+ stringify!($name),
+ OpElem::new(TextElem::new(operator).into())
+ .with_limits(ops!(@limit $($tts)*))
+ .pack()
+ );
+ })*
+
+ let dif = |d| {
+ HElem::new(THIN.into()).with_weak(true).pack()
+ + upright(TextElem::packed(d))
+ };
+ 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,
+ coth,
+ csc,
+ csch,
+ ctg,
+ deg,
+ det (limits),
+ dim,
+ exp,
+ gcd (limits),
+ hom,
+ id,
+ im,
+ inf (limits),
+ ker,
+ lg,
+ lim (limits),
+ liminf: "lim inf" (limits),
+ limsup: "lim sup" (limits),
+ ln,
+ log,
+ max (limits),
+ min (limits),
+ mod,
+ Pr (limits),
+ sec,
+ sech,
+ sin,
+ sinc,
+ sinh,
+ sup (limits),
+ tan,
+ tanh,
+ tg,
+ tr,
+}
diff --git a/crates/typst-library/src/math/root.rs b/crates/typst-library/src/math/root.rs
new file mode 100644
index 00000000..e25c6d42
--- /dev/null
+++ b/crates/typst-library/src/math/root.rs
@@ -0,0 +1,35 @@
+use typst_syntax::Span;
+
+use crate::foundations::{elem, func, Content, NativeElement};
+use crate::math::Mathy;
+
+/// A square root.
+///
+/// ```example
+/// $ sqrt(3 - 2 sqrt(2)) = sqrt(2) - 1 $
+/// ```
+#[func(title = "Square Root")]
+pub fn sqrt(
+ /// The call span of this function.
+ span: Span,
+ /// The expression to take the square root of.
+ radicand: Content,
+) -> Content {
+ RootElem::new(radicand).pack().spanned(span)
+}
+
+/// A general root.
+///
+/// ```example
+/// $ root(3, x) $
+/// ```
+#[elem(Mathy)]
+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,
+}
diff --git a/crates/typst-library/src/math/style.rs b/crates/typst-library/src/math/style.rs
new file mode 100644
index 00000000..f3d28f2a
--- /dev/null
+++ b/crates/typst-library/src/math/style.rs
@@ -0,0 +1,254 @@
+use crate::foundations::{func, Cast, Content, Smart};
+use crate::math::EquationElem;
+
+/// Bold font style in math.
+///
+/// ```example
+/// $ bold(A) := B^+ $
+/// ```
+#[func(keywords = ["mathbf"])]
+pub fn bold(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_bold(true))
+}
+
+/// Upright (non-italic) font style in math.
+///
+/// ```example
+/// $ upright(A) != A $
+/// ```
+#[func(keywords = ["mathup"])]
+pub fn upright(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_italic(Smart::Custom(false)))
+}
+
+/// Italic font style in math.
+///
+/// For roman letters and greek lowercase letters, this is already the default.
+#[func(keywords = ["mathit"])]
+pub fn italic(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_italic(Smart::Custom(true)))
+}
+
+/// Serif (roman) font style in math.
+///
+/// This is already the default.
+#[func(keywords = ["mathrm"])]
+pub fn serif(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_variant(MathVariant::Serif))
+}
+
+/// Sans-serif font style in math.
+///
+/// ```example
+/// $ sans(A B C) $
+/// ```
+#[func(title = "Sans Serif", keywords = ["mathsf"])]
+pub fn sans(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_variant(MathVariant::Sans))
+}
+
+/// Calligraphic font style in math.
+///
+/// ```example
+/// Let $cal(P)$ be the set of ...
+/// ```
+///
+/// This corresponds both to LaTeX's `\mathcal` and `\mathscr` as both of these
+/// styles share the same Unicode codepoints. Switching between the styles is
+/// thus only possible if supported by the font via
+/// [font features]($text.features).
+///
+/// For the default math font, the roundhand style is available through the
+/// `ss01` feature. Therefore, you could define your own version of `\mathscr`
+/// like this:
+///
+/// ```example
+/// #let scr(it) = text(
+/// features: ("ss01",),
+/// box($cal(it)$),
+/// )
+///
+/// We establish $cal(P) != scr(P)$.
+/// ```
+///
+/// (The box is not conceptually necessary, but unfortunately currently needed
+/// due to limitations in Typst's text style handling in math.)
+#[func(title = "Calligraphic", keywords = ["mathcal", "mathscr"])]
+pub fn cal(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_variant(MathVariant::Cal))
+}
+
+/// Fraktur font style in math.
+///
+/// ```example
+/// $ frak(P) $
+/// ```
+#[func(title = "Fraktur", keywords = ["mathfrak"])]
+pub fn frak(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_variant(MathVariant::Frak))
+}
+
+/// Monospace font style in math.
+///
+/// ```example
+/// $ mono(x + y = z) $
+/// ```
+#[func(title = "Monospace", keywords = ["mathtt"])]
+pub fn mono(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_variant(MathVariant::Mono))
+}
+
+/// 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
+/// $ bb(b) $
+/// $ bb(N) = NN $
+/// $ f: NN -> RR $
+/// ```
+#[func(title = "Blackboard Bold", keywords = ["mathbb"])]
+pub fn bb(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ body.styled(EquationElem::set_variant(MathVariant::Bb))
+}
+
+/// Forced display style in math.
+///
+/// This is the normal size for block equations.
+///
+/// ```example
+/// $sum_i x_i/2 = display(sum_i x_i/2)$
+/// ```
+#[func(title = "Display Size", keywords = ["displaystyle"])]
+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 {
+ body.styled(EquationElem::set_size(MathSize::Display))
+ .styled(EquationElem::set_cramped(cramped))
+}
+
+/// Forced inline (text) style in math.
+///
+/// This is the normal size for inline equations.
+///
+/// ```example
+/// $ sum_i x_i/2
+/// = inline(sum_i x_i/2) $
+/// ```
+#[func(title = "Inline Size", keywords = ["textstyle"])]
+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 {
+ body.styled(EquationElem::set_size(MathSize::Text))
+ .styled(EquationElem::set_cramped(cramped))
+}
+
+/// Forced script style in math.
+///
+/// This is the smaller size used in powers or sub- or superscripts.
+///
+/// ```example
+/// $sum_i x_i/2 = script(sum_i x_i/2)$
+/// ```
+#[func(title = "Script Size", keywords = ["scriptstyle"])]
+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 {
+ body.styled(EquationElem::set_size(MathSize::Script))
+ .styled(EquationElem::set_cramped(cramped))
+}
+
+/// Forced second script style in math.
+///
+/// This is the smallest size, used in second-level sub- and superscripts
+/// (script of the script).
+///
+/// ```example
+/// $sum_i x_i/2 = sscript(sum_i x_i/2)$
+/// ```
+#[func(title = "Script-Script Size", keywords = ["scriptscriptstyle"])]
+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 {
+ body.styled(EquationElem::set_size(MathSize::ScriptScript))
+ .styled(EquationElem::set_cramped(cramped))
+}
+
+/// The size of elements in an equation.
+///
+/// See the TeXbook p. 141.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Cast, Hash)]
+pub enum MathSize {
+ /// Second-level sub- and superscripts.
+ ScriptScript,
+ /// Sub- and superscripts.
+ Script,
+ /// Math in text.
+ Text,
+ /// Math on its own line.
+ Display,
+}
+
+/// A mathematical style variant, as defined by Unicode.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Cast, Hash)]
+pub enum MathVariant {
+ #[default]
+ Serif,
+ Sans,
+ Cal,
+ Frak,
+ Mono,
+ Bb,
+}
diff --git a/crates/typst-library/src/math/underover.rs b/crates/typst-library/src/math/underover.rs
new file mode 100644
index 00000000..302c51af
--- /dev/null
+++ b/crates/typst-library/src/math/underover.rs
@@ -0,0 +1,156 @@
+use crate::foundations::{elem, Content};
+use crate::math::Mathy;
+
+/// A horizontal line under content.
+///
+/// ```example
+/// $ underline(1 + 2 + ... + 5) $
+/// ```
+#[elem(Mathy)]
+pub struct UnderlineElem {
+ /// The content above the line.
+ #[required]
+ pub body: Content,
+}
+
+/// A horizontal line over content.
+///
+/// ```example
+/// $ overline(1 + 2 + ... + 5) $
+/// ```
+#[elem(Mathy)]
+pub struct OverlineElem {
+ /// The content below the line.
+ #[required]
+ pub body: Content,
+}
+
+/// A horizontal brace under content, with an optional annotation below.
+///
+/// ```example
+/// $ underbrace(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct UnderbraceElem {
+ /// The content above the brace.
+ #[required]
+ pub body: Content,
+
+ /// The optional content below the brace.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+/// A horizontal brace over content, with an optional annotation above.
+///
+/// ```example
+/// $ overbrace(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct OverbraceElem {
+ /// The content below the brace.
+ #[required]
+ pub body: Content,
+
+ /// The optional content above the brace.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+/// A horizontal bracket under content, with an optional annotation below.
+///
+/// ```example
+/// $ underbracket(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct UnderbracketElem {
+ /// The content above the bracket.
+ #[required]
+ pub body: Content,
+
+ /// The optional content below the bracket.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+/// A horizontal bracket over content, with an optional annotation above.
+///
+/// ```example
+/// $ overbracket(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct OverbracketElem {
+ /// The content below the bracket.
+ #[required]
+ pub body: Content,
+
+ /// The optional content above the bracket.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+/// A horizontal parenthesis under content, with an optional annotation below.
+///
+/// ```example
+/// $ underparen(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct UnderparenElem {
+ /// The content above the parenthesis.
+ #[required]
+ pub body: Content,
+
+ /// The optional content below the parenthesis.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+/// A horizontal parenthesis over content, with an optional annotation above.
+///
+/// ```example
+/// $ overparen(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct OverparenElem {
+ /// The content below the parenthesis.
+ #[required]
+ pub body: Content,
+
+ /// The optional content above the parenthesis.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+/// A horizontal tortoise shell bracket under content, with an optional
+/// annotation below.
+///
+/// ```example
+/// $ undershell(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct UndershellElem {
+ /// The content above the tortoise shell bracket.
+ #[required]
+ pub body: Content,
+
+ /// The optional content below the tortoise shell bracket.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+/// A horizontal tortoise shell bracket over content, with an optional
+/// annotation above.
+///
+/// ```example
+/// $ overshell(1 + 2 + ... + 5, "numbers") $
+/// ```
+#[elem(Mathy)]
+pub struct OvershellElem {
+ /// The content below the tortoise shell bracket.
+ #[required]
+ pub body: Content,
+
+ /// The optional content above the tortoise shell bracket.
+ #[positional]
+ pub annotation: Option<Content>,
+}
diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs
new file mode 100644
index 00000000..d11055b9
--- /dev/null
+++ b/crates/typst-library/src/model/bibliography.rs
@@ -0,0 +1,1101 @@
+use std::collections::HashMap;
+use std::ffi::OsStr;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::num::NonZeroUsize;
+use std::path::Path;
+use std::sync::Arc;
+
+use comemo::Tracked;
+use ecow::{eco_format, EcoString, EcoVec};
+use hayagriva::archive::ArchivedStyle;
+use hayagriva::io::BibLaTeXError;
+use hayagriva::{
+ citationberg, BibliographyDriver, BibliographyRequest, CitationItem, CitationRequest,
+ SpecificLocator,
+};
+use indexmap::IndexMap;
+use once_cell::sync::Lazy;
+use smallvec::{smallvec, SmallVec};
+use typed_arena::Arena;
+use typst_syntax::{Span, Spanned};
+use typst_utils::{LazyHash, NonZeroExt, PicoStr};
+
+use crate::diag::{bail, error, At, FileError, HintedStrResult, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, ty, Args, Array, Bytes, CastInfo, Content, FromValue, IntoValue, Label,
+ NativeElement, Packed, Reflect, Repr, Scope, Show, ShowSet, Smart, Str, StyleChain,
+ Styles, Synthesize, Type, Value,
+};
+use crate::introspection::{Introspector, Locatable, Location};
+use crate::layout::{
+ BlockBody, BlockElem, Em, GridCell, GridChild, GridElem, GridItem, HElem, PadElem,
+ Sizing, TrackSizings, VElem,
+};
+use crate::model::{
+ CitationForm, CiteGroup, Destination, FootnoteElem, HeadingElem, LinkElem, ParElem,
+ Url,
+};
+use crate::routines::{EvalMode, Routines};
+use crate::text::{
+ FontStyle, Lang, LocalName, Region, SubElem, SuperElem, TextElem, WeightDelta,
+};
+use crate::World;
+
+/// 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]($cite) function (`[#cite(<key>)]`). The bibliography will only
+/// show entries for works that were referenced in the document.
+///
+/// # Styles
+/// Typst offers a wide selection of built-in
+/// [citation and bibliography styles]($bibliography.style). Beyond those, you
+/// can add and use custom [CSL](https://citationstyles.org/) (Citation Style
+/// Language) files. Wondering which style to use? Here are some good defaults
+/// based on what discipline you're working in:
+///
+/// | Fields | Typical Styles |
+/// |-----------------|--------------------------------------------------------|
+/// | Engineering, IT | `{"ieee"}` |
+/// | Psychology, Life Sciences | `{"apa"}` |
+/// | Social sciences | `{"chicago-author-date"}` |
+/// | Humanities | `{"mla"}`, `{"chicago-notes"}`, `{"harvard-cite-them-right"}` |
+/// | Economics | `{"harvard-cite-them-right"}` |
+/// | Physics | `{"american-physics-society"}` |
+///
+/// # Example
+/// ```example
+/// This was already noted by
+/// pirates long ago. @arrgh
+///
+/// Multiple sources say ...
+/// @arrgh @netwok.
+///
+/// #bibliography("works.bib")
+/// ```
+#[elem(Locatable, Synthesize, Show, ShowSet, LocalName)]
+pub struct BibliographyElem {
+ /// Path(s) to Hayagriva `.yml` and/or BibLaTeX `.bib` files.
+ #[required]
+ #[parse(
+ let (paths, bibliography) = Bibliography::parse(engine, args)?;
+ paths
+ )]
+ pub path: BibliographyPaths,
+
+ /// The title of the bibliography.
+ ///
+ /// - When set to `{auto}`, an appropriate title for the
+ /// [text language]($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.")}`
+ pub title: Smart<Option<Content>>,
+
+ /// Whether to include all works from the given bibliography files, even
+ /// those that weren't cited in the document.
+ ///
+ /// To selectively add individual cited works without showing them, you can
+ /// also use the `cite` function with [`form`]($cite.form) set to `{none}`.
+ #[default(false)]
+ pub full: bool,
+
+ /// The bibliography style.
+ ///
+ /// Should be either one of the built-in styles (see below) or a path to
+ /// a [CSL file](https://citationstyles.org/). Some of the styles listed
+ /// below appear twice, once with their full name and once with a short
+ /// alias.
+ #[parse(CslStyle::parse(engine, args)?)]
+ #[default(CslStyle::from_name("ieee").unwrap())]
+ pub style: CslStyle,
+
+ /// The loaded bibliography.
+ #[internal]
+ #[required]
+ #[parse(bibliography)]
+ pub bibliography: Bibliography,
+
+ /// The language setting where the bibliography is.
+ #[internal]
+ #[synthesized]
+ pub lang: Lang,
+
+ /// The region setting where the bibliography is.
+ #[internal]
+ #[synthesized]
+ pub region: Option<Region>,
+}
+
+/// A list of bibliography file paths.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct BibliographyPaths(Vec<EcoString>);
+
+cast! {
+ BibliographyPaths,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
+
+impl BibliographyElem {
+ /// Find the document's bibliography.
+ pub fn find(introspector: Tracked<Introspector>) -> StrResult<Packed<Self>> {
+ let query = introspector.query(&Self::elem().select());
+ let mut iter = query.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 yet supported");
+ }
+
+ Ok(elem.to_packed::<Self>().unwrap().clone())
+ }
+
+ /// Whether the bibliography contains the given key.
+ pub fn has(engine: &Engine, key: impl Into<PicoStr>) -> bool {
+ let key = key.into();
+ engine
+ .introspector
+ .query(&Self::elem().select())
+ .iter()
+ .any(|elem| elem.to_packed::<Self>().unwrap().bibliography().has(key))
+ }
+
+ /// Find all bibliography keys.
+ pub fn keys(
+ introspector: Tracked<Introspector>,
+ ) -> Vec<(EcoString, Option<EcoString>)> {
+ let mut vec = vec![];
+ for elem in introspector.query(&Self::elem().select()).iter() {
+ let this = elem.to_packed::<Self>().unwrap();
+ for entry in this.bibliography().entries() {
+ let key = entry.key().into();
+ let detail = entry.title().map(|title| title.value.to_str().into());
+ vec.push((key, detail))
+ }
+ }
+ vec
+ }
+}
+
+impl Synthesize for Packed<BibliographyElem> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let elem = self.as_mut();
+ elem.push_lang(TextElem::lang_in(styles));
+ elem.push_region(TextElem::region_in(styles));
+ Ok(())
+ }
+}
+
+impl Show for Packed<BibliographyElem> {
+ #[typst_macros::time(name = "bibliography", span = self.span())]
+ fn show(&self, engine: &mut Engine, 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).unwrap_or_else(|| {
+ Some(TextElem::packed(Self::local_name_in(styles)).spanned(self.span()))
+ }) {
+ seq.push(
+ HeadingElem::new(title)
+ .with_depth(NonZeroUsize::ONE)
+ .pack()
+ .spanned(self.span()),
+ );
+ }
+
+ let span = self.span();
+ let works = Works::generate(engine).at(span)?;
+ let references = works
+ .references
+ .as_ref()
+ .ok_or("CSL style is not suitable for bibliographies")
+ .at(span)?;
+
+ let row_gutter = ParElem::spacing_in(styles);
+ let row_gutter_elem = VElem::new(row_gutter.into()).with_weak(true).pack();
+
+ if references.iter().any(|(prefix, _)| prefix.is_some()) {
+ let mut cells = vec![];
+ for (prefix, reference) in references {
+ cells.push(GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(prefix.clone().unwrap_or_default()))
+ .spanned(span),
+ )));
+ cells.push(GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(reference.clone())).spanned(span),
+ )));
+ }
+ seq.push(
+ GridElem::new(cells)
+ .with_columns(TrackSizings(smallvec![Sizing::Auto; 2]))
+ .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()]))
+ .with_row_gutter(TrackSizings(smallvec![row_gutter.into()]))
+ .pack()
+ .spanned(self.span()),
+ );
+ } else {
+ for (i, (_, reference)) in references.iter().enumerate() {
+ if i > 0 {
+ seq.push(row_gutter_elem.clone());
+ }
+ seq.push(reference.clone());
+ }
+ }
+
+ let mut content = Content::sequence(seq);
+ if works.hanging_indent {
+ content = content.styled(ParElem::set_hanging_indent(INDENT.into()));
+ }
+
+ Ok(content)
+ }
+}
+
+impl ShowSet for Packed<BibliographyElem> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ const INDENT: Em = Em::new(1.0);
+ let mut out = Styles::new();
+ out.set(HeadingElem::set_numbering(None));
+ out.set(PadElem::set_left(INDENT.into()));
+ out
+ }
+}
+
+impl LocalName for Packed<BibliographyElem> {
+ const KEY: &'static str = "bibliography";
+}
+
+/// A loaded bibliography.
+#[derive(Clone, PartialEq)]
+pub struct Bibliography {
+ map: Arc<IndexMap<PicoStr, hayagriva::Entry>>,
+ hash: u128,
+}
+
+impl Bibliography {
+ /// Parse the bibliography argument.
+ fn parse(
+ engine: &mut Engine,
+ args: &mut Args,
+ ) -> SourceResult<(BibliographyPaths, Bibliography)> {
+ let Spanned { v: paths, span } =
+ args.expect::<Spanned<BibliographyPaths>>("path to bibliography file")?;
+
+ // Load bibliography files.
+ let data = paths
+ .0
+ .iter()
+ .map(|path| {
+ let id = span.resolve_path(path).at(span)?;
+ engine.world.file(id).at(span)
+ })
+ .collect::<SourceResult<Vec<Bytes>>>()?;
+
+ // Parse.
+ let bibliography = Self::load(&paths, &data).at(span)?;
+
+ Ok((paths, bibliography))
+ }
+
+ /// Load bibliography entries from paths.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load bibliography")]
+ fn load(paths: &BibliographyPaths, data: &[Bytes]) -> StrResult<Bibliography> {
+ let mut map = IndexMap::new();
+ let mut duplicates = Vec::<EcoString>::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::from)?;
+
+ let ext = Path::new(path.as_str())
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default();
+
+ let library = match ext.to_lowercase().as_str() {
+ "yml" | "yaml" => hayagriva::io::from_yaml_str(src)
+ .map_err(|err| eco_format!("failed to parse YAML ({err})"))?,
+ "bib" => hayagriva::io::from_biblatex_str(src)
+ .map_err(|errors| format_biblatex_error(path, src, errors))?,
+ _ => bail!("unknown bibliography format (must be .yml/.yaml or .bib)"),
+ };
+
+ for entry in library {
+ match map.entry(entry.key().into()) {
+ indexmap::map::Entry::Vacant(vacant) => {
+ vacant.insert(entry);
+ }
+ indexmap::map::Entry::Occupied(_) => {
+ duplicates.push(entry.key().into());
+ }
+ }
+ }
+ }
+
+ if !duplicates.is_empty() {
+ bail!("duplicate bibliography keys: {}", duplicates.join(", "));
+ }
+
+ Ok(Bibliography {
+ map: Arc::new(map),
+ hash: typst_utils::hash128(data),
+ })
+ }
+
+ fn has(&self, key: impl Into<PicoStr>) -> bool {
+ self.map.contains_key(&key.into())
+ }
+
+ fn entries(&self) -> impl Iterator<Item = &hayagriva::Entry> {
+ self.map.values()
+ }
+}
+
+impl Debug for Bibliography {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.debug_set().entries(self.map.keys()).finish()
+ }
+}
+
+impl Hash for Bibliography {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.hash.hash(state);
+ }
+}
+
+/// Format a BibLaTeX loading error.
+fn format_biblatex_error(path: &str, src: &str, errors: Vec<BibLaTeXError>) -> EcoString {
+ let Some(error) = errors.first() else {
+ return eco_format!("failed to parse BibLaTeX file ({path})");
+ };
+
+ 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!("failed to parse BibLaTeX file ({path}:{line}: {msg})")
+}
+
+/// A loaded CSL style.
+#[ty(cast)]
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub struct CslStyle {
+ name: Option<EcoString>,
+ style: Arc<LazyHash<citationberg::IndependentStyle>>,
+}
+
+impl CslStyle {
+ /// Parse the style argument.
+ pub fn parse(engine: &mut Engine, args: &mut Args) -> SourceResult<Option<CslStyle>> {
+ let Some(Spanned { v: string, span }) =
+ args.named::<Spanned<EcoString>>("style")?
+ else {
+ return Ok(None);
+ };
+
+ Ok(Some(Self::parse_impl(engine, &string, span).at(span)?))
+ }
+
+ /// Parse the style argument with `Smart`.
+ pub fn parse_smart(
+ engine: &mut Engine,
+ args: &mut Args,
+ ) -> SourceResult<Option<Smart<CslStyle>>> {
+ let Some(Spanned { v: smart, span }) =
+ args.named::<Spanned<Smart<EcoString>>>("style")?
+ else {
+ return Ok(None);
+ };
+
+ Ok(Some(match smart {
+ Smart::Auto => Smart::Auto,
+ Smart::Custom(string) => {
+ Smart::Custom(Self::parse_impl(engine, &string, span).at(span)?)
+ }
+ }))
+ }
+
+ /// Parse internally.
+ fn parse_impl(engine: &mut Engine, string: &str, span: Span) -> StrResult<CslStyle> {
+ let ext = Path::new(string)
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default()
+ .to_lowercase();
+
+ if ext == "csl" {
+ let id = span.resolve_path(string)?;
+ let data = engine.world.file(id)?;
+ CslStyle::from_data(&data)
+ } else {
+ CslStyle::from_name(string)
+ }
+ }
+
+ /// Load a built-in CSL style.
+ #[comemo::memoize]
+ pub fn from_name(name: &str) -> StrResult<CslStyle> {
+ match hayagriva::archive::ArchivedStyle::by_name(name).map(ArchivedStyle::get) {
+ Some(citationberg::Style::Independent(style)) => Ok(Self {
+ name: Some(name.into()),
+ style: Arc::new(LazyHash::new(style)),
+ }),
+ _ => bail!("unknown style: `{name}`"),
+ }
+ }
+
+ /// Load a CSL style from file contents.
+ #[comemo::memoize]
+ pub fn from_data(data: &Bytes) -> StrResult<CslStyle> {
+ let text = std::str::from_utf8(data.as_slice()).map_err(FileError::from)?;
+ citationberg::IndependentStyle::from_xml(text)
+ .map(|style| Self { name: None, style: Arc::new(LazyHash::new(style)) })
+ .map_err(|err| eco_format!("failed to load CSL style ({err})"))
+ }
+
+ /// Get the underlying independent style.
+ pub fn get(&self) -> &citationberg::IndependentStyle {
+ self.style.as_ref()
+ }
+}
+
+// This Reflect impl is technically a bit wrong because it doesn't say what
+// FromValue and IntoValue really do. Instead, it says what the `style` argument
+// on `bibliography` and `cite` expect (through manual parsing).
+impl Reflect for CslStyle {
+ #[comemo::memoize]
+ fn input() -> CastInfo {
+ let ty = std::iter::once(CastInfo::Type(Type::of::<Str>()));
+ let options = hayagriva::archive::ArchivedStyle::all().iter().map(|name| {
+ CastInfo::Value(name.names()[0].into_value(), name.display_name())
+ });
+ CastInfo::Union(ty.chain(options).collect())
+ }
+
+ fn output() -> CastInfo {
+ EcoString::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ if let Value::Dyn(dynamic) = &value {
+ if dynamic.is::<Self>() {
+ return true;
+ }
+ }
+
+ false
+ }
+}
+
+impl FromValue for CslStyle {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ if let Value::Dyn(dynamic) = &value {
+ if let Some(concrete) = dynamic.downcast::<Self>() {
+ return Ok(concrete.clone());
+ }
+ }
+
+ Err(<Self as Reflect>::error(&value))
+ }
+}
+
+impl IntoValue for CslStyle {
+ fn into_value(self) -> Value {
+ Value::dynamic(self)
+ }
+}
+
+impl Repr for CslStyle {
+ fn repr(&self) -> EcoString {
+ self.name
+ .as_ref()
+ .map(|name| name.repr())
+ .unwrap_or_else(|| "..".into())
+ }
+}
+
+/// Fully formatted citations and references, generated once (through
+/// memoization) for the whole document. This setup is necessary because
+/// citation formatting is inherently stateful and we need access to all
+/// citations to do it.
+pub(super) struct Works {
+ /// Maps from the location of a citation group to its rendered content.
+ pub citations: HashMap<Location, SourceResult<Content>>,
+ /// Lists all references in the bibliography, with optional prefix, or
+ /// `None` if the citation style can't be used for bibliographies.
+ pub references: Option<Vec<(Option<Content>, Content)>>,
+ /// Whether the bibliography should have hanging indent.
+ pub hanging_indent: bool,
+}
+
+impl Works {
+ /// Generate all citations and the whole bibliography.
+ pub fn generate(engine: &Engine) -> StrResult<Arc<Works>> {
+ Self::generate_impl(engine.routines, engine.world, engine.introspector)
+ }
+
+ /// The internal implementation of [`Works::generate`].
+ #[comemo::memoize]
+ fn generate_impl(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ ) -> StrResult<Arc<Works>> {
+ let mut generator = Generator::new(routines, world, introspector)?;
+ let rendered = generator.drive();
+ let works = generator.display(&rendered)?;
+ Ok(Arc::new(works))
+ }
+}
+
+/// Context for generating the bibliography.
+struct Generator<'a> {
+ /// The routines that is used to evaluate mathematical material in citations.
+ routines: &'a Routines,
+ /// The world that is used to evaluate mathematical material in citations.
+ world: Tracked<'a, dyn World + 'a>,
+ /// The document's bibliography.
+ bibliography: Packed<BibliographyElem>,
+ /// The document's citation groups.
+ groups: EcoVec<Content>,
+ /// Details about each group that are accumulated while driving hayagriva's
+ /// bibliography driver and needed when processing hayagriva's output.
+ infos: Vec<GroupInfo>,
+ /// Citations with unresolved keys.
+ failures: HashMap<Location, SourceResult<Content>>,
+}
+
+/// Details about a group of merged citations. All citations are put into groups
+/// of adjacent ones (e.g., `@foo @bar` will merge into a group of length two).
+/// Even single citations will be put into groups of length ones.
+struct GroupInfo {
+ /// The group's location.
+ location: Location,
+ /// The group's span.
+ span: Span,
+ /// Whether the group should be displayed in a footnote.
+ footnote: bool,
+ /// Details about the groups citations.
+ subinfos: SmallVec<[CiteInfo; 1]>,
+}
+
+/// Details about a citation item in a request.
+struct CiteInfo {
+ /// The citation's key.
+ key: Label,
+ /// The citation's supplement.
+ supplement: Option<Content>,
+ /// Whether this citation was hidden.
+ hidden: bool,
+}
+
+impl<'a> Generator<'a> {
+ /// Create a new generator.
+ fn new(
+ routines: &'a Routines,
+ world: Tracked<'a, dyn World + 'a>,
+ introspector: Tracked<Introspector>,
+ ) -> StrResult<Self> {
+ let bibliography = BibliographyElem::find(introspector)?;
+ let groups = introspector.query(&CiteGroup::elem().select());
+ let infos = Vec::with_capacity(groups.len());
+ Ok(Self {
+ routines,
+ world,
+ bibliography,
+ groups,
+ infos,
+ failures: HashMap::new(),
+ })
+ }
+
+ /// Drives hayagriva's citation driver.
+ fn drive(&mut self) -> hayagriva::Rendered {
+ static LOCALES: Lazy<Vec<citationberg::Locale>> =
+ Lazy::new(hayagriva::archive::locales);
+
+ let database = self.bibliography.bibliography();
+ let bibliography_style = self.bibliography.style(StyleChain::default());
+ let styles = Arena::new();
+
+ // Process all citation groups.
+ let mut driver = BibliographyDriver::new();
+ for elem in &self.groups {
+ let group = elem.to_packed::<CiteGroup>().unwrap();
+ let location = elem.location().unwrap();
+ let children = group.children();
+
+ // Groups should never be empty.
+ let Some(first) = children.first() else { continue };
+
+ let mut subinfos = SmallVec::with_capacity(children.len());
+ let mut items = Vec::with_capacity(children.len());
+ let mut errors = EcoVec::new();
+ let mut normal = true;
+
+ // Create infos and items for each child in the group.
+ for child in children {
+ let key = *child.key();
+ let Some(entry) = database.map.get(&key.into_inner()) else {
+ errors.push(error!(
+ child.span(),
+ "key `{}` does not exist in the bibliography",
+ key.as_str()
+ ));
+ continue;
+ };
+
+ let supplement = child.supplement(StyleChain::default());
+ let locator = supplement.as_ref().map(|_| {
+ SpecificLocator(
+ citationberg::taxonomy::Locator::Custom,
+ hayagriva::LocatorPayload::Transparent,
+ )
+ });
+
+ let mut hidden = false;
+ let special_form = match child.form(StyleChain::default()) {
+ None => {
+ hidden = true;
+ None
+ }
+ Some(CitationForm::Normal) => None,
+ Some(CitationForm::Prose) => Some(hayagriva::CitePurpose::Prose),
+ Some(CitationForm::Full) => Some(hayagriva::CitePurpose::Full),
+ Some(CitationForm::Author) => Some(hayagriva::CitePurpose::Author),
+ Some(CitationForm::Year) => Some(hayagriva::CitePurpose::Year),
+ };
+
+ normal &= special_form.is_none();
+ subinfos.push(CiteInfo { key, supplement, hidden });
+ items.push(CitationItem::new(entry, locator, None, hidden, special_form));
+ }
+
+ if !errors.is_empty() {
+ self.failures.insert(location, Err(errors));
+ continue;
+ }
+
+ let style = match first.style(StyleChain::default()) {
+ Smart::Auto => &bibliography_style.style,
+ Smart::Custom(style) => styles.alloc(style.style),
+ };
+
+ self.infos.push(GroupInfo {
+ location,
+ subinfos,
+ span: first.span(),
+ footnote: normal
+ && style.settings.class == citationberg::StyleClass::Note,
+ });
+
+ driver.citation(CitationRequest::new(
+ items,
+ style,
+ Some(locale(
+ first.lang().copied().unwrap_or(Lang::ENGLISH),
+ first.region().copied().flatten(),
+ )),
+ &LOCALES,
+ None,
+ ));
+ }
+
+ let locale = locale(
+ self.bibliography.lang().copied().unwrap_or(Lang::ENGLISH),
+ self.bibliography.region().copied().flatten(),
+ );
+
+ // Add hidden items for everything if we should print the whole
+ // bibliography.
+ if self.bibliography.full(StyleChain::default()) {
+ for entry in database.map.values() {
+ driver.citation(CitationRequest::new(
+ vec![CitationItem::new(entry, None, None, true, None)],
+ bibliography_style.get(),
+ Some(locale.clone()),
+ &LOCALES,
+ None,
+ ));
+ }
+ }
+
+ driver.finish(BibliographyRequest {
+ style: bibliography_style.get(),
+ locale: Some(locale),
+ locale_files: &LOCALES,
+ })
+ }
+
+ /// Displays hayagriva's output as content for the citations and references.
+ fn display(&mut self, rendered: &hayagriva::Rendered) -> StrResult<Works> {
+ let citations = self.display_citations(rendered)?;
+ let references = self.display_references(rendered)?;
+ let hanging_indent =
+ rendered.bibliography.as_ref().is_some_and(|b| b.hanging_indent);
+ Ok(Works { citations, references, hanging_indent })
+ }
+
+ /// Display the citation groups.
+ fn display_citations(
+ &mut self,
+ rendered: &hayagriva::Rendered,
+ ) -> StrResult<HashMap<Location, SourceResult<Content>>> {
+ // Determine for each citation key where in the bibliography it is,
+ // so that we can link there.
+ let mut links = HashMap::new();
+ if let Some(bibliography) = &rendered.bibliography {
+ let location = self.bibliography.location().unwrap();
+ for (k, item) in bibliography.items.iter().enumerate() {
+ links.insert(item.key.as_str(), location.variant(k + 1));
+ }
+ }
+
+ let mut output = std::mem::take(&mut self.failures);
+ for (info, citation) in self.infos.iter().zip(&rendered.citations) {
+ let supplement = |i: usize| info.subinfos.get(i)?.supplement.clone();
+ let link = |i: usize| links.get(info.subinfos.get(i)?.key.as_str()).copied();
+
+ let renderer = ElemRenderer {
+ routines: self.routines,
+ world: self.world,
+ span: info.span,
+ supplement: &supplement,
+ link: &link,
+ };
+
+ let content = if info.subinfos.iter().all(|sub| sub.hidden) {
+ Content::empty()
+ } else {
+ let mut content = renderer.display_elem_children(
+ &citation.citation,
+ &mut None,
+ true,
+ )?;
+
+ if info.footnote {
+ content = FootnoteElem::with_content(content).pack();
+ }
+
+ content
+ };
+
+ output.insert(info.location, Ok(content));
+ }
+
+ Ok(output)
+ }
+
+ /// Display the bibliography references.
+ #[allow(clippy::type_complexity)]
+ fn display_references(
+ &self,
+ rendered: &hayagriva::Rendered,
+ ) -> StrResult<Option<Vec<(Option<Content>, Content)>>> {
+ let Some(rendered) = &rendered.bibliography else { return Ok(None) };
+
+ // Determine for each citation key where it first occurred, so that we
+ // can link there.
+ let mut first_occurrences = HashMap::new();
+ for info in &self.infos {
+ for subinfo in &info.subinfos {
+ let key = subinfo.key.as_str();
+ first_occurrences.entry(key).or_insert(info.location);
+ }
+ }
+
+ // The location of the bibliography.
+ let location = self.bibliography.location().unwrap();
+
+ let mut output = vec![];
+ for (k, item) in rendered.items.iter().enumerate() {
+ let renderer = ElemRenderer {
+ routines: self.routines,
+ world: self.world,
+ span: self.bibliography.span(),
+ supplement: &|_| None,
+ link: &|_| None,
+ };
+
+ // Each reference is assigned a manually created well-known location
+ // that is derived from the bibliography's location. This way,
+ // citations can link to them.
+ let backlink = location.variant(k + 1);
+
+ // Render the first field.
+ let mut prefix = item
+ .first_field
+ .as_ref()
+ .map(|elem| {
+ let mut content =
+ renderer.display_elem_child(elem, &mut None, false)?;
+ if let Some(location) = first_occurrences.get(item.key.as_str()) {
+ let dest = Destination::Location(*location);
+ content = content.linked(dest);
+ }
+ StrResult::Ok(content)
+ })
+ .transpose()?;
+
+ // Render the main reference content.
+ let mut reference =
+ renderer.display_elem_children(&item.content, &mut prefix, false)?;
+
+ // Attach a backlink to either the prefix or the reference so that
+ // we can link to the bibliography entry.
+ prefix.as_mut().unwrap_or(&mut reference).set_location(backlink);
+
+ output.push((prefix, reference));
+ }
+
+ Ok(Some(output))
+ }
+}
+
+/// Renders hayagriva elements into content.
+struct ElemRenderer<'a> {
+ /// The routines that is used to evaluate mathematical material in citations.
+ routines: &'a Routines,
+ /// The world that is used to evaluate mathematical material.
+ world: Tracked<'a, dyn World + 'a>,
+ /// The span that is attached to all of the resulting content.
+ span: Span,
+ /// Resolves the supplement of i-th citation in the request.
+ supplement: &'a dyn Fn(usize) -> Option<Content>,
+ /// Resolves where the i-th citation in the request should link to.
+ link: &'a dyn Fn(usize) -> Option<Location>,
+}
+
+impl ElemRenderer<'_> {
+ /// Display rendered hayagriva elements.
+ ///
+ /// The `prefix` can be a separate content storage where `left-margin`
+ /// elements will be accumulated into.
+ ///
+ /// `is_citation` dictates whether whitespace at the start of the citation
+ /// will be eliminated. Some CSL styles yield whitespace at the start of
+ /// their citations, which should instead be handled by Typst.
+ fn display_elem_children(
+ &self,
+ elems: &hayagriva::ElemChildren,
+ prefix: &mut Option<Content>,
+ is_citation: bool,
+ ) -> StrResult<Content> {
+ Ok(Content::sequence(
+ elems
+ .0
+ .iter()
+ .enumerate()
+ .map(|(i, elem)| {
+ self.display_elem_child(elem, prefix, is_citation && i == 0)
+ })
+ .collect::<StrResult<Vec<_>>>()?,
+ ))
+ }
+
+ /// Display a rendered hayagriva element.
+ fn display_elem_child(
+ &self,
+ elem: &hayagriva::ElemChild,
+ prefix: &mut Option<Content>,
+ trim_start: bool,
+ ) -> StrResult<Content> {
+ Ok(match elem {
+ hayagriva::ElemChild::Text(formatted) => {
+ self.display_formatted(formatted, trim_start)
+ }
+ hayagriva::ElemChild::Elem(elem) => self.display_elem(elem, prefix)?,
+ hayagriva::ElemChild::Markup(markup) => self.display_math(markup),
+ hayagriva::ElemChild::Link { text, url } => self.display_link(text, url)?,
+ hayagriva::ElemChild::Transparent { cite_idx, format } => {
+ self.display_transparent(*cite_idx, format)
+ }
+ })
+ }
+
+ /// Display a block-level element.
+ fn display_elem(
+ &self,
+ elem: &hayagriva::Elem,
+ prefix: &mut Option<Content>,
+ ) -> StrResult<Content> {
+ use citationberg::Display;
+
+ let block_level = matches!(elem.display, Some(Display::Block | Display::Indent));
+
+ let mut suf_prefix = None;
+ let mut content = self.display_elem_children(
+ &elem.children,
+ if block_level { &mut suf_prefix } else { prefix },
+ false,
+ )?;
+
+ if let Some(prefix) = suf_prefix {
+ const COLUMN_GUTTER: Em = Em::new(0.65);
+ content = GridElem::new(vec![
+ GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(prefix)).spanned(self.span),
+ )),
+ GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(content)).spanned(self.span),
+ )),
+ ])
+ .with_columns(TrackSizings(smallvec![Sizing::Auto; 2]))
+ .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()]))
+ .pack()
+ .spanned(self.span);
+ }
+
+ match elem.display {
+ Some(Display::Block) => {
+ content = BlockElem::new()
+ .with_body(Some(BlockBody::Content(content)))
+ .pack()
+ .spanned(self.span);
+ }
+ Some(Display::Indent) => {
+ content = PadElem::new(content).pack().spanned(self.span);
+ }
+ Some(Display::LeftMargin) => {
+ *prefix.get_or_insert_with(Default::default) += content;
+ return Ok(Content::empty());
+ }
+ _ => {}
+ }
+
+ if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta {
+ if let Some(location) = (self.link)(i) {
+ let dest = Destination::Location(location);
+ content = content.linked(dest);
+ }
+ }
+
+ Ok(content)
+ }
+
+ /// Display math.
+ fn display_math(&self, math: &str) -> Content {
+ (self.routines.eval_string)(
+ self.routines,
+ self.world,
+ math,
+ self.span,
+ EvalMode::Math,
+ Scope::new(),
+ )
+ .map(Value::display)
+ .unwrap_or_else(|_| TextElem::packed(math).spanned(self.span))
+ }
+
+ /// Display a link.
+ fn display_link(&self, text: &hayagriva::Formatted, url: &str) -> StrResult<Content> {
+ let dest = Destination::Url(Url::new(url)?);
+ Ok(LinkElem::new(dest.into(), self.display_formatted(text, false))
+ .pack()
+ .spanned(self.span))
+ }
+
+ /// Display transparent pass-through content.
+ fn display_transparent(&self, i: usize, format: &hayagriva::Formatting) -> Content {
+ let content = (self.supplement)(i).unwrap_or_default();
+ apply_formatting(content, format)
+ }
+
+ /// Display formatted hayagriva text as content.
+ fn display_formatted(
+ &self,
+ formatted: &hayagriva::Formatted,
+ trim_start: bool,
+ ) -> Content {
+ let formatted_text = if trim_start {
+ formatted.text.trim_start()
+ } else {
+ formatted.text.as_str()
+ };
+
+ let content = TextElem::packed(formatted_text).spanned(self.span);
+ apply_formatting(content, &formatted.formatting)
+ }
+}
+
+/// Applies formatting to content.
+fn apply_formatting(mut content: Content, format: &hayagriva::Formatting) -> Content {
+ match format.font_style {
+ citationberg::FontStyle::Normal => {}
+ citationberg::FontStyle::Italic => {
+ content = content.styled(TextElem::set_style(FontStyle::Italic));
+ }
+ }
+
+ match format.font_variant {
+ citationberg::FontVariant::Normal => {}
+ citationberg::FontVariant::SmallCaps => {
+ content = content.styled(TextElem::set_smallcaps(true));
+ }
+ }
+
+ match format.font_weight {
+ citationberg::FontWeight::Normal => {}
+ citationberg::FontWeight::Bold => {
+ content = content.styled(TextElem::set_delta(WeightDelta(300)));
+ }
+ citationberg::FontWeight::Light => {
+ content = content.styled(TextElem::set_delta(WeightDelta(-100)));
+ }
+ }
+
+ match format.text_decoration {
+ citationberg::TextDecoration::None => {}
+ citationberg::TextDecoration::Underline => {
+ content = content.underlined();
+ }
+ }
+
+ let span = content.span();
+ match format.vertical_align {
+ citationberg::VerticalAlign::None => {}
+ citationberg::VerticalAlign::Baseline => {}
+ citationberg::VerticalAlign::Sup => {
+ // Add zero-width weak spacing to make the superscript "sticky".
+ content = HElem::hole().pack() + SuperElem::new(content).pack().spanned(span);
+ }
+ citationberg::VerticalAlign::Sub => {
+ content = HElem::hole().pack() + SubElem::new(content).pack().spanned(span);
+ }
+ }
+
+ content
+}
+
+/// Create a locale code from language and optionally region.
+fn locale(lang: Lang, region: Option<Region>) -> citationberg::LocaleCode {
+ let mut value = String::with_capacity(5);
+ value.push_str(lang.as_str());
+ if let Some(region) = region {
+ value.push('-');
+ value.push_str(region.as_str())
+ }
+ citationberg::LocaleCode(value)
+}
diff --git a/crates/typst-library/src/model/cite.rs b/crates/typst-library/src/model/cite.rs
new file mode 100644
index 00000000..ac0cfa79
--- /dev/null
+++ b/crates/typst-library/src/model/cite.rs
@@ -0,0 +1,175 @@
+use crate::diag::{error, At, HintedString, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Cast, Content, Label, Packed, Show, Smart, StyleChain, Synthesize,
+};
+use crate::introspection::Locatable;
+use crate::model::bibliography::Works;
+use crate::model::CslStyle;
+use crate::text::{Lang, Region, TextElem};
+
+/// Cite a work from the bibliography.
+///
+/// Before you starting citing, you need to add a [bibliography] somewhere in
+/// your document.
+///
+/// # Example
+/// ```example
+/// This was already noted by
+/// pirates long ago. @arrgh
+///
+/// Multiple sources say ...
+/// @arrgh @netwok.
+///
+/// You can also call `cite`
+/// explicitly. #cite(<arrgh>)
+///
+/// #bibliography("works.bib")
+/// ```
+///
+/// If your source name contains certain characters such as slashes, which are
+/// not recognized by the `<>` syntax, you can explicitly call `label` instead.
+///
+/// ```typ
+/// Computer Modern is an example of a modernist serif typeface.
+/// #cite(label("DBLP:books/lib/Knuth86a")).
+/// >>> #bibliography("works.bib")
+/// ```
+///
+/// # Syntax
+/// This function indirectly has dedicated syntax. [References]($ref) can be
+/// used to cite works from the bibliography. The label then corresponds to the
+/// citation key.
+#[elem(Synthesize)]
+pub struct CiteElem {
+ /// The citation key that identifies the entry in the bibliography that
+ /// shall be cited, as a label.
+ ///
+ /// ```example
+ /// // All the same
+ /// @netwok \
+ /// #cite(<netwok>) \
+ /// #cite(label("netwok"))
+ /// >>> #set text(0pt)
+ /// >>> #bibliography("works.bib", style: "apa")
+ /// ```
+ #[required]
+ pub key: Label,
+
+ /// 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. @distress[p.~7]
+ ///
+ /// #bibliography("works.bib")
+ /// ```
+ pub supplement: Option<Content>,
+
+ /// The kind of citation to produce. Different forms are useful in different
+ /// scenarios: A normal citation is useful as a source at the end of a
+ /// sentence, while a "prose" citation is more suitable for inclusion in the
+ /// flow of text.
+ ///
+ /// If set to `{none}`, the cited work is included in the bibliography, but
+ /// nothing will be displayed.
+ ///
+ /// ```example
+ /// #cite(<netwok>, form: "prose")
+ /// show the outsized effects of
+ /// pirate life on the human psyche.
+ /// >>> #set text(0pt)
+ /// >>> #bibliography("works.bib", style: "apa")
+ /// ```
+ #[default(Some(CitationForm::Normal))]
+ pub form: Option<CitationForm>,
+
+ /// The citation style.
+ ///
+ /// Should be either `{auto}`, one of the built-in styles (see below) or a
+ /// path to a [CSL file](https://citationstyles.org/). Some of the styles
+ /// listed below appear twice, once with their full name and once with a
+ /// short alias.
+ ///
+ /// When set to `{auto}`, automatically use the
+ /// [bibliography's style]($bibliography.style) for the citations.
+ #[parse(CslStyle::parse_smart(engine, args)?)]
+ pub style: Smart<CslStyle>,
+
+ /// The text language setting where the citation is.
+ #[internal]
+ #[synthesized]
+ pub lang: Lang,
+
+ /// The text region setting where the citation is.
+ #[internal]
+ #[synthesized]
+ pub region: Option<Region>,
+}
+
+impl Synthesize for Packed<CiteElem> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let elem = self.as_mut();
+ elem.push_lang(TextElem::lang_in(styles));
+ elem.push_region(TextElem::region_in(styles));
+ Ok(())
+ }
+}
+
+cast! {
+ CiteElem,
+ v: Content => v.unpack::<Self>().map_err(|_| "expected citation")?,
+}
+
+/// The form of the citation.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum CitationForm {
+ /// Display in the standard way for the active style.
+ #[default]
+ Normal,
+ /// Produces a citation that is suitable for inclusion in a sentence.
+ Prose,
+ /// Mimics a bibliography entry, with full information about the cited work.
+ Full,
+ /// Shows only the cited work's author(s).
+ Author,
+ /// Shows only the cited work's year.
+ Year,
+}
+
+/// A group of citations.
+///
+/// This is automatically created from adjacent citations during show rule
+/// application.
+#[elem(Locatable, Show)]
+pub struct CiteGroup {
+ /// The citations.
+ #[required]
+ pub children: Vec<Packed<CiteElem>>,
+}
+
+impl Show for Packed<CiteGroup> {
+ #[typst_macros::time(name = "cite", span = self.span())]
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ let location = self.location().unwrap();
+ let span = self.span();
+ Works::generate(engine)
+ .at(span)?
+ .citations
+ .get(&location)
+ .cloned()
+ .ok_or_else(failed_to_format_citation)
+ .at(span)?
+ }
+}
+
+/// The error message when a citation wasn't found in the pre-formatted list.
+#[cold]
+fn failed_to_format_citation() -> HintedString {
+ error!(
+ "cannot format citation in isolation";
+ hint: "check whether this citation is measured \
+ without being inserted into the document"
+ )
+}
diff --git a/crates/typst-library/src/model/document.rs b/crates/typst-library/src/model/document.rs
new file mode 100644
index 00000000..b693d785
--- /dev/null
+++ b/crates/typst-library/src/model/document.rs
@@ -0,0 +1,145 @@
+use ecow::EcoString;
+
+use crate::diag::{bail, HintedStrResult, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Args, Array, Construct, Content, Datetime, Fields, Smart, StyleChain,
+ Styles, Value,
+};
+use crate::introspection::Introspector;
+use crate::layout::Page;
+
+/// 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 not occur inside of any layout container.
+///
+/// ```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.
+#[elem(Construct)]
+pub struct DocumentElem {
+ /// The document's title. This is often rendered as the title of the
+ /// PDF viewer window.
+ ///
+ /// While this can be arbitrary content, PDF viewers only support plain text
+ /// titles, so the conversion might be lossy.
+ #[ghost]
+ pub title: Option<Content>,
+
+ /// The document's authors.
+ #[ghost]
+ pub author: Author,
+
+ /// The document's keywords.
+ #[ghost]
+ pub keywords: Keywords,
+
+ /// The document's creation date.
+ ///
+ /// If this is `{auto}` (default), Typst uses the current date and time.
+ /// Setting it to `{none}` prevents Typst from embedding any creation date
+ /// into the PDF metadata.
+ ///
+ /// The year component must be at least zero in order to be embedded into a
+ /// PDF.
+ ///
+ /// If you want to create byte-by-byte reproducible PDFs, set this to
+ /// something other than `{auto}`.
+ #[ghost]
+ pub date: Smart<Option<Datetime>>,
+}
+
+impl Construct for DocumentElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "can only be used in set rules")
+ }
+}
+
+/// A list of authors.
+#[derive(Debug, Default, Clone, PartialEq, 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::<HintedStrResult<_>>()?),
+}
+
+/// A list of keywords.
+#[derive(Debug, Default, Clone, PartialEq, Hash)]
+pub struct Keywords(Vec<EcoString>);
+
+cast! {
+ Keywords,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
+
+/// A finished document with metadata and page frames.
+#[derive(Debug, Default, Clone)]
+pub struct Document {
+ /// The document's finished pages.
+ pub pages: Vec<Page>,
+ /// Details about the document.
+ pub info: DocumentInfo,
+ /// Provides the ability to execute queries on the document.
+ pub introspector: Introspector,
+}
+
+/// Details about the document.
+#[derive(Debug, Default, Clone, PartialEq, Hash)]
+pub struct DocumentInfo {
+ /// The document's title.
+ pub title: Option<EcoString>,
+ /// The document's author.
+ pub author: Vec<EcoString>,
+ /// The document's keywords.
+ pub keywords: Vec<EcoString>,
+ /// The document's creation date.
+ pub date: Smart<Option<Datetime>>,
+}
+
+impl DocumentInfo {
+ /// Populate this document info with details from the given styles.
+ ///
+ /// Document set rules are a bit special, so we need to do this manually.
+ pub fn populate(&mut self, styles: &Styles) {
+ let chain = StyleChain::new(styles);
+ let has = |field| styles.has::<DocumentElem>(field as _);
+ if has(<DocumentElem as Fields>::Enum::Title) {
+ self.title =
+ DocumentElem::title_in(chain).map(|content| content.plain_text());
+ }
+ if has(<DocumentElem as Fields>::Enum::Author) {
+ self.author = DocumentElem::author_in(chain).0;
+ }
+ if has(<DocumentElem as Fields>::Enum::Keywords) {
+ self.keywords = DocumentElem::keywords_in(chain).0;
+ }
+ if has(<DocumentElem as Fields>::Enum::Date) {
+ self.date = DocumentElem::date_in(chain);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_document_is_send_and_sync() {
+ fn ensure_send_and_sync<T: Send + Sync>() {}
+ ensure_send_and_sync::<Document>();
+ }
+}
diff --git a/crates/typst-library/src/model/emph.rs b/crates/typst-library/src/model/emph.rs
new file mode 100644
index 00000000..e36e5ef7
--- /dev/null
+++ b/crates/typst-library/src/model/emph.rs
@@ -0,0 +1,41 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, StyleChain};
+use crate::text::{ItalicToggle, TextElem};
+
+/// Emphasizes content by toggling italics.
+///
+/// - If the current [text style]($text.style) is `{"normal"}`, this turns it
+/// into `{"italic"}`.
+/// - If it is already `{"italic"}` or `{"oblique"}`, it turns it back to
+/// `{"normal"}`.
+///
+/// # Example
+/// ```example
+/// This is _emphasized._ \
+/// This is #emph[too.]
+///
+/// #show emph: it => {
+/// text(blue, it.body)
+/// }
+///
+/// This is _emphasized_ differently.
+/// ```
+///
+/// # 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.
+#[elem(title = "Emphasis", keywords = ["italic"], Show)]
+pub struct EmphElem {
+ /// The content to emphasize.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<EmphElem> {
+ #[typst_macros::time(name = "emph", span = self.span())]
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(TextElem::set_emph(ItalicToggle(true))))
+ }
+}
diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs
new file mode 100644
index 00000000..bac792d3
--- /dev/null
+++ b/crates/typst-library/src/model/enum.rs
@@ -0,0 +1,271 @@
+use std::str::FromStr;
+
+use smallvec::SmallVec;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Array, Content, NativeElement, Packed, Show, Smart, StyleChain,
+ Styles,
+};
+use crate::layout::{Alignment, BlockElem, Em, HAlignment, Length, VAlignment, VElem};
+use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern, ParElem};
+
+/// A numbered list.
+///
+/// Displays a sequence of items vertically and numbers them consecutively.
+///
+/// # Example
+/// ```example
+/// Automatically numbered:
+/// + Preparations
+/// + Analysis
+/// + Conclusions
+///
+/// Manually numbered:
+/// 2. What is the first step?
+/// 5. I am confused.
+/// + Moving on ...
+///
+/// Multiple lines:
+/// + This enum item has multiple
+/// lines because the next line
+/// is indented.
+///
+/// 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`]($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
+/// 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 marker becomes
+/// part of that item.
+#[elem(scope, title = "Numbered List", Show)]
+pub struct EnumElem {
+ /// Defines the default [spacing]($enum.spacing) of the enumeration. If it
+ /// is `{false}`, the items are spaced apart with
+ /// [paragraph spacing]($par.spacing). If it is `{true}`, they use
+ /// [paragraph leading]($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}`. The markup-defined tightness cannot be
+ /// overridden with set rules.
+ ///
+ /// ```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]($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()))]
+ #[borrowed]
+ 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 ingredients
+ /// + 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 the enumeration.
+ ///
+ /// If set to `{auto}`, uses paragraph [`leading`]($par.leading) for tight
+ /// enumerations and paragraph [`spacing`]($par.spacing) for wide
+ /// (non-tight) enumerations.
+ pub spacing: Smart<Length>,
+
+ /// The alignment that enum numbers should have.
+ ///
+ /// By default, this is set to `{end + top}`, which aligns enum numbers
+ /// towards end of the current text direction (in left-to-right script,
+ /// for example, this is the same as `{right}`) and at the top of the line.
+ /// 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 behaviour, however. (Also to note is that the
+ /// [unordered list]($list) uses a different method for this, by giving the
+ /// `marker` content an alignment directly.).
+ ///
+ /// ````example
+ /// #set enum(number-align: start + bottom)
+ ///
+ /// Here are some powers of two:
+ /// 1. One
+ /// 2. Two
+ /// 4. Four
+ /// 8. Eight
+ /// 16. Sixteen
+ /// 32. Thirty two
+ /// ````
+ #[default(HAlignment::End + VAlignment::Top)]
+ pub number_align: Alignment,
+
+ /// 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<Packed<EnumItem>>,
+
+ /// The numbers of parent items.
+ #[internal]
+ #[fold]
+ #[ghost]
+ pub parents: SmallVec<[usize; 4]>,
+}
+
+#[scope]
+impl EnumElem {
+ #[elem]
+ type EnumItem;
+}
+
+impl Show for Packed<EnumElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized =
+ BlockElem::multi_layouter(self.clone(), engine.routines.layout_enum)
+ .pack()
+ .spanned(self.span());
+
+ if self.tight(styles) {
+ let leading = ParElem::leading_in(styles);
+ let spacing =
+ VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
+ realized = spacing + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+/// An enumeration item.
+#[elem(name = "item", title = "Numbered List Item")]
+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.unpack::<Self>().unwrap_or_else(Self::new),
+}
+
+impl ListLike for EnumElem {
+ type Item = EnumItem;
+
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self {
+ Self::new(children).with_tight(tight)
+ }
+}
+
+impl ListItemLike for EnumItem {
+ fn styled(mut item: Packed<Self>, styles: Styles) -> Packed<Self> {
+ item.body.style_in_place(styles);
+ item
+ }
+}
diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs
new file mode 100644
index 00000000..abdf2a4e
--- /dev/null
+++ b/crates/typst-library/src/model/figure.rs
@@ -0,0 +1,641 @@
+use std::borrow::Cow;
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use ecow::EcoString;
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, select_where, Content, Element, NativeElement, Packed, Selector,
+ Show, ShowSet, Smart, StyleChain, Styles, Synthesize,
+};
+use crate::introspection::{
+ Count, Counter, CounterKey, CounterUpdate, Locatable, Location,
+};
+use crate::layout::{
+ AlignElem, Alignment, BlockBody, BlockElem, Em, HAlignment, Length, OuterVAlignment,
+ PlaceElem, PlacementScope, VAlignment, VElem,
+};
+use crate::model::{Numbering, NumberingPattern, Outlinable, Refable, Supplement};
+use crate::text::{Lang, Region, TextElem};
+use crate::visualize::ImageElem;
+
+/// A figure with an optional caption.
+///
+/// Automatically detects its kind to select the correct counting track. For
+/// example, figures containing images will be numbered separately from figures
+/// containing tables.
+///
+/// # 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]($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.
+///
+/// # Figure behaviour
+/// By default, figures are placed within the flow of content. To make them
+/// float to the top or bottom of the page, you can use the
+/// [`placement`]($figure.placement) argument.
+///
+/// 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 itself
+/// breakable across pages as well with this show rule:
+/// ```typ
+/// #show figure: set block(breakable: true)
+/// ```
+///
+/// See the [block]($block.breakable) documentation for more information about
+/// breakable and non-breakable blocks.
+///
+/// # Caption customization
+/// You can modify the appearance of the figure's caption with its associated
+/// [`caption`]($figure.caption) function. In the example below, we emphasize
+/// all captions:
+///
+/// ```example
+/// #show figure.caption: emph
+///
+/// #figure(
+/// rect[Hello],
+/// caption: [I am emphasized!],
+/// )
+/// ```
+///
+/// By using a [`where`]($function.where) selector, we can scope such rules to
+/// specific kinds of figures. For example, to position the caption above
+/// tables, but keep it below for all other kinds of figures, we could write the
+/// following show-set rule:
+///
+/// ```example
+/// #show figure.where(
+/// kind: table
+/// ): set figure.caption(position: top)
+///
+/// #figure(
+/// table(columns: 2)[A][B][C][D],
+/// caption: [I'm up here],
+/// )
+/// ```
+#[elem(scope, Locatable, Synthesize, Count, Show, ShowSet, Refable, Outlinable)]
+pub struct FigureElem {
+ /// The content of the figure. Often, an [image].
+ #[required]
+ pub body: Content,
+
+ /// The figure's placement on the page.
+ ///
+ /// - `{none}`: The figure stays in-flow exactly where it was specified
+ /// like other content.
+ /// - `{auto}`: The figure picks `{top}` or `{bottom}` depending on which
+ /// is closer.
+ /// - `{top}`: The figure floats to the top of the page.
+ /// - `{bottom}`: The figure floats to the bottom of the page.
+ ///
+ /// The gap between the main flow content and the floating figure is
+ /// controlled by the [`clearance`]($place.clearance) argument on the
+ /// `place` function.
+ ///
+ /// ```example
+ /// #set page(height: 200pt)
+ ///
+ /// = Introduction
+ /// #figure(
+ /// placement: bottom,
+ /// caption: [A glacier],
+ /// image("glacier.jpg", width: 60%),
+ /// )
+ /// #lorem(60)
+ /// ```
+ pub placement: Option<Smart<VAlignment>>,
+
+ /// Relative to which containing scope the figure is placed.
+ ///
+ /// Set this to `{"parent"}` to create a full-width figure in a two-column
+ /// document.
+ ///
+ /// Has no effect if `placement` is `{none}`.
+ ///
+ /// ```example
+ /// #set page(height: 250pt, columns: 2)
+ ///
+ /// = Introduction
+ /// #figure(
+ /// placement: bottom,
+ /// scope: "parent",
+ /// caption: [A glacier],
+ /// image("glacier.jpg", width: 60%),
+ /// )
+ /// #lorem(60)
+ /// ```
+ pub scope: PlacementScope,
+
+ /// The figure's caption.
+ pub caption: Option<Packed<FigureCaption>>,
+
+ /// The kind of figure this is.
+ ///
+ /// All figures of the same kind share a common counter.
+ ///
+ /// If set to `{auto}`, the figure will try to automatically determine its
+ /// kind based on the type of its body. Automatically detected kinds are
+ /// [tables]($table) and [code]($raw). In other cases, the inferred kind is
+ /// that of an [image].
+ ///
+ /// 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], a [table] or [code]($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 other than [`{table}`]($table), [`{raw}`](raw)
+ /// or [`{image}`](image), you will need to manually specify the figure's
+ /// supplement.
+ ///
+ /// ```example
+ /// #figure(
+ /// circle(radius: 10pt),
+ /// caption: [A curious atom.],
+ /// kind: "atom",
+ /// supplement: [Atom],
+ /// )
+ /// ```
+ 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]($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",
+ /// )
+ /// ```
+ #[borrowed]
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// How to number the figure. Accepts a
+ /// [numbering pattern or function]($numbering).
+ #[default(Some(NumberingPattern::from_str("1").unwrap().into()))]
+ #[borrowed]
+ 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`] 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)[@table]: `{counter(figure.where(kind: table))}`
+ /// - For (images)[@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>,
+}
+
+#[scope]
+impl FigureElem {
+ #[elem]
+ type FigureCaption;
+}
+
+impl Synthesize for Packed<FigureElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let span = self.span();
+ let location = self.location();
+ let elem = self.as_mut();
+ let numbering = elem.numbering(styles);
+
+ // Determine the figure's kind.
+ let kind = elem.kind(styles).unwrap_or_else(|| {
+ elem.body()
+ .query_first(Selector::can::<dyn Figurable>())
+ .map(|elem| FigureKind::Elem(elem.func()))
+ .unwrap_or_else(|| FigureKind::Elem(ImageElem::elem()))
+ });
+
+ // Resolve the supplement.
+ let supplement = match elem.supplement(styles).as_ref() {
+ Smart::Auto => {
+ // Default to the local name for the kind, if available.
+ let name = match &kind {
+ FigureKind::Elem(func) => func
+ .local_name(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ )
+ .map(TextElem::packed),
+ FigureKind::Name(_) => None,
+ };
+
+ if numbering.is_some() && name.is_none() {
+ bail!(span, "please specify the figure's supplement")
+ }
+
+ Some(name.unwrap_or_default())
+ }
+ Smart::Custom(None) => None,
+ 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) => elem
+ .body()
+ .query_first(Selector::Elem(func, None))
+ .map(Cow::Owned),
+ FigureKind::Name(_) => None,
+ };
+
+ let target = descendant.unwrap_or_else(|| Cow::Borrowed(elem.body()));
+ Some(supplement.resolve(engine, styles, [target])?)
+ }
+ };
+
+ // Construct the figure's counter.
+ let counter = Counter::new(CounterKey::Selector(
+ select_where!(FigureElem, Kind => kind.clone()),
+ ));
+
+ // Fill the figure's caption.
+ let mut caption = elem.caption(styles);
+ if let Some(caption) = &mut caption {
+ caption.push_kind(kind.clone());
+ caption.push_supplement(supplement.clone());
+ caption.push_numbering(numbering.clone());
+ caption.push_counter(Some(counter.clone()));
+ caption.push_figure_location(location);
+ }
+
+ elem.push_kind(Smart::Custom(kind));
+ elem.push_supplement(Smart::Custom(supplement.map(Supplement::Content)));
+ elem.push_counter(Some(counter));
+ elem.push_caption(caption);
+
+ Ok(())
+ }
+}
+
+impl Show for Packed<FigureElem> {
+ #[typst_macros::time(name = "figure", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+
+ // Build the caption, if any.
+ if let Some(caption) = self.caption(styles) {
+ let v = VElem::new(self.gap(styles).into()).with_weak(true).pack();
+ realized = match caption.position(styles) {
+ OuterVAlignment::Top => caption.pack() + v + realized,
+ OuterVAlignment::Bottom => realized + v + caption.pack(),
+ };
+ }
+
+ // Wrap the contents in a block.
+ realized = BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(self.span());
+
+ // Wrap in a float.
+ if let Some(align) = self.placement(styles) {
+ realized = PlaceElem::new(realized)
+ .with_alignment(align.map(|align| HAlignment::Center + align))
+ .with_scope(self.scope(styles))
+ .with_float(true)
+ .pack()
+ .spanned(self.span());
+ } else if self.scope(styles) == PlacementScope::Parent {
+ bail!(
+ self.span(),
+ "parent-scoped placement is only available for floating figures";
+ hint: "you can enable floating placement with `figure(placement: auto, ..)`"
+ );
+ }
+
+ Ok(realized)
+ }
+}
+
+impl ShowSet for Packed<FigureElem> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ // Still allows breakable figures with
+ // `show figure: set block(breakable: true)`.
+ let mut map = Styles::new();
+ map.set(BlockElem::set_breakable(false));
+ map.set(AlignElem::set_alignment(Alignment::CENTER));
+ map
+ }
+}
+
+impl Count for Packed<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()
+ .is_some()
+ .then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+impl Refable for Packed<FigureElem> {
+ fn supplement(&self) -> Content {
+ // After synthesis, this should always be custom content.
+ match (**self).supplement(StyleChain::default()).as_ref() {
+ Smart::Custom(Some(Supplement::Content(content))) => content.clone(),
+ _ => Content::empty(),
+ }
+ }
+
+ fn counter(&self) -> Counter {
+ (**self)
+ .counter()
+ .cloned()
+ .flatten()
+ .unwrap_or_else(|| Counter::of(FigureElem::elem()))
+ }
+
+ fn numbering(&self) -> Option<&Numbering> {
+ (**self).numbering(StyleChain::default()).as_ref()
+ }
+}
+
+impl Outlinable for Packed<FigureElem> {
+ fn outline(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<Option<Content>> {
+ if !self.outlined(StyleChain::default()) {
+ return Ok(None);
+ }
+
+ let Some(caption) = self.caption(StyleChain::default()) else {
+ return Ok(None);
+ };
+
+ let mut realized = caption.body().clone();
+ if let (
+ Smart::Custom(Some(Supplement::Content(mut supplement))),
+ Some(Some(counter)),
+ Some(numbering),
+ ) = (
+ (**self).supplement(StyleChain::default()).clone(),
+ (**self).counter(),
+ self.numbering(),
+ ) {
+ let numbers = counter.display_at_loc(
+ engine,
+ self.location().unwrap(),
+ styles,
+ numbering,
+ )?;
+
+ if !supplement.is_empty() {
+ supplement += TextElem::packed('\u{a0}');
+ }
+
+ let separator = caption.get_separator(StyleChain::default());
+
+ realized = supplement + numbers + separator + caption.body();
+ }
+
+ Ok(Some(realized))
+ }
+}
+
+/// The caption of a figure. This element can be used in set and show rules to
+/// customize the appearance of captions for all figures or figures of a
+/// specific kind.
+///
+/// In addition to its `pos` and `body`, the `caption` also provides the
+/// figure's `kind`, `supplement`, `counter`, and `numbering` as fields. These
+/// parts can be used in [`where`]($function.where) selectors and show rules to
+/// build a completely custom caption.
+///
+/// ```example
+/// #show figure.caption: emph
+///
+/// #figure(
+/// rect[Hello],
+/// caption: [A rectangle],
+/// )
+/// ```
+#[elem(name = "caption", Synthesize, Show)]
+pub struct FigureCaption {
+ /// The caption's position in the figure. Either `{top}` or `{bottom}`.
+ ///
+ /// ```example
+ /// #show figure.where(
+ /// kind: table
+ /// ): set figure.caption(position: top)
+ ///
+ /// #figure(
+ /// table(columns: 2)[A][B],
+ /// caption: [I'm up here],
+ /// )
+ ///
+ /// #figure(
+ /// rect[Hi],
+ /// caption: [I'm down here],
+ /// )
+ ///
+ /// #figure(
+ /// table(columns: 2)[A][B],
+ /// caption: figure.caption(
+ /// position: bottom,
+ /// [I'm down here too!]
+ /// )
+ /// )
+ /// ```
+ #[default(OuterVAlignment::Bottom)]
+ pub position: OuterVAlignment,
+
+ /// The separator which will appear between the number and body.
+ ///
+ /// If set to `{auto}`, the separator will be adapted to the current
+ /// [language]($text.lang) and [region]($text.region).
+ ///
+ /// ```example
+ /// #set figure.caption(separator: [ --- ])
+ ///
+ /// #figure(
+ /// rect[Hello],
+ /// caption: [A rectangle],
+ /// )
+ /// ```
+ pub separator: Smart<Content>,
+
+ /// The caption's body.
+ ///
+ /// Can be used alongside `kind`, `supplement`, `counter`, `numbering`, and
+ /// `location` to completely customize the caption.
+ ///
+ /// ```example
+ /// #show figure.caption: it => [
+ /// #underline(it.body) |
+ /// #it.supplement
+ /// #context it.counter.display(it.numbering)
+ /// ]
+ ///
+ /// #figure(
+ /// rect[Hello],
+ /// caption: [A rectangle],
+ /// )
+ /// ```
+ #[required]
+ pub body: Content,
+
+ /// The figure's supplement.
+ #[synthesized]
+ pub kind: FigureKind,
+
+ /// The figure's supplement.
+ #[synthesized]
+ pub supplement: Option<Content>,
+
+ /// How to number the figure.
+ #[synthesized]
+ pub numbering: Option<Numbering>,
+
+ /// The counter for the figure.
+ #[synthesized]
+ pub counter: Option<Counter>,
+
+ /// The figure's location.
+ #[internal]
+ #[synthesized]
+ pub figure_location: Option<Location>,
+}
+
+impl FigureCaption {
+ /// Gets the default separator in the given language and (optionally)
+ /// region.
+ fn local_separator(lang: Lang, _: Option<Region>) -> &'static str {
+ match lang {
+ Lang::CHINESE => "\u{2003}",
+ Lang::FRENCH => ".\u{a0}– ",
+ Lang::RUSSIAN => ". ",
+ Lang::ENGLISH | _ => ": ",
+ }
+ }
+
+ fn get_separator(&self, styles: StyleChain) -> Content {
+ self.separator(styles).unwrap_or_else(|| {
+ TextElem::packed(Self::local_separator(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ ))
+ })
+ }
+}
+
+impl Synthesize for Packed<FigureCaption> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let elem = self.as_mut();
+ elem.push_separator(Smart::Custom(elem.get_separator(styles)));
+ Ok(())
+ }
+}
+
+impl Show for Packed<FigureCaption> {
+ #[typst_macros::time(name = "figure.caption", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+
+ if let (
+ Some(Some(mut supplement)),
+ Some(Some(numbering)),
+ Some(Some(counter)),
+ Some(Some(location)),
+ ) = (
+ self.supplement().cloned(),
+ self.numbering(),
+ self.counter(),
+ self.figure_location(),
+ ) {
+ let numbers = counter.display_at_loc(engine, *location, styles, numbering)?;
+ if !supplement.is_empty() {
+ supplement += TextElem::packed('\u{a0}');
+ }
+ realized = supplement + numbers + self.get_separator(styles) + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+cast! {
+ FigureCaption,
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::new),
+}
+
+/// The `kind` parameter of a [`FigureElem`].
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum FigureKind {
+ /// The kind is an element function.
+ Elem(Element),
+ /// 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: Element => 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 {}
diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs
new file mode 100644
index 00000000..d9971dd1
--- /dev/null
+++ b/crates/typst-library/src/model/footnote.rs
@@ -0,0 +1,326 @@
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Content, Label, NativeElement, Packed, Show, ShowSet, Smart,
+ StyleChain, Styles,
+};
+use crate::introspection::{Count, Counter, CounterUpdate, Locatable, Location};
+use crate::layout::{Abs, Em, HElem, Length, Ratio};
+use crate::model::{Destination, Numbering, NumberingPattern, ParElem};
+use crate::text::{SuperElem, TextElem, TextSize};
+use crate::visualize::{LineElem, Stroke};
+
+/// 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`]($footnote.entry). The footnote itself is realized as a
+/// normal superscript, so you can use a set rule on the [`super`] function to
+/// customize it. You can also apply a show rule to customize only the footnote
+/// marker (superscript number) in the running text.
+///
+/// # 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]($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] for more information.
+///
+/// [issue]: https://github.com/typst/typst/issues/1467#issuecomment-1588799440
+#[elem(scope, Locatable, Show, Count)]
+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] in the page [header]($page.header). In the future, there might
+ /// be a simpler way to achieve this.
+ ///
+ /// ```example
+ /// #set footnote(numbering: "*")
+ ///
+ /// Footnotes:
+ /// #footnote[Star],
+ /// #footnote[Dagger]
+ /// ```
+ #[borrowed]
+ #[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,
+}
+
+#[scope]
+impl FootnoteElem {
+ #[elem]
+ type FootnoteEntry;
+}
+
+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))
+ }
+
+ /// Creates a new footnote referencing the footnote with the specified label,
+ /// with the other fields from the current footnote cloned.
+ pub fn into_ref(&self, label: Label) -> Self {
+ Self {
+ body: FootnoteBody::Reference(label),
+ ..self.clone()
+ }
+ }
+
+ /// 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,
+ }
+ }
+}
+
+impl Packed<FootnoteElem> {
+ /// Returns the location of the definition of this footnote.
+ pub fn declaration_location(&self, engine: &Engine) -> StrResult<Location> {
+ match self.body() {
+ FootnoteBody::Reference(label) => {
+ let element = engine.introspector.query_label(*label)?;
+ let footnote = element
+ .to_packed::<FootnoteElem>()
+ .ok_or("referenced element should be a footnote")?;
+ if self.location() == footnote.location() {
+ bail!("footnote cannot reference itself");
+ }
+ footnote.declaration_location(engine)
+ }
+ _ => Ok(self.location().unwrap()),
+ }
+ }
+}
+
+impl Show for Packed<FootnoteElem> {
+ #[typst_macros::time(name = "footnote", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let span = self.span();
+ let loc = self.declaration_location(engine).at(span)?;
+ let numbering = self.numbering(styles);
+ let counter = Counter::of(FootnoteElem::elem());
+ let num = counter.display_at_loc(engine, loc, styles, numbering)?;
+ let sup = SuperElem::new(num).pack().spanned(span);
+ let loc = loc.variant(1);
+ // Add zero-width weak spacing to make the footnote "sticky".
+ Ok(HElem::hole().pack() + sup.linked(Destination::Location(loc)))
+ }
+}
+
+impl Count for Packed<FootnoteElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ (!self.is_ref()).then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+/// The body of a footnote can be either some content or a label referencing
+/// another footnote.
+#[derive(Debug, Clone, PartialEq, Hash)]
+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),
+}
+
+/// 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
+/// #show footnote.entry: set text(red)
+///
+/// My footnote listing
+/// #footnote[It's down here]
+/// has red text!
+/// ```
+///
+/// _Note:_ Footnote entry properties must be uniform across each page run (a
+/// page run is a sequence of pages without an explicit pagebreak in between).
+/// For this reason, set and show rules for footnote entries should be defined
+/// before any page content, typically at the very start of the document.
+#[elem(name = "entry", title = "Footnote Entry", Show, ShowSet)]
+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: Packed<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(Stroke {
+ 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 Packed<FootnoteEntry> {
+ #[typst_macros::time(name = "footnote.entry", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let span = self.span();
+ let note = self.note();
+ let number_gap = Em::new(0.05);
+ let default = StyleChain::default();
+ let numbering = note.numbering(default);
+ let counter = Counter::of(FootnoteElem::elem());
+ let Some(loc) = note.location() else {
+ bail!(
+ span, "footnote entry must have a location";
+ hint: "try using a query or a show rule to customize the footnote instead"
+ );
+ };
+
+ let num = counter.display_at_loc(engine, loc, styles, numbering)?;
+ let sup = SuperElem::new(num)
+ .pack()
+ .spanned(span)
+ .linked(Destination::Location(loc))
+ .located(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().clone(),
+ ]))
+ }
+}
+
+impl ShowSet for Packed<FootnoteEntry> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ let text_size = Em::new(0.85);
+ let leading = Em::new(0.5);
+ let mut out = Styles::new();
+ out.set(ParElem::set_leading(leading.into()));
+ out.set(TextElem::set_size(TextSize(text_size.into())));
+ out
+ }
+}
+
+cast! {
+ FootnoteElem,
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::with_content)
+}
diff --git a/crates/typst-library/src/model/heading.rs b/crates/typst-library/src/model/heading.rs
new file mode 100644
index 00000000..269e95eb
--- /dev/null
+++ b/crates/typst-library/src/model/heading.rs
@@ -0,0 +1,351 @@
+use std::num::NonZeroUsize;
+
+use typst_utils::NonZeroExt;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, Content, NativeElement, Packed, Resolve, Show, ShowSet, Smart, StyleChain,
+ Styles, Synthesize,
+};
+use crate::introspection::{
+ Count, Counter, CounterUpdate, Locatable, Locator, LocatorLink,
+};
+use crate::layout::{Abs, Axes, BlockBody, BlockElem, Em, HElem, Length, Region};
+use crate::model::{Numbering, Outlinable, ParElem, Refable, Supplement};
+use crate::text::{FontWeight, LocalName, 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]($numbering).
+///
+/// Independently of the numbering, Typst can also automatically generate an
+/// [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
+/// #set heading(numbering: "1.a)")
+///
+/// = Introduction
+/// In recent years, ...
+///
+/// == Preliminaries
+/// To start, ...
+/// ```
+///
+/// # 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. The `{offset}` field
+/// can be set to configure the starting depth.
+#[elem(Locatable, Synthesize, Count, Show, ShowSet, LocalName, Refable, Outlinable)]
+pub struct HeadingElem {
+ /// The absolute nesting depth of the heading, starting from one. If set
+ /// to `{auto}`, it is computed from `{offset + depth}`.
+ ///
+ /// This is primarily useful for usage in [show rules]($styling/#show-rules)
+ /// (either with [`where`]($function.where) selectors or by accessing the
+ /// level directly on a shown heading).
+ ///
+ /// ```example
+ /// #show heading.where(level: 2): set text(red)
+ ///
+ /// = Level 1
+ /// == Level 2
+ ///
+ /// #set heading(offset: 1)
+ /// = Also level 2
+ /// == Level 3
+ /// ```
+ pub level: Smart<NonZeroUsize>,
+
+ /// The relative nesting depth of the heading, starting from one. This is
+ /// combined with `{offset}` to compute the actual `{level}`.
+ ///
+ /// This is set by the heading syntax, such that `[== Heading]` creates a
+ /// heading with logical depth of 2, but actual level `{offset + 2}`. If you
+ /// construct a heading manually, you should typically prefer this over
+ /// setting the absolute level.
+ #[default(NonZeroUsize::ONE)]
+ pub depth: NonZeroUsize,
+
+ /// The starting offset of each heading's `{level}`, used to turn its
+ /// relative `{depth}` into its absolute `{level}`.
+ ///
+ /// ```example
+ /// = Level 1
+ ///
+ /// #set heading(offset: 1, numbering: "1.1")
+ /// = Level 2
+ ///
+ /// #heading(offset: 2, depth: 2)[
+ /// I'm level 4
+ /// ]
+ /// ```
+ #[default(0)]
+ pub offset: usize,
+
+ /// How to number the heading. Accepts a
+ /// [numbering pattern or function]($numbering).
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.a.")
+ ///
+ /// = A section
+ /// == A subsection
+ /// === A sub-subsection
+ /// ```
+ #[borrowed]
+ 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].
+ ///
+ /// Note that this property, if set to `{true}`, ensures the heading is also
+ /// shown as a bookmark in the exported PDF's outline (when exporting to
+ /// PDF). To change that behavior, use the `bookmarked` property.
+ ///
+ /// ```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,
+
+ /// Whether the heading should appear as a bookmark in the exported PDF's
+ /// outline. Doesn't affect other export formats, such as PNG.
+ ///
+ /// The default value of `{auto}` indicates that the heading will only
+ /// appear in the exported PDF's outline if its `outlined` property is set
+ /// to `{true}`, that is, if it would also be listed in Typst's [outline].
+ /// Setting this property to either `{true}` (bookmark) or `{false}` (don't
+ /// bookmark) bypasses that behavior.
+ ///
+ /// ```example
+ /// #heading[Normal heading]
+ /// This heading will be shown in
+ /// the PDF's bookmark outline.
+ ///
+ /// #heading(bookmarked: false)[Not bookmarked]
+ /// This heading won't be
+ /// bookmarked in the resulting
+ /// PDF.
+ /// ```
+ #[default(Smart::Auto)]
+ pub bookmarked: Smart<bool>,
+
+ /// The indent all but the first line of a heading should have.
+ ///
+ /// The default value of `{auto}` indicates that the subsequent heading
+ /// lines will be indented based on the width of the numbering.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.")
+ /// #heading[A very, very, very, very, very, very long heading]
+ /// ```
+ #[default(Smart::Auto)]
+ pub hanging_indent: Smart<Length>,
+
+ /// The heading's title.
+ #[required]
+ pub body: Content,
+}
+
+impl HeadingElem {
+ pub fn resolve_level(&self, styles: StyleChain) -> NonZeroUsize {
+ self.level(styles).unwrap_or_else(|| {
+ NonZeroUsize::new(self.offset(styles) + self.depth(styles).get())
+ .expect("overflow to 0 on NoneZeroUsize + usize")
+ })
+ }
+}
+
+impl Synthesize for Packed<HeadingElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ 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(engine, styles, [self.clone().pack()])?
+ }
+ };
+
+ let elem = self.as_mut();
+ elem.push_level(Smart::Custom(elem.resolve_level(styles)));
+ elem.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
+ Ok(())
+ }
+}
+
+impl Show for Packed<HeadingElem> {
+ #[typst_macros::time(name = "heading", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ const SPACING_TO_NUMBERING: Em = Em::new(0.3);
+
+ let span = self.span();
+ let mut realized = self.body().clone();
+
+ let hanging_indent = self.hanging_indent(styles);
+ let mut indent = match hanging_indent {
+ Smart::Custom(length) => length.resolve(styles),
+ Smart::Auto => Abs::zero(),
+ };
+
+ if let Some(numbering) = (**self).numbering(styles).as_ref() {
+ let location = self.location().unwrap();
+ let numbering = Counter::of(HeadingElem::elem())
+ .display_at_loc(engine, location, styles, numbering)?
+ .spanned(span);
+
+ if hanging_indent.is_auto() {
+ let pod = Region::new(Axes::splat(Abs::inf()), Axes::splat(false));
+
+ // We don't have a locator for the numbering here, so we just
+ // use the measurement infrastructure for now.
+ let link = LocatorLink::measure(location);
+ let size = (engine.routines.layout_frame)(
+ engine,
+ &numbering,
+ Locator::link(&link),
+ styles,
+ pod,
+ )?
+ .size();
+
+ indent = size.x + SPACING_TO_NUMBERING.resolve(styles);
+ }
+
+ realized = numbering
+ + HElem::new(SPACING_TO_NUMBERING.into()).with_weak(true).pack()
+ + realized;
+ }
+
+ if indent != Abs::zero() {
+ realized = realized.styled(ParElem::set_hanging_indent(indent.into()));
+ }
+
+ Ok(BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(span))
+ }
+}
+
+impl ShowSet for Packed<HeadingElem> {
+ fn show_set(&self, styles: StyleChain) -> Styles {
+ let level = (**self).resolve_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 out = Styles::new();
+ out.set(TextElem::set_size(TextSize(size.into())));
+ out.set(TextElem::set_weight(FontWeight::BOLD));
+ out.set(BlockElem::set_above(Smart::Custom(above.into())));
+ out.set(BlockElem::set_below(Smart::Custom(below.into())));
+ out.set(BlockElem::set_sticky(true));
+ out
+ }
+}
+
+impl Count for Packed<HeadingElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ (**self)
+ .numbering(StyleChain::default())
+ .is_some()
+ .then(|| CounterUpdate::Step((**self).resolve_level(StyleChain::default())))
+ }
+}
+
+impl Refable for Packed<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(HeadingElem::elem())
+ }
+
+ fn numbering(&self) -> Option<&Numbering> {
+ (**self).numbering(StyleChain::default()).as_ref()
+ }
+}
+
+impl Outlinable for Packed<HeadingElem> {
+ fn outline(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<Option<Content>> {
+ if !self.outlined(StyleChain::default()) {
+ return Ok(None);
+ }
+
+ let mut content = self.body().clone();
+ if let Some(numbering) = (**self).numbering(StyleChain::default()).as_ref() {
+ let numbers = Counter::of(HeadingElem::elem()).display_at_loc(
+ engine,
+ self.location().unwrap(),
+ styles,
+ numbering,
+ )?;
+ content = numbers + SpaceElem::shared().clone() + content;
+ };
+
+ Ok(Some(content))
+ }
+
+ fn level(&self) -> NonZeroUsize {
+ (**self).resolve_level(StyleChain::default())
+ }
+}
+
+impl LocalName for Packed<HeadingElem> {
+ const KEY: &'static str = "heading";
+}
diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs
new file mode 100644
index 00000000..31c65a1d
--- /dev/null
+++ b/crates/typst-library/src/model/link.rs
@@ -0,0 +1,212 @@
+use std::ops::Deref;
+
+use ecow::{eco_format, EcoString};
+use smallvec::SmallVec;
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, Label, Packed, Repr, Show, Smart, StyleChain,
+};
+use crate::introspection::Location;
+use crate::layout::Position;
+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
+/// #show link: underline
+///
+/// https://example.com \
+///
+/// #link("https://example.com") \
+/// #link("https://example.com")[
+/// See example.com
+/// ]
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: Text that starts with `http://` or
+/// `https://` is automatically turned into a link.
+#[elem(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] attached to an element. If you also want automatic text
+ /// for the link based on the element, consider using a
+ /// [reference]($ref) instead.
+ ///
+ /// - A [`location`] (typically retrieved from [`here`], [`locate`] or
+ /// [`query`]).
+ ///
+ /// - A dictionary with a `page` key of type [integer]($int) and `x` and
+ /// `y` coordinates of 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,
+
+ /// This style is set on the content contained in the `link` element.
+ #[internal]
+ #[ghost]
+ pub dests: SmallVec<[Destination; 1]>,
+}
+
+impl LinkElem {
+ /// Create a link element from a URL with its bare text.
+ pub fn from_url(url: Url) -> Self {
+ let body = body_from_url(&url);
+ Self::new(LinkTarget::Dest(Destination::Url(url)), body)
+ }
+}
+
+impl Show for Packed<LinkElem> {
+ #[typst_macros::time(name = "link", span = self.span())]
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ let body = self.body().clone();
+ let linked = match self.dest() {
+ LinkTarget::Dest(dest) => body.linked(dest.clone()),
+ LinkTarget::Label(label) => {
+ let elem = engine.introspector.query_label(*label).at(self.span())?;
+ let dest = Destination::Location(elem.location().unwrap());
+ body.clone().linked(dest)
+ }
+ };
+
+ Ok(linked.styled(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))))
+ }
+}
+
+fn body_from_url(url: &Url) -> 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, PartialEq, Hash)]
+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)
+ }
+}
+
+/// A link destination.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum Destination {
+ /// A link to a URL.
+ Url(Url),
+ /// A link to a point on a page.
+ Position(Position),
+ /// An unresolved link to a location in the document.
+ Location(Location),
+}
+
+impl Destination {}
+
+impl Repr for Destination {
+ fn repr(&self) -> EcoString {
+ eco_format!("{self:?}")
+ }
+}
+
+cast! {
+ Destination,
+ self => match self {
+ Self::Url(v) => v.into_value(),
+ Self::Position(v) => v.into_value(),
+ Self::Location(v) => v.into_value(),
+ },
+ v: Url => Self::Url(v),
+ v: Position => Self::Position(v),
+ v: Location => Self::Location(v),
+}
+
+/// A uniform resource locator with a maximum length.
+#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Url(EcoString);
+
+impl Url {
+ /// Create a URL from a string, checking the maximum length.
+ pub fn new(url: impl Into<EcoString>) -> StrResult<Self> {
+ let url = url.into();
+ if url.len() > 8000 {
+ bail!("URL is too long")
+ }
+ Ok(Self(url))
+ }
+
+ /// Extract the underlying [`EcoString`].
+ pub fn into_inner(self) -> EcoString {
+ self.0
+ }
+}
+
+impl Deref for Url {
+ type Target = EcoString;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+cast! {
+ Url,
+ self => self.0.into_value(),
+ v: EcoString => Self::new(v)?,
+}
diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs
new file mode 100644
index 00000000..18bddd10
--- /dev/null
+++ b/crates/typst-library/src/model/list.rs
@@ -0,0 +1,246 @@
+use comemo::Track;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Array, Content, Context, Depth, Func, NativeElement, Packed, Show,
+ Smart, StyleChain, Styles, Value,
+};
+use crate::layout::{BlockElem, Em, Length, VElem};
+use crate::model::ParElem;
+use crate::text::TextElem;
+
+/// A bullet list.
+///
+/// Displays a sequence of items vertically, with each item introduced by a
+/// marker.
+///
+/// # Example
+/// ```example
+/// Normal list.
+/// - Text
+/// - Math
+/// - Layout
+/// - ...
+///
+/// Multiple lines.
+/// - This list item spans multiple
+/// lines because it is indented.
+///
+/// Function call.
+/// #list(
+/// [Foundations],
+/// [Calculate],
+/// [Construct],
+/// [Data Loading],
+/// )
+/// ```
+///
+/// # 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 marker becomes part of that item.
+#[elem(scope, title = "Bullet List", Show)]
+pub struct ListElem {
+ /// Defines the default [spacing]($list.spacing) of the list. If it is
+ /// `{false}`, the items are spaced apart with
+ /// [paragraph spacing]($par.spacing). If it is `{true}`, they use
+ /// [paragraph leading]($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}`. The markup-defined tightness cannot be
+ /// overridden with set rules.
+ ///
+ /// ```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 markers are cycled. 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
+ /// ```
+ #[borrowed]
+ #[default(ListMarker::Content(vec![
+ // These are all available in the default font, vertically centered, and
+ // roughly of the same size (with the last one having slightly lower
+ // weight because it is not filled).
+ TextElem::packed('\u{2022}'), // Bullet
+ TextElem::packed('\u{2023}'), // Triangular Bullet
+ TextElem::packed('\u{2013}'), // En-dash
+ ]))]
+ 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 the list.
+ ///
+ /// If set to `{auto}`, uses paragraph [`leading`]($par.leading) for tight
+ /// lists and paragraph [`spacing`]($par.spacing) for wide (non-tight)
+ /// lists.
+ pub spacing: Smart<Length>,
+
+ /// 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<Packed<ListItem>>,
+
+ /// The nesting depth.
+ #[internal]
+ #[fold]
+ #[ghost]
+ pub depth: Depth,
+}
+
+#[scope]
+impl ListElem {
+ #[elem]
+ type ListItem;
+}
+
+impl Show for Packed<ListElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized =
+ BlockElem::multi_layouter(self.clone(), engine.routines.layout_list)
+ .pack()
+ .spanned(self.span());
+
+ if self.tight(styles) {
+ let leading = ParElem::leading_in(styles);
+ let spacing =
+ VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
+ realized = spacing + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+/// A bullet list item.
+#[elem(name = "item", title = "Bullet List Item")]
+pub struct ListItem {
+ /// The item's body.
+ #[required]
+ pub body: Content,
+}
+
+cast! {
+ ListItem,
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::new)
+}
+
+/// A list's marker.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum ListMarker {
+ Content(Vec<Content>),
+ Func(Func),
+}
+
+impl ListMarker {
+ /// Resolve the marker for the given depth.
+ pub fn resolve(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ depth: usize,
+ ) -> SourceResult<Content> {
+ Ok(match self {
+ Self::Content(list) => {
+ list.get(depth % list.len()).cloned().unwrap_or_default()
+ }
+ Self::Func(func) => func
+ .call(engine, Context::new(None, Some(styles)).track(), [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),
+}
+
+/// A list, enum, or term list.
+pub trait ListLike: NativeElement {
+ /// The kind of list item this list is composed of.
+ type Item: ListItemLike;
+
+ /// Create this kind of list from its children and tightness.
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self;
+}
+
+/// A list item, enum item, or term list item.
+pub trait ListItemLike: NativeElement {
+ /// Apply styles to the element's body.
+ fn styled(item: Packed<Self>, styles: Styles) -> Packed<Self>;
+}
+
+impl ListLike for ListElem {
+ type Item = ListItem;
+
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self {
+ Self::new(children).with_tight(tight)
+ }
+}
+
+impl ListItemLike for ListItem {
+ fn styled(mut item: Packed<Self>, styles: Styles) -> Packed<Self> {
+ item.body.style_in_place(styles);
+ item
+ }
+}
diff --git a/crates/typst-library/src/model/mod.rs b/crates/typst-library/src/model/mod.rs
new file mode 100644
index 00000000..7dad51c3
--- /dev/null
+++ b/crates/typst-library/src/model/mod.rs
@@ -0,0 +1,75 @@
+//! Structuring elements that define the document model.
+
+mod bibliography;
+mod cite;
+mod document;
+mod emph;
+#[path = "enum.rs"]
+mod enum_;
+mod figure;
+mod footnote;
+mod heading;
+mod link;
+mod list;
+#[path = "numbering.rs"]
+mod numbering_;
+mod outline;
+mod par;
+mod quote;
+mod reference;
+mod strong;
+mod table;
+mod terms;
+
+pub use self::bibliography::*;
+pub use self::cite::*;
+pub use self::document::*;
+pub use self::emph::*;
+pub use self::enum_::*;
+pub use self::figure::*;
+pub use self::footnote::*;
+pub use self::heading::*;
+pub use self::link::*;
+pub use self::list::*;
+pub use self::numbering_::*;
+pub use self::outline::*;
+pub use self::par::*;
+pub use self::quote::*;
+pub use self::reference::*;
+pub use self::strong::*;
+pub use self::table::*;
+pub use self::terms::*;
+
+use crate::foundations::{category, Category, Scope};
+
+/// Document structuring.
+///
+/// Here, you can find functions to structure your document and interact with
+/// that structure. This includes section headings, figures, bibliography
+/// management, cross-referencing and more.
+#[category]
+pub static MODEL: Category;
+
+/// Hook up all `model` definitions.
+pub fn define(global: &mut Scope) {
+ global.category(MODEL);
+ global.define_elem::<DocumentElem>();
+ global.define_elem::<RefElem>();
+ global.define_elem::<LinkElem>();
+ global.define_elem::<OutlineElem>();
+ global.define_elem::<HeadingElem>();
+ global.define_elem::<FigureElem>();
+ global.define_elem::<FootnoteElem>();
+ global.define_elem::<QuoteElem>();
+ global.define_elem::<CiteElem>();
+ global.define_elem::<BibliographyElem>();
+ global.define_elem::<EnumElem>();
+ global.define_elem::<ListElem>();
+ global.define_elem::<ParbreakElem>();
+ global.define_elem::<ParElem>();
+ global.define_elem::<TableElem>();
+ global.define_elem::<TermsElem>();
+ global.define_elem::<EmphElem>();
+ global.define_elem::<StrongElem>();
+ global.define_func::<numbering>();
+}
diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs
new file mode 100644
index 00000000..f0aa06e5
--- /dev/null
+++ b/crates/typst-library/src/model/numbering.rs
@@ -0,0 +1,658 @@
+use std::str::FromStr;
+
+use chinese_number::{
+ from_usize_to_chinese_ten_thousand as usize_to_chinese, ChineseCase, ChineseVariant,
+};
+use comemo::Tracked;
+use ecow::{eco_format, EcoString, EcoVec};
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{cast, func, Context, Func, Str, Value};
+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
+/// #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,
+/// )
+/// ```
+///
+/// # Numbering patterns and numbering functions
+/// There are multiple instances where you can provide a numbering pattern or
+/// function in Typst. For example, when defining how to number
+/// [headings]($heading) or [figures]($figure). Every time, the expected format
+/// is the same as the one described below for the
+/// [`numbering`]($numbering.numbering) parameter.
+///
+/// The following example illustrates that a numbering function is just a
+/// regular [function] that accepts numbers and returns [`content`].
+/// ```example
+/// #let unary(.., last) = "|" * last
+/// #set heading(numbering: unary)
+/// = First heading
+/// = Second heading
+/// = Third heading
+/// ```
+#[func]
+pub fn numbering(
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// Defines how the numbering works.
+ ///
+ /// **Counting symbols** are `1`, `a`, `A`, `i`, `I`, `一`, `壹`, `あ`, `い`,
+ /// `ア`, `イ`, `א`, `가`, `ㄱ`, `*`, `①`, and `⓵`. They are replaced by the
+ /// number in the sequence, preserving the original case.
+ ///
+ /// The `*` character means that symbols should be used to count, in the
+ /// order of `*`, `†`, `‡`, `§`, `¶`, `‖`. If there are more than six
+ /// items, the number is represented using repeated 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>,
+) -> SourceResult<Value> {
+ numbering.apply(engine, context, &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(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ numbers: &[usize],
+ ) -> SourceResult<Value> {
+ Ok(match self {
+ Self::Pattern(pattern) => Value::Str(pattern.apply(numbers).into()),
+ Self::Func(func) => func.call(engine, context, 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 {
+ pub pieces: EcoVec<(EcoString, NumberingKind)>,
+ pub 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), &n)) in self.pieces.iter().zip(&mut numbers).enumerate()
+ {
+ if i > 0 || !self.trimmed {
+ fmt.push_str(prefix);
+ }
+ fmt.push_str(&kind.apply(n));
+ }
+
+ for ((prefix, kind), &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));
+ }
+
+ 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)) = self
+ .pieces
+ .iter()
+ .chain(self.pieces.last().into_iter().cycle())
+ .nth(k)
+ {
+ fmt.push_str(&kind.apply(number));
+ }
+ 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) else {
+ continue;
+ };
+
+ let prefix = pattern[handled..i].into();
+ pieces.push((prefix, kind));
+ 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) in &self.pieces {
+ pat.push_str(prefix);
+ pat.push(kind.to_char());
+ }
+ pat.push_str(&self.suffix);
+ pat.into_value()
+ },
+ v: Str => v.parse()?,
+}
+
+/// Different kinds of numberings.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum NumberingKind {
+ /// Arabic numerals (1, 2, 3, etc.).
+ Arabic,
+ /// Lowercase Latin letters (a, b, c, etc.). Items beyond z use base-26.
+ LowerLatin,
+ /// Uppercase Latin letters (A, B, C, etc.). Items beyond Z use base-26.
+ UpperLatin,
+ /// Lowercase Roman numerals (i, ii, iii, etc.).
+ LowerRoman,
+ /// Uppercase Roman numerals (I, II, III, etc.).
+ UpperRoman,
+ /// Paragraph/note-like symbols: *, †, ‡, §, ¶, and ‖. Further items use repeated symbols.
+ Symbol,
+ /// Hebrew numerals, including Geresh/Gershayim.
+ Hebrew,
+ /// Simplified Chinese standard numerals. This corresponds to the
+ /// `ChineseCase::Lower` variant.
+ LowerSimplifiedChinese,
+ /// Simplified Chinese "banknote" numerals. This corresponds to the
+ /// `ChineseCase::Upper` variant.
+ UpperSimplifiedChinese,
+ // TODO: Pick the numbering pattern based on languages choice.
+ // As the first character of Simplified and Traditional Chinese numbering
+ // are the same, we are unable to determine if the context requires
+ // Simplified or Traditional by only looking at this character.
+ #[allow(unused)]
+ /// Traditional Chinese standard numerals. This corresponds to the
+ /// `ChineseCase::Lower` variant.
+ LowerTraditionalChinese,
+ #[allow(unused)]
+ /// Traditional Chinese "banknote" numerals. This corresponds to the
+ /// `ChineseCase::Upper` variant.
+ UpperTraditionalChinese,
+ /// Hiragana in the gojūon order. Includes n but excludes wi and we.
+ HiraganaAiueo,
+ /// Hiragana in the iroha order. Includes wi and we but excludes n.
+ HiraganaIroha,
+ /// Katakana in the gojūon order. Includes n but excludes wi and we.
+ KatakanaAiueo,
+ /// Katakana in the iroha order. Includes wi and we but excludes n.
+ KatakanaIroha,
+ /// Korean jamo (ㄱ, ㄴ, ㄷ, etc.).
+ KoreanJamo,
+ /// Korean syllables (가, 나, 다, etc.).
+ KoreanSyllable,
+ /// Eastern Arabic numerals, used in some Arabic-speaking countries.
+ EasternArabic,
+ /// The variant of Eastern Arabic numerals used in Persian and Urdu.
+ EasternArabicPersian,
+ /// Devanagari numerals.
+ DevanagariNumber,
+ /// Bengali numerals.
+ BengaliNumber,
+ /// Bengali letters (ক, খ, গ, ...কক, কখ etc.).
+ BengaliLetter,
+ /// Circled numbers (①, ②, ③, etc.), up to 50.
+ CircledNumber,
+ /// Double-circled numbers (⓵, ⓶, ⓷, etc.), up to 10.
+ DoubleCircledNumber,
+}
+
+impl NumberingKind {
+ /// Create a numbering kind from a representative character.
+ pub fn from_char(c: char) -> Option<Self> {
+ Some(match c {
+ '1' => NumberingKind::Arabic,
+ 'a' => NumberingKind::LowerLatin,
+ 'A' => NumberingKind::UpperLatin,
+ 'i' => NumberingKind::LowerRoman,
+ 'I' => NumberingKind::UpperRoman,
+ '*' => NumberingKind::Symbol,
+ 'א' => NumberingKind::Hebrew,
+ '一' => NumberingKind::LowerSimplifiedChinese,
+ '壹' => NumberingKind::UpperSimplifiedChinese,
+ 'あ' => NumberingKind::HiraganaAiueo,
+ 'い' => NumberingKind::HiraganaIroha,
+ 'ア' => NumberingKind::KatakanaAiueo,
+ 'イ' => NumberingKind::KatakanaIroha,
+ 'ㄱ' => NumberingKind::KoreanJamo,
+ '가' => NumberingKind::KoreanSyllable,
+ '\u{0661}' => NumberingKind::EasternArabic,
+ '\u{06F1}' => NumberingKind::EasternArabicPersian,
+ '\u{0967}' => NumberingKind::DevanagariNumber,
+ '\u{09E7}' => NumberingKind::BengaliNumber,
+ '\u{0995}' => NumberingKind::BengaliLetter,
+ '①' => NumberingKind::CircledNumber,
+ '⓵' => NumberingKind::DoubleCircledNumber,
+ _ => return None,
+ })
+ }
+
+ /// The representative character for this numbering kind.
+ pub fn to_char(self) -> char {
+ match self {
+ Self::Arabic => '1',
+ Self::LowerLatin => 'a',
+ Self::UpperLatin => 'A',
+ Self::LowerRoman => 'i',
+ Self::UpperRoman => 'I',
+ Self::Symbol => '*',
+ Self::Hebrew => 'א',
+ Self::LowerSimplifiedChinese | Self::LowerTraditionalChinese => '一',
+ Self::UpperSimplifiedChinese | Self::UpperTraditionalChinese => '壹',
+ Self::HiraganaAiueo => 'あ',
+ Self::HiraganaIroha => 'い',
+ Self::KatakanaAiueo => 'ア',
+ Self::KatakanaIroha => 'イ',
+ Self::KoreanJamo => 'ㄱ',
+ Self::KoreanSyllable => '가',
+ Self::EasternArabic => '\u{0661}',
+ Self::EasternArabicPersian => '\u{06F1}',
+ Self::DevanagariNumber => '\u{0967}',
+ Self::BengaliNumber => '\u{09E7}',
+ Self::BengaliLetter => '\u{0995}',
+ Self::CircledNumber => '①',
+ Self::DoubleCircledNumber => '⓵',
+ }
+ }
+
+ /// Apply the numbering to the given number.
+ pub fn apply(self, n: usize) -> EcoString {
+ match self {
+ Self::Arabic => eco_format!("{n}"),
+ Self::LowerRoman => roman_numeral(n, Case::Lower),
+ Self::UpperRoman => roman_numeral(n, Case::Upper),
+ 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 => hebrew_numeral(n),
+
+ Self::LowerLatin => zeroless(
+ [
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
+ 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ ],
+ n,
+ ),
+ Self::UpperLatin => zeroless(
+ [
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N',
+ 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ ],
+ n,
+ ),
+ Self::HiraganaAiueo => zeroless(
+ [
+ 'あ', 'い', 'う', 'え', 'お', 'か', 'き', 'く', 'け', 'こ', 'さ',
+ 'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'て', 'と', 'な', 'に',
+ 'ぬ', 'ね', 'の', 'は', 'ひ', 'ふ', 'へ', 'ほ', 'ま', 'み', 'む',
+ 'め', 'も', 'や', 'ゆ', 'よ', 'ら', 'り', 'る', 'れ', 'ろ', 'わ',
+ 'を', 'ん',
+ ],
+ n,
+ ),
+ Self::HiraganaIroha => zeroless(
+ [
+ 'い', 'ろ', 'は', 'に', 'ほ', 'へ', 'と', 'ち', 'り', 'ぬ', 'る',
+ 'を', 'わ', 'か', 'よ', 'た', 'れ', 'そ', 'つ', 'ね', 'な', 'ら',
+ 'む', 'う', 'ゐ', 'の', 'お', 'く', 'や', 'ま', 'け', 'ふ', 'こ',
+ 'え', 'て', 'あ', 'さ', 'き', 'ゆ', 'め', 'み', 'し', 'ゑ', 'ひ',
+ 'も', 'せ', 'す',
+ ],
+ n,
+ ),
+ Self::KatakanaAiueo => zeroless(
+ [
+ 'ア', 'イ', 'ウ', 'エ', 'オ', 'カ', 'キ', 'ク', 'ケ', 'コ', 'サ',
+ 'シ', 'ス', 'セ', 'ソ', 'タ', 'チ', 'ツ', 'テ', 'ト', 'ナ', 'ニ',
+ 'ヌ', 'ネ', 'ノ', 'ハ', 'ヒ', 'フ', 'ヘ', 'ホ', 'マ', 'ミ', 'ム',
+ 'メ', 'モ', 'ヤ', 'ユ', 'ヨ', 'ラ', 'リ', 'ル', 'レ', 'ロ', 'ワ',
+ 'ヲ', 'ン',
+ ],
+ n,
+ ),
+ Self::KatakanaIroha => zeroless(
+ [
+ 'イ', 'ロ', 'ハ', 'ニ', 'ホ', 'ヘ', 'ト', 'チ', 'リ', 'ヌ', 'ル',
+ 'ヲ', 'ワ', 'カ', 'ヨ', 'タ', 'レ', 'ソ', 'ツ', 'ネ', 'ナ', 'ラ',
+ 'ム', 'ウ', 'ヰ', 'ノ', 'オ', 'ク', 'ヤ', 'マ', 'ケ', 'フ', 'コ',
+ 'エ', 'テ', 'ア', 'サ', 'キ', 'ユ', 'メ', 'ミ', 'シ', 'ヱ', 'ヒ',
+ 'モ', 'セ', 'ス',
+ ],
+ n,
+ ),
+ Self::KoreanJamo => zeroless(
+ [
+ 'ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ',
+ 'ㅌ', 'ㅍ', 'ㅎ',
+ ],
+ n,
+ ),
+ Self::KoreanSyllable => zeroless(
+ [
+ '가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카',
+ '타', '파', '하',
+ ],
+ n,
+ ),
+ Self::BengaliLetter => zeroless(
+ [
+ 'ক', 'খ', 'গ', 'ঘ', 'ঙ', 'চ', 'ছ', 'জ', 'ঝ', 'ঞ', 'ট', 'ঠ', 'ড', 'ঢ',
+ 'ণ', 'ত', 'থ', 'দ', 'ধ', 'ন', 'প', 'ফ', 'ব', 'ভ', 'ম', 'য', 'র', 'ল',
+ 'শ', 'ষ', 'স', 'হ',
+ ],
+ n,
+ ),
+ Self::CircledNumber => zeroless(
+ [
+ '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭',
+ '⑮', '⑯', '⑰', '⑱', '⑲', '⑳', '㉑', '㉒', '㉓', '㉔', '㉕', '㉖',
+ '㉗', '㉘', '㉙', '㉚', '㉛', '㉜', '㉝', '㉞', '㉟', '㊱', '㊲',
+ '㊳', '㊴', '㊵', '㊶', '㊷', '㊸', '㊹', '㊺', '㊻', '㊼', '㊽',
+ '㊾', '㊿',
+ ],
+ n,
+ ),
+ Self::DoubleCircledNumber => {
+ zeroless(['⓵', '⓶', '⓷', '⓸', '⓹', '⓺', '⓻', '⓼', '⓽', '⓾'], n)
+ }
+
+ Self::LowerSimplifiedChinese => {
+ usize_to_chinese(ChineseVariant::Simple, ChineseCase::Lower, n).into()
+ }
+ Self::UpperSimplifiedChinese => {
+ usize_to_chinese(ChineseVariant::Simple, ChineseCase::Upper, n).into()
+ }
+ Self::LowerTraditionalChinese => {
+ usize_to_chinese(ChineseVariant::Traditional, ChineseCase::Lower, n)
+ .into()
+ }
+ Self::UpperTraditionalChinese => {
+ usize_to_chinese(ChineseVariant::Traditional, ChineseCase::Upper, n)
+ .into()
+ }
+
+ Self::EasternArabic => decimal('\u{0660}', n),
+ Self::EasternArabicPersian => decimal('\u{06F0}', n),
+ Self::DevanagariNumber => decimal('\u{0966}', n),
+ Self::BengaliNumber => decimal('\u{09E6}', n),
+ }
+ }
+}
+
+fn hebrew_numeral(mut n: usize) -> EcoString {
+ 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
+}
+
+fn roman_numeral(mut n: usize, case: Case) -> EcoString {
+ if n == 0 {
+ return match case {
+ Case::Lower => 'n'.into(),
+ Case::Upper => '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
+}
+
+/// 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>(
+ alphabet: [char; N_DIGITS],
+ mut n: usize,
+) -> EcoString {
+ if n == 0 {
+ return '-'.into();
+ }
+ let mut cs = EcoString::new();
+ while n > 0 {
+ n -= 1;
+ cs.push(alphabet[n % N_DIGITS]);
+ n /= N_DIGITS;
+ }
+ cs.chars().rev().collect()
+}
+
+/// Stringify a number using a base-10 counting system with a zero digit.
+///
+/// This function assumes that the digits occupy contiguous codepoints.
+fn decimal(start: char, mut n: usize) -> EcoString {
+ if n == 0 {
+ return start.into();
+ }
+ let mut cs = EcoString::new();
+ while n > 0 {
+ cs.push(char::from_u32((start as u32) + ((n % 10) as u32)).unwrap());
+ n /= 10;
+ }
+ cs.chars().rev().collect()
+}
diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs
new file mode 100644
index 00000000..85257c2c
--- /dev/null
+++ b/crates/typst-library/src/model/outline.rs
@@ -0,0 +1,547 @@
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use comemo::Track;
+use typst_syntax::Span;
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, select_where, Content, Context, Func, LocatableSelector,
+ NativeElement, Packed, Show, ShowSet, Smart, StyleChain, Styles,
+};
+use crate::introspection::{Counter, CounterKey, Locatable};
+use crate::layout::{
+ BoxElem, Dir, Em, Fr, HElem, HideElem, Length, Rel, RepeatElem, Spacing,
+};
+use crate::model::{
+ Destination, HeadingElem, NumberingPattern, ParElem, ParbreakElem, Refable,
+};
+use crate::text::{LinebreakElem, LocalName, 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
+/// #outline()
+///
+/// = Introduction
+/// #lorem(5)
+///
+/// = Prior work
+/// #lorem(10)
+/// ```
+///
+/// # 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]($function.where).
+///
+/// ```example
+/// #outline(
+/// title: [List of Figures],
+/// target: figure.where(kind: image),
+/// )
+///
+/// #figure(
+/// image("tiger.jpg"),
+/// caption: [A nice figure!],
+/// )
+/// ```
+///
+/// # 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`]($outline.entry) element.
+#[elem(scope, keywords = ["Table of Contents"], Show, ShowSet, LocalName)]
+pub struct OutlineElem {
+ /// The title of the outline.
+ ///
+ /// - When set to `{auto}`, an appropriate title for the
+ /// [text language]($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.")}`
+ pub title: Smart<Option<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(select_where!(HeadingElem, Outlined => true)))]
+ #[borrowed]
+ 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]($heading.numbering)).
+ /// - [Relative length]($relative): 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]($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)]
+ #[borrowed]
+ 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>,
+}
+
+#[scope]
+impl OutlineElem {
+ #[elem]
+ type OutlineEntry;
+}
+
+impl Show for Packed<OutlineElem> {
+ #[typst_macros::time(name = "outline", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut seq = vec![ParbreakElem::shared().clone()];
+ // Build the outline title.
+ if let Some(title) = self.title(styles).unwrap_or_else(|| {
+ Some(TextElem::packed(Self::local_name_in(styles)).spanned(self.span()))
+ }) {
+ seq.push(
+ HeadingElem::new(title)
+ .with_depth(NonZeroUsize::ONE)
+ .pack()
+ .spanned(self.span()),
+ );
+ }
+
+ 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 = engine.introspector.query(&self.target(styles).0);
+
+ for elem in &elems {
+ let Some(entry) = OutlineEntry::from_outlinable(
+ engine,
+ self.span(),
+ elem.clone(),
+ self.fill(styles),
+ 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>())
+ .is_some_and(|last| last.level() >= *level)
+ {
+ ancestors.pop();
+ }
+
+ OutlineIndent::apply(
+ indent,
+ engine,
+ &ancestors,
+ &mut seq,
+ styles,
+ self.span(),
+ )?;
+
+ // Add the overridable outline entry, followed by a line break.
+ seq.push(entry.pack());
+ seq.push(LinebreakElem::shared().clone());
+
+ ancestors.push(elem);
+ }
+
+ seq.push(ParbreakElem::shared().clone());
+
+ Ok(Content::sequence(seq))
+ }
+}
+
+impl ShowSet for Packed<OutlineElem> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ let mut out = Styles::new();
+ out.set(HeadingElem::set_outlined(false));
+ out.set(HeadingElem::set_numbering(None));
+ out.set(ParElem::set_first_line_indent(Em::new(0.0).into()));
+ out
+ }
+}
+
+impl LocalName for Packed<OutlineElem> {
+ const KEY: &'static str = "outline";
+}
+
+/// 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,
+ engine: &mut Engine,
+
+ styles: StyleChain,
+ ) -> SourceResult<Option<Content>>;
+
+ /// Returns the nesting level of this element.
+ fn level(&self) -> NonZeroUsize {
+ NonZeroUsize::ONE
+ }
+}
+
+/// Defines how an outline is indented.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum OutlineIndent {
+ Bool(bool),
+ Rel(Rel<Length>),
+ Func(Func),
+}
+
+impl OutlineIndent {
+ fn apply(
+ indent: &Option<Smart<Self>>,
+ engine: &mut Engine,
+ ancestors: &Vec<&Content>,
+ seq: &mut Vec<Content>,
+ styles: StyleChain,
+ 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().display_at_loc(
+ engine,
+ ancestor.location().unwrap(),
+ styles,
+ numbering,
+ )?;
+
+ hidden += numbers + SpaceElem::shared().clone();
+ };
+ }
+
+ if !ancestors.is_empty() {
+ seq.push(HideElem::new(hidden).pack());
+ seq.push(SpaceElem::shared().clone());
+ }
+ }
+
+ // 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(engine, Context::new(None, Some(styles)).track(), [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. To customize an entry's line, you can build it from scratch by
+/// accessing the `level`, `element`, `body`, `fill` and `page` fields on the
+/// entry.
+///
+/// ```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
+/// ```
+#[elem(name = "entry", title = "Outline Entry", 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`]($content.location) method on content
+ /// and can be [linked]($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`] 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(
+ engine: &mut Engine,
+ span: Span,
+ elem: Content,
+ fill: Option<Content>,
+ styles: StyleChain,
+ ) -> SourceResult<Option<Self>> {
+ let Some(outlinable) = elem.with::<dyn Outlinable>() else {
+ bail!(span, "cannot outline {}", elem.func().name());
+ };
+
+ let Some(body) = outlinable.outline(engine, styles)? else {
+ return Ok(None);
+ };
+
+ let location = elem.location().unwrap();
+ let page_numbering = engine
+ .introspector
+ .page_numbering(location)
+ .cloned()
+ .unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into());
+
+ let page = Counter::new(CounterKey::Page).display_at_loc(
+ engine,
+ location,
+ styles,
+ &page_numbering,
+ )?;
+
+ Ok(Some(Self::new(outlinable.level(), elem, body, fill, page)))
+ }
+}
+
+impl Show for Packed<OutlineEntry> {
+ #[typst_macros::time(name = "outline.entry", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: 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 {
+ if elem.can::<dyn Locatable>() && elem.can::<dyn Outlinable>() {
+ bail!(
+ self.span(), "{} must have a location", elem.func().name();
+ hint: "try using a query or a show rule to customize the outline.entry instead",
+ )
+ } else {
+ bail!(self.span(), "cannot outline {}", elem.func().name())
+ }
+ };
+
+ // Isolate the entry body in RTL because the page number is typically
+ // LTR. I'm not sure whether LTR should conceptually also be isolated,
+ // but in any case we don't do it for now because the text shaping
+ // pipeline does tend to choke a bit on default ignorables (in
+ // particular the CJK-Latin spacing).
+ //
+ // See also:
+ // - https://github.com/typst/typst/issues/4476
+ // - https://github.com/typst/typst/issues/5176
+ let rtl = TextElem::dir_in(styles) == Dir::RTL;
+ if rtl {
+ // "Right-to-Left Embedding"
+ seq.push(TextElem::packed("\u{202B}"));
+ }
+
+ seq.push(self.body().clone().linked(Destination::Location(location)));
+
+ if rtl {
+ // "Pop Directional Formatting"
+ seq.push(TextElem::packed("\u{202C}"));
+ }
+
+ // Add filler symbols between the section name and page number.
+ if let Some(filler) = self.fill() {
+ seq.push(SpaceElem::shared().clone());
+ seq.push(
+ BoxElem::new()
+ .with_body(Some(filler.clone()))
+ .with_width(Fr::one().into())
+ .pack()
+ .spanned(self.span()),
+ );
+ seq.push(SpaceElem::shared().clone());
+ } else {
+ seq.push(HElem::new(Fr::one().into()).pack());
+ }
+
+ // Add the page number.
+ let page = self.page().clone().linked(Destination::Location(location));
+ seq.push(page);
+
+ Ok(Content::sequence(seq))
+ }
+}
diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs
new file mode 100644
index 00000000..a43499d8
--- /dev/null
+++ b/crates/typst-library/src/model/par.rs
@@ -0,0 +1,431 @@
+use std::fmt::{self, Debug, Formatter};
+
+use typst_utils::singleton;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, scope, Args, Cast, Construct, Content, NativeElement, Packed, Set, Smart,
+ StyleVec, Unlabellable,
+};
+use crate::introspection::{Count, CounterUpdate, Locatable};
+use crate::layout::{Em, HAlignment, Length, OuterHAlignment};
+use crate::model::Numbering;
+
+/// 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
+/// #set par(
+/// first-line-indent: 1em,
+/// spacing: 0.65em,
+/// 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 ...
+/// ```
+#[elem(scope, title = "Paragraph", Debug, Construct)]
+pub struct ParElem {
+ /// The spacing between lines.
+ ///
+ /// Leading defines the spacing between the [bottom edge]($text.bottom-edge)
+ /// of one line and the [top edge]($text.top-edge) of the following line. By
+ /// default, these two properties are up to the font, but they can also be
+ /// configured manually with a text set rule.
+ ///
+ /// By setting top edge, bottom edge, and leading, you can also configure a
+ /// consistent baseline-to-baseline distance. You could, for instance, set
+ /// the leading to `{1em}`, the top-edge to `{0.8em}`, and the bottom-edge
+ /// to `{-0.2em}` to get a baseline gap of exactly `{2em}`. The exact
+ /// distribution of the top- and bottom-edge values affects the bounds of
+ /// the first and last line.
+ #[resolve]
+ #[ghost]
+ #[default(Em::new(0.65).into())]
+ pub leading: Length,
+
+ /// The spacing between paragraphs.
+ ///
+ /// Just like leading, this defines the spacing between the bottom edge of a
+ /// paragraph's last line and the top edge of the next paragraph's first
+ /// line.
+ ///
+ /// When a paragraph is adjacent to a [`block`] that is not a paragraph,
+ /// that block's [`above`]($block.above) or [`below`]($block.below) property
+ /// takes precedence over the paragraph spacing. Headings, for instance,
+ /// reduce the spacing below them by default for a better look.
+ #[resolve]
+ #[ghost]
+ #[default(Em::new(1.2).into())]
+ pub spacing: Length,
+
+ /// Whether to justify text in its line.
+ ///
+ /// Hyphenation will be enabled for justified paragraphs if the
+ /// [text function's `hyphenate` property]($text.hyphenate) is set to
+ /// `{auto}` and the current language is known.
+ ///
+ /// Note that the current [alignment]($align.alignment) still has an effect
+ /// on the placement of the last line except if it ends with a
+ /// [justified line break]($linebreak.justify).
+ #[ghost]
+ #[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: 207pt)
+ /// #set par(linebreaks: "simple")
+ /// Some texts feature many longer
+ /// words. Those are often exceedingly
+ /// challenging to break in a visually
+ /// pleasing way.
+ ///
+ /// #set par(linebreaks: "optimized")
+ /// Some texts feature many longer
+ /// words. Those are often exceedingly
+ /// challenging to break in a visually
+ /// pleasing way.
+ /// ```
+ #[ghost]
+ 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]($block.spacing) to the [`leading`]($par.leading)
+ /// when using this property (e.g. using `[#set par(spacing: 0.65em)]`).
+ #[ghost]
+ pub first_line_indent: Length,
+
+ /// The indent all but the first line of a paragraph should have.
+ #[ghost]
+ #[resolve]
+ pub hanging_indent: Length,
+
+ /// Indicates whether an overflowing line should be shrunk.
+ ///
+ /// This property is set to `false` on raw blocks, because shrinking a line
+ /// could visually break the indentation.
+ #[ghost]
+ #[internal]
+ #[default(true)]
+ pub shrink: bool,
+
+ /// The contents of the paragraph.
+ #[external]
+ #[required]
+ pub body: Content,
+
+ /// The paragraph's children.
+ #[internal]
+ #[variadic]
+ pub children: StyleVec,
+}
+
+#[scope]
+impl ParElem {
+ #[elem]
+ type ParLine;
+}
+
+impl Construct for ParElem {
+ fn construct(engine: &mut Engine, 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(engine, args)?;
+ let body = args.expect::<Content>("body")?;
+ Ok(Content::sequence([
+ ParbreakElem::shared().clone(),
+ body.styled_with_map(styles),
+ ParbreakElem::shared().clone(),
+ ]))
+ }
+}
+
+impl Debug for ParElem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Par ")?;
+ self.children.fmt(f)
+ }
+}
+
+/// 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
+/// #for i in range(3) {
+/// [Blind text #i: ]
+/// lorem(5)
+/// parbreak()
+/// }
+/// ```
+///
+/// # Syntax
+/// Instead of calling this function, you can insert a blank line into your
+/// markup to create a paragraph break.
+#[elem(title = "Paragraph Break", Unlabellable)]
+pub struct ParbreakElem {}
+
+impl ParbreakElem {
+ /// Get the globally shared paragraph element.
+ pub fn shared() -> &'static Content {
+ singleton!(Content, ParbreakElem::new().pack())
+ }
+}
+
+impl Unlabellable for Packed<ParbreakElem> {}
+
+/// A paragraph line.
+///
+/// This element is exclusively used for line number configuration through set
+/// rules and cannot be placed.
+///
+/// The [`numbering`]($par.line.numbering) option is used to enable line
+/// numbers by specifying a numbering format.
+///
+/// ```example
+/// >>> #set page(margin: (left: 3em))
+/// #set par.line(numbering: "1")
+///
+/// Roses are red. \
+/// Violets are blue. \
+/// Typst is there for you.
+/// ```
+///
+/// The `numbering` option takes either a predefined
+/// [numbering pattern]($numbering) or a function returning styled content. You
+/// can disable line numbers for text inside certain elements by setting the
+/// numbering to `{none}` using show-set rules.
+///
+/// ```example
+/// >>> #set page(margin: (left: 3em))
+/// // Styled red line numbers.
+/// #set par.line(
+/// numbering: n => text(red)[#n]
+/// )
+///
+/// // Disable numbers inside figures.
+/// #show figure: set par.line(
+/// numbering: none
+/// )
+///
+/// Roses are red. \
+/// Violets are blue.
+///
+/// #figure(
+/// caption: [Without line numbers.]
+/// )[
+/// Lorem ipsum \
+/// dolor sit amet
+/// ]
+///
+/// The text above is a sample \
+/// originating from distant times.
+/// ```
+///
+/// This element exposes further options which may be used to control other
+/// aspects of line numbering, such as its [alignment]($par.line.number-align)
+/// or [margin]($par.line.number-margin). In addition, you can control whether
+/// the numbering is reset on each page through the
+/// [`numbering-scope`]($par.line.numbering-scope) option.
+#[elem(name = "line", title = "Paragraph Line", keywords = ["line numbering"], Construct, Locatable)]
+pub struct ParLine {
+ /// How to number each line. Accepts a
+ /// [numbering pattern or function]($numbering).
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(numbering: "I")
+ ///
+ /// Roses are red. \
+ /// Violets are blue. \
+ /// Typst is there for you.
+ /// ```
+ #[ghost]
+ pub numbering: Option<Numbering>,
+
+ /// The alignment of line numbers associated with each line.
+ ///
+ /// The default of `{auto}` indicates a smart default where numbers grow
+ /// horizontally away from the text, considering the margin they're in and
+ /// the current text direction.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(
+ /// numbering: "I",
+ /// number-align: left,
+ /// )
+ ///
+ /// Hello world! \
+ /// Today is a beautiful day \
+ /// For exploring the world.
+ /// ```
+ #[ghost]
+ pub number_align: Smart<HAlignment>,
+
+ /// The margin at which line numbers appear.
+ ///
+ /// _Note:_ In a multi-column document, the line numbers for paragraphs
+ /// inside the last column will always appear on the `{end}` margin (right
+ /// margin for left-to-right text and left margin for right-to-left),
+ /// regardless of this configuration. That behavior cannot be changed at
+ /// this moment.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (right: 3em))
+ /// #set par.line(
+ /// numbering: "1",
+ /// number-margin: right,
+ /// )
+ ///
+ /// = Report
+ /// - Brightness: Dark, yet darker
+ /// - Readings: Negative
+ /// ```
+ #[ghost]
+ #[default(OuterHAlignment::Start)]
+ pub number_margin: OuterHAlignment,
+
+ /// The distance between line numbers and text.
+ ///
+ /// The default value of `{auto}` results in a clearance that is adaptive to
+ /// the page width and yields reasonable results in most cases.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(
+ /// numbering: "1",
+ /// number-clearance: 4pt,
+ /// )
+ ///
+ /// Typesetting \
+ /// Styling \
+ /// Layout
+ /// ```
+ #[ghost]
+ #[default]
+ pub number_clearance: Smart<Length>,
+
+ /// Controls when to reset line numbering.
+ ///
+ /// _Note:_ The line numbering scope must be uniform across each page run (a
+ /// page run is a sequence of pages without an explicit pagebreak in
+ /// between). For this reason, set rules for it should be defined before any
+ /// page content, typically at the very start of the document.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(
+ /// numbering: "1",
+ /// numbering-scope: "page",
+ /// )
+ ///
+ /// First line \
+ /// Second line
+ /// #pagebreak()
+ /// First line again \
+ /// Second line again
+ /// ```
+ #[ghost]
+ #[default(LineNumberingScope::Document)]
+ pub numbering_scope: LineNumberingScope,
+}
+
+impl Construct for ParLine {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+/// Possible line numbering scope options, indicating how often the line number
+/// counter should be reset.
+///
+/// Note that, currently, manually resetting the line number counter is not
+/// supported.
+#[derive(Debug, Cast, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum LineNumberingScope {
+ /// Indicates that the line number counter spans the whole document, i.e.,
+ /// it's never automatically reset.
+ Document,
+ /// Indicates that the line number counter should be reset at the start of
+ /// every new page.
+ Page,
+}
+
+/// A marker used to indicate the presence of a line.
+///
+/// This element is added to each line in a paragraph and later searched to
+/// find out where to add line numbers.
+#[elem(Construct, Locatable, Count)]
+pub struct ParLineMarker {
+ #[internal]
+ #[required]
+ pub numbering: Numbering,
+
+ #[internal]
+ #[required]
+ pub number_align: Smart<HAlignment>,
+
+ #[internal]
+ #[required]
+ pub number_margin: OuterHAlignment,
+
+ #[internal]
+ #[required]
+ pub number_clearance: Smart<Length>,
+}
+
+impl Construct for ParLineMarker {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl Count for Packed<ParLineMarker> {
+ fn update(&self) -> Option<CounterUpdate> {
+ // The line counter must be updated manually by the root flow.
+ None
+ }
+}
diff --git a/crates/typst-library/src/model/quote.rs b/crates/typst-library/src/model/quote.rs
new file mode 100644
index 00000000..110825f1
--- /dev/null
+++ b/crates/typst-library/src/model/quote.rs
@@ -0,0 +1,237 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, Depth, Label, NativeElement, Packed, Show, ShowSet, Smart,
+ StyleChain, Styles,
+};
+use crate::introspection::Locatable;
+use crate::layout::{
+ Alignment, BlockBody, BlockElem, Em, HElem, PadElem, Spacing, VElem,
+};
+use crate::model::{CitationForm, CiteElem};
+use crate::text::{SmartQuoteElem, SmartQuotes, SpaceElem, TextElem};
+
+/// Displays a quote alongside an optional attribution.
+///
+/// # Example
+/// ```example
+/// Plato is often misquoted as the author of #quote[I know that I know
+/// nothing], however, this is a derivation form his original quote:
+///
+/// #set quote(block: true)
+///
+/// #quote(attribution: [Plato])[
+/// ... ἔοικα γοῦν τούτου γε σμικρῷ τινι αὐτῷ τούτῳ σοφώτερος εἶναι, ὅτι
+/// ἃ μὴ οἶδα οὐδὲ οἴομαι εἰδέναι.
+/// ]
+/// #quote(attribution: [from the Henry Cary literal translation of 1897])[
+/// ... I seem, then, in just this little thing to be wiser than this man at
+/// any rate, that what I do not know I do not think I know either.
+/// ]
+/// ```
+///
+/// By default block quotes are padded left and right by `{1em}`, alignment and
+/// padding can be controlled with show rules:
+/// ```example
+/// #set quote(block: true)
+/// #show quote: set align(center)
+/// #show quote: set pad(x: 5em)
+///
+/// #quote[
+/// You cannot pass... I am a servant of the Secret Fire, wielder of the
+/// flame of Anor. You cannot pass. The dark fire will not avail you,
+/// flame of Udûn. Go back to the Shadow! You cannot pass.
+/// ]
+/// ```
+#[elem(Locatable, ShowSet, Show)]
+pub struct QuoteElem {
+ /// Whether this is a block quote.
+ ///
+ /// ```example
+ /// An inline citation would look like
+ /// this: #quote(
+ /// attribution: [René Descartes]
+ /// )[
+ /// cogito, ergo sum
+ /// ], and a block equation like this:
+ /// #quote(
+ /// block: true,
+ /// attribution: [JFK]
+ /// )[
+ /// Ich bin ein Berliner.
+ /// ]
+ /// ```
+ block: bool,
+
+ /// Whether double quotes should be added around this quote.
+ ///
+ /// The double quotes used are inferred from the `quotes` property on
+ /// [smartquote], which is affected by the `lang` property on [text].
+ ///
+ /// - `{true}`: Wrap this quote in double quotes.
+ /// - `{false}`: Do not wrap this quote in double quotes.
+ /// - `{auto}`: Infer whether to wrap this quote in double quotes based on
+ /// the `block` property. If `block` is `{false}`, double quotes are
+ /// automatically added.
+ ///
+ /// ```example
+ /// #set text(lang: "de")
+ ///
+ /// Ein deutsch-sprechender Author
+ /// zitiert unter umständen JFK:
+ /// #quote[Ich bin ein Berliner.]
+ ///
+ /// #set text(lang: "en")
+ ///
+ /// And an english speaking one may
+ /// translate the quote:
+ /// #quote[I am a Berliner.]
+ /// ```
+ quotes: Smart<bool>,
+
+ /// The attribution of this quote, usually the author or source. Can be a
+ /// label pointing to a bibliography entry or any content. By default only
+ /// displayed for block quotes, but can be changed using a `{show}` rule.
+ ///
+ /// ```example
+ /// #quote(attribution: [René Descartes])[
+ /// cogito, ergo sum
+ /// ]
+ ///
+ /// #show quote.where(block: false): it => {
+ /// ["] + h(0pt, weak: true) + it.body + h(0pt, weak: true) + ["]
+ /// if it.attribution != none [ (#it.attribution)]
+ /// }
+ ///
+ /// #quote(
+ /// attribution: link("https://typst.app/home")[typst.com]
+ /// )[
+ /// Compose papers faster
+ /// ]
+ ///
+ /// #set quote(block: true)
+ ///
+ /// #quote(attribution: <tolkien54>)[
+ /// You cannot pass... I am a servant
+ /// of the Secret Fire, wielder of the
+ /// flame of Anor. You cannot pass. The
+ /// dark fire will not avail you, flame
+ /// of Udûn. Go back to the Shadow! You
+ /// cannot pass.
+ /// ]
+ ///
+ /// #bibliography("works.bib", style: "apa")
+ /// ```
+ #[borrowed]
+ attribution: Option<Attribution>,
+
+ /// The quote.
+ #[required]
+ body: Content,
+
+ /// The nesting depth.
+ #[internal]
+ #[fold]
+ #[ghost]
+ depth: Depth,
+}
+
+/// Attribution for a [quote](QuoteElem).
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Attribution {
+ Content(Content),
+ Label(Label),
+}
+
+cast! {
+ Attribution,
+ self => match self {
+ Self::Content(content) => content.into_value(),
+ Self::Label(label) => label.into_value(),
+ },
+ content: Content => Self::Content(content),
+ label: Label => Self::Label(label),
+}
+
+impl Show for Packed<QuoteElem> {
+ #[typst_macros::time(name = "quote", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+ let block = self.block(styles);
+
+ if self.quotes(styles) == Smart::Custom(true) || !block {
+ let quotes = SmartQuotes::get(
+ SmartQuoteElem::quotes_in(styles),
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ SmartQuoteElem::alternative_in(styles),
+ );
+
+ // Alternate between single and double quotes.
+ let Depth(depth) = QuoteElem::depth_in(styles);
+ let double = depth % 2 == 0;
+
+ // Add zero-width weak spacing to make the quotes "sticky".
+ let hole = HElem::hole().pack();
+ realized = Content::sequence([
+ TextElem::packed(quotes.open(double)),
+ hole.clone(),
+ realized,
+ hole,
+ TextElem::packed(quotes.close(double)),
+ ])
+ .styled(QuoteElem::set_depth(Depth(1)));
+ }
+
+ if block {
+ realized = BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(self.span());
+
+ if let Some(attribution) = self.attribution(styles).as_ref() {
+ let mut seq = vec![TextElem::packed('—'), SpaceElem::shared().clone()];
+
+ match attribution {
+ Attribution::Content(content) => {
+ seq.push(content.clone());
+ }
+ Attribution::Label(label) => {
+ seq.push(
+ CiteElem::new(*label)
+ .with_form(Some(CitationForm::Prose))
+ .pack()
+ .spanned(self.span()),
+ );
+ }
+ }
+
+ // Use v(0.9em, weak: true) bring the attribution closer to the
+ // quote.
+ let gap = Spacing::Rel(Em::new(0.9).into());
+ let v = VElem::new(gap).with_weak(true).pack();
+ realized += v + Content::sequence(seq).aligned(Alignment::END);
+ }
+
+ realized = PadElem::new(realized).pack();
+ } else if let Some(Attribution::Label(label)) = self.attribution(styles) {
+ realized += SpaceElem::shared().clone()
+ + CiteElem::new(*label).pack().spanned(self.span());
+ }
+
+ Ok(realized)
+ }
+}
+
+impl ShowSet for Packed<QuoteElem> {
+ fn show_set(&self, styles: StyleChain) -> Styles {
+ let mut out = Styles::new();
+ if self.block(styles) {
+ out.set(PadElem::set_left(Em::new(1.0).into()));
+ out.set(PadElem::set_right(Em::new(1.0).into()));
+ out.set(BlockElem::set_above(Smart::Custom(Em::new(2.4).into())));
+ out.set(BlockElem::set_below(Smart::Custom(Em::new(1.8).into())));
+ }
+ out
+ }
+}
diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs
new file mode 100644
index 00000000..bc1919d6
--- /dev/null
+++ b/crates/typst-library/src/model/reference.rs
@@ -0,0 +1,307 @@
+use comemo::Track;
+use ecow::eco_format;
+
+use crate::diag::{bail, At, Hint, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, Context, Func, IntoValue, Label, NativeElement, Packed, Show,
+ Smart, StyleChain, Synthesize,
+};
+use crate::introspection::{Counter, Locatable};
+use crate::math::EquationElem;
+use crate::model::{
+ BibliographyElem, CiteElem, Destination, Figurable, FootnoteElem, Numbering,
+};
+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] from a bibliography.
+///
+/// Referenceable elements include [headings]($heading), [figures]($figure),
+/// [equations]($math.equation), and [footnotes]($footnote). To create a custom
+/// referenceable element like a theorem, you can create a figure of a custom
+/// [`kind`]($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`] function instead.
+///
+/// # 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.
+/// $ T(n) = O(2^n) $ <slow>
+///
+/// #bibliography("works.bib")
+/// ```
+///
+/// # 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
+/// 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.
+/// link(el.location(),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>
+/// ```
+#[elem(title = "Reference", Synthesize, Locatable, Show)]
+pub struct RefElem {
+ /// The target label that should be referenced.
+ ///
+ /// Can be a label that is defined in the document or an entry from the
+ /// [`bibliography`].
+ #[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.
+ /// ```
+ #[borrowed]
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// A synthesized citation.
+ #[synthesized]
+ pub citation: Option<Packed<CiteElem>>,
+
+ /// The referenced element.
+ #[synthesized]
+ pub element: Option<Content>,
+}
+
+impl Synthesize for Packed<RefElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let citation = to_citation(self, engine, styles)?;
+
+ let elem = self.as_mut();
+ elem.push_citation(Some(citation));
+ elem.push_element(None);
+
+ let target = *elem.target();
+ if !BibliographyElem::has(engine, target) {
+ if let Ok(found) = engine.introspector.query_label(target).cloned() {
+ elem.push_element(Some(found));
+ return Ok(());
+ }
+ }
+
+ Ok(())
+ }
+}
+
+impl Show for Packed<RefElem> {
+ #[typst_macros::time(name = "ref", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let target = *self.target();
+ let elem = engine.introspector.query_label(target);
+ let span = self.span();
+
+ if BibliographyElem::has(engine, target) {
+ if elem.is_ok() {
+ bail!(span, "label occurs in the document and its bibliography");
+ }
+
+ return Ok(to_citation(self, engine, styles)?.pack().spanned(span));
+ }
+
+ let elem = elem.at(span)?;
+
+ if let Some(footnote) = elem.to_packed::<FootnoteElem>() {
+ return Ok(footnote.into_ref(target).pack().spanned(span));
+ }
+
+ let elem = elem.clone();
+ 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 {} numbering with `#set {}(numbering: \"1.\")`",
+ elem.func().name(),
+ if elem.func() == EquationElem::elem() {
+ "math.equation"
+ } else {
+ elem.func().name()
+ }
+ ))
+ .at(span)?;
+
+ let loc = elem.location().unwrap();
+ let numbers = refable.counter().display_at_loc(
+ engine,
+ loc,
+ styles,
+ &numbering.clone().trimmed(),
+ )?;
+
+ let supplement = match self.supplement(styles).as_ref() {
+ Smart::Auto => refable.supplement(),
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => {
+ supplement.resolve(engine, styles, [elem])?
+ }
+ };
+
+ let mut content = numbers;
+ if !supplement.is_empty() {
+ content = supplement + TextElem::packed("\u{a0}") + content;
+ }
+
+ Ok(content.linked(Destination::Location(loc)))
+ }
+}
+
+/// Turn a reference into a citation.
+fn to_citation(
+ reference: &Packed<RefElem>,
+ engine: &mut Engine,
+ styles: StyleChain,
+) -> SourceResult<Packed<CiteElem>> {
+ let mut elem = Packed::new(CiteElem::new(*reference.target()).with_supplement(
+ match reference.supplement(styles).clone() {
+ Smart::Custom(Some(Supplement::Content(content))) => Some(content),
+ _ => None,
+ },
+ ));
+
+ if let Some(loc) = reference.location() {
+ elem.set_location(loc);
+ }
+
+ elem.synthesize(engine, styles)?;
+
+ Ok(elem)
+}
+
+/// Additional content for a reference.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Supplement {
+ Content(Content),
+ Func(Func),
+}
+
+impl Supplement {
+ /// Tries to resolve the supplement into its content.
+ pub fn resolve<T: IntoValue>(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ args: impl IntoIterator<Item = T>,
+ ) -> SourceResult<Content> {
+ Ok(match self {
+ Supplement::Content(content) => content.clone(),
+ Supplement::Func(func) => func
+ .call(engine, Context::new(None, Some(styles)).track(), 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/model/strong.rs b/crates/typst-library/src/model/strong.rs
new file mode 100644
index 00000000..0e23179e
--- /dev/null
+++ b/crates/typst-library/src/model/strong.rs
@@ -0,0 +1,48 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, StyleChain};
+use crate::text::{TextElem, WeightDelta};
+
+/// Strongly emphasizes content by increasing the font weight.
+///
+/// Increases the current font weight by a given `delta`.
+///
+/// # Example
+/// ```example
+/// This is *strong.* \
+/// This is #strong[too.] \
+///
+/// #show strong: set text(red)
+/// And this is *evermore.*
+/// ```
+///
+/// # 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.
+#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"], 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 Packed<StrongElem> {
+ #[typst_macros::time(name = "strong", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self
+ .body()
+ .clone()
+ .styled(TextElem::set_delta(WeightDelta(self.delta(styles)))))
+ }
+}
diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs
new file mode 100644
index 00000000..7dfaf45d
--- /dev/null
+++ b/crates/typst-library/src/model/table.rs
@@ -0,0 +1,724 @@
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, HintedStrResult, HintedString, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Content, NativeElement, Packed, Show, Smart, StyleChain,
+};
+use crate::layout::{
+ show_grid_cell, Abs, Alignment, BlockElem, Celled, GridCell, GridFooter, GridHLine,
+ GridHeader, GridVLine, Length, OuterHAlignment, OuterVAlignment, Rel, Sides,
+ TrackSizings,
+};
+use crate::model::Figurable;
+use crate::text::LocalName;
+use crate::visualize::{Paint, Stroke};
+
+/// 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.
+/// For a hands-on explanation of all the ways you can use and customize tables
+/// in Typst, check out the [table guide]($guides/table-guide).
+///
+/// Because tables are just grids with different defaults for some cell
+/// properties (notably `stroke` and `inset`), refer to the [grid
+/// documentation]($grid) for more information on how to size the table tracks
+/// and specify the cell appearance properties.
+///
+/// If you are unsure whether you should be using a table or a grid, consider
+/// whether the content you are arranging semantically belongs together as a set
+/// of related data points or similar or whether you are just want to enhance
+/// your presentation by arranging unrelated content in a grid. In the former
+/// case, a table is the right choice, while in the latter case, a grid is more
+/// appropriate. Furthermore, Typst will annotate its output in the future such
+/// that screenreaders will announce content in `table` as tabular while a
+/// grid's content will be announced no different than multiple content blocks
+/// in the document flow.
+///
+/// Note that, to override a particular cell's properties or apply show rules on
+/// table cells, you can use the [`table.cell`]($table.cell) element. See its
+/// documentation for more information.
+///
+/// Although the `table` and the `grid` share most properties, set and show
+/// rules on one of them do not affect the other.
+///
+/// To give a table a caption and make it [referenceable]($ref), put it into a
+/// [figure].
+///
+/// # Example
+///
+/// The example below demonstrates some of the most common table options.
+/// ```example
+/// #table(
+/// columns: (1fr, auto, auto),
+/// inset: 10pt,
+/// align: horizon,
+/// table.header(
+/// [], [*Volume*], [*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]
+/// )
+/// ```
+///
+/// Much like with grids, you can use [`table.cell`]($table.cell) to customize
+/// the appearance and the position of each cell.
+///
+/// ```example
+/// >>> #set page(width: auto)
+/// >>> #set text(font: "IBM Plex Sans")
+/// >>> #let gray = rgb("#565565")
+/// >>>
+/// #set table(
+/// stroke: none,
+/// gutter: 0.2em,
+/// fill: (x, y) =>
+/// if x == 0 or y == 0 { gray },
+/// inset: (right: 1.5em),
+/// )
+///
+/// #show table.cell: it => {
+/// if it.x == 0 or it.y == 0 {
+/// set text(white)
+/// strong(it)
+/// } else if it.body == [] {
+/// // Replace empty cells with 'N/A'
+/// pad(..it.inset)[_N/A_]
+/// } else {
+/// it
+/// }
+/// }
+///
+/// #let a = table.cell(
+/// fill: green.lighten(60%),
+/// )[A]
+/// #let b = table.cell(
+/// fill: aqua.lighten(60%),
+/// )[B]
+///
+/// #table(
+/// columns: 4,
+/// [], [Exam 1], [Exam 2], [Exam 3],
+///
+/// [John], [], a, [],
+/// [Mary], [], a, a,
+/// [Robert], b, a, b,
+/// )
+/// ```
+#[elem(scope, Show, LocalName, Figurable)]
+pub struct TableElem {
+ /// The column sizes. See the [grid documentation]($grid) for more
+ /// information on track sizing.
+ #[borrowed]
+ pub columns: TrackSizings,
+
+ /// The row sizes. See the [grid documentation]($grid) for more information
+ /// on track sizing.
+ #[borrowed]
+ pub rows: TrackSizings,
+
+ /// The gaps between rows and columns. This is a shorthand for setting
+ /// `column-gutter` and `row-gutter` to the same value. See the [grid
+ /// documentation]($grid) for more information on gutters.
+ #[external]
+ pub gutter: TrackSizings,
+
+ /// The gaps between columns. Takes precedence over `gutter`. See the
+ /// [grid documentation]($grid) for more information on gutters.
+ #[borrowed]
+ #[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]($grid) for more information on gutters.
+ #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))]
+ #[borrowed]
+ pub row_gutter: TrackSizings,
+
+ /// How to fill the cells.
+ ///
+ /// This can be a color or a function that returns a color. The function
+ /// receives the cells' column and row indices, starting from zero. This can
+ /// be used to implement striped tables.
+ ///
+ /// ```example
+ /// #table(
+ /// fill: (x, _) =>
+ /// if calc.odd(x) { luma(240) }
+ /// else { white },
+ /// align: (x, y) =>
+ /// if y == 0 { center }
+ /// else if x == 0 { left }
+ /// else { right },
+ /// columns: 4,
+ /// [], [*Q1*], [*Q2*], [*Q3*],
+ /// [Revenue:], [1000 €], [2000 €], [3000 €],
+ /// [Expenses:], [500 €], [1000 €], [1500 €],
+ /// [Profit:], [500 €], [1000 €], [1500 €],
+ /// )
+ /// ```
+ #[borrowed]
+ 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 receives the cells' column and row indices, starting from
+ /// zero. If set to `{auto}`, the outer alignment is used.
+ ///
+ /// ```example
+ /// #table(
+ /// columns: 3,
+ /// align: (left, center, right),
+ /// [Hello], [Hello], [Hello],
+ /// [A], [B], [C],
+ /// )
+ /// ```
+ #[borrowed]
+ pub align: Celled<Smart<Alignment>>,
+
+ /// How to [stroke] the cells.
+ ///
+ /// Strokes can be disabled by setting this to `{none}`.
+ ///
+ /// If it is necessary to place lines which can cross spacing between cells
+ /// produced by the `gutter` option, or to override the stroke between
+ /// multiple specific cells, consider specifying one or more of
+ /// [`table.hline`]($table.hline) and [`table.vline`]($table.vline)
+ /// alongside your table cells.
+ ///
+ /// See the [grid documentation]($grid.stroke) for more information on
+ /// strokes.
+ #[resolve]
+ #[fold]
+ #[default(Celled::Value(Sides::splat(Some(Some(Arc::new(Stroke::default()))))))]
+ pub stroke: Celled<Sides<Option<Option<Arc<Stroke>>>>>,
+
+ /// How much to pad the cells' content.
+ ///
+ /// ```example
+ /// #table(
+ /// inset: 10pt,
+ /// [Hello],
+ /// [World],
+ /// )
+ ///
+ /// #table(
+ /// columns: 2,
+ /// inset: (
+ /// x: 20pt,
+ /// y: 10pt,
+ /// ),
+ /// [Hello],
+ /// [World],
+ /// )
+ /// ```
+ #[fold]
+ #[default(Celled::Value(Sides::splat(Some(Abs::pt(5.0).into()))))]
+ pub inset: Celled<Sides<Option<Rel<Length>>>>,
+
+ /// The contents of the table cells, plus any extra table lines specified
+ /// with the [`table.hline`]($table.hline) and
+ /// [`table.vline`]($table.vline) elements.
+ #[variadic]
+ pub children: Vec<TableChild>,
+}
+
+#[scope]
+impl TableElem {
+ #[elem]
+ type TableCell;
+
+ #[elem]
+ type TableHLine;
+
+ #[elem]
+ type TableVLine;
+
+ #[elem]
+ type TableHeader;
+
+ #[elem]
+ type TableFooter;
+}
+
+impl Show for Packed<TableElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::multi_layouter(self.clone(), engine.routines.layout_table)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+impl LocalName for Packed<TableElem> {
+ const KEY: &'static str = "table";
+}
+
+impl Figurable for Packed<TableElem> {}
+
+/// Any child of a table element.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum TableChild {
+ Header(Packed<TableHeader>),
+ Footer(Packed<TableFooter>),
+ Item(TableItem),
+}
+
+cast! {
+ TableChild,
+ self => match self {
+ Self::Header(header) => header.into_value(),
+ Self::Footer(footer) => footer.into_value(),
+ Self::Item(item) => item.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for TableChild {
+ type Error = HintedString;
+
+ fn try_from(value: Content) -> HintedStrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!(
+ "cannot use `grid.header` as a table header";
+ hint: "use `table.header` instead"
+ )
+ }
+ if value.is::<GridFooter>() {
+ bail!(
+ "cannot use `grid.footer` as a table footer";
+ hint: "use `table.footer` instead"
+ )
+ }
+
+ value
+ .into_packed::<TableHeader>()
+ .map(Self::Header)
+ .or_else(|value| value.into_packed::<TableFooter>().map(Self::Footer))
+ .or_else(|value| TableItem::try_from(value).map(Self::Item))
+ }
+}
+
+/// A table item, which is the basic unit of table specification.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum TableItem {
+ HLine(Packed<TableHLine>),
+ VLine(Packed<TableVLine>),
+ Cell(Packed<TableCell>),
+}
+
+cast! {
+ TableItem,
+ self => match self {
+ Self::HLine(hline) => hline.into_value(),
+ Self::VLine(vline) => vline.into_value(),
+ Self::Cell(cell) => cell.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for TableItem {
+ type Error = HintedString;
+
+ fn try_from(value: Content) -> HintedStrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!("cannot place a grid header within another header or footer");
+ }
+ if value.is::<TableHeader>() {
+ bail!("cannot place a table header within another header or footer");
+ }
+ if value.is::<GridFooter>() {
+ bail!("cannot place a grid footer within another footer or header");
+ }
+ if value.is::<TableFooter>() {
+ bail!("cannot place a table footer within another footer or header");
+ }
+ if value.is::<GridCell>() {
+ bail!(
+ "cannot use `grid.cell` as a table cell";
+ hint: "use `table.cell` instead"
+ );
+ }
+ if value.is::<GridHLine>() {
+ bail!(
+ "cannot use `grid.hline` as a table line";
+ hint: "use `table.hline` instead"
+ );
+ }
+ if value.is::<GridVLine>() {
+ bail!(
+ "cannot use `grid.vline` as a table line";
+ hint: "use `table.vline` instead"
+ );
+ }
+
+ Ok(value
+ .into_packed::<TableHLine>()
+ .map(Self::HLine)
+ .or_else(|value| value.into_packed::<TableVLine>().map(Self::VLine))
+ .or_else(|value| value.into_packed::<TableCell>().map(Self::Cell))
+ .unwrap_or_else(|value| {
+ let span = value.span();
+ Self::Cell(Packed::new(TableCell::new(value)).spanned(span))
+ }))
+ }
+}
+
+/// A repeatable table header.
+///
+/// You should wrap your tables' heading rows in this function even if you do not
+/// plan to wrap your table across pages because Typst will use this function to
+/// attach accessibility metadata to tables in the future and ensure universal
+/// access to your document.
+///
+/// You can use the `repeat` parameter to control whether your table's header
+/// will be repeated across pages.
+///
+/// ```example
+/// #set page(height: 11.5em)
+/// #set table(
+/// fill: (x, y) =>
+/// if x == 0 or y == 0 {
+/// gray.lighten(40%)
+/// },
+/// align: right,
+/// )
+///
+/// #show table.cell.where(x: 0): strong
+/// #show table.cell.where(y: 0): strong
+///
+/// #table(
+/// columns: 4,
+/// table.header(
+/// [], [Blue chip],
+/// [Fresh IPO], [Penny st'k],
+/// ),
+/// table.cell(
+/// rowspan: 6,
+/// align: horizon,
+/// rotate(-90deg, reflow: true)[
+/// *USD / day*
+/// ],
+/// ),
+/// [0.20], [104], [5],
+/// [3.17], [108], [4],
+/// [1.59], [84], [1],
+/// [0.26], [98], [15],
+/// [0.01], [195], [4],
+/// [7.34], [57], [2],
+/// )
+/// ```
+#[elem(name = "header", title = "Table Header")]
+pub struct TableHeader {
+ /// Whether this header should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the header.
+ #[variadic]
+ pub children: Vec<TableItem>,
+}
+
+/// A repeatable table footer.
+///
+/// Just like the [`table.header`]($table.header) element, the footer can repeat
+/// itself on every page of the table. This is useful for improving legibility
+/// by adding the column labels in both the header and footer of a large table,
+/// totals, or other information that should be visible on every page.
+///
+/// No other table cells may be placed after the footer.
+#[elem(name = "footer", title = "Table Footer")]
+pub struct TableFooter {
+ /// Whether this footer should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the footer.
+ #[variadic]
+ pub children: Vec<TableItem>,
+}
+
+/// A horizontal line in the table.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// table's `stroke` field. Can cross spacing between cells created through the
+/// table's [`column-gutter`]($table.column-gutter) option.
+///
+/// Use this function instead of the table's `stroke` field if you want to
+/// manually place a horizontal line at a specific position in a single table.
+/// Consider using [table's `stroke`]($table.stroke) field or [`table.cell`'s
+/// `stroke`]($table.cell.stroke) field instead if the line you want to place is
+/// part of all your tables' designs.
+///
+/// ```example
+/// #set table.hline(stroke: .6pt)
+///
+/// #table(
+/// stroke: none,
+/// columns: (auto, 1fr),
+/// [09:00], [Badge pick up],
+/// [09:45], [Opening Keynote],
+/// [10:30], [Talk: Typst's Future],
+/// [11:15], [Session: Good PRs],
+/// table.hline(start: 1),
+/// [Noon], [_Lunch break_],
+/// table.hline(start: 1),
+/// [14:00], [Talk: Tracked Layout],
+/// [15:00], [Talk: Automations],
+/// [16:00], [Workshop: Tables],
+/// table.hline(),
+/// [19:00], [Day 1 Attendee Mixer],
+/// )
+/// ```
+#[elem(name = "hline", title = "Table Horizontal Line")]
+pub struct TableHLine {
+ /// The row above which the horizontal line is placed (zero-indexed).
+ /// Functions identically to the `y` field in [`grid.hline`]($grid.hline.y).
+ pub y: Smart<usize>,
+
+ /// The column at which the horizontal line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The column before which the horizontal line ends (zero-indexed,
+ /// exclusive).
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` removes any lines previously placed across this
+ /// line's range, including hlines or per-cell stroke below it.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its row (`y`) - either
+ /// `{top}` to draw above it or `{bottom}` to draw below it.
+ ///
+ /// This setting is only relevant when row gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `y` field by
+ /// one instead), since then the position below a row becomes different
+ /// from the position above the next row due to the spacing between both.
+ #[default(OuterVAlignment::Top)]
+ pub position: OuterVAlignment,
+}
+
+/// A vertical line in the table. See the docs for [`grid.vline`]($grid.vline)
+/// for more information regarding how to use this element's fields.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// table's `stroke` field. Can cross spacing between cells created through the
+/// table's [`row-gutter`]($table.row-gutter) option.
+///
+/// Similar to [`table.hline`]($table.hline), use this function if you want to
+/// manually place a vertical line at a specific position in a single table and
+/// use the [table's `stroke`]($table.stroke) field or [`table.cell`'s
+/// `stroke`]($table.cell.stroke) field instead if the line you want to place is
+/// part of all your tables' designs.
+#[elem(name = "vline", title = "Table Vertical Line")]
+pub struct TableVLine {
+ /// The column before which the horizontal line is placed (zero-indexed).
+ /// Functions identically to the `x` field in [`grid.vline`]($grid.vline).
+ pub x: Smart<usize>,
+
+ /// The row at which the vertical line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The row on top of which the vertical line ends (zero-indexed,
+ /// exclusive).
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` removes any lines previously placed across this
+ /// line's range, including vlines or per-cell stroke below it.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its column (`x`) -
+ /// either `{start}` to draw before it or `{end}` to draw after it.
+ ///
+ /// The values `{left}` and `{right}` are also accepted, but discouraged as
+ /// they cause your table to be inconsistent between left-to-right and
+ /// right-to-left documents.
+ ///
+ /// This setting is only relevant when column gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `x` field by
+ /// one instead), since then the position after a column becomes different
+ /// from the position before the next column due to the spacing between
+ /// both.
+ #[default(OuterHAlignment::Start)]
+ pub position: OuterHAlignment,
+}
+
+/// A cell in the table. Use this to position a cell manually or to apply
+/// styling. To do the latter, you can either use the function to override the
+/// properties for a particular cell, or use it in show rules to apply certain
+/// styles to multiple cells at once.
+///
+/// Perhaps the most important use case of `{table.cell}` is to make a cell span
+/// multiple columns and/or rows with the `colspan` and `rowspan` fields.
+///
+/// ```example
+/// >>> #set page(width: auto)
+/// #show table.cell.where(y: 0): strong
+/// #set table(
+/// stroke: (x, y) => if y == 0 {
+/// (bottom: 0.7pt + black)
+/// },
+/// align: (x, y) => (
+/// if x > 0 { center }
+/// else { left }
+/// )
+/// )
+///
+/// #table(
+/// columns: 3,
+/// table.header(
+/// [Substance],
+/// [Subcritical °C],
+/// [Supercritical °C],
+/// ),
+/// [Hydrochloric Acid],
+/// [12.0], [92.1],
+/// [Sodium Myreth Sulfate],
+/// [16.6], [104],
+/// [Potassium Hydroxide],
+/// table.cell(colspan: 2)[24.7],
+/// )
+/// ```
+///
+/// For example, you can override the fill, alignment or inset for a single
+/// cell:
+///
+/// ```example
+/// >>> #set page(width: auto)
+/// // You can also import those.
+/// #import table: cell, header
+///
+/// #table(
+/// columns: 2,
+/// align: center,
+/// header(
+/// [*Trip progress*],
+/// [*Itinerary*],
+/// ),
+/// cell(
+/// align: right,
+/// fill: fuchsia.lighten(80%),
+/// [🚗],
+/// ),
+/// [Get in, folks!],
+/// [🚗], [Eat curbside hotdog],
+/// cell(align: left)[🌴🚗],
+/// cell(
+/// inset: 0.06em,
+/// text(1.62em)[🛖🌅🌊],
+/// ),
+/// )
+/// ```
+///
+/// You may also apply a show rule on `table.cell` to style all cells at once.
+/// Combined with selectors, this allows you to apply styles based on a cell's
+/// position:
+///
+/// ```example
+/// #show table.cell.where(x: 0): strong
+///
+/// #table(
+/// columns: 3,
+/// gutter: 3pt,
+/// [Name], [Age], [Strength],
+/// [Hannes], [36], [Grace],
+/// [Irma], [50], [Resourcefulness],
+/// [Vikram], [49], [Perseverance],
+/// )
+/// ```
+#[elem(name = "cell", title = "Table Cell", Show)]
+pub struct TableCell {
+ /// The cell's body.
+ #[required]
+ pub body: Content,
+
+ /// The cell's column (zero-indexed).
+ /// Functions identically to the `x` field in [`grid.cell`]($grid.cell).
+ pub x: Smart<usize>,
+
+ /// The cell's row (zero-indexed).
+ /// Functions identically to the `y` field in [`grid.cell`]($grid.cell).
+ pub y: Smart<usize>,
+
+ /// The amount of columns spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ pub colspan: NonZeroUsize,
+
+ /// The amount of rows spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ pub rowspan: NonZeroUsize,
+
+ /// The cell's [fill]($table.fill) override.
+ pub fill: Smart<Option<Paint>>,
+
+ /// The cell's [alignment]($table.align) override.
+ pub align: Smart<Alignment>,
+
+ /// The cell's [inset]($table.inset) override.
+ pub inset: Smart<Sides<Option<Rel<Length>>>>,
+
+ /// The cell's [stroke]($table.stroke) override.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
+
+ /// Whether rows spanned by this cell can be placed in different pages.
+ /// When equal to `{auto}`, a cell spanning only fixed-size rows is
+ /// unbreakable, while a cell spanning at least one `{auto}`-sized row is
+ /// breakable.
+ pub breakable: Smart<bool>,
+}
+
+cast! {
+ TableCell,
+ v: Content => v.into(),
+}
+
+impl Show for Packed<TableCell> {
+ fn show(&self, _engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ show_grid_cell(self.body().clone(), self.inset(styles), self.align(styles))
+ }
+}
+
+impl Default for Packed<TableCell> {
+ fn default() -> Self {
+ Packed::new(TableCell::new(Content::default()))
+ }
+}
+
+impl From<Content> for TableCell {
+ fn from(value: Content) -> Self {
+ #[allow(clippy::unwrap_or_default)]
+ value.unpack::<Self>().unwrap_or_else(Self::new)
+ }
+}
diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs
new file mode 100644
index 00000000..036a03e2
--- /dev/null
+++ b/crates/typst-library/src/model/terms.rs
@@ -0,0 +1,204 @@
+use typst_utils::Numeric;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Array, Content, NativeElement, Packed, Show, Smart, StyleChain,
+ Styles,
+};
+use crate::layout::{Dir, Em, HElem, Length, Sides, StackChild, StackElem, VElem};
+use crate::model::{ListItemLike, ListLike, ParElem};
+use crate::text::TextElem;
+
+/// 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
+/// / Ligature: A merged glyph.
+/// / Kerning: A spacing adjustment
+/// between two adjacent letters.
+/// ```
+///
+/// # 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.
+#[elem(scope, title = "Term List", Show)]
+pub struct TermsElem {
+ /// Defines the default [spacing]($terms.spacing) of the term list. If it is
+ /// `{false}`, the items are spaced apart with
+ /// [paragraph spacing]($par.spacing). If it is `{true}`, they use
+ /// [paragraph leading]($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}`. The markup-defined tightness cannot be
+ /// overridden with set rules.
+ ///
+ /// ```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())]
+ #[borrowed]
+ 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 the term list.
+ ///
+ /// If set to `{auto}`, uses paragraph [`leading`]($par.leading) for tight
+ /// term lists and paragraph [`spacing`]($par.spacing) for wide
+ /// (non-tight) term lists.
+ pub spacing: Smart<Length>,
+
+ /// 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<Packed<TermItem>>,
+}
+
+#[scope]
+impl TermsElem {
+ #[elem]
+ type TermItem;
+}
+
+impl Show for Packed<TermsElem> {
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let separator = self.separator(styles);
+ let indent = self.indent(styles);
+ let hanging_indent = self.hanging_indent(styles);
+ let gutter = self.spacing(styles).unwrap_or_else(|| {
+ if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ ParElem::spacing_in(styles).into()
+ }
+ });
+
+ let pad = hanging_indent + indent;
+ let unpad = (!hanging_indent.is_zero())
+ .then(|| HElem::new((-hanging_indent).into()).pack());
+
+ let mut children = vec![];
+ for child in self.children().iter() {
+ let mut seq = vec![];
+ seq.extend(unpad.clone());
+ seq.push(child.term().clone().strong());
+ seq.push((*separator).clone());
+ seq.push(child.description().clone());
+ children.push(StackChild::Block(Content::sequence(seq)));
+ }
+
+ let mut padding = Sides::default();
+ if TextElem::dir_in(styles) == Dir::LTR {
+ padding.left = pad.into();
+ } else {
+ padding.right = pad.into();
+ }
+
+ let mut realized = StackElem::new(children)
+ .with_spacing(Some(gutter.into()))
+ .pack()
+ .padded(padding);
+
+ if self.tight(styles) {
+ let leading = ParElem::leading_in(styles);
+ let spacing =
+ VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
+ realized = spacing + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+/// A term list item.
+#[elem(name = "item", title = "Term List Item")]
+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.unpack::<Self>().map_err(|_| "expected term item or array")?,
+}
+
+impl ListLike for TermsElem {
+ type Item = TermItem;
+
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self {
+ Self::new(children).with_tight(tight)
+ }
+}
+
+impl ListItemLike for TermItem {
+ fn styled(mut item: Packed<Self>, styles: Styles) -> Packed<Self> {
+ item.term.style_in_place(styles.clone());
+ item.description.style_in_place(styles);
+ item
+ }
+}
diff --git a/crates/typst-library/src/routines.rs b/crates/typst-library/src/routines.rs
new file mode 100644
index 00000000..6b78b7fb
--- /dev/null
+++ b/crates/typst-library/src/routines.rs
@@ -0,0 +1,368 @@
+#![allow(unused)]
+
+use std::hash::{Hash, Hasher};
+use std::num::NonZeroUsize;
+
+use comemo::{Tracked, TrackedMut};
+use typst_syntax::Span;
+use typst_utils::LazyHash;
+
+use crate::diag::SourceResult;
+use crate::engine::{Engine, Route, Sink, Traced};
+use crate::foundations::{
+ Args, Cast, Closure, Content, Context, Func, Packed, Scope, StyleChain, StyleVec,
+ Styles, Value,
+};
+use crate::introspection::{Introspector, Locator, SplitLocator};
+use crate::layout::{
+ Abs, BoxElem, ColumnsElem, Fragment, Frame, GridElem, InlineItem, MoveElem, PadElem,
+ Region, Regions, Rel, RepeatElem, RotateElem, ScaleElem, Size, SkewElem, StackElem,
+};
+use crate::math::EquationElem;
+use crate::model::{Document, DocumentInfo, EnumElem, ListElem, TableElem};
+use crate::visualize::{
+ CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem,
+ SquareElem,
+};
+use crate::World;
+
+/// Defines the `Routines` struct.
+macro_rules! routines {
+ ($(
+ $(#[$attr:meta])*
+ fn $name:ident $(<$($time:lifetime),*>)? ($($args:tt)*) -> $ret:ty
+ )*) => {
+ /// Defines implementation of various Typst compiler routines as a table
+ /// of function pointers.
+ ///
+ /// This is essentially dynamic linking and done to allow for crate
+ /// splitting.
+ pub struct Routines {
+ $(
+ $(#[$attr])*
+ pub $name: $(for<$($time),*>)? fn ($($args)*) -> $ret
+ ),*
+ }
+
+ impl Hash for Routines {
+ fn hash<H: Hasher>(&self, _: &mut H) {}
+ }
+ };
+}
+
+routines! {
+ /// Evaluates a string as code and return the resulting value.
+ fn eval_string(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ string: &str,
+ span: Span,
+ mode: EvalMode,
+ scope: Scope,
+ ) -> SourceResult<Value>
+
+ /// Call the closure in the context with the arguments.
+ fn eval_closure(
+ func: &Func,
+ closure: &LazyHash<Closure>,
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ context: Tracked<Context>,
+ args: Args,
+ ) -> SourceResult<Value>
+
+ /// Realizes content into a flat list of well-known, styled items.
+ fn realize<'a>(
+ kind: RealizationKind,
+ engine: &mut Engine,
+ locator: &mut SplitLocator,
+ arenas: &'a Arenas,
+ content: &'a Content,
+ styles: StyleChain<'a>,
+ ) -> SourceResult<Vec<Pair<'a>>>
+
+ /// Layout content into a document.
+ fn layout_document(
+ engine: &mut Engine,
+ content: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<Document>
+
+ /// Lays out content into multiple regions.
+ fn layout_fragment(
+ engine: &mut Engine,
+ content: &Content,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out content into a single region, producing a single frame.
+ fn layout_frame(
+ engine: &mut Engine,
+ content: &Content,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out inline content.
+ fn layout_inline(
+ engine: &mut Engine,
+ children: &StyleVec,
+ locator: Locator,
+ styles: StyleChain,
+ consecutive: bool,
+ region: Size,
+ expand: bool,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out a [`BoxElem`].
+ fn layout_box(
+ elem: &Packed<BoxElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Size,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`ListElem`].
+ fn layout_list(
+ elem: &Packed<ListElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out an [`EnumElem`].
+ fn layout_enum(
+ elem: &Packed<EnumElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out a [`GridElem`].
+ fn layout_grid(
+ elem: &Packed<GridElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out a [`TableElem`].
+ fn layout_table(
+ elem: &Packed<TableElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out a [`StackElem`].
+ fn layout_stack(
+ elem: &Packed<StackElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out a [`ColumnsElem`].
+ fn layout_columns(
+ elem: &Packed<ColumnsElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out a [`MoveElem`].
+ fn layout_move(
+ elem: &Packed<MoveElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`RotateElem`].
+ fn layout_rotate(
+ elem: &Packed<RotateElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`ScaleElem`].
+ fn layout_scale(
+ elem: &Packed<ScaleElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`SkewElem`].
+ fn layout_skew(
+ elem: &Packed<SkewElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`RepeatElem`].
+ fn layout_repeat(
+ elem: &Packed<RepeatElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`PadElem`].
+ fn layout_pad(
+ elem: &Packed<PadElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+
+ /// Lays out a [`LineElem`].
+ fn layout_line(
+ elem: &Packed<LineElem>,
+ _: &mut Engine,
+ _: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`PathElem`].
+ fn layout_path(
+ elem: &Packed<PathElem>,
+ _: &mut Engine,
+ _: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`PolygonElem`].
+ fn layout_polygon(
+ elem: &Packed<PolygonElem>,
+ _: &mut Engine,
+ _: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`RectElem`].
+ fn layout_rect(
+ elem: &Packed<RectElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`SquareElem`].
+ fn layout_square(
+ elem: &Packed<SquareElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`EllipseElem`].
+ fn layout_ellipse(
+ elem: &Packed<EllipseElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out a [`CircleElem`].
+ fn layout_circle(
+ elem: &Packed<CircleElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out an [`ImageElem`].
+ fn layout_image(
+ elem: &Packed<ImageElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ ) -> SourceResult<Frame>
+
+ /// Lays out an [`EquationElem`] in a paragraph.
+ fn layout_equation_inline(
+ elem: &Packed<EquationElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Size,
+ ) -> SourceResult<Vec<InlineItem>>
+
+ /// Lays out an [`EquationElem`] in a flow.
+ fn layout_equation_block(
+ elem: &Packed<EquationElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>
+}
+
+/// In which mode to evaluate a string.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum EvalMode {
+ /// Evaluate as code, as after a hash.
+ Code,
+ /// Evaluate as markup, like in a Typst file.
+ Markup,
+ /// Evaluate as math, as in an equation.
+ Math,
+}
+
+/// Defines what kind of realization we are performing.
+pub enum RealizationKind<'a> {
+ /// This the root realization for the document. Requires a mutable reference
+ /// to document metadata that will be filled from `set document` rules.
+ Root(&'a mut DocumentInfo),
+ /// A nested realization in a container (e.g. a `block`).
+ Container,
+ /// A realization within math.
+ Math,
+}
+
+/// Temporary storage arenas for lifetime extension during realization.
+///
+/// Must be kept live while the content returned from realization is processed.
+#[derive(Default)]
+pub struct Arenas {
+ /// A typed arena for owned content.
+ pub content: typed_arena::Arena<Content>,
+ /// A typed arena for owned styles.
+ pub styles: typed_arena::Arena<Styles>,
+ /// An untyped arena for everything that is `Copy`.
+ pub bump: bumpalo::Bump,
+}
+
+/// A pair of content and a style chain that applies to it.
+pub type Pair<'a> = (&'a Content, StyleChain<'a>);
diff --git a/crates/typst-library/src/symbols/emoji.rs b/crates/typst-library/src/symbols/emoji.rs
new file mode 100644
index 00000000..1bd57ec0
--- /dev/null
+++ b/crates/typst-library/src/symbols/emoji.rs
@@ -0,0 +1,1363 @@
+use crate::foundations::{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", scope)
+}
+
+/// A list of named emoji.
+const EMOJI: &[(&str, Symbol)] = typst_macros::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: '🍻'],
+ beet: '🫜',
+ 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: '🥸',
+ distress: '😫',
+ 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: '🖕',
+ ],
+ fingerprint: '🫆',
+ 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: '🤝',
+ ],
+ harp: '🪉',
+ 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: '🔰',
+ shovel: '🪏',
+ 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: '🥬',
+ splatter: '🫟',
+ 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: '🌲',
+ leafless: '🪾',
+ 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..7e0fb55b
--- /dev/null
+++ b/crates/typst-library/src/symbols/mod.rs
@@ -0,0 +1,24 @@
+//! Modifiable symbols.
+
+mod emoji;
+mod sym;
+
+pub use self::emoji::*;
+pub use self::sym::*;
+
+use crate::foundations::{category, Category, Scope};
+
+/// These two modules give names to symbols and emoji to make them easy to
+/// insert with a normal keyboard. Alternatively, you can also always directly
+/// enter Unicode symbols into your text and formulas. In addition to the
+/// symbols listed below, math mode defines `dif` and `Dif`. These are not
+/// normal symbol values because they also affect spacing and font style.
+#[category]
+pub static SYMBOLS: Category;
+
+/// Hook up all `symbol` definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.category(SYMBOLS);
+ global.define_module(sym());
+ global.define_module(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..606e44ea
--- /dev/null
+++ b/crates/typst-library/src/symbols/sym.rs
@@ -0,0 +1,995 @@
+use crate::foundations::{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", scope)
+}
+
+/// The list of general symbols.
+pub(crate) const SYM: &[(&str, Symbol)] = typst_macros::symbols! {
+ // Control.
+ wj: '\u{2060}',
+ zwj: '\u{200D}',
+ zwnj: '\u{200C}',
+ zws: '\u{200B}',
+ lrm: '\u{200E}',
+ rlm: '\u{200F}',
+
+ // Spaces.
+ space: [
+ ' ',
+ nobreak: '\u{A0}',
+ nobreak.narrow: '\u{202F}',
+ 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: '(', l.double: '⦅', r: ')', r.double: '⦆', t: '⏜', b: '⏝'],
+ brace: [l: '{', l.double: '⦃', r: '}', r.double: '⦄', t: '⏞', b: '⏟'],
+ bracket: [l: '[', l.double: '⟦', r: ']', r.double: '⟧', t: '⎴', b: '⎵'],
+ shell: [l: '❲', l.double: '⟬', r: '❳', r.double: '⟭', t: '⏠', b: '⏡'],
+ bar: [v: '|', v.double: '‖', v.triple: '⦀', v.broken: '¦', v.circle: '⦶', h: '―'],
+ fence: [l: '⧘', l.double: '⧚', r: '⧙', r.double: '⧛', dotted: '⦙'],
+ angle: [
+ '∠',
+ l: '⟨',
+ l.curly: '⧼',
+ l.dot: '⦑',
+ l.double: '《',
+ r: '⟩',
+ r.curly: '⧽',
+ r.dot: '⦒',
+ r.double: '》',
+ acute: '⦟',
+ arc: '∡',
+ arc.rev: '⦛',
+ oblique: '⦦',
+ rev: '⦣',
+ right: '∟',
+ right.rev: '⯾',
+ right.arc: '⊾',
+ right.dot: '⦝',
+ right.sq: '⦜',
+ s: '⦞',
+ spatial: '⟀',
+ spheric: '∢',
+ spheric.rev: '⦠',
+ spheric.top: '⦡',
+ ],
+ ceil: [
+ #[call(crate::math::ceil)] l: '⌈',
+ r: '⌉',
+ ],
+ floor: [
+ #[call(crate::math::floor)] l: '⌊',
+ r: '⌋',
+ ],
+
+ // Punctuation.
+ amp: ['&', inv: '⅋'],
+ ast: [
+ op: '∗',
+ basic: '*',
+ low: '⁎',
+ double: '⁑',
+ triple: '⁂',
+ small: '﹡',
+ circle: '⊛',
+ square: '⧆',
+ ],
+ at: '@',
+ backslash: ['\\', circle: '⦸', not: '⧷'],
+ co: '℅',
+ colon: [':', double: '∷', eq: '≔', double.eq: '⩴'],
+ comma: ',',
+ dagger: ['†', double: '‡'],
+ dash: [
+ #[call(crate::math::accent::dash)] en: '–',
+ em: '—',
+ em.two: '⸺',
+ em.three: '⸻',
+ fig: '‒',
+ wave: '〜',
+ colon: '∹',
+ circle: '⊝',
+ wave.double: '〰',
+ ],
+ dot: [
+ #[call(crate::math::accent::dot)] op: '⋅',
+ basic: '.',
+ c: '·',
+ circle: '⊙',
+ circle.big: '⨀',
+ square: '⊡',
+ #[call(crate::math::accent::dot_double)] double: '¨',
+ #[call(crate::math::accent::dot_triple)] triple: '\u{20db}',
+ #[call(crate::math::accent::dot_quad)] 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: '%',
+ permille: '‰',
+ pilcrow: ['¶', rev: '⁋'],
+ section: '§',
+ semi: [';', rev: '⁏'],
+ slash: ['/', double: '⫽', triple: '⫻', big: '⧸'],
+ dots: [h.c: '⋯', h: '…', v: '⋮', down: '⋱', up: '⋰'],
+ tilde: [
+ #[call(crate::math::accent::tilde)] op: '∼',
+ basic: '~',
+ dot: '⩪',
+ eq: '≃',
+ eq.not: '≄',
+ eq.rev: '⋍',
+ equiv: '≅',
+ equiv.not: '≇',
+ nequiv: '≆',
+ not: '≁',
+ rev: '∽',
+ rev.equiv: '≌',
+ triple: '≋',
+ ],
+
+ // Accents, quotes, and primes.
+ acute: [
+ #[call(crate::math::accent::acute)] '´',
+ #[call(crate::math::accent::acute_double)] double: '˝',
+ ],
+ breve: #[call(crate::math::accent::breve)] '˘',
+ caret: '‸',
+ caron: #[call(crate::math::accent::caron)] 'ˇ',
+ hat: #[call(crate::math::accent::hat)] '^',
+ diaer: #[call(crate::math::accent::dot_double)] '¨',
+ grave: #[call(crate::math::accent::grave)] '`',
+ macron: #[call(crate::math::accent::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: '∔',
+ double: '⧺',
+ minus: '±',
+ small: '﹢',
+ square: '⊞',
+ triangle: '⨹',
+ triple: '⧻',
+ ],
+ 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: '⋟',
+ triple: '≡',
+ quad: '≣',
+ ],
+ gt: [
+ '>',
+ circle: '⧁',
+ dot: '⋗',
+ approx: '⪆',
+ double: '≫',
+ eq: '≥',
+ eq.slant: '⩾',
+ eq.lt: '⋛',
+ eq.not: '≱',
+ equiv: '≧',
+ lt: '≷',
+ lt.not: '≹',
+ neq: '⪈',
+ napprox: '⪊',
+ nequiv: '≩',
+ not: '≯',
+ ntilde: '⋧',
+ small: '﹥',
+ tilde: '≳',
+ tilde.not: '≵',
+ tri: '⊳',
+ tri.eq: '⊵',
+ tri.eq.not: '⋭',
+ tri.not: '⋫',
+ triple: '⋙',
+ triple.nested: '⫸',
+ ],
+ lt: [
+ '<',
+ circle: '⧀',
+ dot: '⋖',
+ approx: '⪅',
+ double: '≪',
+ eq: '≤',
+ eq.slant: '⩽' ,
+ eq.gt: '⋚',
+ eq.not: '≰',
+ equiv: '≦',
+ gt: '≶',
+ gt.not: '≸',
+ neq: '⪇',
+ napprox: '⪉',
+ nequiv: '≨',
+ not: '≮',
+ ntilde: '⋦',
+ small: '﹤',
+ tilde: '≲',
+ tilde.not: '≴',
+ tri: '⊲',
+ tri.eq: '⊴',
+ tri.eq.not: '⋬',
+ tri.not: '⋪',
+ triple: '⋘',
+ triple.nested: '⫷',
+ ],
+ approx: ['≈', eq: '≊', not: '≉'],
+ prec: [
+ '≺',
+ approx: '⪷',
+ curly.eq: '≼',
+ curly.eq.not: '⋠',
+ double: '⪻',
+ eq: '⪯',
+ equiv: '⪳',
+ napprox: '⪹',
+ neq: '⪱',
+ nequiv: '⪵',
+ not: '⊀',
+ ntilde: '⋨',
+ tilde: '≾',
+ ],
+ succ: [
+ '≻',
+ approx: '⪸',
+ curly.eq: '≽',
+ curly.eq.not: '⋡',
+ double: '⪼',
+ eq: '⪰',
+ equiv: '⪴',
+ napprox: '⪺',
+ neq: '⪲',
+ nequiv: '⪶',
+ not: '⊁',
+ ntilde: '⋩',
+ tilde: '≿',
+ ],
+ equiv: ['≡', not: '≢'],
+ prop: '∝',
+ original: '⊶',
+ image: '⊷',
+
+ // Set theory.
+ emptyset: [
+ '∅',
+ arrow.r: '⦳',
+ arrow.l: '⦴',
+ bar: '⦱',
+ circle: '⦲',
+ rev: '⦰',
+ ],
+ nothing: [
+ '∅',
+ arrow.r: '⦳',
+ arrow.l: '⦴',
+ bar: '⦱',
+ circle: '⦲',
+ 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: [
+ '∞',
+ bar: '⧞',
+ incomplete: '⧜',
+ tie: '⧝',
+ ],
+ oo: '∞',
+ diff: '∂', // Deprecation planned
+ partial: '∂',
+ gradient: '∇',
+ nabla: '∇',
+ sum: ['∑', integral: '⨋'],
+ product: ['∏', co: '∐'],
+ integral: [
+ '∫',
+ arrow.hook: '⨗',
+ ccw: '⨑',
+ cont: '∮',
+ cont.ccw: '∳',
+ cont.cw: '∲',
+ cw: '∱',
+ dash: '⨍',
+ dash.double: '⨎',
+ double: '∬',
+ quad: '⨌',
+ sect: '⨙',
+ slash: '⨏',
+ square: '⨖',
+ 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: '⊧',
+ forces: ['⊩', not: '⊮'],
+ therefore: '∴',
+ because: '∵',
+ qed: '∎',
+
+ // Function and category theory.
+ compose: '∘',
+ convolve: '∗',
+ multimap: ['⊸', double: '⧟'],
+
+ // Game theory.
+ tiny: '⧾',
+ miny: '⧿',
+
+ // Number theory.
+ divides: ['∣', not: '∤'],
+
+ // Algebra.
+ wreath: '≀',
+
+ // Geometry.
+ parallel: [
+ '∥',
+ struck: '⫲',
+ circle: '⦷',
+ eq: '⋕',
+ equiv: '⩨',
+ not: '∦',
+ slanted.eq: '⧣',
+ slanted.eq.tilde: '⧤',
+ slanted.equiv: '⧥',
+ tilde: '⫳',
+ ],
+ perp: ['⟂', circle: '⦹'],
+
+ // 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: ['☐', cross: '☒', check: '☑', check.heavy: '🗹'],
+ checkmark: ['✓', light: '🗸', heavy: '✔'],
+ crossmark: ['✗', heavy: '✘'],
+ floral: ['❦', l: '☙', r: '❧'],
+ refmark: '※',
+ copyright: ['©', sound: '℗'],
+ copyleft: '🄯',
+ trademark: ['™', registered: '®', service: '℠'],
+ maltese: '✠',
+ suit: [
+ club.filled: '♣',
+ club.stroked: '♧',
+ diamond.filled: '♦',
+ diamond.stroked: '♢',
+ heart.filled: '♥',
+ heart.stroked: '♡',
+ spade.filled: '♠',
+ spade.stroked: '♤',
+ ],
+
+ // Music.
+ note: [
+ up: '🎜',
+ down: '🎝',
+ whole: '𝅝',
+ half: '𝅗𝅥',
+ quarter: '𝅘𝅥',
+ quarter.alt: '♩',
+ eighth: '𝅘𝅥𝅮',
+ eighth.alt: '♪',
+ eighth.beamed: '♫',
+ sixteenth: '𝅘𝅥𝅯',
+ sixteenth.beamed: '♬',
+ grace: '𝆕',
+ grace.slash: '𝆔',
+ ],
+ rest: [
+ whole: '𝄻',
+ multiple: '𝄺',
+ multiple.measure: '𝄩',
+ half: '𝄼',
+ quarter: '𝄽',
+ eighth: '𝄾',
+ sixteenth: '𝄿',
+ ],
+ natural: [
+ '♮',
+ t: '𝄮',
+ b: '𝄯',
+ ],
+ flat: [
+ '♭',
+ t: '𝄬',
+ b: '𝄭',
+ double: '𝄫',
+ quarter: '𝄳',
+ ],
+ sharp: [
+ '♯',
+ t: '𝄰',
+ b: '𝄱',
+ double: '𝄪',
+ quarter: '𝄲',
+ ],
+
+ // Shapes.
+ bullet: '•',
+ circle: [
+ #[call(crate::math::accent::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.t: '△',
+ stroked.b: '▽',
+ stroked.r: '▷',
+ stroked.l: '◁',
+ stroked.bl: '◺',
+ stroked.br: '◿',
+ stroked.tl: '◸',
+ stroked.tr: '◹',
+ stroked.small.t: '▵',
+ stroked.small.b: '▿',
+ stroked.small.r: '▹',
+ stroked.small.l: '◃',
+ stroked.rounded: '🛆',
+ stroked.nested: '⟁',
+ stroked.dot: '◬',
+ filled.t: '▲',
+ filled.b: '▼',
+ filled.r: '▶',
+ filled.l: '◀',
+ filled.bl: '◣',
+ filled.br: '◢',
+ filled.tl: '◤',
+ filled.tr: '◥',
+ filled.small.t: '▴',
+ filled.small.b: '▾',
+ filled.small.r: '▸',
+ filled.small.l: '◂',
+ ],
+ 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: '⬧',
+ ],
+ parallelogram: [
+ stroked: '▱',
+ filled: '▰',
+ ],
+ star: [op: '⋆', stroked: '☆', filled: '★'],
+
+ // Arrows, harpoons, and tacks.
+ arrow: [
+ #[call(crate::math::accent::arrow)] r: '→',
+ r.long.bar: '⟼',
+ r.bar: '↦',
+ r.curve: '⤷',
+ r.turn: '⮎',
+ 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.tilde: '⥲',
+ r.triple: '⇛',
+ r.twohead.bar: '⤅',
+ r.twohead: '↠',
+ r.wave: '↝',
+ #[call(crate::math::accent::arrow_l)] l: '←',
+ l.bar: '↤',
+ l.curve: '⤶',
+ l.turn: '⮌',
+ 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.tilde: '⭉',
+ l.triple: '⇚',
+ l.twohead.bar: '⬶',
+ l.twohead: '↞',
+ l.wave: '↜',
+ t: '↑',
+ t.bar: '↥',
+ t.curve: '⤴',
+ t.turn: '⮍',
+ t.dashed: '⇡',
+ t.double: '⇑',
+ t.filled: '⬆',
+ t.quad: '⟰',
+ t.stop: '⤒',
+ t.stroked: '⇧',
+ t.triple: '⤊',
+ t.twohead: '↟',
+ b: '↓',
+ b.bar: '↧',
+ b.curve: '⤵',
+ b.turn: '⮏',
+ b.dashed: '⇣',
+ b.double: '⇓',
+ b.filled: '⬇',
+ b.quad: '⟱',
+ b.stop: '⤓',
+ b.stroked: '⇩',
+ b.triple: '⤋',
+ b.twohead: '↡',
+ #[call(crate::math::accent::arrow_l_r)] 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: [
+ #[call(crate::math::accent::harpoon)] rt: '⇀',
+ rt.bar: '⥛',
+ rt.stop: '⥓',
+ rb: '⇁',
+ rb.bar: '⥟',
+ rb.stop: '⥗',
+ #[call(crate::math::accent::harpoon_lt)] 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.
+ // In math, the following symbols are replaced with corresponding characters
+ // from Letterlike Symbols.
+ // See https://github.com/typst/typst/pull/3375.
+ aleph: 'א',
+ alef: 'א',
+ beth: 'ב',
+ bet: 'ב',
+ gimmel: 'ג',
+ gimel: 'ג',
+ daleth: 'ד',
+ dalet: 'ד',
+ 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/case.rs b/crates/typst-library/src/text/case.rs
new file mode 100644
index 00000000..69dbf5e1
--- /dev/null
+++ b/crates/typst-library/src/text/case.rs
@@ -0,0 +1,79 @@
+use crate::foundations::{cast, func, Cast, Content, Str};
+use crate::text::TextElem;
+
+/// Converts a string or content to lowercase.
+///
+/// # Example
+/// ```example
+/// #lower("ABC") \
+/// #lower[*My Text*] \
+/// #lower[already low]
+/// ```
+#[func(title = "Lowercase")]
+pub fn lower(
+ /// The text to convert to lowercase.
+ text: Caseable,
+) -> Caseable {
+ case(text, Case::Lower)
+}
+
+/// Converts a string or content to uppercase.
+///
+/// # Example
+/// ```example
+/// #upper("abc") \
+/// #upper[*my text*] \
+/// #upper[ALREADY HIGH]
+/// ```
+#[func(title = "Uppercase")]
+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(),
+ }
+ }
+}
diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs
new file mode 100644
index 00000000..5da7ecec
--- /dev/null
+++ b/crates/typst-library/src/text/deco.rs
@@ -0,0 +1,401 @@
+use smallvec::smallvec;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, Smart, StyleChain};
+use crate::layout::{Abs, Corners, Length, Rel, Sides};
+use crate::text::{BottomEdge, BottomEdgeMetric, TextElem, TopEdge, TopEdgeMetric};
+use crate::visualize::{Color, FixedStroke, Paint, Stroke};
+
+/// Underlines text.
+///
+/// # Example
+/// ```example
+/// This is #underline[important].
+/// ```
+#[elem(Show)]
+pub struct UnderlineElem {
+ /// How to [stroke] the line.
+ ///
+ /// 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<Stroke>,
+
+ /// 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,
+
+ /// Whether the line is placed behind the content it underlines.
+ ///
+ /// ```example
+ /// #set underline(stroke: (thickness: 1em, paint: maroon, cap: "round"))
+ /// #underline(background: true)[This is stylized.] \
+ /// #underline(background: false)[This is partially hidden.]
+ /// ```
+ #[default(false)]
+ pub background: bool,
+
+ /// The content to underline.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<UnderlineElem> {
+ #[typst_macros::time(name = "underline", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(TextElem::set_deco(smallvec![Decoration {
+ line: DecoLine::Underline {
+ stroke: self.stroke(styles).unwrap_or_default(),
+ offset: self.offset(styles),
+ evade: self.evade(styles),
+ background: self.background(styles),
+ },
+ extent: self.extent(styles),
+ }])))
+ }
+}
+
+/// Adds a line over text.
+///
+/// # Example
+/// ```example
+/// #overline[A line over text.]
+/// ```
+#[elem(Show)]
+pub struct OverlineElem {
+ /// How to [stroke] the line.
+ ///
+ /// 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<Stroke>,
+
+ /// 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,
+
+ /// Whether the line is placed behind the content it overlines.
+ ///
+ /// ```example
+ /// #set overline(stroke: (thickness: 1em, paint: maroon, cap: "round"))
+ /// #overline(background: true)[This is stylized.] \
+ /// #overline(background: false)[This is partially hidden.]
+ /// ```
+ #[default(false)]
+ pub background: bool,
+
+ /// The content to add a line over.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<OverlineElem> {
+ #[typst_macros::time(name = "overline", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(TextElem::set_deco(smallvec![Decoration {
+ line: DecoLine::Overline {
+ stroke: self.stroke(styles).unwrap_or_default(),
+ offset: self.offset(styles),
+ evade: self.evade(styles),
+ background: self.background(styles),
+ },
+ extent: self.extent(styles),
+ }])))
+ }
+}
+
+/// Strikes through text.
+///
+/// # Example
+/// ```example
+/// This is #strike[not] relevant.
+/// ```
+#[elem(title = "Strikethrough", Show)]
+pub struct StrikeElem {
+ /// How to [stroke] the line.
+ ///
+ /// 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<Stroke>,
+
+ /// 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,
+
+ /// Whether the line is placed behind the content.
+ ///
+ /// ```example
+ /// #set strike(stroke: red)
+ /// #strike(background: true)[This is behind.] \
+ /// #strike(background: false)[This is in front.]
+ /// ```
+ #[default(false)]
+ pub background: bool,
+
+ /// The content to strike through.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<StrikeElem> {
+ #[typst_macros::time(name = "strike", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(TextElem::set_deco(smallvec![Decoration {
+ // Note that we do not support evade option for strikethrough.
+ line: DecoLine::Strikethrough {
+ stroke: self.stroke(styles).unwrap_or_default(),
+ offset: self.offset(styles),
+ background: self.background(styles),
+ },
+ extent: self.extent(styles),
+ }])))
+ }
+}
+
+/// Highlights text with a background color.
+///
+/// # Example
+/// ```example
+/// This is #highlight[important].
+/// ```
+#[elem(Show)]
+pub struct HighlightElem {
+ /// The color to highlight the text with.
+ ///
+ /// ```example
+ /// This is #highlight(
+ /// fill: blue
+ /// )[highlighted with blue].
+ /// ```
+ #[default(Some(Color::from_u8(0xFF, 0xFD, 0x11, 0xA1).into()))]
+ pub fill: Option<Paint>,
+
+ /// The highlight's border color. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ ///
+ /// ```example
+ /// This is a #highlight(
+ /// stroke: fuchsia
+ /// )[stroked highlighting].
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Stroke>>>,
+
+ /// The top end of the background rectangle.
+ ///
+ /// ```example
+ /// #set highlight(top-edge: "ascender")
+ /// #highlight[a] #highlight[aib]
+ ///
+ /// #set highlight(top-edge: "x-height")
+ /// #highlight[a] #highlight[aib]
+ /// ```
+ #[default(TopEdge::Metric(TopEdgeMetric::Ascender))]
+ pub top_edge: TopEdge,
+
+ /// The bottom end of the background rectangle.
+ ///
+ /// ```example
+ /// #set highlight(bottom-edge: "descender")
+ /// #highlight[a] #highlight[ap]
+ ///
+ /// #set highlight(bottom-edge: "baseline")
+ /// #highlight[a] #highlight[ap]
+ /// ```
+ #[default(BottomEdge::Metric(BottomEdgeMetric::Descender))]
+ pub bottom_edge: BottomEdge,
+
+ /// The amount by which to extend the background to the sides beyond
+ /// (or within if negative) the content.
+ ///
+ /// ```example
+ /// A long #highlight(extent: 4pt)[background].
+ /// ```
+ #[resolve]
+ pub extent: Length,
+
+ /// How much to round the highlight's corners. See the
+ /// [rectangle's documentation]($rect.radius) for more details.
+ ///
+ /// ```example
+ /// Listen #highlight(
+ /// radius: 5pt, extent: 2pt
+ /// )[carefully], it will be on the test.
+ /// ```
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// The content that should be highlighted.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<HighlightElem> {
+ #[typst_macros::time(name = "highlight", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(TextElem::set_deco(smallvec![Decoration {
+ line: DecoLine::Highlight {
+ fill: self.fill(styles),
+ stroke: self
+ .stroke(styles)
+ .unwrap_or_default()
+ .map(|stroke| stroke.map(Stroke::unwrap_or_default)),
+ top_edge: self.top_edge(styles),
+ bottom_edge: self.bottom_edge(styles),
+ radius: self.radius(styles).unwrap_or_default(),
+ },
+ extent: self.extent(styles),
+ }])))
+ }
+}
+
+/// A text decoration.
+///
+/// Can be positioned over, under, or on top of text, or highlight the text with
+/// a background.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Decoration {
+ pub line: DecoLine,
+ pub extent: Abs,
+}
+
+/// A kind of decorative line.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum DecoLine {
+ Underline {
+ stroke: Stroke<Abs>,
+ offset: Smart<Abs>,
+ evade: bool,
+ background: bool,
+ },
+ Strikethrough {
+ stroke: Stroke<Abs>,
+ offset: Smart<Abs>,
+ background: bool,
+ },
+ Overline {
+ stroke: Stroke<Abs>,
+ offset: Smart<Abs>,
+ evade: bool,
+ background: bool,
+ },
+ Highlight {
+ fill: Option<Paint>,
+ stroke: Sides<Option<FixedStroke>>,
+ top_edge: TopEdge,
+ bottom_edge: BottomEdge,
+ radius: Corners<Rel<Abs>>,
+ },
+}
diff --git a/crates/typst-library/src/text/font/book.rs b/crates/typst-library/src/text/font/book.rs
new file mode 100644
index 00000000..23e27f64
--- /dev/null
+++ b/crates/typst-library/src/text/font/book.rs
@@ -0,0 +1,546 @@
+use std::cmp::Reverse;
+use std::collections::BTreeMap;
+use std::fmt::{self, Debug, Formatter};
+
+use serde::{Deserialize, Serialize};
+use ttf_parser::{name_id, PlatformId, Tag};
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::exceptions::find_exception;
+use crate::text::{Font, FontStretch, FontStyle, FontVariant, FontWeight};
+
+/// Metadata about a collection of fonts.
+#[derive(Debug, Default, Clone, Hash)]
+pub struct FontBook {
+ /// Maps from lowercased family names to font indices.
+ families: BTreeMap<String, Vec<usize>>,
+ /// Metadata about each font in the collection.
+ infos: Vec<FontInfo>,
+}
+
+impl FontBook {
+ /// Create a new, empty font book.
+ pub fn new() -> Self {
+ Self { families: BTreeMap::new(), infos: vec![] }
+ }
+
+ /// Create a font book from a collection of font infos.
+ pub fn from_infos(infos: impl IntoIterator<Item = FontInfo>) -> Self {
+ let mut book = Self::new();
+ for info in infos {
+ book.push(info);
+ }
+ book
+ }
+
+ /// Create a font book for a collection of fonts.
+ pub fn from_fonts<'a>(fonts: impl IntoIterator<Item = &'a Font>) -> Self {
+ Self::from_infos(fonts.into_iter().map(|font| font.info().clone()))
+ }
+
+ /// Insert metadata into the font book.
+ pub fn push(&mut self, info: FontInfo) {
+ let index = self.infos.len();
+ let family = info.family.to_lowercase();
+ self.families.entry(family).or_default().push(index);
+ self.infos.push(info);
+ }
+
+ /// Get the font info for the given index.
+ pub fn info(&self, index: usize) -> Option<&FontInfo> {
+ self.infos.get(index)
+ }
+
+ /// Returns true if the book contains a font family with the given name.
+ pub fn contains_family(&self, family: &str) -> bool {
+ self.families.contains_key(family)
+ }
+
+ /// An ordered iterator over all font families this book knows and details
+ /// about the fonts that are part of them.
+ pub fn families(
+ &self,
+ ) -> impl Iterator<Item = (&str, impl Iterator<Item = &FontInfo>)> + '_ {
+ // Since the keys are lowercased, we instead use the family field of the
+ // first face's info.
+ self.families.values().map(|ids| {
+ let family = self.infos[ids[0]].family.as_str();
+ let infos = ids.iter().map(|&id| &self.infos[id]);
+ (family, infos)
+ })
+ }
+
+ /// Try to find a font from the given `family` that matches the given
+ /// `variant` as closely as possible.
+ ///
+ /// The `family` should be all lowercase.
+ pub fn select(&self, family: &str, variant: FontVariant) -> Option<usize> {
+ let ids = self.families.get(family)?;
+ self.find_best_variant(None, variant, ids.iter().copied())
+ }
+
+ /// Iterate over all variants of a family.
+ pub fn select_family(&self, family: &str) -> impl Iterator<Item = usize> + '_ {
+ self.families
+ .get(family)
+ .map(|vec| vec.as_slice())
+ .unwrap_or_default()
+ .iter()
+ .copied()
+ }
+
+ /// Try to find and load a fallback font that
+ /// - is as close as possible to the font `like` (if any)
+ /// - is as close as possible to the given `variant`
+ /// - is suitable for shaping the given `text`
+ pub fn select_fallback(
+ &self,
+ like: Option<&FontInfo>,
+ variant: FontVariant,
+ text: &str,
+ ) -> Option<usize> {
+ // Find the fonts that contain the text's first non-space char ...
+ let c = text.chars().find(|c| !c.is_whitespace())?;
+ let ids = self
+ .infos
+ .iter()
+ .enumerate()
+ .filter(|(_, info)| info.coverage.contains(c as u32))
+ .map(|(index, _)| index);
+
+ // ... and find the best variant among them.
+ self.find_best_variant(like, variant, ids)
+ }
+
+ /// Find the font in the passed iterator that
+ /// - is closest to the font `like` (if any)
+ /// - is closest to the given `variant`
+ ///
+ /// To do that we compute a key for all variants and select the one with the
+ /// minimal key. This key prioritizes:
+ /// - If `like` is some other font:
+ /// - Are both fonts (not) monospaced?
+ /// - Do both fonts (not) have serifs?
+ /// - How many words do the families share in their prefix? E.g. "Noto
+ /// Sans" and "Noto Sans Arabic" share two words, whereas "IBM Plex
+ /// Arabic" shares none with "Noto Sans", so prefer "Noto Sans Arabic"
+ /// if `like` is "Noto Sans". In case there are two equally good
+ /// matches, we prefer the shorter one because it is less special (e.g.
+ /// if `like` is "Noto Sans Arabic", we prefer "Noto Sans" over "Noto
+ /// Sans CJK HK".)
+ /// - The style (normal / italic / oblique). If we want italic or oblique
+ /// but it doesn't exist, the other one of the two is still better than
+ /// normal.
+ /// - The absolute distance to the target stretch.
+ /// - The absolute distance to the target weight.
+ fn find_best_variant(
+ &self,
+ like: Option<&FontInfo>,
+ variant: FontVariant,
+ ids: impl IntoIterator<Item = usize>,
+ ) -> Option<usize> {
+ let mut best = None;
+ let mut best_key = None;
+
+ for id in ids {
+ let current = &self.infos[id];
+ let key = (
+ like.map(|like| {
+ (
+ current.flags.contains(FontFlags::MONOSPACE)
+ != like.flags.contains(FontFlags::MONOSPACE),
+ current.flags.contains(FontFlags::SERIF)
+ != like.flags.contains(FontFlags::SERIF),
+ Reverse(shared_prefix_words(&current.family, &like.family)),
+ current.family.len(),
+ )
+ }),
+ current.variant.style.distance(variant.style),
+ current.variant.stretch.distance(variant.stretch),
+ current.variant.weight.distance(variant.weight),
+ );
+
+ if best_key.map_or(true, |b| key < b) {
+ best = Some(id);
+ best_key = Some(key);
+ }
+ }
+
+ best
+ }
+}
+
+/// Properties of a single font.
+#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
+pub struct FontInfo {
+ /// The typographic font family this font is part of.
+ pub family: String,
+ /// Properties that distinguish this font from other fonts in the same
+ /// family.
+ pub variant: FontVariant,
+ /// Properties of the font.
+ pub flags: FontFlags,
+ /// The unicode coverage of the font.
+ pub coverage: Coverage,
+}
+
+bitflags::bitflags! {
+ /// Bitflags describing characteristics of a font.
+ #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
+ #[derive(Serialize, Deserialize)]
+ #[serde(transparent)]
+ pub struct FontFlags: u32 {
+ /// All glyphs have the same width.
+ const MONOSPACE = 1 << 0;
+ /// Glyphs have short strokes at their stems.
+ const SERIF = 1 << 1;
+ }
+}
+
+impl FontInfo {
+ /// Compute metadata for font at the `index` of the given data.
+ pub fn new(data: &[u8], index: u32) -> Option<Self> {
+ let ttf = ttf_parser::Face::parse(data, index).ok()?;
+ Self::from_ttf(&ttf)
+ }
+
+ /// Compute metadata for all fonts in the given data.
+ pub fn iter(data: &[u8]) -> impl Iterator<Item = FontInfo> + '_ {
+ let count = ttf_parser::fonts_in_collection(data).unwrap_or(1);
+ (0..count).filter_map(move |index| Self::new(data, index))
+ }
+
+ /// Compute metadata for a single ttf-parser face.
+ pub(super) fn from_ttf(ttf: &ttf_parser::Face) -> Option<Self> {
+ let ps_name = find_name(ttf, name_id::POST_SCRIPT_NAME);
+ let exception = ps_name.as_deref().and_then(find_exception);
+ // We cannot use Name ID 16 "Typographic Family", because for some
+ // fonts it groups together more than just Style / Weight / Stretch
+ // variants (e.g. Display variants of Noto fonts) and then some
+ // variants become inaccessible from Typst. And even though the
+ // fsSelection bit WWS should help us decide whether that is the
+ // case, it's wrong for some fonts (e.g. for certain variants of "Noto
+ // Sans Display").
+ //
+ // So, instead we use Name ID 1 "Family" and trim many common
+ // suffixes for which know that they just describe styling (e.g.
+ // "ExtraBold").
+ let family =
+ exception.and_then(|c| c.family.map(str::to_string)).or_else(|| {
+ let family = find_name(ttf, name_id::FAMILY)?;
+ Some(typographic_family(&family).to_string())
+ })?;
+
+ let variant = {
+ let style = exception.and_then(|c| c.style).unwrap_or_else(|| {
+ let mut full = find_name(ttf, name_id::FULL_NAME).unwrap_or_default();
+ full.make_ascii_lowercase();
+
+ // Some fonts miss the relevant bits for italic or oblique, so
+ // we also try to infer that from the full name.
+ let italic = ttf.is_italic() || full.contains("italic");
+ let oblique = ttf.is_oblique()
+ || full.contains("oblique")
+ || full.contains("slanted");
+
+ match (italic, oblique) {
+ (false, false) => FontStyle::Normal,
+ (true, _) => FontStyle::Italic,
+ (_, true) => FontStyle::Oblique,
+ }
+ });
+
+ let weight = exception.and_then(|c| c.weight).unwrap_or_else(|| {
+ let number = ttf.weight().to_number();
+ FontWeight::from_number(number)
+ });
+
+ let stretch = exception
+ .and_then(|c| c.stretch)
+ .unwrap_or_else(|| FontStretch::from_number(ttf.width().to_number()));
+
+ FontVariant { style, weight, stretch }
+ };
+
+ // Determine the unicode coverage.
+ let mut codepoints = vec![];
+ for subtable in ttf.tables().cmap.into_iter().flat_map(|table| table.subtables) {
+ if subtable.is_unicode() {
+ subtable.codepoints(|c| codepoints.push(c));
+ }
+ }
+
+ let mut flags = FontFlags::empty();
+ flags.set(FontFlags::MONOSPACE, ttf.is_monospaced());
+
+ // Determine whether this is a serif or sans-serif font.
+ if let Some(panose) = ttf
+ .raw_face()
+ .table(Tag::from_bytes(b"OS/2"))
+ .and_then(|os2| os2.get(32..45))
+ {
+ if matches!(panose, [2, 2..=10, ..]) {
+ flags.insert(FontFlags::SERIF);
+ }
+ }
+
+ Some(FontInfo {
+ family,
+ variant,
+ flags,
+ coverage: Coverage::from_vec(codepoints),
+ })
+ }
+
+ /// Whether this is the macOS LastResort font. It can yield tofus with
+ /// glyph ID != 0.
+ pub fn is_last_resort(&self) -> bool {
+ self.family == "LastResort"
+ }
+}
+
+/// Try to find and decode the name with the given id.
+pub(super) fn find_name(ttf: &ttf_parser::Face, name_id: u16) -> Option<String> {
+ ttf.names().into_iter().find_map(|entry| {
+ if entry.name_id == name_id {
+ if let Some(string) = entry.to_string() {
+ return Some(string);
+ }
+
+ if entry.platform_id == PlatformId::Macintosh && entry.encoding_id == 0 {
+ return Some(decode_mac_roman(entry.name));
+ }
+ }
+
+ None
+ })
+}
+
+/// Decode mac roman encoded bytes into a string.
+fn decode_mac_roman(coded: &[u8]) -> String {
+ #[rustfmt::skip]
+ const TABLE: [char; 128] = [
+ 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è',
+ 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü',
+ '†', '°', '¢', '£', '§', '•', '¶', 'ß', '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø',
+ '∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø',
+ '¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', '»', '…', '\u{a0}', 'À', 'Ã', 'Õ', 'Œ', 'œ',
+ '–', '—', '“', '”', '‘', '’', '÷', '◊', 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', 'fi', 'fl',
+ '‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô',
+ '\u{f8ff}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ',
+ ];
+
+ fn char_from_mac_roman(code: u8) -> char {
+ if code < 128 {
+ code as char
+ } else {
+ TABLE[(code - 128) as usize]
+ }
+ }
+
+ coded.iter().copied().map(char_from_mac_roman).collect()
+}
+
+/// Trim style naming from a family name and fix bad names.
+fn typographic_family(mut family: &str) -> &str {
+ // Separators between names, modifiers and styles.
+ const SEPARATORS: [char; 3] = [' ', '-', '_'];
+
+ // Modifiers that can appear in combination with suffixes.
+ const MODIFIERS: &[&str] =
+ &["extra", "ext", "ex", "x", "semi", "sem", "sm", "demi", "dem", "ultra"];
+
+ // Style suffixes.
+ #[rustfmt::skip]
+ const SUFFIXES: &[&str] = &[
+ "normal", "italic", "oblique", "slanted",
+ "thin", "th", "hairline", "light", "lt", "regular", "medium", "med",
+ "md", "bold", "bd", "demi", "extb", "black", "blk", "bk", "heavy",
+ "narrow", "condensed", "cond", "cn", "cd", "compressed", "expanded", "exp"
+ ];
+
+ // Trim spacing and weird leading dots in Apple fonts.
+ family = family.trim().trim_start_matches('.');
+
+ // Lowercase the string so that the suffixes match case-insensitively.
+ let lower = family.to_ascii_lowercase();
+ let mut len = usize::MAX;
+ let mut trimmed = lower.as_str();
+
+ // Trim style suffixes repeatedly.
+ while trimmed.len() < len {
+ len = trimmed.len();
+
+ // Find style suffix.
+ let mut t = trimmed;
+ let mut shortened = false;
+ while let Some(s) = SUFFIXES.iter().find_map(|s| t.strip_suffix(s)) {
+ shortened = true;
+ t = s;
+ }
+
+ if !shortened {
+ break;
+ }
+
+ // Strip optional separator.
+ if let Some(s) = t.strip_suffix(SEPARATORS) {
+ trimmed = s;
+ t = s;
+ }
+
+ // Also allow an extra modifier, but apply it only if it is separated it
+ // from the text before it (to prevent false positives).
+ if let Some(t) = MODIFIERS.iter().find_map(|s| t.strip_suffix(s)) {
+ if let Some(stripped) = t.strip_suffix(SEPARATORS) {
+ trimmed = stripped;
+ }
+ }
+ }
+
+ // Apply style suffix trimming.
+ family = &family[..len];
+
+ family
+}
+
+/// How many words the two strings share in their prefix.
+fn shared_prefix_words(left: &str, right: &str) -> usize {
+ left.unicode_words()
+ .zip(right.unicode_words())
+ .take_while(|(l, r)| l == r)
+ .count()
+}
+
+/// A compactly encoded set of codepoints.
+///
+/// The set is represented by alternating specifications of how many codepoints
+/// are not in the set and how many are in the set.
+///
+/// For example, for the set `{2, 3, 4, 9, 10, 11, 15, 18, 19}`, there are:
+/// - 2 codepoints not inside (0, 1)
+/// - 3 codepoints inside (2, 3, 4)
+/// - 4 codepoints not inside (5, 6, 7, 8)
+/// - 3 codepoints inside (9, 10, 11)
+/// - 3 codepoints not inside (12, 13, 14)
+/// - 1 codepoint inside (15)
+/// - 2 codepoints not inside (16, 17)
+/// - 2 codepoints inside (18, 19)
+///
+/// So the resulting encoding is `[2, 3, 4, 3, 3, 1, 2, 2]`.
+#[derive(Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct Coverage(Vec<u32>);
+
+impl Coverage {
+ /// Encode a vector of codepoints.
+ pub fn from_vec(mut codepoints: Vec<u32>) -> Self {
+ codepoints.sort();
+ codepoints.dedup();
+
+ let mut runs = Vec::new();
+ let mut next = 0;
+
+ for c in codepoints {
+ if let Some(run) = runs.last_mut().filter(|_| c == next) {
+ *run += 1;
+ } else {
+ runs.push(c - next);
+ runs.push(1);
+ }
+
+ next = c + 1;
+ }
+
+ Self(runs)
+ }
+
+ /// Whether the codepoint is covered.
+ pub fn contains(&self, c: u32) -> bool {
+ let mut inside = false;
+ let mut cursor = 0;
+
+ for &run in &self.0 {
+ if (cursor..cursor + run).contains(&c) {
+ return inside;
+ }
+ cursor += run;
+ inside = !inside;
+ }
+
+ false
+ }
+
+ /// Iterate over all covered codepoints.
+ pub fn iter(&self) -> impl Iterator<Item = u32> + '_ {
+ let mut inside = false;
+ let mut cursor = 0;
+ self.0.iter().flat_map(move |run| {
+ let range = if inside { cursor..cursor + run } else { 0..0 };
+ inside = !inside;
+ cursor += run;
+ range
+ })
+ }
+}
+
+impl Debug for Coverage {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("Coverage(..)")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_trim_styles() {
+ assert_eq!(typographic_family("Atma Light"), "Atma");
+ assert_eq!(typographic_family("eras bold"), "eras");
+ assert_eq!(typographic_family("footlight mt light"), "footlight mt");
+ assert_eq!(typographic_family("times new roman"), "times new roman");
+ assert_eq!(typographic_family("noto sans mono cond sembd"), "noto sans mono");
+ assert_eq!(typographic_family("noto serif SEMCOND sembd"), "noto serif");
+ assert_eq!(typographic_family("crimson text"), "crimson text");
+ assert_eq!(typographic_family("footlight light"), "footlight");
+ assert_eq!(typographic_family("Noto Sans"), "Noto Sans");
+ assert_eq!(typographic_family("Noto Sans Light"), "Noto Sans");
+ assert_eq!(typographic_family("Noto Sans Semicondensed Heavy"), "Noto Sans");
+ assert_eq!(typographic_family("Familx"), "Familx");
+ assert_eq!(typographic_family("Font Ultra"), "Font Ultra");
+ assert_eq!(typographic_family("Font Ultra Bold"), "Font");
+ }
+
+ #[test]
+ fn test_coverage() {
+ #[track_caller]
+ fn test(set: &[u32], runs: &[u32]) {
+ let coverage = Coverage::from_vec(set.to_vec());
+ assert_eq!(coverage.0, runs);
+
+ let max = 5 + set.iter().copied().max().unwrap_or_default();
+ for c in 0..max {
+ assert_eq!(set.contains(&c), coverage.contains(c));
+ }
+ }
+
+ test(&[], &[]);
+ test(&[0], &[0, 1]);
+ test(&[1], &[1, 1]);
+ test(&[0, 1], &[0, 2]);
+ test(&[0, 1, 3], &[0, 2, 1, 1]);
+ test(
+ // {2, 3, 4, 9, 10, 11, 15, 18, 19}
+ &[18, 19, 2, 4, 9, 11, 15, 3, 3, 10],
+ &[2, 3, 4, 3, 3, 1, 2, 2],
+ )
+ }
+
+ #[test]
+ fn test_coverage_iter() {
+ let codepoints = vec![2, 3, 7, 8, 9, 14, 15, 19, 21];
+ let coverage = Coverage::from_vec(codepoints.clone());
+ assert_eq!(coverage.iter().collect::<Vec<_>>(), codepoints);
+ }
+}
diff --git a/crates/typst-library/src/text/font/color.rs b/crates/typst-library/src/text/font/color.rs
new file mode 100644
index 00000000..08f6fe0a
--- /dev/null
+++ b/crates/typst-library/src/text/font/color.rs
@@ -0,0 +1,627 @@
+//! Utilities for color font handling
+
+use std::io::Read;
+
+use ttf_parser::{GlyphId, RgbaColor};
+use typst_syntax::Span;
+use usvg::tiny_skia_path;
+use xmlwriter::XmlWriter;
+
+use crate::layout::{Abs, Frame, FrameItem, Point, Size};
+use crate::text::{Font, Glyph};
+use crate::visualize::{FixedStroke, Geometry, Image, RasterFormat, VectorFormat};
+
+/// Whether this glyph should be rendered via simple outlining instead of via
+/// `glyph_frame`.
+pub fn should_outline(font: &Font, glyph: &Glyph) -> bool {
+ let ttf = font.ttf();
+ let glyph_id = GlyphId(glyph.id);
+ (ttf.tables().glyf.is_some() || ttf.tables().cff.is_some())
+ && !ttf
+ .glyph_raster_image(glyph_id, u16::MAX)
+ .is_some_and(|img| img.format == ttf_parser::RasterImageFormat::PNG)
+ && !ttf.is_color_glyph(glyph_id)
+ && ttf.glyph_svg_image(glyph_id).is_none()
+}
+
+/// Returns a frame representing a glyph and whether it is a fallback tofu
+/// frame.
+///
+/// Should only be called on glyphs for which [`should_outline`] returns false.
+///
+/// The glyphs are sized in font units, [`text.item.size`] is not taken into
+/// account.
+#[comemo::memoize]
+pub fn glyph_frame(font: &Font, glyph_id: u16) -> (Frame, bool) {
+ let upem = Abs::pt(font.units_per_em());
+ let glyph_id = GlyphId(glyph_id);
+
+ let mut frame = Frame::soft(Size::splat(upem));
+ let mut tofu = false;
+
+ if draw_glyph(&mut frame, font, upem, glyph_id).is_none()
+ && font.ttf().glyph_index(' ') != Some(glyph_id)
+ {
+ // Generate a fallback tofu if the glyph couldn't be drawn, unless it is
+ // the space glyph. Then, an empty frame does the job. (This happens for
+ // some rare CBDT fonts, which don't define a bitmap for the space, but
+ // also don't have a glyf or CFF table.)
+ draw_fallback_tofu(&mut frame, font, upem, glyph_id);
+ tofu = true;
+ }
+
+ (frame, tofu)
+}
+
+/// Tries to draw a glyph.
+fn draw_glyph(
+ frame: &mut Frame,
+ font: &Font,
+ upem: Abs,
+ glyph_id: GlyphId,
+) -> Option<()> {
+ let ttf = font.ttf();
+ if let Some(raster_image) = ttf
+ .glyph_raster_image(glyph_id, u16::MAX)
+ .filter(|img| img.format == ttf_parser::RasterImageFormat::PNG)
+ {
+ draw_raster_glyph(frame, font, upem, raster_image)
+ } else if ttf.is_color_glyph(glyph_id) {
+ draw_colr_glyph(frame, font, upem, glyph_id)
+ } else if ttf.glyph_svg_image(glyph_id).is_some() {
+ draw_svg_glyph(frame, font, upem, glyph_id)
+ } else {
+ None
+ }
+}
+
+/// Draws a fallback tofu box with the advance width of the glyph.
+fn draw_fallback_tofu(frame: &mut Frame, font: &Font, upem: Abs, glyph_id: GlyphId) {
+ let advance = font
+ .ttf()
+ .glyph_hor_advance(glyph_id)
+ .map(|advance| Abs::pt(advance as f64))
+ .unwrap_or(upem / 3.0);
+ let inset = 0.15 * advance;
+ let height = 0.7 * upem;
+ let pos = Point::new(inset, upem - height);
+ let size = Size::new(advance - inset * 2.0, height);
+ let thickness = upem / 20.0;
+ let stroke = FixedStroke { thickness, ..Default::default() };
+ let shape = Geometry::Rect(size).stroked(stroke);
+ frame.push(pos, FrameItem::Shape(shape, Span::detached()));
+}
+
+/// Draws a raster glyph in a frame.
+///
+/// Supports only PNG images.
+fn draw_raster_glyph(
+ frame: &mut Frame,
+ font: &Font,
+ upem: Abs,
+ raster_image: ttf_parser::RasterGlyphImage,
+) -> Option<()> {
+ let image =
+ Image::new(raster_image.data.into(), RasterFormat::Png.into(), None).ok()?;
+
+ // Apple Color emoji doesn't provide offset information (or at least
+ // not in a way ttf-parser understands), so we artificially shift their
+ // baseline to make it look good.
+ let y_offset = if font.info().family.to_lowercase() == "apple color emoji" {
+ 20.0
+ } else {
+ -(raster_image.y as f64)
+ };
+
+ let position = Point::new(
+ upem * raster_image.x as f64 / raster_image.pixels_per_em as f64,
+ upem * y_offset / raster_image.pixels_per_em as f64,
+ );
+ let aspect_ratio = image.width() / image.height();
+ let size = Size::new(upem, upem * aspect_ratio);
+ frame.push(position, FrameItem::Image(image, size, Span::detached()));
+
+ Some(())
+}
+
+/// Draws a glyph from the COLR table into the frame.
+fn draw_colr_glyph(
+ frame: &mut Frame,
+ font: &Font,
+ upem: Abs,
+ glyph_id: GlyphId,
+) -> Option<()> {
+ let mut svg = XmlWriter::new(xmlwriter::Options::default());
+
+ let ttf = font.ttf();
+ let width = ttf.global_bounding_box().width() as f64;
+ let height = ttf.global_bounding_box().height() as f64;
+ let x_min = ttf.global_bounding_box().x_min as f64;
+ let y_max = ttf.global_bounding_box().y_max as f64;
+ let tx = -x_min;
+ let ty = -y_max;
+
+ svg.start_element("svg");
+ svg.write_attribute("xmlns", "http://www.w3.org/2000/svg");
+ svg.write_attribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
+ svg.write_attribute("width", &width);
+ svg.write_attribute("height", &height);
+ svg.write_attribute_fmt("viewBox", format_args!("0 0 {width} {height}"));
+
+ let mut path_buf = String::with_capacity(256);
+ let gradient_index = 1;
+ let clip_path_index = 1;
+
+ svg.start_element("g");
+ svg.write_attribute_fmt(
+ "transform",
+ format_args!("matrix(1 0 0 -1 0 0) matrix(1 0 0 1 {tx} {ty})"),
+ );
+
+ let mut glyph_painter = GlyphPainter {
+ face: ttf,
+ svg: &mut svg,
+ path_buf: &mut path_buf,
+ gradient_index,
+ clip_path_index,
+ palette_index: 0,
+ transform: ttf_parser::Transform::default(),
+ outline_transform: ttf_parser::Transform::default(),
+ transforms_stack: vec![ttf_parser::Transform::default()],
+ };
+
+ ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter)?;
+ svg.end_element();
+
+ let data = svg.end_document().into_bytes();
+
+ let image = Image::new(data.into(), VectorFormat::Svg.into(), None).ok()?;
+
+ let y_shift = Abs::pt(upem.to_pt() - y_max);
+ let position = Point::new(Abs::pt(x_min), y_shift);
+ let size = Size::new(Abs::pt(width), Abs::pt(height));
+ frame.push(position, FrameItem::Image(image, size, Span::detached()));
+
+ Some(())
+}
+
+/// Draws an SVG glyph in a frame.
+fn draw_svg_glyph(
+ frame: &mut Frame,
+ font: &Font,
+ upem: Abs,
+ glyph_id: GlyphId,
+) -> Option<()> {
+ // TODO: Our current conversion of the SVG table works for Twitter Color Emoji,
+ // but might not work for others. See also: https://github.com/RazrFalcon/resvg/pull/776
+ let mut data = font.ttf().glyph_svg_image(glyph_id)?.data;
+
+ // Decompress SVGZ.
+ let mut decoded = vec![];
+ if data.starts_with(&[0x1f, 0x8b]) {
+ let mut decoder = flate2::read::GzDecoder::new(data);
+ decoder.read_to_end(&mut decoded).ok()?;
+ data = &decoded;
+ }
+
+ // Parse XML.
+ let xml = std::str::from_utf8(data).ok()?;
+ let document = roxmltree::Document::parse(xml).ok()?;
+
+ // Parse SVG.
+ let opts = usvg::Options::default();
+ let tree = usvg::Tree::from_xmltree(&document, &opts).ok()?;
+
+ let bbox = tree.root().bounding_box();
+ let width = bbox.width() as f64;
+ let height = bbox.height() as f64;
+ let left = bbox.left() as f64;
+ let top = bbox.top() as f64;
+
+ let mut data = tree.to_string(&usvg::WriteOptions::default());
+
+ // The SVG coordinates and the font coordinates are not the same: the Y axis
+ // is mirrored. But the origin of the axes are the same (which means that
+ // the horizontal axis in the SVG document corresponds to the baseline). See
+ // the reference for more details:
+ // https://learn.microsoft.com/en-us/typography/opentype/spec/svg#coordinate-systems-and-glyph-metrics
+ //
+ // If we used the SVG document as it is, svg2pdf would produce a cropped
+ // glyph (only what is under the baseline would be visible). So we need to
+ // embed the original SVG in another one that has the exact dimensions of
+ // the glyph, with a transform to make it fit. We also need to remove the
+ // viewBox, height and width attributes from the inner SVG, otherwise usvg
+ // takes into account these values to clip the embedded SVG.
+ make_svg_unsized(&mut data);
+ let wrapper_svg = format!(
+ r#"
+ <svg
+ width="{width}"
+ height="{height}"
+ viewBox="0 0 {width} {height}"
+ xmlns="http://www.w3.org/2000/svg">
+ <g transform="matrix(1 0 0 1 {tx} {ty})">
+ {inner}
+ </g>
+ </svg>
+ "#,
+ inner = data,
+ tx = -left,
+ ty = -top,
+ );
+
+ let image =
+ Image::new(wrapper_svg.into_bytes().into(), VectorFormat::Svg.into(), None)
+ .ok()?;
+
+ let position = Point::new(Abs::pt(left), Abs::pt(top) + upem);
+ let size = Size::new(Abs::pt(width), Abs::pt(height));
+ frame.push(position, FrameItem::Image(image, size, Span::detached()));
+
+ Some(())
+}
+
+/// Remove all size specifications (viewBox, width and height attributes) from a
+/// SVG document.
+fn make_svg_unsized(svg: &mut String) {
+ let mut viewbox_range = None;
+ let mut width_range = None;
+ let mut height_range = None;
+
+ let mut s = unscanny::Scanner::new(svg);
+
+ s.eat_until("<svg");
+ s.eat_if("<svg");
+ while !s.eat_if('>') && !s.done() {
+ s.eat_whitespace();
+ let start = s.cursor();
+ let attr_name = s.eat_until('=').trim();
+ // Eat the equal sign and the quote.
+ s.eat();
+ s.eat();
+ let mut escaped = false;
+ while (escaped || !s.eat_if('"')) && !s.done() {
+ escaped = s.eat() == Some('\\');
+ }
+ match attr_name {
+ "viewBox" => viewbox_range = Some(start..s.cursor()),
+ "width" => width_range = Some(start..s.cursor()),
+ "height" => height_range = Some(start..s.cursor()),
+ _ => {}
+ }
+ }
+
+ // Remove the `viewBox` attribute.
+ if let Some(range) = viewbox_range {
+ svg.replace_range(range.clone(), &" ".repeat(range.len()));
+ }
+
+ // Remove the `width` attribute.
+ if let Some(range) = width_range {
+ svg.replace_range(range.clone(), &" ".repeat(range.len()));
+ }
+
+ // Remove the `height` attribute.
+ if let Some(range) = height_range {
+ svg.replace_range(range, "");
+ }
+}
+
+struct ColrBuilder<'a>(&'a mut String);
+
+impl ColrBuilder<'_> {
+ fn finish(&mut self) {
+ if !self.0.is_empty() {
+ self.0.pop(); // remove trailing space
+ }
+ }
+}
+
+impl ttf_parser::OutlineBuilder for ColrBuilder<'_> {
+ fn move_to(&mut self, x: f32, y: f32) {
+ use std::fmt::Write;
+ write!(self.0, "M {x} {y} ").unwrap()
+ }
+
+ fn line_to(&mut self, x: f32, y: f32) {
+ use std::fmt::Write;
+ write!(self.0, "L {x} {y} ").unwrap()
+ }
+
+ fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+ use std::fmt::Write;
+ write!(self.0, "Q {x1} {y1} {x} {y} ").unwrap()
+ }
+
+ fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+ use std::fmt::Write;
+ write!(self.0, "C {x1} {y1} {x2} {y2} {x} {y} ").unwrap()
+ }
+
+ fn close(&mut self) {
+ self.0.push_str("Z ")
+ }
+}
+
+// NOTE: This is only a best-effort translation of COLR into SVG. It's not feature-complete
+// and it's also not possible to make it feature-complete using just raw SVG features.
+pub(crate) struct GlyphPainter<'a> {
+ pub(crate) face: &'a ttf_parser::Face<'a>,
+ pub(crate) svg: &'a mut xmlwriter::XmlWriter,
+ pub(crate) path_buf: &'a mut String,
+ pub(crate) gradient_index: usize,
+ pub(crate) clip_path_index: usize,
+ pub(crate) palette_index: u16,
+ pub(crate) transform: ttf_parser::Transform,
+ pub(crate) outline_transform: ttf_parser::Transform,
+ pub(crate) transforms_stack: Vec<ttf_parser::Transform>,
+}
+
+impl<'a> GlyphPainter<'a> {
+ fn write_gradient_stops(&mut self, stops: ttf_parser::colr::GradientStopsIter) {
+ for stop in stops {
+ self.svg.start_element("stop");
+ self.svg.write_attribute("offset", &stop.stop_offset);
+ self.write_color_attribute("stop-color", stop.color);
+ let opacity = f32::from(stop.color.alpha) / 255.0;
+ self.svg.write_attribute("stop-opacity", &opacity);
+ self.svg.end_element();
+ }
+ }
+
+ fn write_color_attribute(&mut self, name: &str, color: ttf_parser::RgbaColor) {
+ self.svg.write_attribute_fmt(
+ name,
+ format_args!("rgb({}, {}, {})", color.red, color.green, color.blue),
+ );
+ }
+
+ fn write_transform_attribute(&mut self, name: &str, ts: ttf_parser::Transform) {
+ if ts.is_default() {
+ return;
+ }
+
+ self.svg.write_attribute_fmt(
+ name,
+ format_args!("matrix({} {} {} {} {} {})", ts.a, ts.b, ts.c, ts.d, ts.e, ts.f),
+ );
+ }
+
+ fn write_spread_method_attribute(
+ &mut self,
+ extend: ttf_parser::colr::GradientExtend,
+ ) {
+ self.svg.write_attribute(
+ "spreadMethod",
+ match extend {
+ ttf_parser::colr::GradientExtend::Pad => &"pad",
+ ttf_parser::colr::GradientExtend::Repeat => &"repeat",
+ ttf_parser::colr::GradientExtend::Reflect => &"reflect",
+ },
+ );
+ }
+
+ fn paint_solid(&mut self, color: ttf_parser::RgbaColor) {
+ self.svg.start_element("path");
+ self.write_color_attribute("fill", color);
+ let opacity = f32::from(color.alpha) / 255.0;
+ self.svg.write_attribute("fill-opacity", &opacity);
+ self.write_transform_attribute("transform", self.outline_transform);
+ self.svg.write_attribute("d", self.path_buf);
+ self.svg.end_element();
+ }
+
+ fn paint_linear_gradient(&mut self, gradient: ttf_parser::colr::LinearGradient<'a>) {
+ let gradient_id = format!("lg{}", self.gradient_index);
+ self.gradient_index += 1;
+
+ let gradient_transform = paint_transform(self.outline_transform, self.transform);
+
+ // TODO: We ignore x2, y2. Have to apply them somehow.
+ // TODO: The way spreadMode works in ttf and svg is a bit different. In SVG, the spreadMode
+ // will always be applied based on x1/y1 and x2/y2. However, in TTF the spreadMode will
+ // be applied from the first/last stop. So if we have a gradient with x1=0 x2=1, and
+ // a stop at x=0.4 and x=0.6, then in SVG we will always see a padding, while in ttf
+ // we will see the actual spreadMode. We need to account for that somehow.
+ self.svg.start_element("linearGradient");
+ self.svg.write_attribute("id", &gradient_id);
+ self.svg.write_attribute("x1", &gradient.x0);
+ self.svg.write_attribute("y1", &gradient.y0);
+ self.svg.write_attribute("x2", &gradient.x1);
+ self.svg.write_attribute("y2", &gradient.y1);
+ self.svg.write_attribute("gradientUnits", &"userSpaceOnUse");
+ self.write_spread_method_attribute(gradient.extend);
+ self.write_transform_attribute("gradientTransform", gradient_transform);
+ self.write_gradient_stops(
+ gradient.stops(self.palette_index, self.face.variation_coordinates()),
+ );
+ self.svg.end_element();
+
+ self.svg.start_element("path");
+ self.svg
+ .write_attribute_fmt("fill", format_args!("url(#{gradient_id})"));
+ self.write_transform_attribute("transform", self.outline_transform);
+ self.svg.write_attribute("d", self.path_buf);
+ self.svg.end_element();
+ }
+
+ fn paint_radial_gradient(&mut self, gradient: ttf_parser::colr::RadialGradient<'a>) {
+ let gradient_id = format!("rg{}", self.gradient_index);
+ self.gradient_index += 1;
+
+ let gradient_transform = paint_transform(self.outline_transform, self.transform);
+
+ self.svg.start_element("radialGradient");
+ self.svg.write_attribute("id", &gradient_id);
+ self.svg.write_attribute("cx", &gradient.x1);
+ self.svg.write_attribute("cy", &gradient.y1);
+ self.svg.write_attribute("r", &gradient.r1);
+ self.svg.write_attribute("fr", &gradient.r0);
+ self.svg.write_attribute("fx", &gradient.x0);
+ self.svg.write_attribute("fy", &gradient.y0);
+ self.svg.write_attribute("gradientUnits", &"userSpaceOnUse");
+ self.write_spread_method_attribute(gradient.extend);
+ self.write_transform_attribute("gradientTransform", gradient_transform);
+ self.write_gradient_stops(
+ gradient.stops(self.palette_index, self.face.variation_coordinates()),
+ );
+ self.svg.end_element();
+
+ self.svg.start_element("path");
+ self.svg
+ .write_attribute_fmt("fill", format_args!("url(#{gradient_id})"));
+ self.write_transform_attribute("transform", self.outline_transform);
+ self.svg.write_attribute("d", self.path_buf);
+ self.svg.end_element();
+ }
+
+ fn paint_sweep_gradient(&mut self, _: ttf_parser::colr::SweepGradient<'a>) {}
+}
+
+fn paint_transform(
+ outline_transform: ttf_parser::Transform,
+ transform: ttf_parser::Transform,
+) -> ttf_parser::Transform {
+ let outline_transform = tiny_skia_path::Transform::from_row(
+ outline_transform.a,
+ outline_transform.b,
+ outline_transform.c,
+ outline_transform.d,
+ outline_transform.e,
+ outline_transform.f,
+ );
+
+ let gradient_transform = tiny_skia_path::Transform::from_row(
+ transform.a,
+ transform.b,
+ transform.c,
+ transform.d,
+ transform.e,
+ transform.f,
+ );
+
+ let gradient_transform = outline_transform
+ .invert()
+ // In theory, we should error out. But the transform shouldn't ever be uninvertible, so let's ignore it.
+ .unwrap_or_default()
+ .pre_concat(gradient_transform);
+
+ ttf_parser::Transform {
+ a: gradient_transform.sx,
+ b: gradient_transform.ky,
+ c: gradient_transform.kx,
+ d: gradient_transform.sy,
+ e: gradient_transform.tx,
+ f: gradient_transform.ty,
+ }
+}
+
+impl GlyphPainter<'_> {
+ fn clip_with_path(&mut self, path: &str) {
+ let clip_id = format!("cp{}", self.clip_path_index);
+ self.clip_path_index += 1;
+
+ self.svg.start_element("clipPath");
+ self.svg.write_attribute("id", &clip_id);
+ self.svg.start_element("path");
+ self.write_transform_attribute("transform", self.outline_transform);
+ self.svg.write_attribute("d", &path);
+ self.svg.end_element();
+ self.svg.end_element();
+
+ self.svg.start_element("g");
+ self.svg
+ .write_attribute_fmt("clip-path", format_args!("url(#{clip_id})"));
+ }
+}
+
+impl<'a> ttf_parser::colr::Painter<'a> for GlyphPainter<'a> {
+ fn outline_glyph(&mut self, glyph_id: ttf_parser::GlyphId) {
+ self.path_buf.clear();
+ let mut builder = ColrBuilder(self.path_buf);
+ match self.face.outline_glyph(glyph_id, &mut builder) {
+ Some(v) => v,
+ None => return,
+ };
+ builder.finish();
+
+ // We have to write outline using the current transform.
+ self.outline_transform = self.transform;
+ }
+
+ fn push_layer(&mut self, mode: ttf_parser::colr::CompositeMode) {
+ self.svg.start_element("g");
+
+ use ttf_parser::colr::CompositeMode;
+ // TODO: Need to figure out how to represent the other blend modes
+ // in SVG.
+ let mode = match mode {
+ CompositeMode::SourceOver => "normal",
+ CompositeMode::Screen => "screen",
+ CompositeMode::Overlay => "overlay",
+ CompositeMode::Darken => "darken",
+ CompositeMode::Lighten => "lighten",
+ CompositeMode::ColorDodge => "color-dodge",
+ CompositeMode::ColorBurn => "color-burn",
+ CompositeMode::HardLight => "hard-light",
+ CompositeMode::SoftLight => "soft-light",
+ CompositeMode::Difference => "difference",
+ CompositeMode::Exclusion => "exclusion",
+ CompositeMode::Multiply => "multiply",
+ CompositeMode::Hue => "hue",
+ CompositeMode::Saturation => "saturation",
+ CompositeMode::Color => "color",
+ CompositeMode::Luminosity => "luminosity",
+ _ => "normal",
+ };
+ self.svg.write_attribute_fmt(
+ "style",
+ format_args!("mix-blend-mode: {mode}; isolation: isolate"),
+ );
+ }
+
+ fn pop_layer(&mut self) {
+ self.svg.end_element(); // g
+ }
+
+ fn push_transform(&mut self, transform: ttf_parser::Transform) {
+ self.transforms_stack.push(self.transform);
+ self.transform = ttf_parser::Transform::combine(self.transform, transform);
+ }
+
+ fn paint(&mut self, paint: ttf_parser::colr::Paint<'a>) {
+ match paint {
+ ttf_parser::colr::Paint::Solid(color) => self.paint_solid(color),
+ ttf_parser::colr::Paint::LinearGradient(lg) => self.paint_linear_gradient(lg),
+ ttf_parser::colr::Paint::RadialGradient(rg) => self.paint_radial_gradient(rg),
+ ttf_parser::colr::Paint::SweepGradient(sg) => self.paint_sweep_gradient(sg),
+ }
+ }
+
+ fn pop_transform(&mut self) {
+ if let Some(ts) = self.transforms_stack.pop() {
+ self.transform = ts
+ }
+ }
+
+ fn push_clip(&mut self) {
+ self.clip_with_path(&self.path_buf.clone());
+ }
+
+ fn pop_clip(&mut self) {
+ self.svg.end_element();
+ }
+
+ fn push_clip_box(&mut self, clipbox: ttf_parser::colr::ClipBox) {
+ let x_min = clipbox.x_min;
+ let x_max = clipbox.x_max;
+ let y_min = clipbox.y_min;
+ let y_max = clipbox.y_max;
+
+ let clip_path = format!(
+ "M {x_min} {y_min} L {x_max} {y_min} L {x_max} {y_max} L {x_min} {y_max} Z"
+ );
+
+ self.clip_with_path(&clip_path);
+ }
+}
diff --git a/crates/typst-library/src/text/font/exceptions.rs b/crates/typst-library/src/text/font/exceptions.rs
new file mode 100644
index 00000000..6393df4b
--- /dev/null
+++ b/crates/typst-library/src/text/font/exceptions.rs
@@ -0,0 +1,337 @@
+use serde::Deserialize;
+
+use super::{FontStretch, FontStyle, FontWeight};
+
+pub fn find_exception(postscript_name: &str) -> Option<&'static Exception> {
+ EXCEPTION_MAP.get(postscript_name)
+}
+
+#[derive(Debug, Default, Deserialize)]
+pub struct Exception {
+ pub family: Option<&'static str>,
+ pub style: Option<FontStyle>,
+ pub weight: Option<FontWeight>,
+ pub stretch: Option<FontStretch>,
+}
+
+impl Exception {
+ const fn new() -> Self {
+ Self {
+ family: None,
+ style: None,
+ weight: None,
+ stretch: None,
+ }
+ }
+
+ const fn family(self, family: &'static str) -> Self {
+ Self { family: Some(family), ..self }
+ }
+
+ const fn style(self, style: FontStyle) -> Self {
+ Self { style: Some(style), ..self }
+ }
+
+ const fn weight(self, weight: u16) -> Self {
+ Self { weight: Some(FontWeight(weight)), ..self }
+ }
+
+ #[allow(unused)] // left for future use
+ const fn stretch(self, stretch: u16) -> Self {
+ Self { stretch: Some(FontStretch(stretch)), ..self }
+ }
+}
+
+/// A map which keys are PostScript name and values are override entries.
+static EXCEPTION_MAP: phf::Map<&'static str, Exception> = phf::phf_map! {
+ // The old version of Arial-Black, published by Microsoft in 1996 in their
+ // "core fonts for the web" project, has a wrong weight of 400.
+ // See https://corefonts.sourceforge.net/.
+ "Arial-Black" => Exception::new()
+ .weight(900),
+ // Archivo Narrow is different from Archivo and Archivo Black. Since Archivo Black
+ // seems identical to Archivo weight 900, only differentiate between Archivo and
+ // Archivo Narrow.
+ "ArchivoNarrow-Regular" => Exception::new()
+ .family("Archivo Narrow"),
+ "ArchivoNarrow-Italic" => Exception::new()
+ .family("Archivo Narrow"),
+ "ArchivoNarrow-Bold" => Exception::new()
+ .family("Archivo Narrow"),
+ "ArchivoNarrow-BoldItalic" => Exception::new()
+ .family("Archivo Narrow"),
+ // Fandol fonts designed for Chinese typesetting.
+ // See https://ctan.org/tex-archive/fonts/fandol/.
+ "FandolHei-Bold" => Exception::new()
+ .weight(700),
+ "FandolSong-Bold" => Exception::new()
+ .weight(700),
+ // Noto fonts
+ "NotoNaskhArabicUISemi-Bold" => Exception::new()
+ .family("Noto Naskh Arabic UI")
+ .weight(600),
+ "NotoSansSoraSompengSemi-Bold" => Exception::new()
+ .family("Noto Sans Sora Sompeng")
+ .weight(600),
+ "NotoSans-DisplayBlackItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedBlackItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedBold" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedExtraBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedExtraLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedMediumItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedSemiBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayCondensedThinItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedBlackItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedBold" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedExtraBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedExtraLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedMediumItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedSemiBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraCondensedThinItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayExtraLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayMediumItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedBlackItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedBold" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedExtraBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedExtraLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedLightItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedMediumItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedSemiBoldItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplaySemiCondensedThinItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ "NotoSans-DisplayThinItalic" => Exception::new()
+ .family("Noto Sans Display"),
+ // The following three postscript names are only used in the version 2.007
+ // of the Noto Sans font. Other versions, while have different postscript
+ // name, happen to have correct metadata.
+ "NotoSerif-DisplayCondensedBold" => Exception::new()
+ .family("Noto Serif Display"),
+ "NotoSerif-DisplayExtraCondensedBold" => Exception::new()
+ .family("Noto Serif Display"),
+ "NotoSerif-DisplaySemiCondensedBold" => Exception::new()
+ .family("Noto Serif Display"),
+ // New Computer Modern
+ "NewCM08-Book" => Exception::new()
+ .family("New Computer Modern 08")
+ .weight(450),
+ "NewCM08-BookItalic" => Exception::new()
+ .family("New Computer Modern 08")
+ .weight(450),
+ "NewCM08-Italic" => Exception::new()
+ .family("New Computer Modern 08"),
+ "NewCM08-Regular" => Exception::new()
+ .family("New Computer Modern 08"),
+ "NewCM10-Bold" => Exception::new()
+ .family("New Computer Modern"),
+ "NewCM10-BoldItalic" => Exception::new()
+ .family("New Computer Modern"),
+ "NewCM10-Book" => Exception::new()
+ .family("New Computer Modern")
+ .weight(450),
+ "NewCM10-BookItalic" => Exception::new()
+ .family("New Computer Modern")
+ .weight(450),
+ "NewCM10-Italic" => Exception::new()
+ .family("New Computer Modern"),
+ "NewCM10-Regular" => Exception::new()
+ .family("New Computer Modern"),
+ "NewCMMath-Bold" => Exception::new()
+ .family("New Computer Modern Math")
+ .weight(700),
+ "NewCMMath-Book" => Exception::new()
+ .family("New Computer Modern Math")
+ .weight(450),
+ "NewCMMath-Regular" => Exception::new()
+ .family("New Computer Modern Math"),
+ "NewCMMono10-Bold" => Exception::new()
+ .family("New Computer Modern Mono"),
+ "NewCMMono10-BoldOblique" => Exception::new()
+ .family("New Computer Modern Mono"),
+ "NewCMMono10-Book" => Exception::new()
+ .family("New Computer Modern Mono")
+ .weight(450),
+ "NewCMMono10-BookItalic" => Exception::new()
+ .family("New Computer Modern Mono")
+ .weight(450),
+ "NewCMMono10-Italic" => Exception::new()
+ .family("New Computer Modern Mono"),
+ "NewCMMono10-Regular" => Exception::new()
+ .family("New Computer Modern Mono"),
+ "NewCMSans08-Book" => Exception::new()
+ .family("New Computer Modern Sans 08")
+ .weight(450),
+ "NewCMSans08-BookOblique" => Exception::new()
+ .family("New Computer Modern Sans 08")
+ .weight(450),
+ "NewCMSans08-Oblique" => Exception::new()
+ .family("New Computer Modern Sans 08"),
+ "NewCMSans08-Regular" => Exception::new()
+ .family("New Computer Modern Sans 08"),
+ "NewCMSans10-Bold" => Exception::new()
+ .family("New Computer Modern Sans"),
+ "NewCMSans10-BoldOblique" => Exception::new()
+ .family("New Computer Modern Sans"),
+ "NewCMSans10-Book" => Exception::new()
+ .family("New Computer Modern Sans")
+ .weight(450),
+ "NewCMSans10-BookOblique" => Exception::new()
+ .family("New Computer Modern Sans")
+ .weight(450)
+ .style(FontStyle::Oblique),
+ "NewCMSans10-Oblique" => Exception::new()
+ .family("New Computer Modern Sans")
+ .style(FontStyle::Oblique),
+ "NewCMSans10-Regular" => Exception::new()
+ .family("New Computer Modern Sans"),
+ "NewCMUncial08-Bold" => Exception::new()
+ .family("New Computer Modern Uncial 08"),
+ "NewCMUncial08-Book" => Exception::new()
+ .family("New Computer Modern Uncial 08")
+ .weight(450),
+ "NewCMUncial08-Regular" => Exception::new()
+ .family("New Computer Modern Uncial 08"),
+ "NewCMUncial10-Bold" => Exception::new()
+ .family("New Computer Modern Uncial"),
+ "NewCMUncial10-Book" => Exception::new()
+ .family("New Computer Modern Uncial")
+ .weight(450),
+ "NewCMUncial10-Regular" => Exception::new()
+ .family("New Computer Modern Uncial"),
+ // Latin Modern
+ "LMMono8-Regular" => Exception::new()
+ .family("Latin Modern Mono 8"),
+ "LMMono9-Regular" => Exception::new()
+ .family("Latin Modern Mono 9"),
+ "LMMono12-Regular" => Exception::new()
+ .family("Latin Modern Mono 12"),
+ "LMMonoLt10-BoldOblique" => Exception::new()
+ .style(FontStyle::Oblique),
+ "LMMonoLt10-Regular" => Exception::new()
+ .weight(300),
+ "LMMonoLt10-Oblique" => Exception::new()
+ .weight(300)
+ .style(FontStyle::Oblique),
+ "LMMonoLtCond10-Regular" => Exception::new()
+ .weight(300)
+ .stretch(666),
+ "LMMonoLtCond10-Oblique" => Exception::new()
+ .weight(300)
+ .style(FontStyle::Oblique)
+ .stretch(666),
+ "LMMonoPropLt10-Regular" => Exception::new()
+ .weight(300),
+ "LMMonoPropLt10-Oblique" => Exception::new()
+ .weight(300),
+ "LMRoman5-Regular" => Exception::new()
+ .family("Latin Modern Roman 5"),
+ "LMRoman6-Regular" => Exception::new()
+ .family("Latin Modern Roman 6"),
+ "LMRoman7-Regular" => Exception::new()
+ .family("Latin Modern Roman 7"),
+ "LMRoman8-Regular" => Exception::new()
+ .family("Latin Modern Roman 8"),
+ "LMRoman9-Regular" => Exception::new()
+ .family("Latin Modern Roman 9"),
+ "LMRoman12-Regular" => Exception::new()
+ .family("Latin Modern Roman 12"),
+ "LMRoman17-Regular" => Exception::new()
+ .family("Latin Modern Roman 17"),
+ "LMRoman7-Italic" => Exception::new()
+ .family("Latin Modern Roman 7"),
+ "LMRoman8-Italic" => Exception::new()
+ .family("Latin Modern Roman 8"),
+ "LMRoman9-Italic" => Exception::new()
+ .family("Latin Modern Roman 9"),
+ "LMRoman12-Italic" => Exception::new()
+ .family("Latin Modern Roman 12"),
+ "LMRoman5-Bold" => Exception::new()
+ .family("Latin Modern Roman 5"),
+ "LMRoman6-Bold" => Exception::new()
+ .family("Latin Modern Roman 6"),
+ "LMRoman7-Bold" => Exception::new()
+ .family("Latin Modern Roman 7"),
+ "LMRoman8-Bold" => Exception::new()
+ .family("Latin Modern Roman 8"),
+ "LMRoman9-Bold" => Exception::new()
+ .family("Latin Modern Roman 9"),
+ "LMRoman12-Bold" => Exception::new()
+ .family("Latin Modern Roman 12"),
+ "LMRomanSlant8-Regular" => Exception::new()
+ .family("Latin Modern Roman 8"),
+ "LMRomanSlant9-Regular" => Exception::new()
+ .family("Latin Modern Roman 9"),
+ "LMRomanSlant12-Regular" => Exception::new()
+ .family("Latin Modern Roman 12"),
+ "LMRomanSlant17-Regular" => Exception::new()
+ .family("Latin Modern Roman 17"),
+ "LMSans8-Regular" => Exception::new()
+ .family("Latin Modern Sans 8"),
+ "LMSans9-Regular" => Exception::new()
+ .family("Latin Modern Sans 9"),
+ "LMSans12-Regular" => Exception::new()
+ .family("Latin Modern Sans 12"),
+ "LMSans17-Regular" => Exception::new()
+ .family("Latin Modern Sans 17"),
+ "LMSans8-Oblique" => Exception::new()
+ .family("Latin Modern Sans 8"),
+ "LMSans9-Oblique" => Exception::new()
+ .family("Latin Modern Sans 9"),
+ "LMSans12-Oblique" => Exception::new()
+ .family("Latin Modern Sans 12"),
+ "LMSans17-Oblique" => Exception::new()
+ .family("Latin Modern Sans 17"),
+ // STKaiti is a set of Kai fonts. Their weight values need to be corrected
+ // according to their PostScript names.
+ "STKaitiSC-Regular" => Exception::new().weight(400),
+ "STKaitiTC-Regular" => Exception::new().weight(400),
+ "STKaitiSC-Bold" => Exception::new().weight(700),
+ "STKaitiTC-Bold" => Exception::new().weight(700),
+ "STKaitiSC-Black" => Exception::new().weight(900),
+ "STKaitiTC-Black" => Exception::new().weight(900),
+};
diff --git a/crates/typst-library/src/text/font/mod.rs b/crates/typst-library/src/text/font/mod.rs
new file mode 100644
index 00000000..09837312
--- /dev/null
+++ b/crates/typst-library/src/text/font/mod.rs
@@ -0,0 +1,306 @@
+//! Font handling.
+
+pub mod color;
+
+mod book;
+mod exceptions;
+mod variant;
+
+pub use self::book::{Coverage, FontBook, FontFlags, FontInfo};
+pub use self::variant::{FontStretch, FontStyle, FontVariant, FontWeight};
+
+use std::cell::OnceCell;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+use ttf_parser::GlyphId;
+
+use self::book::find_name;
+use crate::foundations::{Bytes, Cast};
+use crate::layout::{Abs, Em, Frame};
+use crate::text::{BottomEdge, TopEdge};
+
+/// An OpenType font.
+///
+/// Values of this type are cheap to clone and hash.
+#[derive(Clone)]
+pub struct Font(Arc<Repr>);
+
+/// The internal representation of a font.
+struct Repr {
+ /// The raw font data, possibly shared with other fonts from the same
+ /// collection. The vector's allocation must not move, because `ttf` points
+ /// into it using unsafe code.
+ data: Bytes,
+ /// The font's index in the buffer.
+ index: u32,
+ /// Metadata about the font.
+ info: FontInfo,
+ /// The font's metrics.
+ metrics: FontMetrics,
+ /// The underlying ttf-parser face.
+ ttf: ttf_parser::Face<'static>,
+ /// The underlying rustybuzz face.
+ rusty: rustybuzz::Face<'static>,
+}
+
+impl Font {
+ /// Parse a font from data and collection index.
+ pub fn new(data: Bytes, index: u32) -> Option<Self> {
+ // Safety:
+ // - The slices's location is stable in memory:
+ // - We don't move the underlying vector
+ // - Nobody else can move it since we have a strong ref to the `Arc`.
+ // - The internal 'static lifetime is not leaked because its rewritten
+ // to the self-lifetime in `ttf()`.
+ let slice: &'static [u8] =
+ unsafe { std::slice::from_raw_parts(data.as_ptr(), data.len()) };
+
+ let ttf = ttf_parser::Face::parse(slice, index).ok()?;
+ let rusty = rustybuzz::Face::from_slice(slice, index)?;
+ let metrics = FontMetrics::from_ttf(&ttf);
+ let info = FontInfo::from_ttf(&ttf)?;
+
+ Some(Self(Arc::new(Repr { data, index, info, metrics, ttf, rusty })))
+ }
+
+ /// Parse all fonts in the given data.
+ pub fn iter(data: Bytes) -> impl Iterator<Item = Self> {
+ let count = ttf_parser::fonts_in_collection(&data).unwrap_or(1);
+ (0..count).filter_map(move |index| Self::new(data.clone(), index))
+ }
+
+ /// The underlying buffer.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The font's index in the buffer.
+ pub fn index(&self) -> u32 {
+ self.0.index
+ }
+
+ /// The font's metadata.
+ pub fn info(&self) -> &FontInfo {
+ &self.0.info
+ }
+
+ /// The font's metrics.
+ pub fn metrics(&self) -> &FontMetrics {
+ &self.0.metrics
+ }
+
+ /// The number of font units per one em.
+ pub fn units_per_em(&self) -> f64 {
+ self.0.metrics.units_per_em
+ }
+
+ /// Convert from font units to an em length.
+ pub fn to_em(&self, units: impl Into<f64>) -> Em {
+ Em::from_units(units, self.units_per_em())
+ }
+
+ /// Look up the horizontal advance width of a glyph.
+ pub fn advance(&self, glyph: u16) -> Option<Em> {
+ self.0
+ .ttf
+ .glyph_hor_advance(GlyphId(glyph))
+ .map(|units| self.to_em(units))
+ }
+
+ /// Lookup a name by id.
+ pub fn find_name(&self, id: u16) -> Option<String> {
+ find_name(&self.0.ttf, id)
+ }
+
+ /// A reference to the underlying `ttf-parser` face.
+ pub fn ttf(&self) -> &ttf_parser::Face<'_> {
+ // We can't implement Deref because that would leak the
+ // internal 'static lifetime.
+ &self.0.ttf
+ }
+
+ /// A reference to the underlying `rustybuzz` face.
+ pub fn rusty(&self) -> &rustybuzz::Face<'_> {
+ // We can't implement Deref because that would leak the
+ // internal 'static lifetime.
+ &self.0.rusty
+ }
+
+ /// Resolve the top and bottom edges of text.
+ pub fn edges(
+ &self,
+ top_edge: TopEdge,
+ bottom_edge: BottomEdge,
+ font_size: Abs,
+ bounds: TextEdgeBounds,
+ ) -> (Abs, Abs) {
+ let cell = OnceCell::new();
+ let bbox = |gid, f: fn(ttf_parser::Rect) -> i16| {
+ cell.get_or_init(|| self.ttf().glyph_bounding_box(GlyphId(gid)))
+ .map(|bbox| self.to_em(f(bbox)).at(font_size))
+ .unwrap_or_default()
+ };
+
+ let top = match top_edge {
+ TopEdge::Metric(metric) => match metric.try_into() {
+ Ok(metric) => self.metrics().vertical(metric).at(font_size),
+ Err(_) => match bounds {
+ TextEdgeBounds::Zero => Abs::zero(),
+ TextEdgeBounds::Frame(frame) => frame.ascent(),
+ TextEdgeBounds::Glyph(gid) => bbox(gid, |b| b.y_max),
+ },
+ },
+ TopEdge::Length(length) => length.at(font_size),
+ };
+
+ let bottom = match bottom_edge {
+ BottomEdge::Metric(metric) => match metric.try_into() {
+ Ok(metric) => -self.metrics().vertical(metric).at(font_size),
+ Err(_) => match bounds {
+ TextEdgeBounds::Zero => Abs::zero(),
+ TextEdgeBounds::Frame(frame) => frame.descent(),
+ TextEdgeBounds::Glyph(gid) => -bbox(gid, |b| b.y_min),
+ },
+ },
+ BottomEdge::Length(length) => -length.at(font_size),
+ };
+
+ (top, bottom)
+ }
+}
+
+impl Hash for Font {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.0.data.hash(state);
+ self.0.index.hash(state);
+ }
+}
+
+impl Debug for Font {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Font({}, {:?})", self.info().family, self.info().variant)
+ }
+}
+
+impl Eq for Font {}
+
+impl PartialEq for Font {
+ fn eq(&self, other: &Self) -> bool {
+ self.0.data == other.0.data && self.0.index == other.0.index
+ }
+}
+
+/// Metrics of a font.
+#[derive(Debug, Copy, Clone)]
+pub struct FontMetrics {
+ /// How many font units represent one em unit.
+ pub units_per_em: f64,
+ /// The distance from the baseline to the typographic ascender.
+ pub ascender: Em,
+ /// The approximate height of uppercase letters.
+ pub cap_height: Em,
+ /// The approximate height of non-ascending lowercase letters.
+ pub x_height: Em,
+ /// The distance from the baseline to the typographic descender.
+ pub descender: Em,
+ /// Recommended metrics for a strikethrough line.
+ pub strikethrough: LineMetrics,
+ /// Recommended metrics for an underline.
+ pub underline: LineMetrics,
+ /// Recommended metrics for an overline.
+ pub overline: LineMetrics,
+}
+
+impl FontMetrics {
+ /// Extract the font's metrics.
+ pub fn from_ttf(ttf: &ttf_parser::Face) -> Self {
+ let units_per_em = f64::from(ttf.units_per_em());
+ let to_em = |units| Em::from_units(units, units_per_em);
+
+ let ascender = to_em(ttf.typographic_ascender().unwrap_or(ttf.ascender()));
+ let cap_height = ttf.capital_height().filter(|&h| h > 0).map_or(ascender, to_em);
+ let x_height = ttf.x_height().filter(|&h| h > 0).map_or(ascender, to_em);
+ let descender = to_em(ttf.typographic_descender().unwrap_or(ttf.descender()));
+ let strikeout = ttf.strikeout_metrics();
+ let underline = ttf.underline_metrics();
+
+ let strikethrough = LineMetrics {
+ position: strikeout.map_or(Em::new(0.25), |s| to_em(s.position)),
+ thickness: strikeout
+ .or(underline)
+ .map_or(Em::new(0.06), |s| to_em(s.thickness)),
+ };
+
+ let underline = LineMetrics {
+ position: underline.map_or(Em::new(-0.2), |s| to_em(s.position)),
+ thickness: underline
+ .or(strikeout)
+ .map_or(Em::new(0.06), |s| to_em(s.thickness)),
+ };
+
+ let overline = LineMetrics {
+ position: cap_height + Em::new(0.1),
+ thickness: underline.thickness,
+ };
+
+ Self {
+ units_per_em,
+ ascender,
+ cap_height,
+ x_height,
+ descender,
+ strikethrough,
+ underline,
+ overline,
+ }
+ }
+
+ /// Look up a vertical metric.
+ pub fn vertical(&self, metric: VerticalFontMetric) -> Em {
+ match metric {
+ VerticalFontMetric::Ascender => self.ascender,
+ VerticalFontMetric::CapHeight => self.cap_height,
+ VerticalFontMetric::XHeight => self.x_height,
+ VerticalFontMetric::Baseline => Em::zero(),
+ VerticalFontMetric::Descender => self.descender,
+ }
+ }
+}
+
+/// Metrics for a decorative line.
+#[derive(Debug, Copy, Clone)]
+pub struct LineMetrics {
+ /// The vertical offset of the line from the baseline. Positive goes
+ /// upwards, negative downwards.
+ pub position: Em,
+ /// The thickness of the line.
+ pub thickness: Em,
+}
+
+/// Identifies a vertical metric of a font.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum VerticalFontMetric {
+ /// The font's ascender, which typically exceeds the height of all glyphs.
+ Ascender,
+ /// The approximate height of uppercase letters.
+ CapHeight,
+ /// The approximate height of non-ascending lowercase letters.
+ XHeight,
+ /// The baseline on which the letters rest.
+ Baseline,
+ /// The font's ascender, which typically exceeds the depth of all glyphs.
+ Descender,
+}
+
+/// Defines how to resolve a `Bounds` text edge.
+#[derive(Debug, Copy, Clone)]
+pub enum TextEdgeBounds<'a> {
+ /// Set the bounds to zero.
+ Zero,
+ /// Use the bounding box of the given glyph for the bounds.
+ Glyph(u16),
+ /// Use the dimension of the given frame for the bounds.
+ Frame(&'a Frame),
+}
diff --git a/crates/typst-library/src/text/font/variant.rs b/crates/typst-library/src/text/font/variant.rs
new file mode 100644
index 00000000..c7a00fb3
--- /dev/null
+++ b/crates/typst-library/src/text/font/variant.rs
@@ -0,0 +1,320 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::EcoString;
+use serde::{Deserialize, Serialize};
+
+use crate::foundations::{cast, Cast, IntoValue, Repr};
+use crate::layout::Ratio;
+
+/// Properties that distinguish a font from other fonts in the same family.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Serialize, Deserialize)]
+pub struct FontVariant {
+ /// The style of the font (normal / italic / oblique).
+ pub style: FontStyle,
+ /// How heavy the font is (100 - 900).
+ pub weight: FontWeight,
+ /// How condensed or expanded the font is (0.5 - 2.0).
+ pub stretch: FontStretch,
+}
+
+impl FontVariant {
+ /// Create a variant from its three components.
+ pub fn new(style: FontStyle, weight: FontWeight, stretch: FontStretch) -> Self {
+ Self { style, weight, stretch }
+ }
+}
+
+impl Debug for FontVariant {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}-{:?}-{:?}", self.style, self.weight, self.stretch)
+ }
+}
+
+/// The style of a font.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Serialize, Deserialize, Cast)]
+#[serde(rename_all = "kebab-case")]
+pub enum FontStyle {
+ /// The default, typically upright style.
+ Normal,
+ /// A cursive style with custom letterform.
+ Italic,
+ /// Just a slanted version of the normal style.
+ Oblique,
+}
+
+impl FontStyle {
+ /// The conceptual distance between the styles, expressed as a number.
+ pub fn distance(self, other: Self) -> u16 {
+ if self == other {
+ 0
+ } else if self != Self::Normal && other != Self::Normal {
+ 1
+ } else {
+ 2
+ }
+ }
+}
+
+impl Default for FontStyle {
+ fn default() -> Self {
+ Self::Normal
+ }
+}
+
+impl From<usvg::FontStyle> for FontStyle {
+ fn from(style: usvg::FontStyle) -> Self {
+ match style {
+ usvg::FontStyle::Normal => Self::Normal,
+ usvg::FontStyle::Italic => Self::Italic,
+ usvg::FontStyle::Oblique => Self::Oblique,
+ }
+ }
+}
+
+/// The weight of a font.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct FontWeight(pub(super) u16);
+
+/// Font weight names and numbers.
+/// See `<https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-weight#common_weight_name_mapping>`
+impl FontWeight {
+ /// Thin weight (100).
+ pub const THIN: Self = Self(100);
+
+ /// Extra light weight (200).
+ pub const EXTRALIGHT: Self = Self(200);
+
+ /// Light weight (300).
+ pub const LIGHT: Self = Self(300);
+
+ /// Regular weight (400).
+ pub const REGULAR: Self = Self(400);
+
+ /// Medium weight (500).
+ pub const MEDIUM: Self = Self(500);
+
+ /// Semibold weight (600).
+ pub const SEMIBOLD: Self = Self(600);
+
+ /// Bold weight (700).
+ pub const BOLD: Self = Self(700);
+
+ /// Extrabold weight (800).
+ pub const EXTRABOLD: Self = Self(800);
+
+ /// Black weight (900).
+ pub const BLACK: Self = Self(900);
+
+ /// Create a font weight from a number between 100 and 900, clamping it if
+ /// necessary.
+ pub fn from_number(weight: u16) -> Self {
+ Self(weight.clamp(100, 900))
+ }
+
+ /// The number between 100 and 900.
+ pub fn to_number(self) -> u16 {
+ self.0
+ }
+
+ /// Add (or remove) weight, saturating at the boundaries of 100 and 900.
+ pub fn thicken(self, delta: i16) -> Self {
+ Self((self.0 as i16).saturating_add(delta).clamp(100, 900) as u16)
+ }
+
+ /// The absolute number distance between this and another font weight.
+ pub fn distance(self, other: Self) -> u16 {
+ (self.0 as i16 - other.0 as i16).unsigned_abs()
+ }
+}
+
+impl Default for FontWeight {
+ fn default() -> Self {
+ Self::REGULAR
+ }
+}
+
+impl Debug for FontWeight {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl From<fontdb::Weight> for FontWeight {
+ fn from(weight: fontdb::Weight) -> Self {
+ Self::from_number(weight.0)
+ }
+}
+
+cast! {
+ FontWeight,
+ self => IntoValue::into_value(match self {
+ FontWeight::THIN => "thin",
+ FontWeight::EXTRALIGHT => "extralight",
+ FontWeight::LIGHT => "light",
+ FontWeight::REGULAR => "regular",
+ FontWeight::MEDIUM => "medium",
+ FontWeight::SEMIBOLD => "semibold",
+ FontWeight::BOLD => "bold",
+ FontWeight::EXTRABOLD => "extrabold",
+ FontWeight::BLACK => "black",
+ _ => return self.to_number().into_value(),
+ }),
+ v: i64 => Self::from_number(v.clamp(0, u16::MAX as i64) as u16),
+ /// Thin weight (100).
+ "thin" => Self::THIN,
+ /// Extra light weight (200).
+ "extralight" => Self::EXTRALIGHT,
+ /// Light weight (300).
+ "light" => Self::LIGHT,
+ /// Regular weight (400).
+ "regular" => Self::REGULAR,
+ /// Medium weight (500).
+ "medium" => Self::MEDIUM,
+ /// Semibold weight (600).
+ "semibold" => Self::SEMIBOLD,
+ /// Bold weight (700).
+ "bold" => Self::BOLD,
+ /// Extrabold weight (800).
+ "extrabold" => Self::EXTRABOLD,
+ /// Black weight (900).
+ "black" => Self::BLACK,
+}
+
+/// The width of a font.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct FontStretch(pub(super) u16);
+
+impl FontStretch {
+ /// Ultra-condensed stretch (50%).
+ pub const ULTRA_CONDENSED: Self = Self(500);
+
+ /// Extra-condensed stretch weight (62.5%).
+ pub const EXTRA_CONDENSED: Self = Self(625);
+
+ /// Condensed stretch (75%).
+ pub const CONDENSED: Self = Self(750);
+
+ /// Semi-condensed stretch (87.5%).
+ pub const SEMI_CONDENSED: Self = Self(875);
+
+ /// Normal stretch (100%).
+ pub const NORMAL: Self = Self(1000);
+
+ /// Semi-expanded stretch (112.5%).
+ pub const SEMI_EXPANDED: Self = Self(1125);
+
+ /// Expanded stretch (125%).
+ pub const EXPANDED: Self = Self(1250);
+
+ /// Extra-expanded stretch (150%).
+ pub const EXTRA_EXPANDED: Self = Self(1500);
+
+ /// Ultra-expanded stretch (200%).
+ pub const ULTRA_EXPANDED: Self = Self(2000);
+
+ /// Create a font stretch from a ratio between 0.5 and 2.0, clamping it if
+ /// necessary.
+ pub fn from_ratio(ratio: Ratio) -> Self {
+ Self((ratio.get().clamp(0.5, 2.0) * 1000.0) as u16)
+ }
+
+ /// Create a font stretch from an OpenType-style number between 1 and 9,
+ /// clamping it if necessary.
+ pub fn from_number(stretch: u16) -> Self {
+ match stretch {
+ 0 | 1 => Self::ULTRA_CONDENSED,
+ 2 => Self::EXTRA_CONDENSED,
+ 3 => Self::CONDENSED,
+ 4 => Self::SEMI_CONDENSED,
+ 5 => Self::NORMAL,
+ 6 => Self::SEMI_EXPANDED,
+ 7 => Self::EXPANDED,
+ 8 => Self::EXTRA_EXPANDED,
+ _ => Self::ULTRA_EXPANDED,
+ }
+ }
+
+ /// The ratio between 0.5 and 2.0 corresponding to this stretch.
+ pub fn to_ratio(self) -> Ratio {
+ Ratio::new(self.0 as f64 / 1000.0)
+ }
+
+ /// Round to one of the pre-defined variants.
+ pub fn round(self) -> Self {
+ match self.0 {
+ ..=562 => Self::ULTRA_CONDENSED,
+ 563..=687 => Self::EXTRA_CONDENSED,
+ 688..=812 => Self::CONDENSED,
+ 813..=937 => Self::SEMI_CONDENSED,
+ 938..=1062 => Self::NORMAL,
+ 1063..=1187 => Self::SEMI_EXPANDED,
+ 1188..=1374 => Self::EXPANDED,
+ 1375..=1749 => Self::EXTRA_EXPANDED,
+ 1750.. => Self::ULTRA_EXPANDED,
+ }
+ }
+
+ /// The absolute ratio distance between this and another font stretch.
+ pub fn distance(self, other: Self) -> Ratio {
+ (self.to_ratio() - other.to_ratio()).abs()
+ }
+}
+
+impl Default for FontStretch {
+ fn default() -> Self {
+ Self::NORMAL
+ }
+}
+
+impl Repr for FontStretch {
+ fn repr(&self) -> EcoString {
+ self.to_ratio().repr()
+ }
+}
+
+impl From<usvg::FontStretch> for FontStretch {
+ fn from(stretch: usvg::FontStretch) -> Self {
+ match stretch {
+ usvg::FontStretch::UltraCondensed => Self::ULTRA_CONDENSED,
+ usvg::FontStretch::ExtraCondensed => Self::EXTRA_CONDENSED,
+ usvg::FontStretch::Condensed => Self::CONDENSED,
+ usvg::FontStretch::SemiCondensed => Self::SEMI_CONDENSED,
+ usvg::FontStretch::Normal => Self::NORMAL,
+ usvg::FontStretch::SemiExpanded => Self::SEMI_EXPANDED,
+ usvg::FontStretch::Expanded => Self::EXPANDED,
+ usvg::FontStretch::ExtraExpanded => Self::EXTRA_EXPANDED,
+ usvg::FontStretch::UltraExpanded => Self::ULTRA_EXPANDED,
+ }
+ }
+}
+
+cast! {
+ FontStretch,
+ self => self.to_ratio().into_value(),
+ v: Ratio => Self::from_ratio(v),
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_font_weight_distance() {
+ let d = |a, b| FontWeight(a).distance(FontWeight(b));
+ assert_eq!(d(500, 200), 300);
+ assert_eq!(d(500, 500), 0);
+ assert_eq!(d(500, 900), 400);
+ assert_eq!(d(10, 100), 90);
+ }
+
+ #[test]
+ fn test_font_stretch_debug() {
+ assert_eq!(FontStretch::EXPANDED.repr(), "125%")
+ }
+}
diff --git a/crates/typst-library/src/text/item.rs b/crates/typst-library/src/text/item.rs
new file mode 100644
index 00000000..ed559aec
--- /dev/null
+++ b/crates/typst-library/src/text/item.rs
@@ -0,0 +1,118 @@
+use std::fmt::{self, Debug, Formatter};
+use std::ops::Range;
+
+use ecow::EcoString;
+use typst_syntax::Span;
+
+use crate::layout::{Abs, Em};
+use crate::text::{is_default_ignorable, Font, Lang, Region};
+use crate::visualize::{FixedStroke, Paint};
+
+/// A run of shaped text.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub struct TextItem {
+ /// The font the glyphs are contained in.
+ pub font: Font,
+ /// The font size.
+ pub size: Abs,
+ /// Glyph color.
+ pub fill: Paint,
+ /// Glyph stroke.
+ pub stroke: Option<FixedStroke>,
+ /// The natural language of the text.
+ pub lang: Lang,
+ /// The region of the text.
+ pub region: Option<Region>,
+ /// The item's plain text.
+ pub text: EcoString,
+ /// The glyphs. The number of glyphs may be different from the number of
+ /// characters in the plain text due to e.g. ligatures.
+ pub glyphs: Vec<Glyph>,
+}
+
+impl TextItem {
+ /// The width of the text run.
+ pub fn width(&self) -> Abs {
+ self.glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(self.size)
+ }
+}
+
+impl Debug for TextItem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Text(")?;
+ self.text.fmt(f)?;
+ f.write_str(")")
+ }
+}
+
+/// A glyph in a run of shaped text.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Glyph {
+ /// The glyph's index in the font.
+ pub id: u16,
+ /// The advance width of the glyph.
+ pub x_advance: Em,
+ /// The horizontal offset of the glyph.
+ pub x_offset: Em,
+ /// The range of the glyph in its item's text. The range's length may
+ /// be more than one due to multi-byte UTF-8 encoding or ligatures.
+ pub range: Range<u16>,
+ /// The source code location of the text.
+ pub span: (Span, u16),
+}
+
+impl Glyph {
+ /// The range of the glyph in its item's text.
+ pub fn range(&self) -> Range<usize> {
+ usize::from(self.range.start)..usize::from(self.range.end)
+ }
+}
+
+/// A slice of a [`TextItem`].
+pub struct TextItemView<'a> {
+ /// The whole item this is a part of
+ pub item: &'a TextItem,
+ /// The glyphs of this slice
+ pub glyph_range: Range<usize>,
+}
+
+impl<'a> TextItemView<'a> {
+ /// Build a TextItemView for the whole contents of a TextItem.
+ pub fn full(text: &'a TextItem) -> Self {
+ Self::from_glyph_range(text, 0..text.glyphs.len())
+ }
+
+ /// Build a new [`TextItemView`] from a [`TextItem`] and a range of glyphs.
+ pub fn from_glyph_range(text: &'a TextItem, glyph_range: Range<usize>) -> Self {
+ TextItemView { item: text, glyph_range }
+ }
+
+ /// Returns an iterator over the glyphs of the slice.
+ ///
+ /// Note that the ranges are not remapped. They still point into the
+ /// original text.
+ pub fn glyphs(&self) -> &[Glyph] {
+ &self.item.glyphs[self.glyph_range.clone()]
+ }
+
+ /// The plain text for the given glyph from `glyphs()`. This is an
+ /// approximation since glyphs do not correspond 1-1 with codepoints.
+ pub fn glyph_text(&self, glyph: &Glyph) -> EcoString {
+ // Trim default ignorables which might have ended up in the glyph's
+ // cluster. Keep interior ones so that joined emojis work. All of this
+ // is a hack and needs to be reworked. See
+ // https://github.com/typst/typst/pull/5099
+ self.item.text[glyph.range()]
+ .trim_matches(is_default_ignorable)
+ .into()
+ }
+
+ /// The total width of this text slice
+ pub fn width(&self) -> Abs {
+ self.glyphs()
+ .iter()
+ .map(|g| g.x_advance)
+ .sum::<Em>()
+ .at(self.item.size)
+ }
+}
diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs
new file mode 100644
index 00000000..64ab1a7c
--- /dev/null
+++ b/crates/typst-library/src/text/lang.rs
@@ -0,0 +1,317 @@
+use std::collections::HashMap;
+use std::str::FromStr;
+
+use ecow::{eco_format, EcoString};
+
+use crate::diag::Hint;
+use crate::foundations::{cast, StyleChain};
+use crate::layout::Dir;
+use crate::text::TextElem;
+
+macro_rules! translation {
+ ($lang:literal) => {
+ ($lang, include_str!(concat!("../../translations/", $lang, ".txt")))
+ };
+}
+
+const TRANSLATIONS: [(&str, &str); 36] = [
+ translation!("ar"),
+ translation!("ca"),
+ translation!("cs"),
+ translation!("da"),
+ translation!("de"),
+ translation!("en"),
+ translation!("es"),
+ translation!("et"),
+ translation!("fi"),
+ translation!("fr"),
+ translation!("gl"),
+ translation!("gr"),
+ translation!("he"),
+ translation!("hu"),
+ translation!("is"),
+ translation!("it"),
+ translation!("ja"),
+ translation!("la"),
+ translation!("nb"),
+ translation!("nl"),
+ translation!("nn"),
+ translation!("pl"),
+ translation!("pt-PT"),
+ translation!("pt"),
+ translation!("ro"),
+ translation!("ru"),
+ translation!("sl"),
+ translation!("sq"),
+ translation!("sr"),
+ translation!("sv"),
+ translation!("tl"),
+ translation!("tr"),
+ translation!("ua"),
+ translation!("vi"),
+ translation!("zh-TW"),
+ translation!("zh"),
+];
+
+/// An identifier for a natural language.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Lang([u8; 3], u8);
+
+impl Lang {
+ pub const ALBANIAN: Self = Self(*b"sq ", 2);
+ pub const ARABIC: Self = Self(*b"ar ", 2);
+ pub const BOKMÅL: Self = Self(*b"nb ", 2);
+ pub const CATALAN: Self = Self(*b"ca ", 2);
+ pub const CHINESE: Self = Self(*b"zh ", 2);
+ pub const CROATIAN: Self = Self(*b"hr ", 2);
+ pub const CZECH: Self = Self(*b"cs ", 2);
+ pub const DANISH: Self = Self(*b"da ", 2);
+ pub const DUTCH: Self = Self(*b"nl ", 2);
+ pub const ENGLISH: Self = Self(*b"en ", 2);
+ pub const ESTONIAN: Self = Self(*b"et ", 2);
+ pub const FILIPINO: Self = Self(*b"tl ", 2);
+ pub const FINNISH: Self = Self(*b"fi ", 2);
+ pub const FRENCH: Self = Self(*b"fr ", 2);
+ pub const GALICIAN: Self = Self(*b"gl ", 2);
+ pub const GERMAN: Self = Self(*b"de ", 2);
+ pub const GREEK: Self = Self(*b"gr ", 2);
+ pub const HEBREW: Self = Self(*b"he ", 2);
+ pub const HUNGARIAN: Self = Self(*b"hu ", 2);
+ pub const ICELANDIC: Self = Self(*b"is ", 2);
+ pub const ITALIAN: Self = Self(*b"it ", 2);
+ pub const JAPANESE: Self = Self(*b"ja ", 2);
+ pub const LATIN: Self = Self(*b"la ", 2);
+ pub const LOWER_SORBIAN: Self = Self(*b"dsb", 3);
+ pub const NYNORSK: Self = Self(*b"nn ", 2);
+ pub const POLISH: Self = Self(*b"pl ", 2);
+ pub const PORTUGUESE: Self = Self(*b"pt ", 2);
+ pub const ROMANIAN: Self = Self(*b"ro ", 2);
+ pub const RUSSIAN: Self = Self(*b"ru ", 2);
+ pub const SERBIAN: Self = Self(*b"sr ", 2);
+ pub const SLOVAK: Self = Self(*b"sk ", 2);
+ pub const SLOVENIAN: Self = Self(*b"sl ", 2);
+ pub const SPANISH: Self = Self(*b"es ", 2);
+ pub const SWEDISH: Self = Self(*b"sv ", 2);
+ pub const TURKISH: Self = Self(*b"tr ", 2);
+ pub const UKRAINIAN: Self = Self(*b"ua ", 2);
+ pub const VIETNAMESE: Self = Self(*b"vi ", 2);
+
+ /// Return the language code as an all lowercase string slice.
+ pub fn as_str(&self) -> &str {
+ std::str::from_utf8(&self.0[..usize::from(self.1)]).unwrap_or_default()
+ }
+
+ /// The default direction for the language.
+ pub fn dir(self) -> Dir {
+ match self.as_str() {
+ "ar" | "dv" | "fa" | "he" | "ks" | "pa" | "ps" | "sd" | "ug" | "ur"
+ | "yi" => Dir::RTL,
+ _ => Dir::LTR,
+ }
+ }
+}
+
+impl FromStr for Lang {
+ type Err = &'static str;
+
+ /// Construct a language from a two- or three-byte ISO 639-1/2/3 code.
+ fn from_str(iso: &str) -> Result<Self, Self::Err> {
+ let len = iso.len();
+ if matches!(len, 2..=3) && iso.is_ascii() {
+ let mut bytes = [b' '; 3];
+ bytes[..len].copy_from_slice(iso.as_bytes());
+ bytes.make_ascii_lowercase();
+ Ok(Self(bytes, len as u8))
+ } else {
+ Err("expected two or three letter language code (ISO 639-1/2/3)")
+ }
+ }
+}
+
+cast! {
+ Lang,
+ self => self.as_str().into_value(),
+ string: EcoString => {
+ let result = Self::from_str(&string);
+ if result.is_err() {
+ if let Some((lang, region)) = string.split_once('-') {
+ if Lang::from_str(lang).is_ok() && Region::from_str(region).is_ok() {
+ return result
+ .hint(eco_format!(
+ "you should leave only \"{}\" in the `lang` parameter and specify \"{}\" in the `region` parameter",
+ lang, region,
+ ));
+ }
+ }
+ }
+
+ result?
+ }
+}
+
+/// An identifier for a region somewhere in the world.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Region([u8; 2]);
+
+impl Region {
+ /// Return the region code as an all uppercase string slice.
+ pub fn as_str(&self) -> &str {
+ std::str::from_utf8(&self.0).unwrap_or_default()
+ }
+}
+
+impl PartialEq<&str> for Region {
+ fn eq(&self, other: &&str) -> bool {
+ self.as_str() == *other
+ }
+}
+
+impl FromStr for Region {
+ type Err = &'static str;
+
+ /// Construct a region from its two-byte ISO 3166-1 alpha-2 code.
+ fn from_str(iso: &str) -> Result<Self, Self::Err> {
+ if iso.len() == 2 && iso.is_ascii() {
+ let mut bytes: [u8; 2] = iso.as_bytes().try_into().unwrap();
+ bytes.make_ascii_uppercase();
+ Ok(Self(bytes))
+ } else {
+ Err("expected two letter region code (ISO 3166-1 alpha-2)")
+ }
+ }
+}
+
+cast! {
+ Region,
+ self => self.as_str().into_value(),
+ string: EcoString => Self::from_str(&string)?,
+}
+
+/// An ISO 15924-type script identifier.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct WritingScript([u8; 4], u8);
+
+impl WritingScript {
+ /// Return the script as an all lowercase string slice.
+ pub fn as_str(&self) -> &str {
+ std::str::from_utf8(&self.0[..usize::from(self.1)]).unwrap_or_default()
+ }
+
+ /// Return the description of the script as raw bytes.
+ pub fn as_bytes(&self) -> &[u8; 4] {
+ &self.0
+ }
+}
+
+impl FromStr for WritingScript {
+ type Err = &'static str;
+
+ /// Construct a region from its ISO 15924 code.
+ fn from_str(iso: &str) -> Result<Self, Self::Err> {
+ let len = iso.len();
+ if matches!(len, 3..=4) && iso.is_ascii() {
+ let mut bytes = [b' '; 4];
+ bytes[..len].copy_from_slice(iso.as_bytes());
+ bytes.make_ascii_lowercase();
+ Ok(Self(bytes, len as u8))
+ } else {
+ Err("expected three or four letter script code (ISO 15924 or 'math')")
+ }
+ }
+}
+
+cast! {
+ WritingScript,
+ self => self.as_str().into_value(),
+ string: EcoString => Self::from_str(&string)?,
+}
+
+/// The name with which an element is referenced.
+pub trait LocalName {
+ /// The key of an element in order to get its localized name.
+ const KEY: &'static str;
+
+ /// Get the name in the given language and (optionally) region.
+ fn local_name(lang: Lang, region: Option<Region>) -> &'static str {
+ localized_str(lang, region, Self::KEY)
+ }
+
+ /// Gets the local name from the style chain.
+ fn local_name_in(styles: StyleChain) -> &'static str
+ where
+ Self: Sized,
+ {
+ Self::local_name(TextElem::lang_in(styles), TextElem::region_in(styles))
+ }
+}
+
+/// Retrieves the localized string for a given language and region.
+/// Silently falls back to English if no fitting string exists for
+/// the given language + region. Panics if no fitting string exists
+/// in both given language + region and English.
+#[comemo::memoize]
+pub fn localized_str(lang: Lang, region: Option<Region>, key: &str) -> &'static str {
+ let lang_region_bundle = parse_language_bundle(lang, region).unwrap();
+ if let Some(str) = lang_region_bundle.get(key) {
+ return str;
+ }
+ let lang_bundle = parse_language_bundle(lang, None).unwrap();
+ if let Some(str) = lang_bundle.get(key) {
+ return str;
+ }
+ let english_bundle = parse_language_bundle(Lang::ENGLISH, None).unwrap();
+ english_bundle.get(key).unwrap()
+}
+
+/// Parses the translation file for a given language and region.
+/// Only returns an error if the language file is malformed.
+#[comemo::memoize]
+fn parse_language_bundle(
+ lang: Lang,
+ region: Option<Region>,
+) -> Result<HashMap<&'static str, &'static str>, &'static str> {
+ let language_tuple = TRANSLATIONS.iter().find(|it| it.0 == lang_str(lang, region));
+ let Some((_lang_name, language_file)) = language_tuple else {
+ return Ok(HashMap::new());
+ };
+
+ let mut bundle = HashMap::new();
+ let lines = language_file.trim().lines();
+ for line in lines {
+ if line.trim().starts_with('#') {
+ continue;
+ }
+ let (key, val) = line
+ .split_once('=')
+ .ok_or("malformed translation file: line without \"=\"")?;
+ let (key, val) = (key.trim(), val.trim());
+ if val.is_empty() {
+ return Err("malformed translation file: empty translation value");
+ }
+ let duplicate = bundle.insert(key.trim(), val.trim());
+ if duplicate.is_some() {
+ return Err("malformed translation file: duplicate key");
+ }
+ }
+ Ok(bundle)
+}
+
+/// Convert language + region to a string to be able to get a file name.
+fn lang_str(lang: Lang, region: Option<Region>) -> EcoString {
+ EcoString::from(lang.as_str())
+ + region.map_or_else(EcoString::new, |r| EcoString::from("-") + r.as_str())
+}
+
+#[cfg(test)]
+mod tests {
+ use typst_utils::option_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_region_option_eq() {
+ let region = Some(Region([b'U', b'S']));
+ assert!(option_eq(region, "US"));
+ assert!(!option_eq(region, "AB"));
+ }
+}
diff --git a/crates/typst-library/src/text/linebreak.rs b/crates/typst-library/src/text/linebreak.rs
new file mode 100644
index 00000000..0519e1c4
--- /dev/null
+++ b/crates/typst-library/src/text/linebreak.rs
@@ -0,0 +1,46 @@
+use typst_utils::singleton;
+
+use crate::foundations::{elem, Content, NativeElement};
+
+/// 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
+/// *Date:* 26.12.2022 \
+/// *Topic:* Infrastructure Test \
+/// *Severity:* High \
+/// ```
+///
+/// # 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.
+#[elem(title = "Line Break")]
+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 LinebreakElem {
+ /// Get the globally shared linebreak element.
+ pub fn shared() -> &'static Content {
+ singleton!(Content, LinebreakElem::new().pack())
+ }
+}
diff --git a/crates/typst-library/src/text/lorem.rs b/crates/typst-library/src/text/lorem.rs
new file mode 100644
index 00000000..5d01a550
--- /dev/null
+++ b/crates/typst-library/src/text/lorem.rs
@@ -0,0 +1,24 @@
+use crate::foundations::{func, Str};
+
+/// 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
+/// = Blind Text
+/// #lorem(30)
+///
+/// = More Blind Text
+/// #lorem(15)
+/// ```
+#[func(keywords = ["Blind Text"])]
+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..acf100b5
--- /dev/null
+++ b/crates/typst-library/src/text/mod.rs
@@ -0,0 +1,1318 @@
+//! Text handling.
+
+mod case;
+mod deco;
+mod font;
+mod item;
+mod lang;
+mod linebreak;
+#[path = "lorem.rs"]
+mod lorem_;
+mod raw;
+mod shift;
+#[path = "smallcaps.rs"]
+mod smallcaps_;
+mod smartquote;
+mod space;
+
+pub use self::case::*;
+pub use self::deco::*;
+pub use self::font::*;
+pub use self::item::*;
+pub use self::lang::*;
+pub use self::linebreak::*;
+pub use self::lorem_::*;
+pub use self::raw::*;
+pub use self::shift::*;
+pub use self::smallcaps_::*;
+pub use self::smartquote::*;
+pub use self::space::*;
+
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::{eco_format, EcoString};
+use icu_properties::sets::CodePointSetData;
+use icu_provider::AsDeserializingBufferProvider;
+use icu_provider_blob::BlobDataProvider;
+use once_cell::sync::Lazy;
+use rustybuzz::Feature;
+use smallvec::SmallVec;
+use ttf_parser::Tag;
+use typst_syntax::Spanned;
+
+use crate::diag::{bail, warning, HintedStrResult, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, category, dict, elem, Args, Array, Cast, Category, Construct, Content, Dict,
+ Fold, IntoValue, NativeElement, Never, NoneValue, Packed, PlainText, Repr, Resolve,
+ Scope, Set, Smart, StyleChain,
+};
+use crate::layout::{Abs, Axis, Dir, Em, Length, Ratio, Rel};
+use crate::model::ParElem;
+use crate::visualize::{Color, Paint, RelativeTo, Stroke};
+use crate::World;
+
+/// Text styling.
+///
+/// The [text function]($text) is of particular interest.
+#[category]
+pub static TEXT: Category;
+
+/// Hook up all `text` definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.category(TEXT);
+ global.define_elem::<TextElem>();
+ global.define_elem::<LinebreakElem>();
+ global.define_elem::<SmartQuoteElem>();
+ global.define_elem::<SubElem>();
+ global.define_elem::<SuperElem>();
+ global.define_elem::<UnderlineElem>();
+ global.define_elem::<OverlineElem>();
+ global.define_elem::<StrikeElem>();
+ global.define_elem::<HighlightElem>();
+ global.define_elem::<SmallcapsElem>();
+ global.define_elem::<RawElem>();
+ global.define_func::<lower>();
+ global.define_func::<upper>();
+ global.define_func::<lorem>();
+}
+
+/// 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
+/// #set text(18pt)
+/// With a set rule.
+///
+/// #emph(text(blue)[
+/// With a function call.
+/// ])
+/// ```
+#[elem(Debug, Construct, PlainText, Repr)]
+pub struct TextElem {
+ /// A font family name or priority list of font family names.
+ ///
+ /// 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.
+ ///
+ /// The collection of available fonts differs by platform:
+ ///
+ /// - In the web app, you can see the list of available fonts by clicking on
+ /// the "Ag" button. You can provide additional fonts by uploading `.ttf`
+ /// or `.otf` files into your project. They will be discovered
+ /// automatically. The priority is: project fonts > server fonts.
+ ///
+ /// - Locally, Typst uses your installed system fonts or embedded fonts in
+ /// the CLI, which are `Libertinus Serif`, `New Computer Modern`,
+ /// `New Computer Modern Math`, and `DejaVu Sans Mono`. In addition, you
+ /// can use the `--font-path` argument or `TYPST_FONT_PATHS` environment
+ /// variable to add directories that should be scanned for fonts. The
+ /// priority is: `--font-paths` > system fonts > embedded fonts. Run
+ /// `typst fonts` to see the fonts that Typst has discovered on your
+ /// system. Note that you can pass the `--ignore-system-fonts` parameter
+ /// to the CLI to ensure Typst won't search for system fonts.
+ ///
+ /// ```example
+ /// #set text(font: "PT Sans")
+ /// This is sans-serif.
+ ///
+ /// #set text(font: (
+ /// "Inria Serif",
+ /// "Noto Sans Arabic",
+ /// ))
+ ///
+ /// This is Latin. \
+ /// هذا عربي.
+ /// ```
+ #[parse({
+ let font_list: Option<Spanned<FontList>> = args.named("font")?;
+ if let Some(list) = &font_list {
+ check_font_list(engine, list);
+ }
+ font_list.map(|font_list| font_list.v)
+ })]
+ #[default(FontList(vec![FontFamily::new("Libertinus Serif")]))]
+ #[borrowed]
+ #[ghost]
+ 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)]
+ #[ghost]
+ 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]
+ /// 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: "Libertinus Serif", style: "italic")[Italic]
+ /// #text(font: "DejaVu Sans", style: "oblique")[Oblique]
+ /// ```
+ #[ghost]
+ 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] 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]
+ /// ```
+ #[ghost]
+ pub weight: FontWeight,
+
+ /// The desired width of the glyphs. Accepts a ratio between `{50%}` and
+ /// `{200%}`. When the desired width 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`]($text.tracking)
+ /// property instead.
+ ///
+ /// ```example
+ /// #text(stretch: 75%)[Condensed] \
+ /// #text(stretch: 100%)[Normal]
+ /// ```
+ #[ghost]
+ 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(TextSize(Abs::pt(11.0).into()))]
+ #[resolve]
+ #[ghost]
+ pub size: TextSize,
+
+ /// The glyph fill paint.
+ ///
+ /// ```example
+ /// #set text(fill: red)
+ /// This text is red.
+ /// ```
+ #[parse({
+ let paint: Option<Spanned<Paint>> = args.named_or_find("fill")?;
+ if let Some(paint) = &paint {
+ if paint.v.relative() == Smart::Custom(RelativeTo::Self_) {
+ bail!(
+ paint.span,
+ "gradients and patterns on text must be relative to the parent";
+ hint: "make sure to set `relative: auto` on your text fill"
+ );
+ }
+ }
+ paint.map(|paint| paint.v)
+ })]
+ #[default(Color::BLACK.into())]
+ #[ghost]
+ pub fill: Paint,
+
+ /// How to stroke the text.
+ ///
+ /// ```example
+ /// #text(stroke: 0.5pt + red)[Stroked]
+ /// ```
+ #[resolve]
+ #[ghost]
+ pub stroke: Option<Stroke>,
+
+ /// The amount of space that should be added between characters.
+ ///
+ /// ```example
+ /// #set text(tracking: 1.5pt)
+ /// Distant text.
+ /// ```
+ #[resolve]
+ #[ghost]
+ 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`]($text.tracking) property instead.
+ ///
+ /// ```example
+ /// #set text(spacing: 200%)
+ /// Text with distant words.
+ /// ```
+ #[resolve]
+ #[default(Rel::one())]
+ #[ghost]
+ pub spacing: Rel<Length>,
+
+ /// Whether to automatically insert spacing between CJK and Latin characters.
+ ///
+ /// ```example
+ /// #set text(cjk-latin-spacing: auto)
+ /// 第4章介绍了基本的API。
+ ///
+ /// #set text(cjk-latin-spacing: none)
+ /// 第4章介绍了基本的API。
+ /// ```
+ #[ghost]
+ pub cjk_latin_spacing: Smart<Option<Never>>,
+
+ /// An amount to shift the text baseline by.
+ ///
+ /// ```example
+ /// A #text(baseline: 3pt)[lowered]
+ /// word.
+ /// ```
+ #[resolve]
+ #[ghost]
+ 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)]
+ #[ghost]
+ 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(TopEdge::Metric(TopEdgeMetric::CapHeight))]
+ #[ghost]
+ pub top_edge: TopEdge,
+
+ /// 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(BottomEdge::Metric(BottomEdgeMetric::Baseline))]
+ #[ghost]
+ pub bottom_edge: BottomEdge,
+
+ /// 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]($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)]
+ #[ghost]
+ 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.
+ #[ghost]
+ pub region: Option<Region>,
+
+ /// The OpenType writing script.
+ ///
+ /// The combination of `{lang}` and `{script}` determine how font features,
+ /// such as glyph substitution, are implemented. Frequently the value is a
+ /// modified (all-lowercase) ISO 15924 script identifier, and the `math`
+ /// writing script is used for features appropriate for mathematical
+ /// symbols.
+ ///
+ /// When set to `{auto}`, the default and recommended setting, an
+ /// appropriate script is chosen for each block of characters sharing a
+ /// common Unicode script property.
+ ///
+ /// ```example
+ /// #set text(
+ /// font: "Libertinus Serif",
+ /// size: 20pt,
+ /// )
+ ///
+ /// #let scedilla = [Ş]
+ /// #scedilla // S with a cedilla
+ ///
+ /// #set text(lang: "ro", script: "latn")
+ /// #scedilla // S with a subscript comma
+ ///
+ /// #set text(lang: "ro", script: "grek")
+ /// #scedilla // S with a cedilla
+ /// ```
+ #[ghost]
+ pub script: Smart<WritingScript>,
+
+ /// 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]($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 get in touch with us through the
+ /// [Forum](https://forum.typst.app/),
+ /// [Discord server](https://discord.gg/2uDybryKPe),
+ /// or our [contact form](https://typst.app/contact).
+ ///
+ /// ```example
+ /// #set text(dir: rtl)
+ /// هذا عربي.
+ /// ```
+ #[resolve]
+ #[ghost]
+ 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]($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]
+ #[ghost]
+ pub hyphenate: Hyphenate,
+
+ /// The "cost" of various choices when laying out text. A higher cost means
+ /// the layout engine will make the choice less often. Costs are specified
+ /// as a ratio of the default cost, so `{50%}` will make text layout twice
+ /// as eager to make a given choice, while `{200%}` will make it half as
+ /// eager.
+ ///
+ /// Currently, the following costs can be customized:
+ /// - `hyphenation`: splitting a word across multiple lines
+ /// - `runt`: ending a paragraph with a line with a single word
+ /// - `widow`: leaving a single line of paragraph on the next page
+ /// - `orphan`: leaving single line of paragraph on the previous page
+ ///
+ /// Hyphenation is generally avoided by placing the whole word on the next
+ /// line, so a higher hyphenation cost can result in awkward justification
+ /// spacing.
+ ///
+ /// Runts are avoided by placing more or fewer words on previous lines, so a
+ /// higher runt cost can result in more awkward in justification spacing.
+ ///
+ /// Text layout prevents widows and orphans by default because they are
+ /// generally discouraged by style guides. However, in some contexts they
+ /// are allowed because the prevention method, which moves a line to the
+ /// next page, can result in an uneven number of lines between pages. The
+ /// `widow` and `orphan` costs allow disabling these modifications.
+ /// (Currently, `{0%}` allows widows/orphans; anything else, including the
+ /// default of `{100%}`, prevents them. More nuanced cost specification for
+ /// these modifications is planned for the future.)
+ ///
+ /// ```example
+ /// #set text(hyphenate: true, size: 11.4pt)
+ /// #set par(justify: true)
+ ///
+ /// #lorem(10)
+ ///
+ /// // Set hyphenation to ten times the normal cost.
+ /// #set text(costs: (hyphenation: 1000%))
+ ///
+ /// #lorem(10)
+ /// ```
+ #[fold]
+ pub costs: Costs,
+
+ /// 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)]
+ #[ghost]
+ 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)]
+ #[ghost]
+ pub alternates: bool,
+
+ /// Which stylistic sets 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.
+ ///
+ /// This can be set to an integer or an array of integers, all
+ /// of which must be between `{1}` and `{20}`, enabling the
+ /// corresponding OpenType feature(s) from `ss01` to `ss20`.
+ /// Setting this to `{none}` will disable all stylistic sets.
+ ///
+ /// ```example
+ /// #set text(font: "IBM Plex Serif")
+ /// ß vs #text(stylistic-set: 5)[ß] \
+ /// 10 years ago vs #text(stylistic-set: (1, 2, 3))[10 years ago]
+ /// ```
+ #[ghost]
+ pub stylistic_set: StylisticSets,
+
+ /// 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)]
+ #[ghost]
+ pub ligatures: bool,
+
+ /// Whether ligatures that should be used sparingly are active. Setting this
+ /// to `{true}` enables the OpenType `dlig` font feature.
+ #[default(false)]
+ #[ghost]
+ pub discretionary_ligatures: bool,
+
+ /// Whether historical ligatures are active. Setting this to `{true}`
+ /// enables the OpenType `hlig` font feature.
+ #[default(false)]
+ #[ghost]
+ 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.
+ /// ```
+ #[ghost]
+ 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.
+ /// ```
+ #[ghost]
+ 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)]
+ #[ghost]
+ 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)]
+ #[ghost]
+ 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]
+ #[ghost]
+ pub features: FontFeatures,
+
+ /// Content in which all text is styled according to the other arguments.
+ #[external]
+ #[required]
+ pub body: Content,
+
+ /// The text.
+ #[required]
+ pub text: EcoString,
+
+ /// The offset of the text in the text syntax node referenced by this
+ /// element's span.
+ #[internal]
+ #[ghost]
+ pub span_offset: usize,
+
+ /// A delta to apply on the font weight.
+ #[internal]
+ #[fold]
+ #[ghost]
+ pub delta: WeightDelta,
+
+ /// Whether the font style should be inverted.
+ #[internal]
+ #[fold]
+ #[default(ItalicToggle(false))]
+ #[ghost]
+ pub emph: ItalicToggle,
+
+ /// Decorative lines.
+ #[internal]
+ #[fold]
+ #[ghost]
+ pub deco: SmallVec<[Decoration; 1]>,
+
+ /// A case transformation that should be applied to the text.
+ #[internal]
+ #[ghost]
+ pub case: Option<Case>,
+
+ /// Whether small capital glyphs should be used. ("smcp")
+ #[internal]
+ #[default(false)]
+ #[ghost]
+ 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 Debug for TextElem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Text({})", self.text)
+ }
+}
+
+impl Repr for TextElem {
+ fn repr(&self) -> EcoString {
+ eco_format!("[{}]", self.text)
+ }
+}
+
+impl Construct for TextElem {
+ fn construct(engine: &mut Engine, 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(engine, args)?;
+ let body = args.expect::<Content>("body")?;
+ Ok(body.styled_with_map(styles))
+ }
+}
+
+impl PlainText for Packed<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<'a> IntoIterator for &'a FontList {
+ type IntoIter = std::slice::Iter<'a, FontFamily>;
+ type Item = &'a FontFamily;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.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::<HintedStrResult<_>>()?),
+}
+
+/// Resolve a prioritized iterator over the font families.
+pub fn families(styles: StyleChain) -> impl Iterator<Item = &str> + Clone {
+ const FALLBACKS: &[&str] = &[
+ "libertinus serif",
+ "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()
+ .map(|family| family.as_str())
+ .chain(tail.iter().copied())
+}
+
+/// 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 WeightDelta(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).0 {
+ variant.style = match variant.style {
+ FontStyle::Normal => FontStyle::Italic,
+ FontStyle::Italic => FontStyle::Normal,
+ FontStyle::Oblique => FontStyle::Normal,
+ }
+ }
+
+ variant
+}
+
+/// The size of text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct TextSize(pub Length);
+
+impl Fold for TextSize {
+ fn fold(self, outer: Self) -> Self {
+ // Multiply the two linear functions.
+ Self(Length {
+ em: Em::new(self.0.em.get() * outer.0.em.get()),
+ abs: self.0.em.get() * outer.0.abs + self.0.abs,
+ })
+ }
+}
+
+impl Resolve for TextSize {
+ type Output = Abs;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.0.resolve(styles)
+ }
+}
+
+cast! {
+ TextSize,
+ self => self.0.into_value(),
+ v: Length => Self(v),
+}
+
+/// Specifies the top edge of text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum TopEdge {
+ /// An edge specified via font metrics or bounding box.
+ Metric(TopEdgeMetric),
+ /// An edge specified as a length.
+ Length(Length),
+}
+
+cast! {
+ TopEdge,
+ self => match self {
+ Self::Metric(metric) => metric.into_value(),
+ Self::Length(length) => length.into_value(),
+ },
+ v: TopEdgeMetric => Self::Metric(v),
+ v: Length => Self::Length(v),
+}
+
+/// Metrics that describe the top edge of text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum TopEdgeMetric {
+ /// The font's ascender, which typically exceeds the height of all glyphs.
+ Ascender,
+ /// The approximate height of uppercase letters.
+ CapHeight,
+ /// The approximate height of non-ascending lowercase letters.
+ XHeight,
+ /// The baseline on which the letters rest.
+ Baseline,
+ /// The top edge of the glyph's bounding box.
+ Bounds,
+}
+
+impl TryInto<VerticalFontMetric> for TopEdgeMetric {
+ type Error = ();
+
+ fn try_into(self) -> Result<VerticalFontMetric, Self::Error> {
+ match self {
+ Self::Ascender => Ok(VerticalFontMetric::Ascender),
+ Self::CapHeight => Ok(VerticalFontMetric::CapHeight),
+ Self::XHeight => Ok(VerticalFontMetric::XHeight),
+ Self::Baseline => Ok(VerticalFontMetric::Baseline),
+ _ => Err(()),
+ }
+ }
+}
+
+/// Specifies the top edge of text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum BottomEdge {
+ /// An edge specified via font metrics or bounding box.
+ Metric(BottomEdgeMetric),
+ /// An edge specified as a length.
+ Length(Length),
+}
+
+cast! {
+ BottomEdge,
+ self => match self {
+ Self::Metric(metric) => metric.into_value(),
+ Self::Length(length) => length.into_value(),
+ },
+ v: BottomEdgeMetric => Self::Metric(v),
+ v: Length => Self::Length(v),
+}
+
+/// Metrics that describe the bottom edge of text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum BottomEdgeMetric {
+ /// The baseline on which the letters rest.
+ Baseline,
+ /// The font's descender, which typically exceeds the depth of all glyphs.
+ Descender,
+ /// The bottom edge of the glyph's bounding box.
+ Bounds,
+}
+
+impl TryInto<VerticalFontMetric> for BottomEdgeMetric {
+ type Error = ();
+
+ fn try_into(self) -> Result<VerticalFontMetric, Self::Error> {
+ match self {
+ Self::Baseline => Ok(VerticalFontMetric::Baseline),
+ Self::Descender => Ok(VerticalFontMetric::Descender),
+ _ => Err(()),
+ }
+ }
+}
+
+/// 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.is_custom_and(|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 set of stylistic sets to enable.
+#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Hash)]
+pub struct StylisticSets(u32);
+
+impl StylisticSets {
+ /// Converts this set into a Typst array of values.
+ pub fn into_array(self) -> Array {
+ self.sets().map(IntoValue::into_value).collect()
+ }
+
+ /// Returns whether this set contains a particular stylistic set.
+ pub fn has(self, ss: u8) -> bool {
+ self.0 & (1 << (ss as u32)) != 0
+ }
+
+ /// Returns an iterator over all stylistic sets to enable.
+ pub fn sets(self) -> impl Iterator<Item = u8> {
+ (1..=20).filter(move |i| self.has(*i))
+ }
+}
+
+cast! {
+ StylisticSets,
+ self => self.into_array().into_value(),
+ _: NoneValue => Self(0),
+ v: i64 => match v {
+ 1 ..= 20 => Self(1 << (v as u32)),
+ _ => bail!("stylistic set must be between 1 and 20"),
+ },
+ v: Vec<i64> => {
+ let mut flags = 0;
+ for i in v {
+ match i {
+ 1 ..= 20 => flags |= 1 << (i as u32),
+ _ => bail!("stylistic set must be between 1 and 20"),
+ }
+ }
+ Self(flags)
+ },
+}
+
+/// 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::<HintedStrResult<_>>()?),
+ 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::<HintedStrResult<_>>()?),
+}
+
+impl Fold for FontFeatures {
+ fn fold(self, outer: Self) -> Self {
+ Self(self.0.fold(outer.0))
+ }
+}
+
+/// Collect the OpenType features to apply.
+pub fn features(styles: StyleChain) -> Vec<Feature> {
+ let mut tags = vec![];
+ let mut feat = |tag: &[u8; 4], value: u32| {
+ 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);
+ }
+
+ for set in TextElem::stylistic_set_in(styles).sets() {
+ let storage = [b's', b's', b'0' + set / 10, b'0' + set % 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"hlig", 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
+}
+
+/// A toggle that turns on and off alternatingly if folded.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct ItalicToggle(pub bool);
+
+impl Fold for ItalicToggle {
+ fn fold(self, outer: Self) -> Self {
+ Self(self.0 ^ outer.0)
+ }
+}
+
+/// A delta that is summed up when folded.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct WeightDelta(pub i64);
+
+impl Fold for WeightDelta {
+ fn fold(self, outer: Self) -> Self {
+ Self(outer.0 + self.0)
+ }
+}
+
+/// Costs for various layout decisions.
+///
+/// Costs are updated (prioritizing the later value) when folded.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+#[non_exhaustive]
+pub struct Costs {
+ hyphenation: Option<Ratio>,
+ runt: Option<Ratio>,
+ widow: Option<Ratio>,
+ orphan: Option<Ratio>,
+}
+
+impl Costs {
+ #[must_use]
+ pub fn hyphenation(&self) -> Ratio {
+ self.hyphenation.unwrap_or(Ratio::one())
+ }
+
+ #[must_use]
+ pub fn runt(&self) -> Ratio {
+ self.runt.unwrap_or(Ratio::one())
+ }
+
+ #[must_use]
+ pub fn widow(&self) -> Ratio {
+ self.widow.unwrap_or(Ratio::one())
+ }
+
+ #[must_use]
+ pub fn orphan(&self) -> Ratio {
+ self.orphan.unwrap_or(Ratio::one())
+ }
+}
+
+impl Fold for Costs {
+ #[inline]
+ fn fold(self, outer: Self) -> Self {
+ Self {
+ hyphenation: self.hyphenation.or(outer.hyphenation),
+ runt: self.runt.or(outer.runt),
+ widow: self.widow.or(outer.widow),
+ orphan: self.orphan.or(outer.orphan),
+ }
+ }
+}
+
+cast! {
+ Costs,
+ self => dict![
+ "hyphenation" => self.hyphenation(),
+ "runt" => self.runt(),
+ "widow" => self.widow(),
+ "orphan" => self.orphan(),
+ ].into_value(),
+ mut v: Dict => {
+ let ret = Self {
+ hyphenation: v.take("hyphenation").ok().map(|v| v.cast()).transpose()?,
+ runt: v.take("runt").ok().map(|v| v.cast()).transpose()?,
+ widow: v.take("widow").ok().map(|v| v.cast()).transpose()?,
+ orphan: v.take("orphan").ok().map(|v| v.cast()).transpose()?,
+ };
+ v.finish(&["hyphenation", "runt", "widow", "orphan"])?;
+ ret
+ },
+}
+
+/// Whether a codepoint is Unicode `Default_Ignorable`.
+pub fn is_default_ignorable(c: char) -> bool {
+ /// The set of Unicode default ignorables.
+ static DEFAULT_IGNORABLE_DATA: Lazy<CodePointSetData> = Lazy::new(|| {
+ icu_properties::sets::load_default_ignorable_code_point(
+ &BlobDataProvider::try_new_from_static_blob(typst_assets::icu::ICU)
+ .unwrap()
+ .as_deserializing(),
+ )
+ .unwrap()
+ });
+ DEFAULT_IGNORABLE_DATA.as_borrowed().contains(c)
+}
+
+/// Checks for font families that are not available.
+fn check_font_list(engine: &mut Engine, list: &Spanned<FontList>) {
+ let book = engine.world.book();
+ for family in &list.v {
+ let found = book.contains_family(family.as_str());
+ if family.as_str() == "linux libertine" {
+ let mut warning = warning!(
+ list.span,
+ "Typst's default font has changed from Linux Libertine to its successor Libertinus Serif";
+ hint: "please set the font to `\"Libertinus Serif\"` instead"
+ );
+
+ if found {
+ warning.hint(
+ "Linux Libertine is available on your system - \
+ you can ignore this warning if you are sure you want to use it",
+ );
+ warning.hint("this warning will be removed in Typst 0.13");
+ }
+
+ engine.sink.warn(warning);
+ } else if !found {
+ engine.sink.warn(warning!(
+ list.span,
+ "unknown font family: {}",
+ family.as_str(),
+ ));
+ }
+ }
+}
diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs
new file mode 100644
index 00000000..5ce77348
--- /dev/null
+++ b/crates/typst-library/src/text/raw.rs
@@ -0,0 +1,926 @@
+use std::hash::Hash;
+use std::ops::Range;
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString, EcoVec};
+use once_cell::sync::Lazy;
+use once_cell::unsync::Lazy as UnsyncLazy;
+use syntect::highlighting::{self as synt, Theme};
+use syntect::parsing::{SyntaxDefinition, SyntaxSet, SyntaxSetBuilder};
+use typst_syntax::{split_newlines, LinkedNode, Span, Spanned};
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::Lang;
+use crate::diag::{At, FileError, HintedStrResult, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Args, Array, Bytes, Content, Fold, NativeElement, Packed,
+ PlainText, Show, ShowSet, Smart, StyleChain, Styles, Synthesize, Value,
+};
+use crate::layout::{BlockBody, BlockElem, Em, HAlignment};
+use crate::model::{Figurable, ParElem};
+use crate::text::{
+ FontFamily, FontList, Hyphenate, LinebreakElem, LocalName, TextElem, TextSize,
+};
+use crate::visualize::Color;
+use crate::World;
+
+// Shorthand for highlighter closures.
+type StyleFn<'a> =
+ &'a mut dyn FnMut(usize, &LinkedNode, Range<usize>, synt::Style) -> Content;
+type LineFn<'a> = &'a mut dyn FnMut(usize, Range<usize>, &mut Vec<Content>);
+type ThemeArgType = Smart<Option<EcoString>>;
+
+/// 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
+/// Adding `rbx` to `rcx` gives
+/// the desired result.
+///
+/// What is ```rust fn main()``` in Rust
+/// would be ```c int main()``` in C.
+///
+/// ```rust
+/// fn main() {
+/// println!("Hello World!");
+/// }
+/// ```
+///
+/// This has ``` `backticks` ``` in it
+/// (but the spaces are trimmed). And
+/// ``` here``` the leading space is
+/// also trimmed.
+/// ````
+///
+/// You can also construct a [`raw`] element programmatically from a string (and
+/// provide the language tag via the optional [`lang`]($raw.lang) argument).
+/// ```example
+/// #raw("fn " + "main() {}", lang: "rust")
+/// ```
+///
+/// # 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.
+/// This works both in markup and code.
+///
+/// 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 (except for the language tag, if applicable)
+/// is rendered as is, in particular, there are no escape sequences.
+///
+/// The language tag is an identifier that directly follows the opening
+/// backticks only if there are three or more backticks. If your text starts
+/// with something that looks like an identifier, but no syntax highlighting is
+/// needed, start the text with a single space (which will be trimmed) or use
+/// the single backtick syntax. If your text should start or end with a
+/// backtick, put a space before or after it (it will be trimmed).
+#[elem(
+ scope,
+ title = "Raw Text / Code",
+ Synthesize,
+ Show,
+ ShowSet,
+ 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: RawContent,
+
+ /// Whether the raw text is displayed as a separate block.
+ ///
+ /// In markup mode, using one-backtick notation makes this `{false}`.
+ /// Using three-backtick notation makes it `{true}` if the enclosed content
+ /// contains at least one line break.
+ ///
+ /// ````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.
+ /// This example searches the current directory recursively
+ /// for the text `Hello World`:
+ ///
+ /// ```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"}`, `{"typc"}`, and `{"typm"}` tags for
+ /// [Typst markup]($reference/syntax/#markup),
+ /// [Typst code]($reference/syntax/#code), and
+ /// [Typst math]($reference/syntax/#math), respectively.
+ ///
+ /// ````example
+ /// ```typ
+ /// This is *Typst!*
+ /// ```
+ ///
+ /// This is ```typ also *Typst*```, but inline!
+ /// ````
+ #[borrowed]
+ 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(HAlignment::Start)]
+ pub align: HAlignment,
+
+ /// One or multiple additional syntax definitions to load. The syntax
+ /// definitions should be in the
+ /// [`sublime-syntax` file format](https://www.sublimetext.com/docs/syntax.html).
+ ///
+ /// ````example
+ /// #set raw(syntaxes: "SExpressions.sublime-syntax")
+ ///
+ /// ```sexp
+ /// (defun factorial (x)
+ /// (if (zerop x)
+ /// ; with a comment
+ /// 1
+ /// (* x (factorial (- x 1)))))
+ /// ```
+ /// ````
+ #[parse(
+ let (syntaxes, syntaxes_data) = parse_syntaxes(engine, args)?;
+ syntaxes
+ )]
+ #[fold]
+ pub syntaxes: SyntaxPaths,
+
+ /// The raw file buffers of syntax definition files.
+ #[internal]
+ #[parse(syntaxes_data)]
+ #[fold]
+ pub syntaxes_data: Vec<Bytes>,
+
+ /// The theme to use for syntax highlighting. Theme files should be in the
+ /// [`tmTheme` file format](https://www.sublimetext.com/docs/color_schemes_tmtheme.html).
+ ///
+ /// Applying a theme only affects the color of specifically highlighted
+ /// text. It does not consider the theme's foreground and background
+ /// properties, so that you retain control over the color of raw text. You
+ /// can apply the foreground color yourself with the [`text`] function and
+ /// the background with a [filled block]($block.fill). You could also use
+ /// the [`xml`] function to extract these properties from the theme.
+ ///
+ /// Additionally, you can set the theme to `{none}` to disable highlighting.
+ ///
+ /// ````example
+ /// #set raw(theme: "halcyon.tmTheme")
+ /// #show raw: it => block(
+ /// fill: rgb("#1d2433"),
+ /// inset: 8pt,
+ /// radius: 5pt,
+ /// text(fill: rgb("#a2aabc"), it)
+ /// )
+ ///
+ /// ```typ
+ /// = Chapter 1
+ /// #let hi = "Hello World"
+ /// ```
+ /// ````
+ #[parse(
+ let (theme_path, theme_data) = parse_theme(engine, args)?;
+ theme_path
+ )]
+ #[borrowed]
+ pub theme: ThemeArgType,
+
+ /// The raw file buffer of syntax theme file.
+ #[internal]
+ #[parse(theme_data.map(Some))]
+ #[borrowed]
+ pub theme_data: Option<Bytes>,
+
+ /// The size for a tab stop in spaces. A tab is replaced with enough spaces to
+ /// align with the next multiple of the size.
+ ///
+ /// ````example
+ /// #set raw(tab-size: 8)
+ /// ```tsv
+ /// Year Month Day
+ /// 2000 2 3
+ /// 2001 2 1
+ /// 2002 3 10
+ /// ```
+ /// ````
+ #[default(2)]
+ pub tab_size: usize,
+
+ /// The stylized lines of raw text.
+ ///
+ /// Made accessible for the [`raw.line` element]($raw.line).
+ /// Allows more styling control in `show` rules.
+ #[synthesized]
+ pub lines: Vec<Packed<RawLine>>,
+}
+
+#[scope]
+impl RawElem {
+ #[elem]
+ type RawLine;
+}
+
+impl RawElem {
+ /// The supported language names and tags.
+ pub fn languages() -> Vec<(&'static str, Vec<&'static str>)> {
+ RAW_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"]),
+ ("Typst (math)", vec!["typm"]),
+ ])
+ .collect()
+ }
+}
+
+impl Synthesize for Packed<RawElem> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let seq = self.highlight(styles);
+ self.push_lines(seq);
+ Ok(())
+ }
+}
+
+impl Packed<RawElem> {
+ #[comemo::memoize]
+ fn highlight(&self, styles: StyleChain) -> Vec<Packed<RawLine>> {
+ let elem = self.as_ref();
+ let lines = preprocess(elem.text(), styles, self.span());
+
+ let count = lines.len() as i64;
+ let lang = elem
+ .lang(styles)
+ .as_ref()
+ .as_ref()
+ .map(|s| s.to_lowercase())
+ .or(Some("txt".into()));
+
+ let extra_syntaxes = UnsyncLazy::new(|| {
+ load_syntaxes(&elem.syntaxes(styles), &elem.syntaxes_data(styles)).unwrap()
+ });
+ let non_highlighted_result = |lines: EcoVec<(EcoString, Span)>| {
+ lines.into_iter().enumerate().map(|(i, (line, line_span))| {
+ Packed::new(RawLine::new(
+ i as i64 + 1,
+ count,
+ line.clone(),
+ TextElem::packed(line).spanned(line_span),
+ ))
+ .spanned(line_span)
+ })
+ };
+
+ let theme = elem.theme(styles).as_ref().as_ref().map(|theme_path| {
+ theme_path.as_ref().map(|path| {
+ load_theme(path, elem.theme_data(styles).as_ref().as_ref().unwrap())
+ .unwrap()
+ })
+ });
+ let theme: &Theme = match theme {
+ Smart::Auto => &RAW_THEME,
+ Smart::Custom(Some(ref theme)) => theme,
+ Smart::Custom(None) => return non_highlighted_result(lines).collect(),
+ };
+ let foreground = theme.settings.foreground.unwrap_or(synt::Color::BLACK);
+
+ let mut seq = vec![];
+ if matches!(lang.as_deref(), Some("typ" | "typst" | "typc" | "typm")) {
+ let text =
+ lines.iter().map(|(s, _)| s.clone()).collect::<Vec<_>>().join("\n");
+ let root = match lang.as_deref() {
+ Some("typc") => typst_syntax::parse_code(&text),
+ Some("typm") => typst_syntax::parse_math(&text),
+ _ => typst_syntax::parse(&text),
+ };
+
+ ThemedHighlighter::new(
+ &text,
+ LinkedNode::new(&root),
+ synt::Highlighter::new(theme),
+ &mut |i, _, range, style| {
+ // Find span and start of line.
+ // Note: Dedent is already applied to the text
+ let span = lines.get(i).map_or_else(Span::detached, |l| l.1);
+ let span_offset = text[..range.start]
+ .rfind('\n')
+ .map_or(0, |i| range.start - (i + 1));
+ styled(&text[range], foreground, style, span, span_offset)
+ },
+ &mut |i, range, line| {
+ let span = lines.get(i).map_or_else(Span::detached, |l| l.1);
+ seq.push(
+ Packed::new(RawLine::new(
+ (i + 1) as i64,
+ count,
+ EcoString::from(&text[range]),
+ Content::sequence(line.drain(..)),
+ ))
+ .spanned(span),
+ );
+ },
+ )
+ .highlight();
+ } else if let Some((syntax_set, syntax)) = lang.and_then(|token| {
+ RAW_SYNTAXES
+ .find_syntax_by_token(&token)
+ .map(|syntax| (&*RAW_SYNTAXES, syntax))
+ .or_else(|| {
+ extra_syntaxes
+ .find_syntax_by_token(&token)
+ .map(|syntax| (&**extra_syntaxes, syntax))
+ })
+ }) {
+ let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
+ for (i, (line, line_span)) in lines.into_iter().enumerate() {
+ let mut line_content = vec![];
+ let mut span_offset = 0;
+ for (style, piece) in highlighter
+ .highlight_line(line.as_str(), syntax_set)
+ .into_iter()
+ .flatten()
+ {
+ line_content.push(styled(
+ piece,
+ foreground,
+ style,
+ line_span,
+ span_offset,
+ ));
+ span_offset += piece.len();
+ }
+
+ seq.push(
+ Packed::new(RawLine::new(
+ i as i64 + 1,
+ count,
+ line,
+ Content::sequence(line_content),
+ ))
+ .spanned(line_span),
+ );
+ }
+ } else {
+ seq.extend(non_highlighted_result(lines));
+ };
+
+ seq
+ }
+}
+
+impl Show for Packed<RawElem> {
+ #[typst_macros::time(name = "raw", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let lines = self.lines().map(|v| v.as_slice()).unwrap_or_default();
+
+ let mut seq = EcoVec::with_capacity((2 * lines.len()).saturating_sub(1));
+ for (i, line) in lines.iter().enumerate() {
+ if i != 0 {
+ seq.push(LinebreakElem::shared().clone());
+ }
+
+ seq.push(line.clone().pack());
+ }
+
+ let mut realized = Content::sequence(seq);
+ if self.block(styles) {
+ // Align the text before inserting it into the block.
+ realized = realized.aligned(self.align(styles).into());
+ realized = BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(self.span());
+ }
+
+ Ok(realized)
+ }
+}
+
+impl ShowSet for Packed<RawElem> {
+ fn show_set(&self, styles: StyleChain) -> Styles {
+ let mut out = Styles::new();
+ out.set(TextElem::set_overhang(false));
+ out.set(TextElem::set_lang(Lang::ENGLISH));
+ out.set(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false))));
+ out.set(TextElem::set_size(TextSize(Em::new(0.8).into())));
+ out.set(TextElem::set_font(FontList(vec![FontFamily::new("DejaVu Sans Mono")])));
+ if self.block(styles) {
+ out.set(ParElem::set_shrink(false));
+ }
+ out
+ }
+}
+
+impl LocalName for Packed<RawElem> {
+ const KEY: &'static str = "raw";
+}
+
+impl Figurable for Packed<RawElem> {}
+
+impl PlainText for Packed<RawElem> {
+ fn plain_text(&self, text: &mut EcoString) {
+ text.push_str(&self.text().get());
+ }
+}
+
+/// The content of the raw text.
+#[derive(Debug, Clone, Hash, PartialEq)]
+pub enum RawContent {
+ /// From a string.
+ Text(EcoString),
+ /// From lines of text.
+ Lines(EcoVec<(EcoString, Span)>),
+}
+
+impl RawContent {
+ /// Returns or synthesizes the text content of the raw text.
+ fn get(&self) -> EcoString {
+ match self.clone() {
+ RawContent::Text(text) => text,
+ RawContent::Lines(lines) => {
+ let mut lines = lines.into_iter().map(|(s, _)| s);
+ if lines.len() <= 1 {
+ lines.next().unwrap_or_default()
+ } else {
+ lines.collect::<Vec<_>>().join("\n").into()
+ }
+ }
+ }
+ }
+}
+
+cast! {
+ RawContent,
+ self => self.get().into_value(),
+ v: EcoString => Self::Text(v),
+}
+
+/// A highlighted line of raw text.
+///
+/// This is a helper element that is synthesized by [`raw`] elements.
+///
+/// It allows you to access various properties of the line, such as the line
+/// number, the raw non-highlighted text, the highlighted text, and whether it
+/// is the first or last line of the raw block.
+#[elem(name = "line", title = "Raw Text / Code Line", Show, PlainText)]
+pub struct RawLine {
+ /// The line number of the raw line inside of the raw block, starts at 1.
+ #[required]
+ pub number: i64,
+
+ /// The total number of lines in the raw block.
+ #[required]
+ pub count: i64,
+
+ /// The line of raw text.
+ #[required]
+ pub text: EcoString,
+
+ /// The highlighted raw text.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<RawLine> {
+ #[typst_macros::time(name = "raw.line", span = self.span())]
+ fn show(&self, _: &mut Engine, _styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone())
+ }
+}
+
+impl PlainText for Packed<RawLine> {
+ fn plain_text(&self, text: &mut EcoString) {
+ text.push_str(self.text());
+ }
+}
+
+/// Wrapper struct for the state required to highlight typst code.
+struct ThemedHighlighter<'a> {
+ /// The code being highlighted.
+ code: &'a str,
+ /// The current node being highlighted.
+ node: LinkedNode<'a>,
+ /// The highlighter.
+ highlighter: synt::Highlighter<'a>,
+ /// The current scopes.
+ scopes: Vec<syntect::parsing::Scope>,
+ /// The current highlighted line.
+ current_line: Vec<Content>,
+ /// The range of the current line.
+ range: Range<usize>,
+ /// The current line number.
+ line: usize,
+ /// The function to style a piece of text.
+ style_fn: StyleFn<'a>,
+ /// The function to append a line.
+ line_fn: LineFn<'a>,
+}
+
+impl<'a> ThemedHighlighter<'a> {
+ pub fn new(
+ code: &'a str,
+ top: LinkedNode<'a>,
+ highlighter: synt::Highlighter<'a>,
+ style_fn: StyleFn<'a>,
+ line_fn: LineFn<'a>,
+ ) -> Self {
+ Self {
+ code,
+ node: top,
+ highlighter,
+ range: 0..0,
+ scopes: Vec::new(),
+ current_line: Vec::new(),
+ line: 0,
+ style_fn,
+ line_fn,
+ }
+ }
+
+ pub fn highlight(&mut self) {
+ self.highlight_inner();
+
+ if !self.current_line.is_empty() {
+ (self.line_fn)(
+ self.line,
+ self.range.start..self.code.len(),
+ &mut self.current_line,
+ );
+
+ self.current_line.clear();
+ }
+ }
+
+ fn highlight_inner(&mut self) {
+ if self.node.children().len() == 0 {
+ let style = self.highlighter.style_for_stack(&self.scopes);
+ let segment = &self.code[self.node.range()];
+
+ let mut len = 0;
+ for (i, line) in split_newlines(segment).into_iter().enumerate() {
+ if i != 0 {
+ (self.line_fn)(
+ self.line,
+ self.range.start..self.range.end + len - 1,
+ &mut self.current_line,
+ );
+ self.range.start = self.range.end + len;
+ self.line += 1;
+ }
+
+ let offset = self.node.range().start + len;
+ let token_range = offset..(offset + line.len());
+ self.current_line.push((self.style_fn)(
+ self.line,
+ &self.node,
+ token_range,
+ style,
+ ));
+
+ len += line.len() + 1;
+ }
+
+ self.range.end += segment.len();
+ }
+
+ for child in self.node.children() {
+ let mut scopes = self.scopes.clone();
+ if let Some(tag) = typst_syntax::highlight(&child) {
+ scopes.push(syntect::parsing::Scope::new(tag.tm_scope()).unwrap())
+ }
+
+ std::mem::swap(&mut scopes, &mut self.scopes);
+ self.node = child;
+ self.highlight_inner();
+ std::mem::swap(&mut scopes, &mut self.scopes);
+ }
+ }
+}
+
+fn preprocess(
+ text: &RawContent,
+ styles: StyleChain,
+ span: Span,
+) -> EcoVec<(EcoString, Span)> {
+ if let RawContent::Lines(lines) = text {
+ if lines.iter().all(|(s, _)| !s.contains('\t')) {
+ return lines.clone();
+ }
+ }
+
+ let mut text = text.get();
+ if text.contains('\t') {
+ let tab_size = RawElem::tab_size_in(styles);
+ text = align_tabs(&text, tab_size);
+ }
+ split_newlines(&text)
+ .into_iter()
+ .map(|line| (line.into(), span))
+ .collect()
+}
+
+/// Style a piece of text with a syntect style.
+fn styled(
+ piece: &str,
+ foreground: synt::Color,
+ style: synt::Style,
+ span: Span,
+ span_offset: usize,
+) -> Content {
+ let mut body = TextElem::packed(piece).spanned(span);
+
+ if span_offset > 0 {
+ body = body.styled(TextElem::set_span_offset(span_offset));
+ }
+
+ if style.foreground != foreground {
+ body = body.styled(TextElem::set_fill(to_typst(style.foreground).into()));
+ }
+
+ if style.font_style.contains(synt::FontStyle::BOLD) {
+ body = body.strong().spanned(span);
+ }
+
+ if style.font_style.contains(synt::FontStyle::ITALIC) {
+ body = body.emph().spanned(span);
+ }
+
+ if style.font_style.contains(synt::FontStyle::UNDERLINE) {
+ body = body.underlined().spanned(span);
+ }
+
+ body
+}
+
+fn to_typst(synt::Color { r, g, b, a }: synt::Color) -> Color {
+ Color::from_u8(r, g, b, a)
+}
+
+fn to_syn(color: Color) -> synt::Color {
+ let [r, g, b, a] = color.to_rgb().to_vec4_u8();
+ synt::Color { r, g, b, a }
+}
+
+/// A list of raw syntax file paths.
+#[derive(Debug, Default, Clone, PartialEq, Hash)]
+pub struct SyntaxPaths(Vec<EcoString>);
+
+cast! {
+ SyntaxPaths,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
+
+impl Fold for SyntaxPaths {
+ fn fold(self, outer: Self) -> Self {
+ Self(self.0.fold(outer.0))
+ }
+}
+
+/// Load a syntax set from a list of syntax file paths.
+#[comemo::memoize]
+#[typst_macros::time(name = "load syntaxes")]
+fn load_syntaxes(paths: &SyntaxPaths, bytes: &[Bytes]) -> StrResult<Arc<SyntaxSet>> {
+ let mut out = SyntaxSetBuilder::new();
+
+ // We might have multiple sublime-syntax/yaml files
+ for (path, bytes) in paths.0.iter().zip(bytes.iter()) {
+ let src = std::str::from_utf8(bytes).map_err(FileError::from)?;
+ out.add(SyntaxDefinition::load_from_str(src, false, None).map_err(|err| {
+ eco_format!("failed to parse syntax file `{path}` ({err})")
+ })?);
+ }
+
+ Ok(Arc::new(out.build()))
+}
+
+/// Function to parse the syntaxes argument.
+/// Much nicer than having it be part of the `element` macro.
+fn parse_syntaxes(
+ engine: &mut Engine,
+ args: &mut Args,
+) -> SourceResult<(Option<SyntaxPaths>, Option<Vec<Bytes>>)> {
+ let Some(Spanned { v: paths, span }) =
+ args.named::<Spanned<SyntaxPaths>>("syntaxes")?
+ else {
+ return Ok((None, None));
+ };
+
+ // Load syntax files.
+ let data = paths
+ .0
+ .iter()
+ .map(|path| {
+ let id = span.resolve_path(path).at(span)?;
+ engine.world.file(id).at(span)
+ })
+ .collect::<SourceResult<Vec<Bytes>>>()?;
+
+ // Check that parsing works.
+ let _ = load_syntaxes(&paths, &data).at(span)?;
+
+ Ok((Some(paths), Some(data)))
+}
+
+#[comemo::memoize]
+#[typst_macros::time(name = "load theme")]
+fn load_theme(path: &str, bytes: &Bytes) -> StrResult<Arc<synt::Theme>> {
+ let mut cursor = std::io::Cursor::new(bytes.as_slice());
+
+ synt::ThemeSet::load_from_reader(&mut cursor)
+ .map(Arc::new)
+ .map_err(|err| eco_format!("failed to parse theme file `{path}` ({err})"))
+}
+
+/// Function to parse the theme argument.
+/// Much nicer than having it be part of the `element` macro.
+fn parse_theme(
+ engine: &mut Engine,
+ args: &mut Args,
+) -> SourceResult<(Option<ThemeArgType>, Option<Bytes>)> {
+ let Some(Spanned { v: path, span }) = args.named::<Spanned<ThemeArgType>>("theme")?
+ else {
+ // Argument `theme` not found.
+ return Ok((None, None));
+ };
+
+ let Smart::Custom(path) = path else {
+ // Argument `theme` is `auto`.
+ return Ok((Some(Smart::Auto), None));
+ };
+
+ let Some(path) = path else {
+ // Argument `theme` is `none`.
+ return Ok((Some(Smart::Custom(None)), None));
+ };
+
+ // Load theme file.
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+
+ // Check that parsing works.
+ let _ = load_theme(&path, &data).at(span)?;
+
+ Ok((Some(Smart::Custom(Some(path))), Some(data)))
+}
+
+/// The syntect syntax definitions.
+///
+/// Syntax set is generated from the syntaxes from the `bat` project
+/// <https://github.com/sharkdp/bat/tree/master/assets/syntaxes>
+pub static RAW_SYNTAXES: Lazy<syntect::parsing::SyntaxSet> =
+ Lazy::new(two_face::syntax::extra_no_newlines);
+
+/// The default theme used for syntax highlighting.
+pub static RAW_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),
+ item("meta.diff.range", Some("#8b41b1"), None),
+ item("markup.inserted, meta.diff.header.to-file", Some("#298e0d"), None),
+ item("markup.deleted, meta.diff.header.from-file", Some("#d73a49"), 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::<Color>().unwrap())),
+ background: None,
+ font_style,
+ },
+ }
+}
+
+/// Replace tabs with spaces to align with multiples of `tab_size`.
+fn align_tabs(text: &str, tab_size: usize) -> EcoString {
+ let replacement = " ".repeat(tab_size);
+ let divisor = tab_size.max(1);
+ let amount = text.chars().filter(|&c| c == '\t').count();
+
+ let mut res = EcoString::with_capacity(text.len() - amount + amount * tab_size);
+ let mut column = 0;
+
+ for grapheme in text.graphemes(true) {
+ match grapheme {
+ "\t" => {
+ let required = tab_size - column % divisor;
+ res.push_str(&replacement[..required]);
+ column += required;
+ }
+ "\n" => {
+ res.push_str(grapheme);
+ column = 0;
+ }
+ _ => {
+ res.push_str(grapheme);
+ column += 1;
+ }
+ }
+ }
+
+ res
+}
diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs
new file mode 100644
index 00000000..003ecf47
--- /dev/null
+++ b/crates/typst-library/src/text/shift.rs
@@ -0,0 +1,210 @@
+use ecow::EcoString;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, SequenceElem, Show, StyleChain};
+use crate::layout::{Em, Length};
+use crate::text::{variant, SpaceElem, TextElem, TextSize};
+use crate::World;
+
+/// Renders text in subscript.
+///
+/// The text is rendered smaller and its baseline is lowered.
+///
+/// # Example
+/// ```example
+/// Revenue#sub[yearly]
+/// ```
+#[elem(title = "Subscript", 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 Packed<SubElem> {
+ #[typst_macros::time(name = "sub", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let body = self.body().clone();
+
+ if self.typographic(styles) {
+ if let Some(text) = convert_script(&body, true) {
+ if is_shapable(engine, &text, styles) {
+ return Ok(TextElem::packed(text));
+ }
+ }
+ };
+
+ Ok(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
+/// 1#super[st] try!
+/// ```
+#[elem(title = "Superscript", 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 Packed<SuperElem> {
+ #[typst_macros::time(name = "super", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let body = self.body().clone();
+
+ if self.typographic(styles) {
+ if let Some(text) = convert_script(&body, false) {
+ if is_shapable(engine, &text, styles) {
+ return Ok(TextElem::packed(text));
+ }
+ }
+ };
+
+ Ok(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` leaves.
+fn convert_script(content: &Content, sub: bool) -> Option<EcoString> {
+ if content.is::<SpaceElem>() {
+ Some(' '.into())
+ } else if let Some(elem) = content.to_packed::<TextElem>() {
+ if sub {
+ elem.text().chars().map(to_subscript_codepoint).collect()
+ } else {
+ elem.text().chars().map(to_superscript_codepoint).collect()
+ }
+ } else if let Some(sequence) = content.to_packed::<SequenceElem>() {
+ sequence
+ .children
+ .iter()
+ .map(|item| convert_script(item, sub))
+ .collect()
+ } else {
+ None
+ }
+}
+
+/// Checks whether the first retrievable family contains all code points of the
+/// given string.
+fn is_shapable(engine: &Engine, text: &str, styles: StyleChain) -> bool {
+ let world = engine.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 character to its corresponding Unicode superscript.
+fn to_superscript_codepoint(c: char) -> Option<char> {
+ match c {
+ '1' => Some('¹'),
+ '2' => Some('²'),
+ '3' => Some('³'),
+ '0' | '4'..='9' => char::from_u32(c as u32 - '0' as u32 + '⁰' as u32),
+ '+' => Some('⁺'),
+ '−' => Some('⁻'),
+ '=' => Some('⁼'),
+ '(' => Some('⁽'),
+ ')' => Some('⁾'),
+ 'n' => Some('ⁿ'),
+ 'i' => Some('ⁱ'),
+ ' ' => Some(' '),
+ _ => None,
+ }
+}
+
+/// Convert a character to its corresponding Unicode subscript.
+fn to_subscript_codepoint(c: char) -> Option<char> {
+ match c {
+ '0'..='9' => char::from_u32(c as u32 - '0' as u32 + '₀' as u32),
+ '+' => Some('₊'),
+ '−' => Some('₋'),
+ '=' => Some('₌'),
+ '(' => Some('₍'),
+ ')' => Some('₎'),
+ 'a' => Some('ₐ'),
+ 'e' => Some('ₑ'),
+ 'o' => Some('ₒ'),
+ 'x' => Some('ₓ'),
+ 'h' => Some('ₕ'),
+ 'k' => Some('ₖ'),
+ 'l' => Some('ₗ'),
+ 'm' => Some('ₘ'),
+ 'n' => Some('ₙ'),
+ 'p' => Some('ₚ'),
+ 's' => Some('ₛ'),
+ 't' => Some('ₜ'),
+ ' ' => Some(' '),
+ _ => None,
+ }
+}
diff --git a/crates/typst-library/src/text/smallcaps.rs b/crates/typst-library/src/text/smallcaps.rs
new file mode 100644
index 00000000..bf003bd1
--- /dev/null
+++ b/crates/typst-library/src/text/smallcaps.rs
@@ -0,0 +1,58 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, StyleChain};
+use crate::text::TextElem;
+
+/// Displays text in small capitals.
+///
+/// # Example
+/// ```example
+/// Hello \
+/// #smallcaps[Hello]
+/// ```
+///
+/// # Smallcaps fonts
+/// By default, this enables the OpenType `smcp` feature for the font. Not all
+/// fonts support this feature. Sometimes smallcaps are part of a dedicated
+/// font. This is, for example, the case for the _Latin Modern_ family of fonts.
+/// In those cases, you can use a show-set rule to customize the appearance of
+/// the text in smallcaps:
+///
+/// ```typ
+/// #show smallcaps: set text(font: "Latin Modern Roman Caps")
+/// ```
+///
+/// In the future, this function will support synthesizing smallcaps from normal
+/// letters, but this is not yet implemented.
+///
+/// # Smallcaps headings
+/// You can use a [show rule]($styling/#show-rules) to apply smallcaps
+/// formatting to all your headings. In the example below, we also center-align
+/// our headings and disable the standard bold font.
+///
+/// ```example
+/// #set par(justify: true)
+/// #set heading(numbering: "I.")
+///
+/// #show heading: smallcaps
+/// #show heading: set align(center)
+/// #show heading: set text(
+/// weight: "regular"
+/// )
+///
+/// = Introduction
+/// #lorem(40)
+/// ```
+#[elem(title = "Small Capitals", Show)]
+pub struct SmallcapsElem {
+ /// The content to display in small capitals.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<SmallcapsElem> {
+ #[typst_macros::time(name = "smallcaps", span = self.span())]
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(TextElem::set_smallcaps(true)))
+ }
+}
diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs
new file mode 100644
index 00000000..5917550d
--- /dev/null
+++ b/crates/typst-library/src/text/smartquote.rs
@@ -0,0 +1,387 @@
+use ecow::EcoString;
+use typst_syntax::is_newline;
+use unicode_segmentation::UnicodeSegmentation;
+
+use crate::diag::{bail, HintedStrResult, StrResult};
+use crate::foundations::{
+ array, cast, dict, elem, Array, Dict, FromValue, Packed, PlainText, Smart, Str,
+};
+use crate::layout::Dir;
+use crate::text::{Lang, Region};
+
+/// A language-aware quote that reacts to its context.
+///
+/// Automatically turns into an appropriate opening or closing quote based on
+/// the active [text language]($text.lang).
+///
+/// # Example
+/// ```example
+/// "This is in quotes."
+///
+/// #set text(lang: "de")
+/// "Das ist in Anführungszeichen."
+///
+/// #set text(lang: "fr")
+/// "C'est entre guillemets."
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: The normal quote characters
+/// (`'` and `"`). Typst automatically makes your quotes smart.
+#[elem(name = "smartquote", PlainText)]
+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,
+
+ /// Whether to use alternative quotes.
+ ///
+ /// Does nothing for languages that don't have alternative quotes, or if
+ /// explicit quotes were set.
+ ///
+ /// ```example
+ /// #set text(lang: "de")
+ /// #set smartquote(alternative: true)
+ ///
+ /// "Das ist in anderen Anführungszeichen."
+ /// ```
+ #[default(false)]
+ pub alternative: bool,
+
+ /// The quotes to use.
+ ///
+ /// - When set to `{auto}`, the appropriate single quotes for the
+ /// [text language]($text.lang) will be used. This is the default.
+ /// - Custom quotes can be passed as a string, array, or dictionary of either
+ /// - [string]($str): a string consisting of two characters containing the
+ /// opening and closing double quotes (characters here refer to Unicode
+ /// grapheme clusters)
+ /// - [array]: an array containing the opening and closing double quotes
+ /// - [dictionary]: an array containing the double and single quotes, each
+ /// specified as either `{auto}`, string, or array
+ ///
+ /// ```example
+ /// #set text(lang: "de")
+ /// 'Das sind normale Anführungszeichen.'
+ ///
+ /// #set smartquote(quotes: "()")
+ /// "Das sind eigene Anführungszeichen."
+ ///
+ /// #set smartquote(quotes: (single: ("[[", "]]"), double: auto))
+ /// 'Das sind eigene Anführungszeichen.'
+ /// ```
+ #[borrowed]
+ pub quotes: Smart<SmartQuoteDict>,
+}
+
+impl PlainText for Packed<SmartQuoteElem> {
+ fn plain_text(&self, text: &mut EcoString) {
+ if self.double.unwrap_or(true) {
+ text.push_str("\"");
+ } else {
+ text.push_str("'");
+ }
+ }
+}
+
+/// A smart quote substitutor with zero lookahead.
+#[derive(Debug, Clone)]
+pub struct SmartQuoter {
+ /// The amount of quotes that have been opened.
+ depth: u8,
+ /// Each bit indicates whether the quote at this nesting depth is a double.
+ /// Maximum supported depth is thus 32.
+ kinds: u32,
+}
+
+impl SmartQuoter {
+ /// Start quoting.
+ pub fn new() -> Self {
+ Self { depth: 0, kinds: 0 }
+ }
+
+ /// Determine which smart quote to substitute given this quoter's nesting
+ /// state and the character immediately preceding the quote.
+ pub fn quote<'a>(
+ &mut self,
+ before: Option<char>,
+ quotes: &SmartQuotes<'a>,
+ double: bool,
+ ) -> &'a str {
+ let opened = self.top();
+ let before = before.unwrap_or(' ');
+
+ // If we are after a number and haven't most recently opened a quote of
+ // this kind, produce a prime. Otherwise, we prefer a closing quote.
+ if before.is_numeric() && opened != Some(double) {
+ return if double { "″" } else { "′" };
+ }
+
+ // If we have a single smart quote, didn't recently open a single
+ // quotation, and are after an alphabetic char or an object (e.g. a
+ // math equation), interpret this as an apostrophe.
+ if !double
+ && opened != Some(false)
+ && (before.is_alphabetic() || before == '\u{FFFC}')
+ {
+ return "’";
+ }
+
+ // If the most recently opened quotation is of this kind and the
+ // previous char does not indicate a nested quotation, close it.
+ if opened == Some(double)
+ && !before.is_whitespace()
+ && !is_newline(before)
+ && !is_opening_bracket(before)
+ {
+ self.pop();
+ return quotes.close(double);
+ }
+
+ // Otherwise, open a new the quotation.
+ self.push(double);
+ quotes.open(double)
+ }
+
+ /// The top of our quotation stack. Returns `Some(double)` for the most
+ /// recently opened quote or `None` if we didn't open one.
+ fn top(&self) -> Option<bool> {
+ self.depth.checked_sub(1).map(|i| (self.kinds >> i) & 1 == 1)
+ }
+
+ /// Push onto the quotation stack.
+ fn push(&mut self, double: bool) {
+ if self.depth < 32 {
+ self.kinds |= (double as u32) << self.depth;
+ self.depth += 1;
+ }
+ }
+
+ /// Pop from the quotation stack.
+ fn pop(&mut self) {
+ self.depth -= 1;
+ self.kinds &= (1 << self.depth) - 1;
+ }
+}
+
+impl Default for SmartQuoter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Whether the character is an opening bracket, parenthesis, or brace.
+fn is_opening_bracket(c: char) -> bool {
+ matches!(c, '(' | '{' | '[')
+}
+
+/// Decides which quotes to substitute smart quotes with.
+pub struct SmartQuotes<'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> SmartQuotes<'s> {
+ /// Create a new `Quotes` struct with the given quotes, optionally falling
+ /// back to 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, Italian, Latin,
+ /// Lithuanian, Latvian, Slovak, Slovenian, Spanish, Bosnian, Finnish,
+ /// Swedish, French, Swiss French, Hungarian, Polish, Romanian, Japanese,
+ /// Traditional Chinese, Russian, Norwegian, and Hebrew.
+ ///
+ /// For unknown languages, the English quotes are used as fallback.
+ pub fn get(
+ quotes: &'s Smart<SmartQuoteDict>,
+ lang: Lang,
+ region: Option<Region>,
+ alternative: bool,
+ ) -> Self {
+ let region = region.as_ref().map(Region::as_str);
+
+ let default = ("‘", "’", "“", "”");
+ let low_high = ("‚", "‘", "„", "“");
+
+ let (single_open, single_close, double_open, double_close) = match lang.as_str() {
+ "de" if matches!(region, Some("CH" | "LI")) => match alternative {
+ false => ("‹", "›", "«", "»"),
+ true => low_high,
+ },
+ "fr" if matches!(region, Some("CH")) => match alternative {
+ false => ("‹\u{202F}", "\u{202F}›", "«\u{202F}", "\u{202F}»"),
+ true => default,
+ },
+ "cs" | "da" | "de" | "sk" | "sl" if alternative => ("›", "‹", "»", "«"),
+ "cs" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high,
+ "da" => ("‘", "’", "“", "”"),
+ "fr" | "ru" if alternative => default,
+ "fr" => ("‹\u{00A0}", "\u{00A0}›", "«\u{00A0}", "\u{00A0}»"),
+ "fi" | "sv" if alternative => ("’", "’", "»", "»"),
+ "bs" | "fi" | "sv" => ("’", "’", "”", "”"),
+ "it" if alternative => default,
+ "la" if alternative => ("“", "”", "«\u{202F}", "\u{202F}»"),
+ "it" | "la" => ("“", "”", "«", "»"),
+ "es" if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"),
+ "hu" | "pl" | "ro" => ("’", "’", "„", "”"),
+ "no" | "nb" | "nn" if alternative => low_high,
+ "ru" | "no" | "nb" | "nn" | "ua" => ("’", "’", "«", "»"),
+ "gr" => ("‘", "’", "«", "»"),
+ "he" => ("’", "’", "”", "”"),
+ _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"),
+ _ => default,
+ };
+
+ fn inner_or_default<'s>(
+ quotes: Smart<&'s SmartQuoteDict>,
+ f: impl FnOnce(&'s SmartQuoteDict) -> Smart<&'s SmartQuoteSet>,
+ default: [&'s str; 2],
+ ) -> [&'s str; 2] {
+ match quotes.and_then(f) {
+ Smart::Auto => default,
+ Smart::Custom(SmartQuoteSet { open, close }) => {
+ [open, close].map(|s| s.as_str())
+ }
+ }
+ }
+
+ let quotes = quotes.as_ref();
+ let [single_open, single_close] =
+ inner_or_default(quotes, |q| q.single.as_ref(), [single_open, single_close]);
+ let [double_open, double_close] =
+ inner_or_default(quotes, |q| q.double.as_ref(), [double_open, double_close]);
+
+ Self {
+ single_open,
+ single_close,
+ double_open,
+ double_close,
+ }
+ }
+
+ /// The opening quote.
+ pub fn open(&self, double: bool) -> &'s str {
+ if double {
+ self.double_open
+ } else {
+ self.single_open
+ }
+ }
+
+ /// The closing quote.
+ pub fn close(&self, double: bool) -> &'s str {
+ if double {
+ self.double_close
+ } else {
+ self.single_close
+ }
+ }
+}
+
+/// An opening and closing quote.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct SmartQuoteSet {
+ open: EcoString,
+ close: EcoString,
+}
+
+cast! {
+ SmartQuoteSet,
+ self => array![self.open, self.close].into_value(),
+ value: Array => {
+ let [open, close] = array_to_set(value)?;
+ Self { open, close }
+ },
+ value: Str => {
+ let [open, close] = str_to_set(value.as_str())?;
+ Self { open, close }
+ },
+}
+
+fn str_to_set(value: &str) -> StrResult<[EcoString; 2]> {
+ let mut iter = value.graphemes(true);
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(open), Some(close), None) => Ok([open.into(), close.into()]),
+ _ => {
+ let count = value.graphemes(true).count();
+ bail!(
+ "expected 2 characters, found {count} character{}",
+ if count > 1 { "s" } else { "" }
+ );
+ }
+ }
+}
+
+fn array_to_set(value: Array) -> HintedStrResult<[EcoString; 2]> {
+ let value = value.as_slice();
+ if value.len() != 2 {
+ bail!(
+ "expected 2 quotes, found {} quote{}",
+ value.len(),
+ if value.len() > 1 { "s" } else { "" }
+ );
+ }
+
+ let open: EcoString = value[0].clone().cast()?;
+ let close: EcoString = value[1].clone().cast()?;
+
+ Ok([open, close])
+}
+
+/// A dict of single and double quotes.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct SmartQuoteDict {
+ double: Smart<SmartQuoteSet>,
+ single: Smart<SmartQuoteSet>,
+}
+
+cast! {
+ SmartQuoteDict,
+ self => dict! { "double" => self.double, "single" => self.single }.into_value(),
+ mut value: Dict => {
+ let keys = ["double", "single"];
+
+ let double = value
+ .take("double")
+ .ok()
+ .map(FromValue::from_value)
+ .transpose()?
+ .unwrap_or(Smart::Auto);
+ let single = value
+ .take("single")
+ .ok()
+ .map(FromValue::from_value)
+ .transpose()?
+ .unwrap_or(Smart::Auto);
+
+ value.finish(&keys)?;
+
+ Self { single, double }
+ },
+ value: SmartQuoteSet => Self {
+ double: Smart::Custom(value),
+ single: Smart::Auto,
+ },
+}
diff --git a/crates/typst-library/src/text/space.rs b/crates/typst-library/src/text/space.rs
new file mode 100644
index 00000000..38a55482
--- /dev/null
+++ b/crates/typst-library/src/text/space.rs
@@ -0,0 +1,31 @@
+use ecow::EcoString;
+use typst_utils::singleton;
+
+use crate::foundations::{
+ elem, Content, NativeElement, Packed, PlainText, Repr, Unlabellable,
+};
+
+/// A text space.
+#[elem(Unlabellable, PlainText, Repr)]
+pub struct SpaceElem {}
+
+impl SpaceElem {
+ /// Get the globally shared space element.
+ pub fn shared() -> &'static Content {
+ singleton!(Content, SpaceElem::new().pack())
+ }
+}
+
+impl Repr for SpaceElem {
+ fn repr(&self) -> EcoString {
+ "[ ]".into()
+ }
+}
+
+impl Unlabellable for Packed<SpaceElem> {}
+
+impl PlainText for Packed<SpaceElem> {
+ fn plain_text(&self, text: &mut EcoString) {
+ text.push(' ');
+ }
+}
diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs
new file mode 100644
index 00000000..1a279fbb
--- /dev/null
+++ b/crates/typst-library/src/visualize/color.rs
@@ -0,0 +1,2013 @@
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::str::FromStr;
+
+use ecow::{eco_format, EcoString, EcoVec};
+use once_cell::sync::Lazy;
+use palette::encoding::{self, Linear};
+use palette::{
+ Alpha, Darken, Desaturate, FromColor, Lighten, OklabHue, RgbHue, Saturate, ShiftHue,
+};
+use qcms::Profile;
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::foundations::{
+ array, cast, func, repr, scope, ty, Args, Array, IntoValue, Module, Repr, Scope, Str,
+ Value,
+};
+use crate::layout::{Angle, Ratio};
+
+// Type aliases for `palette` internal types in f32.
+pub type Oklab = palette::oklab::Oklaba<f32>;
+pub type Oklch = palette::oklch::Oklcha<f32>;
+pub type LinearRgb = palette::rgb::Rgba<Linear<encoding::Srgb>, f32>;
+pub type Rgb = palette::rgb::Rgba<encoding::Srgb, f32>;
+pub type Hsl = palette::hsl::Hsla<encoding::Srgb, f32>;
+pub type Hsv = palette::hsv::Hsva<encoding::Srgb, f32>;
+pub type Luma = palette::luma::Lumaa<encoding::Srgb, f32>;
+
+/// The ICC profile used to convert from CMYK to RGB.
+///
+/// This is a minimal CMYK profile that only contains the necessary information
+/// to convert from CMYK to RGB. It is based on the CGATS TR 001-1995
+/// specification. See
+/// <https://github.com/saucecontrol/Compact-ICC-Profiles#cmyk>.
+static CMYK_TO_XYZ: Lazy<Box<Profile>> =
+ Lazy::new(|| Profile::new_from_slice(typst_assets::icc::CMYK_TO_XYZ, false).unwrap());
+
+/// The target sRGB profile.
+static SRGB_PROFILE: Lazy<Box<Profile>> = Lazy::new(|| {
+ let mut out = Profile::new_sRGB();
+ out.precache_output_transform();
+ out
+});
+
+static TO_SRGB: Lazy<qcms::Transform> = Lazy::new(|| {
+ qcms::Transform::new_to(
+ &CMYK_TO_XYZ,
+ &SRGB_PROFILE,
+ qcms::DataType::CMYK,
+ qcms::DataType::RGB8,
+ // Our input profile only supports perceptual intent.
+ qcms::Intent::Perceptual,
+ )
+ .unwrap()
+});
+
+/// A color in a specific color space.
+///
+/// Typst supports:
+/// - sRGB through the [`rgb` function]($color.rgb)
+/// - Device CMYK through [`cmyk` function]($color.cmyk)
+/// - D65 Gray through the [`luma` function]($color.luma)
+/// - Oklab through the [`oklab` function]($color.oklab)
+/// - Oklch through the [`oklch` function]($color.oklch)
+/// - Linear RGB through the [`color.linear-rgb` function]($color.linear-rgb)
+/// - HSL through the [`color.hsl` function]($color.hsl)
+/// - HSV through the [`color.hsv` function]($color.hsv)
+///
+///
+/// # Example
+///
+/// ```example
+/// #rect(fill: aqua)
+/// ```
+///
+/// # Predefined colors
+/// Typst defines the following built-in colors:
+///
+/// | Color | Definition |
+/// |-----------|:-------------------|
+/// | `black` | `{luma(0)}` |
+/// | `gray` | `{luma(170)}` |
+/// | `silver` | `{luma(221)}` |
+/// | `white` | `{luma(255)}` |
+/// | `navy` | `{rgb("#001f3f")}` |
+/// | `blue` | `{rgb("#0074d9")}` |
+/// | `aqua` | `{rgb("#7fdbff")}` |
+/// | `teal` | `{rgb("#39cccc")}` |
+/// | `eastern` | `{rgb("#239dad")}` |
+/// | `purple` | `{rgb("#b10dc9")}` |
+/// | `fuchsia` | `{rgb("#f012be")}` |
+/// | `maroon` | `{rgb("#85144b")}` |
+/// | `red` | `{rgb("#ff4136")}` |
+/// | `orange` | `{rgb("#ff851b")}` |
+/// | `yellow` | `{rgb("#ffdc00")}` |
+/// | `olive` | `{rgb("#3d9970")}` |
+/// | `green` | `{rgb("#2ecc40")}` |
+/// | `lime` | `{rgb("#01ff70")}` |
+///
+/// The predefined colors and the most important color constructors are
+/// available globally and also in the color type's scope, so you can write
+/// either `color.red` or just `red`.
+///
+/// ```preview
+/// #let colors = (
+/// "black", "gray", "silver", "white",
+/// "navy", "blue", "aqua", "teal",
+/// "eastern", "purple", "fuchsia",
+/// "maroon", "red", "orange", "yellow",
+/// "olive", "green", "lime",
+/// )
+///
+/// #set text(font: "PT Sans")
+/// #set page(width: auto)
+/// #grid(
+/// columns: 9,
+/// gutter: 10pt,
+/// ..colors.map(name => {
+/// let col = eval(name)
+/// let luminance = luma(col).components().first()
+/// set text(fill: white) if luminance < 50%
+/// set square(stroke: black) if col == white
+/// set align(center + horizon)
+/// square(size: 50pt, fill: col, name)
+/// })
+/// )
+/// ```
+///
+/// # Predefined color maps
+/// Typst also includes a number of preset color maps that can be used for
+/// [gradients]($gradient.linear). These are simply arrays of colors defined in
+/// the module `color.map`.
+///
+/// ```example
+/// #circle(fill: gradient.linear(..color.map.crest))
+/// ```
+///
+/// | Map | Details |
+/// |------------|:------------------------------------------------------------|
+/// | `turbo` | A perceptually uniform rainbow-like color map. Read [this blog post](https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html) for more details. |
+/// | `cividis` | A blue to gray to yellow color map. See [this blog post](https://bids.github.io/colormap/) for more details. |
+/// | `rainbow` | Cycles through the full color spectrum. This color map is best used by setting the interpolation color space to [HSL]($color.hsl). The rainbow gradient is **not suitable** for data visualization because it is not perceptually uniform, so the differences between values become unclear to your readers. It should only be used for decorative purposes. |
+/// | `spectral` | Red to yellow to blue color map. |
+/// | `viridis` | A purple to teal to yellow color map. |
+/// | `inferno` | A black to red to yellow color map. |
+/// | `magma` | A black to purple to yellow color map. |
+/// | `plasma` | A purple to pink to yellow color map. |
+/// | `rocket` | A black to red to white color map. |
+/// | `mako` | A black to teal to yellow color map. |
+/// | `vlag` | A light blue to white to red color map. |
+/// | `icefire` | A light teal to black to yellow color map. |
+/// | `flare` | A orange to purple color map that is perceptually uniform. |
+/// | `crest` | A blue to white to red color map. |
+///
+/// Some popular presets are not included because they are not available under a
+/// free licence. Others, like
+/// [Jet](https://jakevdp.github.io/blog/2014/10/16/how-bad-is-your-colormap/),
+/// are not included because they are not color blind friendly. Feel free to use
+/// or create a package with other presets that are useful to you!
+///
+/// ```preview
+/// #set page(width: auto, height: auto)
+/// #set text(font: "PT Sans", size: 8pt)
+///
+/// #let maps = (
+/// "turbo", "cividis", "rainbow", "spectral",
+/// "viridis", "inferno", "magma", "plasma",
+/// "rocket", "mako", "vlag", "icefire",
+/// "flare", "crest",
+/// )
+///
+/// #stack(dir: ltr, spacing: 3pt, ..maps.map((name) => {
+/// let map = eval("color.map." + name)
+/// stack(
+/// dir: ttb,
+/// block(
+/// width: 15pt,
+/// height: 100pt,
+/// fill: gradient.linear(..map, angle: 90deg),
+/// ),
+/// block(
+/// width: 15pt,
+/// height: 32pt,
+/// move(dy: 8pt, rotate(90deg, name)),
+/// ),
+/// )
+/// }))
+/// ```
+#[ty(scope, cast)]
+#[derive(Copy, Clone)]
+pub enum Color {
+ /// A 32-bit luma color.
+ Luma(Luma),
+ /// A 32-bit L\*a\*b\* color in the Oklab color space.
+ Oklab(Oklab),
+ /// A 32-bit LCh color in the Oklab color space.
+ Oklch(Oklch),
+ /// A 32-bit RGB color.
+ Rgb(Rgb),
+ /// A 32-bit linear RGB color.
+ LinearRgb(LinearRgb),
+ /// A 32-bit CMYK color.
+ Cmyk(Cmyk),
+ /// A 32-bit HSL color.
+ Hsl(Hsl),
+ /// A 32-bit HSV color.
+ Hsv(Hsv),
+}
+
+#[scope]
+impl Color {
+ /// The module of preset color maps.
+ pub const MAP: fn() -> Module = || typst_utils::singleton!(Module, map()).clone();
+
+ pub const BLACK: Self = Self::Luma(Luma::new(0.0, 1.0));
+ pub const GRAY: Self = Self::Luma(Luma::new(0.6666666, 1.0));
+ pub const WHITE: Self = Self::Luma(Luma::new(1.0, 1.0));
+ pub const SILVER: Self = Self::Luma(Luma::new(0.8666667, 1.0));
+ pub const NAVY: Self = Self::Rgb(Rgb::new(0.0, 0.121569, 0.247059, 1.0));
+ pub const BLUE: Self = Self::Rgb(Rgb::new(0.0, 0.454902, 0.85098, 1.0));
+ pub const AQUA: Self = Self::Rgb(Rgb::new(0.4980392, 0.858823, 1.0, 1.0));
+ pub const TEAL: Self = Self::Rgb(Rgb::new(0.223529, 0.8, 0.8, 1.0));
+ pub const EASTERN: Self = Self::Rgb(Rgb::new(0.13725, 0.615686, 0.678431, 1.0));
+ pub const PURPLE: Self = Self::Rgb(Rgb::new(0.694118, 0.050980, 0.788235, 1.0));
+ pub const FUCHSIA: Self = Self::Rgb(Rgb::new(0.941177, 0.070588, 0.745098, 1.0));
+ pub const MAROON: Self = Self::Rgb(Rgb::new(0.521569, 0.078431, 0.294118, 1.0));
+ pub const RED: Self = Self::Rgb(Rgb::new(1.0, 0.254902, 0.211765, 1.0));
+ pub const ORANGE: Self = Self::Rgb(Rgb::new(1.0, 0.521569, 0.105882, 1.0));
+ pub const YELLOW: Self = Self::Rgb(Rgb::new(1.0, 0.8627451, 0.0, 1.0));
+ pub const OLIVE: Self = Self::Rgb(Rgb::new(0.239216, 0.6, 0.4392157, 1.0));
+ pub const GREEN: Self = Self::Rgb(Rgb::new(0.1803922, 0.8, 0.2509804, 1.0));
+ pub const LIME: Self = Self::Rgb(Rgb::new(0.0039216, 1.0, 0.4392157, 1.0));
+
+ /// Create a grayscale color.
+ ///
+ /// A grayscale color is represented internally by a single `lightness`
+ /// component.
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #for x in range(250, step: 50) {
+ /// box(square(fill: luma(x)))
+ /// }
+ /// ```
+ #[func]
+ pub fn luma(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The lightness component.
+ #[external]
+ lightness: Component,
+ /// The alpha component.
+ #[external]
+ alpha: RatioComponent,
+ /// Alternatively: The color to convert to grayscale.
+ ///
+ /// If this is given, the `lightness` should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_luma()
+ } else {
+ let Component(gray) =
+ args.expect("gray component").unwrap_or(Component(Ratio::one()));
+ let RatioComponent(alpha) =
+ args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+ Self::Luma(Luma::new(gray.get() as f32, alpha.get() as f32))
+ })
+ }
+
+ /// Create an [Oklab](https://bottosson.github.io/posts/oklab/) color.
+ ///
+ /// This color space is well suited for the following use cases:
+ /// - Color manipulation such as saturating while keeping perceived hue
+ /// - Creating grayscale images with uniform perceived lightness
+ /// - Creating smooth and uniform color transition and gradients
+ ///
+ /// A linear Oklab color is represented internally by an array of four
+ /// components:
+ /// - lightness ([`ratio`])
+ /// - a ([`float`] or [`ratio`].
+ /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`)
+ /// - b ([`float`] or [`ratio`].
+ /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`)
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: oklab(27%, 20%, -3%, 50%)
+ /// )
+ /// ```
+ #[func]
+ pub fn oklab(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The lightness component.
+ #[external]
+ lightness: RatioComponent,
+ /// The a ("green/red") component.
+ #[external]
+ a: ChromaComponent,
+ /// The b ("blue/yellow") component.
+ #[external]
+ b: ChromaComponent,
+ /// The alpha component.
+ #[external]
+ alpha: RatioComponent,
+ /// Alternatively: The color to convert to Oklab.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_oklab()
+ } else {
+ let RatioComponent(l) = args.expect("lightness component")?;
+ let ChromaComponent(a) = args.expect("A component")?;
+ let ChromaComponent(b) = args.expect("B component")?;
+ let RatioComponent(alpha) =
+ args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+ Self::Oklab(Oklab::new(l.get() as f32, a, b, alpha.get() as f32))
+ })
+ }
+
+ /// Create an [Oklch](https://bottosson.github.io/posts/oklab/) color.
+ ///
+ /// This color space is well suited for the following use cases:
+ /// - Color manipulation involving lightness, chroma, and hue
+ /// - Creating grayscale images with uniform perceived lightness
+ /// - Creating smooth and uniform color transition and gradients
+ ///
+ /// A linear Oklch color is represented internally by an array of four
+ /// components:
+ /// - lightness ([`ratio`])
+ /// - chroma ([`float`] or [`ratio`].
+ /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`)
+ /// - hue ([`angle`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: oklch(40%, 0.2, 160deg, 50%)
+ /// )
+ /// ```
+ #[func]
+ pub fn oklch(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The lightness component.
+ #[external]
+ lightness: RatioComponent,
+ /// The chroma component.
+ #[external]
+ chroma: ChromaComponent,
+ /// The hue component.
+ #[external]
+ hue: Angle,
+ /// The alpha component.
+ #[external]
+ alpha: RatioComponent,
+ /// Alternatively: The color to convert to Oklch.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_oklch()
+ } else {
+ let RatioComponent(l) = args.expect("lightness component")?;
+ let ChromaComponent(c) = args.expect("chroma component")?;
+ let h: Angle = args.expect("hue component")?;
+ let RatioComponent(alpha) =
+ args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+ Self::Oklch(Oklch::new(
+ l.get() as f32,
+ c,
+ OklabHue::from_degrees(h.to_deg() as f32),
+ alpha.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an RGB(A) color with linear luma.
+ ///
+ /// This color space is similar to sRGB, but with the distinction that the
+ /// color component are not gamma corrected. This makes it easier to perform
+ /// color operations such as blending and interpolation. Although, you
+ /// should prefer to use the [`oklab` function]($color.oklab) for these.
+ ///
+ /// A linear RGB(A) color is represented internally by an array of four
+ /// components:
+ /// - red ([`ratio`])
+ /// - green ([`ratio`])
+ /// - blue ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(fill: color.linear-rgb(
+ /// 30%, 50%, 10%,
+ /// ))
+ /// ```
+ #[func(title = "Linear RGB")]
+ pub fn linear_rgb(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The red component.
+ #[external]
+ red: Component,
+ /// The green component.
+ #[external]
+ green: Component,
+ /// The blue component.
+ #[external]
+ blue: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color to convert to linear RGB(A).
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_linear_rgb()
+ } 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(Ratio::one()));
+ Self::LinearRgb(LinearRgb::new(
+ r.get() as f32,
+ g.get() as f32,
+ b.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an RGB(A) color.
+ ///
+ /// The color is specified in the sRGB color space.
+ ///
+ /// An RGB(A) color is represented internally by an array of four components:
+ /// - red ([`ratio`])
+ /// - green ([`ratio`])
+ /// - blue ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the [`components`]($color.components)
+ /// method.
+ ///
+ /// ```example
+ /// #square(fill: rgb("#b1f2eb"))
+ /// #square(fill: rgb(87, 127, 230))
+ /// #square(fill: rgb(25%, 13%, 65%))
+ /// ```
+ #[func(title = "RGB")]
+ pub fn rgb(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The red component.
+ #[external]
+ red: Component,
+ /// The green component.
+ #[external]
+ green: Component,
+ /// The blue component.
+ #[external]
+ blue: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color in hexadecimal notation.
+ ///
+ /// Accepts three, four, six or eight hexadecimal digits and optionally
+ /// a leading hash.
+ ///
+ /// If this is given, the individual components should not be given.
+ ///
+ /// ```example
+ /// #text(16pt, rgb("#239dad"))[
+ /// *Typst*
+ /// ]
+ /// ```
+ #[external]
+ hex: Str,
+ /// Alternatively: The color to convert to RGB(a).
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(string) = args.find::<Spanned<Str>>()? {
+ Self::from_str(&string.v).at(string.span)?
+ } else if let Some(color) = args.find::<Color>()? {
+ color.to_rgb()
+ } 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(Ratio::one()));
+ Self::Rgb(Rgb::new(
+ r.get() as f32,
+ g.get() as f32,
+ b.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Create 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.
+ ///
+ /// A CMYK color is represented internally by an array of four components:
+ /// - cyan ([`ratio`])
+ /// - magenta ([`ratio`])
+ /// - yellow ([`ratio`])
+ /// - key ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// Note that CMYK colors are not currently supported when PDF/A output is
+ /// enabled.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: cmyk(27%, 0%, 3%, 5%)
+ /// )
+ /// ```
+ #[func(title = "CMYK")]
+ pub fn cmyk(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The cyan component.
+ #[external]
+ cyan: RatioComponent,
+ /// The magenta component.
+ #[external]
+ magenta: RatioComponent,
+ /// The yellow component.
+ #[external]
+ yellow: RatioComponent,
+ /// The key component.
+ #[external]
+ key: RatioComponent,
+ /// Alternatively: The color to convert to CMYK.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_cmyk()
+ } else {
+ let RatioComponent(c) = args.expect("cyan component")?;
+ let RatioComponent(m) = args.expect("magenta component")?;
+ let RatioComponent(y) = args.expect("yellow component")?;
+ let RatioComponent(k) = args.expect("key/black component")?;
+ Self::Cmyk(Cmyk::new(
+ c.get() as f32,
+ m.get() as f32,
+ y.get() as f32,
+ k.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an HSL color.
+ ///
+ /// This color space is useful for specifying colors by hue, saturation and
+ /// lightness. It is also useful for color manipulation, such as saturating
+ /// while keeping perceived hue.
+ ///
+ /// An HSL color is represented internally by an array of four components:
+ /// - hue ([`angle`])
+ /// - saturation ([`ratio`])
+ /// - lightness ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: color.hsl(30deg, 50%, 60%)
+ /// )
+ /// ```
+ #[func(title = "HSL")]
+ pub fn hsl(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The hue angle.
+ #[external]
+ hue: Angle,
+ /// The saturation component.
+ #[external]
+ saturation: Component,
+ /// The lightness component.
+ #[external]
+ lightness: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color to convert to HSL.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_hsl()
+ } else {
+ let h: Angle = args.expect("hue component")?;
+ let Component(s) = args.expect("saturation component")?;
+ let Component(l) = args.expect("lightness component")?;
+ let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one()));
+ Self::Hsl(Hsl::new(
+ RgbHue::from_degrees(h.to_deg() as f32),
+ s.get() as f32,
+ l.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an HSV color.
+ ///
+ /// This color space is useful for specifying colors by hue, saturation and
+ /// value. It is also useful for color manipulation, such as saturating
+ /// while keeping perceived hue.
+ ///
+ /// An HSV color is represented internally by an array of four components:
+ /// - hue ([`angle`])
+ /// - saturation ([`ratio`])
+ /// - value ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: color.hsv(30deg, 50%, 60%)
+ /// )
+ /// ```
+ #[func(title = "HSV")]
+ pub fn hsv(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The hue angle.
+ #[external]
+ hue: Angle,
+ /// The saturation component.
+ #[external]
+ saturation: Component,
+ /// The value component.
+ #[external]
+ value: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color to convert to HSL.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_hsv()
+ } else {
+ let h: Angle = args.expect("hue component")?;
+ let Component(s) = args.expect("saturation component")?;
+ let Component(v) = args.expect("value component")?;
+ let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one()));
+ Self::Hsv(Hsv::new(
+ RgbHue::from_degrees(h.to_deg() as f32),
+ s.get() as f32,
+ v.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Extracts the components of this color.
+ ///
+ /// The size and values of this array depends on the color space. You can
+ /// obtain the color space using [`space`]($color.space). Below is a table
+ /// of the color spaces and their components:
+ ///
+ /// | Color space | C1 | C2 | C3 | C4 |
+ /// |-------------------------|-----------|------------|-----------|--------|
+ /// | [`luma`]($color.luma) | Lightness | | | |
+ /// | [`oklab`]($color.oklab) | Lightness | `a` | `b` | Alpha |
+ /// | [`oklch`]($color.oklch) | Lightness | Chroma | Hue | Alpha |
+ /// | [`linear-rgb`]($color.linear-rgb) | Red | Green | Blue | Alpha |
+ /// | [`rgb`]($color.rgb) | Red | Green | Blue | Alpha |
+ /// | [`cmyk`]($color.cmyk) | Cyan | Magenta | Yellow | Key |
+ /// | [`hsl`]($color.hsl) | Hue | Saturation | Lightness | Alpha |
+ /// | [`hsv`]($color.hsv) | Hue | Saturation | Value | Alpha |
+ ///
+ /// For the meaning and type of each individual value, see the documentation
+ /// of the corresponding color space. The alpha component is optional and
+ /// only included if the `alpha` argument is `true`. The length of the
+ /// returned array depends on the number of components and whether the alpha
+ /// component is included.
+ ///
+ /// ```example
+ /// // note that the alpha component is included by default
+ /// #rgb(40%, 60%, 80%).components()
+ /// ```
+ #[func]
+ pub fn components(
+ self,
+ /// Whether to include the alpha component.
+ #[named]
+ #[default(true)]
+ alpha: bool,
+ ) -> Array {
+ let mut components = match self {
+ Self::Luma(c) => {
+ array![Ratio::new(c.luma.into()), Ratio::new(c.alpha.into())]
+ }
+ Self::Oklab(c) => {
+ array![
+ Ratio::new(c.l.into()),
+ f64::from(c.a),
+ f64::from(c.b),
+ Ratio::new(c.alpha.into())
+ ]
+ }
+ Self::Oklch(c) => {
+ array![
+ Ratio::new(c.l.into()),
+ f64::from(c.chroma),
+ hue_angle(c.hue.into_degrees()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::LinearRgb(c) => {
+ array![
+ Ratio::new(c.red.into()),
+ Ratio::new(c.green.into()),
+ Ratio::new(c.blue.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::Rgb(c) => {
+ array![
+ Ratio::new(c.red.into()),
+ Ratio::new(c.green.into()),
+ Ratio::new(c.blue.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::Cmyk(c) => {
+ array![
+ Ratio::new(c.c.into()),
+ Ratio::new(c.m.into()),
+ Ratio::new(c.y.into()),
+ Ratio::new(c.k.into())
+ ]
+ }
+ Self::Hsl(c) => {
+ array![
+ hue_angle(c.hue.into_degrees()),
+ Ratio::new(c.saturation.into()),
+ Ratio::new(c.lightness.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::Hsv(c) => {
+ array![
+ hue_angle(c.hue.into_degrees()),
+ Ratio::new(c.saturation.into()),
+ Ratio::new(c.value.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ };
+ // Remove the alpha component if the corresponding argument was set.
+ if !alpha && !matches!(self, Self::Cmyk(_)) {
+ let _ = components.pop();
+ }
+ components
+ }
+
+ /// Returns the constructor function for this color's space:
+ /// - [`luma`]($color.luma)
+ /// - [`oklab`]($color.oklab)
+ /// - [`oklch`]($color.oklch)
+ /// - [`linear-rgb`]($color.linear-rgb)
+ /// - [`rgb`]($color.rgb)
+ /// - [`cmyk`]($color.cmyk)
+ /// - [`hsl`]($color.hsl)
+ /// - [`hsv`]($color.hsv)
+ ///
+ /// ```example
+ /// #let color = cmyk(1%, 2%, 3%, 4%)
+ /// #(color.space() == cmyk)
+ /// ```
+ #[func]
+ pub fn space(self) -> ColorSpace {
+ match self {
+ Self::Luma(_) => ColorSpace::D65Gray,
+ Self::Oklab(_) => ColorSpace::Oklab,
+ Self::Oklch(_) => ColorSpace::Oklch,
+ Self::LinearRgb(_) => ColorSpace::LinearRgb,
+ Self::Rgb(_) => ColorSpace::Srgb,
+ Self::Cmyk(_) => ColorSpace::Cmyk,
+ Self::Hsl(_) => ColorSpace::Hsl,
+ Self::Hsv(_) => ColorSpace::Hsv,
+ }
+ }
+
+ /// Returns the color's RGB(A) hex representation (such as `#ffaa32` or
+ /// `#020304fe`). The alpha component (last two digits in `#020304fe`) is
+ /// omitted if it is equal to `ff` (255 / 100%).
+ #[func]
+ pub fn to_hex(self) -> EcoString {
+ let [r, g, b, a] = self.to_rgb().to_vec4_u8();
+ if a != 255 {
+ eco_format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
+ } else {
+ eco_format!("#{:02x}{:02x}{:02x}", r, g, b)
+ }
+ }
+
+ /// Lightens a color by a given factor.
+ #[func]
+ pub fn lighten(
+ self,
+ /// The factor to lighten the color by.
+ factor: Ratio,
+ ) -> Color {
+ let factor = factor.get() as f32;
+ match self {
+ Self::Luma(c) => Self::Luma(c.lighten(factor)),
+ Self::Oklab(c) => Self::Oklab(c.lighten(factor)),
+ Self::Oklch(c) => Self::Oklch(c.lighten(factor)),
+ Self::LinearRgb(c) => Self::LinearRgb(c.lighten(factor)),
+ Self::Rgb(c) => Self::Rgb(c.lighten(factor)),
+ Self::Cmyk(c) => Self::Cmyk(c.lighten(factor)),
+ Self::Hsl(c) => Self::Hsl(c.lighten(factor)),
+ Self::Hsv(c) => Self::Hsv(c.lighten(factor)),
+ }
+ }
+
+ /// Darkens a color by a given factor.
+ #[func]
+ pub fn darken(
+ self,
+ /// The factor to darken the color by.
+ factor: Ratio,
+ ) -> Color {
+ let factor = factor.get() as f32;
+ match self {
+ Self::Luma(c) => Self::Luma(c.darken(factor)),
+ Self::Oklab(c) => Self::Oklab(c.darken(factor)),
+ Self::Oklch(c) => Self::Oklch(c.darken(factor)),
+ Self::LinearRgb(c) => Self::LinearRgb(c.darken(factor)),
+ Self::Rgb(c) => Self::Rgb(c.darken(factor)),
+ Self::Cmyk(c) => Self::Cmyk(c.darken(factor)),
+ Self::Hsl(c) => Self::Hsl(c.darken(factor)),
+ Self::Hsv(c) => Self::Hsv(c.darken(factor)),
+ }
+ }
+
+ /// Increases the saturation of a color by a given factor.
+ #[func]
+ pub fn saturate(
+ self,
+ /// The call span
+ span: Span,
+ /// The factor to saturate the color by.
+ factor: Ratio,
+ ) -> SourceResult<Color> {
+ Ok(match self {
+ Self::Luma(_) => {
+ bail!(
+ span, "cannot saturate grayscale color";
+ hint: "try converting your color to RGB first"
+ );
+ }
+ Self::Oklab(_) => self.to_hsv().saturate(span, factor)?.to_oklab(),
+ Self::Oklch(_) => self.to_hsv().saturate(span, factor)?.to_oklch(),
+ Self::LinearRgb(_) => self.to_hsv().saturate(span, factor)?.to_linear_rgb(),
+ Self::Rgb(_) => self.to_hsv().saturate(span, factor)?.to_rgb(),
+ Self::Cmyk(_) => self.to_hsv().saturate(span, factor)?.to_cmyk(),
+ Self::Hsl(c) => Self::Hsl(c.saturate(factor.get() as f32)),
+ Self::Hsv(c) => Self::Hsv(c.saturate(factor.get() as f32)),
+ })
+ }
+
+ /// Decreases the saturation of a color by a given factor.
+ #[func]
+ pub fn desaturate(
+ self,
+ /// The call span
+ span: Span,
+ /// The factor to desaturate the color by.
+ factor: Ratio,
+ ) -> SourceResult<Color> {
+ Ok(match self {
+ Self::Luma(_) => {
+ bail!(
+ span, "cannot desaturate grayscale color";
+ hint: "try converting your color to RGB first"
+ );
+ }
+ Self::Oklab(_) => self.to_hsv().desaturate(span, factor)?.to_oklab(),
+ Self::Oklch(_) => self.to_hsv().desaturate(span, factor)?.to_oklch(),
+ Self::LinearRgb(_) => self.to_hsv().desaturate(span, factor)?.to_linear_rgb(),
+ Self::Rgb(_) => self.to_hsv().desaturate(span, factor)?.to_rgb(),
+ Self::Cmyk(_) => self.to_hsv().desaturate(span, factor)?.to_cmyk(),
+ Self::Hsl(c) => Self::Hsl(c.desaturate(factor.get() as f32)),
+ Self::Hsv(c) => Self::Hsv(c.desaturate(factor.get() as f32)),
+ })
+ }
+
+ /// Produces the complementary color using a provided color space.
+ /// You can think of it as the opposite side on a color wheel.
+ ///
+ /// ```example
+ /// #square(fill: yellow)
+ /// #square(fill: yellow.negate())
+ /// #square(fill: yellow.negate(space: rgb))
+ /// ```
+ #[func]
+ pub fn negate(
+ self,
+ /// The color space used for the transformation. By default, a perceptual color space is used.
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ ) -> Color {
+ let result = match self.to_space(space) {
+ Self::Luma(c) => Self::Luma(Luma::new(1.0 - c.luma, c.alpha)),
+ Self::Oklab(c) => Self::Oklab(Oklab::new(1.0 - c.l, -c.a, -c.b, c.alpha)),
+ Self::Oklch(c) => Self::Oklch(Oklch::new(
+ 1.0 - c.l,
+ c.chroma,
+ OklabHue::from_degrees(c.hue.into_degrees() + 180.0),
+ c.alpha,
+ )),
+ Self::LinearRgb(c) => Self::LinearRgb(LinearRgb::new(
+ 1.0 - c.red,
+ 1.0 - c.green,
+ 1.0 - c.blue,
+ c.alpha,
+ )),
+ Self::Rgb(c) => {
+ Self::Rgb(Rgb::new(1.0 - c.red, 1.0 - c.green, 1.0 - c.blue, c.alpha))
+ }
+ Self::Cmyk(c) => Self::Cmyk(Cmyk::new(1.0 - c.c, 1.0 - c.m, 1.0 - c.y, c.k)),
+ Self::Hsl(c) => Self::Hsl(Hsl::new(
+ RgbHue::from_degrees(c.hue.into_degrees() + 180.0),
+ c.saturation,
+ c.lightness,
+ c.alpha,
+ )),
+ Self::Hsv(c) => Self::Hsv(Hsv::new(
+ RgbHue::from_degrees(c.hue.into_degrees() + 180.0),
+ c.saturation,
+ c.value,
+ c.alpha,
+ )),
+ };
+ result.to_space(self.space())
+ }
+
+ /// Rotates the hue of the color by a given angle.
+ #[func]
+ pub fn rotate(
+ self,
+ /// The call span
+ span: Span,
+ /// The angle to rotate the hue by.
+ angle: Angle,
+ /// The color space used to rotate. By default, this happens in a perceptual
+ /// color space ([`oklch`]($color.oklch)).
+ #[named]
+ #[default(ColorSpace::Oklch)]
+ space: ColorSpace,
+ ) -> SourceResult<Color> {
+ Ok(match space {
+ ColorSpace::Oklch => {
+ let Self::Oklch(oklch) = self.to_oklch() else {
+ unreachable!();
+ };
+ let rotated = oklch.shift_hue(angle.to_deg() as f32);
+ Self::Oklch(rotated).to_space(self.space())
+ }
+ ColorSpace::Hsl => {
+ let Self::Hsl(hsl) = self.to_hsl() else {
+ unreachable!();
+ };
+ let rotated = hsl.shift_hue(angle.to_deg() as f32);
+ Self::Hsl(rotated).to_space(self.space())
+ }
+ ColorSpace::Hsv => {
+ let Self::Hsv(hsv) = self.to_hsv() else {
+ unreachable!();
+ };
+ let rotated = hsv.shift_hue(angle.to_deg() as f32);
+ Self::Hsv(rotated).to_space(self.space())
+ }
+ _ => bail!(span, "this colorspace does not support hue rotation"),
+ })
+ }
+
+ /// Create a color by mixing two or more colors.
+ ///
+ /// In color spaces with a hue component (hsl, hsv, oklch), only two colors
+ /// can be mixed at once. Mixing more than two colors in such a space will
+ /// result in an error!
+ ///
+ /// ```example
+ /// #set block(height: 20pt, width: 100%)
+ /// #block(fill: red.mix(blue))
+ /// #block(fill: red.mix(blue, space: rgb))
+ /// #block(fill: color.mix(red, blue, white))
+ /// #block(fill: color.mix((red, 70%), (blue, 30%)))
+ /// ```
+ #[func]
+ pub fn mix(
+ /// The colors, optionally with weights, specified as a pair (array of
+ /// length two) of color and weight (float or ratio).
+ ///
+ /// The weights do not need to add to `{100%}`, they are relative to the
+ /// sum of all weights.
+ #[variadic]
+ colors: Vec<WeightedColor>,
+ /// The color space to mix in. By default, this happens in a perceptual
+ /// color space ([`oklab`]($color.oklab)).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ ) -> StrResult<Color> {
+ Self::mix_iter(colors, space)
+ }
+
+ /// Makes a color more transparent by a given factor.
+ ///
+ /// This method is relative to the existing alpha value.
+ /// If the scale is positive, calculates `alpha - alpha * scale`.
+ /// Negative scales behave like `color.opacify(-scale)`.
+ ///
+ /// ```example
+ /// #block(fill: red)[opaque]
+ /// #block(fill: red.transparentize(50%))[half red]
+ /// #block(fill: red.transparentize(75%))[quarter red]
+ /// ```
+ #[func]
+ pub fn transparentize(
+ self,
+ /// The factor to change the alpha value by.
+ scale: Ratio,
+ ) -> StrResult<Color> {
+ self.scale_alpha(-scale)
+ }
+
+ /// Makes a color more opaque by a given scale.
+ ///
+ /// This method is relative to the existing alpha value.
+ /// If the scale is positive, calculates `alpha + scale - alpha * scale`.
+ /// Negative scales behave like `color.transparentize(-scale)`.
+ ///
+ /// ```example
+ /// #let half-red = red.transparentize(50%)
+ /// #block(fill: half-red.opacify(100%))[opaque]
+ /// #block(fill: half-red.opacify(50%))[three quarters red]
+ /// #block(fill: half-red.opacify(-50%))[one quarter red]
+ /// ```
+ #[func]
+ pub fn opacify(
+ self,
+ /// The scale to change the alpha value by.
+ scale: Ratio,
+ ) -> StrResult<Color> {
+ self.scale_alpha(scale)
+ }
+}
+
+impl Color {
+ /// Same as [`Color::mix`], but takes an iterator instead of a vector.
+ pub fn mix_iter(
+ colors: impl IntoIterator<
+ Item = WeightedColor,
+ IntoIter = impl ExactSizeIterator<Item = WeightedColor>,
+ >,
+ space: ColorSpace,
+ ) -> StrResult<Color> {
+ let mut colors = colors.into_iter();
+ if space.hue_index().is_some() && colors.len() > 2 {
+ bail!("cannot mix more than two colors in a hue-based space");
+ }
+
+ let m = if space.hue_index().is_some() && colors.len() == 2 {
+ let mut m = [0.0; 4];
+
+ let WeightedColor { color: c0, weight: w0 } = colors.next().unwrap();
+ let WeightedColor { color: c1, weight: w1 } = colors.next().unwrap();
+
+ let c0 = c0.to_space(space).to_vec4();
+ let c1 = c1.to_space(space).to_vec4();
+ let w0 = w0 as f32;
+ let w1 = w1 as f32;
+
+ if w0 + w1 <= 0.0 {
+ bail!("sum of weights must be positive");
+ }
+
+ for i in 0..4 {
+ m[i] = (w0 * c0[i] + w1 * c1[i]) / (w0 + w1);
+ }
+
+ // Ensure that the hue circle is traversed in the short direction.
+ if let Some(index) = space.hue_index() {
+ if (c0[index] - c1[index]).abs() > 180.0 {
+ let (h0, h1) = if c0[index] < c1[index] {
+ (c0[index] + 360.0, c1[index])
+ } else {
+ (c0[index], c1[index] + 360.0)
+ };
+ m[index] = (w0 * h0 + w1 * h1) / (w0 + w1);
+ }
+ }
+
+ m
+ } else {
+ let mut total = 0.0;
+ let mut acc = [0.0; 4];
+
+ for WeightedColor { color, weight } in colors {
+ let weight = weight as f32;
+ let v = color.to_space(space).to_vec4();
+ acc[0] += weight * v[0];
+ acc[1] += weight * v[1];
+ acc[2] += weight * v[2];
+ acc[3] += weight * v[3];
+ total += weight;
+ }
+
+ if total <= 0.0 {
+ bail!("sum of weights must be positive");
+ }
+
+ acc.map(|v| v / total)
+ };
+
+ Ok(match space {
+ ColorSpace::Oklab => Color::Oklab(Oklab::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::Oklch => Color::Oklch(Oklch::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::Srgb => Color::Rgb(Rgb::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::LinearRgb => {
+ Color::LinearRgb(LinearRgb::new(m[0], m[1], m[2], m[3]))
+ }
+ ColorSpace::Hsl => {
+ Color::Hsl(Hsl::new(RgbHue::from_degrees(m[0]), m[1], m[2], m[3]))
+ }
+ ColorSpace::Hsv => {
+ Color::Hsv(Hsv::new(RgbHue::from_degrees(m[0]), m[1], m[2], m[3]))
+ }
+ ColorSpace::Cmyk => Color::Cmyk(Cmyk::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::D65Gray => Color::Luma(Luma::new(m[0], m[3])),
+ })
+ }
+
+ /// Construct a new RGBA color from 8-bit values.
+ pub fn from_u8(r: u8, g: u8, b: u8, a: u8) -> Self {
+ Self::Rgb(Rgb::new(
+ f32::from(r) / 255.0,
+ f32::from(g) / 255.0,
+ f32::from(b) / 255.0,
+ f32::from(a) / 255.0,
+ ))
+ }
+
+ /// Converts a 32-bit integer to an RGBA color.
+ pub fn from_u32(color: u32) -> Self {
+ Self::from_u8(
+ ((color >> 24) & 0xFF) as u8,
+ ((color >> 16) & 0xFF) as u8,
+ ((color >> 8) & 0xFF) as u8,
+ (color & 0xFF) as u8,
+ )
+ }
+
+ /// Returns the alpha channel of the color, if it has one.
+ pub fn alpha(&self) -> Option<f32> {
+ match self {
+ Color::Cmyk(_) => None,
+ Color::Luma(c) => Some(c.alpha),
+ Color::Oklab(c) => Some(c.alpha),
+ Color::Oklch(c) => Some(c.alpha),
+ Color::Rgb(c) => Some(c.alpha),
+ Color::LinearRgb(c) => Some(c.alpha),
+ Color::Hsl(c) => Some(c.alpha),
+ Color::Hsv(c) => Some(c.alpha),
+ }
+ }
+
+ /// Sets the alpha channel of the color, if it has one.
+ pub fn with_alpha(mut self, alpha: f32) -> Self {
+ match &mut self {
+ Color::Cmyk(_) => {}
+ Color::Luma(c) => c.alpha = alpha,
+ Color::Oklab(c) => c.alpha = alpha,
+ Color::Oklch(c) => c.alpha = alpha,
+ Color::Rgb(c) => c.alpha = alpha,
+ Color::LinearRgb(c) => c.alpha = alpha,
+ Color::Hsl(c) => c.alpha = alpha,
+ Color::Hsv(c) => c.alpha = alpha,
+ }
+
+ self
+ }
+
+ /// Scales the alpha value of a color by a given amount.
+ ///
+ /// For positive scales, computes `alpha + scale - alpha * scale`.
+ /// For non-positive scales, computes `alpha + alpha * scale`.
+ fn scale_alpha(self, scale: Ratio) -> StrResult<Color> {
+ #[inline]
+ fn transform<C>(mut color: Alpha<C, f32>, scale: Ratio) -> Alpha<C, f32> {
+ let scale = scale.get() as f32;
+ let factor = if scale > 0.0 { 1.0 - color.alpha } else { color.alpha };
+ color.alpha = (color.alpha + scale * factor).clamp(0.0, 1.0);
+ color
+ }
+
+ Ok(match self {
+ Color::Luma(c) => Color::Luma(transform(c, scale)),
+ Color::Oklab(c) => Color::Oklab(transform(c, scale)),
+ Color::Oklch(c) => Color::Oklch(transform(c, scale)),
+ Color::Rgb(c) => Color::Rgb(transform(c, scale)),
+ Color::LinearRgb(c) => Color::LinearRgb(transform(c, scale)),
+ Color::Cmyk(_) => bail!("CMYK does not have an alpha component"),
+ Color::Hsl(c) => Color::Hsl(transform(c, scale)),
+ Color::Hsv(c) => Color::Hsv(transform(c, scale)),
+ })
+ }
+
+ /// Converts the color to a vec of four floats.
+ pub fn to_vec4(&self) -> [f32; 4] {
+ match self {
+ Color::Luma(c) => [c.luma, c.luma, c.luma, c.alpha],
+ Color::Oklab(c) => [c.l, c.a, c.b, c.alpha],
+ Color::Oklch(c) => {
+ [c.l, c.chroma, c.hue.into_degrees().rem_euclid(360.0), c.alpha]
+ }
+ Color::Rgb(c) => [c.red, c.green, c.blue, c.alpha],
+ Color::LinearRgb(c) => [c.red, c.green, c.blue, c.alpha],
+ Color::Cmyk(c) => [c.c, c.m, c.y, c.k],
+ Color::Hsl(c) => [
+ c.hue.into_degrees().rem_euclid(360.0),
+ c.saturation,
+ c.lightness,
+ c.alpha,
+ ],
+ Color::Hsv(c) => {
+ [c.hue.into_degrees().rem_euclid(360.0), c.saturation, c.value, c.alpha]
+ }
+ }
+ }
+
+ /// Converts the color to a vec of four [`u8`]s.
+ pub fn to_vec4_u8(&self) -> [u8; 4] {
+ self.to_vec4().map(|x| (x * 255.0).round() as u8)
+ }
+
+ pub fn to_space(self, space: ColorSpace) -> Self {
+ match space {
+ ColorSpace::Oklab => self.to_oklab(),
+ ColorSpace::Oklch => self.to_oklch(),
+ ColorSpace::Srgb => self.to_rgb(),
+ ColorSpace::LinearRgb => self.to_linear_rgb(),
+ ColorSpace::Hsl => self.to_hsl(),
+ ColorSpace::Hsv => self.to_hsv(),
+ ColorSpace::Cmyk => self.to_cmyk(),
+ ColorSpace::D65Gray => self.to_luma(),
+ }
+ }
+
+ pub fn to_luma(self) -> Self {
+ Self::Luma(match self {
+ Self::Luma(c) => c,
+ Self::Oklab(c) => Luma::from_color(c),
+ Self::Oklch(c) => Luma::from_color(c),
+ Self::Rgb(c) => Luma::from_color(c),
+ Self::LinearRgb(c) => Luma::from_color(c),
+ Self::Cmyk(c) => Luma::from_color(c.to_rgba()),
+ Self::Hsl(c) => Luma::from_color(c),
+ Self::Hsv(c) => Luma::from_color(c),
+ })
+ }
+
+ pub fn to_oklab(self) -> Self {
+ Self::Oklab(match self {
+ Self::Luma(c) => Oklab::from_color(c),
+ Self::Oklab(c) => c,
+ Self::Oklch(c) => Oklab::from_color(c),
+ Self::Rgb(c) => Oklab::from_color(c),
+ Self::LinearRgb(c) => Oklab::from_color(c),
+ Self::Cmyk(c) => Oklab::from_color(c.to_rgba()),
+ Self::Hsl(c) => Oklab::from_color(c),
+ Self::Hsv(c) => Oklab::from_color(c),
+ })
+ }
+
+ pub fn to_oklch(self) -> Self {
+ Self::Oklch(match self {
+ Self::Luma(c) => Oklch::from_color(c),
+ Self::Oklab(c) => Oklch::from_color(c),
+ Self::Oklch(c) => c,
+ Self::Rgb(c) => Oklch::from_color(c),
+ Self::LinearRgb(c) => Oklch::from_color(c),
+ Self::Cmyk(c) => Oklch::from_color(c.to_rgba()),
+ Self::Hsl(c) => Oklch::from_color(c),
+ Self::Hsv(c) => Oklch::from_color(c),
+ })
+ }
+
+ pub fn to_rgb(self) -> Self {
+ Self::Rgb(match self {
+ Self::Luma(c) => Rgb::from_color(c),
+ Self::Oklab(c) => Rgb::from_color(c),
+ Self::Oklch(c) => Rgb::from_color(c),
+ Self::Rgb(c) => c,
+ Self::LinearRgb(c) => Rgb::from_linear(c),
+ Self::Cmyk(c) => Rgb::from_color(c.to_rgba()),
+ Self::Hsl(c) => Rgb::from_color(c),
+ Self::Hsv(c) => Rgb::from_color(c),
+ })
+ }
+
+ pub fn to_linear_rgb(self) -> Self {
+ Self::LinearRgb(match self {
+ Self::Luma(c) => LinearRgb::from_color(c),
+ Self::Oklab(c) => LinearRgb::from_color(c),
+ Self::Oklch(c) => LinearRgb::from_color(c),
+ Self::Rgb(c) => LinearRgb::from_color(c),
+ Self::LinearRgb(c) => c,
+ Self::Cmyk(c) => LinearRgb::from_color(c.to_rgba()),
+ Self::Hsl(c) => Rgb::from_color(c).into_linear(),
+ Self::Hsv(c) => Rgb::from_color(c).into_linear(),
+ })
+ }
+
+ pub fn to_cmyk(self) -> Self {
+ Self::Cmyk(match self {
+ Self::Luma(c) => Cmyk::from_luma(c),
+ Self::Oklab(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ Self::Oklch(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ Self::Rgb(c) => Cmyk::from_rgba(c),
+ Self::LinearRgb(c) => Cmyk::from_rgba(Rgb::from_linear(c)),
+ Self::Cmyk(c) => c,
+ Self::Hsl(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ Self::Hsv(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ })
+ }
+
+ pub fn to_hsl(self) -> Self {
+ Self::Hsl(match self {
+ Self::Luma(c) => Hsl::from_color(c),
+ Self::Oklab(c) => Hsl::from_color(c),
+ Self::Oklch(c) => Hsl::from_color(c),
+ Self::Rgb(c) => Hsl::from_color(c),
+ Self::LinearRgb(c) => Hsl::from_color(Rgb::from_linear(c)),
+ Self::Cmyk(c) => Hsl::from_color(c.to_rgba()),
+ Self::Hsl(c) => c,
+ Self::Hsv(c) => Hsl::from_color(c),
+ })
+ }
+
+ pub fn to_hsv(self) -> Self {
+ Self::Hsv(match self {
+ Self::Luma(c) => Hsv::from_color(c),
+ Self::Oklab(c) => Hsv::from_color(c),
+ Self::Oklch(c) => Hsv::from_color(c),
+ Self::Rgb(c) => Hsv::from_color(c),
+ Self::LinearRgb(c) => Hsv::from_color(Rgb::from_linear(c)),
+ Self::Cmyk(c) => Hsv::from_color(c.to_rgba()),
+ Self::Hsl(c) => Hsv::from_color(c),
+ Self::Hsv(c) => c,
+ })
+ }
+}
+
+impl Debug for Color {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Luma(v) => write!(f, "Luma({}, {})", v.luma, v.alpha),
+ Self::Oklab(v) => write!(f, "Oklab({}, {}, {}, {})", v.l, v.a, v.b, v.alpha),
+ Self::Oklch(v) => {
+ write!(
+ f,
+ "Oklch({}, {}, {:?}, {})",
+ v.l,
+ v.chroma,
+ hue_angle(v.hue.into_degrees()),
+ v.alpha
+ )
+ }
+ Self::Rgb(v) => {
+ write!(f, "Rgb({}, {}, {}, {})", v.red, v.green, v.blue, v.alpha)
+ }
+ Self::LinearRgb(v) => {
+ write!(f, "LinearRgb({}, {}, {}, {})", v.red, v.green, v.blue, v.alpha)
+ }
+ Self::Cmyk(v) => write!(f, "Cmyk({}, {}, {}, {})", v.c, v.m, v.y, v.k),
+ Self::Hsl(v) => write!(
+ f,
+ "Hsl({:?}, {}, {}, {})",
+ hue_angle(v.hue.into_degrees()),
+ v.saturation,
+ v.lightness,
+ v.alpha
+ ),
+ Self::Hsv(v) => write!(
+ f,
+ "Hsv({:?}, {}, {}, {})",
+ hue_angle(v.hue.into_degrees()),
+ v.saturation,
+ v.value,
+ v.alpha
+ ),
+ }
+ }
+}
+
+impl Repr for Color {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Luma(c) => {
+ if c.alpha == 1.0 {
+ eco_format!("luma({})", Ratio::new(c.luma.into()).repr())
+ } else {
+ eco_format!(
+ "luma({}, {})",
+ Ratio::new(c.luma.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Rgb(_) => eco_format!("rgb({})", self.to_hex().repr()),
+ Self::LinearRgb(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "color.linear-rgb({}, {}, {})",
+ Ratio::new(c.red.into()).repr(),
+ Ratio::new(c.green.into()).repr(),
+ Ratio::new(c.blue.into()).repr(),
+ )
+ } else {
+ eco_format!(
+ "color.linear-rgb({}, {}, {}, {})",
+ Ratio::new(c.red.into()).repr(),
+ Ratio::new(c.green.into()).repr(),
+ Ratio::new(c.blue.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Cmyk(c) => {
+ eco_format!(
+ "cmyk({}, {}, {}, {})",
+ Ratio::new(c.c.into()).repr(),
+ Ratio::new(c.m.into()).repr(),
+ Ratio::new(c.y.into()).repr(),
+ Ratio::new(c.k.into()).repr(),
+ )
+ }
+ Self::Oklab(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "oklab({}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.a.into()),
+ repr::format_float_component(c.b.into()),
+ )
+ } else {
+ eco_format!(
+ "oklab({}, {}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.a.into()),
+ repr::format_float_component(c.b.into()),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Oklch(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "oklch({}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.chroma.into()),
+ hue_angle(c.hue.into_degrees()).repr(),
+ )
+ } else {
+ eco_format!(
+ "oklch({}, {}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.chroma.into()),
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Hsl(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "color.hsl({}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.lightness.into()).repr(),
+ )
+ } else {
+ eco_format!(
+ "color.hsl({}, {}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.lightness.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Hsv(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "color.hsv({}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.value.into()).repr(),
+ )
+ } else {
+ eco_format!(
+ "color.hsv({}, {}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.value.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ }
+ }
+}
+
+fn hue_angle(degrees: f32) -> Angle {
+ Angle::deg(f64::from(degrees).rem_euclid(360.0))
+}
+
+impl PartialEq for Color {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ // Lower precision for comparison to avoid rounding errors.
+ // Keeps backward compatibility with previous versions of Typst.
+ (Self::Rgb(_), Self::Rgb(_)) => self.to_vec4_u8() == other.to_vec4_u8(),
+ (Self::Luma(a), Self::Luma(b)) => {
+ (a.luma * 255.0).round() as u8 == (b.luma * 255.0).round() as u8
+ }
+ (Self::Oklab(a), Self::Oklab(b)) => a == b,
+ (Self::Oklch(a), Self::Oklch(b)) => a == b,
+ (Self::LinearRgb(a), Self::LinearRgb(b)) => a == b,
+ (Self::Cmyk(a), Self::Cmyk(b)) => a == b,
+ (Self::Hsl(a), Self::Hsl(b)) => a == b,
+ (Self::Hsv(a), Self::Hsv(b)) => a == b,
+ _ => false,
+ }
+ }
+}
+
+impl Eq for Color {}
+
+impl Hash for Color {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ core::mem::discriminant(self).hash(state);
+ let [x, y, z, w] = self.to_vec4();
+ x.to_bits().hash(state);
+ y.to_bits().hash(state);
+ z.to_bits().hash(state);
+ w.to_bits().hash(state);
+ }
+}
+
+impl FromStr for Color {
+ type Err = &'static str;
+
+ /// Constructs a new color from hex strings like the following:
+ /// - `#aef` (shorthand, with leading hash),
+ /// - `7a03c2` (without alpha),
+ /// - `abcdefff` (with alpha).
+ ///
+ /// The hash is optional and both lower and upper case are fine.
+ fn from_str(hex_str: &str) -> Result<Self, Self::Err> {
+ let hex_str = hex_str.strip_prefix('#').unwrap_or(hex_str);
+ if hex_str.chars().any(|c| !c.is_ascii_hexdigit()) {
+ return Err("color string contains non-hexadecimal letters");
+ }
+
+ let len = hex_str.len();
+ let long = len == 6 || len == 8;
+ let short = len == 3 || len == 4;
+ let alpha = len == 4 || len == 8;
+ if !long && !short {
+ return Err("color string has wrong length");
+ }
+
+ let mut values: [u8; 4] = [u8::MAX; 4];
+ for elem in if alpha { 0..4 } else { 0..3 } {
+ let item_len = if long { 2 } else { 1 };
+ let pos = elem * item_len;
+
+ let item = &hex_str[pos..(pos + item_len)];
+ values[elem] = u8::from_str_radix(item, 16).unwrap();
+
+ if short {
+ // Duplicate number for shorthand notation, i.e. `a` -> `aa`
+ values[elem] += values[elem] * 16;
+ }
+ }
+
+ Ok(Self::from_u8(values[0], values[1], values[2], values[3]))
+ }
+}
+
+impl From<Luma> for Color {
+ fn from(c: Luma) -> Self {
+ Self::Luma(c)
+ }
+}
+
+impl From<Oklab> for Color {
+ fn from(c: Oklab) -> Self {
+ Self::Oklab(c)
+ }
+}
+
+impl From<Oklch> for Color {
+ fn from(c: Oklch) -> Self {
+ Self::Oklch(c)
+ }
+}
+
+impl From<Rgb> for Color {
+ fn from(c: Rgb) -> Self {
+ Self::Rgb(c)
+ }
+}
+
+impl From<LinearRgb> for Color {
+ fn from(c: LinearRgb) -> Self {
+ Self::LinearRgb(c)
+ }
+}
+
+impl From<Cmyk> for Color {
+ fn from(c: Cmyk) -> Self {
+ Self::Cmyk(c)
+ }
+}
+
+impl From<Hsl> for Color {
+ fn from(c: Hsl) -> Self {
+ Self::Hsl(c)
+ }
+}
+
+impl From<Hsv> for Color {
+ fn from(c: Hsv) -> Self {
+ Self::Hsv(c)
+ }
+}
+
+/// An 8-bit CMYK color.
+#[derive(Debug, Copy, Clone, PartialEq)]
+pub struct Cmyk {
+ /// The cyan component.
+ pub c: f32,
+ /// The magenta component.
+ pub m: f32,
+ /// The yellow component.
+ pub y: f32,
+ /// The key (black) component.
+ pub k: f32,
+}
+
+impl Cmyk {
+ fn new(c: f32, m: f32, y: f32, k: f32) -> Self {
+ Self { c, m, y, k }
+ }
+
+ fn from_luma(luma: Luma) -> Self {
+ let l = 1.0 - luma.luma;
+ Cmyk::new(l * 0.75, l * 0.68, l * 0.67, l * 0.90)
+ }
+
+ // This still uses naive conversion, because qcms does not support
+ // converting to CMYK yet.
+ fn from_rgba(rgba: Rgb) -> Self {
+ let r = rgba.red;
+ let g = rgba.green;
+ let b = rgba.blue;
+
+ let k = 1.0 - r.max(g).max(b);
+ if k == 1.0 {
+ return Cmyk::new(0.0, 0.0, 0.0, 1.0);
+ }
+
+ let c = (1.0 - r - k) / (1.0 - k);
+ let m = (1.0 - g - k) / (1.0 - k);
+ let y = (1.0 - b - k) / (1.0 - k);
+
+ Cmyk::new(c, m, y, k)
+ }
+
+ fn to_rgba(self) -> Rgb {
+ let mut dest: [u8; 3] = [0; 3];
+ TO_SRGB.convert(
+ &[
+ (self.c * 255.0).round() as u8,
+ (self.m * 255.0).round() as u8,
+ (self.y * 255.0).round() as u8,
+ (self.k * 255.0).round() as u8,
+ ],
+ &mut dest,
+ );
+
+ Rgb::new(
+ f32::from(dest[0]) / 255.0,
+ f32::from(dest[1]) / 255.0,
+ f32::from(dest[2]) / 255.0,
+ 1.0,
+ )
+ }
+
+ fn lighten(self, factor: f32) -> Self {
+ let lighten = |u: f32| (u - u * factor).clamp(0.0, 1.0);
+ Self::new(lighten(self.c), lighten(self.m), lighten(self.y), lighten(self.k))
+ }
+
+ fn darken(self, factor: f32) -> Self {
+ let darken = |u: f32| (u + (1.0 - u) * factor).clamp(0.0, 1.0);
+ Self::new(darken(self.c), darken(self.m), darken(self.y), darken(self.k))
+ }
+}
+
+/// A color with a weight.
+pub struct WeightedColor {
+ color: Color,
+ weight: f64,
+}
+
+impl WeightedColor {
+ /// Create a new weighted color.
+ pub const fn new(color: Color, weight: f64) -> Self {
+ Self { color, weight }
+ }
+}
+
+cast! {
+ WeightedColor,
+ self => array![self.color, Value::Float(self.weight)].into_value(),
+ color: Color => Self { color, weight: 1.0 },
+ v: Array => {
+ let mut iter = v.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(c), Some(w), None) => Self {
+ color: c.cast()?,
+ weight: w.cast::<Weight>()?.0,
+ },
+ _ => bail!("expected a color or color-weight pair"),
+ }
+ }
+}
+
+/// A weight for color mixing.
+struct Weight(f64);
+
+cast! {
+ Weight,
+ v: f64 => Self(v),
+ v: Ratio => Self(v.get()),
+}
+
+/// A color space for color manipulation.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ColorSpace {
+ /// The perceptual Oklab color space.
+ Oklab,
+ /// The perceptual Oklch color space.
+ Oklch,
+ /// The standard RGB color space.
+ Srgb,
+ /// The D65-gray color space.
+ D65Gray,
+ /// The linear RGB color space.
+ LinearRgb,
+ /// The HSL color space.
+ Hsl,
+ /// The HSV color space.
+ Hsv,
+ /// The CMYK color space.
+ Cmyk,
+}
+
+impl ColorSpace {
+ /// Returns the index of the hue component in this color space, if it has
+ /// one.
+ pub fn hue_index(&self) -> Option<usize> {
+ match self {
+ Self::Hsl | Self::Hsv => Some(0),
+ Self::Oklch => Some(2),
+ _ => None,
+ }
+ }
+}
+
+cast! {
+ ColorSpace,
+ self => match self {
+ Self::Oklab => Color::oklab_data(),
+ Self::Oklch => Color::oklch_data(),
+ Self::Srgb => Color::rgb_data(),
+ Self::D65Gray => Color::luma_data(),
+ Self::LinearRgb => Color::linear_rgb_data(),
+ Self::Hsl => Color::hsl_data(),
+ Self::Hsv => Color::hsv_data(),
+ Self::Cmyk => Color::cmyk_data(),
+ }.into_value(),
+ v: Value => {
+ let expected = "expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`";
+ let Value::Func(func) = v else {
+ bail!("{expected}, found {}", v.ty());
+ };
+
+ // Here comparing the function pointer since it's `Eq`
+ // whereas the `NativeFuncData` is not.
+ if func == Color::oklab_data() {
+ Self::Oklab
+ } else if func == Color::oklch_data() {
+ Self::Oklch
+ } else if func == Color::rgb_data() {
+ Self::Srgb
+ } else if func == Color::luma_data() {
+ Self::D65Gray
+ } else if func == Color::linear_rgb_data() {
+ Self::LinearRgb
+ } else if func == Color::hsl_data() {
+ Self::Hsl
+ } else if func == Color::hsv_data() {
+ Self::Hsv
+ } else if func == Color::cmyk_data() {
+ Self::Cmyk
+ } else {
+ bail!("{expected}");
+ }
+ },
+}
+
+/// A component that must be a ratio.
+pub struct RatioComponent(Ratio);
+
+cast! {
+ RatioComponent,
+ self => self.0.into_value(),
+ v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) {
+ Self(v)
+ } else {
+ bail!("ratio must be between 0% and 100%");
+ },
+}
+
+/// A chroma color component.
+///
+/// Must either be:
+/// - a ratio, in which case it is relative to 0.4.
+/// - a float, in which case it is taken literally.
+pub struct ChromaComponent(f32);
+
+cast! {
+ ChromaComponent,
+ v: f64 => Self(v as f32),
+ v: Ratio => Self((v.get() * 0.4) as f32),
+}
+
+/// An integer or ratio component.
+pub struct Component(Ratio);
+
+cast! {
+ Component,
+ self => self.0.into_value(),
+ v: i64 => match v {
+ 0 ..= 255 => Self(Ratio::new(v as f64 / 255.0)),
+ _ => bail!("number must be between 0 and 255"),
+ },
+ v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) {
+ Self(v)
+ } else {
+ bail!("ratio must be between 0% and 100%");
+ },
+}
+
+/// A module with all preset color maps.
+fn map() -> Module {
+ let mut scope = Scope::new();
+ scope.define("turbo", turbo());
+ scope.define("cividis", cividis());
+ scope.define("rainbow", rainbow());
+ scope.define("spectral", spectral());
+ scope.define("viridis", viridis());
+ scope.define("inferno", inferno());
+ scope.define("magma", magma());
+ scope.define("plasma", plasma());
+ scope.define("rocket", rocket());
+ scope.define("mako", mako());
+ scope.define("vlag", vlag());
+ scope.define("icefire", icefire());
+ scope.define("flare", flare());
+ scope.define("crest", crest());
+ Module::new("map", scope)
+}
+
+/// Defines a gradient preset as a series of colors expressed as u32s.
+macro_rules! preset {
+ ($name:ident; $($colors:literal),* $(,)*) => {
+ fn $name() -> Array {
+ Array::from(
+ [$(Color::from_u32($colors)),*]
+ .iter()
+ .map(|c| c.into_value())
+ .collect::<EcoVec<_>>()
+ )
+ }
+ };
+}
+
+preset!(turbo; 0x23171bff, 0x271a28ff, 0x2b1c33ff, 0x2f1e3fff, 0x32204aff, 0x362354ff, 0x39255fff, 0x3b2768ff, 0x3e2a72ff, 0x402c7bff, 0x422f83ff, 0x44318bff, 0x453493ff, 0x46369bff, 0x4839a2ff, 0x493ca8ff, 0x493eafff, 0x4a41b5ff, 0x4a44bbff, 0x4b46c0ff, 0x4b49c5ff, 0x4b4ccaff, 0x4b4ecfff, 0x4b51d3ff, 0x4a54d7ff, 0x4a56dbff, 0x4959deff, 0x495ce2ff, 0x485fe5ff, 0x4761e7ff, 0x4664eaff, 0x4567ecff, 0x446aeeff, 0x446df0ff, 0x426ff2ff, 0x4172f3ff, 0x4075f5ff, 0x3f78f6ff, 0x3e7af7ff, 0x3d7df7ff, 0x3c80f8ff, 0x3a83f9ff, 0x3985f9ff, 0x3888f9ff, 0x378bf9ff, 0x368df9ff, 0x3590f8ff, 0x3393f8ff, 0x3295f7ff, 0x3198f7ff, 0x309bf6ff, 0x2f9df5ff, 0x2ea0f4ff, 0x2da2f3ff, 0x2ca5f1ff, 0x2ba7f0ff, 0x2aaaefff, 0x2aacedff, 0x29afecff, 0x28b1eaff, 0x28b4e8ff, 0x27b6e6ff, 0x27b8e5ff, 0x26bbe3ff, 0x26bde1ff, 0x26bfdfff, 0x25c1dcff, 0x25c3daff, 0x25c6d8ff, 0x25c8d6ff, 0x25cad3ff, 0x25ccd1ff, 0x25cecfff, 0x26d0ccff, 0x26d2caff, 0x26d4c8ff, 0x27d6c5ff, 0x27d8c3ff, 0x28d9c0ff, 0x29dbbeff, 0x29ddbbff, 0x2adfb8ff, 0x2be0b6ff, 0x2ce2b3ff, 0x2de3b1ff, 0x2ee5aeff, 0x30e6acff, 0x31e8a9ff, 0x32e9a6ff, 0x34eba4ff, 0x35eca1ff, 0x37ed9fff, 0x39ef9cff, 0x3af09aff, 0x3cf197ff, 0x3ef295ff, 0x40f392ff, 0x42f490ff, 0x44f58dff, 0x46f68bff, 0x48f788ff, 0x4af786ff, 0x4df884ff, 0x4ff981ff, 0x51fa7fff, 0x54fa7dff, 0x56fb7aff, 0x59fb78ff, 0x5cfc76ff, 0x5efc74ff, 0x61fd71ff, 0x64fd6fff, 0x66fd6dff, 0x69fd6bff, 0x6cfd69ff, 0x6ffe67ff, 0x72fe65ff, 0x75fe63ff, 0x78fe61ff, 0x7bfe5fff, 0x7efd5dff, 0x81fd5cff, 0x84fd5aff, 0x87fd58ff, 0x8afc56ff, 0x8dfc55ff, 0x90fb53ff, 0x93fb51ff, 0x96fa50ff, 0x99fa4eff, 0x9cf94dff, 0x9ff84bff, 0xa2f84aff, 0xa6f748ff, 0xa9f647ff, 0xacf546ff, 0xaff444ff, 0xb2f343ff, 0xb5f242ff, 0xb8f141ff, 0xbbf03fff, 0xbeef3eff, 0xc1ed3dff, 0xc3ec3cff, 0xc6eb3bff, 0xc9e93aff, 0xcce839ff, 0xcfe738ff, 0xd1e537ff, 0xd4e336ff, 0xd7e235ff, 0xd9e034ff, 0xdcdf33ff, 0xdedd32ff, 0xe0db32ff, 0xe3d931ff, 0xe5d730ff, 0xe7d52fff, 0xe9d42fff, 0xecd22eff, 0xeed02dff, 0xf0ce2cff, 0xf1cb2cff, 0xf3c92bff, 0xf5c72bff, 0xf7c52aff, 0xf8c329ff, 0xfac029ff, 0xfbbe28ff, 0xfdbc28ff, 0xfeb927ff, 0xffb727ff, 0xffb526ff, 0xffb226ff, 0xffb025ff, 0xffad25ff, 0xffab24ff, 0xffa824ff, 0xffa623ff, 0xffa323ff, 0xffa022ff, 0xff9e22ff, 0xff9b21ff, 0xff9921ff, 0xff9621ff, 0xff9320ff, 0xff9020ff, 0xff8e1fff, 0xff8b1fff, 0xff881eff, 0xff851eff, 0xff831dff, 0xff801dff, 0xff7d1dff, 0xff7a1cff, 0xff781cff, 0xff751bff, 0xff721bff, 0xff6f1aff, 0xfd6c1aff, 0xfc6a19ff, 0xfa6719ff, 0xf96418ff, 0xf76118ff, 0xf65f18ff, 0xf45c17ff, 0xf25916ff, 0xf05716ff, 0xee5415ff, 0xec5115ff, 0xea4f14ff, 0xe84c14ff, 0xe64913ff, 0xe44713ff, 0xe24412ff, 0xdf4212ff, 0xdd3f11ff, 0xda3d10ff, 0xd83a10ff, 0xd5380fff, 0xd3360fff, 0xd0330eff, 0xce310dff, 0xcb2f0dff, 0xc92d0cff, 0xc62a0bff, 0xc3280bff, 0xc1260aff, 0xbe2409ff, 0xbb2309ff, 0xb92108ff, 0xb61f07ff, 0xb41d07ff, 0xb11b06ff, 0xaf1a05ff, 0xac1805ff, 0xaa1704ff, 0xa81604ff, 0xa51403ff, 0xa31302ff, 0xa11202ff, 0x9f1101ff, 0x9d1000ff, 0x9b0f00ff, 0x9a0e00ff, 0x980e00ff, 0x960d00ff, 0x950c00ff, 0x940c00ff, 0x930c00ff, 0x920c00ff, 0x910b00ff, 0x910c00ff, 0x900c00ff, 0x900c00ff, 0x900c00ff);
+preset!(cividis; 0x002051ff, 0x002153ff, 0x002255ff, 0x002356ff, 0x002358ff, 0x002459ff, 0x00255aff, 0x00255cff, 0x00265dff, 0x00275eff, 0x00275fff, 0x002860ff, 0x002961ff, 0x002962ff, 0x002a63ff, 0x002b64ff, 0x012b65ff, 0x022c65ff, 0x032d66ff, 0x042d67ff, 0x052e67ff, 0x052f68ff, 0x063069ff, 0x073069ff, 0x08316aff, 0x09326aff, 0x0b326aff, 0x0c336bff, 0x0d346bff, 0x0e346bff, 0x0f356cff, 0x10366cff, 0x12376cff, 0x13376dff, 0x14386dff, 0x15396dff, 0x17396dff, 0x183a6dff, 0x193b6dff, 0x1a3b6dff, 0x1c3c6eff, 0x1d3d6eff, 0x1e3e6eff, 0x203e6eff, 0x213f6eff, 0x23406eff, 0x24406eff, 0x25416eff, 0x27426eff, 0x28436eff, 0x29436eff, 0x2b446eff, 0x2c456eff, 0x2e456eff, 0x2f466eff, 0x30476eff, 0x32486eff, 0x33486eff, 0x34496eff, 0x364a6eff, 0x374a6eff, 0x394b6eff, 0x3a4c6eff, 0x3b4d6eff, 0x3d4d6eff, 0x3e4e6eff, 0x3f4f6eff, 0x414f6eff, 0x42506eff, 0x43516dff, 0x44526dff, 0x46526dff, 0x47536dff, 0x48546dff, 0x4a546dff, 0x4b556dff, 0x4c566dff, 0x4d576dff, 0x4e576eff, 0x50586eff, 0x51596eff, 0x52596eff, 0x535a6eff, 0x545b6eff, 0x565c6eff, 0x575c6eff, 0x585d6eff, 0x595e6eff, 0x5a5e6eff, 0x5b5f6eff, 0x5c606eff, 0x5d616eff, 0x5e616eff, 0x60626eff, 0x61636fff, 0x62646fff, 0x63646fff, 0x64656fff, 0x65666fff, 0x66666fff, 0x67676fff, 0x686870ff, 0x696970ff, 0x6a6970ff, 0x6b6a70ff, 0x6c6b70ff, 0x6d6c70ff, 0x6d6c71ff, 0x6e6d71ff, 0x6f6e71ff, 0x706f71ff, 0x716f71ff, 0x727071ff, 0x737172ff, 0x747172ff, 0x757272ff, 0x767372ff, 0x767472ff, 0x777473ff, 0x787573ff, 0x797673ff, 0x7a7773ff, 0x7b7774ff, 0x7b7874ff, 0x7c7974ff, 0x7d7a74ff, 0x7e7a74ff, 0x7f7b75ff, 0x807c75ff, 0x807d75ff, 0x817d75ff, 0x827e75ff, 0x837f76ff, 0x848076ff, 0x858076ff, 0x858176ff, 0x868276ff, 0x878376ff, 0x888477ff, 0x898477ff, 0x898577ff, 0x8a8677ff, 0x8b8777ff, 0x8c8777ff, 0x8d8877ff, 0x8e8978ff, 0x8e8a78ff, 0x8f8a78ff, 0x908b78ff, 0x918c78ff, 0x928d78ff, 0x938e78ff, 0x938e78ff, 0x948f78ff, 0x959078ff, 0x969178ff, 0x979278ff, 0x989278ff, 0x999378ff, 0x9a9478ff, 0x9b9578ff, 0x9b9678ff, 0x9c9678ff, 0x9d9778ff, 0x9e9878ff, 0x9f9978ff, 0xa09a78ff, 0xa19a78ff, 0xa29b78ff, 0xa39c78ff, 0xa49d78ff, 0xa59e77ff, 0xa69e77ff, 0xa79f77ff, 0xa8a077ff, 0xa9a177ff, 0xaaa276ff, 0xaba376ff, 0xaca376ff, 0xada476ff, 0xaea575ff, 0xafa675ff, 0xb0a775ff, 0xb2a874ff, 0xb3a874ff, 0xb4a974ff, 0xb5aa73ff, 0xb6ab73ff, 0xb7ac72ff, 0xb8ad72ff, 0xbaae72ff, 0xbbae71ff, 0xbcaf71ff, 0xbdb070ff, 0xbeb170ff, 0xbfb26fff, 0xc1b36fff, 0xc2b46eff, 0xc3b56dff, 0xc4b56dff, 0xc5b66cff, 0xc7b76cff, 0xc8b86bff, 0xc9b96aff, 0xcaba6aff, 0xccbb69ff, 0xcdbc68ff, 0xcebc68ff, 0xcfbd67ff, 0xd1be66ff, 0xd2bf66ff, 0xd3c065ff, 0xd4c164ff, 0xd6c263ff, 0xd7c363ff, 0xd8c462ff, 0xd9c561ff, 0xdbc660ff, 0xdcc660ff, 0xddc75fff, 0xdec85eff, 0xe0c95dff, 0xe1ca5cff, 0xe2cb5cff, 0xe3cc5bff, 0xe4cd5aff, 0xe6ce59ff, 0xe7cf58ff, 0xe8d058ff, 0xe9d157ff, 0xead256ff, 0xebd355ff, 0xecd454ff, 0xedd453ff, 0xeed553ff, 0xf0d652ff, 0xf1d751ff, 0xf1d850ff, 0xf2d950ff, 0xf3da4fff, 0xf4db4eff, 0xf5dc4dff, 0xf6dd4dff, 0xf7de4cff, 0xf8df4bff, 0xf8e04bff, 0xf9e14aff, 0xfae249ff, 0xfae349ff, 0xfbe448ff, 0xfbe548ff, 0xfce647ff, 0xfce746ff, 0xfde846ff, 0xfde946ff, 0xfdea45ff);
+preset!(rainbow; 0x7c4bbbff, 0x7f4bbcff, 0x824bbdff, 0x854abeff, 0x884abeff, 0x8b4abfff, 0x8e49bfff, 0x9149c0ff, 0x9449c0ff, 0x9748c0ff, 0x9a48c1ff, 0x9e48c1ff, 0xa148c1ff, 0xa447c1ff, 0xa747c1ff, 0xaa47c0ff, 0xad47c0ff, 0xb046c0ff, 0xb446bfff, 0xb746bfff, 0xba46beff, 0xbd46beff, 0xc046bdff, 0xc346bcff, 0xc646bbff, 0xc946baff, 0xcc46b9ff, 0xcf46b8ff, 0xd246b7ff, 0xd446b5ff, 0xd747b4ff, 0xda47b3ff, 0xdd47b1ff, 0xdf47b0ff, 0xe248aeff, 0xe448acff, 0xe748abff, 0xe949a9ff, 0xec49a7ff, 0xee4aa5ff, 0xf04ba3ff, 0xf34ba1ff, 0xf54c9fff, 0xf74c9dff, 0xf94d9bff, 0xfb4e98ff, 0xfd4f96ff, 0xfe5094ff, 0xff5191ff, 0xff528fff, 0xff538dff, 0xff548aff, 0xff5588ff, 0xff5685ff, 0xff5783ff, 0xff5880ff, 0xff5a7eff, 0xff5b7bff, 0xff5c79ff, 0xff5e76ff, 0xff5f74ff, 0xff6171ff, 0xff626fff, 0xff646cff, 0xff666aff, 0xff6767ff, 0xff6965ff, 0xff6b63ff, 0xff6d60ff, 0xff6e5eff, 0xff705cff, 0xff7259ff, 0xff7457ff, 0xff7655ff, 0xff7853ff, 0xff7a51ff, 0xff7c4fff, 0xff7f4dff, 0xff814bff, 0xff8349ff, 0xff8547ff, 0xff8745ff, 0xff8a44ff, 0xff8c42ff, 0xff8e40ff, 0xff913fff, 0xff933eff, 0xff953cff, 0xff983bff, 0xfd9a3aff, 0xfb9c39ff, 0xfa9f38ff, 0xf8a137ff, 0xf6a436ff, 0xf4a636ff, 0xf2a935ff, 0xf0ab35ff, 0xeeae34ff, 0xecb034ff, 0xeab234ff, 0xe8b534ff, 0xe6b734ff, 0xe4ba34ff, 0xe1bc34ff, 0xdfbf35ff, 0xddc135ff, 0xdbc336ff, 0xd9c636ff, 0xd6c837ff, 0xd4ca38ff, 0xd2cd39ff, 0xd0cf3aff, 0xcdd13bff, 0xcbd33dff, 0xc9d63eff, 0xc7d840ff, 0xc5da41ff, 0xc3dc43ff, 0xc1de45ff, 0xbfe047ff, 0xbde249ff, 0xbbe44bff, 0xb9e64dff, 0xb7e84fff, 0xb5ea52ff, 0xb3ec54ff, 0xb2ed57ff, 0xb0ef59ff, 0xadf05aff, 0xaaf15aff, 0xa6f159ff, 0xa2f259ff, 0x9ff259ff, 0x9bf358ff, 0x97f358ff, 0x94f459ff, 0x90f459ff, 0x8df559ff, 0x89f559ff, 0x85f65aff, 0x82f65bff, 0x7ff65bff, 0x7ef75cff, 0x7cf75dff, 0x7bf75eff, 0x7af75fff, 0x79f760ff, 0x78f762ff, 0x77f763ff, 0x76f764ff, 0x75f766ff, 0x74f768ff, 0x73f769ff, 0x72f76bff, 0x71f76dff, 0x70f76fff, 0x6ff671ff, 0x6ef673ff, 0x6df675ff, 0x6df577ff, 0x6cf579ff, 0x6bf47cff, 0x6af37eff, 0x69f380ff, 0x68f283ff, 0x67f185ff, 0x66f188ff, 0x66f08aff, 0x65ef8dff, 0x64ee8fff, 0x63ed92ff, 0x62ec94ff, 0x62eb97ff, 0x61ea9aff, 0x60e89cff, 0x5fe79fff, 0x5fe6a1ff, 0x5ee4a4ff, 0x5de3a7ff, 0x5ce2a9ff, 0x5ce0acff, 0x5bdfafff, 0x5addb1ff, 0x5adbb4ff, 0x59dab6ff, 0x58d8b9ff, 0x58d6bbff, 0x57d5beff, 0x56d3c0ff, 0x56d1c2ff, 0x55cfc5ff, 0x54cdc7ff, 0x54cbc9ff, 0x53c9cbff, 0x52c7cdff, 0x52c5cfff, 0x51c3d1ff, 0x51c1d3ff, 0x50bfd5ff, 0x50bdd7ff, 0x4fbbd9ff, 0x4eb9daff, 0x4eb6dcff, 0x4db4ddff, 0x4db2dfff, 0x4cb0e0ff, 0x4caee2ff, 0x4babe3ff, 0x4ba9e4ff, 0x4aa7e5ff, 0x4aa4e6ff, 0x49a2e7ff, 0x49a0e8ff, 0x489ee8ff, 0x489be9ff, 0x4799e9ff, 0x4797eaff, 0x4694eaff, 0x4692eaff, 0x4690ebff, 0x458eebff, 0x478bebff, 0x4889ebff, 0x4a87eaff, 0x4c85eaff, 0x4e82eaff, 0x5080e9ff, 0x527ee9ff, 0x537ce8ff, 0x557ae7ff, 0x5778e7ff, 0x5975e6ff, 0x5b73e5ff, 0x5c71e4ff, 0x5e6fe3ff, 0x606de1ff, 0x626be0ff, 0x6369dfff, 0x6567ddff, 0x6765dcff, 0x6864daff, 0x6a62d9ff, 0x6b60d7ff, 0x6d5ed5ff, 0x6e5cd3ff, 0x705bd1ff, 0x7159cfff, 0x7357cdff, 0x7456cbff, 0x7554c9ff, 0x7652c7ff, 0x7751c5ff, 0x794fc2ff, 0x7a4ec0ff, 0x7b4dbeff, 0x7c4bbbff);
+preset!(spectral; 0x9e0142ff, 0xd53e4fff, 0xf46d43ff, 0xfdae61ff, 0xfee08bff, 0xffffbfff, 0xe6f598ff, 0xabdda4ff, 0x66c2a5ff, 0x3288bdff, 0x5e4fa2ff);
+preset!(viridis; 0x440154ff, 0x482777ff, 0x3f4a8aff, 0x31678eff, 0x26838fff, 0x1f9d8aff, 0x6cce5aff, 0xb6de2bff, 0xfee825ff);
+preset!(inferno; 0x000004ff, 0x170b3aff, 0x420a68ff, 0x6b176eff, 0x932667ff, 0xbb3654ff, 0xdd513aff, 0xf3771aff, 0xfca50aff, 0xf6d644ff, 0xfcffa4ff);
+preset!(magma; 0x000004ff, 0x140e37ff, 0x3b0f70ff, 0x641a80ff, 0x8c2981ff, 0xb63679ff, 0xde4968ff, 0xf66f5cff, 0xfe9f6dff, 0xfece91ff, 0xfcfdbfff);
+preset!(plasma; 0x0d0887ff, 0x42039dff, 0x6a00a8ff, 0x900da3ff, 0xb12a90ff, 0xcb4678ff, 0xe16462ff, 0xf1834bff, 0xfca636ff, 0xfccd25ff, 0xf0f921ff);
+preset!(rocket; 0x3051aff, 0x4051aff, 0x5061bff, 0x6071cff, 0x7071dff, 0x8081eff, 0xa091fff, 0xb0920ff, 0xd0a21ff, 0xe0b22ff, 0x100b23ff, 0x110c24ff, 0x130d25ff, 0x140e26ff, 0x160e27ff, 0x170f28ff, 0x180f29ff, 0x1a102aff, 0x1b112bff, 0x1d112cff, 0x1e122dff, 0x20122eff, 0x211330ff, 0x221331ff, 0x241432ff, 0x251433ff, 0x271534ff, 0x281535ff, 0x2a1636ff, 0x2b1637ff, 0x2d1738ff, 0x2e1739ff, 0x30173aff, 0x31183bff, 0x33183cff, 0x34193dff, 0x35193eff, 0x37193fff, 0x381a40ff, 0x3a1a41ff, 0x3c1a42ff, 0x3d1a42ff, 0x3f1b43ff, 0x401b44ff, 0x421b45ff, 0x431c46ff, 0x451c47ff, 0x461c48ff, 0x481c48ff, 0x491d49ff, 0x4b1d4aff, 0x4c1d4bff, 0x4e1d4bff, 0x501d4cff, 0x511e4dff, 0x531e4dff, 0x541e4eff, 0x561e4fff, 0x581e4fff, 0x591e50ff, 0x5b1e51ff, 0x5c1e51ff, 0x5e1f52ff, 0x601f52ff, 0x611f53ff, 0x631f53ff, 0x641f54ff, 0x661f54ff, 0x681f55ff, 0x691f55ff, 0x6b1f56ff, 0x6d1f56ff, 0x6e1f57ff, 0x701f57ff, 0x711f57ff, 0x731f58ff, 0x751f58ff, 0x761f58ff, 0x781f59ff, 0x7a1f59ff, 0x7b1f59ff, 0x7d1f5aff, 0x7f1e5aff, 0x811e5aff, 0x821e5aff, 0x841e5aff, 0x861e5bff, 0x871e5bff, 0x891e5bff, 0x8b1d5bff, 0x8c1d5bff, 0x8e1d5bff, 0x901d5bff, 0x921c5bff, 0x931c5bff, 0x951c5bff, 0x971c5bff, 0x981b5bff, 0x9a1b5bff, 0x9c1b5bff, 0x9e1a5bff, 0x9f1a5bff, 0xa11a5bff, 0xa3195bff, 0xa4195bff, 0xa6195aff, 0xa8185aff, 0xaa185aff, 0xab185aff, 0xad1759ff, 0xaf1759ff, 0xb01759ff, 0xb21758ff, 0xb41658ff, 0xb51657ff, 0xb71657ff, 0xb91657ff, 0xba1656ff, 0xbc1656ff, 0xbd1655ff, 0xbf1654ff, 0xc11754ff, 0xc21753ff, 0xc41753ff, 0xc51852ff, 0xc71951ff, 0xc81951ff, 0xca1a50ff, 0xcb1b4fff, 0xcd1c4eff, 0xce1d4eff, 0xcf1e4dff, 0xd11f4cff, 0xd2204cff, 0xd3214bff, 0xd5224aff, 0xd62449ff, 0xd72549ff, 0xd82748ff, 0xd92847ff, 0xdb2946ff, 0xdc2b46ff, 0xdd2c45ff, 0xde2e44ff, 0xdf2f44ff, 0xe03143ff, 0xe13342ff, 0xe23442ff, 0xe33641ff, 0xe43841ff, 0xe53940ff, 0xe63b40ff, 0xe73d3fff, 0xe83f3fff, 0xe8403eff, 0xe9423eff, 0xea443eff, 0xeb463eff, 0xeb483eff, 0xec4a3eff, 0xec4c3eff, 0xed4e3eff, 0xed503eff, 0xee523fff, 0xee543fff, 0xef5640ff, 0xef5840ff, 0xef5a41ff, 0xf05c42ff, 0xf05e42ff, 0xf06043ff, 0xf16244ff, 0xf16445ff, 0xf16646ff, 0xf26747ff, 0xf26948ff, 0xf26b49ff, 0xf26d4bff, 0xf26f4cff, 0xf3714dff, 0xf3734eff, 0xf37450ff, 0xf37651ff, 0xf37852ff, 0xf47a54ff, 0xf47c55ff, 0xf47d57ff, 0xf47f58ff, 0xf4815aff, 0xf4835bff, 0xf4845dff, 0xf4865eff, 0xf58860ff, 0xf58a61ff, 0xf58b63ff, 0xf58d64ff, 0xf58f66ff, 0xf59067ff, 0xf59269ff, 0xf5946bff, 0xf5966cff, 0xf5976eff, 0xf59970ff, 0xf69b71ff, 0xf69c73ff, 0xf69e75ff, 0xf6a077ff, 0xf6a178ff, 0xf6a37aff, 0xf6a47cff, 0xf6a67eff, 0xf6a880ff, 0xf6a981ff, 0xf6ab83ff, 0xf6ad85ff, 0xf6ae87ff, 0xf6b089ff, 0xf6b18bff, 0xf6b38dff, 0xf6b48fff, 0xf6b691ff, 0xf6b893ff, 0xf6b995ff, 0xf6bb97ff, 0xf6bc99ff, 0xf6be9bff, 0xf6bf9dff, 0xf6c19fff, 0xf7c2a2ff, 0xf7c4a4ff, 0xf7c6a6ff, 0xf7c7a8ff, 0xf7c9aaff, 0xf7caacff, 0xf7ccafff, 0xf7cdb1ff, 0xf7cfb3ff, 0xf7d0b5ff, 0xf8d1b8ff, 0xf8d3baff, 0xf8d4bcff, 0xf8d6beff, 0xf8d7c0ff, 0xf8d9c3ff, 0xf8dac5ff, 0xf8dcc7ff, 0xf9ddc9ff, 0xf9dfcbff, 0xf9e0cdff, 0xf9e2d0ff, 0xf9e3d2ff, 0xf9e5d4ff, 0xfae6d6ff, 0xfae8d8ff, 0xfae9daff, 0xfaebddff);
+preset!(mako; 0xb0405ff, 0xd0406ff, 0xe0508ff, 0xf0609ff, 0x10060aff, 0x11070cff, 0x12080dff, 0x13090fff, 0x140910ff, 0x150a12ff, 0x160b13ff, 0x170c15ff, 0x180d16ff, 0x190e18ff, 0x1a0e19ff, 0x1b0f1aff, 0x1c101cff, 0x1d111dff, 0x1e111fff, 0x1f1220ff, 0x201322ff, 0x211423ff, 0x221425ff, 0x231526ff, 0x241628ff, 0x251729ff, 0x26172bff, 0x27182dff, 0x28192eff, 0x291930ff, 0x291a31ff, 0x2a1b33ff, 0x2b1c35ff, 0x2c1c36ff, 0x2d1d38ff, 0x2e1e39ff, 0x2e1e3bff, 0x2f1f3dff, 0x30203eff, 0x312140ff, 0x312142ff, 0x322243ff, 0x332345ff, 0x342447ff, 0x342548ff, 0x35254aff, 0x35264cff, 0x36274dff, 0x37284fff, 0x372851ff, 0x382953ff, 0x382a54ff, 0x392b56ff, 0x3a2c58ff, 0x3a2c59ff, 0x3b2d5bff, 0x3b2e5dff, 0x3b2f5fff, 0x3c3060ff, 0x3c3162ff, 0x3d3164ff, 0x3d3266ff, 0x3e3367ff, 0x3e3469ff, 0x3e356bff, 0x3f366dff, 0x3f366fff, 0x3f3770ff, 0x403872ff, 0x403974ff, 0x403a76ff, 0x403b78ff, 0x403c79ff, 0x413d7bff, 0x413e7dff, 0x413e7fff, 0x413f80ff, 0x414082ff, 0x414184ff, 0x414285ff, 0x414387ff, 0x414488ff, 0x40468aff, 0x40478bff, 0x40488dff, 0x40498eff, 0x3f4a8fff, 0x3f4b90ff, 0x3f4c92ff, 0x3e4d93ff, 0x3e4f94ff, 0x3e5095ff, 0x3d5195ff, 0x3d5296ff, 0x3c5397ff, 0x3c5598ff, 0x3b5698ff, 0x3b5799ff, 0x3b589aff, 0x3a599aff, 0x3a5b9bff, 0x3a5c9bff, 0x395d9cff, 0x395e9cff, 0x385f9cff, 0x38619dff, 0x38629dff, 0x38639dff, 0x37649eff, 0x37659eff, 0x37669eff, 0x37689fff, 0x36699fff, 0x366a9fff, 0x366b9fff, 0x366ca0ff, 0x366da0ff, 0x366fa0ff, 0x3670a0ff, 0x3671a0ff, 0x3572a1ff, 0x3573a1ff, 0x3574a1ff, 0x3575a1ff, 0x3576a2ff, 0x3578a2ff, 0x3579a2ff, 0x357aa2ff, 0x357ba3ff, 0x357ca3ff, 0x357da3ff, 0x357ea4ff, 0x347fa4ff, 0x3480a4ff, 0x3482a4ff, 0x3483a5ff, 0x3484a5ff, 0x3485a5ff, 0x3486a5ff, 0x3487a6ff, 0x3488a6ff, 0x3489a6ff, 0x348ba6ff, 0x348ca7ff, 0x348da7ff, 0x348ea7ff, 0x348fa7ff, 0x3490a8ff, 0x3491a8ff, 0x3492a8ff, 0x3493a8ff, 0x3495a9ff, 0x3496a9ff, 0x3497a9ff, 0x3498a9ff, 0x3499aaff, 0x349aaaff, 0x359baaff, 0x359caaff, 0x359eaaff, 0x359fabff, 0x35a0abff, 0x35a1abff, 0x36a2abff, 0x36a3abff, 0x36a4abff, 0x37a5acff, 0x37a6acff, 0x37a8acff, 0x38a9acff, 0x38aaacff, 0x39abacff, 0x39acacff, 0x3aadacff, 0x3aaeadff, 0x3bafadff, 0x3cb1adff, 0x3cb2adff, 0x3db3adff, 0x3eb4adff, 0x3fb5adff, 0x3fb6adff, 0x40b7adff, 0x41b8adff, 0x42b9adff, 0x43baadff, 0x44bcadff, 0x45bdadff, 0x46beadff, 0x47bfadff, 0x48c0adff, 0x49c1adff, 0x4bc2adff, 0x4cc3adff, 0x4dc4adff, 0x4fc5adff, 0x50c6adff, 0x52c7adff, 0x53c9adff, 0x55caadff, 0x57cbadff, 0x59ccadff, 0x5bcdadff, 0x5ecdadff, 0x60ceacff, 0x62cfacff, 0x65d0adff, 0x68d1adff, 0x6ad2adff, 0x6dd3adff, 0x70d4adff, 0x73d4adff, 0x76d5aeff, 0x79d6aeff, 0x7cd6afff, 0x7fd7afff, 0x82d8b0ff, 0x85d9b1ff, 0x88d9b1ff, 0x8bdab2ff, 0x8edbb3ff, 0x91dbb4ff, 0x94dcb5ff, 0x96ddb5ff, 0x99ddb6ff, 0x9cdeb7ff, 0x9edfb8ff, 0xa1dfb9ff, 0xa4e0bbff, 0xa6e1bcff, 0xa9e1bdff, 0xabe2beff, 0xaee3c0ff, 0xb0e4c1ff, 0xb2e4c2ff, 0xb5e5c4ff, 0xb7e6c5ff, 0xb9e6c7ff, 0xbbe7c8ff, 0xbee8caff, 0xc0e9ccff, 0xc2e9cdff, 0xc4eacfff, 0xc6ebd1ff, 0xc8ecd2ff, 0xcaedd4ff, 0xccedd6ff, 0xceeed7ff, 0xd0efd9ff, 0xd2f0dbff, 0xd4f1dcff, 0xd6f1deff, 0xd8f2e0ff, 0xdaf3e1ff, 0xdcf4e3ff, 0xdef5e5ff);
+preset!(vlag; 0x2369bdff, 0x266abdff, 0x296cbcff, 0x2c6dbcff, 0x2f6ebcff, 0x316fbcff, 0x3470bcff, 0x3671bcff, 0x3972bcff, 0x3b73bcff, 0x3d74bcff, 0x3f75bcff, 0x4276bcff, 0x4477bcff, 0x4678bcff, 0x4879bcff, 0x4a7bbcff, 0x4c7cbcff, 0x4e7dbcff, 0x507ebcff, 0x517fbcff, 0x5380bcff, 0x5581bcff, 0x5782bcff, 0x5983bdff, 0x5b84bdff, 0x5c85bdff, 0x5e86bdff, 0x6087bdff, 0x6288bdff, 0x6489beff, 0x658abeff, 0x678bbeff, 0x698cbeff, 0x6a8dbfff, 0x6c8ebfff, 0x6e90bfff, 0x6f91bfff, 0x7192c0ff, 0x7393c0ff, 0x7594c0ff, 0x7695c1ff, 0x7896c1ff, 0x7997c1ff, 0x7b98c2ff, 0x7d99c2ff, 0x7e9ac2ff, 0x809bc3ff, 0x829cc3ff, 0x839dc4ff, 0x859ec4ff, 0x87a0c4ff, 0x88a1c5ff, 0x8aa2c5ff, 0x8ba3c6ff, 0x8da4c6ff, 0x8fa5c7ff, 0x90a6c7ff, 0x92a7c8ff, 0x93a8c8ff, 0x95a9c8ff, 0x97abc9ff, 0x98acc9ff, 0x9aadcaff, 0x9baecbff, 0x9dafcbff, 0x9fb0ccff, 0xa0b1ccff, 0xa2b2cdff, 0xa3b4cdff, 0xa5b5ceff, 0xa7b6ceff, 0xa8b7cfff, 0xaab8d0ff, 0xabb9d0ff, 0xadbbd1ff, 0xafbcd1ff, 0xb0bdd2ff, 0xb2bed3ff, 0xb3bfd3ff, 0xb5c0d4ff, 0xb7c2d5ff, 0xb8c3d5ff, 0xbac4d6ff, 0xbbc5d7ff, 0xbdc6d7ff, 0xbfc8d8ff, 0xc0c9d9ff, 0xc2cadaff, 0xc3cbdaff, 0xc5cddbff, 0xc7cedcff, 0xc8cfddff, 0xcad0ddff, 0xcbd1deff, 0xcdd3dfff, 0xcfd4e0ff, 0xd0d5e0ff, 0xd2d7e1ff, 0xd4d8e2ff, 0xd5d9e3ff, 0xd7dae4ff, 0xd9dce5ff, 0xdadde5ff, 0xdcdee6ff, 0xdde0e7ff, 0xdfe1e8ff, 0xe1e2e9ff, 0xe2e3eaff, 0xe4e5ebff, 0xe6e6ecff, 0xe7e7ecff, 0xe9e9edff, 0xebeaeeff, 0xecebefff, 0xeeedf0ff, 0xefeef1ff, 0xf1eff2ff, 0xf2f0f2ff, 0xf3f1f3ff, 0xf5f2f4ff, 0xf6f3f4ff, 0xf7f4f4ff, 0xf8f4f5ff, 0xf9f5f5ff, 0xf9f5f5ff, 0xfaf5f5ff, 0xfaf5f5ff, 0xfaf5f4ff, 0xfaf5f4ff, 0xfaf4f3ff, 0xfaf3f3ff, 0xfaf3f2ff, 0xfaf2f1ff, 0xfaf0efff, 0xf9efeeff, 0xf9eeedff, 0xf8edebff, 0xf7ebeaff, 0xf7eae8ff, 0xf6e8e7ff, 0xf5e7e5ff, 0xf5e5e4ff, 0xf4e3e2ff, 0xf3e2e0ff, 0xf2e0dfff, 0xf2dfddff, 0xf1dddbff, 0xf0dbdaff, 0xefdad8ff, 0xefd8d6ff, 0xeed7d5ff, 0xedd5d3ff, 0xecd3d2ff, 0xecd2d0ff, 0xebd0ceff, 0xeacfcdff, 0xeacdcbff, 0xe9cbc9ff, 0xe8cac8ff, 0xe7c8c6ff, 0xe7c7c5ff, 0xe6c5c3ff, 0xe5c3c1ff, 0xe5c2c0ff, 0xe4c0beff, 0xe3bfbdff, 0xe3bdbbff, 0xe2bcb9ff, 0xe1bab8ff, 0xe1b9b6ff, 0xe0b7b5ff, 0xdfb5b3ff, 0xdfb4b2ff, 0xdeb2b0ff, 0xdeb1aeff, 0xddafadff, 0xdcaeabff, 0xdcacaaff, 0xdbaba8ff, 0xdaa9a7ff, 0xdaa8a5ff, 0xd9a6a4ff, 0xd9a5a2ff, 0xd8a3a0ff, 0xd7a29fff, 0xd7a09dff, 0xd69f9cff, 0xd59d9aff, 0xd59c99ff, 0xd49a97ff, 0xd49896ff, 0xd39794ff, 0xd29593ff, 0xd29491ff, 0xd19290ff, 0xd1918eff, 0xd08f8dff, 0xcf8e8bff, 0xcf8c8aff, 0xce8b88ff, 0xcd8987ff, 0xcd8885ff, 0xcc8784ff, 0xcc8582ff, 0xcb8481ff, 0xca827fff, 0xca817eff, 0xc97f7dff, 0xc87e7bff, 0xc87c7aff, 0xc77b78ff, 0xc77977ff, 0xc67875ff, 0xc57674ff, 0xc57572ff, 0xc47371ff, 0xc3726fff, 0xc3706eff, 0xc26f6dff, 0xc16d6bff, 0xc16c6aff, 0xc06a68ff, 0xc06967ff, 0xbf6765ff, 0xbe6664ff, 0xbe6463ff, 0xbd6361ff, 0xbc6160ff, 0xbc605eff, 0xbb5e5dff, 0xba5d5cff, 0xb95b5aff, 0xb95a59ff, 0xb85857ff, 0xb75756ff, 0xb75555ff, 0xb65453ff, 0xb55252ff, 0xb55151ff, 0xb44f4fff, 0xb34d4eff, 0xb24c4cff, 0xb24a4bff, 0xb1494aff, 0xb04748ff, 0xaf4647ff, 0xaf4446ff, 0xae4244ff, 0xad4143ff, 0xac3f42ff, 0xac3e40ff, 0xab3c3fff, 0xaa3a3eff, 0xa9393cff, 0xa9373bff);
+preset!(icefire; 0xbde7dbff, 0xbae5daff, 0xb7e3d9ff, 0xb4e1d9ff, 0xb2dfd8ff, 0xafddd7ff, 0xacdbd7ff, 0xa9d9d6ff, 0xa7d7d5ff, 0xa4d5d5ff, 0xa1d3d4ff, 0x9ed1d3ff, 0x9bcfd3ff, 0x98cdd2ff, 0x95cbd2ff, 0x93cad1ff, 0x90c8d1ff, 0x8dc6d0ff, 0x8ac4d0ff, 0x87c2cfff, 0x84c1cfff, 0x81bfcfff, 0x7ebdceff, 0x7bbbceff, 0x78b9ceff, 0x75b8ceff, 0x72b6ceff, 0x6eb4cdff, 0x6bb2cdff, 0x68b0cdff, 0x65afcdff, 0x63adcdff, 0x60abcdff, 0x5da9cdff, 0x5aa7cdff, 0x58a5cdff, 0x55a3cdff, 0x53a2cdff, 0x50a0cdff, 0x4e9ecdff, 0x4c9ccdff, 0x499aceff, 0x4798ceff, 0x4596ceff, 0x4394ceff, 0x4192ceff, 0x3f90ceff, 0x3e8ecfff, 0x3c8ccfff, 0x3a89cfff, 0x3987cfff, 0x3885d0ff, 0x3783d0ff, 0x3781d0ff, 0x377fd0ff, 0x377cd0ff, 0x377ad0ff, 0x3878cfff, 0x3975cfff, 0x3a73ceff, 0x3b71cdff, 0x3d6eccff, 0x3e6ccbff, 0x3f69c9ff, 0x4167c7ff, 0x4265c5ff, 0x4363c3ff, 0x4560c1ff, 0x465ebeff, 0x475cbcff, 0x475ab9ff, 0x4858b6ff, 0x4956b3ff, 0x4954b0ff, 0x4952adff, 0x4a50a9ff, 0x4a4fa5ff, 0x494da1ff, 0x494c9eff, 0x494a9aff, 0x484996ff, 0x474792ff, 0x47468eff, 0x46458aff, 0x454386ff, 0x444282ff, 0x43417fff, 0x42407bff, 0x413e77ff, 0x3f3d74ff, 0x3e3c70ff, 0x3d3b6dff, 0x3c3a69ff, 0x3b3866ff, 0x393763ff, 0x38365fff, 0x37355cff, 0x363459ff, 0x343356ff, 0x333153ff, 0x323050ff, 0x312f4dff, 0x302e4aff, 0x2e2d48ff, 0x2d2c45ff, 0x2c2b42ff, 0x2b2a40ff, 0x2a293dff, 0x29283bff, 0x282739ff, 0x272636ff, 0x262534ff, 0x252532ff, 0x242430ff, 0x24232eff, 0x23222dff, 0x22222bff, 0x222129ff, 0x212028ff, 0x212026ff, 0x202025ff, 0x201f24ff, 0x1f1f23ff, 0x1f1f21ff, 0x1f1e21ff, 0x1f1e20ff, 0x1f1e1fff, 0x1f1e1eff, 0x1f1e1eff, 0x201e1eff, 0x211e1eff, 0x221e1eff, 0x231e1eff, 0x251e1fff, 0x261e1fff, 0x271e1fff, 0x291e20ff, 0x2a1e20ff, 0x2c1e21ff, 0x2d1f21ff, 0x2f1f22ff, 0x311f23ff, 0x332023ff, 0x352024ff, 0x372025ff, 0x392126ff, 0x3b2127ff, 0x3d2228ff, 0x3f2228ff, 0x412329ff, 0x43232aff, 0x46242bff, 0x48242cff, 0x4a252eff, 0x4d252fff, 0x4f2630ff, 0x522731ff, 0x542732ff, 0x572833ff, 0x5a2834ff, 0x5c2935ff, 0x5f2936ff, 0x622937ff, 0x642a38ff, 0x672a39ff, 0x6a2b3aff, 0x6d2b3bff, 0x702b3cff, 0x722c3dff, 0x752c3eff, 0x782c3fff, 0x7b2d40ff, 0x7e2d40ff, 0x812d41ff, 0x842d42ff, 0x872d42ff, 0x8a2e43ff, 0x8d2e43ff, 0x902e44ff, 0x932e44ff, 0x962e44ff, 0x992e44ff, 0x9c2f45ff, 0x9f2f44ff, 0xa22f44ff, 0xa52f44ff, 0xa83044ff, 0xab3043ff, 0xae3143ff, 0xb13242ff, 0xb33341ff, 0xb63441ff, 0xb93540ff, 0xbb363fff, 0xbe373eff, 0xc0393dff, 0xc33a3cff, 0xc53c3cff, 0xc73d3bff, 0xc93f3aff, 0xcc4139ff, 0xce4338ff, 0xd04537ff, 0xd24737ff, 0xd34936ff, 0xd54b35ff, 0xd74e35ff, 0xd95034ff, 0xda5334ff, 0xdc5534ff, 0xde5733ff, 0xdf5a33ff, 0xe15c33ff, 0xe25f33ff, 0xe36233ff, 0xe56433ff, 0xe66734ff, 0xe76a34ff, 0xe86d35ff, 0xe96f36ff, 0xea7238ff, 0xeb753aff, 0xec783bff, 0xed7b3eff, 0xed7e40ff, 0xee8142ff, 0xef8445ff, 0xef8748ff, 0xf0894bff, 0xf18c4eff, 0xf18f51ff, 0xf29255ff, 0xf29558ff, 0xf3985bff, 0xf39a5fff, 0xf49d63ff, 0xf5a066ff, 0xf5a36aff, 0xf6a56dff, 0xf6a871ff, 0xf7ab75ff, 0xf7ae79ff, 0xf8b07cff, 0xf8b380ff, 0xf9b684ff, 0xfab887ff, 0xfabb8bff, 0xfbbe8fff, 0xfbc192ff, 0xfcc396ff, 0xfcc69aff, 0xfdc99eff, 0xfdcca1ff, 0xfecea5ff, 0xfed1a9ff, 0xffd4acff);
+preset!(flare; 0xedb081ff, 0xedaf80ff, 0xedae7fff, 0xedad7fff, 0xedac7eff, 0xedab7eff, 0xecaa7dff, 0xeca97cff, 0xeca87cff, 0xeca77bff, 0xeca67bff, 0xeca57aff, 0xeca479ff, 0xeca379ff, 0xeca278ff, 0xeca178ff, 0xeca077ff, 0xec9f76ff, 0xeb9e76ff, 0xeb9d75ff, 0xeb9c75ff, 0xeb9b74ff, 0xeb9a73ff, 0xeb9973ff, 0xeb9972ff, 0xeb9872ff, 0xeb9771ff, 0xea9671ff, 0xea9570ff, 0xea946fff, 0xea936fff, 0xea926eff, 0xea916eff, 0xea906dff, 0xea8f6cff, 0xea8e6cff, 0xe98d6bff, 0xe98c6bff, 0xe98b6aff, 0xe98a6aff, 0xe98969ff, 0xe98868ff, 0xe98768ff, 0xe98667ff, 0xe88567ff, 0xe88466ff, 0xe88366ff, 0xe88265ff, 0xe88165ff, 0xe88064ff, 0xe87f64ff, 0xe77e63ff, 0xe77d63ff, 0xe77c63ff, 0xe77b62ff, 0xe77a62ff, 0xe67961ff, 0xe67861ff, 0xe67760ff, 0xe67660ff, 0xe67560ff, 0xe5745fff, 0xe5735fff, 0xe5725fff, 0xe5715eff, 0xe5705eff, 0xe46f5eff, 0xe46e5eff, 0xe46d5dff, 0xe46c5dff, 0xe36b5dff, 0xe36a5dff, 0xe3695dff, 0xe3685cff, 0xe2675cff, 0xe2665cff, 0xe2655cff, 0xe1645cff, 0xe1635cff, 0xe1625cff, 0xe0615cff, 0xe0605cff, 0xe05f5cff, 0xdf5f5cff, 0xdf5e5cff, 0xde5d5cff, 0xde5c5cff, 0xde5b5cff, 0xdd5a5cff, 0xdd595cff, 0xdc585cff, 0xdc575cff, 0xdb565dff, 0xdb565dff, 0xda555dff, 0xda545dff, 0xd9535dff, 0xd9525eff, 0xd8525eff, 0xd7515eff, 0xd7505eff, 0xd64f5fff, 0xd64f5fff, 0xd54e5fff, 0xd44d60ff, 0xd44c60ff, 0xd34c60ff, 0xd24b60ff, 0xd24a61ff, 0xd14a61ff, 0xd04962ff, 0xd04962ff, 0xcf4862ff, 0xce4763ff, 0xcd4763ff, 0xcc4663ff, 0xcc4664ff, 0xcb4564ff, 0xca4564ff, 0xc94465ff, 0xc84465ff, 0xc84365ff, 0xc74366ff, 0xc64366ff, 0xc54266ff, 0xc44267ff, 0xc34167ff, 0xc24167ff, 0xc14168ff, 0xc14068ff, 0xc04068ff, 0xbf4069ff, 0xbe3f69ff, 0xbd3f69ff, 0xbc3f69ff, 0xbb3f6aff, 0xba3e6aff, 0xb93e6aff, 0xb83e6bff, 0xb73d6bff, 0xb63d6bff, 0xb53d6bff, 0xb43d6bff, 0xb33c6cff, 0xb23c6cff, 0xb13c6cff, 0xb13c6cff, 0xb03b6dff, 0xaf3b6dff, 0xae3b6dff, 0xad3b6dff, 0xac3a6dff, 0xab3a6dff, 0xaa3a6eff, 0xa93a6eff, 0xa8396eff, 0xa7396eff, 0xa6396eff, 0xa5396eff, 0xa4386fff, 0xa3386fff, 0xa2386fff, 0xa1386fff, 0xa1376fff, 0xa0376fff, 0x9f376fff, 0x9e3770ff, 0x9d3670ff, 0x9c3670ff, 0x9b3670ff, 0x9a3670ff, 0x993570ff, 0x983570ff, 0x973570ff, 0x963570ff, 0x953470ff, 0x943470ff, 0x943471ff, 0x933471ff, 0x923371ff, 0x913371ff, 0x903371ff, 0x8f3371ff, 0x8e3271ff, 0x8d3271ff, 0x8c3271ff, 0x8b3271ff, 0x8a3171ff, 0x893171ff, 0x883171ff, 0x873171ff, 0x873171ff, 0x863071ff, 0x853071ff, 0x843071ff, 0x833070ff, 0x822f70ff, 0x812f70ff, 0x802f70ff, 0x7f2f70ff, 0x7e2f70ff, 0x7d2e70ff, 0x7c2e70ff, 0x7b2e70ff, 0x7a2e70ff, 0x792e6fff, 0x782e6fff, 0x772d6fff, 0x762d6fff, 0x752d6fff, 0x752d6fff, 0x742d6eff, 0x732c6eff, 0x722c6eff, 0x712c6eff, 0x702c6eff, 0x6f2c6dff, 0x6e2c6dff, 0x6d2b6dff, 0x6c2b6dff, 0x6b2b6cff, 0x6a2b6cff, 0x692b6cff, 0x682a6cff, 0x672a6bff, 0x662a6bff, 0x652a6bff, 0x642a6aff, 0x642a6aff, 0x63296aff, 0x62296aff, 0x612969ff, 0x602969ff, 0x5f2969ff, 0x5e2868ff, 0x5d2868ff, 0x5c2868ff, 0x5b2867ff, 0x5a2767ff, 0x592767ff, 0x582766ff, 0x582766ff, 0x572766ff, 0x562666ff, 0x552665ff, 0x542665ff, 0x532665ff, 0x522564ff, 0x512564ff, 0x502564ff, 0x4f2463ff, 0x4f2463ff, 0x4e2463ff, 0x4d2463ff, 0x4c2362ff, 0x4b2362ff);
+preset!(crest; 0xa5cd90ff, 0xa4cc90ff, 0xa3cc91ff, 0xa2cb91ff, 0xa0cb91ff, 0x9fca91ff, 0x9eca91ff, 0x9dc991ff, 0x9cc891ff, 0x9bc891ff, 0x9ac791ff, 0x99c791ff, 0x98c691ff, 0x96c691ff, 0x95c591ff, 0x94c591ff, 0x93c491ff, 0x92c491ff, 0x91c391ff, 0x90c391ff, 0x8fc291ff, 0x8ec291ff, 0x8dc191ff, 0x8bc191ff, 0x8ac091ff, 0x89bf91ff, 0x88bf91ff, 0x87be91ff, 0x86be91ff, 0x85bd91ff, 0x84bd91ff, 0x82bc91ff, 0x81bc91ff, 0x80bb91ff, 0x7fbb91ff, 0x7eba91ff, 0x7dba91ff, 0x7cb991ff, 0x7bb991ff, 0x79b891ff, 0x78b891ff, 0x77b791ff, 0x76b791ff, 0x75b690ff, 0x74b690ff, 0x73b590ff, 0x72b490ff, 0x71b490ff, 0x70b390ff, 0x6fb390ff, 0x6eb290ff, 0x6db290ff, 0x6cb190ff, 0x6bb190ff, 0x6ab090ff, 0x69b090ff, 0x68af90ff, 0x67ae90ff, 0x66ae90ff, 0x65ad90ff, 0x64ad90ff, 0x63ac90ff, 0x62ac90ff, 0x62ab90ff, 0x61aa90ff, 0x60aa90ff, 0x5fa990ff, 0x5ea990ff, 0x5da890ff, 0x5ca890ff, 0x5ba790ff, 0x5ba690ff, 0x5aa690ff, 0x59a590ff, 0x58a590ff, 0x57a490ff, 0x57a490ff, 0x56a390ff, 0x55a290ff, 0x54a290ff, 0x53a190ff, 0x53a190ff, 0x52a090ff, 0x519f90ff, 0x509f90ff, 0x509e90ff, 0x4f9e90ff, 0x4e9d90ff, 0x4e9d90ff, 0x4d9c90ff, 0x4c9b90ff, 0x4b9b90ff, 0x4b9a8fff, 0x4a9a8fff, 0x49998fff, 0x49988fff, 0x48988fff, 0x47978fff, 0x47978fff, 0x46968fff, 0x45958fff, 0x45958fff, 0x44948fff, 0x43948fff, 0x43938fff, 0x42928fff, 0x41928fff, 0x41918fff, 0x40918fff, 0x40908eff, 0x3f8f8eff, 0x3e8f8eff, 0x3e8e8eff, 0x3d8e8eff, 0x3c8d8eff, 0x3c8c8eff, 0x3b8c8eff, 0x3a8b8eff, 0x3a8b8eff, 0x398a8eff, 0x388a8eff, 0x38898eff, 0x37888eff, 0x37888dff, 0x36878dff, 0x35878dff, 0x35868dff, 0x34858dff, 0x33858dff, 0x33848dff, 0x32848dff, 0x31838dff, 0x31828dff, 0x30828dff, 0x2f818dff, 0x2f818dff, 0x2e808dff, 0x2d808cff, 0x2d7f8cff, 0x2c7e8cff, 0x2c7e8cff, 0x2b7d8cff, 0x2a7d8cff, 0x2a7c8cff, 0x297b8cff, 0x287b8cff, 0x287a8cff, 0x277a8cff, 0x27798cff, 0x26788cff, 0x25788cff, 0x25778cff, 0x24778bff, 0x24768bff, 0x23758bff, 0x23758bff, 0x22748bff, 0x22748bff, 0x21738bff, 0x21728bff, 0x20728bff, 0x20718bff, 0x20718bff, 0x1f708bff, 0x1f6f8aff, 0x1e6f8aff, 0x1e6e8aff, 0x1e6d8aff, 0x1e6d8aff, 0x1d6c8aff, 0x1d6c8aff, 0x1d6b8aff, 0x1d6a8aff, 0x1d6a8aff, 0x1c6989ff, 0x1c6889ff, 0x1c6889ff, 0x1c6789ff, 0x1c6689ff, 0x1c6689ff, 0x1c6589ff, 0x1c6488ff, 0x1c6488ff, 0x1c6388ff, 0x1d6388ff, 0x1d6288ff, 0x1d6188ff, 0x1d6187ff, 0x1d6087ff, 0x1d5f87ff, 0x1d5f87ff, 0x1e5e87ff, 0x1e5d86ff, 0x1e5d86ff, 0x1e5c86ff, 0x1e5b86ff, 0x1f5b86ff, 0x1f5a85ff, 0x1f5985ff, 0x1f5985ff, 0x205885ff, 0x205784ff, 0x205784ff, 0x205684ff, 0x215584ff, 0x215583ff, 0x215483ff, 0x225383ff, 0x225283ff, 0x225282ff, 0x225182ff, 0x235082ff, 0x235081ff, 0x234f81ff, 0x244e81ff, 0x244e80ff, 0x244d80ff, 0x254c80ff, 0x254c7fff, 0x254b7fff, 0x254a7fff, 0x26497eff, 0x26497eff, 0x26487eff, 0x27477dff, 0x27477dff, 0x27467cff, 0x27457cff, 0x28457cff, 0x28447bff, 0x28437bff, 0x28427aff, 0x29427aff, 0x29417aff, 0x294079ff, 0x294079ff, 0x2a3f78ff, 0x2a3e78ff, 0x2a3d78ff, 0x2a3d77ff, 0x2a3c77ff, 0x2a3b76ff, 0x2b3b76ff, 0x2b3a76ff, 0x2b3975ff, 0x2b3875ff, 0x2b3875ff, 0x2b3774ff, 0x2b3674ff, 0x2c3574ff, 0x2c3573ff, 0x2c3473ff, 0x2c3373ff, 0x2c3272ff, 0x2c3172ff, 0x2c3172ff);
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_color_strings() {
+ #[track_caller]
+ fn test(hex: &str, r: u8, g: u8, b: u8, a: u8) {
+ assert_eq!(Color::from_str(hex), Ok(Color::from_u8(r, g, b, a)));
+ }
+
+ test("f61243ff", 0xf6, 0x12, 0x43, 255);
+ test("b3d8b3", 0xb3, 0xd8, 0xb3, 255);
+ test("fCd2a9AD", 0xfc, 0xd2, 0xa9, 0xad);
+ test("233", 0x22, 0x33, 0x33, 255);
+ test("111b", 0x11, 0x11, 0x11, 0xbb);
+ }
+
+ #[test]
+ fn test_parse_invalid_colors() {
+ #[track_caller]
+ fn test(hex: &str, message: &str) {
+ assert_eq!(Color::from_str(hex), Err(message));
+ }
+
+ test("a5", "color string has wrong length");
+ test("12345", "color string has wrong length");
+ test("f075ff011", "color string has wrong length");
+ test("hmmm", "color string contains non-hexadecimal letters");
+ test("14B2AH", "color string contains non-hexadecimal letters");
+ }
+}
diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs
new file mode 100644
index 00000000..2be7e370
--- /dev/null
+++ b/crates/typst-library/src/visualize/gradient.rs
@@ -0,0 +1,1260 @@
+use std::f64::consts::{FRAC_PI_2, PI, TAU};
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+use std::sync::Arc;
+
+use ecow::EcoString;
+use kurbo::Vec2;
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, SourceResult};
+use crate::foundations::{
+ array, cast, func, scope, ty, Args, Array, Cast, Func, IntoValue, Repr, Smart,
+};
+use crate::layout::{Angle, Axes, Dir, Quadrant, Ratio};
+use crate::visualize::{Color, ColorSpace, WeightedColor};
+
+/// A color gradient.
+///
+/// Typst supports linear gradients through the
+/// [`gradient.linear` function]($gradient.linear), radial gradients through
+/// the [`gradient.radial` function]($gradient.radial), and conic gradients
+/// through the [`gradient.conic` function]($gradient.conic).
+///
+/// A gradient can be used for the following purposes:
+/// - As a fill to paint the interior of a shape:
+/// `{rect(fill: gradient.linear(..))}`
+/// - As a stroke to paint the outline of a shape:
+/// `{rect(stroke: 1pt + gradient.linear(..))}`
+/// - As the fill of text:
+/// `{set text(fill: gradient.linear(..))}`
+/// - As a color map you can [sample]($gradient.sample) from:
+/// `{gradient.linear(..).sample(50%)}`
+///
+/// # Examples
+/// ```example
+/// >>> #set square(size: 50pt)
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// square(fill: gradient.linear(..color.map.rainbow)),
+/// square(fill: gradient.radial(..color.map.rainbow)),
+/// square(fill: gradient.conic(..color.map.rainbow)),
+/// )
+/// ```
+///
+/// Gradients are also supported on text, but only when setting the
+/// [relativeness]($gradient.relative) to either `{auto}` (the default value) or
+/// `{"parent"}`. To create word-by-word or glyph-by-glyph gradients, you can
+/// wrap the words or characters of your text in [boxes]($box) manually or
+/// through a [show rule]($styling/#show-rules).
+///
+/// ```example
+/// >>> #set page(width: auto, height: auto, margin: 12pt)
+/// >>> #set text(size: 12pt)
+/// #set text(fill: gradient.linear(red, blue))
+/// #let rainbow(content) = {
+/// set text(fill: gradient.linear(..color.map.rainbow))
+/// box(content)
+/// }
+///
+/// This is a gradient on text, but with a #rainbow[twist]!
+/// ```
+///
+/// # Stops
+/// A gradient is composed of a series of stops. Each of these stops has a color
+/// and an offset. The offset is a [ratio]($ratio) between `{0%}` and `{100%}` or
+/// an angle between `{0deg}` and `{360deg}`. The offset is a relative position
+/// that determines how far along the gradient the stop is located. The stop's
+/// color is the color of the gradient at that position. You can choose to omit
+/// the offsets when defining a gradient. In this case, Typst will space all
+/// stops evenly.
+///
+/// # Relativeness
+/// The location of the `{0%}` and `{100%}` stops depends on the dimensions
+/// of a container. This container can either be the shape that it is being
+/// painted on, or the closest surrounding container. This is controlled by the
+/// `relative` argument of a gradient constructor. By default, gradients are
+/// relative to the shape they are being painted on, unless the gradient is
+/// applied on text, in which case they are relative to the closest ancestor
+/// container.
+///
+/// Typst determines the ancestor container as follows:
+/// - For shapes that are placed at the root/top level of the document, the
+/// closest ancestor is the page itself.
+/// - For other shapes, the ancestor is the innermost [`block`] or [`box`] that
+/// contains the shape. This includes the boxes and blocks that are implicitly
+/// created by show rules and elements. For example, a [`rotate`] will not
+/// affect the parent of a gradient, but a [`grid`] will.
+///
+/// # Color spaces and interpolation
+/// Gradients can be interpolated in any color space. By default, gradients are
+/// interpolated in the [Oklab]($color.oklab) color space, which is a
+/// [perceptually uniform](https://programmingdesignsystems.com/color/perceptually-uniform-color-spaces/index.html)
+/// color space. This means that the gradient will be perceived as having a
+/// smooth progression of colors. This is particularly useful for data
+/// visualization.
+///
+/// However, you can choose to interpolate the gradient in any supported color
+/// space you want, but beware that some color spaces are not suitable for
+/// perceptually interpolating between colors. Consult the table below when
+/// choosing an interpolation space.
+///
+/// | Color space | Perceptually uniform? |
+/// | ------------------------------- |-----------------------|
+/// | [Oklab]($color.oklab) | *Yes* |
+/// | [Oklch]($color.oklch) | *Yes* |
+/// | [sRGB]($color.rgb) | *No* |
+/// | [linear-RGB]($color.linear-rgb) | *Yes* |
+/// | [CMYK]($color.cmyk) | *No* |
+/// | [Grayscale]($color.luma) | *Yes* |
+/// | [HSL]($color.hsl) | *No* |
+/// | [HSV]($color.hsv) | *No* |
+///
+/// ```preview
+/// >>> #set text(fill: white, font: "IBM Plex Sans", 8pt)
+/// >>> #set block(spacing: 0pt)
+/// #let spaces = (
+/// ("Oklab", color.oklab),
+/// ("Oklch", color.oklch),
+/// ("linear-RGB", color.linear-rgb),
+/// ("sRGB", color.rgb),
+/// ("CMYK", color.cmyk),
+/// ("HSL", color.hsl),
+/// ("HSV", color.hsv),
+/// ("Grayscale", color.luma),
+/// )
+///
+/// #for (name, space) in spaces {
+/// block(
+/// width: 100%,
+/// inset: 4pt,
+/// fill: gradient.linear(
+/// red,
+/// blue,
+/// space: space,
+/// ),
+/// strong(upper(name)),
+/// )
+/// }
+/// ```
+///
+/// # Direction
+/// Some gradients are sensitive to direction. For example, a linear gradient
+/// has an angle that determines its direction. Typst uses a clockwise angle,
+/// with 0° being from left to right, 90° from top to bottom, 180° from right to
+/// left, and 270° from bottom to top.
+///
+/// ```example
+/// >>> #set square(size: 50pt)
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// square(fill: gradient.linear(red, blue, angle: 0deg)),
+/// square(fill: gradient.linear(red, blue, angle: 90deg)),
+/// square(fill: gradient.linear(red, blue, angle: 180deg)),
+/// square(fill: gradient.linear(red, blue, angle: 270deg)),
+/// )
+/// ```
+///
+/// # Presets
+/// Typst predefines color maps that you can use with your gradients. See the
+/// [`color`]($color/#predefined-color-maps) documentation for more details.
+///
+/// # Note on file sizes
+///
+/// Gradients can be quite large, especially if they have many stops. This is
+/// because gradients are stored as a list of colors and offsets, which can
+/// take up a lot of space. If you are concerned about file sizes, you should
+/// consider the following:
+/// - SVG gradients are currently inefficiently encoded. This will be improved
+/// in the future.
+/// - PDF gradients in the [`color.oklab`]($color.oklab), [`color.hsv`]($color.hsv),
+/// [`color.hsl`]($color.hsl), and [`color.oklch`]($color.oklch) color spaces
+/// are stored as a list of [`color.rgb`]($color.rgb) colors with extra stops
+/// in between. This avoids needing to encode these color spaces in your PDF
+/// file, but it does add extra stops to your gradient, which can increase
+/// the file size.
+#[ty(scope, cast)]
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub enum Gradient {
+ Linear(Arc<LinearGradient>),
+ Radial(Arc<RadialGradient>),
+ Conic(Arc<ConicGradient>),
+}
+
+#[scope]
+#[allow(clippy::too_many_arguments)]
+impl Gradient {
+ /// Creates a new linear gradient, in which colors transition along a
+ /// straight line.
+ ///
+ /// ```example
+ /// #rect(
+ /// width: 100%,
+ /// height: 20pt,
+ /// fill: gradient.linear(
+ /// ..color.map.viridis,
+ /// ),
+ /// )
+ /// ```
+ #[func(title = "Linear Gradient")]
+ pub fn linear(
+ /// The args of this function.
+ args: &mut Args,
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// The color space in which to interpolate the gradient.
+ ///
+ /// Defaults to a perceptually uniform color space called
+ /// [Oklab]($color.oklab).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ /// The [relative placement](#relativeness) of the gradient.
+ ///
+ /// For an element placed at the root/top level of the document, the
+ /// parent is the page itself. For other elements, the parent is the
+ /// innermost block, box, column, grid, or stack that contains the
+ /// element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The direction of the gradient.
+ #[external]
+ #[default(Dir::LTR)]
+ dir: Dir,
+ /// The angle of the gradient.
+ #[external]
+ angle: Angle,
+ ) -> SourceResult<Gradient> {
+ let angle = if let Some(angle) = args.named::<Angle>("angle")? {
+ angle
+ } else if let Some(dir) = args.named::<Dir>("dir")? {
+ match dir {
+ Dir::LTR => Angle::rad(0.0),
+ Dir::RTL => Angle::rad(PI),
+ Dir::TTB => Angle::rad(FRAC_PI_2),
+ Dir::BTT => Angle::rad(3.0 * FRAC_PI_2),
+ }
+ } else {
+ Angle::rad(0.0)
+ };
+
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ Ok(Self::Linear(Arc::new(LinearGradient {
+ stops: process_stops(&stops)?,
+ angle,
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a new radial gradient, in which colors radiate away from an
+ /// origin.
+ ///
+ /// The gradient is defined by two circles: the focal circle and the end
+ /// circle. The focal circle is a circle with center `focal-center` and
+ /// radius `focal-radius`, that defines the points at which the gradient
+ /// starts and has the color of the first stop. The end circle is a circle
+ /// with center `center` and radius `radius`, that defines the points at
+ /// which the gradient ends and has the color of the last stop. The gradient
+ /// is then interpolated between these two circles.
+ ///
+ /// Using these four values, also called the focal point for the starting
+ /// circle and the center and radius for the end circle, we can define a
+ /// gradient with more interesting properties than a basic radial gradient.
+ ///
+ /// ```example
+ /// >>> #set circle(radius: 30pt)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// circle(fill: gradient.radial(
+ /// ..color.map.viridis,
+ /// )),
+ /// circle(fill: gradient.radial(
+ /// ..color.map.viridis,
+ /// focal-center: (10%, 40%),
+ /// focal-radius: 5%,
+ /// )),
+ /// )
+ /// ```
+ #[func]
+ fn radial(
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// The color space in which to interpolate the gradient.
+ ///
+ /// Defaults to a perceptually uniform color space called
+ /// [Oklab]($color.oklab).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ /// The [relative placement](#relativeness) of the gradient.
+ ///
+ /// For an element placed at the root/top level of the document, the parent
+ /// is the page itself. For other elements, the parent is the innermost block,
+ /// box, column, grid, or stack that contains the element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The center of the end circle of the gradient.
+ ///
+ /// A value of `{(50%, 50%)}` means that the end circle is
+ /// centered inside of its container.
+ #[named]
+ #[default(Axes::splat(Ratio::new(0.5)))]
+ center: Axes<Ratio>,
+ /// The radius of the end circle of the gradient.
+ ///
+ /// By default, it is set to `{50%}`. The ending radius must be bigger
+ /// than the focal radius.
+ #[named]
+ #[default(Spanned::new(Ratio::new(0.5), Span::detached()))]
+ radius: Spanned<Ratio>,
+ /// The center of the focal circle of the gradient.
+ ///
+ /// The focal center must be inside of the end circle.
+ ///
+ /// A value of `{(50%, 50%)}` means that the focal circle is
+ /// centered inside of its container.
+ ///
+ /// By default it is set to the same as the center of the last circle.
+ #[named]
+ #[default(Smart::Auto)]
+ focal_center: Smart<Axes<Ratio>>,
+ /// The radius of the focal circle of the gradient.
+ ///
+ /// The focal center must be inside of the end circle.
+ ///
+ /// By default, it is set to `{0%}`. The focal radius must be smaller
+ /// than the ending radius`.
+ #[named]
+ #[default(Spanned::new(Ratio::new(0.0), Span::detached()))]
+ focal_radius: Spanned<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ if focal_radius.v > radius.v {
+ bail!(
+ focal_radius.span,
+ "the focal radius must be smaller than the end radius";
+ hint: "try using a focal radius of `0%` instead"
+ );
+ }
+
+ let focal_center = focal_center.unwrap_or(center);
+ let d_center_sqr = (focal_center.x - center.x).get().powi(2)
+ + (focal_center.y - center.y).get().powi(2);
+ if d_center_sqr.sqrt() >= (radius.v - focal_radius.v).get() {
+ bail!(
+ span,
+ "the focal circle must be inside of the end circle";
+ hint: "try using a focal center of `auto` instead"
+ );
+ }
+
+ Ok(Gradient::Radial(Arc::new(RadialGradient {
+ stops: process_stops(&stops)?,
+ center: center.map(From::from),
+ radius: radius.v,
+ focal_center,
+ focal_radius: focal_radius.v,
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a new conic gradient, in which colors change radially around a
+ /// center point.
+ ///
+ /// You can control the center point of the gradient by using the `center`
+ /// argument. By default, the center point is the center of the shape.
+ ///
+ /// ```example
+ /// >>> #set circle(radius: 30pt)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// circle(fill: gradient.conic(
+ /// ..color.map.viridis,
+ /// )),
+ /// circle(fill: gradient.conic(
+ /// ..color.map.viridis,
+ /// center: (20%, 30%),
+ /// )),
+ /// )
+ /// ```
+ #[func]
+ pub fn conic(
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// The angle of the gradient.
+ #[named]
+ #[default(Angle::zero())]
+ angle: Angle,
+ /// The color space in which to interpolate the gradient.
+ ///
+ /// Defaults to a perceptually uniform color space called
+ /// [Oklab]($color.oklab).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ /// The [relative placement](#relativeness) of the gradient.
+ ///
+ /// For an element placed at the root/top level of the document, the parent
+ /// is the page itself. For other elements, the parent is the innermost block,
+ /// box, column, grid, or stack that contains the element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The center of the last circle of the gradient.
+ ///
+ /// A value of `{(50%, 50%)}` means that the end circle is
+ /// centered inside of its container.
+ #[named]
+ #[default(Axes::splat(Ratio::new(0.5)))]
+ center: Axes<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ Ok(Gradient::Conic(Arc::new(ConicGradient {
+ stops: process_stops(&stops)?,
+ angle,
+ center: center.map(From::from),
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a sharp version of this gradient.
+ ///
+ /// Sharp gradients have discrete jumps between colors, instead of a
+ /// smooth transition. They are particularly useful for creating color
+ /// lists for a preset gradient.
+ ///
+ /// ```example
+ /// #set rect(width: 100%, height: 20pt)
+ /// #let grad = gradient.linear(..color.map.rainbow)
+ /// #rect(fill: grad)
+ /// #rect(fill: grad.sharp(5))
+ /// #rect(fill: grad.sharp(5, smoothness: 20%))
+ /// ```
+ #[func]
+ pub fn sharp(
+ &self,
+ /// The number of stops in the gradient.
+ steps: Spanned<usize>,
+ /// How much to smooth the gradient.
+ #[named]
+ #[default(Spanned::new(Ratio::zero(), Span::detached()))]
+ smoothness: Spanned<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if steps.v < 2 {
+ bail!(steps.span, "sharp gradients must have at least two stops");
+ }
+
+ if smoothness.v.get() < 0.0 || smoothness.v.get() > 1.0 {
+ bail!(smoothness.span, "smoothness must be between 0 and 1");
+ }
+
+ let n = steps.v;
+ let smoothness = smoothness.v.get();
+ let colors = (0..n)
+ .flat_map(|i| {
+ let c = self
+ .sample(RatioOrAngle::Ratio(Ratio::new(i as f64 / (n - 1) as f64)));
+
+ [c, c]
+ })
+ .collect::<Vec<_>>();
+
+ let mut positions = Vec::with_capacity(n * 2);
+ let index_to_progress = |i| i as f64 * 1.0 / n as f64;
+
+ let progress = smoothness * 1.0 / (4.0 * n as f64);
+ for i in 0..n {
+ let mut j = 2 * i;
+ positions.push(index_to_progress(i));
+ if j > 0 {
+ positions[j] += progress;
+ }
+
+ j += 1;
+ positions.push(index_to_progress(i + 1));
+ if j < colors.len() - 1 {
+ positions[j] -= progress;
+ }
+ }
+
+ let mut stops = colors
+ .into_iter()
+ .zip(positions)
+ .map(|(c, p)| (c, Ratio::new(p)))
+ .collect::<Vec<_>>();
+
+ stops.dedup();
+
+ Ok(match self {
+ Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
+ stops,
+ angle: linear.angle,
+ space: linear.space,
+ relative: linear.relative,
+ anti_alias: false,
+ })),
+ Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient {
+ stops,
+ center: radial.center,
+ radius: radial.radius,
+ focal_center: radial.focal_center,
+ focal_radius: radial.focal_radius,
+ space: radial.space,
+ relative: radial.relative,
+ anti_alias: false,
+ })),
+ Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
+ stops,
+ angle: conic.angle,
+ center: conic.center,
+ space: conic.space,
+ relative: conic.relative,
+ anti_alias: false,
+ })),
+ })
+ }
+
+ /// Repeats this gradient a given number of times, optionally mirroring it
+ /// at each repetition.
+ ///
+ /// ```example
+ /// #circle(
+ /// radius: 40pt,
+ /// fill: gradient
+ /// .radial(aqua, white)
+ /// .repeat(4),
+ /// )
+ /// ```
+ #[func]
+ pub fn repeat(
+ &self,
+ /// The number of times to repeat the gradient.
+ repetitions: Spanned<usize>,
+ /// Whether to mirror the gradient at each repetition.
+ #[named]
+ #[default(false)]
+ mirror: bool,
+ ) -> SourceResult<Gradient> {
+ if repetitions.v == 0 {
+ bail!(repetitions.span, "must repeat at least once");
+ }
+
+ let n = repetitions.v;
+ let mut stops = std::iter::repeat(self.stops_ref())
+ .take(n)
+ .enumerate()
+ .flat_map(|(i, stops)| {
+ let mut stops = stops
+ .iter()
+ .map(move |&(color, offset)| {
+ let t = i as f64 / n as f64;
+ let r = offset.get();
+ if i % 2 == 1 && mirror {
+ (color, Ratio::new(t + (1.0 - r) / n as f64))
+ } else {
+ (color, Ratio::new(t + r / n as f64))
+ }
+ })
+ .collect::<Vec<_>>();
+
+ if i % 2 == 1 && mirror {
+ stops.reverse();
+ }
+
+ stops
+ })
+ .collect::<Vec<_>>();
+
+ stops.dedup();
+
+ Ok(match self {
+ Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
+ stops,
+ angle: linear.angle,
+ space: linear.space,
+ relative: linear.relative,
+ anti_alias: linear.anti_alias,
+ })),
+ Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient {
+ stops,
+ center: radial.center,
+ radius: radial.radius,
+ focal_center: radial.focal_center,
+ focal_radius: radial.focal_radius,
+ space: radial.space,
+ relative: radial.relative,
+ anti_alias: radial.anti_alias,
+ })),
+ Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
+ stops,
+ angle: conic.angle,
+ center: conic.center,
+ space: conic.space,
+ relative: conic.relative,
+ anti_alias: conic.anti_alias,
+ })),
+ })
+ }
+
+ /// Returns the kind of this gradient.
+ #[func]
+ pub fn kind(&self) -> Func {
+ match self {
+ Self::Linear(_) => Self::linear_data().into(),
+ Self::Radial(_) => Self::radial_data().into(),
+ Self::Conic(_) => Self::conic_data().into(),
+ }
+ }
+
+ /// Returns the stops of this gradient.
+ #[func]
+ pub fn stops(&self) -> Vec<GradientStop> {
+ match self {
+ Self::Linear(linear) => linear
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ Self::Radial(radial) => radial
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ Self::Conic(conic) => conic
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ }
+ }
+
+ /// Returns the mixing space of this gradient.
+ #[func]
+ pub fn space(&self) -> ColorSpace {
+ match self {
+ Self::Linear(linear) => linear.space,
+ Self::Radial(radial) => radial.space,
+ Self::Conic(conic) => conic.space,
+ }
+ }
+
+ /// Returns the relative placement of this gradient.
+ #[func]
+ pub fn relative(&self) -> Smart<RelativeTo> {
+ match self {
+ Self::Linear(linear) => linear.relative,
+ Self::Radial(radial) => radial.relative,
+ Self::Conic(conic) => conic.relative,
+ }
+ }
+
+ /// Returns the angle of this gradient.
+ #[func]
+ pub fn angle(&self) -> Option<Angle> {
+ match self {
+ Self::Linear(linear) => Some(linear.angle),
+ Self::Radial(_) => None,
+ Self::Conic(conic) => Some(conic.angle),
+ }
+ }
+
+ /// Sample the gradient at a given position.
+ ///
+ /// The position is either a position along the gradient (a [ratio] between
+ /// `{0%}` and `{100%}`) or an [angle]. Any value outside of this range will
+ /// be clamped.
+ #[func]
+ pub fn sample(
+ &self,
+ /// The position at which to sample the gradient.
+ t: RatioOrAngle,
+ ) -> Color {
+ let value: f64 = t.to_ratio().get();
+
+ match self {
+ Self::Linear(linear) => sample_stops(&linear.stops, linear.space, value),
+ Self::Radial(radial) => sample_stops(&radial.stops, radial.space, value),
+ Self::Conic(conic) => sample_stops(&conic.stops, conic.space, value),
+ }
+ }
+
+ /// Samples the gradient at multiple positions at once and returns the
+ /// results as an array.
+ #[func]
+ pub fn samples(
+ &self,
+ /// The positions at which to sample the gradient.
+ #[variadic]
+ ts: Vec<RatioOrAngle>,
+ ) -> Array {
+ ts.into_iter().map(|t| self.sample(t).into_value()).collect()
+ }
+}
+
+impl Gradient {
+ /// Clones this gradient, but with a different relative placement.
+ pub fn with_relative(mut self, relative: RelativeTo) -> Self {
+ match &mut self {
+ Self::Linear(linear) => {
+ Arc::make_mut(linear).relative = Smart::Custom(relative);
+ }
+ Self::Radial(radial) => {
+ Arc::make_mut(radial).relative = Smart::Custom(relative);
+ }
+ Self::Conic(conic) => {
+ Arc::make_mut(conic).relative = Smart::Custom(relative);
+ }
+ }
+
+ self
+ }
+ /// Returns a reference to the stops of this gradient.
+ pub fn stops_ref(&self) -> &[(Color, Ratio)] {
+ match self {
+ Gradient::Linear(linear) => &linear.stops,
+ Gradient::Radial(radial) => &radial.stops,
+ Gradient::Conic(conic) => &conic.stops,
+ }
+ }
+
+ /// Samples the gradient at a given position, in the given container.
+ /// Handles the aspect ratio and angle directly.
+ pub fn sample_at(&self, (x, y): (f32, f32), (width, height): (f32, f32)) -> Color {
+ // Normalize the coordinates.
+ let (mut x, mut y) = (x / width, y / height);
+ let t = match self {
+ Self::Linear(linear) => {
+ // Aspect ratio correction.
+ let angle = Gradient::correct_aspect_ratio(
+ linear.angle,
+ Ratio::new((width / height) as f64),
+ )
+ .to_rad();
+ let (sin, cos) = angle.sin_cos();
+
+ let length = sin.abs() + cos.abs();
+ if angle > FRAC_PI_2 && angle < 3.0 * FRAC_PI_2 {
+ x = 1.0 - x;
+ }
+
+ if angle > PI {
+ y = 1.0 - y;
+ }
+
+ (x as f64 * cos.abs() + y as f64 * sin.abs()) / length
+ }
+ Self::Radial(radial) => {
+ // Source: @Enivex - https://typst.app/project/pYLeS0QyCCe8mf0pdnwoAI
+ let cr = radial.radius.get();
+ let fr = radial.focal_radius.get();
+ let z = Vec2::new(x as f64, y as f64);
+ let p = Vec2::new(radial.center.x.get(), radial.center.y.get());
+ let q =
+ Vec2::new(radial.focal_center.x.get(), radial.focal_center.y.get());
+
+ if (z - q).hypot() < fr {
+ 0.0
+ } else if (z - p).hypot() > cr {
+ 1.0
+ } else {
+ let uz = (z - q).normalize();
+ let az = (q - p).dot(uz);
+ let rho = cr.powi(2) - (q - p).hypot().powi(2);
+ let bz = (az.powi(2) + rho).sqrt() - az;
+
+ ((z - q).hypot() - fr) / (bz - fr)
+ }
+ }
+ Self::Conic(conic) => {
+ let (x, y) =
+ (x as f64 - conic.center.x.get(), y as f64 - conic.center.y.get());
+ let angle = Gradient::correct_aspect_ratio(
+ conic.angle,
+ Ratio::new((width / height) as f64),
+ );
+ ((-y.atan2(x) + PI + angle.to_rad()) % TAU) / TAU
+ }
+ };
+
+ self.sample(RatioOrAngle::Ratio(Ratio::new(t.clamp(0.0, 1.0))))
+ }
+
+ /// Does this gradient need to be anti-aliased?
+ pub fn anti_alias(&self) -> bool {
+ match self {
+ Self::Linear(linear) => linear.anti_alias,
+ Self::Radial(radial) => radial.anti_alias,
+ Self::Conic(conic) => conic.anti_alias,
+ }
+ }
+
+ /// Returns the relative placement of this gradient, handling
+ /// the special case of `auto`.
+ pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo {
+ self.relative().unwrap_or_else(|| {
+ if on_text {
+ RelativeTo::Parent
+ } else {
+ RelativeTo::Self_
+ }
+ })
+ }
+
+ /// Corrects this angle for the aspect ratio of a gradient.
+ ///
+ /// This is used specifically for gradients.
+ pub fn correct_aspect_ratio(angle: Angle, aspect_ratio: Ratio) -> Angle {
+ let rad = (angle.to_rad().rem_euclid(TAU).tan() / aspect_ratio.get()).atan();
+ let rad = match angle.quadrant() {
+ Quadrant::First => rad,
+ Quadrant::Second => rad + PI,
+ Quadrant::Third => rad + PI,
+ Quadrant::Fourth => rad + TAU,
+ };
+ Angle::rad(rad.rem_euclid(TAU))
+ }
+}
+
+impl Debug for Gradient {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Linear(v) => v.fmt(f),
+ Self::Radial(v) => v.fmt(f),
+ Self::Conic(v) => v.fmt(f),
+ }
+ }
+}
+
+impl Repr for Gradient {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Radial(radial) => radial.repr(),
+ Self::Linear(linear) => linear.repr(),
+ Self::Conic(conic) => conic.repr(),
+ }
+ }
+}
+
+/// A gradient that interpolates between two colors along an axis.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct LinearGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The direction of this gradient.
+ pub angle: Angle,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for LinearGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.linear(");
+
+ let angle = self.angle.to_rad().rem_euclid(TAU);
+ if angle.abs() < f64::EPSILON {
+ // Default value, do nothing
+ } else if (angle - FRAC_PI_2).abs() < f64::EPSILON {
+ r.push_str("dir: rtl, ");
+ } else if (angle - PI).abs() < f64::EPSILON {
+ r.push_str("dir: ttb, ");
+ } else if (angle - 3.0 * FRAC_PI_2).abs() < f64::EPSILON {
+ r.push_str("dir: btt, ");
+ } else {
+ r.push_str("angle: ");
+ r.push_str(&self.angle.repr());
+ r.push_str(", ");
+ }
+
+ if self.space != ColorSpace::Oklab {
+ r.push_str("space: ");
+ r.push_str(&self.space.into_value().repr());
+ r.push_str(", ");
+ }
+
+ if self.relative.is_custom() {
+ r.push_str("relative: ");
+ r.push_str(&self.relative.into_value().repr());
+ r.push_str(", ");
+ }
+
+ for (i, (color, offset)) in self.stops.iter().enumerate() {
+ r.push('(');
+ r.push_str(&color.repr());
+ r.push_str(", ");
+ r.push_str(&offset.repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// A gradient that interpolates between two colors along a circle.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct RadialGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The center of last circle of this gradient.
+ pub center: Axes<Ratio>,
+ /// The radius of last circle of this gradient.
+ pub radius: Ratio,
+ /// The center of first circle of this gradient.
+ pub focal_center: Axes<Ratio>,
+ /// The radius of first circle of this gradient.
+ pub focal_radius: Ratio,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for RadialGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.radial(");
+
+ if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) {
+ r.push_str("center: (");
+ r.push_str(&self.center.x.repr());
+ r.push_str(", ");
+ r.push_str(&self.center.y.repr());
+ r.push_str("), ");
+ }
+
+ if self.radius != Ratio::new(0.5) {
+ r.push_str("radius: ");
+ r.push_str(&self.radius.repr());
+ r.push_str(", ");
+ }
+
+ if self.focal_center != self.center {
+ r.push_str("focal-center: (");
+ r.push_str(&self.focal_center.x.repr());
+ r.push_str(", ");
+ r.push_str(&self.focal_center.y.repr());
+ r.push_str("), ");
+ }
+
+ if self.focal_radius != Ratio::zero() {
+ r.push_str("focal-radius: ");
+ r.push_str(&self.focal_radius.repr());
+ r.push_str(", ");
+ }
+
+ if self.space != ColorSpace::Oklab {
+ r.push_str("space: ");
+ r.push_str(&self.space.into_value().repr());
+ r.push_str(", ");
+ }
+
+ if self.relative.is_custom() {
+ r.push_str("relative: ");
+ r.push_str(&self.relative.into_value().repr());
+ r.push_str(", ");
+ }
+
+ for (i, (color, offset)) in self.stops.iter().enumerate() {
+ r.push('(');
+ r.push_str(&color.repr());
+ r.push_str(", ");
+ r.push_str(&offset.repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// A gradient that interpolates between two colors radially
+/// around a center point.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct ConicGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The direction of this gradient.
+ pub angle: Angle,
+ /// The center of last circle of this gradient.
+ pub center: Axes<Ratio>,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for ConicGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.conic(");
+
+ let angle = self.angle.to_rad().rem_euclid(TAU);
+ if angle.abs() > f64::EPSILON {
+ r.push_str("angle: ");
+ r.push_str(&self.angle.repr());
+ r.push_str(", ");
+ }
+
+ if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) {
+ r.push_str("center: (");
+ r.push_str(&self.center.x.repr());
+ r.push_str(", ");
+ r.push_str(&self.center.y.repr());
+ r.push_str("), ");
+ }
+
+ if self.space != ColorSpace::Oklab {
+ r.push_str("space: ");
+ r.push_str(&self.space.into_value().repr());
+ r.push_str(", ");
+ }
+
+ if self.relative.is_custom() {
+ r.push_str("relative: ");
+ r.push_str(&self.relative.into_value().repr());
+ r.push_str(", ");
+ }
+
+ for (i, (color, offset)) in self.stops.iter().enumerate() {
+ r.push('(');
+ r.push_str(&color.repr());
+ r.push_str(", ");
+ r.push_str(&Angle::deg(offset.get() * 360.0).repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// What is the gradient relative to.
+#[derive(Cast, Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum RelativeTo {
+ /// The gradient is relative to itself (its own bounding box).
+ Self_,
+ /// The gradient is relative to its parent (the parent's bounding box).
+ Parent,
+}
+
+/// A color stop.
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub struct GradientStop {
+ /// The color for this stop.
+ pub color: Color,
+ /// The offset of the stop along the gradient.
+ pub offset: Option<Ratio>,
+}
+
+impl GradientStop {
+ /// Create a new stop from a `color` and an `offset`.
+ pub fn new(color: Color, offset: Ratio) -> Self {
+ Self { color, offset: Some(offset) }
+ }
+}
+
+cast! {
+ GradientStop,
+ self => if let Some(offset) = self.offset {
+ array![self.color.into_value(), offset].into_value()
+ } else {
+ self.color.into_value()
+ },
+ color: Color => Self { color, offset: None },
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Self {
+ color: a.cast()?,
+ offset: Some(b.cast()?)
+ },
+ _ => Err("a color stop must contain exactly two entries")?,
+ }
+ }
+}
+
+/// A ratio or an angle.
+#[derive(Copy, Clone, PartialEq, Eq, Hash)]
+pub enum RatioOrAngle {
+ Ratio(Ratio),
+ Angle(Angle),
+}
+
+impl RatioOrAngle {
+ pub fn to_ratio(self) -> Ratio {
+ match self {
+ Self::Ratio(ratio) => ratio,
+ Self::Angle(angle) => Ratio::new(angle.to_rad().rem_euclid(TAU) / TAU),
+ }
+ .clamp(Ratio::zero(), Ratio::one())
+ }
+}
+
+cast! {
+ RatioOrAngle,
+ self => match self {
+ Self::Ratio(ratio) => ratio.into_value(),
+ Self::Angle(angle) => angle.into_value(),
+ },
+ ratio: Ratio => Self::Ratio(ratio),
+ angle: Angle => Self::Angle(angle),
+}
+
+/// Pre-processes the stops, checking that they are valid and computing the
+/// offsets if necessary.
+///
+/// Returns an error if the stops are invalid.
+///
+/// This is split into its own function because it is used by all of the
+/// different gradient types.
+#[comemo::memoize]
+fn process_stops(stops: &[Spanned<GradientStop>]) -> SourceResult<Vec<(Color, Ratio)>> {
+ let has_offset = stops.iter().any(|stop| stop.v.offset.is_some());
+ if has_offset {
+ let mut last_stop = f64::NEG_INFINITY;
+ for Spanned { v: stop, span } in stops.iter() {
+ let Some(stop) = stop.offset else {
+ bail!(
+ *span, "either all stops must have an offset or none of them can";
+ hint: "try adding an offset to all stops"
+ );
+ };
+
+ if stop.get() < last_stop {
+ bail!(*span, "offsets must be in strictly monotonic order");
+ }
+
+ last_stop = stop.get();
+ }
+
+ let out = stops
+ .iter()
+ .map(|Spanned { v: GradientStop { color, offset }, span }| {
+ if offset.unwrap().get() > 1.0 || offset.unwrap().get() < 0.0 {
+ bail!(*span, "offset must be between 0 and 1");
+ }
+ Ok((*color, offset.unwrap()))
+ })
+ .collect::<SourceResult<Vec<_>>>()?;
+
+ if out[0].1 != Ratio::zero() {
+ bail!(
+ stops[0].span,
+ "first stop must have an offset of 0";
+ hint: "try setting this stop to `0%`"
+ );
+ }
+
+ if out[out.len() - 1].1 != Ratio::one() {
+ bail!(
+ stops[out.len() - 1].span,
+ "last stop must have an offset of 100%";
+ hint: "try setting this stop to `100%`"
+ );
+ }
+
+ return Ok(out);
+ }
+
+ Ok(stops
+ .iter()
+ .enumerate()
+ .map(|(i, stop)| {
+ let offset = i as f64 / (stops.len() - 1) as f64;
+ (stop.v.color, Ratio::new(offset))
+ })
+ .collect())
+}
+
+/// Sample the stops at a given position.
+fn sample_stops(stops: &[(Color, Ratio)], mixing_space: ColorSpace, t: f64) -> Color {
+ let t = t.clamp(0.0, 1.0);
+ let mut low = 0;
+ let mut high = stops.len();
+
+ while low < high {
+ let mid = (low + high) / 2;
+ if stops[mid].1.get() < t {
+ low = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+
+ if low == 0 {
+ low = 1;
+ }
+
+ let (col_0, pos_0) = stops[low - 1];
+ let (col_1, pos_1) = stops[low];
+ let t = (t - pos_0.get()) / (pos_1.get() - pos_0.get());
+
+ Color::mix_iter(
+ [WeightedColor::new(col_0, 1.0 - t), WeightedColor::new(col_1, t)],
+ mixing_space,
+ )
+ .unwrap()
+}
diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs
new file mode 100644
index 00000000..359db252
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/mod.rs
@@ -0,0 +1,360 @@
+//! Image handling.
+
+mod raster;
+mod svg;
+
+pub use self::raster::{RasterFormat, RasterImage};
+pub use self::svg::SvgImage;
+
+use std::fmt::{self, Debug, Formatter};
+use std::sync::Arc;
+
+use comemo::Tracked;
+use ecow::EcoString;
+use typst_syntax::{Span, Spanned};
+use typst_utils::LazyHash;
+
+use crate::diag::{At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, func, scope, Bytes, Cast, Content, NativeElement, Packed, Show, Smart,
+ StyleChain,
+};
+use crate::layout::{BlockElem, Length, Rel, Sizing};
+use crate::loading::Readable;
+use crate::model::Figurable;
+use crate::text::LocalName;
+use crate::World;
+
+/// A raster or vector graphic.
+///
+/// You can wrap the image in a [`figure`] to give it a number and caption.
+///
+/// Like most elements, images are _block-level_ by default and thus do not
+/// integrate themselves into adjacent paragraphs. To force an image to become
+/// inline, put it into a [`box`].
+///
+/// # Example
+/// ```example
+/// #figure(
+/// image("molecular.jpg", width: 80%),
+/// caption: [
+/// A step in the molecular testing
+/// pipeline of our lab.
+/// ],
+/// )
+/// ```
+#[elem(scope, Show, LocalName, Figurable)]
+pub struct ImageElem {
+ /// Path to an image file
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ #[required]
+ #[parse(
+ let Spanned { v: path, span } =
+ args.expect::<Spanned<EcoString>>("path to image file")?;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ path
+ )]
+ #[borrowed]
+ pub path: EcoString,
+
+ /// The raw file data.
+ #[internal]
+ #[required]
+ #[parse(Readable::Bytes(data))]
+ pub data: Readable,
+
+ /// The image's format. Detected automatically by default.
+ ///
+ /// Supported formats are PNG, JPEG, GIF, and SVG. Using a PDF as an image
+ /// is [not currently supported](https://github.com/typst/typst/issues/145).
+ pub format: Smart<ImageFormat>,
+
+ /// The width of the image.
+ pub width: Smart<Rel<Length>>,
+
+ /// The height of the image.
+ pub height: Sizing,
+
+ /// A text describing the image.
+ pub alt: Option<EcoString>,
+
+ /// How the image should adjust itself to a given area (the area is defined
+ /// by the `width` and `height` fields). Note that `fit` doesn't visually
+ /// change anything if the area's aspect ratio is the same as the image's
+ /// one.
+ ///
+ /// ```example
+ /// #set page(width: 300pt, height: 50pt, margin: 10pt)
+ /// #image("tiger.jpg", width: 100%, fit: "cover")
+ /// #image("tiger.jpg", width: 100%, fit: "contain")
+ /// #image("tiger.jpg", width: 100%, fit: "stretch")
+ /// ```
+ #[default(ImageFit::Cover)]
+ pub fit: ImageFit,
+}
+
+#[scope]
+impl ImageElem {
+ /// Decode a raster or vector graphic from bytes or a string.
+ ///
+ /// ```example
+ /// #let original = read("diagram.svg")
+ /// #let changed = original.replace(
+ /// "#2B80FF", // blue
+ /// green.to-hex(),
+ /// )
+ ///
+ /// #image.decode(original)
+ /// #image.decode(changed)
+ /// ```
+ #[func(title = "Decode Image")]
+ pub fn decode(
+ /// The call span of this function.
+ span: Span,
+ /// The data to decode as an image. Can be a string for SVGs.
+ data: Readable,
+ /// The image's format. Detected automatically by default.
+ #[named]
+ format: Option<Smart<ImageFormat>>,
+ /// The width of the image.
+ #[named]
+ width: Option<Smart<Rel<Length>>>,
+ /// The height of the image.
+ #[named]
+ height: Option<Sizing>,
+ /// A text describing the image.
+ #[named]
+ alt: Option<Option<EcoString>>,
+ /// How the image should adjust itself to a given area.
+ #[named]
+ fit: Option<ImageFit>,
+ ) -> StrResult<Content> {
+ let mut elem = ImageElem::new(EcoString::new(), data);
+ if let Some(format) = format {
+ elem.push_format(format);
+ }
+ if let Some(width) = width {
+ elem.push_width(width);
+ }
+ if let Some(height) = height {
+ elem.push_height(height);
+ }
+ if let Some(alt) = alt {
+ elem.push_alt(alt);
+ }
+ if let Some(fit) = fit {
+ elem.push_fit(fit);
+ }
+ Ok(elem.pack().spanned(span))
+ }
+}
+
+impl Show for Packed<ImageElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_image)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+impl LocalName for Packed<ImageElem> {
+ const KEY: &'static str = "figure";
+}
+
+impl Figurable for Packed<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 (preserves aspect ratio by
+ /// cropping the image only horizontally or vertically). This is the
+ /// default.
+ Cover,
+ /// The image should be fully contained in the area (preserves aspect
+ /// ratio; doesn't crop the image; one dimension can be narrower than
+ /// specified).
+ Contain,
+ /// The image should be stretched so that it exactly fills the area, even if
+ /// this means that the image will be distorted (doesn't preserve aspect
+ /// ratio and doesn't crop the image).
+ Stretch,
+}
+
+/// A loaded raster or vector image.
+///
+/// Values of this type are cheap to clone and hash.
+#[derive(Clone, Hash, Eq, PartialEq)]
+pub struct Image(Arc<LazyHash<Repr>>);
+
+/// The internal representation.
+#[derive(Hash)]
+struct Repr {
+ /// The raw, undecoded image data.
+ kind: ImageKind,
+ /// A text describing the image.
+ alt: Option<EcoString>,
+}
+
+/// A kind of image.
+#[derive(Hash)]
+pub enum ImageKind {
+ /// A raster image.
+ Raster(RasterImage),
+ /// An SVG image.
+ Svg(SvgImage),
+}
+
+impl Image {
+ /// When scaling an image to it's natural size, we default to this DPI
+ /// if the image doesn't contain DPI metadata.
+ pub const DEFAULT_DPI: f64 = 72.0;
+
+ /// Should always be the same as the default DPI used by usvg.
+ pub const USVG_DEFAULT_DPI: f64 = 96.0;
+
+ /// Create an image from a buffer and a format.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load image")]
+ pub fn new(
+ data: Bytes,
+ format: ImageFormat,
+ alt: Option<EcoString>,
+ ) -> StrResult<Image> {
+ let kind = match format {
+ ImageFormat::Raster(format) => {
+ ImageKind::Raster(RasterImage::new(data, format)?)
+ }
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ ImageKind::Svg(SvgImage::new(data)?)
+ }
+ };
+
+ Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt }))))
+ }
+
+ /// Create a possibly font-dependent image from a buffer and a format.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load image")]
+ pub fn with_fonts(
+ data: Bytes,
+ format: ImageFormat,
+ alt: Option<EcoString>,
+ world: Tracked<dyn World + '_>,
+ families: &[&str],
+ ) -> StrResult<Image> {
+ let kind = match format {
+ ImageFormat::Raster(format) => {
+ ImageKind::Raster(RasterImage::new(data, format)?)
+ }
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ ImageKind::Svg(SvgImage::with_fonts(data, world, families)?)
+ }
+ };
+
+ Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt }))))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.data(),
+ ImageKind::Svg(svg) => svg.data(),
+ }
+ }
+
+ /// The format of the image.
+ pub fn format(&self) -> ImageFormat {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.format().into(),
+ ImageKind::Svg(_) => VectorFormat::Svg.into(),
+ }
+ }
+
+ /// The width of the image in pixels.
+ pub fn width(&self) -> f64 {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.width() as f64,
+ ImageKind::Svg(svg) => svg.width(),
+ }
+ }
+
+ /// The height of the image in pixels.
+ pub fn height(&self) -> f64 {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.height() as f64,
+ ImageKind::Svg(svg) => svg.height(),
+ }
+ }
+
+ /// The image's pixel density in pixels per inch, if known.
+ pub fn dpi(&self) -> Option<f64> {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.dpi(),
+ ImageKind::Svg(_) => Some(Image::USVG_DEFAULT_DPI),
+ }
+ }
+
+ /// A text describing the image.
+ pub fn alt(&self) -> Option<&str> {
+ self.0.alt.as_deref()
+ }
+
+ /// The decoded image.
+ pub fn kind(&self) -> &ImageKind {
+ &self.0.kind
+ }
+}
+
+impl Debug for Image {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.debug_struct("Image")
+ .field("format", &self.format())
+ .field("width", &self.width())
+ .field("height", &self.height())
+ .field("alt", &self.alt())
+ .finish()
+ }
+}
+
+/// A raster or vector image format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ImageFormat {
+ /// A raster graphics format.
+ Raster(RasterFormat),
+ /// A vector graphics format.
+ Vector(VectorFormat),
+}
+
+/// A vector graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum VectorFormat {
+ /// The vector graphics format of the web.
+ Svg,
+}
+
+impl From<RasterFormat> for ImageFormat {
+ fn from(format: RasterFormat) -> Self {
+ Self::Raster(format)
+ }
+}
+
+impl From<VectorFormat> for ImageFormat {
+ fn from(format: VectorFormat) -> Self {
+ Self::Vector(format)
+ }
+}
+
+cast! {
+ ImageFormat,
+ self => match self {
+ Self::Raster(v) => v.into_value(),
+ Self::Vector(v) => v.into_value()
+ },
+ v: RasterFormat => Self::Raster(v),
+ v: VectorFormat => Self::Vector(v),
+}
diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs
new file mode 100644
index 00000000..829826c7
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/raster.rs
@@ -0,0 +1,286 @@
+use std::cmp::Ordering;
+use std::hash::{Hash, Hasher};
+use std::io;
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use image::codecs::gif::GifDecoder;
+use image::codecs::jpeg::JpegDecoder;
+use image::codecs::png::PngDecoder;
+use image::{guess_format, DynamicImage, ImageDecoder, ImageResult, Limits};
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{Bytes, Cast};
+
+/// A decoded raster image.
+#[derive(Clone, Hash)]
+pub struct RasterImage(Arc<Repr>);
+
+/// The internal representation.
+struct Repr {
+ data: Bytes,
+ format: RasterFormat,
+ dynamic: image::DynamicImage,
+ icc: Option<Vec<u8>>,
+ dpi: Option<f64>,
+}
+
+impl RasterImage {
+ /// Decode a raster image.
+ #[comemo::memoize]
+ pub fn new(data: Bytes, format: RasterFormat) -> StrResult<RasterImage> {
+ fn decode_with<T: ImageDecoder>(
+ decoder: ImageResult<T>,
+ ) -> ImageResult<(image::DynamicImage, Option<Vec<u8>>)> {
+ let mut decoder = decoder?;
+ let icc = decoder.icc_profile().ok().flatten().filter(|icc| !icc.is_empty());
+ decoder.set_limits(Limits::default())?;
+ let dynamic = image::DynamicImage::from_decoder(decoder)?;
+ Ok((dynamic, icc))
+ }
+
+ let cursor = io::Cursor::new(&data);
+ let (mut dynamic, icc) = match format {
+ RasterFormat::Jpg => decode_with(JpegDecoder::new(cursor)),
+ RasterFormat::Png => decode_with(PngDecoder::new(cursor)),
+ RasterFormat::Gif => decode_with(GifDecoder::new(cursor)),
+ }
+ .map_err(format_image_error)?;
+
+ let exif = exif::Reader::new()
+ .read_from_container(&mut std::io::Cursor::new(&data))
+ .ok();
+
+ // Apply rotation from EXIF metadata.
+ if let Some(rotation) = exif.as_ref().and_then(exif_rotation) {
+ apply_rotation(&mut dynamic, rotation);
+ }
+
+ // Extract pixel density.
+ let dpi = determine_dpi(&data, exif.as_ref());
+
+ Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi })))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The image's format.
+ pub fn format(&self) -> RasterFormat {
+ self.0.format
+ }
+
+ /// The image's pixel width.
+ pub fn width(&self) -> u32 {
+ self.dynamic().width()
+ }
+
+ /// The image's pixel height.
+ pub fn height(&self) -> u32 {
+ self.dynamic().height()
+ }
+
+ /// The image's pixel density in pixels per inch, if known.
+ pub fn dpi(&self) -> Option<f64> {
+ self.0.dpi
+ }
+
+ /// Access the underlying dynamic image.
+ pub fn dynamic(&self) -> &image::DynamicImage {
+ &self.0.dynamic
+ }
+
+ /// Access the ICC profile, if any.
+ pub fn icc(&self) -> Option<&[u8]> {
+ self.0.icc.as_deref()
+ }
+}
+
+impl Hash for Repr {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ // The image is fully defined by data and format.
+ self.data.hash(state);
+ self.format.hash(state);
+ }
+}
+
+/// A raster graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum RasterFormat {
+ /// Raster format for illustrations and transparent graphics.
+ Png,
+ /// Lossy raster format suitable for photos.
+ Jpg,
+ /// Raster format that is typically used for short animated clips.
+ Gif,
+}
+
+impl RasterFormat {
+ /// Try to detect the format of data in a buffer.
+ pub fn detect(data: &[u8]) -> Option<Self> {
+ guess_format(data).ok().and_then(|format| format.try_into().ok())
+ }
+}
+
+impl From<RasterFormat> for image::ImageFormat {
+ fn from(format: RasterFormat) -> Self {
+ match format {
+ RasterFormat::Png => image::ImageFormat::Png,
+ RasterFormat::Jpg => image::ImageFormat::Jpeg,
+ RasterFormat::Gif => image::ImageFormat::Gif,
+ }
+ }
+}
+
+impl TryFrom<image::ImageFormat> for RasterFormat {
+ type Error = EcoString;
+
+ fn try_from(format: image::ImageFormat) -> StrResult<Self> {
+ Ok(match format {
+ image::ImageFormat::Png => RasterFormat::Png,
+ image::ImageFormat::Jpeg => RasterFormat::Jpg,
+ image::ImageFormat::Gif => RasterFormat::Gif,
+ _ => bail!("Format not yet supported."),
+ })
+ }
+}
+
+/// Try to get the rotation from the EXIF metadata.
+fn exif_rotation(exif: &exif::Exif) -> Option<u32> {
+ exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?
+ .value
+ .get_uint(0)
+}
+
+/// Apply an EXIF rotation to a dynamic image.
+fn apply_rotation(image: &mut DynamicImage, rotation: u32) {
+ use image::imageops as ops;
+ match rotation {
+ 2 => ops::flip_horizontal_in_place(image),
+ 3 => ops::rotate180_in_place(image),
+ 4 => ops::flip_vertical_in_place(image),
+ 5 => {
+ ops::flip_horizontal_in_place(image);
+ *image = image.rotate270();
+ }
+ 6 => *image = image.rotate90(),
+ 7 => {
+ ops::flip_horizontal_in_place(image);
+ *image = image.rotate90();
+ }
+ 8 => *image = image.rotate270(),
+ _ => {}
+ }
+}
+
+/// Try to determine the DPI (dots per inch) of the image.
+fn determine_dpi(data: &[u8], exif: Option<&exif::Exif>) -> Option<f64> {
+ // Try to extract the DPI from the EXIF metadata. If that doesn't yield
+ // anything, fall back to specialized procedures for extracting JPEG or PNG
+ // DPI metadata. GIF does not have any.
+ exif.and_then(exif_dpi)
+ .or_else(|| jpeg_dpi(data))
+ .or_else(|| png_dpi(data))
+}
+
+/// Try to get the DPI from the EXIF metadata.
+fn exif_dpi(exif: &exif::Exif) -> Option<f64> {
+ let axis = |tag| {
+ let dpi = exif.get_field(tag, exif::In::PRIMARY)?;
+ let exif::Value::Rational(rational) = &dpi.value else { return None };
+ Some(rational.first()?.to_f64())
+ };
+
+ [axis(exif::Tag::XResolution), axis(exif::Tag::YResolution)]
+ .into_iter()
+ .flatten()
+ .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
+}
+
+/// Tries to extract the DPI from raw JPEG data (by inspecting the JFIF APP0
+/// section).
+fn jpeg_dpi(data: &[u8]) -> Option<f64> {
+ let validate_at = |index: usize, expect: &[u8]| -> Option<()> {
+ data.get(index..)?.starts_with(expect).then_some(())
+ };
+ let u16_at = |index: usize| -> Option<u16> {
+ data.get(index..index + 2)?.try_into().ok().map(u16::from_be_bytes)
+ };
+
+ validate_at(0, b"\xFF\xD8\xFF\xE0\0")?;
+ validate_at(6, b"JFIF\0")?;
+ validate_at(11, b"\x01")?;
+
+ let len = u16_at(4)?;
+ if len < 16 {
+ return None;
+ }
+
+ let units = *data.get(13)?;
+ let x = u16_at(14)?;
+ let y = u16_at(16)?;
+ let dpu = x.max(y) as f64;
+
+ Some(match units {
+ 1 => dpu, // already inches
+ 2 => dpu * 2.54, // cm -> inches
+ _ => return None,
+ })
+}
+
+/// Tries to extract the DPI from raw PNG data.
+fn png_dpi(mut data: &[u8]) -> Option<f64> {
+ let mut decoder = png::StreamingDecoder::new();
+ let dims = loop {
+ let (consumed, event) = decoder.update(data, &mut Vec::new()).ok()?;
+ match event {
+ png::Decoded::PixelDimensions(dims) => break dims,
+ // Bail as soon as there is anything data-like.
+ png::Decoded::ChunkBegin(_, png::chunk::IDAT)
+ | png::Decoded::ImageData
+ | png::Decoded::ImageEnd => return None,
+ _ => {}
+ }
+ data = data.get(consumed..)?;
+ if consumed == 0 {
+ return None;
+ }
+ };
+
+ let dpu = dims.xppu.max(dims.yppu) as f64;
+ match dims.unit {
+ png::Unit::Meter => Some(dpu * 0.0254), // meter -> inches
+ png::Unit::Unspecified => None,
+ }
+}
+
+/// Format the user-facing raster graphic decoding error message.
+fn format_image_error(error: image::ImageError) -> EcoString {
+ match error {
+ image::ImageError::Limits(_) => "file is too large".into(),
+ err => eco_format!("failed to decode image ({err})"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{RasterFormat, RasterImage};
+ use crate::foundations::Bytes;
+
+ #[test]
+ fn test_image_dpi() {
+ #[track_caller]
+ fn test(path: &str, format: RasterFormat, dpi: f64) {
+ let data = typst_dev_assets::get(path).unwrap();
+ let bytes = Bytes::from_static(data);
+ let image = RasterImage::new(bytes, format).unwrap();
+ assert_eq!(image.dpi().map(f64::round), Some(dpi));
+ }
+
+ test("images/f2t.jpg", RasterFormat::Jpg, 220.0);
+ test("images/tiger.jpg", RasterFormat::Jpg, 72.0);
+ test("images/graph.png", RasterFormat::Png, 144.0);
+ }
+}
diff --git a/crates/typst-library/src/visualize/image/svg.rs b/crates/typst-library/src/visualize/image/svg.rs
new file mode 100644
index 00000000..f7a498a8
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/svg.rs
@@ -0,0 +1,289 @@
+use std::collections::HashMap;
+use std::hash::{Hash, Hasher};
+use std::sync::{Arc, Mutex};
+
+use comemo::Tracked;
+use ecow::EcoString;
+use siphasher::sip128::{Hasher128, SipHasher13};
+
+use crate::diag::{format_xml_like_error, StrResult};
+use crate::foundations::Bytes;
+use crate::layout::Axes;
+use crate::text::{
+ Font, FontBook, FontFlags, FontStretch, FontStyle, FontVariant, FontWeight,
+};
+use crate::World;
+
+/// A decoded SVG.
+#[derive(Clone, Hash)]
+pub struct SvgImage(Arc<Repr>);
+
+/// The internal representation.
+struct Repr {
+ data: Bytes,
+ size: Axes<f64>,
+ font_hash: u128,
+ tree: usvg::Tree,
+}
+
+impl SvgImage {
+ /// Decode an SVG image without fonts.
+ #[comemo::memoize]
+ pub fn new(data: Bytes) -> StrResult<SvgImage> {
+ let tree =
+ usvg::Tree::from_data(&data, &base_options()).map_err(format_usvg_error)?;
+ Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash: 0, tree })))
+ }
+
+ /// Decode an SVG image with access to fonts.
+ #[comemo::memoize]
+ pub fn with_fonts(
+ data: Bytes,
+ world: Tracked<dyn World + '_>,
+ families: &[&str],
+ ) -> StrResult<SvgImage> {
+ let book = world.book();
+ let resolver = Mutex::new(FontResolver::new(world, book, families));
+ let tree = usvg::Tree::from_data(
+ &data,
+ &usvg::Options {
+ font_resolver: usvg::FontResolver {
+ select_font: Box::new(|font, db| {
+ resolver.lock().unwrap().select_font(font, db)
+ }),
+ select_fallback: Box::new(|c, exclude_fonts, db| {
+ resolver.lock().unwrap().select_fallback(c, exclude_fonts, db)
+ }),
+ },
+ ..base_options()
+ },
+ )
+ .map_err(format_usvg_error)?;
+ let font_hash = resolver.into_inner().unwrap().finish();
+ Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash, tree })))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The SVG's width in pixels.
+ pub fn width(&self) -> f64 {
+ self.0.size.x
+ }
+
+ /// The SVG's height in pixels.
+ pub fn height(&self) -> f64 {
+ self.0.size.y
+ }
+
+ /// Accesses the usvg tree.
+ pub fn tree(&self) -> &usvg::Tree {
+ &self.0.tree
+ }
+}
+
+impl Hash for Repr {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ // An SVG might contain fonts, which must be incorporated into the hash.
+ // We can't hash a usvg tree directly, but the raw SVG data + a hash of
+ // all used fonts gives us something similar.
+ self.data.hash(state);
+ self.font_hash.hash(state);
+ }
+}
+
+/// The base conversion options, to be extended with font-related options
+/// because those can change across the document.
+fn base_options() -> usvg::Options<'static> {
+ usvg::Options {
+ // Disable usvg's default to "Times New Roman".
+ font_family: String::new(),
+
+ // We don't override the DPI here, because we already
+ // force the image into the corresponding DPI by setting
+ // the width and height. Changing the DPI only trips up
+ // the logic in `resvg`.
+
+ // Override usvg's resource loading defaults.
+ resources_dir: None,
+ image_href_resolver: usvg::ImageHrefResolver {
+ resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
+ resolve_string: Box::new(|_, _| None),
+ },
+
+ ..Default::default()
+ }
+}
+
+/// The pixel size of an SVG.
+fn tree_size(tree: &usvg::Tree) -> Axes<f64> {
+ Axes::new(tree.size().width() as f64, tree.size().height() as f64)
+}
+
+/// Format the user-facing SVG decoding error message.
+fn format_usvg_error(error: usvg::Error) -> EcoString {
+ match error {
+ usvg::Error::NotAnUtf8Str => "file is not valid utf-8".into(),
+ usvg::Error::MalformedGZip => "file is not compressed correctly".into(),
+ usvg::Error::ElementsLimitReached => "file is too large".into(),
+ usvg::Error::InvalidSize => {
+ "failed to parse SVG (width, height, or viewbox is invalid)".into()
+ }
+ usvg::Error::ParsingFailed(error) => format_xml_like_error("SVG", error),
+ }
+}
+
+/// Provides Typst's fonts to usvg.
+struct FontResolver<'a> {
+ /// Typst's font book.
+ book: &'a FontBook,
+ /// The world we use to load fonts.
+ world: Tracked<'a, dyn World + 'a>,
+ /// The active list of font families at the location of the SVG.
+ families: &'a [&'a str],
+ /// A mapping from Typst font indices to fontdb IDs.
+ to_id: HashMap<usize, Option<fontdb::ID>>,
+ /// The reverse mapping.
+ from_id: HashMap<fontdb::ID, Font>,
+ /// Accumulates a hash of all used fonts.
+ hasher: SipHasher13,
+}
+
+impl<'a> FontResolver<'a> {
+ /// Create a new font provider.
+ fn new(
+ world: Tracked<'a, dyn World + 'a>,
+ book: &'a FontBook,
+ families: &'a [&'a str],
+ ) -> Self {
+ Self {
+ book,
+ world,
+ families,
+ to_id: HashMap::new(),
+ from_id: HashMap::new(),
+ hasher: SipHasher13::new(),
+ }
+ }
+
+ /// Returns a hash of all used fonts.
+ fn finish(self) -> u128 {
+ self.hasher.finish128().as_u128()
+ }
+}
+
+impl FontResolver<'_> {
+ /// Select a font.
+ fn select_font(
+ &mut self,
+ font: &usvg::Font,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ let variant = FontVariant {
+ style: font.style().into(),
+ weight: FontWeight::from_number(font.weight()),
+ stretch: font.stretch().into(),
+ };
+
+ // Find a family that is available.
+ font.families()
+ .iter()
+ .filter_map(|family| match family {
+ usvg::FontFamily::Named(named) => Some(named.as_str()),
+ // We don't support generic families at the moment.
+ _ => None,
+ })
+ .chain(self.families.iter().copied())
+ .filter_map(|named| self.book.select(&named.to_lowercase(), variant))
+ .find_map(|index| self.get_or_load(index, db))
+ }
+
+ /// Select a fallback font.
+ fn select_fallback(
+ &mut self,
+ c: char,
+ exclude_fonts: &[fontdb::ID],
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ // Get the font info of the originally selected font.
+ let like = exclude_fonts
+ .first()
+ .and_then(|first| self.from_id.get(first))
+ .map(|font| font.info());
+
+ // usvg doesn't provide a variant in the fallback handler, but
+ // `exclude_fonts` is actually never empty in practice. Still, we
+ // prefer to fall back to the default variant rather than panicking
+ // in case that changes in the future.
+ let variant = like.map(|info| info.variant).unwrap_or_default();
+
+ // Select the font.
+ let index =
+ self.book.select_fallback(like, variant, c.encode_utf8(&mut [0; 4]))?;
+
+ self.get_or_load(index, db)
+ }
+
+ /// Tries to retrieve the ID for the index or loads the font, allocating
+ /// a new ID.
+ fn get_or_load(
+ &mut self,
+ index: usize,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ self.to_id
+ .get(&index)
+ .copied()
+ .unwrap_or_else(|| self.load(index, db))
+ }
+
+ /// Tries to load the font with the given index in the font book into the
+ /// database and returns its ID.
+ fn load(
+ &mut self,
+ index: usize,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ let font = self.world.font(index)?;
+ let info = font.info();
+ let variant = info.variant;
+ let id = Arc::make_mut(db).push_face_info(fontdb::FaceInfo {
+ id: fontdb::ID::dummy(),
+ source: fontdb::Source::Binary(Arc::new(font.data().clone())),
+ index: font.index(),
+ families: vec![(
+ info.family.clone(),
+ ttf_parser::Language::English_UnitedStates,
+ )],
+ post_script_name: String::new(),
+ style: match variant.style {
+ FontStyle::Normal => fontdb::Style::Normal,
+ FontStyle::Italic => fontdb::Style::Italic,
+ FontStyle::Oblique => fontdb::Style::Oblique,
+ },
+ weight: fontdb::Weight(variant.weight.to_number()),
+ stretch: match variant.stretch.round() {
+ FontStretch::ULTRA_CONDENSED => ttf_parser::Width::UltraCondensed,
+ FontStretch::EXTRA_CONDENSED => ttf_parser::Width::ExtraCondensed,
+ FontStretch::CONDENSED => ttf_parser::Width::Condensed,
+ FontStretch::SEMI_CONDENSED => ttf_parser::Width::SemiCondensed,
+ FontStretch::NORMAL => ttf_parser::Width::Normal,
+ FontStretch::SEMI_EXPANDED => ttf_parser::Width::SemiExpanded,
+ FontStretch::EXPANDED => ttf_parser::Width::Expanded,
+ FontStretch::EXTRA_EXPANDED => ttf_parser::Width::ExtraExpanded,
+ FontStretch::ULTRA_EXPANDED => ttf_parser::Width::UltraExpanded,
+ _ => unreachable!(),
+ },
+ monospaced: info.flags.contains(FontFlags::MONOSPACE),
+ });
+
+ font.hash(&mut self.hasher);
+
+ self.to_id.insert(index, Some(id));
+ self.from_id.insert(id, font);
+
+ Some(id)
+ }
+}
diff --git a/crates/typst-library/src/visualize/line.rs b/crates/typst-library/src/visualize/line.rs
new file mode 100644
index 00000000..d9ddab84
--- /dev/null
+++ b/crates/typst-library/src/visualize/line.rs
@@ -0,0 +1,64 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, NativeElement, Packed, Show, StyleChain};
+use crate::layout::{Abs, Angle, Axes, BlockElem, Length, Rel};
+use crate::visualize::Stroke;
+
+/// A line from one point to another.
+///
+/// # Example
+/// ```example
+/// #set page(height: 100pt)
+///
+/// #line(length: 100%)
+/// #line(end: (50%, 50%))
+/// #line(
+/// length: 4cm,
+/// stroke: 2pt + maroon,
+/// )
+/// ```
+#[elem(Show)]
+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.
+ ///
+ /// ```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: Stroke,
+}
+
+impl Show for Packed<LineElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_line)
+ .pack()
+ .spanned(self.span()))
+ }
+}
diff --git a/crates/typst-library/src/visualize/mod.rs b/crates/typst-library/src/visualize/mod.rs
new file mode 100644
index 00000000..5c8bf646
--- /dev/null
+++ b/crates/typst-library/src/visualize/mod.rs
@@ -0,0 +1,50 @@
+//! Drawing and visualization.
+
+mod color;
+mod gradient;
+mod image;
+mod line;
+mod paint;
+mod path;
+mod pattern;
+mod polygon;
+mod shape;
+mod stroke;
+
+pub use self::color::*;
+pub use self::gradient::*;
+pub use self::image::*;
+pub use self::line::*;
+pub use self::paint::*;
+pub use self::path::*;
+pub use self::pattern::*;
+pub use self::polygon::*;
+pub use self::shape::*;
+pub use self::stroke::*;
+
+use crate::foundations::{category, Category, Scope};
+
+/// Drawing and data visualization.
+///
+/// If you want to create more advanced drawings or plots, also have a look at
+/// the [CetZ](https://github.com/johannes-wolf/cetz) package as well as more
+/// specialized [packages]($universe) for your use case.
+#[category]
+pub static VISUALIZE: Category;
+
+/// Hook up all visualize definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.category(VISUALIZE);
+ global.define_type::<Color>();
+ global.define_type::<Gradient>();
+ global.define_type::<Pattern>();
+ global.define_type::<Stroke>();
+ global.define_elem::<ImageElem>();
+ global.define_elem::<LineElem>();
+ global.define_elem::<RectElem>();
+ global.define_elem::<SquareElem>();
+ global.define_elem::<EllipseElem>();
+ global.define_elem::<CircleElem>();
+ global.define_elem::<PolygonElem>();
+ global.define_elem::<PathElem>();
+}
diff --git a/crates/typst-library/src/visualize/paint.rs b/crates/typst-library/src/visualize/paint.rs
new file mode 100644
index 00000000..cd1006aa
--- /dev/null
+++ b/crates/typst-library/src/visualize/paint.rs
@@ -0,0 +1,102 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::EcoString;
+
+use crate::foundations::{cast, Repr, Smart};
+use crate::visualize::{Color, Gradient, Pattern, RelativeTo};
+
+/// How a fill or stroke should be painted.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub enum Paint {
+ /// A solid color.
+ Solid(Color),
+ /// A gradient.
+ Gradient(Gradient),
+ /// A pattern.
+ Pattern(Pattern),
+}
+
+impl Paint {
+ /// Unwraps a solid color used for text rendering.
+ pub fn unwrap_solid(&self) -> Color {
+ match self {
+ Self::Solid(color) => *color,
+ Self::Gradient(_) | Self::Pattern(_) => panic!("expected solid color"),
+ }
+ }
+
+ /// Gets the relative coordinate system for this paint.
+ pub fn relative(&self) -> Smart<RelativeTo> {
+ match self {
+ Self::Solid(_) => Smart::Auto,
+ Self::Gradient(gradient) => gradient.relative(),
+ Self::Pattern(pattern) => pattern.relative(),
+ }
+ }
+
+ /// Turns this paint into a paint for a text decoration.
+ ///
+ /// If this paint is a gradient, it will be converted to a gradient with
+ /// relative set to [`RelativeTo::Parent`].
+ pub fn as_decoration(&self) -> Self {
+ match self {
+ Self::Solid(color) => Self::Solid(*color),
+ Self::Gradient(gradient) => {
+ Self::Gradient(gradient.clone().with_relative(RelativeTo::Parent))
+ }
+ Self::Pattern(pattern) => {
+ Self::Pattern(pattern.clone().with_relative(RelativeTo::Parent))
+ }
+ }
+ }
+}
+
+impl Debug for Paint {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Solid(v) => v.fmt(f),
+ Self::Gradient(v) => v.fmt(f),
+ Self::Pattern(v) => v.fmt(f),
+ }
+ }
+}
+
+impl From<Pattern> for Paint {
+ fn from(pattern: Pattern) -> Self {
+ Self::Pattern(pattern)
+ }
+}
+
+impl Repr for Paint {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Solid(color) => color.repr(),
+ Self::Gradient(gradient) => gradient.repr(),
+ Self::Pattern(pattern) => pattern.repr(),
+ }
+ }
+}
+
+impl<T: Into<Color>> From<T> for Paint {
+ fn from(t: T) -> Self {
+ Self::Solid(t.into())
+ }
+}
+
+impl From<Gradient> for Paint {
+ fn from(gradient: Gradient) -> Self {
+ Self::Gradient(gradient)
+ }
+}
+
+cast! {
+ Paint,
+ self => match self {
+ Self::Solid(color) => color.into_value(),
+ Self::Gradient(gradient) => gradient.into_value(),
+ Self::Pattern(pattern) => pattern.into_value(),
+ },
+ color: Color => Self::Solid(color),
+ gradient: Gradient => Self::Gradient(gradient),
+ pattern: Pattern => Self::Pattern(pattern),
+}
diff --git a/crates/typst-library/src/visualize/path.rs b/crates/typst-library/src/visualize/path.rs
new file mode 100644
index 00000000..76fd0df0
--- /dev/null
+++ b/crates/typst-library/src/visualize/path.rs
@@ -0,0 +1,276 @@
+use kurbo::ParamCurveExtrema;
+
+use self::PathVertex::{AllControlPoints, MirroredControlPoint, Vertex};
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ array, cast, elem, Array, Content, NativeElement, Packed, Reflect, Show, Smart,
+ StyleChain,
+};
+use crate::layout::{Abs, Axes, BlockElem, Length, Point, Rel, Size};
+use crate::visualize::{FillRule, Paint, Stroke};
+
+/// A path through a list of points, connected by Bezier curves.
+///
+/// # Example
+/// ```example
+/// #path(
+/// fill: blue.lighten(80%),
+/// stroke: blue,
+/// closed: true,
+/// (0pt, 50pt),
+/// (100%, 50pt),
+/// ((50%, 0pt), (40pt, 0pt)),
+/// )
+/// ```
+#[elem(Show)]
+pub struct PathElem {
+ /// How to fill the path.
+ ///
+ /// When setting a fill, the default stroke disappears. To create a
+ /// rectangle with both fill and stroke, you have to configure both.
+ pub fill: Option<Paint>,
+
+ /// The drawing rule used to fill the path.
+ ///
+ /// ```example
+ /// // We use `.with` to get a new
+ /// // function that has the common
+ /// // arguments pre-applied.
+ /// #let star = path.with(
+ /// fill: red,
+ /// closed: true,
+ /// (25pt, 0pt),
+ /// (10pt, 50pt),
+ /// (50pt, 20pt),
+ /// (0pt, 20pt),
+ /// (40pt, 50pt),
+ /// )
+ ///
+ /// #star(fill-rule: "non-zero")
+ /// #star(fill-rule: "even-odd")
+ /// ```
+ #[default]
+ pub fill_rule: FillRule,
+
+ /// How to [stroke] the path. This can be:
+ ///
+ /// 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<Stroke>>,
+
+ /// 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`] or [`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 Show for Packed<PathElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_path)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A component used for path creation.
+#[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"),
+ }
+ },
+}
+
+/// A bezier path.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct Path(pub Vec<PathItem>);
+
+/// An item in a bezier path.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum PathItem {
+ MoveTo(Point),
+ LineTo(Point),
+ CubicTo(Point, Point, Point),
+ ClosePath,
+}
+
+impl Path {
+ /// Create an empty path.
+ pub const fn new() -> Self {
+ Self(vec![])
+ }
+
+ /// Create a path that describes a rectangle.
+ pub fn rect(size: Size) -> Self {
+ let z = Abs::zero();
+ let point = Point::new;
+ let mut path = Self::new();
+ path.move_to(point(z, z));
+ path.line_to(point(size.x, z));
+ path.line_to(point(size.x, size.y));
+ path.line_to(point(z, size.y));
+ path.close_path();
+ path
+ }
+
+ /// Create a path that describes an axis-aligned ellipse.
+ pub fn ellipse(size: Size) -> Self {
+ // https://stackoverflow.com/a/2007782
+ let z = Abs::zero();
+ let rx = size.x / 2.0;
+ let ry = size.y / 2.0;
+ let m = 0.551784;
+ let mx = m * rx;
+ let my = m * ry;
+ let point = |x, y| Point::new(x + rx, y + ry);
+
+ let mut path = Path::new();
+ path.move_to(point(-rx, z));
+ path.cubic_to(point(-rx, -my), point(-mx, -ry), point(z, -ry));
+ path.cubic_to(point(mx, -ry), point(rx, -my), point(rx, z));
+ path.cubic_to(point(rx, my), point(mx, ry), point(z, ry));
+ path.cubic_to(point(-mx, ry), point(-rx, my), point(-rx, z));
+ path
+ }
+
+ /// Push a [`MoveTo`](PathItem::MoveTo) item.
+ pub fn move_to(&mut self, p: Point) {
+ self.0.push(PathItem::MoveTo(p));
+ }
+
+ /// Push a [`LineTo`](PathItem::LineTo) item.
+ pub fn line_to(&mut self, p: Point) {
+ self.0.push(PathItem::LineTo(p));
+ }
+
+ /// Push a [`CubicTo`](PathItem::CubicTo) item.
+ pub fn cubic_to(&mut self, p1: Point, p2: Point, p3: Point) {
+ self.0.push(PathItem::CubicTo(p1, p2, p3));
+ }
+
+ /// Push a [`ClosePath`](PathItem::ClosePath) item.
+ pub fn close_path(&mut self) {
+ self.0.push(PathItem::ClosePath);
+ }
+
+ /// Computes the size of bounding box of this path.
+ pub fn bbox_size(&self) -> Size {
+ let mut min_x = Abs::inf();
+ let mut min_y = Abs::inf();
+ let mut max_x = -Abs::inf();
+ let mut max_y = -Abs::inf();
+
+ let mut cursor = Point::zero();
+ for item in self.0.iter() {
+ match item {
+ PathItem::MoveTo(to) => {
+ min_x = min_x.min(cursor.x);
+ min_y = min_y.min(cursor.y);
+ max_x = max_x.max(cursor.x);
+ max_y = max_y.max(cursor.y);
+ cursor = *to;
+ }
+ PathItem::LineTo(to) => {
+ min_x = min_x.min(cursor.x);
+ min_y = min_y.min(cursor.y);
+ max_x = max_x.max(cursor.x);
+ max_y = max_y.max(cursor.y);
+ cursor = *to;
+ }
+ PathItem::CubicTo(c0, c1, end) => {
+ let cubic = kurbo::CubicBez::new(
+ kurbo::Point::new(cursor.x.to_pt(), cursor.y.to_pt()),
+ kurbo::Point::new(c0.x.to_pt(), c0.y.to_pt()),
+ kurbo::Point::new(c1.x.to_pt(), c1.y.to_pt()),
+ kurbo::Point::new(end.x.to_pt(), end.y.to_pt()),
+ );
+
+ let bbox = cubic.bounding_box();
+ min_x = min_x.min(Abs::pt(bbox.x0)).min(Abs::pt(bbox.x1));
+ min_y = min_y.min(Abs::pt(bbox.y0)).min(Abs::pt(bbox.y1));
+ max_x = max_x.max(Abs::pt(bbox.x0)).max(Abs::pt(bbox.x1));
+ max_y = max_y.max(Abs::pt(bbox.y0)).max(Abs::pt(bbox.y1));
+ cursor = *end;
+ }
+ PathItem::ClosePath => (),
+ }
+ }
+
+ Size::new(max_x - min_x, max_y - min_y)
+ }
+}
diff --git a/crates/typst-library/src/visualize/pattern.rs b/crates/typst-library/src/visualize/pattern.rs
new file mode 100644
index 00000000..2017ea65
--- /dev/null
+++ b/crates/typst-library/src/visualize/pattern.rs
@@ -0,0 +1,285 @@
+use std::hash::Hash;
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use typst_syntax::{Span, Spanned};
+use typst_utils::{LazyHash, Numeric};
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, repr, scope, ty, Content, Smart, StyleChain};
+use crate::introspection::Locator;
+use crate::layout::{Abs, Axes, Frame, Length, Region, Size};
+use crate::visualize::RelativeTo;
+use crate::World;
+
+/// A repeating pattern fill.
+///
+/// Typst supports the most common pattern type of tiled patterns, where a
+/// pattern is repeated in a grid-like fashion, covering the entire area of an
+/// element that is filled or stroked. The pattern is defined by a tile size and
+/// a body defining the content of each cell. You can also add horizontal or
+/// vertical spacing between the cells of the pattern.
+///
+/// # Examples
+///
+/// ```example
+/// #let pat = pattern(size: (30pt, 30pt))[
+/// #place(line(start: (0%, 0%), end: (100%, 100%)))
+/// #place(line(start: (0%, 100%), end: (100%, 0%)))
+/// ]
+///
+/// #rect(fill: pat, width: 100%, height: 60pt, stroke: 1pt)
+/// ```
+///
+/// Patterns are also supported on text, but only when setting the
+/// [relativeness]($pattern.relative) to either `{auto}` (the default value) or
+/// `{"parent"}`. To create word-by-word or glyph-by-glyph patterns, you can
+/// wrap the words or characters of your text in [boxes]($box) manually or
+/// through a [show rule]($styling/#show-rules).
+///
+/// ```example
+/// #let pat = pattern(
+/// size: (30pt, 30pt),
+/// relative: "parent",
+/// square(
+/// size: 30pt,
+/// fill: gradient
+/// .conic(..color.map.rainbow),
+/// )
+/// )
+///
+/// #set text(fill: pat)
+/// #lorem(10)
+/// ```
+///
+/// You can also space the elements further or closer apart using the
+/// [`spacing`]($pattern.spacing) feature of the pattern. If the spacing
+/// is lower than the size of the pattern, the pattern will overlap.
+/// If it is higher, the pattern will have gaps of the same color as the
+/// background of the pattern.
+///
+/// ```example
+/// #let pat = pattern(
+/// size: (30pt, 30pt),
+/// spacing: (10pt, 10pt),
+/// relative: "parent",
+/// square(
+/// size: 30pt,
+/// fill: gradient
+/// .conic(..color.map.rainbow),
+/// ),
+/// )
+///
+/// #rect(
+/// width: 100%,
+/// height: 60pt,
+/// fill: pat,
+/// )
+/// ```
+///
+/// # Relativeness
+/// The location of the starting point of the pattern is dependent on the
+/// dimensions of a container. This container can either be the shape that it is
+/// being painted on, or the closest surrounding container. This is controlled
+/// by the `relative` argument of a pattern constructor. By default, patterns
+/// are relative to the shape they are being painted on, unless the pattern is
+/// applied on text, in which case they are relative to the closest ancestor
+/// container.
+///
+/// Typst determines the ancestor container as follows:
+/// - For shapes that are placed at the root/top level of the document, the
+/// closest ancestor is the page itself.
+/// - For other shapes, the ancestor is the innermost [`block`] or [`box`] that
+/// contains the shape. This includes the boxes and blocks that are implicitly
+/// created by show rules and elements. For example, a [`rotate`] will not
+/// affect the parent of a gradient, but a [`grid`] will.
+#[ty(scope, cast)]
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Pattern(Arc<Repr>);
+
+/// Internal representation of [`Pattern`].
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+struct Repr {
+ /// The pattern's rendered content.
+ frame: LazyHash<Frame>,
+ /// The pattern's tile size.
+ size: Size,
+ /// The pattern's tile spacing.
+ spacing: Size,
+ /// The pattern's relative transform.
+ relative: Smart<RelativeTo>,
+}
+
+#[scope]
+impl Pattern {
+ /// Construct a new pattern.
+ ///
+ /// ```example
+ /// #let pat = pattern(
+ /// size: (20pt, 20pt),
+ /// relative: "parent",
+ /// place(
+ /// dx: 5pt,
+ /// dy: 5pt,
+ /// rotate(45deg, square(
+ /// size: 5pt,
+ /// fill: black,
+ /// )),
+ /// ),
+ /// )
+ ///
+ /// #rect(width: 100%, height: 60pt, fill: pat)
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ engine: &mut Engine,
+ /// The callsite span.
+ span: Span,
+ /// The bounding box of each cell of the pattern.
+ #[named]
+ #[default(Spanned::new(Smart::Auto, Span::detached()))]
+ size: Spanned<Smart<Axes<Length>>>,
+ /// The spacing between cells of the pattern.
+ #[named]
+ #[default(Spanned::new(Axes::splat(Length::zero()), Span::detached()))]
+ spacing: Spanned<Axes<Length>>,
+ /// The [relative placement](#relativeness) of the pattern.
+ ///
+ /// For an element placed at the root/top level of the document, the
+ /// parent is the page itself. For other elements, the parent is the
+ /// innermost block, box, column, grid, or stack that contains the
+ /// element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The content of each cell of the pattern.
+ body: Content,
+ ) -> SourceResult<Pattern> {
+ let size_span = size.span;
+ if let Smart::Custom(size) = size.v {
+ // Ensure that sizes are absolute.
+ if !size.x.em.is_zero() || !size.y.em.is_zero() {
+ bail!(size_span, "pattern tile size must be absolute");
+ }
+
+ // Ensure that sizes are non-zero and finite.
+ if size.x.is_zero()
+ || size.y.is_zero()
+ || !size.x.is_finite()
+ || !size.y.is_finite()
+ {
+ bail!(size_span, "pattern tile size must be non-zero and non-infinite");
+ }
+ }
+
+ // Ensure that spacing is absolute.
+ if !spacing.v.x.em.is_zero() || !spacing.v.y.em.is_zero() {
+ bail!(spacing.span, "pattern tile spacing must be absolute");
+ }
+
+ // Ensure that spacing is finite.
+ if !spacing.v.x.is_finite() || !spacing.v.y.is_finite() {
+ bail!(spacing.span, "pattern tile spacing must be finite");
+ }
+
+ // The size of the frame
+ let size = size.v.map(|l| l.map(|a| a.abs));
+ let region = size.unwrap_or_else(|| Axes::splat(Abs::inf()));
+
+ // Layout the pattern.
+ let world = engine.world;
+ let library = world.library();
+ let locator = Locator::root();
+ let styles = StyleChain::new(&library.styles);
+ let pod = Region::new(region, Axes::splat(false));
+ let mut frame =
+ (engine.routines.layout_frame)(engine, &body, locator, styles, pod)?;
+
+ // Set the size of the frame if the size is enforced.
+ if let Smart::Custom(size) = size {
+ frame.set_size(size);
+ }
+
+ // Check that the frame is non-zero.
+ if frame.width().is_zero() || frame.height().is_zero() {
+ bail!(
+ span, "pattern tile size must be non-zero";
+ hint: "try setting the size manually"
+ );
+ }
+
+ Ok(Self(Arc::new(Repr {
+ size: frame.size(),
+ frame: LazyHash::new(frame),
+ spacing: spacing.v.map(|l| l.abs),
+ relative,
+ })))
+ }
+}
+
+impl Pattern {
+ /// Set the relative placement of the pattern.
+ pub fn with_relative(mut self, relative: RelativeTo) -> Self {
+ if let Some(this) = Arc::get_mut(&mut self.0) {
+ this.relative = Smart::Custom(relative);
+ } else {
+ self.0 = Arc::new(Repr {
+ relative: Smart::Custom(relative),
+ ..self.0.as_ref().clone()
+ });
+ }
+
+ self
+ }
+
+ /// Return the frame of the pattern.
+ pub fn frame(&self) -> &Frame {
+ &self.0.frame
+ }
+
+ /// Return the size of the pattern in absolute units.
+ pub fn size(&self) -> Size {
+ self.0.size
+ }
+
+ /// Return the spacing of the pattern in absolute units.
+ pub fn spacing(&self) -> Size {
+ self.0.spacing
+ }
+
+ /// Returns the relative placement of the pattern.
+ pub fn relative(&self) -> Smart<RelativeTo> {
+ self.0.relative
+ }
+
+ /// Returns the relative placement of the pattern.
+ pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo {
+ self.0.relative.unwrap_or_else(|| {
+ if on_text {
+ RelativeTo::Parent
+ } else {
+ RelativeTo::Self_
+ }
+ })
+ }
+}
+
+impl repr::Repr for Pattern {
+ fn repr(&self) -> EcoString {
+ let mut out =
+ eco_format!("pattern(({}, {})", self.0.size.x.repr(), self.0.size.y.repr());
+
+ if self.0.spacing.is_zero() {
+ out.push_str(", spacing: (");
+ out.push_str(&self.0.spacing.x.repr());
+ out.push_str(", ");
+ out.push_str(&self.0.spacing.y.repr());
+ out.push(')');
+ }
+
+ out.push_str(", ..)");
+
+ out
+ }
+}
diff --git a/crates/typst-library/src/visualize/polygon.rs b/crates/typst-library/src/visualize/polygon.rs
new file mode 100644
index 00000000..33e4fd32
--- /dev/null
+++ b/crates/typst-library/src/visualize/polygon.rs
@@ -0,0 +1,135 @@
+use std::f64::consts::PI;
+
+use typst_syntax::Span;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, func, scope, Content, NativeElement, Packed, Show, Smart, StyleChain,
+};
+use crate::layout::{Axes, BlockElem, Em, Length, Rel};
+use crate::visualize::{FillRule, Paint, Stroke};
+
+/// A closed polygon.
+///
+/// The polygon is defined by its corner points and is closed automatically.
+///
+/// # Example
+/// ```example
+/// #polygon(
+/// fill: blue.lighten(80%),
+/// stroke: blue,
+/// (20%, 0pt),
+/// (60%, 0pt),
+/// (80%, 2cm),
+/// (0%, 2cm),
+/// )
+/// ```
+#[elem(scope, Show)]
+pub struct PolygonElem {
+ /// How to fill the polygon.
+ ///
+ /// When setting a fill, the default stroke disappears. To create a
+ /// rectangle with both fill and stroke, you have to configure both.
+ pub fill: Option<Paint>,
+
+ /// The drawing rule used to fill the polygon.
+ ///
+ /// See the [path documentation]($path.fill-rule) for an example.
+ #[default]
+ pub fill_rule: FillRule,
+
+ /// How to [stroke] the polygon. This can be:
+ ///
+ /// 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<Stroke>>,
+
+ /// The vertices of the polygon. Each point is specified as an array of two
+ /// [relative lengths]($relative).
+ #[variadic]
+ pub vertices: Vec<Axes<Rel<Length>>>,
+}
+
+#[scope]
+impl PolygonElem {
+ /// A regular polygon, defined by its size and number of vertices.
+ ///
+ /// ```example
+ /// #polygon.regular(
+ /// fill: blue.lighten(80%),
+ /// stroke: blue,
+ /// size: 30pt,
+ /// vertices: 3,
+ /// )
+ /// ```
+ #[func(title = "Regular Polygon")]
+ pub fn regular(
+ /// The call span of this function.
+ span: Span,
+ /// How to fill the polygon. See the general
+ /// [polygon's documentation]($polygon.fill) for more details.
+ #[named]
+ fill: Option<Option<Paint>>,
+
+ /// How to stroke the polygon. See the general
+ /// [polygon's documentation]($polygon.stroke) for more details.
+ #[named]
+ stroke: Option<Smart<Option<Stroke>>>,
+
+ /// The diameter of the [circumcircle](https://en.wikipedia.org/wiki/Circumcircle)
+ /// of the regular polygon.
+ #[named]
+ #[default(Em::one().into())]
+ size: Length,
+
+ /// The number of vertices in the polygon.
+ #[named]
+ #[default(3)]
+ vertices: u64,
+ ) -> Content {
+ let radius = size / 2.0;
+ let angle = |i: f64| {
+ 2.0 * PI * i / (vertices as f64) + PI * (1.0 / 2.0 - 1.0 / vertices as f64)
+ };
+ let (horizontal_offset, vertical_offset) = (0..=vertices)
+ .map(|v| {
+ (
+ (radius * angle(v as f64).cos()) + radius,
+ (radius * angle(v as f64).sin()) + radius,
+ )
+ })
+ .fold((radius, radius), |(min_x, min_y), (v_x, v_y)| {
+ (
+ if min_x < v_x { min_x } else { v_x },
+ if min_y < v_y { min_y } else { v_y },
+ )
+ });
+ let vertices = (0..=vertices)
+ .map(|v| {
+ let x = (radius * angle(v as f64).cos()) + radius - horizontal_offset;
+ let y = (radius * angle(v as f64).sin()) + radius - vertical_offset;
+ Axes::new(x, y).map(Rel::from)
+ })
+ .collect();
+
+ let mut elem = PolygonElem::new(vertices);
+ if let Some(fill) = fill {
+ elem.push_fill(fill);
+ }
+ if let Some(stroke) = stroke {
+ elem.push_stroke(stroke);
+ }
+ elem.pack().spanned(span)
+ }
+}
+
+impl Show for Packed<PolygonElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_polygon)
+ .pack()
+ .spanned(self.span()))
+ }
+}
diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs
new file mode 100644
index 00000000..01e316a8
--- /dev/null
+++ b/crates/typst-library/src/visualize/shape.rs
@@ -0,0 +1,448 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, Cast, Content, NativeElement, Packed, Show, Smart, StyleChain,
+};
+use crate::layout::{Abs, BlockElem, Corners, Length, Point, Rel, Sides, Size, Sizing};
+use crate::visualize::{FixedStroke, Paint, Path, Stroke};
+
+/// A rectangle with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #rect(width: 35%, height: 30pt)
+///
+/// // With content.
+/// #rect[
+/// Automatically sized \
+/// to fit the content.
+/// ]
+/// ```
+#[elem(title = "Rectangle", Show)]
+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: Sizing,
+
+ /// 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]
+ /// - A dictionary describing the stroke for each side individually. 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<Stroke>>>>,
+
+ /// 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.
+ /// See the [box's documentation]($box.outset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(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]($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]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<RectElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_rect)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A square with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #square(size: 40pt)
+///
+/// // With content.
+/// #square[
+/// Automatically \
+/// sized to fit.
+/// ]
+/// ```
+#[elem(Show)]
+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.map(Into::into),
+ })]
+ pub height: Sizing,
+
+ /// How to fill the square. See the [rectangle's documentation]($rect.fill)
+ /// for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the square. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Sides<Option<Option<Stroke>>>>,
+
+ /// How much to round the square's corners. See the
+ /// [rectangle's documentation]($rect.radius) for more details.
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the square's content. See the
+ /// [box's documentation]($box.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(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 [box's documentation]($box.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]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<SquareElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_square)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// An ellipse with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #ellipse(width: 35%, height: 30pt)
+///
+/// // With content.
+/// #ellipse[
+/// #set align(center)
+/// Automatically sized \
+/// to fit the content.
+/// ]
+/// ```
+#[elem(Show)]
+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: Sizing,
+
+ /// How to fill the ellipse. See the [rectangle's documentation]($rect.fill)
+ /// for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the ellipse. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Option<Stroke>>,
+
+ /// How much to pad the ellipse's content. See the
+ /// [box's documentation]($box.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(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 [box's documentation]($box.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]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<EllipseElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_ellipse)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A circle with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #circle(radius: 25pt)
+///
+/// // With content.
+/// #circle[
+/// #set align(center + horizon)
+/// Automatically \
+/// sized to fit.
+/// ]
+/// ```
+#[elem(Show)]
+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.map(Into::into),
+ })]
+ pub height: Sizing,
+
+ /// How to fill the circle. See the [rectangle's documentation]($rect.fill)
+ /// for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the circle. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Smart::Auto)]
+ pub stroke: Smart<Option<Stroke>>,
+
+ /// How much to pad the circle's content. See the
+ /// [box's documentation]($box.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(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 [box's documentation]($box.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]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<CircleElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_circle)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A geometric shape with optional fill and stroke.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Shape {
+ /// The shape's geometry.
+ pub geometry: Geometry,
+ /// The shape's background fill.
+ pub fill: Option<Paint>,
+ /// The shape's fill rule.
+ pub fill_rule: FillRule,
+ /// The shape's border stroke.
+ pub stroke: Option<FixedStroke>,
+}
+
+/// A path filling rule.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum FillRule {
+ /// Specifies that "inside" is computed by a non-zero sum of signed edge crossings.
+ #[default]
+ NonZero,
+ /// Specifies that "inside" is computed by an odd number of edge crossings.
+ EvenOdd,
+}
+
+/// A shape's geometry.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum Geometry {
+ /// A line to a point (relative to its position).
+ Line(Point),
+ /// A rectangle with its origin in the topleft corner.
+ Rect(Size),
+ /// A bezier path.
+ Path(Path),
+}
+
+impl Geometry {
+ /// Fill the geometry without a stroke.
+ pub fn filled(self, fill: impl Into<Paint>) -> Shape {
+ Shape {
+ geometry: self,
+ fill: Some(fill.into()),
+ fill_rule: FillRule::default(),
+ stroke: None,
+ }
+ }
+
+ /// Stroke the geometry without a fill.
+ pub fn stroked(self, stroke: FixedStroke) -> Shape {
+ Shape {
+ geometry: self,
+ fill: None,
+ fill_rule: FillRule::default(),
+ stroke: Some(stroke),
+ }
+ }
+
+ /// The bounding box of the geometry.
+ pub fn bbox_size(&self) -> Size {
+ match self {
+ Self::Line(line) => Size::new(line.x, line.y),
+ Self::Rect(s) => *s,
+ Self::Path(p) => p.bbox_size(),
+ }
+ }
+}
diff --git a/crates/typst-library/src/visualize/stroke.rs b/crates/typst-library/src/visualize/stroke.rs
new file mode 100644
index 00000000..4ca10920
--- /dev/null
+++ b/crates/typst-library/src/visualize/stroke.rs
@@ -0,0 +1,617 @@
+use ecow::EcoString;
+use typst_utils::{Numeric, Scalar};
+
+use crate::diag::{HintedStrResult, SourceResult};
+use crate::foundations::{
+ cast, dict, func, scope, ty, Args, Cast, Dict, Fold, FromValue, NoneValue, Repr,
+ Resolve, Smart, StyleChain, Value,
+};
+use crate::layout::{Abs, Length};
+use crate::visualize::{Color, Gradient, Paint, Pattern};
+
+/// Defines how to draw a line.
+///
+/// A stroke has a _paint_ (a solid color or gradient), a _thickness,_ a line
+/// _cap,_ a line _join,_ a _miter limit,_ and a _dash_ pattern. All of these
+/// values are optional and have sensible defaults.
+///
+/// # Example
+/// ```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: 2pt + gradient.linear(..color.map.rainbow)),
+/// )
+/// ```
+///
+/// # Simple strokes
+/// You can create a simple solid stroke from a color, a thickness, or a
+/// combination of the two. Specifically, wherever a stroke is expected you can
+/// pass any of the following values:
+///
+/// - 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}`.
+///
+/// For full control, you can also provide a [dictionary] or a `{stroke}` object
+/// to any function that expects a stroke. The dictionary's keys may include any
+/// of the parameters for the constructor function, shown below.
+///
+/// # Fields
+/// On a stroke object, you can access any of the fields listed in the
+/// constructor function. For example, `{(2pt + blue).thickness}` is `{2pt}`.
+/// Meanwhile, `{stroke(red).cap}` is `{auto}` because it's unspecified. Fields
+/// set to `{auto}` are inherited.
+#[ty(scope, cast)]
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct Stroke<T: Numeric = Length> {
+ /// The stroke's paint.
+ pub paint: Smart<Paint>,
+ /// The stroke's thickness.
+ pub thickness: Smart<T>,
+ /// The stroke's line cap.
+ pub cap: Smart<LineCap>,
+ /// The stroke's line join.
+ pub join: Smart<LineJoin>,
+ /// The stroke's line dash pattern.
+ pub dash: Smart<Option<DashPattern<T>>>,
+ /// The miter limit.
+ pub miter_limit: Smart<Scalar>,
+}
+
+impl Stroke {
+ /// Create a stroke from a paint and a thickness.
+ pub fn from_pair(paint: impl Into<Paint>, thickness: Length) -> Self {
+ Self {
+ paint: Smart::Custom(paint.into()),
+ thickness: Smart::Custom(thickness),
+ ..Default::default()
+ }
+ }
+}
+
+#[scope]
+impl Stroke {
+ /// Converts a value to a stroke or constructs a stroke with the given
+ /// parameters.
+ ///
+ /// Note that in most cases you do not need to convert values to strokes in
+ /// order to use them, as they will be converted automatically. However,
+ /// this constructor can be useful to ensure a value has all the fields of a
+ /// stroke.
+ ///
+ /// ```example
+ /// #let my-func(x) = {
+ /// x = stroke(x) // Convert to a stroke
+ /// [Stroke has thickness #x.thickness.]
+ /// }
+ /// #my-func(3pt) \
+ /// #my-func(red) \
+ /// #my-func(stroke(cap: "round", thickness: 1pt))
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+
+ /// The color or gradient to use for the stroke.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{black}`.
+ #[external]
+ paint: Smart<Paint>,
+
+ /// The stroke's thickness.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{1pt}`.
+ #[external]
+ thickness: Smart<Length>,
+
+ /// How the ends of the stroke are rendered.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{"butt"}`.
+ #[external]
+ cap: Smart<LineCap>,
+
+ /// How sharp turns are rendered.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{"miter"}`.
+ #[external]
+ join: Smart<LineJoin>,
+
+ /// The dash pattern to use. This can be:
+ ///
+ /// - One of the predefined patterns:
+ /// - `{"solid"}` or `{none}`
+ /// - `{"dotted"}`
+ /// - `{"densely-dotted"}`
+ /// - `{"loosely-dotted"}`
+ /// - `{"dashed"}`
+ /// - `{"densely-dashed"}`
+ /// - `{"loosely-dashed"}`
+ /// - `{"dash-dotted"}`
+ /// - `{"densely-dash-dotted"}`
+ /// - `{"loosely-dash-dotted"}`
+ /// - An [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] with the keys `array` (same as the array above),
+ /// and `phase` (of type [length]), which defines where in the pattern
+ /// to start drawing.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{none}`.
+ ///
+ /// ```example
+ /// #set line(length: 100%, stroke: 2pt)
+ /// #stack(
+ /// spacing: 1em,
+ /// line(stroke: (dash: "dashed")),
+ /// line(stroke: (dash: (10pt, 5pt, "dot", 5pt))),
+ /// line(stroke: (dash: (array: (10pt, 5pt, "dot", 5pt), phase: 10pt))),
+ /// )
+ /// ```
+ #[external]
+ dash: Smart<Option<DashPattern>>,
+
+ /// Number at which protruding sharp bends are rendered with a bevel
+ /// instead or a miter join. The higher the number, the sharper an angle
+ /// can be before it is bevelled. Only applicable if `join` is
+ /// `{"miter"}`.
+ ///
+ /// Specifically, the miter limit is the maximum ratio between the
+ /// corner's protrusion length and the stroke's thickness.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{4.0}`.
+ ///
+ /// ```example
+ /// #let points = ((15pt, 0pt), (0pt, 30pt), (30pt, 30pt), (10pt, 20pt))
+ /// #set path(stroke: 6pt + blue)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1cm,
+ /// path(stroke: (miter-limit: 1), ..points),
+ /// path(stroke: (miter-limit: 4), ..points),
+ /// path(stroke: (miter-limit: 5), ..points),
+ /// )
+ /// ```
+ #[external]
+ miter_limit: Smart<f64>,
+ ) -> SourceResult<Stroke> {
+ if let Some(stroke) = args.eat::<Stroke>()? {
+ return Ok(stroke);
+ }
+
+ fn take<T: FromValue>(args: &mut Args, arg: &str) -> SourceResult<Smart<T>> {
+ Ok(args.named::<Smart<T>>(arg)?.unwrap_or(Smart::Auto))
+ }
+
+ let paint = take::<Paint>(args, "paint")?;
+ let thickness = take::<Length>(args, "thickness")?;
+ let cap = take::<LineCap>(args, "cap")?;
+ let join = take::<LineJoin>(args, "join")?;
+ let dash = take::<Option<DashPattern>>(args, "dash")?;
+ let miter_limit = take::<f64>(args, "miter-limit")?.map(Scalar::new);
+
+ Ok(Self { paint, thickness, cap, join, dash, miter_limit })
+ }
+}
+
+impl<T: Numeric> Stroke<T> {
+ /// Map the contained lengths with `f`.
+ pub fn map<F, U: Numeric>(self, f: F) -> Stroke<U>
+ where
+ F: Fn(T) -> U,
+ {
+ Stroke {
+ paint: self.paint,
+ thickness: self.thickness.map(&f),
+ cap: self.cap,
+ join: self.join,
+ dash: self.dash.map(|pattern| {
+ pattern.map(|pattern| DashPattern {
+ array: pattern
+ .array
+ .into_iter()
+ .map(|l| match l {
+ DashLength::Length(v) => DashLength::Length(f(v)),
+ DashLength::LineWidth => DashLength::LineWidth,
+ })
+ .collect(),
+ phase: f(pattern.phase),
+ })
+ }),
+ miter_limit: self.miter_limit,
+ }
+ }
+}
+
+impl Stroke<Abs> {
+ /// Unpack the stroke, filling missing fields from the `default`.
+ pub fn unwrap_or(self, default: FixedStroke) -> FixedStroke {
+ let thickness = self.thickness.unwrap_or(default.thickness);
+ let dash = self
+ .dash
+ .map(|pattern| {
+ pattern.map(|pattern| DashPattern {
+ array: pattern
+ .array
+ .into_iter()
+ .map(|l| l.finish(thickness))
+ .collect(),
+ phase: pattern.phase,
+ })
+ })
+ .unwrap_or(default.dash);
+
+ FixedStroke {
+ paint: self.paint.unwrap_or(default.paint),
+ thickness,
+ cap: self.cap.unwrap_or(default.cap),
+ join: self.join.unwrap_or(default.join),
+ dash,
+ miter_limit: self.miter_limit.unwrap_or(default.miter_limit),
+ }
+ }
+
+ /// Unpack the stroke, filling missing fields with the default values.
+ pub fn unwrap_or_default(self) -> FixedStroke {
+ // we want to do this; the Clippy lint is not type-aware
+ #[allow(clippy::unwrap_or_default)]
+ self.unwrap_or(FixedStroke::default())
+ }
+}
+
+impl<T: Numeric + Repr> Repr for Stroke<T> {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::new();
+ let Self { paint, thickness, cap, join, dash, miter_limit } = &self;
+ if cap.is_auto() && join.is_auto() && dash.is_auto() && miter_limit.is_auto() {
+ match (&self.paint, &self.thickness) {
+ (Smart::Custom(paint), Smart::Custom(thickness)) => {
+ r.push_str(&thickness.repr());
+ r.push_str(" + ");
+ r.push_str(&paint.repr());
+ }
+ (Smart::Custom(paint), Smart::Auto) => r.push_str(&paint.repr()),
+ (Smart::Auto, Smart::Custom(thickness)) => r.push_str(&thickness.repr()),
+ (Smart::Auto, Smart::Auto) => r.push_str("1pt + black"),
+ }
+ } else {
+ r.push('(');
+ let mut sep = "";
+ if let Smart::Custom(paint) = &paint {
+ r.push_str(sep);
+ r.push_str("paint: ");
+ r.push_str(&paint.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(thickness) = &thickness {
+ r.push_str(sep);
+ r.push_str("thickness: ");
+ r.push_str(&thickness.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(cap) = &cap {
+ r.push_str(sep);
+ r.push_str("cap: ");
+ r.push_str(&cap.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(join) = &join {
+ r.push_str(sep);
+ r.push_str("join: ");
+ r.push_str(&join.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(dash) = &dash {
+ r.push_str(sep);
+ r.push_str("dash: ");
+ if let Some(dash) = dash {
+ r.push_str(&dash.repr());
+ } else {
+ r.push_str(&NoneValue.repr());
+ }
+ sep = ", ";
+ }
+ if let Smart::Custom(miter_limit) = &miter_limit {
+ r.push_str(sep);
+ r.push_str("miter-limit: ");
+ r.push_str(&miter_limit.get().repr());
+ }
+ r.push(')');
+ }
+ r
+ }
+}
+
+impl<T: Numeric + Fold> Fold for Stroke<T> {
+ fn fold(self, outer: Self) -> Self {
+ Self {
+ paint: self.paint.or(outer.paint),
+ thickness: self.thickness.or(outer.thickness),
+ cap: self.cap.or(outer.cap),
+ join: self.join.or(outer.join),
+ dash: self.dash.or(outer.dash),
+ miter_limit: self.miter_limit.or(outer.miter_limit),
+ }
+ }
+}
+
+impl Resolve for Stroke {
+ type Output = Stroke<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ Stroke {
+ paint: self.paint,
+ thickness: self.thickness.resolve(styles),
+ cap: self.cap,
+ join: self.join,
+ dash: self.dash.resolve(styles),
+ miter_limit: self.miter_limit,
+ }
+ }
+}
+
+cast! {
+ type Stroke,
+ thickness: Length => Self {
+ thickness: Smart::Custom(thickness),
+ ..Default::default()
+ },
+ color: Color => Self {
+ paint: Smart::Custom(color.into()),
+ ..Default::default()
+ },
+ gradient: Gradient => Self {
+ paint: Smart::Custom(gradient.into()),
+ ..Default::default()
+ },
+ pattern: Pattern => Self {
+ paint: Smart::Custom(pattern.into()),
+ ..Default::default()
+ },
+ mut dict: Dict => {
+ // Get a value by key, accepting either Auto or something convertible to type T.
+ fn take<T: FromValue>(dict: &mut Dict, key: &str) -> HintedStrResult<Smart<T>> {
+ Ok(dict.take(key).ok().map(Smart::<T>::from_value)
+ .transpose()?.unwrap_or(Smart::Auto))
+ }
+
+ let paint = take::<Paint>(&mut dict, "paint")?;
+ let thickness = take::<Length>(&mut dict, "thickness")?;
+ let cap = take::<LineCap>(&mut dict, "cap")?;
+ let join = take::<LineJoin>(&mut dict, "join")?;
+ let dash = take::<Option<DashPattern>>(&mut dict, "dash")?;
+ let miter_limit = take::<f64>(&mut dict, "miter-limit")?;
+ dict.finish(&["paint", "thickness", "cap", "join", "dash", "miter-limit"])?;
+
+ Self {
+ paint,
+ thickness,
+ cap,
+ join,
+ dash,
+ miter_limit: miter_limit.map(Scalar::new),
+ }
+ },
+}
+
+cast! {
+ Stroke<Abs>,
+ self => self.map(Length::from).into_value(),
+}
+
+/// The line cap of a stroke
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum LineCap {
+ /// Square stroke cap with the edge at the stroke's end point.
+ Butt,
+ /// Circular stroke cap centered at the stroke's end point.
+ Round,
+ /// Square stroke cap centered at the stroke's end point.
+ Square,
+}
+
+impl Repr for LineCap {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Butt => "butt".repr(),
+ Self::Round => "round".repr(),
+ Self::Square => "square".repr(),
+ }
+ }
+}
+
+/// The line join of a stroke
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum LineJoin {
+ /// Segments are joined with sharp edges. Sharp bends exceeding the miter
+ /// limit are bevelled instead.
+ Miter,
+ /// Segments are joined with circular corners.
+ Round,
+ /// Segments are joined with a bevel (a straight edge connecting the butts
+ /// of the joined segments).
+ Bevel,
+}
+
+impl Repr for LineJoin {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Miter => "miter".repr(),
+ Self::Round => "round".repr(),
+ Self::Bevel => "bevel".repr(),
+ }
+ }
+}
+
+/// A line dash pattern.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct DashPattern<T: Numeric = Length, DT = DashLength<T>> {
+ /// The dash array.
+ pub array: Vec<DT>,
+ /// The dash phase.
+ pub phase: T,
+}
+
+impl<T: Numeric + Repr, DT: Repr> Repr for DashPattern<T, DT> {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("(array: (");
+ for (i, elem) in self.array.iter().enumerate() {
+ if i != 0 {
+ r.push_str(", ")
+ }
+ r.push_str(&elem.repr())
+ }
+ r.push_str("), phase: ");
+ r.push_str(&self.phase.repr());
+ r.push(')');
+ r
+ }
+}
+
+impl<T: Numeric + Default> From<Vec<DashLength<T>>> for DashPattern<T> {
+ fn from(array: Vec<DashLength<T>>) -> Self {
+ Self { array, phase: T::default() }
+ }
+}
+
+impl Resolve for DashPattern {
+ type Output = DashPattern<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ DashPattern {
+ array: self.array.into_iter().map(|l| l.resolve(styles)).collect(),
+ phase: self.phase.resolve(styles),
+ }
+ }
+}
+
+// Same names as tikz:
+// https://tex.stackexchange.com/questions/45275/tikz-get-values-for-predefined-dash-patterns
+cast! {
+ DashPattern,
+ self => dict! { "array" => self.array, "phase" => self.phase }.into_value(),
+
+ "solid" => Vec::new().into(),
+ "dotted" => vec![DashLength::LineWidth, Abs::pt(2.0).into()].into(),
+ "densely-dotted" => vec![DashLength::LineWidth, Abs::pt(1.0).into()].into(),
+ "loosely-dotted" => vec![DashLength::LineWidth, Abs::pt(4.0).into()].into(),
+ "dashed" => vec![Abs::pt(3.0).into(), Abs::pt(3.0).into()].into(),
+ "densely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into()].into(),
+ "loosely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(6.0).into()].into(),
+ "dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into(), DashLength::LineWidth, Abs::pt(2.0).into()].into(),
+ "densely-dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(1.0).into(), DashLength::LineWidth, Abs::pt(1.0).into()].into(),
+ "loosely-dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(4.0).into(), DashLength::LineWidth, Abs::pt(4.0).into()].into(),
+
+ array: Vec<DashLength> => Self { array, phase: Length::zero() },
+ mut dict: Dict => {
+ let array: Vec<DashLength> = dict.take("array")?.cast()?;
+ let phase = dict.take("phase").ok().map(Value::cast)
+ .transpose()?.unwrap_or(Length::zero());
+ dict.finish(&["array", "phase"])?;
+ Self {
+ array,
+ phase,
+ }
+ },
+}
+
+/// The length of a dash in a line dash pattern.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum DashLength<T: Numeric = Length> {
+ LineWidth,
+ Length(T),
+}
+
+impl<T: Numeric> DashLength<T> {
+ fn finish(self, line_width: T) -> T {
+ match self {
+ Self::LineWidth => line_width,
+ Self::Length(l) => l,
+ }
+ }
+}
+
+impl<T: Numeric + Repr> Repr for DashLength<T> {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::LineWidth => "dot".repr(),
+ Self::Length(v) => v.repr(),
+ }
+ }
+}
+
+impl Resolve for DashLength {
+ type Output = DashLength<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ match self {
+ Self::LineWidth => DashLength::LineWidth,
+ Self::Length(v) => DashLength::Length(v.resolve(styles)),
+ }
+ }
+}
+
+impl From<Abs> for DashLength {
+ fn from(l: Abs) -> Self {
+ DashLength::Length(l.into())
+ }
+}
+
+cast! {
+ DashLength,
+ self => match self {
+ Self::LineWidth => "dot".into_value(),
+ Self::Length(v) => v.into_value(),
+ },
+ "dot" => Self::LineWidth,
+ v: Length => Self::Length(v),
+}
+
+/// A fully specified stroke of a geometric shape.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct FixedStroke {
+ /// The stroke's paint.
+ pub paint: Paint,
+ /// The stroke's thickness.
+ pub thickness: Abs,
+ /// The stroke's line cap.
+ pub cap: LineCap,
+ /// The stroke's line join.
+ pub join: LineJoin,
+ /// The stroke's line dash pattern.
+ pub dash: Option<DashPattern<Abs, Abs>>,
+ /// The miter limit. Defaults to 4.0, same as `tiny-skia`.
+ pub miter_limit: Scalar,
+}
+
+impl FixedStroke {
+ /// Create a stroke from a paint and a thickness.
+ pub fn from_pair(paint: impl Into<Paint>, thickness: Abs) -> Self {
+ Self {
+ paint: paint.into(),
+ thickness,
+ ..Default::default()
+ }
+ }
+}
+
+impl Default for FixedStroke {
+ fn default() -> Self {
+ Self {
+ paint: Paint::Solid(Color::BLACK),
+ thickness: Abs::pt(1.0),
+ cap: LineCap::Butt,
+ join: LineJoin::Miter,
+ dash: None,
+ miter_limit: Scalar::new(4.0),
+ }
+ }
+}