summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2023-07-02 19:59:52 +0200
committerLaurenz <laurmaedje@gmail.com>2023-07-02 20:07:43 +0200
commitebfdb1dafa430786db10dad2ef7d5467c1bdbed1 (patch)
tree2bbc24ddb4124c4bb14dec0e536129d4de37b056 /crates
parent3ab19185093d7709f824b95b979060ce125389d8 (diff)
Move everything into `crates/` directory
Diffstat (limited to 'crates')
-rw-r--r--crates/typst-cli/Cargo.toml59
-rw-r--r--crates/typst-cli/build.rs60
-rw-r--r--crates/typst-cli/src/args.rs119
-rw-r--r--crates/typst-cli/src/compile.rs239
-rw-r--r--crates/typst-cli/src/fonts.rs183
-rw-r--r--crates/typst-cli/src/main.rs79
-rw-r--r--crates/typst-cli/src/package.rs77
-rw-r--r--crates/typst-cli/src/tracing.rs138
-rw-r--r--crates/typst-cli/src/watch.rs170
-rw-r--r--crates/typst-cli/src/world.rs256
-rw-r--r--crates/typst-docs/Cargo.toml27
-rw-r--r--crates/typst-docs/src/contribs.rs86
-rw-r--r--crates/typst-docs/src/html.rs513
-rw-r--r--crates/typst-docs/src/lib.rs1002
-rw-r--r--crates/typst-library/Cargo.toml51
-rw-r--r--crates/typst-library/assets/cj_linebreak_data.postcardbin0 -> 18848 bytes
-rw-r--r--crates/typst-library/assets/icudata.postcardbin0 -> 352005 bytes
-rw-r--r--crates/typst-library/assets/syntect.binbin0 -> 687378 bytes
-rw-r--r--crates/typst-library/src/compute/calc.rs1024
-rw-r--r--crates/typst-library/src/compute/construct.rs743
-rw-r--r--crates/typst-library/src/compute/data.rs492
-rw-r--r--crates/typst-library/src/compute/foundations.rs215
-rw-r--r--crates/typst-library/src/compute/mod.rs39
-rw-r--r--crates/typst-library/src/layout/align.rs67
-rw-r--r--crates/typst-library/src/layout/columns.rs172
-rw-r--r--crates/typst-library/src/layout/container.rs497
-rw-r--r--crates/typst-library/src/layout/enum.rs318
-rw-r--r--crates/typst-library/src/layout/flow.rs583
-rw-r--r--crates/typst-library/src/layout/fragment.rs87
-rw-r--r--crates/typst-library/src/layout/grid.rs708
-rw-r--r--crates/typst-library/src/layout/hide.rs30
-rw-r--r--crates/typst-library/src/layout/list.rs239
-rw-r--r--crates/typst-library/src/layout/measure.rs56
-rw-r--r--crates/typst-library/src/layout/mod.rs709
-rw-r--r--crates/typst-library/src/layout/pad.rs125
-rw-r--r--crates/typst-library/src/layout/page.rs898
-rw-r--r--crates/typst-library/src/layout/par.rs1566
-rw-r--r--crates/typst-library/src/layout/place.rs103
-rw-r--r--crates/typst-library/src/layout/regions.rs144
-rw-r--r--crates/typst-library/src/layout/repeat.rs79
-rw-r--r--crates/typst-library/src/layout/spacing.rs240
-rw-r--r--crates/typst-library/src/layout/stack.rs339
-rw-r--r--crates/typst-library/src/layout/table.rs334
-rw-r--r--crates/typst-library/src/layout/terms.rs166
-rw-r--r--crates/typst-library/src/layout/transform.rs194
-rw-r--r--crates/typst-library/src/lib.rs141
-rw-r--r--crates/typst-library/src/math/accent.rs139
-rw-r--r--crates/typst-library/src/math/align.rs63
-rw-r--r--crates/typst-library/src/math/attach.rs411
-rw-r--r--crates/typst-library/src/math/cancel.rs187
-rw-r--r--crates/typst-library/src/math/ctx.rs268
-rw-r--r--crates/typst-library/src/math/delimited.rs200
-rw-r--r--crates/typst-library/src/math/frac.rs148
-rw-r--r--crates/typst-library/src/math/fragment.rs414
-rw-r--r--crates/typst-library/src/math/matrix.rs313
-rw-r--r--crates/typst-library/src/math/mod.rs480
-rw-r--r--crates/typst-library/src/math/op.rs113
-rw-r--r--crates/typst-library/src/math/root.rs156
-rw-r--r--crates/typst-library/src/math/row.rs258
-rw-r--r--crates/typst-library/src/math/spacing.rs60
-rw-r--r--crates/typst-library/src/math/stretch.rs199
-rw-r--r--crates/typst-library/src/math/style.rs620
-rw-r--r--crates/typst-library/src/math/underover.rs339
-rw-r--r--crates/typst-library/src/meta/bibliography.rs724
-rw-r--r--crates/typst-library/src/meta/context.rs220
-rw-r--r--crates/typst-library/src/meta/counter.rs683
-rw-r--r--crates/typst-library/src/meta/document.rs86
-rw-r--r--crates/typst-library/src/meta/figure.rs351
-rw-r--r--crates/typst-library/src/meta/footnote.rs299
-rw-r--r--crates/typst-library/src/meta/heading.rs239
-rw-r--r--crates/typst-library/src/meta/link.rs137
-rw-r--r--crates/typst-library/src/meta/mod.rs64
-rw-r--r--crates/typst-library/src/meta/numbering.rs525
-rw-r--r--crates/typst-library/src/meta/outline.rs528
-rw-r--r--crates/typst-library/src/meta/query.rs145
-rw-r--r--crates/typst-library/src/meta/reference.rs276
-rw-r--r--crates/typst-library/src/meta/state.rs440
-rw-r--r--crates/typst-library/src/prelude.rs42
-rw-r--r--crates/typst-library/src/shared/behave.rs109
-rw-r--r--crates/typst-library/src/shared/ext.rs92
-rw-r--r--crates/typst-library/src/shared/mod.rs7
-rw-r--r--crates/typst-library/src/symbols/emoji.rs1356
-rw-r--r--crates/typst-library/src/symbols/mod.rs15
-rw-r--r--crates/typst-library/src/symbols/sym.rs842
-rw-r--r--crates/typst-library/src/text/deco.rs420
-rw-r--r--crates/typst-library/src/text/misc.rs330
-rw-r--r--crates/typst-library/src/text/mod.rs769
-rw-r--r--crates/typst-library/src/text/quotes.rs209
-rw-r--r--crates/typst-library/src/text/raw.rs398
-rw-r--r--crates/typst-library/src/text/shaping.rs973
-rw-r--r--crates/typst-library/src/text/shift.rs229
-rw-r--r--crates/typst-library/src/visualize/image.rs197
-rw-r--r--crates/typst-library/src/visualize/line.rs118
-rw-r--r--crates/typst-library/src/visualize/mod.rs45
-rw-r--r--crates/typst-library/src/visualize/path.rs211
-rw-r--r--crates/typst-library/src/visualize/polygon.rs93
-rw-r--r--crates/typst-library/src/visualize/shape.rs569
-rw-r--r--crates/typst-macros/Cargo.toml22
-rw-r--r--crates/typst-macros/src/castable.rs331
-rw-r--r--crates/typst-macros/src/element.rs554
-rw-r--r--crates/typst-macros/src/func.rs268
-rw-r--r--crates/typst-macros/src/lib.rs63
-rw-r--r--crates/typst-macros/src/symbols.rs78
-rw-r--r--crates/typst-macros/src/util.rs145
-rw-r--r--crates/typst/Cargo.toml56
-rw-r--r--crates/typst/src/diag.rs376
-rw-r--r--crates/typst/src/doc.rs719
-rw-r--r--crates/typst/src/eval/args.rs216
-rw-r--r--crates/typst/src/eval/array.rs508
-rw-r--r--crates/typst/src/eval/auto.rs39
-rw-r--r--crates/typst/src/eval/cast.rs316
-rw-r--r--crates/typst/src/eval/datetime.rs201
-rw-r--r--crates/typst/src/eval/dict.rs235
-rw-r--r--crates/typst/src/eval/func.rs643
-rw-r--r--crates/typst/src/eval/int.rs81
-rw-r--r--crates/typst/src/eval/library.rs182
-rw-r--r--crates/typst/src/eval/methods.rs373
-rw-r--r--crates/typst/src/eval/mod.rs1908
-rw-r--r--crates/typst/src/eval/module.rs98
-rw-r--r--crates/typst/src/eval/none.rs74
-rw-r--r--crates/typst/src/eval/ops.rs429
-rw-r--r--crates/typst/src/eval/scope.rs178
-rw-r--r--crates/typst/src/eval/str.rs620
-rw-r--r--crates/typst/src/eval/symbol.rs210
-rw-r--r--crates/typst/src/eval/value.rs461
-rw-r--r--crates/typst/src/export/mod.rs7
-rw-r--r--crates/typst/src/export/pdf/font.rs204
-rw-r--r--crates/typst/src/export/pdf/image.rs143
-rw-r--r--crates/typst/src/export/pdf/mod.rs235
-rw-r--r--crates/typst/src/export/pdf/outline.rs127
-rw-r--r--crates/typst/src/export/pdf/page.rs565
-rw-r--r--crates/typst/src/export/render.rs673
-rw-r--r--crates/typst/src/file.rs303
-rw-r--r--crates/typst/src/font/book.rs546
-rw-r--r--crates/typst/src/font/mod.rs249
-rw-r--r--crates/typst/src/font/variant.rs270
-rw-r--r--crates/typst/src/geom/abs.rs266
-rw-r--r--crates/typst/src/geom/align.rs239
-rw-r--r--crates/typst/src/geom/angle.rs188
-rw-r--r--crates/typst/src/geom/axes.rs305
-rw-r--r--crates/typst/src/geom/color.rs386
-rw-r--r--crates/typst/src/geom/corners.rs219
-rw-r--r--crates/typst/src/geom/dir.rs79
-rw-r--r--crates/typst/src/geom/ellipse.rs22
-rw-r--r--crates/typst/src/geom/em.rs153
-rw-r--r--crates/typst/src/geom/fr.rs119
-rw-r--r--crates/typst/src/geom/length.rs128
-rw-r--r--crates/typst/src/geom/macros.rs47
-rw-r--r--crates/typst/src/geom/mod.rs121
-rw-r--r--crates/typst/src/geom/paint.rs30
-rw-r--r--crates/typst/src/geom/path.rs54
-rw-r--r--crates/typst/src/geom/point.rs146
-rw-r--r--crates/typst/src/geom/ratio.rs133
-rw-r--r--crates/typst/src/geom/rel.rs246
-rw-r--r--crates/typst/src/geom/rounded.rs182
-rw-r--r--crates/typst/src/geom/scalar.rs175
-rw-r--r--crates/typst/src/geom/shape.rs35
-rw-r--r--crates/typst/src/geom/sides.rs268
-rw-r--r--crates/typst/src/geom/size.rs78
-rw-r--r--crates/typst/src/geom/smart.rs146
-rw-r--r--crates/typst/src/geom/stroke.rs387
-rw-r--r--crates/typst/src/geom/transform.rs77
-rw-r--r--crates/typst/src/ide/analyze.rs111
-rw-r--r--crates/typst/src/ide/complete.rs1201
-rw-r--r--crates/typst/src/ide/highlight.rs430
-rw-r--r--crates/typst/src/ide/jump.rs173
-rw-r--r--crates/typst/src/ide/mod.rs97
-rw-r--r--crates/typst/src/ide/tooltip.rs222
-rw-r--r--crates/typst/src/image.rs449
-rw-r--r--crates/typst/src/lib.rs147
-rw-r--r--crates/typst/src/model/content.rs614
-rw-r--r--crates/typst/src/model/element.rs134
-rw-r--r--crates/typst/src/model/introspect.rs352
-rw-r--r--crates/typst/src/model/label.rs16
-rw-r--r--crates/typst/src/model/mod.rs148
-rw-r--r--crates/typst/src/model/realize.rs228
-rw-r--r--crates/typst/src/model/selector.rs296
-rw-r--r--crates/typst/src/model/styles.rs750
-rw-r--r--crates/typst/src/syntax/ast.rs1994
-rw-r--r--crates/typst/src/syntax/kind.rs448
-rw-r--r--crates/typst/src/syntax/lexer.rs738
-rw-r--r--crates/typst/src/syntax/mod.rs23
-rw-r--r--crates/typst/src/syntax/node.rs889
-rw-r--r--crates/typst/src/syntax/parser.rs1643
-rw-r--r--crates/typst/src/syntax/reparser.rs322
-rw-r--r--crates/typst/src/syntax/source.rs421
-rw-r--r--crates/typst/src/syntax/span.rs148
-rw-r--r--crates/typst/src/util/bytes.rs59
-rw-r--r--crates/typst/src/util/fat.rs55
-rw-r--r--crates/typst/src/util/mod.rs268
190 files changed, 58787 insertions, 0 deletions
diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml
new file mode 100644
index 00000000..e77cbc2a
--- /dev/null
+++ b/crates/typst-cli/Cargo.toml
@@ -0,0 +1,59 @@
+[package]
+name = "typst-cli"
+description = "The command line interface for Typst."
+categories = ["compilers", "command-line-utilities"]
+keywords = ["typst", "cli"]
+version.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+
+[[bin]]
+name = "typst"
+path = "src/main.rs"
+test = false
+doctest = false
+bench = false
+doc = false
+
+[dependencies]
+typst = { path = "../typst" }
+typst-library = { path = "../typst-library" }
+chrono = { version = "0.4.24", default-features = false, features = ["clock", "std"] }
+clap = { version = "4.2.4", features = ["derive", "env"] }
+codespan-reporting = "0.11"
+comemo = "0.3"
+dirs = "5"
+flate2 = "1"
+inferno = "0.11.15"
+memmap2 = "0.5"
+notify = "5"
+once_cell = "1"
+open = "4.0.2"
+same-file = "1"
+siphasher = "0.3"
+tar = "0.4"
+tempfile = "3.5.0"
+tracing = "0.1.37"
+tracing-error = "0.2"
+tracing-flame = "0.2.0"
+tracing-subscriber = "0.3.17"
+ureq = "2"
+walkdir = "2"
+
+[build-dependencies]
+clap = { version = "4.2.4", features = ["derive", "string"] }
+clap_complete = "4.2.1"
+clap_mangen = "0.2.10"
+
+[features]
+default = ["embed-fonts"]
+
+# Embeds some fonts into the binary:
+# - For text: Linux Libertine, New Computer Modern
+# - For math: New Computer Modern Math
+# - For code: Deja Vu Sans Mono
+embed-fonts = []
diff --git a/crates/typst-cli/build.rs b/crates/typst-cli/build.rs
new file mode 100644
index 00000000..86325e1d
--- /dev/null
+++ b/crates/typst-cli/build.rs
@@ -0,0 +1,60 @@
+use std::env;
+use std::fs::{create_dir_all, File};
+use std::path::Path;
+use std::process::Command;
+
+use clap::{CommandFactory, ValueEnum};
+use clap_complete::{generate_to, Shell};
+use clap_mangen::Man;
+
+#[path = "src/args.rs"]
+#[allow(dead_code)]
+mod args;
+
+fn main() {
+ println!("cargo:rerun-if-env-changed=TYPST_VERSION");
+ println!("cargo:rerun-if-env-changed=GEN_ARTIFACTS");
+
+ if option_env!("TYPST_VERSION").is_none() {
+ println!("cargo:rustc-env=TYPST_VERSION={}", typst_version());
+ }
+
+ if let Some(dir) = env::var_os("GEN_ARTIFACTS") {
+ let out = &Path::new(&dir);
+ create_dir_all(out).unwrap();
+ let cmd = &mut args::CliArguments::command();
+
+ Man::new(cmd.clone())
+ .render(&mut File::create(out.join("typst.1")).unwrap())
+ .unwrap();
+
+ for subcmd in cmd.get_subcommands() {
+ let name = format!("typst-{}", subcmd.get_name());
+ Man::new(subcmd.clone().name(&name))
+ .render(&mut File::create(out.join(format!("{name}.1"))).unwrap())
+ .unwrap();
+ }
+
+ for shell in Shell::value_variants() {
+ generate_to(*shell, cmd, "typst", out).unwrap();
+ }
+ }
+}
+
+/// Also used by `args.rs`.
+fn typst_version() -> String {
+ if let Some(version) = option_env!("TYPST_VERSION") {
+ return version.to_owned();
+ }
+
+ let pkg = env!("CARGO_PKG_VERSION");
+ let hash = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .ok()
+ .filter(|output| output.status.success())
+ .and_then(|output| String::from_utf8(output.stdout.get(..8)?.into()).ok())
+ .unwrap_or_else(|| "unknown hash".into());
+
+ format!("{pkg} ({hash})")
+}
diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs
new file mode 100644
index 00000000..7fdb041b
--- /dev/null
+++ b/crates/typst-cli/src/args.rs
@@ -0,0 +1,119 @@
+use std::fmt::{self, Display, Formatter};
+use std::path::PathBuf;
+
+use clap::{ArgAction, Parser, Subcommand, ValueEnum};
+
+/// typst creates PDF files from .typ files
+#[derive(Debug, Clone, Parser)]
+#[clap(name = "typst", version = crate::typst_version(), author)]
+pub struct CliArguments {
+ /// The typst command to run
+ #[command(subcommand)]
+ pub command: Command,
+
+ /// Sets the level of logging verbosity:
+ /// -v = warning & error, -vv = info, -vvv = debug, -vvvv = trace
+ #[clap(short, long, action = ArgAction::Count)]
+ pub verbosity: u8,
+}
+
+/// What to do.
+#[derive(Debug, Clone, Subcommand)]
+#[command()]
+pub enum Command {
+ /// Compiles the input file into a PDF file
+ #[command(visible_alias = "c")]
+ Compile(CompileCommand),
+
+ /// Watches the input file and recompiles on changes
+ #[command(visible_alias = "w")]
+ Watch(CompileCommand),
+
+ /// List all discovered fonts in system and custom font paths
+ Fonts(FontsCommand),
+}
+
+/// Compiles the input file into a PDF file
+#[derive(Debug, Clone, Parser)]
+pub struct CompileCommand {
+ /// Path to input Typst file
+ pub input: PathBuf,
+
+ /// Path to output PDF file or PNG file(s)
+ pub output: Option<PathBuf>,
+
+ /// Configure the project root
+ #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")]
+ pub root: Option<PathBuf>,
+
+ /// Add additional directories to search for fonts
+ #[clap(
+ long = "font-path",
+ env = "TYPST_FONT_PATHS",
+ value_name = "DIR",
+ action = ArgAction::Append,
+ )]
+ pub font_paths: Vec<PathBuf>,
+
+ /// Opens the output file after compilation using the default PDF viewer
+ #[arg(long = "open")]
+ pub open: Option<Option<String>>,
+
+ /// The PPI to use if exported as PNG
+ #[arg(long = "ppi", default_value_t = 144.0)]
+ pub ppi: f32,
+
+ /// In which format to emit diagnostics
+ #[clap(
+ long,
+ default_value_t = DiagnosticFormat::Human,
+ value_parser = clap::value_parser!(DiagnosticFormat)
+ )]
+ pub diagnostic_format: DiagnosticFormat,
+
+ /// Produces a flamegraph of the compilation process
+ #[arg(long = "flamegraph", value_name = "OUTPUT_SVG")]
+ pub flamegraph: Option<Option<PathBuf>>,
+}
+
+impl CompileCommand {
+ /// The output path.
+ pub fn output(&self) -> PathBuf {
+ self.output
+ .clone()
+ .unwrap_or_else(|| self.input.with_extension("pdf"))
+ }
+}
+
+/// List all discovered fonts in system and custom font paths
+#[derive(Debug, Clone, Parser)]
+pub struct FontsCommand {
+ /// Add additional directories to search for fonts
+ #[clap(
+ long = "font-path",
+ env = "TYPST_FONT_PATHS",
+ value_name = "DIR",
+ action = ArgAction::Append,
+ )]
+ pub font_paths: Vec<PathBuf>,
+
+ /// Also list style variants of each font family
+ #[arg(long)]
+ pub variants: bool,
+}
+
+/// Which format to use for diagnostics.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)]
+pub enum DiagnosticFormat {
+ Human,
+ Short,
+}
+
+impl Display for DiagnosticFormat {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ self.to_possible_value()
+ .expect("no values are skipped")
+ .get_name()
+ .fmt(f)
+ }
+}
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs
new file mode 100644
index 00000000..3250202b
--- /dev/null
+++ b/crates/typst-cli/src/compile.rs
@@ -0,0 +1,239 @@
+use std::fs;
+use std::path::Path;
+
+use codespan_reporting::diagnostic::{Diagnostic, Label};
+use codespan_reporting::term::{self, termcolor};
+use termcolor::{ColorChoice, StandardStream};
+use typst::diag::{bail, SourceError, StrResult};
+use typst::doc::Document;
+use typst::eval::eco_format;
+use typst::file::FileId;
+use typst::geom::Color;
+use typst::syntax::Source;
+use typst::World;
+
+use crate::args::{CompileCommand, DiagnosticFormat};
+use crate::watch::Status;
+use crate::world::SystemWorld;
+use crate::{color_stream, set_failed};
+
+type CodespanResult<T> = Result<T, CodespanError>;
+type CodespanError = codespan_reporting::files::Error;
+
+/// Execute a compilation command.
+pub fn compile(mut command: CompileCommand) -> StrResult<()> {
+ let mut world = SystemWorld::new(&command)?;
+ compile_once(&mut world, &mut command, false)?;
+ Ok(())
+}
+
+/// Compile a single time.
+///
+/// Returns whether it compiled without errors.
+#[tracing::instrument(skip_all)]
+pub fn compile_once(
+ world: &mut SystemWorld,
+ command: &mut CompileCommand,
+ watching: bool,
+) -> StrResult<()> {
+ tracing::info!("Starting compilation");
+
+ let start = std::time::Instant::now();
+ if watching {
+ Status::Compiling.print(command).unwrap();
+ }
+
+ // Reset everything and ensure that the main file is still present.
+ world.reset();
+ world.source(world.main()).map_err(|err| err.to_string())?;
+
+ let result = typst::compile(world);
+ let duration = start.elapsed();
+
+ match result {
+ // Export the PDF / PNG.
+ Ok(document) => {
+ export(&document, command)?;
+
+ tracing::info!("Compilation succeeded in {duration:?}");
+ if watching {
+ Status::Success(duration).print(command).unwrap();
+ }
+
+ if let Some(open) = command.open.take() {
+ open_file(open.as_deref(), &command.output())?;
+ }
+ }
+
+ // Print diagnostics.
+ Err(errors) => {
+ set_failed();
+ tracing::info!("Compilation failed");
+
+ if watching {
+ Status::Error.print(command).unwrap();
+ }
+
+ print_diagnostics(world, *errors, command.diagnostic_format)
+ .map_err(|_| "failed to print diagnostics")?;
+ }
+ }
+
+ Ok(())
+}
+
+/// Export into the target format.
+fn export(document: &Document, command: &CompileCommand) -> StrResult<()> {
+ match command.output().extension() {
+ Some(ext) if ext.eq_ignore_ascii_case("png") => export_png(document, command),
+ _ => export_pdf(document, command),
+ }
+}
+
+/// Export to a PDF.
+fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> {
+ let output = command.output();
+ let buffer = typst::export::pdf(document);
+ fs::write(output, buffer).map_err(|_| "failed to write PDF file")?;
+ Ok(())
+}
+
+/// Export to one or multiple PNGs.
+fn export_png(document: &Document, command: &CompileCommand) -> StrResult<()> {
+ // Determine whether we have a `{n}` numbering.
+ let output = command.output();
+ let string = output.to_str().unwrap_or_default();
+ let numbered = string.contains("{n}");
+ if !numbered && document.pages.len() > 1 {
+ bail!("cannot export multiple PNGs without `{{n}}` in output path");
+ }
+
+ // Find a number width that accommodates all pages. For instance, the
+ // first page should be numbered "001" if there are between 100 and
+ // 999 pages.
+ let width = 1 + document.pages.len().checked_ilog10().unwrap_or(0) as usize;
+ let mut storage;
+
+ for (i, frame) in document.pages.iter().enumerate() {
+ let pixmap = typst::export::render(frame, command.ppi / 72.0, Color::WHITE);
+ let path = if numbered {
+ storage = string.replace("{n}", &format!("{:0width$}", i + 1));
+ Path::new(&storage)
+ } else {
+ output.as_path()
+ };
+ pixmap.save_png(path).map_err(|_| "failed to write PNG file")?;
+ }
+
+ Ok(())
+}
+
+/// Opens the given file using:
+/// - The default file viewer if `open` is `None`.
+/// - The given viewer provided by `open` if it is `Some`.
+fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> {
+ if let Some(app) = open {
+ open::with_in_background(path, app);
+ } else {
+ open::that_in_background(path);
+ }
+
+ Ok(())
+}
+
+/// Print diagnostic messages to the terminal.
+fn print_diagnostics(
+ world: &SystemWorld,
+ errors: Vec<SourceError>,
+ diagnostic_format: DiagnosticFormat,
+) -> Result<(), codespan_reporting::files::Error> {
+ let mut w = match diagnostic_format {
+ DiagnosticFormat::Human => color_stream(),
+ DiagnosticFormat::Short => StandardStream::stderr(ColorChoice::Never),
+ };
+
+ let mut config = term::Config { tab_width: 2, ..Default::default() };
+ if diagnostic_format == DiagnosticFormat::Short {
+ config.display_style = term::DisplayStyle::Short;
+ }
+
+ for error in errors {
+ // The main diagnostic.
+ let diag = Diagnostic::error()
+ .with_message(error.message)
+ .with_notes(
+ error
+ .hints
+ .iter()
+ .map(|e| (eco_format!("hint: {e}")).into())
+ .collect(),
+ )
+ .with_labels(vec![Label::primary(error.span.id(), error.span.range(world))]);
+
+ term::emit(&mut w, &config, world, &diag)?;
+
+ // Stacktrace-like helper diagnostics.
+ for point in error.trace {
+ let message = point.v.to_string();
+ let help = Diagnostic::help().with_message(message).with_labels(vec![
+ Label::primary(point.span.id(), point.span.range(world)),
+ ]);
+
+ term::emit(&mut w, &config, world, &help)?;
+ }
+ }
+
+ Ok(())
+}
+
+impl<'a> codespan_reporting::files::Files<'a> for SystemWorld {
+ type FileId = FileId;
+ type Name = FileId;
+ type Source = Source;
+
+ fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> {
+ Ok(id)
+ }
+
+ fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> {
+ Ok(self.lookup(id))
+ }
+
+ fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult<usize> {
+ let source = self.lookup(id);
+ source
+ .byte_to_line(given)
+ .ok_or_else(|| CodespanError::IndexTooLarge {
+ given,
+ max: source.len_bytes(),
+ })
+ }
+
+ fn line_range(
+ &'a self,
+ id: FileId,
+ given: usize,
+ ) -> CodespanResult<std::ops::Range<usize>> {
+ let source = self.lookup(id);
+ source
+ .line_to_range(given)
+ .ok_or_else(|| CodespanError::LineTooLarge { given, max: source.len_lines() })
+ }
+
+ fn column_number(
+ &'a self,
+ id: FileId,
+ _: usize,
+ given: usize,
+ ) -> CodespanResult<usize> {
+ let source = self.lookup(id);
+ source.byte_to_column(given).ok_or_else(|| {
+ let max = source.len_bytes();
+ if given <= max {
+ CodespanError::InvalidCharBoundary { given }
+ } else {
+ CodespanError::IndexTooLarge { given, max }
+ }
+ })
+ }
+}
diff --git a/crates/typst-cli/src/fonts.rs b/crates/typst-cli/src/fonts.rs
new file mode 100644
index 00000000..d8209fee
--- /dev/null
+++ b/crates/typst-cli/src/fonts.rs
@@ -0,0 +1,183 @@
+use std::cell::OnceCell;
+use std::env;
+use std::fs::{self, File};
+use std::path::{Path, PathBuf};
+
+use memmap2::Mmap;
+use typst::diag::StrResult;
+use typst::font::{Font, FontBook, FontInfo, FontVariant};
+use typst::util::Bytes;
+use walkdir::WalkDir;
+
+use crate::args::FontsCommand;
+
+/// Execute a font listing command.
+pub fn fonts(command: FontsCommand) -> StrResult<()> {
+ let mut searcher = FontSearcher::new();
+ searcher.search(&command.font_paths);
+
+ for (name, infos) in searcher.book.families() {
+ println!("{name}");
+ if command.variants {
+ for info in infos {
+ let FontVariant { style, weight, stretch } = info.variant;
+ println!("- Style: {style:?}, Weight: {weight:?}, Stretch: {stretch:?}");
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Searches for fonts.
+pub struct FontSearcher {
+ /// Metadata about all discovered fonts.
+ pub book: FontBook,
+ /// Slots that the fonts are loaded into.
+ pub fonts: Vec<FontSlot>,
+}
+
+/// Holds details about the location of a font and lazily the font itself.
+pub struct FontSlot {
+ /// The path at which the font can be found on the system.
+ path: PathBuf,
+ /// The index of the font in its collection. Zero if the path does not point
+ /// to a collection.
+ index: u32,
+ /// The lazily loaded font.
+ font: OnceCell<Option<Font>>,
+}
+
+impl FontSlot {
+ /// Get the font for this slot.
+ pub fn get(&self) -> Option<Font> {
+ self.font
+ .get_or_init(|| {
+ let data = fs::read(&self.path).ok()?.into();
+ Font::new(data, self.index)
+ })
+ .clone()
+ }
+}
+
+impl FontSearcher {
+ /// Create a new, empty system searcher.
+ pub fn new() -> Self {
+ Self { book: FontBook::new(), fonts: vec![] }
+ }
+
+ /// Search everything that is available.
+ pub fn search(&mut self, font_paths: &[PathBuf]) {
+ self.search_system();
+
+ #[cfg(feature = "embed-fonts")]
+ self.add_embedded();
+
+ for path in font_paths {
+ self.search_dir(path)
+ }
+ }
+
+ /// Add fonts that are embedded in the binary.
+ #[cfg(feature = "embed-fonts")]
+ fn add_embedded(&mut self) {
+ let mut process = |bytes: &'static [u8]| {
+ let buffer = Bytes::from_static(bytes);
+ for (i, font) in Font::iter(buffer).enumerate() {
+ self.book.push(font.info().clone());
+ self.fonts.push(FontSlot {
+ path: PathBuf::new(),
+ index: i as u32,
+ font: OnceCell::from(Some(font)),
+ });
+ }
+ };
+
+ macro_rules! add {
+ ($filename:literal) => {
+ process(include_bytes!(concat!("../../../assets/fonts/", $filename)));
+ };
+ }
+
+ // Embed default fonts.
+ add!("LinLibertine_R.ttf");
+ add!("LinLibertine_RB.ttf");
+ add!("LinLibertine_RBI.ttf");
+ add!("LinLibertine_RI.ttf");
+ add!("NewCMMath-Book.otf");
+ add!("NewCMMath-Regular.otf");
+ add!("NewCM10-Regular.otf");
+ add!("NewCM10-Bold.otf");
+ add!("NewCM10-Italic.otf");
+ add!("NewCM10-BoldItalic.otf");
+ add!("DejaVuSansMono.ttf");
+ add!("DejaVuSansMono-Bold.ttf");
+ add!("DejaVuSansMono-Oblique.ttf");
+ add!("DejaVuSansMono-BoldOblique.ttf");
+ }
+
+ /// Search for fonts in the linux system font directories.
+ fn search_system(&mut self) {
+ if cfg!(target_os = "macos") {
+ self.search_dir("/Library/Fonts");
+ self.search_dir("/Network/Library/Fonts");
+ self.search_dir("/System/Library/Fonts");
+ } else if cfg!(unix) {
+ self.search_dir("/usr/share/fonts");
+ self.search_dir("/usr/local/share/fonts");
+ } else if cfg!(windows) {
+ self.search_dir(
+ env::var_os("WINDIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| "C:\\Windows".into())
+ .join("Fonts"),
+ );
+
+ if let Some(roaming) = dirs::config_dir() {
+ self.search_dir(roaming.join("Microsoft\\Windows\\Fonts"));
+ }
+
+ if let Some(local) = dirs::cache_dir() {
+ self.search_dir(local.join("Microsoft\\Windows\\Fonts"));
+ }
+ }
+
+ if let Some(dir) = dirs::font_dir() {
+ self.search_dir(dir);
+ }
+ }
+
+ /// Search for all fonts in a directory recursively.
+ fn search_dir(&mut self, path: impl AsRef<Path>) {
+ for entry in WalkDir::new(path)
+ .follow_links(true)
+ .sort_by(|a, b| a.file_name().cmp(b.file_name()))
+ .into_iter()
+ .filter_map(|e| e.ok())
+ {
+ let path = entry.path();
+ if matches!(
+ path.extension().and_then(|s| s.to_str()),
+ Some("ttf" | "otf" | "TTF" | "OTF" | "ttc" | "otc" | "TTC" | "OTC"),
+ ) {
+ self.search_file(path);
+ }
+ }
+ }
+
+ /// Index the fonts in the file at the given path.
+ fn search_file(&mut self, path: &Path) {
+ if let Ok(file) = File::open(path) {
+ if let Ok(mmap) = unsafe { Mmap::map(&file) } {
+ for (i, info) in FontInfo::iter(&mmap).enumerate() {
+ self.book.push(info);
+ self.fonts.push(FontSlot {
+ path: path.into(),
+ index: i as u32,
+ font: OnceCell::new(),
+ });
+ }
+ }
+ }
+ }
+}
diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs
new file mode 100644
index 00000000..425d05fd
--- /dev/null
+++ b/crates/typst-cli/src/main.rs
@@ -0,0 +1,79 @@
+mod args;
+mod compile;
+mod fonts;
+mod package;
+mod tracing;
+mod watch;
+mod world;
+
+use std::cell::Cell;
+use std::env;
+use std::io::{self, IsTerminal, Write};
+use std::process::ExitCode;
+
+use clap::Parser;
+use codespan_reporting::term::{self, termcolor};
+use termcolor::{ColorChoice, WriteColor};
+
+use crate::args::{CliArguments, Command};
+
+thread_local! {
+ /// The CLI's exit code.
+ static EXIT: Cell<ExitCode> = Cell::new(ExitCode::SUCCESS);
+}
+
+/// Entry point.
+fn main() -> ExitCode {
+ let arguments = CliArguments::parse();
+ let _guard = match crate::tracing::setup_tracing(&arguments) {
+ Ok(guard) => guard,
+ Err(err) => {
+ eprintln!("failed to initialize tracing {}", err);
+ None
+ }
+ };
+
+ let res = match arguments.command {
+ Command::Compile(command) => crate::compile::compile(command),
+ Command::Watch(command) => crate::watch::watch(command),
+ Command::Fonts(command) => crate::fonts::fonts(command),
+ };
+
+ if let Err(msg) = res {
+ set_failed();
+ print_error(&msg).expect("failed to print error");
+ }
+
+ EXIT.with(|cell| cell.get())
+}
+
+/// Ensure a failure exit code.
+fn set_failed() {
+ EXIT.with(|cell| cell.set(ExitCode::FAILURE));
+}
+
+/// Print an application-level error (independent from a source file).
+fn print_error(msg: &str) -> io::Result<()> {
+ let mut w = color_stream();
+ let styles = term::Styles::default();
+
+ w.set_color(&styles.header_error)?;
+ write!(w, "error")?;
+
+ w.reset()?;
+ writeln!(w, ": {msg}.")
+}
+
+/// Get stderr with color support if desirable.
+fn color_stream() -> termcolor::StandardStream {
+ termcolor::StandardStream::stderr(if std::io::stderr().is_terminal() {
+ ColorChoice::Auto
+ } else {
+ ColorChoice::Never
+ })
+}
+
+/// Used by `args.rs`.
+fn typst_version() -> &'static str {
+ env!("TYPST_VERSION")
+}
diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs
new file mode 100644
index 00000000..6853796b
--- /dev/null
+++ b/crates/typst-cli/src/package.rs
@@ -0,0 +1,77 @@
+use std::fs;
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+
+use codespan_reporting::term::{self, termcolor};
+use termcolor::WriteColor;
+use typst::diag::{PackageError, PackageResult};
+use typst::file::PackageSpec;
+
+use super::color_stream;
+
+/// Make a package available in the on-disk cache.
+pub fn prepare_package(spec: &PackageSpec) -> PackageResult<PathBuf> {
+ let subdir =
+ format!("typst/packages/{}/{}-{}", spec.namespace, spec.name, spec.version);
+
+ if let Some(data_dir) = dirs::data_dir() {
+ let dir = data_dir.join(&subdir);
+ if dir.exists() {
+ return Ok(dir);
+ }
+ }
+
+ if let Some(cache_dir) = dirs::cache_dir() {
+ let dir = cache_dir.join(&subdir);
+
+ // Download from network if it doesn't exist yet.
+ if spec.namespace == "preview" && !dir.exists() {
+ download_package(spec, &dir)?;
+ }
+
+ if dir.exists() {
+ return Ok(dir);
+ }
+ }
+
+ Err(PackageError::NotFound(spec.clone()))
+}
+
+/// Download a package over the network.
+fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> {
+ // The `@preview` namespace is the only namespace that supports on-demand
+ // fetching.
+ assert_eq!(spec.namespace, "preview");
+
+ let url = format!(
+ "https://packages.typst.org/preview/{}-{}.tar.gz",
+ spec.name, spec.version
+ );
+
+ print_downloading(spec).unwrap();
+ let reader = match ureq::get(&url).call() {
+ Ok(response) => response.into_reader(),
+ Err(ureq::Error::Status(404, _)) => {
+ return Err(PackageError::NotFound(spec.clone()))
+ }
+ Err(_) => return Err(PackageError::NetworkFailed),
+ };
+
+ let decompressed = flate2::read::GzDecoder::new(reader);
+ tar::Archive::new(decompressed).unpack(package_dir).map_err(|_| {
+ fs::remove_dir_all(package_dir).ok();
+ PackageError::MalformedArchive
+ })
+}
+
+/// Print that a package downloading is happening.
+fn print_downloading(spec: &PackageSpec) -> io::Result<()> {
+ let mut w = color_stream();
+ let styles = term::Styles::default();
+
+ w.set_color(&styles.header_help)?;
+ write!(w, "downloading")?;
+
+ w.reset()?;
+ writeln!(w, " {spec}")
+}
diff --git a/crates/typst-cli/src/tracing.rs b/crates/typst-cli/src/tracing.rs
new file mode 100644
index 00000000..80c2ff65
--- /dev/null
+++ b/crates/typst-cli/src/tracing.rs
@@ -0,0 +1,138 @@
+use std::fs::File;
+use std::io::{self, BufReader, BufWriter, Seek, SeekFrom};
+use std::path::PathBuf;
+
+use inferno::flamegraph::Options;
+use tracing::metadata::LevelFilter;
+use tracing_error::ErrorLayer;
+use tracing_flame::{FlameLayer, FlushGuard};
+use tracing_subscriber::fmt;
+use tracing_subscriber::prelude::*;
+
+use crate::args::{CliArguments, Command};
+
+/// Initializes the tracing system and returns a guard that will flush the
+/// flamegraph to disk when dropped.
+pub fn setup_tracing(args: &CliArguments) -> io::Result<Option<impl Drop>> {
+ let flamegraph = match &args.command {
+ Command::Compile(command) => command.flamegraph.as_ref(),
+ Command::Watch(command) if command.flamegraph.is_some() => {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "cannot use --flamegraph with watch command",
+ ));
+ }
+ _ => None,
+ };
+
+ // Short circuit if we don't need to initialize flamegraph or debugging.
+ if flamegraph.is_none() && args.verbosity == 0 {
+ tracing_subscriber::fmt()
+ .without_time()
+ .with_max_level(level_filter(args))
+ .init();
+
+ return Ok(None);
+ }
+
+ // Build the FMT layer printing to the console.
+ let fmt_layer = fmt::Layer::default().without_time().with_filter(level_filter(args));
+
+ // Error layer for building backtraces
+ let error_layer = ErrorLayer::default();
+
+ // Build the registry.
+ let registry = tracing_subscriber::registry().with(fmt_layer).with(error_layer);
+
+ let Some(path) = flamegraph else {
+ registry.init();
+ return Ok(None);
+ };
+
+ // Create a temporary file to store the flamegraph data.
+ let temp_file = tempfile::tempfile()?;
+ let writer = BufWriter::new(temp_file.try_clone()?);
+
+ // Build the flamegraph layer.
+ let flame_layer = FlameLayer::new(writer)
+ .with_empty_samples(false)
+ .with_threads_collapsed(true)
+ .with_module_path(false)
+ .with_file_and_line(true);
+ let flush_guard = flame_layer.flush_on_drop();
+
+ // Build the subscriber.
+ registry.with(flame_layer).init();
+
+ tracing::warn!(
+ "Flamegraph is enabled, this can create a large temporary \
+ file and slow down the compilation process."
+ );
+
+ Ok(Some(TracingGuard {
+ flush_guard: Some(flush_guard),
+ temp_file,
+ output_svg: path.clone().unwrap_or_else(|| "flamegraph.svg".into()),
+ }))
+}
+
+/// Returns the log level filter for the given verbosity level.
+fn level_filter(args: &CliArguments) -> LevelFilter {
+ match args.verbosity {
+ 0 => LevelFilter::OFF,
+ 1 => LevelFilter::WARN,
+ 2 => LevelFilter::INFO,
+ 3 => LevelFilter::DEBUG,
+ _ => LevelFilter::TRACE,
+ }
+}
+
+/// Will flush the flamegraph to disk when dropped.
+struct TracingGuard {
+ flush_guard: Option<FlushGuard<BufWriter<File>>>,
+ temp_file: File,
+ output_svg: PathBuf,
+}
+
+impl TracingGuard {
+ fn finish(&mut self) -> io::Result<()> {
+ if self.flush_guard.is_none() {
+ return Ok(());
+ }
+
+ tracing::info!("Flushing tracing flamegraph...");
+
+ // At this point, we're done tracing, so we can drop the guard.
+ // This will flush the tracing output to disk.
+ // We can then read the file and generate the flamegraph.
+ drop(self.flush_guard.take());
+
+ // Reset the file pointer to the beginning.
+ self.temp_file.seek(SeekFrom::Start(0))?;
+
+ // Create the readers and writers.
+ let reader = BufReader::new(&mut self.temp_file);
+ let output = BufWriter::new(File::create(&self.output_svg)?);
+
+ // Create the options: default in flame chart mode
+ let mut options = Options::default();
+ options.flame_chart = true;
+
+ inferno::flamegraph::from_reader(&mut options, reader, output)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+
+ Ok(())
+ }
+}
+
+impl Drop for TracingGuard {
+ fn drop(&mut self) {
+ if !std::thread::panicking() {
+ if let Err(e) = self.finish() {
+ // Since we are finished, we cannot rely on tracing to log the
+ // error.
+ eprintln!("failed to flush tracing flamegraph: {e}");
+ }
+ }
+ }
+}
diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs
new file mode 100644
index 00000000..2ad73f85
--- /dev/null
+++ b/crates/typst-cli/src/watch.rs
@@ -0,0 +1,170 @@
+use std::collections::HashSet;
+use std::io::{self, IsTerminal, Write};
+use std::path::{Path, PathBuf};
+
+use codespan_reporting::term::{self, termcolor};
+use notify::{RecommendedWatcher, RecursiveMode, Watcher};
+use same_file::is_same_file;
+use termcolor::WriteColor;
+use typst::diag::StrResult;
+use typst::eval::eco_format;
+
+use crate::args::CompileCommand;
+use crate::color_stream;
+use crate::compile::compile_once;
+use crate::world::SystemWorld;
+
+/// Execute a watching compilation command.
+pub fn watch(mut command: CompileCommand) -> StrResult<()> {
+ // Create the world that serves sources, files, and fonts.
+ let mut world = SystemWorld::new(&command)?;
+
+ // Perform initial compilation.
+ compile_once(&mut world, &mut command, true)?;
+
+ // Setup file watching.
+ let (tx, rx) = std::sync::mpsc::channel();
+ let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())
+ .map_err(|_| "failed to setup file watching")?;
+
+ // Watch all the files that are used by the input file and its dependencies.
+ watch_dependencies(&mut world, &mut watcher, HashSet::new())?;
+
+ // Handle events.
+ let timeout = std::time::Duration::from_millis(100);
+ let output = command.output();
+ loop {
+ let mut recompile = false;
+ for event in rx
+ .recv()
+ .into_iter()
+ .chain(std::iter::from_fn(|| rx.recv_timeout(timeout).ok()))
+ {
+ let event = event.map_err(|_| "failed to watch directory")?;
+ recompile |= is_event_relevant(&event, &output);
+ }
+
+ if recompile {
+ // Retrieve the dependencies of the last compilation.
+ let previous: HashSet<PathBuf> =
+ world.dependencies().map(ToOwned::to_owned).collect();
+
+ // Recompile.
+ compile_once(&mut world, &mut command, true)?;
+ comemo::evict(10);
+
+ // Adjust the watching.
+ watch_dependencies(&mut world, &mut watcher, previous)?;
+ }
+ }
+}
+
+/// Adjust the file watching. Watches all new dependencies and unwatches
+/// all `previous` dependencies that are not relevant anymore.
+#[tracing::instrument(skip_all)]
+fn watch_dependencies(
+ world: &mut SystemWorld,
+ watcher: &mut dyn Watcher,
+ mut previous: HashSet<PathBuf>,
+) -> StrResult<()> {
+ // Watch new paths that weren't watched yet.
+ for path in world.dependencies() {
+ let watched = previous.remove(path);
+ if path.exists() && !watched {
+ tracing::info!("Watching {}", path.display());
+ watcher
+ .watch(path, RecursiveMode::NonRecursive)
+ .map_err(|_| eco_format!("failed to watch {path:?}"))?;
+ }
+ }
+
+ // Unwatch old paths that don't need to be watched anymore.
+ for path in previous {
+ tracing::info!("Unwatching {}", path.display());
+ watcher.unwatch(&path).ok();
+ }
+
+ Ok(())
+}
+
+/// Whether a watch event is relevant for compilation.
+fn is_event_relevant(event: &notify::Event, output: &Path) -> bool {
+ // Never recompile because the output file changed.
+ if event
+ .paths
+ .iter()
+ .all(|path| is_same_file(path, output).unwrap_or(false))
+ {
+ return false;
+ }
+
+ match &event.kind {
+ notify::EventKind::Any => true,
+ notify::EventKind::Access(_) => false,
+ notify::EventKind::Create(_) => true,
+ notify::EventKind::Modify(kind) => match kind {
+ notify::event::ModifyKind::Any => true,
+ notify::event::ModifyKind::Data(_) => true,
+ notify::event::ModifyKind::Metadata(_) => false,
+ notify::event::ModifyKind::Name(_) => true,
+ notify::event::ModifyKind::Other => false,
+ },
+ notify::EventKind::Remove(_) => true,
+ notify::EventKind::Other => false,
+ }
+}
+
+/// The status in which the watcher can be.
+pub enum Status {
+ Compiling,
+ Success(std::time::Duration),
+ Error,
+}
+
+impl Status {
+ /// Clear the terminal and render the status message.
+ pub fn print(&self, command: &CompileCommand) -> io::Result<()> {
+ let output = command.output();
+ let timestamp = chrono::offset::Local::now().format("%H:%M:%S");
+ let color = self.color();
+
+ let mut w = color_stream();
+ if std::io::stderr().is_terminal() {
+ // Clear the terminal.
+ let esc = 27 as char;
+ write!(w, "{esc}c{esc}[1;1H")?;
+ }
+
+ w.set_color(&color)?;
+ write!(w, "watching")?;
+ w.reset()?;
+ writeln!(w, " {}", command.input.display())?;
+
+ w.set_color(&color)?;
+ write!(w, "writing to")?;
+ w.reset()?;
+ writeln!(w, " {}", output.display())?;
+
+ writeln!(w)?;
+ writeln!(w, "[{timestamp}] {}", self.message())?;
+ writeln!(w)?;
+
+ w.flush()
+ }
+
+ fn message(&self) -> String {
+ match self {
+ Self::Compiling => "compiling ...".into(),
+ Self::Success(duration) => format!("compiled successfully in {duration:.2?}"),
+ Self::Error => "compiled with errors".into(),
+ }
+ }
+
+ fn color(&self) -> termcolor::ColorSpec {
+ let styles = term::Styles::default();
+ match self {
+ Self::Error => styles.header_error,
+ _ => styles.header_note,
+ }
+ }
+}
diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs
new file mode 100644
index 00000000..f09a3f6c
--- /dev/null
+++ b/crates/typst-cli/src/world.rs
@@ -0,0 +1,256 @@
+use std::cell::{OnceCell, RefCell, RefMut};
+use std::collections::HashMap;
+use std::fs;
+use std::hash::Hash;
+use std::path::{Path, PathBuf};
+
+use chrono::Datelike;
+use comemo::Prehashed;
+use same_file::Handle;
+use siphasher::sip128::{Hasher128, SipHasher13};
+use typst::diag::{FileError, FileResult, StrResult};
+use typst::eval::{eco_format, Datetime, Library};
+use typst::file::FileId;
+use typst::font::{Font, FontBook};
+use typst::syntax::Source;
+use typst::util::{Bytes, PathExt};
+use typst::World;
+
+use crate::args::CompileCommand;
+use crate::fonts::{FontSearcher, FontSlot};
+use crate::package::prepare_package;
+
+/// A world that provides access to the operating system.
+pub struct SystemWorld {
+ /// The root relative to which absolute paths are resolved.
+ root: PathBuf,
+ /// The input path.
+ main: FileId,
+ /// Typst's standard library.
+ library: Prehashed<Library>,
+ /// Metadata about discovered fonts.
+ book: Prehashed<FontBook>,
+ /// Locations of and storage for lazily loaded fonts.
+ fonts: Vec<FontSlot>,
+ /// Maps package-path combinations to canonical hashes. All package-path
+ /// combinations that point to the same file are mapped to the same hash. To
+ /// be used in conjunction with `paths`.
+ hashes: RefCell<HashMap<FileId, FileResult<PathHash>>>,
+ /// Maps canonical path hashes to source files and buffers.
+ paths: RefCell<HashMap<PathHash, PathSlot>>,
+ /// The current date if requested. This is stored here to ensure it is
+ /// always the same within one compilation. Reset between compilations.
+ today: OnceCell<Option<Datetime>>,
+}
+
+impl SystemWorld {
+ /// Create a new system world.
+ pub fn new(command: &CompileCommand) -> StrResult<Self> {
+ let mut searcher = FontSearcher::new();
+ searcher.search(&command.font_paths);
+
+ // Resolve the system-global input path.
+ let system_input = command.input.canonicalize().map_err(|_| {
+ eco_format!("input file not found (searched at {})", command.input.display())
+ })?;
+
+ // Resolve the system-global root directory.
+ let root = {
+ let path = command
+ .root
+ .as_deref()
+ .or_else(|| system_input.parent())
+ .unwrap_or(Path::new("."));
+ path.canonicalize().map_err(|_| {
+ eco_format!("root directory not found (searched at {})", path.display())
+ })?
+ };
+
+ // Resolve the input path within the project.
+ let project_input = system_input
+ .strip_prefix(&root)
+ .map(|path| Path::new("/").join(path))
+ .map_err(|_| "input file must be contained in project root")?;
+
+ Ok(Self {
+ root,
+ main: FileId::new(None, &project_input),
+ library: Prehashed::new(typst_library::build()),
+ book: Prehashed::new(searcher.book),
+ fonts: searcher.fonts,
+ hashes: RefCell::default(),
+ paths: RefCell::default(),
+ today: OnceCell::new(),
+ })
+ }
+
+ /// The id of the main source file.
+ pub fn main(&self) -> FileId {
+ self.main
+ }
+
+ /// Return all paths the last compilation depended on.
+ pub fn dependencies(&mut self) -> impl Iterator<Item = &Path> {
+ self.paths.get_mut().values().map(|slot| slot.system_path.as_path())
+ }
+
+ /// Reset the compilation state in preparation of a new compilation.
+ pub fn reset(&mut self) {
+ self.hashes.borrow_mut().clear();
+ self.paths.borrow_mut().clear();
+ self.today.take();
+ }
+
+ /// Lookup a source file by id.
+ #[track_caller]
+ pub fn lookup(&self, id: FileId) -> Source {
+ self.source(id).expect("file id does not point to any source file")
+ }
+}
+
+impl World for SystemWorld {
+ fn library(&self) -> &Prehashed<Library> {
+ &self.library
+ }
+
+ fn book(&self) -> &Prehashed<FontBook> {
+ &self.book
+ }
+
+ fn main(&self) -> Source {
+ self.source(self.main).unwrap()
+ }
+
+ fn source(&self, id: FileId) -> FileResult<Source> {
+ self.slot(id)?.source()
+ }
+
+ fn file(&self, id: FileId) -> FileResult<Bytes> {
+ self.slot(id)?.file()
+ }
+
+ fn font(&self, index: usize) -> Option<Font> {
+ self.fonts[index].get()
+ }
+
+ fn today(&self, offset: Option<i64>) -> Option<Datetime> {
+ *self.today.get_or_init(|| {
+ let naive = match offset {
+ None => chrono::Local::now().naive_local(),
+ Some(o) => (chrono::Utc::now() + chrono::Duration::hours(o)).naive_utc(),
+ };
+
+ Datetime::from_ymd(
+ naive.year(),
+ naive.month().try_into().ok()?,
+ naive.day().try_into().ok()?,
+ )
+ })
+ }
+}
+
+impl SystemWorld {
+ /// Access the canonical slot for the given file id.
+ #[tracing::instrument(skip_all)]
+ fn slot(&self, id: FileId) -> FileResult<RefMut<PathSlot>> {
+ let mut system_path = PathBuf::new();
+ let hash = self
+ .hashes
+ .borrow_mut()
+ .entry(id)
+ .or_insert_with(|| {
+ // Determine the root path relative to which the file path
+ // will be resolved.
+ let root = match id.package() {
+ Some(spec) => prepare_package(spec)?,
+ None => self.root.clone(),
+ };
+
+ // Join the path to the root. If it tries to escape, deny
+ // access. Note: It can still escape via symlinks.
+ system_path =
+ root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?;
+
+ PathHash::new(&system_path)
+ })
+ .clone()?;
+
+ Ok(RefMut::map(self.paths.borrow_mut(), |paths| {
+ paths.entry(hash).or_insert_with(|| PathSlot {
+ id,
+ // This will only trigger if the `or_insert_with` above also
+ // triggered.
+ system_path,
+ source: OnceCell::new(),
+ buffer: OnceCell::new(),
+ })
+ }))
+ }
+}
+
+/// Holds canonical data for all paths pointing to the same entity.
+///
+/// Both fields can be populated if the file is both imported and read().
+struct PathSlot {
+ /// The slot's canonical file id.
+ id: FileId,
+ /// The slot's path on the system.
+ system_path: PathBuf,
+ /// The lazily loaded source file for a path hash.
+ source: OnceCell<FileResult<Source>>,
+ /// The lazily loaded buffer for a path hash.
+ buffer: OnceCell<FileResult<Bytes>>,
+}
+
+impl PathSlot {
+ fn source(&self) -> FileResult<Source> {
+ self.source
+ .get_or_init(|| {
+ let buf = read(&self.system_path)?;
+ let text = decode_utf8(buf)?;
+ Ok(Source::new(self.id, text))
+ })
+ .clone()
+ }
+
+ fn file(&self) -> FileResult<Bytes> {
+ self.buffer
+ .get_or_init(|| read(&self.system_path).map(Bytes::from))
+ .clone()
+ }
+}
+
+/// A hash that is the same for all paths pointing to the same entity.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+struct PathHash(u128);
+
+impl PathHash {
+ fn new(path: &Path) -> FileResult<Self> {
+ let f = |e| FileError::from_io(e, path);
+ let handle = Handle::from_path(path).map_err(f)?;
+ let mut state = SipHasher13::new();
+ handle.hash(&mut state);
+ Ok(Self(state.finish128().as_u128()))
+ }
+}
+
+/// Read a file.
+fn read(path: &Path) -> FileResult<Vec<u8>> {
+ let f = |e| FileError::from_io(e, path);
+ if fs::metadata(path).map_err(f)?.is_dir() {
+ Err(FileError::IsDirectory)
+ } else {
+ fs::read(path).map_err(f)
+ }
+}
+
+/// Decode UTF-8 with an optional BOM.
+fn decode_utf8(buf: Vec<u8>) -> FileResult<String> {
+ Ok(if buf.starts_with(b"\xef\xbb\xbf") {
+ // Remove UTF-8 BOM.
+ std::str::from_utf8(&buf[3..])?.into()
+ } else {
+ // Assume UTF-8.
+ String::from_utf8(buf)?
+ })
+}
diff --git a/crates/typst-docs/Cargo.toml b/crates/typst-docs/Cargo.toml
new file mode 100644
index 00000000..47088815
--- /dev/null
+++ b/crates/typst-docs/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "typst-docs"
+version.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+edition.workspace = true
+publish = false
+
+[lib]
+doctest = false
+bench = false
+
+[dependencies]
+typst = { path = "../typst" }
+typst-library = { path = "../typst-library" }
+comemo = "0.3"
+heck = "0.4"
+include_dir = "0.7"
+once_cell = "1"
+pulldown-cmark = "0.9"
+serde = { version = "1", features = ["derive"] }
+serde_yaml = "0.8"
+syntect = { version = "5", default-features = false, features = ["parsing", "html", "regex-fancy"] }
+typed-arena = "2"
+unicode_names2 = "0.6.0"
+unscanny = "0.1"
+yaml-front-matter = "0.1"
diff --git a/crates/typst-docs/src/contribs.rs b/crates/typst-docs/src/contribs.rs
new file mode 100644
index 00000000..901b5f69
--- /dev/null
+++ b/crates/typst-docs/src/contribs.rs
@@ -0,0 +1,86 @@
+use std::cmp::Reverse;
+use std::collections::HashMap;
+use std::fmt::Write;
+
+use serde::Deserialize;
+
+use super::{Html, Resolver};
+
+/// Build HTML detailing the contributors between two tags.
+pub fn contributors(resolver: &dyn Resolver, from: &str, to: &str) -> Option<Html> {
+ let staff = ["laurmaedje", "reknih"];
+
+ // Determine number of contributions per person.
+ let mut contributors = HashMap::<String, Contributor>::new();
+ for commit in resolver.commits(from, to) {
+ contributors
+ .entry(commit.author.login.clone())
+ .or_insert_with(|| Contributor {
+ login: commit.author.login,
+ avatar: commit.author.avatar_url,
+ contributions: 0,
+ })
+ .contributions += 1;
+ }
+
+ // Keep only non-staff people.
+ let mut contributors: Vec<_> = contributors
+ .into_values()
+ .filter(|c| !staff.contains(&c.login.as_str()))
+ .collect();
+
+ // Sort by highest number of commits.
+ contributors.sort_by_key(|c| (Reverse(c.contributions), c.login.clone()));
+ if contributors.is_empty() {
+ return None;
+ }
+
+ let mut html = "Thanks to everyone who contributed to this release!".to_string();
+ html += "<ul class=\"contribs\">";
+
+ for Contributor { login, avatar, contributions } in contributors {
+ let login = login.replace('\"', "&quot;").replace('&', "&amp;");
+ let avatar = avatar.replace("?v=", "?s=64&v=");
+ let s = if contributions > 1 { "s" } else { "" };
+ write!(
+ html,
+ r#"<li>
+ <a href="https://github.com/{login}" target="_blank">
+ <img
+ width="64"
+ height="64"
+ src="{avatar}"
+ alt="GitHub avatar of {login}"
+ title="@{login} made {contributions} contribution{s}"
+ crossorigin="anonymous"
+ >
+ </a>
+ </li>"#
+ )
+ .unwrap();
+ }
+
+ html += "</ul>";
+
+ Some(Html::new(html))
+}
+
+#[derive(Debug)]
+struct Contributor {
+ login: String,
+ avatar: String,
+ contributions: usize,
+}
+
+/// A commit on the `typst` repository.
+#[derive(Debug, Deserialize)]
+pub struct Commit {
+ author: Author,
+}
+
+/// A commit author.
+#[derive(Debug, Deserialize)]
+pub struct Author {
+ login: String,
+ avatar_url: String,
+}
diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs
new file mode 100644
index 00000000..0d40f46b
--- /dev/null
+++ b/crates/typst-docs/src/html.rs
@@ -0,0 +1,513 @@
+use std::ops::Range;
+
+use comemo::Prehashed;
+use pulldown_cmark as md;
+use typed_arena::Arena;
+use typst::diag::FileResult;
+use typst::eval::Datetime;
+use typst::file::FileId;
+use typst::font::{Font, FontBook};
+use typst::geom::{Point, Size};
+use typst::syntax::Source;
+use typst::util::Bytes;
+use typst::World;
+use yaml_front_matter::YamlFrontMatter;
+
+use super::*;
+
+/// HTML documentation.
+#[derive(Serialize)]
+#[serde(transparent)]
+pub struct Html {
+ raw: String,
+ #[serde(skip)]
+ md: String,
+ #[serde(skip)]
+ description: Option<String>,
+ #[serde(skip)]
+ outline: Vec<OutlineItem>,
+}
+
+impl Html {
+ /// Create HTML from a raw string.
+ pub fn new(raw: String) -> Self {
+ Self {
+ md: String::new(),
+ raw,
+ description: None,
+ outline: vec![],
+ }
+ }
+
+ /// Convert markdown to HTML.
+ #[track_caller]
+ pub fn markdown(resolver: &dyn Resolver, md: &str) -> Self {
+ Self::markdown_with_id_base(resolver, md, "")
+ }
+
+ /// Convert markdown to HTML, preceding all fragment identifiers with the
+ /// `id_base`.
+ #[track_caller]
+ pub fn markdown_with_id_base(
+ resolver: &dyn Resolver,
+ md: &str,
+ id_base: &str,
+ ) -> Self {
+ let mut text = md;
+ let mut description = None;
+ let document = YamlFrontMatter::parse::<Metadata>(md);
+ if let Ok(document) = &document {
+ text = &document.content;
+ description = Some(document.metadata.description.clone())
+ }
+
+ let options = md::Options::ENABLE_TABLES | md::Options::ENABLE_HEADING_ATTRIBUTES;
+
+ let ids = Arena::new();
+ let mut handler = Handler::new(resolver, id_base.into(), &ids);
+ let iter = md::Parser::new_ext(text, options)
+ .filter_map(|mut event| handler.handle(&mut event).then_some(event));
+
+ let mut raw = String::new();
+ md::html::push_html(&mut raw, iter);
+ raw.truncate(raw.trim_end().len());
+
+ Html {
+ md: text.into(),
+ raw,
+ description,
+ outline: handler.outline,
+ }
+ }
+
+ /// The raw HTML.
+ pub fn as_str(&self) -> &str {
+ &self.raw
+ }
+
+ /// The original Markdown, if any.
+ pub fn md(&self) -> &str {
+ &self.md
+ }
+
+ /// The title of the HTML.
+ ///
+ /// Returns `None` if the HTML doesn't start with an `h1` tag.
+ pub fn title(&self) -> Option<&str> {
+ let mut s = Scanner::new(&self.raw);
+ s.eat_if("<h1>").then(|| s.eat_until("</h1>"))
+ }
+
+ /// The outline of the HTML.
+ pub fn outline(&self) -> Vec<OutlineItem> {
+ self.outline.clone()
+ }
+
+ /// The description from the front matter.
+ pub fn description(&self) -> Option<String> {
+ self.description.clone()
+ }
+}
+
+impl Debug for Html {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Html({:?})", self.title().unwrap_or(".."))
+ }
+}
+
+/// Front matter metadata.
+#[derive(Deserialize)]
+struct Metadata {
+ description: String,
+}
+
+struct Handler<'a> {
+ resolver: &'a dyn Resolver,
+ lang: Option<String>,
+ code: String,
+ outline: Vec<OutlineItem>,
+ id_base: String,
+ ids: &'a Arena<String>,
+}
+
+impl<'a> Handler<'a> {
+ fn new(resolver: &'a dyn Resolver, id_base: String, ids: &'a Arena<String>) -> Self {
+ Self {
+ resolver,
+ lang: None,
+ code: String::new(),
+ outline: vec![],
+ id_base,
+ ids,
+ }
+ }
+
+ fn handle(&mut self, event: &mut md::Event<'a>) -> bool {
+ match event {
+ // Rewrite Markdown images.
+ md::Event::Start(md::Tag::Image(_, path, _)) => {
+ *path = self.handle_image(path).into();
+ }
+
+ // Rewrite HTML images.
+ md::Event::Html(html) if html.starts_with("<img") => {
+ let range = html_attr_range(html, "src").unwrap();
+ let path = &html[range.clone()];
+ let mut buf = html.to_string();
+ buf.replace_range(range, &self.handle_image(path));
+ *html = buf.into();
+ }
+
+ // Register HTML headings for the outline.
+ md::Event::Start(md::Tag::Heading(level, Some(id), _)) => {
+ self.handle_heading(id, level);
+ }
+
+ // Also handle heading closings.
+ md::Event::End(md::Tag::Heading(level, Some(_), _)) => {
+ if *level > md::HeadingLevel::H1 && !self.id_base.is_empty() {
+ nest_heading(level);
+ }
+ }
+
+ // Rewrite contributor sections.
+ md::Event::Html(html) if html.starts_with("<contributors") => {
+ let from = html_attr(html, "from").unwrap();
+ let to = html_attr(html, "to").unwrap();
+ let Some(output) = contributors(self.resolver, from, to) else { return false };
+ *html = output.raw.into();
+ }
+
+ // Rewrite links.
+ md::Event::Start(md::Tag::Link(ty, dest, _)) => {
+ assert!(
+ matches!(ty, md::LinkType::Inline | md::LinkType::Reference),
+ "unsupported link type: {ty:?}",
+ );
+
+ *dest = self
+ .handle_link(dest)
+ .unwrap_or_else(|| panic!("invalid link: {dest}"))
+ .into();
+ }
+
+ // Inline raw.
+ md::Event::Code(code) => {
+ let mut chars = code.chars();
+ let parser = match (chars.next(), chars.next_back()) {
+ (Some('['), Some(']')) => typst::syntax::parse,
+ (Some('{'), Some('}')) => typst::syntax::parse_code,
+ _ => return true,
+ };
+
+ let root = parser(&code[1..code.len() - 1]);
+ let html = typst::ide::highlight_html(&root);
+ *event = md::Event::Html(html.into());
+ }
+
+ // Code blocks.
+ md::Event::Start(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(lang))) => {
+ self.lang = Some(lang.as_ref().into());
+ self.code = String::new();
+ return false;
+ }
+ md::Event::End(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(_))) => {
+ let Some(lang) = self.lang.take() else { return false };
+ let html = code_block(self.resolver, &lang, &self.code);
+ *event = md::Event::Html(html.raw.into());
+ }
+
+ // Example with preview.
+ md::Event::Text(text) => {
+ if self.lang.is_some() {
+ self.code.push_str(text);
+ return false;
+ }
+ }
+
+ _ => {}
+ }
+
+ true
+ }
+
+ fn handle_image(&self, link: &str) -> String {
+ if let Some(file) = FILES.get_file(link) {
+ self.resolver.image(link, file.contents())
+ } else if let Some(url) = self.resolver.link(link) {
+ url
+ } else {
+ panic!("missing image: {link}")
+ }
+ }
+
+ fn handle_heading(&mut self, id: &mut &'a str, level: &mut md::HeadingLevel) {
+ if *level == md::HeadingLevel::H1 {
+ return;
+ }
+
+ // Special case for things like "v0.3.0".
+ let name = if id.starts_with('v') && id.contains('.') {
+ id.to_string()
+ } else {
+ id.to_title_case()
+ };
+
+ let mut children = &mut self.outline;
+ let mut depth = *level as usize;
+ while depth > 2 {
+ if !children.is_empty() {
+ children = &mut children.last_mut().unwrap().children;
+ }
+ depth -= 1;
+ }
+
+ // Put base before id.
+ if !self.id_base.is_empty() {
+ nest_heading(level);
+ *id = self.ids.alloc(format!("{}-{id}", self.id_base)).as_str();
+ }
+
+ children.push(OutlineItem { id: id.to_string(), name, children: vec![] });
+ }
+
+ fn handle_link(&self, link: &str) -> Option<String> {
+ if link.starts_with('#') || link.starts_with("http") {
+ return Some(link.into());
+ }
+
+ if !link.starts_with('$') {
+ return self.resolver.link(link);
+ }
+
+ let root = link.split('/').next()?;
+ let rest = &link[root.len()..].trim_matches('/');
+ let base = match root {
+ "$tutorial" => "/docs/tutorial/",
+ "$reference" => "/docs/reference/",
+ "$category" => "/docs/reference/",
+ "$syntax" => "/docs/reference/syntax/",
+ "$styling" => "/docs/reference/styling/",
+ "$scripting" => "/docs/reference/scripting/",
+ "$types" => "/docs/reference/types/",
+ "$type" => "/docs/reference/types/",
+ "$func" => "/docs/reference/",
+ "$guides" => "/docs/guides/",
+ "$packages" => "/docs/packages/",
+ "$changelog" => "/docs/changelog/",
+ "$community" => "/docs/community/",
+ _ => panic!("unknown link root: {root}"),
+ };
+
+ let mut route = base.to_string();
+ if root == "$type" && rest.contains('.') {
+ let mut parts = rest.split('.');
+ let ty = parts.next()?;
+ let method = parts.next()?;
+ route.push_str(ty);
+ route.push_str("/#methods-");
+ route.push_str(method);
+ } else if root == "$func" {
+ let mut parts = rest.split('.').peekable();
+ let first = parts.peek().copied();
+ let mut focus = &LIBRARY.global;
+ while let Some(m) = first.and_then(|name| module(focus, name).ok()) {
+ focus = m;
+ parts.next();
+ }
+
+ let name = parts.next()?;
+
+ let value = focus.get(name).ok()?;
+ let Value::Func(func) = value else { return None };
+ let info = func.info()?;
+ route.push_str(info.category);
+ route.push('/');
+
+ if let Some(group) = GROUPS
+ .iter()
+ .filter(|_| first == Some("math"))
+ .find(|group| group.functions.iter().any(|func| func == info.name))
+ {
+ route.push_str(&group.name);
+ route.push_str("/#");
+ route.push_str(info.name);
+ if let Some(param) = parts.next() {
+ route.push_str("-parameters-");
+ route.push_str(param);
+ }
+ } else {
+ route.push_str(name);
+ route.push('/');
+ if let Some(next) = parts.next() {
+ if info.params.iter().any(|param| param.name == next) {
+ route.push_str("#parameters-");
+ route.push_str(next);
+ } else if info.scope.iter().any(|(name, _)| name == next) {
+ route.push('#');
+ route.push_str(info.name);
+ route.push('-');
+ route.push_str(next);
+ } else {
+ return None;
+ }
+ }
+ }
+ } else {
+ route.push_str(rest);
+ }
+
+ if !route.contains('#') && !route.ends_with('/') {
+ route.push('/');
+ }
+
+ Some(route)
+ }
+}
+
+/// Render a code block to HTML.
+fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html {
+ let mut display = String::new();
+ let mut compile = String::new();
+ for line in text.lines() {
+ if let Some(suffix) = line.strip_prefix(">>>") {
+ compile.push_str(suffix);
+ compile.push('\n');
+ } else if let Some(suffix) = line.strip_prefix("<<< ") {
+ display.push_str(suffix);
+ display.push('\n');
+ } else {
+ display.push_str(line);
+ display.push('\n');
+ compile.push_str(line);
+ compile.push('\n');
+ }
+ }
+
+ let mut parts = lang.split(':');
+ let lang = parts.next().unwrap_or(lang);
+
+ let mut zoom: Option<[Abs; 4]> = None;
+ let mut single = false;
+ if let Some(args) = parts.next() {
+ single = true;
+ if !args.contains("single") {
+ zoom = args
+ .split(',')
+ .take(4)
+ .map(|s| Abs::pt(s.parse().unwrap()))
+ .collect::<Vec<_>>()
+ .try_into()
+ .ok();
+ }
+ }
+
+ if lang.is_empty() {
+ let mut buf = String::from("<pre>");
+ md::escape::escape_html(&mut buf, &display).unwrap();
+ buf.push_str("</pre>");
+ return Html::new(buf);
+ } else if !matches!(lang, "example" | "typ") {
+ let set = &*typst_library::text::SYNTAXES;
+ let buf = syntect::html::highlighted_html_for_string(
+ &display,
+ set,
+ set.find_syntax_by_token(lang)
+ .unwrap_or_else(|| panic!("unsupported highlighting language: {lang}")),
+ &typst_library::text::THEME,
+ )
+ .expect("failed to highlight code");
+ return Html::new(buf);
+ }
+
+ let root = typst::syntax::parse(&display);
+ let highlighted = Html::new(typst::ide::highlight_html(&root));
+ if lang == "typ" {
+ return Html::new(format!("<pre>{}</pre>", highlighted.as_str()));
+ }
+
+ let id = FileId::new(None, Path::new("/main.typ"));
+ let source = Source::new(id, compile);
+ let world = DocWorld(source);
+ let mut frames = match typst::compile(&world) {
+ Ok(doc) => doc.pages,
+ Err(err) => {
+ let msg = &err[0].message;
+ panic!("while trying to compile:\n{text}:\n\nerror: {msg}");
+ }
+ };
+
+ if let Some([x, y, w, h]) = zoom {
+ frames[0].translate(Point::new(-x, -y));
+ *frames[0].size_mut() = Size::new(w, h);
+ }
+
+ if single {
+ frames.truncate(1);
+ }
+
+ resolver.example(highlighted, &frames)
+}
+
+/// Extract an attribute value from an HTML element.
+fn html_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> {
+ html.get(html_attr_range(html, attr)?)
+}
+
+/// Extract the range of the attribute value of an HTML element.
+fn html_attr_range(html: &str, attr: &str) -> Option<Range<usize>> {
+ let needle = format!("{attr}=\"");
+ let offset = html.find(&needle)? + needle.len();
+ let len = html[offset..].find('"')?;
+ Some(offset..offset + len)
+}
+
+/// Increase the nesting level of a Markdown heading.
+fn nest_heading(level: &mut md::HeadingLevel) {
+ *level = match &level {
+ md::HeadingLevel::H1 => md::HeadingLevel::H2,
+ md::HeadingLevel::H2 => md::HeadingLevel::H3,
+ md::HeadingLevel::H3 => md::HeadingLevel::H4,
+ md::HeadingLevel::H4 => md::HeadingLevel::H5,
+ md::HeadingLevel::H5 => md::HeadingLevel::H6,
+ v => **v,
+ };
+}
+
+/// A world for example compilations.
+struct DocWorld(Source);
+
+impl World for DocWorld {
+ fn library(&self) -> &Prehashed<Library> {
+ &LIBRARY
+ }
+
+ fn book(&self) -> &Prehashed<FontBook> {
+ &FONTS.0
+ }
+
+ fn main(&self) -> Source {
+ self.0.clone()
+ }
+
+ fn source(&self, _: FileId) -> FileResult<Source> {
+ Ok(self.0.clone())
+ }
+
+ fn file(&self, id: FileId) -> FileResult<Bytes> {
+ assert!(id.package().is_none());
+ Ok(FILES
+ .get_file(id.path().strip_prefix("/").unwrap())
+ .unwrap_or_else(|| panic!("failed to load {:?}", id.path().display()))
+ .contents()
+ .into())
+ }
+
+ fn font(&self, index: usize) -> Option<Font> {
+ Some(FONTS.1[index].clone())
+ }
+
+ fn today(&self, _: Option<i64>) -> Option<Datetime> {
+ Some(Datetime::from_ymd(1970, 1, 1).unwrap())
+ }
+}
diff --git a/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs
new file mode 100644
index 00000000..d7f816d2
--- /dev/null
+++ b/crates/typst-docs/src/lib.rs
@@ -0,0 +1,1002 @@
+//! Documentation provider for Typst.
+
+mod contribs;
+mod html;
+
+pub use contribs::{contributors, Author, Commit};
+pub use html::Html;
+
+use std::fmt::{self, Debug, Formatter};
+use std::path::Path;
+
+use comemo::Prehashed;
+use heck::ToTitleCase;
+use include_dir::{include_dir, Dir};
+use once_cell::sync::Lazy;
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+use serde_yaml as yaml;
+use typst::doc::Frame;
+use typst::eval::{CastInfo, Func, FuncInfo, Library, Module, ParamInfo, Value};
+use typst::font::{Font, FontBook};
+use typst::geom::{Abs, Smart};
+use typst_library::layout::{Margin, PageElem};
+use unscanny::Scanner;
+
+static DOCS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../docs");
+static FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/files");
+static DETAILS: Lazy<yaml::Mapping> = Lazy::new(|| yaml("reference/details.yml"));
+static GROUPS: Lazy<Vec<GroupData>> = Lazy::new(|| yaml("reference/groups.yml"));
+
+static FONTS: Lazy<(Prehashed<FontBook>, Vec<Font>)> = Lazy::new(|| {
+ static DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/fonts");
+ let fonts: Vec<_> = DIR
+ .files()
+ .flat_map(|file| Font::iter(file.contents().into()))
+ .collect();
+ let book = FontBook::from_fonts(&fonts);
+ (Prehashed::new(book), fonts)
+});
+
+static LIBRARY: Lazy<Prehashed<Library>> = Lazy::new(|| {
+ let mut lib = typst_library::build();
+ lib.styles
+ .set(PageElem::set_width(Smart::Custom(Abs::pt(240.0).into())));
+ lib.styles.set(PageElem::set_height(Smart::Auto));
+ lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom(
+ Abs::pt(15.0).into(),
+ )))));
+ typst::eval::set_lang_items(lib.items.clone());
+ Prehashed::new(lib)
+});
+
+/// Build documentation pages.
+pub fn provide(resolver: &dyn Resolver) -> Vec<PageModel> {
+ vec![
+ markdown_page(resolver, "/docs/", "overview.md").with_route("/docs/"),
+ tutorial_pages(resolver),
+ reference_pages(resolver),
+ guides_pages(resolver),
+ packages_page(resolver),
+ markdown_page(resolver, "/docs/", "changelog.md"),
+ markdown_page(resolver, "/docs/", "community.md"),
+ ]
+}
+
+/// Resolve consumer dependencies.
+pub trait Resolver {
+ /// Try to resolve a link that the system cannot resolve itself.
+ fn link(&self, link: &str) -> Option<String>;
+
+ /// Produce an URL for an image file.
+ fn image(&self, filename: &str, data: &[u8]) -> String;
+
+ /// Produce HTML for an example.
+ fn example(&self, source: Html, frames: &[Frame]) -> Html;
+
+ /// Determine the commits between two tags.
+ fn commits(&self, from: &str, to: &str) -> Vec<Commit>;
+}
+
+/// Details about a documentation page and its children.
+#[derive(Debug, Serialize)]
+pub struct PageModel {
+ pub route: String,
+ pub title: String,
+ pub description: String,
+ pub part: Option<&'static str>,
+ pub outline: Vec<OutlineItem>,
+ pub body: BodyModel,
+ pub children: Vec<Self>,
+}
+
+impl PageModel {
+ fn with_route(self, route: &str) -> Self {
+ Self { route: route.into(), ..self }
+ }
+
+ fn with_part(self, part: &'static str) -> Self {
+ Self { part: Some(part), ..self }
+ }
+}
+
+/// An element in the "On This Page" outline.
+#[derive(Debug, Clone, Serialize)]
+pub struct OutlineItem {
+ id: String,
+ name: String,
+ children: Vec<Self>,
+}
+
+/// Details about the body of a documentation page.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+#[serde(tag = "kind", content = "content")]
+pub enum BodyModel {
+ Html(Html),
+ Category(CategoryModel),
+ Func(FuncModel),
+ Funcs(FuncsModel),
+ Type(TypeModel),
+ Symbols(SymbolsModel),
+ Packages(Html),
+}
+
+/// Build the tutorial.
+fn tutorial_pages(resolver: &dyn Resolver) -> PageModel {
+ let mut page = markdown_page(resolver, "/docs/", "tutorial/welcome.md");
+ page.children = DOCS
+ .get_dir("tutorial")
+ .unwrap()
+ .files()
+ .filter(|file| file.path() != Path::new("tutorial/welcome.md"))
+ .map(|file| markdown_page(resolver, "/docs/tutorial/", file.path()))
+ .collect();
+ page
+}
+
+/// Build the reference.
+fn reference_pages(resolver: &dyn Resolver) -> PageModel {
+ let mut page = markdown_page(resolver, "/docs/", "reference/welcome.md");
+ page.children = vec![
+ markdown_page(resolver, "/docs/reference/", "reference/syntax.md")
+ .with_part("Language"),
+ markdown_page(resolver, "/docs/reference/", "reference/styling.md"),
+ markdown_page(resolver, "/docs/reference/", "reference/scripting.md"),
+ types_page(resolver, "/docs/reference/"),
+ category_page(resolver, "text").with_part("Content"),
+ category_page(resolver, "math"),
+ category_page(resolver, "layout"),
+ category_page(resolver, "visualize"),
+ category_page(resolver, "meta"),
+ category_page(resolver, "symbols"),
+ category_page(resolver, "foundations").with_part("Compute"),
+ category_page(resolver, "calculate"),
+ category_page(resolver, "construct"),
+ category_page(resolver, "data-loading"),
+ ];
+ page
+}
+
+/// Build the guides section.
+fn guides_pages(resolver: &dyn Resolver) -> PageModel {
+ let mut page = markdown_page(resolver, "/docs/", "guides/welcome.md");
+ page.children =
+ vec![markdown_page(resolver, "/docs/guides/", "guides/guide-for-latex-users.md")];
+ page
+}
+
+/// Build the packages section.
+fn packages_page(resolver: &dyn Resolver) -> PageModel {
+ PageModel {
+ route: "/docs/packages/".into(),
+ title: "Packages".into(),
+ description: "Packages for Typst.".into(),
+ part: None,
+ outline: vec![],
+ body: BodyModel::Packages(Html::markdown(resolver, details("packages"))),
+ children: vec![],
+ }
+}
+
+/// Create a page from a markdown file.
+#[track_caller]
+fn markdown_page(
+ resolver: &dyn Resolver,
+ parent: &str,
+ path: impl AsRef<Path>,
+) -> PageModel {
+ assert!(parent.starts_with('/') && parent.ends_with('/'));
+ let md = DOCS.get_file(path).unwrap().contents_utf8().unwrap();
+ let html = Html::markdown(resolver, md);
+ let title = html.title().expect("chapter lacks a title").to_string();
+ PageModel {
+ route: format!("{parent}{}/", urlify(&title)),
+ title,
+ description: html.description().unwrap(),
+ part: None,
+ outline: html.outline(),
+ body: BodyModel::Html(html),
+ children: vec![],
+ }
+}
+
+/// Details about a category.
+#[derive(Debug, Serialize)]
+pub struct CategoryModel {
+ pub name: String,
+ pub details: Html,
+ pub kind: &'static str,
+ pub items: Vec<CategoryItem>,
+}
+
+/// Details about a category item.
+#[derive(Debug, Serialize)]
+pub struct CategoryItem {
+ pub name: String,
+ pub route: String,
+ pub oneliner: String,
+ pub code: bool,
+}
+
+/// Create a page for a category.
+#[track_caller]
+fn category_page(resolver: &dyn Resolver, category: &str) -> PageModel {
+ let route = format!("/docs/reference/{category}/");
+ let mut children = vec![];
+ let mut items = vec![];
+
+ let focus = match category {
+ "math" => &LIBRARY.math,
+ "calculate" => module(&LIBRARY.global, "calc").unwrap(),
+ _ => &LIBRARY.global,
+ };
+
+ let parents: &[&str] = match category {
+ "math" => &[],
+ "calculate" => &["calc"],
+ _ => &[],
+ };
+
+ let grouped = match category {
+ "math" => GROUPS.as_slice(),
+ _ => &[],
+ };
+
+ // Add functions.
+ for (_, value) in focus.scope().iter() {
+ let Value::Func(func) = value else { continue };
+ let Some(info) = func.info() else { continue };
+ if info.category != category {
+ continue;
+ }
+
+ // Skip grouped functions.
+ if grouped
+ .iter()
+ .flat_map(|group| &group.functions)
+ .any(|f| f == info.name)
+ {
+ continue;
+ }
+
+ let subpage = function_page(resolver, &route, func, info, parents);
+ items.push(CategoryItem {
+ name: info.name.into(),
+ route: subpage.route.clone(),
+ oneliner: oneliner(info.docs).into(),
+ code: true,
+ });
+ children.push(subpage);
+ }
+
+ // Add grouped functions.
+ for group in grouped {
+ let mut functions = vec![];
+ let mut outline = vec![OutlineItem {
+ id: "summary".into(),
+ name: "Summary".into(),
+ children: vec![],
+ }];
+
+ for name in &group.functions {
+ let value = focus.get(name).unwrap();
+ let Value::Func(func) = value else { panic!("not a function") };
+ let info = func.info().unwrap();
+ let func = func_model(resolver, func, info, &[], info.name);
+ let id = urlify(&func.path.join("-"));
+ let children = func_outline(&func, &id, false);
+ outline.push(OutlineItem { id, name: func.display.into(), children });
+ functions.push(func);
+ }
+
+ let route = format!("{}{}/", route, group.name);
+ items.push(CategoryItem {
+ name: group.name.clone(),
+ route: route.clone(),
+ oneliner: oneliner(&group.description).into(),
+ code: false,
+ });
+
+ children.push(PageModel {
+ route,
+ title: group.display.clone(),
+ description: format!("Documentation for {} group of functions.", group.name),
+ part: None,
+ outline,
+ body: BodyModel::Funcs(FuncsModel {
+ name: group.name.clone(),
+ display: group.display.clone(),
+ details: Html::markdown(resolver, &group.description),
+ functions,
+ }),
+ children: vec![],
+ });
+ }
+
+ children.sort_by_cached_key(|child| child.title.clone());
+ items.sort_by_cached_key(|item| item.name.clone());
+
+ // Add symbol pages. These are ordered manually.
+ if category == "symbols" {
+ for module in ["sym", "emoji"] {
+ let subpage = symbol_page(resolver, &route, module);
+ items.push(CategoryItem {
+ name: module.into(),
+ route: subpage.route.clone(),
+ oneliner: oneliner(details(module)).into(),
+ code: true,
+ });
+ children.push(subpage);
+ }
+ }
+
+ let name = category.to_title_case();
+ let kind = match category {
+ "symbols" => "Modules",
+ _ => "Functions",
+ };
+
+ PageModel {
+ route,
+ title: name.clone(),
+ description: format!("Documentation for functions related to {name} in Typst."),
+ part: None,
+ outline: category_outline(kind),
+ body: BodyModel::Category(CategoryModel {
+ name,
+ details: Html::markdown(resolver, details(category)),
+ kind,
+ items,
+ }),
+ children,
+ }
+}
+
+/// Produce an outline for a category page.
+fn category_outline(kind: &str) -> Vec<OutlineItem> {
+ vec![
+ OutlineItem {
+ id: "summary".into(),
+ name: "Summary".into(),
+ children: vec![],
+ },
+ OutlineItem {
+ id: urlify(kind),
+ name: kind.into(),
+ children: vec![],
+ },
+ ]
+}
+
+/// Details about a function.
+#[derive(Debug, Serialize)]
+pub struct FuncModel {
+ pub path: Vec<&'static str>,
+ pub display: &'static str,
+ pub keywords: Option<&'static str>,
+ pub oneliner: &'static str,
+ pub element: bool,
+ pub details: Html,
+ pub params: Vec<ParamModel>,
+ pub returns: Vec<&'static str>,
+ pub methods: Vec<MethodModel>,
+ pub scope: Vec<Self>,
+}
+
+/// Details about a group of functions.
+#[derive(Debug, Serialize)]
+pub struct FuncsModel {
+ pub name: String,
+ pub display: String,
+ pub details: Html,
+ pub functions: Vec<FuncModel>,
+}
+
+/// Create a page for a function.
+fn function_page(
+ resolver: &dyn Resolver,
+ parent: &str,
+ func: &Func,
+ info: &FuncInfo,
+ parents: &[&'static str],
+) -> PageModel {
+ let model = func_model(resolver, func, info, parents, "");
+ PageModel {
+ route: format!("{parent}{}/", urlify(info.name)),
+ title: info.display.to_string(),
+ description: format!("Documentation for the `{}` function.", info.name),
+ part: None,
+ outline: func_outline(&model, "", true),
+ body: BodyModel::Func(model),
+ children: vec![],
+ }
+}
+
+/// Produce a function's model.
+fn func_model(
+ resolver: &dyn Resolver,
+ func: &Func,
+ info: &FuncInfo,
+ parents: &[&'static str],
+ id_base: &str,
+) -> FuncModel {
+ let mut s = unscanny::Scanner::new(info.docs);
+ let docs = s.eat_until("\n## Methods").trim();
+
+ let mut path = parents.to_vec();
+ let mut name = info.name;
+ for parent in parents.iter().rev() {
+ name = name
+ .strip_prefix(parent)
+ .or(name.strip_prefix(parent.strip_suffix('s').unwrap_or(parent)))
+ .unwrap_or(name)
+ .trim_matches('-');
+ }
+ path.push(name);
+
+ let scope = info
+ .scope
+ .iter()
+ .filter_map(|(_, value)| {
+ let Value::Func(func) = value else { return None };
+ let info = func.info().unwrap();
+ Some(func_model(resolver, func, info, &path, id_base))
+ })
+ .collect();
+
+ let mut returns = vec![];
+ casts(resolver, &mut returns, &mut vec![], &info.returns);
+ returns.sort_by_key(|ty| type_index(ty));
+ if returns == ["none"] {
+ returns.clear();
+ }
+
+ FuncModel {
+ path,
+ display: info.display,
+ keywords: info.keywords,
+ oneliner: oneliner(docs),
+ element: func.element().is_some(),
+ details: Html::markdown_with_id_base(resolver, docs, id_base),
+ params: info.params.iter().map(|param| param_model(resolver, param)).collect(),
+ returns,
+ methods: method_models(resolver, info.docs),
+ scope,
+ }
+}
+
+/// Produce an outline for a function page.
+fn func_outline(model: &FuncModel, base: &str, summary: bool) -> Vec<OutlineItem> {
+ let mut outline = vec![];
+
+ if summary {
+ outline.push(OutlineItem {
+ id: "summary".into(),
+ name: "Summary".into(),
+ children: vec![],
+ });
+ }
+
+ outline.extend(model.details.outline());
+
+ if !model.params.is_empty() {
+ let join = if base.is_empty() { "" } else { "-" };
+ outline.push(OutlineItem {
+ id: format!("{base}{join}parameters"),
+ name: "Parameters".into(),
+ children: model
+ .params
+ .iter()
+ .map(|param| OutlineItem {
+ id: format!("{base}{join}parameters-{}", urlify(param.name)),
+ name: param.name.into(),
+ children: vec![],
+ })
+ .collect(),
+ });
+ }
+
+ for func in &model.scope {
+ let id = urlify(&func.path.join("-"));
+ let children = func_outline(func, &id, false);
+ outline.push(OutlineItem { id, name: func.display.into(), children })
+ }
+
+ outline.extend(methods_outline(&model.methods));
+ outline
+}
+
+/// Details about a function parameter.
+#[derive(Debug, Serialize)]
+pub struct ParamModel {
+ pub name: &'static str,
+ pub details: Html,
+ pub example: Option<Html>,
+ pub types: Vec<&'static str>,
+ pub strings: Vec<StrParam>,
+ pub default: Option<Html>,
+ pub positional: bool,
+ pub named: bool,
+ pub required: bool,
+ pub variadic: bool,
+ pub settable: bool,
+}
+
+/// A specific string that can be passed as an argument.
+#[derive(Debug, Serialize)]
+pub struct StrParam {
+ pub string: String,
+ pub details: Html,
+}
+
+/// Produce a parameter's model.
+fn param_model(resolver: &dyn Resolver, info: &ParamInfo) -> ParamModel {
+ let mut types = vec![];
+ let mut strings = vec![];
+ casts(resolver, &mut types, &mut strings, &info.cast);
+ if !strings.is_empty() && !types.contains(&"string") {
+ types.push("string");
+ }
+ types.sort_by_key(|ty| type_index(ty));
+
+ let mut details = info.docs;
+ let mut example = None;
+ if let Some(mut i) = info.docs.find("```example") {
+ while info.docs[..i].ends_with('`') {
+ i -= 1;
+ }
+ details = &info.docs[..i];
+ example = Some(&info.docs[i..]);
+ }
+
+ ParamModel {
+ name: info.name,
+ details: Html::markdown(resolver, details),
+ example: example.map(|md| Html::markdown(resolver, md)),
+ types,
+ strings,
+ default: info.default.map(|default| {
+ let node = typst::syntax::parse_code(&default().repr());
+ Html::new(typst::ide::highlight_html(&node))
+ }),
+ positional: info.positional,
+ named: info.named,
+ required: info.required,
+ variadic: info.variadic,
+ settable: info.settable,
+ }
+}
+
+/// Process cast information into types and strings.
+fn casts(
+ resolver: &dyn Resolver,
+ types: &mut Vec<&'static str>,
+ strings: &mut Vec<StrParam>,
+ info: &CastInfo,
+) {
+ match info {
+ CastInfo::Any => types.push("any"),
+ CastInfo::Value(Value::Str(string), docs) => strings.push(StrParam {
+ string: string.to_string(),
+ details: Html::markdown(resolver, docs),
+ }),
+ CastInfo::Value(..) => {}
+ CastInfo::Type(ty) => types.push(ty),
+ CastInfo::Union(options) => {
+ for option in options {
+ casts(resolver, types, strings, option);
+ }
+ }
+ }
+}
+
+/// A collection of symbols.
+#[derive(Debug, Serialize)]
+pub struct TypeModel {
+ pub name: String,
+ pub oneliner: &'static str,
+ pub details: Html,
+ pub methods: Vec<MethodModel>,
+}
+
+/// Details about a built-in method on a type.
+#[derive(Debug, Serialize)]
+pub struct MethodModel {
+ pub name: &'static str,
+ pub details: Html,
+ pub params: Vec<ParamModel>,
+ pub returns: Vec<&'static str>,
+}
+
+/// Create a page for the types.
+fn types_page(resolver: &dyn Resolver, parent: &str) -> PageModel {
+ let route = format!("{parent}types/");
+ let mut children = vec![];
+ let mut items = vec![];
+
+ for model in type_models(resolver) {
+ let route = format!("{route}{}/", urlify(&model.name));
+ items.push(CategoryItem {
+ name: model.name.clone(),
+ route: route.clone(),
+ oneliner: model.oneliner.into(),
+ code: true,
+ });
+ children.push(PageModel {
+ route,
+ title: model.name.to_title_case(),
+ description: format!("Documentation for the `{}` type.", model.name),
+ part: None,
+ outline: type_outline(&model),
+ body: BodyModel::Type(model),
+ children: vec![],
+ });
+ }
+
+ PageModel {
+ route,
+ title: "Types".into(),
+ description: "Documentation for Typst's built-in types.".into(),
+ part: None,
+ outline: category_outline("Types"),
+ body: BodyModel::Category(CategoryModel {
+ name: "Types".into(),
+ details: Html::markdown(resolver, details("types")),
+ kind: "Types",
+ items,
+ }),
+ children,
+ }
+}
+
+/// Produce the types' models.
+fn type_models(resolver: &dyn Resolver) -> Vec<TypeModel> {
+ let file = DOCS.get_file("reference/types.md").unwrap();
+ let text = file.contents_utf8().unwrap();
+
+ let mut s = unscanny::Scanner::new(text);
+ let mut types = vec![];
+
+ while s.eat_if("# ") {
+ let part = s.eat_until("\n# ");
+ types.push(type_model(resolver, part));
+ s.eat_if('\n');
+ }
+
+ types
+}
+
+/// Produce a type's model.
+fn type_model(resolver: &dyn Resolver, part: &'static str) -> TypeModel {
+ let mut s = unscanny::Scanner::new(part);
+ let display = s.eat_until('\n').trim();
+ let docs = s.eat_until("\n## Methods").trim();
+ TypeModel {
+ name: display.to_lowercase(),
+ oneliner: oneliner(docs),
+ details: Html::markdown(resolver, docs),
+ methods: method_models(resolver, part),
+ }
+}
+
+/// Produce multiple methods' models.
+fn method_models(resolver: &dyn Resolver, docs: &'static str) -> Vec<MethodModel> {
+ let mut s = unscanny::Scanner::new(docs);
+ s.eat_until("\n## Methods");
+ s.eat_whitespace();
+
+ let mut methods = vec![];
+ if s.eat_if("## Methods") {
+ s.eat_until("\n### ");
+ while s.eat_if("\n### ") {
+ methods.push(method_model(resolver, s.eat_until("\n### ")));
+ }
+ }
+
+ methods
+}
+
+/// Produce a method's model.
+fn method_model(resolver: &dyn Resolver, part: &'static str) -> MethodModel {
+ let mut s = unscanny::Scanner::new(part);
+ let mut params = vec![];
+ let mut returns = vec![];
+
+ let name = s.eat_until('(').trim();
+ s.expect("()");
+ let docs = s.eat_until("\n- ").trim();
+
+ while s.eat_if("\n- ") {
+ let name = s.eat_until(':');
+ s.expect(": ");
+ let types: Vec<_> =
+ s.eat_until(['(', '\n']).split(" or ").map(str::trim).collect();
+ if !types.iter().all(|ty| type_index(ty) != usize::MAX) {
+ panic!(
+ "unknown type in method {} parameter {}",
+ name,
+ types.iter().find(|ty| type_index(ty) == usize::MAX).unwrap()
+ )
+ }
+
+ if name == "returns" {
+ returns = types;
+ continue;
+ }
+
+ s.expect('(');
+
+ let mut named = false;
+ let mut positional = false;
+ let mut required = false;
+ let mut variadic = false;
+ for part in s.eat_until(')').split(',').map(str::trim) {
+ match part {
+ "named" => named = true,
+ "positional" => positional = true,
+ "required" => required = true,
+ "variadic" => variadic = true,
+ _ => panic!("unknown parameter flag {:?}", part),
+ }
+ }
+
+ s.expect(')');
+
+ params.push(ParamModel {
+ name,
+ details: Html::markdown(resolver, s.eat_until("\n- ").trim()),
+ example: None,
+ types,
+ strings: vec![],
+ default: None,
+ positional,
+ named,
+ required,
+ variadic,
+ settable: false,
+ });
+ }
+
+ MethodModel {
+ name,
+ details: Html::markdown(resolver, docs),
+ params,
+ returns,
+ }
+}
+
+/// Produce an outline for a type page.
+fn type_outline(model: &TypeModel) -> Vec<OutlineItem> {
+ let mut outline = vec![OutlineItem {
+ id: "summary".into(),
+ name: "Summary".into(),
+ children: vec![],
+ }];
+
+ outline.extend(methods_outline(&model.methods));
+ outline
+}
+
+/// Produce an outline for a type's method.
+fn methods_outline(methods: &[MethodModel]) -> Option<OutlineItem> {
+ (!methods.is_empty()).then(|| OutlineItem {
+ id: "methods".into(),
+ name: "Methods".into(),
+ children: methods.iter().map(method_outline).collect(),
+ })
+}
+
+/// Produce an outline for a type's method.
+fn method_outline(model: &MethodModel) -> OutlineItem {
+ OutlineItem {
+ id: format!("methods-{}", urlify(model.name)),
+ name: model.name.into(),
+ children: model
+ .params
+ .iter()
+ .map(|param| OutlineItem {
+ id: format!(
+ "methods-{}-parameters-{}",
+ urlify(model.name),
+ urlify(param.name)
+ ),
+ name: param.name.into(),
+ children: vec![],
+ })
+ .collect(),
+ }
+}
+
+/// A collection of symbols.
+#[derive(Debug, Serialize)]
+pub struct SymbolsModel {
+ pub name: &'static str,
+ pub details: Html,
+ pub list: Vec<SymbolModel>,
+}
+
+/// Details about a symbol.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SymbolModel {
+ pub name: String,
+ pub shorthand: Option<&'static str>,
+ pub codepoint: u32,
+ pub accent: bool,
+ pub unicode_name: Option<String>,
+ pub alternates: Vec<String>,
+}
+
+/// Create a page for symbols.
+fn symbol_page(resolver: &dyn Resolver, parent: &str, name: &str) -> PageModel {
+ let module = module(&LIBRARY.global, name).unwrap();
+
+ let mut list = vec![];
+ for (name, value) in module.scope().iter() {
+ let Value::Symbol(symbol) = value else { continue };
+ let complete = |variant: &str| {
+ if variant.is_empty() {
+ name.into()
+ } else {
+ format!("{}.{}", name, variant)
+ }
+ };
+
+ for (variant, c) in symbol.variants() {
+ list.push(SymbolModel {
+ name: complete(variant),
+ shorthand: typst::syntax::ast::Shorthand::LIST
+ .iter()
+ .copied()
+ .find(|&(_, x)| x == c)
+ .map(|(s, _)| s),
+ codepoint: c as u32,
+ accent: typst::eval::Symbol::combining_accent(c).is_some(),
+ unicode_name: unicode_names2::name(c)
+ .map(|s| s.to_string().to_title_case()),
+ alternates: symbol
+ .variants()
+ .filter(|(other, _)| other != &variant)
+ .map(|(other, _)| complete(other))
+ .collect(),
+ });
+ }
+ }
+
+ let title = match name {
+ "sym" => "General",
+ "emoji" => "Emoji",
+ _ => unreachable!(),
+ };
+
+ PageModel {
+ route: format!("{parent}{name}/"),
+ title: title.into(),
+ description: format!("Documentation for the `{name}` module."),
+ part: None,
+ outline: vec![],
+ body: BodyModel::Symbols(SymbolsModel {
+ name: title,
+ details: Html::markdown(resolver, details(name)),
+ list,
+ }),
+ children: vec![],
+ }
+}
+
+/// Data about a collection of functions.
+#[derive(Debug, Deserialize)]
+struct GroupData {
+ name: String,
+ display: String,
+ functions: Vec<String>,
+ description: String,
+}
+
+/// Extract a module from another module.
+#[track_caller]
+fn module<'a>(parent: &'a Module, name: &str) -> Result<&'a Module, String> {
+ match parent.scope().get(name) {
+ Some(Value::Module(module)) => Ok(module),
+ _ => Err(format!("module doesn't contain module `{name}`")),
+ }
+}
+
+/// Load YAML from a path.
+#[track_caller]
+fn yaml<T: DeserializeOwned>(path: &str) -> T {
+ let file = DOCS.get_file(path).unwrap();
+ yaml::from_slice(file.contents()).unwrap()
+}
+
+/// Load details for an identifying key.
+#[track_caller]
+fn details(key: &str) -> &str {
+ DETAILS
+ .get(&yaml::Value::String(key.into()))
+ .and_then(|value| value.as_str())
+ .unwrap_or_else(|| panic!("missing details for {key}"))
+}
+
+/// Turn a title into an URL fragment.
+pub fn urlify(title: &str) -> String {
+ title
+ .chars()
+ .map(|c| c.to_ascii_lowercase())
+ .map(|c| match c {
+ 'a'..='z' | '0'..='9' => c,
+ _ => '-',
+ })
+ .collect()
+}
+
+/// Extract the first line of documentation.
+fn oneliner(docs: &str) -> &str {
+ docs.lines().next().unwrap_or_default()
+}
+
+/// The order of types in the documentation.
+fn type_index(ty: &str) -> usize {
+ TYPE_ORDER.iter().position(|&v| v == ty).unwrap_or(usize::MAX)
+}
+
+const TYPE_ORDER: &[&str] = &[
+ "any",
+ "none",
+ "auto",
+ "boolean",
+ "integer",
+ "float",
+ "length",
+ "angle",
+ "ratio",
+ "relative length",
+ "fraction",
+ "color",
+ "datetime",
+ "string",
+ "regex",
+ "label",
+ "content",
+ "array",
+ "dictionary",
+ "function",
+ "arguments",
+ "location",
+ "dir",
+ "alignment",
+ "2d alignment",
+ "selector",
+ "stroke",
+];
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_docs() {
+ provide(&TestResolver);
+ }
+
+ struct TestResolver;
+
+ impl Resolver for TestResolver {
+ fn link(&self, _: &str) -> Option<String> {
+ None
+ }
+
+ fn example(&self, _: Html, _: &[Frame]) -> Html {
+ Html::new(String::new())
+ }
+
+ fn image(&self, _: &str, _: &[u8]) -> String {
+ String::new()
+ }
+
+ fn commits(&self, _: &str, _: &str) -> Vec<Commit> {
+ vec![]
+ }
+ }
+}
diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml
new file mode 100644
index 00000000..23c37d75
--- /dev/null
+++ b/crates/typst-library/Cargo.toml
@@ -0,0 +1,51 @@
+[package]
+name = "typst-library"
+description = "The standard library for Typst."
+categories = ["compilers", "science"]
+keywords = ["typst"]
+version.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+
+[lib]
+test = false
+doctest = false
+bench = false
+
+[dependencies]
+typst = { path = "../typst" }
+az = "1.2"
+chinese-number = { version = "0.7.2", default-features = false, features = ["number-to-chinese"] }
+comemo = "0.3"
+csv = "1"
+ecow = "0.1"
+hayagriva = "0.3"
+hypher = "0.1"
+icu_properties = { version = "1.2.0", features = ["serde"] }
+icu_provider = { version = "1.2.0", features = ["sync"] }
+icu_provider_adapters = "1.2.0"
+icu_provider_blob = "1.2.0"
+icu_segmenter = { version = "1.2.1", features = ["serde"] }
+kurbo = "0.9"
+lipsum = "0.9"
+log = "0.4"
+once_cell = "1"
+roxmltree = "0.18"
+rustybuzz = "0.7"
+serde_json = "1"
+serde_yaml = "0.8"
+smallvec = "1.10"
+syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy"] }
+time = { version = "0.3.20", features = ["formatting"] }
+toml = { version = "0.7.3", default-features = false, features = ["parse"] }
+tracing = "0.1.37"
+ttf-parser = "0.18.1"
+typed-arena = "2"
+unicode-bidi = "0.3.13"
+unicode-math-class = "0.1"
+unicode-script = "0.5"
+unicode-segmentation = "1"
diff --git a/crates/typst-library/assets/cj_linebreak_data.postcard b/crates/typst-library/assets/cj_linebreak_data.postcard
new file mode 100644
index 00000000..910dd167
--- /dev/null
+++ b/crates/typst-library/assets/cj_linebreak_data.postcard
Binary files differ
diff --git a/crates/typst-library/assets/icudata.postcard b/crates/typst-library/assets/icudata.postcard
new file mode 100644
index 00000000..e910bf8e
--- /dev/null
+++ b/crates/typst-library/assets/icudata.postcard
Binary files differ
diff --git a/crates/typst-library/assets/syntect.bin b/crates/typst-library/assets/syntect.bin
new file mode 100644
index 00000000..043602a4
--- /dev/null
+++ b/crates/typst-library/assets/syntect.bin
Binary files differ
diff --git a/crates/typst-library/src/compute/calc.rs b/crates/typst-library/src/compute/calc.rs
new file mode 100644
index 00000000..81715007
--- /dev/null
+++ b/crates/typst-library/src/compute/calc.rs
@@ -0,0 +1,1024 @@
+//! Calculations and processing of numeric values.
+
+use std::cmp;
+use std::cmp::Ordering;
+use std::ops::{Div, Rem};
+
+use typst::eval::{Module, Scope};
+
+use crate::prelude::*;
+
+/// A module with computational functions.
+pub fn module() -> Module {
+ let mut scope = Scope::new();
+ scope.define("abs", abs_func());
+ scope.define("pow", pow_func());
+ scope.define("exp", exp_func());
+ scope.define("sqrt", sqrt_func());
+ scope.define("sin", sin_func());
+ scope.define("cos", cos_func());
+ scope.define("tan", tan_func());
+ scope.define("asin", asin_func());
+ scope.define("acos", acos_func());
+ scope.define("atan", atan_func());
+ scope.define("atan2", atan2_func());
+ scope.define("sinh", sinh_func());
+ scope.define("cosh", cosh_func());
+ scope.define("tanh", tanh_func());
+ scope.define("log", log_func());
+ scope.define("ln", ln_func());
+ scope.define("fact", fact_func());
+ scope.define("perm", perm_func());
+ scope.define("binom", binom_func());
+ scope.define("gcd", gcd_func());
+ scope.define("lcm", lcm_func());
+ scope.define("floor", floor_func());
+ scope.define("ceil", ceil_func());
+ scope.define("trunc", trunc_func());
+ scope.define("fract", fract_func());
+ scope.define("round", round_func());
+ scope.define("clamp", clamp_func());
+ scope.define("min", min_func());
+ scope.define("max", max_func());
+ scope.define("even", even_func());
+ scope.define("odd", odd_func());
+ scope.define("rem", rem_func());
+ scope.define("quo", quo_func());
+ scope.define("inf", f64::INFINITY);
+ scope.define("nan", f64::NAN);
+ scope.define("pi", std::f64::consts::PI);
+ scope.define("e", std::f64::consts::E);
+ Module::new("calc").with_scope(scope)
+}
+
+/// Calculates the absolute value of a numeric value.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.abs(-5) \
+/// #calc.abs(5pt - 2cm) \
+/// #calc.abs(2fr)
+/// ```
+///
+/// Display: Absolute
+/// Category: calculate
+#[func]
+pub fn abs(
+ /// The value whose absolute value to calculate.
+ value: ToAbs,
+) -> Value {
+ value.0
+}
+
+/// A value of which the absolute value can be taken.
+pub struct ToAbs(Value);
+
+cast! {
+ ToAbs,
+ v: i64 => Self(v.abs().into_value()),
+ v: f64 => Self(v.abs().into_value()),
+ v: Length => Self(Value::Length(v.try_abs()
+ .ok_or("cannot take absolute value of this length")?)),
+ v: Angle => Self(Value::Angle(v.abs())),
+ v: Ratio => Self(Value::Ratio(v.abs())),
+ v: Fr => Self(Value::Fraction(v.abs())),
+}
+
+/// Raises a value to some exponent.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.pow(2, 3)
+/// ```
+///
+/// Display: Power
+/// Category: calculate
+#[func]
+pub fn pow(
+ /// The base of the power.
+ base: Num,
+ /// The exponent of the power.
+ exponent: Spanned<Num>,
+ /// The callsite span.
+ span: Span,
+) -> SourceResult<Num> {
+ match exponent.v {
+ _ if exponent.v.float() == 0.0 && base.float() == 0.0 => {
+ bail!(span, "zero to the power of zero is undefined")
+ }
+ Num::Int(i) if i32::try_from(i).is_err() => {
+ bail!(exponent.span, "exponent is too large")
+ }
+ Num::Float(f) if !f.is_normal() && f != 0.0 => {
+ bail!(exponent.span, "exponent may not be infinite, subnormal, or NaN")
+ }
+ _ => {}
+ };
+
+ let result = match (base, exponent.v) {
+ (Num::Int(a), Num::Int(b)) if b >= 0 => a
+ .checked_pow(b as u32)
+ .map(Num::Int)
+ .ok_or("the result is too large")
+ .at(span)?,
+ (a, b) => Num::Float(if a.float() == std::f64::consts::E {
+ b.float().exp()
+ } else if a.float() == 2.0 {
+ b.float().exp2()
+ } else if let Num::Int(b) = b {
+ a.float().powi(b as i32)
+ } else {
+ a.float().powf(b.float())
+ }),
+ };
+
+ if result.float().is_nan() {
+ bail!(span, "the result is not a real number")
+ }
+
+ Ok(result)
+}
+
+/// Raises a value to some exponent of e.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.exp(1)
+/// ```
+///
+/// Display: Exponential
+/// Category: calculate
+#[func]
+pub fn exp(
+ /// The exponent of the power.
+ exponent: Spanned<Num>,
+ /// The callsite span.
+ span: Span,
+) -> SourceResult<f64> {
+ match exponent.v {
+ Num::Int(i) if i32::try_from(i).is_err() => {
+ bail!(exponent.span, "exponent is too large")
+ }
+ Num::Float(f) if !f.is_normal() && f != 0.0 => {
+ bail!(exponent.span, "exponent may not be infinite, subnormal, or NaN")
+ }
+ _ => {}
+ };
+
+ let result = exponent.v.float().exp();
+ if result.is_nan() {
+ bail!(span, "the result is not a real number")
+ }
+
+ Ok(result)
+}
+
+/// Extracts the square root of a number.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.sqrt(16) \
+/// #calc.sqrt(2.5)
+/// ```
+///
+/// Display: Square Root
+/// Category: calculate
+#[func]
+pub fn sqrt(
+ /// The number whose square root to calculate. Must be non-negative.
+ value: Spanned<Num>,
+) -> SourceResult<f64> {
+ if value.v.float() < 0.0 {
+ bail!(value.span, "cannot take square root of negative number");
+ }
+ Ok(value.v.float().sqrt())
+}
+
+/// Calculates the sine of an angle.
+///
+/// When called with an integer or a float, they will be interpreted as
+/// radians.
+///
+/// ## Example { #example }
+/// ```example
+/// #assert(calc.sin(90deg) == calc.sin(-270deg))
+/// #calc.sin(1.5) \
+/// #calc.sin(90deg)
+/// ```
+///
+/// Display: Sine
+/// Category: calculate
+#[func]
+pub fn sin(
+ /// The angle whose sine to calculate.
+ angle: AngleLike,
+) -> f64 {
+ match angle {
+ AngleLike::Angle(a) => a.sin(),
+ AngleLike::Int(n) => (n as f64).sin(),
+ AngleLike::Float(n) => n.sin(),
+ }
+}
+
+/// Calculates the cosine of an angle.
+///
+/// When called with an integer or a float, they will be interpreted as
+/// radians.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.cos(90deg) \
+/// #calc.cos(1.5) \
+/// #calc.cos(90deg)
+/// ```
+///
+/// Display: Cosine
+/// Category: calculate
+#[func]
+pub fn cos(
+ /// The angle whose cosine to calculate.
+ angle: AngleLike,
+) -> f64 {
+ match angle {
+ AngleLike::Angle(a) => a.cos(),
+ AngleLike::Int(n) => (n as f64).cos(),
+ AngleLike::Float(n) => n.cos(),
+ }
+}
+
+/// Calculates the tangent of an angle.
+///
+/// When called with an integer or a float, they will be interpreted as
+/// radians.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.tan(1.5) \
+/// #calc.tan(90deg)
+/// ```
+///
+/// Display: Tangent
+/// Category: calculate
+#[func]
+pub fn tan(
+ /// The angle whose tangent to calculate.
+ angle: AngleLike,
+) -> f64 {
+ match angle {
+ AngleLike::Angle(a) => a.tan(),
+ AngleLike::Int(n) => (n as f64).tan(),
+ AngleLike::Float(n) => n.tan(),
+ }
+}
+
+/// Calculates the arcsine of a number.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.asin(0) \
+/// #calc.asin(1)
+/// ```
+///
+/// Display: Arcsine
+/// Category: calculate
+#[func]
+pub fn asin(
+ /// The number whose arcsine to calculate. Must be between -1 and 1.
+ value: Spanned<Num>,
+) -> SourceResult<Angle> {
+ let val = value.v.float();
+ if val < -1.0 || val > 1.0 {
+ bail!(value.span, "value must be between -1 and 1");
+ }
+ Ok(Angle::rad(val.asin()))
+}
+
+/// Calculates the arccosine of a number.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.acos(0) \
+/// #calc.acos(1)
+/// ```
+///
+/// Display: Arccosine
+/// Category: calculate
+#[func]
+pub fn acos(
+ /// The number whose arcsine to calculate. Must be between -1 and 1.
+ value: Spanned<Num>,
+) -> SourceResult<Angle> {
+ let val = value.v.float();
+ if val < -1.0 || val > 1.0 {
+ bail!(value.span, "value must be between -1 and 1");
+ }
+ Ok(Angle::rad(val.acos()))
+}
+
+/// Calculates the arctangent of a number.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.atan(0) \
+/// #calc.atan(1)
+/// ```
+///
+/// Display: Arctangent
+/// Category: calculate
+#[func]
+pub fn atan(
+ /// The number whose arctangent to calculate.
+ value: Num,
+) -> Angle {
+ Angle::rad(value.float().atan())
+}
+
+/// Calculates the four-quadrant arctangent of a coordinate.
+///
+/// The arguments are `(x, y)`, not `(y, x)`.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.atan2(1, 1) \
+/// #calc.atan2(-2, -3)
+/// ```
+///
+/// Display: Four-quadrant Arctangent
+/// Category: calculate
+#[func]
+pub fn atan2(
+ /// The X coordinate.
+ x: Num,
+ /// The Y coordinate.
+ y: Num,
+) -> Angle {
+ Angle::rad(f64::atan2(y.float(), x.float()))
+}
+
+/// Calculates the hyperbolic sine of an angle.
+///
+/// When called with an integer or a float, they will be interpreted as radians.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.sinh(0) \
+/// #calc.sinh(45deg)
+/// ```
+///
+/// Display: Hyperbolic sine
+/// Category: calculate
+#[func]
+pub fn sinh(
+ /// The angle whose hyperbolic sine to calculate.
+ angle: AngleLike,
+) -> f64 {
+ match angle {
+ AngleLike::Angle(a) => a.to_rad().sinh(),
+ AngleLike::Int(n) => (n as f64).sinh(),
+ AngleLike::Float(n) => n.sinh(),
+ }
+}
+
+/// Calculates the hyperbolic cosine of an angle.
+///
+/// When called with an integer or a float, they will be interpreted as radians.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.cosh(0) \
+/// #calc.cosh(45deg)
+/// ```
+///
+/// Display: Hyperbolic cosine
+/// Category: calculate
+#[func]
+pub fn cosh(
+ /// The angle whose hyperbolic cosine to calculate.
+ angle: AngleLike,
+) -> f64 {
+ match angle {
+ AngleLike::Angle(a) => a.to_rad().cosh(),
+ AngleLike::Int(n) => (n as f64).cosh(),
+ AngleLike::Float(n) => n.cosh(),
+ }
+}
+
+/// Calculates the hyperbolic tangent of an angle.
+///
+/// When called with an integer or a float, they will be interpreted as radians.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.tanh(0) \
+/// #calc.tanh(45deg)
+/// ```
+///
+/// Display: Hyperbolic tangent
+/// Category: calculate
+#[func]
+pub fn tanh(
+ /// The angle whose hyperbolic tangent to calculate.
+ angle: AngleLike,
+) -> f64 {
+ match angle {
+ AngleLike::Angle(a) => a.to_rad().tanh(),
+ AngleLike::Int(n) => (n as f64).tanh(),
+ AngleLike::Float(n) => n.tanh(),
+ }
+}
+
+/// Calculates the logarithm of a number.
+///
+/// If the base is not specified, the logarithm is calculated in base 10.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.log(100)
+/// ```
+///
+/// Display: Logarithm
+/// Category: calculate
+#[func]
+pub fn log(
+ /// The number whose logarithm to calculate. Must be strictly positive.
+ value: Spanned<Num>,
+ /// The base of the logarithm. May not be zero.
+ #[named]
+ #[default(Spanned::new(10.0, Span::detached()))]
+ base: Spanned<f64>,
+ /// The callsite span.
+ span: Span,
+) -> SourceResult<f64> {
+ let number = value.v.float();
+ if number <= 0.0 {
+ bail!(value.span, "value must be strictly positive")
+ }
+
+ if !base.v.is_normal() {
+ bail!(base.span, "base may not be zero, NaN, infinite, or subnormal")
+ }
+
+ let result = if base.v == std::f64::consts::E {
+ number.ln()
+ } else if base.v == 2.0 {
+ number.log2()
+ } else if base.v == 10.0 {
+ number.log10()
+ } else {
+ number.log(base.v)
+ };
+
+ if result.is_infinite() || result.is_nan() {
+ bail!(span, "the result is not a real number")
+ }
+
+ Ok(result)
+}
+
+/// Calculates the natural logarithm of a number.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.ln(calc.e)
+/// ```
+///
+/// Display: Natural Logarithm
+/// Category: calculate
+#[func]
+pub fn ln(
+ /// The number whose logarithm to calculate. Must be strictly positive.
+ value: Spanned<Num>,
+ /// The callsite span.
+ span: Span,
+) -> SourceResult<f64> {
+ let number = value.v.float();
+ if number <= 0.0 {
+ bail!(value.span, "value must be strictly positive")
+ }
+
+ let result = number.ln();
+ if result.is_infinite() {
+ bail!(span, "result close to -inf")
+ }
+
+ Ok(result)
+}
+
+/// Calculates the factorial of a number.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.fact(5)
+/// ```
+///
+/// Display: Factorial
+/// Category: calculate
+#[func]
+pub fn fact(
+ /// The number whose factorial to calculate. Must be non-negative.
+ number: u64,
+) -> StrResult<i64> {
+ Ok(fact_impl(1, number).ok_or("the result is too large")?)
+}
+
+/// Calculates a permutation.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.perm(10, 5)
+/// ```
+///
+/// Display: Permutation
+/// Category: calculate
+#[func]
+pub fn perm(
+ /// The base number. Must be non-negative.
+ base: u64,
+ /// The number of permutations. Must be non-negative.
+ numbers: u64,
+) -> StrResult<i64> {
+ // By convention.
+ if base < numbers {
+ return Ok(0);
+ }
+
+ Ok(fact_impl(base - numbers + 1, base).ok_or("the result is too large")?)
+}
+
+/// Calculates the product of a range of numbers. Used to calculate
+/// permutations. Returns None if the result is larger than `i64::MAX`
+fn fact_impl(start: u64, end: u64) -> Option<i64> {
+ // By convention
+ if end + 1 < start {
+ return Some(0);
+ }
+
+ let real_start: u64 = cmp::max(1, start);
+ let mut count: u64 = 1;
+ for i in real_start..=end {
+ count = count.checked_mul(i)?;
+ }
+
+ count.try_into().ok()
+}
+
+/// Calculates a binomial coefficient.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.binom(10, 5)
+/// ```
+///
+/// Display: Binomial
+/// Category: calculate
+#[func]
+pub fn binom(
+ /// The upper coefficient. Must be non-negative.
+ n: u64,
+ /// The lower coefficient. Must be non-negative.
+ k: u64,
+) -> StrResult<i64> {
+ Ok(binom_impl(n, k).ok_or("the result is too large")?)
+}
+
+/// Calculates a binomial coefficient, with `n` the upper coefficient and `k`
+/// the lower coefficient. Returns `None` if the result is larger than
+/// `i64::MAX`
+fn binom_impl(n: u64, k: u64) -> Option<i64> {
+ if k > n {
+ return Some(0);
+ }
+
+ // By symmetry
+ let real_k = cmp::min(n - k, k);
+ if real_k == 0 {
+ return Some(1);
+ }
+
+ let mut result: u64 = 1;
+ for i in 0..real_k {
+ result = result.checked_mul(n - i)?.checked_div(i + 1)?;
+ }
+
+ result.try_into().ok()
+}
+
+/// Calculates the greatest common divisor of two integers.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.gcd(7, 42)
+/// ```
+///
+/// Display: Greatest Common Divisor
+/// Category: calculate
+#[func]
+pub fn gcd(
+ /// The first integer.
+ a: i64,
+ /// The second integer.
+ b: i64,
+) -> i64 {
+ let (mut a, mut b) = (a, b);
+ while b != 0 {
+ let temp = b;
+ b = a % b;
+ a = temp;
+ }
+
+ a.abs()
+}
+
+/// Calculates the least common multiple of two integers.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.lcm(96, 13)
+/// ```
+///
+/// Display: Least Common Multiple
+/// Category: calculate
+#[func]
+pub fn lcm(
+ /// The first integer.
+ a: i64,
+ /// The second integer.
+ b: i64,
+) -> StrResult<i64> {
+ if a == b {
+ return Ok(a.abs());
+ }
+
+ Ok(a.checked_div(gcd(a, b))
+ .and_then(|gcd| gcd.checked_mul(b))
+ .map(|v| v.abs())
+ .ok_or("the return value is too large")?)
+}
+
+/// Rounds a number down to the nearest integer.
+///
+/// If the number is already an integer, it is returned unchanged.
+///
+/// ## Example { #example }
+/// ```example
+/// #assert(calc.floor(3.14) == 3)
+/// #assert(calc.floor(3) == 3)
+/// #calc.floor(500.1)
+/// ```
+///
+/// Display: Round down
+/// Category: calculate
+#[func]
+pub fn floor(
+ /// The number to round down.
+ value: Num,
+) -> i64 {
+ match value {
+ Num::Int(n) => n,
+ Num::Float(n) => n.floor() as i64,
+ }
+}
+
+/// Rounds a number up to the nearest integer.
+///
+/// If the number is already an integer, it is returned unchanged.
+///
+/// ## Example { #example }
+/// ```example
+/// #assert(calc.ceil(3.14) == 4)
+/// #assert(calc.ceil(3) == 3)
+/// #calc.ceil(500.1)
+/// ```
+///
+/// Display: Round up
+/// Category: calculate
+#[func]
+pub fn ceil(
+ /// The number to round up.
+ value: Num,
+) -> i64 {
+ match value {
+ Num::Int(n) => n,
+ Num::Float(n) => n.ceil() as i64,
+ }
+}
+
+/// Returns the integer part of a number.
+///
+/// If the number is already an integer, it is returned unchanged.
+///
+/// ## Example { #example }
+/// ```example
+/// #assert(calc.trunc(3) == 3)
+/// #assert(calc.trunc(-3.7) == -3)
+/// #assert(calc.trunc(15.9) == 15)
+/// ```
+///
+/// Display: Truncate
+/// Category: calculate
+#[func]
+pub fn trunc(
+ /// The number to truncate.
+ value: Num,
+) -> i64 {
+ match value {
+ Num::Int(n) => n,
+ Num::Float(n) => n.trunc() as i64,
+ }
+}
+
+/// Returns the fractional part of a number.
+///
+/// If the number is an integer, returns `0`.
+///
+/// ## Example { #example }
+/// ```example
+/// #assert(calc.fract(3) == 0)
+/// #calc.fract(-3.1)
+/// ```
+///
+/// Display: Fractional
+/// Category: calculate
+#[func]
+pub fn fract(
+ /// The number to truncate.
+ value: Num,
+) -> Num {
+ match value {
+ Num::Int(_) => Num::Int(0),
+ Num::Float(n) => Num::Float(n.fract()),
+ }
+}
+
+/// Rounds a number to the nearest integer.
+///
+/// Optionally, a number of decimal places can be specified.
+///
+/// ## Example { #example }
+/// ```example
+/// #assert(calc.round(3.14) == 3)
+/// #assert(calc.round(3.5) == 4)
+/// #calc.round(3.1415, digits: 2)
+/// ```
+///
+/// Display: Round
+/// Category: calculate
+#[func]
+pub fn round(
+ /// The number to round.
+ value: Num,
+ /// The number of decimal places.
+ #[named]
+ #[default(0)]
+ digits: i64,
+) -> Num {
+ match value {
+ Num::Int(n) if digits == 0 => Num::Int(n),
+ _ => {
+ let n = value.float();
+ let factor = 10.0_f64.powi(digits as i32);
+ Num::Float((n * factor).round() / factor)
+ }
+ }
+}
+
+/// Clamps a number between a minimum and maximum value.
+///
+/// ## Example { #example }
+/// ```example
+/// #assert(calc.clamp(5, 0, 10) == 5)
+/// #assert(calc.clamp(5, 6, 10) == 6)
+/// #calc.clamp(5, 0, 4)
+/// ```
+///
+/// Display: Clamp
+/// Category: calculate
+#[func]
+pub fn clamp(
+ /// The number to clamp.
+ value: Num,
+ /// The inclusive minimum value.
+ min: Num,
+ /// The inclusive maximum value.
+ max: Spanned<Num>,
+) -> SourceResult<Num> {
+ if max.v.float() < min.float() {
+ bail!(max.span, "max must be greater than or equal to min")
+ }
+ Ok(value.apply3(min, max.v, i64::clamp, f64::clamp))
+}
+
+/// Determines the minimum of a sequence of values.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.min(1, -3, -5, 20, 3, 6) \
+/// #calc.min("typst", "in", "beta")
+/// ```
+///
+/// Display: Minimum
+/// Category: calculate
+#[func]
+pub fn min(
+ /// The sequence of values from which to extract the minimum.
+ /// Must not be empty.
+ #[variadic]
+ values: Vec<Spanned<Value>>,
+ /// The callsite span.
+ span: Span,
+) -> SourceResult<Value> {
+ minmax(span, values, Ordering::Less)
+}
+
+/// Determines the maximum of a sequence of values.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.max(1, -3, -5, 20, 3, 6) \
+/// #calc.max("typst", "in", "beta")
+/// ```
+///
+/// Display: Maximum
+/// Category: calculate
+#[func]
+pub fn max(
+ /// The sequence of values from which to extract the maximum.
+ /// Must not be empty.
+ #[variadic]
+ values: Vec<Spanned<Value>>,
+ /// The callsite span.
+ span: Span,
+) -> SourceResult<Value> {
+ minmax(span, values, Ordering::Greater)
+}
+
+/// Find the minimum or maximum of a sequence of values.
+fn minmax(
+ span: Span,
+ values: Vec<Spanned<Value>>,
+ goal: Ordering,
+) -> SourceResult<Value> {
+ let mut iter = values.into_iter();
+ let Some(Spanned { v: mut extremum, ..}) = iter.next() else {
+ bail!(span, "expected at least one value");
+ };
+
+ for Spanned { v, span } in iter {
+ let ordering = typst::eval::ops::compare(&v, &extremum).at(span)?;
+ if ordering == goal {
+ extremum = v;
+ }
+ }
+
+ Ok(extremum)
+}
+
+/// Determines whether an integer is even.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.even(4) \
+/// #calc.even(5) \
+/// #range(10).filter(calc.even)
+/// ```
+///
+/// Display: Even
+/// Category: calculate
+#[func]
+pub fn even(
+ /// The number to check for evenness.
+ value: i64,
+) -> bool {
+ value % 2 == 0
+}
+
+/// Determines whether an integer is odd.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.odd(4) \
+/// #calc.odd(5) \
+/// #range(10).filter(calc.odd)
+/// ```
+///
+/// Display: Odd
+/// Category: calculate
+#[func]
+pub fn odd(
+ /// The number to check for oddness.
+ value: i64,
+) -> bool {
+ value % 2 != 0
+}
+
+/// Calculates the remainder of two numbers.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.rem(20, 6) \
+/// #calc.rem(1.75, 0.5)
+/// ```
+///
+/// Display: Remainder
+/// Category: calculate
+#[func]
+pub fn rem(
+ /// The dividend of the remainder.
+ dividend: Num,
+ /// The divisor of the remainder.
+ divisor: Spanned<Num>,
+) -> SourceResult<Num> {
+ if divisor.v.float() == 0.0 {
+ bail!(divisor.span, "divisor must not be zero");
+ }
+ Ok(dividend.apply2(divisor.v, Rem::rem, Rem::rem))
+}
+
+/// Calculates the quotient of two numbers.
+///
+/// ## Example { #example }
+/// ```example
+/// #calc.quo(14, 5) \
+/// #calc.quo(3.46, 0.5)
+/// ```
+///
+/// Display: Quotient
+/// Category: calculate
+#[func]
+pub fn quo(
+ /// The dividend of the quotient.
+ dividend: Num,
+ /// The divisor of the quotient.
+ divisor: Spanned<Num>,
+) -> SourceResult<i64> {
+ if divisor.v.float() == 0.0 {
+ bail!(divisor.span, "divisor must not be zero");
+ }
+
+ Ok(floor(dividend.apply2(divisor.v, Div::div, Div::div)))
+}
+
+/// A value which can be passed to functions that work with integers and floats.
+#[derive(Debug, Copy, Clone)]
+pub enum Num {
+ Int(i64),
+ Float(f64),
+}
+
+impl Num {
+ pub fn apply2(
+ self,
+ other: Self,
+ int: impl FnOnce(i64, i64) -> i64,
+ float: impl FnOnce(f64, f64) -> f64,
+ ) -> Num {
+ match (self, other) {
+ (Self::Int(a), Self::Int(b)) => Num::Int(int(a, b)),
+ (a, b) => Num::Float(float(a.float(), b.float())),
+ }
+ }
+
+ pub fn apply3(
+ self,
+ other: Self,
+ third: Self,
+ int: impl FnOnce(i64, i64, i64) -> i64,
+ float: impl FnOnce(f64, f64, f64) -> f64,
+ ) -> Num {
+ match (self, other, third) {
+ (Self::Int(a), Self::Int(b), Self::Int(c)) => Num::Int(int(a, b, c)),
+ (a, b, c) => Num::Float(float(a.float(), b.float(), c.float())),
+ }
+ }
+
+ pub fn float(self) -> f64 {
+ match self {
+ Self::Int(v) => v as f64,
+ Self::Float(v) => v,
+ }
+ }
+}
+
+cast! {
+ Num,
+ self => match self {
+ Self::Int(v) => v.into_value(),
+ Self::Float(v) => v.into_value(),
+ },
+ v: i64 => Self::Int(v),
+ v: f64 => Self::Float(v),
+}
+
+/// A value that can be passed to a trigonometric function.
+pub enum AngleLike {
+ Int(i64),
+ Float(f64),
+ Angle(Angle),
+}
+
+cast! {
+ AngleLike,
+ v: i64 => Self::Int(v),
+ v: f64 => Self::Float(v),
+ v: Angle => Self::Angle(v),
+}
diff --git a/crates/typst-library/src/compute/construct.rs b/crates/typst-library/src/compute/construct.rs
new file mode 100644
index 00000000..956212ee
--- /dev/null
+++ b/crates/typst-library/src/compute/construct.rs
@@ -0,0 +1,743 @@
+use std::num::NonZeroI64;
+use std::str::FromStr;
+
+use time::{Month, PrimitiveDateTime};
+
+use typst::eval::{Datetime, Regex};
+
+use crate::prelude::*;
+
+/// Converts a value to an integer.
+///
+/// - Booleans are converted to `0` or `1`.
+/// - Floats are floored to the next 64-bit integer.
+/// - Strings are parsed in base 10.
+///
+/// ## Example { #example }
+/// ```example
+/// #int(false) \
+/// #int(true) \
+/// #int(2.7) \
+/// #{ int("27") + int("4") }
+/// ```
+///
+/// Display: Integer
+/// Category: construct
+#[func]
+pub fn int(
+ /// The value that should be converted to an integer.
+ value: ToInt,
+) -> i64 {
+ value.0
+}
+
+/// A value that can be cast to an integer.
+pub struct ToInt(i64);
+
+cast! {
+ ToInt,
+ v: bool => Self(v as i64),
+ v: i64 => Self(v),
+ v: f64 => Self(v as i64),
+ v: EcoString => Self(v.parse().map_err(|_| eco_format!("invalid integer: {}", v))?),
+}
+
+/// Converts a value to a float.
+///
+/// - Booleans are converted to `0.0` or `1.0`.
+/// - Integers are converted to the closest 64-bit float.
+/// - Ratios are divided by 100%.
+/// - Strings are parsed in base 10 to the closest 64-bit float.
+/// Exponential notation is supported.
+///
+/// ## Example { #example }
+/// ```example
+/// #float(false) \
+/// #float(true) \
+/// #float(4) \
+/// #float(40%) \
+/// #float("2.7") \
+/// #float("1e5")
+/// ```
+///
+/// Display: Float
+/// Category: construct
+#[func]
+pub fn float(
+ /// The value that should be converted to a float.
+ value: ToFloat,
+) -> f64 {
+ value.0
+}
+
+/// A value that can be cast to a float.
+pub struct ToFloat(f64);
+
+cast! {
+ ToFloat,
+ v: bool => Self(v as i64 as f64),
+ v: i64 => Self(v as f64),
+ v: f64 => Self(v),
+ v: Ratio => Self(v.get()),
+ v: EcoString => Self(v.parse().map_err(|_| eco_format!("invalid float: {}", v))?),
+}
+
+/// Creates a grayscale color.
+///
+/// ## Example { #example }
+/// ```example
+/// #for x in range(250, step: 50) {
+/// box(square(fill: luma(x)))
+/// }
+/// ```
+///
+/// Display: Luma
+/// Category: construct
+#[func]
+pub fn luma(
+ /// The gray component.
+ gray: Component,
+) -> Color {
+ LumaColor::new(gray.0).into()
+}
+
+/// Creates an RGB(A) color.
+///
+/// The color is specified in the sRGB color space.
+///
+/// _Note:_ While you can specify transparent colors and Typst's preview will
+/// render them correctly, the PDF export does not handle them properly at the
+/// moment. This will be fixed in the future.
+///
+/// ## Example { #example }
+/// ```example
+/// #square(fill: rgb("#b1f2eb"))
+/// #square(fill: rgb(87, 127, 230))
+/// #square(fill: rgb(25%, 13%, 65%))
+/// ```
+///
+/// Display: RGB
+/// Category: construct
+#[func]
+pub fn rgb(
+ /// The color in hexadecimal notation.
+ ///
+ /// Accepts three, four, six or eight hexadecimal digits and optionally
+ /// a leading hashtag.
+ ///
+ /// If this string is given, the individual components should not be given.
+ ///
+ /// ```example
+ /// #text(16pt, rgb("#239dad"))[
+ /// *Typst*
+ /// ]
+ /// ```
+ #[external]
+ hex: EcoString,
+ /// The red component.
+ #[external]
+ red: Component,
+ /// The green component.
+ #[external]
+ green: Component,
+ /// The blue component.
+ #[external]
+ blue: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// The arguments.
+ args: Args,
+) -> SourceResult<Color> {
+ let mut args = args;
+ Ok(if let Some(string) = args.find::<Spanned<EcoString>>()? {
+ match RgbaColor::from_str(&string.v) {
+ Ok(color) => color.into(),
+ Err(msg) => bail!(string.span, "{msg}"),
+ }
+ } else {
+ let Component(r) = args.expect("red component")?;
+ let Component(g) = args.expect("green component")?;
+ let Component(b) = args.expect("blue component")?;
+ let Component(a) = args.eat()?.unwrap_or(Component(255));
+ RgbaColor::new(r, g, b, a).into()
+ })
+}
+
+/// An integer or ratio component.
+pub struct Component(u8);
+
+cast! {
+ Component,
+ v: i64 => match v {
+ 0 ..= 255 => Self(v as u8),
+ _ => bail!("number must be between 0 and 255"),
+ },
+ v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) {
+ Self((v.get() * 255.0).round() as u8)
+ } else {
+ bail!("ratio must be between 0% and 100%");
+ },
+}
+
+/// Creates a new datetime.
+///
+/// You can specify the [datetime]($type/datetime) using a year, month, day,
+/// hour, minute, and second. You can also get the current date with
+/// [`datetime.today`]($func/datetime.today).
+///
+/// ## Example
+/// ```example
+/// #let date = datetime(
+/// year: 2012,
+/// month: 8,
+/// day: 3,
+/// )
+///
+/// #date.display() \
+/// #date.display(
+/// "[day].[month].[year]"
+/// )
+/// ```
+///
+/// ## Format
+/// _Note_: Depending on which components of the datetime you specify, Typst
+/// will store it in one of the following three ways:
+/// * If you specify year, month and day, Typst will store just a date.
+/// * If you specify hour, minute and second, Typst will store just a time.
+/// * If you specify all of year, month, day, hour, minute and second, Typst
+/// will store a full datetime.
+///
+/// Depending on how it is stored, the [`display`]($type/datetime.display)
+/// method will choose a different formatting by default.
+///
+/// Display: Datetime
+/// Category: construct
+#[func]
+#[scope(
+ scope.define("today", datetime_today_func());
+ scope
+)]
+pub fn datetime(
+ /// The year of the datetime.
+ #[named]
+ year: Option<YearComponent>,
+ /// The month of the datetime.
+ #[named]
+ month: Option<MonthComponent>,
+ /// The day of the datetime.
+ #[named]
+ day: Option<DayComponent>,
+ /// The hour of the datetime.
+ #[named]
+ hour: Option<HourComponent>,
+ /// The minute of the datetime.
+ #[named]
+ minute: Option<MinuteComponent>,
+ /// The second of the datetime.
+ #[named]
+ second: Option<SecondComponent>,
+) -> StrResult<Datetime> {
+ let time = match (hour, minute, second) {
+ (Some(hour), Some(minute), Some(second)) => {
+ match time::Time::from_hms(hour.0, minute.0, second.0) {
+ Ok(time) => Some(time),
+ Err(_) => bail!("time is invalid"),
+ }
+ }
+ (None, None, None) => None,
+ _ => bail!("time is incomplete"),
+ };
+
+ let date = match (year, month, day) {
+ (Some(year), Some(month), Some(day)) => {
+ match time::Date::from_calendar_date(year.0, month.0, day.0) {
+ Ok(date) => Some(date),
+ Err(_) => bail!("date is invalid"),
+ }
+ }
+ (None, None, None) => None,
+ _ => bail!("date is incomplete"),
+ };
+
+ Ok(match (date, time) {
+ (Some(date), Some(time)) => {
+ Datetime::Datetime(PrimitiveDateTime::new(date, time))
+ }
+ (Some(date), None) => Datetime::Date(date),
+ (None, Some(time)) => Datetime::Time(time),
+ (None, None) => {
+ bail!("at least one of date or time must be fully specified")
+ }
+ })
+}
+
+pub struct YearComponent(i32);
+pub struct MonthComponent(Month);
+pub struct DayComponent(u8);
+pub struct HourComponent(u8);
+pub struct MinuteComponent(u8);
+pub struct SecondComponent(u8);
+
+cast! {
+ YearComponent,
+ v: i32 => Self(v),
+}
+
+cast! {
+ MonthComponent,
+ v: u8 => Self(Month::try_from(v).map_err(|_| "month is invalid")?)
+}
+
+cast! {
+ DayComponent,
+ v: u8 => Self(v),
+}
+
+cast! {
+ HourComponent,
+ v: u8 => Self(v),
+}
+
+cast! {
+ MinuteComponent,
+ v: u8 => Self(v),
+}
+
+cast! {
+ SecondComponent,
+ v: u8 => Self(v),
+}
+
+/// Returns the current date.
+///
+/// Refer to the documentation of the [`display`]($type/datetime.display) method
+/// for details on how to affect the formatting of the date.
+///
+/// ## Example
+/// ```example
+/// Today's date is
+/// #datetime.today().display().
+/// ```
+///
+/// Display: Today
+/// Category: construct
+#[func]
+pub fn datetime_today(
+ /// An offset to apply to the current UTC date. If set to `{auto}`, the
+ /// offset will be the local offset.
+ #[named]
+ #[default]
+ offset: Smart<i64>,
+ /// The virtual machine.
+ vt: &mut Vt,
+) -> StrResult<Datetime> {
+ Ok(vt
+ .world
+ .today(offset.as_custom())
+ .ok_or("unable to get the current date")?)
+}
+
+/// Creates a CMYK color.
+///
+/// This is useful if you want to target a specific printer. The conversion
+/// to RGB for display preview might differ from how your printer reproduces
+/// the color.
+///
+/// ## Example { #example }
+/// ```example
+/// #square(
+/// fill: cmyk(27%, 0%, 3%, 5%)
+/// )
+/// ````
+///
+/// Display: CMYK
+/// Category: construct
+#[func]
+pub fn cmyk(
+ /// The cyan component.
+ cyan: RatioComponent,
+ /// The magenta component.
+ magenta: RatioComponent,
+ /// The yellow component.
+ yellow: RatioComponent,
+ /// The key component.
+ key: RatioComponent,
+) -> Color {
+ CmykColor::new(cyan.0, magenta.0, yellow.0, key.0).into()
+}
+
+/// A component that must be a ratio.
+pub struct RatioComponent(u8);
+
+cast! {
+ RatioComponent,
+ v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) {
+ Self((v.get() * 255.0).round() as u8)
+ } else {
+ bail!("ratio must be between 0% and 100%");
+ },
+}
+
+/// Creates a custom symbol with modifiers.
+///
+/// ## Example { #example }
+/// ```example
+/// #let envelope = symbol(
+/// "🖂",
+/// ("stamped", "🖃"),
+/// ("stamped.pen", "🖆"),
+/// ("lightning", "🖄"),
+/// ("fly", "🖅"),
+/// )
+///
+/// #envelope
+/// #envelope.stamped
+/// #envelope.stamped.pen
+/// #envelope.lightning
+/// #envelope.fly
+/// ```
+///
+/// Display: Symbol
+/// Category: construct
+#[func]
+pub fn symbol(
+ /// The variants of the symbol.
+ ///
+ /// Can be a just a string consisting of a single character for the
+ /// modifierless variant or an array with two strings specifying the modifiers
+ /// and the symbol. Individual modifiers should be separated by dots. When
+ /// displaying a symbol, Typst selects the first from the variants that have
+ /// all attached modifiers and the minimum number of other modifiers.
+ #[variadic]
+ variants: Vec<Spanned<Variant>>,
+ /// The callsite span.
+ span: Span,
+) -> SourceResult<Symbol> {
+ let mut list = Vec::new();
+ if variants.is_empty() {
+ bail!(span, "expected at least one variant");
+ }
+ for Spanned { v, span } in variants {
+ if list.iter().any(|(prev, _)| &v.0 == prev) {
+ bail!(span, "duplicate variant");
+ }
+ list.push((v.0, v.1));
+ }
+ Ok(Symbol::runtime(list.into_boxed_slice()))
+}
+
+/// A value that can be cast to a symbol.
+pub struct Variant(EcoString, char);
+
+cast! {
+ Variant,
+ c: char => Self(EcoString::new(), c),
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Self(a.cast()?, b.cast()?),
+ _ => bail!("point array must contain exactly two entries"),
+ }
+ },
+}
+
+/// Converts a value to a string.
+///
+/// - Integers are formatted in base 10. This can be overridden with the
+/// optional `base` parameter.
+/// - Floats are formatted in base 10 and never in exponential notation.
+/// - From labels the name is extracted.
+///
+/// If you wish to convert from and to Unicode code points, see
+/// [`str.to-unicode`]($func/str.to-unicode) and
+/// [`str.from-unicode`]($func/str.from-unicode).
+///
+/// ## Example { #example }
+/// ```example
+/// #str(10) \
+/// #str(4000, base: 16) \
+/// #str(2.7) \
+/// #str(1e8) \
+/// #str(<intro>)
+/// ```
+///
+/// Display: String
+/// Category: construct
+#[func]
+#[scope(
+ scope.define("to-unicode", str_to_unicode_func());
+ scope.define("from-unicode", str_from_unicode_func());
+ scope
+)]
+pub fn str(
+ /// The value that should be converted to a string.
+ value: ToStr,
+ /// The base (radix) to display integers in, between 2 and 36.
+ #[named]
+ #[default(Spanned::new(10, Span::detached()))]
+ base: Spanned<i64>,
+) -> SourceResult<Str> {
+ Ok(match value {
+ ToStr::Str(s) => {
+ if base.v != 10 {
+ bail!(base.span, "base is only supported for integers");
+ }
+ s
+ }
+ ToStr::Int(n) => {
+ if base.v < 2 || base.v > 36 {
+ bail!(base.span, "base must be between 2 and 36");
+ }
+ int_to_base(n, base.v).into()
+ }
+ })
+}
+
+/// A value that can be cast to a string.
+pub enum ToStr {
+ /// A string value ready to be used as-is.
+ Str(Str),
+ /// An integer about to be formatted in a given base.
+ Int(i64),
+}
+
+cast! {
+ ToStr,
+ v: i64 => Self::Int(v),
+ v: f64 => Self::Str(format_str!("{}", v)),
+ v: Label => Self::Str(v.0.into()),
+ v: Str => Self::Str(v),
+}
+
+/// Format an integer in a base.
+fn int_to_base(mut n: i64, base: i64) -> EcoString {
+ if n == 0 {
+ return "0".into();
+ }
+
+ // In Rust, `format!("{:x}", -14i64)` is not `-e` but `fffffffffffffff2`.
+ // So we can only use the built-in for decimal, not bin/oct/hex.
+ if base == 10 {
+ return eco_format!("{n}");
+ }
+
+ // The largest output is `to_base(i64::MIN, 2)`, which is 65 chars long.
+ const SIZE: usize = 65;
+ let mut digits = [b'\0'; SIZE];
+ let mut i = SIZE;
+
+ // It's tempting to take the absolute value, but this will fail for i64::MIN.
+ // Instead, we turn n negative, as -i64::MAX is perfectly representable.
+ let negative = n < 0;
+ if n > 0 {
+ n = -n;
+ }
+
+ while n != 0 {
+ let digit = char::from_digit(-(n % base) as u32, base as u32);
+ i -= 1;
+ digits[i] = digit.unwrap_or('?') as u8;
+ n /= base;
+ }
+
+ if negative {
+ i -= 1;
+ digits[i] = b'-';
+ }
+
+ std::str::from_utf8(&digits[i..]).unwrap_or_default().into()
+}
+
+/// Converts a character into its corresponding code point.
+///
+/// ## Example
+/// ```example
+/// #str.to-unicode("a") \
+/// #"a\u{0300}".codepoints().map(str.to-unicode)
+/// ```
+///
+/// Display: String To Unicode
+/// Category: construct
+#[func]
+pub fn str_to_unicode(
+ /// The character that should be converted.
+ value: char,
+) -> u32 {
+ value.into()
+}
+
+/// Converts a Unicode code point into its corresponding string.
+///
+/// ```example
+/// #str.from-unicode(97)
+/// ```
+///
+/// Display: String From Unicode
+/// Category: construct
+#[func]
+pub fn str_from_unicode(
+ /// The code point that should be converted.
+ value: CodePoint,
+) -> Str {
+ format_str!("{}", value.0)
+}
+
+/// The numeric representation of a single unicode code point.
+pub struct CodePoint(char);
+
+cast! {
+ CodePoint,
+ v: i64 => {
+ Self(v.try_into().ok().and_then(|v: u32| v.try_into().ok()).ok_or_else(
+ || eco_format!("{:#x} is not a valid codepoint", v),
+ )?)
+ },
+}
+
+/// Creates a label from a string.
+///
+/// Inserting a label into content attaches it to the closest previous element
+/// that is not a space. Then, the element can be [referenced]($func/ref) and
+/// styled through the label.
+///
+/// ## Example { #example }
+/// ```example
+/// #show <a>: set text(blue)
+/// #show label("b"): set text(red)
+///
+/// = Heading <a>
+/// *Strong* #label("b")
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: You can create a label by enclosing
+/// its name in angle brackets. This works both in markup and code.
+///
+/// Display: Label
+/// Category: construct
+#[func]
+pub fn label(
+ /// The name of the label.
+ name: EcoString,
+) -> Label {
+ Label(name)
+}
+
+/// Creates a regular expression from a string.
+///
+/// The result can be used as a
+/// [show rule selector]($styling/#show-rules) and with
+/// [string methods]($type/string) like `find`, `split`, and `replace`.
+///
+/// See [the specification of the supported syntax](https://docs.rs/regex/latest/regex/#syntax).
+///
+/// ## Example { #example }
+/// ```example
+/// // Works with show rules.
+/// #show regex("\d+"): set text(red)
+///
+/// The numbers 1 to 10.
+///
+/// // Works with string methods.
+/// #("a,b;c"
+/// .split(regex("[,;]")))
+/// ```
+///
+/// Display: Regex
+/// Category: construct
+#[func]
+pub fn regex(
+ /// The regular expression as a string.
+ ///
+ /// Most regex escape sequences just work because they are not valid Typst
+ /// escape sequences. To produce regex escape sequences that are also valid in
+ /// Typst (e.g. `[\\]`), you need to escape twice. Thus, to match a verbatim
+ /// backslash, you would need to write `{regex("\\\\")}`.
+ ///
+ /// If you need many escape sequences, you can also create a raw element
+ /// and extract its text to use it for your regular expressions:
+ /// ```{regex(`\d+\.\d+\.\d+`.text)}```.
+ regex: Spanned<EcoString>,
+) -> SourceResult<Regex> {
+ Regex::new(&regex.v).at(regex.span)
+}
+
+/// Creates an array consisting of consecutive integers.
+///
+/// If you pass just one positional parameter, it is interpreted as the `end` of
+/// the range. If you pass two, they describe the `start` and `end` of the
+/// range.
+///
+/// ## Example { #example }
+/// ```example
+/// #range(5) \
+/// #range(2, 5) \
+/// #range(20, step: 4) \
+/// #range(21, step: 4) \
+/// #range(5, 2, step: -1)
+/// ```
+///
+/// Display: Range
+/// Category: construct
+#[func]
+pub fn range(
+ /// The start of the range (inclusive).
+ #[external]
+ #[default]
+ start: i64,
+ /// The end of the range (exclusive).
+ #[external]
+ end: i64,
+ /// The distance between the generated numbers.
+ #[named]
+ #[default(NonZeroI64::new(1).unwrap())]
+ step: NonZeroI64,
+ /// The arguments.
+ args: Args,
+) -> SourceResult<Array> {
+ let mut args = args;
+ let first = args.expect::<i64>("end")?;
+ let (start, end) = match args.eat::<i64>()? {
+ Some(second) => (first, second),
+ None => (0, first),
+ };
+
+ let step = step.get();
+
+ let mut x = start;
+ let mut array = Array::new();
+
+ while x.cmp(&end) == 0.cmp(&step) {
+ array.push(Value::Int(x));
+ x += step;
+ }
+
+ Ok(array)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_to_base() {
+ assert_eq!(&int_to_base(0, 10), "0");
+ assert_eq!(&int_to_base(0, 16), "0");
+ assert_eq!(&int_to_base(0, 36), "0");
+ assert_eq!(
+ &int_to_base(i64::MAX, 2),
+ "111111111111111111111111111111111111111111111111111111111111111"
+ );
+ assert_eq!(
+ &int_to_base(i64::MIN, 2),
+ "-1000000000000000000000000000000000000000000000000000000000000000"
+ );
+ assert_eq!(&int_to_base(i64::MAX, 10), "9223372036854775807");
+ assert_eq!(&int_to_base(i64::MIN, 10), "-9223372036854775808");
+ assert_eq!(&int_to_base(i64::MAX, 16), "7fffffffffffffff");
+ assert_eq!(&int_to_base(i64::MIN, 16), "-8000000000000000");
+ assert_eq!(&int_to_base(i64::MAX, 36), "1y2p0ij32e8e7");
+ assert_eq!(&int_to_base(i64::MIN, 36), "-1y2p0ij32e8e8");
+ }
+}
diff --git a/crates/typst-library/src/compute/data.rs b/crates/typst-library/src/compute/data.rs
new file mode 100644
index 00000000..6e3a298e
--- /dev/null
+++ b/crates/typst-library/src/compute/data.rs
@@ -0,0 +1,492 @@
+use typst::diag::{format_xml_like_error, FileError};
+use typst::eval::Datetime;
+
+use crate::prelude::*;
+
+/// Reads plain text from a file.
+///
+/// The file will be read and returned as a string.
+///
+/// ## Example { #example }
+/// ```example
+/// #let text = read("data.html")
+///
+/// An example for a HTML file:\
+/// #raw(text, lang: "html")
+/// ```
+///
+/// Display: Read
+/// Category: data-loading
+#[func]
+pub fn read(
+ /// Path to a file.
+ path: Spanned<EcoString>,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Str> {
+ let Spanned { v: path, span } = path;
+ let id = vm.location().join(&path).at(span)?;
+ let data = vm.world().file(id).at(span)?;
+ let text = std::str::from_utf8(&data)
+ .map_err(|_| "file is not valid utf-8")
+ .at(span)?;
+ Ok(text.into())
+}
+
+/// Reads structured data from a CSV file.
+///
+/// The CSV file will be read and parsed into a 2-dimensional array of strings:
+/// Each row in the CSV file will be represented as an array of strings, and all
+/// rows will be collected into a single array. Header rows will not be
+/// stripped.
+///
+/// ## Example { #example }
+/// ```example
+/// #let results = csv("data.csv")
+///
+/// #table(
+/// columns: 2,
+/// [*Condition*], [*Result*],
+/// ..results.flatten(),
+/// )
+/// ```
+///
+/// Display: CSV
+/// Category: data-loading
+#[func]
+pub fn csv(
+ /// Path to a CSV file.
+ path: Spanned<EcoString>,
+ /// The delimiter that separates columns in the CSV file.
+ /// Must be a single ASCII character.
+ #[named]
+ #[default]
+ delimiter: Delimiter,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Array> {
+ let Spanned { v: path, span } = path;
+ let id = vm.location().join(&path).at(span)?;
+ let data = vm.world().file(id).at(span)?;
+
+ let mut builder = csv::ReaderBuilder::new();
+ builder.has_headers(false);
+ builder.delimiter(delimiter.0 as u8);
+
+ let mut reader = builder.from_reader(data.as_slice());
+ let mut array = Array::new();
+
+ for (line, result) in reader.records().enumerate() {
+ // Original solution use line from error, but that is incorrect with
+ // `has_headers` set to `false`. See issue:
+ // https://github.com/BurntSushi/rust-csv/issues/184
+ let line = line + 1; // Counting lines from 1
+ let row = result.map_err(|err| format_csv_error(err, line)).at(span)?;
+ let sub = row.into_iter().map(|field| field.into_value()).collect();
+ array.push(Value::Array(sub))
+ }
+
+ Ok(array)
+}
+
+/// The delimiter to use when parsing CSV files.
+pub struct Delimiter(char);
+
+impl Default for Delimiter {
+ fn default() -> Self {
+ Self(',')
+ }
+}
+
+cast! {
+ Delimiter,
+ self => self.0.into_value(),
+ v: EcoString => {
+ let mut chars = v.chars();
+ let first = chars.next().ok_or("delimiter must not be empty")?;
+ if chars.next().is_some() {
+ bail!("delimiter must be a single character");
+ }
+
+ if !first.is_ascii() {
+ bail!("delimiter must be an ASCII character");
+ }
+
+ Self(first)
+ },
+}
+
+/// Format the user-facing CSV error message.
+fn format_csv_error(error: csv::Error, line: usize) -> EcoString {
+ match error.kind() {
+ csv::ErrorKind::Utf8 { .. } => "file is not valid utf-8".into(),
+ csv::ErrorKind::UnequalLengths { expected_len, len, .. } => {
+ eco_format!(
+ "failed to parse csv file: found {len} instead of {expected_len} fields in line {line}"
+ )
+ }
+ _ => "failed to parse csv file".into(),
+ }
+}
+
+/// Reads structured data from a JSON file.
+///
+/// The file must contain a valid JSON object or array. JSON objects will be
+/// converted into Typst dictionaries, and JSON arrays will be converted into
+/// Typst arrays. Strings and booleans will be converted into the Typst
+/// equivalents, `null` will be converted into `{none}`, and numbers will be
+/// converted to floats or integers depending on whether they are whole numbers.
+///
+/// The function returns a dictionary or an array, depending on the JSON file.
+///
+/// The JSON files in the example contain objects with the keys `temperature`,
+/// `unit`, and `weather`.
+///
+/// ## Example { #example }
+/// ```example
+/// #let forecast(day) = block[
+/// #box(square(
+/// width: 2cm,
+/// inset: 8pt,
+/// fill: if day.weather == "sunny" {
+/// yellow
+/// } else {
+/// aqua
+/// },
+/// align(
+/// bottom + right,
+/// strong(day.weather),
+/// ),
+/// ))
+/// #h(6pt)
+/// #set text(22pt, baseline: -8pt)
+/// #day.temperature °#day.unit
+/// ]
+///
+/// #forecast(json("monday.json"))
+/// #forecast(json("tuesday.json"))
+/// ```
+///
+/// Display: JSON
+/// Category: data-loading
+#[func]
+pub fn json(
+ /// Path to a JSON file.
+ path: Spanned<EcoString>,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = vm.location().join(&path).at(span)?;
+ let data = vm.world().file(id).at(span)?;
+ let value: serde_json::Value =
+ serde_json::from_slice(&data).map_err(format_json_error).at(span)?;
+ Ok(convert_json(value))
+}
+
+/// Convert a JSON value to a Typst value.
+fn convert_json(value: serde_json::Value) -> Value {
+ match value {
+ serde_json::Value::Null => Value::None,
+ serde_json::Value::Bool(v) => v.into_value(),
+ serde_json::Value::Number(v) => match v.as_i64() {
+ Some(int) => int.into_value(),
+ None => v.as_f64().unwrap_or(f64::NAN).into_value(),
+ },
+ serde_json::Value::String(v) => v.into_value(),
+ serde_json::Value::Array(v) => {
+ v.into_iter().map(convert_json).collect::<Array>().into_value()
+ }
+ serde_json::Value::Object(v) => v
+ .into_iter()
+ .map(|(key, value)| (key.into(), convert_json(value)))
+ .collect::<Dict>()
+ .into_value(),
+ }
+}
+
+/// Format the user-facing JSON error message.
+fn format_json_error(error: serde_json::Error) -> EcoString {
+ assert!(error.is_syntax() || error.is_eof());
+ eco_format!("failed to parse json file: syntax error in line {}", error.line())
+}
+
+/// Reads structured data from a TOML file.
+///
+/// The file must contain a valid TOML table. TOML tables will be
+/// converted into Typst dictionaries, and TOML arrays will be converted into
+/// Typst arrays. Strings and booleans will be converted into the Typst
+/// equivalents and numbers will be converted to floats or integers depending on
+/// whether they are whole numbers. For the time being, datetimes will be
+/// converted to strings as Typst does not have a built-in datetime yet.
+///
+/// The TOML file in the example consists of a table with the keys `title`,
+/// `version`, and `authors`.
+///
+/// ## Example { #example }
+/// ```example
+/// #let details = toml("details.toml")
+///
+/// Title: #details.title \
+/// Version: #details.version \
+/// Authors: #(details.authors
+/// .join(", ", last: " and "))
+/// ```
+///
+/// Display: TOML
+/// Category: data-loading
+#[func]
+pub fn toml(
+ /// Path to a TOML file.
+ path: Spanned<EcoString>,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = vm.location().join(&path).at(span)?;
+ let data = vm.world().file(id).at(span)?;
+
+ let raw = std::str::from_utf8(&data)
+ .map_err(|_| "file is not valid utf-8")
+ .at(span)?;
+
+ let value: toml::Value = toml::from_str(raw).map_err(format_toml_error).at(span)?;
+ Ok(convert_toml(value))
+}
+
+/// Convert a TOML value to a Typst value.
+fn convert_toml(value: toml::Value) -> Value {
+ match value {
+ toml::Value::String(v) => v.into_value(),
+ toml::Value::Integer(v) => v.into_value(),
+ toml::Value::Float(v) => v.into_value(),
+ toml::Value::Boolean(v) => v.into_value(),
+ toml::Value::Array(v) => {
+ v.into_iter().map(convert_toml).collect::<Array>().into_value()
+ }
+ toml::Value::Table(v) => v
+ .into_iter()
+ .map(|(key, value)| (key.into(), convert_toml(value)))
+ .collect::<Dict>()
+ .into_value(),
+ toml::Value::Datetime(v) => match (v.date, v.time) {
+ (None, None) => Value::None,
+ (Some(date), None) => {
+ Datetime::from_ymd(date.year as i32, date.month, date.day).into_value()
+ }
+ (None, Some(time)) => {
+ Datetime::from_hms(time.hour, time.minute, time.second).into_value()
+ }
+ (Some(date), Some(time)) => Datetime::from_ymd_hms(
+ date.year as i32,
+ date.month,
+ date.day,
+ time.hour,
+ time.minute,
+ time.second,
+ )
+ .into_value(),
+ },
+ }
+}
+
+/// Format the user-facing TOML error message.
+fn format_toml_error(error: toml::de::Error) -> EcoString {
+ if let Some(range) = error.span() {
+ eco_format!(
+ "failed to parse toml file: {}, index {}-{}",
+ error.message(),
+ range.start,
+ range.end
+ )
+ } else {
+ eco_format!("failed to parse toml file: {}", error.message())
+ }
+}
+
+/// Reads structured data from a YAML file.
+///
+/// The file must contain a valid YAML object or array. YAML mappings will be
+/// converted into Typst dictionaries, and YAML sequences will be converted into
+/// Typst arrays. Strings and booleans will be converted into the Typst
+/// equivalents, null-values (`null`, `~` or empty ``) will be converted into
+/// `{none}`, and numbers will be converted to floats or integers depending on
+/// whether they are whole numbers.
+///
+/// Note that mapping keys that are not a string cause the entry to be
+/// discarded.
+///
+/// Custom YAML tags are ignored, though the loaded value will still be
+/// present.
+///
+/// The function returns a dictionary or value or an array, depending on
+/// the YAML file.
+///
+/// The YAML files in the example contain objects with authors as keys,
+/// each with a sequence of their own submapping with the keys
+/// "title" and "published"
+///
+/// ## Example { #example }
+/// ```example
+/// #let bookshelf(contents) = {
+/// for (author, works) in contents {
+/// author
+/// for work in works [
+/// - #work.title (#work.published)
+/// ]
+/// }
+/// }
+///
+/// #bookshelf(
+/// yaml("scifi-authors.yaml")
+/// )
+/// ```
+///
+/// Display: YAML
+/// Category: data-loading
+#[func]
+pub fn yaml(
+ /// Path to a YAML file.
+ path: Spanned<EcoString>,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = vm.location().join(&path).at(span)?;
+ let data = vm.world().file(id).at(span)?;
+ let value: serde_yaml::Value =
+ serde_yaml::from_slice(&data).map_err(format_yaml_error).at(span)?;
+ Ok(convert_yaml(value))
+}
+
+/// Convert a YAML value to a Typst value.
+fn convert_yaml(value: serde_yaml::Value) -> Value {
+ match value {
+ serde_yaml::Value::Null => Value::None,
+ serde_yaml::Value::Bool(v) => v.into_value(),
+ serde_yaml::Value::Number(v) => match v.as_i64() {
+ Some(int) => int.into_value(),
+ None => v.as_f64().unwrap_or(f64::NAN).into_value(),
+ },
+ serde_yaml::Value::String(v) => v.into_value(),
+ serde_yaml::Value::Sequence(v) => {
+ v.into_iter().map(convert_yaml).collect::<Array>().into_value()
+ }
+ serde_yaml::Value::Mapping(v) => v
+ .into_iter()
+ .map(|(key, value)| (convert_yaml_key(key), convert_yaml(value)))
+ .filter_map(|(key, value)| key.map(|key| (key, value)))
+ .collect::<Dict>()
+ .into_value(),
+ }
+}
+
+/// Converts an arbitrary YAML mapping key into a Typst Dict Key.
+/// Currently it only does so for strings, everything else
+/// returns None
+fn convert_yaml_key(key: serde_yaml::Value) -> Option<Str> {
+ match key {
+ serde_yaml::Value::String(v) => Some(Str::from(v)),
+ _ => None,
+ }
+}
+
+/// Format the user-facing YAML error message.
+fn format_yaml_error(error: serde_yaml::Error) -> EcoString {
+ eco_format!("failed to parse yaml file: {}", error.to_string().trim())
+}
+
+/// Reads structured data from an XML file.
+///
+/// The XML file is parsed into an array of dictionaries and strings. XML nodes
+/// can be elements or strings. Elements are represented as dictionaries with
+/// the the following keys:
+///
+/// - `tag`: The name of the element as a string.
+/// - `attrs`: A dictionary of the element's attributes as strings.
+/// - `children`: An array of the element's child nodes.
+///
+/// The XML file in the example contains a root `news` tag with multiple
+/// `article` tags. Each article has a `title`, `author`, and `content` tag. The
+/// `content` tag contains one or more paragraphs, which are represented as `p`
+/// tags.
+///
+/// ## Example { #example }
+/// ```example
+/// #let find-child(elem, tag) = {
+/// elem.children
+/// .find(e => "tag" in e and e.tag == tag)
+/// }
+///
+/// #let article(elem) = {
+/// let title = find-child(elem, "title")
+/// let author = find-child(elem, "author")
+/// let pars = find-child(elem, "content")
+///
+/// heading(title.children.first())
+/// text(10pt, weight: "medium")[
+/// Published by
+/// #author.children.first()
+/// ]
+///
+/// for p in pars.children {
+/// if (type(p) == "dictionary") {
+/// parbreak()
+/// p.children.first()
+/// }
+/// }
+/// }
+///
+/// #let data = xml("example.xml")
+/// #for elem in data.first().children {
+/// if (type(elem) == "dictionary") {
+/// article(elem)
+/// }
+/// }
+/// ```
+///
+/// Display: XML
+/// Category: data-loading
+#[func]
+pub fn xml(
+ /// Path to an XML file.
+ path: Spanned<EcoString>,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = vm.location().join(&path).at(span)?;
+ let data = vm.world().file(id).at(span)?;
+ let text = std::str::from_utf8(&data).map_err(FileError::from).at(span)?;
+ let document = roxmltree::Document::parse(text).map_err(format_xml_error).at(span)?;
+ Ok(convert_xml(document.root()))
+}
+
+/// Convert an XML node to a Typst value.
+fn convert_xml(node: roxmltree::Node) -> Value {
+ if node.is_text() {
+ return node.text().unwrap_or_default().into_value();
+ }
+
+ let children: Array = node.children().map(convert_xml).collect();
+ if node.is_root() {
+ return Value::Array(children);
+ }
+
+ let tag: Str = node.tag_name().name().into();
+ let attrs: Dict = node
+ .attributes()
+ .map(|attr| (attr.name().into(), attr.value().into_value()))
+ .collect();
+
+ Value::Dict(dict! {
+ "tag" => tag,
+ "attrs" => attrs,
+ "children" => children,
+ })
+}
+
+/// Format the user-facing XML error message.
+fn format_xml_error(error: roxmltree::Error) -> EcoString {
+ format_xml_like_error("xml file", error)
+}
diff --git a/crates/typst-library/src/compute/foundations.rs b/crates/typst-library/src/compute/foundations.rs
new file mode 100644
index 00000000..f83d71a0
--- /dev/null
+++ b/crates/typst-library/src/compute/foundations.rs
@@ -0,0 +1,215 @@
+use crate::prelude::*;
+
+/// Determines the type of a value.
+///
+/// Returns the name of the value's type.
+///
+/// ## Example { #example }
+/// ```example
+/// #type(12) \
+/// #type(14.7) \
+/// #type("hello") \
+/// #type(none) \
+/// #type([Hi]) \
+/// #type(x => x + 1)
+/// ```
+///
+/// Display: Type
+/// Category: foundations
+#[func]
+pub fn type_(
+ /// The value whose type's to determine.
+ value: Value,
+) -> Str {
+ value.type_name().into()
+}
+
+/// Returns the string representation of a value.
+///
+/// When inserted into content, most values are displayed as this representation
+/// in monospace with syntax-highlighting. The exceptions are `{none}`,
+/// integers, floats, strings, content, and functions.
+///
+/// **Note:** This function is for debugging purposes. Its output should not be
+/// considered stable and may change at any time!
+///
+/// ## Example { #example }
+/// ```example
+/// #none vs #repr(none) \
+/// #"hello" vs #repr("hello") \
+/// #(1, 2) vs #repr((1, 2)) \
+/// #[*Hi*] vs #repr([*Hi*])
+/// ```
+///
+/// Display: Representation
+/// Category: foundations
+#[func]
+pub fn repr(
+ /// The value whose string representation to produce.
+ value: Value,
+) -> Str {
+ value.repr()
+}
+
+/// Fails with an error.
+///
+/// ## Example { #example }
+/// The code below produces the error `panicked with: "this is wrong"`.
+/// ```typ
+/// #panic("this is wrong")
+/// ```
+///
+/// Display: Panic
+/// Category: foundations
+#[func]
+pub fn panic(
+ /// The values to panic with.
+ #[variadic]
+ values: Vec<Value>,
+) -> StrResult<Never> {
+ let mut msg = EcoString::from("panicked");
+ if !values.is_empty() {
+ msg.push_str(" with: ");
+ for (i, value) in values.iter().enumerate() {
+ if i > 0 {
+ msg.push_str(", ");
+ }
+ msg.push_str(&value.repr());
+ }
+ }
+ Err(msg)
+}
+
+/// Ensures that a condition is fulfilled.
+///
+/// Fails with an error if the condition is not fulfilled. Does not
+/// produce any output in the document.
+///
+/// If you wish to test equality between two values, see
+/// [`assert.eq`]($func/assert.eq) and [`assert.ne`]($func/assert.ne).
+///
+/// ## Example { #example }
+/// ```typ
+/// #assert(1 < 2, message: "math broke")
+/// ```
+///
+/// Display: Assert
+/// Category: foundations
+#[func]
+#[scope(
+ scope.define("eq", assert_eq_func());
+ scope.define("ne", assert_ne_func());
+ scope
+)]
+pub fn assert(
+ /// The condition that must be true for the assertion to pass.
+ condition: bool,
+ /// The error message when the assertion fails.
+ #[named]
+ message: Option<EcoString>,
+) -> StrResult<NoneValue> {
+ if !condition {
+ if let Some(message) = message {
+ bail!("assertion failed: {message}");
+ } else {
+ bail!("assertion failed");
+ }
+ }
+ Ok(NoneValue)
+}
+
+/// Ensures that two values are equal.
+///
+/// Fails with an error if the first value is not equal to the second. Does not
+/// produce any output in the document.
+///
+/// ## Example { #example }
+/// ```typ
+/// #assert.eq(10, 10)
+/// ```
+///
+/// Display: Assert Equals
+/// Category: foundations
+#[func]
+pub fn assert_eq(
+ /// The first value to compare.
+ left: Value,
+
+ /// The second value to compare.
+ right: Value,
+
+ /// An optional message to display on error instead of the representations
+ /// of the compared values.
+ #[named]
+ message: Option<EcoString>,
+) -> StrResult<NoneValue> {
+ if left != right {
+ if let Some(message) = message {
+ bail!("equality assertion failed: {message}");
+ } else {
+ bail!("equality assertion failed: value {left:?} was not equal to {right:?}");
+ }
+ }
+ Ok(NoneValue)
+}
+
+/// Ensures that two values are not equal.
+///
+/// Fails with an error if the first value is equal to the second. Does not
+/// produce any output in the document.
+///
+/// ## Example { #example }
+/// ```typ
+/// #assert.ne(3, 4)
+/// ```
+///
+/// Display: Assert Not Equals
+/// Category: foundations
+#[func]
+pub fn assert_ne(
+ /// The first value to compare.
+ left: Value,
+
+ /// The second value to compare.
+ right: Value,
+
+ /// An optional message to display on error instead of the representations
+ /// of the compared values.
+ #[named]
+ message: Option<EcoString>,
+) -> StrResult<NoneValue> {
+ if left == right {
+ if let Some(message) = message {
+ bail!("inequality assertion failed: {message}");
+ } else {
+ bail!("inequality assertion failed: value {left:?} was equal to {right:?}");
+ }
+ }
+ Ok(NoneValue)
+}
+
+/// Evaluates a string as Typst code.
+///
+/// This function should only be used as a last resort.
+///
+/// ## Example { #example }
+/// ```example
+/// #eval("1 + 1") \
+/// #eval("(1, 2, 3, 4)").len() \
+/// #eval("[*Strong text*]")
+/// ```
+///
+/// Display: Evaluate
+/// Category: foundations
+#[func]
+pub fn eval(
+ /// A string of Typst code to evaluate.
+ ///
+ /// The code in the string cannot interact with the file system.
+ source: Spanned<String>,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Value> {
+ let Spanned { v: text, span } = source;
+ typst::eval::eval_string(vm.world(), &text, span)
+}
diff --git a/crates/typst-library/src/compute/mod.rs b/crates/typst-library/src/compute/mod.rs
new file mode 100644
index 00000000..e9e4870c
--- /dev/null
+++ b/crates/typst-library/src/compute/mod.rs
@@ -0,0 +1,39 @@
+//! Computational functions.
+
+pub mod calc;
+mod construct;
+mod data;
+mod foundations;
+
+pub use self::construct::*;
+pub use self::data::*;
+pub use self::foundations::*;
+
+use crate::prelude::*;
+
+/// Hook up all compute definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.define("type", type_func());
+ global.define("repr", repr_func());
+ global.define("panic", panic_func());
+ global.define("assert", assert_func());
+ global.define("eval", eval_func());
+ global.define("int", int_func());
+ global.define("float", float_func());
+ global.define("luma", luma_func());
+ global.define("rgb", rgb_func());
+ global.define("cmyk", cmyk_func());
+ global.define("datetime", datetime_func());
+ global.define("symbol", symbol_func());
+ global.define("str", str_func());
+ global.define("label", label_func());
+ global.define("regex", regex_func());
+ global.define("range", range_func());
+ global.define("read", read_func());
+ global.define("csv", csv_func());
+ global.define("json", json_func());
+ global.define("toml", toml_func());
+ global.define("yaml", yaml_func());
+ global.define("xml", xml_func());
+ global.define("calc", calc::module());
+}
diff --git a/crates/typst-library/src/layout/align.rs b/crates/typst-library/src/layout/align.rs
new file mode 100644
index 00000000..bbfe9f7e
--- /dev/null
+++ b/crates/typst-library/src/layout/align.rs
@@ -0,0 +1,67 @@
+use crate::prelude::*;
+
+/// Aligns content horizontally and vertically.
+///
+/// ## Example { #example }
+/// ```example
+/// #set align(center)
+///
+/// Centered text, a sight to see \
+/// In perfect balance, visually \
+/// Not left nor right, it stands alone \
+/// A work of art, a visual throne
+/// ```
+///
+/// Display: Align
+/// Category: layout
+#[element(Show)]
+pub struct AlignElem {
+ /// The alignment along both axes.
+ ///
+ /// Possible values for horizontal alignments are:
+ /// - `start`
+ /// - `end`
+ /// - `left`
+ /// - `center`
+ /// - `right`
+ ///
+ /// The `start` and `end` alignments are relative to the current [text
+ /// direction]($func/text.dir).
+ ///
+ /// Possible values for vertical alignments are:
+ /// - `top`
+ /// - `horizon`
+ /// - `bottom`
+ ///
+ /// To align along both axes at the same time, add the two alignments using
+ /// the `+` operator to get a `2d alignment`. For example, `top + right`
+ /// aligns the content to the top right corner.
+ ///
+ /// ```example
+ /// #set page(height: 6cm)
+ /// #set text(lang: "ar")
+ ///
+ /// مثال
+ /// #align(
+ /// end + horizon,
+ /// rect(inset: 12pt)[ركن]
+ /// )
+ /// ```
+ #[positional]
+ #[fold]
+ #[default(Axes::new(GenAlign::Start, GenAlign::Specific(Align::Top)))]
+ pub alignment: Axes<Option<GenAlign>>,
+
+ /// The content to align.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for AlignElem {
+ #[tracing::instrument(name = "AlignElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self
+ .body()
+ .styled(Self::set_alignment(self.alignment(styles).map(Some))))
+ }
+}
diff --git a/crates/typst-library/src/layout/columns.rs b/crates/typst-library/src/layout/columns.rs
new file mode 100644
index 00000000..d2138491
--- /dev/null
+++ b/crates/typst-library/src/layout/columns.rs
@@ -0,0 +1,172 @@
+use crate::prelude::*;
+use crate::text::TextElem;
+
+/// Separates a region into multiple equally sized columns.
+///
+/// The `column` function allows to separate the interior of any container into
+/// multiple columns. It will not equalize the height of the columns, instead,
+/// the columns will take up the height of their container or the remaining
+/// height on the page. The columns function can break across pages if
+/// necessary.
+///
+/// ## Example { #example }
+/// ```example
+/// = Towards Advanced Deep Learning
+///
+/// #box(height: 68pt,
+/// columns(2, gutter: 11pt)[
+/// #set par(justify: true)
+/// This research was funded by the
+/// National Academy of Sciences.
+/// NAoS provided support for field
+/// tests and interviews with a
+/// grant of up to USD 40.000 for a
+/// period of 6 months.
+/// ]
+/// )
+///
+/// In recent years, deep learning has
+/// increasingly been used to solve a
+/// variety of problems.
+/// ```
+///
+/// Display: Columns
+/// Category: layout
+#[element(Layout)]
+pub struct ColumnsElem {
+ /// The number of columns.
+ #[positional]
+ #[default(NonZeroUsize::new(2).unwrap())]
+ pub count: NonZeroUsize,
+
+ /// The size of the gutter space between each column.
+ #[resolve]
+ #[default(Ratio::new(0.04).into())]
+ pub gutter: Rel<Length>,
+
+ /// The content that should be layouted into the columns.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for ColumnsElem {
+ #[tracing::instrument(name = "ColumnsElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let body = self.body();
+
+ // Separating the infinite space into infinite columns does not make
+ // much sense.
+ if !regions.size.x.is_finite() {
+ return body.layout(vt, styles, regions);
+ }
+
+ // Determine the width of the gutter and each column.
+ let columns = self.count(styles).get();
+ let gutter = self.gutter(styles).relative_to(regions.base().x);
+ let width = (regions.size.x - gutter * (columns - 1) as f64) / columns as f64;
+
+ let backlog: Vec<_> = std::iter::once(&regions.size.y)
+ .chain(regions.backlog)
+ .flat_map(|&height| std::iter::repeat(height).take(columns))
+ .skip(1)
+ .collect();
+
+ // Create the pod regions.
+ let pod = Regions {
+ size: Size::new(width, regions.size.y),
+ full: regions.full,
+ backlog: &backlog,
+ last: regions.last,
+ expand: Axes::new(true, regions.expand.y),
+ root: regions.root,
+ };
+
+ // Layout the children.
+ let mut frames = body.layout(vt, styles, pod)?.into_iter();
+ let mut finished = vec![];
+
+ let dir = TextElem::dir_in(styles);
+ let total_regions = (frames.len() as f32 / columns as f32).ceil() as usize;
+
+ // Stitch together the columns for each region.
+ for region in regions.iter().take(total_regions) {
+ // The height should be the parent height if we should expand.
+ // Otherwise its the maximum column height for the frame. In that
+ // case, the frame is first created with zero height and then
+ // resized.
+ let height = if regions.expand.y { region.y } else { Abs::zero() };
+ let mut output = Frame::new(Size::new(regions.size.x, height));
+ let mut cursor = Abs::zero();
+
+ for _ in 0..columns {
+ let Some(frame) = frames.next() else { break };
+ if !regions.expand.y {
+ output.size_mut().y.set_max(frame.height());
+ }
+
+ let width = frame.width();
+ let x = if dir == Dir::LTR {
+ cursor
+ } else {
+ regions.size.x - cursor - width
+ };
+
+ output.push_frame(Point::with_x(x), frame);
+ cursor += width + gutter;
+ }
+
+ finished.push(output);
+ }
+
+ Ok(Fragment::frames(finished))
+ }
+}
+
+/// Forces a column break.
+///
+/// The function will behave like a [page break]($func/pagebreak) when used in a
+/// single column layout or the last column on a page. Otherwise, content after
+/// the column break will be placed in the next column.
+///
+/// ## Example { #example }
+/// ```example
+/// #set page(columns: 2)
+/// Preliminary findings from our
+/// ongoing research project have
+/// revealed a hitherto unknown
+/// phenomenon of extraordinary
+/// significance.
+///
+/// #colbreak()
+/// Through rigorous experimentation
+/// and analysis, we have discovered
+/// a hitherto uncharacterized process
+/// that defies our current
+/// understanding of the fundamental
+/// laws of nature.
+/// ```
+///
+/// Display: Column Break
+/// Category: layout
+#[element(Behave)]
+pub struct ColbreakElem {
+ /// If `{true}`, the column break is skipped if the current column is
+ /// already empty.
+ #[default(false)]
+ pub weak: bool,
+}
+
+impl Behave for ColbreakElem {
+ fn behaviour(&self) -> Behaviour {
+ if self.weak(StyleChain::default()) {
+ Behaviour::Weak(1)
+ } else {
+ Behaviour::Destructive
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs
new file mode 100644
index 00000000..c79669d0
--- /dev/null
+++ b/crates/typst-library/src/layout/container.rs
@@ -0,0 +1,497 @@
+use typst::eval::AutoValue;
+
+use super::VElem;
+use crate::layout::Spacing;
+use crate::prelude::*;
+
+/// An inline-level container that sizes content.
+///
+/// All elements except inline math, text, and boxes are block-level and cannot
+/// occur inside of a paragraph. The box function can be used to integrate such
+/// elements into a paragraph. Boxes take the size of their contents by default
+/// but can also be sized explicitly.
+///
+/// ## Example { #example }
+/// ```example
+/// Refer to the docs
+/// #box(
+/// height: 9pt,
+/// image("docs.svg")
+/// )
+/// for more information.
+/// ```
+///
+/// Display: Box
+/// Category: layout
+#[element(Layout)]
+pub struct BoxElem {
+ /// The width of the box.
+ ///
+ /// Boxes can have [fractional]($type/fraction) widths, as the example
+ /// below demonstrates.
+ ///
+ /// _Note:_ Currently, only boxes and only their widths might be fractionally
+ /// sized within paragraphs. Support for fractionally sized images, shapes,
+ /// and more might be added in the future.
+ ///
+ /// ```example
+ /// Line in #box(width: 1fr, line(length: 100%)) between.
+ /// ```
+ pub width: Sizing,
+
+ /// The height of the box.
+ pub height: Smart<Rel<Length>>,
+
+ /// An amount to shift the box's baseline by.
+ ///
+ /// ```example
+ /// Image: #box(baseline: 40%, image("tiger.jpg", width: 2cm)).
+ /// ```
+ #[resolve]
+ pub baseline: Rel<Length>,
+
+ /// The box's background color. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// The box's border color. See the
+ /// [rectangle's documentation]($func/rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<PartialStroke>>>,
+
+ /// How much to round the box's corners. See the [rectangle's
+ /// documentation]($func/rect.radius) for more details.
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the box's content. See the [rectangle's
+ /// documentation]($func/rect.inset) for more details.
+ #[resolve]
+ #[fold]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the box's size without affecting the layout.
+ ///
+ /// This is useful to prevent padding from affecting line layout. For a
+ /// generalized version of the example below, see the documentation for the
+ /// [raw text's block parameter]($func/raw.block).
+ ///
+ /// ```example
+ /// An inline
+ /// #box(
+ /// fill: luma(235),
+ /// inset: (x: 3pt, y: 0pt),
+ /// outset: (y: 3pt),
+ /// radius: 2pt,
+ /// )[rectangle].
+ /// ```
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// Whether to clip the content inside the box.
+ #[default(false)]
+ pub clip: bool,
+
+ /// The contents of the box.
+ #[positional]
+ pub body: Option<Content>,
+}
+
+impl Layout for BoxElem {
+ #[tracing::instrument(name = "BoxElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let width = match self.width(styles) {
+ Sizing::Auto => Smart::Auto,
+ Sizing::Rel(rel) => Smart::Custom(rel),
+ Sizing::Fr(_) => Smart::Custom(Ratio::one().into()),
+ };
+
+ // Resolve the sizing to a concrete size.
+ let sizing = Axes::new(width, self.height(styles));
+ let expand = sizing.as_ref().map(Smart::is_custom);
+ let size = sizing
+ .resolve(styles)
+ .zip(regions.base())
+ .map(|(s, b)| s.map(|v| v.relative_to(b)))
+ .unwrap_or(regions.base());
+
+ // Apply inset.
+ let mut body = self.body(styles).unwrap_or_default();
+ let inset = self.inset(styles);
+ if inset.iter().any(|v| !v.is_zero()) {
+ body = body.padded(inset.map(|side| side.map(Length::from)));
+ }
+
+ // Select the appropriate base and expansion for the child depending
+ // on whether it is automatically or relatively sized.
+ let pod = Regions::one(size, expand);
+ let mut frame = body.layout(vt, styles, pod)?.into_frame();
+
+ // Enforce correct size.
+ *frame.size_mut() = expand.select(size, frame.size());
+
+ // Apply baseline shift.
+ let shift = self.baseline(styles).relative_to(frame.height());
+ if !shift.is_zero() {
+ frame.set_baseline(frame.baseline() - shift);
+ }
+
+ // Clip the contents
+ if self.clip(styles) {
+ frame.clip();
+ }
+
+ // Prepare fill and stroke.
+ let fill = self.fill(styles);
+ let stroke = self.stroke(styles).map(|s| s.map(PartialStroke::unwrap_or_default));
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ let outset = self.outset(styles);
+ let radius = self.radius(styles);
+ frame.fill_and_stroke(fill, stroke, outset, radius, self.span());
+ }
+
+ // Apply metadata.
+ frame.meta(styles, false);
+
+ Ok(Fragment::frame(frame))
+ }
+}
+
+/// A block-level container.
+///
+/// Such a container can be used to separate content, size it, and give it a
+/// background or border.
+///
+/// ## Examples { #examples }
+/// With a block, you can give a background to content while still allowing it
+/// to break across multiple pages.
+/// ```example
+/// #set page(height: 100pt)
+/// #block(
+/// fill: luma(230),
+/// inset: 8pt,
+/// radius: 4pt,
+/// lorem(30),
+/// )
+/// ```
+///
+/// Blocks are also useful to force elements that would otherwise be inline to
+/// become block-level, especially when writing show rules.
+/// ```example
+/// #show heading: it => it.body
+/// = Blockless
+/// More text.
+///
+/// #show heading: it => block(it.body)
+/// = Blocky
+/// More text.
+/// ```
+///
+/// Display: Block
+/// Category: layout
+#[element(Layout)]
+pub struct BlockElem {
+ /// The block's width.
+ ///
+ /// ```example
+ /// #set align(center)
+ /// #block(
+ /// width: 60%,
+ /// inset: 8pt,
+ /// fill: silver,
+ /// lorem(10),
+ /// )
+ /// ```
+ pub width: Smart<Rel<Length>>,
+
+ /// The block's height. When the height is larger than the remaining space
+ /// on a page and [`breakable`]($func/block.breakable) is `{true}`, the
+ /// block will continue on the next page with the remaining height.
+ ///
+ /// ```example
+ /// #set page(height: 80pt)
+ /// #set align(center)
+ /// #block(
+ /// width: 80%,
+ /// height: 150%,
+ /// fill: aqua,
+ /// )
+ /// ```
+ pub height: Smart<Rel<Length>>,
+
+ /// Whether the block can be broken and continue on the next page.
+ ///
+ /// ```example
+ /// #set page(height: 80pt)
+ /// The following block will
+ /// jump to its own page.
+ /// #block(
+ /// breakable: false,
+ /// lorem(15),
+ /// )
+ /// ```
+ #[default(true)]
+ pub breakable: bool,
+
+ /// The block's background color. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// The block's border color. See the
+ /// [rectangle's documentation]($func/rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<PartialStroke>>>,
+
+ /// How much to round the block's corners. See the [rectangle's
+ /// documentation]($func/rect.radius) for more details.
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the block's content. See the [rectangle's
+ /// documentation]($func/rect.inset) for more details.
+ #[resolve]
+ #[fold]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the block's size without affecting the layout. See
+ /// the [rectangle's documentation]($func/rect.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The spacing around this block. This is shorthand to set `above` and
+ /// `below` to the same value.
+ ///
+ /// ```example
+ /// #set align(center)
+ /// #show math.equation: set block(above: 8pt, below: 16pt)
+ ///
+ /// This sum of $x$ and $y$:
+ /// $ x + y = z $
+ /// A second paragraph.
+ /// ```
+ #[external]
+ #[default(Em::new(1.2).into())]
+ pub spacing: Spacing,
+
+ /// The spacing between this block and its predecessor. Takes precedence
+ /// over `spacing`. Can be used in combination with a show rule to adjust
+ /// the spacing around arbitrary block-level elements.
+ #[external]
+ #[default(Em::new(1.2).into())]
+ pub above: Spacing,
+ #[internal]
+ #[parse(
+ let spacing = args.named("spacing")?;
+ args.named("above")?
+ .map(VElem::block_around)
+ .or_else(|| spacing.map(VElem::block_spacing))
+ )]
+ #[default(VElem::block_spacing(Em::new(1.2).into()))]
+ pub above: VElem,
+
+ /// The spacing between this block and its successor. Takes precedence
+ /// over `spacing`.
+ #[external]
+ #[default(Em::new(1.2).into())]
+ pub below: Spacing,
+ #[internal]
+ #[parse(
+ args.named("below")?
+ .map(VElem::block_around)
+ .or_else(|| spacing.map(VElem::block_spacing))
+ )]
+ #[default(VElem::block_spacing(Em::new(1.2).into()))]
+ pub below: VElem,
+
+ /// Whether to clip the content inside the block.
+ #[default(false)]
+ pub clip: bool,
+
+ /// The contents of the block.
+ #[positional]
+ pub body: Option<Content>,
+
+ /// Whether this block must stick to the following one.
+ ///
+ /// Use this to prevent page breaks between e.g. a heading and its body.
+ #[internal]
+ #[default(false)]
+ pub sticky: bool,
+}
+
+impl Layout for BlockElem {
+ #[tracing::instrument(name = "BlockElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ // Apply inset.
+ let mut body = self.body(styles).unwrap_or_default();
+ let inset = self.inset(styles);
+ if inset.iter().any(|v| !v.is_zero()) {
+ body = body.clone().padded(inset.map(|side| side.map(Length::from)));
+ }
+
+ // Resolve the sizing to a concrete size.
+ let sizing = Axes::new(self.width(styles), self.height(styles));
+ let mut expand = sizing.as_ref().map(Smart::is_custom);
+ let mut size = sizing
+ .resolve(styles)
+ .zip(regions.base())
+ .map(|(s, b)| s.map(|v| v.relative_to(b)))
+ .unwrap_or(regions.base());
+
+ // Layout the child.
+ let mut frames = if self.breakable(styles) {
+ // Measure to ensure frames for all regions have the same width.
+ if sizing.x == Smart::Auto {
+ let pod = Regions::one(size, Axes::splat(false));
+ let frame = body.measure(vt, styles, pod)?.into_frame();
+ size.x = frame.width();
+ expand.x = true;
+ }
+
+ let mut pod = regions;
+ pod.size.x = size.x;
+ pod.expand = expand;
+
+ if expand.y {
+ pod.full = size.y;
+ }
+
+ // Generate backlog for fixed height.
+ let mut heights = vec![];
+ if sizing.y.is_custom() {
+ let mut remaining = size.y;
+ for region in regions.iter() {
+ let limited = region.y.min(remaining);
+ heights.push(limited);
+ remaining -= limited;
+ if Abs::zero().fits(remaining) {
+ break;
+ }
+ }
+
+ if let Some(last) = heights.last_mut() {
+ *last += remaining;
+ }
+
+ pod.size.y = heights[0];
+ pod.backlog = &heights[1..];
+ pod.last = None;
+ }
+
+ let mut frames = body.layout(vt, styles, pod)?.into_frames();
+ for (frame, &height) in frames.iter_mut().zip(&heights) {
+ *frame.size_mut() =
+ expand.select(Size::new(size.x, height), frame.size());
+ }
+ frames
+ } else {
+ let pod = Regions::one(size, expand);
+ let mut frames = body.layout(vt, styles, pod)?.into_frames();
+ *frames[0].size_mut() = expand.select(size, frames[0].size());
+ frames
+ };
+
+ // Clip the contents
+ if self.clip(styles) {
+ for frame in frames.iter_mut() {
+ frame.clip();
+ }
+ }
+
+ // Prepare fill and stroke.
+ let fill = self.fill(styles);
+ let stroke = self.stroke(styles).map(|s| s.map(PartialStroke::unwrap_or_default));
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ let mut skip = false;
+ if let [first, rest @ ..] = frames.as_slice() {
+ skip = first.is_empty() && rest.iter().any(|frame| !frame.is_empty());
+ }
+
+ let outset = self.outset(styles);
+ let radius = self.radius(styles);
+ for frame in frames.iter_mut().skip(skip as usize) {
+ frame.fill_and_stroke(
+ fill.clone(),
+ stroke.clone(),
+ outset,
+ radius,
+ self.span(),
+ );
+ }
+ }
+
+ // Apply metadata.
+ for frame in &mut frames {
+ frame.meta(styles, false);
+ }
+
+ Ok(Fragment::frames(frames))
+ }
+}
+
+/// Defines how to size a grid cell along an axis.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Sizing {
+ /// A track that fits its cell's contents.
+ Auto,
+ /// A track size specified in absolute terms and relative to the parent's
+ /// size.
+ Rel(Rel<Length>),
+ /// A track size specified as a fraction of the remaining free space in the
+ /// parent.
+ Fr(Fr),
+}
+
+impl Sizing {
+ /// Whether this is fractional sizing.
+ pub fn is_fractional(self) -> bool {
+ matches!(self, Self::Fr(_))
+ }
+}
+
+impl Default for Sizing {
+ fn default() -> Self {
+ Self::Auto
+ }
+}
+
+impl<T: Into<Spacing>> From<T> for Sizing {
+ fn from(spacing: T) -> Self {
+ match spacing.into() {
+ Spacing::Rel(rel) => Self::Rel(rel),
+ Spacing::Fr(fr) => Self::Fr(fr),
+ }
+ }
+}
+
+cast! {
+ Sizing,
+ self => match self {
+ Self::Auto => Value::Auto,
+ Self::Rel(rel) => rel.into_value(),
+ Self::Fr(fr) => fr.into_value(),
+ },
+ _: AutoValue => Self::Auto,
+ v: Rel<Length> => Self::Rel(v),
+ v: Fr => Self::Fr(v),
+}
diff --git a/crates/typst-library/src/layout/enum.rs b/crates/typst-library/src/layout/enum.rs
new file mode 100644
index 00000000..d66477fc
--- /dev/null
+++ b/crates/typst-library/src/layout/enum.rs
@@ -0,0 +1,318 @@
+use std::str::FromStr;
+
+use crate::layout::{BlockElem, ParElem, Sizing, Spacing};
+use crate::meta::{Numbering, NumberingPattern};
+use crate::prelude::*;
+use crate::text::TextElem;
+
+use super::GridLayouter;
+
+/// A numbered list.
+///
+/// Displays a sequence of items vertically and numbers them consecutively.
+///
+/// ## Example { #example }
+/// ```example
+/// Automatically numbered:
+/// + Preparations
+/// + Analysis
+/// + Conclusions
+///
+/// Manually numbered:
+/// 2. What is the first step?
+/// 5. I am confused.
+/// + Moving on ...
+///
+/// Function call.
+/// #enum[First][Second]
+/// ```
+///
+/// You can easily switch all your enumerations to a different numbering style
+/// with a set rule.
+/// ```example
+/// #set enum(numbering: "a)")
+///
+/// + Starting off ...
+/// + Don't forget step two
+/// ```
+///
+/// You can also use [`enum.item`]($func/enum.item) to programmatically
+/// customize the number of each item in the enumeration:
+///
+/// ```example
+/// #enum(
+/// enum.item(1)[First step],
+/// enum.item(5)[Fifth step],
+/// enum.item(10)[Tenth step]
+/// )
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This functions also has dedicated syntax:
+///
+/// - Starting a line with a plus sign creates an automatically numbered
+/// enumeration item.
+/// - Starting a line with a number followed by a dot creates an explicitly
+/// numbered enumeration item.
+///
+/// Enumeration items can contain multiple paragraphs and other block-level
+/// content. All content that is indented more than an item's plus sign or dot
+/// becomes part of that item.
+///
+/// Display: Numbered List
+/// Category: layout
+#[element(Layout)]
+#[scope(
+ scope.define("item", EnumItem::func());
+ scope
+)]
+pub struct EnumElem {
+ /// If this is `{false}`, the items are spaced apart with
+ /// [enum spacing]($func/enum.spacing). If it is `{true}`, they use normal
+ /// [leading]($func/par.leading) instead. This makes the enumeration more
+ /// compact, which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`.
+ ///
+ /// ```example
+ /// + If an enum has a lot of text, and
+ /// maybe other inline content, it
+ /// should not be tight anymore.
+ ///
+ /// + To make an enum wide, simply
+ /// insert a blank line between the
+ /// items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// How to number the enumeration. Accepts a
+ /// [numbering pattern or function]($func/numbering).
+ ///
+ /// If the numbering pattern contains multiple counting symbols, they apply
+ /// to nested enums. If given a function, the function receives one argument
+ /// if `full` is `{false}` and multiple arguments if `full` is `{true}`.
+ ///
+ /// ```example
+ /// #set enum(numbering: "1.a)")
+ /// + Different
+ /// + Numbering
+ /// + Nested
+ /// + Items
+ /// + Style
+ ///
+ /// #set enum(numbering: n => super[#n])
+ /// + Superscript
+ /// + Numbering!
+ /// ```
+ #[default(Numbering::Pattern(NumberingPattern::from_str("1.").unwrap()))]
+ pub numbering: Numbering,
+
+ /// Which number to start the enumeration with.
+ ///
+ /// ```example
+ /// #enum(
+ /// start: 3,
+ /// [Skipping],
+ /// [Ahead],
+ /// )
+ /// ```
+ #[default(1)]
+ pub start: usize,
+
+ /// Whether to display the full numbering, including the numbers of
+ /// all parent enumerations.
+ ///
+ ///
+ /// ```example
+ /// #set enum(numbering: "1.a)", full: true)
+ /// + Cook
+ /// + Heat water
+ /// + Add integredients
+ /// + Eat
+ /// ```
+ #[default(false)]
+ pub full: bool,
+
+ /// The indentation of each item.
+ #[resolve]
+ pub indent: Length,
+
+ /// The space between the numbering and the body of each item.
+ #[resolve]
+ #[default(Em::new(0.5).into())]
+ pub body_indent: Length,
+
+ /// The spacing between the items of a wide (non-tight) enumeration.
+ ///
+ /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below).
+ pub spacing: Smart<Spacing>,
+
+ /// The horizontal alignment that enum numbers should have.
+ ///
+ /// By default, this is set to `{end}`, which aligns enum numbers
+ /// towards end of the current text direction (in left-to-right script,
+ /// for example, this is the same as `{right}`). The choice of `{end}`
+ /// for horizontal alignment of enum numbers is usually preferred over
+ /// `{start}`, as numbers then grow away from the text instead of towards
+ /// it, avoiding certain visual issues. This option lets you override this
+ /// behavior, however.
+ ///
+ /// ````example
+ /// #set enum(number-align: start)
+ ///
+ /// Here are some powers of two:
+ /// 1. One
+ /// 2. Two
+ /// 4. Four
+ /// 8. Eight
+ /// 16. Sixteen
+ /// 32. Thirty two
+ /// ````
+ #[default(HorizontalAlign(GenAlign::End))]
+ pub number_align: HorizontalAlign,
+
+ /// The numbered list's items.
+ ///
+ /// When using the enum syntax, adjacent items are automatically collected
+ /// into enumerations, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for phase in (
+ /// "Launch",
+ /// "Orbit",
+ /// "Descent",
+ /// ) [+ #phase]
+ /// ```
+ #[variadic]
+ pub children: Vec<EnumItem>,
+
+ /// The numbers of parent items.
+ #[internal]
+ #[fold]
+ parents: Parent,
+}
+
+impl Layout for EnumElem {
+ #[tracing::instrument(name = "EnumElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let numbering = self.numbering(styles);
+ let indent = self.indent(styles);
+ let body_indent = self.body_indent(styles);
+ let gutter = if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ self.spacing(styles)
+ .unwrap_or_else(|| BlockElem::below_in(styles).amount())
+ };
+
+ let mut cells = vec![];
+ let mut number = self.start(styles);
+ let mut parents = self.parents(styles);
+ let full = self.full(styles);
+
+ // Horizontally align based on the given respective parameter.
+ // Vertically align to the top to avoid inheriting 'horizon' or
+ // 'bottom' alignment from the context and having the number be
+ // displaced in relation to the item it refers to.
+ let number_align: Axes<Option<GenAlign>> =
+ Axes::new(self.number_align(styles).into(), Align::Top.into()).map(Some);
+
+ for item in self.children() {
+ number = item.number(styles).unwrap_or(number);
+
+ let resolved = if full {
+ parents.push(number);
+ let content = numbering.apply_vt(vt, &parents)?.display();
+ parents.pop();
+ content
+ } else {
+ match &numbering {
+ Numbering::Pattern(pattern) => {
+ TextElem::packed(pattern.apply_kth(parents.len(), number))
+ }
+ other => other.apply_vt(vt, &[number])?.display(),
+ }
+ };
+
+ // Disable overhang as a workaround to end-aligned dots glitching
+ // and decreasing spacing between numbers and items.
+ let resolved =
+ resolved.aligned(number_align).styled(TextElem::set_overhang(false));
+
+ cells.push(Content::empty());
+ cells.push(resolved);
+ cells.push(Content::empty());
+ cells.push(item.body().styled(Self::set_parents(Parent(number))));
+ number = number.saturating_add(1);
+ }
+
+ let layouter = GridLayouter::new(
+ Axes::with_x(&[
+ Sizing::Rel(indent.into()),
+ Sizing::Auto,
+ Sizing::Rel(body_indent.into()),
+ Sizing::Auto,
+ ]),
+ Axes::with_y(&[gutter.into()]),
+ &cells,
+ regions,
+ styles,
+ );
+
+ Ok(layouter.layout(vt)?.fragment)
+ }
+}
+
+/// An enumeration item.
+///
+/// Display: Numbered List Item
+/// Category: layout
+#[element]
+pub struct EnumItem {
+ /// The item's number.
+ #[positional]
+ pub number: Option<usize>,
+
+ /// The item's body.
+ #[required]
+ pub body: Content,
+}
+
+cast! {
+ EnumItem,
+ array: Array => {
+ let mut iter = array.into_iter();
+ let (number, body) = match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => (a.cast()?, b.cast()?),
+ _ => bail!("array must contain exactly two entries"),
+ };
+ Self::new(body).with_number(number)
+ },
+ v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::new(v.clone())),
+}
+
+struct Parent(usize);
+
+cast! {
+ Parent,
+ self => self.0.into_value(),
+ v: usize => Self(v),
+}
+
+impl Fold for Parent {
+ type Output = Vec<usize>;
+
+ fn fold(self, mut outer: Self::Output) -> Self::Output {
+ outer.push(self.0);
+ outer
+ }
+}
diff --git a/crates/typst-library/src/layout/flow.rs b/crates/typst-library/src/layout/flow.rs
new file mode 100644
index 00000000..accd092a
--- /dev/null
+++ b/crates/typst-library/src/layout/flow.rs
@@ -0,0 +1,583 @@
+use std::mem;
+
+use super::{
+ AlignElem, BlockElem, ColbreakElem, ColumnsElem, ParElem, PlaceElem, Spacing, VElem,
+};
+use crate::meta::{FootnoteElem, FootnoteEntry};
+use crate::prelude::*;
+use crate::visualize::{
+ CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem,
+ SquareElem,
+};
+
+/// Arranges spacing, paragraphs and block-level elements into a flow.
+///
+/// This element is responsible for layouting both the top-level content flow
+/// and the contents of boxes.
+///
+/// Display: Flow
+/// Category: layout
+#[element(Layout)]
+pub struct FlowElem {
+ /// The children that will be arranges into a flow.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Layout for FlowElem {
+ #[tracing::instrument(name = "FlowElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut layouter = FlowLayouter::new(regions, styles);
+
+ for mut child in &self.children() {
+ let outer = styles;
+ let mut styles = styles;
+ if let Some((elem, map)) = child.to_styled() {
+ child = elem;
+ styles = outer.chain(map);
+ }
+
+ if let Some(elem) = child.to::<VElem>() {
+ layouter.layout_spacing(vt, elem, styles)?;
+ } else if let Some(elem) = child.to::<ParElem>() {
+ layouter.layout_par(vt, elem, styles)?;
+ } else if child.is::<LineElem>()
+ || child.is::<RectElem>()
+ || child.is::<SquareElem>()
+ || child.is::<EllipseElem>()
+ || child.is::<CircleElem>()
+ || child.is::<ImageElem>()
+ || child.is::<PolygonElem>()
+ || child.is::<PathElem>()
+ {
+ let layoutable = child.with::<dyn Layout>().unwrap();
+ layouter.layout_single(vt, layoutable, styles)?;
+ } else if child.is::<MetaElem>() {
+ let mut frame = Frame::new(Size::zero());
+ frame.meta(styles, true);
+ layouter.items.push(FlowItem::Frame {
+ frame,
+ aligns: Axes::new(Align::Top, Align::Left),
+ sticky: true,
+ movable: false,
+ });
+ } else if child.can::<dyn Layout>() {
+ layouter.layout_multiple(vt, child, styles)?;
+ } else if child.is::<ColbreakElem>() {
+ if !layouter.regions.backlog.is_empty() || layouter.regions.last.is_some()
+ {
+ layouter.finish_region()?;
+ }
+ } else {
+ bail!(child.span(), "unexpected flow child");
+ }
+ }
+
+ layouter.finish()
+ }
+}
+
+/// Performs flow layout.
+struct FlowLayouter<'a> {
+ /// Whether this is the root flow.
+ root: bool,
+ /// The regions to layout children into.
+ regions: Regions<'a>,
+ /// The shared styles.
+ styles: StyleChain<'a>,
+ /// Whether the flow should expand to fill the region.
+ expand: Axes<bool>,
+ /// The initial size of `regions.size` that was available before we started
+ /// subtracting.
+ initial: Size,
+ /// Whether the last block was a paragraph.
+ last_was_par: bool,
+ /// Spacing and layouted blocks for the current region.
+ items: Vec<FlowItem>,
+ /// Whether we have any footnotes in the current region.
+ has_footnotes: bool,
+ /// Footnote configuration.
+ footnote_config: FootnoteConfig,
+ /// Finished frames for previous regions.
+ finished: Vec<Frame>,
+}
+
+/// Cached footnote configuration.
+struct FootnoteConfig {
+ separator: Content,
+ clearance: Abs,
+ gap: Abs,
+}
+
+/// A prepared item in a flow layout.
+#[derive(Debug)]
+enum FlowItem {
+ /// Spacing between other items and whether it is weak.
+ Absolute(Abs, bool),
+ /// Fractional spacing between other items.
+ Fractional(Fr),
+ /// A frame for a layouted block, how to align it, whether it sticks to the
+ /// item after it (for orphan prevention), and whether it is movable
+ /// (to keep it together with its footnotes).
+ Frame { frame: Frame, aligns: Axes<Align>, sticky: bool, movable: bool },
+ /// An absolutely placed frame.
+ Placed(Frame),
+ /// A footnote frame (can also be the separator).
+ Footnote(Frame),
+}
+
+impl FlowItem {
+ /// The inherent height of the item.
+ fn height(&self) -> Abs {
+ match self {
+ Self::Absolute(v, _) => *v,
+ Self::Fractional(_) | Self::Placed(_) => Abs::zero(),
+ Self::Frame { frame, .. } | Self::Footnote(frame) => frame.height(),
+ }
+ }
+}
+
+impl<'a> FlowLayouter<'a> {
+ /// Create a new flow layouter.
+ fn new(mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self {
+ let expand = regions.expand;
+
+ // Disable vertical expansion & root for children.
+ regions.expand.y = false;
+ let root = mem::replace(&mut regions.root, false);
+
+ Self {
+ root,
+ regions,
+ styles,
+ expand,
+ initial: regions.size,
+ last_was_par: false,
+ items: vec![],
+ has_footnotes: false,
+ footnote_config: FootnoteConfig {
+ separator: FootnoteEntry::separator_in(styles),
+ clearance: FootnoteEntry::clearance_in(styles),
+ gap: FootnoteEntry::gap_in(styles),
+ },
+ finished: vec![],
+ }
+ }
+
+ /// Layout vertical spacing.
+ #[tracing::instrument(name = "FlowLayouter::layout_spacing", skip_all)]
+ fn layout_spacing(
+ &mut self,
+ vt: &mut Vt,
+ v: &VElem,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ self.layout_item(
+ vt,
+ match v.amount() {
+ Spacing::Rel(rel) => FlowItem::Absolute(
+ rel.resolve(styles).relative_to(self.initial.y),
+ v.weakness(styles) > 0,
+ ),
+ Spacing::Fr(fr) => FlowItem::Fractional(fr),
+ },
+ )
+ }
+
+ /// Layout a paragraph.
+ #[tracing::instrument(name = "FlowLayouter::layout_par", skip_all)]
+ fn layout_par(
+ &mut self,
+ vt: &mut Vt,
+ par: &ParElem,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let aligns = AlignElem::alignment_in(styles).resolve(styles);
+ let leading = ParElem::leading_in(styles);
+ let consecutive = self.last_was_par;
+ let lines = par
+ .layout(vt, styles, consecutive, self.regions.base(), self.regions.expand.x)?
+ .into_frames();
+
+ let mut sticky = self.items.len();
+ for (i, item) in self.items.iter().enumerate().rev() {
+ match *item {
+ FlowItem::Absolute(_, _) => {}
+ FlowItem::Frame { sticky: true, .. } => sticky = i,
+ _ => break,
+ }
+ }
+
+ if let Some(first) = lines.first() {
+ if !self.regions.size.y.fits(first.height()) && !self.regions.in_last() {
+ let carry: Vec<_> = self.items.drain(sticky..).collect();
+ self.finish_region()?;
+ for item in carry {
+ self.layout_item(vt, item)?;
+ }
+ }
+ }
+
+ for (i, frame) in lines.into_iter().enumerate() {
+ if i > 0 {
+ self.layout_item(vt, FlowItem::Absolute(leading, true))?;
+ }
+
+ self.layout_item(
+ vt,
+ FlowItem::Frame { frame, aligns, sticky: false, movable: true },
+ )?;
+ }
+
+ self.last_was_par = true;
+ Ok(())
+ }
+
+ /// Layout into a single region.
+ #[tracing::instrument(name = "FlowLayouter::layout_single", skip_all)]
+ fn layout_single(
+ &mut self,
+ vt: &mut Vt,
+ content: &dyn Layout,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let aligns = AlignElem::alignment_in(styles).resolve(styles);
+ let sticky = BlockElem::sticky_in(styles);
+ let pod = Regions::one(self.regions.base(), Axes::splat(false));
+ let frame = content.layout(vt, styles, pod)?.into_frame();
+ self.layout_item(vt, FlowItem::Frame { frame, aligns, sticky, movable: true })?;
+ self.last_was_par = false;
+ Ok(())
+ }
+
+ /// Layout into multiple regions.
+ fn layout_multiple(
+ &mut self,
+ vt: &mut Vt,
+ block: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ // Placed elements that are out of flow produce placed items which
+ // aren't aligned later.
+ if let Some(placed) = block.to::<PlaceElem>() {
+ if placed.out_of_flow(styles) {
+ let frame = block.layout(vt, styles, self.regions)?.into_frame();
+ self.layout_item(vt, FlowItem::Placed(frame))?;
+ return Ok(());
+ }
+ } else if self.regions.is_full() {
+ // Skip directly if region is already full.
+ self.finish_region()?;
+ }
+
+ // How to align the block.
+ let aligns = if let Some(align) = block.to::<AlignElem>() {
+ align.alignment(styles)
+ } else if let Some((_, local)) = block.to_styled() {
+ AlignElem::alignment_in(styles.chain(local))
+ } else {
+ AlignElem::alignment_in(styles)
+ }
+ .resolve(styles);
+
+ // Temporarily delegerate rootness to the columns.
+ let is_root = self.root;
+ if is_root && block.is::<ColumnsElem>() {
+ self.root = false;
+ self.regions.root = true;
+ }
+
+ // Layout the block itself.
+ let sticky = BlockElem::sticky_in(styles);
+ let fragment = block.layout(vt, styles, self.regions)?;
+ let mut notes = Vec::new();
+
+ for (i, frame) in fragment.into_iter().enumerate() {
+ // Find footnotes in the frame.
+ if self.root {
+ find_footnotes(&mut notes, &frame);
+ }
+
+ if i > 0 {
+ self.finish_region()?;
+ }
+
+ self.layout_item(
+ vt,
+ FlowItem::Frame { frame, aligns, sticky, movable: false },
+ )?;
+ }
+
+ if self.root && !self.handle_footnotes(vt, &mut notes, false, false)? {
+ self.finish_region()?;
+ self.handle_footnotes(vt, &mut notes, false, true)?;
+ }
+
+ self.root = is_root;
+ self.regions.root = false;
+ self.last_was_par = false;
+
+ Ok(())
+ }
+
+ /// Layout a finished frame.
+ #[tracing::instrument(name = "FlowLayouter::layout_item", skip_all)]
+ fn layout_item(&mut self, vt: &mut Vt, item: FlowItem) -> SourceResult<()> {
+ match item {
+ FlowItem::Absolute(v, weak) => {
+ if weak
+ && !self
+ .items
+ .iter()
+ .any(|item| matches!(item, FlowItem::Frame { .. }))
+ {
+ return Ok(());
+ }
+ self.regions.size.y -= v
+ }
+ FlowItem::Fractional(_) => {}
+ FlowItem::Frame { ref frame, movable, .. } => {
+ let size = frame.size();
+ if !self.regions.size.y.fits(size.y) && !self.regions.in_last() {
+ self.finish_region()?;
+ }
+
+ self.regions.size.y -= size.y;
+ if self.root && movable {
+ let mut notes = Vec::new();
+ find_footnotes(&mut notes, frame);
+ self.items.push(item);
+ if !self.handle_footnotes(vt, &mut notes, true, false)? {
+ let item = self.items.pop();
+ self.finish_region()?;
+ self.items.extend(item);
+ self.regions.size.y -= size.y;
+ self.handle_footnotes(vt, &mut notes, true, true)?;
+ }
+ return Ok(());
+ }
+ }
+ FlowItem::Placed(_) => {}
+ FlowItem::Footnote(_) => {}
+ }
+
+ self.items.push(item);
+ Ok(())
+ }
+
+ /// Finish the frame for one region.
+ fn finish_region(&mut self) -> SourceResult<()> {
+ // Trim weak spacing.
+ while self
+ .items
+ .last()
+ .map_or(false, |item| matches!(item, FlowItem::Absolute(_, true)))
+ {
+ self.items.pop();
+ }
+
+ // Determine the used size.
+ let mut fr = Fr::zero();
+ let mut used = Size::zero();
+ let mut footnote_height = Abs::zero();
+ let mut first_footnote = true;
+ for item in &self.items {
+ match item {
+ FlowItem::Absolute(v, _) => used.y += *v,
+ FlowItem::Fractional(v) => fr += *v,
+ FlowItem::Frame { frame, .. } => {
+ let size = frame.size();
+ used.y += size.y;
+ used.x.set_max(size.x);
+ }
+ FlowItem::Placed(_) => {}
+ FlowItem::Footnote(frame) => {
+ let size = frame.size();
+ footnote_height += size.y;
+ if !first_footnote {
+ footnote_height += self.footnote_config.gap;
+ }
+ first_footnote = false;
+ used.x.set_max(size.x);
+ }
+ }
+ }
+ used.y += footnote_height;
+
+ // Determine the size of the flow in this region depending on whether
+ // the region expands. Also account for fractional spacing and
+ // footnotes.
+ let mut size = self.expand.select(self.initial, used).min(self.initial);
+ if (fr.get() > 0.0 || self.has_footnotes) && self.initial.y.is_finite() {
+ size.y = self.initial.y;
+ }
+
+ let mut output = Frame::new(size);
+ let mut offset = Abs::zero();
+ let mut ruler = Align::Top;
+ let mut footnote_offset = size.y - footnote_height;
+
+ // Place all frames.
+ for item in self.items.drain(..) {
+ match item {
+ FlowItem::Absolute(v, _) => {
+ offset += v;
+ }
+ FlowItem::Fractional(v) => {
+ let remaining = self.initial.y - used.y;
+ offset += v.share(fr, remaining);
+ }
+ FlowItem::Frame { frame, aligns, .. } => {
+ ruler = ruler.max(aligns.y);
+ let x = aligns.x.position(size.x - frame.width());
+ let y = offset + ruler.position(size.y - used.y);
+ let pos = Point::new(x, y);
+ offset += frame.height();
+ output.push_frame(pos, frame);
+ }
+ FlowItem::Footnote(frame) => {
+ let pos = Point::with_y(footnote_offset);
+ footnote_offset += frame.height() + self.footnote_config.gap;
+ output.push_frame(pos, frame);
+ }
+ FlowItem::Placed(frame) => {
+ output.push_frame(Point::zero(), frame);
+ }
+ }
+ }
+
+ // Advance to the next region.
+ self.finished.push(output);
+ self.regions.next();
+ self.initial = self.regions.size;
+ self.has_footnotes = false;
+ Ok(())
+ }
+
+ /// Finish layouting and return the resulting fragment.
+ fn finish(mut self) -> SourceResult<Fragment> {
+ if self.expand.y {
+ while !self.regions.backlog.is_empty() {
+ self.finish_region()?;
+ }
+ }
+
+ self.finish_region()?;
+ Ok(Fragment::frames(self.finished))
+ }
+}
+
+impl FlowLayouter<'_> {
+ /// Processes all footnotes in the frame.
+ #[tracing::instrument(skip_all)]
+ fn handle_footnotes(
+ &mut self,
+ vt: &mut Vt,
+ notes: &mut Vec<FootnoteElem>,
+ movable: bool,
+ force: bool,
+ ) -> SourceResult<bool> {
+ let items_len = self.items.len();
+ let notes_len = notes.len();
+
+ // Process footnotes one at a time.
+ let mut k = 0;
+ while k < notes.len() {
+ if notes[k].is_ref() {
+ k += 1;
+ continue;
+ }
+
+ if !self.has_footnotes {
+ self.layout_footnote_separator(vt)?;
+ }
+
+ self.regions.size.y -= self.footnote_config.gap;
+ let frames = FootnoteEntry::new(notes[k].clone())
+ .pack()
+ .layout(vt, self.styles, self.regions.with_root(false))?
+ .into_frames();
+
+ // If the entries didn't fit, abort (to keep footnote and entry
+ // together).
+ if !force
+ && (k == 0 || movable)
+ && frames.first().map_or(false, Frame::is_empty)
+ {
+ // Remove existing footnotes attempts because we need to
+ // move the item to the next page.
+ notes.truncate(notes_len);
+
+ // Undo region modifications.
+ for item in self.items.drain(items_len..) {
+ self.regions.size.y -= item.height();
+ }
+
+ return Ok(false);
+ }
+
+ let prev = notes.len();
+ for (i, frame) in frames.into_iter().enumerate() {
+ find_footnotes(notes, &frame);
+ if i > 0 {
+ self.finish_region()?;
+ self.layout_footnote_separator(vt)?;
+ self.regions.size.y -= self.footnote_config.gap;
+ }
+ self.regions.size.y -= frame.height();
+ self.items.push(FlowItem::Footnote(frame));
+ }
+
+ k += 1;
+
+ // Process the nested notes before dealing with further top-level
+ // notes.
+ let nested = notes.len() - prev;
+ if nested > 0 {
+ notes[k..].rotate_right(nested);
+ }
+ }
+
+ Ok(true)
+ }
+
+ /// Layout and save the footnote separator, typically a line.
+ #[tracing::instrument(skip_all)]
+ fn layout_footnote_separator(&mut self, vt: &mut Vt) -> SourceResult<()> {
+ let expand = Axes::new(self.regions.expand.x, false);
+ let pod = Regions::one(self.regions.base(), expand);
+ let separator = &self.footnote_config.separator;
+
+ let mut frame = separator.layout(vt, self.styles, pod)?.into_frame();
+ frame.size_mut().y += self.footnote_config.clearance;
+ frame.translate(Point::with_y(self.footnote_config.clearance));
+
+ self.has_footnotes = true;
+ self.regions.size.y -= frame.height();
+ self.items.push(FlowItem::Footnote(frame));
+
+ Ok(())
+ }
+}
+
+/// Finds all footnotes in the frame.
+#[tracing::instrument(skip_all)]
+fn find_footnotes(notes: &mut Vec<FootnoteElem>, frame: &Frame) {
+ for (_, item) in frame.items() {
+ match item {
+ FrameItem::Group(group) => find_footnotes(notes, &group.frame),
+ FrameItem::Meta(Meta::Elem(content), _)
+ if !notes.iter().any(|note| note.0.location() == content.location()) =>
+ {
+ let Some(footnote) = content.to::<FootnoteElem>() else { continue };
+ notes.push(footnote.clone());
+ }
+ _ => {}
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/fragment.rs b/crates/typst-library/src/layout/fragment.rs
new file mode 100644
index 00000000..3550df2a
--- /dev/null
+++ b/crates/typst-library/src/layout/fragment.rs
@@ -0,0 +1,87 @@
+use crate::prelude::*;
+
+/// A partial layout result.
+#[derive(Clone)]
+pub struct Fragment(Vec<Frame>);
+
+impl Fragment {
+ /// Create a fragment from a single frame.
+ pub fn frame(frame: Frame) -> Self {
+ Self(vec![frame])
+ }
+
+ /// Create a fragment from multiple frames.
+ pub fn frames(frames: Vec<Frame>) -> Self {
+ Self(frames)
+ }
+
+ /// Return `true` if the length is 0.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// The number of frames in the fragment.
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Extract the first and only frame.
+ ///
+ /// Panics if there are multiple frames.
+ #[track_caller]
+ pub fn into_frame(self) -> Frame {
+ assert_eq!(self.0.len(), 1, "expected exactly one frame");
+ self.0.into_iter().next().unwrap()
+ }
+
+ /// Extract the frames.
+ pub fn into_frames(self) -> Vec<Frame> {
+ self.0
+ }
+
+ /// Iterate over the contained frames.
+ pub fn iter(&self) -> std::slice::Iter<Frame> {
+ self.0.iter()
+ }
+
+ /// Iterate over the contained frames.
+ pub fn iter_mut(&mut self) -> std::slice::IterMut<Frame> {
+ self.0.iter_mut()
+ }
+}
+
+impl Debug for Fragment {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self.0.as_slice() {
+ [frame] => frame.fmt(f),
+ frames => frames.fmt(f),
+ }
+ }
+}
+
+impl IntoIterator for Fragment {
+ type Item = Frame;
+ type IntoIter = std::vec::IntoIter<Frame>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a Fragment {
+ type Item = &'a Frame;
+ type IntoIter = std::slice::Iter<'a, Frame>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a mut Fragment {
+ type Item = &'a mut Frame;
+ type IntoIter = std::slice::IterMut<'a, Frame>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.iter_mut()
+ }
+}
diff --git a/crates/typst-library/src/layout/grid.rs b/crates/typst-library/src/layout/grid.rs
new file mode 100644
index 00000000..4f5175e9
--- /dev/null
+++ b/crates/typst-library/src/layout/grid.rs
@@ -0,0 +1,708 @@
+use crate::prelude::*;
+use crate::text::TextElem;
+
+use super::Sizing;
+
+/// Arranges content in a grid.
+///
+/// The grid element allows you to arrange content in a grid. You can define the
+/// number of rows and columns, as well as the size of the gutters between them.
+/// There are multiple sizing modes for columns and rows that can be used to
+/// create complex layouts.
+///
+/// The sizing of the grid is determined by the track sizes specified in the
+/// arguments. Because each of the sizing parameters accepts the same values, we
+/// will explain them just once, here. Each sizing argument accepts an array of
+/// individual track sizes. A track size is either:
+///
+/// - `{auto}`: The track will be sized to fit its contents. It will be at most
+/// as large as the remaining space. If there is more than one `{auto}` track
+/// which, and together they claim more than the available space, the `{auto}`
+/// tracks will fairly distribute the available space among themselves.
+///
+/// - A fixed or relative length (e.g. `{10pt}` or `{20% - 1cm}`): The track
+/// will be exactly of this size.
+///
+/// - A fractional length (e.g. `{1fr}`): Once all other tracks have been sized,
+/// the remaining space will be divided among the fractional tracks according
+/// to their fractions. For example, if there are two fractional tracks, each
+/// with a fraction of `{1fr}`, they will each take up half of the remaining
+/// space.
+///
+/// To specify a single track, the array can be omitted in favor of a single
+/// value. To specify multiple `{auto}` tracks, enter the number of tracks
+/// instead of an array. For example, `columns:` `{3}` is equivalent to
+/// `columns:` `{(auto, auto, auto)}`.
+///
+/// ## Example { #example }
+/// ```example
+/// #set text(10pt, style: "italic")
+/// #let cell = rect.with(
+/// inset: 8pt,
+/// fill: rgb("e4e5ea"),
+/// width: 100%,
+/// radius: 6pt
+/// )
+/// #grid(
+/// columns: (60pt, 1fr, 60pt),
+/// rows: (60pt, auto),
+/// gutter: 3pt,
+/// cell(height: 100%)[Easy to learn],
+/// cell(height: 100%)[Great output],
+/// cell(height: 100%)[Intuitive],
+/// cell[Our best Typst yet],
+/// cell[
+/// Responsive design in print
+/// for everyone
+/// ],
+/// cell[One more thing...],
+/// )
+/// ```
+///
+/// Display: Grid
+/// Category: layout
+#[element(Layout)]
+pub struct GridElem {
+ /// The column sizes.
+ ///
+ /// Either specify a track size array or provide an integer to create a grid
+ /// with that many `{auto}`-sized columns. Note that opposed to rows and
+ /// gutters, providing a single track size will only ever create a single
+ /// column.
+ pub columns: TrackSizings,
+
+ /// The row sizes.
+ ///
+ /// If there are more cells than fit the defined rows, the last row is
+ /// repeated until there are no more cells.
+ pub rows: TrackSizings,
+
+ /// The gaps between rows & columns.
+ ///
+ /// If there are more gutters than defined sizes, the last gutter is repeated.
+ #[external]
+ pub gutter: TrackSizings,
+
+ /// The gaps between columns. Takes precedence over `gutter`.
+ #[parse(
+ let gutter = args.named("gutter")?;
+ args.named("column-gutter")?.or_else(|| gutter.clone())
+ )]
+ pub column_gutter: TrackSizings,
+
+ /// The gaps between rows. Takes precedence over `gutter`.
+ #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))]
+ pub row_gutter: TrackSizings,
+
+ /// The contents of the grid cells.
+ ///
+ /// The cells are populated in row-major order.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Layout for GridElem {
+ #[tracing::instrument(name = "GridElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ // Prepare grid layout by unifying content and gutter tracks.
+ let cells = self.children();
+ let layouter = GridLayouter::new(
+ Axes::new(&self.columns(styles).0, &self.rows(styles).0),
+ Axes::new(&self.column_gutter(styles).0, &self.row_gutter(styles).0),
+ &cells,
+ regions,
+ styles,
+ );
+
+ // Measure the columns and layout the grid row-by-row.
+ Ok(layouter.layout(vt)?.fragment)
+ }
+}
+
+/// Track sizing definitions.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct TrackSizings(pub Vec<Sizing>);
+
+cast! {
+ TrackSizings,
+ self => self.0.into_value(),
+ sizing: Sizing => Self(vec![sizing]),
+ count: NonZeroUsize => Self(vec![Sizing::Auto; count.get()]),
+ values: Array => Self(values.into_iter().map(Value::cast).collect::<StrResult<_>>()?),
+}
+
+/// Performs grid layout.
+pub struct GridLayouter<'a> {
+ /// The grid cells.
+ cells: &'a [Content],
+ /// Whether this is an RTL grid.
+ is_rtl: bool,
+ /// Whether this grid has gutters.
+ has_gutter: bool,
+ /// The column tracks including gutter tracks.
+ cols: Vec<Sizing>,
+ /// The row tracks including gutter tracks.
+ rows: Vec<Sizing>,
+ /// The regions to layout children into.
+ regions: Regions<'a>,
+ /// The inherited styles.
+ styles: StyleChain<'a>,
+ /// Resolved column sizes.
+ rcols: Vec<Abs>,
+ /// The sum of `rcols`.
+ width: Abs,
+ /// Resolve row sizes, by region.
+ rrows: Vec<Vec<RowPiece>>,
+ /// Rows in the current region.
+ lrows: Vec<Row>,
+ /// The initial size of the current region before we started subtracting.
+ initial: Size,
+ /// Frames for finished regions.
+ finished: Vec<Frame>,
+}
+
+/// The resulting sizes of columns and rows in a grid.
+#[derive(Debug)]
+pub struct GridLayout {
+ /// The fragment.
+ pub fragment: Fragment,
+ /// The column widths.
+ pub cols: Vec<Abs>,
+ /// The heights of the resulting rows segments, by region.
+ pub rows: Vec<Vec<RowPiece>>,
+}
+
+/// Details about a resulting row piece.
+#[derive(Debug)]
+pub struct RowPiece {
+ /// The height of the segment.
+ pub height: Abs,
+ /// The index of the row.
+ pub y: usize,
+}
+
+/// Produced by initial row layout, auto and relative rows are already finished,
+/// fractional rows not yet.
+enum Row {
+ /// Finished row frame of auto or relative row with y index.
+ Frame(Frame, usize),
+ /// Fractional row with y index.
+ Fr(Fr, usize),
+}
+
+impl<'a> GridLayouter<'a> {
+ /// Create a new grid layouter.
+ ///
+ /// This prepares grid layout by unifying content and gutter tracks.
+ pub fn new(
+ tracks: Axes<&[Sizing]>,
+ gutter: Axes<&[Sizing]>,
+ cells: &'a [Content],
+ regions: Regions<'a>,
+ styles: StyleChain<'a>,
+ ) -> Self {
+ let mut cols = vec![];
+ let mut rows = vec![];
+
+ // Number of content columns: Always at least one.
+ let c = tracks.x.len().max(1);
+
+ // Number of content rows: At least as many as given, but also at least
+ // as many as needed to place each item.
+ let r = {
+ let len = cells.len();
+ let given = tracks.y.len();
+ let needed = len / c + (len % c).clamp(0, 1);
+ given.max(needed)
+ };
+
+ let has_gutter = gutter.any(|tracks| !tracks.is_empty());
+ let auto = Sizing::Auto;
+ let zero = Sizing::Rel(Rel::zero());
+ let get_or = |tracks: &[_], idx, default| {
+ tracks.get(idx).or(tracks.last()).copied().unwrap_or(default)
+ };
+
+ // Collect content and gutter columns.
+ for x in 0..c {
+ cols.push(get_or(tracks.x, x, auto));
+ if has_gutter {
+ cols.push(get_or(gutter.x, x, zero));
+ }
+ }
+
+ // Collect content and gutter rows.
+ for y in 0..r {
+ rows.push(get_or(tracks.y, y, auto));
+ if has_gutter {
+ rows.push(get_or(gutter.y, y, zero));
+ }
+ }
+
+ // Remove superfluous gutter tracks.
+ if has_gutter {
+ cols.pop();
+ rows.pop();
+ }
+
+ // Reverse for RTL.
+ let is_rtl = TextElem::dir_in(styles) == Dir::RTL;
+ if is_rtl {
+ cols.reverse();
+ }
+
+ // We use these regions for auto row measurement. Since at that moment,
+ // columns are already sized, we can enable horizontal expansion.
+ let mut regions = regions;
+ regions.expand = Axes::new(true, false);
+
+ Self {
+ cells,
+ is_rtl,
+ has_gutter,
+ rows,
+ regions,
+ styles,
+ rcols: vec![Abs::zero(); cols.len()],
+ cols,
+ width: Abs::zero(),
+ rrows: vec![],
+ lrows: vec![],
+ initial: regions.size,
+ finished: vec![],
+ }
+ }
+
+ /// Determines the columns sizes and then layouts the grid row-by-row.
+ pub fn layout(mut self, vt: &mut Vt) -> SourceResult<GridLayout> {
+ self.measure_columns(vt)?;
+
+ for y in 0..self.rows.len() {
+ // Skip to next region if current one is full, but only for content
+ // rows, not for gutter rows.
+ if self.regions.is_full() && (!self.has_gutter || y % 2 == 0) {
+ self.finish_region(vt)?;
+ }
+
+ match self.rows[y] {
+ Sizing::Auto => self.layout_auto_row(vt, y)?,
+ Sizing::Rel(v) => self.layout_relative_row(vt, v, y)?,
+ Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)),
+ }
+ }
+
+ self.finish_region(vt)?;
+
+ Ok(GridLayout {
+ fragment: Fragment::frames(self.finished),
+ cols: self.rcols,
+ rows: self.rrows,
+ })
+ }
+
+ /// Determine all column sizes.
+ #[tracing::instrument(name = "GridLayouter::measure_columns", skip_all)]
+ fn measure_columns(&mut self, vt: &mut Vt) -> SourceResult<()> {
+ // Sum of sizes of resolved relative tracks.
+ let mut rel = Abs::zero();
+
+ // Sum of fractions of all fractional tracks.
+ let mut fr = Fr::zero();
+
+ // Resolve the size of all relative columns and compute the sum of all
+ // fractional tracks.
+ for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) {
+ match col {
+ Sizing::Auto => {}
+ Sizing::Rel(v) => {
+ let resolved =
+ v.resolve(self.styles).relative_to(self.regions.base().x);
+ *rcol = resolved;
+ rel += resolved;
+ }
+ Sizing::Fr(v) => fr += v,
+ }
+ }
+
+ // Size that is not used by fixed-size columns.
+ let available = self.regions.size.x - rel;
+ if available >= Abs::zero() {
+ // Determine size of auto columns.
+ let (auto, count) = self.measure_auto_columns(vt, available)?;
+
+ // If there is remaining space, distribute it to fractional columns,
+ // otherwise shrink auto columns.
+ let remaining = available - auto;
+ if remaining >= Abs::zero() {
+ self.grow_fractional_columns(remaining, fr);
+ } else {
+ self.shrink_auto_columns(available, count);
+ }
+ }
+
+ // Sum up the resolved column sizes once here.
+ self.width = self.rcols.iter().sum();
+
+ Ok(())
+ }
+
+ /// Measure the size that is available to auto columns.
+ fn measure_auto_columns(
+ &mut self,
+ vt: &mut Vt,
+ available: Abs,
+ ) -> SourceResult<(Abs, usize)> {
+ let mut auto = Abs::zero();
+ let mut count = 0;
+
+ // Determine size of auto columns by laying out all cells in those
+ // columns, measuring them and finding the largest one.
+ for (x, &col) in self.cols.iter().enumerate() {
+ if col != Sizing::Auto {
+ continue;
+ }
+
+ let mut resolved = Abs::zero();
+ for y in 0..self.rows.len() {
+ if let Some(cell) = self.cell(x, y) {
+ // For relative rows, we can already resolve the correct
+ // base and for auto and fr we could only guess anyway.
+ let height = match self.rows[y] {
+ Sizing::Rel(v) => {
+ v.resolve(self.styles).relative_to(self.regions.base().y)
+ }
+ _ => self.regions.base().y,
+ };
+
+ let size = Size::new(available, height);
+ let pod = Regions::one(size, Axes::splat(false));
+ let frame = cell.measure(vt, self.styles, pod)?.into_frame();
+ resolved.set_max(frame.width());
+ }
+ }
+
+ self.rcols[x] = resolved;
+ auto += resolved;
+ count += 1;
+ }
+
+ Ok((auto, count))
+ }
+
+ /// Distribute remaining space to fractional columns.
+ fn grow_fractional_columns(&mut self, remaining: Abs, fr: Fr) {
+ if fr.is_zero() {
+ return;
+ }
+
+ for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) {
+ if let Sizing::Fr(v) = col {
+ *rcol = v.share(fr, remaining);
+ }
+ }
+ }
+
+ /// Redistribute space to auto columns so that each gets a fair share.
+ fn shrink_auto_columns(&mut self, available: Abs, count: usize) {
+ let mut last;
+ let mut fair = -Abs::inf();
+ let mut redistribute = available;
+ let mut overlarge = count;
+ let mut changed = true;
+
+ // Iteratively remove columns that don't need to be shrunk.
+ while changed && overlarge > 0 {
+ changed = false;
+ last = fair;
+ fair = redistribute / (overlarge as f64);
+
+ for (&col, &rcol) in self.cols.iter().zip(&self.rcols) {
+ // Remove an auto column if it is not overlarge (rcol <= fair),
+ // but also hasn't already been removed (rcol > last).
+ if col == Sizing::Auto && rcol <= fair && rcol > last {
+ redistribute -= rcol;
+ overlarge -= 1;
+ changed = true;
+ }
+ }
+ }
+
+ // Redistribute space fairly among overlarge columns.
+ for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) {
+ if col == Sizing::Auto && *rcol > fair {
+ *rcol = fair;
+ }
+ }
+ }
+
+ /// Layout a row with automatic height. Such a row may break across multiple
+ /// regions.
+ fn layout_auto_row(&mut self, vt: &mut Vt, y: usize) -> SourceResult<()> {
+ // Determine the size for each region of the row. If the first region
+ // ends up empty for some column, skip the region and remeasure.
+ let mut resolved = match self.measure_auto_row(vt, y, true)? {
+ Some(resolved) => resolved,
+ None => {
+ self.finish_region(vt)?;
+ self.measure_auto_row(vt, y, false)?.unwrap()
+ }
+ };
+
+ // Nothing to layout.
+ if resolved.is_empty() {
+ return Ok(());
+ }
+
+ // Layout into a single region.
+ if let &[first] = resolved.as_slice() {
+ let frame = self.layout_single_row(vt, first, y)?;
+ self.push_row(frame, y);
+ return Ok(());
+ }
+
+ // Expand all but the last region.
+ // Skip the first region if the space is eaten up by an fr row.
+ let len = resolved.len();
+ for (region, target) in self
+ .regions
+ .iter()
+ .zip(&mut resolved[..len - 1])
+ .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize)
+ {
+ target.set_max(region.y);
+ }
+
+ // Layout into multiple regions.
+ let fragment = self.layout_multi_row(vt, &resolved, y)?;
+ let len = fragment.len();
+ for (i, frame) in fragment.into_iter().enumerate() {
+ self.push_row(frame, y);
+ if i + 1 < len {
+ self.finish_region(vt)?;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Measure the regions sizes of an auto row. The option is always `Some(_)`
+ /// if `can_skip` is false.
+ fn measure_auto_row(
+ &mut self,
+ vt: &mut Vt,
+ y: usize,
+ can_skip: bool,
+ ) -> SourceResult<Option<Vec<Abs>>> {
+ let mut resolved: Vec<Abs> = vec![];
+
+ for (x, &rcol) in self.rcols.iter().enumerate() {
+ if let Some(cell) = self.cell(x, y) {
+ let mut pod = self.regions;
+ pod.size.x = rcol;
+
+ let frames = cell.measure(vt, self.styles, pod)?.into_frames();
+
+ // Skip the first region if one cell in it is empty. Then,
+ // remeasure.
+ if let [first, rest @ ..] = frames.as_slice() {
+ if can_skip
+ && first.is_empty()
+ && rest.iter().any(|frame| !frame.is_empty())
+ {
+ return Ok(None);
+ }
+ }
+
+ let mut sizes = frames.iter().map(|frame| frame.height());
+ for (target, size) in resolved.iter_mut().zip(&mut sizes) {
+ target.set_max(size);
+ }
+
+ // New heights are maximal by virtue of being new. Note that
+ // this extend only uses the rest of the sizes iterator.
+ resolved.extend(sizes);
+ }
+ }
+
+ Ok(Some(resolved))
+ }
+
+ /// Layout a row with relative height. Such a row cannot break across
+ /// multiple regions, but it may force a region break.
+ fn layout_relative_row(
+ &mut self,
+ vt: &mut Vt,
+ v: Rel<Length>,
+ y: usize,
+ ) -> SourceResult<()> {
+ let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
+ let frame = self.layout_single_row(vt, resolved, y)?;
+
+ // Skip to fitting region.
+ let height = frame.height();
+ while !self.regions.size.y.fits(height) && !self.regions.in_last() {
+ self.finish_region(vt)?;
+
+ // Don't skip multiple regions for gutter and don't push a row.
+ if self.has_gutter && y % 2 == 1 {
+ return Ok(());
+ }
+ }
+
+ self.push_row(frame, y);
+
+ Ok(())
+ }
+
+ /// Layout a row with fixed height and return its frame.
+ fn layout_single_row(
+ &mut self,
+ vt: &mut Vt,
+ height: Abs,
+ y: usize,
+ ) -> SourceResult<Frame> {
+ let mut output = Frame::new(Size::new(self.width, height));
+ let mut pos = Point::zero();
+
+ for (x, &rcol) in self.rcols.iter().enumerate() {
+ if let Some(cell) = self.cell(x, y) {
+ let size = Size::new(rcol, height);
+ let mut pod = Regions::one(size, Axes::splat(true));
+ if self.rows[y] == Sizing::Auto {
+ pod.full = self.regions.full;
+ }
+ let frame = cell.layout(vt, self.styles, pod)?.into_frame();
+ output.push_frame(pos, frame);
+ }
+
+ pos.x += rcol;
+ }
+
+ Ok(output)
+ }
+
+ /// Layout a row spanning multiple regions.
+ fn layout_multi_row(
+ &mut self,
+ vt: &mut Vt,
+ heights: &[Abs],
+ y: usize,
+ ) -> SourceResult<Fragment> {
+ // Prepare frames.
+ let mut outputs: Vec<_> = heights
+ .iter()
+ .map(|&h| Frame::new(Size::new(self.width, h)))
+ .collect();
+
+ // Prepare regions.
+ let size = Size::new(self.width, heights[0]);
+ let mut pod = Regions::one(size, Axes::splat(true));
+ pod.full = self.regions.full;
+ pod.backlog = &heights[1..];
+
+ // Layout the row.
+ let mut pos = Point::zero();
+ for (x, &rcol) in self.rcols.iter().enumerate() {
+ if let Some(cell) = self.cell(x, y) {
+ pod.size.x = rcol;
+
+ // Push the layouted frames into the individual output frames.
+ let fragment = cell.layout(vt, self.styles, pod)?;
+ for (output, frame) in outputs.iter_mut().zip(fragment) {
+ output.push_frame(pos, frame);
+ }
+ }
+
+ pos.x += rcol;
+ }
+
+ Ok(Fragment::frames(outputs))
+ }
+
+ /// Push a row frame into the current region.
+ fn push_row(&mut self, frame: Frame, y: usize) {
+ self.regions.size.y -= frame.height();
+ self.lrows.push(Row::Frame(frame, y));
+ }
+
+ /// Finish rows for one region.
+ fn finish_region(&mut self, vt: &mut Vt) -> SourceResult<()> {
+ // Determine the height of existing rows in the region.
+ let mut used = Abs::zero();
+ let mut fr = Fr::zero();
+ for row in &self.lrows {
+ match row {
+ Row::Frame(frame, _) => used += frame.height(),
+ Row::Fr(v, _) => fr += *v,
+ }
+ }
+
+ // Determine the size of the grid in this region, expanding fully if
+ // there are fr rows.
+ let mut size = Size::new(self.width, used).min(self.initial);
+ if fr.get() > 0.0 && self.initial.y.is_finite() {
+ size.y = self.initial.y;
+ }
+
+ // The frame for the region.
+ let mut output = Frame::new(size);
+ let mut pos = Point::zero();
+ let mut rrows = vec![];
+
+ // Place finished rows and layout fractional rows.
+ for row in std::mem::take(&mut self.lrows) {
+ let (frame, y) = match row {
+ Row::Frame(frame, y) => (frame, y),
+ Row::Fr(v, y) => {
+ let remaining = self.regions.full - used;
+ let height = v.share(fr, remaining);
+ (self.layout_single_row(vt, height, y)?, y)
+ }
+ };
+
+ let height = frame.height();
+ output.push_frame(pos, frame);
+ rrows.push(RowPiece { height, y });
+ pos.y += height;
+ }
+
+ self.finished.push(output);
+ self.rrows.push(rrows);
+ self.regions.next();
+ self.initial = self.regions.size;
+
+ Ok(())
+ }
+
+ /// Get the content of the cell in column `x` and row `y`.
+ ///
+ /// Returns `None` if it's a gutter cell.
+ #[track_caller]
+ fn cell(&self, mut x: usize, y: usize) -> Option<&'a Content> {
+ assert!(x < self.cols.len());
+ assert!(y < self.rows.len());
+
+ // Columns are reorder, but the cell slice is not.
+ if self.is_rtl {
+ x = self.cols.len() - 1 - x;
+ }
+
+ if self.has_gutter {
+ // Even columns and rows are children, odd ones are gutter.
+ if x % 2 == 0 && y % 2 == 0 {
+ let c = 1 + self.cols.len() / 2;
+ self.cells.get((y / 2) * c + x / 2)
+ } else {
+ None
+ }
+ } else {
+ let c = self.cols.len();
+ self.cells.get(y * c + x)
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/hide.rs b/crates/typst-library/src/layout/hide.rs
new file mode 100644
index 00000000..c6e83e0c
--- /dev/null
+++ b/crates/typst-library/src/layout/hide.rs
@@ -0,0 +1,30 @@
+use crate::prelude::*;
+
+/// Hides content without affecting layout.
+///
+/// The `hide` function allows you to hide content while the layout still 'sees'
+/// it. This is useful to create whitespace that is exactly as large as some
+/// content. It may also be useful to redact content because its arguments are
+/// not included in the output.
+///
+/// ## Example { #example }
+/// ```example
+/// Hello Jane \
+/// #hide[Hello] Joe
+/// ```
+///
+/// Display: Hide
+/// Category: layout
+#[element(Show)]
+pub struct HideElem {
+ /// The content to hide.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for HideElem {
+ #[tracing::instrument(name = "HideElem::show", skip(self))]
+ fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().styled(MetaElem::set_data(vec![Meta::Hide])))
+ }
+}
diff --git a/crates/typst-library/src/layout/list.rs b/crates/typst-library/src/layout/list.rs
new file mode 100644
index 00000000..e39ec3f5
--- /dev/null
+++ b/crates/typst-library/src/layout/list.rs
@@ -0,0 +1,239 @@
+use crate::layout::{BlockElem, ParElem, Sizing, Spacing};
+use crate::prelude::*;
+use crate::text::TextElem;
+
+use super::GridLayouter;
+
+/// A bullet list.
+///
+/// Displays a sequence of items vertically, with each item introduced by a
+/// marker.
+///
+/// ## Example { #example }
+/// ```example
+/// - *Content*
+/// - Text
+/// - Math
+/// - Layout
+/// - Visualize
+/// - Meta
+/// - Symbols
+///
+/// - *Compute*
+/// #list(
+/// [Foundations],
+/// [Calculate],
+/// [Construct],
+/// [Data Loading],
+/// )
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This functions also has dedicated syntax: Start a line with a hyphen,
+/// followed by a space to create a list item. A list item can contain multiple
+/// paragraphs and other block-level content. All content that is indented
+/// more than an item's hyphen becomes part of that item.
+///
+/// Display: Bullet List
+/// Category: layout
+#[element(Layout)]
+#[scope(
+ scope.define("item", ListItem::func());
+ scope
+)]
+pub struct ListElem {
+ /// If this is `{false}`, the items are spaced apart with [list
+ /// spacing]($func/list.spacing). If it is `{true}`, they use normal
+ /// [leading]($func/par.leading) instead. This makes the list more compact,
+ /// which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`.
+ ///
+ /// ```example
+ /// - If a list has a lot of text, and
+ /// maybe other inline content, it
+ /// should not be tight anymore.
+ ///
+ /// - To make a list wide, simply insert
+ /// a blank line between the items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// The marker which introduces each item.
+ ///
+ /// Instead of plain content, you can also pass an array with multiple
+ /// markers that should be used for nested lists. If the list nesting depth
+ /// exceeds the number of markers, the last one is repeated. For total
+ /// control, you may pass a function that maps the list's nesting depth
+ /// (starting from `{0}`) to a desired marker.
+ ///
+ /// ```example
+ /// #set list(marker: [--])
+ /// - A more classic list
+ /// - With en-dashes
+ ///
+ /// #set list(marker: ([•], [--]))
+ /// - Top-level
+ /// - Nested
+ /// - Items
+ /// - Items
+ /// ```
+ #[default(ListMarker::Content(vec![TextElem::packed('•')]))]
+ pub marker: ListMarker,
+
+ /// The indent of each item.
+ #[resolve]
+ pub indent: Length,
+
+ /// The spacing between the marker and the body of each item.
+ #[resolve]
+ #[default(Em::new(0.5).into())]
+ pub body_indent: Length,
+
+ /// The spacing between the items of a wide (non-tight) list.
+ ///
+ /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below).
+ pub spacing: Smart<Spacing>,
+
+ /// The bullet list's children.
+ ///
+ /// When using the list syntax, adjacent items are automatically collected
+ /// into lists, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for letter in "ABC" [
+ /// - Letter #letter
+ /// ]
+ /// ```
+ #[variadic]
+ pub children: Vec<ListItem>,
+
+ /// The nesting depth.
+ #[internal]
+ #[fold]
+ depth: Depth,
+}
+
+impl Layout for ListElem {
+ #[tracing::instrument(name = "ListElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let indent = self.indent(styles);
+ let body_indent = self.body_indent(styles);
+ let gutter = if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ self.spacing(styles)
+ .unwrap_or_else(|| BlockElem::below_in(styles).amount())
+ };
+
+ let depth = self.depth(styles);
+ let marker = self
+ .marker(styles)
+ .resolve(vt, depth)?
+ // avoid '#set align' interference with the list
+ .aligned(Align::LEFT_TOP.into());
+
+ let mut cells = vec![];
+ for item in self.children() {
+ cells.push(Content::empty());
+ cells.push(marker.clone());
+ cells.push(Content::empty());
+ cells.push(item.body().styled(Self::set_depth(Depth)));
+ }
+
+ let layouter = GridLayouter::new(
+ Axes::with_x(&[
+ Sizing::Rel(indent.into()),
+ Sizing::Auto,
+ Sizing::Rel(body_indent.into()),
+ Sizing::Auto,
+ ]),
+ Axes::with_y(&[gutter.into()]),
+ &cells,
+ regions,
+ styles,
+ );
+
+ Ok(layouter.layout(vt)?.fragment)
+ }
+}
+
+/// A bullet list item.
+///
+/// Display: Bullet List Item
+/// Category: layout
+#[element]
+pub struct ListItem {
+ /// The item's body.
+ #[required]
+ pub body: Content,
+}
+
+cast! {
+ ListItem,
+ v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::new(v.clone())),
+}
+
+/// A list's marker.
+#[derive(Debug, Clone, Hash)]
+pub enum ListMarker {
+ Content(Vec<Content>),
+ Func(Func),
+}
+
+impl ListMarker {
+ /// Resolve the marker for the given depth.
+ fn resolve(&self, vt: &mut Vt, depth: usize) -> SourceResult<Content> {
+ Ok(match self {
+ Self::Content(list) => {
+ list.get(depth).or(list.last()).cloned().unwrap_or_default()
+ }
+ Self::Func(func) => func.call_vt(vt, [depth])?.display(),
+ })
+ }
+}
+
+cast! {
+ ListMarker,
+ self => match self {
+ Self::Content(vec) => if vec.len() == 1 {
+ vec.into_iter().next().unwrap().into_value()
+ } else {
+ vec.into_value()
+ },
+ Self::Func(func) => func.into_value(),
+ },
+ v: Content => Self::Content(vec![v]),
+ array: Array => {
+ if array.is_empty() {
+ bail!("array must contain at least one marker");
+ }
+ Self::Content(array.into_iter().map(Value::display).collect())
+ },
+ v: Func => Self::Func(v),
+}
+
+struct Depth;
+
+cast! {
+ Depth,
+ self => Value::None,
+ _: Value => Self,
+}
+
+impl Fold for Depth {
+ type Output = usize;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ outer + 1
+ }
+}
diff --git a/crates/typst-library/src/layout/measure.rs b/crates/typst-library/src/layout/measure.rs
new file mode 100644
index 00000000..eb8e509e
--- /dev/null
+++ b/crates/typst-library/src/layout/measure.rs
@@ -0,0 +1,56 @@
+use crate::prelude::*;
+
+/// Measures the layouted size of content.
+///
+/// The `measure` function lets you determine the layouted size of content.
+/// Note that an infinite space is assumed, therefore the measured height/width
+/// may not necessarily match the final height/width of the measured content.
+/// If you want to measure in the current layout dimensions, you can combined
+/// `measure` and [`layout`]($func/layout).
+///
+/// # Example { #example }
+/// The same content can have a different size depending on the styles that
+/// are active when it is layouted. For example, in the example below
+/// `[#content]` is of course bigger when we increase the font size.
+///
+/// ```example
+/// #let content = [Hello!]
+/// #content
+/// #set text(14pt)
+/// #content
+/// ```
+///
+/// To do a meaningful measurement, you therefore first need to retrieve the
+/// active styles with the [`style`]($func/style) function. You can then pass
+/// them to the `measure` function.
+///
+/// ```example
+/// #let thing(body) = style(styles => {
+/// let size = measure(body, styles)
+/// [Width of "#body" is #size.width]
+/// })
+///
+/// #thing[Hey] \
+/// #thing[Welcome]
+/// ```
+///
+/// The measure function returns a dictionary with the entries `width` and
+/// `height`, both of type [`length`]($type/length).
+///
+/// Display: Measure
+/// Category: layout
+#[func]
+pub fn measure(
+ /// The content whose size to measure.
+ content: Content,
+ /// The styles with which to layout the content.
+ styles: Styles,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Dict> {
+ let pod = Regions::one(Axes::splat(Abs::inf()), Axes::splat(false));
+ let styles = StyleChain::new(&styles);
+ let frame = content.measure(&mut vm.vt, styles, pod)?.into_frame();
+ let Size { x, y } = frame.size();
+ Ok(dict! { "width" => x, "height" => y })
+}
diff --git a/crates/typst-library/src/layout/mod.rs b/crates/typst-library/src/layout/mod.rs
new file mode 100644
index 00000000..41490eb8
--- /dev/null
+++ b/crates/typst-library/src/layout/mod.rs
@@ -0,0 +1,709 @@
+//! Composable layouts.
+
+mod align;
+mod columns;
+mod container;
+#[path = "enum.rs"]
+mod enum_;
+mod flow;
+mod fragment;
+mod grid;
+mod hide;
+mod list;
+mod measure;
+mod pad;
+mod page;
+mod par;
+mod place;
+mod regions;
+mod repeat;
+mod spacing;
+mod stack;
+mod table;
+mod terms;
+mod transform;
+
+pub use self::align::*;
+pub use self::columns::*;
+pub use self::container::*;
+pub use self::enum_::*;
+pub use self::flow::*;
+pub use self::fragment::*;
+pub use self::grid::*;
+pub use self::hide::*;
+pub use self::list::*;
+pub use self::measure::*;
+pub use self::pad::*;
+pub use self::page::*;
+pub use self::par::*;
+pub use self::place::*;
+pub use self::regions::*;
+pub use self::repeat::*;
+pub use self::spacing::*;
+pub use self::stack::*;
+pub use self::table::*;
+pub use self::terms::*;
+pub use self::transform::*;
+
+use std::mem;
+
+use typed_arena::Arena;
+use typst::diag::SourceResult;
+use typst::eval::Tracer;
+use typst::model::DelayedErrors;
+use typst::model::{applicable, realize, StyleVecBuilder};
+
+use crate::math::{EquationElem, LayoutMath};
+use crate::meta::DocumentElem;
+use crate::prelude::*;
+use crate::shared::BehavedBuilder;
+use crate::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem};
+use crate::visualize::{
+ CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem,
+ SquareElem,
+};
+
+/// Hook up all layout definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.define("page", PageElem::func());
+ global.define("pagebreak", PagebreakElem::func());
+ global.define("v", VElem::func());
+ global.define("par", ParElem::func());
+ global.define("parbreak", ParbreakElem::func());
+ global.define("h", HElem::func());
+ global.define("box", BoxElem::func());
+ global.define("block", BlockElem::func());
+ global.define("list", ListElem::func());
+ global.define("enum", EnumElem::func());
+ global.define("terms", TermsElem::func());
+ global.define("table", TableElem::func());
+ global.define("stack", StackElem::func());
+ global.define("grid", GridElem::func());
+ global.define("columns", ColumnsElem::func());
+ global.define("colbreak", ColbreakElem::func());
+ global.define("place", PlaceElem::func());
+ global.define("align", AlignElem::func());
+ global.define("pad", PadElem::func());
+ global.define("repeat", RepeatElem::func());
+ global.define("move", MoveElem::func());
+ global.define("scale", ScaleElem::func());
+ global.define("rotate", RotateElem::func());
+ global.define("hide", HideElem::func());
+ global.define("measure", measure_func());
+ global.define("ltr", Dir::LTR);
+ global.define("rtl", Dir::RTL);
+ global.define("ttb", Dir::TTB);
+ global.define("btt", Dir::BTT);
+ global.define("start", GenAlign::Start);
+ global.define("end", GenAlign::End);
+ global.define("left", GenAlign::Specific(Align::Left));
+ global.define("center", GenAlign::Specific(Align::Center));
+ global.define("right", GenAlign::Specific(Align::Right));
+ global.define("top", GenAlign::Specific(Align::Top));
+ global.define("horizon", GenAlign::Specific(Align::Horizon));
+ global.define("bottom", GenAlign::Specific(Align::Bottom));
+}
+
+/// Root-level layout.
+pub trait LayoutRoot {
+ /// Layout into one frame per page.
+ fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document>;
+}
+
+impl LayoutRoot for Content {
+ #[tracing::instrument(name = "Content::layout_root", skip_all)]
+ fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document> {
+ #[comemo::memoize]
+ fn cached(
+ content: &Content,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ styles: StyleChain,
+ ) -> SourceResult<Document> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let scratch = Scratch::default();
+ let (realized, styles) = realize_root(&mut vt, &scratch, content, styles)?;
+ realized
+ .with::<dyn LayoutRoot>()
+ .unwrap()
+ .layout_root(&mut vt, styles)
+ }
+
+ tracing::info!("Starting layout");
+ cached(
+ self,
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ styles,
+ )
+ }
+}
+
+/// Layout into regions.
+pub trait Layout {
+ /// Layout into one frame per region.
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>;
+
+ /// Layout without side effects.
+ ///
+ /// This element must be layouted again in the same order for the results to
+ /// be valid.
+ #[tracing::instrument(name = "Layout::measure", skip_all)]
+ fn measure(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut locator = Locator::chained(vt.locator.track());
+ let mut vt = Vt {
+ world: vt.world,
+ introspector: vt.introspector,
+ locator: &mut locator,
+ tracer: TrackedMut::reborrow_mut(&mut vt.tracer),
+ delayed: TrackedMut::reborrow_mut(&mut vt.delayed),
+ };
+ self.layout(&mut vt, styles, regions)
+ }
+}
+
+impl Layout for Content {
+ #[tracing::instrument(name = "Content::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ #[allow(clippy::too_many_arguments)]
+ #[comemo::memoize]
+ fn cached(
+ content: &Content,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let scratch = Scratch::default();
+ let (realized, styles) = realize_block(&mut vt, &scratch, content, styles)?;
+ realized
+ .with::<dyn Layout>()
+ .unwrap()
+ .layout(&mut vt, styles, regions)
+ }
+
+ tracing::info!("Layouting `Content`");
+
+ let fragment = cached(
+ self,
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ styles,
+ regions,
+ )?;
+
+ vt.locator.visit_frames(&fragment);
+ Ok(fragment)
+ }
+}
+
+/// Realize into an element that is capable of root-level layout.
+#[tracing::instrument(skip_all)]
+fn realize_root<'a>(
+ vt: &mut Vt,
+ scratch: &'a Scratch<'a>,
+ content: &'a Content,
+ styles: StyleChain<'a>,
+) -> SourceResult<(Content, StyleChain<'a>)> {
+ if content.can::<dyn LayoutRoot>() && !applicable(content, styles) {
+ return Ok((content.clone(), styles));
+ }
+
+ let mut builder = Builder::new(vt, scratch, true);
+ builder.accept(content, styles)?;
+ builder.interrupt_page(Some(styles))?;
+ let (pages, shared) = builder.doc.unwrap().pages.finish();
+ Ok((DocumentElem::new(pages.to_vec()).pack(), shared))
+}
+
+/// Realize into an element that is capable of block-level layout.
+#[tracing::instrument(skip_all)]
+fn realize_block<'a>(
+ vt: &mut Vt,
+ scratch: &'a Scratch<'a>,
+ content: &'a Content,
+ styles: StyleChain<'a>,
+) -> SourceResult<(Content, StyleChain<'a>)> {
+ if content.can::<dyn Layout>()
+ && !content.is::<LineElem>()
+ && !content.is::<RectElem>()
+ && !content.is::<SquareElem>()
+ && !content.is::<EllipseElem>()
+ && !content.is::<CircleElem>()
+ && !content.is::<ImageElem>()
+ && !content.is::<PolygonElem>()
+ && !content.is::<PathElem>()
+ && !applicable(content, styles)
+ {
+ return Ok((content.clone(), styles));
+ }
+
+ let mut builder = Builder::new(vt, scratch, false);
+ builder.accept(content, styles)?;
+ builder.interrupt_par()?;
+ let (children, shared) = builder.flow.0.finish();
+ Ok((FlowElem::new(children.to_vec()).pack(), shared))
+}
+
+/// Builds a document or a flow element from content.
+struct Builder<'a, 'v, 't> {
+ /// The virtual typesetter.
+ vt: &'v mut Vt<'t>,
+ /// Scratch arenas for building.
+ scratch: &'a Scratch<'a>,
+ /// The current document building state.
+ doc: Option<DocBuilder<'a>>,
+ /// The current flow building state.
+ flow: FlowBuilder<'a>,
+ /// The current paragraph building state.
+ par: ParBuilder<'a>,
+ /// The current list building state.
+ list: ListBuilder<'a>,
+}
+
+/// Temporary storage arenas for building.
+#[derive(Default)]
+struct Scratch<'a> {
+ /// An arena where intermediate style chains are stored.
+ styles: Arena<StyleChain<'a>>,
+ /// An arena where intermediate content resulting from show rules is stored.
+ content: Arena<Content>,
+}
+
+impl<'a, 'v, 't> Builder<'a, 'v, 't> {
+ fn new(vt: &'v mut Vt<'t>, scratch: &'a Scratch<'a>, top: bool) -> Self {
+ Self {
+ vt,
+ scratch,
+ doc: top.then(DocBuilder::default),
+ flow: FlowBuilder::default(),
+ par: ParBuilder::default(),
+ list: ListBuilder::default(),
+ }
+ }
+
+ fn accept(
+ &mut self,
+ mut content: &'a Content,
+ styles: StyleChain<'a>,
+ ) -> SourceResult<()> {
+ if content.can::<dyn LayoutMath>() && !content.is::<EquationElem>() {
+ content =
+ self.scratch.content.alloc(EquationElem::new(content.clone()).pack());
+ }
+
+ if let Some(realized) = realize(self.vt, content, styles)? {
+ let stored = self.scratch.content.alloc(realized);
+ return self.accept(stored, styles);
+ }
+
+ if let Some((elem, local)) = content.to_styled() {
+ return self.styled(elem, local, styles);
+ }
+
+ if let Some(children) = content.to_sequence() {
+ for elem in children {
+ self.accept(elem, styles)?;
+ }
+ return Ok(());
+ }
+
+ if self.list.accept(content, styles) {
+ return Ok(());
+ }
+
+ self.interrupt_list()?;
+
+ if self.list.accept(content, styles) {
+ return Ok(());
+ }
+
+ if self.par.accept(content, styles) {
+ return Ok(());
+ }
+
+ self.interrupt_par()?;
+
+ if self.flow.accept(content, styles) {
+ return Ok(());
+ }
+
+ let keep = content
+ .to::<PagebreakElem>()
+ .map_or(false, |pagebreak| !pagebreak.weak(styles));
+
+ self.interrupt_page(keep.then_some(styles))?;
+
+ if let Some(doc) = &mut self.doc {
+ if doc.accept(content, styles) {
+ return Ok(());
+ }
+ }
+
+ if content.is::<PagebreakElem>() {
+ bail!(content.span(), "pagebreaks are not allowed inside of containers");
+ } else {
+ bail!(content.span(), "{} is not allowed here", content.func().name());
+ }
+ }
+
+ fn styled(
+ &mut self,
+ elem: &'a Content,
+ map: &'a Styles,
+ styles: StyleChain<'a>,
+ ) -> SourceResult<()> {
+ let stored = self.scratch.styles.alloc(styles);
+ let styles = stored.chain(map);
+ self.interrupt_style(map, None)?;
+ self.accept(elem, styles)?;
+ self.interrupt_style(map, Some(styles))?;
+ Ok(())
+ }
+
+ fn interrupt_style(
+ &mut self,
+ local: &Styles,
+ outer: Option<StyleChain<'a>>,
+ ) -> SourceResult<()> {
+ if let Some(Some(span)) = local.interruption::<DocumentElem>() {
+ if self.doc.is_none() {
+ bail!(span, "document set rules are not allowed inside of containers");
+ }
+ if outer.is_none()
+ && (!self.flow.0.is_empty()
+ || !self.par.0.is_empty()
+ || !self.list.items.is_empty())
+ {
+ bail!(span, "document set rules must appear before any content");
+ }
+ } else if let Some(Some(span)) = local.interruption::<PageElem>() {
+ if self.doc.is_none() {
+ bail!(span, "page configuration is not allowed inside of containers");
+ }
+ self.interrupt_page(outer)?;
+ } else if local.interruption::<ParElem>().is_some()
+ || local.interruption::<AlignElem>().is_some()
+ {
+ self.interrupt_par()?;
+ } else if local.interruption::<ListElem>().is_some()
+ || local.interruption::<EnumElem>().is_some()
+ || local.interruption::<TermsElem>().is_some()
+ {
+ self.interrupt_list()?;
+ }
+ Ok(())
+ }
+
+ fn interrupt_list(&mut self) -> SourceResult<()> {
+ if !self.list.items.is_empty() {
+ let staged = mem::take(&mut self.list.staged);
+ let (list, styles) = mem::take(&mut self.list).finish();
+ let stored = self.scratch.content.alloc(list);
+ self.accept(stored, styles)?;
+ for (content, styles) in staged {
+ self.accept(content, styles)?;
+ }
+ }
+ Ok(())
+ }
+
+ fn interrupt_par(&mut self) -> SourceResult<()> {
+ self.interrupt_list()?;
+ if !self.par.0.is_empty() {
+ let (par, styles) = mem::take(&mut self.par).finish();
+ let stored = self.scratch.content.alloc(par);
+ self.accept(stored, styles)?;
+ }
+
+ Ok(())
+ }
+
+ fn interrupt_page(&mut self, styles: Option<StyleChain<'a>>) -> SourceResult<()> {
+ self.interrupt_par()?;
+ let Some(doc) = &mut self.doc else { return Ok(()) };
+ if !self.flow.0.is_empty() || (doc.keep_next && styles.is_some()) {
+ let (flow, shared) = mem::take(&mut self.flow).0.finish();
+ let styles = if shared == StyleChain::default() {
+ styles.unwrap_or_default()
+ } else {
+ shared
+ };
+ let page = PageElem::new(FlowElem::new(flow.to_vec()).pack());
+ let stored = self.scratch.content.alloc(page.pack());
+ self.accept(stored, styles)?;
+ }
+ Ok(())
+ }
+}
+
+/// Accepts pagebreaks and pages.
+struct DocBuilder<'a> {
+ /// The page runs built so far.
+ pages: StyleVecBuilder<'a, Content>,
+ /// Whether to keep a following page even if it is empty.
+ keep_next: bool,
+ /// Whether the next page should be cleared to an even or odd number.
+ clear_next: Option<Parity>,
+}
+
+impl<'a> DocBuilder<'a> {
+ fn accept(&mut self, content: &Content, styles: StyleChain<'a>) -> bool {
+ if let Some(pagebreak) = content.to::<PagebreakElem>() {
+ self.keep_next = !pagebreak.weak(styles);
+ self.clear_next = pagebreak.to(styles);
+ return true;
+ }
+
+ if let Some(page) = content.to::<PageElem>() {
+ let elem = if let Some(clear_to) = self.clear_next.take() {
+ let mut page = page.clone();
+ page.push_clear_to(Some(clear_to));
+ page.pack()
+ } else {
+ content.clone()
+ };
+
+ self.pages.push(elem, styles);
+ self.keep_next = false;
+ return true;
+ }
+
+ false
+ }
+}
+
+impl Default for DocBuilder<'_> {
+ fn default() -> Self {
+ Self {
+ pages: StyleVecBuilder::new(),
+ keep_next: true,
+ clear_next: None,
+ }
+ }
+}
+
+/// Accepts flow content.
+#[derive(Default)]
+struct FlowBuilder<'a>(BehavedBuilder<'a>, bool);
+
+impl<'a> FlowBuilder<'a> {
+ fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool {
+ if content.is::<ParbreakElem>() {
+ self.1 = true;
+ return true;
+ }
+
+ let last_was_parbreak = self.1;
+ self.1 = false;
+
+ if content.is::<VElem>()
+ || content.is::<ColbreakElem>()
+ || content.is::<MetaElem>()
+ {
+ self.0.push(content.clone(), styles);
+ return true;
+ }
+
+ if content.can::<dyn Layout>() || content.is::<ParElem>() {
+ let is_tight_list = if let Some(elem) = content.to::<ListElem>() {
+ elem.tight(styles)
+ } else if let Some(elem) = content.to::<EnumElem>() {
+ elem.tight(styles)
+ } else if let Some(elem) = content.to::<TermsElem>() {
+ elem.tight(styles)
+ } else {
+ false
+ };
+
+ if !last_was_parbreak && is_tight_list {
+ let leading = ParElem::leading_in(styles);
+ let spacing = VElem::list_attach(leading.into());
+ self.0.push(spacing.pack(), styles);
+ }
+
+ let (above, below) = if let Some(block) = content.to::<BlockElem>() {
+ (block.above(styles), block.below(styles))
+ } else {
+ (BlockElem::above_in(styles), BlockElem::below_in(styles))
+ };
+
+ self.0.push(above.pack(), styles);
+ self.0.push(content.clone(), styles);
+ self.0.push(below.pack(), styles);
+ return true;
+ }
+
+ false
+ }
+}
+
+/// Accepts paragraph content.
+#[derive(Default)]
+struct ParBuilder<'a>(BehavedBuilder<'a>);
+
+impl<'a> ParBuilder<'a> {
+ fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool {
+ if content.is::<MetaElem>() {
+ if !self.0.is_basically_empty() {
+ self.0.push(content.clone(), styles);
+ return true;
+ }
+ } else if content.is::<SpaceElem>()
+ || content.is::<TextElem>()
+ || content.is::<HElem>()
+ || content.is::<LinebreakElem>()
+ || content.is::<SmartQuoteElem>()
+ || content.to::<EquationElem>().map_or(false, |elem| !elem.block(styles))
+ || content.is::<BoxElem>()
+ {
+ self.0.push(content.clone(), styles);
+ return true;
+ }
+
+ false
+ }
+
+ fn finish(self) -> (Content, StyleChain<'a>) {
+ let (children, shared) = self.0.finish();
+ (ParElem::new(children.to_vec()).pack(), shared)
+ }
+}
+
+/// Accepts list / enum items, spaces, paragraph breaks.
+struct ListBuilder<'a> {
+ /// The list items collected so far.
+ items: StyleVecBuilder<'a, Content>,
+ /// Whether the list contains no paragraph breaks.
+ tight: bool,
+ /// Trailing content for which it is unclear whether it is part of the list.
+ staged: Vec<(&'a Content, StyleChain<'a>)>,
+}
+
+impl<'a> ListBuilder<'a> {
+ fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool {
+ if !self.items.is_empty()
+ && (content.is::<SpaceElem>() || content.is::<ParbreakElem>())
+ {
+ self.staged.push((content, styles));
+ return true;
+ }
+
+ if (content.is::<ListItem>()
+ || content.is::<EnumItem>()
+ || content.is::<TermItem>())
+ && self
+ .items
+ .elems()
+ .next()
+ .map_or(true, |first| first.func() == content.func())
+ {
+ self.items.push(content.clone(), styles);
+ self.tight &= self.staged.drain(..).all(|(t, _)| !t.is::<ParbreakElem>());
+ return true;
+ }
+
+ false
+ }
+
+ fn finish(self) -> (Content, StyleChain<'a>) {
+ let (items, shared) = self.items.finish();
+ let item = items.items().next().unwrap();
+ let output = if item.is::<ListItem>() {
+ ListElem::new(
+ items
+ .iter()
+ .map(|(item, local)| {
+ let item = item.to::<ListItem>().unwrap();
+ item.clone().with_body(item.body().styled_with_map(local.clone()))
+ })
+ .collect::<Vec<_>>(),
+ )
+ .with_tight(self.tight)
+ .pack()
+ } else if item.is::<EnumItem>() {
+ EnumElem::new(
+ items
+ .iter()
+ .map(|(item, local)| {
+ let item = item.to::<EnumItem>().unwrap();
+ item.clone().with_body(item.body().styled_with_map(local.clone()))
+ })
+ .collect::<Vec<_>>(),
+ )
+ .with_tight(self.tight)
+ .pack()
+ } else if item.is::<TermItem>() {
+ TermsElem::new(
+ items
+ .iter()
+ .map(|(item, local)| {
+ let item = item.to::<TermItem>().unwrap();
+ item.clone()
+ .with_term(item.term().styled_with_map(local.clone()))
+ .with_description(
+ item.description().styled_with_map(local.clone()),
+ )
+ })
+ .collect::<Vec<_>>(),
+ )
+ .with_tight(self.tight)
+ .pack()
+ } else {
+ unreachable!()
+ };
+ (output, shared)
+ }
+}
+
+impl Default for ListBuilder<'_> {
+ fn default() -> Self {
+ Self {
+ items: StyleVecBuilder::default(),
+ tight: true,
+ staged: vec![],
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/pad.rs b/crates/typst-library/src/layout/pad.rs
new file mode 100644
index 00000000..a3d5646b
--- /dev/null
+++ b/crates/typst-library/src/layout/pad.rs
@@ -0,0 +1,125 @@
+use crate::prelude::*;
+
+/// Adds spacing around content.
+///
+/// The spacing can be specified for each side individually, or for all sides at
+/// once by specifying a positional argument.
+///
+/// ## Example { #example }
+/// ```example
+/// #set align(center)
+///
+/// #pad(x: 16pt, image("typing.jpg"))
+/// _Typing speeds can be
+/// measured in words per minute._
+/// ```
+///
+/// Display: Padding
+/// Category: layout
+#[element(Layout)]
+pub struct PadElem {
+ /// The padding at the left side.
+ #[parse(
+ let all = args.named("rest")?.or(args.find()?);
+ let x = args.named("x")?.or(all);
+ let y = args.named("y")?.or(all);
+ args.named("left")?.or(x)
+ )]
+ pub left: Rel<Length>,
+
+ /// The padding at the top side.
+ #[parse(args.named("top")?.or(y))]
+ pub top: Rel<Length>,
+
+ /// The padding at the right side.
+ #[parse(args.named("right")?.or(x))]
+ pub right: Rel<Length>,
+
+ /// The padding at the bottom side.
+ #[parse(args.named("bottom")?.or(y))]
+ pub bottom: Rel<Length>,
+
+ /// The horizontal padding. Both `left` and `right` take precedence over
+ /// this.
+ #[external]
+ pub x: Rel<Length>,
+
+ /// The vertical padding. Both `top` and `bottom` take precedence over this.
+ #[external]
+ pub y: Rel<Length>,
+
+ /// The padding for all sides. All other parameters take precedence over
+ /// this.
+ #[external]
+ pub rest: Rel<Length>,
+
+ /// The content to pad at the sides.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for PadElem {
+ #[tracing::instrument(name = "PadElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let sides = Sides::new(
+ self.left(styles),
+ self.top(styles),
+ self.right(styles),
+ self.bottom(styles),
+ );
+
+ // Layout child into padded regions.
+ let mut backlog = vec![];
+ let padding = sides.resolve(styles);
+ let pod = regions.map(&mut backlog, |size| shrink(size, padding));
+ let mut fragment = self.body().layout(vt, styles, pod)?;
+
+ for frame in &mut fragment {
+ // Apply the padding inversely such that the grown size padded
+ // yields the frame's size.
+ let padded = grow(frame.size(), padding);
+ let padding = padding.relative_to(padded);
+ let offset = Point::new(padding.left, padding.top);
+
+ // Grow the frame and translate everything in the frame inwards.
+ frame.set_size(padded);
+ frame.translate(offset);
+ }
+
+ Ok(fragment)
+ }
+}
+
+/// Shrink a size by padding relative to the size itself.
+fn shrink(size: Size, padding: Sides<Rel<Abs>>) -> Size {
+ size - padding.relative_to(size).sum_by_axis()
+}
+
+/// Grow a size by padding relative to the grown size.
+/// This is the inverse operation to `shrink()`.
+///
+/// For the horizontal axis the derivation looks as follows.
+/// (Vertical axis is analogous.)
+///
+/// Let w be the grown target width,
+/// s be the given width,
+/// l be the left padding,
+/// r be the right padding,
+/// p = l + r.
+///
+/// We want that: w - l.resolve(w) - r.resolve(w) = s
+///
+/// Thus: w - l.resolve(w) - r.resolve(w) = s
+/// <=> w - p.resolve(w) = s
+/// <=> w - p.rel * w - p.abs = s
+/// <=> (1 - p.rel) * w = s + p.abs
+/// <=> w = (s + p.abs) / (1 - p.rel)
+fn grow(size: Size, padding: Sides<Rel<Abs>>) -> Size {
+ size.zip(padding.sum_by_axis())
+ .map(|(s, p)| (s + p.abs).safe_div(1.0 - p.rel.get()))
+}
diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs
new file mode 100644
index 00000000..3b81f92c
--- /dev/null
+++ b/crates/typst-library/src/layout/page.rs
@@ -0,0 +1,898 @@
+use std::ptr;
+use std::str::FromStr;
+
+use super::{AlignElem, ColumnsElem};
+use crate::meta::{Counter, CounterKey, Numbering};
+use crate::prelude::*;
+use crate::text::TextElem;
+
+/// Layouts its child onto one or multiple pages.
+///
+/// Although this function is primarily used in set rules to affect page
+/// properties, it can also be used to explicitly render its argument onto
+/// a set of pages of its own.
+///
+/// Pages can be set to use `{auto}` as their width or height. In this case,
+/// the pages will grow to fit their content on the respective axis.
+///
+/// ## Example { #example }
+/// ```example
+/// >>> #set page(margin: auto)
+/// #set page("us-letter")
+///
+/// There you go, US friends!
+/// ```
+///
+/// Display: Page
+/// Category: layout
+#[element]
+pub struct PageElem {
+ /// A standard paper size to set width and height.
+ #[external]
+ #[default(Paper::A4)]
+ pub paper: Paper,
+
+ /// The width of the page.
+ ///
+ /// ```example
+ /// #set page(
+ /// width: 3cm,
+ /// margin: (x: 0cm),
+ /// )
+ ///
+ /// #for i in range(3) {
+ /// box(square(width: 1cm))
+ /// }
+ /// ```
+ #[resolve]
+ #[parse(
+ let paper = args.named_or_find::<Paper>("paper")?;
+ args.named("width")?
+ .or_else(|| paper.map(|paper| Smart::Custom(paper.width().into())))
+ )]
+ #[default(Smart::Custom(Paper::A4.width().into()))]
+ pub width: Smart<Length>,
+
+ /// The height of the page.
+ ///
+ /// If this is set to `{auto}`, page breaks can only be triggered manually
+ /// by inserting a [page break]($func/pagebreak). Most examples throughout
+ /// this documentation use `{auto}` for the height of the page to
+ /// dynamically grow and shrink to fit their content.
+ #[resolve]
+ #[parse(
+ args.named("height")?
+ .or_else(|| paper.map(|paper| Smart::Custom(paper.height().into())))
+ )]
+ #[default(Smart::Custom(Paper::A4.height().into()))]
+ pub height: Smart<Length>,
+
+ /// Whether the page is flipped into landscape orientation.
+ ///
+ /// ```example
+ /// #set page(
+ /// "us-business-card",
+ /// flipped: true,
+ /// fill: rgb("f2e5dd"),
+ /// )
+ ///
+ /// #set align(bottom + end)
+ /// #text(14pt)[*Sam H. Richards*] \
+ /// _Procurement Manager_
+ ///
+ /// #set text(10pt)
+ /// 17 Main Street \
+ /// New York, NY 10001 \
+ /// +1 555 555 5555
+ /// ```
+ #[default(false)]
+ pub flipped: bool,
+
+ /// The page's margins.
+ ///
+ /// - A single length: The same margin on all sides.
+ /// - `{auto}`: The margin is set to the default value for the page's size.
+ /// - A dictionary: With a dictionary, the margins can be set individually.
+ /// The dictionary can contain the following keys in order of precedence:
+ /// - `top`: The top margin.
+ /// - `right`: The right margin.
+ /// - `bottom`: The bottom margin.
+ /// - `left`: The left margin.
+ /// - `inside`: The margin at the inner side of the page (where the
+ /// [binding]($func/page.binding) is).
+ /// - `outside`: The margin at the outer side of the page (opposite to the
+ /// [binding]($func/page.binding)).
+ /// - `x`: The horizontal margins.
+ /// - `y`: The vertical margins.
+ /// - `rest`: The margins on all sides except those for which the
+ /// dictionary explicitly sets a size.
+ ///
+ /// The values for `left` and `right` are mutually exclusive with
+ /// the values for `inside` and `outside`.
+ ///
+ /// ```example
+ /// #set page(
+ /// width: 3cm,
+ /// height: 4cm,
+ /// margin: (x: 8pt, y: 4pt),
+ /// )
+ ///
+ /// #rect(
+ /// width: 100%,
+ /// height: 100%,
+ /// fill: aqua,
+ /// )
+ /// ```
+ #[fold]
+ pub margin: Margin,
+
+ /// On which side the pages will be bound.
+ ///
+ /// - `{auto}`: Equivalent to `left` if the [text direction]($func/text.dir)
+ /// is left-to-right and `right` if it is right-to-left.
+ /// - `left`: Bound on the left side.
+ /// - `right`: Bound on the right side.
+ ///
+ /// This affects the meaning of the `inside` and `outside` options for
+ /// margins.
+ pub binding: Smart<Binding>,
+
+ /// How many columns the page has.
+ ///
+ /// ```example:single
+ /// #set page(columns: 2, height: 4.8cm)
+ /// Climate change is one of the most
+ /// pressing issues of our time, with
+ /// the potential to devastate
+ /// communities, ecosystems, and
+ /// economies around the world. It's
+ /// clear that we need to take urgent
+ /// action to reduce our carbon
+ /// emissions and mitigate the impacts
+ /// of a rapidly changing climate.
+ /// ```
+ #[default(NonZeroUsize::ONE)]
+ pub columns: NonZeroUsize,
+
+ /// The page's background color.
+ ///
+ /// This instructs the printer to color the complete page with the given
+ /// color. If you are considering larger production runs, it may be more
+ /// environmentally friendly and cost-effective to source pre-dyed pages and
+ /// not set this property.
+ ///
+ /// ```example
+ /// #set page(fill: rgb("444352"))
+ /// #set text(fill: rgb("fdfdfd"))
+ /// *Dark mode enabled.*
+ /// ```
+ pub fill: Option<Paint>,
+
+ /// How to [number]($func/numbering) the pages.
+ ///
+ /// If an explicit `footer` is given, the numbering is ignored.
+ ///
+ /// ```example
+ /// #set page(
+ /// height: 100pt,
+ /// margin: (top: 16pt, bottom: 24pt),
+ /// numbering: "1 / 1",
+ /// )
+ ///
+ /// #lorem(48)
+ /// ```
+ pub numbering: Option<Numbering>,
+
+ /// The alignment of the page numbering.
+ ///
+ /// ```example
+ /// #set page(
+ /// margin: (top: 16pt, bottom: 24pt),
+ /// numbering: "1",
+ /// number-align: right,
+ /// )
+ ///
+ /// #lorem(30)
+ /// ```
+ #[default(Align::Center.into())]
+ pub number_align: Axes<Option<GenAlign>>,
+
+ /// The page's header. Fills the top margin of each page.
+ ///
+ /// ```example
+ /// #set par(justify: true)
+ /// #set page(
+ /// margin: (top: 32pt, bottom: 20pt),
+ /// header: [
+ /// #set text(8pt)
+ /// #smallcaps[Typst Academcy]
+ /// #h(1fr) _Exercise Sheet 3_
+ /// ],
+ /// )
+ ///
+ /// #lorem(19)
+ /// ```
+ pub header: Option<Content>,
+
+ /// The amount the header is raised into the top margin.
+ #[resolve]
+ #[default(Ratio::new(0.3).into())]
+ pub header_ascent: Rel<Length>,
+
+ /// The page's footer. Fills the bottom margin of each page.
+ ///
+ /// For just a page number, the `numbering` property, typically suffices. If
+ /// you want to create a custom footer, but still display the page number,
+ /// you can directly access the [page counter]($func/counter).
+ ///
+ /// ```example
+ /// #set par(justify: true)
+ /// #set page(
+ /// height: 100pt,
+ /// margin: 20pt,
+ /// footer: [
+ /// #set align(right)
+ /// #set text(8pt)
+ /// #counter(page).display(
+ /// "1 of I",
+ /// both: true,
+ /// )
+ /// ]
+ /// )
+ ///
+ /// #lorem(48)
+ /// ```
+ pub footer: Option<Content>,
+
+ /// The amount the footer is lowered into the bottom margin.
+ #[resolve]
+ #[default(Ratio::new(0.3).into())]
+ pub footer_descent: Rel<Length>,
+
+ /// Content in the page's background.
+ ///
+ /// This content will be placed behind the page's body. It can be
+ /// used to place a background image or a watermark.
+ ///
+ /// ```example
+ /// #set page(background: rotate(24deg,
+ /// text(18pt, fill: rgb("FFCBC4"))[
+ /// *CONFIDENTIAL*
+ /// ]
+ /// ))
+ ///
+ /// = Typst's secret plans
+ /// In the year 2023, we plan to take
+ /// over the world (of typesetting).
+ /// ```
+ pub background: Option<Content>,
+
+ /// Content in the page's foreground.
+ ///
+ /// This content will overlay the page's body.
+ ///
+ /// ```example
+ /// #set page(foreground: text(24pt)[🥸])
+ ///
+ /// Reviewer 2 has marked our paper
+ /// "Weak Reject" because they did
+ /// not understand our approach...
+ /// ```
+ pub foreground: Option<Content>,
+
+ /// The contents of the page(s).
+ ///
+ /// Multiple pages will be created if the content does not fit on a single
+ /// page. A new page with the page properties prior to the function invocation
+ /// will be created after the body has been typeset.
+ #[required]
+ pub body: Content,
+
+ /// Whether the page should be aligned to an even or odd page.
+ /// Not part of the public API for now.
+ #[internal]
+ pub clear_to: Option<Parity>,
+}
+
+impl PageElem {
+ /// A document can consist of multiple `PageElem`s, one per run of pages
+ /// with equal properties (not one per actual output page!). The `number` is
+ /// the physical page number of the first page of this run. It is mutated
+ /// while we post-process the pages in this function. This function returns
+ /// a fragment consisting of multiple frames, one per output page of this
+ /// page run.
+ #[tracing::instrument(skip_all)]
+ pub fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ mut number: NonZeroUsize,
+ ) -> SourceResult<Fragment> {
+ tracing::info!("Page layout");
+
+ // When one of the lengths is infinite the page fits its content along
+ // that axis.
+ let width = self.width(styles).unwrap_or(Abs::inf());
+ let height = self.height(styles).unwrap_or(Abs::inf());
+ let mut size = Size::new(width, height);
+ if self.flipped(styles) {
+ std::mem::swap(&mut size.x, &mut size.y);
+ }
+
+ let mut min = width.min(height);
+ if !min.is_finite() {
+ min = Paper::A4.width();
+ }
+
+ // Determine the margins.
+ let default = Rel::<Length>::from(0.1190 * min);
+ let margin = self.margin(styles);
+ let two_sided = margin.two_sided.unwrap_or(false);
+ let margin = margin
+ .sides
+ .map(|side| side.and_then(Smart::as_custom).unwrap_or(default))
+ .resolve(styles)
+ .relative_to(size);
+
+ // Determine the binding.
+ let binding =
+ self.binding(styles)
+ .unwrap_or_else(|| match TextElem::dir_in(styles) {
+ Dir::LTR => Binding::Left,
+ _ => Binding::Right,
+ });
+
+ // Realize columns.
+ let mut child = self.body();
+ let columns = self.columns(styles);
+ if columns.get() > 1 {
+ child = ColumnsElem::new(child).with_count(columns).pack();
+ }
+
+ let area = size - margin.sum_by_axis();
+ let mut regions = Regions::repeat(area, area.map(Abs::is_finite));
+ regions.root = true;
+
+ // Layout the child.
+ let mut frames = child.layout(vt, styles, regions)?.into_frames();
+
+ // Align the child to the pagebreak's parity.
+ if self.clear_to(styles).is_some_and(|p| !p.matches(number.get())) {
+ let size = area.map(Abs::is_finite).select(area, Size::zero());
+ frames.insert(0, Frame::new(size));
+ }
+
+ let fill = self.fill(styles);
+ let foreground = self.foreground(styles);
+ let background = self.background(styles);
+ let header = self.header(styles);
+ let header_ascent = self.header_ascent(styles);
+ let footer = self.footer(styles).or_else(|| {
+ self.numbering(styles).map(|numbering| {
+ let both = match &numbering {
+ Numbering::Pattern(pattern) => pattern.pieces() >= 2,
+ Numbering::Func(_) => true,
+ };
+ Counter::new(CounterKey::Page)
+ .display(Some(numbering), both)
+ .aligned(self.number_align(styles))
+ })
+ });
+ let footer_descent = self.footer_descent(styles);
+
+ let numbering_meta = FrameItem::Meta(
+ Meta::PageNumbering(self.numbering(styles).into_value()),
+ Size::zero(),
+ );
+
+ // Post-process pages.
+ for frame in frames.iter_mut() {
+ tracing::info!("Layouting page #{number}");
+
+ // The padded width of the page's content without margins.
+ let pw = frame.width();
+
+ // If two sided, left becomes inside and right becomes outside.
+ // Thus, for left-bound pages, we want to swap on even pages and
+ // for right-bound pages, we want to swap on odd pages.
+ let mut margin = margin;
+ if two_sided && binding.swap(number) {
+ std::mem::swap(&mut margin.left, &mut margin.right);
+ }
+
+ // Realize margins.
+ frame.set_size(frame.size() + margin.sum_by_axis());
+ frame.translate(Point::new(margin.left, margin.top));
+ frame.push(Point::zero(), numbering_meta.clone());
+
+ // The page size with margins.
+ let size = frame.size();
+
+ // Realize overlays.
+ for (name, marginal) in [
+ ("header", &header),
+ ("footer", &footer),
+ ("background", &background),
+ ("foreground", &foreground),
+ ] {
+ tracing::info!("Layouting {name}");
+
+ let Some(content) = marginal else { continue };
+
+ let (pos, area, align);
+ if ptr::eq(marginal, &header) {
+ let ascent = header_ascent.relative_to(margin.top);
+ pos = Point::with_x(margin.left);
+ area = Size::new(pw, margin.top - ascent);
+ align = Align::Bottom.into();
+ } else if ptr::eq(marginal, &footer) {
+ let descent = footer_descent.relative_to(margin.bottom);
+ pos = Point::new(margin.left, size.y - margin.bottom + descent);
+ area = Size::new(pw, margin.bottom - descent);
+ align = Align::Top.into();
+ } else {
+ pos = Point::zero();
+ area = size;
+ align = Align::CENTER_HORIZON.into();
+ };
+
+ let pod = Regions::one(area, Axes::splat(true));
+ let sub = content
+ .clone()
+ .styled(AlignElem::set_alignment(align))
+ .layout(vt, styles, pod)?
+ .into_frame();
+
+ if ptr::eq(marginal, &header) || ptr::eq(marginal, &background) {
+ frame.prepend_frame(pos, sub);
+ } else {
+ frame.push_frame(pos, sub);
+ }
+ }
+
+ if let Some(fill) = &fill {
+ frame.fill(fill.clone());
+ }
+
+ number = number.saturating_add(1);
+ }
+
+ Ok(Fragment::frames(frames))
+ }
+}
+
+/// Specification of the page's margins.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Margin {
+ /// The margins for each side.
+ pub sides: Sides<Option<Smart<Rel<Length>>>>,
+ /// Whether to swap `left` and `right` to make them `inside` and `outside`
+ /// (when to swap depends on the binding).
+ pub two_sided: Option<bool>,
+}
+
+impl Margin {
+ /// Create an instance with four equal components.
+ pub fn splat(value: Option<Smart<Rel<Length>>>) -> Self {
+ Self { sides: Sides::splat(value), two_sided: None }
+ }
+}
+
+impl Fold for Margin {
+ type Output = Margin;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ let sides =
+ self.sides
+ .zip(outer.sides)
+ .map(|(inner, outer)| match (inner, outer) {
+ (Some(value), Some(outer)) => Some(value.fold(outer)),
+ _ => inner.or(outer),
+ });
+ let two_sided = self.two_sided.or(outer.two_sided);
+ Margin { sides, two_sided }
+ }
+}
+
+cast! {
+ Margin,
+ self => {
+ let mut dict = Dict::new();
+ let mut handle = |key: &str, component: Value| {
+ let value = component.into_value();
+ if value != Value::None {
+ dict.insert(key.into(), value);
+ }
+ };
+
+ handle("top", self.sides.top.into_value());
+ handle("bottom", self.sides.bottom.into_value());
+ if self.two_sided.unwrap_or(false) {
+ handle("inside", self.sides.left.into_value());
+ handle("outside", self.sides.right.into_value());
+ } else {
+ handle("left", self.sides.left.into_value());
+ handle("right", self.sides.right.into_value());
+ }
+
+ Value::Dict(dict)
+ },
+ _: AutoValue => Self::splat(Some(Smart::Auto)),
+ v: Rel<Length> => Self::splat(Some(Smart::Custom(v))),
+ mut dict: Dict => {
+ let mut take = |key| dict.take(key).ok().map(Value::cast).transpose();
+
+ let rest = take("rest")?;
+ let x = take("x")?.or(rest);
+ let y = take("y")?.or(rest);
+ let top = take("top")?.or(y);
+ let bottom = take("bottom")?.or(y);
+ let outside = take("outside")?;
+ let inside = take("inside")?;
+ let left = take("left")?;
+ let right = take("right")?;
+
+ let implicitly_two_sided = outside.is_some() || inside.is_some();
+ let implicitly_not_two_sided = left.is_some() || right.is_some();
+ if implicitly_two_sided && implicitly_not_two_sided {
+ bail!("`inside` and `outside` are mutually exclusive with `left` and `right`");
+ }
+
+ // - If 'implicitly_two_sided' is false here, then
+ // 'implicitly_not_two_sided' will be guaranteed to be true
+ // due to the previous two 'if' conditions.
+ // - If both are false, this means that this margin change does not
+ // affect lateral margins, and thus shouldn't make a difference on
+ // the 'two_sided' attribute of this margin.
+ let two_sided = (implicitly_two_sided || implicitly_not_two_sided)
+ .then_some(implicitly_two_sided);
+
+ dict.finish(&[
+ "left", "top", "right", "bottom", "outside", "inside", "x", "y", "rest",
+ ])?;
+
+ Margin {
+ sides: Sides {
+ left: inside.or(left).or(x),
+ top,
+ right: outside.or(right).or(x),
+ bottom,
+ },
+ two_sided,
+ }
+ }
+}
+
+/// Specification of the page's binding.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Binding {
+ /// Bound on the left, as customary in LTR languages.
+ Left,
+ /// Bound on the right, as customary in RTL languages.
+ Right,
+}
+
+impl Binding {
+ /// Whether to swap left and right margin for the page with this number.
+ fn swap(self, number: NonZeroUsize) -> bool {
+ match self {
+ // Left-bound must swap on even pages
+ // (because it is correct on the first page).
+ Self::Left => number.get() % 2 == 0,
+ // Right-bound must swap on odd pages
+ // (because it is wrong on the first page).
+ Self::Right => number.get() % 2 == 1,
+ }
+ }
+}
+
+cast! {
+ Binding,
+ self => match self {
+ Self::Left => GenAlign::Specific(Align::Left).into_value(),
+ Self::Right => GenAlign::Specific(Align::Right).into_value(),
+ },
+ v: GenAlign => match v {
+ GenAlign::Specific(Align::Left) => Self::Left,
+ GenAlign::Specific(Align::Right) => Self::Right,
+ _ => bail!("must be `left` or `right`"),
+ },
+}
+
+/// A header, footer, foreground or background definition.
+#[derive(Debug, Clone, Hash)]
+pub enum Marginal {
+ /// Bare content.
+ Content(Content),
+ /// A closure mapping from a page number to content.
+ Func(Func),
+}
+
+impl Marginal {
+ /// Resolve the marginal based on the page number.
+ pub fn resolve(&self, vt: &mut Vt, page: usize) -> SourceResult<Content> {
+ Ok(match self {
+ Self::Content(content) => content.clone(),
+ Self::Func(func) => func.call_vt(vt, [page])?.display(),
+ })
+ }
+}
+
+cast! {
+ Marginal,
+ self => match self {
+ Self::Content(v) => v.into_value(),
+ Self::Func(v) => v.into_value(),
+ },
+ v: Content => Self::Content(v),
+ v: Func => Self::Func(v),
+}
+
+/// A manual page break.
+///
+/// Must not be used inside any containers.
+///
+/// ## Example { #example }
+/// ```example
+/// The next page contains
+/// more details on compound theory.
+/// #pagebreak()
+///
+/// == Compound Theory
+/// In 1984, the first ...
+/// ```
+///
+/// Display: Page Break
+/// Category: layout
+#[element]
+pub struct PagebreakElem {
+ /// If `{true}`, the page break is skipped if the current page is already
+ /// empty.
+ #[default(false)]
+ pub weak: bool,
+
+ /// If given, ensures that the next page will be an even/odd page, with an
+ /// empty page in between if necessary.
+ ///
+ /// ```example
+ /// #set page(height: 30pt)
+ ///
+ /// First.
+ /// #pagebreak(to: "odd")
+ /// Third.
+ /// ```
+ pub to: Option<Parity>,
+}
+
+/// Whether something should be even or odd.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Parity {
+ /// Next page will be an even page.
+ Even,
+ /// Next page will be an odd page.
+ Odd,
+}
+
+impl Parity {
+ /// Whether the given number matches the parity.
+ fn matches(self, number: usize) -> bool {
+ match self {
+ Self::Even => number % 2 == 0,
+ Self::Odd => number % 2 == 1,
+ }
+ }
+}
+
+/// Specification of a paper.
+#[derive(Debug, Copy, Clone, Hash)]
+pub struct Paper {
+ /// The name of the paper.
+ name: &'static str,
+ /// The width of the paper in millimeters.
+ width: Scalar,
+ /// The height of the paper in millimeters.
+ height: Scalar,
+}
+
+impl Paper {
+ /// The width of the paper.
+ pub fn width(self) -> Abs {
+ Abs::mm(self.width.0)
+ }
+
+ /// The height of the paper.
+ pub fn height(self) -> Abs {
+ Abs::mm(self.height.0)
+ }
+}
+
+/// Defines paper constants and a paper parsing implementation.
+macro_rules! papers {
+ ($(($var:ident: $width:expr, $height: expr, $name:literal))*) => {
+ /// Predefined papers.
+ ///
+ /// Each paper is parsable from its name in kebab-case.
+ impl Paper {
+ $(pub const $var: Self = Self {
+ name: $name,
+ width: Scalar($width),
+ height: Scalar($height),
+ };)*
+ }
+
+ impl FromStr for Paper {
+ type Err = &'static str;
+
+ fn from_str(name: &str) -> Result<Self, Self::Err> {
+ match name.to_lowercase().as_str() {
+ $($name => Ok(Self::$var),)*
+ _ => Err("unknown paper size"),
+ }
+ }
+ }
+
+ cast! {
+ Paper,
+ self => self.name.into_value(),
+ $(
+ /// Produces a paper of the respective size.
+ $name => Self::$var,
+ )*
+ }
+ };
+}
+
+// All paper sizes in mm.
+//
+// Resources:
+// - https://papersizes.io/
+// - https://en.wikipedia.org/wiki/Paper_size
+// - https://www.theedkins.co.uk/jo/units/oldunits/print.htm
+// - https://vintagepaper.co/blogs/news/traditional-paper-sizes
+papers! {
+ // ---------------------------------------------------------------------- //
+ // ISO 216 A Series
+ (A0: 841.0, 1189.0, "a0")
+ (A1: 594.0, 841.0, "a1")
+ (A2: 420.0, 594.0, "a2")
+ (A3: 297.0, 420.0, "a3")
+ (A4: 210.0, 297.0, "a4")
+ (A5: 148.0, 210.0, "a5")
+ (A6: 105.0, 148.0, "a6")
+ (A7: 74.0, 105.0, "a7")
+ (A8: 52.0, 74.0, "a8")
+ (A9: 37.0, 52.0, "a9")
+ (A10: 26.0, 37.0, "a10")
+ (A11: 18.0, 26.0, "a11")
+
+ // ISO 216 B Series
+ (ISO_B1: 707.0, 1000.0, "iso-b1")
+ (ISO_B2: 500.0, 707.0, "iso-b2")
+ (ISO_B3: 353.0, 500.0, "iso-b3")
+ (ISO_B4: 250.0, 353.0, "iso-b4")
+ (ISO_B5: 176.0, 250.0, "iso-b5")
+ (ISO_B6: 125.0, 176.0, "iso-b6")
+ (ISO_B7: 88.0, 125.0, "iso-b7")
+ (ISO_B8: 62.0, 88.0, "iso-b8")
+
+ // ISO 216 C Series
+ (ISO_C3: 324.0, 458.0, "iso-c3")
+ (ISO_C4: 229.0, 324.0, "iso-c4")
+ (ISO_C5: 162.0, 229.0, "iso-c5")
+ (ISO_C6: 114.0, 162.0, "iso-c6")
+ (ISO_C7: 81.0, 114.0, "iso-c7")
+ (ISO_C8: 57.0, 81.0, "iso-c8")
+
+ // DIN D Series (extension to ISO)
+ (DIN_D3: 272.0, 385.0, "din-d3")
+ (DIN_D4: 192.0, 272.0, "din-d4")
+ (DIN_D5: 136.0, 192.0, "din-d5")
+ (DIN_D6: 96.0, 136.0, "din-d6")
+ (DIN_D7: 68.0, 96.0, "din-d7")
+ (DIN_D8: 48.0, 68.0, "din-d8")
+
+ // SIS (used in academia)
+ (SIS_G5: 169.0, 239.0, "sis-g5")
+ (SIS_E5: 115.0, 220.0, "sis-e5")
+
+ // ANSI Extensions
+ (ANSI_A: 216.0, 279.0, "ansi-a")
+ (ANSI_B: 279.0, 432.0, "ansi-b")
+ (ANSI_C: 432.0, 559.0, "ansi-c")
+ (ANSI_D: 559.0, 864.0, "ansi-d")
+ (ANSI_E: 864.0, 1118.0, "ansi-e")
+
+ // ANSI Architectural Paper
+ (ARCH_A: 229.0, 305.0, "arch-a")
+ (ARCH_B: 305.0, 457.0, "arch-b")
+ (ARCH_C: 457.0, 610.0, "arch-c")
+ (ARCH_D: 610.0, 914.0, "arch-d")
+ (ARCH_E1: 762.0, 1067.0, "arch-e1")
+ (ARCH_E: 914.0, 1219.0, "arch-e")
+
+ // JIS B Series
+ (JIS_B0: 1030.0, 1456.0, "jis-b0")
+ (JIS_B1: 728.0, 1030.0, "jis-b1")
+ (JIS_B2: 515.0, 728.0, "jis-b2")
+ (JIS_B3: 364.0, 515.0, "jis-b3")
+ (JIS_B4: 257.0, 364.0, "jis-b4")
+ (JIS_B5: 182.0, 257.0, "jis-b5")
+ (JIS_B6: 128.0, 182.0, "jis-b6")
+ (JIS_B7: 91.0, 128.0, "jis-b7")
+ (JIS_B8: 64.0, 91.0, "jis-b8")
+ (JIS_B9: 45.0, 64.0, "jis-b9")
+ (JIS_B10: 32.0, 45.0, "jis-b10")
+ (JIS_B11: 22.0, 32.0, "jis-b11")
+
+ // SAC D Series
+ (SAC_D0: 764.0, 1064.0, "sac-d0")
+ (SAC_D1: 532.0, 760.0, "sac-d1")
+ (SAC_D2: 380.0, 528.0, "sac-d2")
+ (SAC_D3: 264.0, 376.0, "sac-d3")
+ (SAC_D4: 188.0, 260.0, "sac-d4")
+ (SAC_D5: 130.0, 184.0, "sac-d5")
+ (SAC_D6: 92.0, 126.0, "sac-d6")
+
+ // ISO 7810 ID
+ (ISO_ID_1: 85.6, 53.98, "iso-id-1")
+ (ISO_ID_2: 74.0, 105.0, "iso-id-2")
+ (ISO_ID_3: 88.0, 125.0, "iso-id-3")
+
+ // ---------------------------------------------------------------------- //
+ // Asia
+ (ASIA_F4: 210.0, 330.0, "asia-f4")
+
+ // Japan
+ (JP_SHIROKU_BAN_4: 264.0, 379.0, "jp-shiroku-ban-4")
+ (JP_SHIROKU_BAN_5: 189.0, 262.0, "jp-shiroku-ban-5")
+ (JP_SHIROKU_BAN_6: 127.0, 188.0, "jp-shiroku-ban-6")
+ (JP_KIKU_4: 227.0, 306.0, "jp-kiku-4")
+ (JP_KIKU_5: 151.0, 227.0, "jp-kiku-5")
+ (JP_BUSINESS_CARD: 91.0, 55.0, "jp-business-card")
+
+ // China
+ (CN_BUSINESS_CARD: 90.0, 54.0, "cn-business-card")
+
+ // Europe
+ (EU_BUSINESS_CARD: 85.0, 55.0, "eu-business-card")
+
+ // French Traditional (AFNOR)
+ (FR_TELLIERE: 340.0, 440.0, "fr-tellière")
+ (FR_COURONNE_ECRITURE: 360.0, 460.0, "fr-couronne-écriture")
+ (FR_COURONNE_EDITION: 370.0, 470.0, "fr-couronne-édition")
+ (FR_RAISIN: 500.0, 650.0, "fr-raisin")
+ (FR_CARRE: 450.0, 560.0, "fr-carré")
+ (FR_JESUS: 560.0, 760.0, "fr-jésus")
+
+ // United Kingdom Imperial
+ (UK_BRIEF: 406.4, 342.9, "uk-brief")
+ (UK_DRAFT: 254.0, 406.4, "uk-draft")
+ (UK_FOOLSCAP: 203.2, 330.2, "uk-foolscap")
+ (UK_QUARTO: 203.2, 254.0, "uk-quarto")
+ (UK_CROWN: 508.0, 381.0, "uk-crown")
+ (UK_BOOK_A: 111.0, 178.0, "uk-book-a")
+ (UK_BOOK_B: 129.0, 198.0, "uk-book-b")
+
+ // Unites States
+ (US_LETTER: 215.9, 279.4, "us-letter")
+ (US_LEGAL: 215.9, 355.6, "us-legal")
+ (US_TABLOID: 279.4, 431.8, "us-tabloid")
+ (US_EXECUTIVE: 84.15, 266.7, "us-executive")
+ (US_FOOLSCAP_FOLIO: 215.9, 342.9, "us-foolscap-folio")
+ (US_STATEMENT: 139.7, 215.9, "us-statement")
+ (US_LEDGER: 431.8, 279.4, "us-ledger")
+ (US_OFICIO: 215.9, 340.36, "us-oficio")
+ (US_GOV_LETTER: 203.2, 266.7, "us-gov-letter")
+ (US_GOV_LEGAL: 215.9, 330.2, "us-gov-legal")
+ (US_BUSINESS_CARD: 88.9, 50.8, "us-business-card")
+ (US_DIGEST: 139.7, 215.9, "us-digest")
+ (US_TRADE: 152.4, 228.6, "us-trade")
+
+ // ---------------------------------------------------------------------- //
+ // Other
+ (NEWSPAPER_COMPACT: 280.0, 430.0, "newspaper-compact")
+ (NEWSPAPER_BERLINER: 315.0, 470.0, "newspaper-berliner")
+ (NEWSPAPER_BROADSHEET: 381.0, 578.0, "newspaper-broadsheet")
+ (PRESENTATION_16_9: 297.0, 167.0625, "presentation-16-9")
+ (PRESENTATION_4_3: 280.0, 210.0, "presentation-4-3")
+}
diff --git a/crates/typst-library/src/layout/par.rs b/crates/typst-library/src/layout/par.rs
new file mode 100644
index 00000000..6b914e80
--- /dev/null
+++ b/crates/typst-library/src/layout/par.rs
@@ -0,0 +1,1566 @@
+use icu_properties::{maps::CodePointMapData, LineBreak};
+use icu_provider::AsDeserializingBufferProvider;
+use icu_provider_adapters::fork::ForkByKeyProvider;
+use icu_provider_blob::BlobDataProvider;
+use icu_segmenter::{LineBreakIteratorUtf8, LineSegmenter};
+use once_cell::sync::Lazy;
+use typst::eval::Tracer;
+use typst::model::DelayedErrors;
+use unicode_bidi::{BidiInfo, Level as BidiLevel};
+use unicode_script::{Script, UnicodeScript};
+
+use super::{BoxElem, HElem, Sizing, Spacing};
+use crate::layout::AlignElem;
+use crate::math::EquationElem;
+use crate::prelude::*;
+use crate::text::{
+ is_gb_style, shape, LinebreakElem, Quoter, Quotes, ShapedText, SmartQuoteElem,
+ SpaceElem, TextElem,
+};
+
+/// Arranges text, spacing and inline-level elements into a paragraph.
+///
+/// Although this function is primarily used in set rules to affect paragraph
+/// properties, it can also be used to explicitly render its argument onto a
+/// paragraph of its own.
+///
+/// ## Example { #example }
+/// ```example
+/// #show par: set block(spacing: 0.65em)
+/// #set par(
+/// first-line-indent: 1em,
+/// justify: true,
+/// )
+///
+/// We proceed by contradiction.
+/// Suppose that there exists a set
+/// of positive integers $a$, $b$, and
+/// $c$ that satisfies the equation
+/// $a^n + b^n = c^n$ for some
+/// integer value of $n > 2$.
+///
+/// Without loss of generality,
+/// let $a$ be the smallest of the
+/// three integers. Then, we ...
+/// ```
+///
+/// Display: Paragraph
+/// Category: layout
+#[element(Construct)]
+pub struct ParElem {
+ /// The spacing between lines.
+ #[resolve]
+ #[default(Em::new(0.65).into())]
+ pub leading: Length,
+
+ /// Whether to justify text in its line.
+ ///
+ /// Hyphenation will be enabled for justified paragraphs if the [text
+ /// property hyphenate]($func/text.hyphenate) is set to `{auto}` and the
+ /// current language is known.
+ ///
+ /// Note that the current [alignment]($func/align) still has an effect on
+ /// the placement of the last line except if it ends with a [justified line
+ /// break]($func/linebreak.justify).
+ #[default(false)]
+ pub justify: bool,
+
+ /// How to determine line breaks.
+ ///
+ /// When this property is set to `{auto}`, its default value, optimized line
+ /// breaks will be used for justified paragraphs. Enabling optimized line
+ /// breaks for ragged paragraphs may also be worthwhile to improve the
+ /// appearance of the text.
+ ///
+ /// ```example
+ /// #set page(width: 190pt)
+ /// #set par(linebreaks: "simple")
+ /// Some texts are frustratingly
+ /// challenging to break in a
+ /// visually pleasing way. This
+ /// very aesthetic example is one
+ /// of them.
+ ///
+ /// #set par(linebreaks: "optimized")
+ /// Some texts are frustratingly
+ /// challenging to break in a
+ /// visually pleasing way. This
+ /// very aesthetic example is one
+ /// of them.
+ /// ```
+ #[default]
+ pub linebreaks: Smart<Linebreaks>,
+
+ /// The indent the first line of a paragraph should have.
+ ///
+ /// Only the first line of a consecutive paragraph will be indented (not
+ /// the first one in a block or on the page).
+ ///
+ /// By typographic convention, paragraph breaks are indicated either by some
+ /// space between paragraphs or by indented first lines. Consider reducing
+ /// the [paragraph spacing]($func/block.spacing) to the [`leading`] when
+ /// using this property (e.g. using
+ /// `[#show par: set block(spacing: 0.65em)]`).
+ pub first_line_indent: Length,
+
+ /// The indent all but the first line of a paragraph should have.
+ #[resolve]
+ pub hanging_indent: Length,
+
+ /// The contents of the paragraph.
+ #[external]
+ #[required]
+ pub body: Content,
+
+ /// The paragraph's children.
+ #[internal]
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Construct for ParElem {
+ fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> {
+ // The paragraph constructor is special: It doesn't create a paragraph
+ // element. Instead, it just ensures that the passed content lives in a
+ // separate paragraph and styles it.
+ let styles = Self::set(args)?;
+ let body = args.expect::<Content>("body")?;
+ Ok(Content::sequence([
+ ParbreakElem::new().pack(),
+ body.styled_with_map(styles),
+ ParbreakElem::new().pack(),
+ ]))
+ }
+}
+
+impl ParElem {
+ /// Layout the paragraph into a collection of lines.
+ #[tracing::instrument(name = "ParElement::layout", skip_all)]
+ pub fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ consecutive: bool,
+ region: Size,
+ expand: bool,
+ ) -> SourceResult<Fragment> {
+ #[comemo::memoize]
+ #[allow(clippy::too_many_arguments)]
+ fn cached(
+ par: &ParElem,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ styles: StyleChain,
+ consecutive: bool,
+ region: Size,
+ expand: bool,
+ ) -> SourceResult<Fragment> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let children = par.children();
+
+ // Collect all text into one string for BiDi analysis.
+ let (text, segments, spans) = collect(&children, &styles, consecutive)?;
+
+ // Perform BiDi analysis and then prepare paragraph layout by building a
+ // representation on which we can do line breaking without layouting
+ // each and every line from scratch.
+ let p = prepare(&mut vt, &children, &text, segments, spans, styles, region)?;
+
+ // Break the paragraph into lines.
+ let lines = linebreak(&vt, &p, region.x - p.hang);
+
+ // Stack the lines into one frame per region.
+ finalize(&mut vt, &p, &lines, region, expand)
+ }
+
+ let fragment = cached(
+ self,
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ styles,
+ consecutive,
+ region,
+ expand,
+ )?;
+
+ vt.locator.visit_frames(&fragment);
+ Ok(fragment)
+ }
+}
+
+/// How to determine line breaks in a paragraph.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Linebreaks {
+ /// Determine the line breaks in a simple first-fit style.
+ Simple,
+ /// Optimize the line breaks for the whole paragraph.
+ ///
+ /// Typst will try to produce more evenly filled lines of text by
+ /// considering the whole paragraph when calculating line breaks.
+ Optimized,
+}
+
+/// A paragraph break.
+///
+/// This starts a new paragraph. Especially useful when used within code like
+/// [for loops]($scripting/#loops). Multiple consecutive
+/// paragraph breaks collapse into a single one.
+///
+/// ## Example { #example }
+/// ```example
+/// #for i in range(3) {
+/// [Blind text #i: ]
+/// lorem(5)
+/// parbreak()
+/// }
+/// ```
+///
+/// ## Syntax { #syntax }
+/// Instead of calling this function, you can insert a blank line into your
+/// markup to create a paragraph break.
+///
+/// Display: Paragraph Break
+/// Category: layout
+#[element(Unlabellable)]
+pub struct ParbreakElem {}
+
+impl Unlabellable for ParbreakElem {}
+
+/// Range of a substring of text.
+type Range = std::ops::Range<usize>;
+
+// The characters by which spacing, inline content and pins are replaced in the
+// paragraph's full text.
+const SPACING_REPLACE: char = ' '; // Space
+const OBJ_REPLACE: char = '\u{FFFC}'; // Object Replacement Character
+
+/// A paragraph representation in which children are already layouted and text
+/// is already preshaped.
+///
+/// In many cases, we can directly reuse these results when constructing a line.
+/// Only when a line break falls onto a text index that is not safe-to-break per
+/// rustybuzz, we have to reshape that portion.
+struct Preparation<'a> {
+ /// Bidirectional text embedding levels for the paragraph.
+ bidi: BidiInfo<'a>,
+ /// Text runs, spacing and layouted elements.
+ items: Vec<Item<'a>>,
+ /// The span mapper.
+ spans: SpanMapper,
+ /// The styles shared by all children.
+ styles: StyleChain<'a>,
+ /// Whether to hyphenate if it's the same for all children.
+ hyphenate: Option<bool>,
+ /// The text language if it's the same for all children.
+ lang: Option<Lang>,
+ /// The paragraph's resolved alignment.
+ align: Align,
+ /// Whether to justify the paragraph.
+ justify: bool,
+ /// The paragraph's hanging indent.
+ hang: Abs,
+}
+
+impl<'a> Preparation<'a> {
+ /// Find the item that contains the given `text_offset`.
+ fn find(&self, text_offset: usize) -> Option<&Item<'a>> {
+ let mut cursor = 0;
+ for item in &self.items {
+ let end = cursor + item.len();
+ if (cursor..end).contains(&text_offset) {
+ return Some(item);
+ }
+ cursor = end;
+ }
+ None
+ }
+
+ /// Return the items that intersect the given `text_range`.
+ ///
+ /// Returns the expanded range around the items and the items.
+ fn slice(&self, text_range: Range) -> (Range, &[Item<'a>]) {
+ let mut cursor = 0;
+ let mut start = 0;
+ let mut end = 0;
+ let mut expanded = text_range.clone();
+
+ for (i, item) in self.items.iter().enumerate() {
+ if cursor <= text_range.start {
+ start = i;
+ expanded.start = cursor;
+ }
+
+ let len = item.len();
+ if cursor < text_range.end || cursor + len <= text_range.end {
+ end = i + 1;
+ expanded.end = cursor + len;
+ } else {
+ break;
+ }
+
+ cursor += len;
+ }
+
+ (expanded, &self.items[start..end])
+ }
+}
+
+/// A segment of one or multiple collapsed children.
+#[derive(Debug, Copy, Clone)]
+enum Segment<'a> {
+ /// One or multiple collapsed text or text-equivalent children. Stores how
+ /// long the segment is (in bytes of the full text string).
+ Text(usize),
+ /// Horizontal spacing between other segments.
+ Spacing(Spacing),
+ /// A mathematical equation.
+ Equation(&'a EquationElem),
+ /// A box with arbitrary content.
+ Box(&'a BoxElem, bool),
+ /// Metadata.
+ Meta,
+}
+
+impl Segment<'_> {
+ /// The text length of the item.
+ fn len(&self) -> usize {
+ match *self {
+ Self::Text(len) => len,
+ Self::Spacing(_) => SPACING_REPLACE.len_utf8(),
+ Self::Box(_, true) => SPACING_REPLACE.len_utf8(),
+ Self::Equation(_) | Self::Box(_, _) => OBJ_REPLACE.len_utf8(),
+ Self::Meta => 0,
+ }
+ }
+}
+
+/// A prepared item in a paragraph layout.
+#[derive(Debug)]
+enum Item<'a> {
+ /// A shaped text run with consistent style and direction.
+ Text(ShapedText<'a>),
+ /// Absolute spacing between other items.
+ Absolute(Abs),
+ /// Fractional spacing between other items.
+ Fractional(Fr, Option<(&'a BoxElem, StyleChain<'a>)>),
+ /// Layouted inline-level content.
+ Frame(Frame),
+ /// Metadata.
+ Meta(Frame),
+}
+
+impl<'a> Item<'a> {
+ /// If this a text item, return it.
+ fn text(&self) -> Option<&ShapedText<'a>> {
+ match self {
+ Self::Text(shaped) => Some(shaped),
+ _ => None,
+ }
+ }
+
+ fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> {
+ match self {
+ Self::Text(shaped) => Some(shaped),
+ _ => None,
+ }
+ }
+
+ /// The text length of the item.
+ fn len(&self) -> usize {
+ match self {
+ Self::Text(shaped) => shaped.text.len(),
+ Self::Absolute(_) | Self::Fractional(_, _) => SPACING_REPLACE.len_utf8(),
+ Self::Frame(_) => OBJ_REPLACE.len_utf8(),
+ Self::Meta(_) => 0,
+ }
+ }
+
+ /// The natural layouted width of the item.
+ fn width(&self) -> Abs {
+ match self {
+ Self::Text(shaped) => shaped.width,
+ Self::Absolute(v) => *v,
+ Self::Frame(frame) => frame.width(),
+ Self::Fractional(_, _) | Self::Meta(_) => Abs::zero(),
+ }
+ }
+}
+
+/// Maps byte offsets back to spans.
+#[derive(Default)]
+pub struct SpanMapper(Vec<(usize, Span)>);
+
+impl SpanMapper {
+ /// Create a new span mapper.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Push a span for a segment with the given length.
+ pub fn push(&mut self, len: usize, span: Span) {
+ self.0.push((len, span));
+ }
+
+ /// Determine the span at the given byte offset.
+ ///
+ /// May return a detached span.
+ pub fn span_at(&self, offset: usize) -> (Span, u16) {
+ let mut cursor = 0;
+ for &(len, span) in &self.0 {
+ if (cursor..=cursor + len).contains(&offset) {
+ return (span, u16::try_from(offset - cursor).unwrap_or(0));
+ }
+ cursor += len;
+ }
+ (Span::detached(), 0)
+ }
+}
+
+/// A layouted line, consisting of a sequence of layouted paragraph items that
+/// are mostly borrowed from the preparation phase. This type enables you to
+/// measure the size of a line in a range before committing to building the
+/// line's frame.
+///
+/// At most two paragraph items must be created individually for this line: The
+/// first and last one since they may be broken apart by the start or end of the
+/// line, respectively. But even those can partially reuse previous results when
+/// the break index is safe-to-break per rustybuzz.
+struct Line<'a> {
+ /// Bidi information about the paragraph.
+ bidi: &'a BidiInfo<'a>,
+ /// The trimmed range the line spans in the paragraph.
+ trimmed: Range,
+ /// The untrimmed end where the line ends.
+ end: usize,
+ /// A reshaped text item if the line sliced up a text item at the start.
+ first: Option<Item<'a>>,
+ /// Inner items which don't need to be reprocessed.
+ inner: &'a [Item<'a>],
+ /// A reshaped text item if the line sliced up a text item at the end. If
+ /// there is only one text item, this takes precedence over `first`.
+ last: Option<Item<'a>>,
+ /// The width of the line.
+ width: Abs,
+ /// Whether the line should be justified.
+ justify: bool,
+ /// Whether the line ends with a hyphen or dash, either naturally or through
+ /// hyphenation.
+ dash: bool,
+}
+
+impl<'a> Line<'a> {
+ /// Iterate over the line's items.
+ fn items(&self) -> impl Iterator<Item = &Item<'a>> {
+ self.first.iter().chain(self.inner).chain(&self.last)
+ }
+
+ /// Return items that intersect the given `text_range`.
+ fn slice(&self, text_range: Range) -> impl Iterator<Item = &Item<'a>> {
+ let mut cursor = self.trimmed.start;
+ let mut start = 0;
+ let mut end = 0;
+
+ for (i, item) in self.items().enumerate() {
+ if cursor <= text_range.start {
+ start = i;
+ }
+
+ let len = item.len();
+ if cursor < text_range.end || cursor + len <= text_range.end {
+ end = i + 1;
+ } else {
+ break;
+ }
+
+ cursor += len;
+ }
+
+ self.items().skip(start).take(end - start)
+ }
+
+ /// How many glyphs are in the text where we can insert additional
+ /// space when encountering underfull lines.
+ fn justifiables(&self) -> usize {
+ let mut count = 0;
+ for shaped in self.items().filter_map(Item::text) {
+ count += shaped.justifiables();
+ }
+ // CJK character at line end should not be adjusted.
+ if self
+ .items()
+ .last()
+ .and_then(Item::text)
+ .map(|s| s.cjk_justifiable_at_last())
+ .unwrap_or(false)
+ {
+ count -= 1;
+ }
+
+ count
+ }
+
+ /// How much can the line stretch
+ fn stretchability(&self) -> Abs {
+ self.items().filter_map(Item::text).map(|s| s.stretchability()).sum()
+ }
+
+ /// How much can the line shrink
+ fn shrinkability(&self) -> Abs {
+ self.items().filter_map(Item::text).map(|s| s.shrinkability()).sum()
+ }
+
+ /// The sum of fractions in the line.
+ fn fr(&self) -> Fr {
+ self.items()
+ .filter_map(|item| match item {
+ Item::Fractional(fr, _) => Some(*fr),
+ _ => None,
+ })
+ .sum()
+ }
+}
+
+/// Collect all text of the paragraph into one string. This also performs
+/// string-level preprocessing like case transformations.
+#[allow(clippy::type_complexity)]
+fn collect<'a>(
+ children: &'a [Content],
+ styles: &'a StyleChain<'a>,
+ consecutive: bool,
+) -> SourceResult<(String, Vec<(Segment<'a>, StyleChain<'a>)>, SpanMapper)> {
+ let mut full = String::new();
+ let mut quoter = Quoter::new();
+ let mut segments = vec![];
+ let mut spans = SpanMapper::new();
+ let mut iter = children.iter().peekable();
+
+ let first_line_indent = ParElem::first_line_indent_in(*styles);
+ if !first_line_indent.is_zero()
+ && consecutive
+ && AlignElem::alignment_in(*styles).x.resolve(*styles)
+ == TextElem::dir_in(*styles).start().into()
+ {
+ full.push(SPACING_REPLACE);
+ segments.push((Segment::Spacing(first_line_indent.into()), *styles));
+ }
+
+ let hang = ParElem::hanging_indent_in(*styles);
+ if !hang.is_zero() {
+ full.push(SPACING_REPLACE);
+ segments.push((Segment::Spacing((-hang).into()), *styles));
+ }
+
+ while let Some(mut child) = iter.next() {
+ let outer = styles;
+ let mut styles = *styles;
+ if let Some((elem, local)) = child.to_styled() {
+ child = elem;
+ styles = outer.chain(local);
+ }
+
+ let segment = if child.is::<SpaceElem>() {
+ full.push(' ');
+ Segment::Text(1)
+ } else if let Some(elem) = child.to::<TextElem>() {
+ let prev = full.len();
+ if let Some(case) = TextElem::case_in(styles) {
+ full.push_str(&case.apply(&elem.text()));
+ } else {
+ full.push_str(&elem.text());
+ }
+ Segment::Text(full.len() - prev)
+ } else if let Some(elem) = child.to::<HElem>() {
+ if elem.amount().is_zero() {
+ continue;
+ }
+
+ full.push(SPACING_REPLACE);
+ Segment::Spacing(elem.amount())
+ } else if let Some(elem) = child.to::<LinebreakElem>() {
+ let c = if elem.justify(styles) { '\u{2028}' } else { '\n' };
+ full.push(c);
+ Segment::Text(c.len_utf8())
+ } else if let Some(elem) = child.to::<SmartQuoteElem>() {
+ let prev = full.len();
+ if SmartQuoteElem::enabled_in(styles) {
+ let lang = TextElem::lang_in(styles);
+ let region = TextElem::region_in(styles);
+ let quotes = Quotes::from_lang(lang, region);
+ let peeked = iter.peek().and_then(|child| {
+ let child = if let Some((child, _)) = child.to_styled() {
+ child
+ } else {
+ child
+ };
+ if let Some(elem) = child.to::<TextElem>() {
+ elem.text().chars().next()
+ } else if child.is::<SmartQuoteElem>() {
+ Some('"')
+ } else if child.is::<SpaceElem>()
+ || child.is::<HElem>()
+ || child.is::<LinebreakElem>()
+ {
+ Some(SPACING_REPLACE)
+ } else {
+ Some(OBJ_REPLACE)
+ }
+ });
+
+ full.push_str(quoter.quote(&quotes, elem.double(styles), peeked));
+ } else {
+ full.push(if elem.double(styles) { '"' } else { '\'' });
+ }
+ Segment::Text(full.len() - prev)
+ } else if let Some(elem) = child.to::<EquationElem>() {
+ full.push(OBJ_REPLACE);
+ Segment::Equation(elem)
+ } else if let Some(elem) = child.to::<BoxElem>() {
+ let frac = elem.width(styles).is_fractional();
+ full.push(if frac { SPACING_REPLACE } else { OBJ_REPLACE });
+ Segment::Box(elem, frac)
+ } else if child.is::<MetaElem>() {
+ Segment::Meta
+ } else {
+ bail!(child.span(), "unexpected paragraph child");
+ };
+
+ if let Some(last) = full.chars().last() {
+ quoter.last(last);
+ }
+
+ spans.push(segment.len(), child.span());
+
+ if let (Some((Segment::Text(last_len), last_styles)), Segment::Text(len)) =
+ (segments.last_mut(), segment)
+ {
+ if *last_styles == styles {
+ *last_len += len;
+ continue;
+ }
+ }
+
+ segments.push((segment, styles));
+ }
+
+ Ok((full, segments, spans))
+}
+
+/// Prepare paragraph layout by shaping the whole paragraph and layouting all
+/// contained inline-level content.
+fn prepare<'a>(
+ vt: &mut Vt,
+ children: &'a [Content],
+ text: &'a str,
+ segments: Vec<(Segment<'a>, StyleChain<'a>)>,
+ spans: SpanMapper,
+ styles: StyleChain<'a>,
+ region: Size,
+) -> SourceResult<Preparation<'a>> {
+ let bidi = BidiInfo::new(
+ text,
+ match TextElem::dir_in(styles) {
+ Dir::LTR => Some(BidiLevel::ltr()),
+ Dir::RTL => Some(BidiLevel::rtl()),
+ _ => None,
+ },
+ );
+
+ let mut cursor = 0;
+ let mut items = vec![];
+
+ // Shape / layout the children and collect them into items.
+ for (segment, styles) in segments {
+ let end = cursor + segment.len();
+ match segment {
+ Segment::Text(_) => {
+ shape_range(&mut items, vt, &bidi, cursor..end, &spans, styles);
+ }
+ Segment::Spacing(spacing) => match spacing {
+ Spacing::Rel(v) => {
+ let resolved = v.resolve(styles).relative_to(region.x);
+ items.push(Item::Absolute(resolved));
+ }
+ Spacing::Fr(v) => {
+ items.push(Item::Fractional(v, None));
+ }
+ },
+ Segment::Equation(equation) => {
+ let pod = Regions::one(region, Axes::splat(false));
+ let mut frame = equation.layout(vt, styles, pod)?.into_frame();
+ frame.translate(Point::with_y(TextElem::baseline_in(styles)));
+ items.push(Item::Frame(frame));
+ }
+ Segment::Box(elem, _) => {
+ if let Sizing::Fr(v) = elem.width(styles) {
+ items.push(Item::Fractional(v, Some((elem, styles))));
+ } else {
+ let pod = Regions::one(region, Axes::splat(false));
+ let mut frame = elem.layout(vt, styles, pod)?.into_frame();
+ frame.translate(Point::with_y(TextElem::baseline_in(styles)));
+ items.push(Item::Frame(frame));
+ }
+ }
+ Segment::Meta => {
+ let mut frame = Frame::new(Size::zero());
+ frame.meta(styles, true);
+ items.push(Item::Meta(frame));
+ }
+ }
+
+ cursor = end;
+ }
+
+ Ok(Preparation {
+ bidi,
+ items,
+ spans,
+ styles,
+ hyphenate: shared_get(styles, children, TextElem::hyphenate_in),
+ lang: shared_get(styles, children, TextElem::lang_in),
+ align: AlignElem::alignment_in(styles).x.resolve(styles),
+ justify: ParElem::justify_in(styles),
+ hang: ParElem::hanging_indent_in(styles),
+ })
+}
+
+/// Group a range of text by BiDi level and script, shape the runs and generate
+/// items for them.
+fn shape_range<'a>(
+ items: &mut Vec<Item<'a>>,
+ vt: &Vt,
+ bidi: &BidiInfo<'a>,
+ range: Range,
+ spans: &SpanMapper,
+ styles: StyleChain<'a>,
+) {
+ let lang = TextElem::lang_in(styles);
+ let region = TextElem::region_in(styles);
+ let mut process = |range: Range, level: BidiLevel| {
+ let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL };
+ let shaped =
+ shape(vt, range.start, &bidi.text[range], spans, styles, dir, lang, region);
+ items.push(Item::Text(shaped));
+ };
+
+ let mut prev_level = BidiLevel::ltr();
+ let mut prev_script = Script::Unknown;
+ let mut cursor = range.start;
+
+ // Group by embedding level and script.
+ for i in range.clone() {
+ if !bidi.text.is_char_boundary(i) {
+ continue;
+ }
+
+ let level = bidi.levels[i];
+ let script =
+ bidi.text[i..].chars().next().map_or(Script::Unknown, |c| c.script());
+
+ if level != prev_level || !is_compatible(script, prev_script) {
+ if cursor < i {
+ process(cursor..i, prev_level);
+ }
+ cursor = i;
+ prev_level = level;
+ prev_script = script;
+ } else if is_generic_script(prev_script) {
+ prev_script = script;
+ }
+ }
+
+ process(cursor..range.end, prev_level);
+}
+
+/// Whether this is not a specific script.
+fn is_generic_script(script: Script) -> bool {
+ matches!(script, Script::Unknown | Script::Common | Script::Inherited)
+}
+
+/// Whether these script can be part of the same shape run.
+fn is_compatible(a: Script, b: Script) -> bool {
+ is_generic_script(a) || is_generic_script(b) || a == b
+}
+
+/// Get a style property, but only if it is the same for all children of the
+/// paragraph.
+fn shared_get<T: PartialEq>(
+ styles: StyleChain<'_>,
+ children: &[Content],
+ getter: fn(StyleChain) -> T,
+) -> Option<T> {
+ let value = getter(styles);
+ children
+ .iter()
+ .filter_map(|child| child.to_styled())
+ .all(|(_, local)| getter(styles.chain(local)) == value)
+ .then_some(value)
+}
+
+/// Find suitable linebreaks.
+fn linebreak<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ let linebreaks = ParElem::linebreaks_in(p.styles).unwrap_or_else(|| {
+ if ParElem::justify_in(p.styles) {
+ Linebreaks::Optimized
+ } else {
+ Linebreaks::Simple
+ }
+ });
+
+ match linebreaks {
+ Linebreaks::Simple => linebreak_simple(vt, p, width),
+ Linebreaks::Optimized => linebreak_optimized(vt, p, width),
+ }
+}
+
+/// Perform line breaking in simple first-fit style. This means that we build
+/// lines greedily, always taking the longest possible line. This may lead to
+/// very unbalanced line, but is fast and simple.
+fn linebreak_simple<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ let mut lines = vec![];
+ let mut start = 0;
+ let mut last = None;
+
+ for (end, mandatory, hyphen) in breakpoints(p) {
+ // Compute the line and its size.
+ let mut attempt = line(vt, p, start..end, mandatory, hyphen);
+
+ // If the line doesn't fit anymore, we push the last fitting attempt
+ // into the stack and rebuild the line from the attempt's end. The
+ // resulting line cannot be broken up further.
+ if !width.fits(attempt.width) {
+ if let Some((last_attempt, last_end)) = last.take() {
+ lines.push(last_attempt);
+ start = last_end;
+ attempt = line(vt, p, start..end, mandatory, hyphen);
+ }
+ }
+
+ // Finish the current line if there is a mandatory line break (i.e.
+ // due to "\n") or if the line doesn't fit horizontally already
+ // since then no shorter line will be possible.
+ if mandatory || !width.fits(attempt.width) {
+ lines.push(attempt);
+ start = end;
+ last = None;
+ } else {
+ last = Some((attempt, end));
+ }
+ }
+
+ if let Some((line, _)) = last {
+ lines.push(line);
+ }
+
+ lines
+}
+
+/// Perform line breaking in optimized Knuth-Plass style. Here, we use more
+/// context to determine the line breaks than in the simple first-fit style. For
+/// example, we might choose to cut a line short even though there is still a
+/// bit of space to improve the fit of one of the following lines. The
+/// Knuth-Plass algorithm is based on the idea of "cost". A line which has a
+/// very tight or very loose fit has a higher cost than one that is just right.
+/// Ending a line with a hyphen incurs extra cost and endings two successive
+/// lines with hyphens even more.
+///
+/// To find the layout with the minimal total cost the algorithm uses dynamic
+/// programming: For each possible breakpoint it determines the optimal
+/// paragraph layout _up to that point_. It walks over all possible start points
+/// for a line ending at that point and finds the one for which the cost of the
+/// line plus the cost of the optimal paragraph up to the start point (already
+/// computed and stored in dynamic programming table) is minimal. The final
+/// result is simply the layout determined for the last breakpoint at the end of
+/// text.
+fn linebreak_optimized<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ /// The cost of a line or paragraph layout.
+ type Cost = f64;
+
+ /// An entry in the dynamic programming table.
+ struct Entry<'a> {
+ pred: usize,
+ total: Cost,
+ line: Line<'a>,
+ }
+
+ // Cost parameters.
+ const HYPH_COST: Cost = 0.5;
+ const CONSECUTIVE_DASH_COST: Cost = 300.0;
+ const MAX_COST: Cost = 1_000_000.0;
+ const MIN_RATIO: f64 = -1.0;
+
+ // Dynamic programming table.
+ let mut active = 0;
+ let mut table = vec![Entry {
+ pred: 0,
+ total: 0.0,
+ line: line(vt, p, 0..0, false, false),
+ }];
+
+ let em = TextElem::size_in(p.styles);
+
+ for (end, mandatory, hyphen) in breakpoints(p) {
+ let k = table.len();
+ let eof = end == p.bidi.text.len();
+ let mut best: Option<Entry> = None;
+
+ // Find the optimal predecessor.
+ for (i, pred) in table.iter_mut().enumerate().skip(active) {
+ // Layout the line.
+ let start = pred.line.end;
+ let attempt = line(vt, p, start..end, mandatory, hyphen);
+
+ // Determine how much the line's spaces would need to be stretched
+ // to make it the desired width.
+ let delta = width - attempt.width;
+ // Determine how much stretch are permitted.
+ let adjust = if delta >= Abs::zero() {
+ attempt.stretchability()
+ } else {
+ attempt.shrinkability()
+ };
+ // Ideally, the ratio should between -1.0 and 1.0, but sometimes a value above 1.0
+ // is possible, in which case the line is underfull.
+ let mut ratio = delta / adjust;
+ if ratio.is_nan() {
+ // The line is not stretchable, but it just fits.
+ // This often happens with monospace fonts and CJK texts.
+ ratio = 0.0;
+ }
+ if ratio > 1.0 {
+ // We should stretch the line above its stretchability. Now calculate the extra amount.
+ let extra_stretch = (delta - adjust) / attempt.justifiables() as f64;
+ // Normalize the amount by half Em size.
+ ratio = 1.0 + extra_stretch / (em / 2.0);
+ }
+
+ // Determine the cost of the line.
+ let min_ratio = if p.justify { MIN_RATIO } else { 0.0 };
+ let mut cost = if ratio < min_ratio {
+ // The line is overfull. This is the case if
+ // - justification is on, but we'd need to shrink too much
+ // - justification is off and the line just doesn't fit
+ // Since any longer line will also be overfull, we can deactivate
+ // this breakpoint.
+ active = i + 1;
+ MAX_COST
+ } else if mandatory || eof {
+ // This is a mandatory break and the line is not overfull, so
+ // all breakpoints before this one become inactive since no line
+ // can span above the mandatory break.
+ active = k;
+ // If ratio > 0, we need to stretch the line only when justify is needed.
+ // If ratio < 0, we always need to shrink the line.
+ if (ratio > 0.0 && attempt.justify) || ratio < 0.0 {
+ ratio.powi(3).abs()
+ } else {
+ 0.0
+ }
+ } else {
+ // Normal line with cost of |ratio^3|.
+ ratio.powi(3).abs()
+ };
+
+ // Penalize hyphens.
+ if hyphen {
+ cost += HYPH_COST;
+ }
+
+ // In Knuth paper, cost = (1 + 100|r|^3 + p)^2 + a,
+ // where r is the ratio, p=50 is penaty, and a=3000 is consecutive penaty.
+ // We divide the whole formula by 10, resulting (0.01 + |r|^3 + p)^2 + a,
+ // where p=0.5 and a=300
+ cost = (0.01 + cost).powi(2);
+
+ // Penalize two consecutive dashes (not necessarily hyphens) extra.
+ if attempt.dash && pred.line.dash {
+ cost += CONSECUTIVE_DASH_COST;
+ }
+
+ // The total cost of this line and its chain of predecessors.
+ let total = pred.total + cost;
+
+ // If this attempt is better than what we had before, take it!
+ if best.as_ref().map_or(true, |best| best.total >= total) {
+ best = Some(Entry { pred: i, total, line: attempt });
+ }
+ }
+
+ table.push(best.unwrap());
+ }
+
+ // Retrace the best path.
+ let mut lines = vec![];
+ let mut idx = table.len() - 1;
+ while idx != 0 {
+ table.truncate(idx + 1);
+ let entry = table.pop().unwrap();
+ lines.push(entry.line);
+ idx = entry.pred;
+ }
+
+ lines.reverse();
+ lines
+}
+
+/// Generated by the following command:
+///
+/// ```sh
+/// icu4x-datagen --locales full --keys-for-bin target/debug/typst \
+/// --format blob --out library/assets/icudata.postcard --overwrite
+/// ```
+///
+/// Install icu4x-datagen with `cargo install icu4x-datagen`.
+static ICU_DATA: &[u8] = include_bytes!("../../assets/icudata.postcard");
+
+/// Generated by the following command:
+///
+/// ```sh
+/// icu4x-datagen --locales zh ja --keys segmenter/line@1 --format blob \
+/// --out library/assets/cj_linebreak_data.postcard --overwrite
+/// ```
+///
+/// The used icu4x-datagen should be patched by
+/// https://github.com/peng1999/icu4x/commit/b9beb6cbf633d61fc3d7983e5baf7f4449fbfae5
+static CJ_LINEBREAK_DATA: &[u8] =
+ include_bytes!("../../assets/cj_linebreak_data.postcard");
+
+/// The general line break segmenter.
+static SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| {
+ let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap();
+ LineSegmenter::try_new_lstm_with_buffer_provider(&provider).unwrap()
+});
+
+/// The Unicode line break properties for each code point.
+static CJ_SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| {
+ let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap();
+ let cj_blob = BlobDataProvider::try_new_from_static_blob(CJ_LINEBREAK_DATA).unwrap();
+ let cj_provider = ForkByKeyProvider::new(cj_blob, provider);
+ LineSegmenter::try_new_lstm_with_buffer_provider(&cj_provider).unwrap()
+});
+
+/// The line break segmenter for Chinese/Jpanese text.
+static LINEBREAK_DATA: Lazy<CodePointMapData<LineBreak>> = Lazy::new(|| {
+ let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap();
+ let deser_provider = provider.as_deserializing();
+ icu_properties::maps::load_line_break(&deser_provider).unwrap()
+});
+
+/// Determine all possible points in the text where lines can broken.
+///
+/// Returns for each breakpoint the text index, whether the break is mandatory
+/// (after `\n`) and whether a hyphen is required (when breaking inside of a
+/// word).
+fn breakpoints<'a>(p: &'a Preparation<'a>) -> Breakpoints<'a> {
+ let mut linebreaks = if matches!(p.lang, Some(Lang::CHINESE | Lang::JAPANESE)) {
+ CJ_SEGMENTER.segment_str(p.bidi.text)
+ } else {
+ SEGMENTER.segment_str(p.bidi.text)
+ };
+ // The iterator always yields a breakpoint at index 0, we want to ignore it
+ linebreaks.next();
+ Breakpoints {
+ p,
+ linebreaks,
+ syllables: None,
+ offset: 0,
+ suffix: 0,
+ end: 0,
+ mandatory: false,
+ }
+}
+
+/// An iterator over the line break opportunities in a text.
+struct Breakpoints<'a> {
+ /// The paragraph's items.
+ p: &'a Preparation<'a>,
+ /// The inner iterator over the unicode line break opportunities.
+ linebreaks: LineBreakIteratorUtf8<'a, 'a>,
+ /// Iterator over syllables of the current word.
+ syllables: Option<hypher::Syllables<'a>>,
+ /// The current text offset.
+ offset: usize,
+ /// The trimmed end of the current word.
+ suffix: usize,
+ /// The untrimmed end of the current word.
+ end: usize,
+ /// Whether the break after the current word is mandatory.
+ mandatory: bool,
+}
+
+impl Iterator for Breakpoints<'_> {
+ type Item = (usize, bool, bool);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ // If we're currently in a hyphenated "word", process the next syllable.
+ if let Some(syllable) = self.syllables.as_mut().and_then(Iterator::next) {
+ self.offset += syllable.len();
+ if self.offset == self.suffix {
+ self.offset = self.end;
+ }
+
+ // Filter out hyphenation opportunities where hyphenation was
+ // actually disabled.
+ let hyphen = self.offset < self.end;
+ if hyphen && !self.hyphenate(self.offset) {
+ return self.next();
+ }
+
+ return Some((self.offset, self.mandatory && !hyphen, hyphen));
+ }
+
+ let lb = LINEBREAK_DATA.as_borrowed();
+
+ // Get the next "word".
+ self.end = self.linebreaks.next()?;
+ self.mandatory =
+ self.p.bidi.text[..self.end].chars().next_back().map_or(false, |c| {
+ matches!(
+ lb.get(c),
+ LineBreak::MandatoryBreak
+ | LineBreak::CarriageReturn
+ | LineBreak::LineFeed
+ | LineBreak::NextLine
+ ) || self.end == self.p.bidi.text.len()
+ });
+
+ // Hyphenate the next word.
+ if self.p.hyphenate != Some(false) {
+ if let Some(lang) = self.lang(self.offset) {
+ let word = &self.p.bidi.text[self.offset..self.end];
+ let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic());
+ if !trimmed.is_empty() {
+ self.suffix = self.offset + trimmed.len();
+ self.syllables = Some(hypher::hyphenate(trimmed, lang));
+ return self.next();
+ }
+ }
+ }
+
+ self.offset = self.end;
+ Some((self.end, self.mandatory, false))
+ }
+}
+
+impl Breakpoints<'_> {
+ /// Whether hyphenation is enabled at the given offset.
+ fn hyphenate(&self, offset: usize) -> bool {
+ self.p
+ .hyphenate
+ .or_else(|| {
+ let shaped = self.p.find(offset)?.text()?;
+ Some(TextElem::hyphenate_in(shaped.styles))
+ })
+ .unwrap_or(false)
+ }
+
+ /// The text language at the given offset.
+ fn lang(&self, offset: usize) -> Option<hypher::Lang> {
+ let lang = self.p.lang.or_else(|| {
+ let shaped = self.p.find(offset)?.text()?;
+ Some(TextElem::lang_in(shaped.styles))
+ })?;
+
+ let bytes = lang.as_str().as_bytes().try_into().ok()?;
+ hypher::Lang::from_iso(bytes)
+ }
+}
+
+/// Create a line which spans the given range.
+fn line<'a>(
+ vt: &Vt,
+ p: &'a Preparation,
+ mut range: Range,
+ mandatory: bool,
+ hyphen: bool,
+) -> Line<'a> {
+ let end = range.end;
+ let mut justify = p.justify && end < p.bidi.text.len() && !mandatory;
+
+ if range.is_empty() {
+ return Line {
+ bidi: &p.bidi,
+ end,
+ trimmed: range,
+ first: None,
+ inner: &[],
+ last: None,
+ width: Abs::zero(),
+ justify,
+ dash: false,
+ };
+ }
+
+ // Slice out the relevant items.
+ let (expanded, mut inner) = p.slice(range.clone());
+ let mut width = Abs::zero();
+
+ // Reshape the last item if it's split in half or hyphenated.
+ let mut last = None;
+ let mut dash = false;
+ if let Some((Item::Text(shaped), before)) = inner.split_last() {
+ // Compute the range we want to shape, trimming whitespace at the
+ // end of the line.
+ let base = expanded.end - shaped.text.len();
+ let start = range.start.max(base);
+ let text = &p.bidi.text[start..range.end];
+ // U+200B ZERO WIDTH SPACE is used to provide a line break opportunity,
+ // we want to trim it too.
+ let trimmed = text.trim_end().trim_end_matches('\u{200B}');
+ range.end = start + trimmed.len();
+
+ // Deal with hyphens, dashes and justification.
+ let shy = trimmed.ends_with('\u{ad}');
+ dash = hyphen || shy || trimmed.ends_with(['-', '–', '—']);
+ justify |= text.ends_with('\u{2028}');
+
+ // Deal with CJK punctuation at line ends.
+ let gb_style = is_gb_style(shaped.lang, shaped.region);
+ let end_cjk_punct = trimmed
+ .ends_with(['”', '’', ',', '。', '、', ':', ';', '》', ')', '』', '」']);
+
+ // Usually, we don't want to shape an empty string because:
+ // - We don't want the height of trimmed whitespace in a different
+ // font to be considered for the line height.
+ // - Even if it's in the same font, its unnecessary.
+ //
+ // There is one exception though. When the whole line is empty, we
+ // need the shaped empty string to make the line the appropriate
+ // height. That is the case exactly if the string is empty and there
+ // are no other items in the line.
+ if hyphen || start + shaped.text.len() > range.end || end_cjk_punct {
+ if hyphen || start < range.end || before.is_empty() {
+ let mut reshaped = shaped.reshape(vt, &p.spans, start..range.end);
+ if hyphen || shy {
+ reshaped.push_hyphen(vt);
+ }
+ let punct = reshaped.glyphs.last();
+ if let Some(punct) = punct {
+ if punct.is_cjk_left_aligned_punctuation(gb_style) {
+ let shrink_amount = punct.shrinkability().1;
+ let punct = reshaped.glyphs.to_mut().last_mut().unwrap();
+ punct.shrink_right(shrink_amount);
+ reshaped.width -= shrink_amount.at(reshaped.size);
+ }
+ }
+ width += reshaped.width;
+ last = Some(Item::Text(reshaped));
+ }
+
+ inner = before;
+ }
+ }
+
+ // Deal with CJK punctuation at line starts.
+ let text = &p.bidi.text[range.start..end];
+ let start_cjk_punct = text.starts_with(['“', '‘', '《', '(', '『', '「']);
+
+ // Reshape the start item if it's split in half.
+ let mut first = None;
+ if let Some((Item::Text(shaped), after)) = inner.split_first() {
+ // Compute the range we want to shape.
+ let base = expanded.start;
+ let end = range.end.min(base + shaped.text.len());
+
+ // Reshape if necessary.
+ if range.start + shaped.text.len() > end || start_cjk_punct {
+ if range.start < end || start_cjk_punct {
+ let reshaped = shaped.reshape(vt, &p.spans, range.start..end);
+ width += reshaped.width;
+ first = Some(Item::Text(reshaped));
+ }
+
+ inner = after;
+ }
+ }
+
+ if start_cjk_punct {
+ let reshaped = first.as_mut().or(last.as_mut()).and_then(Item::text_mut);
+ if let Some(reshaped) = reshaped {
+ if let Some(punct) = reshaped.glyphs.first() {
+ if punct.is_cjk_right_aligned_punctuation() {
+ let shrink_amount = punct.shrinkability().0;
+ let punct = reshaped.glyphs.to_mut().first_mut().unwrap();
+ punct.shrink_left(shrink_amount);
+ let amount_abs = shrink_amount.at(reshaped.size);
+ reshaped.width -= amount_abs;
+ width -= amount_abs;
+ }
+ }
+ }
+ }
+
+ // Measure the inner items.
+ for item in inner {
+ width += item.width();
+ }
+
+ Line {
+ bidi: &p.bidi,
+ trimmed: range,
+ end,
+ first,
+ inner,
+ last,
+ width,
+ justify,
+ dash,
+ }
+}
+
+/// Combine layouted lines into one frame per region.
+fn finalize(
+ vt: &mut Vt,
+ p: &Preparation,
+ lines: &[Line],
+ region: Size,
+ expand: bool,
+) -> SourceResult<Fragment> {
+ // Determine the paragraph's width: Full width of the region if we
+ // should expand or there's fractional spacing, fit-to-width otherwise.
+ let width = if !region.x.is_finite()
+ || (!expand && lines.iter().all(|line| line.fr().is_zero()))
+ {
+ p.hang + lines.iter().map(|line| line.width).max().unwrap_or_default()
+ } else {
+ region.x
+ };
+
+ // Stack the lines into one frame per region.
+ let mut frames: Vec<Frame> = lines
+ .iter()
+ .map(|line| commit(vt, p, line, width, region.y))
+ .collect::<SourceResult<_>>()?;
+
+ // Prevent orphans.
+ let leading = ParElem::leading_in(p.styles);
+ if frames.len() >= 2 && !frames[1].is_empty() {
+ let second = frames.remove(1);
+ let first = &mut frames[0];
+ merge(first, second, leading);
+ }
+
+ // Prevent widows.
+ let len = frames.len();
+ if len >= 2 && !frames[len - 2].is_empty() {
+ let second = frames.pop().unwrap();
+ let first = frames.last_mut().unwrap();
+ merge(first, second, leading);
+ }
+
+ Ok(Fragment::frames(frames))
+}
+
+/// Merge two line frames
+fn merge(first: &mut Frame, second: Frame, leading: Abs) {
+ let offset = first.height() + leading;
+ let total = offset + second.height();
+ first.push_frame(Point::with_y(offset), second);
+ first.size_mut().y = total;
+}
+
+/// Commit to a line and build its frame.
+fn commit(
+ vt: &mut Vt,
+ p: &Preparation,
+ line: &Line,
+ width: Abs,
+ full: Abs,
+) -> SourceResult<Frame> {
+ let mut remaining = width - line.width - p.hang;
+ let mut offset = Abs::zero();
+
+ // Reorder the line from logical to visual order.
+ let (reordered, starts_rtl) = reorder(line);
+ if !starts_rtl {
+ offset += p.hang;
+ }
+
+ // Handle hanging punctuation to the left.
+ if let Some(Item::Text(text)) = reordered.first() {
+ if let Some(glyph) = text.glyphs.first() {
+ if !text.dir.is_positive()
+ && TextElem::overhang_in(text.styles)
+ && (reordered.len() > 1 || text.glyphs.len() > 1)
+ {
+ let amount = overhang(glyph.c) * glyph.x_advance.at(text.size);
+ offset -= amount;
+ remaining += amount;
+ }
+ }
+ }
+
+ // Handle hanging punctuation to the right.
+ if let Some(Item::Text(text)) = reordered.last() {
+ if let Some(glyph) = text.glyphs.last() {
+ if text.dir.is_positive()
+ && TextElem::overhang_in(text.styles)
+ && (reordered.len() > 1 || text.glyphs.len() > 1)
+ {
+ let amount = overhang(glyph.c) * glyph.x_advance.at(text.size);
+ remaining += amount;
+ }
+ }
+ }
+
+ // Determine how much additional space is needed.
+ // The justicication_ratio is for the first step justification,
+ // extra_justification is for the last step.
+ // For more info on multi-step justification, see Procedures for Inter-
+ // Character Space Expansion in W3C document Chinese Layout Requirements.
+ let fr = line.fr();
+ let mut justification_ratio = 0.0;
+ let mut extra_justification = Abs::zero();
+
+ let shrink = line.shrinkability();
+ let stretch = line.stretchability();
+ if remaining < Abs::zero() && shrink > Abs::zero() {
+ // Attempt to reduce the length of the line, using shrinkability.
+ justification_ratio = (remaining / shrink).max(-1.0);
+ remaining = (remaining + shrink).min(Abs::zero());
+ } else if line.justify && fr.is_zero() {
+ // Attempt to increase the length of the line, using stretchability.
+ if stretch > Abs::zero() {
+ justification_ratio = (remaining / stretch).min(1.0);
+ remaining = (remaining - stretch).max(Abs::zero());
+ }
+
+ let justifiables = line.justifiables();
+ if justifiables > 0 && remaining > Abs::zero() {
+ // Underfull line, distribute the extra space.
+ extra_justification = remaining / justifiables as f64;
+ remaining = Abs::zero();
+ }
+ }
+
+ let mut top = Abs::zero();
+ let mut bottom = Abs::zero();
+
+ // Build the frames and determine the height and baseline.
+ let mut frames = vec![];
+ for item in reordered {
+ let mut push = |offset: &mut Abs, frame: Frame| {
+ let width = frame.width();
+ top.set_max(frame.baseline());
+ bottom.set_max(frame.size().y - frame.baseline());
+ frames.push((*offset, frame));
+ *offset += width;
+ };
+
+ match item {
+ Item::Absolute(v) => {
+ offset += *v;
+ }
+ Item::Fractional(v, elem) => {
+ let amount = v.share(fr, remaining);
+ if let Some((elem, styles)) = elem {
+ let region = Size::new(amount, full);
+ let pod = Regions::one(region, Axes::new(true, false));
+ let mut frame = elem.layout(vt, *styles, pod)?.into_frame();
+ frame.translate(Point::with_y(TextElem::baseline_in(*styles)));
+ push(&mut offset, frame);
+ } else {
+ offset += amount;
+ }
+ }
+ Item::Text(shaped) => {
+ let frame = shaped.build(vt, justification_ratio, extra_justification);
+ push(&mut offset, frame);
+ }
+ Item::Frame(frame) | Item::Meta(frame) => {
+ push(&mut offset, frame.clone());
+ }
+ }
+ }
+
+ // Remaining space is distributed now.
+ if !fr.is_zero() {
+ remaining = Abs::zero();
+ }
+
+ let size = Size::new(width, top + bottom);
+ let mut output = Frame::new(size);
+ output.set_baseline(top);
+
+ // Construct the line's frame.
+ for (offset, frame) in frames {
+ let x = offset + p.align.position(remaining);
+ let y = top - frame.baseline();
+ output.push_frame(Point::new(x, y), frame);
+ }
+
+ Ok(output)
+}
+
+/// Return a line's items in visual order.
+fn reorder<'a>(line: &'a Line<'a>) -> (Vec<&Item<'a>>, bool) {
+ let mut reordered = vec![];
+
+ // The bidi crate doesn't like empty lines.
+ if line.trimmed.is_empty() {
+ return (line.slice(line.trimmed.clone()).collect(), false);
+ }
+
+ // Find the paragraph that contains the line.
+ let para = line
+ .bidi
+ .paragraphs
+ .iter()
+ .find(|para| para.range.contains(&line.trimmed.start))
+ .unwrap();
+
+ // Compute the reordered ranges in visual order (left to right).
+ let (levels, runs) = line.bidi.visual_runs(para, line.trimmed.clone());
+ let starts_rtl = levels.first().map_or(false, |level| level.is_rtl());
+
+ // Collect the reordered items.
+ for run in runs {
+ // Skip reset L1 runs because handling them would require reshaping
+ // again in some cases.
+ if line.bidi.levels[run.start] != levels[run.start] {
+ continue;
+ }
+
+ let prev = reordered.len();
+ reordered.extend(line.slice(run.clone()));
+
+ if levels[run.start].is_rtl() {
+ reordered[prev..].reverse();
+ }
+ }
+
+ (reordered, starts_rtl)
+}
+
+/// How much a character should hang into the end margin.
+///
+/// For more discussion, see:
+/// https://recoveringphysicist.com/21/
+fn overhang(c: char) -> f64 {
+ match c {
+ // Dashes.
+ '–' | '—' => 0.2,
+ '-' => 0.55,
+
+ // Punctuation.
+ '.' | ',' => 0.8,
+ ':' | ';' => 0.3,
+
+ // Arabic
+ '\u{60C}' | '\u{6D4}' => 0.4,
+
+ _ => 0.0,
+ }
+}
diff --git a/crates/typst-library/src/layout/place.rs b/crates/typst-library/src/layout/place.rs
new file mode 100644
index 00000000..6602948c
--- /dev/null
+++ b/crates/typst-library/src/layout/place.rs
@@ -0,0 +1,103 @@
+use crate::prelude::*;
+
+/// Places content at an absolute position.
+///
+/// Placed content will not affect the position of other content. Place is
+/// always relative to its parent container and will be in the foreground of all
+/// other content in the container. Page margins will be respected.
+///
+///
+/// ## Example { #example }
+/// ```example
+/// #set page(height: 60pt)
+/// Hello, world!
+///
+/// #place(
+/// top + right,
+/// square(
+/// width: 20pt,
+/// stroke: 2pt + blue
+/// ),
+/// )
+/// ```
+///
+/// Display: Place
+/// Category: layout
+#[element(Layout, Behave)]
+pub struct PlaceElem {
+ /// Relative to which position in the parent container to place the content.
+ ///
+ /// When an axis of the page is `{auto}` sized, all alignments relative to that
+ /// axis will be ignored, instead, the item will be placed in the origin of the
+ /// axis.
+ #[positional]
+ #[default(Axes::with_x(Some(GenAlign::Start)))]
+ pub alignment: Axes<Option<GenAlign>>,
+
+ /// The horizontal displacement of the placed content.
+ ///
+ /// ```example
+ /// #set page(height: 100pt)
+ /// #for i in range(16) {
+ /// let amount = i * 4pt
+ /// place(center, dx: amount - 32pt, dy: amount)[A]
+ /// }
+ /// ```
+ pub dx: Rel<Length>,
+
+ /// The vertical displacement of the placed content.
+ pub dy: Rel<Length>,
+
+ /// The content to place.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for PlaceElem {
+ #[tracing::instrument(name = "PlaceElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let out_of_flow = self.out_of_flow(styles);
+
+ // The pod is the base area of the region because for absolute
+ // placement we don't really care about the already used area.
+ let pod = {
+ let finite = regions.base().map(Abs::is_finite);
+ let expand = finite & (regions.expand | out_of_flow);
+ Regions::one(regions.base(), expand)
+ };
+
+ let child = self
+ .body()
+ .moved(Axes::new(self.dx(styles), self.dy(styles)))
+ .aligned(self.alignment(styles));
+
+ let mut frame = child.layout(vt, styles, pod)?.into_frame();
+
+ // If expansion is off, zero all sizes so that we don't take up any
+ // space in our parent. Otherwise, respect the expand settings.
+ let target = regions.expand.select(regions.size, Size::zero());
+ frame.resize(target, Align::LEFT_TOP);
+
+ Ok(Fragment::frame(frame))
+ }
+}
+
+impl PlaceElem {
+ /// Whether this element wants to be placed relative to its its parent's
+ /// base origin. Instead of relative to the parent's current flow/cursor
+ /// position.
+ pub fn out_of_flow(&self, styles: StyleChain) -> bool {
+ self.alignment(styles).y.is_some()
+ }
+}
+
+impl Behave for PlaceElem {
+ fn behaviour(&self) -> Behaviour {
+ Behaviour::Ignorant
+ }
+}
diff --git a/crates/typst-library/src/layout/regions.rs b/crates/typst-library/src/layout/regions.rs
new file mode 100644
index 00000000..6dd549b0
--- /dev/null
+++ b/crates/typst-library/src/layout/regions.rs
@@ -0,0 +1,144 @@
+use std::fmt::{self, Debug, Formatter};
+
+use typst::geom::{Abs, Axes, Size};
+
+/// A sequence of regions to layout into.
+#[derive(Copy, Clone, Hash)]
+pub struct Regions<'a> {
+ /// The remaining size of the first region.
+ pub size: Size,
+ /// The full height of the region for relative sizing.
+ pub full: Abs,
+ /// The height of followup regions. The width is the same for all regions.
+ pub backlog: &'a [Abs],
+ /// The height of the final region that is repeated once the backlog is
+ /// drained. The width is the same for all regions.
+ pub last: Option<Abs>,
+ /// Whether elements should expand to fill the regions instead of shrinking
+ /// to fit the content.
+ pub expand: Axes<bool>,
+ /// Whether these are the root regions or direct descendants.
+ ///
+ /// True for the padded page regions and columns directly in the page,
+ /// false otherwise.
+ pub root: bool,
+}
+
+impl Regions<'_> {
+ /// Create a new region sequence with exactly one region.
+ pub fn one(size: Size, expand: Axes<bool>) -> Self {
+ Self {
+ size,
+ full: size.y,
+ backlog: &[],
+ last: None,
+ expand,
+ root: false,
+ }
+ }
+
+ /// Create a new sequence of same-size regions that repeats indefinitely.
+ pub fn repeat(size: Size, expand: Axes<bool>) -> Self {
+ Self {
+ size,
+ full: size.y,
+ backlog: &[],
+ last: Some(size.y),
+ expand,
+ root: false,
+ }
+ }
+
+ /// The base size, which doesn't take into account that the regions is
+ /// already partially used up.
+ ///
+ /// This is also used for relative sizing.
+ pub fn base(&self) -> Size {
+ Size::new(self.size.x, self.full)
+ }
+
+ /// Create new regions where all sizes are mapped with `f`.
+ ///
+ /// Note that since all regions must have the same width, the width returned
+ /// by `f` is ignored for the backlog and the final region.
+ pub fn map<'v, F>(&self, backlog: &'v mut Vec<Abs>, mut f: F) -> Regions<'v>
+ where
+ F: FnMut(Size) -> Size,
+ {
+ let x = self.size.x;
+ backlog.clear();
+ backlog.extend(self.backlog.iter().map(|&y| f(Size::new(x, y)).y));
+ Regions {
+ size: f(self.size),
+ full: f(Size::new(x, self.full)).y,
+ backlog,
+ last: self.last.map(|y| f(Size::new(x, y)).y),
+ expand: self.expand,
+ root: false,
+ }
+ }
+
+ /// Whether the first region is full and a region break is called for.
+ pub fn is_full(&self) -> bool {
+ Abs::zero().fits(self.size.y) && !self.in_last()
+ }
+
+ /// Whether the first region is the last usable region.
+ ///
+ /// If this is true, calling `next()` will have no effect.
+ pub fn in_last(&self) -> bool {
+ self.backlog.is_empty() && self.last.map_or(true, |height| self.size.y == height)
+ }
+
+ /// The same regions, but with different `root` configuration.
+ pub fn with_root(self, root: bool) -> Self {
+ Self { root, ..self }
+ }
+
+ /// Advance to the next region if there is any.
+ pub fn next(&mut self) {
+ if let Some(height) = self
+ .backlog
+ .split_first()
+ .map(|(first, tail)| {
+ self.backlog = tail;
+ *first
+ })
+ .or(self.last)
+ {
+ self.size.y = height;
+ self.full = height;
+ }
+ }
+
+ /// An iterator that returns the sizes of the first and all following
+ /// regions, equivalently to what would be produced by calling
+ /// [`next()`](Self::next) repeatedly until all regions are exhausted.
+ /// This iterator may be infinite.
+ pub fn iter(&self) -> impl Iterator<Item = Size> + '_ {
+ let first = std::iter::once(self.size);
+ let backlog = self.backlog.iter();
+ let last = self.last.iter().cycle();
+ first.chain(backlog.chain(last).map(|&h| Size::new(self.size.x, h)))
+ }
+}
+
+impl Debug for Regions<'_> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ f.write_str("Regions ")?;
+ let mut list = f.debug_list();
+ let mut prev = self.size.y;
+ list.entry(&self.size);
+ for &height in self.backlog {
+ list.entry(&Size::new(self.size.x, height));
+ prev = height;
+ }
+ if let Some(last) = self.last {
+ if last != prev {
+ list.entry(&Size::new(self.size.x, last));
+ }
+ list.entry(&(..));
+ }
+ list.finish()
+ }
+}
diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs
new file mode 100644
index 00000000..646eb991
--- /dev/null
+++ b/crates/typst-library/src/layout/repeat.rs
@@ -0,0 +1,79 @@
+use crate::prelude::*;
+
+use super::AlignElem;
+
+/// Repeats content to the available space.
+///
+/// This can be useful when implementing a custom index, reference, or outline.
+///
+/// Space may be inserted between the instances of the body parameter, so be
+/// sure to include negative space if you need the instances to overlap.
+///
+/// Errors if there no bounds on the available space, as it would create
+/// infinite content.
+///
+/// ## Example { #example }
+/// ```example
+/// Sign on the dotted line:
+/// #box(width: 1fr, repeat[.])
+///
+/// #set text(10pt)
+/// #v(8pt, weak: true)
+/// #align(right)[
+/// Berlin, the 22nd of December, 2022
+/// ]
+/// ```
+///
+/// Display: Repeat
+/// Category: layout
+#[element(Layout)]
+pub struct RepeatElem {
+ /// The content to repeat.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for RepeatElem {
+ #[tracing::instrument(name = "RepeatElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.size, Axes::new(false, false));
+ let piece = self.body().layout(vt, styles, pod)?.into_frame();
+ let align = AlignElem::alignment_in(styles).x.resolve(styles);
+
+ let fill = regions.size.x;
+ let width = piece.width();
+ let count = (fill / width).floor();
+ let remaining = fill % width;
+ let apart = remaining / (count - 1.0);
+
+ let size = Size::new(regions.size.x, piece.height());
+
+ if !size.is_finite() {
+ bail!(self.span(), "repeat with no size restrictions");
+ }
+
+ let mut frame = Frame::new(size);
+ if piece.has_baseline() {
+ frame.set_baseline(piece.baseline());
+ }
+
+ let mut offset = Abs::zero();
+ if count == 1.0 {
+ offset += align.position(remaining);
+ }
+
+ if width > Abs::zero() {
+ for _ in 0..(count as usize).min(1000) {
+ frame.push_frame(Point::with_x(offset), piece.clone());
+ offset += piece.width() + apart;
+ }
+ }
+
+ Ok(Fragment::frame(frame))
+ }
+}
diff --git a/crates/typst-library/src/layout/spacing.rs b/crates/typst-library/src/layout/spacing.rs
new file mode 100644
index 00000000..e7dc24fb
--- /dev/null
+++ b/crates/typst-library/src/layout/spacing.rs
@@ -0,0 +1,240 @@
+use std::cmp::Ordering;
+
+use crate::prelude::*;
+
+/// Inserts horizontal spacing into a paragraph.
+///
+/// The spacing can be absolute, relative, or fractional. In the last case, the
+/// remaining space on the line is distributed among all fractional spacings
+/// according to their relative fractions.
+///
+/// ## Example { #example }
+/// ```example
+/// First #h(1cm) Second \
+/// First #h(30%) Second \
+/// First #h(2fr) Second #h(1fr) Third
+/// ```
+///
+/// ## Mathematical Spacing { #math-spacing }
+/// In [mathematical formulas]($category/math), you can additionally use these
+/// constants to add spacing between elements: `thin`, `med`, `thick`, `quad`.
+///
+/// Display: Spacing (H)
+/// Category: layout
+#[element(Behave)]
+pub struct HElem {
+ /// How much spacing to insert.
+ #[required]
+ pub amount: Spacing,
+
+ /// If `{true}`, the spacing collapses at the start or end of a paragraph.
+ /// Moreover, from multiple adjacent weak spacings all but the largest one
+ /// collapse.
+ ///
+ /// ```example
+ /// #h(1cm, weak: true)
+ /// We identified a group of
+ /// _weak_ specimens that fail to
+ /// manifest in most cases. However,
+ /// when #h(8pt, weak: true)
+ /// supported
+ /// #h(8pt, weak: true) on both
+ /// sides, they do show up.
+ /// ```
+ #[default(false)]
+ pub weak: bool,
+}
+
+impl Behave for HElem {
+ fn behaviour(&self) -> Behaviour {
+ if self.amount().is_fractional() {
+ Behaviour::Destructive
+ } else if self.weak(StyleChain::default()) {
+ Behaviour::Weak(1)
+ } else {
+ Behaviour::Ignorant
+ }
+ }
+
+ fn larger(&self, prev: &Content) -> bool {
+ let Some(prev) = prev.to::<Self>() else { return false };
+ self.amount() > prev.amount()
+ }
+}
+
+/// Inserts vertical spacing into a flow of blocks.
+///
+/// The spacing can be absolute, relative, or fractional. In the last case,
+/// the remaining space on the page is distributed among all fractional spacings
+/// according to their relative fractions.
+///
+/// ## Example { #example }
+/// ```example
+/// #grid(
+/// rows: 3cm,
+/// columns: 6,
+/// gutter: 1fr,
+/// [A #parbreak() B],
+/// [A #v(0pt) B],
+/// [A #v(10pt) B],
+/// [A #v(0pt, weak: true) B],
+/// [A #v(40%, weak: true) B],
+/// [A #v(1fr) B],
+/// )
+/// ```
+///
+/// Display: Spacing (V)
+/// Category: layout
+#[element(Behave)]
+pub struct VElem {
+ /// How much spacing to insert.
+ #[required]
+ pub amount: Spacing,
+
+ /// If `{true}`, the spacing collapses at the start or end of a flow.
+ /// Moreover, from multiple adjacent weak spacings all but the largest one
+ /// collapse. Weak spacings will always collapse adjacent paragraph spacing,
+ /// even if the paragraph spacing is larger.
+ ///
+ /// ```example
+ /// The following theorem is
+ /// foundational to the field:
+ /// #v(4pt, weak: true)
+ /// $ x^2 + y^2 = r^2 $
+ /// #v(4pt, weak: true)
+ /// The proof is simple:
+ /// ```
+ #[external]
+ pub weak: bool,
+
+ /// The element's weakness level, see also [`Behaviour`].
+ #[internal]
+ #[parse(args.named("weak")?.map(|v: bool| v as usize))]
+ pub weakness: usize,
+}
+
+impl VElem {
+ /// Normal strong spacing.
+ pub fn strong(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(0)
+ }
+
+ /// User-created weak spacing.
+ pub fn weak(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(1)
+ }
+
+ /// Weak spacing with list attach weakness.
+ pub fn list_attach(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(2)
+ }
+
+ /// Weak spacing with BlockElem::ABOVE/BELOW weakness.
+ pub fn block_around(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(3)
+ }
+
+ /// Weak spacing with BlockElem::SPACING weakness.
+ pub fn block_spacing(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(4)
+ }
+}
+
+impl Behave for VElem {
+ fn behaviour(&self) -> Behaviour {
+ if self.amount().is_fractional() {
+ Behaviour::Destructive
+ } else if self.weakness(StyleChain::default()) > 0 {
+ Behaviour::Weak(self.weakness(StyleChain::default()))
+ } else {
+ Behaviour::Ignorant
+ }
+ }
+
+ fn larger(&self, prev: &Content) -> bool {
+ let Some(prev) = prev.to::<Self>() else { return false };
+ self.amount() > prev.amount()
+ }
+}
+
+cast! {
+ VElem,
+ v: Content => v.to::<Self>().cloned().ok_or("expected `v` element")?,
+}
+
+/// Kinds of spacing.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Spacing {
+ /// Spacing specified in absolute terms and relative to the parent's size.
+ Rel(Rel<Length>),
+ /// Spacing specified as a fraction of the remaining free space in the
+ /// parent.
+ Fr(Fr),
+}
+
+impl Spacing {
+ /// Whether this is fractional spacing.
+ pub fn is_fractional(self) -> bool {
+ matches!(self, Self::Fr(_))
+ }
+
+ /// Whether the spacing is actually no spacing.
+ pub fn is_zero(&self) -> bool {
+ match self {
+ Self::Rel(rel) => rel.is_zero(),
+ Self::Fr(fr) => fr.is_zero(),
+ }
+ }
+}
+
+impl From<Abs> for Spacing {
+ fn from(abs: Abs) -> Self {
+ Self::Rel(abs.into())
+ }
+}
+
+impl From<Em> for Spacing {
+ fn from(em: Em) -> Self {
+ Self::Rel(Rel::new(Ratio::zero(), em.into()))
+ }
+}
+
+impl From<Length> for Spacing {
+ fn from(length: Length) -> Self {
+ Self::Rel(length.into())
+ }
+}
+
+impl From<Fr> for Spacing {
+ fn from(fr: Fr) -> Self {
+ Self::Fr(fr)
+ }
+}
+
+impl PartialOrd for Spacing {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ match (self, other) {
+ (Self::Rel(a), Self::Rel(b)) => a.partial_cmp(b),
+ (Self::Fr(a), Self::Fr(b)) => a.partial_cmp(b),
+ _ => None,
+ }
+ }
+}
+
+cast! {
+ Spacing,
+ self => match self {
+ Self::Rel(rel) => {
+ if rel.rel.is_zero() {
+ rel.abs.into_value()
+ } else if rel.abs.is_zero() {
+ rel.rel.into_value()
+ } else {
+ rel.into_value()
+ }
+ }
+ Self::Fr(fr) => fr.into_value(),
+ },
+ v: Rel<Length> => Self::Rel(v),
+ v: Fr => Self::Fr(v),
+}
diff --git a/crates/typst-library/src/layout/stack.rs b/crates/typst-library/src/layout/stack.rs
new file mode 100644
index 00000000..97305ddf
--- /dev/null
+++ b/crates/typst-library/src/layout/stack.rs
@@ -0,0 +1,339 @@
+use super::{AlignElem, Spacing};
+use crate::prelude::*;
+
+/// Arranges content and spacing horizontally or vertically.
+///
+/// The stack places a list of items along an axis, with optional spacing
+/// between each item.
+///
+/// ## Example { #example }
+/// ```example
+/// #stack(
+/// dir: ttb,
+/// rect(width: 40pt),
+/// rect(width: 120pt),
+/// rect(width: 90pt),
+/// )
+/// ```
+///
+/// Display: Stack
+/// Category: layout
+#[element(Layout)]
+pub struct StackElem {
+ /// The direction along which the items are stacked. Possible values are:
+ ///
+ /// - `{ltr}`: Left to right.
+ /// - `{rtl}`: Right to left.
+ /// - `{ttb}`: Top to bottom.
+ /// - `{btt}`: Bottom to top.
+ #[default(Dir::TTB)]
+ pub dir: Dir,
+
+ /// Spacing to insert between items where no explicit spacing was provided.
+ pub spacing: Option<Spacing>,
+
+ /// The children to stack along the axis.
+ #[variadic]
+ pub children: Vec<StackChild>,
+}
+
+impl Layout for StackElem {
+ #[tracing::instrument(name = "StackElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut layouter = StackLayouter::new(self.dir(styles), regions, styles);
+
+ // Spacing to insert before the next block.
+ let spacing = self.spacing(styles);
+ let mut deferred = None;
+
+ for child in self.children() {
+ match child {
+ StackChild::Spacing(kind) => {
+ layouter.layout_spacing(kind);
+ deferred = None;
+ }
+ StackChild::Block(block) => {
+ if let Some(kind) = deferred {
+ layouter.layout_spacing(kind);
+ }
+
+ layouter.layout_block(vt, &block, styles)?;
+ deferred = spacing;
+ }
+ }
+ }
+
+ Ok(layouter.finish())
+ }
+}
+
+/// A child of a stack element.
+#[derive(Hash)]
+pub enum StackChild {
+ /// Spacing between other children.
+ Spacing(Spacing),
+ /// Arbitrary block-level content.
+ Block(Content),
+}
+
+impl Debug for StackChild {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Spacing(kind) => kind.fmt(f),
+ Self::Block(block) => block.fmt(f),
+ }
+ }
+}
+
+cast! {
+ StackChild,
+ self => match self {
+ Self::Spacing(spacing) => spacing.into_value(),
+ Self::Block(content) => content.into_value(),
+ },
+ v: Spacing => Self::Spacing(v),
+ v: Content => Self::Block(v),
+}
+
+/// Performs stack layout.
+struct StackLayouter<'a> {
+ /// The stacking direction.
+ dir: Dir,
+ /// The axis of the stacking direction.
+ axis: Axis,
+ /// The regions to layout children into.
+ regions: Regions<'a>,
+ /// The inherited styles.
+ styles: StyleChain<'a>,
+ /// Whether the stack itself should expand to fill the region.
+ expand: Axes<bool>,
+ /// The initial size of the current region before we started subtracting.
+ initial: Size,
+ /// The generic size used by the frames for the current region.
+ used: Gen<Abs>,
+ /// The sum of fractions in the current region.
+ fr: Fr,
+ /// Already layouted items whose exact positions are not yet known due to
+ /// fractional spacing.
+ items: Vec<StackItem>,
+ /// Finished frames for previous regions.
+ finished: Vec<Frame>,
+}
+
+/// A prepared item in a stack layout.
+enum StackItem {
+ /// Absolute spacing between other items.
+ Absolute(Abs),
+ /// Fractional spacing between other items.
+ Fractional(Fr),
+ /// A frame for a layouted block.
+ Frame(Frame, Axes<Align>),
+}
+
+impl<'a> StackLayouter<'a> {
+ /// Create a new stack layouter.
+ fn new(dir: Dir, mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self {
+ let axis = dir.axis();
+ let expand = regions.expand;
+
+ // Disable expansion along the block axis for children.
+ regions.expand.set(axis, false);
+
+ Self {
+ dir,
+ axis,
+ regions,
+ styles,
+ expand,
+ initial: regions.size,
+ used: Gen::zero(),
+ fr: Fr::zero(),
+ items: vec![],
+ finished: vec![],
+ }
+ }
+
+ /// Add spacing along the spacing direction.
+ #[tracing::instrument(name = "StackLayouter::layout_spacing", skip_all)]
+ fn layout_spacing(&mut self, spacing: Spacing) {
+ match spacing {
+ Spacing::Rel(v) => {
+ // Resolve the spacing and limit it to the remaining space.
+ let resolved = v
+ .resolve(self.styles)
+ .relative_to(self.regions.base().get(self.axis));
+ let remaining = self.regions.size.get_mut(self.axis);
+ let limited = resolved.min(*remaining);
+ if self.dir.axis() == Axis::Y {
+ *remaining -= limited;
+ }
+ self.used.main += limited;
+ self.items.push(StackItem::Absolute(resolved));
+ }
+ Spacing::Fr(v) => {
+ self.fr += v;
+ self.items.push(StackItem::Fractional(v));
+ }
+ }
+ }
+
+ /// Layout an arbitrary block.
+ #[tracing::instrument(name = "StackLayouter::layout_block", skip_all)]
+ fn layout_block(
+ &mut self,
+ vt: &mut Vt,
+ block: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ if self.regions.is_full() {
+ self.finish_region();
+ }
+
+ // Block-axis alignment of the `AlignElement` is respected by stacks.
+ let aligns = if let Some(align) = block.to::<AlignElem>() {
+ align.alignment(styles)
+ } else if let Some((_, local)) = block.to_styled() {
+ AlignElem::alignment_in(styles.chain(local))
+ } else {
+ AlignElem::alignment_in(styles)
+ }
+ .resolve(styles);
+
+ let fragment = block.layout(vt, styles, self.regions)?;
+ let len = fragment.len();
+ for (i, frame) in fragment.into_iter().enumerate() {
+ // Grow our size, shrink the region and save the frame for later.
+ let size = frame.size();
+ if self.dir.axis() == Axis::Y {
+ self.regions.size.y -= size.y;
+ }
+
+ let gen = match self.axis {
+ Axis::X => Gen::new(size.y, size.x),
+ Axis::Y => Gen::new(size.x, size.y),
+ };
+
+ self.used.main += gen.main;
+ self.used.cross.set_max(gen.cross);
+
+ self.items.push(StackItem::Frame(frame, aligns));
+
+ if i + 1 < len {
+ self.finish_region();
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Advance to the next region.
+ fn finish_region(&mut self) {
+ // Determine the size of the stack in this region depending on whether
+ // the region expands.
+ let mut size = self
+ .expand
+ .select(self.initial, self.used.into_axes(self.axis))
+ .min(self.initial);
+
+ // Expand fully if there are fr spacings.
+ let full = self.initial.get(self.axis);
+ let remaining = full - self.used.main;
+ if self.fr.get() > 0.0 && full.is_finite() {
+ self.used.main = full;
+ size.set(self.axis, full);
+ }
+
+ let mut output = Frame::new(size);
+ let mut cursor = Abs::zero();
+ let mut ruler: Align = self.dir.start().into();
+
+ // Place all frames.
+ for item in self.items.drain(..) {
+ match item {
+ StackItem::Absolute(v) => cursor += v,
+ StackItem::Fractional(v) => cursor += v.share(self.fr, remaining),
+ StackItem::Frame(frame, aligns) => {
+ if self.dir.is_positive() {
+ ruler = ruler.max(aligns.get(self.axis));
+ } else {
+ ruler = ruler.min(aligns.get(self.axis));
+ }
+
+ // Align along the main axis.
+ let parent = size.get(self.axis);
+ let child = frame.size().get(self.axis);
+ let main = ruler.position(parent - self.used.main)
+ + if self.dir.is_positive() {
+ cursor
+ } else {
+ self.used.main - child - cursor
+ };
+
+ // Align along the cross axis.
+ let other = self.axis.other();
+ let cross = aligns
+ .get(other)
+ .position(size.get(other) - frame.size().get(other));
+
+ let pos = Gen::new(cross, main).to_point(self.axis);
+ cursor += child;
+ output.push_frame(pos, frame);
+ }
+ }
+ }
+
+ // Advance to the next region.
+ self.regions.next();
+ self.initial = self.regions.size;
+ self.used = Gen::zero();
+ self.fr = Fr::zero();
+ self.finished.push(output);
+ }
+
+ /// Finish layouting and return the resulting frames.
+ fn finish(mut self) -> Fragment {
+ self.finish_region();
+ Fragment::frames(self.finished)
+ }
+}
+
+/// A container with a main and cross component.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+struct Gen<T> {
+ /// The main component.
+ pub cross: T,
+ /// The cross component.
+ pub main: T,
+}
+
+impl<T> Gen<T> {
+ /// Create a new instance from the two components.
+ const fn new(cross: T, main: T) -> Self {
+ Self { cross, main }
+ }
+
+ /// Convert to the specific representation, given the current main axis.
+ fn into_axes(self, main: Axis) -> Axes<T> {
+ match main {
+ Axis::X => Axes::new(self.main, self.cross),
+ Axis::Y => Axes::new(self.cross, self.main),
+ }
+ }
+}
+
+impl Gen<Abs> {
+ /// The zero value.
+ fn zero() -> Self {
+ Self { cross: Abs::zero(), main: Abs::zero() }
+ }
+
+ /// Convert to a point.
+ fn to_point(self, main: Axis) -> Point {
+ self.into_axes(main).to_point()
+ }
+}
diff --git a/crates/typst-library/src/layout/table.rs b/crates/typst-library/src/layout/table.rs
new file mode 100644
index 00000000..c2faedba
--- /dev/null
+++ b/crates/typst-library/src/layout/table.rs
@@ -0,0 +1,334 @@
+use typst::eval::{CastInfo, Reflect};
+
+use crate::layout::{AlignElem, GridLayouter, TrackSizings};
+use crate::meta::{Figurable, LocalName};
+use crate::prelude::*;
+
+/// A table of items.
+///
+/// Tables are used to arrange content in cells. Cells can contain arbitrary
+/// content, including multiple paragraphs and are specified in row-major order.
+/// Because tables are just grids with configurable cell properties, refer to
+/// the [grid documentation]($func/grid) for more information on how to size the
+/// table tracks.
+///
+/// To give a table a caption and make it [referenceable]($func/ref), put it
+/// into a [figure]($func/figure).
+///
+/// ## Example { #example }
+/// ```example
+/// #table(
+/// columns: (1fr, auto, auto),
+/// inset: 10pt,
+/// align: horizon,
+/// [], [*Area*], [*Parameters*],
+/// image("cylinder.svg"),
+/// $ pi h (D^2 - d^2) / 4 $,
+/// [
+/// $h$: height \
+/// $D$: outer radius \
+/// $d$: inner radius
+/// ],
+/// image("tetrahedron.svg"),
+/// $ sqrt(2) / 12 a^3 $,
+/// [$a$: edge length]
+/// )
+/// ```
+///
+/// Display: Table
+/// Category: layout
+#[element(Layout, LocalName, Figurable)]
+pub struct TableElem {
+ /// The column sizes. See the [grid documentation]($func/grid) for more
+ /// information on track sizing.
+ pub columns: TrackSizings,
+
+ /// The row sizes. See the [grid documentation]($func/grid) for more
+ /// information on track sizing.
+ pub rows: TrackSizings,
+
+ /// The gaps between rows & columns. See the [grid
+ /// documentation]($func/grid) for more information on gutters.
+ #[external]
+ pub gutter: TrackSizings,
+
+ /// The gaps between columns. Takes precedence over `gutter`. See the [grid
+ /// documentation]($func/grid) for more information on gutters.
+ #[parse(
+ let gutter = args.named("gutter")?;
+ args.named("column-gutter")?.or_else(|| gutter.clone())
+ )]
+ pub column_gutter: TrackSizings,
+
+ /// The gaps between rows. Takes precedence over `gutter`. See the [grid
+ /// documentation]($func/grid) for more information on gutters.
+ #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))]
+ pub row_gutter: TrackSizings,
+
+ /// How to fill the cells.
+ ///
+ /// This can be a color or a function that returns a color. The function is
+ /// passed the cells' column and row index, starting at zero. This can be
+ /// used to implement striped tables.
+ ///
+ /// ```example
+ /// #table(
+ /// fill: (col, _) => if calc.odd(col) { luma(240) } else { white },
+ /// align: (col, row) =>
+ /// if row == 0 { center }
+ /// else if col == 0 { left }
+ /// else { right },
+ /// columns: 4,
+ /// [], [*Q1*], [*Q2*], [*Q3*],
+ /// [Revenue:], [1000 €], [2000 €], [3000 €],
+ /// [Expenses:], [500 €], [1000 €], [1500 €],
+ /// [Profit:], [500 €], [1000 €], [1500 €],
+ /// )
+ /// ```
+ pub fill: Celled<Option<Paint>>,
+
+ /// How to align the cells' content.
+ ///
+ /// This can either be a single alignment, an array of alignments
+ /// (corresponding to each column) or a function that returns an alignment.
+ /// The function is passed the cells' column and row index, starting at zero.
+ /// If set to `{auto}`, the outer alignment is used.
+ ///
+ /// ```example
+ /// #table(
+ /// columns: 3,
+ /// align: (x, y) => (left, center, right).at(x),
+ /// [Hello], [Hello], [Hello],
+ /// [A], [B], [C],
+ /// )
+ /// ```
+ pub align: Celled<Smart<Axes<Option<GenAlign>>>>,
+
+ /// How to stroke the cells.
+ ///
+ /// See the [line's documentation]($func/line.stroke) for more details.
+ /// Strokes can be disabled by setting this to `{none}`.
+ ///
+ /// _Note:_ Richer stroke customization for individual cells is not yet
+ /// implemented, but will be in the future. In the meantime, you can use
+ /// the third-party [tablex library](https://github.com/PgBiel/typst-tablex/).
+ #[resolve]
+ #[fold]
+ #[default(Some(PartialStroke::default()))]
+ pub stroke: Option<PartialStroke>,
+
+ /// How much to pad the cells' content.
+ #[default(Abs::pt(5.0).into())]
+ pub inset: Rel<Length>,
+
+ /// The contents of the table cells.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Layout for TableElem {
+ #[tracing::instrument(name = "TableElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let inset = self.inset(styles);
+ let align = self.align(styles);
+
+ let tracks = Axes::new(self.columns(styles).0, self.rows(styles).0);
+ let gutter = Axes::new(self.column_gutter(styles).0, self.row_gutter(styles).0);
+ let cols = tracks.x.len().max(1);
+ let cells: Vec<_> = self
+ .children()
+ .into_iter()
+ .enumerate()
+ .map(|(i, child)| {
+ let mut child = child.padded(Sides::splat(inset));
+
+ let x = i % cols;
+ let y = i / cols;
+ if let Smart::Custom(alignment) = align.resolve(vt, x, y)? {
+ child = child.styled(AlignElem::set_alignment(alignment));
+ }
+
+ Ok(child)
+ })
+ .collect::<SourceResult<_>>()?;
+
+ let fill = self.fill(styles);
+ let stroke = self.stroke(styles).map(PartialStroke::unwrap_or_default);
+
+ // Prepare grid layout by unifying content and gutter tracks.
+ let layouter = GridLayouter::new(
+ tracks.as_deref(),
+ gutter.as_deref(),
+ &cells,
+ regions,
+ styles,
+ );
+
+ // Measure the columns and layout the grid row-by-row.
+ let mut layout = layouter.layout(vt)?;
+
+ // Add lines and backgrounds.
+ for (frame, rows) in layout.fragment.iter_mut().zip(&layout.rows) {
+ if layout.cols.is_empty() || rows.is_empty() {
+ continue;
+ }
+
+ // Render table lines.
+ if let Some(stroke) = &stroke {
+ let thickness = stroke.thickness;
+ let half = thickness / 2.0;
+
+ // Render horizontal lines.
+ for offset in points(rows.iter().map(|piece| piece.height)) {
+ let target = Point::with_x(frame.width() + thickness);
+ let hline = Geometry::Line(target).stroked(stroke.clone());
+ frame.prepend(
+ Point::new(-half, offset),
+ FrameItem::Shape(hline, self.span()),
+ );
+ }
+
+ // Render vertical lines.
+ for offset in points(layout.cols.iter().copied()) {
+ let target = Point::with_y(frame.height() + thickness);
+ let vline = Geometry::Line(target).stroked(stroke.clone());
+ frame.prepend(
+ Point::new(offset, -half),
+ FrameItem::Shape(vline, self.span()),
+ );
+ }
+ }
+
+ // Render cell backgrounds.
+ let mut dx = Abs::zero();
+ for (x, &col) in layout.cols.iter().enumerate() {
+ let mut dy = Abs::zero();
+ for row in rows {
+ if let Some(fill) = fill.resolve(vt, x, row.y)? {
+ let pos = Point::new(dx, dy);
+ let size = Size::new(col, row.height);
+ let rect = Geometry::Rect(size).filled(fill);
+ frame.prepend(pos, FrameItem::Shape(rect, self.span()));
+ }
+ dy += row.height;
+ }
+ dx += col;
+ }
+ }
+
+ Ok(layout.fragment)
+ }
+}
+
+/// Turn an iterator of extents into an iterator of offsets before, in between,
+/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm].
+fn points(extents: impl IntoIterator<Item = Abs>) -> impl Iterator<Item = Abs> {
+ let mut offset = Abs::zero();
+ std::iter::once(Abs::zero())
+ .chain(extents.into_iter())
+ .map(move |extent| {
+ offset += extent;
+ offset
+ })
+}
+
+/// A value that can be configured per cell.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Celled<T> {
+ /// A bare value, the same for all cells.
+ Value(T),
+ /// A closure mapping from cell coordinates to a value.
+ Func(Func),
+ /// An array of alignment values corresponding to each column.
+ Array(Vec<T>),
+}
+
+impl<T: Default + Clone + FromValue> Celled<T> {
+ /// Resolve the value based on the cell position.
+ pub fn resolve(&self, vt: &mut Vt, x: usize, y: usize) -> SourceResult<T> {
+ Ok(match self {
+ Self::Value(value) => value.clone(),
+ Self::Func(func) => func.call_vt(vt, [x, y])?.cast().at(func.span())?,
+ Self::Array(array) => x
+ .checked_rem(array.len())
+ .and_then(|i| array.get(i))
+ .cloned()
+ .unwrap_or_default(),
+ })
+ }
+}
+
+impl<T: Default> Default for Celled<T> {
+ fn default() -> Self {
+ Self::Value(T::default())
+ }
+}
+
+impl<T: Reflect> Reflect for Celled<T> {
+ fn describe() -> CastInfo {
+ T::describe() + Array::describe() + Func::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Array::castable(value) || Func::castable(value) || T::castable(value)
+ }
+}
+
+impl<T: IntoValue> IntoValue for Celled<T> {
+ fn into_value(self) -> Value {
+ match self {
+ Self::Value(value) => value.into_value(),
+ Self::Func(func) => func.into_value(),
+ Self::Array(arr) => arr.into_value(),
+ }
+ }
+}
+
+impl<T: FromValue> FromValue for Celled<T> {
+ fn from_value(value: Value) -> StrResult<Self> {
+ match value {
+ Value::Func(v) => Ok(Self::Func(v)),
+ Value::Array(array) => Ok(Self::Array(
+ array.into_iter().map(T::from_value).collect::<StrResult<_>>()?,
+ )),
+ v if T::castable(&v) => Ok(Self::Value(T::from_value(v)?)),
+ v => Err(Self::error(&v)),
+ }
+ }
+}
+
+impl LocalName for TableElem {
+ fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "Tabel",
+ Lang::ARABIC => "جدول",
+ Lang::BOKMÅL => "Tabell",
+ Lang::CHINESE => "表",
+ Lang::CZECH => "Tabulka",
+ Lang::DANISH => "Tabel",
+ Lang::DUTCH => "Tabel",
+ Lang::FILIPINO => "Talaan",
+ Lang::FRENCH => "Tableau",
+ Lang::GERMAN => "Tabelle",
+ Lang::ITALIAN => "Tabella",
+ Lang::NYNORSK => "Tabell",
+ Lang::POLISH => "Tabela",
+ Lang::PORTUGUESE => "Tabela",
+ Lang::RUSSIAN => "Таблица",
+ Lang::SLOVENIAN => "Tabela",
+ Lang::SPANISH => "Tabla",
+ Lang::SWEDISH => "Tabell",
+ Lang::TURKISH => "Tablo",
+ Lang::UKRAINIAN => "Таблиця",
+ Lang::VIETNAMESE => "Bảng",
+ Lang::ENGLISH | _ => "Table",
+ }
+ }
+}
+
+impl Figurable for TableElem {}
diff --git a/crates/typst-library/src/layout/terms.rs b/crates/typst-library/src/layout/terms.rs
new file mode 100644
index 00000000..d693f100
--- /dev/null
+++ b/crates/typst-library/src/layout/terms.rs
@@ -0,0 +1,166 @@
+use super::{HElem, VElem};
+use crate::layout::{BlockElem, ParElem, Spacing};
+use crate::prelude::*;
+
+/// A list of terms and their descriptions.
+///
+/// Displays a sequence of terms and their descriptions vertically. When the
+/// descriptions span over multiple lines, they use hanging indent to
+/// communicate the visual hierarchy.
+///
+/// ## Example { #example }
+/// ```example
+/// / Ligature: A merged glyph.
+/// / Kerning: A spacing adjustment
+/// between two adjacent letters.
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: Starting a line with a slash,
+/// followed by a term, a colon and a description creates a term list item.
+///
+/// Display: Term List
+/// Category: layout
+#[element(Layout)]
+#[scope(
+ scope.define("item", TermItem::func());
+ scope
+)]
+pub struct TermsElem {
+ /// If this is `{false}`, the items are spaced apart with [term list
+ /// spacing]($func/terms.spacing). If it is `{true}`, they use normal
+ /// [leading]($func/par.leading) instead. This makes the term list more
+ /// compact, which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`.
+ ///
+ /// ```example
+ /// / Fact: If a term list has a lot
+ /// of text, and maybe other inline
+ /// content, it should not be tight
+ /// anymore.
+ ///
+ /// / Tip: To make it wide, simply
+ /// insert a blank line between the
+ /// items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// The separator between the item and the description.
+ ///
+ /// If you want to just separate them with a certain amount of space, use
+ /// `{h(2cm, weak: true)}` as the separator and replace `{2cm}` with your
+ /// desired amount of space.
+ ///
+ /// ```example
+ /// #set terms(separator: [: ])
+ ///
+ /// / Colon: A nice separator symbol.
+ /// ```
+ #[default(HElem::new(Em::new(0.6).into()).with_weak(true).pack())]
+ pub separator: Content,
+
+ /// The indentation of each item.
+ pub indent: Length,
+
+ /// The hanging indent of the description.
+ ///
+ /// This is in addition to the whole item's `indent`.
+ ///
+ /// ```example
+ /// #set terms(hanging-indent: 0pt)
+ /// / Term: This term list does not
+ /// make use of hanging indents.
+ /// ```
+ #[default(Em::new(2.0).into())]
+ pub hanging_indent: Length,
+
+ /// The spacing between the items of a wide (non-tight) term list.
+ ///
+ /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below).
+ pub spacing: Smart<Spacing>,
+
+ /// The term list's children.
+ ///
+ /// When using the term list syntax, adjacent items are automatically
+ /// collected into term lists, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for (year, product) in (
+ /// "1978": "TeX",
+ /// "1984": "LaTeX",
+ /// "2019": "Typst",
+ /// ) [/ #product: Born in #year.]
+ /// ```
+ #[variadic]
+ pub children: Vec<TermItem>,
+}
+
+impl Layout for TermsElem {
+ #[tracing::instrument(name = "TermsElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let separator = self.separator(styles);
+ let indent = self.indent(styles);
+ let hanging_indent = self.hanging_indent(styles);
+ let gutter = if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ self.spacing(styles)
+ .unwrap_or_else(|| BlockElem::below_in(styles).amount())
+ };
+
+ let mut seq = vec![];
+ for (i, child) in self.children().into_iter().enumerate() {
+ if i > 0 {
+ seq.push(VElem::new(gutter).with_weakness(1).pack());
+ }
+ if !indent.is_zero() {
+ seq.push(HElem::new(indent.into()).pack());
+ }
+ seq.push(child.term().strong());
+ seq.push(separator.clone());
+ seq.push(child.description());
+ }
+
+ Content::sequence(seq)
+ .styled(ParElem::set_hanging_indent(hanging_indent + indent))
+ .layout(vt, styles, regions)
+ }
+}
+
+/// A term list item.
+///
+/// Display: Term List Item
+/// Category: layout
+#[element]
+pub struct TermItem {
+ /// The term described by the list item.
+ #[required]
+ pub term: Content,
+
+ /// The description of the term.
+ #[required]
+ pub description: Content,
+}
+
+cast! {
+ TermItem,
+ array: Array => {
+ let mut iter = array.into_iter();
+ let (term, description) = match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => (a.cast()?, b.cast()?),
+ _ => bail!("array must contain exactly two entries"),
+ };
+ Self::new(term, description)
+ },
+ v: Content => v.to::<Self>().cloned().ok_or("expected term item or array")?,
+}
diff --git a/crates/typst-library/src/layout/transform.rs b/crates/typst-library/src/layout/transform.rs
new file mode 100644
index 00000000..a57a5edc
--- /dev/null
+++ b/crates/typst-library/src/layout/transform.rs
@@ -0,0 +1,194 @@
+use typst::geom::Transform;
+
+use crate::prelude::*;
+
+/// Moves content without affecting layout.
+///
+/// The `move` function allows you to move content while the layout still 'sees'
+/// it at the original positions. Containers will still be sized as if the
+/// content was not moved.
+///
+/// ## Example { #example }
+/// ```example
+/// #rect(inset: 0pt, move(
+/// dx: 6pt, dy: 6pt,
+/// rect(
+/// inset: 8pt,
+/// fill: white,
+/// stroke: black,
+/// [Abra cadabra]
+/// )
+/// ))
+/// ```
+///
+/// Display: Move
+/// Category: layout
+#[element(Layout)]
+pub struct MoveElem {
+ /// The horizontal displacement of the content.
+ pub dx: Rel<Length>,
+
+ /// The vertical displacement of the content.
+ pub dy: Rel<Length>,
+
+ /// The content to move.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for MoveElem {
+ #[tracing::instrument(name = "MoveElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.base(), Axes::splat(false));
+ let mut frame = self.body().layout(vt, styles, pod)?.into_frame();
+ let delta = Axes::new(self.dx(styles), self.dy(styles)).resolve(styles);
+ let delta = delta.zip(regions.base()).map(|(d, s)| d.relative_to(s));
+ frame.translate(delta.to_point());
+ Ok(Fragment::frame(frame))
+ }
+}
+
+/// Rotates content without affecting layout.
+///
+/// Rotates an element by a given angle. The layout will act as if the element
+/// was not rotated.
+///
+/// ## Example { #example }
+/// ```example
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// ..range(16)
+/// .map(i => rotate(24deg * i)[X]),
+/// )
+/// ```
+///
+/// Display: Rotate
+/// Category: layout
+#[element(Layout)]
+pub struct RotateElem {
+ /// The amount of rotation.
+ ///
+ /// ```example
+ /// #rotate(-1.571rad)[Space!]
+ /// ```
+ ///
+ #[positional]
+ pub angle: Angle,
+
+ /// The origin of the rotation.
+ ///
+ /// If, for instance, you wanted the bottom left corner of the rotated
+ /// element to stay aligned with the baseline, you would set it to `bottom +
+ /// left` instead.
+ ///
+ /// ```example
+ /// #set text(spacing: 8pt)
+ /// #let square = square.with(width: 8pt)
+ ///
+ /// #box(square())
+ /// #box(rotate(30deg, origin: center, square()))
+ /// #box(rotate(30deg, origin: top + left, square()))
+ /// #box(rotate(30deg, origin: bottom + right, square()))
+ /// ```
+ #[resolve]
+ #[fold]
+ #[default(Align::CENTER_HORIZON)]
+ pub origin: Axes<Option<GenAlign>>,
+
+ /// The content to rotate.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for RotateElem {
+ #[tracing::instrument(name = "RotateElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.base(), Axes::splat(false));
+ let mut frame = self.body().layout(vt, styles, pod)?.into_frame();
+ let Axes { x, y } =
+ self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s));
+ let ts = Transform::translate(x, y)
+ .pre_concat(Transform::rotate(self.angle(styles)))
+ .pre_concat(Transform::translate(-x, -y));
+ frame.transform(ts);
+ Ok(Fragment::frame(frame))
+ }
+}
+
+/// Scales content without affecting layout.
+///
+/// Lets you mirror content by specifying a negative scale on a single axis.
+///
+/// ## Example { #example }
+/// ```example
+/// #set align(center)
+/// #scale(x: -100%)[This is mirrored.]
+/// ```
+///
+/// Display: Scale
+/// Category: layout
+#[element(Layout)]
+pub struct ScaleElem {
+ /// The horizontal scaling factor.
+ ///
+ /// The body will be mirrored horizontally if the parameter is negative.
+ #[parse(
+ let all = args.find()?;
+ args.named("x")?.or(all)
+ )]
+ #[default(Ratio::one())]
+ pub x: Ratio,
+
+ /// The vertical scaling factor.
+ ///
+ /// The body will be mirrored vertically if the parameter is negative.
+ #[parse(args.named("y")?.or(all))]
+ #[default(Ratio::one())]
+ pub y: Ratio,
+
+ /// The origin of the transformation.
+ ///
+ /// ```example
+ /// A#box(scale(75%)[A])A \
+ /// B#box(scale(75%, origin: bottom + left)[B])B
+ /// ```
+ #[resolve]
+ #[fold]
+ #[default(Align::CENTER_HORIZON)]
+ pub origin: Axes<Option<GenAlign>>,
+
+ /// The content to scale.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for ScaleElem {
+ #[tracing::instrument(name = "ScaleElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.base(), Axes::splat(false));
+ let mut frame = self.body().layout(vt, styles, pod)?.into_frame();
+ let Axes { x, y } =
+ self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s));
+ let transform = Transform::translate(x, y)
+ .pre_concat(Transform::scale(self.x(styles), self.y(styles)))
+ .pre_concat(Transform::translate(-x, -y));
+ frame.transform(transform);
+ Ok(Fragment::frame(frame))
+ }
+}
diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs
new file mode 100644
index 00000000..0bd88501
--- /dev/null
+++ b/crates/typst-library/src/lib.rs
@@ -0,0 +1,141 @@
+//! Typst's standard library.
+
+#![allow(clippy::wildcard_in_or_patterns)]
+#![allow(clippy::manual_range_contains)]
+#![allow(clippy::comparison_chain)]
+
+pub mod compute;
+pub mod layout;
+pub mod math;
+pub mod meta;
+pub mod prelude;
+pub mod shared;
+pub mod symbols;
+pub mod text;
+pub mod visualize;
+
+use typst::diag::At;
+use typst::eval::{LangItems, Library, Module, Scope};
+use typst::geom::Smart;
+use typst::model::{Element, Styles};
+
+use self::layout::LayoutRoot;
+
+/// Construct the standard library.
+pub fn build() -> Library {
+ let math = math::module();
+ let global = global(math.clone());
+ Library { global, math, styles: styles(), items: items() }
+}
+
+/// Construct the module with global definitions.
+#[tracing::instrument(skip_all)]
+fn global(math: Module) -> Module {
+ let mut global = Scope::deduplicating();
+
+ // Categories.
+ text::define(&mut global);
+ layout::define(&mut global);
+ visualize::define(&mut global);
+ meta::define(&mut global);
+ compute::define(&mut global);
+ symbols::define(&mut global);
+ global.define("math", math);
+
+ Module::new("global").with_scope(global)
+}
+
+/// Construct the standard style map.
+fn styles() -> Styles {
+ Styles::new()
+}
+
+/// Construct the standard lang item mapping.
+fn items() -> LangItems {
+ LangItems {
+ layout: |world, content, styles| content.layout_root(world, styles),
+ em: text::TextElem::size_in,
+ dir: text::TextElem::dir_in,
+ space: || text::SpaceElem::new().pack(),
+ linebreak: || text::LinebreakElem::new().pack(),
+ text: |text| text::TextElem::new(text).pack(),
+ text_func: text::TextElem::func(),
+ text_str: |content| Some(content.to::<text::TextElem>()?.text()),
+ smart_quote: |double| text::SmartQuoteElem::new().with_double(double).pack(),
+ parbreak: || layout::ParbreakElem::new().pack(),
+ strong: |body| text::StrongElem::new(body).pack(),
+ emph: |body| text::EmphElem::new(body).pack(),
+ raw: |text, lang, block| {
+ let mut elem = text::RawElem::new(text).with_block(block);
+ if let Some(lang) = lang {
+ elem.push_lang(Some(lang));
+ }
+ elem.pack()
+ },
+ raw_languages: text::RawElem::languages,
+ link: |url| meta::LinkElem::from_url(url).pack(),
+ reference: |target, supplement| {
+ let mut elem = meta::RefElem::new(target);
+ if let Some(supplement) = supplement {
+ elem.push_supplement(Smart::Custom(Some(meta::Supplement::Content(
+ supplement,
+ ))));
+ }
+ elem.pack()
+ },
+ bibliography_keys: meta::BibliographyElem::keys,
+ heading: |level, title| meta::HeadingElem::new(title).with_level(level).pack(),
+ heading_func: meta::HeadingElem::func(),
+ list_item: |body| layout::ListItem::new(body).pack(),
+ enum_item: |number, body| {
+ let mut elem = layout::EnumItem::new(body);
+ if let Some(number) = number {
+ elem.push_number(Some(number));
+ }
+ elem.pack()
+ },
+ term_item: |term, description| layout::TermItem::new(term, description).pack(),
+ equation: |body, block| math::EquationElem::new(body).with_block(block).pack(),
+ math_align_point: || math::AlignPointElem::new().pack(),
+ math_delimited: |open, body, close| math::LrElem::new(open + body + close).pack(),
+ math_attach: |base, t, b, tl, bl, tr, br| {
+ let mut elem = math::AttachElem::new(base);
+ if let Some(t) = t {
+ elem.push_t(Some(t));
+ }
+ if let Some(b) = b {
+ elem.push_b(Some(b));
+ }
+ if let Some(tl) = tl {
+ elem.push_tl(Some(tl));
+ }
+ if let Some(bl) = bl {
+ elem.push_bl(Some(bl));
+ }
+ if let Some(tr) = tr {
+ elem.push_tr(Some(tr));
+ }
+ if let Some(br) = br {
+ elem.push_br(Some(br));
+ }
+ elem.pack()
+ },
+ math_accent: |base, accent| {
+ math::AccentElem::new(base, math::Accent::new(accent)).pack()
+ },
+ math_frac: |num, denom| math::FracElem::new(num, denom).pack(),
+ math_root: |index, radicand| {
+ math::RootElem::new(radicand).with_index(index).pack()
+ },
+ library_method: |vm, dynamic, method, args, span| {
+ if let Some(counter) = dynamic.downcast::<meta::Counter>().cloned() {
+ counter.call_method(vm, method, args, span)
+ } else if let Some(state) = dynamic.downcast::<meta::State>().cloned() {
+ state.call_method(vm, method, args, span)
+ } else {
+ Err(format!("type {} has no method `{method}`", dynamic.type_name()))
+ .at(span)
+ }
+ },
+ }
+}
diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs
new file mode 100644
index 00000000..d1bee198
--- /dev/null
+++ b/crates/typst-library/src/math/accent.rs
@@ -0,0 +1,139 @@
+use super::*;
+
+/// How much the accent can be shorter than the base.
+const ACCENT_SHORT_FALL: Em = Em::new(0.5);
+
+/// Attaches an accent to a base.
+///
+/// ## Example { #example }
+/// ```example
+/// $grave(a) = accent(a, `)$ \
+/// $arrow(a) = accent(a, arrow)$ \
+/// $tilde(a) = accent(a, \u{0303})$
+/// ```
+///
+/// Display: Accent
+/// Category: math
+#[element(LayoutMath)]
+pub struct AccentElem {
+ /// The base to which the accent is applied.
+ /// May consist of multiple letters.
+ ///
+ /// ```example
+ /// $arrow(A B C)$
+ /// ```
+ #[required]
+ pub base: Content,
+
+ /// The accent to apply to the base.
+ ///
+ /// Supported accents include:
+ ///
+ /// | Accent | Name | Codepoint |
+ /// | ------------- | --------------- | --------- |
+ /// | Grave | `grave` | <code>&DiacriticalGrave;</code> |
+ /// | Acute | `acute` | `´` |
+ /// | Circumflex | `hat` | `^` |
+ /// | Tilde | `tilde` | `~` |
+ /// | Macron | `macron` | `¯` |
+ /// | Breve | `breve` | `˘` |
+ /// | Dot | `dot` | `.` |
+ /// | Double dot | `dot.double` | `¨` |
+ /// | Triple dot | `dot.triple` | <code>&tdot;</code> |
+ /// | Quadruple dot | `dot.quad` | <code>&DotDot;</code> |
+ /// | Diaeresis | `diaer` | `¨` |
+ /// | Circle | `circle` | `∘` |
+ /// | Double acute | `acute.double` | `˝` |
+ /// | Caron | `caron` | `ˇ` |
+ /// | Right arrow | `arrow`, `->` | `→` |
+ /// | Left arrow | `arrow.l`, `<-` | `←` |
+ #[required]
+ pub accent: Accent,
+}
+
+impl LayoutMath for AccentElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ ctx.style(ctx.style.with_cramped(true));
+ let base = ctx.layout_fragment(&self.base())?;
+ ctx.unstyle();
+
+ // Preserve class to preserve automatic spacing.
+ let base_class = base.class().unwrap_or(MathClass::Normal);
+ let base_attach = match &base {
+ MathFragment::Glyph(base) => {
+ attachment(ctx, base.id, base.italics_correction)
+ }
+ _ => (base.width() + base.italics_correction()) / 2.0,
+ };
+
+ // Forcing the accent to be at least as large as the base makes it too
+ // wide in many case.
+ let Accent(c) = self.accent();
+ let glyph = GlyphFragment::new(ctx, c, self.span());
+ let short_fall = ACCENT_SHORT_FALL.scaled(ctx);
+ let variant = glyph.stretch_horizontal(ctx, base.width(), short_fall);
+ let accent = variant.frame;
+ let accent_attach = match variant.id {
+ Some(id) => attachment(ctx, id, variant.italics_correction),
+ None => accent.width() / 2.0,
+ };
+
+ // Descent is negative because the accent's ink bottom is above the
+ // baseline. Therefore, the default gap is the accent's negated descent
+ // minus the accent base height. Only if the base is very small, we need
+ // a larger gap so that the accent doesn't move too low.
+ let accent_base_height = scaled!(ctx, accent_base_height);
+ let gap = -accent.descent() - base.height().min(accent_base_height);
+ let size = Size::new(base.width(), accent.height() + gap + base.height());
+ let accent_pos = Point::with_x(base_attach - accent_attach);
+ let base_pos = Point::with_y(accent.height() + gap);
+ let base_ascent = base.ascent();
+ let baseline = base_pos.y + base.ascent();
+
+ let mut frame = Frame::new(size);
+ frame.set_baseline(baseline);
+ frame.push_frame(accent_pos, accent);
+ frame.push_frame(base_pos, base.into_frame());
+ ctx.push(
+ FrameFragment::new(ctx, frame)
+ .with_class(base_class)
+ .with_base_ascent(base_ascent),
+ );
+
+ Ok(())
+ }
+}
+
+/// The horizontal attachment position for the given glyph.
+fn attachment(ctx: &MathContext, id: GlyphId, italics_correction: Abs) -> Abs {
+ ctx.table
+ .glyph_info
+ .and_then(|info| info.top_accent_attachments)
+ .and_then(|attachments| attachments.get(id))
+ .map(|record| record.value.scaled(ctx))
+ .unwrap_or_else(|| {
+ let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default();
+ (advance.scaled(ctx) + italics_correction) / 2.0
+ })
+}
+
+/// An accent character.
+pub struct Accent(char);
+
+impl Accent {
+ /// Normalize a character into an accent.
+ pub fn new(c: char) -> Self {
+ Self(Symbol::combining_accent(c).unwrap_or(c))
+ }
+}
+
+cast! {
+ Accent,
+ self => self.0.into_value(),
+ v: char => Self::new(v),
+ v: Content => match v.to::<TextElem>() {
+ Some(elem) => Value::Str(elem.text().into()).cast()?,
+ None => bail!("expected text"),
+ },
+}
diff --git a/crates/typst-library/src/math/align.rs b/crates/typst-library/src/math/align.rs
new file mode 100644
index 00000000..aee89a89
--- /dev/null
+++ b/crates/typst-library/src/math/align.rs
@@ -0,0 +1,63 @@
+use super::*;
+
+/// A math alignment point: `&`, `&&`.
+///
+/// Display: Alignment Point
+/// Category: math
+#[element(LayoutMath)]
+pub struct AlignPointElem {}
+
+impl LayoutMath for AlignPointElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ ctx.push(MathFragment::Align);
+ Ok(())
+ }
+}
+
+pub(super) struct AlignmentResult {
+ pub points: Vec<Abs>,
+ pub width: Abs,
+}
+
+/// Determine the position of the alignment points.
+pub(super) fn alignments(rows: &[MathRow]) -> AlignmentResult {
+ let mut widths = Vec::<Abs>::new();
+
+ let mut pending_width = Abs::zero();
+ for row in rows {
+ let mut width = Abs::zero();
+ let mut alignment_index = 0;
+
+ for fragment in row.iter() {
+ if matches!(fragment, MathFragment::Align) {
+ if alignment_index < widths.len() {
+ widths[alignment_index].set_max(width);
+ } else {
+ widths.push(width.max(pending_width));
+ }
+ width = Abs::zero();
+ alignment_index += 1;
+ } else {
+ width += fragment.width();
+ }
+ }
+ if widths.is_empty() {
+ pending_width.set_max(width);
+ } else if alignment_index < widths.len() {
+ widths[alignment_index].set_max(width);
+ } else {
+ widths.push(width.max(pending_width));
+ }
+ }
+
+ let mut points = widths;
+ for i in 1..points.len() {
+ let prev = points[i - 1];
+ points[i] += prev;
+ }
+ AlignmentResult {
+ width: points.last().copied().unwrap_or(pending_width),
+ points,
+ }
+}
diff --git a/crates/typst-library/src/math/attach.rs b/crates/typst-library/src/math/attach.rs
new file mode 100644
index 00000000..fedeb908
--- /dev/null
+++ b/crates/typst-library/src/math/attach.rs
@@ -0,0 +1,411 @@
+use super::*;
+
+/// A base with optional attachments.
+///
+/// ## Example { #example }
+/// ```example
+/// // With syntax.
+/// $ sum_(i=0)^n a_i = 2^(1+i) $
+///
+/// // With function call.
+/// $ attach(
+/// Pi, t: alpha, b: beta,
+/// tl: 1, tr: 2+3, bl: 4+5, br: 6,
+/// ) $
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax for attachments after the base: Use
+/// the underscore (`_`) to indicate a subscript i.e. bottom attachment and the
+/// hat (`^`) to indicate a superscript i.e. top attachment.
+///
+/// Display: Attachment
+/// Category: math
+#[element(LayoutMath)]
+pub struct AttachElem {
+ /// The base to which things are attached.
+ #[required]
+ pub base: Content,
+
+ /// The top attachment, smartly positioned at top-right or above the base.
+ ///
+ /// You can wrap the base in `{limits()}` or `{scripts()}` to override the
+ /// smart positioning.
+ pub t: Option<Content>,
+
+ /// The bottom attachment, smartly positioned at the bottom-right or below
+ /// the base.
+ ///
+ /// You can wrap the base in `{limits()}` or `{scripts()}` to override the
+ /// smart positioning.
+ pub b: Option<Content>,
+
+ /// The top-left attachment (before the base).
+ pub tl: Option<Content>,
+
+ /// The bottom-left attachment (before base).
+ pub bl: Option<Content>,
+
+ /// The top-right attachment (after the base).
+ pub tr: Option<Content>,
+
+ /// The bottom-right attachment (after the base).
+ pub br: Option<Content>,
+}
+
+impl LayoutMath for AttachElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ type GetAttachment = fn(&AttachElem, styles: StyleChain) -> Option<Content>;
+ let layout_attachment = |ctx: &mut MathContext, getter: GetAttachment| {
+ getter(self, ctx.styles())
+ .map(|elem| ctx.layout_fragment(&elem))
+ .transpose()
+ };
+
+ let base = ctx.layout_fragment(&self.base())?;
+
+ ctx.style(ctx.style.for_superscript());
+ let tl = layout_attachment(ctx, Self::tl)?;
+ let tr = layout_attachment(ctx, Self::tr)?;
+ let t = layout_attachment(ctx, Self::t)?;
+ ctx.unstyle();
+
+ ctx.style(ctx.style.for_subscript());
+ let bl = layout_attachment(ctx, Self::bl)?;
+ let br = layout_attachment(ctx, Self::br)?;
+ let b = layout_attachment(ctx, Self::b)?;
+ ctx.unstyle();
+
+ let limits = base.limits().active(ctx);
+ let (t, tr) = if limits || tr.is_some() { (t, tr) } else { (None, t) };
+ let (b, br) = if limits || br.is_some() { (b, br) } else { (None, b) };
+ layout_attachments(ctx, base, [tl, t, tr, bl, b, br])
+ }
+}
+
+/// Forces a base to display attachments as scripts.
+///
+/// ## Example { #example }
+/// ```example
+/// $ scripts(sum)_1^2 != sum_1^2 $
+/// ```
+///
+/// Display: Scripts
+/// Category: math
+#[element(LayoutMath)]
+pub struct ScriptsElem {
+ /// The base to attach the scripts to.
+ #[required]
+ pub body: Content,
+}
+
+impl LayoutMath for ScriptsElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let mut fragment = ctx.layout_fragment(&self.body())?;
+ fragment.set_limits(Limits::Never);
+ ctx.push(fragment);
+ Ok(())
+ }
+}
+
+/// Forces a base to display attachments as limits.
+///
+/// ## Example { #example }
+/// ```example
+/// $ limits(A)_1^2 != A_1^2 $
+/// ```
+///
+/// Display: Limits
+/// Category: math
+#[element(LayoutMath)]
+pub struct LimitsElem {
+ /// The base to attach the limits to.
+ #[required]
+ pub body: Content,
+
+ /// Whether to also force limits in inline equations.
+ ///
+ /// When applying limits globally (e.g., through a show rule), it is
+ /// typically a good idea to disable this.
+ #[default(true)]
+ pub inline: bool,
+}
+
+impl LayoutMath for LimitsElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let mut fragment = ctx.layout_fragment(&self.body())?;
+ fragment.set_limits(if self.inline(ctx.styles()) {
+ Limits::Always
+ } else {
+ Limits::Display
+ });
+ ctx.push(fragment);
+ Ok(())
+ }
+}
+
+/// Describes in which situation a frame should use limits for attachments.
+#[derive(Debug, Copy, Clone)]
+pub enum Limits {
+ /// Always scripts.
+ Never,
+ /// Display limits only in `display` math.
+ Display,
+ /// Always limits.
+ Always,
+}
+
+impl Limits {
+ /// The default limit configuration if the given character is the base.
+ pub fn for_char(c: char) -> Self {
+ if Self::DEFAULT_TO_LIMITS.contains(&c) {
+ Limits::Display
+ } else {
+ Limits::Never
+ }
+ }
+
+ /// Whether limits should be displayed in this context
+ pub fn active(&self, ctx: &MathContext) -> bool {
+ match self {
+ Self::Always => true,
+ Self::Display => ctx.style.size == MathSize::Display,
+ Self::Never => false,
+ }
+ }
+
+ /// Unicode codepoints that should show attachments as limits in display
+ /// mode.
+ #[rustfmt::skip]
+ const DEFAULT_TO_LIMITS: &[char] = &[
+ /* ∏ */ '\u{220F}', /* ∐ */ '\u{2210}', /* ∑ */ '\u{2211}',
+ /* ⋀ */ '\u{22C0}', /* ⋁ */ '\u{22C1}',
+ /* ⋂ */ '\u{22C2}', /* ⋃ */ '\u{22C3}',
+ /* ⨀ */ '\u{2A00}', /* ⨁ */ '\u{2A01}', /* ⨂ */ '\u{2A02}',
+ /* ⨃ */ '\u{2A03}', /* ⨄ */ '\u{2A04}',
+ /* ⨅ */ '\u{2A05}', /* ⨆ */ '\u{2A06}',
+ ];
+}
+
+macro_rules! measure {
+ ($e: ident, $attr: ident) => {
+ $e.as_ref().map(|e| e.$attr()).unwrap_or_default()
+ };
+}
+
+/// Layout the attachments.
+fn layout_attachments(
+ ctx: &mut MathContext,
+ base: MathFragment,
+ [tl, t, tr, bl, b, br]: [Option<MathFragment>; 6],
+) -> SourceResult<()> {
+ let (shift_up, shift_down) =
+ compute_shifts_up_and_down(ctx, &base, [&tl, &tr, &bl, &br]);
+
+ let sup_delta = Abs::zero();
+ let sub_delta = -base.italics_correction();
+ let (base_width, base_ascent, base_descent) =
+ (base.width(), base.ascent(), base.descent());
+ let base_class = base.class().unwrap_or(MathClass::Normal);
+
+ let ascent = base_ascent
+ .max(shift_up + measure!(tr, ascent))
+ .max(shift_up + measure!(tl, ascent))
+ .max(shift_up + measure!(t, height));
+
+ let descent = base_descent
+ .max(shift_down + measure!(br, descent))
+ .max(shift_down + measure!(bl, descent))
+ .max(shift_down + measure!(b, height));
+
+ let pre_sup_width = measure!(tl, width);
+ let pre_sub_width = measure!(bl, width);
+ let pre_width_dif = pre_sup_width - pre_sub_width; // Could be negative.
+ let pre_width_max = pre_sup_width.max(pre_sub_width);
+ let post_max_width =
+ (sup_delta + measure!(tr, width)).max(sub_delta + measure!(br, width));
+
+ let (center_frame, base_offset) = attach_top_and_bottom(ctx, base, t, b);
+ let base_pos =
+ Point::new(sup_delta + pre_width_max, ascent - base_ascent - base_offset);
+ if [&tl, &bl, &tr, &br].iter().all(|&e| e.is_none()) {
+ ctx.push(FrameFragment::new(ctx, center_frame).with_class(base_class));
+ return Ok(());
+ }
+
+ let mut frame = Frame::new(Size::new(
+ pre_width_max + base_width + post_max_width + scaled!(ctx, space_after_script),
+ ascent + descent,
+ ));
+ frame.set_baseline(ascent);
+ frame.push_frame(base_pos, center_frame);
+
+ if let Some(tl) = tl {
+ let pos =
+ Point::new(-pre_width_dif.min(Abs::zero()), ascent - shift_up - tl.ascent());
+ frame.push_frame(pos, tl.into_frame());
+ }
+
+ if let Some(bl) = bl {
+ let pos =
+ Point::new(pre_width_dif.max(Abs::zero()), ascent + shift_down - bl.ascent());
+ frame.push_frame(pos, bl.into_frame());
+ }
+
+ if let Some(tr) = tr {
+ let pos = Point::new(
+ sup_delta + pre_width_max + base_width,
+ ascent - shift_up - tr.ascent(),
+ );
+ frame.push_frame(pos, tr.into_frame());
+ }
+
+ if let Some(br) = br {
+ let pos = Point::new(
+ sub_delta + pre_width_max + base_width,
+ ascent + shift_down - br.ascent(),
+ );
+ frame.push_frame(pos, br.into_frame());
+ }
+
+ ctx.push(FrameFragment::new(ctx, frame).with_class(base_class));
+
+ Ok(())
+}
+
+fn attach_top_and_bottom(
+ ctx: &mut MathContext,
+ base: MathFragment,
+ t: Option<MathFragment>,
+ b: Option<MathFragment>,
+) -> (Frame, Abs) {
+ let upper_gap_min = scaled!(ctx, upper_limit_gap_min);
+ let upper_rise_min = scaled!(ctx, upper_limit_baseline_rise_min);
+ let lower_gap_min = scaled!(ctx, lower_limit_gap_min);
+ let lower_drop_min = scaled!(ctx, lower_limit_baseline_drop_min);
+
+ let mut base_offset = Abs::zero();
+ let mut width = base.width();
+ let mut height = base.height();
+
+ if let Some(t) = &t {
+ let top_gap = upper_gap_min.max(upper_rise_min - t.descent());
+ width.set_max(t.width());
+ height += t.height() + top_gap;
+ base_offset = top_gap + t.height();
+ }
+
+ if let Some(b) = &b {
+ let bottom_gap = lower_gap_min.max(lower_drop_min - b.ascent());
+ width.set_max(b.width());
+ height += b.height() + bottom_gap;
+ }
+
+ let base_pos = Point::new((width - base.width()) / 2.0, base_offset);
+ let delta = base.italics_correction() / 2.0;
+
+ let mut frame = Frame::new(Size::new(width, height));
+ frame.set_baseline(base_pos.y + base.ascent());
+ frame.push_frame(base_pos, base.into_frame());
+
+ if let Some(t) = t {
+ let top_pos = Point::with_x((width - t.width()) / 2.0 + delta);
+ frame.push_frame(top_pos, t.into_frame());
+ }
+
+ if let Some(b) = b {
+ let bottom_pos =
+ Point::new((width - b.width()) / 2.0 - delta, height - b.height());
+ frame.push_frame(bottom_pos, b.into_frame());
+ }
+
+ (frame, base_offset)
+}
+
+fn compute_shifts_up_and_down(
+ ctx: &MathContext,
+ base: &MathFragment,
+ [tl, tr, bl, br]: [&Option<MathFragment>; 4],
+) -> (Abs, Abs) {
+ let sup_shift_up = if ctx.style.cramped {
+ scaled!(ctx, superscript_shift_up_cramped)
+ } else {
+ scaled!(ctx, superscript_shift_up)
+ };
+
+ let sup_bottom_min = scaled!(ctx, superscript_bottom_min);
+ let sup_bottom_max_with_sub = scaled!(ctx, superscript_bottom_max_with_subscript);
+ let sup_drop_max = scaled!(ctx, superscript_baseline_drop_max);
+ let gap_min = scaled!(ctx, sub_superscript_gap_min);
+ let sub_shift_down = scaled!(ctx, subscript_shift_down);
+ let sub_top_max = scaled!(ctx, subscript_top_max);
+ let sub_drop_min = scaled!(ctx, subscript_baseline_drop_min);
+
+ let mut shift_up = Abs::zero();
+ let mut shift_down = Abs::zero();
+ let is_char_box = is_character_box(base);
+
+ if tl.is_some() || tr.is_some() {
+ let ascent = match &base {
+ MathFragment::Frame(frame) => frame.base_ascent,
+ _ => base.ascent(),
+ };
+ shift_up = shift_up
+ .max(sup_shift_up)
+ .max(if is_char_box { Abs::zero() } else { ascent - sup_drop_max })
+ .max(sup_bottom_min + measure!(tl, descent))
+ .max(sup_bottom_min + measure!(tr, descent));
+ }
+
+ if bl.is_some() || br.is_some() {
+ shift_down = shift_down
+ .max(sub_shift_down)
+ .max(if is_char_box { Abs::zero() } else { base.descent() + sub_drop_min })
+ .max(measure!(bl, ascent) - sub_top_max)
+ .max(measure!(br, ascent) - sub_top_max);
+ }
+
+ for (sup, sub) in [(tl, bl), (tr, br)] {
+ if let (Some(sup), Some(sub)) = (&sup, &sub) {
+ let sup_bottom = shift_up - sup.descent();
+ let sub_top = sub.ascent() - shift_down;
+ let gap = sup_bottom - sub_top;
+ if gap >= gap_min {
+ continue;
+ }
+
+ let increase = gap_min - gap;
+ let sup_only =
+ (sup_bottom_max_with_sub - sup_bottom).clamp(Abs::zero(), increase);
+ let rest = (increase - sup_only) / 2.0;
+ shift_up += sup_only + rest;
+ shift_down += rest;
+ }
+ }
+
+ (shift_up, shift_down)
+}
+
+/// Whether the fragment consists of a single character or atomic piece of text.
+fn is_character_box(fragment: &MathFragment) -> bool {
+ match fragment {
+ MathFragment::Glyph(_) | MathFragment::Variant(_) => {
+ fragment.class() != Some(MathClass::Large)
+ }
+ MathFragment::Frame(fragment) => is_atomic_text_frame(&fragment.frame),
+ _ => false,
+ }
+}
+
+/// Handles e.g. "sin", "log", "exp", "CustomOperator".
+fn is_atomic_text_frame(frame: &Frame) -> bool {
+ // Meta information isn't visible or renderable, so we exclude it.
+ let mut iter = frame
+ .items()
+ .map(|(_, item)| item)
+ .filter(|item| !matches!(item, FrameItem::Meta(_, _)));
+ matches!(iter.next(), Some(FrameItem::Text(_))) && iter.next().is_none()
+}
diff --git a/crates/typst-library/src/math/cancel.rs b/crates/typst-library/src/math/cancel.rs
new file mode 100644
index 00000000..f576a727
--- /dev/null
+++ b/crates/typst-library/src/math/cancel.rs
@@ -0,0 +1,187 @@
+use super::*;
+
+/// Displays a diagonal line over a part of an equation.
+///
+/// This is commonly used to show the elimination of a term.
+///
+/// ## Example { #example }
+/// ```example
+/// >>> #set page(width: 140pt)
+/// Here, we can simplify:
+/// $ (a dot b dot cancel(x)) /
+/// cancel(x) $
+/// ```
+///
+/// Display: Cancel
+/// Category: math
+#[element(LayoutMath)]
+pub struct CancelElem {
+ /// The content over which the line should be placed.
+ #[required]
+ pub body: Content,
+
+ /// The length of the line, relative to the length of the diagonal spanning
+ /// the whole element being "cancelled". A value of `{100%}` would then have
+ /// the line span precisely the element's diagonal.
+ ///
+ /// ```example
+ /// >>> #set page(width: 140pt)
+ /// $ a + cancel(x, length: #200%)
+ /// - cancel(x, length: #200%) $
+ /// ```
+ #[default(Rel::new(Ratio::one(), Abs::pt(3.0).into()))]
+ pub length: Rel<Length>,
+
+ /// If the cancel line should be inverted (pointing to the top left instead
+ /// of top right).
+ ///
+ /// ```example
+ /// >>> #set page(width: 140pt)
+ /// $ (a cancel((b + c), inverted: #true)) /
+ /// cancel(b + c, inverted: #true) $
+ /// ```
+ #[default(false)]
+ pub inverted: bool,
+
+ /// If two opposing cancel lines should be drawn, forming a cross over the
+ /// element. Overrides `inverted`.
+ ///
+ /// ```example
+ /// >>> #set page(width: 140pt)
+ /// $ cancel(Pi, cross: #true) $
+ /// ```
+ #[default(false)]
+ pub cross: bool,
+
+ /// How to rotate the cancel line. See the [line's
+ /// documentation]($func/line.angle) for more details.
+ ///
+ /// ```example
+ /// >>> #set page(width: 140pt)
+ /// $ cancel(Pi, rotation: #30deg) $
+ /// ```
+ #[default(Angle::zero())]
+ pub rotation: Angle,
+
+ /// How to stroke the cancel line. See the
+ /// [line's documentation]($func/line.stroke) for more details.
+ ///
+ /// ```example
+ /// >>> #set page(width: 140pt)
+ /// $ cancel(
+ /// sum x,
+ /// stroke: #(
+ /// paint: red,
+ /// thickness: 1.5pt,
+ /// dash: "dashed",
+ /// ),
+ /// ) $
+ /// ```
+ #[resolve]
+ #[fold]
+ #[default(PartialStroke {
+ // Default stroke has 0.5pt for better visuals.
+ thickness: Smart::Custom(Abs::pt(0.5)),
+ ..Default::default()
+ })]
+ pub stroke: PartialStroke,
+}
+
+impl LayoutMath for CancelElem {
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let body = ctx.layout_fragment(&self.body())?;
+ // Use the same math class as the body, in order to preserve automatic spacing around it.
+ let body_class = body.class().unwrap_or(MathClass::Special);
+ let mut body = body.into_frame();
+
+ let styles = ctx.styles();
+ let body_size = body.size();
+ let span = self.span();
+ let length = self.length(styles).resolve(styles);
+
+ let stroke = self.stroke(styles).unwrap_or(Stroke {
+ paint: TextElem::fill_in(styles),
+ ..Default::default()
+ });
+
+ let invert = self.inverted(styles);
+ let cross = self.cross(styles);
+ let angle = self.rotation(styles);
+
+ let invert_first_line = !cross && invert;
+ let first_line = draw_cancel_line(
+ length,
+ stroke.clone(),
+ invert_first_line,
+ angle,
+ body_size,
+ span,
+ );
+
+ // The origin of our line is the very middle of the element.
+ let center = body_size.to_point() / 2.0;
+ body.push_frame(center, first_line);
+
+ if cross {
+ // Draw the second line.
+ let second_line =
+ draw_cancel_line(length, stroke, true, angle, body_size, span);
+
+ body.push_frame(center, second_line);
+ }
+
+ ctx.push(FrameFragment::new(ctx, body).with_class(body_class));
+
+ Ok(())
+ }
+}
+
+/// Draws a cancel line.
+fn draw_cancel_line(
+ length: Rel<Abs>,
+ stroke: Stroke,
+ invert: bool,
+ angle: Angle,
+ body_size: Size,
+ span: Span,
+) -> Frame {
+ // B
+ // /|
+ // diagonal / | height
+ // / |
+ // / |
+ // O ----
+ // width
+ let diagonal = body_size.to_point().hypot();
+ let length = length.relative_to(diagonal);
+ let (width, height) = (body_size.x, body_size.y);
+ let mid = body_size / 2.0;
+
+ // Scale the amount needed such that the cancel line has the given 'length'
+ // (reference length, or 100%, is the whole diagonal).
+ // Scales from the center.
+ let scale = length.to_raw() / diagonal.to_raw();
+
+ // invert horizontally if 'invert' was given
+ let scale_x = scale * if invert { -1.0 } else { 1.0 };
+ let scale_y = scale;
+ let scales = Axes::new(scale_x, scale_y);
+
+ // Draw a line from bottom left to top right of the given element, where the
+ // origin represents the very middle of that element, that is, a line from
+ // (-width / 2, height / 2) with length components (width, -height) (sign is
+ // inverted in the y-axis). After applying the scale, the line will have the
+ // correct length and orientation (inverted if needed).
+ let start = Axes::new(-mid.x, mid.y).zip(scales).map(|(l, s)| l * s);
+ let delta = Axes::new(width, -height).zip(scales).map(|(l, s)| l * s);
+
+ let mut frame = Frame::new(body_size);
+ frame.push(
+ start.to_point(),
+ FrameItem::Shape(Geometry::Line(delta.to_point()).stroked(stroke), span),
+ );
+
+ // Having the middle of the line at the origin is convenient here.
+ frame.transform(Transform::rotate(angle));
+ frame
+}
diff --git a/crates/typst-library/src/math/ctx.rs b/crates/typst-library/src/math/ctx.rs
new file mode 100644
index 00000000..a1dc6cf4
--- /dev/null
+++ b/crates/typst-library/src/math/ctx.rs
@@ -0,0 +1,268 @@
+use ttf_parser::math::MathValue;
+use typst::font::{FontStyle, FontWeight};
+use typst::model::realize;
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::*;
+
+macro_rules! scaled {
+ ($ctx:expr, text: $text:ident, display: $display:ident $(,)?) => {
+ match $ctx.style.size {
+ MathSize::Display => scaled!($ctx, $display),
+ _ => scaled!($ctx, $text),
+ }
+ };
+ ($ctx:expr, $name:ident) => {
+ $ctx.constants.$name().scaled($ctx)
+ };
+}
+
+macro_rules! percent {
+ ($ctx:expr, $name:ident) => {
+ $ctx.constants.$name() as f64 / 100.0
+ };
+}
+
+/// The context for math layout.
+pub struct MathContext<'a, 'b, 'v> {
+ pub vt: &'v mut Vt<'b>,
+ pub regions: Regions<'static>,
+ pub font: &'a Font,
+ pub ttf: &'a ttf_parser::Face<'a>,
+ pub table: ttf_parser::math::Table<'a>,
+ pub constants: ttf_parser::math::Constants<'a>,
+ pub ssty_table: Option<ttf_parser::gsub::AlternateSubstitution<'a>>,
+ pub space_width: Em,
+ pub fragments: Vec<MathFragment>,
+ pub local: Styles,
+ pub style: MathStyle,
+ pub size: Abs,
+ outer: StyleChain<'a>,
+ style_stack: Vec<(MathStyle, Abs)>,
+}
+
+impl<'a, 'b, 'v> MathContext<'a, 'b, 'v> {
+ pub fn new(
+ vt: &'v mut Vt<'b>,
+ styles: StyleChain<'a>,
+ regions: Regions,
+ font: &'a Font,
+ block: bool,
+ ) -> Self {
+ let table = font.ttf().tables().math.unwrap();
+ let constants = table.constants.unwrap();
+
+ let ssty_table = font
+ .ttf()
+ .tables()
+ .gsub
+ .and_then(|gsub| {
+ gsub.features
+ .find(ttf_parser::Tag::from_bytes(b"ssty"))
+ .and_then(|feature| feature.lookup_indices.get(0))
+ .and_then(|index| gsub.lookups.get(index))
+ })
+ .and_then(|ssty| {
+ ssty.subtables.get::<ttf_parser::gsub::SubstitutionSubtable>(0)
+ })
+ .and_then(|ssty| match ssty {
+ ttf_parser::gsub::SubstitutionSubtable::Alternate(alt_glyphs) => {
+ Some(alt_glyphs)
+ }
+ _ => None,
+ });
+
+ let size = TextElem::size_in(styles);
+ let ttf = font.ttf();
+ let space_width = ttf
+ .glyph_index(' ')
+ .and_then(|id| ttf.glyph_hor_advance(id))
+ .map(|advance| font.to_em(advance))
+ .unwrap_or(THICK);
+
+ let variant = variant(styles);
+ Self {
+ vt,
+ regions: Regions::one(regions.base(), Axes::splat(false)),
+ font,
+ ttf: font.ttf(),
+ table,
+ constants,
+ ssty_table,
+ space_width,
+ fragments: vec![],
+ local: Styles::new(),
+ style: MathStyle {
+ variant: MathVariant::Serif,
+ size: if block { MathSize::Display } else { MathSize::Text },
+ cramped: false,
+ bold: variant.weight >= FontWeight::BOLD,
+ italic: match variant.style {
+ FontStyle::Normal => Smart::Auto,
+ FontStyle::Italic | FontStyle::Oblique => Smart::Custom(true),
+ },
+ },
+ size,
+ outer: styles,
+ style_stack: vec![],
+ }
+ }
+
+ pub fn push(&mut self, fragment: impl Into<MathFragment>) {
+ self.fragments.push(fragment.into());
+ }
+
+ pub fn extend(&mut self, fragments: Vec<MathFragment>) {
+ self.fragments.extend(fragments);
+ }
+
+ pub fn layout_fragment(
+ &mut self,
+ elem: &dyn LayoutMath,
+ ) -> SourceResult<MathFragment> {
+ let row = self.layout_fragments(elem)?;
+ Ok(MathRow::new(row).into_fragment(self))
+ }
+
+ pub fn layout_fragments(
+ &mut self,
+ elem: &dyn LayoutMath,
+ ) -> SourceResult<Vec<MathFragment>> {
+ let prev = std::mem::take(&mut self.fragments);
+ elem.layout_math(self)?;
+ Ok(std::mem::replace(&mut self.fragments, prev))
+ }
+
+ pub fn layout_row(&mut self, elem: &dyn LayoutMath) -> SourceResult<MathRow> {
+ let fragments = self.layout_fragments(elem)?;
+ Ok(MathRow::new(fragments))
+ }
+
+ pub fn layout_frame(&mut self, elem: &dyn LayoutMath) -> SourceResult<Frame> {
+ Ok(self.layout_fragment(elem)?.into_frame())
+ }
+
+ pub fn layout_content(&mut self, content: &Content) -> SourceResult<Frame> {
+ Ok(content
+ .layout(self.vt, self.outer.chain(&self.local), self.regions)?
+ .into_frame())
+ }
+
+ pub fn layout_text(&mut self, elem: &TextElem) -> SourceResult<MathFragment> {
+ let text = elem.text();
+ let span = elem.span();
+ let mut chars = text.chars();
+ let fragment = if let Some(mut glyph) = chars
+ .next()
+ .filter(|_| chars.next().is_none())
+ .map(|c| self.style.styled_char(c))
+ .and_then(|c| GlyphFragment::try_new(self, c, span))
+ {
+ // A single letter that is available in the math font.
+ match self.style.size {
+ MathSize::Display => {
+ if glyph.class == Some(MathClass::Large) {
+ let height = scaled!(self, display_operator_min_height);
+ glyph.stretch_vertical(self, height, Abs::zero()).into()
+ } else {
+ glyph.into()
+ }
+ }
+ MathSize::Script => {
+ glyph.make_scriptsize(self);
+ glyph.into()
+ }
+ MathSize::ScriptScript => {
+ glyph.make_scriptscriptsize(self);
+ glyph.into()
+ }
+ _ => glyph.into(),
+ }
+ } else if text.chars().all(|c| c.is_ascii_digit()) {
+ // Numbers aren't that difficult.
+ let mut fragments = vec![];
+ for c in text.chars() {
+ let c = self.style.styled_char(c);
+ fragments.push(GlyphFragment::new(self, c, span).into());
+ }
+ let frame = MathRow::new(fragments).into_frame(self);
+ FrameFragment::new(self, frame).into()
+ } else {
+ // Anything else is handled by Typst's standard text layout.
+ let spaced = text.graphemes(true).nth(1).is_some();
+ let mut style = self.style;
+ if self.style.italic == Smart::Auto {
+ style = style.with_italic(false);
+ }
+ let text: EcoString = text.chars().map(|c| style.styled_char(c)).collect();
+ let frame = self.layout_content(&TextElem::packed(text).spanned(span))?;
+ FrameFragment::new(self, frame)
+ .with_class(MathClass::Alphabetic)
+ .with_spaced(spaced)
+ .into()
+ };
+ Ok(fragment)
+ }
+
+ pub fn styles(&self) -> StyleChain {
+ self.outer.chain(&self.local)
+ }
+
+ pub fn realize(&mut self, content: &Content) -> SourceResult<Option<Content>> {
+ realize(self.vt, content, self.outer.chain(&self.local))
+ }
+
+ pub fn style(&mut self, style: MathStyle) {
+ self.style_stack.push((self.style, self.size));
+ let base_size = TextElem::size_in(self.styles()) / self.style.size.factor(self);
+ self.size = base_size * style.size.factor(self);
+ self.local.set(TextElem::set_size(TextSize(self.size.into())));
+ self.local
+ .set(TextElem::set_style(if style.italic == Smart::Custom(true) {
+ FontStyle::Italic
+ } else {
+ FontStyle::Normal
+ }));
+ self.local.set(TextElem::set_weight(if style.bold {
+ FontWeight::BOLD
+ } else {
+ FontWeight::REGULAR
+ }));
+ self.style = style;
+ }
+
+ pub fn unstyle(&mut self) {
+ (self.style, self.size) = self.style_stack.pop().unwrap();
+ self.local.unset();
+ self.local.unset();
+ self.local.unset();
+ }
+}
+
+pub(super) trait Scaled {
+ fn scaled(self, ctx: &MathContext) -> Abs;
+}
+
+impl Scaled for i16 {
+ fn scaled(self, ctx: &MathContext) -> Abs {
+ ctx.font.to_em(self).scaled(ctx)
+ }
+}
+
+impl Scaled for u16 {
+ fn scaled(self, ctx: &MathContext) -> Abs {
+ ctx.font.to_em(self).scaled(ctx)
+ }
+}
+
+impl Scaled for Em {
+ fn scaled(self, ctx: &MathContext) -> Abs {
+ self.at(ctx.size)
+ }
+}
+
+impl Scaled for MathValue<'_> {
+ fn scaled(self, ctx: &MathContext) -> Abs {
+ self.value.scaled(ctx)
+ }
+}
diff --git a/crates/typst-library/src/math/delimited.rs b/crates/typst-library/src/math/delimited.rs
new file mode 100644
index 00000000..99cd6c33
--- /dev/null
+++ b/crates/typst-library/src/math/delimited.rs
@@ -0,0 +1,200 @@
+use super::*;
+
+/// How much less high scaled delimiters can be than what they wrap.
+pub(super) const DELIM_SHORT_FALL: Em = Em::new(0.1);
+
+/// Scales delimiters.
+///
+/// While matched delimiters scale by default, this can be used to scale
+/// unmatched delimiters and to control the delimiter scaling more precisely.
+///
+/// ## Example { #example }
+/// ```example
+/// $ lr(]a, b/2]) $
+/// $ lr(]sum_(x=1)^n] x, size: #50%) $
+/// ```
+///
+/// Display: Left/Right
+/// Category: math
+#[element(LayoutMath)]
+pub struct LrElem {
+ /// The size of the brackets, relative to the height of the wrapped content.
+ pub size: Smart<Rel<Length>>,
+
+ /// The delimited content, including the delimiters.
+ #[required]
+ #[parse(
+ let mut body = Content::empty();
+ for (i, arg) in args.all::<Content>()?.into_iter().enumerate() {
+ if i > 0 {
+ body += TextElem::packed(',');
+ }
+ body += arg;
+ }
+ body
+ )]
+ pub body: Content,
+}
+
+impl LayoutMath for LrElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let mut body = self.body();
+ if let Some(elem) = body.to::<LrElem>() {
+ if elem.size(ctx.styles()).is_auto() {
+ body = elem.body();
+ }
+ }
+
+ let mut fragments = ctx.layout_fragments(&body)?;
+ let axis = scaled!(ctx, axis_height);
+ let max_extent = fragments
+ .iter()
+ .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis))
+ .max()
+ .unwrap_or_default();
+
+ let height = self
+ .size(ctx.styles())
+ .unwrap_or(Rel::one())
+ .resolve(ctx.styles())
+ .relative_to(2.0 * max_extent);
+
+ match fragments.as_mut_slice() {
+ [one] => scale(ctx, one, height, None),
+ [first, .., last] => {
+ scale(ctx, first, height, Some(MathClass::Opening));
+ scale(ctx, last, height, Some(MathClass::Closing));
+ }
+ _ => {}
+ }
+
+ ctx.extend(fragments);
+
+ Ok(())
+ }
+}
+
+/// Scale a math fragment to a height.
+fn scale(
+ ctx: &mut MathContext,
+ fragment: &mut MathFragment,
+ height: Abs,
+ apply: Option<MathClass>,
+) {
+ if matches!(
+ fragment.class(),
+ Some(MathClass::Opening | MathClass::Closing | MathClass::Fence)
+ ) {
+ let glyph = match fragment {
+ MathFragment::Glyph(glyph) => glyph.clone(),
+ MathFragment::Variant(variant) => {
+ GlyphFragment::new(ctx, variant.c, variant.span)
+ }
+ _ => return,
+ };
+
+ let short_fall = DELIM_SHORT_FALL.scaled(ctx);
+ *fragment =
+ MathFragment::Variant(glyph.stretch_vertical(ctx, height, short_fall));
+
+ if let Some(class) = apply {
+ fragment.set_class(class);
+ }
+ }
+}
+
+/// Floors an expression.
+///
+/// ## Example { #example }
+/// ```example
+/// $ floor(x/2) $
+/// ```
+///
+/// Display: Floor
+/// Category: math
+#[func]
+pub fn floor(
+ /// The expression to floor.
+ body: Content,
+) -> Content {
+ delimited(body, '⌊', '⌋')
+}
+
+/// Ceils an expression.
+///
+/// ## Example { #example }
+/// ```example
+/// $ ceil(x/2) $
+/// ```
+///
+/// Display: Ceil
+/// Category: math
+#[func]
+pub fn ceil(
+ /// The expression to ceil.
+ body: Content,
+) -> Content {
+ delimited(body, '⌈', '⌉')
+}
+
+/// Rounds an expression.
+///
+/// ## Example { #example }
+/// ```example
+/// $ round(x/2) $
+/// ```
+///
+/// Display: Round
+/// Category: math
+#[func]
+pub fn round(
+ /// The expression to round.
+ body: Content,
+) -> Content {
+ delimited(body, '⌊', '⌉')
+}
+
+/// Takes the absolute value of an expression.
+///
+/// ## Example { #example }
+/// ```example
+/// $ abs(x/2) $
+/// ```
+///
+///
+/// Display: Abs
+/// Category: math
+#[func]
+pub fn abs(
+ /// The expression to take the absolute value of.
+ body: Content,
+) -> Content {
+ delimited(body, '|', '|')
+}
+
+/// Takes the norm of an expression.
+///
+/// ## Example { #example }
+/// ```example
+/// $ norm(x/2) $
+/// ```
+///
+/// Display: Norm
+/// Category: math
+#[func]
+pub fn norm(
+ /// The expression to take the norm of.
+ body: Content,
+) -> Content {
+ delimited(body, '‖', '‖')
+}
+
+fn delimited(body: Content, left: char, right: char) -> Content {
+ LrElem::new(Content::sequence([
+ TextElem::packed(left),
+ body,
+ TextElem::packed(right),
+ ]))
+ .pack()
+}
diff --git a/crates/typst-library/src/math/frac.rs b/crates/typst-library/src/math/frac.rs
new file mode 100644
index 00000000..0e1f78cc
--- /dev/null
+++ b/crates/typst-library/src/math/frac.rs
@@ -0,0 +1,148 @@
+use super::*;
+
+const FRAC_AROUND: Em = Em::new(0.1);
+
+/// A mathematical fraction.
+///
+/// ## Example { #example }
+/// ```example
+/// $ 1/2 < (x+1)/2 $
+/// $ ((x+1)) / 2 = frac(a, b) $
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: Use a slash to turn neighbouring
+/// expressions into a fraction. Multiple atoms can be grouped into a single
+/// expression using round grouping parenthesis. Such parentheses are removed
+/// from the output, but you can nest multiple to force them.
+///
+/// Display: Fraction
+/// Category: math
+#[element(LayoutMath)]
+pub struct FracElem {
+ /// The fraction's numerator.
+ #[required]
+ pub num: Content,
+
+ /// The fraction's denominator.
+ #[required]
+ pub denom: Content,
+}
+
+impl LayoutMath for FracElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout(ctx, &self.num(), &self.denom(), false, self.span())
+ }
+}
+
+/// A binomial expression.
+///
+/// ## Example { #example }
+/// ```example
+/// $ binom(n, k) $
+/// ```
+///
+/// Display: Binomial
+/// Category: math
+#[element(LayoutMath)]
+pub struct BinomElem {
+ /// The binomial's upper index.
+ #[required]
+ pub upper: Content,
+
+ /// The binomial's lower index.
+ #[required]
+ pub lower: Content,
+}
+
+impl LayoutMath for BinomElem {
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout(ctx, &self.upper(), &self.lower(), true, self.span())
+ }
+}
+
+/// Layout a fraction or binomial.
+fn layout(
+ ctx: &mut MathContext,
+ num: &Content,
+ denom: &Content,
+ binom: bool,
+ span: Span,
+) -> SourceResult<()> {
+ let short_fall = DELIM_SHORT_FALL.scaled(ctx);
+ let axis = scaled!(ctx, axis_height);
+ let thickness = scaled!(ctx, fraction_rule_thickness);
+ let shift_up = scaled!(
+ ctx,
+ text: fraction_numerator_shift_up,
+ display: fraction_numerator_display_style_shift_up,
+ );
+ let shift_down = scaled!(
+ ctx,
+ text: fraction_denominator_shift_down,
+ display: fraction_denominator_display_style_shift_down,
+ );
+ let num_min = scaled!(
+ ctx,
+ text: fraction_numerator_gap_min,
+ display: fraction_num_display_style_gap_min,
+ );
+ let denom_min = scaled!(
+ ctx,
+ text: fraction_denominator_gap_min,
+ display: fraction_denom_display_style_gap_min,
+ );
+
+ ctx.style(ctx.style.for_numerator());
+ let num = ctx.layout_frame(num)?;
+ ctx.unstyle();
+
+ ctx.style(ctx.style.for_denominator());
+ let denom = ctx.layout_frame(denom)?;
+ ctx.unstyle();
+
+ let around = FRAC_AROUND.scaled(ctx);
+ let num_gap = (shift_up - axis - num.descent()).max(num_min + thickness / 2.0);
+ let denom_gap = (shift_down + axis - denom.ascent()).max(denom_min + thickness / 2.0);
+
+ let line_width = num.width().max(denom.width());
+ let width = line_width + 2.0 * around;
+ let height = num.height() + num_gap + thickness + denom_gap + denom.height();
+ let size = Size::new(width, height);
+ let num_pos = Point::with_x((width - num.width()) / 2.0);
+ let line_pos =
+ Point::new((width - line_width) / 2.0, num.height() + num_gap + thickness / 2.0);
+ let denom_pos = Point::new((width - denom.width()) / 2.0, height - denom.height());
+ let baseline = line_pos.y + axis;
+
+ let mut frame = Frame::new(size);
+ frame.set_baseline(baseline);
+ frame.push_frame(num_pos, num);
+ frame.push_frame(denom_pos, denom);
+
+ if binom {
+ ctx.push(
+ GlyphFragment::new(ctx, '(', span).stretch_vertical(ctx, height, short_fall),
+ );
+ ctx.push(FrameFragment::new(ctx, frame));
+ ctx.push(
+ GlyphFragment::new(ctx, ')', span).stretch_vertical(ctx, height, short_fall),
+ );
+ } else {
+ frame.push(
+ line_pos,
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(line_width)).stroked(Stroke {
+ paint: TextElem::fill_in(ctx.styles()),
+ thickness,
+ ..Stroke::default()
+ }),
+ span,
+ ),
+ );
+ ctx.push(FrameFragment::new(ctx, frame));
+ }
+
+ Ok(())
+}
diff --git a/crates/typst-library/src/math/fragment.rs b/crates/typst-library/src/math/fragment.rs
new file mode 100644
index 00000000..139ce07b
--- /dev/null
+++ b/crates/typst-library/src/math/fragment.rs
@@ -0,0 +1,414 @@
+use super::*;
+use ttf_parser::gsub::AlternateSet;
+
+#[derive(Debug, Clone)]
+pub enum MathFragment {
+ Glyph(GlyphFragment),
+ Variant(VariantFragment),
+ Frame(FrameFragment),
+ Spacing(Abs),
+ Space(Abs),
+ Linebreak,
+ Align,
+}
+
+impl MathFragment {
+ pub fn size(&self) -> Size {
+ Size::new(self.width(), self.height())
+ }
+
+ pub fn width(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.width,
+ Self::Variant(variant) => variant.frame.width(),
+ Self::Frame(fragment) => fragment.frame.width(),
+ Self::Spacing(amount) => *amount,
+ Self::Space(amount) => *amount,
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn height(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.height(),
+ Self::Variant(variant) => variant.frame.height(),
+ Self::Frame(fragment) => fragment.frame.height(),
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn ascent(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.ascent,
+ Self::Variant(variant) => variant.frame.ascent(),
+ Self::Frame(fragment) => fragment.frame.baseline(),
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn descent(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.descent,
+ Self::Variant(variant) => variant.frame.descent(),
+ Self::Frame(fragment) => fragment.frame.descent(),
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn class(&self) -> Option<MathClass> {
+ match self {
+ Self::Glyph(glyph) => glyph.class,
+ Self::Variant(variant) => variant.class,
+ Self::Frame(fragment) => Some(fragment.class),
+ _ => None,
+ }
+ }
+
+ pub fn style(&self) -> Option<MathStyle> {
+ match self {
+ Self::Glyph(glyph) => Some(glyph.style),
+ Self::Variant(variant) => Some(variant.style),
+ Self::Frame(fragment) => Some(fragment.style),
+ _ => None,
+ }
+ }
+
+ pub fn font_size(&self) -> Option<Abs> {
+ match self {
+ Self::Glyph(glyph) => Some(glyph.font_size),
+ Self::Variant(variant) => Some(variant.font_size),
+ Self::Frame(fragment) => Some(fragment.font_size),
+ _ => None,
+ }
+ }
+
+ pub fn set_class(&mut self, class: MathClass) {
+ match self {
+ Self::Glyph(glyph) => glyph.class = Some(class),
+ Self::Variant(variant) => variant.class = Some(class),
+ Self::Frame(fragment) => fragment.class = class,
+ _ => {}
+ }
+ }
+
+ pub fn set_limits(&mut self, limits: Limits) {
+ match self {
+ Self::Glyph(glyph) => glyph.limits = limits,
+ Self::Variant(variant) => variant.limits = limits,
+ Self::Frame(fragment) => fragment.limits = limits,
+ _ => {}
+ }
+ }
+
+ pub fn is_spaced(&self) -> bool {
+ match self {
+ MathFragment::Frame(frame) => frame.spaced,
+ _ => self.class() == Some(MathClass::Fence),
+ }
+ }
+
+ pub fn italics_correction(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.italics_correction,
+ Self::Variant(variant) => variant.italics_correction,
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn into_frame(self) -> Frame {
+ match self {
+ Self::Glyph(glyph) => glyph.into_frame(),
+ Self::Variant(variant) => variant.frame,
+ Self::Frame(fragment) => fragment.frame,
+ _ => Frame::new(self.size()),
+ }
+ }
+
+ pub fn limits(&self) -> Limits {
+ match self {
+ MathFragment::Glyph(glyph) => glyph.limits,
+ MathFragment::Variant(variant) => variant.limits,
+ MathFragment::Frame(fragment) => fragment.limits,
+ _ => Limits::Never,
+ }
+ }
+}
+
+impl From<GlyphFragment> for MathFragment {
+ fn from(glyph: GlyphFragment) -> Self {
+ Self::Glyph(glyph)
+ }
+}
+
+impl From<VariantFragment> for MathFragment {
+ fn from(variant: VariantFragment) -> Self {
+ Self::Variant(variant)
+ }
+}
+
+impl From<FrameFragment> for MathFragment {
+ fn from(fragment: FrameFragment) -> Self {
+ Self::Frame(fragment)
+ }
+}
+
+#[derive(Clone)]
+pub struct GlyphFragment {
+ pub id: GlyphId,
+ pub c: char,
+ pub font: Font,
+ pub lang: Lang,
+ pub fill: Paint,
+ pub width: Abs,
+ pub ascent: Abs,
+ pub descent: Abs,
+ pub italics_correction: Abs,
+ pub style: MathStyle,
+ pub font_size: Abs,
+ pub class: Option<MathClass>,
+ pub span: Span,
+ pub meta: Vec<Meta>,
+ pub limits: Limits,
+}
+
+impl GlyphFragment {
+ pub fn new(ctx: &MathContext, c: char, span: Span) -> Self {
+ let id = ctx.ttf.glyph_index(c).unwrap_or_default();
+ Self::with_id(ctx, c, id, span)
+ }
+
+ pub fn try_new(ctx: &MathContext, c: char, span: Span) -> Option<Self> {
+ let c = ctx.style.styled_char(c);
+ let id = ctx.ttf.glyph_index(c)?;
+ Some(Self::with_id(ctx, c, id, span))
+ }
+
+ pub fn with_id(ctx: &MathContext, c: char, id: GlyphId, span: Span) -> Self {
+ let class = match c {
+ ':' => Some(MathClass::Relation),
+ _ => unicode_math_class::class(c),
+ };
+ let mut fragment = Self {
+ id,
+ c,
+ font: ctx.font.clone(),
+ lang: TextElem::lang_in(ctx.styles()),
+ fill: TextElem::fill_in(ctx.styles()),
+ style: ctx.style,
+ font_size: ctx.size,
+ width: Abs::zero(),
+ ascent: Abs::zero(),
+ descent: Abs::zero(),
+ limits: Limits::for_char(c),
+ italics_correction: Abs::zero(),
+ class,
+ span,
+ meta: MetaElem::data_in(ctx.styles()),
+ };
+ fragment.set_id(ctx, id);
+ fragment
+ }
+
+ /// Sets element id and boxes in appropriate way without changing other
+ /// styles. This is used to replace the glyph with a stretch variant.
+ pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) {
+ let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default();
+ let italics = italics_correction(ctx, id).unwrap_or_default();
+ let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect {
+ x_min: 0,
+ y_min: 0,
+ x_max: 0,
+ y_max: 0,
+ });
+
+ let mut width = advance.scaled(ctx);
+ if !is_extended_shape(ctx, id) {
+ width += italics;
+ }
+
+ self.id = id;
+ self.width = width;
+ self.ascent = bbox.y_max.scaled(ctx);
+ self.descent = -bbox.y_min.scaled(ctx);
+ self.italics_correction = italics;
+ }
+
+ pub fn height(&self) -> Abs {
+ self.ascent + self.descent
+ }
+
+ pub fn into_variant(self) -> VariantFragment {
+ VariantFragment {
+ c: self.c,
+ id: Some(self.id),
+ style: self.style,
+ font_size: self.font_size,
+ italics_correction: self.italics_correction,
+ class: self.class,
+ span: self.span,
+ limits: self.limits,
+ frame: self.into_frame(),
+ }
+ }
+
+ pub fn into_frame(self) -> Frame {
+ let item = TextItem {
+ font: self.font.clone(),
+ size: self.font_size,
+ fill: self.fill,
+ lang: self.lang,
+ text: self.c.into(),
+ glyphs: vec![Glyph {
+ id: self.id.0,
+ x_advance: Em::from_length(self.width, self.font_size),
+ x_offset: Em::zero(),
+ range: 0..self.c.len_utf8() as u16,
+ span: (self.span, 0),
+ }],
+ };
+ let size = Size::new(self.width, self.ascent + self.descent);
+ let mut frame = Frame::new(size);
+ frame.set_baseline(self.ascent);
+ frame.push(Point::with_y(self.ascent), FrameItem::Text(item));
+ frame.meta_iter(self.meta);
+ frame
+ }
+
+ pub fn make_scriptsize(&mut self, ctx: &MathContext) {
+ let alt_id =
+ script_alternatives(ctx, self.id).and_then(|alts| alts.alternates.get(0));
+
+ if let Some(alt_id) = alt_id {
+ self.set_id(ctx, alt_id);
+ }
+ }
+
+ pub fn make_scriptscriptsize(&mut self, ctx: &MathContext) {
+ let alts = script_alternatives(ctx, self.id);
+ let alt_id = alts
+ .and_then(|alts| alts.alternates.get(1).or_else(|| alts.alternates.get(0)));
+
+ if let Some(alt_id) = alt_id {
+ self.set_id(ctx, alt_id);
+ }
+ }
+}
+
+impl Debug for GlyphFragment {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "GlyphFragment({:?})", self.c)
+ }
+}
+
+#[derive(Clone)]
+pub struct VariantFragment {
+ pub c: char,
+ pub id: Option<GlyphId>,
+ pub italics_correction: Abs,
+ pub frame: Frame,
+ pub style: MathStyle,
+ pub font_size: Abs,
+ pub class: Option<MathClass>,
+ pub span: Span,
+ pub limits: Limits,
+}
+
+impl Debug for VariantFragment {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "VariantFragment({:?})", self.c)
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct FrameFragment {
+ pub frame: Frame,
+ pub style: MathStyle,
+ pub font_size: Abs,
+ pub class: MathClass,
+ pub limits: Limits,
+ pub spaced: bool,
+ pub base_ascent: Abs,
+}
+
+impl FrameFragment {
+ pub fn new(ctx: &MathContext, mut frame: Frame) -> Self {
+ let base_ascent = frame.ascent();
+ frame.meta(ctx.styles(), false);
+ Self {
+ frame,
+ font_size: ctx.size,
+ style: ctx.style,
+ class: MathClass::Normal,
+ limits: Limits::Never,
+ spaced: false,
+ base_ascent,
+ }
+ }
+
+ pub fn with_class(self, class: MathClass) -> Self {
+ Self { class, ..self }
+ }
+
+ pub fn with_limits(self, limits: Limits) -> Self {
+ Self { limits, ..self }
+ }
+
+ pub fn with_spaced(self, spaced: bool) -> Self {
+ Self { spaced, ..self }
+ }
+
+ pub fn with_base_ascent(self, base_ascent: Abs) -> Self {
+ Self { base_ascent, ..self }
+ }
+}
+
+/// Look up the italics correction for a glyph.
+fn italics_correction(ctx: &MathContext, id: GlyphId) -> Option<Abs> {
+ Some(ctx.table.glyph_info?.italic_corrections?.get(id)?.scaled(ctx))
+}
+
+/// Look up the script/scriptscript alternates for a glyph
+fn script_alternatives<'a>(
+ ctx: &MathContext<'a, '_, '_>,
+ id: GlyphId,
+) -> Option<AlternateSet<'a>> {
+ ctx.ssty_table.and_then(|ssty| {
+ ssty.coverage.get(id).and_then(|index| ssty.alternate_sets.get(index))
+ })
+}
+
+/// Look up the italics correction for a glyph.
+fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool {
+ ctx.table
+ .glyph_info
+ .and_then(|info| info.extended_shapes)
+ .and_then(|info| info.get(id))
+ .is_some()
+}
+
+/// Look up a kerning value at a specific corner and height.
+///
+/// This can be integrated once we've found a font that actually provides this
+/// data.
+#[allow(unused)]
+fn kern_at_height(
+ ctx: &MathContext,
+ id: GlyphId,
+ corner: Corner,
+ height: Abs,
+) -> Option<Abs> {
+ let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?;
+ let kern = match corner {
+ Corner::TopLeft => kerns.top_left,
+ Corner::TopRight => kerns.top_right,
+ Corner::BottomRight => kerns.bottom_right,
+ Corner::BottomLeft => kerns.bottom_left,
+ }?;
+
+ let mut i = 0;
+ while i < kern.count() && height > kern.height(i)?.scaled(ctx) {
+ i += 1;
+ }
+
+ Some(kern.kern(i)?.scaled(ctx))
+}
diff --git a/crates/typst-library/src/math/matrix.rs b/crates/typst-library/src/math/matrix.rs
new file mode 100644
index 00000000..aaccc332
--- /dev/null
+++ b/crates/typst-library/src/math/matrix.rs
@@ -0,0 +1,313 @@
+use super::*;
+
+const ROW_GAP: Em = Em::new(0.5);
+const COL_GAP: Em = Em::new(0.5);
+const VERTICAL_PADDING: Ratio = Ratio::new(0.1);
+
+/// A column vector.
+///
+/// Content in the vector's elements can be aligned with the `&` symbol.
+///
+/// ## Example { #example }
+/// ```example
+/// $ vec(a, b, c) dot vec(1, 2, 3)
+/// = a + 2b + 3c $
+/// ```
+///
+/// Display: Vector
+/// Category: math
+#[element(LayoutMath)]
+pub struct VecElem {
+ /// The delimiter to use.
+ ///
+ /// ```example
+ /// #set math.vec(delim: "[")
+ /// $ vec(1, 2) $
+ /// ```
+ #[default(Some(Delimiter::Paren))]
+ pub delim: Option<Delimiter>,
+
+ /// The elements of the vector.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl LayoutMath for VecElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let delim = self.delim(ctx.styles());
+ let frame = layout_vec_body(ctx, &self.children(), Align::Center)?;
+ layout_delimiters(
+ ctx,
+ frame,
+ delim.map(Delimiter::open),
+ delim.map(Delimiter::close),
+ self.span(),
+ )
+ }
+}
+
+/// A matrix.
+///
+/// The elements of a row should be separated by commas, while the rows
+/// themselves should be separated by semicolons. The semicolon syntax merges
+/// preceding arguments separated by commas into an array. You can also use this
+/// special syntax of math function calls to define custom functions that take
+/// 2D data.
+///
+/// Content in cells that are in the same row can be aligned with the `&` symbol.
+///
+/// ## Example { #example }
+/// ```example
+/// $ mat(
+/// 1, 2, ..., 10;
+/// 2, 2, ..., 10;
+/// dots.v, dots.v, dots.down, dots.v;
+/// 10, 10, ..., 10;
+/// ) $
+/// ```
+///
+/// Display: Matrix
+/// Category: math
+#[element(LayoutMath)]
+pub struct MatElem {
+ /// The delimiter to use.
+ ///
+ /// ```example
+ /// #set math.mat(delim: "[")
+ /// $ mat(1, 2; 3, 4) $
+ /// ```
+ #[default(Some(Delimiter::Paren))]
+ pub delim: Option<Delimiter>,
+
+ /// An array of arrays with the rows of the matrix.
+ ///
+ /// ```example
+ /// #let data = ((1, 2, 3), (4, 5, 6))
+ /// #let matrix = math.mat(..data)
+ /// $ v := matrix $
+ /// ```
+ #[variadic]
+ #[parse(
+ let mut rows = vec![];
+ let mut width = 0;
+
+ let values = args.all::<Spanned<Value>>()?;
+ if values.iter().any(|spanned| matches!(spanned.v, Value::Array(_))) {
+ for Spanned { v, span } in values {
+ let array = v.cast::<Array>().at(span)?;
+ let row: Vec<_> = array.into_iter().map(Value::display).collect();
+ width = width.max(row.len());
+ rows.push(row);
+ }
+ } else {
+ rows = vec![values.into_iter().map(|spanned| spanned.v.display()).collect()];
+ }
+
+ for row in &mut rows {
+ if row.len() < width {
+ row.resize(width, Content::empty());
+ }
+ }
+
+ rows
+ )]
+ pub rows: Vec<Vec<Content>>,
+}
+
+impl LayoutMath for MatElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let delim = self.delim(ctx.styles());
+ let frame = layout_mat_body(ctx, &self.rows())?;
+ layout_delimiters(
+ ctx,
+ frame,
+ delim.map(Delimiter::open),
+ delim.map(Delimiter::close),
+ self.span(),
+ )
+ }
+}
+
+/// A case distinction.
+///
+/// Content across different branches can be aligned with the `&` symbol.
+///
+/// ## Example { #example }
+/// ```example
+/// $ f(x, y) := cases(
+/// 1 "if" (x dot y)/2 <= 0,
+/// 2 "if" x "is even",
+/// 3 "if" x in NN,
+/// 4 "else",
+/// ) $
+/// ```
+///
+/// Display: Cases
+/// Category: math
+#[element(LayoutMath)]
+pub struct CasesElem {
+ /// The delimiter to use.
+ ///
+ /// ```example
+ /// #set math.cases(delim: "[")
+ /// $ x = cases(1, 2) $
+ /// ```
+ #[default(Delimiter::Brace)]
+ pub delim: Delimiter,
+
+ /// The branches of the case distinction.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl LayoutMath for CasesElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let delim = self.delim(ctx.styles());
+ let frame = layout_vec_body(ctx, &self.children(), Align::Left)?;
+ layout_delimiters(ctx, frame, Some(delim.open()), None, self.span())
+ }
+}
+
+/// A vector / matrix delimiter.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Delimiter {
+ /// Delimit with parentheses.
+ #[string("(")]
+ Paren,
+ /// Delimit with brackets.
+ #[string("[")]
+ Bracket,
+ /// Delimit with curly braces.
+ #[string("{")]
+ Brace,
+ /// Delimit with vertical bars.
+ #[string("|")]
+ Bar,
+ /// Delimit with double vertical bars.
+ #[string("||")]
+ DoubleBar,
+}
+
+impl Delimiter {
+ /// The delimiter's opening character.
+ fn open(self) -> char {
+ match self {
+ Self::Paren => '(',
+ Self::Bracket => '[',
+ Self::Brace => '{',
+ Self::Bar => '|',
+ Self::DoubleBar => '‖',
+ }
+ }
+
+ /// The delimiter's closing character.
+ fn close(self) -> char {
+ match self {
+ Self::Paren => ')',
+ Self::Bracket => ']',
+ Self::Brace => '}',
+ Self::Bar => '|',
+ Self::DoubleBar => '‖',
+ }
+ }
+}
+
+/// Layout the inner contents of a vector.
+fn layout_vec_body(
+ ctx: &mut MathContext,
+ column: &[Content],
+ align: Align,
+) -> SourceResult<Frame> {
+ let gap = ROW_GAP.scaled(ctx);
+ ctx.style(ctx.style.for_denominator());
+ let mut flat = vec![];
+ for child in column {
+ flat.push(ctx.layout_row(child)?);
+ }
+ ctx.unstyle();
+ Ok(stack(ctx, flat, align, gap, 0))
+}
+
+/// Layout the inner contents of a matrix.
+fn layout_mat_body(ctx: &mut MathContext, rows: &[Vec<Content>]) -> SourceResult<Frame> {
+ let row_gap = ROW_GAP.scaled(ctx);
+ let col_gap = COL_GAP.scaled(ctx);
+
+ let ncols = rows.first().map_or(0, |row| row.len());
+ let nrows = rows.len();
+ if ncols == 0 || nrows == 0 {
+ return Ok(Frame::new(Size::zero()));
+ }
+
+ let mut heights = vec![(Abs::zero(), Abs::zero()); nrows];
+
+ ctx.style(ctx.style.for_denominator());
+ let mut cols = vec![vec![]; ncols];
+ for (row, (ascent, descent)) in rows.iter().zip(&mut heights) {
+ for (cell, col) in row.iter().zip(&mut cols) {
+ let cell = ctx.layout_row(cell)?;
+ ascent.set_max(cell.ascent());
+ descent.set_max(cell.descent());
+ col.push(cell);
+ }
+ }
+ ctx.unstyle();
+
+ let mut frame = Frame::new(Size::new(
+ Abs::zero(),
+ heights.iter().map(|&(a, b)| a + b).sum::<Abs>() + row_gap * (nrows - 1) as f64,
+ ));
+ let mut x = Abs::zero();
+ for col in cols {
+ let AlignmentResult { points, width: rcol } = alignments(&col);
+ let mut y = Abs::zero();
+ for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) {
+ let cell = cell.into_aligned_frame(ctx, &points, Align::Center);
+ let pos = Point::new(
+ if points.is_empty() { x + (rcol - cell.width()) / 2.0 } else { x },
+ y + ascent - cell.ascent(),
+ );
+ frame.push_frame(pos, cell);
+ y += ascent + descent + row_gap;
+ }
+ x += rcol + col_gap;
+ }
+ frame.size_mut().x = x - col_gap;
+
+ Ok(frame)
+}
+
+/// Layout the outer wrapper around a vector's or matrices' body.
+fn layout_delimiters(
+ ctx: &mut MathContext,
+ mut frame: Frame,
+ left: Option<char>,
+ right: Option<char>,
+ span: Span,
+) -> SourceResult<()> {
+ let axis = scaled!(ctx, axis_height);
+ let short_fall = DELIM_SHORT_FALL.scaled(ctx);
+ let height = frame.height();
+ let target = height + VERTICAL_PADDING.of(height);
+ frame.set_baseline(height / 2.0 + axis);
+
+ if let Some(left) = left {
+ ctx.push(
+ GlyphFragment::new(ctx, left, span).stretch_vertical(ctx, target, short_fall),
+ );
+ }
+
+ ctx.push(FrameFragment::new(ctx, frame));
+
+ if let Some(right) = right {
+ ctx.push(
+ GlyphFragment::new(ctx, right, span)
+ .stretch_vertical(ctx, target, short_fall),
+ );
+ }
+
+ Ok(())
+}
diff --git a/crates/typst-library/src/math/mod.rs b/crates/typst-library/src/math/mod.rs
new file mode 100644
index 00000000..0429265f
--- /dev/null
+++ b/crates/typst-library/src/math/mod.rs
@@ -0,0 +1,480 @@
+//! Mathematical formulas.
+
+#[macro_use]
+mod ctx;
+mod accent;
+mod align;
+mod attach;
+mod cancel;
+mod delimited;
+mod frac;
+mod fragment;
+mod matrix;
+mod op;
+mod root;
+mod row;
+mod spacing;
+mod stretch;
+mod style;
+mod underover;
+
+pub use self::accent::*;
+pub use self::align::*;
+pub use self::attach::*;
+pub use self::cancel::*;
+pub use self::delimited::*;
+pub use self::frac::*;
+pub use self::matrix::*;
+pub use self::op::*;
+pub use self::root::*;
+pub use self::style::*;
+pub use self::underover::*;
+
+use ttf_parser::{GlyphId, Rect};
+use typst::eval::{Module, Scope};
+use typst::font::{Font, FontWeight};
+use typst::model::Guard;
+use typst::util::option_eq;
+use unicode_math_class::MathClass;
+
+use self::ctx::*;
+use self::fragment::*;
+use self::row::*;
+use self::spacing::*;
+use crate::layout::{HElem, ParElem, Spacing};
+use crate::meta::Supplement;
+use crate::meta::{
+ Count, Counter, CounterUpdate, LocalName, Numbering, Outlinable, Refable,
+};
+use crate::prelude::*;
+use crate::text::{
+ families, variant, FontFamily, FontList, LinebreakElem, SpaceElem, TextElem, TextSize,
+};
+
+/// Create a module with all math definitions.
+pub fn module() -> Module {
+ let mut math = Scope::deduplicating();
+ math.define("equation", EquationElem::func());
+ math.define("text", TextElem::func());
+
+ // Grouping.
+ math.define("lr", LrElem::func());
+ math.define("abs", abs_func());
+ math.define("norm", norm_func());
+ math.define("floor", floor_func());
+ math.define("ceil", ceil_func());
+ math.define("round", round_func());
+
+ // Attachments and accents.
+ math.define("attach", AttachElem::func());
+ math.define("scripts", ScriptsElem::func());
+ math.define("limits", LimitsElem::func());
+ math.define("accent", AccentElem::func());
+ math.define("underline", UnderlineElem::func());
+ math.define("overline", OverlineElem::func());
+ math.define("underbrace", UnderbraceElem::func());
+ math.define("overbrace", OverbraceElem::func());
+ math.define("underbracket", UnderbracketElem::func());
+ math.define("overbracket", OverbracketElem::func());
+ math.define("cancel", CancelElem::func());
+
+ // Fractions and matrix-likes.
+ math.define("frac", FracElem::func());
+ math.define("binom", BinomElem::func());
+ math.define("vec", VecElem::func());
+ math.define("mat", MatElem::func());
+ math.define("cases", CasesElem::func());
+
+ // Roots.
+ math.define("sqrt", sqrt_func());
+ math.define("root", RootElem::func());
+
+ // Styles.
+ math.define("upright", upright_func());
+ math.define("bold", bold_func());
+ math.define("italic", italic_func());
+ math.define("serif", serif_func());
+ math.define("sans", sans_func());
+ math.define("cal", cal_func());
+ math.define("frak", frak_func());
+ math.define("mono", mono_func());
+ math.define("bb", bb_func());
+
+ math.define("display", display_func());
+ math.define("inline", inline_func());
+ math.define("script", script_func());
+ math.define("sscript", sscript_func());
+
+ // Text operators.
+ math.define("op", OpElem::func());
+ op::define(&mut math);
+
+ // Spacings.
+ spacing::define(&mut math);
+
+ // Symbols.
+ for (name, symbol) in crate::symbols::SYM {
+ math.define(*name, symbol.clone());
+ }
+
+ Module::new("math").with_scope(math)
+}
+
+/// A mathematical equation.
+///
+/// Can be displayed inline with text or as a separate block.
+///
+/// ## Example { #example }
+/// ```example
+/// #set text(font: "New Computer Modern")
+///
+/// Let $a$, $b$, and $c$ be the side
+/// lengths of right-angled triangle.
+/// Then, we know that:
+/// $ a^2 + b^2 = c^2 $
+///
+/// Prove by induction:
+/// $ sum_(k=1)^n k = (n(n+1)) / 2 $
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: Write mathematical markup within
+/// dollar signs to create an equation. Starting and ending the equation with at
+/// least one space lifts it into a separate block that is centered
+/// horizontally. For more details about math syntax, see the
+/// [main math page]($category/math).
+///
+/// Display: Equation
+/// Category: math
+#[element(
+ Locatable, Synthesize, Show, Finalize, Layout, LayoutMath, Count, LocalName, Refable,
+ Outlinable
+)]
+pub struct EquationElem {
+ /// Whether the equation is displayed as a separate block.
+ #[default(false)]
+ pub block: bool,
+
+ /// How to [number]($func/numbering) block-level equations.
+ ///
+ /// ```example
+ /// #set math.equation(numbering: "(1)")
+ ///
+ /// We define:
+ /// $ phi.alt := (1 + sqrt(5)) / 2 $ <ratio>
+ ///
+ /// With @ratio, we get:
+ /// $ F_n = floor(1 / sqrt(5) phi.alt^n) $
+ /// ```
+ pub numbering: Option<Numbering>,
+
+ /// A supplement for the equation.
+ ///
+ /// For references to equations, this is added before the referenced number.
+ ///
+ /// If a function is specified, it is passed the referenced equation and
+ /// should return content.
+ ///
+ /// ```example
+ /// #set math.equation(numbering: "(1)", supplement: [Eq.])
+ ///
+ /// We define:
+ /// $ phi.alt := (1 + sqrt(5)) / 2 $ <ratio>
+ ///
+ /// With @ratio, we get:
+ /// $ F_n = floor(1 / sqrt(5) phi.alt^n) $
+ /// ```
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// The contents of the equation.
+ #[required]
+ pub body: Content,
+}
+
+impl Synthesize for EquationElem {
+ fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ // Resolve the supplement.
+ let supplement = match self.supplement(styles) {
+ Smart::Auto => TextElem::packed(self.local_name_in(styles)),
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => supplement.resolve(vt, [self.clone()])?,
+ };
+
+ self.push_block(self.block(styles));
+ self.push_numbering(self.numbering(styles));
+ self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
+
+ Ok(())
+ }
+}
+
+impl Show for EquationElem {
+ #[tracing::instrument(name = "EquationElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.clone().pack().guarded(Guard::Base(Self::func()));
+ if self.block(styles) {
+ realized = realized.aligned(Axes::with_x(Some(Align::Center.into())))
+ }
+ Ok(realized)
+ }
+}
+
+impl Finalize for EquationElem {
+ fn finalize(&self, realized: Content, _: StyleChain) -> Content {
+ realized
+ .styled(TextElem::set_weight(FontWeight::from_number(450)))
+ .styled(TextElem::set_font(FontList(vec![FontFamily::new(
+ "New Computer Modern Math",
+ )])))
+ }
+}
+
+impl Layout for EquationElem {
+ #[tracing::instrument(name = "EquationElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ const NUMBER_GUTTER: Em = Em::new(0.5);
+
+ let block = self.block(styles);
+
+ // Find a math font.
+ let variant = variant(styles);
+ let world = vt.world;
+ let Some(font) = families(styles)
+ .find_map(|family| {
+ let id = world.book().select(family.as_str(), variant)?;
+ let font = world.font(id)?;
+ let _ = font.ttf().tables().math?.constants?;
+ Some(font)
+ })
+ else {
+ bail!(self.span(), "current font does not support math");
+ };
+
+ let mut ctx = MathContext::new(vt, styles, regions, &font, block);
+ let mut frame = ctx.layout_frame(self)?;
+
+ if block {
+ if let Some(numbering) = self.numbering(styles) {
+ let pod = Regions::one(regions.base(), Axes::splat(false));
+ let counter = Counter::of(Self::func())
+ .display(Some(numbering), false)
+ .layout(vt, styles, pod)?
+ .into_frame();
+
+ let width = if regions.size.x.is_finite() {
+ regions.size.x
+ } else {
+ frame.width()
+ + 2.0 * (counter.width() + NUMBER_GUTTER.resolve(styles))
+ };
+
+ let height = frame.height().max(counter.height());
+ frame.resize(Size::new(width, height), Align::CENTER_HORIZON);
+
+ let x = if TextElem::dir_in(styles).is_positive() {
+ frame.width() - counter.width()
+ } else {
+ Abs::zero()
+ };
+ let y = (frame.height() - counter.height()) / 2.0;
+
+ frame.push_frame(Point::new(x, y), counter)
+ }
+ } else {
+ let slack = ParElem::leading_in(styles) * 0.7;
+ let top_edge = TextElem::top_edge_in(styles).resolve(styles, font.metrics());
+ let bottom_edge =
+ -TextElem::bottom_edge_in(styles).resolve(styles, font.metrics());
+
+ let ascent = top_edge.max(frame.ascent() - slack);
+ let descent = bottom_edge.max(frame.descent() - slack);
+ frame.translate(Point::with_y(ascent - frame.baseline()));
+ frame.size_mut().y = ascent + descent;
+ }
+
+ // Apply metadata.
+ frame.meta(styles, false);
+
+ Ok(Fragment::frame(frame))
+ }
+}
+
+impl Count for EquationElem {
+ fn update(&self) -> Option<CounterUpdate> {
+ (self.block(StyleChain::default())
+ && self.numbering(StyleChain::default()).is_some())
+ .then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+impl LocalName for EquationElem {
+ fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "Ekuacion",
+ Lang::ARABIC => "معادلة",
+ Lang::BOKMÅL => "Ligning",
+ Lang::CHINESE if option_eq(region, "TW") => "方程式",
+ Lang::CHINESE => "等式",
+ Lang::CZECH => "Rovnice",
+ Lang::DANISH => "Ligning",
+ Lang::DUTCH => "Vergelijking",
+ Lang::FILIPINO => "Ekwasyon",
+ Lang::FRENCH => "Équation",
+ Lang::GERMAN => "Gleichung",
+ Lang::ITALIAN => "Equazione",
+ Lang::NYNORSK => "Likning",
+ Lang::POLISH => "Równanie",
+ Lang::PORTUGUESE => "Equação",
+ Lang::RUSSIAN => "Уравнение",
+ Lang::SLOVENIAN => "Enačba",
+ Lang::SPANISH => "Ecuación",
+ Lang::SWEDISH => "Ekvation",
+ Lang::TURKISH => "Denklem",
+ Lang::UKRAINIAN => "Рівняння",
+ Lang::VIETNAMESE => "Phương trình",
+ Lang::ENGLISH | _ => "Equation",
+ }
+ }
+}
+
+impl Refable for EquationElem {
+ fn supplement(&self) -> Content {
+ // After synthesis, this should always be custom content.
+ match self.supplement(StyleChain::default()) {
+ Smart::Custom(Some(Supplement::Content(content))) => content,
+ _ => Content::empty(),
+ }
+ }
+
+ fn counter(&self) -> Counter {
+ Counter::of(Self::func())
+ }
+
+ fn numbering(&self) -> Option<Numbering> {
+ self.numbering(StyleChain::default())
+ }
+}
+
+impl Outlinable for EquationElem {
+ fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>> {
+ let Some(numbering) = self.numbering(StyleChain::default()) else {
+ return Ok(None);
+ };
+
+ // After synthesis, this should always be custom content.
+ let mut supplement = match self.supplement(StyleChain::default()) {
+ Smart::Custom(Some(Supplement::Content(content))) => content,
+ _ => Content::empty(),
+ };
+
+ if !supplement.is_empty() {
+ supplement += TextElem::packed("\u{a0}");
+ }
+
+ let numbers = self
+ .counter()
+ .at(vt, self.0.location().unwrap())?
+ .display(vt, &numbering)?;
+
+ Ok(Some(supplement + numbers))
+ }
+}
+
+pub trait LayoutMath {
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()>;
+}
+
+impl LayoutMath for EquationElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ self.body().layout_math(ctx)
+ }
+}
+
+impl LayoutMath for Content {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ // Directly layout the body of nested equations instead of handling it
+ // like a normal equation so that things like this work:
+ // ```
+ // #let my = $pi$
+ // $ my r^2 $
+ // ```
+ if let Some(elem) = self.to::<EquationElem>() {
+ return elem.layout_math(ctx);
+ }
+
+ if let Some(realized) = ctx.realize(self)? {
+ return realized.layout_math(ctx);
+ }
+
+ if let Some(children) = self.to_sequence() {
+ for child in children {
+ child.layout_math(ctx)?;
+ }
+ return Ok(());
+ }
+
+ if let Some((elem, styles)) = self.to_styled() {
+ if TextElem::font_in(ctx.styles().chain(styles))
+ != TextElem::font_in(ctx.styles())
+ {
+ let frame = ctx.layout_content(self)?;
+ ctx.push(FrameFragment::new(ctx, frame).with_spaced(true));
+ return Ok(());
+ }
+
+ let prev_map = std::mem::replace(&mut ctx.local, styles.clone());
+ let prev_size = ctx.size;
+ ctx.local.apply(prev_map.clone());
+ ctx.size = TextElem::size_in(ctx.styles());
+ elem.layout_math(ctx)?;
+ ctx.size = prev_size;
+ ctx.local = prev_map;
+ return Ok(());
+ }
+
+ if self.is::<SpaceElem>() {
+ ctx.push(MathFragment::Space(ctx.space_width.scaled(ctx)));
+ return Ok(());
+ }
+
+ if self.is::<LinebreakElem>() {
+ ctx.push(MathFragment::Linebreak);
+ return Ok(());
+ }
+
+ if let Some(elem) = self.to::<HElem>() {
+ if let Spacing::Rel(rel) = elem.amount() {
+ if rel.rel.is_zero() {
+ ctx.push(MathFragment::Spacing(rel.abs.resolve(ctx.styles())));
+ }
+ }
+ return Ok(());
+ }
+
+ if let Some(elem) = self.to::<TextElem>() {
+ let fragment = ctx.layout_text(elem)?;
+ ctx.push(fragment);
+ return Ok(());
+ }
+
+ if let Some(elem) = self.with::<dyn LayoutMath>() {
+ return elem.layout_math(ctx);
+ }
+
+ let mut frame = ctx.layout_content(self)?;
+ if !frame.has_baseline() {
+ let axis = scaled!(ctx, axis_height);
+ frame.set_baseline(frame.height() / 2.0 + axis);
+ }
+ ctx.push(FrameFragment::new(ctx, frame).with_spaced(true));
+
+ Ok(())
+ }
+}
diff --git a/crates/typst-library/src/math/op.rs b/crates/typst-library/src/math/op.rs
new file mode 100644
index 00000000..8ad74c49
--- /dev/null
+++ b/crates/typst-library/src/math/op.rs
@@ -0,0 +1,113 @@
+use typst::eval::Scope;
+
+use super::*;
+
+/// A text operator in an equation.
+///
+/// ## Example { #example }
+/// ```example
+/// $ tan x = (sin x)/(cos x) $
+/// $ op("custom",
+/// limits: #true)_(n->oo) n $
+/// ```
+///
+/// ## Predefined Operators { #predefined }
+/// Typst predefines the operators `arccos`, `arcsin`, `arctan`, `arg`,
+/// `cos`, `cosh`, `cot`, `ctg`, `coth`, `csc`, `deg`, `det`, `dim`,
+/// `exp`, `gcd`, `hom`, `mod`, `inf`, `ker`, `lg`, `lim`, `ln`, `log`,
+/// `max`, `min`, `Pr`, `sec`, `sin`, `sinc`, `sinh`, `sup`, `tan`, `tg`,
+/// `tanh`, `liminf`, and `limsup`.
+///
+/// Display: Text Operator
+/// Category: math
+#[element(LayoutMath)]
+pub struct OpElem {
+ /// The operator's text.
+ #[required]
+ pub text: EcoString,
+
+ /// Whether the operator should show attachments as limits in display mode.
+ #[default(false)]
+ pub limits: bool,
+}
+
+impl LayoutMath for OpElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let fragment =
+ ctx.layout_text(&TextElem::new(self.text()).spanned(self.span()))?;
+ ctx.push(
+ FrameFragment::new(ctx, fragment.into_frame())
+ .with_class(MathClass::Large)
+ .with_limits(if self.limits(ctx.styles()) {
+ Limits::Display
+ } else {
+ Limits::Never
+ }),
+ );
+ Ok(())
+ }
+}
+
+macro_rules! ops {
+ ($($name:ident $(: $value:literal)? $(($tts:tt))?),* $(,)?) => {
+ pub(super) fn define(math: &mut Scope) {
+ $(math.define(
+ stringify!($name),
+ OpElem::new(ops!(@name $name $(: $value)?).into())
+ .with_limits(ops!(@limit $($tts)*))
+ .pack()
+ );)*
+
+ let dif = |d| {
+ HElem::new(THIN.into()).pack()
+ + MathStyleElem::new(TextElem::packed(d)).with_italic(Some(false)).pack()
+ };
+ math.define("dif", dif('d'));
+ math.define("Dif", dif('D'));
+ }
+ };
+ (@name $name:ident) => { stringify!($name) };
+ (@name $name:ident: $value:literal) => { $value };
+ (@limit limits) => { true };
+ (@limit) => { false };
+}
+
+ops! {
+ arccos,
+ arcsin,
+ arctan,
+ arg,
+ cos,
+ cosh,
+ cot,
+ ctg,
+ coth,
+ csc,
+ deg,
+ det (limits),
+ dim,
+ exp,
+ gcd (limits),
+ hom,
+ mod,
+ inf (limits),
+ ker,
+ lg,
+ lim (limits),
+ ln,
+ log,
+ max (limits),
+ min (limits),
+ Pr (limits),
+ sec,
+ sin,
+ sinc,
+ sinh,
+ sup (limits),
+ tan,
+ tg,
+ tanh,
+ liminf: "lim inf" (limits),
+ limsup: "lim sup" (limits),
+}
diff --git a/crates/typst-library/src/math/root.rs b/crates/typst-library/src/math/root.rs
new file mode 100644
index 00000000..d1c5f46a
--- /dev/null
+++ b/crates/typst-library/src/math/root.rs
@@ -0,0 +1,156 @@
+use super::*;
+
+/// A square root.
+///
+/// ## Example { #example }
+/// ```example
+/// $ sqrt(x^2) = x = sqrt(x)^2 $
+/// ```
+///
+/// Display: Square Root
+/// Category: math
+#[func]
+pub fn sqrt(
+ /// The expression to take the square root of.
+ radicand: Content,
+) -> Content {
+ RootElem::new(radicand).pack()
+}
+
+/// A general root.
+///
+/// ## Example { #example }
+/// ```example
+/// $ root(3, x) $
+/// ```
+///
+/// Display: Root
+/// Category: math
+#[element(LayoutMath)]
+pub struct RootElem {
+ /// Which root of the radicand to take.
+ #[positional]
+ pub index: Option<Content>,
+
+ /// The expression to take the root of.
+ #[required]
+ pub radicand: Content,
+}
+
+impl LayoutMath for RootElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout(ctx, self.index(ctx.styles()).as_ref(), &self.radicand(), self.span())
+ }
+}
+
+/// Layout a root.
+///
+/// https://www.w3.org/TR/mathml-core/#radicals-msqrt-mroot
+fn layout(
+ ctx: &mut MathContext,
+ mut index: Option<&Content>,
+ radicand: &Content,
+ span: Span,
+) -> SourceResult<()> {
+ let gap = scaled!(
+ ctx,
+ text: radical_vertical_gap,
+ display: radical_display_style_vertical_gap,
+ );
+ let thickness = scaled!(ctx, radical_rule_thickness);
+ let extra_ascender = scaled!(ctx, radical_extra_ascender);
+ let kern_before = scaled!(ctx, radical_kern_before_degree);
+ let kern_after = scaled!(ctx, radical_kern_after_degree);
+ let raise_factor = percent!(ctx, radical_degree_bottom_raise_percent);
+
+ // Layout radicand.
+ ctx.style(ctx.style.with_cramped(true));
+ let radicand = ctx.layout_frame(radicand)?;
+ ctx.unstyle();
+
+ // Layout root symbol.
+ let target = radicand.height() + thickness + gap;
+ let sqrt = precomposed(ctx, index, target)
+ .map(|frame| {
+ index = None;
+ frame
+ })
+ .unwrap_or_else(|| {
+ let glyph = GlyphFragment::new(ctx, '√', span);
+ glyph.stretch_vertical(ctx, target, Abs::zero()).frame
+ });
+
+ // Layout the index.
+ // Script-script style looks too small, we use Script style instead.
+ ctx.style(ctx.style.with_size(MathSize::Script));
+ let index = index.map(|elem| ctx.layout_frame(elem)).transpose()?;
+ ctx.unstyle();
+
+ let gap = gap.max((sqrt.height() - radicand.height() - thickness) / 2.0);
+ let descent = radicand.descent() + gap;
+ let inner_ascent = extra_ascender + thickness + gap + radicand.ascent();
+
+ let mut sqrt_offset = Abs::zero();
+ let mut shift_up = Abs::zero();
+ let mut ascent = inner_ascent;
+
+ if let Some(index) = &index {
+ sqrt_offset = kern_before + index.width() + kern_after;
+ shift_up = raise_factor * sqrt.height() - descent + index.descent();
+ ascent.set_max(shift_up + index.ascent());
+ }
+
+ let radicant_offset = sqrt_offset + sqrt.width();
+ let width = radicant_offset + radicand.width();
+ let size = Size::new(width, ascent + descent);
+
+ let sqrt_pos = Point::new(sqrt_offset, ascent - inner_ascent);
+ let line_pos = Point::new(radicant_offset, ascent - inner_ascent + thickness / 2.0);
+ let radicand_pos = Point::new(radicant_offset, ascent - radicand.ascent());
+
+ let mut frame = Frame::new(size);
+ frame.set_baseline(ascent);
+
+ if let Some(index) = index {
+ let index_pos = Point::new(kern_before, ascent - shift_up - index.ascent());
+ frame.push_frame(index_pos, index);
+ }
+
+ frame.push_frame(sqrt_pos, sqrt);
+ frame.push(
+ line_pos,
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(radicand.width())).stroked(Stroke {
+ paint: TextElem::fill_in(ctx.styles()),
+ thickness,
+ ..Stroke::default()
+ }),
+ span,
+ ),
+ );
+
+ frame.push_frame(radicand_pos, radicand);
+ ctx.push(FrameFragment::new(ctx, frame));
+
+ Ok(())
+}
+
+/// Select a precomposed radical, if the font has it.
+fn precomposed(ctx: &MathContext, index: Option<&Content>, target: Abs) -> Option<Frame> {
+ let elem = index?.to::<TextElem>()?;
+ let c = match elem.text().as_str() {
+ "3" => '∛',
+ "4" => '∜',
+ _ => return None,
+ };
+
+ ctx.ttf.glyph_index(c)?;
+ let glyph = GlyphFragment::new(ctx, c, elem.span());
+ let variant = glyph.stretch_vertical(ctx, target, Abs::zero()).frame;
+ if variant.height() < target {
+ return None;
+ }
+
+ Some(variant)
+}
diff --git a/crates/typst-library/src/math/row.rs b/crates/typst-library/src/math/row.rs
new file mode 100644
index 00000000..687f82b8
--- /dev/null
+++ b/crates/typst-library/src/math/row.rs
@@ -0,0 +1,258 @@
+use std::iter::once;
+
+use crate::layout::AlignElem;
+
+use super::*;
+
+pub const TIGHT_LEADING: Em = Em::new(0.25);
+
+#[derive(Debug, Default, Clone)]
+pub struct MathRow(Vec<MathFragment>);
+
+impl MathRow {
+ pub fn new(fragments: Vec<MathFragment>) -> Self {
+ let iter = fragments.into_iter().peekable();
+ let mut last: Option<usize> = None;
+ let mut space: Option<MathFragment> = None;
+ let mut resolved: Vec<MathFragment> = vec![];
+
+ for mut fragment in iter {
+ match fragment {
+ // Keep space only if supported by spaced fragments.
+ MathFragment::Space(_) => {
+ if last.is_some() {
+ space = Some(fragment);
+ }
+ continue;
+ }
+
+ // Explicit spacing disables automatic spacing.
+ MathFragment::Spacing(_) => {
+ last = None;
+ space = None;
+ resolved.push(fragment);
+ continue;
+ }
+
+ // Alignment points are resolved later.
+ MathFragment::Align => {
+ resolved.push(fragment);
+ continue;
+ }
+
+ // New line, new things.
+ MathFragment::Linebreak => {
+ resolved.push(fragment);
+ space = None;
+ last = None;
+ continue;
+ }
+
+ _ => {}
+ }
+
+ // Convert variable operators into binary operators if something
+ // precedes them and they are not preceded by a operator or comparator.
+ if fragment.class() == Some(MathClass::Vary)
+ && matches!(
+ last.and_then(|i| resolved[i].class()),
+ Some(
+ MathClass::Normal
+ | MathClass::Alphabetic
+ | MathClass::Closing
+ | MathClass::Fence
+ )
+ )
+ {
+ fragment.set_class(MathClass::Binary);
+ }
+
+ // Insert spacing between the last and this item.
+ if let Some(i) = last {
+ if let Some(s) = spacing(&resolved[i], space.take(), &fragment) {
+ resolved.insert(i + 1, s);
+ }
+ }
+
+ last = Some(resolved.len());
+ resolved.push(fragment);
+ }
+
+ Self(resolved)
+ }
+
+ pub fn iter(&self) -> std::slice::Iter<'_, MathFragment> {
+ self.0.iter()
+ }
+
+ /// Extract the sublines of the row.
+ ///
+ /// It is very unintuitive, but in current state of things, a `MathRow` can
+ /// contain several actual rows. That function deconstructs it to "single"
+ /// rows. Hopefully this is only a temporary hack.
+ pub fn rows(&self) -> Vec<Self> {
+ self.0
+ .split(|frag| matches!(frag, MathFragment::Linebreak))
+ .map(|slice| Self(slice.to_vec()))
+ .collect()
+ }
+
+ pub fn ascent(&self) -> Abs {
+ self.iter().map(MathFragment::ascent).max().unwrap_or_default()
+ }
+
+ pub fn descent(&self) -> Abs {
+ self.iter().map(MathFragment::descent).max().unwrap_or_default()
+ }
+
+ pub fn class(&self) -> MathClass {
+ // Predict the class of the output of 'into_fragment'
+ if self.0.len() == 1 {
+ self.0
+ .first()
+ .and_then(|fragment| fragment.class())
+ .unwrap_or(MathClass::Special)
+ } else {
+ // FrameFragment::new() (inside 'into_fragment' in this branch) defaults
+ // to MathClass::Normal for its class.
+ MathClass::Normal
+ }
+ }
+
+ pub fn into_frame(self, ctx: &MathContext) -> Frame {
+ let styles = ctx.styles();
+ let align = AlignElem::alignment_in(styles).x.resolve(styles);
+ self.into_aligned_frame(ctx, &[], align)
+ }
+
+ pub fn into_fragment(self, ctx: &MathContext) -> MathFragment {
+ if self.0.len() == 1 {
+ self.0.into_iter().next().unwrap()
+ } else {
+ FrameFragment::new(ctx, self.into_frame(ctx)).into()
+ }
+ }
+
+ pub fn into_aligned_frame(
+ self,
+ ctx: &MathContext,
+ points: &[Abs],
+ align: Align,
+ ) -> Frame {
+ if self.iter().any(|frag| matches!(frag, MathFragment::Linebreak)) {
+ let leading = if ctx.style.size >= MathSize::Text {
+ ParElem::leading_in(ctx.styles())
+ } else {
+ TIGHT_LEADING.scaled(ctx)
+ };
+
+ let mut rows: Vec<_> = self.rows();
+
+ if matches!(rows.last(), Some(row) if row.0.is_empty()) {
+ rows.pop();
+ }
+
+ let AlignmentResult { points, width } = alignments(&rows);
+ let mut frame = Frame::new(Size::zero());
+
+ for (i, row) in rows.into_iter().enumerate() {
+ let sub = row.into_line_frame(&points, align);
+ let size = frame.size_mut();
+ if i > 0 {
+ size.y += leading;
+ }
+
+ let mut pos = Point::with_y(size.y);
+ if points.is_empty() {
+ pos.x = align.position(width - sub.width());
+ }
+ size.y += sub.height();
+ size.x.set_max(sub.width());
+ frame.push_frame(pos, sub);
+ }
+ frame
+ } else {
+ self.into_line_frame(points, align)
+ }
+ }
+
+ fn into_line_frame(self, points: &[Abs], align: Align) -> Frame {
+ let ascent = self.ascent();
+ let mut frame = Frame::new(Size::new(Abs::zero(), ascent + self.descent()));
+ frame.set_baseline(ascent);
+
+ let mut next_x = {
+ let mut widths = Vec::new();
+ if !points.is_empty() && align != Align::Left {
+ let mut width = Abs::zero();
+ for fragment in self.iter() {
+ if matches!(fragment, MathFragment::Align) {
+ widths.push(width);
+ width = Abs::zero();
+ } else {
+ width += fragment.width();
+ }
+ }
+ widths.push(width);
+ }
+ let widths = widths;
+
+ let mut prev_points = once(Abs::zero()).chain(points.iter().copied());
+ let mut point_widths = points.iter().copied().zip(widths);
+ let mut alternator = LeftRightAlternator::Right;
+ move || match align {
+ Align::Left => prev_points.next(),
+ Align::Right => point_widths.next().map(|(point, width)| point - width),
+ _ => point_widths
+ .next()
+ .zip(prev_points.next())
+ .zip(alternator.next())
+ .map(|(((point, width), prev_point), alternator)| match alternator {
+ LeftRightAlternator::Left => prev_point,
+ LeftRightAlternator::Right => point - width,
+ }),
+ }
+ };
+ let mut x = next_x().unwrap_or_default();
+
+ for fragment in self.0.into_iter() {
+ if matches!(fragment, MathFragment::Align) {
+ x = next_x().unwrap_or(x);
+ continue;
+ }
+
+ let y = ascent - fragment.ascent();
+ let pos = Point::new(x, y);
+ x += fragment.width();
+ frame.push_frame(pos, fragment.into_frame());
+ }
+
+ frame.size_mut().x = x;
+ frame
+ }
+}
+
+impl<T: Into<MathFragment>> From<T> for MathRow {
+ fn from(fragment: T) -> Self {
+ Self(vec![fragment.into()])
+ }
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+enum LeftRightAlternator {
+ Left,
+ Right,
+}
+
+impl Iterator for LeftRightAlternator {
+ type Item = LeftRightAlternator;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let r = Some(*self);
+ match self {
+ Self::Left => *self = Self::Right,
+ Self::Right => *self = Self::Left,
+ }
+ r
+ }
+}
diff --git a/crates/typst-library/src/math/spacing.rs b/crates/typst-library/src/math/spacing.rs
new file mode 100644
index 00000000..848aca78
--- /dev/null
+++ b/crates/typst-library/src/math/spacing.rs
@@ -0,0 +1,60 @@
+use super::*;
+
+pub(super) const THIN: Em = Em::new(1.0 / 6.0);
+pub(super) const MEDIUM: Em = Em::new(2.0 / 9.0);
+pub(super) const THICK: Em = Em::new(5.0 / 18.0);
+pub(super) const QUAD: Em = Em::new(1.0);
+
+/// Hook up all spacings.
+pub(super) fn define(math: &mut Scope) {
+ math.define("thin", HElem::new(THIN.into()).pack());
+ math.define("med", HElem::new(MEDIUM.into()).pack());
+ math.define("thick", HElem::new(THICK.into()).pack());
+ math.define("quad", HElem::new(QUAD.into()).pack());
+}
+
+/// Create the spacing between two fragments in a given style.
+pub(super) fn spacing(
+ l: &MathFragment,
+ space: Option<MathFragment>,
+ r: &MathFragment,
+) -> Option<MathFragment> {
+ use MathClass::*;
+
+ let class = |f: &MathFragment| f.class().unwrap_or(Special);
+ let resolve = |v: Em, f: &MathFragment| {
+ Some(MathFragment::Spacing(f.font_size().map_or(Abs::zero(), |size| v.at(size))))
+ };
+ let script =
+ |f: &MathFragment| f.style().map_or(false, |s| s.size <= MathSize::Script);
+
+ match (class(l), class(r)) {
+ // No spacing before punctuation; thin spacing after punctuation, unless
+ // in script size.
+ (_, Punctuation) => None,
+ (Punctuation, _) if !script(l) => resolve(THIN, l),
+
+ // No spacing after opening delimiters and before closing delimiters.
+ (Opening, _) | (_, Closing) => None,
+
+ // Thick spacing around relations, unless followed by a another relation
+ // or in script size.
+ (Relation, Relation) => None,
+ (Relation, _) if !script(l) => resolve(THICK, l),
+ (_, Relation) if !script(r) => resolve(THICK, r),
+
+ // Medium spacing around binary operators, unless in script size.
+ (Binary, _) if !script(l) => resolve(MEDIUM, l),
+ (_, Binary) if !script(r) => resolve(MEDIUM, r),
+
+ // Thin spacing around large operators, unless next to a delimiter.
+ (Large, Opening | Fence) | (Closing | Fence, Large) => None,
+ (Large, _) => resolve(THIN, l),
+ (_, Large) => resolve(THIN, r),
+
+ // Spacing around spaced frames.
+ _ if (l.is_spaced() || r.is_spaced()) => space,
+
+ _ => None,
+ }
+}
diff --git a/crates/typst-library/src/math/stretch.rs b/crates/typst-library/src/math/stretch.rs
new file mode 100644
index 00000000..910f7a81
--- /dev/null
+++ b/crates/typst-library/src/math/stretch.rs
@@ -0,0 +1,199 @@
+use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart};
+use ttf_parser::LazyArray16;
+
+use super::*;
+
+/// Maximum number of times extenders can be repeated.
+const MAX_REPEATS: usize = 1024;
+
+impl GlyphFragment {
+ /// Try to stretch a glyph to a desired height.
+ pub fn stretch_vertical(
+ self,
+ ctx: &MathContext,
+ height: Abs,
+ short_fall: Abs,
+ ) -> VariantFragment {
+ stretch_glyph(ctx, self, height, short_fall, false)
+ }
+
+ /// Try to stretch a glyph to a desired width.
+ pub fn stretch_horizontal(
+ self,
+ ctx: &MathContext,
+ width: Abs,
+ short_fall: Abs,
+ ) -> VariantFragment {
+ stretch_glyph(ctx, self, width, short_fall, true)
+ }
+}
+
+/// Try to stretch a glyph to a desired width or height.
+///
+/// The resulting frame may not have the exact desired width.
+fn stretch_glyph(
+ ctx: &MathContext,
+ mut base: GlyphFragment,
+ target: Abs,
+ short_fall: Abs,
+ horizontal: bool,
+) -> VariantFragment {
+ let short_target = target - short_fall;
+ let mut min_overlap = Abs::zero();
+ let construction = ctx
+ .table
+ .variants
+ .and_then(|variants| {
+ min_overlap = variants.min_connector_overlap.scaled(ctx);
+ if horizontal {
+ variants.horizontal_constructions
+ } else {
+ variants.vertical_constructions
+ }
+ .get(base.id)
+ })
+ .unwrap_or(GlyphConstruction { assembly: None, variants: LazyArray16::new(&[]) });
+
+ // If the base glyph is good enough, use it.
+ let advance = if horizontal { base.width } else { base.height() };
+ if short_target <= advance {
+ return base.into_variant();
+ }
+
+ // Search for a pre-made variant with a good advance.
+ let mut best_id = base.id;
+ let mut best_advance = base.width;
+ for variant in construction.variants {
+ best_id = variant.variant_glyph;
+ best_advance = base.font.to_em(variant.advance_measurement).at(base.font_size);
+ if short_target <= best_advance {
+ break;
+ }
+ }
+
+ // This is either good or the best we've got.
+ if short_target <= best_advance || construction.assembly.is_none() {
+ base.set_id(ctx, best_id);
+ return base.into_variant();
+ }
+
+ // Assemble from parts.
+ let assembly = construction.assembly.unwrap();
+ assemble(ctx, base, assembly, min_overlap, target, horizontal)
+}
+
+/// Assemble a glyph from parts.
+fn assemble(
+ ctx: &MathContext,
+ base: GlyphFragment,
+ assembly: GlyphAssembly,
+ min_overlap: Abs,
+ target: Abs,
+ horizontal: bool,
+) -> VariantFragment {
+ // Determine the number of times the extenders need to be repeated as well
+ // as a ratio specifying how much to spread the parts apart
+ // (0 = maximal overlap, 1 = minimal overlap).
+ let mut full;
+ let mut ratio;
+ let mut repeat = 0;
+ loop {
+ full = Abs::zero();
+ ratio = 0.0;
+
+ let mut parts = parts(assembly, repeat).peekable();
+ let mut growable = Abs::zero();
+
+ while let Some(part) = parts.next() {
+ let mut advance = part.full_advance.scaled(ctx);
+ if let Some(next) = parts.peek() {
+ let max_overlap = part
+ .end_connector_length
+ .min(next.start_connector_length)
+ .scaled(ctx);
+
+ advance -= max_overlap;
+ growable += max_overlap - min_overlap;
+ }
+
+ full += advance;
+ }
+
+ if full < target {
+ let delta = target - full;
+ ratio = (delta / growable).min(1.0);
+ full += ratio * growable;
+ }
+
+ if target <= full || repeat >= MAX_REPEATS {
+ break;
+ }
+
+ repeat += 1;
+ }
+
+ let mut selected = vec![];
+ let mut parts = parts(assembly, repeat).peekable();
+ while let Some(part) = parts.next() {
+ let mut advance = part.full_advance.scaled(ctx);
+ if let Some(next) = parts.peek() {
+ let max_overlap =
+ part.end_connector_length.min(next.start_connector_length).scaled(ctx);
+ advance -= max_overlap;
+ advance += ratio * (max_overlap - min_overlap);
+ }
+
+ let mut fragment = base.clone();
+ fragment.set_id(ctx, part.glyph_id);
+ selected.push((fragment, advance));
+ }
+
+ let size;
+ let baseline;
+ if horizontal {
+ let height = base.ascent + base.descent;
+ size = Size::new(full, height);
+ baseline = base.ascent;
+ } else {
+ let axis = scaled!(ctx, axis_height);
+ let width = selected.iter().map(|(f, _)| f.width).max().unwrap_or_default();
+ size = Size::new(width, full);
+ baseline = full / 2.0 + axis;
+ }
+
+ let mut frame = Frame::new(size);
+ let mut offset = Abs::zero();
+ frame.set_baseline(baseline);
+ frame.meta_iter(base.meta);
+
+ for (fragment, advance) in selected {
+ let pos = if horizontal {
+ Point::new(offset, frame.baseline() - fragment.ascent)
+ } else {
+ Point::with_y(full - offset - fragment.height())
+ };
+ frame.push_frame(pos, fragment.into_frame());
+ offset += advance;
+ }
+
+ VariantFragment {
+ c: base.c,
+ id: None,
+ frame,
+ style: base.style,
+ font_size: base.font_size,
+ italics_correction: Abs::zero(),
+ class: base.class,
+ span: base.span,
+ limits: base.limits,
+ }
+}
+
+/// Return an iterator over the assembly's parts with extenders repeated the
+/// specified number of times.
+fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator<Item = GlyphPart> + '_ {
+ assembly.parts.into_iter().flat_map(move |part| {
+ let count = if part.part_flags.extender() { repeat } else { 1 };
+ std::iter::repeat(part).take(count)
+ })
+}
diff --git a/crates/typst-library/src/math/style.rs b/crates/typst-library/src/math/style.rs
new file mode 100644
index 00000000..235770db
--- /dev/null
+++ b/crates/typst-library/src/math/style.rs
@@ -0,0 +1,620 @@
+use super::*;
+
+/// Bold font style in math.
+///
+/// ## Example { #example }
+/// ```example
+/// $ bold(A) := B^+ $
+/// ```
+///
+/// Display: Bold
+/// Category: math
+#[func]
+pub fn bold(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_bold(Some(true)).pack()
+}
+
+/// Upright (non-italic) font style in math.
+///
+/// ## Example { #example }
+/// ```example
+/// $ upright(A) != A $
+/// ```
+///
+/// Display: Upright
+/// Category: math
+#[func]
+pub fn upright(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_italic(Some(false)).pack()
+}
+
+/// Italic font style in math.
+///
+/// For roman letters and greek lowercase letters, this is already the default.
+///
+/// Display: Italic
+/// Category: math
+#[func]
+pub fn italic(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_italic(Some(true)).pack()
+}
+/// Serif (roman) font style in math.
+///
+/// This is already the default.
+///
+/// Display: Serif
+/// Category: math
+#[func]
+pub fn serif(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_variant(Some(MathVariant::Serif)).pack()
+}
+
+/// Sans-serif font style in math.
+///
+/// ## Example { #example }
+/// ```example
+/// $ sans(A B C) $
+/// ```
+///
+/// Display: Sans-serif
+/// Category: math
+#[func]
+pub fn sans(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_variant(Some(MathVariant::Sans)).pack()
+}
+
+/// Calligraphic font style in math.
+///
+/// ## Example { #example }
+/// ```example
+/// Let $cal(P)$ be the set of ...
+/// ```
+///
+/// Display: Calligraphic
+/// Category: math
+#[func]
+pub fn cal(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_variant(Some(MathVariant::Cal)).pack()
+}
+
+/// Fraktur font style in math.
+///
+/// ## Example { #example }
+/// ```example
+/// $ frak(P) $
+/// ```
+///
+/// Display: Fraktur
+/// Category: math
+#[func]
+pub fn frak(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_variant(Some(MathVariant::Frak)).pack()
+}
+
+/// Monospace font style in math.
+///
+/// ## Example { #example }
+/// ```example
+/// $ mono(x + y = z) $
+/// ```
+///
+/// Display: Monospace
+/// Category: math
+#[func]
+pub fn mono(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_variant(Some(MathVariant::Mono)).pack()
+}
+
+/// Blackboard bold (double-struck) font style in math.
+///
+/// For uppercase latin letters, blackboard bold is additionally available
+/// through [symbols]($category/symbols/sym) of the form `NN` and `RR`.
+///
+/// ## Example { #example }
+/// ```example
+/// $ bb(b) $
+/// $ bb(N) = NN $
+/// $ f: NN -> RR $
+/// ```
+///
+/// Display: Blackboard Bold
+/// Category: math
+#[func]
+pub fn bb(
+ /// The content to style.
+ body: Content,
+) -> Content {
+ MathStyleElem::new(body).with_variant(Some(MathVariant::Bb)).pack()
+}
+
+/// Forced display style in math.
+///
+/// This is the normal size for block equations.
+///
+/// ## Example { #example }
+/// ```example
+/// $sum_i x_i/2 = display(sum_i x_i/2)$
+/// ```
+///
+/// Display: Display Size
+/// Category: math
+#[func]
+pub fn display(
+ /// The content to size.
+ body: Content,
+ /// Whether to impose a height restriction for exponents, like regular sub-
+ /// and superscripts do.
+ #[named]
+ #[default(false)]
+ cramped: bool,
+) -> Content {
+ MathStyleElem::new(body)
+ .with_size(Some(MathSize::Display))
+ .with_cramped(Some(cramped))
+ .pack()
+}
+
+/// Forced inline (text) style in math.
+///
+/// This is the normal size for inline equations.
+///
+/// ## Example { #example }
+/// ```example
+/// $ sum_i x_i/2
+/// = inline(sum_i x_i/2) $
+/// ```
+///
+/// Display: Inline Size
+/// Category: math
+#[func]
+pub fn inline(
+ /// The content to size.
+ body: Content,
+ /// Whether to impose a height restriction for exponents, like regular sub-
+ /// and superscripts do.
+ #[named]
+ #[default(false)]
+ cramped: bool,
+) -> Content {
+ MathStyleElem::new(body)
+ .with_size(Some(MathSize::Text))
+ .with_cramped(Some(cramped))
+ .pack()
+}
+
+/// Forced script style in math.
+///
+/// This is the smaller size used in powers or sub- or superscripts.
+///
+/// ## Example { #example }
+/// ```example
+/// $sum_i x_i/2 = script(sum_i x_i/2)$
+/// ```
+///
+/// Display: Script Size
+/// Category: math
+#[func]
+pub fn script(
+ /// The content to size.
+ body: Content,
+ /// Whether to impose a height restriction for exponents, like regular sub-
+ /// and superscripts do.
+ #[named]
+ #[default(true)]
+ cramped: bool,
+) -> Content {
+ MathStyleElem::new(body)
+ .with_size(Some(MathSize::Script))
+ .with_cramped(Some(cramped))
+ .pack()
+}
+
+/// Forced second script style in math.
+///
+/// This is the smallest size, used in second-level sub- and superscripts
+/// (script of the script).
+///
+/// ## Example { #example }
+/// ```example
+/// $sum_i x_i/2 = sscript(sum_i x_i/2)$
+/// ```
+///
+/// Display: Script-Script Size
+/// Category: math
+#[func]
+pub fn sscript(
+ /// The content to size.
+ body: Content,
+ /// Whether to impose a height restriction for exponents, like regular sub-
+ /// and superscripts do.
+ #[named]
+ #[default(true)]
+ cramped: bool,
+) -> Content {
+ MathStyleElem::new(body)
+ .with_size(Some(MathSize::ScriptScript))
+ .with_cramped(Some(cramped))
+ .pack()
+}
+
+/// A font variant in math.
+///
+/// Display: Bold
+/// Category: math
+#[element(LayoutMath)]
+pub struct MathStyleElem {
+ /// The content to style.
+ #[required]
+ pub body: Content,
+
+ /// The variant to select.
+ pub variant: Option<MathVariant>,
+
+ /// Whether to use bold glyphs.
+ pub bold: Option<bool>,
+
+ /// Whether to use italic glyphs.
+ pub italic: Option<bool>,
+
+ /// Whether to use forced size
+ pub size: Option<MathSize>,
+
+ /// Whether to limit height of exponents
+ pub cramped: Option<bool>,
+}
+
+impl LayoutMath for MathStyleElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ let mut style = ctx.style;
+ if let Some(variant) = self.variant(StyleChain::default()) {
+ style = style.with_variant(variant);
+ }
+ if let Some(bold) = self.bold(StyleChain::default()) {
+ style = style.with_bold(bold);
+ }
+ if let Some(italic) = self.italic(StyleChain::default()) {
+ style = style.with_italic(italic);
+ }
+ if let Some(size) = self.size(StyleChain::default()) {
+ style = style.with_size(size);
+ }
+ if let Some(cramped) = self.cramped(StyleChain::default()) {
+ style = style.with_cramped(cramped);
+ }
+ ctx.style(style);
+ self.body().layout_math(ctx)?;
+ ctx.unstyle();
+ Ok(())
+ }
+}
+
+/// Text properties in math.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub struct MathStyle {
+ /// The style variant to select.
+ pub variant: MathVariant,
+ /// The size of the glyphs.
+ pub size: MathSize,
+ /// Affects the height of exponents.
+ pub cramped: bool,
+ /// Whether to use bold glyphs.
+ pub bold: bool,
+ /// Whether to use italic glyphs.
+ pub italic: Smart<bool>,
+}
+
+impl MathStyle {
+ /// This style, with the given `variant`.
+ pub fn with_variant(self, variant: MathVariant) -> Self {
+ Self { variant, ..self }
+ }
+
+ /// This style, with the given `size`.
+ pub fn with_size(self, size: MathSize) -> Self {
+ Self { size, ..self }
+ }
+
+ /// This style, with `cramped` set to the given value.
+ pub fn with_cramped(self, cramped: bool) -> Self {
+ Self { cramped, ..self }
+ }
+
+ /// This style, with `bold` set to the given value.
+ pub fn with_bold(self, bold: bool) -> Self {
+ Self { bold, ..self }
+ }
+
+ /// This style, with `italic` set to the given value.
+ pub fn with_italic(self, italic: bool) -> Self {
+ Self { italic: Smart::Custom(italic), ..self }
+ }
+
+ /// The style for subscripts in the current style.
+ pub fn for_subscript(self) -> Self {
+ self.for_superscript().with_cramped(true)
+ }
+
+ /// The style for superscripts in the current style.
+ pub fn for_superscript(self) -> Self {
+ self.with_size(match self.size {
+ MathSize::Display | MathSize::Text => MathSize::Script,
+ MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript,
+ })
+ }
+
+ /// The style for numerators in the current style.
+ pub fn for_numerator(self) -> Self {
+ self.with_size(match self.size {
+ MathSize::Display => MathSize::Text,
+ MathSize::Text => MathSize::Script,
+ MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript,
+ })
+ }
+
+ /// The style for denominators in the current style.
+ pub fn for_denominator(self) -> Self {
+ self.for_numerator().with_cramped(true)
+ }
+
+ /// Apply the style to a character.
+ pub fn styled_char(self, c: char) -> char {
+ styled_char(self, c)
+ }
+}
+
+/// The size of elements in an equation.
+///
+/// See the TeXbook p. 141.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Cast)]
+pub enum MathSize {
+ /// Second-level sub- and superscripts.
+ ScriptScript,
+ /// Sub- and superscripts.
+ Script,
+ /// Math in text.
+ Text,
+ /// Math on its own line.
+ Display,
+}
+
+impl MathSize {
+ pub(super) fn factor(self, ctx: &MathContext) -> f64 {
+ match self {
+ Self::Display | Self::Text => 1.0,
+ Self::Script => percent!(ctx, script_percent_scale_down),
+ Self::ScriptScript => percent!(ctx, script_script_percent_scale_down),
+ }
+ }
+}
+
+/// A mathematical style variant, as defined by Unicode.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Cast)]
+pub enum MathVariant {
+ Serif,
+ Sans,
+ Cal,
+ Frak,
+ Mono,
+ Bb,
+}
+
+impl Default for MathVariant {
+ fn default() -> Self {
+ Self::Serif
+ }
+}
+
+/// Select the correct styled math letter.
+///
+/// https://www.w3.org/TR/mathml-core/#new-text-transform-mappings
+/// https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols
+pub(super) fn styled_char(style: MathStyle, c: char) -> char {
+ use MathVariant::*;
+
+ let MathStyle { variant, bold, .. } = style;
+ let italic = style.italic.unwrap_or(matches!(
+ c,
+ 'a'..='z' | 'ı' | 'ȷ' | 'A'..='Z' | 'α'..='ω' |
+ '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ'
+ ));
+
+ if let Some(c) = basic_exception(c) {
+ return c;
+ }
+
+ if let Some(c) = latin_exception(c, variant, bold, italic) {
+ return c;
+ }
+
+ if let Some(c) = greek_exception(c, variant, bold, italic) {
+ return c;
+ }
+
+ let base = match c {
+ 'A'..='Z' => 'A',
+ 'a'..='z' => 'a',
+ 'Α'..='Ω' => 'Α',
+ 'α'..='ω' => 'α',
+ '0'..='9' => '0',
+ _ => return c,
+ };
+
+ let tuple = (variant, bold, italic);
+ let start = match c {
+ // Latin upper.
+ 'A'..='Z' => match tuple {
+ (Serif, false, false) => 0x0041,
+ (Serif, true, false) => 0x1D400,
+ (Serif, false, true) => 0x1D434,
+ (Serif, true, true) => 0x1D468,
+ (Sans, false, false) => 0x1D5A0,
+ (Sans, true, false) => 0x1D5D4,
+ (Sans, false, true) => 0x1D608,
+ (Sans, true, true) => 0x1D63C,
+ (Cal, false, _) => 0x1D49C,
+ (Cal, true, _) => 0x1D4D0,
+ (Frak, false, _) => 0x1D504,
+ (Frak, true, _) => 0x1D56C,
+ (Mono, _, _) => 0x1D670,
+ (Bb, _, _) => 0x1D538,
+ },
+
+ // Latin lower.
+ 'a'..='z' => match tuple {
+ (Serif, false, false) => 0x0061,
+ (Serif, true, false) => 0x1D41A,
+ (Serif, false, true) => 0x1D44E,
+ (Serif, true, true) => 0x1D482,
+ (Sans, false, false) => 0x1D5BA,
+ (Sans, true, false) => 0x1D5EE,
+ (Sans, false, true) => 0x1D622,
+ (Sans, true, true) => 0x1D656,
+ (Cal, false, _) => 0x1D4B6,
+ (Cal, true, _) => 0x1D4EA,
+ (Frak, false, _) => 0x1D51E,
+ (Frak, true, _) => 0x1D586,
+ (Mono, _, _) => 0x1D68A,
+ (Bb, _, _) => 0x1D552,
+ },
+
+ // Greek upper.
+ 'Α'..='Ω' => match tuple {
+ (Serif, false, false) => 0x0391,
+ (Serif, true, false) => 0x1D6A8,
+ (Serif, false, true) => 0x1D6E2,
+ (Serif, true, true) => 0x1D71C,
+ (Sans, _, false) => 0x1D756,
+ (Sans, _, true) => 0x1D790,
+ (Cal | Frak | Mono | Bb, _, _) => return c,
+ },
+
+ // Greek lower.
+ 'α'..='ω' => match tuple {
+ (Serif, false, false) => 0x03B1,
+ (Serif, true, false) => 0x1D6C2,
+ (Serif, false, true) => 0x1D6FC,
+ (Serif, true, true) => 0x1D736,
+ (Sans, _, false) => 0x1D770,
+ (Sans, _, true) => 0x1D7AA,
+ (Cal | Frak | Mono | Bb, _, _) => return c,
+ },
+
+ // Numbers.
+ '0'..='9' => match tuple {
+ (Serif, false, _) => 0x0030,
+ (Serif, true, _) => 0x1D7CE,
+ (Bb, _, _) => 0x1D7D8,
+ (Sans, false, _) => 0x1D7E2,
+ (Sans, true, _) => 0x1D7EC,
+ (Mono, _, _) => 0x1D7F6,
+ (Cal | Frak, _, _) => return c,
+ },
+
+ _ => unreachable!(),
+ };
+
+ std::char::from_u32(start + (c as u32 - base as u32)).unwrap()
+}
+
+fn basic_exception(c: char) -> Option<char> {
+ Some(match c {
+ '〈' => '⟨',
+ '〉' => '⟩',
+ '《' => '⟪',
+ '》' => '⟫',
+ _ => return None,
+ })
+}
+
+fn latin_exception(
+ c: char,
+ variant: MathVariant,
+ bold: bool,
+ italic: bool,
+) -> Option<char> {
+ use MathVariant::*;
+ Some(match (c, variant, bold, italic) {
+ ('B', Cal, false, _) => 'ℬ',
+ ('E', Cal, false, _) => 'ℰ',
+ ('F', Cal, false, _) => 'ℱ',
+ ('H', Cal, false, _) => 'ℋ',
+ ('I', Cal, false, _) => 'ℐ',
+ ('L', Cal, false, _) => 'ℒ',
+ ('M', Cal, false, _) => 'ℳ',
+ ('R', Cal, false, _) => 'ℛ',
+ ('C', Frak, false, _) => 'ℭ',
+ ('H', Frak, false, _) => 'ℌ',
+ ('I', Frak, false, _) => 'ℑ',
+ ('R', Frak, false, _) => 'ℜ',
+ ('Z', Frak, false, _) => 'ℨ',
+ ('C', Bb, ..) => 'ℂ',
+ ('H', Bb, ..) => 'ℍ',
+ ('N', Bb, ..) => 'ℕ',
+ ('P', Bb, ..) => 'ℙ',
+ ('Q', Bb, ..) => 'ℚ',
+ ('R', Bb, ..) => 'ℝ',
+ ('Z', Bb, ..) => 'ℤ',
+ ('h', Serif, false, true) => 'ℎ',
+ ('e', Cal, false, _) => 'ℯ',
+ ('g', Cal, false, _) => 'ℊ',
+ ('o', Cal, false, _) => 'ℴ',
+ ('ı', Serif, .., true) => '𝚤',
+ ('ȷ', Serif, .., true) => '𝚥',
+ _ => return None,
+ })
+}
+
+fn greek_exception(
+ c: char,
+ variant: MathVariant,
+ bold: bool,
+ italic: bool,
+) -> Option<char> {
+ use MathVariant::*;
+ let list = match c {
+ 'ϴ' => ['𝚹', '𝛳', '𝜭', '𝝧', '𝞡'],
+ '∇' => ['𝛁', '𝛻', '𝜵', '𝝯', '𝞩'],
+ '∂' => ['𝛛', '𝜕', '𝝏', '𝞉', '𝟃'],
+ 'ϵ' => ['𝛜', '𝜖', '𝝐', '𝞊', '𝟄'],
+ 'ϑ' => ['𝛝', '𝜗', '𝝑', '𝞋', '𝟅'],
+ 'ϰ' => ['𝛞', '𝜘', '𝝒', '𝞌', '𝟆'],
+ 'ϕ' => ['𝛟', '𝜙', '𝝓', '𝞍', '𝟇'],
+ 'ϱ' => ['𝛠', '𝜚', '𝝔', '𝞎', '𝟈'],
+ 'ϖ' => ['𝛡', '𝜛', '𝝕', '𝞏', '𝟉'],
+ _ => return None,
+ };
+
+ Some(match (variant, bold, italic) {
+ (Serif, true, false) => list[0],
+ (Serif, false, true) => list[1],
+ (Serif, true, true) => list[2],
+ (Sans, _, false) => list[3],
+ (Sans, _, true) => list[4],
+ _ => return None,
+ })
+}
diff --git a/crates/typst-library/src/math/underover.rs b/crates/typst-library/src/math/underover.rs
new file mode 100644
index 00000000..796c9ebc
--- /dev/null
+++ b/crates/typst-library/src/math/underover.rs
@@ -0,0 +1,339 @@
+use super::*;
+
+const BRACE_GAP: Em = Em::new(0.25);
+const BRACKET_GAP: Em = Em::new(0.25);
+
+/// A marker to distinguish under- vs. overlines.
+enum LineKind {
+ Over,
+ Under,
+}
+
+/// A horizontal line under content.
+///
+/// ## Example { #example }
+/// ```example
+/// $ underline(1 + 2 + ... + 5) $
+/// ```
+///
+/// Display: Underline
+/// Category: math
+#[element(LayoutMath)]
+pub struct UnderlineElem {
+ /// The content above the line.
+ #[required]
+ pub body: Content,
+}
+
+impl LayoutMath for UnderlineElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout_underoverline(ctx, &self.body(), self.span(), LineKind::Under)
+ }
+}
+
+/// A horizontal line over content.
+///
+/// ## Example { #example }
+/// ```example
+/// $ overline(1 + 2 + ... + 5) $
+/// ```
+///
+/// Display: Overline
+/// Category: math
+#[element(LayoutMath)]
+pub struct OverlineElem {
+ /// The content below the line.
+ #[required]
+ pub body: Content,
+}
+
+impl LayoutMath for OverlineElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout_underoverline(ctx, &self.body(), self.span(), LineKind::Over)
+ }
+}
+
+/// layout under- or overlined content
+fn layout_underoverline(
+ ctx: &mut MathContext,
+ body: &Content,
+ span: Span,
+ line: LineKind,
+) -> SourceResult<()> {
+ let (extra_height, content, line_pos, content_pos, baseline, bar_height);
+ match line {
+ LineKind::Under => {
+ let sep = scaled!(ctx, underbar_extra_descender);
+ bar_height = scaled!(ctx, underbar_rule_thickness);
+ let gap = scaled!(ctx, underbar_vertical_gap);
+ extra_height = sep + bar_height + gap;
+
+ content = ctx.layout_fragment(body)?;
+
+ line_pos = Point::with_y(content.height() + gap + bar_height / 2.0);
+ content_pos = Point::zero();
+ baseline = content.ascent()
+ }
+ LineKind::Over => {
+ let sep = scaled!(ctx, overbar_extra_ascender);
+ bar_height = scaled!(ctx, overbar_rule_thickness);
+ let gap = scaled!(ctx, overbar_vertical_gap);
+ extra_height = sep + bar_height + gap;
+
+ ctx.style(ctx.style.with_cramped(true));
+ content = ctx.layout_fragment(body)?;
+ ctx.unstyle();
+
+ line_pos = Point::with_y(sep + bar_height / 2.0);
+ content_pos = Point::with_y(extra_height);
+ baseline = content.ascent() + extra_height;
+ }
+ }
+
+ let width = content.width();
+ let height = content.height() + extra_height;
+ let size = Size::new(width, height);
+
+ let content_class = content.class().unwrap_or(MathClass::Normal);
+ let mut frame = Frame::new(size);
+ frame.set_baseline(baseline);
+ frame.push_frame(content_pos, content.into_frame());
+ frame.push(
+ line_pos,
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(width)).stroked(Stroke {
+ paint: TextElem::fill_in(ctx.styles()),
+ thickness: bar_height,
+ ..Stroke::default()
+ }),
+ span,
+ ),
+ );
+
+ ctx.push(FrameFragment::new(ctx, frame).with_class(content_class));
+
+ Ok(())
+}
+
+/// A horizontal brace under content, with an optional annotation below.
+///
+/// ## Example { #example }
+/// ```example
+/// $ underbrace(1 + 2 + ... + 5, "numbers") $
+/// ```
+///
+/// Display: Underbrace
+/// Category: math
+#[element(LayoutMath)]
+pub struct UnderbraceElem {
+ /// The content above the brace.
+ #[required]
+ pub body: Content,
+
+ /// The optional content below the brace.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+impl LayoutMath for UnderbraceElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ &self.body(),
+ &self.annotation(ctx.styles()),
+ '⏟',
+ BRACE_GAP,
+ false,
+ self.span(),
+ )
+ }
+}
+
+/// A horizontal brace over content, with an optional annotation above.
+///
+/// ## Example { #example }
+/// ```example
+/// $ overbrace(1 + 2 + ... + 5, "numbers") $
+/// ```
+///
+/// Display: Overbrace
+/// Category: math
+#[element(LayoutMath)]
+pub struct OverbraceElem {
+ /// The content below the brace.
+ #[required]
+ pub body: Content,
+
+ /// The optional content above the brace.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+impl LayoutMath for OverbraceElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ &self.body(),
+ &self.annotation(ctx.styles()),
+ '⏞',
+ BRACE_GAP,
+ true,
+ self.span(),
+ )
+ }
+}
+
+/// A horizontal bracket under content, with an optional annotation below.
+///
+/// ## Example { #example }
+/// ```example
+/// $ underbracket(1 + 2 + ... + 5, "numbers") $
+/// ```
+///
+/// Display: Underbracket
+/// Category: math
+#[element(LayoutMath)]
+pub struct UnderbracketElem {
+ /// The content above the bracket.
+ #[required]
+ pub body: Content,
+
+ /// The optional content below the bracket.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+impl LayoutMath for UnderbracketElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ &self.body(),
+ &self.annotation(ctx.styles()),
+ '⎵',
+ BRACKET_GAP,
+ false,
+ self.span(),
+ )
+ }
+}
+
+/// A horizontal bracket over content, with an optional annotation above.
+///
+/// ## Example { #example }
+/// ```example
+/// $ overbracket(1 + 2 + ... + 5, "numbers") $
+/// ```
+///
+/// Display: Overbracket
+/// Category: math
+#[element(LayoutMath)]
+pub struct OverbracketElem {
+ /// The content below the bracket.
+ #[required]
+ pub body: Content,
+
+ /// The optional content above the bracket.
+ #[positional]
+ pub annotation: Option<Content>,
+}
+
+impl LayoutMath for OverbracketElem {
+ #[tracing::instrument(skip(ctx))]
+ fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ &self.body(),
+ &self.annotation(ctx.styles()),
+ '⎴',
+ BRACKET_GAP,
+ true,
+ self.span(),
+ )
+ }
+}
+
+/// Layout an over- or underbrace-like object.
+fn layout_underoverspreader(
+ ctx: &mut MathContext,
+ body: &Content,
+ annotation: &Option<Content>,
+ c: char,
+ gap: Em,
+ reverse: bool,
+ span: Span,
+) -> SourceResult<()> {
+ let gap = gap.scaled(ctx);
+ let body = ctx.layout_row(body)?;
+ let body_class = body.class();
+ let body = body.into_fragment(ctx);
+ let glyph = GlyphFragment::new(ctx, c, span);
+ let stretched = glyph.stretch_horizontal(ctx, body.width(), Abs::zero());
+
+ let mut rows = vec![MathRow::new(vec![body]), stretched.into()];
+ ctx.style(if reverse {
+ ctx.style.for_subscript()
+ } else {
+ ctx.style.for_superscript()
+ });
+ rows.extend(
+ annotation
+ .as_ref()
+ .map(|annotation| ctx.layout_row(annotation))
+ .transpose()?,
+ );
+ ctx.unstyle();
+
+ let mut baseline = 0;
+ if reverse {
+ rows.reverse();
+ baseline = rows.len() - 1;
+ }
+
+ let frame = stack(ctx, rows, Align::Center, gap, baseline);
+ ctx.push(FrameFragment::new(ctx, frame).with_class(body_class));
+
+ Ok(())
+}
+
+/// Stack rows on top of each other.
+///
+/// Add a `gap` between each row and uses the baseline of the `baseline`th
+/// row for the whole frame.
+pub(super) fn stack(
+ ctx: &MathContext,
+ rows: Vec<MathRow>,
+ align: Align,
+ gap: Abs,
+ baseline: usize,
+) -> Frame {
+ let rows: Vec<_> = rows.into_iter().flat_map(|r| r.rows()).collect();
+ let AlignmentResult { points, width } = alignments(&rows);
+ let rows: Vec<_> = rows
+ .into_iter()
+ .map(|row| row.into_aligned_frame(ctx, &points, align))
+ .collect();
+
+ let mut y = Abs::zero();
+ let mut frame = Frame::new(Size::new(
+ width,
+ rows.iter().map(|row| row.height()).sum::<Abs>()
+ + rows.len().saturating_sub(1) as f64 * gap,
+ ));
+
+ for (i, row) in rows.into_iter().enumerate() {
+ let x = align.position(width - row.width());
+ let pos = Point::new(x, y);
+ if i == baseline {
+ frame.set_baseline(y + row.baseline());
+ }
+ y += row.height() + gap;
+ frame.push_frame(pos, row);
+ }
+
+ frame
+}
diff --git a/crates/typst-library/src/meta/bibliography.rs b/crates/typst-library/src/meta/bibliography.rs
new file mode 100644
index 00000000..0531997d
--- /dev/null
+++ b/crates/typst-library/src/meta/bibliography.rs
@@ -0,0 +1,724 @@
+use std::collections::HashMap;
+use std::ffi::OsStr;
+use std::path::Path;
+use std::sync::Arc;
+
+use ecow::{eco_vec, EcoVec};
+use hayagriva::io::{BibLaTeXError, YamlBibliographyError};
+use hayagriva::style::{self, Brackets, Citation, Database, DisplayString, Formatting};
+use hayagriva::Entry;
+use typst::diag::FileError;
+use typst::util::{option_eq, Bytes};
+
+use super::{LinkElem, LocalName, RefElem};
+use crate::layout::{BlockElem, GridElem, ParElem, Sizing, TrackSizings, VElem};
+use crate::meta::{FootnoteElem, HeadingElem};
+use crate::prelude::*;
+use crate::text::TextElem;
+
+/// A bibliography / reference listing.
+///
+/// You can create a new bibliography by calling this function with a path
+/// to a bibliography file in either one of two formats:
+///
+/// - A Hayagriva `.yml` file. Hayagriva is a new bibliography file format
+/// designed for use with Typst. Visit its
+/// [documentation](https://github.com/typst/hayagriva/blob/main/docs/file-format.md)
+/// for more details.
+/// - A BibLaTeX `.bib` file.
+///
+/// As soon as you add a bibliography somewhere in your document, you can start
+/// citing things with reference syntax (`[@key]`) or explicit calls to the
+/// [citation]($func/cite) function (`[#cite("key")]`). The bibliography will
+/// only show entries for works that were referenced in the document.
+///
+/// # Example
+/// ```example
+/// This was already noted by
+/// pirates long ago. @arrgh
+///
+/// Multiple sources say ...
+/// #cite("arrgh", "netwok").
+///
+/// #bibliography("works.bib")
+/// ```
+///
+/// Display: Bibliography
+/// Category: meta
+#[element(Locatable, Synthesize, Show, Finalize, LocalName)]
+pub struct BibliographyElem {
+ /// Path to a Hayagriva `.yml` or BibLaTeX `.bib` file.
+ #[required]
+ #[parse(
+ let Spanned { v: paths, span } =
+ args.expect::<Spanned<BibPaths>>("path to bibliography file")?;
+
+ // Load bibliography files.
+ let data = paths.0
+ .iter()
+ .map(|path| {
+ let id = vm.location().join(path).at(span)?;
+ vm.world().file(id).at(span)
+ })
+ .collect::<SourceResult<Vec<Bytes>>>()?;
+
+ // Check that parsing works.
+ let _ = load(&paths, &data).at(span)?;
+
+ paths
+ )]
+ pub path: BibPaths,
+
+ /// The raw file buffers.
+ #[internal]
+ #[required]
+ #[parse(data)]
+ pub data: Vec<Bytes>,
+
+ /// The title of the bibliography.
+ ///
+ /// - When set to `{auto}`, an appropriate title for the [text
+ /// language]($func/text.lang) will be used. This is the default.
+ /// - When set to `{none}`, the bibliography will not have a title.
+ /// - A custom title can be set by passing content.
+ ///
+ /// The bibliography's heading will not be numbered by default, but you can
+ /// force it to be with a show-set rule:
+ /// `{show bibliography: set heading(numbering: "1.")}`
+ /// ```
+ #[default(Some(Smart::Auto))]
+ pub title: Option<Smart<Content>>,
+
+ /// The bibliography style.
+ #[default(BibliographyStyle::Ieee)]
+ pub style: BibliographyStyle,
+}
+
+/// A list of bibliography file paths.
+#[derive(Debug, Default, Clone, Hash)]
+pub struct BibPaths(Vec<EcoString>);
+
+cast! {
+ BibPaths,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<StrResult<_>>()?),
+}
+
+impl BibliographyElem {
+ /// Find the document's bibliography.
+ pub fn find(introspector: Tracked<Introspector>) -> StrResult<Self> {
+ let mut iter = introspector.query(&Self::func().select()).into_iter();
+ let Some(elem) = iter.next() else {
+ bail!("the document does not contain a bibliography");
+ };
+
+ if iter.next().is_some() {
+ bail!("multiple bibliographies are not supported");
+ }
+
+ Ok(elem.to::<Self>().unwrap().clone())
+ }
+
+ /// Whether the bibliography contains the given key.
+ pub fn has(vt: &Vt, key: &str) -> bool {
+ vt.introspector
+ .query(&Self::func().select())
+ .into_iter()
+ .flat_map(|elem| {
+ let elem = elem.to::<Self>().unwrap();
+ load(&elem.path(), &elem.data())
+ })
+ .flatten()
+ .any(|entry| entry.key() == key)
+ }
+
+ /// Find all bibliography keys.
+ pub fn keys(
+ introspector: Tracked<Introspector>,
+ ) -> Vec<(EcoString, Option<EcoString>)> {
+ Self::find(introspector)
+ .and_then(|elem| load(&elem.path(), &elem.data()))
+ .into_iter()
+ .flatten()
+ .map(|entry| {
+ let key = entry.key().into();
+ let detail =
+ entry.title().map(|title| title.canonical.value.as_str().into());
+ (key, detail)
+ })
+ .collect()
+ }
+}
+
+impl Synthesize for BibliographyElem {
+ fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ self.push_style(self.style(styles));
+ Ok(())
+ }
+}
+
+impl Show for BibliographyElem {
+ #[tracing::instrument(name = "BibliographyElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ const COLUMN_GUTTER: Em = Em::new(0.65);
+ const INDENT: Em = Em::new(1.5);
+
+ let mut seq = vec![];
+ if let Some(title) = self.title(styles) {
+ let title =
+ title.unwrap_or_else(|| {
+ TextElem::packed(self.local_name(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ ))
+ .spanned(self.span())
+ });
+
+ seq.push(HeadingElem::new(title).with_level(NonZeroUsize::ONE).pack());
+ }
+
+ Ok(vt.delayed(|vt| {
+ let works = Works::new(vt).at(self.span())?;
+
+ let row_gutter = BlockElem::below_in(styles).amount();
+ if works.references.iter().any(|(prefix, _)| prefix.is_some()) {
+ let mut cells = vec![];
+ for (prefix, reference) in &works.references {
+ cells.push(prefix.clone().unwrap_or_default());
+ cells.push(reference.clone());
+ }
+
+ seq.push(VElem::new(row_gutter).with_weakness(3).pack());
+ seq.push(
+ GridElem::new(cells)
+ .with_columns(TrackSizings(vec![Sizing::Auto; 2]))
+ .with_column_gutter(TrackSizings(vec![COLUMN_GUTTER.into()]))
+ .with_row_gutter(TrackSizings(vec![row_gutter.into()]))
+ .pack(),
+ );
+ } else {
+ let mut entries = vec![];
+ for (_, reference) in &works.references {
+ entries.push(VElem::new(row_gutter).with_weakness(3).pack());
+ entries.push(reference.clone());
+ }
+
+ seq.push(
+ Content::sequence(entries)
+ .styled(ParElem::set_hanging_indent(INDENT.into())),
+ );
+ }
+
+ Ok(Content::sequence(seq))
+ }))
+ }
+}
+
+impl Finalize for BibliographyElem {
+ fn finalize(&self, realized: Content, _: StyleChain) -> Content {
+ realized.styled(HeadingElem::set_numbering(None))
+ }
+}
+
+impl LocalName for BibliographyElem {
+ fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "Bibliografi",
+ Lang::ARABIC => "المراجع",
+ Lang::BOKMÅL => "Bibliografi",
+ Lang::CHINESE if option_eq(region, "TW") => "書目",
+ Lang::CHINESE => "参考文献",
+ Lang::CZECH => "Bibliografie",
+ Lang::DANISH => "Bibliografi",
+ Lang::DUTCH => "Bibliografie",
+ Lang::FILIPINO => "Bibliograpiya",
+ Lang::FRENCH => "Bibliographie",
+ Lang::GERMAN => "Bibliographie",
+ Lang::ITALIAN => "Bibliografia",
+ Lang::NYNORSK => "Bibliografi",
+ Lang::POLISH => "Bibliografia",
+ Lang::PORTUGUESE => "Bibliografia",
+ Lang::RUSSIAN => "Библиография",
+ Lang::SLOVENIAN => "Literatura",
+ Lang::SPANISH => "Bibliografía",
+ Lang::SWEDISH => "Bibliografi",
+ Lang::TURKISH => "Kaynakça",
+ Lang::UKRAINIAN => "Бібліографія",
+ Lang::VIETNAMESE => "Tài liệu tham khảo",
+ Lang::ENGLISH | _ => "Bibliography",
+ }
+ }
+}
+
+/// A bibliography style.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum BibliographyStyle {
+ /// Follows guidance of the American Psychological Association. Based on the
+ /// 7th edition of the APA Publication Manual.
+ Apa,
+ /// The Chicago Author Date style. Based on the 17th edition of the Chicago
+ /// Manual of Style, Chapter 15.
+ ChicagoAuthorDate,
+ /// The Chicago Notes style. Based on the 17th edition of the Chicago
+ /// Manual of Style, Chapter 14.
+ ChicagoNotes,
+ /// The style of the Institute of Electrical and Electronics Engineers.
+ /// Based on the 2018 IEEE Reference Guide.
+ Ieee,
+ /// Follows guidance of the Modern Language Association. Based on the 8th
+ /// edition of the MLA Handbook.
+ Mla,
+}
+
+impl BibliographyStyle {
+ /// The default citation style for this bibliography style.
+ pub fn default_citation_style(self) -> CitationStyle {
+ match self {
+ Self::Apa => CitationStyle::ChicagoAuthorDate,
+ Self::ChicagoAuthorDate => CitationStyle::ChicagoAuthorDate,
+ Self::ChicagoNotes => CitationStyle::ChicagoNotes,
+ Self::Ieee => CitationStyle::Numerical,
+ Self::Mla => CitationStyle::ChicagoAuthorDate,
+ }
+ }
+}
+
+/// Cite a work from the bibliography.
+///
+/// Before you starting citing, you need to add a
+/// [bibliography]($func/bibliography) somewhere in your document.
+///
+/// # Example
+/// ```example
+/// This was already noted by
+/// pirates long ago. @arrgh
+///
+/// Multiple sources say ...
+/// #cite("arrgh", "netwok").
+///
+/// #bibliography("works.bib")
+/// ```
+///
+/// # Syntax
+/// This function indirectly has dedicated syntax. [References]($func/ref)
+/// can be used to cite works from the bibliography. The label then
+/// corresponds to the citation key.
+///
+/// Display: Citation
+/// Category: meta
+#[element(Locatable, Synthesize, Show)]
+pub struct CiteElem {
+ /// The citation keys that identify the elements that shall be cited in
+ /// the bibliography.
+ ///
+ /// Reference syntax supports only a single key.
+ #[variadic]
+ pub keys: Vec<EcoString>,
+
+ /// A supplement for the citation such as page or chapter number.
+ ///
+ /// In reference syntax, the supplement can be added in square brackets:
+ ///
+ /// ```example
+ /// This has been proven over and
+ /// over again. @distress[p.~7]
+ ///
+ /// #bibliography("works.bib")
+ /// ```
+ #[positional]
+ pub supplement: Option<Content>,
+
+ /// Whether the citation should include brackets.
+ ///
+ /// ```example
+ /// #set cite(brackets: false)
+ ///
+ /// @netwok follow these methods
+ /// in their work ...
+ ///
+ /// #bibliography(
+ /// "works.bib",
+ /// style: "chicago-author-date",
+ /// )
+ /// ```
+ #[default(true)]
+ pub brackets: bool,
+
+ /// The citation style.
+ ///
+ /// When set to `{auto}`, automatically picks the preferred citation style
+ /// for the bibliography's style.
+ ///
+ /// ```example
+ /// #set cite(style: "alphanumerical")
+ /// Alphanumerical references.
+ /// @netwok
+ ///
+ /// #bibliography("works.bib")
+ /// ```
+ pub style: Smart<CitationStyle>,
+}
+
+impl Synthesize for CiteElem {
+ fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ self.push_supplement(self.supplement(styles));
+ self.push_brackets(self.brackets(styles));
+ self.push_style(self.style(styles));
+ Ok(())
+ }
+}
+
+impl Show for CiteElem {
+ #[tracing::instrument(name = "CiteElem::show", skip(self, vt))]
+ fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(vt.delayed(|vt| {
+ let works = Works::new(vt).at(self.span())?;
+ let location = self.0.location().unwrap();
+ works
+ .citations
+ .get(&location)
+ .cloned()
+ .flatten()
+ .ok_or("bibliography does not contain this key")
+ .at(self.span())
+ }))
+ }
+}
+
+cast! {
+ CiteElem,
+ v: Content => v.to::<Self>().cloned().ok_or("expected citation")?,
+}
+
+/// A citation style.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum CitationStyle {
+ /// IEEE-style numerical reference markers.
+ Numerical,
+ /// A simple alphanumerical style. For example, the output could be Rass97
+ /// or MKG+21.
+ Alphanumerical,
+ /// Citations that just consist of the entry keys.
+ Keys,
+ /// The Chicago Author Date style. Based on the 17th edition of the Chicago
+ /// Manual of Style, Chapter 15.
+ ChicagoAuthorDate,
+ /// The Chicago Notes style. Based on the 17th edition of the Chicago
+ /// Manual of Style, Chapter 14.
+ ChicagoNotes,
+ /// A Chicago-like author-title format. Results could look like this:
+ /// Prokopov, “It Is Fast or It Is Wrong”.
+ ChicagoAuthorTitle,
+}
+
+impl CitationStyle {
+ fn is_short(self) -> bool {
+ matches!(self, Self::Numerical | Self::Alphanumerical | Self::Keys)
+ }
+}
+
+/// Fully formatted citations and references.
+#[derive(Default)]
+struct Works {
+ citations: HashMap<Location, Option<Content>>,
+ references: Vec<(Option<Content>, Content)>,
+}
+
+impl Works {
+ /// Prepare all things need to cite a work or format a bibliography.
+ fn new(vt: &Vt) -> StrResult<Arc<Self>> {
+ let bibliography = BibliographyElem::find(vt.introspector)?;
+ let citations = vt
+ .introspector
+ .query(&Selector::Or(eco_vec![
+ RefElem::func().select(),
+ CiteElem::func().select(),
+ ]))
+ .into_iter()
+ .map(|elem| match elem.to::<RefElem>() {
+ Some(reference) => reference.citation().unwrap(),
+ _ => elem.to::<CiteElem>().unwrap().clone(),
+ })
+ .collect();
+ Ok(create(bibliography, citations))
+ }
+}
+
+/// Generate all citations and the whole bibliography.
+#[comemo::memoize]
+fn create(bibliography: BibliographyElem, citations: Vec<CiteElem>) -> Arc<Works> {
+ let span = bibliography.span();
+ let entries = load(&bibliography.path(), &bibliography.data()).unwrap();
+ let style = bibliography.style(StyleChain::default());
+ let bib_location = bibliography.0.location().unwrap();
+ let ref_location = |target: &Entry| {
+ let i = entries
+ .iter()
+ .position(|entry| entry.key() == target.key())
+ .unwrap_or_default();
+ bib_location.variant(i)
+ };
+
+ let mut db = Database::new();
+ let mut ids = HashMap::new();
+ let mut preliminary = vec![];
+
+ for citation in citations {
+ let cite_id = citation.0.location().unwrap();
+ let entries = citation
+ .keys()
+ .into_iter()
+ .map(|key| {
+ let entry = entries.iter().find(|entry| entry.key() == key)?;
+ ids.entry(entry.key()).or_insert(cite_id);
+ db.push(entry);
+ Some(entry)
+ })
+ .collect::<Option<Vec<_>>>();
+ preliminary.push((citation, entries));
+ }
+
+ let mut current = CitationStyle::Numerical;
+ let mut citation_style: Box<dyn style::CitationStyle> =
+ Box::new(style::Numerical::new());
+
+ let citations = preliminary
+ .into_iter()
+ .map(|(citation, cited)| {
+ let location = citation.0.location().unwrap();
+ let Some(cited) = cited else { return (location, None) };
+
+ let mut supplement = citation.supplement(StyleChain::default());
+ let brackets = citation.brackets(StyleChain::default());
+ let style = citation
+ .style(StyleChain::default())
+ .unwrap_or(style.default_citation_style());
+
+ if style != current {
+ current = style;
+ citation_style = match style {
+ CitationStyle::Numerical => Box::new(style::Numerical::new()),
+ CitationStyle::Alphanumerical => {
+ Box::new(style::Alphanumerical::new())
+ }
+ CitationStyle::ChicagoAuthorDate => {
+ Box::new(style::ChicagoAuthorDate::new())
+ }
+ CitationStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()),
+ CitationStyle::ChicagoAuthorTitle => {
+ Box::new(style::AuthorTitle::new())
+ }
+ CitationStyle::Keys => Box::new(style::Keys::new()),
+ };
+ }
+
+ let len = cited.len();
+ let mut content = Content::empty();
+ for (i, entry) in cited.into_iter().enumerate() {
+ let supplement = if i + 1 == len { supplement.take() } else { None };
+ let mut display = db
+ .citation(
+ &mut *citation_style,
+ &[Citation {
+ entry,
+ supplement: supplement.is_some().then_some(SUPPLEMENT),
+ }],
+ )
+ .display;
+
+ if style.is_short() {
+ display.value = display.value.replace(' ', "\u{a0}");
+ }
+
+ if brackets && len == 1 {
+ display = display.with_default_brackets(&*citation_style);
+ }
+
+ if i > 0 {
+ content += TextElem::packed(",\u{a0}");
+ }
+
+ // Format and link to the reference entry.
+ content += format_display_string(&display, supplement, citation.span())
+ .linked(Destination::Location(ref_location(entry)));
+ }
+
+ if brackets && len > 1 {
+ content = match citation_style.brackets() {
+ Brackets::None => content,
+ Brackets::Round => {
+ TextElem::packed('(') + content + TextElem::packed(')')
+ }
+ Brackets::Square => {
+ TextElem::packed('[') + content + TextElem::packed(']')
+ }
+ };
+ }
+
+ if style == CitationStyle::ChicagoNotes {
+ content = FootnoteElem::with_content(content).pack();
+ }
+
+ (location, Some(content))
+ })
+ .collect();
+
+ let bibliography_style: Box<dyn style::BibliographyStyle> = match style {
+ BibliographyStyle::Apa => Box::new(style::Apa::new()),
+ BibliographyStyle::ChicagoAuthorDate => Box::new(style::ChicagoAuthorDate::new()),
+ BibliographyStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()),
+ BibliographyStyle::Ieee => Box::new(style::Ieee::new()),
+ BibliographyStyle::Mla => Box::new(style::Mla::new()),
+ };
+
+ let references = db
+ .bibliography(&*bibliography_style, None)
+ .into_iter()
+ .map(|reference| {
+ let backlink = ref_location(reference.entry);
+ let prefix = reference.prefix.map(|prefix| {
+ // Format and link to first citation.
+ let bracketed = prefix.with_default_brackets(&*citation_style);
+ format_display_string(&bracketed, None, span)
+ .linked(Destination::Location(ids[reference.entry.key()]))
+ .backlinked(backlink)
+ });
+
+ let mut reference = format_display_string(&reference.display, None, span);
+ if prefix.is_none() {
+ reference = reference.backlinked(backlink);
+ }
+
+ (prefix, reference)
+ })
+ .collect();
+
+ Arc::new(Works { citations, references })
+}
+
+/// Load bibliography entries from a path.
+#[comemo::memoize]
+fn load(paths: &BibPaths, data: &[Bytes]) -> StrResult<EcoVec<hayagriva::Entry>> {
+ let mut result = EcoVec::new();
+
+ // We might have multiple bib/yaml files
+ for (path, bytes) in paths.0.iter().zip(data) {
+ let src = std::str::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?;
+ let entries = parse_bib(path, src)?;
+ result.extend(entries);
+ }
+
+ // Biblatex only checks for duplicate keys within files
+ // -> We have to do this between files again
+ let mut keys = result.iter().map(|r| r.key()).collect::<Vec<_>>();
+ keys.sort_unstable();
+ // Waiting for `slice_partition_dedup` #54279
+ let mut duplicates = Vec::new();
+ for pair in keys.windows(2) {
+ if pair[0] == pair[1] {
+ duplicates.push(pair[0]);
+ }
+ }
+
+ if !duplicates.is_empty() {
+ Err(eco_format!("duplicate bibliography keys: {}", duplicates.join(", ")))
+ } else {
+ Ok(result)
+ }
+}
+
+/// Parse a bibliography file (bib/yml/yaml)
+fn parse_bib(path_str: &str, src: &str) -> StrResult<Vec<hayagriva::Entry>> {
+ let path = Path::new(path_str);
+ let ext = path.extension().and_then(OsStr::to_str).unwrap_or_default();
+ match ext.to_lowercase().as_str() {
+ "yml" | "yaml" => {
+ hayagriva::io::from_yaml_str(src).map_err(format_hayagriva_error)
+ }
+ "bib" => hayagriva::io::from_biblatex_str(src).map_err(|err| {
+ err.into_iter()
+ .next()
+ .map(|error| format_biblatex_error(path_str, src, error))
+ .unwrap_or_else(|| eco_format!("failed to parse {path_str}"))
+ }),
+ _ => bail!("unknown bibliography format (must be .yml/.yaml or .bib)"),
+ }
+}
+
+/// Format a Hayagriva loading error.
+fn format_hayagriva_error(error: YamlBibliographyError) -> EcoString {
+ eco_format!("{error}")
+}
+
+/// Format a BibLaTeX loading error.
+fn format_biblatex_error(path: &str, src: &str, error: BibLaTeXError) -> EcoString {
+ let (span, msg) = match error {
+ BibLaTeXError::Parse(error) => (error.span, error.kind.to_string()),
+ BibLaTeXError::Type(error) => (error.span, error.kind.to_string()),
+ };
+ let line = src.get(..span.start).unwrap_or_default().lines().count();
+ eco_format!("parsing failed at {path}:{line}: {msg}")
+}
+
+/// Hayagriva only supports strings, but we have a content supplement. To deal
+/// with this, we pass this string to hayagriva instead of our content, find it
+/// in the output and replace it with the content.
+const SUPPLEMENT: &str = "cdc579c45cf3d648905c142c7082683f";
+
+/// Format a display string into content.
+fn format_display_string(
+ string: &DisplayString,
+ mut supplement: Option<Content>,
+ span: Span,
+) -> Content {
+ let mut stops: Vec<_> = string
+ .formatting
+ .iter()
+ .flat_map(|(range, _)| [range.start, range.end])
+ .collect();
+
+ if let Some(i) = string.value.find(SUPPLEMENT) {
+ stops.push(i);
+ stops.push(i + SUPPLEMENT.len());
+ }
+
+ stops.sort();
+ stops.dedup();
+ stops.push(string.value.len());
+
+ let mut start = 0;
+ let mut seq = vec![];
+ for stop in stops {
+ let segment = string.value.get(start..stop).unwrap_or_default();
+ if segment.is_empty() {
+ continue;
+ }
+
+ let mut content = if segment == SUPPLEMENT && supplement.is_some() {
+ supplement.take().unwrap_or_default()
+ } else {
+ TextElem::packed(segment).spanned(span)
+ };
+
+ for (range, fmt) in &string.formatting {
+ if !range.contains(&start) {
+ continue;
+ }
+
+ content = match fmt {
+ Formatting::Bold => content.strong(),
+ Formatting::Italic => content.emph(),
+ Formatting::Link(link) => {
+ LinkElem::new(Destination::Url(link.as_str().into()).into(), content)
+ .pack()
+ }
+ };
+ }
+
+ seq.push(content);
+ start = stop;
+ }
+
+ Content::sequence(seq)
+}
diff --git a/crates/typst-library/src/meta/context.rs b/crates/typst-library/src/meta/context.rs
new file mode 100644
index 00000000..a42c6980
--- /dev/null
+++ b/crates/typst-library/src/meta/context.rs
@@ -0,0 +1,220 @@
+use crate::prelude::*;
+
+/// Provides access to the location of content.
+///
+/// This is useful in combination with [queries]($func/query),
+/// [counters]($func/counter), [state]($func/state), and [links]($func/link).
+/// See their documentation for more details.
+///
+/// ```example
+/// #locate(loc => [
+/// My location: \
+/// #loc.position()!
+/// ])
+/// ```
+///
+/// ## Methods
+/// ### page()
+/// Returns the page number for this location.
+///
+/// Note that this does not return the value of the [page counter]($func/counter)
+/// at this location, but the true page number (starting from one).
+///
+/// If you want to know the value of the page counter, use
+/// `{counter(page).at(loc)}` instead.
+///
+/// - returns: integer
+///
+/// ### position()
+/// Returns a dictionary with the page number and the x, y position for this
+/// location. The page number starts at one and the coordinates are measured
+/// from the top-left of the page.
+///
+/// If you only need the page number, use `page()` instead as it allows Typst
+/// to skip unnecessary work.
+///
+/// - returns: dictionary
+///
+/// ### page-numbering()
+/// Returns the page numbering pattern of the page at this location. This can be
+/// used when displaying the page counter in order to obtain the local numbering.
+/// This is useful if you are building custom indices or outlines.
+///
+/// If the page numbering is set to `none` at that location, this function returns `none`.
+///
+/// - returns: string or function or none
+///
+/// Display: Locate
+/// Category: meta
+#[func]
+pub fn locate(
+ /// A function that receives a `location`. Its return value is displayed
+ /// in the document.
+ ///
+ /// This function is called once for each time the content returned by
+ /// `locate` appears in the document. That makes it possible to generate
+ /// content that depends on its own location in the document.
+ func: Func,
+) -> Content {
+ LocateElem::new(func).pack()
+}
+
+/// Executes a `locate` call.
+///
+/// Display: Locate
+/// Category: special
+#[element(Locatable, Show)]
+struct LocateElem {
+ /// The function to call with the location.
+ #[required]
+ func: Func,
+}
+
+impl Show for LocateElem {
+ #[tracing::instrument(name = "LocateElem::show", skip(self, vt))]
+ fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(vt.delayed(|vt| {
+ let location = self.0.location().unwrap();
+ Ok(self.func().call_vt(vt, [location])?.display())
+ }))
+ }
+}
+
+/// Provides access to active styles.
+///
+/// The styles are currently opaque and only useful in combination with the
+/// [`measure`]($func/measure) function. See its documentation for more details.
+/// In the future, the provided styles might also be directly accessed to look
+/// up styles defined by [set rules]($styling/#set-rules).
+///
+/// ```example
+/// #let thing(body) = style(styles => {
+/// let size = measure(body, styles)
+/// [Width of "#body" is #size.width]
+/// })
+///
+/// #thing[Hey] \
+/// #thing[Welcome]
+/// ```
+///
+/// Display: Style
+/// Category: meta
+#[func]
+pub fn style(
+ /// A function to call with the styles. Its return value is displayed
+ /// in the document.
+ ///
+ /// This function is called once for each time the content returned by
+ /// `style` appears in the document. That makes it possible to generate
+ /// content that depends on the style context it appears in.
+ func: Func,
+) -> Content {
+ StyleElem::new(func).pack()
+}
+
+/// Executes a style access.
+///
+/// Display: Style
+/// Category: special
+#[element(Show)]
+struct StyleElem {
+ /// The function to call with the styles.
+ #[required]
+ func: Func,
+}
+
+impl Show for StyleElem {
+ #[tracing::instrument(name = "StyleElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.func().call_vt(vt, [styles.to_map()])?.display())
+ }
+}
+
+/// Provides access to the current outer container's (or page's, if none) size
+/// (width and height).
+///
+/// The given function must accept a single parameter, `size`, which is a
+/// dictionary with keys `width` and `height`, both of type
+/// [`length`]($type/length).
+///
+
+/// ```example
+/// #let text = lorem(30)
+/// #layout(size => style(styles => [
+/// #let (height,) = measure(
+/// block(width: size.width, text),
+/// styles,
+/// )
+/// This text is #height high with
+/// the current page width: \
+/// #text
+/// ]))
+/// ```
+///
+/// If the `layout` call is placed inside of a box width a width of `{800pt}`
+/// and a height of `{400pt}`, then the specified function will be given the
+/// parameter `{(width: 800pt, height: 400pt)}`. If it placed directly into the
+/// page it receives the page's dimensions minus its margins. This is mostly
+/// useful in combination with [measurement]($func/measure).
+///
+/// You can also use this function to resolve [`ratio`]($type/ratio) to fixed
+/// lengths. This might come in handy if you're building your own layout
+/// abstractions.
+///
+/// ```example
+/// #layout(size => {
+/// let half = 50% * size.width
+/// [Half a page is #half wide.]
+/// })
+/// ```
+///
+/// Note that this function will provide an infinite width or height if one of
+/// the page width or height is `auto`, respectively.
+///
+/// Display: Layout
+/// Category: meta
+#[func]
+pub fn layout(
+ /// A function to call with the outer container's size. Its return value is
+ /// displayed in the document.
+ ///
+ /// The container's size is given as a [dictionary]($type/dictionary) with
+ /// the keys `width` and `height`.
+ ///
+ /// This function is called once for each time the content returned by
+ /// `layout` appears in the document. That makes it possible to generate
+ /// content that depends on the size of the container it is inside of.
+ func: Func,
+) -> Content {
+ LayoutElem::new(func).pack()
+}
+
+/// Executes a `layout` call.
+///
+/// Display: Layout
+/// Category: special
+#[element(Layout)]
+struct LayoutElem {
+ /// The function to call with the outer container's (or page's) size.
+ #[required]
+ func: Func,
+}
+
+impl Layout for LayoutElem {
+ #[tracing::instrument(name = "LayoutElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ // Gets the current region's base size, which will be the size of the
+ // outer container, or of the page if there is no such container.
+ let Size { x, y } = regions.base();
+ let result = self
+ .func()
+ .call_vt(vt, [dict! { "width" => x, "height" => y }])?
+ .display();
+ result.layout(vt, styles, regions)
+ }
+}
diff --git a/crates/typst-library/src/meta/counter.rs b/crates/typst-library/src/meta/counter.rs
new file mode 100644
index 00000000..9a223b32
--- /dev/null
+++ b/crates/typst-library/src/meta/counter.rs
@@ -0,0 +1,683 @@
+use std::fmt::{self, Debug, Formatter, Write};
+use std::str::FromStr;
+
+use ecow::{eco_vec, EcoVec};
+use smallvec::{smallvec, SmallVec};
+use typst::eval::Tracer;
+use typst::model::DelayedErrors;
+
+use super::{FigureElem, HeadingElem, Numbering, NumberingPattern};
+use crate::layout::PageElem;
+use crate::math::EquationElem;
+use crate::prelude::*;
+
+/// Counts through pages, elements, and more.
+///
+/// With the counter function, you can access and modify counters for pages,
+/// headings, figures, and more. Moreover, you can define custom counters for
+/// other things you want to count.
+///
+/// ## Displaying a counter { #displaying }
+/// To display the current value of the heading counter, you call the `counter`
+/// function with the `key` set to `heading` and then call the `display` method
+/// on the counter. To see any output, you also have to enable heading
+/// [numbering]($func/heading.numbering).
+///
+/// The `display` method optionally takes an argument telling it how to format
+/// the counter. This can be a [numbering pattern or a
+/// function]($func/numbering).
+///
+/// ```example
+/// #set heading(numbering: "1.")
+///
+/// = Introduction
+/// Some text here.
+///
+/// = Background
+/// The current value is:
+/// #counter(heading).display()
+///
+/// Or in roman numerals:
+/// #counter(heading).display("I")
+/// ```
+///
+/// ## Modifying a counter { #modifying }
+/// To modify a counter, you can use the `step` and `update` methods:
+///
+/// - The `step` method increases the value of the counter by one. Because
+/// counters can have multiple levels (in the case of headings for sections,
+/// subsections, and so on), the `step` method optionally takes a `level`
+/// argument. If given, the counter steps at the given depth.
+///
+/// - The `update` method allows you to arbitrarily modify the counter. In its
+/// basic form, you give it an integer (or multiple for multiple levels). For
+/// more flexibility, you can instead also give it a function that gets the
+/// current value and returns a new value.
+///
+/// The heading counter is stepped before the heading is displayed, so
+/// `Analysis` gets the number seven even though the counter is at six after the
+/// second update.
+///
+/// ```example
+/// #set heading(numbering: "1.")
+///
+/// = Introduction
+/// #counter(heading).step()
+///
+/// = Background
+/// #counter(heading).update(3)
+/// #counter(heading).update(n => n * 2)
+///
+/// = Analysis
+/// Let's skip 7.1.
+/// #counter(heading).step(level: 2)
+///
+/// == Analysis
+/// Still at #counter(heading).display().
+/// ```
+///
+/// ## Custom counters { #custom-counters }
+/// To define your own counter, call the `counter` function with a string as a
+/// key. This key identifies the counter globally.
+///
+/// ```example
+/// #let mine = counter("mycounter")
+/// #mine.display() \
+/// #mine.step()
+/// #mine.display() \
+/// #mine.update(c => c * 3)
+/// #mine.display() \
+/// ```
+///
+/// ## How to step { #how-to-step }
+/// When you define and use a custom counter, in general, you should first step
+/// the counter and then display it. This way, the stepping behaviour of a
+/// counter can depend on the element it is stepped for. If you were writing a
+/// counter for, let's say, theorems, your theorem's definition would thus first
+/// include the counter step and only then display the counter and the theorem's
+/// contents.
+///
+/// ```example
+/// #let c = counter("theorem")
+/// #let theorem(it) = block[
+/// #c.step()
+/// *Theorem #c.display():* #it
+/// ]
+///
+/// #theorem[$1 = 1$]
+/// #theorem[$2 < 3$]
+/// ```
+///
+/// The rationale behind this is best explained on the example of the heading
+/// counter: An update to the heading counter depends on the heading's level.
+/// By stepping directly before the heading, we can correctly step from `1` to
+/// `1.1` when encountering a level 2 heading. If we were to step after the
+/// heading, we wouldn't know what to step to.
+///
+/// Because counters should always be stepped before the elements they count,
+/// they always start at zero. This way, they are at one for the first display
+/// (which happens after the first step).
+///
+/// ## Page counter { #page-counter }
+/// The page counter is special. It is automatically stepped at each pagebreak.
+/// But like other counters, you can also step it manually. For example, you
+/// could have Roman page numbers for your preface, then switch to Arabic page
+/// numbers for your main content and reset the page counter to one.
+///
+/// ```example
+/// >>> #set page(
+/// >>> height: 100pt,
+/// >>> margin: (bottom: 24pt, rest: 16pt),
+/// >>> )
+/// #set page(numbering: "(i)")
+///
+/// = Preface
+/// The preface is numbered with
+/// roman numerals.
+///
+/// #set page(numbering: "1 / 1")
+/// #counter(page).update(1)
+///
+/// = Main text
+/// Here, the counter is reset to one.
+/// We also display both the current
+/// page and total number of pages in
+/// Arabic numbers.
+/// ```
+///
+/// ## Time travel { #time-travel }
+/// Counters can travel through time! You can find out the final value of the
+/// counter before it is reached and even determine what the value was at any
+/// particular location in the document.
+///
+/// ```example
+/// #let mine = counter("mycounter")
+///
+/// = Values
+/// #locate(loc => {
+/// let start-val = mine.at(loc)
+/// let elements = query(<intro>, loc)
+/// let intro-val = mine.at(
+/// elements.first().location()
+/// )
+/// let final-val = mine.final(loc)
+/// [Starts as: #start-val \
+/// Value at intro is: #intro-val \
+/// Final value is: #final-val \ ]
+/// })
+///
+/// #mine.update(n => n + 3)
+///
+/// = Introduction <intro>
+/// #lorem(10)
+///
+/// #mine.step()
+/// #mine.step()
+/// ```
+///
+/// Let's dissect what happens in the example above:
+///
+/// - We call [`locate`]($func/locate) to get access to the current location in
+/// the document. We then pass this location to our counter's `at` method to
+/// get its value at the current location. The `at` method always returns an
+/// array because counters can have multiple levels. As the counter starts at
+/// one, the first value is thus `{(1,)}`.
+///
+/// - We now [`query`]($func/query) the document for all elements with the
+/// `{<intro>}` label. The result is an array from which we extract the first
+/// (and only) element's [location]($type/content.location). We then look up
+/// the value of the counter at that location. The first update to the counter
+/// sets it to `{1 + 3 = 4}`. At the introduction heading, the value is thus
+/// `{(4,)}`.
+///
+/// - Last but not least, we call the `final` method on the counter. It tells us
+/// what the counter's value will be at the end of the document. We also need
+/// to give it a location to prove that we are inside of a `locate` call, but
+/// which one doesn't matter. After the heading follow two calls to `step()`,
+/// so the final value is `{(6,)}`.
+///
+/// ## Other kinds of state { #other-state }
+/// The `counter` function is closely related to [state]($func/state) function.
+/// Read its documentation for more details on state management in Typst and
+/// why it doesn't just use normal variables for counters.
+///
+/// ## Methods
+/// ### display()
+/// Displays the value of the counter.
+///
+/// - numbering: string or function (positional)
+/// A [numbering pattern or a function]($func/numbering), which specifies how
+/// to display the counter. If given a function, that function receives each
+/// number of the counter as a separate argument. If the amount of numbers
+/// varies, e.g. for the heading argument, you can use an
+/// [argument sink]($type/arguments).
+///
+/// If this is omitted, displays the counter with the numbering style for the
+/// counted element or with the pattern `{"1.1"}` if no such style exists.
+///
+/// - both: boolean (named)
+/// If enabled, displays the current and final top-level count together. Both
+/// can be styled through a single numbering pattern. This is used by the page
+/// numbering property to display the current and total number of pages when a
+/// pattern like `{"1 / 1"}` is given.
+///
+/// - returns: content
+///
+/// ### step()
+/// Increases the value of the counter by one.
+///
+/// The update will be in effect at the position where the returned content is
+/// inserted into the document. If you don't put the output into the document,
+/// nothing happens! This would be the case, for example, if you write
+/// `{let _ = counter(page).step()}`. Counter updates are always applied in
+/// layout order and in that case, Typst wouldn't know when to step the counter.
+///
+/// - level: integer (named)
+/// The depth at which to step the counter. Defaults to `{1}`.
+///
+/// - returns: content
+///
+/// ### update()
+/// Updates the value of the counter.
+///
+/// Just like with `step`, the update only occurs if you put the resulting
+/// content into the document.
+///
+/// - value: integer or array or function (positional, required)
+/// If given an integer or array of integers, sets the counter to that value.
+/// If given a function, that function receives the previous counter value
+/// (with each number as a separate argument) and has to return the new
+/// value (integer or array).
+///
+/// - returns: content
+///
+/// ### at()
+/// Gets the value of the counter at the given location. Always returns an
+/// array of integers, even if the counter has just one number.
+///
+/// - location: location (positional, required)
+/// The location at which the counter value should be retrieved. A suitable
+/// location can be retrieved from [`locate`]($func/locate) or
+/// [`query`]($func/query).
+///
+/// - returns: array
+///
+/// ### final()
+/// Gets the value of the counter at the end of the document. Always returns an
+/// array of integers, even if the counter has just one number.
+///
+/// - location: location (positional, required)
+/// Can be any location. Why is it required then? Typst has to evaluate parts
+/// of your code multiple times to determine all counter values. By only
+/// allowing this method within [`locate`]($func/locate) calls, the amount of
+/// code that can depend on the method's result is reduced. If you could call
+/// `final` directly at the top level of a module, the evaluation of the whole
+/// module and its exports could depend on the counter's value.
+///
+/// - returns: array
+///
+/// Display: Counter
+/// Category: meta
+#[func]
+pub fn counter(
+ /// The key that identifies this counter.
+ ///
+ /// - If it is a string, creates a custom counter that is only affected by
+ /// manual updates,
+ /// - If this is a `{<label>}`, counts through all elements with that label,
+ /// - If this is an element function or selector, counts through its elements,
+ /// - If this is the [`page`]($func/page) function, counts through pages.
+ key: CounterKey,
+) -> Counter {
+ Counter::new(key)
+}
+
+/// Counts through pages, elements, and more.
+#[derive(Clone, PartialEq, Hash)]
+pub struct Counter(CounterKey);
+
+impl Counter {
+ /// Create a new counter from a key.
+ pub fn new(key: CounterKey) -> Self {
+ Self(key)
+ }
+
+ /// The counter for the given element.
+ pub fn of(func: ElemFunc) -> Self {
+ Self::new(CounterKey::Selector(Selector::Elem(func, None)))
+ }
+
+ /// Call a method on counter.
+ #[tracing::instrument(skip(vm))]
+ pub fn call_method(
+ self,
+ vm: &mut Vm,
+ method: &str,
+ mut args: Args,
+ span: Span,
+ ) -> SourceResult<Value> {
+ let value = match method {
+ "display" => self
+ .display(args.eat()?, args.named("both")?.unwrap_or(false))
+ .into_value(),
+ "step" => self
+ .update(CounterUpdate::Step(
+ args.named("level")?.unwrap_or(NonZeroUsize::ONE),
+ ))
+ .into_value(),
+ "update" => self.update(args.expect("value or function")?).into_value(),
+ "at" => self.at(&mut vm.vt, args.expect("location")?)?.into_value(),
+ "final" => self.final_(&mut vm.vt, args.expect("location")?)?.into_value(),
+ _ => bail!(span, "type counter has no method `{}`", method),
+ };
+ args.finish()?;
+ Ok(value)
+ }
+
+ /// Display the current value of the counter.
+ pub fn display(self, numbering: Option<Numbering>, both: bool) -> Content {
+ DisplayElem::new(self, numbering, both).pack()
+ }
+
+ /// Get the value of the state at the given location.
+ pub fn at(&self, vt: &mut Vt, location: Location) -> SourceResult<CounterState> {
+ let sequence = self.sequence(vt)?;
+ let offset = vt.introspector.query(&self.selector().before(location, true)).len();
+ let (mut state, page) = sequence[offset].clone();
+ if self.is_page() {
+ let delta = vt.introspector.page(location).get().saturating_sub(page.get());
+ state.step(NonZeroUsize::ONE, delta);
+ }
+
+ Ok(state)
+ }
+
+ /// Get the value of the state at the final location.
+ pub fn final_(&self, vt: &mut Vt, _: Location) -> SourceResult<CounterState> {
+ let sequence = self.sequence(vt)?;
+ let (mut state, page) = sequence.last().unwrap().clone();
+ if self.is_page() {
+ let delta = vt.introspector.pages().get().saturating_sub(page.get());
+ state.step(NonZeroUsize::ONE, delta);
+ }
+ Ok(state)
+ }
+
+ /// Get the current and final value of the state combined in one state.
+ pub fn both(&self, vt: &mut Vt, location: Location) -> SourceResult<CounterState> {
+ let sequence = self.sequence(vt)?;
+ let offset = vt
+ .introspector
+ .query(&Selector::before(self.selector(), location, true))
+ .len();
+ let (mut at_state, at_page) = sequence[offset].clone();
+ let (mut final_state, final_page) = sequence.last().unwrap().clone();
+ if self.is_page() {
+ let at_delta =
+ vt.introspector.page(location).get().saturating_sub(at_page.get());
+ at_state.step(NonZeroUsize::ONE, at_delta);
+ let final_delta =
+ vt.introspector.pages().get().saturating_sub(final_page.get());
+ final_state.step(NonZeroUsize::ONE, final_delta);
+ }
+ Ok(CounterState(smallvec![at_state.first(), final_state.first()]))
+ }
+
+ /// Produce content that performs a state update.
+ pub fn update(self, update: CounterUpdate) -> Content {
+ UpdateElem::new(self, update).pack()
+ }
+
+ /// Produce the whole sequence of counter states.
+ ///
+ /// This has to happen just once for all counters, cutting down the number
+ /// of counter updates from quadratic to linear.
+ fn sequence(
+ &self,
+ vt: &mut Vt,
+ ) -> SourceResult<EcoVec<(CounterState, NonZeroUsize)>> {
+ self.sequence_impl(
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ )
+ }
+
+ /// Memoized implementation of `sequence`.
+ #[comemo::memoize]
+ fn sequence_impl(
+ &self,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ ) -> SourceResult<EcoVec<(CounterState, NonZeroUsize)>> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let mut state = CounterState(match &self.0 {
+ // special case, because pages always start at one.
+ CounterKey::Page => smallvec![1],
+ _ => smallvec![0],
+ });
+ let mut page = NonZeroUsize::ONE;
+ let mut stops = eco_vec![(state.clone(), page)];
+
+ for elem in introspector.query(&self.selector()) {
+ if self.is_page() {
+ let prev = page;
+ page = introspector.page(elem.location().unwrap());
+
+ let delta = page.get() - prev.get();
+ if delta > 0 {
+ state.step(NonZeroUsize::ONE, delta);
+ }
+ }
+
+ if let Some(update) = match elem.to::<UpdateElem>() {
+ Some(elem) => Some(elem.update()),
+ None => match elem.with::<dyn Count>() {
+ Some(countable) => countable.update(),
+ None => Some(CounterUpdate::Step(NonZeroUsize::ONE)),
+ },
+ } {
+ state.update(&mut vt, update)?;
+ }
+
+ stops.push((state.clone(), page));
+ }
+
+ Ok(stops)
+ }
+
+ /// The selector relevant for this counter's updates.
+ fn selector(&self) -> Selector {
+ let mut selector =
+ Selector::Elem(UpdateElem::func(), Some(dict! { "counter" => self.clone() }));
+
+ if let CounterKey::Selector(key) = &self.0 {
+ selector = Selector::Or(eco_vec![selector, key.clone()]);
+ }
+
+ selector
+ }
+
+ /// Whether this is the page counter.
+ fn is_page(&self) -> bool {
+ self.0 == CounterKey::Page
+ }
+}
+
+impl Debug for Counter {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("counter(")?;
+ self.0.fmt(f)?;
+ f.write_char(')')
+ }
+}
+
+cast! {
+ type Counter: "counter",
+}
+
+/// Identifies a counter.
+#[derive(Clone, PartialEq, Hash)]
+pub enum CounterKey {
+ /// The page counter.
+ Page,
+ /// Counts elements matching the given selectors. Only works for locatable
+ /// elements or labels.
+ Selector(Selector),
+ /// Counts through manual counters with the same key.
+ Str(Str),
+}
+
+cast! {
+ CounterKey,
+ v: Str => Self::Str(v),
+ label: Label => Self::Selector(Selector::Label(label)),
+ v: ElemFunc => {
+ if v == PageElem::func() {
+ Self::Page
+ } else {
+ Self::Selector(LocatableSelector::from_value(v.into_value())?.0)
+ }
+ },
+ selector: LocatableSelector => Self::Selector(selector.0),
+}
+
+impl Debug for CounterKey {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Page => f.pad("page"),
+ Self::Selector(selector) => selector.fmt(f),
+ Self::Str(str) => str.fmt(f),
+ }
+ }
+}
+
+/// An update to perform on a counter.
+#[derive(Clone, PartialEq, Hash)]
+pub enum CounterUpdate {
+ /// Set the counter to the specified state.
+ Set(CounterState),
+ /// Increase the number for the given level by one.
+ Step(NonZeroUsize),
+ /// Apply the given function to the counter's state.
+ Func(Func),
+}
+
+impl Debug for CounterUpdate {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("..")
+ }
+}
+
+cast! {
+ type CounterUpdate: "counter update",
+ v: CounterState => Self::Set(v),
+ v: Func => Self::Func(v),
+}
+
+/// Elements that have special counting behaviour.
+pub trait Count {
+ /// Get the counter update for this element.
+ fn update(&self) -> Option<CounterUpdate>;
+}
+
+/// Counts through elements with different levels.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub struct CounterState(pub SmallVec<[usize; 3]>);
+
+impl CounterState {
+ /// Advance the counter and return the numbers for the given heading.
+ pub fn update(&mut self, vt: &mut Vt, update: CounterUpdate) -> SourceResult<()> {
+ match update {
+ CounterUpdate::Set(state) => *self = state,
+ CounterUpdate::Step(level) => self.step(level, 1),
+ CounterUpdate::Func(func) => {
+ *self = func.call_vt(vt, self.0.iter().copied())?.cast().at(func.span())?
+ }
+ }
+ Ok(())
+ }
+
+ /// Advance the number of the given level by the specified amount.
+ pub fn step(&mut self, level: NonZeroUsize, by: usize) {
+ let level = level.get();
+
+ if self.0.len() >= level {
+ self.0[level - 1] = self.0[level - 1].saturating_add(by);
+ self.0.truncate(level);
+ }
+
+ while self.0.len() < level {
+ self.0.push(1);
+ }
+ }
+
+ /// Get the first number of the state.
+ pub fn first(&self) -> usize {
+ self.0.first().copied().unwrap_or(1)
+ }
+
+ /// Display the counter state with a numbering.
+ pub fn display(&self, vt: &mut Vt, numbering: &Numbering) -> SourceResult<Content> {
+ Ok(numbering.apply_vt(vt, &self.0)?.display())
+ }
+}
+
+cast! {
+ CounterState,
+ self => Value::Array(self.0.into_iter().map(IntoValue::into_value).collect()),
+ num: usize => Self(smallvec![num]),
+ array: Array => Self(array
+ .into_iter()
+ .map(Value::cast)
+ .collect::<StrResult<_>>()?),
+}
+
+/// Executes a display of a state.
+///
+/// Display: State
+/// Category: special
+#[element(Locatable, Show)]
+struct DisplayElem {
+ /// The counter.
+ #[required]
+ counter: Counter,
+
+ /// The numbering to display the counter with.
+ #[required]
+ numbering: Option<Numbering>,
+
+ /// Whether to display both the current and final value.
+ #[required]
+ both: bool,
+}
+
+impl Show for DisplayElem {
+ #[tracing::instrument(name = "DisplayElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(vt.delayed(|vt| {
+ let location = self.0.location().unwrap();
+ let counter = self.counter();
+ let numbering = self
+ .numbering()
+ .or_else(|| {
+ let CounterKey::Selector(Selector::Elem(func, _)) = counter.0 else {
+ return None;
+ };
+
+ if func == HeadingElem::func() {
+ HeadingElem::numbering_in(styles)
+ } else if func == FigureElem::func() {
+ FigureElem::numbering_in(styles)
+ } else if func == EquationElem::func() {
+ EquationElem::numbering_in(styles)
+ } else {
+ None
+ }
+ })
+ .unwrap_or_else(|| NumberingPattern::from_str("1.1").unwrap().into());
+
+ let state = if self.both() {
+ counter.both(vt, location)?
+ } else {
+ counter.at(vt, location)?
+ };
+
+ state.display(vt, &numbering)
+ }))
+ }
+}
+
+/// Executes a display of a state.
+///
+/// Display: State
+/// Category: special
+#[element(Locatable, Show)]
+struct UpdateElem {
+ /// The counter.
+ #[required]
+ counter: Counter,
+
+ /// The update to perform on the counter.
+ #[required]
+ update: CounterUpdate,
+}
+
+impl Show for UpdateElem {
+ #[tracing::instrument(name = "UpdateElem::show", skip(self))]
+ fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(Content::empty())
+ }
+}
diff --git a/crates/typst-library/src/meta/document.rs b/crates/typst-library/src/meta/document.rs
new file mode 100644
index 00000000..db036e0a
--- /dev/null
+++ b/crates/typst-library/src/meta/document.rs
@@ -0,0 +1,86 @@
+use crate::layout::{LayoutRoot, PageElem};
+use crate::prelude::*;
+
+/// The root element of a document and its metadata.
+///
+/// All documents are automatically wrapped in a `document` element. You cannot
+/// create a document element yourself. This function is only used with
+/// [set rules]($styling/#set-rules) to specify document metadata. Such a set
+/// rule must appear before any of the document's contents.
+///
+/// ```example
+/// #set document(title: "Hello")
+///
+/// This has no visible output, but
+/// embeds metadata into the PDF!
+/// ```
+///
+/// Note that metadata set with this function is not rendered within the
+/// document. Instead, it is embedded in the compiled PDF file.
+///
+/// Display: Document
+/// Category: meta
+#[element(Construct, LayoutRoot)]
+pub struct DocumentElem {
+ /// The document's title. This is often rendered as the title of the
+ /// PDF viewer window.
+ pub title: Option<EcoString>,
+
+ /// The document's authors.
+ pub author: Author,
+
+ /// The page runs.
+ #[internal]
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Construct for DocumentElem {
+ fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "can only be used in set rules")
+ }
+}
+
+impl LayoutRoot for DocumentElem {
+ /// Layout the document into a sequence of frames, one per page.
+ #[tracing::instrument(name = "DocumentElem::layout_root", skip_all)]
+ fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document> {
+ tracing::info!("Document layout");
+
+ let mut pages = vec![];
+
+ for mut child in &self.children() {
+ let outer = styles;
+ let mut styles = styles;
+ if let Some((elem, local)) = child.to_styled() {
+ styles = outer.chain(local);
+ child = elem;
+ }
+
+ if let Some(page) = child.to::<PageElem>() {
+ let number = NonZeroUsize::ONE.saturating_add(pages.len());
+ let fragment = page.layout(vt, styles, number)?;
+ pages.extend(fragment);
+ } else {
+ bail!(child.span(), "unexpected document child");
+ }
+ }
+
+ Ok(Document {
+ pages,
+ title: self.title(styles),
+ author: self.author(styles).0,
+ })
+ }
+}
+
+/// A list of authors.
+#[derive(Debug, Default, Clone, Hash)]
+pub struct Author(Vec<EcoString>);
+
+cast! {
+ Author,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<StrResult<_>>()?),
+}
diff --git a/crates/typst-library/src/meta/figure.rs b/crates/typst-library/src/meta/figure.rs
new file mode 100644
index 00000000..0d218770
--- /dev/null
+++ b/crates/typst-library/src/meta/figure.rs
@@ -0,0 +1,351 @@
+use std::str::FromStr;
+
+use super::{
+ Count, Counter, CounterKey, CounterUpdate, LocalName, Numbering, NumberingPattern,
+};
+use crate::layout::{BlockElem, VElem};
+use crate::meta::{Outlinable, Refable, Supplement};
+use crate::prelude::*;
+use crate::text::TextElem;
+use crate::visualize::ImageElem;
+
+/// A figure with an optional caption.
+///
+/// Automatically detects its contents to select the correct counting track.
+/// For example, figures containing images will be numbered separately from
+/// figures containing tables.
+///
+/// ## Examples { #examples }
+/// The example below shows a basic figure with an image:
+/// ```example
+/// @glacier shows a glacier. Glaciers
+/// are complex systems.
+///
+/// #figure(
+/// image("glacier.jpg", width: 80%),
+/// caption: [A curious figure.],
+/// ) <glacier>
+/// ```
+///
+/// You can also insert [tables]($func/table) into figures to give them a
+/// caption. The figure will detect this and automatically use a separate
+/// counter.
+///
+/// ```example
+/// #figure(
+/// table(
+/// columns: 4,
+/// [t], [1], [2], [3],
+/// [y], [0.3s], [0.4s], [0.8s],
+/// ),
+/// caption: [Timing results],
+/// )
+/// ```
+///
+/// This behaviour can be overridden by explicitly specifying the figure's
+/// `kind`. All figures of the same kind share a common counter.
+///
+/// ## Modifying the appearance { #modifying-appearance }
+/// You can completely customize the look of your figures with a [show
+/// rule]($styling/#show-rules). In the example below, we show the figure's
+/// caption above its body and display its supplement and counter after the
+/// caption.
+///
+/// ```example
+/// #show figure: it => align(center)[
+/// #it.caption |
+/// #emph[
+/// #it.supplement
+/// #it.counter.display(it.numbering)
+/// ]
+/// #v(10pt, weak: true)
+/// #it.body
+/// ]
+///
+/// #figure(
+/// image("molecular.jpg", width: 80%),
+/// caption: [
+/// The molecular testing pipeline.
+/// ],
+/// )
+/// ```
+///
+/// If your figure is too large and its contents are breakable across pages
+/// (e.g. if it contains a large table), then you can make the figure breakable
+/// across pages as well by using `#show figure: set block(breakable: true)`
+/// (see the [block]($func/block) documentation for more information).
+///
+/// Display: Figure
+/// Category: meta
+#[element(Locatable, Synthesize, Count, Show, Finalize, Refable, Outlinable)]
+pub struct FigureElem {
+ /// The content of the figure. Often, an [image]($func/image).
+ #[required]
+ pub body: Content,
+
+ /// The figure's caption.
+ pub caption: Option<Content>,
+
+ /// The kind of the figure this is.
+ ///
+ /// If set to `{auto}`, the figure will try to automatically determine its
+ /// kind. All figures of the same kind share a common counter.
+ ///
+ /// Setting this to something other than `{auto}` will override the
+ /// automatic detection. This can be useful if
+ /// - you wish to create a custom figure type that is not an
+ /// [image]($func/image), a [table]($func/table) or [code]($func/raw),
+ /// - you want to force the figure to use a specific counter regardless of
+ /// its content.
+ ///
+ /// You can set the kind to be an element function or a string. If you set
+ /// it to an element function that is not supported by the figure, you will
+ /// need to manually specify the figure's supplement.
+ ///
+ /// ```example
+ /// #figure(
+ /// circle(radius: 10pt),
+ /// caption: [A curious atom.],
+ /// kind: "atom",
+ /// supplement: [Atom],
+ /// )
+ /// ```
+ #[default(Smart::Auto)]
+ pub kind: Smart<FigureKind>,
+
+ /// The figure's supplement.
+ ///
+ /// If set to `{auto}`, the figure will try to automatically determine the
+ /// correct supplement based on the `kind` and the active [text
+ /// language]($func/text.lang). If you are using a custom figure type, you
+ /// will need to manually specify the supplement.
+ ///
+ /// If a function is specified, it is passed the first descendant of the
+ /// specified `kind` (typically, the figure's body) and should return
+ /// content.
+ ///
+ /// ```example
+ /// #figure(
+ /// [The contents of my figure!],
+ /// caption: [My custom figure],
+ /// supplement: [Bar],
+ /// kind: "foo",
+ /// )
+ /// ```
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// How to number the figure. Accepts a
+ /// [numbering pattern or function]($func/numbering).
+ #[default(Some(NumberingPattern::from_str("1").unwrap().into()))]
+ pub numbering: Option<Numbering>,
+
+ /// The vertical gap between the body and caption.
+ #[default(Em::new(0.65).into())]
+ pub gap: Length,
+
+ /// Whether the figure should appear in an [`outline`]($func/outline)
+ /// of figures.
+ #[default(true)]
+ pub outlined: bool,
+
+ /// Convenience field to get access to the counter for this figure.
+ ///
+ /// The counter only depends on the `kind`:
+ /// - For (tables)[$func/table]: `{counter(figure.where(kind: table))}`
+ /// - For (images)[$func/image]: `{counter(figure.where(kind: image))}`
+ /// - For a custom kind: `{counter(figure.where(kind: kind))}`
+ ///
+ /// These are the counters you'll need to modify if you want to skip a
+ /// number or reset the counter.
+ #[synthesized]
+ pub counter: Option<Counter>,
+}
+
+impl Synthesize for FigureElem {
+ fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ let numbering = self.numbering(styles);
+
+ // Determine the figure's kind.
+ let kind = self.kind(styles).unwrap_or_else(|| {
+ self.body()
+ .query_first(Selector::can::<dyn Figurable>())
+ .cloned()
+ .map(|elem| FigureKind::Elem(elem.func()))
+ .unwrap_or_else(|| FigureKind::Elem(ImageElem::func()))
+ });
+
+ // Resolve the supplement.
+ let supplement = match self.supplement(styles) {
+ Smart::Auto => {
+ // Default to the local name for the kind, if available.
+ let name = match &kind {
+ FigureKind::Elem(func) => {
+ let empty = Content::new(*func);
+ empty.with::<dyn LocalName>().map(|c| {
+ TextElem::packed(c.local_name(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ ))
+ })
+ }
+ FigureKind::Name(_) => None,
+ };
+
+ if numbering.is_some() && name.is_none() {
+ bail!(self.span(), "please specify the figure's supplement")
+ }
+
+ name.unwrap_or_default()
+ }
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => {
+ // Resolve the supplement with the first descendant of the kind or
+ // just the body, if none was found.
+ let descendant = match kind {
+ FigureKind::Elem(func) => {
+ self.body().query_first(Selector::Elem(func, None)).cloned()
+ }
+ FigureKind::Name(_) => None,
+ };
+
+ let target = descendant.unwrap_or_else(|| self.body());
+ supplement.resolve(vt, [target])?
+ }
+ };
+
+ // Construct the figure's counter.
+ let counter = Counter::new(CounterKey::Selector(Selector::Elem(
+ Self::func(),
+ Some(dict! {
+ "kind" => kind.clone(),
+ }),
+ )));
+
+ self.push_caption(self.caption(styles));
+ self.push_kind(Smart::Custom(kind));
+ self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
+ self.push_numbering(numbering);
+ self.push_outlined(self.outlined(styles));
+ self.push_counter(Some(counter));
+
+ Ok(())
+ }
+}
+
+impl Show for FigureElem {
+ #[tracing::instrument(name = "FigureElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body();
+
+ // Build the caption, if any.
+ if let Some(caption) = self.full_caption(vt)? {
+ realized += VElem::weak(self.gap(styles).into()).pack();
+ realized += caption;
+ }
+
+ // Wrap the contents in a block.
+ Ok(BlockElem::new()
+ .with_body(Some(realized))
+ .pack()
+ .aligned(Axes::with_x(Some(Align::Center.into()))))
+ }
+}
+
+impl Finalize for FigureElem {
+ fn finalize(&self, realized: Content, _: StyleChain) -> Content {
+ // Allow breakable figures with `show figure: set block(breakable: true)`.
+ realized.styled(BlockElem::set_breakable(false))
+ }
+}
+
+impl Count for FigureElem {
+ fn update(&self) -> Option<CounterUpdate> {
+ // If the figure is numbered, step the counter by one.
+ // This steps the `counter(figure)` which is global to all numbered figures.
+ self.numbering(StyleChain::default())
+ .is_some()
+ .then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+impl Refable for FigureElem {
+ fn supplement(&self) -> Content {
+ // After synthesis, this should always be custom content.
+ match self.supplement(StyleChain::default()) {
+ Smart::Custom(Some(Supplement::Content(content))) => content,
+ _ => Content::empty(),
+ }
+ }
+
+ fn counter(&self) -> Counter {
+ self.counter().unwrap_or_else(|| Counter::of(Self::func()))
+ }
+
+ fn numbering(&self) -> Option<Numbering> {
+ self.numbering(StyleChain::default())
+ }
+}
+
+impl Outlinable for FigureElem {
+ fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>> {
+ if !self.outlined(StyleChain::default()) {
+ return Ok(None);
+ }
+
+ self.full_caption(vt)
+ }
+}
+
+impl FigureElem {
+ /// Builds the full caption for the figure (with supplement and numbering).
+ pub fn full_caption(&self, vt: &mut Vt) -> SourceResult<Option<Content>> {
+ let Some(mut caption) = self.caption(StyleChain::default()) else {
+ return Ok(None);
+ };
+
+ if let (
+ Smart::Custom(Some(Supplement::Content(mut supplement))),
+ Some(counter),
+ Some(numbering),
+ ) = (
+ self.supplement(StyleChain::default()),
+ self.counter(),
+ self.numbering(StyleChain::default()),
+ ) {
+ let loc = self.0.location().unwrap();
+ let numbers = counter.at(vt, loc)?.display(vt, &numbering)?;
+
+ if !supplement.is_empty() {
+ supplement += TextElem::packed("\u{a0}");
+ }
+
+ caption = supplement + numbers + TextElem::packed(": ") + caption;
+ }
+
+ Ok(Some(caption))
+ }
+}
+
+/// The `kind` parameter of a [`FigureElem`].
+#[derive(Debug, Clone)]
+pub enum FigureKind {
+ /// The kind is an element function.
+ Elem(ElemFunc),
+ /// The kind is a name.
+ Name(EcoString),
+}
+
+cast! {
+ FigureKind,
+ self => match self {
+ Self::Elem(v) => v.into_value(),
+ Self::Name(v) => v.into_value(),
+ },
+ v: ElemFunc => Self::Elem(v),
+ v: EcoString => Self::Name(v),
+}
+
+/// An element that can be auto-detected in a figure.
+///
+/// This trait is used to determine the type of a figure.
+pub trait Figurable: LocalName {}
diff --git a/crates/typst-library/src/meta/footnote.rs b/crates/typst-library/src/meta/footnote.rs
new file mode 100644
index 00000000..31ec9fe9
--- /dev/null
+++ b/crates/typst-library/src/meta/footnote.rs
@@ -0,0 +1,299 @@
+use comemo::Prehashed;
+use std::str::FromStr;
+
+use super::{Counter, Numbering, NumberingPattern};
+use crate::layout::{HElem, ParElem};
+use crate::meta::{Count, CounterUpdate};
+use crate::prelude::*;
+use crate::text::{SuperElem, TextElem, TextSize};
+use crate::visualize::LineElem;
+
+/// The body of a footnote can be either some content or a label referencing
+/// another footnote.
+#[derive(Debug)]
+pub enum FootnoteBody {
+ Content(Content),
+ Reference(Label),
+}
+
+cast! {
+ FootnoteBody,
+ self => match self {
+ Self::Content(v) => v.into_value(),
+ Self::Reference(v) => v.into_value(),
+ },
+ v: Content => Self::Content(v),
+ v: Label => Self::Reference(v),
+}
+
+/// A footnote.
+///
+/// Includes additional remarks and references on the same page with footnotes.
+/// A footnote will insert a superscript number that links to the note at the
+/// bottom of the page. Notes are numbered sequentially throughout your document
+/// and can break across multiple pages.
+///
+/// To customize the appearance of the entry in the footnote listing, see
+/// [`footnote.entry`]($func/footnote.entry). The footnote itself is realized as
+/// a normal superscript, so you can use a set rule on the
+/// [`super`]($func/super) function to customize it.
+///
+/// ## Example { #example }
+/// ```example
+/// Check the docs for more details.
+/// #footnote[https://typst.app/docs]
+/// ```
+///
+/// The footnote automatically attaches itself to the preceding word, even if
+/// there is a space before it in the markup. To force space, you can use the
+/// string `[#" "]` or explicit [horizontal spacing]($func/h).
+///
+/// By giving a label to a footnote, you can have multiple references to it.
+///
+/// ```example
+/// You can edit Typst documents online.
+/// #footnote[https://typst.app/app] <fn>
+/// Checkout Typst's website. @fn
+/// And the online app. #footnote(<fn>)
+/// ```
+///
+/// _Note:_ Set and show rules in the scope where `footnote` is called may not
+/// apply to the footnote's content. See [here][issue] more information.
+///
+/// [issue]: https://github.com/typst/typst/issues/1467#issuecomment-1588799440
+///
+/// Display: Footnote
+/// Category: meta
+#[element(Locatable, Synthesize, Show, Count)]
+#[scope(
+ scope.define("entry", FootnoteEntry::func());
+ scope
+)]
+pub struct FootnoteElem {
+ /// How to number footnotes.
+ ///
+ /// By default, the footnote numbering continues throughout your document.
+ /// If you prefer per-page footnote numbering, you can reset the footnote
+ /// [counter]($func/counter) in the page [header]($func/page.header). In the
+ /// future, there might be a simpler way to achieve this.
+ ///
+ /// ```example
+ /// #set footnote(numbering: "*")
+ ///
+ /// Footnotes:
+ /// #footnote[Star],
+ /// #footnote[Dagger]
+ /// ```
+ #[default(Numbering::Pattern(NumberingPattern::from_str("1").unwrap()))]
+ pub numbering: Numbering,
+
+ /// The content to put into the footnote. Can also be the label of another
+ /// footnote this one should point to.
+ #[required]
+ pub body: FootnoteBody,
+}
+
+impl FootnoteElem {
+ /// Creates a new footnote that the passed content as its body.
+ pub fn with_content(content: Content) -> Self {
+ Self::new(FootnoteBody::Content(content))
+ }
+
+ /// Creates a new footnote referencing the footnote with the specified label.
+ pub fn with_label(label: Label) -> Self {
+ Self::new(FootnoteBody::Reference(label))
+ }
+
+ /// Tests if this footnote is a reference to another footnote.
+ pub fn is_ref(&self) -> bool {
+ matches!(self.body(), FootnoteBody::Reference(_))
+ }
+
+ /// Returns the content of the body of this footnote if it is not a ref.
+ pub fn body_content(&self) -> Option<Content> {
+ match self.body() {
+ FootnoteBody::Content(content) => Some(content),
+ _ => None,
+ }
+ }
+
+ /// Returns the location of the definition of this footnote.
+ pub fn declaration_location(&self, vt: &Vt) -> StrResult<Location> {
+ match self.body() {
+ FootnoteBody::Reference(label) => {
+ let element: Prehashed<Content> = vt.introspector.query_label(&label)?;
+ let footnote = element
+ .to::<FootnoteElem>()
+ .ok_or("referenced element should be a footnote")?;
+ footnote.declaration_location(vt)
+ }
+ _ => Ok(self.0.location().unwrap()),
+ }
+ }
+}
+
+impl Synthesize for FootnoteElem {
+ fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ self.push_numbering(self.numbering(styles));
+ Ok(())
+ }
+}
+
+impl Show for FootnoteElem {
+ #[tracing::instrument(name = "FootnoteElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(vt.delayed(|vt| {
+ let loc = self.declaration_location(vt).at(self.span())?;
+ let numbering = self.numbering(styles);
+ let counter = Counter::of(Self::func());
+ let num = counter.at(vt, loc)?.display(vt, &numbering)?;
+ let sup = SuperElem::new(num).pack();
+ let hole = HElem::new(Abs::zero().into()).with_weak(true).pack();
+ let loc = loc.variant(1);
+ Ok(hole + sup.linked(Destination::Location(loc)))
+ }))
+ }
+}
+
+impl Count for FootnoteElem {
+ fn update(&self) -> Option<CounterUpdate> {
+ (!self.is_ref()).then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+/// An entry in a footnote list.
+///
+/// This function is not intended to be called directly. Instead, it is used
+/// in set and show rules to customize footnote listings.
+///
+/// ## Example { #example }
+/// ```example
+/// #show footnote.entry: set text(red)
+///
+/// My footnote listing
+/// #footnote[It's down here]
+/// has red text!
+/// ```
+///
+/// Display: Footnote Entry
+/// Category: meta
+#[element(Show, Finalize)]
+pub struct FootnoteEntry {
+ /// The footnote for this entry. It's location can be used to determine
+ /// the footnote counter state.
+ ///
+ /// ```example
+ /// #show footnote.entry: it => {
+ /// let loc = it.note.location()
+ /// numbering(
+ /// "1: ",
+ /// ..counter(footnote).at(loc),
+ /// )
+ /// it.note.body
+ /// }
+ ///
+ /// Customized #footnote[Hello]
+ /// listing #footnote[World! 🌏]
+ /// ```
+ #[required]
+ pub note: FootnoteElem,
+
+ /// The separator between the document body and the footnote listing.
+ ///
+ /// ```example
+ /// #set footnote.entry(
+ /// separator: repeat[.]
+ /// )
+ ///
+ /// Testing a different separator.
+ /// #footnote[
+ /// Unconventional, but maybe
+ /// not that bad?
+ /// ]
+ /// ```
+ #[default(
+ LineElem::new()
+ .with_length(Ratio::new(0.3).into())
+ .with_stroke(PartialStroke {
+ thickness: Smart::Custom(Abs::pt(0.5).into()),
+ ..Default::default()
+ })
+ .pack()
+ )]
+ pub separator: Content,
+
+ /// The amount of clearance between the document body and the separator.
+ ///
+ /// ```example
+ /// #set footnote.entry(clearance: 3em)
+ ///
+ /// Footnotes also need ...
+ /// #footnote[
+ /// ... some space to breathe.
+ /// ]
+ /// ```
+ #[default(Em::new(1.0).into())]
+ #[resolve]
+ pub clearance: Length,
+
+ /// The gap between footnote entries.
+ ///
+ /// ```example
+ /// #set footnote.entry(gap: 0.8em)
+ ///
+ /// Footnotes:
+ /// #footnote[Spaced],
+ /// #footnote[Apart]
+ /// ```
+ #[default(Em::new(0.5).into())]
+ #[resolve]
+ pub gap: Length,
+
+ /// The indent of each footnote entry.
+ ///
+ /// ```example
+ /// #set footnote.entry(indent: 0em)
+ ///
+ /// Footnotes:
+ /// #footnote[No],
+ /// #footnote[Indent]
+ /// ```
+ #[default(Em::new(1.0).into())]
+ pub indent: Length,
+}
+
+impl Show for FootnoteEntry {
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let note = self.note();
+ let number_gap = Em::new(0.05);
+ let numbering = note.numbering(StyleChain::default());
+ let counter = Counter::of(FootnoteElem::func());
+ let loc = note.0.location().unwrap();
+ let num = counter.at(vt, loc)?.display(vt, &numbering)?;
+ let sup = SuperElem::new(num)
+ .pack()
+ .linked(Destination::Location(loc))
+ .backlinked(loc.variant(1));
+ Ok(Content::sequence([
+ HElem::new(self.indent(styles).into()).pack(),
+ sup,
+ HElem::new(number_gap.into()).with_weak(true).pack(),
+ note.body_content().unwrap(),
+ ]))
+ }
+}
+
+impl Finalize for FootnoteEntry {
+ fn finalize(&self, realized: Content, _: StyleChain) -> Content {
+ let text_size = Em::new(0.85);
+ let leading = Em::new(0.5);
+ realized
+ .styled(ParElem::set_leading(leading.into()))
+ .styled(TextElem::set_size(TextSize(text_size.into())))
+ }
+}
+
+cast! {
+ FootnoteElem,
+ v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::with_content(v.clone())),
+}
diff --git a/crates/typst-library/src/meta/heading.rs b/crates/typst-library/src/meta/heading.rs
new file mode 100644
index 00000000..d70bc0a9
--- /dev/null
+++ b/crates/typst-library/src/meta/heading.rs
@@ -0,0 +1,239 @@
+use typst::font::FontWeight;
+use typst::util::option_eq;
+
+use super::{Counter, CounterUpdate, LocalName, Numbering, Outlinable, Refable};
+use crate::layout::{BlockElem, HElem, VElem};
+use crate::meta::{Count, Supplement};
+use crate::prelude::*;
+use crate::text::{SpaceElem, TextElem, TextSize};
+
+/// A section heading.
+///
+/// With headings, you can structure your document into sections. Each heading
+/// has a _level,_ which starts at one and is unbounded upwards. This level
+/// indicates the logical role of the following content (section, subsection,
+/// etc.) A top-level heading indicates a top-level section of the document
+/// (not the document's title).
+///
+/// Typst can automatically number your headings for you. To enable numbering,
+/// specify how you want your headings to be numbered with a
+/// [numbering pattern or function]($func/numbering).
+///
+/// Independently from the numbering, Typst can also automatically generate an
+/// [outline]($func/outline) of all headings for you. To exclude one or more
+/// headings from this outline, you can set the `outlined` parameter to
+/// `{false}`.
+///
+/// ## Example { #example }
+/// ```example
+/// #set heading(numbering: "1.a)")
+///
+/// = Introduction
+/// In recent years, ...
+///
+/// == Preliminaries
+/// To start, ...
+/// ```
+///
+/// ## Syntax { #syntax }
+/// Headings have dedicated syntax: They can be created by starting a line with
+/// one or multiple equals signs, followed by a space. The number of equals
+/// signs determines the heading's logical nesting depth.
+///
+/// Display: Heading
+/// Category: meta
+#[element(Locatable, Synthesize, Count, Show, Finalize, LocalName, Refable, Outlinable)]
+pub struct HeadingElem {
+ /// The logical nesting depth of the heading, starting from one.
+ #[default(NonZeroUsize::ONE)]
+ pub level: NonZeroUsize,
+
+ /// How to number the heading. Accepts a
+ /// [numbering pattern or function]($func/numbering).
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.a.")
+ ///
+ /// = A section
+ /// == A subsection
+ /// === A sub-subsection
+ /// ```
+ pub numbering: Option<Numbering>,
+
+ /// A supplement for the heading.
+ ///
+ /// For references to headings, this is added before the referenced number.
+ ///
+ /// If a function is specified, it is passed the referenced heading and
+ /// should return content.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.", supplement: [Chapter])
+ ///
+ /// = Introduction <intro>
+ /// In @intro, we see how to turn
+ /// Sections into Chapters. And
+ /// in @intro[Part], it is done
+ /// manually.
+ /// ```
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// Whether the heading should appear in the outline.
+ ///
+ /// ```example
+ /// #outline()
+ ///
+ /// #heading[Normal]
+ /// This is a normal heading.
+ ///
+ /// #heading(outlined: false)[Hidden]
+ /// This heading does not appear
+ /// in the outline.
+ /// ```
+ #[default(true)]
+ pub outlined: bool,
+
+ /// The heading's title.
+ #[required]
+ pub body: Content,
+}
+
+impl Synthesize for HeadingElem {
+ fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ // Resolve the supplement.
+ let supplement = match self.supplement(styles) {
+ Smart::Auto => TextElem::packed(self.local_name_in(styles)),
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => supplement.resolve(vt, [self.clone()])?,
+ };
+
+ self.push_level(self.level(styles));
+ self.push_numbering(self.numbering(styles));
+ self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
+ self.push_outlined(self.outlined(styles));
+
+ Ok(())
+ }
+}
+
+impl Show for HeadingElem {
+ #[tracing::instrument(name = "HeadingElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body();
+ if let Some(numbering) = self.numbering(styles) {
+ realized = Counter::of(Self::func())
+ .display(Some(numbering), false)
+ .spanned(self.span())
+ + HElem::new(Em::new(0.3).into()).with_weak(true).pack()
+ + realized;
+ }
+ Ok(BlockElem::new().with_body(Some(realized)).pack())
+ }
+}
+
+impl Finalize for HeadingElem {
+ fn finalize(&self, realized: Content, styles: StyleChain) -> Content {
+ let level = self.level(styles).get();
+ let scale = match level {
+ 1 => 1.4,
+ 2 => 1.2,
+ _ => 1.0,
+ };
+
+ let size = Em::new(scale);
+ let above = Em::new(if level == 1 { 1.8 } else { 1.44 }) / scale;
+ let below = Em::new(0.75) / scale;
+
+ let mut styles = Styles::new();
+ styles.set(TextElem::set_size(TextSize(size.into())));
+ styles.set(TextElem::set_weight(FontWeight::BOLD));
+ styles.set(BlockElem::set_above(VElem::block_around(above.into())));
+ styles.set(BlockElem::set_below(VElem::block_around(below.into())));
+ styles.set(BlockElem::set_sticky(true));
+ realized.styled_with_map(styles)
+ }
+}
+
+impl Count for HeadingElem {
+ fn update(&self) -> Option<CounterUpdate> {
+ self.numbering(StyleChain::default())
+ .is_some()
+ .then(|| CounterUpdate::Step(self.level(StyleChain::default())))
+ }
+}
+
+cast! {
+ HeadingElem,
+ v: Content => v.to::<Self>().ok_or("expected heading")?.clone(),
+}
+
+impl Refable for HeadingElem {
+ fn supplement(&self) -> Content {
+ // After synthesis, this should always be custom content.
+ match self.supplement(StyleChain::default()) {
+ Smart::Custom(Some(Supplement::Content(content))) => content,
+ _ => Content::empty(),
+ }
+ }
+
+ fn counter(&self) -> Counter {
+ Counter::of(Self::func())
+ }
+
+ fn numbering(&self) -> Option<Numbering> {
+ self.numbering(StyleChain::default())
+ }
+}
+
+impl Outlinable for HeadingElem {
+ fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>> {
+ if !self.outlined(StyleChain::default()) {
+ return Ok(None);
+ }
+
+ let mut content = self.body();
+ if let Some(numbering) = self.numbering(StyleChain::default()) {
+ let numbers = Counter::of(Self::func())
+ .at(vt, self.0.location().unwrap())?
+ .display(vt, &numbering)?;
+ content = numbers + SpaceElem::new().pack() + content;
+ };
+
+ Ok(Some(content))
+ }
+
+ fn level(&self) -> NonZeroUsize {
+ self.level(StyleChain::default())
+ }
+}
+
+impl LocalName for HeadingElem {
+ fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "Kapitull",
+ Lang::ARABIC => "الفصل",
+ Lang::BOKMÅL => "Kapittel",
+ Lang::CHINESE if option_eq(region, "TW") => "小節",
+ Lang::CHINESE => "小节",
+ Lang::CZECH => "Kapitola",
+ Lang::DANISH => "Afsnit",
+ Lang::DUTCH => "Hoofdstuk",
+ Lang::FILIPINO => "Seksyon",
+ Lang::FRENCH => "Chapitre",
+ Lang::GERMAN => "Abschnitt",
+ Lang::ITALIAN => "Sezione",
+ Lang::NYNORSK => "Kapittel",
+ Lang::POLISH => "Sekcja",
+ Lang::PORTUGUESE if option_eq(region, "PT") => "Secção",
+ Lang::PORTUGUESE => "Seção",
+ Lang::RUSSIAN => "Раздел",
+ Lang::SLOVENIAN => "Poglavje",
+ Lang::SPANISH => "Sección",
+ Lang::SWEDISH => "Kapitel",
+ Lang::TURKISH => "Bölüm",
+ Lang::UKRAINIAN => "Розділ",
+ Lang::VIETNAMESE => "Phần", // TODO: This may be wrong.
+ Lang::ENGLISH | _ => "Section",
+ }
+ }
+}
diff --git a/crates/typst-library/src/meta/link.rs b/crates/typst-library/src/meta/link.rs
new file mode 100644
index 00000000..2a53b84f
--- /dev/null
+++ b/crates/typst-library/src/meta/link.rs
@@ -0,0 +1,137 @@
+use crate::prelude::*;
+use crate::text::{Hyphenate, TextElem};
+
+/// Links to a URL or a location in the document.
+///
+/// By default, links are not styled any different from normal text. However,
+/// you can easily apply a style of your choice with a show rule.
+///
+/// ## Example { #example }
+/// ```example
+/// #show link: underline
+///
+/// https://example.com \
+///
+/// #link("https://example.com") \
+/// #link("https://example.com")[
+/// See example.com
+/// ]
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: Text that starts with `http://` or
+/// `https://` is automatically turned into a link.
+///
+/// Display: Link
+/// Category: meta
+#[element(Show)]
+pub struct LinkElem {
+ /// The destination the link points to.
+ ///
+ /// - To link to web pages, `dest` should be a valid URL string. If the URL
+ /// is in the `mailto:` or `tel:` scheme and the `body` parameter is
+ /// omitted, the email address or phone number will be the link's body,
+ /// without the scheme.
+ ///
+ /// - To link to another part of the document, `dest` can take one of three
+ /// forms:
+ /// - A [label]($func/label) attached to an element. If you also want
+ /// automatic text for the link based on the element, consider using
+ /// a [reference]($func/ref) instead.
+ ///
+ /// - A [location]($func/locate) resulting from a [`locate`]($func/locate)
+ /// call or [`query`]($func/query).
+ ///
+ /// - A dictionary with a `page` key of type [integer]($type/integer) and
+ /// `x` and `y` coordinates of type [length]($type/length). Pages are
+ /// counted from one, and the coordinates are relative to the page's top
+ /// left corner.
+ ///
+ /// ```example
+ /// = Introduction <intro>
+ /// #link("mailto:hello@typst.app") \
+ /// #link(<intro>)[Go to intro] \
+ /// #link((page: 1, x: 0pt, y: 0pt))[
+ /// Go to top
+ /// ]
+ /// ```
+ #[required]
+ #[parse(
+ let dest = args.expect::<LinkTarget>("destination")?;
+ dest.clone()
+ )]
+ pub dest: LinkTarget,
+
+ /// The content that should become a link.
+ ///
+ /// If `dest` is an URL string, the parameter can be omitted. In this case,
+ /// the URL will be shown as the link.
+ #[required]
+ #[parse(match &dest {
+ LinkTarget::Dest(Destination::Url(url)) => match args.eat()? {
+ Some(body) => body,
+ None => body_from_url(url),
+ },
+ _ => args.expect("body")?,
+ })]
+ pub body: Content,
+}
+
+impl LinkElem {
+ /// Create a link element from a URL with its bare text.
+ pub fn from_url(url: EcoString) -> Self {
+ let body = body_from_url(&url);
+ Self::new(LinkTarget::Dest(Destination::Url(url)), body)
+ }
+}
+
+impl Show for LinkElem {
+ #[tracing::instrument(name = "LinkElem::show", skip(self, vt))]
+ fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ let body = self.body();
+ let linked = match self.dest() {
+ LinkTarget::Dest(dest) => body.linked(dest),
+ LinkTarget::Label(label) => vt
+ .delayed(|vt| {
+ let elem = vt.introspector.query_label(&label).at(self.span())?;
+ let dest = Destination::Location(elem.location().unwrap());
+ Ok(Some(body.clone().linked(dest)))
+ })
+ .unwrap_or(body),
+ };
+
+ Ok(linked.styled(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))))
+ }
+}
+
+fn body_from_url(url: &EcoString) -> Content {
+ let mut text = url.as_str();
+ for prefix in ["mailto:", "tel:"] {
+ text = text.trim_start_matches(prefix);
+ }
+ let shorter = text.len() < url.len();
+ TextElem::packed(if shorter { text.into() } else { url.clone() })
+}
+
+/// A target where a link can go.
+#[derive(Debug, Clone)]
+pub enum LinkTarget {
+ Dest(Destination),
+ Label(Label),
+}
+
+cast! {
+ LinkTarget,
+ self => match self {
+ Self::Dest(v) => v.into_value(),
+ Self::Label(v) => v.into_value(),
+ },
+ v: Destination => Self::Dest(v),
+ v: Label => Self::Label(v),
+}
+
+impl From<Destination> for LinkTarget {
+ fn from(dest: Destination) -> Self {
+ Self::Dest(dest)
+ }
+}
diff --git a/crates/typst-library/src/meta/mod.rs b/crates/typst-library/src/meta/mod.rs
new file mode 100644
index 00000000..dcac6379
--- /dev/null
+++ b/crates/typst-library/src/meta/mod.rs
@@ -0,0 +1,64 @@
+//! Interaction between document parts.
+
+mod bibliography;
+mod context;
+mod counter;
+mod document;
+mod figure;
+mod footnote;
+mod heading;
+mod link;
+mod numbering;
+mod outline;
+mod query;
+mod reference;
+mod state;
+
+pub use self::bibliography::*;
+pub use self::context::*;
+pub use self::counter::*;
+pub use self::document::*;
+pub use self::figure::*;
+pub use self::footnote::*;
+pub use self::heading::*;
+pub use self::link::*;
+pub use self::numbering::*;
+pub use self::outline::*;
+pub use self::query::*;
+pub use self::reference::*;
+pub use self::state::*;
+
+use crate::prelude::*;
+use crate::text::TextElem;
+
+/// Hook up all meta definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.define("document", DocumentElem::func());
+ global.define("ref", RefElem::func());
+ global.define("link", LinkElem::func());
+ global.define("outline", OutlineElem::func());
+ global.define("heading", HeadingElem::func());
+ global.define("figure", FigureElem::func());
+ global.define("footnote", FootnoteElem::func());
+ global.define("cite", CiteElem::func());
+ global.define("bibliography", BibliographyElem::func());
+ global.define("locate", locate_func());
+ global.define("style", style_func());
+ global.define("layout", layout_func());
+ global.define("counter", counter_func());
+ global.define("numbering", numbering_func());
+ global.define("state", state_func());
+ global.define("query", query_func());
+ global.define("selector", selector_func());
+}
+
+/// The named with which an element is referenced.
+pub trait LocalName {
+ /// Get the name in the given language and (optionally) region.
+ fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str;
+
+ /// Resolve the local name with a style chain.
+ fn local_name_in(&self, styles: StyleChain) -> &'static str {
+ self.local_name(TextElem::lang_in(styles), TextElem::region_in(styles))
+ }
+}
diff --git a/crates/typst-library/src/meta/numbering.rs b/crates/typst-library/src/meta/numbering.rs
new file mode 100644
index 00000000..8698f7b9
--- /dev/null
+++ b/crates/typst-library/src/meta/numbering.rs
@@ -0,0 +1,525 @@
+use std::str::FromStr;
+
+use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToChinese};
+use ecow::EcoVec;
+
+use crate::prelude::*;
+use crate::text::Case;
+
+/// Applies a numbering to a sequence of numbers.
+///
+/// A numbering defines how a sequence of numbers should be displayed as
+/// content. It is defined either through a pattern string or an arbitrary
+/// function.
+///
+/// A numbering pattern consists of counting symbols, for which the actual
+/// number is substituted, their prefixes, and one suffix. The prefixes and the
+/// suffix are repeated as-is.
+///
+/// ## Example { #example }
+/// ```example
+/// #numbering("1.1)", 1, 2, 3) \
+/// #numbering("1.a.i", 1, 2) \
+/// #numbering("I – 1", 12, 2) \
+/// #numbering(
+/// (..nums) => nums
+/// .pos()
+/// .map(str)
+/// .join(".") + ")",
+/// 1, 2, 3,
+/// )
+/// ```
+///
+/// Display: Numbering
+/// Category: meta
+#[func]
+pub fn numbering(
+ /// Defines how the numbering works.
+ ///
+ /// **Counting symbols** are `1`, `a`, `A`, `i`, `I`, `い`, `イ`, `א`, `가`,
+ /// `ㄱ`, and `*`. They are replaced by the number in the sequence, in the
+ /// given case.
+ ///
+ /// The `*` character means that symbols should be used to count, in the
+ /// order of `*`, `†`, `‡`, `§`, `¶`, and `‖`. If there are more than six
+ /// items, the number is represented using multiple symbols.
+ ///
+ /// **Suffixes** are all characters after the last counting symbol. They are
+ /// repeated as-is at the end of any rendered number.
+ ///
+ /// **Prefixes** are all characters that are neither counting symbols nor
+ /// suffixes. They are repeated as-is at in front of their rendered
+ /// equivalent of their counting symbol.
+ ///
+ /// This parameter can also be an arbitrary function that gets each number
+ /// as an individual argument. When given a function, the `numbering`
+ /// function just forwards the arguments to that function. While this is not
+ /// particularly useful in itself, it means that you can just give arbitrary
+ /// numberings to the `numbering` function without caring whether they are
+ /// defined as a pattern or function.
+ numbering: Numbering,
+ /// The numbers to apply the numbering to. Must be positive.
+ ///
+ /// If `numbering` is a pattern and more numbers than counting symbols are
+ /// given, the last counting symbol with its prefix is repeated.
+ #[variadic]
+ numbers: Vec<usize>,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Value> {
+ numbering.apply_vm(vm, &numbers)
+}
+
+/// How to number a sequence of things.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Numbering {
+ /// A pattern with prefix, numbering, lower / upper case and suffix.
+ Pattern(NumberingPattern),
+ /// A closure mapping from an item's number to content.
+ Func(Func),
+}
+
+impl Numbering {
+ /// Apply the pattern to the given numbers.
+ pub fn apply_vm(&self, vm: &mut Vm, numbers: &[usize]) -> SourceResult<Value> {
+ Ok(match self {
+ Self::Pattern(pattern) => Value::Str(pattern.apply(numbers).into()),
+ Self::Func(func) => {
+ let args = Args::new(func.span(), numbers.iter().copied());
+ func.call_vm(vm, args)?
+ }
+ })
+ }
+
+ /// Apply the pattern to the given numbers.
+ pub fn apply_vt(&self, vt: &mut Vt, numbers: &[usize]) -> SourceResult<Value> {
+ Ok(match self {
+ Self::Pattern(pattern) => Value::Str(pattern.apply(numbers).into()),
+ Self::Func(func) => func.call_vt(vt, numbers.iter().copied())?,
+ })
+ }
+
+ /// Trim the prefix suffix if this is a pattern.
+ pub fn trimmed(mut self) -> Self {
+ if let Self::Pattern(pattern) = &mut self {
+ pattern.trimmed = true;
+ }
+ self
+ }
+}
+
+impl From<NumberingPattern> for Numbering {
+ fn from(pattern: NumberingPattern) -> Self {
+ Self::Pattern(pattern)
+ }
+}
+
+cast! {
+ Numbering,
+ self => match self {
+ Self::Pattern(pattern) => pattern.into_value(),
+ Self::Func(func) => func.into_value(),
+ },
+ v: NumberingPattern => Self::Pattern(v),
+ v: Func => Self::Func(v),
+}
+
+/// How to turn a number into text.
+///
+/// A pattern consists of a prefix, followed by one of `1`, `a`, `A`, `i`,
+/// `I`, `い`, `イ`, `א`, `가`, `ㄱ`, or `*`, and then a suffix.
+///
+/// Examples of valid patterns:
+/// - `1)`
+/// - `a.`
+/// - `(I)`
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct NumberingPattern {
+ pieces: EcoVec<(EcoString, NumberingKind, Case)>,
+ suffix: EcoString,
+ trimmed: bool,
+}
+
+impl NumberingPattern {
+ /// Apply the pattern to the given number.
+ pub fn apply(&self, numbers: &[usize]) -> EcoString {
+ let mut fmt = EcoString::new();
+ let mut numbers = numbers.iter();
+
+ for (i, ((prefix, kind, case), &n)) in
+ self.pieces.iter().zip(&mut numbers).enumerate()
+ {
+ if i > 0 || !self.trimmed {
+ fmt.push_str(prefix);
+ }
+ fmt.push_str(&kind.apply(n, *case));
+ }
+
+ for ((prefix, kind, case), &n) in
+ self.pieces.last().into_iter().cycle().zip(numbers)
+ {
+ if prefix.is_empty() {
+ fmt.push_str(&self.suffix);
+ } else {
+ fmt.push_str(prefix);
+ }
+ fmt.push_str(&kind.apply(n, *case));
+ }
+
+ if !self.trimmed {
+ fmt.push_str(&self.suffix);
+ }
+
+ fmt
+ }
+
+ /// Apply only the k-th segment of the pattern to a number.
+ pub fn apply_kth(&self, k: usize, number: usize) -> EcoString {
+ let mut fmt = EcoString::new();
+ if let Some((prefix, _, _)) = self.pieces.first() {
+ fmt.push_str(prefix);
+ }
+ if let Some((_, kind, case)) = self
+ .pieces
+ .iter()
+ .chain(self.pieces.last().into_iter().cycle())
+ .nth(k)
+ {
+ fmt.push_str(&kind.apply(number, *case));
+ }
+ fmt.push_str(&self.suffix);
+ fmt
+ }
+
+ /// How many counting symbols this pattern has.
+ pub fn pieces(&self) -> usize {
+ self.pieces.len()
+ }
+}
+
+impl FromStr for NumberingPattern {
+ type Err = &'static str;
+
+ fn from_str(pattern: &str) -> Result<Self, Self::Err> {
+ let mut pieces = EcoVec::new();
+ let mut handled = 0;
+
+ for (i, c) in pattern.char_indices() {
+ let Some(kind) = NumberingKind::from_char(c.to_ascii_lowercase()) else {
+ continue;
+ };
+
+ let prefix = pattern[handled..i].into();
+ let case =
+ if c.is_uppercase() || c == '壹' { Case::Upper } else { Case::Lower };
+ pieces.push((prefix, kind, case));
+ handled = c.len_utf8() + i;
+ }
+
+ let suffix = pattern[handled..].into();
+ if pieces.is_empty() {
+ return Err("invalid numbering pattern");
+ }
+
+ Ok(Self { pieces, suffix, trimmed: false })
+ }
+}
+
+cast! {
+ NumberingPattern,
+ self => {
+ let mut pat = EcoString::new();
+ for (prefix, kind, case) in &self.pieces {
+ pat.push_str(prefix);
+ let mut c = kind.to_char();
+ if *case == Case::Upper {
+ c = c.to_ascii_uppercase();
+ }
+ pat.push(c);
+ }
+ pat.push_str(&self.suffix);
+ pat.into_value()
+ },
+ v: Str => v.parse()?,
+}
+
+/// Different kinds of numberings.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+enum NumberingKind {
+ Arabic,
+ Letter,
+ Roman,
+ Symbol,
+ Hebrew,
+ SimplifiedChinese,
+ // TODO: Pick the numbering pattern based on languages choice.
+ // As the `1st` numbering character of Chinese (Simplified) and
+ // Chinese (Traditional) is same, we are unable to determine
+ // if the context is Simplified or Traditional by only this
+ // character.
+ #[allow(unused)]
+ TraditionalChinese,
+ HiraganaIroha,
+ KatakanaIroha,
+ KoreanJamo,
+ KoreanSyllable,
+}
+
+impl NumberingKind {
+ /// Create a numbering kind from a lowercase character.
+ pub fn from_char(c: char) -> Option<Self> {
+ Some(match c {
+ '1' => NumberingKind::Arabic,
+ 'a' => NumberingKind::Letter,
+ 'i' => NumberingKind::Roman,
+ '*' => NumberingKind::Symbol,
+ 'א' => NumberingKind::Hebrew,
+ '一' | '壹' => NumberingKind::SimplifiedChinese,
+ 'い' => NumberingKind::HiraganaIroha,
+ 'イ' => NumberingKind::KatakanaIroha,
+ 'ㄱ' => NumberingKind::KoreanJamo,
+ '가' => NumberingKind::KoreanSyllable,
+ _ => return None,
+ })
+ }
+
+ /// The lowercase character for this numbering kind.
+ pub fn to_char(self) -> char {
+ match self {
+ Self::Arabic => '1',
+ Self::Letter => 'a',
+ Self::Roman => 'i',
+ Self::Symbol => '*',
+ Self::Hebrew => 'א',
+ Self::SimplifiedChinese => '一',
+ Self::TraditionalChinese => '一',
+ Self::HiraganaIroha => 'い',
+ Self::KatakanaIroha => 'イ',
+ Self::KoreanJamo => 'ㄱ',
+ Self::KoreanSyllable => '가',
+ }
+ }
+
+ /// Apply the numbering to the given number.
+ pub fn apply(self, mut n: usize, case: Case) -> EcoString {
+ match self {
+ Self::Arabic => {
+ eco_format!("{n}")
+ }
+ Self::Letter => zeroless::<26>(
+ |x| match case {
+ Case::Lower => char::from(b'a' + x as u8),
+ Case::Upper => char::from(b'A' + x as u8),
+ },
+ n,
+ ),
+ Self::HiraganaIroha => zeroless::<47>(
+ |x| {
+ [
+ 'い', 'ろ', 'は', 'に', 'ほ', 'へ', 'と', 'ち', 'り', 'ぬ', 'る',
+ 'を', 'わ', 'か', 'よ', 'た', 'れ', 'そ', 'つ', 'ね', 'な', 'ら',
+ 'む', 'う', 'ゐ', 'の', 'お', 'く', 'や', 'ま', 'け', 'ふ', 'こ',
+ 'え', 'て', 'あ', 'さ', 'き', 'ゆ', 'め', 'み', 'し', 'ゑ', 'ひ',
+ 'も', 'せ', 'す',
+ ][x]
+ },
+ n,
+ ),
+ Self::KatakanaIroha => zeroless::<47>(
+ |x| {
+ [
+ 'イ', 'ロ', 'ハ', 'ニ', 'ホ', 'ヘ', 'ト', 'チ', 'リ', 'ヌ', 'ル',
+ 'ヲ', 'ワ', 'カ', 'ヨ', 'タ', 'レ', 'ソ', 'ツ', 'ネ', 'ナ', 'ラ',
+ 'ム', 'ウ', 'ヰ', 'ノ', 'オ', 'ク', 'ヤ', 'マ', 'ケ', 'フ', 'コ',
+ 'エ', 'テ', 'ア', 'サ', 'キ', 'ユ', 'メ', 'ミ', 'シ', 'ヱ', 'ヒ',
+ 'モ', 'セ', 'ス',
+ ][x]
+ },
+ n,
+ ),
+ Self::Roman => {
+ if n == 0 {
+ return 'N'.into();
+ }
+
+ // Adapted from Yann Villessuzanne's roman.rs under the
+ // Unlicense, at https://github.com/linfir/roman.rs/
+ let mut fmt = EcoString::new();
+ for &(name, value) in &[
+ ("M̅", 1000000),
+ ("D̅", 500000),
+ ("C̅", 100000),
+ ("L̅", 50000),
+ ("X̅", 10000),
+ ("V̅", 5000),
+ ("I̅V̅", 4000),
+ ("M", 1000),
+ ("CM", 900),
+ ("D", 500),
+ ("CD", 400),
+ ("C", 100),
+ ("XC", 90),
+ ("L", 50),
+ ("XL", 40),
+ ("X", 10),
+ ("IX", 9),
+ ("V", 5),
+ ("IV", 4),
+ ("I", 1),
+ ] {
+ while n >= value {
+ n -= value;
+ for c in name.chars() {
+ match case {
+ Case::Lower => fmt.extend(c.to_lowercase()),
+ Case::Upper => fmt.push(c),
+ }
+ }
+ }
+ }
+
+ fmt
+ }
+ Self::Symbol => {
+ if n == 0 {
+ return '-'.into();
+ }
+
+ const SYMBOLS: &[char] = &['*', '†', '‡', '§', '¶', '‖'];
+ let symbol = SYMBOLS[(n - 1) % SYMBOLS.len()];
+ let amount = ((n - 1) / SYMBOLS.len()) + 1;
+ std::iter::repeat(symbol).take(amount).collect()
+ }
+ Self::Hebrew => {
+ if n == 0 {
+ return '-'.into();
+ }
+
+ let mut fmt = EcoString::new();
+ 'outer: for &(name, value) in &[
+ ('ת', 400),
+ ('ש', 300),
+ ('ר', 200),
+ ('ק', 100),
+ ('צ', 90),
+ ('פ', 80),
+ ('ע', 70),
+ ('ס', 60),
+ ('נ', 50),
+ ('מ', 40),
+ ('ל', 30),
+ ('כ', 20),
+ ('י', 10),
+ ('ט', 9),
+ ('ח', 8),
+ ('ז', 7),
+ ('ו', 6),
+ ('ה', 5),
+ ('ד', 4),
+ ('ג', 3),
+ ('ב', 2),
+ ('א', 1),
+ ] {
+ while n >= value {
+ match n {
+ 15 => fmt.push_str("ט״ו"),
+ 16 => fmt.push_str("ט״ז"),
+ _ => {
+ let append_geresh = n == value && fmt.is_empty();
+ if n == value && !fmt.is_empty() {
+ fmt.push('״');
+ }
+ fmt.push(name);
+ if append_geresh {
+ fmt.push('׳');
+ }
+
+ n -= value;
+ continue;
+ }
+ }
+ break 'outer;
+ }
+ }
+ fmt
+ }
+ l @ (Self::SimplifiedChinese | Self::TraditionalChinese) => {
+ let chinese_case = match case {
+ Case::Lower => ChineseCase::Lower,
+ Case::Upper => ChineseCase::Upper,
+ };
+
+ match (n as u8).to_chinese(
+ match l {
+ Self::SimplifiedChinese => ChineseVariant::Simple,
+ Self::TraditionalChinese => ChineseVariant::Traditional,
+ _ => unreachable!(),
+ },
+ chinese_case,
+ ChineseCountMethod::TenThousand,
+ ) {
+ Ok(num_str) => EcoString::from(num_str),
+ Err(_) => '-'.into(),
+ }
+ }
+ Self::KoreanJamo => zeroless::<14>(
+ |x| {
+ [
+ 'ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ',
+ 'ㅌ', 'ㅍ', 'ㅎ',
+ ][x]
+ },
+ n,
+ ),
+ Self::KoreanSyllable => zeroless::<14>(
+ |x| {
+ [
+ '가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카',
+ '타', '파', '하',
+ ][x]
+ },
+ n,
+ ),
+ }
+ }
+}
+
+/// Stringify a number using a base-N counting system with no zero digit.
+///
+/// This is best explained by example. Suppose our digits are 'A', 'B', and 'C'.
+/// we would get the following:
+///
+/// ```text
+/// 1 => "A"
+/// 2 => "B"
+/// 3 => "C"
+/// 4 => "AA"
+/// 5 => "AB"
+/// 6 => "AC"
+/// 7 => "BA"
+/// 8 => "BB"
+/// 9 => "BC"
+/// 10 => "CA"
+/// 11 => "CB"
+/// 12 => "CC"
+/// 13 => "AAA"
+/// etc.
+/// ```
+///
+/// You might be familiar with this scheme from the way spreadsheet software
+/// tends to label its columns.
+fn zeroless<const N_DIGITS: usize>(
+ mk_digit: impl Fn(usize) -> char,
+ mut n: usize,
+) -> EcoString {
+ if n == 0 {
+ return '-'.into();
+ }
+ let mut cs = vec![];
+ while n > 0 {
+ n -= 1;
+ cs.push(mk_digit(n % N_DIGITS));
+ n /= N_DIGITS;
+ }
+ cs.into_iter().rev().collect()
+}
diff --git a/crates/typst-library/src/meta/outline.rs b/crates/typst-library/src/meta/outline.rs
new file mode 100644
index 00000000..ba858a69
--- /dev/null
+++ b/crates/typst-library/src/meta/outline.rs
@@ -0,0 +1,528 @@
+use std::str::FromStr;
+
+use typst::util::option_eq;
+
+use super::{
+ Counter, CounterKey, HeadingElem, LocalName, Numbering, NumberingPattern, Refable,
+};
+use crate::layout::{BoxElem, HElem, HideElem, ParbreakElem, RepeatElem, Spacing};
+use crate::prelude::*;
+use crate::text::{LinebreakElem, SpaceElem, TextElem};
+
+/// A table of contents, figures, or other elements.
+///
+/// This function generates a list of all occurrences of an element in the
+/// document, up to a given depth. The element's numbering and page number will
+/// be displayed in the outline alongside its title or caption. By default this
+/// generates a table of contents.
+///
+/// ## Example { #example }
+/// ```example
+/// #outline()
+///
+/// = Introduction
+/// #lorem(5)
+///
+/// = Prior work
+/// #lorem(10)
+/// ```
+///
+/// ## Alternative outlines { #alternative-outlines }
+/// By setting the `target` parameter, the outline can be used to generate a
+/// list of other kinds of elements than headings. In the example below, we list
+/// all figures containing images by setting `target` to `{figure.where(kind:
+/// image)}`. We could have also set it to just `figure`, but then the list
+/// would also include figures containing tables or other material. For more
+/// details on the `where` selector, [see here]($type/content.where).
+///
+/// ```example
+/// #outline(
+/// title: [List of Figures],
+/// target: figure.where(kind: image),
+/// )
+///
+/// #figure(
+/// image("tiger.jpg"),
+/// caption: [A nice figure!],
+/// )
+/// ```
+///
+/// ## Styling the outline { #styling-the-outline }
+/// The outline element has several options for customization, such as its
+/// `title` and `indent` parameters. If desired, however, it is possible to
+/// have more control over the outline's look and style through the
+/// [`outline.entry`]($func/outline.entry) element.
+///
+/// Display: Outline
+/// Category: meta
+/// Keywords: Table of Contents
+#[element(Show, Finalize, LocalName)]
+#[scope(
+ scope.define("entry", OutlineEntry::func());
+ scope
+)]
+pub struct OutlineElem {
+ /// The title of the outline.
+ ///
+ /// - When set to `{auto}`, an appropriate title for the
+ /// [text language]($func/text.lang) will be used. This is the default.
+ /// - When set to `{none}`, the outline will not have a title.
+ /// - A custom title can be set by passing content.
+ ///
+ /// The outline's heading will not be numbered by default, but you can
+ /// force it to be with a show-set rule:
+ /// `{show outline: set heading(numbering: "1.")}`
+ /// ```
+ #[default(Some(Smart::Auto))]
+ pub title: Option<Smart<Content>>,
+
+ /// The type of element to include in the outline.
+ ///
+ /// To list figures containing a specific kind of element, like a table, you
+ /// can write `{figure.where(kind: table)}`.
+ ///
+ /// ```example
+ /// #outline(
+ /// title: [List of Tables],
+ /// target: figure.where(kind: table),
+ /// )
+ ///
+ /// #figure(
+ /// table(
+ /// columns: 4,
+ /// [t], [1], [2], [3],
+ /// [y], [0.3], [0.7], [0.5],
+ /// ),
+ /// caption: [Experiment results],
+ /// )
+ /// ```
+ #[default(LocatableSelector(Selector::Elem(
+ HeadingElem::func(),
+ Some(dict! { "outlined" => true })
+ )))]
+ pub target: LocatableSelector,
+
+ /// The maximum level up to which elements are included in the outline. When
+ /// this argument is `{none}`, all elements are included.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.")
+ /// #outline(depth: 2)
+ ///
+ /// = Yes
+ /// Top-level section.
+ ///
+ /// == Still
+ /// Subsection.
+ ///
+ /// === Nope
+ /// Not included.
+ /// ```
+ pub depth: Option<NonZeroUsize>,
+
+ /// How to indent the outline's entries.
+ ///
+ /// - `{none}`: No indent
+ /// - `{auto}`: Indents the numbering of the nested entry with the title of
+ /// its parent entry. This only has an effect if the entries are numbered
+ /// (e.g., via [heading numbering]($func/heading.numbering)).
+ /// - [Relative length]($type/relative-length): Indents the item by this length
+ /// multiplied by its nesting level. Specifying `{2em}`, for instance,
+ /// would indent top-level headings (not nested) by `{0em}`, second level
+ /// headings by `{2em}` (nested once), third-level headings by `{4em}`
+ /// (nested twice) and so on.
+ /// - [Function]($type/function): You can completely customize this setting
+ /// with a function. That function receives the nesting level as a
+ /// parameter (starting at 0 for top-level headings/elements) and can
+ /// return a relative length or content making up the indent. For example,
+ /// `{n => n * 2em}` would be equivalent to just specifying `{2em}`,
+ /// while `{n => [→ ] * n}` would indent with one arrow per nesting
+ /// level.
+ ///
+ /// *Migration hints:* Specifying `{true}` (equivalent to `{auto}`) or
+ /// `{false}` (equivalent to `{none}`) for this option is deprecated and
+ /// will be removed in a future release.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.a.")
+ ///
+ /// #outline(
+ /// title: [Contents (Automatic)],
+ /// indent: auto,
+ /// )
+ ///
+ /// #outline(
+ /// title: [Contents (Length)],
+ /// indent: 2em,
+ /// )
+ ///
+ /// #outline(
+ /// title: [Contents (Function)],
+ /// indent: n => [→ ] * n,
+ /// )
+ ///
+ /// = About ACME Corp.
+ /// == History
+ /// === Origins
+ /// #lorem(10)
+ ///
+ /// == Products
+ /// #lorem(10)
+ /// ```
+ #[default(None)]
+ pub indent: Option<Smart<OutlineIndent>>,
+
+ /// Content to fill the space between the title and the page number. Can be
+ /// set to `none` to disable filling.
+ ///
+ /// ```example
+ /// #outline(fill: line(length: 100%))
+ ///
+ /// = A New Beginning
+ /// ```
+ #[default(Some(RepeatElem::new(TextElem::packed(".")).pack()))]
+ pub fill: Option<Content>,
+}
+
+impl Show for OutlineElem {
+ #[tracing::instrument(name = "OutlineElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let mut seq = vec![ParbreakElem::new().pack()];
+ // Build the outline title.
+ if let Some(title) = self.title(styles) {
+ let title =
+ title.unwrap_or_else(|| {
+ TextElem::packed(self.local_name(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ ))
+ .spanned(self.span())
+ });
+
+ seq.push(HeadingElem::new(title).with_level(NonZeroUsize::ONE).pack());
+ }
+
+ let indent = self.indent(styles);
+ let depth = self.depth(styles).unwrap_or(NonZeroUsize::new(usize::MAX).unwrap());
+
+ let mut ancestors: Vec<&Content> = vec![];
+ let elems = vt.introspector.query(&self.target(styles).0);
+
+ for elem in &elems {
+ let Some(entry) = OutlineEntry::from_outlinable(
+ vt,
+ self.span(),
+ elem.clone().into_inner(),
+ self.fill(styles),
+ )? else {
+ continue;
+ };
+
+ let level = entry.level();
+ if depth < level {
+ continue;
+ }
+
+ // Deals with the ancestors of the current element.
+ // This is only applicable for elements with a hierarchy/level.
+ while ancestors
+ .last()
+ .and_then(|ancestor| ancestor.with::<dyn Outlinable>())
+ .map_or(false, |last| last.level() >= level)
+ {
+ ancestors.pop();
+ }
+
+ OutlineIndent::apply(&indent, vt, &ancestors, &mut seq, self.span())?;
+
+ // Add the overridable outline entry, followed by a line break.
+ seq.push(entry.pack());
+ seq.push(LinebreakElem::new().pack());
+
+ ancestors.push(elem);
+ }
+
+ seq.push(ParbreakElem::new().pack());
+
+ Ok(Content::sequence(seq))
+ }
+}
+
+impl Finalize for OutlineElem {
+ fn finalize(&self, realized: Content, _: StyleChain) -> Content {
+ realized
+ .styled(HeadingElem::set_outlined(false))
+ .styled(HeadingElem::set_numbering(None))
+ }
+}
+
+impl LocalName for OutlineElem {
+ fn local_name(&self, lang: Lang, region: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "Përmbajtja",
+ Lang::ARABIC => "المحتويات",
+ Lang::BOKMÅL => "Innhold",
+ Lang::CHINESE if option_eq(region, "TW") => "目錄",
+ Lang::CHINESE => "目录",
+ Lang::CZECH => "Obsah",
+ Lang::DANISH => "Indhold",
+ Lang::DUTCH => "Inhoudsopgave",
+ Lang::FILIPINO => "Talaan ng mga Nilalaman",
+ Lang::FRENCH => "Table des matières",
+ Lang::GERMAN => "Inhaltsverzeichnis",
+ Lang::ITALIAN => "Indice",
+ Lang::NYNORSK => "Innhald",
+ Lang::POLISH => "Spis treści",
+ Lang::PORTUGUESE if option_eq(region, "PT") => "Índice",
+ Lang::PORTUGUESE => "Sumário",
+ Lang::RUSSIAN => "Содержание",
+ Lang::SLOVENIAN => "Kazalo",
+ Lang::SPANISH => "Índice",
+ Lang::SWEDISH => "Innehåll",
+ Lang::TURKISH => "İçindekiler",
+ Lang::UKRAINIAN => "Зміст",
+ Lang::VIETNAMESE => "Mục lục",
+ Lang::ENGLISH | _ => "Contents",
+ }
+ }
+}
+
+/// Marks an element as being able to be outlined. This is used to implement the
+/// `#outline()` element.
+pub trait Outlinable: Refable {
+ /// Produce an outline item for this element.
+ fn outline(&self, vt: &mut Vt) -> SourceResult<Option<Content>>;
+
+ /// Returns the nesting level of this element.
+ fn level(&self) -> NonZeroUsize {
+ NonZeroUsize::ONE
+ }
+}
+
+#[derive(Debug, Clone)]
+pub enum OutlineIndent {
+ Bool(bool),
+ Rel(Rel<Length>),
+ Func(Func),
+}
+
+impl OutlineIndent {
+ fn apply(
+ indent: &Option<Smart<Self>>,
+ vt: &mut Vt,
+ ancestors: &Vec<&Content>,
+ seq: &mut Vec<Content>,
+ span: Span,
+ ) -> SourceResult<()> {
+ match indent {
+ // 'none' | 'false' => no indenting
+ None | Some(Smart::Custom(OutlineIndent::Bool(false))) => {}
+
+ // 'auto' | 'true' => use numbering alignment for indenting
+ Some(Smart::Auto | Smart::Custom(OutlineIndent::Bool(true))) => {
+ // Add hidden ancestors numberings to realize the indent.
+ let mut hidden = Content::empty();
+ for ancestor in ancestors {
+ let ancestor_outlinable = ancestor.with::<dyn Outlinable>().unwrap();
+
+ if let Some(numbering) = ancestor_outlinable.numbering() {
+ let numbers = ancestor_outlinable
+ .counter()
+ .at(vt, ancestor.location().unwrap())?
+ .display(vt, &numbering)?;
+
+ hidden += numbers + SpaceElem::new().pack();
+ };
+ }
+
+ if !ancestors.is_empty() {
+ seq.push(HideElem::new(hidden).pack());
+ seq.push(SpaceElem::new().pack());
+ }
+ }
+
+ // Length => indent with some fixed spacing per level
+ Some(Smart::Custom(OutlineIndent::Rel(length))) => {
+ seq.push(
+ HElem::new(Spacing::Rel(*length)).pack().repeat(ancestors.len()),
+ );
+ }
+
+ // Function => call function with the current depth and take
+ // the returned content
+ Some(Smart::Custom(OutlineIndent::Func(func))) => {
+ let depth = ancestors.len();
+ let LengthOrContent(content) =
+ func.call_vt(vt, [depth])?.cast().at(span)?;
+ if !content.is_empty() {
+ seq.push(content);
+ }
+ }
+ };
+
+ Ok(())
+ }
+}
+
+cast! {
+ OutlineIndent,
+ self => match self {
+ Self::Bool(v) => v.into_value(),
+ Self::Rel(v) => v.into_value(),
+ Self::Func(v) => v.into_value()
+ },
+ v: bool => OutlineIndent::Bool(v),
+ v: Rel<Length> => OutlineIndent::Rel(v),
+ v: Func => OutlineIndent::Func(v),
+}
+
+struct LengthOrContent(Content);
+
+cast! {
+ LengthOrContent,
+ v: Rel<Length> => Self(HElem::new(Spacing::Rel(v)).pack()),
+ v: Content => Self(v),
+}
+
+/// Represents each entry line in an outline, including the reference to the
+/// outlined element, its page number, and the filler content between both.
+///
+/// This element is intended for use with show rules to control the appearance
+/// of outlines.
+///
+/// ## Example { #example }
+/// The example below shows how to style entries for top-level sections to make
+/// them stand out.
+///
+/// ```example
+/// #set heading(numbering: "1.")
+///
+/// #show outline.entry.where(
+/// level: 1
+/// ): it => {
+/// v(12pt, weak: true)
+/// strong(it)
+/// }
+///
+/// #outline(indent: auto)
+///
+/// = Introduction
+/// = Background
+/// == History
+/// == State of the Art
+/// = Analysis
+/// == Setup
+/// ```
+///
+/// To completely customize an entry's line, you can also build it from scratch
+/// by accessing the `level`, `element`, `body`, `fill` and `page` fields on the entry.
+///
+/// Display: Outline Entry
+/// Category: meta
+#[element(Show)]
+pub struct OutlineEntry {
+ /// The nesting level of this outline entry. Starts at `{1}` for top-level
+ /// entries.
+ #[required]
+ pub level: NonZeroUsize,
+
+ /// The element this entry refers to. Its location will be available
+ /// through the [`location`]($type/content.location) method on content
+ /// and can be [linked]($func/link) to.
+ #[required]
+ pub element: Content,
+
+ /// The content which is displayed in place of the referred element at its
+ /// entry in the outline. For a heading, this would be its number followed
+ /// by the heading's title, for example.
+ #[required]
+ pub body: Content,
+
+ /// The content used to fill the space between the element's outline and
+ /// its page number, as defined by the outline element this entry is
+ /// located in. When `{none}`, empty space is inserted in that gap instead.
+ ///
+ /// Note that, when using show rules to override outline entries, it is
+ /// recommended to wrap the filling content in a [`box`]($func/box) with
+ /// fractional width. For example, `{box(width: 1fr, repeat[-])}` would show
+ /// precisely as many `-` characters as necessary to fill a particular gap.
+ #[required]
+ pub fill: Option<Content>,
+
+ /// The page number of the element this entry links to, formatted with the
+ /// numbering set for the referenced page.
+ #[required]
+ pub page: Content,
+}
+
+impl OutlineEntry {
+ /// Generates an OutlineEntry from the given element, if possible (errors if
+ /// the element does not implement `Outlinable`). If the element should not
+ /// be outlined (e.g. heading with 'outlined: false'), does not generate an
+ /// entry instance (returns `Ok(None)`).
+ fn from_outlinable(
+ vt: &mut Vt,
+ span: Span,
+ elem: Content,
+ fill: Option<Content>,
+ ) -> SourceResult<Option<Self>> {
+ let Some(outlinable) = elem.with::<dyn Outlinable>() else {
+ bail!(span, "cannot outline {}", elem.func().name());
+ };
+
+ let Some(body) = outlinable.outline(vt)? else {
+ return Ok(None);
+ };
+
+ let location = elem.location().unwrap();
+ let page_numbering = vt
+ .introspector
+ .page_numbering(location)
+ .cast::<Option<Numbering>>()
+ .unwrap()
+ .unwrap_or_else(|| {
+ Numbering::Pattern(NumberingPattern::from_str("1").unwrap())
+ });
+
+ let page = Counter::new(CounterKey::Page)
+ .at(vt, location)?
+ .display(vt, &page_numbering)?;
+
+ Ok(Some(Self::new(outlinable.level(), elem, body, fill, page)))
+ }
+}
+
+impl Show for OutlineEntry {
+ fn show(&self, _vt: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ let mut seq = vec![];
+ let elem = self.element();
+
+ // In case a user constructs an outline entry with an arbitrary element.
+ let Some(location) = elem.location() else {
+ bail!(self.span(), "cannot outline {}", elem.func().name())
+ };
+
+ // The body text remains overridable.
+ seq.push(self.body().linked(Destination::Location(location)));
+
+ // Add filler symbols between the section name and page number.
+ if let Some(filler) = self.fill() {
+ seq.push(SpaceElem::new().pack());
+ seq.push(
+ BoxElem::new()
+ .with_body(Some(filler))
+ .with_width(Fr::one().into())
+ .pack(),
+ );
+ seq.push(SpaceElem::new().pack());
+ } else {
+ seq.push(HElem::new(Fr::one().into()).pack());
+ }
+
+ // Add the page number.
+ let page = self.page().linked(Destination::Location(location));
+ seq.push(page);
+
+ Ok(Content::sequence(seq))
+ }
+}
diff --git a/crates/typst-library/src/meta/query.rs b/crates/typst-library/src/meta/query.rs
new file mode 100644
index 00000000..826b812e
--- /dev/null
+++ b/crates/typst-library/src/meta/query.rs
@@ -0,0 +1,145 @@
+use crate::prelude::*;
+
+/// Finds elements in the document.
+///
+/// The `query` functions lets you search your document for elements of a
+/// particular type or with a particular label.
+///
+/// To use it, you first need to retrieve the current document location with the
+/// [`locate`]($func/locate) function. You can then decide whether you want to
+/// find all elements, just the ones before that location, or just the ones
+/// after it.
+///
+/// ## Finding elements { #finding-elements }
+/// In the example below, we create a custom page header that displays the text
+/// "Typst Academy" in small capitals and the current section title. On the
+/// first page, the section title is omitted because the header is before the
+/// first section heading.
+///
+/// To realize this layout, we call `locate` and then query for all headings
+/// after the current location. The function we pass to locate is called twice
+/// in this case: Once per page.
+///
+/// - On the first page the query for all headings before the current location
+/// yields an empty array: There are no previous headings. We check for this
+/// case and and just display "Typst Academy".
+///
+/// - For the second page, we retrieve the last element from the query's result.
+/// This is the latest heading before the current position and as such, it is
+/// the heading of the section we are currently in. We access its content
+/// through the `body` field and display it alongside "Typst Academy".
+///
+/// ```example
+/// >>> #set page(
+/// >>> width: 240pt,
+/// >>> height: 180pt,
+/// >>> margin: (top: 35pt, rest: 15pt),
+/// >>> header-ascent: 12pt,
+/// >>> )
+/// #set page(header: locate(loc => {
+/// let elems = query(
+/// selector(heading).before(loc),
+/// loc,
+/// )
+/// let academy = smallcaps[
+/// Typst Academy
+/// ]
+/// if elems == () {
+/// align(right, academy)
+/// } else {
+/// let body = elems.last().body
+/// academy + h(1fr) + emph(body)
+/// }
+/// }))
+///
+/// = Introduction
+/// #lorem(23)
+///
+/// = Background
+/// #lorem(30)
+///
+/// = Analysis
+/// #lorem(15)
+/// ```
+///
+/// ## A word of caution { #caution }
+/// To resolve all your queries, Typst evaluates and layouts parts of the
+/// document multiple times. However, there is no guarantee that your queries
+/// can actually be completely resolved. If you aren't careful a query can
+/// affect itself—leading to a result that never stabilizes.
+///
+/// In the example below, we query for all headings in the document. We then
+/// generate as many headings. In the beginning, there's just one heading,
+/// titled `Real`. Thus, `count` is `1` and one `Fake` heading is generated.
+/// Typst sees that the query's result has changed and processes it again. This
+/// time, `count` is `2` and two `Fake` headings are generated. This goes on and
+/// on. As we can see, the output has five headings. This is because Typst
+/// simply gives up after five attempts.
+///
+/// In general, you should try not to write queries that affect themselves.
+/// The same words of caution also apply to other introspection features like
+/// [counters]($func/counter) and [state]($func/state).
+///
+/// ```example
+/// = Real
+/// #locate(loc => {
+/// let elems = query(heading, loc)
+/// let count = elems.len()
+/// count * [= Fake]
+/// })
+/// ```
+///
+/// ## Migration Hints { #migration-hints }
+/// The `before` and `after` arguments have been removed in version 0.3.0. You
+/// can now use flexible selector combinator methods instead. For example,
+/// `query(heading, before: loc)` becomes `query(heading.before(loc), loc)`.
+/// Please refer to the [selector documentation]($type/selector) for more
+/// details.
+///
+/// Display: Query
+/// Category: meta
+#[func]
+pub fn query(
+ /// Can be an element function like a `heading` or `figure`, a `{<label>}`
+ /// or a more complex selector like `{heading.where(level: 1)}`.
+ ///
+ /// Currently, only a subset of element functions is supported. Aside from
+ /// headings and figures, this includes equations, references and all
+ /// elements with an explicit label. As a result, you _can_ query for e.g.
+ /// [`strong`]($func/strong) elements, but you will find only those that
+ /// have an explicit label attached to them. This limitation will be
+ /// resolved in the future.
+ target: LocatableSelector,
+ /// Can be any location. Why is it required then? As noted before, Typst has
+ /// to evaluate parts of your code multiple times to determine the values of
+ /// all state. By only allowing this function within
+ /// [`locate`]($func/locate) calls, the amount of code that can depend on
+ /// the query's result is reduced. If you could call it directly at the top
+ /// level of a module, the evaluation of the whole module and its exports
+ /// could depend on the query's result.
+ location: Location,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> Array {
+ let _ = location;
+ let vec = vm.vt.introspector.query(&target.0);
+ vec.into_iter()
+ .map(|elem| Value::Content(elem.into_inner()))
+ .collect()
+}
+
+/// Turns a value into a selector. The following values are accepted:
+/// - An element function like a `heading` or `figure`.
+/// - A `{<label>}`.
+/// - A more complex selector like `{heading.where(level: 1)}`.
+///
+/// Display: Selector
+/// Category: meta
+#[func]
+pub fn selector(
+ /// Can be an element function like a `heading` or `figure`, a `{<label>}`
+ /// or a more complex selector like `{heading.where(level: 1)}`.
+ target: Selector,
+) -> Selector {
+ target
+}
diff --git a/crates/typst-library/src/meta/reference.rs b/crates/typst-library/src/meta/reference.rs
new file mode 100644
index 00000000..5bd04431
--- /dev/null
+++ b/crates/typst-library/src/meta/reference.rs
@@ -0,0 +1,276 @@
+use super::{BibliographyElem, CiteElem, Counter, Figurable, Numbering};
+use crate::meta::FootnoteElem;
+use crate::prelude::*;
+use crate::text::TextElem;
+
+/// A reference to a label or bibliography.
+///
+/// Produces a textual reference to a label. For example, a reference to a
+/// heading will yield an appropriate string such as "Section 1" for a reference
+/// to the first heading. The references are also links to the respective
+/// element. Reference syntax can also be used to [cite]($func/cite) from a
+/// bibliography.
+///
+/// Referenceable elements include [headings]($func/heading),
+/// [figures]($func/figure), [equations]($func/math.equation), and
+/// [footnotes]($func/footnote). To create a custom referenceable element like a
+/// theorem, you can create a figure of a custom [`kind`]($func/figure.kind) and
+/// write a show rule for it. In the future, there might be a more direct way to
+/// define a custom referenceable element.
+///
+/// If you just want to link to a labelled element and not get an automatic
+/// textual reference, consider using the [`link`]($func/link) function instead.
+///
+/// ## Example { #example }
+/// ```example
+/// #set heading(numbering: "1.")
+/// #set math.equation(numbering: "(1)")
+///
+/// = Introduction <intro>
+/// Recent developments in
+/// typesetting software have
+/// rekindled hope in previously
+/// frustrated researchers. @distress
+/// As shown in @results, we ...
+///
+/// = Results <results>
+/// We discuss our approach in
+/// comparison with others.
+///
+/// == Performance <perf>
+/// @slow demonstrates what slow
+/// software looks like.
+/// $ O(n) = 2^n $ <slow>
+///
+/// #bibliography("works.bib")
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: A reference to a label can be
+/// created by typing an `@` followed by the name of the label (e.g.
+/// `[= Introduction <intro>]` can be referenced by typing `[@intro]`).
+///
+/// To customize the supplement, add content in square brackets after the
+/// reference: `[@intro[Chapter]]`.
+///
+/// ## Customization { #customization }
+/// If you write a show rule for references, you can access the referenced
+/// element through the `element` field of the reference. The `element` may
+/// be `{none}` even if it exists if Typst hasn't discovered it yet, so you
+/// always need to handle that case in your code.
+///
+/// ```example
+/// #set heading(numbering: "1.")
+/// #set math.equation(numbering: "(1)")
+///
+/// #show ref: it => {
+/// let eq = math.equation
+/// let el = it.element
+/// if el != none and el.func() == eq {
+/// // Override equation references.
+/// numbering(
+/// el.numbering,
+/// ..counter(eq).at(el.location())
+/// )
+/// } else {
+/// // Other references as usual.
+/// it
+/// }
+/// }
+///
+/// = Beginnings <beginning>
+/// In @beginning we prove @pythagoras.
+/// $ a^2 + b^2 = c^2 $ <pythagoras>
+/// ```
+///
+/// Display: Reference
+/// Category: meta
+#[element(Synthesize, Locatable, Show)]
+pub struct RefElem {
+ /// The target label that should be referenced.
+ #[required]
+ pub target: Label,
+
+ /// A supplement for the reference.
+ ///
+ /// For references to headings or figures, this is added before the
+ /// referenced number. For citations, this can be used to add a page number.
+ ///
+ /// If a function is specified, it is passed the referenced element and
+ /// should return content.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.")
+ /// #set ref(supplement: it => {
+ /// if it.func() == heading {
+ /// "Chapter"
+ /// } else {
+ /// "Thing"
+ /// }
+ /// })
+ ///
+ /// = Introduction <intro>
+ /// In @intro, we see how to turn
+ /// Sections into Chapters. And
+ /// in @intro[Part], it is done
+ /// manually.
+ /// ```
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// A synthesized citation.
+ #[synthesized]
+ pub citation: Option<CiteElem>,
+
+ /// The referenced element.
+ #[synthesized]
+ pub element: Option<Content>,
+}
+
+impl Synthesize for RefElem {
+ fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ let citation = self.to_citation(vt, styles)?;
+ self.push_citation(Some(citation));
+ self.push_element(None);
+
+ let target = self.target();
+ if !BibliographyElem::has(vt, &target.0) {
+ if let Ok(elem) = vt.introspector.query_label(&target) {
+ self.push_element(Some(elem.into_inner()));
+ return Ok(());
+ }
+ }
+
+ Ok(())
+ }
+}
+
+impl Show for RefElem {
+ #[tracing::instrument(name = "RefElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(vt.delayed(|vt| {
+ let target = self.target();
+ let elem = vt.introspector.query_label(&self.target());
+ let span = self.span();
+
+ if BibliographyElem::has(vt, &target.0) {
+ if elem.is_ok() {
+ bail!(span, "label occurs in the document and its bibliography");
+ }
+
+ return Ok(self.to_citation(vt, styles)?.pack().spanned(span));
+ }
+
+ let elem = elem.at(span)?;
+
+ if elem.func() == FootnoteElem::func() {
+ return Ok(FootnoteElem::with_label(target).pack().spanned(span));
+ }
+
+ let refable = elem
+ .with::<dyn Refable>()
+ .ok_or_else(|| {
+ if elem.can::<dyn Figurable>() {
+ eco_format!(
+ "cannot reference {} directly, try putting it into a figure",
+ elem.func().name()
+ )
+ } else {
+ eco_format!("cannot reference {}", elem.func().name())
+ }
+ })
+ .at(span)?;
+
+ let numbering = refable
+ .numbering()
+ .ok_or_else(|| {
+ eco_format!(
+ "cannot reference {} without numbering",
+ elem.func().name()
+ )
+ })
+ .hint(eco_format!(
+ "you can enable heading numbering with `#set {}(numbering: \"1.\")`",
+ elem.func().name()
+ ))
+ .at(span)?;
+
+ let numbers = refable
+ .counter()
+ .at(vt, elem.location().unwrap())?
+ .display(vt, &numbering.trimmed())?;
+
+ let supplement = match self.supplement(styles) {
+ Smart::Auto => refable.supplement(),
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => {
+ supplement.resolve(vt, [(*elem).clone()])?
+ }
+ };
+
+ let mut content = numbers;
+ if !supplement.is_empty() {
+ content = supplement + TextElem::packed("\u{a0}") + content;
+ }
+
+ Ok(content.linked(Destination::Location(elem.location().unwrap())))
+ }))
+ }
+}
+
+impl RefElem {
+ /// Turn the reference into a citation.
+ pub fn to_citation(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<CiteElem> {
+ let mut elem = CiteElem::new(vec![self.target().0]);
+ elem.0.set_location(self.0.location().unwrap());
+ elem.synthesize(vt, styles)?;
+ elem.push_supplement(match self.supplement(styles) {
+ Smart::Custom(Some(Supplement::Content(content))) => Some(content),
+ _ => None,
+ });
+
+ Ok(elem)
+ }
+}
+
+/// Additional content for a reference.
+pub enum Supplement {
+ Content(Content),
+ Func(Func),
+}
+
+impl Supplement {
+ /// Tries to resolve the supplement into its content.
+ pub fn resolve<T: IntoValue>(
+ &self,
+ vt: &mut Vt,
+ args: impl IntoIterator<Item = T>,
+ ) -> SourceResult<Content> {
+ Ok(match self {
+ Supplement::Content(content) => content.clone(),
+ Supplement::Func(func) => func.call_vt(vt, args)?.display(),
+ })
+ }
+}
+
+cast! {
+ Supplement,
+ self => match self {
+ Self::Content(v) => v.into_value(),
+ Self::Func(v) => v.into_value(),
+ },
+ v: Content => Self::Content(v),
+ v: Func => Self::Func(v),
+}
+
+/// Marks an element as being able to be referenced. This is used to implement
+/// the `@ref` element.
+pub trait Refable {
+ /// The supplement, if not overridden by the reference.
+ fn supplement(&self) -> Content;
+
+ /// Returns the counter of this element.
+ fn counter(&self) -> Counter;
+
+ /// Returns the numbering of this element.
+ fn numbering(&self) -> Option<Numbering>;
+}
diff --git a/crates/typst-library/src/meta/state.rs b/crates/typst-library/src/meta/state.rs
new file mode 100644
index 00000000..aee53a29
--- /dev/null
+++ b/crates/typst-library/src/meta/state.rs
@@ -0,0 +1,440 @@
+use std::fmt::{self, Debug, Formatter, Write};
+
+use ecow::{eco_vec, EcoVec};
+use typst::eval::Tracer;
+use typst::model::DelayedErrors;
+
+use crate::prelude::*;
+
+/// Manages stateful parts of your document.
+///
+/// Let's say you have some computations in your document and want to remember
+/// the result of your last computation to use it in the next one. You might try
+/// something similar to the code below and expect it to output 10, 13, 26, and
+/// 21. However this **does not work** in Typst. If you test this code, you will
+/// see that Typst complains with the following error message: _Variables from
+/// outside the function are read-only and cannot be modified._
+///
+/// ```typ
+/// #let x = 0
+/// #let compute(expr) = {
+/// x = eval(
+/// expr.replace("x", str(x))
+/// )
+/// [New value is #x. ]
+/// }
+///
+/// #compute("10") \
+/// #compute("x + 3") \
+/// #compute("x * 2") \
+/// #compute("x - 5")
+/// ```
+///
+/// ## State and document markup { #state-and-markup }
+/// Why does it do that? Because, in general, this kind of computation with side
+/// effects is problematic in document markup and Typst is upfront about that.
+/// For the results to make sense, the computation must proceed in the same
+/// order in which the results will be laid out in the document. In our simple
+/// example, that's the case, but in general it might not be.
+///
+/// Let's look at a slightly different, but similar kind of state: The heading
+/// numbering. We want to increase the heading counter at each heading. Easy
+/// enough, right? Just add one. Well, it's not that simple. Consider the
+/// following example:
+///
+/// ```example
+/// #set heading(numbering: "1.")
+/// #let template(body) = [
+/// = Outline
+/// ...
+/// #body
+/// ]
+///
+/// #show: template
+///
+/// = Introduction
+/// ...
+/// ```
+///
+/// Here, Typst first processes the body of the document after the show rule,
+/// sees the `Introduction` heading, then passes the resulting content to the
+/// `template` function and only then sees the `Outline`. Just counting up would
+/// number the `Introduction` with `1` and the `Outline` with `2`.
+///
+/// ## Managing state in Typst { #state-in-typst }
+/// So what do we do instead? We use Typst's state management system. Calling
+/// the `state` function with an identifying string key and an optional initial
+/// value gives you a state value which exposes a few methods. The two most
+/// important ones are `display` and `update`:
+///
+/// - The `display` method shows the current value of the state. You can
+/// optionally give it a function that receives the value and formats it in
+/// some way.
+///
+/// - The `update` method modifies the state. You can give it any value. If
+/// given a non-function value, it sets the state to that value. If given a
+/// function, that function receives the previous state and has to return the
+/// new state.
+///
+/// Our initial example would now look like this:
+///
+/// ```example
+/// #let s = state("x", 0)
+/// #let compute(expr) = [
+/// #s.update(x =>
+/// eval(expr.replace("x", str(x)))
+/// )
+/// New value is #s.display().
+/// ]
+///
+/// #compute("10") \
+/// #compute("x + 3") \
+/// #compute("x * 2") \
+/// #compute("x - 5")
+/// ```
+///
+/// State managed by Typst is always updated in layout order, not in evaluation
+/// order. The `update` method returns content and its effect occurs at the
+/// position where the returned content is inserted into the document.
+///
+/// As a result, we can now also store some of the computations in
+/// variables, but they still show the correct results:
+///
+/// ```example
+/// >>> #let s = state("x", 0)
+/// >>> #let compute(expr) = [
+/// >>> #s.update(x =>
+/// >>> eval(expr.replace("x", str(x)))
+/// >>> )
+/// >>> New value is #s.display().
+/// >>> ]
+/// <<< ...
+///
+/// #let more = [
+/// #compute("x * 2") \
+/// #compute("x - 5")
+/// ]
+///
+/// #compute("10") \
+/// #compute("x + 3") \
+/// #more
+/// ```
+///
+/// This example is of course a bit silly, but in practice this is often exactly
+/// what you want! A good example are heading counters, which is why Typst's
+/// [counting system]($func/counter) is very similar to its state system.
+///
+/// ## Time Travel { #time-travel }
+/// By using Typst's state management system you also get time travel
+/// capabilities! By combining the state system with [`locate`]($func/locate)
+/// and [`query`]($func/query), we can find out what the value of the state will
+/// be at any position in the document from anywhere else. In particular, the
+/// `at` method gives us the value of the state at any location and the `final`
+/// methods gives us the value of the state at the end of the document.
+///
+/// ```example
+/// >>> #let s = state("x", 0)
+/// >>> #let compute(expr) = [
+/// >>> #s.update(x => {
+/// >>> eval(expr.replace("x", str(x)))
+/// >>> })
+/// >>> New value is #s.display().
+/// >>> ]
+/// <<< ...
+///
+/// Value at `<here>` is
+/// #locate(loc => s.at(
+/// query(<here>, loc)
+/// .first()
+/// .location()
+/// ))
+///
+/// #compute("10") \
+/// #compute("x + 3") \
+/// *Here.* <here> \
+/// #compute("x * 2") \
+/// #compute("x - 5")
+/// ```
+///
+/// ## A word of caution { #caution }
+/// To resolve the values of all states, Typst evaluates parts of your code
+/// multiple times. However, there is no guarantee that your state manipulation
+/// can actually be completely resolved.
+///
+/// For instance, if you generate state updates depending on the final value of
+/// a state, the results might never converge. The example below illustrates
+/// this. We initialize our state with `1` and then update it to its own final
+/// value plus 1. So it should be `2`, but then its final value is `2`, so it
+/// should be `3`, and so on. This example display `4` because Typst simply
+/// gives up after a few attempts.
+///
+/// ```example
+/// #let s = state("x", 1)
+/// #locate(loc => {
+/// s.update(s.final(loc) + 1)
+/// })
+/// #s.display()
+/// ```
+///
+/// In general, you should _typically_ not generate state updates from within
+/// `locate` calls or `display` calls of state or counters. Instead, pass a
+/// function to `update` that determines the value of the state based on its
+/// previous value.
+///
+/// ## Methods
+/// ### display()
+/// Displays the value of the state.
+///
+/// - format: function (positional)
+/// A function which receives the value of the state and can return arbitrary
+/// content which is then displayed. If this is omitted, the value is directly
+/// displayed.
+///
+/// - returns: content
+///
+/// ### update()
+/// Updates the value of the state.
+///
+/// The update will be in effect at the position where the returned content is
+/// inserted into the document. If you don't put the output into the document,
+/// nothing happens! This would be the case, for example, if you write
+/// `{let _ = state("key").update(7)}`. State updates are always applied in
+/// layout order and in that case, Typst wouldn't know when to update the state.
+///
+/// - value: any or function (positional, required)
+/// If given a non function-value, sets the state to that value. If given a
+/// function, that function receives the previous state and has to return the
+/// new state.
+///
+/// - returns: content
+///
+/// ### at()
+/// Gets the value of the state at the given location.
+///
+/// - location: location (positional, required)
+/// The location at which the state's value should be retrieved. A suitable
+/// location can be retrieved from [`locate`]($func/locate) or
+/// [`query`]($func/query).
+///
+/// - returns: any
+///
+/// ### final()
+/// Gets the value of the state at the end of the document.
+///
+/// - location: location (positional, required)
+/// Can be any location. Why is it required then? As noted before, Typst has
+/// to evaluate parts of your code multiple times to determine the values of
+/// all state. By only allowing this method within [`locate`]($func/locate)
+/// calls, the amount of code that can depend on the method's result is
+/// reduced. If you could call `final` directly at the top level of a module,
+/// the evaluation of the whole module and its exports could depend on the
+/// state's value.
+///
+/// - returns: any
+///
+/// Display: State
+/// Category: meta
+#[func]
+pub fn state(
+ /// The key that identifies this state.
+ key: Str,
+ /// The initial value of the state.
+ #[default]
+ init: Value,
+) -> State {
+ State { key, init }
+}
+
+/// A state.
+#[derive(Clone, PartialEq, Hash)]
+pub struct State {
+ /// The key that identifies the state.
+ key: Str,
+ /// The initial value of the state.
+ init: Value,
+}
+
+impl State {
+ /// Call a method on a state.
+ #[tracing::instrument(skip(vm))]
+ pub fn call_method(
+ self,
+ vm: &mut Vm,
+ method: &str,
+ mut args: Args,
+ span: Span,
+ ) -> SourceResult<Value> {
+ let value = match method {
+ "display" => self.display(args.eat()?).into_value(),
+ "at" => self.at(&mut vm.vt, args.expect("location")?)?,
+ "final" => self.final_(&mut vm.vt, args.expect("location")?)?,
+ "update" => self.update(args.expect("value or function")?).into_value(),
+ _ => bail!(span, "type state has no method `{}`", method),
+ };
+ args.finish()?;
+ Ok(value)
+ }
+
+ /// Display the current value of the state.
+ pub fn display(self, func: Option<Func>) -> Content {
+ DisplayElem::new(self, func).pack()
+ }
+
+ /// Get the value of the state at the given location.
+ #[tracing::instrument(skip(self, vt))]
+ pub fn at(self, vt: &mut Vt, location: Location) -> SourceResult<Value> {
+ let sequence = self.sequence(vt)?;
+ let offset = vt.introspector.query(&self.selector().before(location, true)).len();
+ Ok(sequence[offset].clone())
+ }
+
+ /// Get the value of the state at the final location.
+ #[tracing::instrument(skip(self, vt))]
+ pub fn final_(self, vt: &mut Vt, _: Location) -> SourceResult<Value> {
+ let sequence = self.sequence(vt)?;
+ Ok(sequence.last().unwrap().clone())
+ }
+
+ /// Produce content that performs a state update.
+ pub fn update(self, update: StateUpdate) -> Content {
+ UpdateElem::new(self, update).pack()
+ }
+
+ /// Produce the whole sequence of states.
+ ///
+ /// This has to happen just once for all states, cutting down the number
+ /// of state updates from quadratic to linear.
+ fn sequence(&self, vt: &mut Vt) -> SourceResult<EcoVec<Value>> {
+ self.sequence_impl(
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ )
+ }
+
+ /// Memoized implementation of `sequence`.
+ #[comemo::memoize]
+ fn sequence_impl(
+ &self,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ ) -> SourceResult<EcoVec<Value>> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let mut state = self.init.clone();
+ let mut stops = eco_vec![state.clone()];
+
+ for elem in introspector.query(&self.selector()) {
+ let elem = elem.to::<UpdateElem>().unwrap();
+ match elem.update() {
+ StateUpdate::Set(value) => state = value,
+ StateUpdate::Func(func) => state = func.call_vt(&mut vt, [state])?,
+ }
+ stops.push(state.clone());
+ }
+
+ Ok(stops)
+ }
+
+ /// The selector for this state's updates.
+ fn selector(&self) -> Selector {
+ Selector::Elem(UpdateElem::func(), Some(dict! { "state" => self.clone() }))
+ }
+}
+
+impl Debug for State {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("state(")?;
+ self.key.fmt(f)?;
+ f.write_str(", ")?;
+ self.init.fmt(f)?;
+ f.write_char(')')
+ }
+}
+
+cast! {
+ type State: "state",
+}
+
+/// An update to perform on a state.
+#[derive(Clone, PartialEq, Hash)]
+pub enum StateUpdate {
+ /// Set the state to the specified value.
+ Set(Value),
+ /// Apply the given function to the state.
+ Func(Func),
+}
+
+impl Debug for StateUpdate {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("..")
+ }
+}
+
+cast! {
+ type StateUpdate: "state update",
+ v: Func => Self::Func(v),
+ v: Value => Self::Set(v),
+}
+
+/// Executes a display of a state.
+///
+/// Display: State
+/// Category: special
+#[element(Locatable, Show)]
+struct DisplayElem {
+ /// The state.
+ #[required]
+ state: State,
+
+ /// The function to display the state with.
+ #[required]
+ func: Option<Func>,
+}
+
+impl Show for DisplayElem {
+ #[tracing::instrument(name = "DisplayElem::show", skip(self, vt))]
+ fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(vt.delayed(|vt| {
+ let location = self.0.location().unwrap();
+ let value = self.state().at(vt, location)?;
+ Ok(match self.func() {
+ Some(func) => func.call_vt(vt, [value])?.display(),
+ None => value.display(),
+ })
+ }))
+ }
+}
+
+/// Executes a display of a state.
+///
+/// Display: State
+/// Category: special
+#[element(Locatable, Show)]
+struct UpdateElem {
+ /// The state.
+ #[required]
+ state: State,
+
+ /// The update to perform on the state.
+ #[required]
+ update: StateUpdate,
+}
+
+impl Show for UpdateElem {
+ #[tracing::instrument(name = "UpdateElem::show")]
+ fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(Content::empty())
+ }
+}
diff --git a/crates/typst-library/src/prelude.rs b/crates/typst-library/src/prelude.rs
new file mode 100644
index 00000000..c720e770
--- /dev/null
+++ b/crates/typst-library/src/prelude.rs
@@ -0,0 +1,42 @@
+//! Helpful imports for creating library functionality.
+
+#[doc(no_inline)]
+pub use std::fmt::{self, Debug, Formatter};
+#[doc(no_inline)]
+pub use std::num::NonZeroUsize;
+
+#[doc(no_inline)]
+pub use comemo::{Track, Tracked, TrackedMut};
+#[doc(no_inline)]
+pub use ecow::{eco_format, EcoString};
+#[doc(no_inline)]
+pub use typst::diag::{bail, error, At, Hint, SourceResult, StrResult};
+#[doc(no_inline)]
+pub use typst::doc::*;
+#[doc(no_inline)]
+pub use typst::eval::{
+ array, cast, dict, format_str, func, Args, Array, AutoValue, Cast, Dict, FromValue,
+ Func, IntoValue, Never, NoneValue, Scope, Str, Symbol, Type, Value, Vm,
+};
+#[doc(no_inline)]
+pub use typst::file::FileId;
+#[doc(no_inline)]
+pub use typst::geom::*;
+#[doc(no_inline)]
+pub use typst::model::{
+ element, Behave, Behaviour, Construct, Content, ElemFunc, Element, Finalize, Fold,
+ Introspector, Label, Locatable, LocatableSelector, Location, Locator, MetaElem,
+ PlainText, Resolve, Selector, Set, Show, StyleChain, StyleVec, Styles, Synthesize,
+ Unlabellable, Vt,
+};
+#[doc(no_inline)]
+pub use typst::syntax::{Span, Spanned};
+#[doc(no_inline)]
+pub use typst::util::NonZeroExt;
+#[doc(no_inline)]
+pub use typst::World;
+
+#[doc(no_inline)]
+pub use crate::layout::{Fragment, Layout, Regions};
+#[doc(no_inline)]
+pub use crate::shared::{ContentExt, StylesExt};
diff --git a/crates/typst-library/src/shared/behave.rs b/crates/typst-library/src/shared/behave.rs
new file mode 100644
index 00000000..6a1aa127
--- /dev/null
+++ b/crates/typst-library/src/shared/behave.rs
@@ -0,0 +1,109 @@
+//! Element interaction.
+
+use typst::model::{Behave, Behaviour, Content, StyleChain, StyleVec, StyleVecBuilder};
+
+/// A wrapper around a [`StyleVecBuilder`] that allows elements to interact.
+#[derive(Debug)]
+pub struct BehavedBuilder<'a> {
+ /// The internal builder.
+ builder: StyleVecBuilder<'a, Content>,
+ /// Staged weak and ignorant elements that we can't yet commit to the
+ /// builder. The option is `Some(_)` for weak elements and `None` for
+ /// ignorant elements.
+ staged: Vec<(Content, Behaviour, StyleChain<'a>)>,
+ /// What the last non-ignorant item was.
+ last: Behaviour,
+}
+
+impl<'a> BehavedBuilder<'a> {
+ /// Create a new style-vec builder.
+ pub fn new() -> Self {
+ Self {
+ builder: StyleVecBuilder::new(),
+ staged: vec![],
+ last: Behaviour::Destructive,
+ }
+ }
+
+ /// Whether the builder is totally empty.
+ pub fn is_empty(&self) -> bool {
+ self.builder.is_empty() && self.staged.is_empty()
+ }
+
+ /// Whether the builder is empty except for some weak elements that will
+ /// probably collapse.
+ pub fn is_basically_empty(&self) -> bool {
+ self.builder.is_empty()
+ && self
+ .staged
+ .iter()
+ .all(|(_, behaviour, _)| matches!(behaviour, Behaviour::Weak(_)))
+ }
+
+ /// Push an item into the sequence.
+ pub fn push(&mut self, elem: Content, styles: StyleChain<'a>) {
+ let interaction = elem
+ .with::<dyn Behave>()
+ .map_or(Behaviour::Supportive, Behave::behaviour);
+
+ match interaction {
+ Behaviour::Weak(level) => {
+ if matches!(self.last, Behaviour::Weak(_)) {
+ let item = elem.with::<dyn Behave>().unwrap();
+ let i = self.staged.iter().position(|prev| {
+ let Behaviour::Weak(prev_level) = prev.1 else { return false };
+ level < prev_level
+ || (level == prev_level && item.larger(&prev.0))
+ });
+ let Some(i) = i else { return };
+ self.staged.remove(i);
+ }
+
+ if self.last != Behaviour::Destructive {
+ self.staged.push((elem, interaction, styles));
+ self.last = interaction;
+ }
+ }
+ Behaviour::Supportive => {
+ self.flush(true);
+ self.builder.push(elem, styles);
+ self.last = interaction;
+ }
+ Behaviour::Destructive => {
+ self.flush(false);
+ self.builder.push(elem, styles);
+ self.last = interaction;
+ }
+ Behaviour::Ignorant => {
+ self.staged.push((elem, interaction, styles));
+ }
+ }
+ }
+
+ /// Iterate over the contained elements.
+ pub fn elems(&self) -> impl DoubleEndedIterator<Item = &Content> {
+ self.builder.elems().chain(self.staged.iter().map(|(item, ..)| item))
+ }
+
+ /// Return the finish style vec and the common prefix chain.
+ pub fn finish(mut self) -> (StyleVec<Content>, StyleChain<'a>) {
+ self.flush(false);
+ self.builder.finish()
+ }
+
+ /// Push the staged elements, filtering out weak elements if `supportive` is
+ /// false.
+ fn flush(&mut self, supportive: bool) {
+ for (item, interaction, styles) in self.staged.drain(..) {
+ if supportive || interaction == Behaviour::Ignorant {
+ self.builder.push(item, styles);
+ }
+ }
+ }
+}
+
+impl<'a> Default for BehavedBuilder<'a> {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/crates/typst-library/src/shared/ext.rs b/crates/typst-library/src/shared/ext.rs
new file mode 100644
index 00000000..d7c80a30
--- /dev/null
+++ b/crates/typst-library/src/shared/ext.rs
@@ -0,0 +1,92 @@
+//! Extension traits.
+
+use crate::layout::{AlignElem, MoveElem, PadElem};
+use crate::prelude::*;
+use crate::text::{EmphElem, FontFamily, FontList, StrongElem, TextElem, UnderlineElem};
+
+/// Additional methods on content.
+pub trait ContentExt {
+ /// Make this content strong.
+ fn strong(self) -> Self;
+
+ /// Make this content emphasized.
+ fn emph(self) -> Self;
+
+ /// Underline this content.
+ fn underlined(self) -> Self;
+
+ /// Link the content somewhere.
+ fn linked(self, dest: Destination) -> Self;
+
+ /// Make the content linkable by `.linked(Destination::Location(loc))`.
+ ///
+ /// Should be used in combination with [`Location::variant`].
+ fn backlinked(self, loc: Location) -> Self;
+
+ /// Set alignments for this content.
+ fn aligned(self, aligns: Axes<Option<GenAlign>>) -> Self;
+
+ /// Pad this content at the sides.
+ fn padded(self, padding: Sides<Rel<Length>>) -> Self;
+
+ /// Transform this content's contents without affecting layout.
+ fn moved(self, delta: Axes<Rel<Length>>) -> Self;
+}
+
+impl ContentExt for Content {
+ fn strong(self) -> Self {
+ StrongElem::new(self).pack()
+ }
+
+ fn emph(self) -> Self {
+ EmphElem::new(self).pack()
+ }
+
+ fn underlined(self) -> Self {
+ UnderlineElem::new(self).pack()
+ }
+
+ fn linked(self, dest: Destination) -> Self {
+ self.styled(MetaElem::set_data(vec![Meta::Link(dest)]))
+ }
+
+ fn backlinked(self, loc: Location) -> Self {
+ let mut backlink = Content::empty();
+ backlink.set_location(loc);
+ self.styled(MetaElem::set_data(vec![Meta::Elem(backlink)]))
+ }
+
+ fn aligned(self, aligns: Axes<Option<GenAlign>>) -> Self {
+ self.styled(AlignElem::set_alignment(aligns))
+ }
+
+ fn padded(self, padding: Sides<Rel<Length>>) -> Self {
+ PadElem::new(self)
+ .with_left(padding.left)
+ .with_top(padding.top)
+ .with_right(padding.right)
+ .with_bottom(padding.bottom)
+ .pack()
+ }
+
+ fn moved(self, delta: Axes<Rel<Length>>) -> Self {
+ MoveElem::new(self).with_dx(delta.x).with_dy(delta.y).pack()
+ }
+}
+
+/// Additional methods for style lists.
+pub trait StylesExt {
+ /// Set a font family composed of a preferred family and existing families
+ /// from a style chain.
+ fn set_family(&mut self, preferred: FontFamily, existing: StyleChain);
+}
+
+impl StylesExt for Styles {
+ fn set_family(&mut self, preferred: FontFamily, existing: StyleChain) {
+ self.set(TextElem::set_font(FontList(
+ std::iter::once(preferred)
+ .chain(TextElem::font_in(existing))
+ .collect(),
+ )));
+ }
+}
diff --git a/crates/typst-library/src/shared/mod.rs b/crates/typst-library/src/shared/mod.rs
new file mode 100644
index 00000000..f54241cf
--- /dev/null
+++ b/crates/typst-library/src/shared/mod.rs
@@ -0,0 +1,7 @@
+//! Shared definitions for the standard library.
+
+mod behave;
+mod ext;
+
+pub use behave::*;
+pub use ext::*;
diff --git a/crates/typst-library/src/symbols/emoji.rs b/crates/typst-library/src/symbols/emoji.rs
new file mode 100644
index 00000000..44bc3e14
--- /dev/null
+++ b/crates/typst-library/src/symbols/emoji.rs
@@ -0,0 +1,1356 @@
+use typst::eval::{symbols, Module, Scope, Symbol};
+
+/// A module with all emoji.
+pub fn emoji() -> Module {
+ let mut scope = Scope::new();
+ for (name, symbol) in EMOJI {
+ scope.define(*name, symbol.clone());
+ }
+ Module::new("emoji").with_scope(scope)
+}
+
+/// A list of named emoji.
+const EMOJI: &[(&str, Symbol)] = symbols! {
+ abacus: '🧮',
+ abc: '🔤',
+ abcd: '🔡',
+ ABCD: '🔠',
+ accordion: '🪗',
+ aesculapius: '⚕',
+ airplane: [
+ '✈',
+ landing: '🛬',
+ small: '🛩',
+ takeoff: '🛫',
+ ],
+ alembic: '⚗',
+ alien: ['👽', monster: '👾'],
+ ambulance: '🚑',
+ amphora: '🏺',
+ anchor: '⚓',
+ anger: '💢',
+ ant: '🐜',
+ apple: [green: '🍏', red: '🍎'],
+ arm: [mech: '🦾', muscle: '💪', selfie: '🤳'],
+ arrow: [
+ r.filled: '➡',
+ r.hook: '↪',
+ r.soon: '🔜',
+ l.filled: '⬅',
+ l.hook: '↩',
+ l.back: '🔙',
+ l.end: '🔚',
+ t.filled: '⬆',
+ t.curve: '⤴',
+ t.top: '🔝',
+ b.filled: '⬇',
+ b.curve: '⤵',
+ l.r: '↔',
+ l.r.on: '🔛',
+ t.b: '↕',
+ bl: '↙',
+ br: '↘',
+ tl: '↖',
+ tr: '↗',
+ ],
+ arrows: [cycle: '🔄'],
+ ast: ['*', box: '✳'],
+ atm: '🏧',
+ atom: '⚛',
+ aubergine: '🍆',
+ avocado: '🥑',
+ axe: '🪓',
+ baby: ['👶', angel: '👼', box: '🚼'],
+ babybottle: '🍼',
+ backpack: '🎒',
+ bacon: '🥓',
+ badger: '🦡',
+ badminton: '🏸',
+ bagel: '🥯',
+ baggageclaim: '🛄',
+ baguette: '🥖',
+ balloon: '🎈',
+ ballot: [check: '☑'],
+ ballotbox: '🗳',
+ banana: '🍌',
+ banjo: '🪕',
+ bank: '🏦',
+ barberpole: '💈',
+ baseball: '⚾',
+ basecap: '🧢',
+ basket: '🧺',
+ basketball: ['⛹', ball: '🏀'],
+ bat: '🦇',
+ bathtub: ['🛀', foam: '🛁'],
+ battery: ['🔋', low: '🪫'],
+ beach: [palm: '🏝', umbrella: '🏖'],
+ beads: '📿',
+ beans: '🫘',
+ bear: '🐻',
+ beaver: '🦫',
+ bed: ['🛏', person: '🛌'],
+ bee: '🐝',
+ beer: ['🍺', clink: '🍻'],
+ beetle: ['🪲', lady: '🐞'],
+ bell: ['🔔', ding: '🛎', not: '🔕'],
+ bento: '🍱',
+ bicyclist: ['🚴', mountain: '🚵'],
+ bike: ['🚲', not: '🚳'],
+ bikini: '👙',
+ billiards: '🎱',
+ bin: '🗑',
+ biohazard: '☣',
+ bird: '🐦',
+ bison: '🦬',
+ blood: '🩸',
+ blouse: '👚',
+ blowfish: '🐡',
+ blueberries: '🫐',
+ boar: '🐗',
+ boat: [
+ sail: '⛵',
+ row: '🚣',
+ motor: '🛥',
+ speed: '🚤',
+ canoe: '🛶',
+ ],
+ bolt: '🔩',
+ bomb: '💣',
+ bone: '🦴',
+ book: [
+ red: '📕',
+ blue: '📘',
+ green: '📗',
+ orange: '📙',
+ spiral: '📒',
+ open: '📖',
+ ],
+ bookmark: '🔖',
+ books: '📚',
+ boomerang: '🪃',
+ bordercontrol: '🛂',
+ bouquet: '💐',
+ bow: '🏹',
+ bowl: [spoon: '🥣', steam: '🍜'],
+ bowling: '🎳',
+ boxing: '🥊',
+ boy: '👦',
+ brain: '🧠',
+ bread: '🍞',
+ brick: '🧱',
+ bride: '👰',
+ bridge: [fog: '🌁', night: '🌉'],
+ briefcase: '💼',
+ briefs: '🩲',
+ brightness: [high: '🔆', low: '🔅'],
+ broccoli: '🥦',
+ broom: '🧹',
+ brush: '🖌',
+ bubble: [
+ speech.r: '💬',
+ speech.l: '🗨',
+ thought: '💭',
+ anger.r: '🗯',
+ ],
+ bubbles: '🫧',
+ bubbletea: '🧋',
+ bucket: '🪣',
+ buffalo: [water: '🐃'],
+ bug: '🐛',
+ builder: '👷',
+ burger: '🍔',
+ burrito: '🌯',
+ bus: [
+ '🚌',
+ front: '🚍',
+ small: '🚐',
+ stop: '🚏',
+ trolley: '🚎',
+ ],
+ butter: '🧈',
+ butterfly: '🦋',
+ button: ['🔲', alt: '🔳', radio: '🔘'],
+ cabinet: [file: '🗄'],
+ cablecar: ['🚠', small: '🚡'],
+ cactus: '🌵',
+ cake: [
+ '🎂',
+ fish: '🍥',
+ moon: '🥮',
+ slice: '🍰',
+ ],
+ calendar: ['📅', spiral: '🗓', tearoff: '📆'],
+ camel: ['🐫', dromedar: '🐪'],
+ camera: [
+ '📷',
+ flash: '📸',
+ movie: '🎥',
+ movie.box: '🎦',
+ video: '📹',
+ ],
+ camping: '🏕',
+ can: '🥫',
+ candle: '🕯',
+ candy: '🍬',
+ cane: '🦯',
+ car: [
+ '🚗',
+ front: '🚘',
+ pickup: '🛻',
+ police: '🚓',
+ police.front: '🚔',
+ racing: '🏎',
+ rickshaw: '🛺',
+ suv: '🚙',
+ ],
+ card: [credit: '💳', id: '🪪'],
+ cardindex: '📇',
+ carrot: '🥕',
+ cart: '🛒',
+ cassette: '📼',
+ castle: [eu: '🏰', jp: '🏯'],
+ cat: [
+ '🐈',
+ face: '🐱',
+ face.angry: '😾',
+ face.cry: '😿',
+ face.heart: '😻',
+ face.joy: '😹',
+ face.kiss: '😽',
+ face.laugh: '😸',
+ face.shock: '🙀',
+ face.smile: '😺',
+ face.smirk: '😼',
+ ],
+ chain: '🔗',
+ chains: '⛓',
+ chair: '🪑',
+ champagne: '🍾',
+ chart: [
+ bar: '📊',
+ up: '📈',
+ down: '📉',
+ yen.up: '💹',
+ ],
+ checkmark: [heavy: '✔', box: '✅'],
+ cheese: '🧀',
+ cherries: '🍒',
+ chess: '♟',
+ chestnut: '🌰',
+ chicken: [
+ '🐔',
+ baby: '🐥',
+ baby.egg: '🐣',
+ baby.head: '🐤',
+ leg: '🍗',
+ male: '🐓',
+ ],
+ child: '🧒',
+ chipmunk: '🐿',
+ chocolate: '🍫',
+ chopsticks: '🥢',
+ church: ['⛪', love: '💒'],
+ cigarette: ['🚬', not: '🚭'],
+ circle: [
+ black: '⚫',
+ blue: '🔵',
+ brown: '🟤',
+ green: '🟢',
+ orange: '🟠',
+ purple: '🟣',
+ white: '⚪',
+ red: '🔴',
+ yellow: '🟡',
+ stroked: '⭕',
+ ],
+ circus: '🎪',
+ city: [
+ '🏙',
+ dusk: '🌆',
+ night: '🌃',
+ sunset: '🌇',
+ ],
+ clamp: '🗜',
+ clapperboard: '🎬',
+ climbing: '🧗',
+ clip: '📎',
+ clipboard: '📋',
+ clips: '🖇',
+ clock: [
+ one: '🕐',
+ one.thirty: '🕜',
+ two: '🕑',
+ two.thirty: '🕝',
+ three: '🕒',
+ three.thirty: '🕞',
+ four: '🕓',
+ four.thirty: '🕟',
+ five: '🕔',
+ five.thirty: '🕠',
+ six: '🕕',
+ six.thirty: '🕡',
+ seven: '🕖',
+ seven.thirty: '🕢',
+ eight: '🕗',
+ eight.thirty: '🕣',
+ nine: '🕘',
+ nine.thirty: '🕤',
+ ten: '🕙',
+ ten.thirty: '🕥',
+ eleven: '🕚',
+ eleven.thirty: '🕦',
+ twelve: '🕛',
+ twelve.thirty: '🕧',
+ alarm: '⏰',
+ old: '🕰',
+ timer: '⏲',
+ ],
+ cloud: [
+ '☁',
+ dust: '💨',
+ rain: '🌧',
+ snow: '🌨',
+ storm: '⛈',
+ sun: '⛅',
+ sun.hidden: '🌥',
+ sun.rain: '🌦',
+ thunder: '🌩',
+ ],
+ coat: ['🧥', lab: '🥼'],
+ cockroach: '🪳',
+ cocktail: [martini: '🍸', tropical: '🍹'],
+ coconut: '🥥',
+ coffee: '☕',
+ coffin: '⚰',
+ coin: '🪙',
+ comet: '☄',
+ compass: '🧭',
+ computer: '🖥',
+ computermouse: '🖱',
+ confetti: '🎊',
+ construction: '🚧',
+ controller: '🎮',
+ cookie: ['🍪', fortune: '🥠'],
+ cooking: '🍳',
+ cool: '🆒',
+ copyright: '©',
+ coral: '🪸',
+ corn: '🌽',
+ couch: '🛋',
+ couple: '💑',
+ cow: ['🐄', face: '🐮'],
+ crab: '🦀',
+ crane: '🏗',
+ crayon: '🖍',
+ cricket: '🦗',
+ cricketbat: '🏏',
+ crocodile: '🐊',
+ croissant: '🥐',
+ crossmark: ['❌', box: '❎'],
+ crown: '👑',
+ crutch: '🩼',
+ crystal: '🔮',
+ cucumber: '🥒',
+ cup: [straw: '🥤'],
+ cupcake: '🧁',
+ curling: '🥌',
+ curry: '🍛',
+ custard: '🍮',
+ customs: '🛃',
+ cutlery: '🍴',
+ cyclone: '🌀',
+ dancing: [man: '🕺', woman: '💃', women.bunny: '👯'],
+ darts: '🎯',
+ dash: [wave.double: '〰'],
+ deer: '🦌',
+ desert: '🏜',
+ detective: '🕵',
+ diamond: [
+ blue: '🔷',
+ blue.small: '🔹',
+ orange: '🔶',
+ orange.small: '🔸',
+ dot: '💠',
+ ],
+ die: '🎲',
+ dino: [pod: '🦕', rex: '🦖'],
+ disc: [cd: '💿', dvd: '📀', mini: '💽'],
+ discoball: '🪩',
+ diving: '🤿',
+ dodo: '🦤',
+ dog: [
+ '🐕',
+ face: '🐶',
+ guide: '🦮',
+ poodle: '🐩',
+ ],
+ dollar: '💲',
+ dolphin: '🐬',
+ donut: '🍩',
+ door: '🚪',
+ dove: [peace: '🕊'],
+ dragon: ['🐉', face: '🐲'],
+ dress: ['👗', kimono: '👘', sari: '🥻'],
+ drop: '💧',
+ drops: '💦',
+ drum: ['🥁', big: '🪘'],
+ duck: '🦆',
+ dumpling: '🥟',
+ eagle: '🦅',
+ ear: ['👂', aid: '🦻'],
+ egg: '🥚',
+ eighteen: [not: '🔞'],
+ elephant: '🐘',
+ elevator: '🛗',
+ elf: '🧝',
+ email: '📧',
+ excl: [
+ '❗',
+ white: '❕',
+ double: '‼',
+ quest: '⁉',
+ ],
+ explosion: '💥',
+ extinguisher: '🧯',
+ eye: '👁',
+ eyes: '👀',
+ face: [
+ grin: '😀',
+ angry: '😠',
+ angry.red: '😡',
+ anguish: '😧',
+ astonish: '😲',
+ bandage: '🤕',
+ beam: '😁',
+ blank: '😶',
+ clown: '🤡',
+ cold: '🥶',
+ concern: '😦',
+ cool: '😎',
+ cover: '🤭',
+ cowboy: '🤠',
+ cry: '😭',
+ devil.smile: '😈',
+ devil.frown: '👿',
+ diagonal: '🫤',
+ disguise: '🥸',
+ dizzy: '😵',
+ dotted: '🫥',
+ down: '😞',
+ down.sweat: '😓',
+ drool: '🤤',
+ explode: '🤯',
+ eyeroll: '🙄',
+ friendly: '☺',
+ fear: '😨',
+ fear.sweat: '😰',
+ fever: '🤒',
+ flush: '😳',
+ frown: '☹',
+ frown.slight: '🙁',
+ frust: '😣',
+ goofy: '🤪',
+ halo: '😇',
+ happy: '😊',
+ heart: '😍',
+ hearts: '🥰',
+ heat: '🥵',
+ hug: '🤗',
+ inv: '🙃',
+ joy: '😂',
+ kiss: '😗',
+ kiss.smile: '😙',
+ kiss.heart: '😘',
+ kiss.blush: '😚',
+ lick: '😋',
+ lie: '🤥',
+ mask: '😷',
+ meh: '😒',
+ melt: '🫠',
+ money: '🤑',
+ monocle: '🧐',
+ nausea: '🤢',
+ nerd: '🤓',
+ neutral: '😐',
+ open: '😃',
+ party: '🥳',
+ peek: '🫣',
+ plead: '🥺',
+ relief: '😌',
+ rofl: '🤣',
+ sad: '😔',
+ salute: '🫡',
+ shock: '😱',
+ shush: '🤫',
+ skeptic: '🤨',
+ sleep: '😴',
+ sleepy: '😪',
+ smile: '😄',
+ smile.slight: '🙂',
+ smile.sweat: '😅',
+ smile.tear: '🥲',
+ smirk: '😏',
+ sneeze: '🤧',
+ speak.not: '🫢',
+ squint: '😆',
+ stars: '🤩',
+ straight: '😑',
+ suffer: '😖',
+ surprise: '😯',
+ symbols: '🤬',
+ tear: '😢',
+ tear.relief: '😥',
+ tear.withheld: '🥹',
+ teeth: '😬',
+ think: '🤔',
+ tired: '😫',
+ tongue: '😛',
+ tongue.squint: '😝',
+ tongue.wink: '😜',
+ triumph: '😤',
+ unhappy: '😕',
+ vomit: '🤮',
+ weary: '😩',
+ wink: '😉',
+ woozy: '🥴',
+ worry: '😟',
+ wow: '😮',
+ yawn: '🥱',
+ zip: '🤐',
+ ],
+ factory: '🏭',
+ fairy: '🧚',
+ faith: [
+ christ: '✝',
+ dharma: '☸',
+ islam: '☪',
+ judaism: '✡',
+ menorah: '🕎',
+ om: '🕉',
+ orthodox: '☦',
+ peace: '☮',
+ star.dot: '🔯',
+ worship: '🛐',
+ yinyang: '☯',
+ ],
+ falafel: '🧆',
+ family: '👪',
+ fax: '📠',
+ feather: '🪶',
+ feeding: [breast: '🤱'],
+ fencing: '🤺',
+ ferriswheel: '🎡',
+ filebox: '🗃',
+ filedividers: '🗂',
+ film: '🎞',
+ finger: [
+ r: '👉',
+ l: '👈',
+ t: '👆',
+ t.alt: '☝',
+ b: '👇',
+ front: '🫵',
+ m: '🖕',
+ ],
+ fingers: [cross: '🤞', pinch: '🤌', snap: '🫰'],
+ fire: '🔥',
+ firecracker: '🧨',
+ fireengine: '🚒',
+ fireworks: '🎆',
+ fish: ['🐟', tropical: '🐠'],
+ fishing: '🎣',
+ fist: [
+ front: '👊',
+ r: '🤜',
+ l: '🤛',
+ raised: '✊',
+ ],
+ flag: [
+ black: '🏴',
+ white: '🏳',
+ goal: '🏁',
+ golf: '⛳',
+ red: '🚩',
+ ],
+ flags: [jp.crossed: '🎌'],
+ flamingo: '🦩',
+ flashlight: '🔦',
+ flatbread: '🫓',
+ fleur: '⚜',
+ floppy: '💾',
+ flower: [
+ hibiscus: '🌺',
+ lotus: '🪷',
+ pink: '🌸',
+ rose: '🌹',
+ sun: '🌻',
+ tulip: '🌷',
+ white: '💮',
+ wilted: '🥀',
+ yellow: '🌼',
+ ],
+ fly: '🪰',
+ fog: '🌫',
+ folder: ['📁', open: '📂'],
+ fondue: '🫕',
+ foot: '🦶',
+ football: ['⚽', am: '🏈'],
+ forex: '💱',
+ fountain: '⛲',
+ fox: '🦊',
+ free: '🆓',
+ fries: '🍟',
+ frisbee: '🥏',
+ frog: [face: '🐸'],
+ fuelpump: '⛽',
+ garlic: '🧄',
+ gear: '⚙',
+ gem: '💎',
+ genie: '🧞',
+ ghost: '👻',
+ giraffe: '🦒',
+ girl: '👧',
+ glass: [
+ clink: '🥂',
+ milk: '🥛',
+ pour: '🫗',
+ tumbler: '🥃',
+ ],
+ glasses: ['👓', sun: '🕶'],
+ globe: [
+ am: '🌎',
+ as.au: '🌏',
+ eu.af: '🌍',
+ meridian: '🌐',
+ ],
+ gloves: '🧤',
+ goal: '🥅',
+ goat: '🐐',
+ goggles: '🥽',
+ golfing: '🏌',
+ gorilla: '🦍',
+ grapes: '🍇',
+ guard: [man: '💂'],
+ guitar: '🎸',
+ gymnastics: '🤸',
+ haircut: '💇',
+ hammer: ['🔨', pick: '⚒', wrench: '🛠'],
+ hamsa: '🪬',
+ hamster: [face: '🐹'],
+ hand: [
+ raised: '✋',
+ raised.alt: '🤚',
+ r: '🫱',
+ l: '🫲',
+ t: '🫴',
+ b: '🫳',
+ ok: '👌',
+ call: '🤙',
+ love: '🤟',
+ part: '🖖',
+ peace: '✌',
+ pinch: '🤏',
+ rock: '🤘',
+ splay: '🖐',
+ wave: '👋',
+ write: '✍',
+ ],
+ handbag: '👜',
+ handball: '🤾',
+ handholding: [man.man: '👬', woman.man: '👫', woman.woman: '👭'],
+ hands: [
+ folded: '🙏',
+ palms: '🤲',
+ clap: '👏',
+ heart: '🫶',
+ open: '👐',
+ raised: '🙌',
+ shake: '🤝',
+ ],
+ hash: '#',
+ hat: [ribbon: '👒', top: '🎩'],
+ headphone: '🎧',
+ heart: [
+ '❤',
+ arrow: '💘',
+ beat: '💓',
+ black: '🖤',
+ blue: '💙',
+ box: '💟',
+ broken: '💔',
+ brown: '🤎',
+ double: '💕',
+ excl: '❣',
+ green: '💚',
+ grow: '💗',
+ orange: '🧡',
+ purple: '💜',
+ real: '🫀',
+ revolve: '💞',
+ ribbon: '💝',
+ spark: '💖',
+ white: '🤍',
+ yellow: '💛',
+ ],
+ hedgehog: '🦔',
+ helicopter: '🚁',
+ helix: '🧬',
+ helmet: [cross: '⛑', military: '🪖'],
+ hippo: '🦛',
+ hockey: '🏑',
+ hole: '🕳',
+ honey: '🍯',
+ hongbao: '🧧',
+ hook: '🪝',
+ horn: [postal: '📯'],
+ horse: [
+ '🐎',
+ carousel: '🎠',
+ face: '🐴',
+ race: '🏇',
+ ],
+ hospital: '🏥',
+ hotdog: '🌭',
+ hotel: ['🏨', love: '🏩'],
+ hotspring: '♨',
+ hourglass: ['⌛', flow: '⏳'],
+ house: [
+ '🏠',
+ derelict: '🏚',
+ garden: '🏡',
+ multiple: '🏘',
+ ],
+ hundred: '💯',
+ hut: '🛖',
+ ice: '🧊',
+ icecream: ['🍨', shaved: '🍧', soft: '🍦'],
+ icehockey: '🏒',
+ id: '🆔',
+ info: 'ℹ',
+ izakaya: '🏮',
+ jar: '🫙',
+ jeans: '👖',
+ jigsaw: '🧩',
+ joystick: '🕹',
+ juggling: '🤹',
+ juice: '🧃',
+ kaaba: '🕋',
+ kadomatsu: '🎍',
+ kangaroo: '🦘',
+ gachi: '🈷',
+ go: '🈴',
+ hi: '㊙',
+ ka: '🉑',
+ kachi: '🈹',
+ kara: '🈳',
+ kon: '🈲',
+ man: '🈵',
+ muryo: '🈚',
+ shin: '🈸',
+ shuku: '㊗',
+ toku: '🉐',
+ yo: '🈺',
+ yubi: '🈯',
+ yuryo: '🈶',
+ koko: '🈁',
+ sa: '🈂',
+ kebab: '🥙',
+ key: ['🔑', old: '🗝'],
+ keyboard: '⌨',
+ kiss: '💏',
+ kissmark: '💋',
+ kite: '🪁',
+ kiwi: '🥝',
+ knife: ['🔪', dagger: '🗡'],
+ knot: '🪢',
+ koala: '🐨',
+ koinobori: '🎏',
+ label: '🏷',
+ lacrosse: '🥍',
+ ladder: '🪜',
+ lamp: [diya: '🪔'],
+ laptop: '💻',
+ a: '🅰',
+ ab: '🆎',
+ b: '🅱',
+ cl: '🆑',
+ o: '🅾',
+ leaf: [
+ clover.three: '☘',
+ clover.four: '🍀',
+ fall: '🍂',
+ herb: '🌿',
+ maple: '🍁',
+ wind: '🍃',
+ ],
+ leftluggage: '🛅',
+ leg: ['🦵', mech: '🦿'],
+ lemon: '🍋',
+ leopard: '🐆',
+ letter: [love: '💌'],
+ liberty: '🗽',
+ lightbulb: '💡',
+ lightning: '⚡',
+ lion: '🦁',
+ lipstick: '💄',
+ litter: ['🚮', not: '🚯'],
+ lizard: '🦎',
+ llama: '🦙',
+ lobster: '🦞',
+ lock: [
+ '🔒',
+ key: '🔐',
+ open: '🔓',
+ pen: '🔏',
+ ],
+ lollipop: '🍭',
+ lotion: '🧴',
+ luggage: '🧳',
+ lungs: '🫁',
+ mage: '🧙',
+ magnet: '🧲',
+ magnify: [r: '🔎', l: '🔍'],
+ mahjong: [dragon.red: '🀄'],
+ mail: ['✉', arrow: '📩'],
+ mailbox: [
+ closed.empty: '📪',
+ closed.full: '📫',
+ open.empty: '📭',
+ open.full: '📬',
+ ],
+ mammoth: '🦣',
+ man: [
+ '👨',
+ box: '🚹',
+ crown: '🤴',
+ guapimao: '👲',
+ levitate: '🕴',
+ old: '👴',
+ pregnant: '🫃',
+ turban: '👳',
+ tuxedo: '🤵',
+ ],
+ mango: '🥭',
+ map: [world: '🗺', jp: '🗾'],
+ martialarts: '🥋',
+ masks: '🎭',
+ mate: '🧉',
+ matryoshka: '🪆',
+ meat: ['🥩', bone: '🍖'],
+ medal: [
+ first: '🥇',
+ second: '🥈',
+ third: '🥉',
+ sports: '🏅',
+ military: '🎖',
+ ],
+ megaphone: ['📢', simple: '📣'],
+ melon: '🍈',
+ merperson: '🧜',
+ metro: 'Ⓜ',
+ microbe: '🦠',
+ microphone: ['🎤', studio: '🎙'],
+ microscope: '🔬',
+ milkyway: '🌌',
+ mirror: '🪞',
+ mixer: '🎛',
+ money: [
+ bag: '💰',
+ dollar: '💵',
+ euro: '💶',
+ pound: '💷',
+ yen: '💴',
+ wings: '💸',
+ ],
+ monkey: [
+ '🐒',
+ face: '🐵',
+ hear.not: '🙉',
+ see.not: '🙈',
+ speak.not: '🙊',
+ ],
+ moon: [
+ crescent: '🌙',
+ full: '🌕',
+ full.face: '🌝',
+ new: '🌑',
+ new.face: '🌚',
+ wane.one: '🌖',
+ wane.two: '🌗',
+ wane.three.face: '🌜',
+ wane.three: '🌘',
+ wax.one: '🌒',
+ wax.two: '🌓',
+ wax.two.face: '🌛',
+ wax.three: '🌔',
+ ],
+ mortarboard: '🎓',
+ mosque: '🕌',
+ mosquito: '🦟',
+ motorcycle: '🏍',
+ motorway: '🛣',
+ mountain: [
+ '⛰',
+ fuji: '🗻',
+ snow: '🏔',
+ sunrise: '🌄',
+ ],
+ mouse: ['🐁', face: '🐭'],
+ mousetrap: '🪤',
+ mouth: ['👄', bite: '🫦'],
+ moyai: '🗿',
+ museum: '🏛',
+ mushroom: '🍄',
+ musicalscore: '🎼',
+ nails: [polish: '💅'],
+ namebadge: '📛',
+ nazar: '🧿',
+ necktie: '👔',
+ needle: '🪡',
+ nest: [empty: '🪹', eggs: '🪺'],
+ new: '🆕',
+ newspaper: ['📰', rolled: '🗞'],
+ ng: '🆖',
+ ningyo: '🎎',
+ ninja: '🥷',
+ noentry: '⛔',
+ nose: '👃',
+ notebook: ['📓', deco: '📔'],
+ notepad: '🗒',
+ notes: ['🎵', triple: '🎶'],
+ numbers: '🔢',
+ octopus: '🐙',
+ office: '🏢',
+ oil: '🛢',
+ ok: '🆗',
+ olive: '🫒',
+ oni: '👹',
+ onion: '🧅',
+ orangutan: '🦧',
+ otter: '🦦',
+ owl: '🦉',
+ ox: '🐂',
+ oyster: '🦪',
+ package: '📦',
+ paella: '🥘',
+ page: ['📄', curl: '📃', pencil: '📝'],
+ pager: '📟',
+ pages: [tabs: '📑'],
+ painting: '🖼',
+ palette: '🎨',
+ pancakes: '🥞',
+ panda: '🐼',
+ parachute: '🪂',
+ park: '🏞',
+ parking: '🅿',
+ parrot: '🦜',
+ partalteration: '〽',
+ party: '🎉',
+ peach: '🍑',
+ peacock: '🦚',
+ peanuts: '🥜',
+ pear: '🍐',
+ pedestrian: ['🚶', not: '🚷'],
+ pen: [ball: '🖊', fountain: '🖋'],
+ pencil: '✏',
+ penguin: '🐧',
+ pepper: ['🫑', hot: '🌶'],
+ person: [
+ '🧑',
+ angry: '🙎',
+ beard: '🧔',
+ blonde: '👱',
+ bow: '🙇',
+ crown: '🫅',
+ deaf: '🧏',
+ facepalm: '🤦',
+ frown: '🙍',
+ hijab: '🧕',
+ kneel: '🧎',
+ lotus: '🧘',
+ massage: '💆',
+ no: '🙅',
+ ok: '🙆',
+ old: '🧓',
+ pregnant: '🫄',
+ raise: '🙋',
+ sassy: '💁',
+ shrug: '🤷',
+ stand: '🧍',
+ steam: '🧖',
+ ],
+ petri: '🧫',
+ phone: [
+ '📱',
+ arrow: '📲',
+ classic: '☎',
+ not: '📵',
+ off: '📴',
+ receiver: '📞',
+ signal: '📶',
+ vibrate: '📳',
+ ],
+ piano: '🎹',
+ pick: '⛏',
+ pie: '🥧',
+ pig: ['🐖', face: '🐷', nose: '🐽'],
+ pill: '💊',
+ pin: ['📌', round: '📍'],
+ pinata: '🪅',
+ pineapple: '🍍',
+ pingpong: '🏓',
+ pistol: '🔫',
+ pizza: '🍕',
+ placard: '🪧',
+ planet: '🪐',
+ plant: '🪴',
+ plaster: '🩹',
+ plate: [cutlery: '🍽'],
+ playback: [
+ down: '⏬',
+ eject: '⏏',
+ forward: '⏩',
+ pause: '⏸',
+ record: '⏺',
+ repeat: '🔁',
+ repeat.once: '🔂',
+ repeat.v: '🔃',
+ restart: '⏮',
+ rewind: '⏪',
+ shuffle: '🔀',
+ skip: '⏭',
+ stop: '⏹',
+ toggle: '⏯',
+ up: '⏫',
+ ],
+ playingcard: [flower: '🎴', joker: '🃏'],
+ plunger: '🪠',
+ policeofficer: '👮',
+ poo: '💩',
+ popcorn: '🍿',
+ post: [eu: '🏤', jp: '🏣'],
+ postbox: '📮',
+ potato: ['🥔', sweet: '🍠'],
+ pouch: '👝',
+ powerplug: '🔌',
+ present: '🎁',
+ pretzel: '🥨',
+ printer: '🖨',
+ prints: [foot: '👣', paw: '🐾'],
+ prohibited: '🚫',
+ projector: '📽',
+ pumpkin: [lantern: '🎃'],
+ purse: '👛',
+ quest: ['❓', white: '❔'],
+ rabbit: ['🐇', face: '🐰'],
+ raccoon: '🦝',
+ radio: '📻',
+ radioactive: '☢',
+ railway: '🛤',
+ rainbow: '🌈',
+ ram: '🐏',
+ rat: '🐀',
+ razor: '🪒',
+ receipt: '🧾',
+ recycling: '♻',
+ reg: '®',
+ restroom: '🚻',
+ rhino: '🦏',
+ ribbon: ['🎀', remind: '🎗'],
+ rice: [
+ '🍚',
+ cracker: '🍘',
+ ear: '🌾',
+ onigiri: '🍙',
+ ],
+ ring: '💍',
+ ringbuoy: '🛟',
+ robot: '🤖',
+ rock: '🪨',
+ rocket: '🚀',
+ rollercoaster: '🎢',
+ rosette: '🏵',
+ rugby: '🏉',
+ ruler: ['📏', triangle: '📐'],
+ running: '🏃',
+ safetypin: '🧷',
+ safetyvest: '🦺',
+ sake: '🍶',
+ salad: '🥗',
+ salt: '🧂',
+ sandwich: '🥪',
+ santa: [man: '🎅', woman: '🤶'],
+ satdish: '📡',
+ satellite: '🛰',
+ saw: '🪚',
+ saxophone: '🎷',
+ scales: '⚖',
+ scarf: '🧣',
+ school: '🏫',
+ scissors: '✂',
+ scooter: ['🛴', motor: '🛵'],
+ scorpion: '🦂',
+ screwdriver: '🪛',
+ scroll: '📜',
+ seal: '🦭',
+ seat: '💺',
+ seedling: '🌱',
+ shark: '🦈',
+ sheep: '🐑',
+ shell: [spiral: '🐚'],
+ shield: '🛡',
+ ship: ['🚢', cruise: '🛳', ferry: '⛴'],
+ shirt: [sports: '🎽', t: '👕'],
+ shoe: [
+ '👞',
+ ballet: '🩰',
+ flat: '🥿',
+ heel: '👠',
+ hike: '🥾',
+ ice: '⛸',
+ roller: '🛼',
+ sandal.heel: '👡',
+ ski: '🎿',
+ sneaker: '👟',
+ tall: '👢',
+ thong: '🩴',
+ ],
+ shopping: '🛍',
+ shorts: '🩳',
+ shoshinsha: '🔰',
+ shower: '🚿',
+ shrimp: ['🦐', fried: '🍤'],
+ shrine: '⛩',
+ sign: [crossing: '🚸', stop: '🛑'],
+ silhouette: [
+ '👤',
+ double: '👥',
+ hug: '🫂',
+ speak: '🗣',
+ ],
+ siren: '🚨',
+ skateboard: '🛹',
+ skewer: [dango: '🍡', oden: '🍢'],
+ skiing: '⛷',
+ skull: ['💀', bones: '☠'],
+ skunk: '🦨',
+ sled: '🛷',
+ slide: '🛝',
+ slider: '🎚',
+ sloth: '🦥',
+ slots: '🎰',
+ snail: '🐌',
+ snake: '🐍',
+ snowboarding: '🏂',
+ snowflake: '❄',
+ snowman: ['⛄', snow: '☃'],
+ soap: '🧼',
+ socks: '🧦',
+ softball: '🥎',
+ sos: '🆘',
+ soup: '🍲',
+ spaghetti: '🍝',
+ sparkle: [box: '❇'],
+ sparkler: '🎇',
+ sparkles: '✨',
+ speaker: [
+ '🔈',
+ not: '🔇',
+ wave: '🔉',
+ waves: '🔊',
+ ],
+ spider: '🕷',
+ spiderweb: '🕸',
+ spinach: '🥬',
+ sponge: '🧽',
+ spoon: '🥄',
+ square: [
+ black: '⬛',
+ black.tiny: '▪',
+ black.small: '◾',
+ black.medium: '◼',
+ white: '⬜',
+ white.tiny: '▫',
+ white.small: '◽',
+ white.medium: '◻',
+ blue: '🟦',
+ brown: '🟫',
+ green: '🟩',
+ orange: '🟧',
+ purple: '🟪',
+ red: '🟥',
+ yellow: '🟨',
+ ],
+ squid: '🦑',
+ stadium: '🏟',
+ star: [
+ '⭐',
+ arc: '💫',
+ box: '✴',
+ glow: '🌟',
+ shoot: '🌠',
+ ],
+ stethoscope: '🩺',
+ store: [big: '🏬', small: '🏪'],
+ strawberry: '🍓',
+ suit: [
+ club: '♣',
+ diamond: '♦',
+ heart: '♥',
+ spade: '♠',
+ ],
+ sun: ['☀', cloud: '🌤', face: '🌞'],
+ sunrise: '🌅',
+ superhero: '🦸',
+ supervillain: '🦹',
+ surfing: '🏄',
+ sushi: '🍣',
+ swan: '🦢',
+ swimming: '🏊',
+ swimsuit: '🩱',
+ swords: '⚔',
+ symbols: '🔣',
+ synagogue: '🕍',
+ syringe: '💉',
+ taco: '🌮',
+ takeout: '🥡',
+ tamale: '🫔',
+ tanabata: '🎋',
+ tangerine: '🍊',
+ tap: ['🚰', not: '🚱'],
+ taxi: ['🚕', front: '🚖'],
+ teacup: '🍵',
+ teapot: '🫖',
+ teddy: '🧸',
+ telescope: '🔭',
+ temple: '🛕',
+ ten: '🔟',
+ tengu: '👺',
+ tennis: '🎾',
+ tent: '⛺',
+ testtube: '🧪',
+ thermometer: '🌡',
+ thread: '🧵',
+ thumb: [up: '👍', down: '👎'],
+ ticket: [event: '🎟', travel: '🎫'],
+ tiger: ['🐅', face: '🐯'],
+ tm: '™',
+ toilet: '🚽',
+ toiletpaper: '🧻',
+ tomato: '🍅',
+ tombstone: '🪦',
+ tongue: '👅',
+ toolbox: '🧰',
+ tooth: '🦷',
+ toothbrush: '🪥',
+ tornado: '🌪',
+ tower: [tokyo: '🗼'],
+ trackball: '🖲',
+ tractor: '🚜',
+ trafficlight: [v: '🚦', h: '🚥'],
+ train: [
+ '🚆',
+ car: '🚃',
+ light: '🚈',
+ metro: '🚇',
+ mono: '🚝',
+ mountain: '🚞',
+ speed: '🚄',
+ speed.bullet: '🚅',
+ steam: '🚂',
+ stop: '🚉',
+ suspend: '🚟',
+ tram: '🚊',
+ tram.car: '🚋',
+ ],
+ transgender: '⚧',
+ tray: [inbox: '📥', mail: '📨', outbox: '📤'],
+ tree: [
+ deciduous: '🌳',
+ evergreen: '🌲',
+ palm: '🌴',
+ xmas: '🎄',
+ ],
+ triangle: [
+ r: '▶',
+ l: '◀',
+ t: '🔼',
+ b: '🔽',
+ t.red: '🔺',
+ b.red: '🔻',
+ ],
+ trident: '🔱',
+ troll: '🧌',
+ trophy: '🏆',
+ truck: ['🚚', trailer: '🚛'],
+ trumpet: '🎺',
+ tsukimi: '🎑',
+ turkey: '🦃',
+ turtle: '🐢',
+ tv: '📺',
+ ufo: '🛸',
+ umbrella: [
+ open: '☂',
+ closed: '🌂',
+ rain: '☔',
+ sun: '⛱',
+ ],
+ unicorn: '🦄',
+ unknown: '🦳',
+ up: '🆙',
+ urn: '⚱',
+ vampire: '🧛',
+ violin: '🎻',
+ volcano: '🌋',
+ volleyball: '🏐',
+ vs: '🆚',
+ waffle: '🧇',
+ wand: '🪄',
+ warning: '⚠',
+ watch: ['⌚', stop: '⏱'],
+ watermelon: '🍉',
+ waterpolo: '🤽',
+ wave: '🌊',
+ wc: '🚾',
+ weightlifting: '🏋',
+ whale: ['🐋', spout: '🐳'],
+ wheel: '🛞',
+ wheelchair: ['🦽', box: '♿', motor: '🦼'],
+ wind: '🌬',
+ windchime: '🎐',
+ window: '🪟',
+ wine: '🍷',
+ wolf: '🐺',
+ woman: [
+ '👩',
+ box: '🚺',
+ crown: '👸',
+ old: '👵',
+ pregnant: '🤰',
+ ],
+ wood: '🪵',
+ worm: '🪱',
+ wrench: '🔧',
+ wrestling: '🤼',
+ xray: '🩻',
+ yarn: '🧶',
+ yoyo: '🪀',
+ zebra: '🦓',
+ zodiac: [
+ aquarius: '♒',
+ aries: '♈',
+ cancer: '♋',
+ capri: '♑',
+ gemini: '♊',
+ leo: '♌',
+ libra: '♎',
+ ophi: '⛎',
+ pisces: '♓',
+ sagit: '♐',
+ scorpio: '♏',
+ taurus: '♉',
+ virgo: '♍',
+ ],
+ zombie: '🧟',
+ zzz: '💤',
+};
diff --git a/crates/typst-library/src/symbols/mod.rs b/crates/typst-library/src/symbols/mod.rs
new file mode 100644
index 00000000..5036aa11
--- /dev/null
+++ b/crates/typst-library/src/symbols/mod.rs
@@ -0,0 +1,15 @@
+//! Modifiable symbols.
+
+mod emoji;
+mod sym;
+
+pub use emoji::*;
+pub use sym::*;
+
+use crate::prelude::*;
+
+/// Hook up all symbol definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.define("sym", sym());
+ global.define("emoji", emoji());
+}
diff --git a/crates/typst-library/src/symbols/sym.rs b/crates/typst-library/src/symbols/sym.rs
new file mode 100644
index 00000000..8b1b0b39
--- /dev/null
+++ b/crates/typst-library/src/symbols/sym.rs
@@ -0,0 +1,842 @@
+use typst::eval::{symbols, Module, Scope, Symbol};
+
+/// A module with all general symbols.
+pub fn sym() -> Module {
+ let mut scope = Scope::new();
+ for (name, symbol) in SYM {
+ scope.define(*name, symbol.clone());
+ }
+ Module::new("sym").with_scope(scope)
+}
+
+/// The list of general symbols.
+pub(crate) const SYM: &[(&str, Symbol)] = symbols! {
+ // Control.
+ wj: '\u{2060}',
+ zwj: '\u{200D}',
+ zwnj: '\u{200C}',
+ zws: '\u{200B}',
+
+ // Spaces.
+ space: [
+ ' ',
+ nobreak: '\u{A0}',
+ en: '\u{2002}',
+ quad: '\u{2003}',
+ third: '\u{2004}',
+ quarter: '\u{2005}',
+ sixth: '\u{2006}',
+ med: '\u{205F}',
+ fig: '\u{2007}',
+ punct: '\u{2008}',
+ thin: '\u{2009}',
+ hair: '\u{200A}',
+ ],
+
+ // Delimiters.
+ paren: [l: '(', r: ')', t: '⏜', b: '⏝'],
+ brace: [l: '{', r: '}', t: '⏞', b: '⏟'],
+ bracket: [l: '[', l.double: '⟦', r: ']', r.double: '⟧', t: '⎴', b: '⎵'],
+ turtle: [l: '〔', r: '〕', t: '⏠', b: '⏡'],
+ bar: [v: '|', v.double: '‖', v.triple: '⦀', v.broken: '¦', v.circle: '⦶', h: '―'],
+ fence: [l: '⧘', l.double: '⧚', r: '⧙', r.double: '⧛', dotted: '⦙'],
+ angle: [
+ '∠',
+ l: '⟨',
+ r: '⟩',
+ l.double: '《',
+ r.double: '》',
+ acute: '⦟',
+ arc: '∡',
+ arc.rev: '⦛',
+ rev: '⦣',
+ right: '∟',
+ right.rev: '⯾',
+ right.arc: '⊾',
+ right.dot: '⦝',
+ right.sq: '⦜',
+ spatial: '⟀',
+ spheric: '∢',
+ spheric.rev: '⦠',
+ spheric.top: '⦡',
+ ],
+
+ // Punctuation.
+ amp: ['&', inv: '⅋'],
+ ast: [
+ op: '∗',
+ basic: '*',
+ low: '⁎',
+ double: '⁑',
+ triple: '⁂',
+ small: '﹡',
+ circle: '⊛',
+ sq: '⧆',
+ ],
+ at: '@',
+ backslash: ['\\', circle: '⦸', not: '⧷'],
+ co: '℅',
+ colon: [':', eq: '≔', double.eq: '⩴'],
+ comma: ',',
+ dagger: ['†', double: '‡'],
+ dash: [
+ en: '–',
+ em: '—',
+ fig: '‒',
+ wave: '〜',
+ colon: '∹',
+ circle: '⊝',
+ wave.double: '〰',
+ ],
+ dot: [
+ op: '⋅',
+ basic: '.',
+ c: '·',
+ circle: '⊙',
+ circle.big: '⨀',
+ square: '⊡',
+ double: '¨',
+ triple: '\u{20db}',
+ quad: '\u{20dc}',
+ ],
+ excl: ['!', double: '‼', inv: '¡', quest: '⁉'],
+ quest: ['?', double: '⁇', excl: '⁈', inv: '¿'],
+ interrobang: '‽',
+ hash: '#',
+ hyph: ['‐', minus: '\u{2D}', nobreak: '\u{2011}', point: '‧', soft: '\u{ad}'],
+ percent: '%',
+ copyright: ['©', sound: '℗'],
+ permille: '‰',
+ pilcrow: ['¶', rev: '⁋'],
+ section: '§',
+ semi: [';', rev: '⁏'],
+ slash: ['/', double: '⫽', triple: '⫻', big: '\u{29f8}'],
+ dots: [h.c: '⋯', h: '…', v: '⋮', down: '⋱', up: '⋰'],
+ tilde: [
+ op: '∼',
+ basic: '~',
+ eq: '≃',
+ eq.not: '≄',
+ eq.rev: '⋍',
+ eqq: '≅',
+ eqq.not: '≇',
+ neqq: '≆',
+ not: '≁',
+ rev: '∽',
+ rev.eqq: '≌',
+ triple: '≋',
+ ],
+
+ // Accents, quotes, and primes.
+ acute: ['´', double: '˝'],
+ breve: '˘',
+ caret: '‸',
+ caron: 'ˇ',
+ hat: '^',
+ diaer: '¨',
+ grave: '`',
+ macron: '¯',
+ quote: [
+ double: '"',
+ single: '\'',
+ l.double: '“',
+ l.single: '‘',
+ r.double: '”',
+ r.single: '’',
+ angle.l.double: '«',
+ angle.l.single: '‹',
+ angle.r.double: '»',
+ angle.r.single: '›',
+ high.double: '‟',
+ high.single: '‛',
+ low.double: '„',
+ low.single: '‚',
+ ],
+ prime: [
+ '′',
+ rev: '‵',
+ double: '″',
+ double.rev: '‶',
+ triple: '‴',
+ triple.rev: '‷',
+ quad: '⁗',
+ ],
+
+ // https://en.wikipedia.org/wiki/List_of_mathematical_symbols_by_subject
+ // Arithmetic.
+ plus: [
+ '+',
+ circle: '⊕',
+ circle.arrow: '⟴',
+ circle.big: '⨁',
+ dot: '∔',
+ minus: '±',
+ small: '﹢',
+ square: '⊞',
+ triangle: '⨹',
+ ],
+ minus: [
+ '−',
+ circle: '⊖',
+ dot: '∸',
+ plus: '∓',
+ square: '⊟',
+ tilde: '≂',
+ triangle: '⨺',
+ ],
+ div: ['÷', circle: '⨸'],
+ times: [
+ '×',
+ big: '⨉',
+ circle: '⊗',
+ circle.big: '⨂',
+ div: '⋇',
+ three.l: '⋋',
+ three.r: '⋌',
+ l: '⋉',
+ r: '⋊',
+ square: '⊠',
+ triangle: '⨻',
+ ],
+ ratio: '∶',
+
+ // Relations.
+ eq: [
+ '=',
+ star: '≛',
+ circle: '⊜',
+ colon: '≕',
+ def: '≝',
+ delta: '≜',
+ equi: '≚',
+ est: '≙',
+ gt: '⋝',
+ lt: '⋜',
+ m: '≞',
+ not: '≠',
+ prec: '⋞',
+ quest: '≟',
+ small: '﹦',
+ succ: '⋟',
+ ],
+ gt: [
+ '>',
+ circle: '⧁',
+ dot: '⋗',
+ double: '≫',
+ eq: '≥',
+ eq.slant: '⩾',
+ eq.lt: '⋛',
+ eq.not: '≱',
+ eqq: '≧',
+ lt: '≷',
+ lt.not: '≹',
+ neqq: '≩',
+ not: '≯',
+ ntilde: '⋧',
+ small: '﹥',
+ tilde: '≳',
+ tilde.not: '≵',
+ tri: '⊳',
+ tri.eq: '⊵',
+ tri.eq.not: '⋭',
+ tri.not: '⋫',
+ triple: '⋙',
+ triple.nested: '⫸',
+ ],
+ lt: [
+ '<',
+ circle: '⧀',
+ dot: '⋖',
+ double: '≪',
+ eq: '≤',
+ eq.slant: '⩽' ,
+ eq.gt: '⋚',
+ eq.not: '≰',
+ eqq: '≦',
+ gt: '≶',
+ gt.not: '≸',
+ neqq: '≨',
+ not: '≮',
+ ntilde: '⋦',
+ small: '﹤',
+ tilde: '≲',
+ tilde.not: '≴',
+ tri: '⊲',
+ tri.eq: '⊴',
+ tri.eq.not: '⋬',
+ tri.not: '⋪',
+ triple: '⋘',
+ triple.nested: '⫷',
+ ],
+ approx: ['≈', eq: '≊', not: '≉'],
+ prec: [
+ '≺',
+ approx: '⪷',
+ double: '⪻',
+ eq: '≼',
+ eq.not: '⋠',
+ eqq: '⪳',
+ napprox: '⪹',
+ neqq: '⪵',
+ not: '⊀',
+ ntilde: '⋨',
+ tilde: '≾',
+ ],
+ succ: [
+ '≻',
+ approx: '⪸',
+ double: '⪼',
+ eq: '≽',
+ eq.not: '⋡',
+ eqq: '⪴',
+ napprox: '⪺',
+ neqq: '⪶',
+ not: '⊁',
+ ntilde: '⋩',
+ tilde: '≿',
+ ],
+ ident: ['≡', not: '≢', strict: '≣'],
+ prop: '∝',
+
+ // Set theory.
+ nothing: ['∅', rev: '⦰'],
+ without: '∖',
+ complement: '∁',
+ in: [
+ '∈',
+ not: '∉',
+ rev: '∋',
+ rev.not: '∌',
+ rev.small: '∍',
+ small: '∊',
+ ],
+ subset: [
+ '⊂',
+ dot: '⪽',
+ double: '⋐',
+ eq: '⊆',
+ eq.not: '⊈',
+ eq.sq: '⊑',
+ eq.sq.not: '⋢',
+ neq: '⊊',
+ not: '⊄',
+ sq: '⊏',
+ sq.neq: '⋤',
+ ],
+ supset: [
+ '⊃',
+ dot: '⪾',
+ double: '⋑',
+ eq: '⊇',
+ eq.not: '⊉',
+ eq.sq: '⊒',
+ eq.sq.not: '⋣',
+ neq: '⊋',
+ not: '⊅',
+ sq: '⊐',
+ sq.neq: '⋥',
+ ],
+ union: [
+ '∪',
+ arrow: '⊌',
+ big: '⋃',
+ dot: '⊍',
+ dot.big: '⨃',
+ double: '⋓',
+ minus: '⩁',
+ or: '⩅',
+ plus: '⊎',
+ plus.big: '⨄',
+ sq: '⊔',
+ sq.big: '⨆',
+ sq.double: '⩏',
+ ],
+ sect: [
+ '∩',
+ and: '⩄',
+ big: '⋂',
+ dot: '⩀',
+ double: '⋒',
+ sq: '⊓',
+ sq.big: '⨅',
+ sq.double: '⩎',
+ ],
+
+ // Calculus.
+ infinity: '∞',
+ oo: '∞',
+ diff: '∂',
+ nabla: '∇',
+ sum: ['∑', integral: '⨋'],
+ product: ['∏', co: '∐'],
+ integral: [
+ '∫',
+ arrow.hook: '⨗',
+ ccw: '⨑',
+ cont: '∮',
+ cont.ccw: '∳',
+ cont.cw: '∲',
+ cw: '∱',
+ double: '∬',
+ quad: '⨌',
+ sect: '⨙',
+ sq: '⨖',
+ surf: '∯',
+ times: '⨘',
+ triple: '∭',
+ union: '⨚',
+ vol: '∰',
+ ],
+ laplace: '∆',
+
+ // Logic.
+ forall: '∀',
+ exists: ['∃', not: '∄'],
+ top: '⊤',
+ bot: '⊥',
+ not: '¬',
+ and: ['∧', big: '⋀', curly: '⋏', dot: '⟑', double: '⩓'],
+ or: ['∨', big: '⋁', curly: '⋎', dot: '⟇', double: '⩔'],
+ xor: ['⊕', big: '⨁'],
+ models: '⊧',
+ therefore: '∴',
+ because: '∵',
+ qed: '∎',
+
+ // Function and category theory.
+ compose: '∘',
+ convolve: '∗',
+ multimap: '⊸',
+
+ // Number theory.
+ divides: ['∣', not: '∤'],
+ perp: ['⟂', circle: '⦹'],
+
+ // Algebra.
+ wreath: '≀',
+
+ // Geometry.
+ parallel: ['∥', circle: '⦷', not: '∦'],
+
+ // Miscellaneous Technical.
+ diameter: '⌀',
+ join: ['⨝', r: '⟖', l: '⟕', l.r: '⟗'],
+ degree: ['°', c: '℃', f: '℉'],
+ smash: '⨳',
+
+ // Currency.
+ bitcoin: '₿',
+ dollar: '$',
+ euro: '€',
+ franc: '₣',
+ lira: '₺',
+ peso: '₱',
+ pound: '£',
+ ruble: '₽',
+ rupee: '₹',
+ won: '₩',
+ yen: '¥',
+
+ // Miscellaneous.
+ ballot: ['☐', x: '☒'],
+ checkmark: ['✓', light: '🗸'],
+ floral: ['❦', l: '☙', r: '❧'],
+ notes: [up: '🎜', down: '🎝'],
+ refmark: '※',
+ servicemark: '℠',
+ maltese: '✠',
+ suit: [club: '♣', diamond: '♦', heart: '♥', spade: '♠'],
+
+ // Shapes.
+ bullet: '•',
+ circle: [
+ stroked: '○',
+ stroked.tiny: '∘',
+ stroked.small: '⚬',
+ stroked.big: '◯',
+ filled: '●',
+ filled.tiny: '⦁',
+ filled.small: '∙',
+ filled.big: '⬤',
+ dotted: '◌',
+ nested: '⊚',
+ ],
+ ellipse: [
+ stroked.h: '⬭',
+ stroked.v: '⬯',
+ filled.h: '⬬',
+ filled.v: '⬮',
+ ],
+ triangle: [
+ stroked.r: '▷',
+ stroked.l: '◁',
+ stroked.t: '△',
+ stroked.b: '▽',
+ stroked.bl: '◺',
+ stroked.br: '◿',
+ stroked.tl: '◸',
+ stroked.tr: '◹',
+ stroked.small.r: '▹',
+ stroked.small.b: '▿',
+ stroked.small.l: '◃',
+ stroked.small.t: '▵',
+ stroked.rounded: '🛆',
+ stroked.nested: '⟁',
+ stroked.dot: '◬',
+ filled.r: '▶',
+ filled.l: '◀',
+ filled.t: '▲',
+ filled.b: '▼',
+ filled.bl: '◣',
+ filled.br: '◢',
+ filled.tl: '◤',
+ filled.tr: '◥',
+ filled.small.r: '▸',
+ filled.small.b: '▾',
+ filled.small.l: '◂',
+ filled.small.t: '▴',
+ ],
+ square: [
+ stroked: '□',
+ stroked.tiny: '▫',
+ stroked.small: '◽',
+ stroked.medium: '◻',
+ stroked.big: '⬜',
+ stroked.dotted: '⬚',
+ stroked.rounded: '▢',
+ filled: '■',
+ filled.tiny: '▪',
+ filled.small: '◾',
+ filled.medium: '◼',
+ filled.big: '⬛',
+ ],
+ rect: [
+ stroked.h: '▭',
+ stroked.v: '▯',
+ filled.h: '▬',
+ filled.v: '▮',
+ ],
+ penta: [stroked: '⬠', filled: '⬟'],
+ hexa: [stroked: '⬡', filled: '⬢'],
+ diamond: [
+ stroked: '◇',
+ stroked.small: '⋄',
+ stroked.medium: '⬦',
+ stroked.dot: '⟐',
+ filled: '◆',
+ filled.medium: '⬥',
+ filled.small: '⬩',
+ ],
+ lozenge: [
+ stroked: '◊',
+ stroked.small: '⬫',
+ stroked.medium: '⬨',
+ filled: '⧫',
+ filled.small: '⬪',
+ filled.medium: '⬧',
+ ],
+ star: [op: '⋆', stroked: '★', filled: '★'],
+
+ // Arrows, harpoons, and tacks.
+ arrow: [
+ r: '→',
+ r.long.bar: '⟼',
+ r.bar: '↦',
+ r.curve: '⤷',
+ r.dashed: '⇢',
+ r.dotted: '⤑',
+ r.double: '⇒',
+ r.double.bar: '⤇',
+ r.double.long: '⟹',
+ r.double.long.bar: '⟾',
+ r.double.not: '⇏',
+ r.filled: '➡',
+ r.hook: '↪',
+ r.long: '⟶',
+ r.long.squiggly: '⟿',
+ r.loop: '↬',
+ r.not: '↛',
+ r.quad: '⭆',
+ r.squiggly: '⇝',
+ r.stop: '⇥',
+ r.stroked: '⇨',
+ r.tail: '↣',
+ r.triple: '⇛',
+ r.twohead.bar: '⤅',
+ r.twohead: '↠',
+ r.wave: '↝',
+ l: '←',
+ l.bar: '↤',
+ l.curve: '⤶',
+ l.dashed: '⇠',
+ l.dotted: '⬸',
+ l.double: '⇐',
+ l.double.bar: '⤆',
+ l.double.long: '⟸',
+ l.double.long.bar: '⟽',
+ l.double.not: '⇍',
+ l.filled: '⬅',
+ l.hook: '↩',
+ l.long: '⟵',
+ l.long.bar: '⟻',
+ l.long.squiggly: '⬳',
+ l.loop: '↫',
+ l.not: '↚',
+ l.quad: '⭅',
+ l.squiggly: '⇜',
+ l.stop: '⇤',
+ l.stroked: '⇦',
+ l.tail: '↢',
+ l.triple: '⇚',
+ l.twohead.bar: '⬶',
+ l.twohead: '↞',
+ l.wave: '↜',
+ t: '↑',
+ t.bar: '↥',
+ t.curve: '⤴',
+ t.dashed: '⇡',
+ t.double: '⇑',
+ t.filled: '⬆',
+ t.quad: '⟰',
+ t.stop: '⤒',
+ t.stroked: '⇧',
+ t.triple: '⤊',
+ t.twohead: '↟',
+ b: '↓',
+ b.bar: '↧',
+ b.curve: '⤵',
+ b.dashed: '⇣',
+ b.double: '⇓',
+ b.filled: '⬇',
+ b.quad: '⟱',
+ b.stop: '⤓',
+ b.stroked: '⇩',
+ b.triple: '⤋',
+ b.twohead: '↡',
+ l.r: '↔',
+ l.r.double: '⇔',
+ l.r.double.long: '⟺',
+ l.r.double.not: '⇎',
+ l.r.filled: '⬌',
+ l.r.long: '⟷',
+ l.r.not: '↮',
+ l.r.stroked: '⬄',
+ l.r.wave: '↭',
+ t.b: '↕',
+ t.b.double: '⇕',
+ t.b.filled: '⬍',
+ t.b.stroked: '⇳',
+ tr: '↗',
+ tr.double: '⇗',
+ tr.filled: '⬈',
+ tr.hook: '⤤',
+ tr.stroked: '⬀',
+ br: '↘',
+ br.double: '⇘',
+ br.filled: '⬊',
+ br.hook: '⤥',
+ br.stroked: '⬂',
+ tl: '↖',
+ tl.double: '⇖',
+ tl.filled: '⬉',
+ tl.hook: '⤣',
+ tl.stroked: '⬁',
+ bl: '↙',
+ bl.double: '⇙',
+ bl.filled: '⬋',
+ bl.hook: '⤦',
+ bl.stroked: '⬃',
+ tl.br: '⤡',
+ tr.bl: '⤢',
+ ccw: '↺',
+ ccw.half: '↶',
+ cw: '↻',
+ cw.half: '↷',
+ zigzag: '↯',
+ ],
+ arrows: [
+ rr: '⇉',
+ ll: '⇇',
+ tt: '⇈',
+ bb: '⇊',
+ lr: '⇆',
+ lr.stop: '↹',
+ rl: '⇄',
+ tb: '⇅',
+ bt: '⇵',
+ rrr: '⇶',
+ lll: '⬱',
+ ],
+ arrowhead: [
+ t: '⌃',
+ b: '⌄',
+ ],
+ harpoon: [
+ rt: '⇀',
+ rt.bar: '⥛',
+ rt.stop: '⥓',
+ rb: '⇁',
+ rb.bar: '⥟',
+ rb.stop: '⥗',
+ lt: '↼',
+ lt.bar: '⥚',
+ lt.stop: '⥒',
+ lb: '↽',
+ lb.bar: '⥞',
+ lb.stop: '⥖',
+ tl: '↿',
+ tl.bar: '⥠',
+ tl.stop: '⥘',
+ tr: '↾',
+ tr.bar: '⥜',
+ tr.stop: '⥔',
+ bl: '⇃',
+ bl.bar: '⥡',
+ bl.stop: '⥙',
+ br: '⇂',
+ br.bar: '⥝',
+ br.stop: '⥕',
+ lt.rt: '⥎',
+ lb.rb: '⥐',
+ lb.rt: '⥋',
+ lt.rb: '⥊',
+ tl.bl: '⥑',
+ tr.br: '⥏',
+ tl.br: '⥍',
+ tr.bl: '⥌',
+ ],
+ harpoons: [
+ rtrb: '⥤',
+ blbr: '⥥',
+ bltr: '⥯',
+ lbrb: '⥧',
+ ltlb: '⥢',
+ ltrb: '⇋',
+ ltrt: '⥦',
+ rblb: '⥩',
+ rtlb: '⇌',
+ rtlt: '⥨',
+ tlbr: '⥮',
+ tltr: '⥣',
+ ],
+ tack: [
+ r: '⊢',
+ r.not: '⊬',
+ r.long: '⟝',
+ r.short: '⊦',
+ r.double: '⊨',
+ r.double.not: '⊭',
+ l: '⊣',
+ l.long: '⟞',
+ l.short: '⫞',
+ l.double: '⫤',
+ t: '⊥',
+ t.big: '⟘',
+ t.double: '⫫',
+ t.short: '⫠',
+ b: '⊤',
+ b.big: '⟙',
+ b.double: '⫪',
+ b.short: '⫟',
+ l.r: '⟛',
+ ],
+
+ // Lowercase Greek.
+ alpha: 'α',
+ beta: ['β', alt: 'ϐ'],
+ chi: 'χ',
+ delta: 'δ',
+ epsilon: ['ε', alt: 'ϵ'],
+ eta: 'η',
+ gamma: 'γ',
+ iota: 'ι',
+ kai: 'ϗ',
+ kappa: ['κ', alt: 'ϰ'],
+ lambda: 'λ',
+ mu: 'μ',
+ nu: 'ν',
+ ohm: ['Ω', inv: '℧'],
+ omega: 'ω',
+ omicron: 'ο',
+ phi: ['φ', alt: 'ϕ'],
+ pi: ['π', alt: 'ϖ'],
+ psi: 'ψ',
+ rho: ['ρ', alt: 'ϱ'],
+ sigma: ['σ', alt: 'ς'],
+ tau: 'τ',
+ theta: ['θ', alt: 'ϑ'],
+ upsilon: 'υ',
+ xi: 'ξ',
+ zeta: 'ζ',
+
+ // Uppercase Greek.
+ Alpha: 'Α',
+ Beta: 'Β',
+ Chi: 'Χ',
+ Delta: 'Δ',
+ Epsilon: 'Ε',
+ Eta: 'Η',
+ Gamma: 'Γ',
+ Iota: 'Ι',
+ Kai: 'Ϗ',
+ Kappa: 'Κ',
+ Lambda: 'Λ',
+ Mu: 'Μ',
+ Nu: 'Ν',
+ Omega: 'Ω',
+ Omicron: 'Ο',
+ Phi: 'Φ',
+ Pi: 'Π',
+ Psi: 'Ψ',
+ Rho: 'Ρ',
+ Sigma: 'Σ',
+ Tau: 'Τ',
+ Theta: 'Θ',
+ Upsilon: 'Υ',
+ Xi: 'Ξ',
+ Zeta: 'Ζ',
+
+ // Hebrew.
+ alef: 'א',
+ bet: 'ב',
+ gimel: 'ג',
+ shin: 'ש',
+
+ // Double-struck.
+ AA: '𝔸',
+ BB: '𝔹',
+ CC: 'ℂ',
+ DD: '𝔻',
+ EE: '𝔼',
+ FF: '𝔽',
+ GG: '𝔾',
+ HH: 'ℍ',
+ II: '𝕀',
+ JJ: '𝕁',
+ KK: '𝕂',
+ LL: '𝕃',
+ MM: '𝕄',
+ NN: 'ℕ',
+ OO: '𝕆',
+ PP: 'ℙ',
+ QQ: 'ℚ',
+ RR: 'ℝ',
+ SS: '𝕊',
+ TT: '𝕋',
+ UU: '𝕌',
+ VV: '𝕍',
+ WW: '𝕎',
+ XX: '𝕏',
+ YY: '𝕐',
+ ZZ: 'ℤ',
+
+ // Miscellaneous letter-likes.
+ ell: 'ℓ',
+ planck: ['ℎ', reduce: 'ℏ'],
+ angstrom: 'Å',
+ kelvin: 'K',
+ Re: 'ℜ',
+ Im: 'ℑ',
+ dotless: [i: '𝚤', j: '𝚥'],
+};
diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs
new file mode 100644
index 00000000..9ec4ca32
--- /dev/null
+++ b/crates/typst-library/src/text/deco.rs
@@ -0,0 +1,420 @@
+use kurbo::{BezPath, Line, ParamCurve};
+use ttf_parser::{GlyphId, OutlineBuilder};
+
+use super::TextElem;
+use crate::prelude::*;
+
+/// Underlines text.
+///
+/// ## Example { #example }
+/// ```example
+/// This is #underline[important].
+/// ```
+///
+/// Display: Underline
+/// Category: text
+#[element(Show)]
+pub struct UnderlineElem {
+ /// How to stroke the line.
+ ///
+ /// See the [line's documentation]($func/line.stroke) for more details. If
+ /// set to `{auto}`, takes on the text's color and a thickness defined in
+ /// the current font.
+ ///
+ /// ```example
+ /// Take #underline(
+ /// stroke: 1.5pt + red,
+ /// offset: 2pt,
+ /// [care],
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<PartialStroke>,
+
+ /// The position of the line relative to the baseline, read from the font
+ /// tables if `{auto}`.
+ ///
+ /// ```example
+ /// #underline(offset: 5pt)[
+ /// The Tale Of A Faraway Line I
+ /// ]
+ /// ```
+ #[resolve]
+ pub offset: Smart<Length>,
+
+ /// The amount by which to extend the line beyond (or within if negative)
+ /// the content.
+ ///
+ /// ```example
+ /// #align(center,
+ /// underline(extent: 2pt)[Chapter 1]
+ /// )
+ /// ```
+ #[resolve]
+ pub extent: Length,
+
+ /// Whether the line skips sections in which it would collide with the
+ /// glyphs.
+ ///
+ /// ```example
+ /// This #underline(evade: true)[is great].
+ /// This #underline(evade: false)[is less great].
+ /// ```
+ #[default(true)]
+ pub evade: bool,
+
+ /// The content to underline.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for UnderlineElem {
+ #[tracing::instrument(name = "UnderlineElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().styled(TextElem::set_deco(Decoration {
+ line: DecoLine::Underline,
+ stroke: self.stroke(styles).unwrap_or_default(),
+ offset: self.offset(styles),
+ extent: self.extent(styles),
+ evade: self.evade(styles),
+ })))
+ }
+}
+
+/// Adds a line over text.
+///
+/// ## Example { #example }
+/// ```example
+/// #overline[A line over text.]
+/// ```
+///
+/// Display: Overline
+/// Category: text
+#[element(Show)]
+pub struct OverlineElem {
+ /// How to stroke the line.
+ ///
+ /// See the [line's documentation]($func/line.stroke) for more details. If
+ /// set to `{auto}`, takes on the text's color and a thickness defined in
+ /// the current font.
+ ///
+ /// ```example
+ /// #set text(fill: olive)
+ /// #overline(
+ /// stroke: green.darken(20%),
+ /// offset: -12pt,
+ /// [The Forest Theme],
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<PartialStroke>,
+
+ /// The position of the line relative to the baseline. Read from the font
+ /// tables if `{auto}`.
+ ///
+ /// ```example
+ /// #overline(offset: -1.2em)[
+ /// The Tale Of A Faraway Line II
+ /// ]
+ /// ```
+ #[resolve]
+ pub offset: Smart<Length>,
+
+ /// The amount by which to extend the line beyond (or within if negative)
+ /// the content.
+ ///
+ /// ```example
+ /// #set overline(extent: 4pt)
+ /// #set underline(extent: 4pt)
+ /// #overline(underline[Typography Today])
+ /// ```
+ #[resolve]
+ pub extent: Length,
+
+ /// Whether the line skips sections in which it would collide with the
+ /// glyphs.
+ ///
+ /// ```example
+ /// #overline(
+ /// evade: false,
+ /// offset: -7.5pt,
+ /// stroke: 1pt,
+ /// extent: 3pt,
+ /// [Temple],
+ /// )
+ /// ```
+ #[default(true)]
+ pub evade: bool,
+
+ /// The content to add a line over.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for OverlineElem {
+ #[tracing::instrument(name = "OverlineElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().styled(TextElem::set_deco(Decoration {
+ line: DecoLine::Overline,
+ stroke: self.stroke(styles).unwrap_or_default(),
+ offset: self.offset(styles),
+ extent: self.extent(styles),
+ evade: self.evade(styles),
+ })))
+ }
+}
+
+/// Strikes through text.
+///
+/// ## Example { #example }
+/// ```example
+/// This is #strike[not] relevant.
+/// ```
+///
+/// Display: Strikethrough
+/// Category: text
+#[element(Show)]
+pub struct StrikeElem {
+ /// How to stroke the line.
+ ///
+ /// See the [line's documentation]($func/line.stroke) for more details. If
+ /// set to `{auto}`, takes on the text's color and a thickness defined in
+ /// the current font.
+ ///
+ /// _Note:_ Please don't use this for real redaction as you can still
+ /// copy paste the text.
+ ///
+ /// ```example
+ /// This is #strike(stroke: 1.5pt + red)[very stricken through]. \
+ /// This is #strike(stroke: 10pt)[redacted].
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<PartialStroke>,
+
+ /// The position of the line relative to the baseline. Read from the font
+ /// tables if `{auto}`.
+ ///
+ /// This is useful if you are unhappy with the offset your font provides.
+ ///
+ /// ```example
+ /// #set text(font: "Inria Serif")
+ /// This is #strike(offset: auto)[low-ish]. \
+ /// This is #strike(offset: -3.5pt)[on-top].
+ /// ```
+ #[resolve]
+ pub offset: Smart<Length>,
+
+ /// The amount by which to extend the line beyond (or within if negative)
+ /// the content.
+ ///
+ /// ```example
+ /// This #strike(extent: -2pt)[skips] parts of the word.
+ /// This #strike(extent: 2pt)[extends] beyond the word.
+ /// ```
+ #[resolve]
+ pub extent: Length,
+
+ /// The content to strike through.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for StrikeElem {
+ #[tracing::instrument(name = "StrikeElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().styled(TextElem::set_deco(Decoration {
+ line: DecoLine::Strikethrough,
+ stroke: self.stroke(styles).unwrap_or_default(),
+ offset: self.offset(styles),
+ extent: self.extent(styles),
+ evade: false,
+ })))
+ }
+}
+
+/// Defines a line that is positioned over, under or on top of text.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Decoration {
+ pub line: DecoLine,
+ pub stroke: PartialStroke<Abs>,
+ pub offset: Smart<Abs>,
+ pub extent: Abs,
+ pub evade: bool,
+}
+
+impl Fold for Decoration {
+ type Output = Vec<Self>;
+
+ fn fold(self, mut outer: Self::Output) -> Self::Output {
+ outer.insert(0, self);
+ outer
+ }
+}
+
+cast! {
+ type Decoration: "decoration",
+}
+
+/// A kind of decorative line.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum DecoLine {
+ Underline,
+ Strikethrough,
+ Overline,
+}
+
+/// Add line decorations to a single run of shaped text.
+pub(super) fn decorate(
+ frame: &mut Frame,
+ deco: &Decoration,
+ text: &TextItem,
+ shift: Abs,
+ pos: Point,
+ width: Abs,
+) {
+ let font_metrics = text.font.metrics();
+ let metrics = match deco.line {
+ DecoLine::Strikethrough => font_metrics.strikethrough,
+ DecoLine::Overline => font_metrics.overline,
+ DecoLine::Underline => font_metrics.underline,
+ };
+
+ let offset = deco.offset.unwrap_or(-metrics.position.at(text.size)) - shift;
+ let stroke = deco.stroke.clone().unwrap_or(Stroke {
+ paint: text.fill.clone(),
+ thickness: metrics.thickness.at(text.size),
+ ..Stroke::default()
+ });
+
+ let gap_padding = 0.08 * text.size;
+ let min_width = 0.162 * text.size;
+
+ let start = pos.x - deco.extent;
+ let end = pos.x + (width + 2.0 * deco.extent);
+
+ let mut push_segment = |from: Abs, to: Abs| {
+ let origin = Point::new(from, pos.y + offset);
+ let target = Point::new(to - from, Abs::zero());
+
+ if target.x >= min_width || !deco.evade {
+ let shape = Geometry::Line(target).stroked(stroke.clone());
+ frame.push(origin, FrameItem::Shape(shape, Span::detached()));
+ }
+ };
+
+ if !deco.evade {
+ push_segment(start, end);
+ return;
+ }
+
+ let line = Line::new(
+ kurbo::Point::new(pos.x.to_raw(), offset.to_raw()),
+ kurbo::Point::new((pos.x + width).to_raw(), offset.to_raw()),
+ );
+
+ let mut x = pos.x;
+ let mut intersections = vec![];
+
+ for glyph in text.glyphs.iter() {
+ let dx = glyph.x_offset.at(text.size) + x;
+ let mut builder =
+ BezPathBuilder::new(font_metrics.units_per_em, text.size, dx.to_raw());
+
+ let bbox = text.font.ttf().outline_glyph(GlyphId(glyph.id), &mut builder);
+ let path = builder.finish();
+
+ x += glyph.x_advance.at(text.size);
+
+ // Only do the costly segments intersection test if the line
+ // intersects the bounding box.
+ let intersect = bbox.map_or(false, |bbox| {
+ let y_min = -text.font.to_em(bbox.y_max).at(text.size);
+ let y_max = -text.font.to_em(bbox.y_min).at(text.size);
+ offset >= y_min && offset <= y_max
+ });
+
+ if intersect {
+ // Find all intersections of segments with the line.
+ intersections.extend(
+ path.segments()
+ .flat_map(|seg| seg.intersect_line(line))
+ .map(|is| Abs::raw(line.eval(is.line_t).x)),
+ );
+ }
+ }
+
+ // Add start and end points, taking padding into account.
+ intersections.push(start - gap_padding);
+ intersections.push(end + gap_padding);
+ // When emitting the decorative line segments, we move from left to
+ // right. The intersections are not necessarily in this order, yet.
+ intersections.sort();
+
+ for edge in intersections.windows(2) {
+ let l = edge[0];
+ let r = edge[1];
+
+ // If we are too close, don't draw the segment
+ if r - l < gap_padding {
+ continue;
+ } else {
+ push_segment(l + gap_padding, r - gap_padding);
+ }
+ }
+}
+
+/// Builds a kurbo [`BezPath`] for a glyph.
+struct BezPathBuilder {
+ path: BezPath,
+ units_per_em: f64,
+ font_size: Abs,
+ x_offset: f64,
+}
+
+impl BezPathBuilder {
+ fn new(units_per_em: f64, font_size: Abs, x_offset: f64) -> Self {
+ Self {
+ path: BezPath::new(),
+ units_per_em,
+ font_size,
+ x_offset,
+ }
+ }
+
+ fn finish(self) -> BezPath {
+ self.path
+ }
+
+ fn p(&self, x: f32, y: f32) -> kurbo::Point {
+ kurbo::Point::new(self.s(x) + self.x_offset, -self.s(y))
+ }
+
+ fn s(&self, v: f32) -> f64 {
+ Em::from_units(v, self.units_per_em).at(self.font_size).to_raw()
+ }
+}
+
+impl OutlineBuilder for BezPathBuilder {
+ fn move_to(&mut self, x: f32, y: f32) {
+ self.path.move_to(self.p(x, y));
+ }
+
+ fn line_to(&mut self, x: f32, y: f32) {
+ self.path.line_to(self.p(x, y));
+ }
+
+ fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+ self.path.quad_to(self.p(x1, y1), self.p(x, y));
+ }
+
+ fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+ self.path.curve_to(self.p(x1, y1), self.p(x2, y2), self.p(x, y));
+ }
+
+ fn close(&mut self) {
+ self.path.close_path();
+ }
+}
diff --git a/crates/typst-library/src/text/misc.rs b/crates/typst-library/src/text/misc.rs
new file mode 100644
index 00000000..811b027e
--- /dev/null
+++ b/crates/typst-library/src/text/misc.rs
@@ -0,0 +1,330 @@
+use super::TextElem;
+use crate::prelude::*;
+
+/// A text space.
+///
+/// Display: Space
+/// Category: text
+#[element(Behave, Unlabellable, PlainText)]
+pub struct SpaceElem {}
+
+impl Behave for SpaceElem {
+ fn behaviour(&self) -> Behaviour {
+ Behaviour::Weak(2)
+ }
+}
+
+impl Unlabellable for SpaceElem {}
+
+impl PlainText for SpaceElem {
+ fn plain_text(&self, text: &mut EcoString) {
+ text.push(' ');
+ }
+}
+
+/// Inserts a line break.
+///
+/// Advances the paragraph to the next line. A single trailing line break at the
+/// end of a paragraph is ignored, but more than one creates additional empty
+/// lines.
+///
+/// ## Example { #example }
+/// ```example
+/// *Date:* 26.12.2022 \
+/// *Topic:* Infrastructure Test \
+/// *Severity:* High \
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: To insert a line break, simply write
+/// a backslash followed by whitespace. This always creates an unjustified
+/// break.
+///
+/// Display: Line Break
+/// Category: text
+#[element(Behave)]
+pub struct LinebreakElem {
+ /// Whether to justify the line before the break.
+ ///
+ /// This is useful if you found a better line break opportunity in your
+ /// justified text than Typst did.
+ ///
+ /// ```example
+ /// #set par(justify: true)
+ /// #let jb = linebreak(justify: true)
+ ///
+ /// I have manually tuned the #jb
+ /// line breaks in this paragraph #jb
+ /// for an _interesting_ result. #jb
+ /// ```
+ #[default(false)]
+ pub justify: bool,
+}
+
+impl Behave for LinebreakElem {
+ fn behaviour(&self) -> Behaviour {
+ Behaviour::Destructive
+ }
+}
+
+/// Strongly emphasizes content by increasing the font weight.
+///
+/// Increases the current font weight by a given `delta`.
+///
+/// ## Example { #example }
+/// ```example
+/// This is *strong.* \
+/// This is #strong[too.] \
+///
+/// #show strong: set text(red)
+/// And this is *evermore.*
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: To strongly emphasize content,
+/// simply enclose it in stars/asterisks (`*`). Note that this only works at
+/// word boundaries. To strongly emphasize part of a word, you have to use the
+/// function.
+///
+/// Display: Strong Emphasis
+/// Category: text
+#[element(Show)]
+pub struct StrongElem {
+ /// The delta to apply on the font weight.
+ ///
+ /// ```example
+ /// #set strong(delta: 0)
+ /// No *effect!*
+ /// ```
+ #[default(300)]
+ pub delta: i64,
+
+ /// The content to strongly emphasize.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for StrongElem {
+ #[tracing::instrument(name = "StrongElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().styled(TextElem::set_delta(Delta(self.delta(styles)))))
+ }
+}
+
+/// A delta that is summed up when folded.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Delta(pub i64);
+
+cast! {
+ Delta,
+ self => self.0.into_value(),
+ v: i64 => Self(v),
+}
+
+impl Fold for Delta {
+ type Output = i64;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ outer + self.0
+ }
+}
+
+/// Emphasizes content by setting it in italics.
+///
+/// - If the current [text style]($func/text.style) is `{"normal"}`,
+/// this turns it into `{"italic"}`.
+/// - If it is already `{"italic"}` or `{"oblique"}`,
+/// it turns it back to `{"normal"}`.
+///
+/// ## Example { #example }
+/// ```example
+/// This is _emphasized._ \
+/// This is #emph[too.]
+///
+/// #show emph: it => {
+/// text(blue, it.body)
+/// }
+///
+/// This is _emphasized_ differently.
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: To emphasize content, simply
+/// enclose it in underscores (`_`). Note that this only works at word
+/// boundaries. To emphasize part of a word, you have to use the function.
+///
+/// Display: Emphasis
+/// Category: text
+#[element(Show)]
+pub struct EmphElem {
+ /// The content to emphasize.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for EmphElem {
+ #[tracing::instrument(name = "EmphElem::show", skip(self))]
+ fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().styled(TextElem::set_emph(Toggle)))
+ }
+}
+
+/// A toggle that turns on and off alternatingly if folded.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Toggle;
+
+cast! {
+ Toggle,
+ self => Value::None,
+ _: Value => Self,
+}
+
+impl Fold for Toggle {
+ type Output = bool;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ !outer
+ }
+}
+
+/// Converts text or content to lowercase.
+///
+/// ## Example { #example }
+/// ```example
+/// #lower("ABC") \
+/// #lower[*My Text*] \
+/// #lower[already low]
+/// ```
+///
+/// Display: Lowercase
+/// Category: text
+#[func]
+pub fn lower(
+ /// The text to convert to lowercase.
+ text: Caseable,
+) -> Caseable {
+ case(text, Case::Lower)
+}
+
+/// Converts text or content to uppercase.
+///
+/// ## Example { #example }
+/// ```example
+/// #upper("abc") \
+/// #upper[*my text*] \
+/// #upper[ALREADY HIGH]
+/// ```
+///
+/// Display: Uppercase
+/// Category: text
+#[func]
+pub fn upper(
+ /// The text to convert to uppercase.
+ text: Caseable,
+) -> Caseable {
+ case(text, Case::Upper)
+}
+
+/// Change the case of text.
+fn case(text: Caseable, case: Case) -> Caseable {
+ match text {
+ Caseable::Str(v) => Caseable::Str(case.apply(&v).into()),
+ Caseable::Content(v) => {
+ Caseable::Content(v.styled(TextElem::set_case(Some(case))))
+ }
+ }
+}
+
+/// A value whose case can be changed.
+pub enum Caseable {
+ Str(Str),
+ Content(Content),
+}
+
+cast! {
+ Caseable,
+ self => match self {
+ Self::Str(v) => v.into_value(),
+ Self::Content(v) => v.into_value(),
+ },
+ v: Str => Self::Str(v),
+ v: Content => Self::Content(v),
+}
+
+/// A case transformation on text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Case {
+ /// Everything is lowercased.
+ Lower,
+ /// Everything is uppercased.
+ Upper,
+}
+
+impl Case {
+ /// Apply the case to a string.
+ pub fn apply(self, text: &str) -> String {
+ match self {
+ Self::Lower => text.to_lowercase(),
+ Self::Upper => text.to_uppercase(),
+ }
+ }
+}
+
+/// Displays text in small capitals.
+///
+/// _Note:_ This enables the OpenType `smcp` feature for the font. Not all fonts
+/// support this feature. Sometimes smallcaps are part of a dedicated font and
+/// sometimes they are not available at all. In the future, this function will
+/// support selecting a dedicated smallcaps font as well as synthesizing
+/// smallcaps from normal letters, but this is not yet implemented.
+///
+/// ## Example { #example }
+/// ```example
+/// #set par(justify: true)
+/// #set heading(numbering: "I.")
+///
+/// #show heading: it => {
+/// set block(below: 10pt)
+/// set text(weight: "regular")
+/// align(center, smallcaps(it))
+/// }
+///
+/// = Introduction
+/// #lorem(40)
+/// ```
+///
+/// Display: Small Capitals
+/// Category: text
+#[func]
+pub fn smallcaps(
+ /// The text to display to small capitals.
+ body: Content,
+) -> Content {
+ body.styled(TextElem::set_smallcaps(true))
+}
+
+/// Creates blind text.
+///
+/// This function yields a Latin-like _Lorem Ipsum_ blind text with the given
+/// number of words. The sequence of words generated by the function is always
+/// the same but randomly chosen. As usual for blind texts, it does not make any
+/// sense. Use it as a placeholder to try layouts.
+///
+/// ## Example { #example }
+/// ```example
+/// = Blind Text
+/// #lorem(30)
+///
+/// = More Blind Text
+/// #lorem(15)
+/// ```
+///
+/// Display: Blind Text
+/// Category: text
+#[func]
+pub fn lorem(
+ /// The length of the blind text in words.
+ words: usize,
+) -> Str {
+ lipsum::lipsum(words).replace("--", "–").into()
+}
diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs
new file mode 100644
index 00000000..ff8cbad8
--- /dev/null
+++ b/crates/typst-library/src/text/mod.rs
@@ -0,0 +1,769 @@
+//! Text handling.
+
+mod deco;
+mod misc;
+mod quotes;
+mod raw;
+mod shaping;
+mod shift;
+
+pub use self::deco::*;
+pub use self::misc::*;
+pub use self::quotes::*;
+pub use self::raw::*;
+pub use self::shaping::*;
+pub use self::shift::*;
+
+use rustybuzz::Tag;
+use typst::font::{FontMetrics, FontStretch, FontStyle, FontWeight, VerticalFontMetric};
+
+use crate::layout::ParElem;
+use crate::prelude::*;
+
+/// Hook up all text definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.define("text", TextElem::func());
+ global.define("linebreak", LinebreakElem::func());
+ global.define("smartquote", SmartQuoteElem::func());
+ global.define("strong", StrongElem::func());
+ global.define("emph", EmphElem::func());
+ global.define("lower", lower_func());
+ global.define("upper", upper_func());
+ global.define("smallcaps", smallcaps_func());
+ global.define("sub", SubElem::func());
+ global.define("super", SuperElem::func());
+ global.define("underline", UnderlineElem::func());
+ global.define("strike", StrikeElem::func());
+ global.define("overline", OverlineElem::func());
+ global.define("raw", RawElem::func());
+ global.define("lorem", lorem_func());
+}
+
+/// Customizes the look and layout of text in a variety of ways.
+///
+/// This function is used frequently, both with set rules and directly. While
+/// the set rule is often the simpler choice, calling the `text` function
+/// directly can be useful when passing text as an argument to another function.
+///
+/// ## Example { #example }
+/// ```example
+/// #set text(18pt)
+/// With a set rule.
+///
+/// #emph(text(blue)[
+/// With a function call.
+/// ])
+/// ```
+///
+/// Display: Text
+/// Category: text
+#[element(Construct, PlainText)]
+pub struct TextElem {
+ /// A prioritized sequence of font families.
+ ///
+ /// When processing text, Typst tries all specified font families in order
+ /// until it finds a font that has the necessary glyphs. In the example
+ /// below, the font `Inria Serif` is preferred, but since it does not
+ /// contain Arabic glyphs, the arabic text uses `Noto Sans Arabic` instead.
+ ///
+ /// ```example
+ /// #set text(font: (
+ /// "Inria Serif",
+ /// "Noto Sans Arabic",
+ /// ))
+ ///
+ /// This is Latin. \
+ /// هذا عربي.
+ ///
+ /// ```
+ #[default(FontList(vec![FontFamily::new("Linux Libertine")]))]
+ pub font: FontList,
+
+ /// Whether to allow last resort font fallback when the primary font list
+ /// contains no match. This lets Typst search through all available fonts
+ /// for the most similar one that has the necessary glyphs.
+ ///
+ /// _Note:_ Currently, there are no warnings when fallback is disabled and
+ /// no glyphs are found. Instead, your text shows up in the form of "tofus":
+ /// Small boxes that indicate the lack of an appropriate glyph. In the
+ /// future, you will be able to instruct Typst to issue warnings so you know
+ /// something is up.
+ ///
+ /// ```example
+ /// #set text(font: "Inria Serif")
+ /// هذا عربي
+ ///
+ /// #set text(fallback: false)
+ /// هذا عربي
+ /// ```
+ #[default(true)]
+ pub fallback: bool,
+
+ /// The desired font style.
+ ///
+ /// When an italic style is requested and only an oblique one is available,
+ /// it is used. Similarly, the other way around, an italic style can stand
+ /// in for an oblique one. When neither an italic nor an oblique style is
+ /// available, Typst selects the normal style. Since most fonts are only
+ /// available either in an italic or oblique style, the difference between
+ /// italic and oblique style is rarely observable.
+ ///
+ /// If you want to emphasize your text, you should do so using the
+ /// [emph]($func/emph) function instead. This makes it easy to adapt the
+ /// style later if you change your mind about how to signify the emphasis.
+ ///
+ /// ```example
+ /// #text(font: "Linux Libertine", style: "italic")[Italic]
+ /// #text(font: "DejaVu Sans", style: "oblique")[Oblique]
+ /// ```
+ pub style: FontStyle,
+
+ /// The desired thickness of the font's glyphs. Accepts an integer between
+ /// `{100}` and `{900}` or one of the predefined weight names. When the
+ /// desired weight is not available, Typst selects the font from the family
+ /// that is closest in weight.
+ ///
+ /// If you want to strongly emphasize your text, you should do so using the
+ /// [strong]($func/strong) function instead. This makes it easy to adapt the
+ /// style later if you change your mind about how to signify the strong
+ /// emphasis.
+ ///
+ /// ```example
+ /// #set text(font: "IBM Plex Sans")
+ ///
+ /// #text(weight: "light")[Light] \
+ /// #text(weight: "regular")[Regular] \
+ /// #text(weight: "medium")[Medium] \
+ /// #text(weight: 500)[Medium] \
+ /// #text(weight: "bold")[Bold]
+ /// ```
+ pub weight: FontWeight,
+
+ /// The desired width of the glyphs. Accepts a ratio between `{50%}` and
+ /// `{200%}`. When the desired weight is not available, Typst selects the
+ /// font from the family that is closest in stretch. This will only stretch
+ /// the text if a condensed or expanded version of the font is available.
+ ///
+ /// If you want to adjust the amount of space between characters instead of
+ /// stretching the glyphs itself, use the [`tracking`]($func/text.tracking)
+ /// property instead.
+ ///
+ /// ```example
+ /// #text(stretch: 75%)[Condensed] \
+ /// #text(stretch: 100%)[Normal]
+ /// ```
+ pub stretch: FontStretch,
+
+ /// The size of the glyphs. This value forms the basis of the `em` unit:
+ /// `{1em}` is equivalent to the font size.
+ ///
+ /// You can also give the font size itself in `em` units. Then, it is
+ /// relative to the previous font size.
+ ///
+ /// ```example
+ /// #set text(size: 20pt)
+ /// very #text(1.5em)[big] text
+ /// ```
+ #[parse(args.named_or_find("size")?)]
+ #[fold]
+ #[default(Abs::pt(11.0))]
+ pub size: TextSize,
+
+ /// The glyph fill color.
+ ///
+ /// ```example
+ /// #set text(fill: red)
+ /// This text is red.
+ /// ```
+ #[parse(args.named_or_find("fill")?)]
+ #[default(Color::BLACK.into())]
+ pub fill: Paint,
+
+ /// The amount of space that should be added between characters.
+ ///
+ /// ```example
+ /// #set text(tracking: 1.5pt)
+ /// Distant text.
+ /// ```
+ #[resolve]
+ pub tracking: Length,
+
+ /// The amount of space between words.
+ ///
+ /// Can be given as an absolute length, but also relative to the width of
+ /// the space character in the font.
+ ///
+ /// If you want to adjust the amount of space between characters rather than
+ /// words, use the [`tracking`]($func/text.tracking) property instead.
+ ///
+ /// ```example
+ /// #set text(spacing: 200%)
+ /// Text with distant words.
+ /// ```
+ #[resolve]
+ #[default(Rel::one())]
+ pub spacing: Rel<Length>,
+
+ /// An amount to shift the text baseline by.
+ ///
+ /// ```example
+ /// A #text(baseline: 3pt)[lowered]
+ /// word.
+ /// ```
+ #[resolve]
+ pub baseline: Length,
+
+ /// Whether certain glyphs can hang over into the margin in justified text.
+ /// This can make justification visually more pleasing.
+ ///
+ /// ```example
+ /// #set par(justify: true)
+ /// This justified text has a hyphen in
+ /// the paragraph's first line. Hanging
+ /// the hyphen slightly into the margin
+ /// results in a clearer paragraph edge.
+ ///
+ /// #set text(overhang: false)
+ /// This justified text has a hyphen in
+ /// the paragraph's first line. Hanging
+ /// the hyphen slightly into the margin
+ /// results in a clearer paragraph edge.
+ /// ```
+ #[default(true)]
+ pub overhang: bool,
+
+ /// The top end of the conceptual frame around the text used for layout and
+ /// positioning. This affects the size of containers that hold text.
+ ///
+ /// ```example
+ /// #set rect(inset: 0pt)
+ /// #set text(size: 20pt)
+ ///
+ /// #set text(top-edge: "ascender")
+ /// #rect(fill: aqua)[Typst]
+ ///
+ /// #set text(top-edge: "cap-height")
+ /// #rect(fill: aqua)[Typst]
+ /// ```
+ #[default(TextEdge::Metric(VerticalFontMetric::CapHeight))]
+ pub top_edge: TextEdge,
+
+ /// The bottom end of the conceptual frame around the text used for layout
+ /// and positioning. This affects the size of containers that hold text.
+ ///
+ /// ```example
+ /// #set rect(inset: 0pt)
+ /// #set text(size: 20pt)
+ ///
+ /// #set text(bottom-edge: "baseline")
+ /// #rect(fill: aqua)[Typst]
+ ///
+ /// #set text(bottom-edge: "descender")
+ /// #rect(fill: aqua)[Typst]
+ /// ```
+ #[default(TextEdge::Metric(VerticalFontMetric::Baseline))]
+ pub bottom_edge: TextEdge,
+
+ /// An [ISO 639-1/2/3 language code.](https://en.wikipedia.org/wiki/ISO_639)
+ ///
+ /// Setting the correct language affects various parts of Typst:
+ ///
+ /// - The text processing pipeline can make more informed choices.
+ /// - Hyphenation will use the correct patterns for the language.
+ /// - [Smart quotes]($func/smartquote) turns into the correct quotes for the
+ /// language.
+ /// - And all other things which are language-aware.
+ ///
+ /// ```example
+ /// #set text(lang: "de")
+ /// #outline()
+ ///
+ /// = Einleitung
+ /// In diesem Dokument, ...
+ /// ```
+ #[default(Lang::ENGLISH)]
+ pub lang: Lang,
+
+ /// An [ISO 3166-1 alpha-2 region code.](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)
+ ///
+ /// This lets the text processing pipeline make more informed choices.
+ pub region: Option<Region>,
+
+ /// The dominant direction for text and inline objects. Possible values are:
+ ///
+ /// - `{auto}`: Automatically infer the direction from the `lang` property.
+ /// - `{ltr}`: Layout text from left to right.
+ /// - `{rtl}`: Layout text from right to left.
+ ///
+ /// When writing in right-to-left scripts like Arabic or Hebrew, you should
+ /// set the [text language]($func/text.lang) or direction. While individual
+ /// runs of text are automatically layouted in the correct direction,
+ /// setting the dominant direction gives the bidirectional reordering
+ /// algorithm the necessary information to correctly place punctuation and
+ /// inline objects. Furthermore, setting the direction affects the alignment
+ /// values `start` and `end`, which are equivalent to `left` and `right` in
+ /// `ltr` text and the other way around in `rtl` text.
+ ///
+ /// If you set this to `rtl` and experience bugs or in some way bad looking
+ /// output, please do get in touch with us through the
+ /// [contact form](https://typst.app/contact) or our
+ /// [Discord server]($community/#discord)!
+ ///
+ /// ```example
+ /// #set text(dir: rtl)
+ /// هذا عربي.
+ /// ```
+ #[resolve]
+ pub dir: TextDir,
+
+ /// Whether to hyphenate text to improve line breaking. When `{auto}`, text
+ /// will be hyphenated if and only if justification is enabled.
+ ///
+ /// Setting the [text language]($func/text.lang) ensures that the correct
+ /// hyphenation patterns are used.
+ ///
+ /// ```example
+ /// #set page(width: 200pt)
+ ///
+ /// #set par(justify: true)
+ /// This text illustrates how
+ /// enabling hyphenation can
+ /// improve justification.
+ ///
+ /// #set text(hyphenate: false)
+ /// This text illustrates how
+ /// enabling hyphenation can
+ /// improve justification.
+ /// ```
+ #[resolve]
+ pub hyphenate: Hyphenate,
+
+ /// Whether to apply kerning.
+ ///
+ /// When enabled, specific letter pairings move closer together or further
+ /// apart for a more visually pleasing result. The example below
+ /// demonstrates how decreasing the gap between the "T" and "o" results in a
+ /// more natural look. Setting this to `{false}` disables kerning by turning
+ /// off the OpenType `kern` font feature.
+ ///
+ /// ```example
+ /// #set text(size: 25pt)
+ /// Totally
+ ///
+ /// #set text(kerning: false)
+ /// Totally
+ /// ```
+ #[default(true)]
+ pub kerning: bool,
+
+ /// Whether to apply stylistic alternates.
+ ///
+ /// Sometimes fonts contain alternative glyphs for the same codepoint.
+ /// Setting this to `{true}` switches to these by enabling the OpenType
+ /// `salt` font feature.
+ ///
+ /// ```example
+ /// #set text(
+ /// font: "IBM Plex Sans",
+ /// size: 20pt,
+ /// )
+ ///
+ /// 0, a, g, ß
+ ///
+ /// #set text(alternates: true)
+ /// 0, a, g, ß
+ /// ```
+ #[default(false)]
+ pub alternates: bool,
+
+ /// Which stylistic set to apply. Font designers can categorize alternative
+ /// glyphs forms into stylistic sets. As this value is highly font-specific,
+ /// you need to consult your font to know which sets are available. When set
+ /// to an integer between `{1}` and `{20}`, enables the corresponding
+ /// OpenType font feature from `ss01`, ..., `ss20`.
+ pub stylistic_set: Option<StylisticSet>,
+
+ /// Whether standard ligatures are active.
+ ///
+ /// Certain letter combinations like "fi" are often displayed as a single
+ /// merged glyph called a _ligature._ Setting this to `{false}` disables
+ /// these ligatures by turning off the OpenType `liga` and `clig` font
+ /// features.
+ ///
+ /// ```example
+ /// #set text(size: 20pt)
+ /// A fine ligature.
+ ///
+ /// #set text(ligatures: false)
+ /// A fine ligature.
+ /// ```
+ #[default(true)]
+ pub ligatures: bool,
+
+ /// Whether ligatures that should be used sparingly are active. Setting this
+ /// to `{true}` enables the OpenType `dlig` font feature.
+ #[default(false)]
+ pub discretionary_ligatures: bool,
+
+ /// Whether historical ligatures are active. Setting this to `{true}`
+ /// enables the OpenType `hlig` font feature.
+ #[default(false)]
+ pub historical_ligatures: bool,
+
+ /// Which kind of numbers / figures to select. When set to `{auto}`, the
+ /// default numbers for the font are used.
+ ///
+ /// ```example
+ /// #set text(font: "Noto Sans", 20pt)
+ /// #set text(number-type: "lining")
+ /// Number 9.
+ ///
+ /// #set text(number-type: "old-style")
+ /// Number 9.
+ /// ```
+ pub number_type: Smart<NumberType>,
+
+ /// The width of numbers / figures. When set to `{auto}`, the default
+ /// numbers for the font are used.
+ ///
+ /// ```example
+ /// #set text(font: "Noto Sans", 20pt)
+ /// #set text(number-width: "proportional")
+ /// A 12 B 34. \
+ /// A 56 B 78.
+ ///
+ /// #set text(number-width: "tabular")
+ /// A 12 B 34. \
+ /// A 56 B 78.
+ /// ```
+ pub number_width: Smart<NumberWidth>,
+
+ /// Whether to have a slash through the zero glyph. Setting this to `{true}`
+ /// enables the OpenType `zero` font feature.
+ ///
+ /// ```example
+ /// 0, #text(slashed-zero: true)[0]
+ /// ```
+ #[default(false)]
+ pub slashed_zero: bool,
+
+ /// Whether to turn numbers into fractions. Setting this to `{true}`
+ /// enables the OpenType `frac` font feature.
+ ///
+ /// It is not advisable to enable this property globally as it will mess
+ /// with all appearances of numbers after a slash (e.g., in URLs). Instead,
+ /// enable it locally when you want a fraction.
+ ///
+ /// ```example
+ /// 1/2 \
+ /// #text(fractions: true)[1/2]
+ /// ```
+ #[default(false)]
+ pub fractions: bool,
+
+ /// Raw OpenType features to apply.
+ ///
+ /// - If given an array of strings, sets the features identified by the
+ /// strings to `{1}`.
+ /// - If given a dictionary mapping to numbers, sets the features
+ /// identified by the keys to the values.
+ ///
+ /// ```example
+ /// // Enable the `frac` feature manually.
+ /// #set text(features: ("frac",))
+ /// 1/2
+ /// ```
+ #[fold]
+ pub features: FontFeatures,
+
+ /// Content in which all text is styled according to the other arguments.
+ #[external]
+ #[required]
+ pub body: Content,
+
+ /// The text.
+ #[internal]
+ #[required]
+ pub text: EcoString,
+
+ /// A delta to apply on the font weight.
+ #[internal]
+ #[fold]
+ pub delta: Delta,
+
+ /// Whether the font style should be inverted.
+ #[internal]
+ #[fold]
+ #[default(false)]
+ pub emph: Toggle,
+
+ /// Decorative lines.
+ #[internal]
+ #[fold]
+ pub deco: Decoration,
+
+ /// A case transformation that should be applied to the text.
+ #[internal]
+ pub case: Option<Case>,
+
+ /// Whether small capital glyphs should be used. ("smcp")
+ #[internal]
+ #[default(false)]
+ pub smallcaps: bool,
+}
+
+impl TextElem {
+ /// Create a new packed text element.
+ pub fn packed(text: impl Into<EcoString>) -> Content {
+ Self::new(text.into()).pack()
+ }
+}
+
+impl Construct for TextElem {
+ fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> {
+ // The text constructor is special: It doesn't create a text element.
+ // Instead, it leaves the passed argument structurally unchanged, but
+ // styles all text in it.
+ let styles = Self::set(args)?;
+ let body = args.expect::<Content>("body")?;
+ Ok(body.styled_with_map(styles))
+ }
+}
+
+impl PlainText for TextElem {
+ fn plain_text(&self, text: &mut EcoString) {
+ text.push_str(&self.text());
+ }
+}
+
+/// A lowercased font family like "arial".
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub struct FontFamily(EcoString);
+
+impl FontFamily {
+ /// Create a named font family variant.
+ pub fn new(string: &str) -> Self {
+ Self(string.to_lowercase().into())
+ }
+
+ /// The lowercased family name.
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl Debug for FontFamily {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+cast! {
+ FontFamily,
+ self => self.0.into_value(),
+ string: EcoString => Self::new(&string),
+}
+
+/// Font family fallback list.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct FontList(pub Vec<FontFamily>);
+
+impl IntoIterator for FontList {
+ type IntoIter = std::vec::IntoIter<FontFamily>;
+ type Item = FontFamily;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+cast! {
+ FontList,
+ self => if self.0.len() == 1 {
+ self.0.into_iter().next().unwrap().0.into_value()
+ } else {
+ self.0.into_value()
+ },
+ family: FontFamily => Self(vec![family]),
+ values: Array => Self(values.into_iter().map(|v| v.cast()).collect::<StrResult<_>>()?),
+}
+
+/// The size of text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct TextSize(pub Length);
+
+impl Fold for TextSize {
+ type Output = Abs;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ self.0.em.at(outer) + self.0.abs
+ }
+}
+
+cast! {
+ TextSize,
+ self => self.0.into_value(),
+ v: Length => Self(v),
+}
+
+/// Specifies the bottom or top edge of text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum TextEdge {
+ /// An edge specified using one of the well-known font metrics.
+ Metric(VerticalFontMetric),
+ /// An edge specified as a length.
+ Length(Length),
+}
+
+impl TextEdge {
+ /// Resolve the value of the text edge given a font's metrics.
+ pub fn resolve(self, styles: StyleChain, metrics: &FontMetrics) -> Abs {
+ match self {
+ Self::Metric(metric) => metrics.vertical(metric).resolve(styles),
+ Self::Length(length) => length.resolve(styles),
+ }
+ }
+}
+
+cast! {
+ TextEdge,
+ self => match self {
+ Self::Metric(metric) => metric.into_value(),
+ Self::Length(length) => length.into_value(),
+ },
+ v: VerticalFontMetric => Self::Metric(v),
+ v: Length => Self::Length(v),
+}
+
+/// The direction of text and inline objects in their line.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct TextDir(pub Smart<Dir>);
+
+cast! {
+ TextDir,
+ self => self.0.into_value(),
+ v: Smart<Dir> => {
+ if v.map_or(false, |dir| dir.axis() == Axis::Y) {
+ bail!("text direction must be horizontal");
+ }
+ Self(v)
+ },
+}
+
+impl Resolve for TextDir {
+ type Output = Dir;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ match self.0 {
+ Smart::Auto => TextElem::lang_in(styles).dir(),
+ Smart::Custom(dir) => dir,
+ }
+ }
+}
+
+/// Whether to hyphenate text.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Hyphenate(pub Smart<bool>);
+
+cast! {
+ Hyphenate,
+ self => self.0.into_value(),
+ v: Smart<bool> => Self(v),
+}
+
+impl Resolve for Hyphenate {
+ type Output = bool;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ match self.0 {
+ Smart::Auto => ParElem::justify_in(styles),
+ Smart::Custom(v) => v,
+ }
+ }
+}
+
+/// A stylistic set in a font.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct StylisticSet(u8);
+
+impl StylisticSet {
+ /// Create a new set, clamping to 1-20.
+ pub fn new(index: u8) -> Self {
+ Self(index.clamp(1, 20))
+ }
+
+ /// Get the value, guaranteed to be 1-20.
+ pub fn get(self) -> u8 {
+ self.0
+ }
+}
+
+cast! {
+ StylisticSet,
+ self => self.0.into_value(),
+ v: i64 => match v {
+ 1 ..= 20 => Self::new(v as u8),
+ _ => bail!("stylistic set must be between 1 and 20"),
+ },
+}
+
+/// Which kind of numbers / figures to select.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum NumberType {
+ /// Numbers that fit well with capital text (the OpenType `lnum`
+ /// font feature).
+ Lining,
+ /// Numbers that fit well into a flow of upper- and lowercase text (the
+ /// OpenType `onum` font feature).
+ OldStyle,
+}
+
+/// The width of numbers / figures.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum NumberWidth {
+ /// Numbers with glyph-specific widths (the OpenType `pnum` font feature).
+ Proportional,
+ /// Numbers of equal width (the OpenType `tnum` font feature).
+ Tabular,
+}
+
+/// OpenType font features settings.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct FontFeatures(pub Vec<(Tag, u32)>);
+
+cast! {
+ FontFeatures,
+ self => self.0
+ .into_iter()
+ .map(|(tag, num)| {
+ let bytes = tag.to_bytes();
+ let key = std::str::from_utf8(&bytes).unwrap_or_default();
+ (key.into(), num.into_value())
+ })
+ .collect::<Dict>()
+ .into_value(),
+ values: Array => Self(values
+ .into_iter()
+ .map(|v| {
+ let tag = v.cast::<EcoString>()?;
+ Ok((Tag::from_bytes_lossy(tag.as_bytes()), 1))
+ })
+ .collect::<StrResult<_>>()?),
+ values: Dict => Self(values
+ .into_iter()
+ .map(|(k, v)| {
+ let num = v.cast::<u32>()?;
+ let tag = Tag::from_bytes_lossy(k.as_bytes());
+ Ok((tag, num))
+ })
+ .collect::<StrResult<_>>()?),
+}
+
+impl Fold for FontFeatures {
+ type Output = Self;
+
+ fn fold(mut self, outer: Self::Output) -> Self::Output {
+ self.0.extend(outer.0);
+ self
+ }
+}
diff --git a/crates/typst-library/src/text/quotes.rs b/crates/typst-library/src/text/quotes.rs
new file mode 100644
index 00000000..d0798064
--- /dev/null
+++ b/crates/typst-library/src/text/quotes.rs
@@ -0,0 +1,209 @@
+use typst::syntax::is_newline;
+
+use crate::prelude::*;
+
+/// A language-aware quote that reacts to its context.
+///
+/// Automatically turns into an appropriate opening or closing quote based on
+/// the active [text language]($func/text.lang).
+///
+/// ## Example { #example }
+/// ```example
+/// "This is in quotes."
+///
+/// #set text(lang: "de")
+/// "Das ist in Anführungszeichen."
+///
+/// #set text(lang: "fr")
+/// "C'est entre guillemets."
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: The normal quote characters
+/// (`'` and `"`). Typst automatically makes your quotes smart.
+///
+/// Display: Smart Quote
+/// Category: text
+#[element]
+pub struct SmartQuoteElem {
+ /// Whether this should be a double quote.
+ #[default(true)]
+ pub double: bool,
+
+ /// Whether smart quotes are enabled.
+ ///
+ /// To disable smartness for a single quote, you can also escape it with a
+ /// backslash.
+ ///
+ /// ```example
+ /// #set smartquote(enabled: false)
+ ///
+ /// These are "dumb" quotes.
+ /// ```
+ #[default(true)]
+ pub enabled: bool,
+}
+
+/// State machine for smart quote substitution.
+#[derive(Debug, Clone)]
+pub struct Quoter {
+ /// How many quotes have been opened.
+ quote_depth: usize,
+ /// Whether an opening quote might follow.
+ expect_opening: bool,
+ /// Whether the last character was numeric.
+ last_num: bool,
+}
+
+impl Quoter {
+ /// Start quoting.
+ pub fn new() -> Self {
+ Self {
+ quote_depth: 0,
+ expect_opening: true,
+ last_num: false,
+ }
+ }
+
+ /// Process the last seen character.
+ pub fn last(&mut self, c: char) {
+ self.expect_opening = is_ignorable(c) || is_opening_bracket(c);
+ self.last_num = c.is_numeric();
+ }
+
+ /// Process and substitute a quote.
+ pub fn quote<'a>(
+ &mut self,
+ quotes: &Quotes<'a>,
+ double: bool,
+ peeked: Option<char>,
+ ) -> &'a str {
+ let peeked = peeked.unwrap_or(' ');
+ if self.expect_opening {
+ self.quote_depth += 1;
+ quotes.open(double)
+ } else if self.quote_depth > 0
+ && (peeked.is_ascii_punctuation() || is_ignorable(peeked))
+ {
+ self.quote_depth -= 1;
+ quotes.close(double)
+ } else if self.last_num {
+ quotes.prime(double)
+ } else {
+ quotes.fallback(double)
+ }
+ }
+}
+
+impl Default for Quoter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+fn is_ignorable(c: char) -> bool {
+ c.is_whitespace() || is_newline(c)
+}
+
+fn is_opening_bracket(c: char) -> bool {
+ matches!(c, '(' | '{' | '[')
+}
+
+/// Decides which quotes to substitute smart quotes with.
+pub struct Quotes<'s> {
+ /// The opening single quote.
+ pub single_open: &'s str,
+ /// The closing single quote.
+ pub single_close: &'s str,
+ /// The opening double quote.
+ pub double_open: &'s str,
+ /// The closing double quote.
+ pub double_close: &'s str,
+}
+
+impl<'s> Quotes<'s> {
+ /// Create a new `Quotes` struct with the defaults for a language and
+ /// region.
+ ///
+ /// The language should be specified as an all-lowercase ISO 639-1 code, the
+ /// region as an all-uppercase ISO 3166-alpha2 code.
+ ///
+ /// Currently, the supported languages are: English, Czech, Danish, German,
+ /// Swiss / Liechtensteinian German, Estonian, Icelandic, Lithuanian,
+ /// Latvian, Slovak, Slovenian, Spanish, Bosnian, Finnish, Swedish, French,
+ /// Hungarian, Polish, Romanian, Japanese, Traditional Chinese, Russian, and
+ /// Norwegian.
+ ///
+ /// For unknown languages, the English quotes are used.
+ pub fn from_lang(lang: Lang, region: Option<Region>) -> Self {
+ let region = region.as_ref().map(Region::as_str);
+ let (single_open, single_close, double_open, double_close) = match lang.as_str() {
+ "de" if matches!(region, Some("CH" | "LI")) => ("‹", "›", "«", "»"),
+ "cs" | "da" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => {
+ ("‚", "‘", "„", "“")
+ }
+ "fr" => ("‹\u{00A0}", "\u{00A0}›", "«\u{00A0}", "\u{00A0}»"),
+ "bs" | "fi" | "sv" => ("’", "’", "”", "”"),
+ "es" if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"),
+ "hu" | "pl" | "ro" => ("’", "’", "„", "”"),
+ "ru" | "no" | "nb" | "nn" | "ua" => ("’", "’", "«", "»"),
+ _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"),
+ _ => return Self::default(),
+ };
+
+ Self {
+ single_open,
+ single_close,
+ double_open,
+ double_close,
+ }
+ }
+
+ /// The opening quote.
+ fn open(&self, double: bool) -> &'s str {
+ if double {
+ self.double_open
+ } else {
+ self.single_open
+ }
+ }
+
+ /// The closing quote.
+ fn close(&self, double: bool) -> &'s str {
+ if double {
+ self.double_close
+ } else {
+ self.single_close
+ }
+ }
+
+ /// Which character should be used as a prime.
+ fn prime(&self, double: bool) -> &'static str {
+ if double {
+ "″"
+ } else {
+ "′"
+ }
+ }
+
+ /// Which character should be used as a fallback quote.
+ fn fallback(&self, double: bool) -> &'static str {
+ if double {
+ "\""
+ } else {
+ "’"
+ }
+ }
+}
+
+impl Default for Quotes<'_> {
+ /// Returns the english quotes as default.
+ fn default() -> Self {
+ Self {
+ single_open: "‘",
+ single_close: "’",
+ double_open: "“",
+ double_close: "”",
+ }
+ }
+}
diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs
new file mode 100644
index 00000000..24f39bb6
--- /dev/null
+++ b/crates/typst-library/src/text/raw.rs
@@ -0,0 +1,398 @@
+use once_cell::sync::Lazy;
+use syntect::highlighting as synt;
+use typst::syntax::{self, LinkedNode};
+
+use super::{
+ FontFamily, FontList, Hyphenate, LinebreakElem, SmartQuoteElem, TextElem, TextSize,
+};
+use crate::layout::BlockElem;
+use crate::meta::{Figurable, LocalName};
+use crate::prelude::*;
+
+/// Raw text with optional syntax highlighting.
+///
+/// Displays the text verbatim and in a monospace font. This is typically used
+/// to embed computer code into your document.
+///
+/// ## Example { #example }
+/// ````example
+/// Adding `rbx` to `rcx` gives
+/// the desired result.
+///
+/// ```rust
+/// fn main() {
+/// println!("Hello World!");
+/// }
+/// ```
+/// ````
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax. You can enclose text in 1 or 3+
+/// backticks (`` ` ``) to make it raw. Two backticks produce empty raw text.
+/// When you use three or more backticks, you can additionally specify a
+/// language tag for syntax highlighting directly after the opening backticks.
+/// Within raw blocks, everything is rendered as is, in particular, there are no
+/// escape sequences.
+///
+/// Display: Raw Text / Code
+/// Category: text
+#[element(Synthesize, Show, Finalize, LocalName, Figurable, PlainText)]
+pub struct RawElem {
+ /// The raw text.
+ ///
+ /// You can also use raw blocks creatively to create custom syntaxes for
+ /// your automations.
+ ///
+ /// ````example
+ /// // Parse numbers in raw blocks with the
+ /// // `mydsl` tag and sum them up.
+ /// #show raw.where(lang: "mydsl"): it => {
+ /// let sum = 0
+ /// for part in it.text.split("+") {
+ /// sum += int(part.trim())
+ /// }
+ /// sum
+ /// }
+ ///
+ /// ```mydsl
+ /// 1 + 2 + 3 + 4 + 5
+ /// ```
+ /// ````
+ #[required]
+ pub text: EcoString,
+
+ /// Whether the raw text is displayed as a separate block.
+ ///
+ /// In markup mode, using one-backtick notation makes this `{false}`,
+ /// whereas using three-backtick notation makes it `{true}`.
+ ///
+ /// ````example
+ /// // Display inline code in a small box
+ /// // that retains the correct baseline.
+ /// #show raw.where(block: false): box.with(
+ /// fill: luma(240),
+ /// inset: (x: 3pt, y: 0pt),
+ /// outset: (y: 3pt),
+ /// radius: 2pt,
+ /// )
+ ///
+ /// // Display block code in a larger block
+ /// // with more padding.
+ /// #show raw.where(block: true): block.with(
+ /// fill: luma(240),
+ /// inset: 10pt,
+ /// radius: 4pt,
+ /// )
+ ///
+ /// With `rg`, you can search through your files quickly.
+ ///
+ /// ```bash
+ /// rg "Hello World"
+ /// ```
+ /// ````
+ #[default(false)]
+ pub block: bool,
+
+ /// The language to syntax-highlight in.
+ ///
+ /// Apart from typical language tags known from Markdown, this supports the
+ /// `{"typ"}` and `{"typc"}` tags for Typst markup and Typst code,
+ /// respectively.
+ ///
+ /// ````example
+ /// ```typ
+ /// This is *Typst!*
+ /// ```
+ /// ````
+ pub lang: Option<EcoString>,
+
+ /// The horizontal alignment that each line in a raw block should have.
+ /// This option is ignored if this is not a raw block (if specified
+ /// `block: false` or single backticks were used in markup mode).
+ ///
+ /// By default, this is set to `{start}`, meaning that raw text is
+ /// aligned towards the start of the text direction inside the block
+ /// by default, regardless of the current context's alignment (allowing
+ /// you to center the raw block itself without centering the text inside
+ /// it, for example).
+ ///
+ /// ````example
+ /// #set raw(align: center)
+ ///
+ /// ```typc
+ /// let f(x) = x
+ /// code = "centered"
+ /// ```
+ /// ````
+ #[default(HorizontalAlign(GenAlign::Start))]
+ pub align: HorizontalAlign,
+}
+
+impl RawElem {
+ /// The supported language names and tags.
+ pub fn languages() -> Vec<(&'static str, Vec<&'static str>)> {
+ SYNTAXES
+ .syntaxes()
+ .iter()
+ .map(|syntax| {
+ (
+ syntax.name.as_str(),
+ syntax.file_extensions.iter().map(|s| s.as_str()).collect(),
+ )
+ })
+ .chain([("Typst", vec!["typ"]), ("Typst (code)", vec!["typc"])])
+ .collect()
+ }
+}
+
+impl Synthesize for RawElem {
+ fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
+ self.push_lang(self.lang(styles));
+ Ok(())
+ }
+}
+
+impl Show for RawElem {
+ #[tracing::instrument(name = "RawElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let text = self.text();
+ let lang = self.lang(styles).as_ref().map(|s| s.to_lowercase());
+ let foreground = THEME
+ .settings
+ .foreground
+ .map(to_typst)
+ .map_or(Color::BLACK, Color::from);
+
+ let mut realized = if matches!(lang.as_deref(), Some("typ" | "typst" | "typc")) {
+ let root = match lang.as_deref() {
+ Some("typc") => syntax::parse_code(&text),
+ _ => syntax::parse(&text),
+ };
+
+ let mut seq = vec![];
+ let highlighter = synt::Highlighter::new(&THEME);
+ highlight_themed(
+ &LinkedNode::new(&root),
+ vec![],
+ &highlighter,
+ &mut |node, style| {
+ seq.push(styled(&text[node.range()], foreground.into(), style));
+ },
+ );
+
+ Content::sequence(seq)
+ } else if let Some(syntax) =
+ lang.and_then(|token| SYNTAXES.find_syntax_by_token(&token))
+ {
+ let mut seq = vec![];
+ let mut highlighter = syntect::easy::HighlightLines::new(syntax, &THEME);
+ for (i, line) in text.lines().enumerate() {
+ if i != 0 {
+ seq.push(LinebreakElem::new().pack());
+ }
+
+ for (style, piece) in
+ highlighter.highlight_line(line, &SYNTAXES).into_iter().flatten()
+ {
+ seq.push(styled(piece, foreground.into(), style));
+ }
+ }
+
+ Content::sequence(seq)
+ } else {
+ TextElem::packed(text)
+ };
+
+ if self.block(styles) {
+ // Align the text before inserting it into the block.
+ realized = realized.aligned(Axes::with_x(Some(self.align(styles).into())));
+ realized = BlockElem::new().with_body(Some(realized)).pack();
+ }
+
+ Ok(realized)
+ }
+}
+
+impl Finalize for RawElem {
+ fn finalize(&self, realized: Content, _: StyleChain) -> Content {
+ let mut styles = Styles::new();
+ styles.set(TextElem::set_overhang(false));
+ styles.set(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false))));
+ styles.set(TextElem::set_size(TextSize(Em::new(0.8).into())));
+ styles
+ .set(TextElem::set_font(FontList(vec![FontFamily::new("DejaVu Sans Mono")])));
+ styles.set(SmartQuoteElem::set_enabled(false));
+ realized.styled_with_map(styles)
+ }
+}
+
+impl LocalName for RawElem {
+ fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "List",
+ Lang::ARABIC => "قائمة",
+ Lang::BOKMÅL => "Utskrift",
+ Lang::CHINESE => "代码",
+ Lang::CZECH => "Seznam",
+ Lang::DANISH => "Liste",
+ Lang::DUTCH => "Listing",
+ Lang::FILIPINO => "Listahan",
+ Lang::FRENCH => "Liste",
+ Lang::GERMAN => "Listing",
+ Lang::ITALIAN => "Codice",
+ Lang::NYNORSK => "Utskrift",
+ Lang::POLISH => "Program",
+ Lang::RUSSIAN => "Листинг",
+ Lang::SLOVENIAN => "Program",
+ Lang::SWEDISH => "Listing",
+ Lang::TURKISH => "Liste",
+ Lang::UKRAINIAN => "Лістинг",
+ Lang::VIETNAMESE => "Chương trình", // TODO: This may be wrong.
+ Lang::ENGLISH | _ => "Listing",
+ }
+ }
+}
+
+impl Figurable for RawElem {}
+
+impl PlainText for RawElem {
+ fn plain_text(&self, text: &mut EcoString) {
+ text.push_str(&self.text());
+ }
+}
+
+/// Highlight a syntax node in a theme by calling `f` with ranges and their
+/// styles.
+fn highlight_themed<F>(
+ node: &LinkedNode,
+ scopes: Vec<syntect::parsing::Scope>,
+ highlighter: &synt::Highlighter,
+ f: &mut F,
+) where
+ F: FnMut(&LinkedNode, synt::Style),
+{
+ if node.children().len() == 0 {
+ let style = highlighter.style_for_stack(&scopes);
+ f(node, style);
+ return;
+ }
+
+ for child in node.children() {
+ let mut scopes = scopes.clone();
+ if let Some(tag) = typst::ide::highlight(&child) {
+ scopes.push(syntect::parsing::Scope::new(tag.tm_scope()).unwrap())
+ }
+ highlight_themed(&child, scopes, highlighter, f);
+ }
+}
+
+/// Style a piece of text with a syntect style.
+fn styled(piece: &str, foreground: Paint, style: synt::Style) -> Content {
+ let mut body = TextElem::packed(piece);
+
+ let paint = to_typst(style.foreground).into();
+ if paint != foreground {
+ body = body.styled(TextElem::set_fill(paint));
+ }
+
+ if style.font_style.contains(synt::FontStyle::BOLD) {
+ body = body.strong();
+ }
+
+ if style.font_style.contains(synt::FontStyle::ITALIC) {
+ body = body.emph();
+ }
+
+ if style.font_style.contains(synt::FontStyle::UNDERLINE) {
+ body = body.underlined();
+ }
+
+ body
+}
+
+fn to_typst(synt::Color { r, g, b, a }: synt::Color) -> RgbaColor {
+ RgbaColor { r, g, b, a }
+}
+
+fn to_syn(RgbaColor { r, g, b, a }: RgbaColor) -> synt::Color {
+ synt::Color { r, g, b, a }
+}
+
+/// The syntect syntax definitions.
+///
+/// Code for syntax set generation is below. The `syntaxes` directory is from
+/// <https://github.com/sharkdp/bat/tree/master/assets/syntaxes>
+///
+/// ```ignore
+/// fn main() {
+/// let mut builder = syntect::parsing::SyntaxSet::load_defaults_nonewlines().into_builder();
+/// builder.add_from_folder("syntaxes/02_Extra", false).unwrap();
+/// syntect::dumps::dump_to_file(&builder.build(), "syntect.bin").unwrap();
+/// }
+/// ```
+///
+/// The following syntaxes are disabled due to compatibility issues:
+/// ```text
+/// syntaxes/02_Extra/Assembly (ARM).sublime-syntax
+/// syntaxes/02_Extra/Elixir/Regular Expressions (Elixir).sublime-syntax
+/// syntaxes/02_Extra/JavaScript (Babel).sublime-syntax
+/// syntaxes/02_Extra/LiveScript.sublime-syntax
+/// syntaxes/02_Extra/PowerShell.sublime-syntax
+/// syntaxes/02_Extra/SCSS_Sass/Syntaxes/Sass.sublime-syntax
+/// syntaxes/02_Extra/SLS/SLS.sublime-syntax
+/// syntaxes/02_Extra/VimHelp.sublime-syntax
+/// syntaxes/02_Extra/cmd-help/syntaxes/cmd-help.sublime-syntax
+/// ```
+pub static SYNTAXES: Lazy<syntect::parsing::SyntaxSet> =
+ Lazy::new(|| syntect::dumps::from_binary(include_bytes!("../../assets/syntect.bin")));
+
+/// The default theme used for syntax highlighting.
+pub static THEME: Lazy<synt::Theme> = Lazy::new(|| synt::Theme {
+ name: Some("Typst Light".into()),
+ author: Some("The Typst Project Developers".into()),
+ settings: synt::ThemeSettings::default(),
+ scopes: vec![
+ item("comment", Some("#8a8a8a"), None),
+ item("constant.character.escape", Some("#1d6c76"), None),
+ item("markup.bold", None, Some(synt::FontStyle::BOLD)),
+ item("markup.italic", None, Some(synt::FontStyle::ITALIC)),
+ item("markup.underline", None, Some(synt::FontStyle::UNDERLINE)),
+ item("markup.raw", Some("#818181"), None),
+ item("string.other.math.typst", None, None),
+ item("punctuation.definition.math", Some("#298e0d"), None),
+ item("keyword.operator.math", Some("#1d6c76"), None),
+ item("markup.heading, entity.name.section", None, Some(synt::FontStyle::BOLD)),
+ item(
+ "markup.heading.typst",
+ None,
+ Some(synt::FontStyle::BOLD | synt::FontStyle::UNDERLINE),
+ ),
+ item("punctuation.definition.list", Some("#8b41b1"), None),
+ item("markup.list.term", None, Some(synt::FontStyle::BOLD)),
+ item("entity.name.label, markup.other.reference", Some("#1d6c76"), None),
+ item("keyword, constant.language, variable.language", Some("#d73a49"), None),
+ item("storage.type, storage.modifier", Some("#d73a49"), None),
+ item("constant", Some("#b60157"), None),
+ item("string", Some("#298e0d"), None),
+ item("entity.name, variable.function, support", Some("#4b69c6"), None),
+ item("support.macro", Some("#16718d"), None),
+ item("meta.annotation", Some("#301414"), None),
+ item("entity.other, meta.interpolation", Some("#8b41b1"), None),
+ ],
+});
+
+/// Create a syntect theme item.
+fn item(
+ scope: &str,
+ color: Option<&str>,
+ font_style: Option<synt::FontStyle>,
+) -> synt::ThemeItem {
+ synt::ThemeItem {
+ scope: scope.parse().unwrap(),
+ style: synt::StyleModifier {
+ foreground: color.map(|s| to_syn(s.parse::<RgbaColor>().unwrap())),
+ background: None,
+ font_style,
+ },
+ }
+}
diff --git a/crates/typst-library/src/text/shaping.rs b/crates/typst-library/src/text/shaping.rs
new file mode 100644
index 00000000..ec8812fe
--- /dev/null
+++ b/crates/typst-library/src/text/shaping.rs
@@ -0,0 +1,973 @@
+use std::borrow::Cow;
+use std::ops::Range;
+use std::str::FromStr;
+
+use az::SaturatingAs;
+use rustybuzz::{Feature, Tag, UnicodeBuffer};
+use typst::font::{Font, FontStyle, FontVariant};
+use typst::util::SliceExt;
+use unicode_script::{Script, UnicodeScript};
+
+use super::{decorate, FontFamily, NumberType, NumberWidth, TextElem};
+use crate::layout::SpanMapper;
+use crate::prelude::*;
+
+/// The result of shaping text.
+///
+/// This type contains owned or borrowed shaped text runs, which can be
+/// measured, used to reshape substrings more quickly and converted into a
+/// frame.
+pub struct ShapedText<'a> {
+ /// The start of the text in the full paragraph.
+ pub base: usize,
+ /// The text that was shaped.
+ pub text: &'a str,
+ /// The text direction.
+ pub dir: Dir,
+ /// The text language.
+ pub lang: Lang,
+ /// The text region.
+ pub region: Option<Region>,
+ /// The text's style properties.
+ pub styles: StyleChain<'a>,
+ /// The font variant.
+ pub variant: FontVariant,
+ /// The font size.
+ pub size: Abs,
+ /// The width of the text's bounding box.
+ pub width: Abs,
+ /// The shaped glyphs.
+ pub glyphs: Cow<'a, [ShapedGlyph]>,
+}
+
+/// A single glyph resulting from shaping.
+#[derive(Debug, Clone)]
+pub struct ShapedGlyph {
+ /// The font the glyph is contained in.
+ pub font: Font,
+ /// The glyph's index in the font.
+ pub glyph_id: u16,
+ /// The advance width of the glyph.
+ pub x_advance: Em,
+ /// The horizontal offset of the glyph.
+ pub x_offset: Em,
+ /// The vertical offset of the glyph.
+ pub y_offset: Em,
+ /// The adjustability of the glyph.
+ pub adjustability: Adjustability,
+ /// The byte range of this glyph's cluster in the full paragraph. A cluster
+ /// is a sequence of one or multiple glyphs that cannot be separated and
+ /// must always be treated as a union.
+ ///
+ /// The range values of the glyphs in a [`ShapedText`] should not
+ /// overlap with each other, and they should be monotonically
+ /// increasing (for left-to-right or top-to-bottom text) or
+ /// monotonically decreasing (for right-to-left or bottom-to-top
+ /// text).
+ pub range: Range<usize>,
+ /// Whether splitting the shaping result before this glyph would yield the
+ /// same results as shaping the parts to both sides of `text_index`
+ /// separately.
+ pub safe_to_break: bool,
+ /// The first char in this glyph's cluster.
+ pub c: char,
+ /// The source code location of the glyph and its byte offset within it.
+ pub span: (Span, u16),
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct Adjustability {
+ /// The left and right strechability
+ pub stretchability: (Em, Em),
+ /// The left and right shrinkability
+ pub shrinkability: (Em, Em),
+}
+
+impl ShapedGlyph {
+ /// Whether the glyph is a space.
+ pub fn is_space(&self) -> bool {
+ matches!(self.c, ' ' | '\u{00A0}' | ' ')
+ }
+
+ /// Whether the glyph is justifiable.
+ pub fn is_justifiable(&self) -> bool {
+ // GB style is not relevant here.
+ self.is_space()
+ || self.is_cjk_script()
+ || self.is_cjk_left_aligned_punctuation(true)
+ || self.is_cjk_right_aligned_punctuation()
+ || self.is_cjk_center_aligned_punctuation(true)
+ }
+
+ pub fn is_cjk_script(&self) -> bool {
+ use Script::*;
+ // U+30FC: Katakana-Hiragana Prolonged Sound Mark
+ matches!(self.c.script(), Hiragana | Katakana | Han) || self.c == '\u{30FC}'
+ }
+
+ pub fn is_cjk_punctuation(&self) -> bool {
+ self.is_cjk_left_aligned_punctuation(true)
+ || self.is_cjk_right_aligned_punctuation()
+ || self.is_cjk_center_aligned_punctuation(true)
+ }
+
+ /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+ pub fn is_cjk_left_aligned_punctuation(&self, gb_style: bool) -> bool {
+ // CJK quotation marks shares codepoints with latin quotation marks.
+ // But only the CJK ones have full width.
+ if matches!(self.c, '”' | '’')
+ && self.x_advance + self.stretchability().1 == Em::one()
+ {
+ return true;
+ }
+
+ if gb_style && matches!(self.c, ',' | '。' | '、' | ':' | ';') {
+ return true;
+ }
+
+ matches!(self.c, '》' | ')' | '』' | '」')
+ }
+
+ /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+ pub fn is_cjk_right_aligned_punctuation(&self) -> bool {
+ // CJK quotation marks shares codepoints with latin quotation marks.
+ // But only the CJK ones have full width.
+ if matches!(self.c, '“' | '‘')
+ && self.x_advance + self.stretchability().0 == Em::one()
+ {
+ return true;
+ }
+
+ matches!(self.c, '《' | '(' | '『' | '「')
+ }
+
+ /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+ pub fn is_cjk_center_aligned_punctuation(&self, gb_style: bool) -> bool {
+ if !gb_style && matches!(self.c, ',' | '。' | '、' | ':' | ';') {
+ return true;
+ }
+
+ // U+30FB: Katakana Middle Dot
+ matches!(self.c, '\u{30FB}')
+ }
+
+ pub fn base_adjustability(&self, gb_style: bool) -> Adjustability {
+ let width = self.x_advance;
+ if self.is_space() {
+ Adjustability {
+ // The number for spaces is from Knuth-Plass' paper
+ stretchability: (Em::zero(), width / 2.0),
+ shrinkability: (Em::zero(), width / 3.0),
+ }
+ } else if self.is_cjk_left_aligned_punctuation(gb_style) {
+ Adjustability {
+ stretchability: (Em::zero(), Em::zero()),
+ shrinkability: (Em::zero(), width / 2.0),
+ }
+ } else if self.is_cjk_right_aligned_punctuation() {
+ Adjustability {
+ stretchability: (Em::zero(), Em::zero()),
+ shrinkability: (width / 2.0, Em::zero()),
+ }
+ } else if self.is_cjk_center_aligned_punctuation(gb_style) {
+ Adjustability {
+ stretchability: (Em::zero(), Em::zero()),
+ shrinkability: (width / 4.0, width / 4.0),
+ }
+ } else {
+ Adjustability::default()
+ }
+ }
+
+ /// The stretchability of the character.
+ pub fn stretchability(&self) -> (Em, Em) {
+ self.adjustability.stretchability
+ }
+
+ /// The shrinkability of the character.
+ pub fn shrinkability(&self) -> (Em, Em) {
+ self.adjustability.shrinkability
+ }
+
+ /// Shrink the width of glyph on the left side.
+ pub fn shrink_left(&mut self, amount: Em) {
+ self.x_offset -= amount;
+ self.x_advance -= amount;
+ self.adjustability.shrinkability.0 -= amount;
+ self.adjustability.stretchability.0 += amount;
+ }
+
+ /// Shrink the width of glyph on the right side.
+ pub fn shrink_right(&mut self, amount: Em) {
+ self.x_advance -= amount;
+ self.adjustability.shrinkability.1 -= amount;
+ self.adjustability.stretchability.1 += amount;
+ }
+}
+
+/// A side you can go toward.
+enum Side {
+ /// To the left-hand side.
+ Left,
+ /// To the right-hand side.
+ Right,
+}
+
+impl<'a> ShapedText<'a> {
+ /// Build the shaped text's frame.
+ ///
+ /// The `justification` defines how much extra advance width each
+ /// [justifiable glyph](ShapedGlyph::is_justifiable) will get.
+ pub fn build(
+ &self,
+ vt: &Vt,
+ justification_ratio: f64,
+ extra_justification: Abs,
+ ) -> Frame {
+ let (top, bottom) = self.measure(vt);
+ let size = Size::new(self.width, top + bottom);
+
+ let mut offset = Abs::zero();
+ let mut frame = Frame::new(size);
+ frame.set_baseline(top);
+
+ let shift = TextElem::baseline_in(self.styles);
+ let lang = TextElem::lang_in(self.styles);
+ let decos = TextElem::deco_in(self.styles);
+ let fill = TextElem::fill_in(self.styles);
+
+ for ((font, y_offset), group) in
+ self.glyphs.as_ref().group_by_key(|g| (g.font.clone(), g.y_offset))
+ {
+ let mut range = group[0].range.clone();
+ for glyph in group {
+ range.start = range.start.min(glyph.range.start);
+ range.end = range.end.max(glyph.range.end);
+ }
+
+ let pos = Point::new(offset, top + shift - y_offset.at(self.size));
+ let glyphs = group
+ .iter()
+ .map(|glyph| {
+ let adjustability_left = if justification_ratio < 0.0 {
+ glyph.shrinkability().0
+ } else {
+ glyph.stretchability().0
+ };
+ let adjustability_right = if justification_ratio < 0.0 {
+ glyph.shrinkability().1
+ } else {
+ glyph.stretchability().1
+ };
+
+ let justification_left = adjustability_left * justification_ratio;
+ let mut justification_right =
+ adjustability_right * justification_ratio;
+ if glyph.is_justifiable() {
+ justification_right +=
+ Em::from_length(extra_justification, self.size)
+ }
+
+ frame.size_mut().x += justification_left.at(self.size)
+ + justification_right.at(self.size);
+
+ Glyph {
+ id: glyph.glyph_id,
+ x_advance: glyph.x_advance
+ + justification_left
+ + justification_right,
+ x_offset: glyph.x_offset + justification_left,
+ range: (glyph.range.start - range.start).saturating_as()
+ ..(glyph.range.end - range.start).saturating_as(),
+ span: glyph.span,
+ }
+ })
+ .collect();
+
+ let item = TextItem {
+ font,
+ size: self.size,
+ lang,
+ fill: fill.clone(),
+ text: self.text[range.start - self.base..range.end - self.base].into(),
+ glyphs,
+ };
+
+ let layer = frame.layer();
+ let width = item.width();
+
+ // Apply line decorations.
+ for deco in &decos {
+ decorate(&mut frame, deco, &item, shift, pos, width);
+ }
+
+ frame.insert(layer, pos, FrameItem::Text(item));
+ offset += width;
+ }
+
+ // Apply metadata.
+ frame.meta(self.styles, false);
+
+ frame
+ }
+
+ /// Measure the top and bottom extent of this text.
+ fn measure(&self, vt: &Vt) -> (Abs, Abs) {
+ let mut top = Abs::zero();
+ let mut bottom = Abs::zero();
+
+ let top_edge = TextElem::top_edge_in(self.styles);
+ let bottom_edge = TextElem::bottom_edge_in(self.styles);
+
+ // Expand top and bottom by reading the font's vertical metrics.
+ let mut expand = |font: &Font| {
+ let metrics = font.metrics();
+ top.set_max(top_edge.resolve(self.styles, metrics));
+ bottom.set_max(-bottom_edge.resolve(self.styles, metrics));
+ };
+
+ if self.glyphs.is_empty() {
+ // When there are no glyphs, we just use the vertical metrics of the
+ // first available font.
+ let world = vt.world;
+ for family in families(self.styles) {
+ if let Some(font) = world
+ .book()
+ .select(family.as_str(), self.variant)
+ .and_then(|id| world.font(id))
+ {
+ expand(&font);
+ break;
+ }
+ }
+ } else {
+ for g in self.glyphs.iter() {
+ expand(&g.font);
+ }
+ }
+
+ (top, bottom)
+ }
+
+ /// How many glyphs are in the text where we can insert additional
+ /// space when encountering underfull lines.
+ pub fn justifiables(&self) -> usize {
+ self.glyphs.iter().filter(|g| g.is_justifiable()).count()
+ }
+
+ /// Whether the last glyph is a CJK character which should not be justified
+ /// on line end.
+ pub fn cjk_justifiable_at_last(&self) -> bool {
+ self.glyphs
+ .last()
+ .map(|g| g.is_cjk_script() || g.is_cjk_punctuation())
+ .unwrap_or(false)
+ }
+
+ /// The stretchability of the text.
+ pub fn stretchability(&self) -> Abs {
+ self.glyphs
+ .iter()
+ .map(|g| g.stretchability().0 + g.stretchability().1)
+ .sum::<Em>()
+ .at(self.size)
+ }
+
+ /// The shrinkability of the text
+ pub fn shrinkability(&self) -> Abs {
+ self.glyphs
+ .iter()
+ .map(|g| g.shrinkability().0 + g.shrinkability().1)
+ .sum::<Em>()
+ .at(self.size)
+ }
+
+ /// Reshape a range of the shaped text, reusing information from this
+ /// shaping process if possible.
+ ///
+ /// The text `range` is relative to the whole paragraph.
+ pub fn reshape(
+ &'a self,
+ vt: &Vt,
+ spans: &SpanMapper,
+ text_range: Range<usize>,
+ ) -> ShapedText<'a> {
+ let text = &self.text[text_range.start - self.base..text_range.end - self.base];
+ if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) {
+ #[cfg(debug_assertions)]
+ assert_all_glyphs_in_range(glyphs, text, text_range.clone());
+ Self {
+ base: text_range.start,
+ text,
+ dir: self.dir,
+ lang: self.lang,
+ region: self.region,
+ styles: self.styles,
+ size: self.size,
+ variant: self.variant,
+ width: glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(self.size),
+ glyphs: Cow::Borrowed(glyphs),
+ }
+ } else {
+ shape(
+ vt,
+ text_range.start,
+ text,
+ spans,
+ self.styles,
+ self.dir,
+ self.lang,
+ self.region,
+ )
+ }
+ }
+
+ /// Push a hyphen to end of the text.
+ pub fn push_hyphen(&mut self, vt: &Vt) {
+ families(self.styles).find_map(|family| {
+ let world = vt.world;
+ let font = world
+ .book()
+ .select(family.as_str(), self.variant)
+ .and_then(|id| world.font(id))?;
+ let ttf = font.ttf();
+ let glyph_id = ttf.glyph_index('-')?;
+ let x_advance = font.to_em(ttf.glyph_hor_advance(glyph_id)?);
+ let range = self
+ .glyphs
+ .last()
+ .map(|g| g.range.end..g.range.end)
+ .unwrap_or_default();
+ self.width += x_advance.at(self.size);
+ self.glyphs.to_mut().push(ShapedGlyph {
+ font,
+ glyph_id: glyph_id.0,
+ x_advance,
+ x_offset: Em::zero(),
+ y_offset: Em::zero(),
+ adjustability: Adjustability::default(),
+ range,
+ safe_to_break: true,
+ c: '-',
+ span: (Span::detached(), 0),
+ });
+ Some(())
+ });
+ }
+
+ /// Find the subslice of glyphs that represent the given text range if both
+ /// sides are safe to break.
+ fn slice_safe_to_break(&self, text_range: Range<usize>) -> Option<&[ShapedGlyph]> {
+ let Range { mut start, mut end } = text_range;
+ if !self.dir.is_positive() {
+ std::mem::swap(&mut start, &mut end);
+ }
+
+ let left = self.find_safe_to_break(start, Side::Left)?;
+ let right = self.find_safe_to_break(end, Side::Right)?;
+ Some(&self.glyphs[left..right])
+ }
+
+ /// Find the glyph offset matching the text index that is most towards the
+ /// given side and safe-to-break.
+ fn find_safe_to_break(&self, text_index: usize, towards: Side) -> Option<usize> {
+ let ltr = self.dir.is_positive();
+
+ // Handle edge cases.
+ let len = self.glyphs.len();
+ if text_index == self.base {
+ return Some(if ltr { 0 } else { len });
+ } else if text_index == self.base + self.text.len() {
+ return Some(if ltr { len } else { 0 });
+ }
+
+ // Find any glyph with the text index.
+ let mut idx = self
+ .glyphs
+ .binary_search_by(|g| {
+ let ordering = g.range.start.cmp(&text_index);
+ if ltr {
+ ordering
+ } else {
+ ordering.reverse()
+ }
+ })
+ .ok()?;
+
+ let next = match towards {
+ Side::Left => usize::checked_sub,
+ Side::Right => usize::checked_add,
+ };
+
+ // Search for the outermost glyph with the text index.
+ while let Some(next) = next(idx, 1) {
+ if self.glyphs.get(next).map_or(true, |g| g.range.start != text_index) {
+ break;
+ }
+ idx = next;
+ }
+
+ // RTL needs offset one because the left side of the range should be
+ // exclusive and the right side inclusive, contrary to the normal
+ // behaviour of ranges.
+ self.glyphs[idx].safe_to_break.then_some(idx + usize::from(!ltr))
+ }
+}
+
+impl Debug for ShapedText<'_> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.text.fmt(f)
+ }
+}
+
+/// Holds shaping results and metadata common to all shaped segments.
+struct ShapingContext<'a, 'v> {
+ vt: &'a Vt<'v>,
+ spans: &'a SpanMapper,
+ glyphs: Vec<ShapedGlyph>,
+ used: Vec<Font>,
+ styles: StyleChain<'a>,
+ size: Abs,
+ variant: FontVariant,
+ tags: Vec<rustybuzz::Feature>,
+ fallback: bool,
+ dir: Dir,
+}
+
+/// Shape text into [`ShapedText`].
+#[allow(clippy::too_many_arguments)]
+pub fn shape<'a>(
+ vt: &Vt,
+ base: usize,
+ text: &'a str,
+ spans: &SpanMapper,
+ styles: StyleChain<'a>,
+ dir: Dir,
+ lang: Lang,
+ region: Option<Region>,
+) -> ShapedText<'a> {
+ let size = TextElem::size_in(styles);
+ let mut ctx = ShapingContext {
+ vt,
+ spans,
+ size,
+ glyphs: vec![],
+ used: vec![],
+ styles,
+ variant: variant(styles),
+ tags: tags(styles),
+ fallback: TextElem::fallback_in(styles),
+ dir,
+ };
+
+ if !text.is_empty() {
+ shape_segment(&mut ctx, base, text, families(styles));
+ }
+
+ track_and_space(&mut ctx);
+ calculate_adjustability(&mut ctx, lang, region);
+
+ #[cfg(debug_assertions)]
+ assert_all_glyphs_in_range(&ctx.glyphs, text, base..(base + text.len()));
+ #[cfg(debug_assertions)]
+ assert_glyph_ranges_in_order(&ctx.glyphs, dir);
+
+ ShapedText {
+ base,
+ text,
+ dir,
+ lang,
+ region,
+ styles,
+ variant: ctx.variant,
+ size,
+ width: ctx.glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(size),
+ glyphs: Cow::Owned(ctx.glyphs),
+ }
+}
+
+/// Shape text with font fallback using the `families` iterator.
+fn shape_segment(
+ ctx: &mut ShapingContext,
+ base: usize,
+ text: &str,
+ mut families: impl Iterator<Item = FontFamily> + Clone,
+) {
+ // Fonts dont have newlines and tabs.
+ if text.chars().all(|c| c == '\n' || c == '\t') {
+ return;
+ }
+
+ // Find the next available family.
+ let world = ctx.vt.world;
+ let book = world.book();
+ let mut selection = families.find_map(|family| {
+ book.select(family.as_str(), ctx.variant)
+ .and_then(|id| world.font(id))
+ .filter(|font| !ctx.used.contains(font))
+ });
+
+ // Do font fallback if the families are exhausted and fallback is enabled.
+ if selection.is_none() && ctx.fallback {
+ let first = ctx.used.first().map(Font::info);
+ selection = book
+ .select_fallback(first, ctx.variant, text)
+ .and_then(|id| world.font(id))
+ .filter(|font| !ctx.used.contains(font));
+ }
+
+ // Extract the font id or shape notdef glyphs if we couldn't find any font.
+ let Some(font) = selection else {
+ if let Some(font) = ctx.used.first().cloned() {
+ shape_tofus(ctx, base, text, font);
+ }
+ return;
+ };
+
+ ctx.used.push(font.clone());
+
+ // Fill the buffer with our text.
+ let mut buffer = UnicodeBuffer::new();
+ buffer.push_str(text);
+ buffer.set_language(language(ctx.styles));
+ buffer.set_direction(match ctx.dir {
+ Dir::LTR => rustybuzz::Direction::LeftToRight,
+ Dir::RTL => rustybuzz::Direction::RightToLeft,
+ _ => unimplemented!("vertical text layout"),
+ });
+
+ // Shape!
+ let buffer = rustybuzz::shape(font.rusty(), &ctx.tags, buffer);
+ let infos = buffer.glyph_infos();
+ let pos = buffer.glyph_positions();
+ let ltr = ctx.dir.is_positive();
+
+ // Collect the shaped glyphs, doing fallback and shaping parts again with
+ // the next font if necessary.
+ let mut i = 0;
+ while i < infos.len() {
+ let info = &infos[i];
+ let cluster = info.cluster as usize;
+
+ // Add the glyph to the shaped output.
+ if info.glyph_id != 0 {
+ // Determine the text range of the glyph.
+ let start = base + cluster;
+ let end = base
+ + if ltr { i.checked_add(1) } else { i.checked_sub(1) }
+ .and_then(|last| infos.get(last))
+ .map_or(text.len(), |info| info.cluster as usize);
+
+ ctx.glyphs.push(ShapedGlyph {
+ font: font.clone(),
+ glyph_id: info.glyph_id as u16,
+ // TODO: Don't ignore y_advance.
+ x_advance: font.to_em(pos[i].x_advance),
+ x_offset: font.to_em(pos[i].x_offset),
+ y_offset: font.to_em(pos[i].y_offset),
+ adjustability: Adjustability::default(),
+ range: start..end,
+ safe_to_break: !info.unsafe_to_break(),
+ c: text[cluster..].chars().next().unwrap(),
+ span: ctx.spans.span_at(start),
+ });
+ } else {
+ // First, search for the end of the tofu sequence.
+ let k = i;
+ while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) {
+ i += 1;
+ }
+
+ // Then, determine the start and end text index for the tofu
+ // sequence.
+ //
+ // Examples:
+ // Everything is shown in visual order. Tofus are written as "_".
+ // We want to find out that the tofus span the text `2..6`.
+ // Note that the clusters are longer than 1 char.
+ //
+ // Left-to-right:
+ // Text: h a l i h a l l o
+ // Glyphs: A _ _ C E
+ // Clusters: 0 2 4 6 8
+ // k=1 i=2
+ //
+ // Right-to-left:
+ // Text: O L L A H I L A H
+ // Glyphs: E C _ _ A
+ // Clusters: 8 6 4 2 0
+ // k=2 i=3
+ let start = infos[if ltr { k } else { i }].cluster as usize;
+ let end = if ltr { i.checked_add(1) } else { k.checked_sub(1) }
+ .and_then(|last| infos.get(last))
+ .map_or(text.len(), |info| info.cluster as usize);
+
+ // Trim half-baked cluster.
+ let remove = base + start..base + end;
+ while ctx.glyphs.last().map_or(false, |g| remove.contains(&g.range.start)) {
+ ctx.glyphs.pop();
+ }
+
+ // Recursively shape the tofu sequence with the next family.
+ shape_segment(ctx, base + start, &text[start..end], families.clone());
+ }
+
+ i += 1;
+ }
+
+ ctx.used.pop();
+}
+
+/// Shape the text with tofus from the given font.
+fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) {
+ let x_advance = font.advance(0).unwrap_or_default();
+ let add_glyph = |(cluster, c): (usize, char)| {
+ let start = base + cluster;
+ let end = start + c.len_utf8();
+ ctx.glyphs.push(ShapedGlyph {
+ font: font.clone(),
+ glyph_id: 0,
+ x_advance,
+ x_offset: Em::zero(),
+ y_offset: Em::zero(),
+ adjustability: Adjustability::default(),
+ range: start..end,
+ safe_to_break: true,
+ c,
+ span: ctx.spans.span_at(start),
+ });
+ };
+ if ctx.dir.is_positive() {
+ text.char_indices().for_each(add_glyph);
+ } else {
+ text.char_indices().rev().for_each(add_glyph);
+ }
+}
+
+/// Apply tracking and spacing to the shaped glyphs.
+fn track_and_space(ctx: &mut ShapingContext) {
+ let tracking = Em::from_length(TextElem::tracking_in(ctx.styles), ctx.size);
+ let spacing =
+ TextElem::spacing_in(ctx.styles).map(|abs| Em::from_length(abs, ctx.size));
+
+ let mut glyphs = ctx.glyphs.iter_mut().peekable();
+ while let Some(glyph) = glyphs.next() {
+ // Make non-breaking space same width as normal space.
+ if glyph.c == '\u{00A0}' {
+ glyph.x_advance -= nbsp_delta(&glyph.font).unwrap_or_default();
+ }
+
+ if glyph.is_space() {
+ glyph.x_advance = spacing.relative_to(glyph.x_advance);
+ }
+
+ if glyphs
+ .peek()
+ .map_or(false, |next| glyph.range.start != next.range.start)
+ {
+ glyph.x_advance += tracking;
+ }
+ }
+}
+
+pub fn is_gb_style(lang: Lang, region: Option<Region>) -> bool {
+ // Most CJK variants, including zh-CN, ja-JP, zh-SG, zh-MY use GB-style punctuation,
+ // while zh-HK and zh-TW use alternative style. We default to use GB-style.
+ !(lang == Lang::CHINESE
+ && matches!(region.as_ref().map(Region::as_str), Some("TW" | "HK")))
+}
+
+/// Calculate stretchability and shrinkability of each glyph,
+/// and CJK punctuation adjustments according to Chinese Layout Requirements.
+fn calculate_adjustability(ctx: &mut ShapingContext, lang: Lang, region: Option<Region>) {
+ let gb_style = is_gb_style(lang, region);
+
+ for glyph in &mut ctx.glyphs {
+ glyph.adjustability = glyph.base_adjustability(gb_style);
+ }
+
+ let mut glyphs = ctx.glyphs.iter_mut().peekable();
+ while let Some(glyph) = glyphs.next() {
+ // Only GB style needs further adjustment.
+ if glyph.is_cjk_punctuation() && !gb_style {
+ continue;
+ }
+
+ // Now we apply consecutive punctuation adjustment, specified in Chinese Layout
+ // Requirements, section 3.1.6.1 Punctuation Adjustment Space, and Japanese Layout
+ // Requirements, section 3.1 Line Composition Rules for Punctuation Marks
+ let Some(next) = glyphs.peek_mut() else { continue };
+ let width = glyph.x_advance;
+ let delta = width / 2.0;
+ if glyph.is_cjk_punctuation()
+ && next.is_cjk_punctuation()
+ && (glyph.shrinkability().1 + next.shrinkability().0) >= delta
+ {
+ let left_delta = glyph.shrinkability().1.min(delta);
+ glyph.shrink_right(left_delta);
+ next.shrink_left(delta - left_delta);
+ }
+ }
+}
+
+/// Difference between non-breaking and normal space.
+fn nbsp_delta(font: &Font) -> Option<Em> {
+ let space = font.ttf().glyph_index(' ')?.0;
+ let nbsp = font.ttf().glyph_index('\u{00A0}')?.0;
+ Some(font.advance(nbsp)? - font.advance(space)?)
+}
+
+/// Resolve the font variant.
+pub fn variant(styles: StyleChain) -> FontVariant {
+ let mut variant = FontVariant::new(
+ TextElem::style_in(styles),
+ TextElem::weight_in(styles),
+ TextElem::stretch_in(styles),
+ );
+
+ let delta = TextElem::delta_in(styles);
+ variant.weight = variant
+ .weight
+ .thicken(delta.clamp(i16::MIN as i64, i16::MAX as i64) as i16);
+
+ if TextElem::emph_in(styles) {
+ variant.style = match variant.style {
+ FontStyle::Normal => FontStyle::Italic,
+ FontStyle::Italic => FontStyle::Normal,
+ FontStyle::Oblique => FontStyle::Normal,
+ }
+ }
+
+ variant
+}
+
+/// Resolve a prioritized iterator over the font families.
+pub fn families(styles: StyleChain) -> impl Iterator<Item = FontFamily> + Clone {
+ const FALLBACKS: &[&str] = &[
+ "linux libertine",
+ "twitter color emoji",
+ "noto color emoji",
+ "apple color emoji",
+ "segoe ui emoji",
+ ];
+
+ let tail = if TextElem::fallback_in(styles) { FALLBACKS } else { &[] };
+ TextElem::font_in(styles)
+ .into_iter()
+ .chain(tail.iter().copied().map(FontFamily::new))
+}
+
+/// Collect the tags of the OpenType features to apply.
+fn tags(styles: StyleChain) -> Vec<Feature> {
+ let mut tags = vec![];
+ let mut feat = |tag, value| {
+ tags.push(Feature::new(Tag::from_bytes(tag), value, ..));
+ };
+
+ // Features that are on by default in Harfbuzz are only added if disabled.
+ if !TextElem::kerning_in(styles) {
+ feat(b"kern", 0);
+ }
+
+ // Features that are off by default in Harfbuzz are only added if enabled.
+ if TextElem::smallcaps_in(styles) {
+ feat(b"smcp", 1);
+ }
+
+ if TextElem::alternates_in(styles) {
+ feat(b"salt", 1);
+ }
+
+ let storage;
+ if let Some(set) = TextElem::stylistic_set_in(styles) {
+ storage = [b's', b's', b'0' + set.get() / 10, b'0' + set.get() % 10];
+ feat(&storage, 1);
+ }
+
+ if !TextElem::ligatures_in(styles) {
+ feat(b"liga", 0);
+ feat(b"clig", 0);
+ }
+
+ if TextElem::discretionary_ligatures_in(styles) {
+ feat(b"dlig", 1);
+ }
+
+ if TextElem::historical_ligatures_in(styles) {
+ feat(b"hilg", 1);
+ }
+
+ match TextElem::number_type_in(styles) {
+ Smart::Auto => {}
+ Smart::Custom(NumberType::Lining) => feat(b"lnum", 1),
+ Smart::Custom(NumberType::OldStyle) => feat(b"onum", 1),
+ }
+
+ match TextElem::number_width_in(styles) {
+ Smart::Auto => {}
+ Smart::Custom(NumberWidth::Proportional) => feat(b"pnum", 1),
+ Smart::Custom(NumberWidth::Tabular) => feat(b"tnum", 1),
+ }
+
+ if TextElem::slashed_zero_in(styles) {
+ feat(b"zero", 1);
+ }
+
+ if TextElem::fractions_in(styles) {
+ feat(b"frac", 1);
+ }
+
+ for (tag, value) in TextElem::features_in(styles).0 {
+ tags.push(Feature::new(tag, value, ..))
+ }
+
+ tags
+}
+
+/// Process the language and and region of a style chain into a
+/// rustybuzz-compatible BCP 47 language.
+fn language(styles: StyleChain) -> rustybuzz::Language {
+ let mut bcp: EcoString = TextElem::lang_in(styles).as_str().into();
+ if let Some(region) = TextElem::region_in(styles) {
+ bcp.push('-');
+ bcp.push_str(region.as_str());
+ }
+ rustybuzz::Language::from_str(&bcp).unwrap()
+}
+
+/// Returns true if all glyphs in `glyphs` have ranges within the range `range`.
+#[cfg(debug_assertions)]
+fn assert_all_glyphs_in_range(glyphs: &[ShapedGlyph], text: &str, range: Range<usize>) {
+ if glyphs
+ .iter()
+ .any(|g| g.range.start < range.start || g.range.end > range.end)
+ {
+ panic!("one or more glyphs in {text:?} fell out of range");
+ }
+}
+
+/// Asserts that the ranges of `glyphs` is in the proper order according to `dir`.
+///
+/// This asserts instead of returning a bool in order to provide a more informative message when the invariant is violated.
+#[cfg(debug_assertions)]
+fn assert_glyph_ranges_in_order(glyphs: &[ShapedGlyph], dir: Dir) {
+ if glyphs.is_empty() {
+ return;
+ }
+
+ // Iterator::is_sorted and friends are unstable as of Rust 1.70.0
+ for i in 0..(glyphs.len() - 1) {
+ let a = &glyphs[i];
+ let b = &glyphs[i + 1];
+ let ord = a.range.start.cmp(&b.range.start);
+ let ord = if dir.is_positive() { ord } else { ord.reverse() };
+ if ord == std::cmp::Ordering::Greater {
+ panic!(
+ "glyph ranges should be monotonically {}, \
+ but found glyphs out of order:\n\n\
+ first: {a:#?}\nsecond: {b:#?}",
+ if dir.is_positive() { "increasing" } else { "decreasing" },
+ );
+ }
+ }
+}
diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs
new file mode 100644
index 00000000..65e309e1
--- /dev/null
+++ b/crates/typst-library/src/text/shift.rs
@@ -0,0 +1,229 @@
+use super::{variant, SpaceElem, TextElem, TextSize};
+use crate::prelude::*;
+
+/// Renders text in subscript.
+///
+/// The text is rendered smaller and its baseline is lowered.
+///
+/// ## Example { #example }
+/// ```example
+/// Revenue#sub[yearly]
+/// ```
+///
+/// Display: Subscript
+/// Category: text
+#[element(Show)]
+pub struct SubElem {
+ /// Whether to prefer the dedicated subscript characters of the font.
+ ///
+ /// If this is enabled, Typst first tries to transform the text to subscript
+ /// codepoints. If that fails, it falls back to rendering lowered and shrunk
+ /// normal letters.
+ ///
+ /// ```example
+ /// N#sub(typographic: true)[1]
+ /// N#sub(typographic: false)[1]
+ /// ```
+ #[default(true)]
+ pub typographic: bool,
+
+ /// The baseline shift for synthetic subscripts. Does not apply if
+ /// `typographic` is true and the font has subscript codepoints for the
+ /// given `body`.
+ #[default(Em::new(0.2).into())]
+ pub baseline: Length,
+
+ /// The font size for synthetic subscripts. Does not apply if
+ /// `typographic` is true and the font has subscript codepoints for the
+ /// given `body`.
+ #[default(TextSize(Em::new(0.6).into()))]
+ pub size: TextSize,
+
+ /// The text to display in subscript.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for SubElem {
+ #[tracing::instrument(name = "SubElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let body = self.body();
+ let mut transformed = None;
+ if self.typographic(styles) {
+ if let Some(text) = search_text(&body, true) {
+ if is_shapable(vt, &text, styles) {
+ transformed = Some(TextElem::packed(text));
+ }
+ }
+ };
+
+ Ok(transformed.unwrap_or_else(|| {
+ body.styled(TextElem::set_baseline(self.baseline(styles)))
+ .styled(TextElem::set_size(self.size(styles)))
+ }))
+ }
+}
+
+/// Renders text in superscript.
+///
+/// The text is rendered smaller and its baseline is raised.
+///
+/// ## Example { #example }
+/// ```example
+/// 1#super[st] try!
+/// ```
+///
+/// Display: Superscript
+/// Category: text
+#[element(Show)]
+pub struct SuperElem {
+ /// Whether to prefer the dedicated superscript characters of the font.
+ ///
+ /// If this is enabled, Typst first tries to transform the text to
+ /// superscript codepoints. If that fails, it falls back to rendering
+ /// raised and shrunk normal letters.
+ ///
+ /// ```example
+ /// N#super(typographic: true)[1]
+ /// N#super(typographic: false)[1]
+ /// ```
+ #[default(true)]
+ pub typographic: bool,
+
+ /// The baseline shift for synthetic superscripts. Does not apply if
+ /// `typographic` is true and the font has superscript codepoints for the
+ /// given `body`.
+ #[default(Em::new(-0.5).into())]
+ pub baseline: Length,
+
+ /// The font size for synthetic superscripts. Does not apply if
+ /// `typographic` is true and the font has superscript codepoints for the
+ /// given `body`.
+ #[default(TextSize(Em::new(0.6).into()))]
+ pub size: TextSize,
+
+ /// The text to display in superscript.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for SuperElem {
+ #[tracing::instrument(name = "SuperElem::show", skip_all)]
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ let body = self.body();
+ let mut transformed = None;
+ if self.typographic(styles) {
+ if let Some(text) = search_text(&body, false) {
+ if is_shapable(vt, &text, styles) {
+ transformed = Some(TextElem::packed(text));
+ }
+ }
+ };
+
+ Ok(transformed.unwrap_or_else(|| {
+ body.styled(TextElem::set_baseline(self.baseline(styles)))
+ .styled(TextElem::set_size(self.size(styles)))
+ }))
+ }
+}
+
+/// Find and transform the text contained in `content` to the given script kind
+/// if and only if it only consists of `Text`, `Space`, and `Empty` leafs.
+fn search_text(content: &Content, sub: bool) -> Option<EcoString> {
+ if content.is::<SpaceElem>() {
+ Some(' '.into())
+ } else if let Some(elem) = content.to::<TextElem>() {
+ convert_script(&elem.text(), sub)
+ } else if let Some(children) = content.to_sequence() {
+ let mut full = EcoString::new();
+ for item in children {
+ match search_text(item, sub) {
+ Some(text) => full.push_str(&text),
+ None => return None,
+ }
+ }
+ Some(full)
+ } else {
+ None
+ }
+}
+
+/// Checks whether the first retrievable family contains all code points of the
+/// given string.
+fn is_shapable(vt: &Vt, text: &str, styles: StyleChain) -> bool {
+ let world = vt.world;
+ for family in TextElem::font_in(styles) {
+ if let Some(font) = world
+ .book()
+ .select(family.as_str(), variant(styles))
+ .and_then(|id| world.font(id))
+ {
+ return text.chars().all(|c| font.ttf().glyph_index(c).is_some());
+ }
+ }
+
+ false
+}
+
+/// Convert a string to sub- or superscript codepoints if all characters
+/// can be mapped to such a codepoint.
+fn convert_script(text: &str, sub: bool) -> Option<EcoString> {
+ let mut result = EcoString::with_capacity(text.len());
+ let converter = if sub { to_subscript_codepoint } else { to_superscript_codepoint };
+
+ for c in text.chars() {
+ match converter(c) {
+ Some(c) => result.push(c),
+ None => return None,
+ }
+ }
+
+ Some(result)
+}
+
+/// Convert a character to its corresponding Unicode superscript.
+fn to_superscript_codepoint(c: char) -> Option<char> {
+ char::from_u32(match c {
+ '0' => 0x2070,
+ '1' => 0x00B9,
+ '2' => 0x00B2,
+ '3' => 0x00B3,
+ '4'..='9' => 0x2070 + (c as u32 + 4 - '4' as u32),
+ '+' => 0x207A,
+ '-' => 0x207B,
+ '=' => 0x207C,
+ '(' => 0x207D,
+ ')' => 0x207E,
+ 'n' => 0x207F,
+ 'i' => 0x2071,
+ ' ' => 0x0020,
+ _ => return None,
+ })
+}
+
+/// Convert a character to its corresponding Unicode subscript.
+fn to_subscript_codepoint(c: char) -> Option<char> {
+ char::from_u32(match c {
+ '0' => 0x2080,
+ '1'..='9' => 0x2080 + (c as u32 - '0' as u32),
+ '+' => 0x208A,
+ '-' => 0x208B,
+ '=' => 0x208C,
+ '(' => 0x208D,
+ ')' => 0x208E,
+ 'a' => 0x2090,
+ 'e' => 0x2091,
+ 'o' => 0x2092,
+ 'x' => 0x2093,
+ 'h' => 0x2095,
+ 'k' => 0x2096,
+ 'l' => 0x2097,
+ 'm' => 0x2098,
+ 'n' => 0x2099,
+ 'p' => 0x209A,
+ 's' => 0x209B,
+ 't' => 0x209C,
+ ' ' => 0x0020,
+ _ => return None,
+ })
+}
diff --git a/crates/typst-library/src/visualize/image.rs b/crates/typst-library/src/visualize/image.rs
new file mode 100644
index 00000000..b169cc4d
--- /dev/null
+++ b/crates/typst-library/src/visualize/image.rs
@@ -0,0 +1,197 @@
+use std::ffi::OsStr;
+use std::path::Path;
+
+use typst::image::{Image, ImageFormat, RasterFormat, VectorFormat};
+use typst::util::Bytes;
+
+use crate::meta::{Figurable, LocalName};
+use crate::prelude::*;
+use crate::text::families;
+
+/// A raster or vector graphic.
+///
+/// Supported formats are PNG, JPEG, GIF and SVG.
+///
+/// _Note:_ Work on SVG export is ongoing and there might be visual inaccuracies
+/// in the resulting PDF. Make sure to double-check embedded SVG images. If you
+/// have an issue, also feel free to report it on [GitHub][gh-svg].
+///
+/// ## Example { #example }
+/// ```example
+/// #figure(
+/// image("molecular.jpg", width: 80%),
+/// caption: [
+/// A step in the molecular testing
+/// pipeline of our lab.
+/// ],
+/// )
+/// ```
+///
+/// [gh-svg]: https://github.com/typst/typst/issues?q=is%3Aopen+is%3Aissue+label%3Asvg
+///
+/// Display: Image
+/// Category: visualize
+#[element(Layout, LocalName, Figurable)]
+pub struct ImageElem {
+ /// Path to an image file.
+ #[required]
+ #[parse(
+ let Spanned { v: path, span } =
+ args.expect::<Spanned<EcoString>>("path to image file")?;
+ let id = vm.location().join(&path).at(span)?;
+ let data = vm.world().file(id).at(span)?;
+ path
+ )]
+ pub path: EcoString,
+
+ /// The raw file data.
+ #[internal]
+ #[required]
+ #[parse(data)]
+ pub data: Bytes,
+
+ /// The width of the image.
+ pub width: Smart<Rel<Length>>,
+
+ /// The height of the image.
+ pub height: Smart<Rel<Length>>,
+
+ /// A text describing the image.
+ pub alt: Option<EcoString>,
+
+ /// How the image should adjust itself to a given area.
+ #[default(ImageFit::Cover)]
+ pub fit: ImageFit,
+}
+
+impl Layout for ImageElem {
+ #[tracing::instrument(name = "ImageElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let ext = Path::new(self.path().as_str())
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default()
+ .to_lowercase();
+
+ let format = match ext.as_str() {
+ "png" => ImageFormat::Raster(RasterFormat::Png),
+ "jpg" | "jpeg" => ImageFormat::Raster(RasterFormat::Jpg),
+ "gif" => ImageFormat::Raster(RasterFormat::Gif),
+ "svg" | "svgz" => ImageFormat::Vector(VectorFormat::Svg),
+ _ => bail!(self.span(), "unknown image format"),
+ };
+
+ let image = Image::with_fonts(
+ self.data(),
+ format,
+ vt.world,
+ families(styles).next().as_ref().map(|f| f.as_str()),
+ self.alt(styles),
+ )
+ .at(self.span())?;
+
+ let sizing = Axes::new(self.width(styles), self.height(styles));
+ let region = sizing
+ .zip(regions.base())
+ .map(|(s, r)| s.map(|v| v.resolve(styles).relative_to(r)))
+ .unwrap_or(regions.base());
+
+ let expand = sizing.as_ref().map(Smart::is_custom) | regions.expand;
+ let region_ratio = region.x / region.y;
+
+ // Find out whether the image is wider or taller than the target size.
+ let pxw = image.width() as f64;
+ let pxh = image.height() as f64;
+ let px_ratio = pxw / pxh;
+ let wide = px_ratio > region_ratio;
+
+ // The space into which the image will be placed according to its fit.
+ let target = if expand.x && expand.y {
+ region
+ } else if expand.x || (!expand.y && wide && region.x.is_finite()) {
+ Size::new(region.x, region.y.min(region.x.safe_div(px_ratio)))
+ } else if region.y.is_finite() {
+ Size::new(region.x.min(region.y * px_ratio), region.y)
+ } else {
+ Size::new(Abs::pt(pxw), Abs::pt(pxh))
+ };
+
+ // Compute the actual size of the fitted image.
+ let fit = self.fit(styles);
+ let fitted = match fit {
+ ImageFit::Cover | ImageFit::Contain => {
+ if wide == (fit == ImageFit::Contain) {
+ Size::new(target.x, target.x / px_ratio)
+ } else {
+ Size::new(target.y * px_ratio, target.y)
+ }
+ }
+ ImageFit::Stretch => target,
+ };
+
+ // First, place the image in a frame of exactly its size and then resize
+ // the frame to the target size, center aligning the image in the
+ // process.
+ let mut frame = Frame::new(fitted);
+ frame.push(Point::zero(), FrameItem::Image(image, fitted, self.span()));
+ frame.resize(target, Align::CENTER_HORIZON);
+
+ // Create a clipping group if only part of the image should be visible.
+ if fit == ImageFit::Cover && !target.fits(fitted) {
+ frame.clip();
+ }
+
+ // Apply metadata.
+ frame.meta(styles, false);
+
+ Ok(Fragment::frame(frame))
+ }
+}
+
+impl LocalName for ImageElem {
+ fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "Figurë",
+ Lang::ARABIC => "شكل",
+ Lang::BOKMÅL => "Figur",
+ Lang::CHINESE => "图",
+ Lang::CZECH => "Obrázek",
+ Lang::DANISH => "Figur",
+ Lang::DUTCH => "Figuur",
+ Lang::FILIPINO => "Pigura",
+ Lang::FRENCH => "Figure",
+ Lang::GERMAN => "Abbildung",
+ Lang::ITALIAN => "Figura",
+ Lang::NYNORSK => "Figur",
+ Lang::POLISH => "Rysunek",
+ Lang::PORTUGUESE => "Figura",
+ Lang::RUSSIAN => "Рисунок",
+ Lang::SLOVENIAN => "Slika",
+ Lang::SPANISH => "Figura",
+ Lang::SWEDISH => "Figur",
+ Lang::TURKISH => "Şekil",
+ Lang::UKRAINIAN => "Рисунок",
+ Lang::VIETNAMESE => "Hình",
+ Lang::ENGLISH | _ => "Figure",
+ }
+ }
+}
+
+impl Figurable for ImageElem {}
+
+/// How an image should adjust itself to a given area.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum ImageFit {
+ /// The image should completely cover the area. This is the default.
+ Cover,
+ /// The image should be fully contained in the area.
+ Contain,
+ /// The image should be stretched so that it exactly fills the area, even if
+ /// this means that the image will be distorted.
+ Stretch,
+}
diff --git a/crates/typst-library/src/visualize/line.rs b/crates/typst-library/src/visualize/line.rs
new file mode 100644
index 00000000..62a381a9
--- /dev/null
+++ b/crates/typst-library/src/visualize/line.rs
@@ -0,0 +1,118 @@
+use crate::prelude::*;
+
+/// A line from one point to another.
+///
+/// ## Example { #example }
+/// ```example
+/// #set page(height: 100pt)
+///
+/// #line(length: 100%)
+/// #line(end: (50%, 50%))
+/// #line(
+/// length: 4cm,
+/// stroke: 2pt + maroon,
+/// )
+/// ```
+///
+/// Display: Line
+/// Category: visualize
+#[element(Layout)]
+pub struct LineElem {
+ /// The start point of the line.
+ ///
+ /// Must be an array of exactly two relative lengths.
+ #[resolve]
+ pub start: Axes<Rel<Length>>,
+
+ /// The offset from `start` where the line ends.
+ #[resolve]
+ pub end: Option<Axes<Rel<Length>>>,
+
+ /// The line's length. This is only respected if `end` is `none`.
+ #[resolve]
+ #[default(Abs::pt(30.0).into())]
+ pub length: Rel<Length>,
+
+ /// The angle at which the line points away from the origin. This is only
+ /// respected if `end` is `none`.
+ pub angle: Angle,
+
+ /// How to stroke the line. This can be:
+ ///
+ /// - A length specifying the stroke's thickness. The color is inherited,
+ /// defaulting to black.
+ /// - A color to use for the stroke. The thickness is inherited, defaulting
+ /// to `{1pt}`.
+ /// - A stroke combined from color and thickness using the `+` operator as
+ /// in `{2pt + red}`.
+ /// - A stroke described by a dictionary with any of the following keys:
+ /// - `paint`: The [color]($type/color) to use for the stroke.
+ /// - `thickness`: The stroke's thickness as a [length]($type/length).
+ /// - `cap`: How the line terminates. One of `{"butt"}`, `{"round"}`, or
+ /// `{"square"}`.
+ /// - `join`: How sharp turns of a contour are rendered. One of
+ /// `{"miter"}`, `{"round"}`, or `{"bevel"}`. Not applicable to lines
+ /// but to [polygons]($func/polygon) or [paths]($func/path).
+ /// - `miter-limit`: Number at which protruding sharp angles are rendered
+ /// with a bevel instead. The higher the number, the sharper an angle
+ /// can be before it is bevelled. Only applicable if `join` is
+ /// `{"miter"}`. Defaults to `{4.0}`.
+ /// - `dash`: The dash pattern to use. Can be any of the following:
+ /// - One of the predefined patterns `{"solid"}`, `{"dotted"}`,
+ /// `{"densely-dotted"}`, `{"loosely-dotted"}`, `{"dashed"}`,
+ /// `{"densely-dashed"}`, `{"loosely-dashed"}`, `{"dash-dotted"}`,
+ /// `{"densely-dash-dotted"}` or `{"loosely-dash-dotted"}`
+ /// - An [array]($type/array) with alternating lengths for dashes and
+ /// gaps. You can also use the string `{"dot"}` for a length equal to
+ /// the line thickness.
+ /// - A [dictionary]($type/dictionary) with the keys `array` (same as
+ /// the array above), and `phase` (of type [length]($type/length)),
+ /// which defines where in the pattern to start drawing.
+ ///
+ /// ```example
+ /// #set line(length: 100%)
+ /// #stack(
+ /// spacing: 1em,
+ /// line(stroke: 2pt + red),
+ /// line(stroke: (paint: blue, thickness: 4pt, cap: "round")),
+ /// line(stroke: (paint: blue, thickness: 1pt, dash: "dashed")),
+ /// line(stroke: (paint: blue, thickness: 1pt, dash: ("dot", 2pt, 4pt, 2pt))),
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: PartialStroke,
+}
+
+impl Layout for LineElem {
+ #[tracing::instrument(name = "LineElem::layout", skip_all)]
+ fn layout(
+ &self,
+ _: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let resolve = |axes: Axes<Rel<Abs>>| {
+ axes.zip(regions.base()).map(|(l, b)| l.relative_to(b))
+ };
+
+ let start = resolve(self.start(styles));
+ let delta =
+ self.end(styles).map(|end| resolve(end) - start).unwrap_or_else(|| {
+ let length = self.length(styles);
+ let angle = self.angle(styles);
+ let x = angle.cos() * length;
+ let y = angle.sin() * length;
+ resolve(Axes::new(x, y))
+ });
+
+ let stroke = self.stroke(styles).unwrap_or_default();
+ let size = start.max(start + delta).max(Size::zero());
+ let target = regions.expand.select(regions.size, size);
+
+ let mut frame = Frame::new(target);
+ let shape = Geometry::Line(delta.to_point()).stroked(stroke);
+ frame.push(start.to_point(), FrameItem::Shape(shape, self.span()));
+ Ok(Fragment::frame(frame))
+ }
+}
diff --git a/crates/typst-library/src/visualize/mod.rs b/crates/typst-library/src/visualize/mod.rs
new file mode 100644
index 00000000..ea873f44
--- /dev/null
+++ b/crates/typst-library/src/visualize/mod.rs
@@ -0,0 +1,45 @@
+//! Drawing and visualization.
+
+mod image;
+mod line;
+mod path;
+mod polygon;
+mod shape;
+
+pub use self::image::*;
+pub use self::line::*;
+pub use self::path::*;
+pub use self::polygon::*;
+pub use self::shape::*;
+
+use crate::prelude::*;
+
+/// Hook up all visualize definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.define("image", ImageElem::func());
+ global.define("line", LineElem::func());
+ global.define("rect", RectElem::func());
+ global.define("square", SquareElem::func());
+ global.define("ellipse", EllipseElem::func());
+ global.define("circle", CircleElem::func());
+ global.define("polygon", PolygonElem::func());
+ global.define("path", PathElem::func());
+ global.define("black", Color::BLACK);
+ global.define("gray", Color::GRAY);
+ global.define("silver", Color::SILVER);
+ global.define("white", Color::WHITE);
+ global.define("navy", Color::NAVY);
+ global.define("blue", Color::BLUE);
+ global.define("aqua", Color::AQUA);
+ global.define("teal", Color::TEAL);
+ global.define("eastern", Color::EASTERN);
+ global.define("purple", Color::PURPLE);
+ global.define("fuchsia", Color::FUCHSIA);
+ global.define("maroon", Color::MAROON);
+ global.define("red", Color::RED);
+ global.define("orange", Color::ORANGE);
+ global.define("yellow", Color::YELLOW);
+ global.define("olive", Color::OLIVE);
+ global.define("green", Color::GREEN);
+ global.define("lime", Color::LIME);
+}
diff --git a/crates/typst-library/src/visualize/path.rs b/crates/typst-library/src/visualize/path.rs
new file mode 100644
index 00000000..d78abce1
--- /dev/null
+++ b/crates/typst-library/src/visualize/path.rs
@@ -0,0 +1,211 @@
+use kurbo::{CubicBez, ParamCurveExtrema};
+use typst::eval::Reflect;
+
+use crate::prelude::*;
+
+use PathVertex::{AllControlPoints, MirroredControlPoint, Vertex};
+
+/// A path through a list of points, connected by Bezier curves.
+///
+/// ## Example { #example }
+/// ```example
+/// #path(
+/// fill: blue.lighten(80%),
+/// stroke: blue,
+/// closed: true,
+/// (0pt, 50pt),
+/// (100%, 50pt),
+/// ((50%, 0pt), (40pt, 0pt)),
+/// )
+/// ```
+///
+/// Display: Path
+/// Category: visualize
+#[element(Layout)]
+pub struct PathElem {
+ /// How to fill the path. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ ///
+ /// Currently all paths are filled according to the
+ /// [non-zero winding rule](https://en.wikipedia.org/wiki/Nonzero-rule).
+ pub fill: Option<Paint>,
+
+ /// How to stroke the path. This can be:
+ ///
+ /// See the [line's documentation]($func/line.stroke) for more details. Can
+ /// be set to `{none}` to disable the stroke or to `{auto}` for a stroke of
+ /// `{1pt}` black if and if only if no fill is given.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Option<PartialStroke>>,
+
+ /// Whether to close this path with one last bezier curve. This curve will
+ /// takes into account the adjacent control points. If you want to close
+ /// with a straight line, simply add one last point that's the same as the
+ /// start point.
+ #[default(false)]
+ pub closed: bool,
+
+ /// The vertices of the path.
+ ///
+ /// Each vertex can be defined in 3 ways:
+ ///
+ /// - A regular point, as given to the [`line`]($func/line) or
+ /// [`polygon`]($func/polygon) function.
+ /// - An array of two points, the first being the vertex and the second
+ /// being the control point. The control point is expressed relative to
+ /// the vertex and is mirrored to get the second control point. The given
+ /// control point is the one that affects the curve coming _into_ this
+ /// vertex (even for the first point). The mirrored control point affects
+ /// the curve going out of this vertex.
+ /// - An array of three points, the first being the vertex and the next
+ /// being the control points (control point for curves coming in and out,
+ /// respectively)
+ #[variadic]
+ pub vertices: Vec<PathVertex>,
+}
+
+impl Layout for PathElem {
+ #[tracing::instrument(name = "PathElem::layout", skip_all)]
+ fn layout(
+ &self,
+ _: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let resolve = |axes: Axes<Rel<Length>>| {
+ axes.resolve(styles)
+ .zip(regions.base())
+ .map(|(l, b)| l.relative_to(b))
+ .to_point()
+ };
+
+ let vertices: Vec<PathVertex> = self.vertices();
+ let points: Vec<Point> = vertices.iter().map(|c| resolve(c.vertex())).collect();
+
+ let mut size = Size::zero();
+ if points.is_empty() {
+ return Ok(Fragment::frame(Frame::new(size)));
+ }
+
+ // Only create a path if there are more than zero points.
+ // Construct a closed path given all points.
+ let mut path = Path::new();
+ path.move_to(points[0]);
+
+ let mut add_cubic =
+ |from_point: Point, to_point: Point, from: PathVertex, to: PathVertex| {
+ let from_control_point = resolve(from.control_point_from()) + from_point;
+ let to_control_point = resolve(to.control_point_to()) + to_point;
+ path.cubic_to(from_control_point, to_control_point, to_point);
+
+ let p0 = kurbo::Point::new(from_point.x.to_raw(), from_point.y.to_raw());
+ let p1 = kurbo::Point::new(
+ from_control_point.x.to_raw(),
+ from_control_point.y.to_raw(),
+ );
+ let p2 = kurbo::Point::new(
+ to_control_point.x.to_raw(),
+ to_control_point.y.to_raw(),
+ );
+ let p3 = kurbo::Point::new(to_point.x.to_raw(), to_point.y.to_raw());
+ let extrema = CubicBez::new(p0, p1, p2, p3).bounding_box();
+ size.x.set_max(Abs::raw(extrema.x1));
+ size.y.set_max(Abs::raw(extrema.y1));
+ };
+
+ for (vertex_window, point_window) in vertices.windows(2).zip(points.windows(2)) {
+ let from = vertex_window[0];
+ let to = vertex_window[1];
+ let from_point = point_window[0];
+ let to_point = point_window[1];
+
+ add_cubic(from_point, to_point, from, to);
+ }
+
+ if self.closed(styles) {
+ let from = *vertices.last().unwrap(); // We checked that we have at least one element.
+ let to = vertices[0];
+ let from_point = *points.last().unwrap();
+ let to_point = points[0];
+
+ add_cubic(from_point, to_point, from, to);
+ path.close_path();
+ }
+
+ // Prepare fill and stroke.
+ let fill = self.fill(styles);
+ let stroke = match self.stroke(styles) {
+ Smart::Auto if fill.is_none() => Some(Stroke::default()),
+ Smart::Auto => None,
+ Smart::Custom(stroke) => stroke.map(PartialStroke::unwrap_or_default),
+ };
+
+ let mut frame = Frame::new(size);
+ let shape = Shape { geometry: Geometry::Path(path), stroke, fill };
+ frame.push(Point::zero(), FrameItem::Shape(shape, self.span()));
+
+ Ok(Fragment::frame(frame))
+ }
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum PathVertex {
+ Vertex(Axes<Rel<Length>>),
+ MirroredControlPoint(Axes<Rel<Length>>, Axes<Rel<Length>>),
+ AllControlPoints(Axes<Rel<Length>>, Axes<Rel<Length>>, Axes<Rel<Length>>),
+}
+
+impl PathVertex {
+ pub fn vertex(&self) -> Axes<Rel<Length>> {
+ match self {
+ Vertex(x) => *x,
+ MirroredControlPoint(x, _) => *x,
+ AllControlPoints(x, _, _) => *x,
+ }
+ }
+
+ pub fn control_point_from(&self) -> Axes<Rel<Length>> {
+ match self {
+ Vertex(_) => Axes::new(Rel::zero(), Rel::zero()),
+ MirroredControlPoint(_, a) => a.map(|x| -x),
+ AllControlPoints(_, _, b) => *b,
+ }
+ }
+
+ pub fn control_point_to(&self) -> Axes<Rel<Length>> {
+ match self {
+ Vertex(_) => Axes::new(Rel::zero(), Rel::zero()),
+ MirroredControlPoint(_, a) => *a,
+ AllControlPoints(_, a, _) => *a,
+ }
+ }
+}
+
+cast! {
+ PathVertex,
+ self => match self {
+ Vertex(x) => x.into_value(),
+ MirroredControlPoint(x, c) => array![x, c].into_value(),
+ AllControlPoints(x, c1, c2) => array![x, c1, c2].into_value(),
+ },
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next(), iter.next()) {
+ (Some(a), None, None, None) => {
+ Vertex(a.cast()?)
+ },
+ (Some(a), Some(b), None, None) => {
+ if Axes::<Rel<Length>>::castable(&a) {
+ MirroredControlPoint(a.cast()?, b.cast()?)
+ } else {
+ Vertex(Axes::new(a.cast()?, b.cast()?))
+ }
+ },
+ (Some(a), Some(b), Some(c), None) => {
+ AllControlPoints(a.cast()?, b.cast()?, c.cast()?)
+ },
+ _ => bail!("path vertex must have 1, 2, or 3 points"),
+ }
+ },
+}
diff --git a/crates/typst-library/src/visualize/polygon.rs b/crates/typst-library/src/visualize/polygon.rs
new file mode 100644
index 00000000..d2d3132a
--- /dev/null
+++ b/crates/typst-library/src/visualize/polygon.rs
@@ -0,0 +1,93 @@
+use crate::prelude::*;
+
+/// A closed polygon.
+///
+/// The polygon is defined by its corner points and is closed automatically.
+///
+/// ## Example { #example }
+/// ```example
+/// #polygon(
+/// fill: blue.lighten(80%),
+/// stroke: blue,
+/// (20%, 0pt),
+/// (60%, 0pt),
+/// (80%, 2cm),
+/// (0%, 2cm),
+/// )
+/// ```
+///
+/// Display: Polygon
+/// Category: visualize
+#[element(Layout)]
+pub struct PolygonElem {
+ /// How to fill the polygon. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ ///
+ /// Currently all polygons are filled according to the
+ /// [non-zero winding rule](https://en.wikipedia.org/wiki/Nonzero-rule).
+ pub fill: Option<Paint>,
+
+ /// How to stroke the polygon. This can be:
+ ///
+ /// See the [line's documentation]($func/line.stroke) for more details. Can
+ /// be set to `{none}` to disable the stroke or to `{auto}` for a stroke of
+ /// `{1pt}` black if and if only if no fill is given.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Option<PartialStroke>>,
+
+ /// The vertices of the polygon. Each point is specified as an array of two
+ /// [relative lengths]($type/relative-length).
+ #[variadic]
+ pub vertices: Vec<Axes<Rel<Length>>>,
+}
+
+impl Layout for PolygonElem {
+ #[tracing::instrument(name = "PolygonElem::layout", skip_all)]
+ fn layout(
+ &self,
+ _: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let points: Vec<Point> = self
+ .vertices()
+ .iter()
+ .map(|c| {
+ c.resolve(styles)
+ .zip(regions.base())
+ .map(|(l, b)| l.relative_to(b))
+ .to_point()
+ })
+ .collect();
+
+ let size = points.iter().fold(Point::zero(), |max, c| c.max(max)).to_size();
+ let mut frame = Frame::new(size);
+
+ // Only create a path if there are more than zero points.
+ if points.is_empty() {
+ return Ok(Fragment::frame(frame));
+ }
+
+ // Prepare fill and stroke.
+ let fill = self.fill(styles);
+ let stroke = match self.stroke(styles) {
+ Smart::Auto if fill.is_none() => Some(Stroke::default()),
+ Smart::Auto => None,
+ Smart::Custom(stroke) => stroke.map(PartialStroke::unwrap_or_default),
+ };
+
+ // Construct a closed path given all points.
+ let mut path = Path::new();
+ path.move_to(points[0]);
+ for &point in &points[1..] {
+ path.line_to(point);
+ }
+ path.close_path();
+
+ let shape = Shape { geometry: Geometry::Path(path), stroke, fill };
+ frame.push(Point::zero(), FrameItem::Shape(shape, self.span()));
+
+ Ok(Fragment::frame(frame))
+ }
+}
diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs
new file mode 100644
index 00000000..6129b70b
--- /dev/null
+++ b/crates/typst-library/src/visualize/shape.rs
@@ -0,0 +1,569 @@
+use std::f64::consts::SQRT_2;
+
+use crate::prelude::*;
+
+/// A rectangle with optional content.
+///
+/// ## Example { #example }
+/// ```example
+/// // Without content.
+/// #rect(width: 35%, height: 30pt)
+///
+/// // With content.
+/// #rect[
+/// Automatically sized \
+/// to fit the content.
+/// ]
+/// ```
+///
+/// Display: Rectangle
+/// Category: visualize
+#[element(Layout)]
+pub struct RectElem {
+ /// The rectangle's width, relative to its parent container.
+ pub width: Smart<Rel<Length>>,
+
+ /// The rectangle's height, relative to its parent container.
+ pub height: Smart<Rel<Length>>,
+
+ /// How to fill the rectangle.
+ ///
+ /// When setting a fill, the default stroke disappears. To create a
+ /// rectangle with both fill and stroke, you have to configure both.
+ ///
+ /// ```example
+ /// #rect(fill: blue)
+ /// ```
+ pub fill: Option<Paint>,
+
+ /// How to stroke the rectangle. This can be:
+ ///
+ /// - `{none}` to disable stroking
+ /// - `{auto}` for a stroke of `{1pt + black}` if and if only if no fill is
+ /// given.
+ /// - Any kind of stroke that can also be used for
+ /// [lines]($func/line.stroke).
+ /// - A dictionary describing the stroke for each side inidvidually. The
+ /// dictionary can contain the following keys in order of precedence:
+ /// - `top`: The top stroke.
+ /// - `right`: The right stroke.
+ /// - `bottom`: The bottom stroke.
+ /// - `left`: The left stroke.
+ /// - `x`: The horizontal stroke.
+ /// - `y`: The vertical stroke.
+ /// - `rest`: The stroke on all sides except those for which the
+ /// dictionary explicitly sets a size.
+ ///
+ /// ```example
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// rect(stroke: red),
+ /// rect(stroke: 2pt),
+ /// rect(stroke: 2pt + red),
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Sides<Option<Option<PartialStroke>>>>,
+
+ /// How much to round the rectangle's corners, relative to the minimum of
+ /// the width and height divided by two. This can be:
+ ///
+ /// - A relative length for a uniform corner radius.
+ /// - A dictionary: With a dictionary, the stroke for each side can be set
+ /// individually. The dictionary can contain the following keys in order
+ /// of precedence:
+ /// - `top-left`: The top-left corner radius.
+ /// - `top-right`: The top-right corner radius.
+ /// - `bottom-right`: The bottom-right corner radius.
+ /// - `bottom-left`: The bottom-left corner radius.
+ /// - `left`: The top-left and bottom-left corner radii.
+ /// - `top`: The top-left and top-right corner radii.
+ /// - `right`: The top-right and bottom-right corner radii.
+ /// - `bottom`: The bottom-left and bottom-right corner radii.
+ /// - `rest`: The radii for all corners except those for which the
+ /// dictionary explicitly sets a size.
+ ///
+ /// ```example
+ /// #set rect(stroke: 4pt)
+ /// #rect(
+ /// radius: (
+ /// left: 5pt,
+ /// top-right: 20pt,
+ /// bottom-right: 10pt,
+ /// ),
+ /// stroke: (
+ /// left: red,
+ /// top: yellow,
+ /// right: green,
+ /// bottom: blue,
+ /// ),
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the rectangle's content.
+ ///
+ /// _Note:_ When the rectangle contains text, its exact size depends on the
+ /// current [text edges]($func/text.top-edge).
+ ///
+ /// ```example
+ /// #rect(inset: 0pt)[Tight]
+ /// ```
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Abs::pt(5.0).into()))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the rectangle's size without affecting the layout.
+ /// See the [box's documentation]($func/box.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the rectangle.
+ ///
+ /// When this is omitted, the rectangle takes on a default size of at most
+ /// `{45pt}` by `{30pt}`.
+ #[positional]
+ pub body: Option<Content>,
+}
+
+impl Layout for RectElem {
+ #[tracing::instrument(name = "RectElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ layout(
+ vt,
+ styles,
+ regions,
+ ShapeKind::Rect,
+ &self.body(styles),
+ Axes::new(self.width(styles), self.height(styles)),
+ self.fill(styles),
+ self.stroke(styles),
+ self.inset(styles),
+ self.outset(styles),
+ self.radius(styles),
+ self.span(),
+ )
+ }
+}
+
+/// A square with optional content.
+///
+/// ## Example { #example }
+/// ```example
+/// // Without content.
+/// #square(size: 40pt)
+///
+/// // With content.
+/// #square[
+/// Automatically \
+/// sized to fit.
+/// ]
+/// ```
+///
+/// Display: Square
+/// Category: visualize
+#[element(Layout)]
+pub struct SquareElem {
+ /// The square's side length. This is mutually exclusive with `width` and
+ /// `height`.
+ #[external]
+ pub size: Smart<Length>,
+
+ /// The square's width. This is mutually exclusive with `size` and `height`.
+ ///
+ /// In contrast to `size`, this can be relative to the parent container's
+ /// width.
+ #[parse(
+ let size = args.named::<Smart<Length>>("size")?.map(|s| s.map(Rel::from));
+ match size {
+ None => args.named("width")?,
+ size => size,
+ }
+ )]
+ pub width: Smart<Rel<Length>>,
+
+ /// The square's height. This is mutually exclusive with `size` and `width`.
+ ///
+ /// In contrast to `size`, this can be relative to the parent container's
+ /// height.
+ #[parse(match size {
+ None => args.named("height")?,
+ size => size,
+ })]
+ pub height: Smart<Rel<Length>>,
+
+ /// How to fill the square. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the square. See the [rectangle's
+ /// documentation]($func/rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Sides<Option<Option<PartialStroke>>>>,
+
+ /// How much to round the square's corners. See the [rectangle's
+ /// documentation]($func/rect.radius) for more details.
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the square's content. See the [rectangle's
+ /// documentation]($func/rect.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Abs::pt(5.0).into()))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the square's size without affecting the layout. See
+ /// the [rectangle's documentation]($func/rect.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the square. The square expands to fit this
+ /// content, keeping the 1-1 aspect ratio.
+ ///
+ /// When this is omitted, the square takes on a default size of at most
+ /// `{30pt}`.
+ #[positional]
+ pub body: Option<Content>,
+}
+
+impl Layout for SquareElem {
+ #[tracing::instrument(name = "SquareElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ layout(
+ vt,
+ styles,
+ regions,
+ ShapeKind::Square,
+ &self.body(styles),
+ Axes::new(self.width(styles), self.height(styles)),
+ self.fill(styles),
+ self.stroke(styles),
+ self.inset(styles),
+ self.outset(styles),
+ self.radius(styles),
+ self.span(),
+ )
+ }
+}
+
+/// An ellipse with optional content.
+///
+/// ## Example { #example }
+/// ```example
+/// // Without content.
+/// #ellipse(width: 35%, height: 30pt)
+///
+/// // With content.
+/// #ellipse[
+/// #set align(center)
+/// Automatically sized \
+/// to fit the content.
+/// ]
+/// ```
+///
+/// Display: Ellipse
+/// Category: visualize
+#[element(Layout)]
+pub struct EllipseElem {
+ /// The ellipse's width, relative to its parent container.
+ pub width: Smart<Rel<Length>>,
+
+ /// The ellipse's height, relative to its parent container.
+ pub height: Smart<Rel<Length>>,
+
+ /// How to fill the ellipse. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the ellipse. See the [rectangle's
+ /// documentation]($func/rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Option<PartialStroke>>,
+
+ /// How much to pad the ellipse's content. See the [rectangle's
+ /// documentation]($func/rect.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Abs::pt(5.0).into()))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the ellipse's size without affecting the layout. See
+ /// the [rectangle's documentation]($func/rect.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the ellipse.
+ ///
+ /// When this is omitted, the ellipse takes on a default size of at most
+ /// `{45pt}` by `{30pt}`.
+ #[positional]
+ pub body: Option<Content>,
+}
+
+impl Layout for EllipseElem {
+ #[tracing::instrument(name = "EllipseElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ layout(
+ vt,
+ styles,
+ regions,
+ ShapeKind::Ellipse,
+ &self.body(styles),
+ Axes::new(self.width(styles), self.height(styles)),
+ self.fill(styles),
+ self.stroke(styles).map(Sides::splat),
+ self.inset(styles),
+ self.outset(styles),
+ Corners::splat(Rel::zero()),
+ self.span(),
+ )
+ }
+}
+
+/// A circle with optional content.
+///
+/// ## Example { #example }
+/// ```example
+/// // Without content.
+/// #circle(radius: 25pt)
+///
+/// // With content.
+/// #circle[
+/// #set align(center + horizon)
+/// Automatically \
+/// sized to fit.
+/// ]
+/// ```
+///
+/// Display: Circle
+/// Category: visualize
+#[element(Layout)]
+pub struct CircleElem {
+ /// The circle's radius. This is mutually exclusive with `width` and
+ /// `height`.
+ #[external]
+ pub radius: Length,
+
+ /// The circle's width. This is mutually exclusive with `radius` and
+ /// `height`.
+ ///
+ /// In contrast to `radius`, this can be relative to the parent container's
+ /// width.
+ #[parse(
+ let size = args
+ .named::<Smart<Length>>("radius")?
+ .map(|s| s.map(|r| 2.0 * Rel::from(r)));
+ match size {
+ None => args.named("width")?,
+ size => size,
+ }
+ )]
+ pub width: Smart<Rel<Length>>,
+
+ /// The circle's height.This is mutually exclusive with `radius` and
+ /// `width`.
+ ///
+ /// In contrast to `radius`, this can be relative to the parent container's
+ /// height.
+ #[parse(match size {
+ None => args.named("height")?,
+ size => size,
+ })]
+ pub height: Smart<Rel<Length>>,
+
+ /// How to fill the circle. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the circle. See the [rectangle's
+ /// documentation]($func/rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Smart::Auto)]
+ pub stroke: Smart<Option<PartialStroke>>,
+
+ /// How much to pad the circle's content. See the [rectangle's
+ /// documentation]($func/rect.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Abs::pt(5.0).into()))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the circle's size without affecting the layout. See
+ /// the [rectangle's documentation]($func/rect.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the circle. The circle expands to fit this
+ /// content, keeping the 1-1 aspect ratio.
+ #[positional]
+ pub body: Option<Content>,
+}
+
+impl Layout for CircleElem {
+ #[tracing::instrument(name = "CircleElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ layout(
+ vt,
+ styles,
+ regions,
+ ShapeKind::Circle,
+ &self.body(styles),
+ Axes::new(self.width(styles), self.height(styles)),
+ self.fill(styles),
+ self.stroke(styles).map(Sides::splat),
+ self.inset(styles),
+ self.outset(styles),
+ Corners::splat(Rel::zero()),
+ self.span(),
+ )
+ }
+}
+
+/// Layout a shape.
+#[tracing::instrument(name = "shape::layout", skip_all)]
+#[allow(clippy::too_many_arguments)]
+fn layout(
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ kind: ShapeKind,
+ body: &Option<Content>,
+ sizing: Axes<Smart<Rel<Length>>>,
+ fill: Option<Paint>,
+ stroke: Smart<Sides<Option<PartialStroke<Abs>>>>,
+ mut inset: Sides<Rel<Abs>>,
+ outset: Sides<Rel<Abs>>,
+ radius: Corners<Rel<Abs>>,
+ span: Span,
+) -> SourceResult<Fragment> {
+ let resolved = sizing
+ .zip(regions.base())
+ .map(|(s, r)| s.map(|v| v.resolve(styles).relative_to(r)));
+
+ let mut frame;
+ if let Some(child) = body {
+ let region = resolved.unwrap_or(regions.base());
+ if kind.is_round() {
+ inset = inset.map(|side| side + Ratio::new(0.5 - SQRT_2 / 4.0));
+ }
+
+ // Pad the child.
+ let child = child.clone().padded(inset.map(|side| side.map(Length::from)));
+ let expand = sizing.as_ref().map(Smart::is_custom);
+ let pod = Regions::one(region, expand);
+ frame = child.layout(vt, styles, pod)?.into_frame();
+
+ // Enforce correct size.
+ *frame.size_mut() = expand.select(region, frame.size());
+
+ // Relayout with full expansion into square region to make sure
+ // the result is really a square or circle.
+ if kind.is_quadratic() {
+ frame.set_size(Size::splat(frame.size().max_by_side()));
+ let length = frame.size().max_by_side().min(region.min_by_side());
+ let pod = Regions::one(Size::splat(length), Axes::splat(true));
+ frame = child.layout(vt, styles, pod)?.into_frame();
+ }
+
+ // Enforce correct size again.
+ *frame.size_mut() = expand.select(region, frame.size());
+ if kind.is_quadratic() {
+ frame.set_size(Size::splat(frame.size().max_by_side()));
+ }
+ } else {
+ // The default size that a shape takes on if it has no child and
+ // enough space.
+ let default = Size::new(Abs::pt(45.0), Abs::pt(30.0));
+ let mut size = resolved.unwrap_or(default.min(regions.base()));
+ if kind.is_quadratic() {
+ size = Size::splat(size.min_by_side());
+ }
+ frame = Frame::new(size);
+ }
+
+ // Prepare stroke.
+ let stroke = match stroke {
+ Smart::Auto if fill.is_none() => Sides::splat(Some(Stroke::default())),
+ Smart::Auto => Sides::splat(None),
+ Smart::Custom(strokes) => {
+ strokes.map(|s| s.map(PartialStroke::unwrap_or_default))
+ }
+ };
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ if kind.is_round() {
+ let outset = outset.relative_to(frame.size());
+ let size = frame.size() + outset.sum_by_axis();
+ let pos = Point::new(-outset.left, -outset.top);
+ let shape = ellipse(size, fill, stroke.left);
+ frame.prepend(pos, FrameItem::Shape(shape, span));
+ } else {
+ frame.fill_and_stroke(fill, stroke, outset, radius, span);
+ }
+ }
+
+ // Apply metadata.
+ frame.meta(styles, false);
+
+ Ok(Fragment::frame(frame))
+}
+
+/// A category of shape.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ShapeKind {
+ /// A rectangle with equal side lengths.
+ Square,
+ /// A quadrilateral with four right angles.
+ Rect,
+ /// An ellipse with coinciding foci.
+ Circle,
+ /// A curve around two focal points.
+ Ellipse,
+}
+
+impl ShapeKind {
+ /// Whether this shape kind is curvy.
+ fn is_round(self) -> bool {
+ matches!(self, Self::Circle | Self::Ellipse)
+ }
+
+ /// Whether this shape kind has equal side length.
+ fn is_quadratic(self) -> bool {
+ matches!(self, Self::Square | Self::Circle)
+ }
+}
diff --git a/crates/typst-macros/Cargo.toml b/crates/typst-macros/Cargo.toml
new file mode 100644
index 00000000..15f2b2bd
--- /dev/null
+++ b/crates/typst-macros/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "typst-macros"
+description = "Proc-macros for Typst."
+version.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+
+[lib]
+proc-macro = true
+test = false
+doctest = false
+bench = false
+
+[dependencies]
+heck = "0.4"
+proc-macro2 = "1"
+quote = "1"
+syn = { version = "2", features = ["full", "extra-traits"] }
diff --git a/crates/typst-macros/src/castable.rs b/crates/typst-macros/src/castable.rs
new file mode 100644
index 00000000..05c1b4d1
--- /dev/null
+++ b/crates/typst-macros/src/castable.rs
@@ -0,0 +1,331 @@
+use super::*;
+
+/// Expand the `#[derive(Cast)]` macro.
+pub fn derive_cast(item: &DeriveInput) -> Result<TokenStream> {
+ let ty = &item.ident;
+
+ let syn::Data::Enum(data) = &item.data else {
+ bail!(item, "only enums are supported");
+ };
+
+ let mut variants = vec![];
+ for variant in &data.variants {
+ if let Some((_, expr)) = &variant.discriminant {
+ bail!(expr, "explicit discriminant is not allowed");
+ }
+
+ let string = if let Some(attr) =
+ variant.attrs.iter().find(|attr| attr.path().is_ident("string"))
+ {
+ attr.parse_args::<syn::LitStr>()?.value()
+ } else {
+ kebab_case(&variant.ident)
+ };
+
+ variants.push(Variant {
+ ident: variant.ident.clone(),
+ string,
+ docs: documentation(&variant.attrs),
+ });
+ }
+
+ let strs_to_variants = variants.iter().map(|Variant { ident, string, docs }| {
+ quote! {
+ #[doc = #docs]
+ #string => Self::#ident
+ }
+ });
+
+ let variants_to_strs = variants.iter().map(|Variant { ident, string, .. }| {
+ quote! {
+ #ty::#ident => #string
+ }
+ });
+
+ Ok(quote! {
+ ::typst::eval::cast! {
+ #ty,
+ self => ::typst::eval::IntoValue::into_value(match self {
+ #(#variants_to_strs),*
+ }),
+ #(#strs_to_variants),*
+ }
+ })
+}
+
+/// An enum variant in a `derive(Cast)`.
+struct Variant {
+ ident: Ident,
+ string: String,
+ docs: String,
+}
+
+/// Expand the `cast!` macro.
+pub fn cast(stream: TokenStream) -> Result<TokenStream> {
+ let input: CastInput = syn::parse2(stream)?;
+ let ty = &input.ty;
+ let eval = quote! { ::typst::eval };
+
+ let castable_body = create_castable_body(&input);
+ let describe_body = create_describe_body(&input);
+ let into_value_body = create_into_value_body(&input);
+ let from_value_body = create_from_value_body(&input);
+
+ let reflect = (!input.from_value.is_empty() || input.name.is_some()).then(|| {
+ quote! {
+ impl #eval::Reflect for #ty {
+ fn describe() -> #eval::CastInfo {
+ #describe_body
+ }
+
+ fn castable(value: &#eval::Value) -> bool {
+ #castable_body
+ }
+ }
+ }
+ });
+
+ let into_value = (input.into_value.is_some() || input.name.is_some()).then(|| {
+ quote! {
+ impl #eval::IntoValue for #ty {
+ fn into_value(self) -> #eval::Value {
+ #into_value_body
+ }
+ }
+ }
+ });
+
+ let from_value = (!input.from_value.is_empty() || input.name.is_some()).then(|| {
+ quote! {
+ impl #eval::FromValue for #ty {
+ fn from_value(value: #eval::Value) -> ::typst::diag::StrResult<Self> {
+ #from_value_body
+ }
+ }
+ }
+ });
+
+ let ty = input.name.as_ref().map(|name| {
+ quote! {
+ impl #eval::Type for #ty {
+ const TYPE_NAME: &'static str = #name;
+ }
+ }
+ });
+
+ Ok(quote! {
+ #reflect
+ #into_value
+ #from_value
+ #ty
+ })
+}
+
+/// The input to `cast!`.
+struct CastInput {
+ ty: syn::Type,
+ name: Option<syn::LitStr>,
+ into_value: Option<syn::Expr>,
+ from_value: Punctuated<Cast, Token![,]>,
+}
+
+impl Parse for CastInput {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let ty;
+ let mut name = None;
+ if input.peek(syn::Token![type]) {
+ let _: syn::Token![type] = input.parse()?;
+ ty = input.parse()?;
+ let _: syn::Token![:] = input.parse()?;
+ name = Some(input.parse()?);
+ } else {
+ ty = input.parse()?;
+ }
+
+ let _: syn::Token![,] = input.parse()?;
+
+ let mut into_value = None;
+ if input.peek(syn::Token![self]) {
+ let _: syn::Token![self] = input.parse()?;
+ let _: syn::Token![=>] = input.parse()?;
+ into_value = Some(input.parse()?);
+ let _: syn::Token![,] = input.parse()?;
+ }
+
+ let from_value = Punctuated::parse_terminated(input)?;
+ Ok(Self { ty, name, into_value, from_value })
+ }
+}
+
+impl Parse for Cast {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let attrs = input.call(syn::Attribute::parse_outer)?;
+ let pattern = input.parse()?;
+ let _: syn::Token![=>] = input.parse()?;
+ let expr = input.parse()?;
+ Ok(Self { attrs, pattern, expr })
+ }
+}
+
+impl Parse for Pattern {
+ fn parse(input: ParseStream) -> Result<Self> {
+ if input.peek(syn::LitStr) {
+ Ok(Pattern::Str(input.parse()?))
+ } else {
+ let pat = syn::Pat::parse_single(input)?;
+ let _: syn::Token![:] = input.parse()?;
+ let ty = input.parse()?;
+ Ok(Pattern::Ty(pat, ty))
+ }
+ }
+}
+
+/// A single cast, e.g. `v: i64 => Self::Int(v)`.
+struct Cast {
+ attrs: Vec<syn::Attribute>,
+ pattern: Pattern,
+ expr: syn::Expr,
+}
+
+/// A pattern in a cast, e.g.`"ascender"` or `v: i64`.
+enum Pattern {
+ Str(syn::LitStr),
+ Ty(syn::Pat, syn::Type),
+}
+
+fn create_castable_body(input: &CastInput) -> TokenStream {
+ let mut strings = vec![];
+ let mut casts = vec![];
+
+ for cast in &input.from_value {
+ match &cast.pattern {
+ Pattern::Str(lit) => {
+ strings.push(quote! { #lit => return true });
+ }
+ Pattern::Ty(_, ty) => {
+ casts.push(quote! {
+ if <#ty as ::typst::eval::Reflect>::castable(value) {
+ return true;
+ }
+ });
+ }
+ }
+ }
+
+ let dynamic_check = input.name.is_some().then(|| {
+ quote! {
+ if let ::typst::eval::Value::Dyn(dynamic) = &value {
+ if dynamic.is::<Self>() {
+ return true;
+ }
+ }
+ }
+ });
+
+ let str_check = (!strings.is_empty()).then(|| {
+ quote! {
+ if let ::typst::eval::Value::Str(string) = &value {
+ match string.as_str() {
+ #(#strings,)*
+ _ => {}
+ }
+ }
+ }
+ });
+
+ quote! {
+ #dynamic_check
+ #str_check
+ #(#casts)*
+ false
+ }
+}
+
+fn create_describe_body(input: &CastInput) -> TokenStream {
+ let mut infos = vec![];
+
+ for cast in &input.from_value {
+ let docs = documentation(&cast.attrs);
+ infos.push(match &cast.pattern {
+ Pattern::Str(lit) => {
+ quote! {
+ ::typst::eval::CastInfo::Value(
+ ::typst::eval::IntoValue::into_value(#lit),
+ #docs,
+ )
+ }
+ }
+ Pattern::Ty(_, ty) => {
+ quote! { <#ty as ::typst::eval::Reflect>::describe() }
+ }
+ });
+ }
+
+ if let Some(name) = &input.name {
+ infos.push(quote! {
+ ::typst::eval::CastInfo::Type(#name)
+ });
+ }
+
+ quote! {
+ #(#infos)+*
+ }
+}
+
+fn create_into_value_body(input: &CastInput) -> TokenStream {
+ if let Some(expr) = &input.into_value {
+ quote! { #expr }
+ } else {
+ quote! { ::typst::eval::Value::dynamic(self) }
+ }
+}
+
+fn create_from_value_body(input: &CastInput) -> TokenStream {
+ let mut string_arms = vec![];
+ let mut cast_checks = vec![];
+
+ for cast in &input.from_value {
+ let expr = &cast.expr;
+ match &cast.pattern {
+ Pattern::Str(lit) => {
+ string_arms.push(quote! { #lit => return Ok(#expr) });
+ }
+ Pattern::Ty(binding, ty) => {
+ cast_checks.push(quote! {
+ if <#ty as ::typst::eval::Reflect>::castable(&value) {
+ let #binding = <#ty as ::typst::eval::FromValue>::from_value(value)?;
+ return Ok(#expr);
+ }
+ });
+ }
+ }
+ }
+
+ let dynamic_check = input.name.is_some().then(|| {
+ quote! {
+ if let ::typst::eval::Value::Dyn(dynamic) = &value {
+ if let Some(concrete) = dynamic.downcast::<Self>() {
+ return Ok(concrete.clone());
+ }
+ }
+ }
+ });
+
+ let str_check = (!string_arms.is_empty()).then(|| {
+ quote! {
+ if let ::typst::eval::Value::Str(string) = &value {
+ match string.as_str() {
+ #(#string_arms,)*
+ _ => {}
+ }
+ }
+ }
+ });
+
+ quote! {
+ #dynamic_check
+ #str_check
+ #(#cast_checks)*
+ Err(<Self as ::typst::eval::Reflect>::error(&value))
+ }
+}
diff --git a/crates/typst-macros/src/element.rs b/crates/typst-macros/src/element.rs
new file mode 100644
index 00000000..6ce91fcb
--- /dev/null
+++ b/crates/typst-macros/src/element.rs
@@ -0,0 +1,554 @@
+use super::*;
+
+/// Expand the `#[element]` macro.
+pub fn element(stream: TokenStream, body: &syn::ItemStruct) -> Result<TokenStream> {
+ let element = prepare(stream, body)?;
+ Ok(create(&element))
+}
+
+struct Elem {
+ name: String,
+ display: String,
+ category: String,
+ keywords: Option<String>,
+ docs: String,
+ vis: syn::Visibility,
+ ident: Ident,
+ capable: Vec<Ident>,
+ fields: Vec<Field>,
+ scope: Option<BlockWithReturn>,
+}
+
+struct Field {
+ name: String,
+ docs: String,
+ internal: bool,
+ external: bool,
+ positional: bool,
+ required: bool,
+ variadic: bool,
+ synthesized: bool,
+ fold: bool,
+ resolve: bool,
+ parse: Option<BlockWithReturn>,
+ default: syn::Expr,
+ vis: syn::Visibility,
+ ident: Ident,
+ ident_in: Ident,
+ with_ident: Ident,
+ push_ident: Ident,
+ set_ident: Ident,
+ ty: syn::Type,
+ output: syn::Type,
+}
+
+impl Field {
+ fn inherent(&self) -> bool {
+ self.required || self.variadic
+ }
+
+ fn settable(&self) -> bool {
+ !self.inherent()
+ }
+}
+
+/// Preprocess the element's definition.
+fn prepare(stream: TokenStream, body: &syn::ItemStruct) -> Result<Elem> {
+ let syn::Fields::Named(named) = &body.fields else {
+ bail!(body, "expected named fields");
+ };
+
+ let mut fields = vec![];
+ for field in &named.named {
+ let Some(ident) = field.ident.clone() else {
+ bail!(field, "expected named field");
+ };
+
+ let mut attrs = field.attrs.clone();
+ let variadic = has_attr(&mut attrs, "variadic");
+ let required = has_attr(&mut attrs, "required") || variadic;
+ let positional = has_attr(&mut attrs, "positional") || required;
+
+ if ident == "label" {
+ bail!(ident, "invalid field name");
+ }
+
+ let mut field = Field {
+ name: kebab_case(&ident),
+ docs: documentation(&attrs),
+ internal: has_attr(&mut attrs, "internal"),
+ external: has_attr(&mut attrs, "external"),
+ positional,
+ required,
+ variadic,
+ synthesized: has_attr(&mut attrs, "synthesized"),
+ fold: has_attr(&mut attrs, "fold"),
+ resolve: has_attr(&mut attrs, "resolve"),
+ parse: parse_attr(&mut attrs, "parse")?.flatten(),
+ default: parse_attr(&mut attrs, "default")?
+ .flatten()
+ .unwrap_or_else(|| parse_quote! { ::std::default::Default::default() }),
+ vis: field.vis.clone(),
+ ident: ident.clone(),
+ ident_in: Ident::new(&format!("{}_in", ident), ident.span()),
+ with_ident: Ident::new(&format!("with_{}", ident), ident.span()),
+ push_ident: Ident::new(&format!("push_{}", ident), ident.span()),
+ set_ident: Ident::new(&format!("set_{}", ident), ident.span()),
+ ty: field.ty.clone(),
+ output: field.ty.clone(),
+ };
+
+ if field.required && (field.fold || field.resolve) {
+ bail!(ident, "required fields cannot be folded or resolved");
+ }
+
+ if field.required && !field.positional {
+ bail!(ident, "only positional fields can be required");
+ }
+
+ if field.resolve {
+ let output = &field.output;
+ field.output = parse_quote! { <#output as ::typst::model::Resolve>::Output };
+ }
+ if field.fold {
+ let output = &field.output;
+ field.output = parse_quote! { <#output as ::typst::model::Fold>::Output };
+ }
+
+ validate_attrs(&attrs)?;
+ fields.push(field);
+ }
+
+ let capable = Punctuated::<Ident, Token![,]>::parse_terminated
+ .parse2(stream)?
+ .into_iter()
+ .collect();
+
+ let mut attrs = body.attrs.clone();
+ let docs = documentation(&attrs);
+ let mut lines = docs.split('\n').collect();
+ let keywords = meta_line(&mut lines, "Keywords").ok().map(Into::into);
+ let category = meta_line(&mut lines, "Category")?.into();
+ let display = meta_line(&mut lines, "Display")?.into();
+ let docs = lines.join("\n").trim().into();
+
+ let element = Elem {
+ name: body.ident.to_string().trim_end_matches("Elem").to_lowercase(),
+ display,
+ category,
+ keywords,
+ docs,
+ vis: body.vis.clone(),
+ ident: body.ident.clone(),
+ capable,
+ fields,
+ scope: parse_attr(&mut attrs, "scope")?.flatten(),
+ };
+
+ validate_attrs(&attrs)?;
+ Ok(element)
+}
+
+/// Produce the element's definition.
+fn create(element: &Elem) -> TokenStream {
+ let Elem { vis, ident, docs, .. } = element;
+ let all = element.fields.iter().filter(|field| !field.external);
+ let settable = all.clone().filter(|field| !field.synthesized && field.settable());
+
+ // Inherent methods and functions.
+ let new = create_new_func(element);
+ let field_methods = all.clone().map(create_field_method);
+ let field_in_methods = settable.clone().map(create_field_in_method);
+ let with_field_methods = all.clone().map(create_with_field_method);
+ let push_field_methods = all.map(create_push_field_method);
+ let field_style_methods = settable.map(create_set_field_method);
+
+ // Trait implementations.
+ let element_impl = create_pack_impl(element);
+ let construct_impl = element
+ .capable
+ .iter()
+ .all(|capability| capability != "Construct")
+ .then(|| create_construct_impl(element));
+ let set_impl = create_set_impl(element);
+ let locatable_impl = element
+ .capable
+ .iter()
+ .any(|capability| capability == "Locatable")
+ .then(|| quote! { impl ::typst::model::Locatable for #ident {} });
+
+ quote! {
+ #[doc = #docs]
+ #[derive(Debug, Clone, Hash)]
+ #[repr(transparent)]
+ #vis struct #ident(pub ::typst::model::Content);
+
+ impl #ident {
+ #new
+ #(#field_methods)*
+ #(#field_in_methods)*
+ #(#with_field_methods)*
+ #(#push_field_methods)*
+ #(#field_style_methods)*
+
+ /// The element's span.
+ pub fn span(&self) -> ::typst::syntax::Span {
+ self.0.span()
+ }
+
+ /// Set the element's span.
+ pub fn spanned(self, span: ::typst::syntax::Span) -> Self {
+ Self(self.0.spanned(span))
+ }
+ }
+
+ #element_impl
+ #construct_impl
+ #set_impl
+ #locatable_impl
+
+ impl ::typst::eval::IntoValue for #ident {
+ fn into_value(self) -> ::typst::eval::Value {
+ ::typst::eval::Value::Content(self.0)
+ }
+ }
+ }
+}
+
+/// Create the `new` function for the element.
+fn create_new_func(element: &Elem) -> TokenStream {
+ let relevant = element
+ .fields
+ .iter()
+ .filter(|field| !field.external && !field.synthesized && field.inherent());
+ let params = relevant.clone().map(|Field { ident, ty, .. }| {
+ quote! { #ident: #ty }
+ });
+ let builder_calls = relevant.map(|Field { ident, with_ident, .. }| {
+ quote! { .#with_ident(#ident) }
+ });
+ quote! {
+ /// Create a new element.
+ pub fn new(#(#params),*) -> Self {
+ Self(::typst::model::Content::new(
+ <Self as ::typst::model::Element>::func()
+ ))
+ #(#builder_calls)*
+ }
+ }
+}
+
+/// Create an accessor methods for a field.
+fn create_field_method(field: &Field) -> TokenStream {
+ let Field { vis, docs, ident, name, output, .. } = field;
+ if field.inherent() || field.synthesized {
+ quote! {
+ #[doc = #docs]
+ #[track_caller]
+ #vis fn #ident(&self) -> #output {
+ self.0.expect_field(#name)
+ }
+ }
+ } else {
+ let access = create_style_chain_access(field, quote! { self.0.field(#name) });
+ quote! {
+ #[doc = #docs]
+ #vis fn #ident(&self, styles: ::typst::model::StyleChain) -> #output {
+ #access
+ }
+ }
+ }
+}
+
+/// Create a style chain access method for a field.
+fn create_field_in_method(field: &Field) -> TokenStream {
+ let Field { vis, ident_in, name, output, .. } = field;
+ let doc = format!("Access the `{}` field in the given style chain.", name);
+ let access = create_style_chain_access(field, quote! { None });
+ quote! {
+ #[doc = #doc]
+ #vis fn #ident_in(styles: ::typst::model::StyleChain) -> #output {
+ #access
+ }
+ }
+}
+
+/// Create a style chain access method for a field.
+fn create_style_chain_access(field: &Field, inherent: TokenStream) -> TokenStream {
+ let Field { name, ty, default, .. } = field;
+ let getter = match (field.fold, field.resolve) {
+ (false, false) => quote! { get },
+ (false, true) => quote! { get_resolve },
+ (true, false) => quote! { get_fold },
+ (true, true) => quote! { get_resolve_fold },
+ };
+
+ quote! {
+ styles.#getter::<#ty>(
+ <Self as ::typst::model::Element>::func(),
+ #name,
+ #inherent,
+ || #default,
+ )
+ }
+}
+
+/// Create a builder pattern method for a field.
+fn create_with_field_method(field: &Field) -> TokenStream {
+ let Field { vis, ident, with_ident, name, ty, .. } = field;
+ let doc = format!("Set the [`{}`](Self::{}) field.", name, ident);
+ quote! {
+ #[doc = #doc]
+ #vis fn #with_ident(mut self, #ident: #ty) -> Self {
+ Self(self.0.with_field(#name, #ident))
+ }
+ }
+}
+
+/// Create a set-style method for a field.
+fn create_push_field_method(field: &Field) -> TokenStream {
+ let Field { vis, ident, push_ident, name, ty, .. } = field;
+ let doc = format!("Push the [`{}`](Self::{}) field.", name, ident);
+ quote! {
+ #[doc = #doc]
+ #vis fn #push_ident(&mut self, #ident: #ty) {
+ self.0.push_field(#name, #ident);
+ }
+ }
+}
+
+/// Create a setter method for a field.
+fn create_set_field_method(field: &Field) -> TokenStream {
+ let Field { vis, ident, set_ident, name, ty, .. } = field;
+ let doc = format!("Create a style property for the `{}` field.", name);
+ quote! {
+ #[doc = #doc]
+ #vis fn #set_ident(#ident: #ty) -> ::typst::model::Style {
+ ::typst::model::Style::Property(::typst::model::Property::new(
+ <Self as ::typst::model::Element>::func(),
+ #name,
+ #ident,
+ ))
+ }
+ }
+}
+
+/// Create the element's `Pack` implementation.
+fn create_pack_impl(element: &Elem) -> TokenStream {
+ let Elem { ident, name, display, keywords, category, docs, .. } = element;
+ let vtable_func = create_vtable_func(element);
+ let infos = element
+ .fields
+ .iter()
+ .filter(|field| !field.internal && !field.synthesized)
+ .map(create_param_info);
+ let scope = create_scope_builder(element.scope.as_ref());
+ let keywords = quote_option(keywords);
+ quote! {
+ impl ::typst::model::Element for #ident {
+ fn pack(self) -> ::typst::model::Content {
+ self.0
+ }
+
+ fn unpack(content: &::typst::model::Content) -> ::std::option::Option<&Self> {
+ // Safety: Elements are #[repr(transparent)].
+ content.is::<Self>().then(|| unsafe {
+ ::std::mem::transmute(content)
+ })
+ }
+
+ fn func() -> ::typst::model::ElemFunc {
+ static NATIVE: ::typst::model::NativeElemFunc = ::typst::model::NativeElemFunc {
+ name: #name,
+ vtable: #vtable_func,
+ construct: <#ident as ::typst::model::Construct>::construct,
+ set: <#ident as ::typst::model::Set>::set,
+ info: ::typst::eval::Lazy::new(|| typst::eval::FuncInfo {
+ name: #name,
+ display: #display,
+ keywords: #keywords,
+ docs: #docs,
+ params: ::std::vec![#(#infos),*],
+ returns: ::typst::eval::CastInfo::Union(::std::vec![
+ ::typst::eval::CastInfo::Type("content")
+ ]),
+ category: #category,
+ scope: #scope,
+ }),
+ };
+ (&NATIVE).into()
+ }
+ }
+ }
+}
+
+/// Create the element's casting vtable.
+fn create_vtable_func(element: &Elem) -> TokenStream {
+ let ident = &element.ident;
+ let relevant = element.capable.iter().filter(|&ident| ident != "Construct");
+ let checks = relevant.map(|capability| {
+ quote! {
+ if id == ::std::any::TypeId::of::<dyn #capability>() {
+ return Some(unsafe {
+ ::typst::util::fat::vtable(&null as &dyn #capability)
+ });
+ }
+ }
+ });
+
+ quote! {
+ |id| {
+ let null = Self(::typst::model::Content::new(
+ <#ident as ::typst::model::Element>::func()
+ ));
+ #(#checks)*
+ None
+ }
+ }
+}
+
+/// Create a parameter info for a field.
+fn create_param_info(field: &Field) -> TokenStream {
+ let Field {
+ name,
+ docs,
+ positional,
+ variadic,
+ required,
+ default,
+ fold,
+ ty,
+ output,
+ ..
+ } = field;
+ let named = !positional;
+ let settable = field.settable();
+ let default_ty = if *fold { &output } else { &ty };
+ let default = quote_option(&settable.then(|| {
+ quote! {
+ || {
+ let typed: #default_ty = #default;
+ ::typst::eval::IntoValue::into_value(typed)
+ }
+ }
+ }));
+ let ty = if *variadic {
+ quote! { <#ty as ::typst::eval::Variadics>::Inner }
+ } else {
+ quote! { #ty }
+ };
+ quote! {
+ ::typst::eval::ParamInfo {
+ name: #name,
+ docs: #docs,
+ cast: <#ty as ::typst::eval::Reflect>::describe(),
+ default: #default,
+ positional: #positional,
+ named: #named,
+ variadic: #variadic,
+ required: #required,
+ settable: #settable,
+ }
+ }
+}
+
+/// Create the element's `Construct` implementation.
+fn create_construct_impl(element: &Elem) -> TokenStream {
+ let ident = &element.ident;
+ let handlers = element
+ .fields
+ .iter()
+ .filter(|field| {
+ !field.external
+ && !field.synthesized
+ && (!field.internal || field.parse.is_some())
+ })
+ .map(|field| {
+ let push_ident = &field.push_ident;
+ let (prefix, value) = create_field_parser(field);
+ if field.settable() {
+ quote! {
+ #prefix
+ if let Some(value) = #value {
+ element.#push_ident(value);
+ }
+ }
+ } else {
+ quote! {
+ #prefix
+ element.#push_ident(#value);
+ }
+ }
+ });
+
+ quote! {
+ impl ::typst::model::Construct for #ident {
+ fn construct(
+ vm: &mut ::typst::eval::Vm,
+ args: &mut ::typst::eval::Args,
+ ) -> ::typst::diag::SourceResult<::typst::model::Content> {
+ let mut element = Self(::typst::model::Content::new(
+ <Self as ::typst::model::Element>::func()
+ ));
+ #(#handlers)*
+ Ok(element.0)
+ }
+ }
+ }
+}
+
+/// Create the element's `Set` implementation.
+fn create_set_impl(element: &Elem) -> TokenStream {
+ let ident = &element.ident;
+ let handlers = element
+ .fields
+ .iter()
+ .filter(|field| {
+ !field.external
+ && !field.synthesized
+ && field.settable()
+ && (!field.internal || field.parse.is_some())
+ })
+ .map(|field| {
+ let set_ident = &field.set_ident;
+ let (prefix, value) = create_field_parser(field);
+ quote! {
+ #prefix
+ if let Some(value) = #value {
+ styles.set(Self::#set_ident(value));
+ }
+ }
+ });
+
+ quote! {
+ impl ::typst::model::Set for #ident {
+ fn set(
+ args: &mut ::typst::eval::Args,
+ ) -> ::typst::diag::SourceResult<::typst::model::Styles> {
+ let mut styles = ::typst::model::Styles::new();
+ #(#handlers)*
+ Ok(styles)
+ }
+ }
+ }
+}
+
+/// Create argument parsing code for a field.
+fn create_field_parser(field: &Field) -> (TokenStream, TokenStream) {
+ if let Some(BlockWithReturn { prefix, expr }) = &field.parse {
+ return (quote! { #(#prefix);* }, quote! { #expr });
+ }
+
+ let name = &field.name;
+ let value = if field.variadic {
+ quote! { args.all()? }
+ } else if field.required {
+ quote! { args.expect(#name)? }
+ } else if field.positional {
+ quote! { args.find()? }
+ } else {
+ quote! { args.named(#name)? }
+ };
+
+ (quote! {}, value)
+}
diff --git a/crates/typst-macros/src/func.rs b/crates/typst-macros/src/func.rs
new file mode 100644
index 00000000..4a68e846
--- /dev/null
+++ b/crates/typst-macros/src/func.rs
@@ -0,0 +1,268 @@
+use super::*;
+
+/// Expand the `#[func]` macro.
+pub fn func(stream: TokenStream, item: &syn::ItemFn) -> Result<TokenStream> {
+ let func = prepare(stream, item)?;
+ Ok(create(&func, item))
+}
+
+struct Func {
+ name: String,
+ display: String,
+ category: String,
+ keywords: Option<String>,
+ docs: String,
+ vis: syn::Visibility,
+ ident: Ident,
+ ident_func: Ident,
+ parent: Option<syn::Type>,
+ vm: bool,
+ vt: bool,
+ args: bool,
+ span: bool,
+ params: Vec<Param>,
+ returns: syn::Type,
+ scope: Option<BlockWithReturn>,
+}
+
+struct Param {
+ name: String,
+ docs: String,
+ external: bool,
+ named: bool,
+ variadic: bool,
+ default: Option<syn::Expr>,
+ ident: Ident,
+ ty: syn::Type,
+}
+
+fn prepare(stream: TokenStream, item: &syn::ItemFn) -> Result<Func> {
+ let sig = &item.sig;
+
+ let Parent(parent) = syn::parse2(stream)?;
+
+ let mut vm = false;
+ let mut vt = false;
+ let mut args = false;
+ let mut span = false;
+ let mut params = vec![];
+ for input in &sig.inputs {
+ let syn::FnArg::Typed(typed) = input else {
+ bail!(input, "self is not allowed here");
+ };
+
+ let syn::Pat::Ident(syn::PatIdent {
+ by_ref: None,
+ mutability: None,
+ ident,
+ ..
+ }) = &*typed.pat else {
+ bail!(typed.pat, "expected identifier");
+ };
+
+ match ident.to_string().as_str() {
+ "vm" => vm = true,
+ "vt" => vt = true,
+ "args" => args = true,
+ "span" => span = true,
+ _ => {
+ let mut attrs = typed.attrs.clone();
+ params.push(Param {
+ name: kebab_case(ident),
+ docs: documentation(&attrs),
+ external: has_attr(&mut attrs, "external"),
+ named: has_attr(&mut attrs, "named"),
+ variadic: has_attr(&mut attrs, "variadic"),
+ default: parse_attr(&mut attrs, "default")?.map(|expr| {
+ expr.unwrap_or_else(
+ || parse_quote! { ::std::default::Default::default() },
+ )
+ }),
+ ident: ident.clone(),
+ ty: (*typed.ty).clone(),
+ });
+
+ validate_attrs(&attrs)?;
+ }
+ }
+ }
+
+ let mut attrs = item.attrs.clone();
+ let docs = documentation(&attrs);
+ let mut lines = docs.split('\n').collect();
+ let keywords = meta_line(&mut lines, "Keywords").ok().map(Into::into);
+ let category = meta_line(&mut lines, "Category")?.into();
+ let display = meta_line(&mut lines, "Display")?.into();
+ let docs = lines.join("\n").trim().into();
+
+ let func = Func {
+ name: sig.ident.to_string().trim_end_matches('_').replace('_', "-"),
+ display,
+ category,
+ keywords,
+ docs,
+ vis: item.vis.clone(),
+ ident: sig.ident.clone(),
+ ident_func: Ident::new(
+ &format!("{}_func", sig.ident.to_string().trim_end_matches('_')),
+ sig.ident.span(),
+ ),
+ parent,
+ params,
+ returns: match &sig.output {
+ syn::ReturnType::Default => parse_quote! { () },
+ syn::ReturnType::Type(_, ty) => ty.as_ref().clone(),
+ },
+ scope: parse_attr(&mut attrs, "scope")?.flatten(),
+ vm,
+ vt,
+ args,
+ span,
+ };
+
+ Ok(func)
+}
+
+fn create(func: &Func, item: &syn::ItemFn) -> TokenStream {
+ let Func {
+ name,
+ display,
+ category,
+ docs,
+ vis,
+ ident,
+ ident_func,
+ returns,
+ ..
+ } = func;
+
+ let handlers = func
+ .params
+ .iter()
+ .filter(|param| !param.external)
+ .map(create_param_parser);
+
+ let args = func
+ .params
+ .iter()
+ .filter(|param| !param.external)
+ .map(|param| &param.ident);
+
+ let parent = func.parent.as_ref().map(|ty| quote! { #ty:: });
+ let vm_ = func.vm.then(|| quote! { vm, });
+ let vt_ = func.vt.then(|| quote! { &mut vm.vt, });
+ let args_ = func.args.then(|| quote! { args.take(), });
+ let span_ = func.span.then(|| quote! { args.span, });
+ let wrapper = quote! {
+ |vm, args| {
+ let __typst_func = #parent #ident;
+ #(#handlers)*
+ let output = __typst_func(#(#args,)* #vm_ #vt_ #args_ #span_);
+ ::typst::eval::IntoResult::into_result(output, args.span)
+ }
+ };
+
+ let mut item = item.clone();
+ item.attrs.clear();
+
+ let inputs = item.sig.inputs.iter().cloned().filter_map(|mut input| {
+ if let syn::FnArg::Typed(typed) = &mut input {
+ if typed.attrs.iter().any(|attr| attr.path().is_ident("external")) {
+ return None;
+ }
+ typed.attrs.clear();
+ }
+ Some(input)
+ });
+
+ item.sig.inputs = parse_quote! { #(#inputs),* };
+
+ let keywords = quote_option(&func.keywords);
+ let params = func.params.iter().map(create_param_info);
+ let scope = create_scope_builder(func.scope.as_ref());
+
+ quote! {
+ #[doc(hidden)]
+ #vis fn #ident_func() -> &'static ::typst::eval::NativeFunc {
+ static FUNC: ::typst::eval::NativeFunc = ::typst::eval::NativeFunc {
+ func: #wrapper,
+ info: ::typst::eval::Lazy::new(|| typst::eval::FuncInfo {
+ name: #name,
+ display: #display,
+ keywords: #keywords,
+ category: #category,
+ docs: #docs,
+ params: ::std::vec![#(#params),*],
+ returns: <#returns as ::typst::eval::Reflect>::describe(),
+ scope: #scope,
+ }),
+ };
+ &FUNC
+ }
+
+ #[doc = #docs]
+ #item
+ }
+}
+
+/// Create a parameter info for a field.
+fn create_param_info(param: &Param) -> TokenStream {
+ let Param { name, docs, named, variadic, ty, default, .. } = param;
+ let positional = !named;
+ let required = default.is_none();
+ let default = quote_option(&default.as_ref().map(|_default| {
+ quote! {
+ || {
+ let typed: #ty = #default;
+ ::typst::eval::IntoValue::into_value(typed)
+ }
+ }
+ }));
+ let ty = if *variadic {
+ quote! { <#ty as ::typst::eval::Variadics>::Inner }
+ } else {
+ quote! { #ty }
+ };
+ quote! {
+ ::typst::eval::ParamInfo {
+ name: #name,
+ docs: #docs,
+ cast: <#ty as ::typst::eval::Reflect>::describe(),
+ default: #default,
+ positional: #positional,
+ named: #named,
+ variadic: #variadic,
+ required: #required,
+ settable: false,
+ }
+ }
+}
+
+/// Create argument parsing code for a parameter.
+fn create_param_parser(param: &Param) -> TokenStream {
+ let Param { name, ident, ty, .. } = param;
+
+ let mut value = if param.variadic {
+ quote! { args.all()? }
+ } else if param.named {
+ quote! { args.named(#name)? }
+ } else if param.default.is_some() {
+ quote! { args.eat()? }
+ } else {
+ quote! { args.expect(#name)? }
+ };
+
+ if let Some(default) = &param.default {
+ value = quote! { #value.unwrap_or_else(|| #default) }
+ }
+
+ quote! { let mut #ident: #ty = #value; }
+}
+
+struct Parent(Option<syn::Type>);
+
+impl Parse for Parent {
+ fn parse(input: ParseStream) -> Result<Self> {
+ Ok(Self(if !input.is_empty() { Some(input.parse()?) } else { None }))
+ }
+}
diff --git a/crates/typst-macros/src/lib.rs b/crates/typst-macros/src/lib.rs
new file mode 100644
index 00000000..49840ef2
--- /dev/null
+++ b/crates/typst-macros/src/lib.rs
@@ -0,0 +1,63 @@
+//! Procedural macros for Typst.
+
+extern crate proc_macro;
+
+#[macro_use]
+mod util;
+mod castable;
+mod element;
+mod func;
+mod symbols;
+
+use proc_macro::TokenStream as BoundaryStream;
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::ext::IdentExt;
+use syn::parse::{Parse, ParseStream, Parser};
+use syn::punctuated::Punctuated;
+use syn::{parse_quote, DeriveInput, Ident, Result, Token};
+
+use self::util::*;
+
+/// Turns a function into a `NativeFunc`.
+#[proc_macro_attribute]
+pub fn func(stream: BoundaryStream, item: BoundaryStream) -> BoundaryStream {
+ let item = syn::parse_macro_input!(item as syn::ItemFn);
+ func::func(stream.into(), &item)
+ .unwrap_or_else(|err| err.to_compile_error())
+ .into()
+}
+
+/// Turns a type into an `Element`.
+#[proc_macro_attribute]
+pub fn element(stream: BoundaryStream, item: BoundaryStream) -> BoundaryStream {
+ let item = syn::parse_macro_input!(item as syn::ItemStruct);
+ element::element(stream.into(), &item)
+ .unwrap_or_else(|err| err.to_compile_error())
+ .into()
+}
+
+/// Implements `Reflect`, `FromValue`, and `IntoValue` for an enum.
+#[proc_macro_derive(Cast, attributes(string))]
+pub fn derive_cast(item: BoundaryStream) -> BoundaryStream {
+ let item = syn::parse_macro_input!(item as DeriveInput);
+ castable::derive_cast(&item)
+ .unwrap_or_else(|err| err.to_compile_error())
+ .into()
+}
+
+/// Implements `Reflect`, `FromValue`, and `IntoValue` for a type.
+#[proc_macro]
+pub fn cast(stream: BoundaryStream) -> BoundaryStream {
+ castable::cast(stream.into())
+ .unwrap_or_else(|err| err.to_compile_error())
+ .into()
+}
+
+/// Defines a list of `Symbol`s.
+#[proc_macro]
+pub fn symbols(stream: BoundaryStream) -> BoundaryStream {
+ symbols::symbols(stream.into())
+ .unwrap_or_else(|err| err.to_compile_error())
+ .into()
+}
diff --git a/crates/typst-macros/src/symbols.rs b/crates/typst-macros/src/symbols.rs
new file mode 100644
index 00000000..cdb7f5d7
--- /dev/null
+++ b/crates/typst-macros/src/symbols.rs
@@ -0,0 +1,78 @@
+use super::*;
+
+/// Expand the `symbols!` macro.
+pub fn symbols(stream: TokenStream) -> Result<TokenStream> {
+ let list: Punctuated<Symbol, Token![,]> =
+ Punctuated::parse_terminated.parse2(stream)?;
+ let pairs = list.iter().map(|symbol| {
+ let name = symbol.name.to_string();
+ let kind = match &symbol.kind {
+ Kind::Single(c) => quote! { typst::eval::Symbol::new(#c), },
+ Kind::Multiple(variants) => {
+ let variants = variants.iter().map(|variant| {
+ let name = &variant.name;
+ let c = &variant.c;
+ quote! { (#name, #c) }
+ });
+ quote! {
+ typst::eval::Symbol::list(&[#(#variants),*])
+ }
+ }
+ };
+ quote! { (#name, #kind) }
+ });
+ Ok(quote! { &[#(#pairs),*] })
+}
+
+struct Symbol {
+ name: syn::Ident,
+ kind: Kind,
+}
+
+enum Kind {
+ Single(syn::LitChar),
+ Multiple(Punctuated<Variant, Token![,]>),
+}
+
+struct Variant {
+ name: String,
+ c: syn::LitChar,
+}
+
+impl Parse for Symbol {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let name = input.call(Ident::parse_any)?;
+ input.parse::<Token![:]>()?;
+ let kind = input.parse()?;
+ Ok(Self { name, kind })
+ }
+}
+
+impl Parse for Kind {
+ fn parse(input: ParseStream) -> Result<Self> {
+ if input.peek(syn::LitChar) {
+ Ok(Self::Single(input.parse()?))
+ } else {
+ let content;
+ syn::bracketed!(content in input);
+ Ok(Self::Multiple(Punctuated::parse_terminated(&content)?))
+ }
+ }
+}
+
+impl Parse for Variant {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let mut name = String::new();
+ if input.peek(syn::Ident::peek_any) {
+ name.push_str(&input.call(Ident::parse_any)?.to_string());
+ while input.peek(Token![.]) {
+ input.parse::<Token![.]>()?;
+ name.push('.');
+ name.push_str(&input.call(Ident::parse_any)?.to_string());
+ }
+ input.parse::<Token![:]>()?;
+ }
+ let c = input.parse()?;
+ Ok(Self { name, c })
+ }
+}
diff --git a/crates/typst-macros/src/util.rs b/crates/typst-macros/src/util.rs
new file mode 100644
index 00000000..389fed06
--- /dev/null
+++ b/crates/typst-macros/src/util.rs
@@ -0,0 +1,145 @@
+use heck::ToKebabCase;
+use quote::ToTokens;
+
+use super::*;
+
+/// Return an error at the given item.
+macro_rules! bail {
+ (callsite, $($tts:tt)*) => {
+ return Err(syn::Error::new(
+ proc_macro2::Span::call_site(),
+ format!("typst: {}", format!($($tts)*))
+ ))
+ };
+ ($item:expr, $($tts:tt)*) => {
+ return Err(syn::Error::new_spanned(
+ &$item,
+ format!("typst: {}", format!($($tts)*))
+ ))
+ };
+}
+
+/// For parsing attributes of the form:
+/// #[attr(
+/// statement;
+/// statement;
+/// returned_expression
+/// )]
+pub struct BlockWithReturn {
+ pub prefix: Vec<syn::Stmt>,
+ pub expr: syn::Stmt,
+}
+
+impl Parse for BlockWithReturn {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let mut stmts = syn::Block::parse_within(input)?;
+ let Some(expr) = stmts.pop() else {
+ return Err(input.error("expected at least one expression"));
+ };
+ Ok(Self { prefix: stmts, expr })
+ }
+}
+
+/// Whether an attribute list has a specified attribute.
+pub fn has_attr(attrs: &mut Vec<syn::Attribute>, target: &str) -> bool {
+ take_attr(attrs, target).is_some()
+}
+
+/// Whether an attribute list has a specified attribute.
+pub fn parse_attr<T: Parse>(
+ attrs: &mut Vec<syn::Attribute>,
+ target: &str,
+) -> Result<Option<Option<T>>> {
+ take_attr(attrs, target)
+ .map(|attr| {
+ Ok(match attr.meta {
+ syn::Meta::Path(_) => None,
+ syn::Meta::List(list) => Some(list.parse_args()?),
+ syn::Meta::NameValue(meta) => bail!(meta, "not valid here"),
+ })
+ })
+ .transpose()
+}
+
+/// Whether an attribute list has a specified attribute.
+pub fn take_attr(
+ attrs: &mut Vec<syn::Attribute>,
+ target: &str,
+) -> Option<syn::Attribute> {
+ attrs
+ .iter()
+ .position(|attr| attr.path().is_ident(target))
+ .map(|i| attrs.remove(i))
+}
+
+/// Ensure that no unrecognized attributes remain.
+pub fn validate_attrs(attrs: &[syn::Attribute]) -> Result<()> {
+ for attr in attrs {
+ if !attr.path().is_ident("doc") && !attr.path().is_ident("derive") {
+ let ident = attr.path().get_ident().unwrap();
+ bail!(ident, "unrecognized attribute: {ident}");
+ }
+ }
+ Ok(())
+}
+
+/// Convert an identifier to a kebab-case string.
+pub fn kebab_case(name: &Ident) -> String {
+ name.to_string().to_kebab_case()
+}
+
+/// Extract documentation comments from an attribute list.
+pub fn documentation(attrs: &[syn::Attribute]) -> String {
+ let mut doc = String::new();
+
+ // Parse doc comments.
+ for attr in attrs {
+ if let syn::Meta::NameValue(meta) = &attr.meta {
+ if meta.path.is_ident("doc") {
+ if let syn::Expr::Lit(lit) = &meta.value {
+ if let syn::Lit::Str(string) = &lit.lit {
+ let full = string.value();
+ let line = full.strip_prefix(' ').unwrap_or(&full);
+ doc.push_str(line);
+ doc.push('\n');
+ }
+ }
+ }
+ }
+ }
+
+ doc.trim().into()
+}
+
+/// Extract a line of metadata from documentation.
+pub fn meta_line<'a>(lines: &mut Vec<&'a str>, key: &str) -> Result<&'a str> {
+ match lines.last().and_then(|line| line.strip_prefix(&format!("{key}:"))) {
+ Some(value) => {
+ lines.pop();
+ Ok(value.trim())
+ }
+ None => bail!(callsite, "missing metadata key: {key}"),
+ }
+}
+
+/// Creates a block responsible for building a `Scope`.
+pub fn create_scope_builder(scope_block: Option<&BlockWithReturn>) -> TokenStream {
+ if let Some(BlockWithReturn { prefix, expr }) = scope_block {
+ quote! { {
+ let mut scope = ::typst::eval::Scope::deduplicating();
+ #(#prefix);*
+ #expr
+ } }
+ } else {
+ quote! { ::typst::eval::Scope::new() }
+ }
+}
+
+/// Quotes an option literally.
+pub fn quote_option<T: ToTokens>(option: &Option<T>) -> TokenStream {
+ if let Some(value) = option {
+ quote! { Some(#value) }
+ } else {
+ quote! { None }
+ }
+}
diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml
new file mode 100644
index 00000000..c9f3bb02
--- /dev/null
+++ b/crates/typst/Cargo.toml
@@ -0,0 +1,56 @@
+[package]
+name = "typst"
+description = "A new markup-based typesetting system that is powerful and easy to learn."
+categories = ["compilers", "science"]
+keywords = ["markup", "typesetting"]
+version.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+
+[lib]
+doctest = false
+bench = false
+
+[dependencies]
+typst-macros = { path = "../typst-macros" }
+bitflags = { version = "2", features = ["serde"] }
+bytemuck = "1"
+comemo = "0.3"
+ecow = { version = "0.1.1", features = ["serde"] }
+flate2 = "1"
+fontdb = "0.13"
+if_chain = "1"
+image = { version = "0.24", default-features = false, features = ["png", "jpeg", "gif"] }
+indexmap = "1.9.3"
+log = "0.4"
+miniz_oxide = "0.7"
+once_cell = "1"
+pdf-writer = "0.7.1"
+pixglyph = "0.1"
+regex = "1"
+resvg = { version = "0.32", default-features = false }
+roxmltree = "0.18"
+rustybuzz = "0.7"
+serde = { version = "1", features = ["derive"] }
+siphasher = "0.3"
+subsetter = "0.1.1"
+svg2pdf = { git = "https://github.com/typst/svg2pdf" }
+tiny-skia = "0.9.0"
+toml = { version = "0.7.3", default-features = false, features = ["parse"] }
+tracing = "0.1.37"
+ttf-parser = "0.18.1"
+unicode-general-category = "0.6"
+unicode-ident = "1.0"
+unicode-math-class = "0.1"
+unicode-segmentation = "1"
+unscanny = "0.1"
+usvg = { version = "0.32", default-features = false, features = ["text"] }
+xmp-writer = "0.1"
+time = { version = "0.3.20", features = ["std", "formatting"] }
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+stacker = "0.1.15"
diff --git a/crates/typst/src/diag.rs b/crates/typst/src/diag.rs
new file mode 100644
index 00000000..b5995be4
--- /dev/null
+++ b/crates/typst/src/diag.rs
@@ -0,0 +1,376 @@
+//! 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 crate::file::PackageSpec;
+use crate::syntax::{Span, Spanned};
+use crate::World;
+
+/// 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`.
+///
+/// ```
+/// bail!("bailing with a {}", "string result");
+/// bail!(span, "bailing with a {}", "source result");
+/// ```
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __bail {
+ ($fmt:literal $(, $arg:expr)* $(,)?) => {
+ return Err($crate::diag::eco_format!($fmt, $($arg),*))
+ };
+
+ ($error:expr) => {
+ return Err(Box::new(vec![$error]))
+ };
+
+ ($span:expr, $fmt:literal $(, $arg:expr)* $(,)?) => {
+ return Err(Box::new(vec![$crate::diag::SourceError::new(
+ $span,
+ $crate::diag::eco_format!($fmt, $($arg),*),
+ )]))
+ };
+}
+
+#[doc(inline)]
+pub use crate::__bail as bail;
+
+/// Construct an [`EcoString`] or [`SourceError`].
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __error {
+ ($fmt:literal $(, $arg:expr)* $(,)?) => {
+ $crate::diag::eco_format!($fmt, $($arg),*)
+ };
+
+ ($span:expr, $fmt:literal $(, $arg:expr)* $(,)?) => {
+ $crate::diag::SourceError::new(
+ $span,
+ $crate::diag::eco_format!($fmt, $($arg),*),
+ )
+ };
+}
+
+#[doc(inline)]
+pub use crate::__error as error;
+#[doc(hidden)]
+pub use ecow::{eco_format, EcoString};
+
+/// A result that can carry multiple source errors.
+pub type SourceResult<T> = Result<T, Box<Vec<SourceError>>>;
+
+/// An error 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 SourceError {
+ /// The span of the erroneous 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 error.
+ pub trace: Vec<Spanned<Tracepoint>>,
+ /// Additonal hints to the user, indicating how this error could be avoided
+ /// or worked around.
+ pub hints: Vec<EcoString>,
+}
+
+impl SourceError {
+ /// Create a new, bare error.
+ pub fn new(span: Span, message: impl Into<EcoString>) -> Self {
+ Self {
+ span,
+ trace: vec![],
+ message: message.into(),
+ hints: vec![],
+ }
+ }
+
+ /// Adds user-facing hints to the error.
+ pub fn with_hints(mut self, hints: impl IntoIterator<Item = EcoString>) -> Self {
+ self.hints.extend(hints);
+ self
+ }
+}
+
+/// A part of an error's [trace](SourceError::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| {
+ if span.is_detached() {
+ return errors;
+ }
+
+ let trace_range = span.range(&*world);
+ for error in errors.iter_mut().filter(|e| !e.span.is_detached()) {
+ // Skip traces that surround the error.
+ if error.span.id() == span.id() {
+ let error_range = error.span.range(&*world);
+ if 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`] 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| Box::new(vec![SourceError::new(span, message)]))
+ }
+}
+
+/// A result type with a string error message and hints.
+pub type HintedStrResult<T> = Result<T, HintedString>;
+
+/// A string message with hints.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct HintedString {
+ /// A diagnostic message describing the problem.
+ pub message: EcoString,
+ /// Additonal hints to the user, indicating how this error could be avoided
+ /// or worked around.
+ pub hints: Vec<EcoString>,
+}
+
+impl<T> At<T> for Result<T, HintedString> {
+ fn at(self, span: Span) -> SourceResult<T> {
+ self.map_err(|diags| {
+ Box::new(vec![SourceError::new(span, diags.message).with_hints(diags.hints)])
+ })
+ }
+}
+
+/// 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> Hint<T> for StrResult<T> {
+ fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T> {
+ self.map_err(|message| HintedString { message, hints: vec![hint.into()] })
+ }
+}
+
+impl<T> Hint<T> for HintedStrResult<T> {
+ fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T> {
+ self.map_err(|mut error| {
+ error.hints.push(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.
+ Other,
+}
+
+impl FileError {
+ /// Create a file error from an I/O error.
+ pub fn from_io(error: io::Error, path: &Path) -> Self {
+ match error.kind() {
+ io::ErrorKind::NotFound => Self::NotFound(path.into()),
+ io::ErrorKind::PermissionDenied => Self::AccessDenied,
+ io::ErrorKind::InvalidData
+ if error.to_string().contains("stream did not contain valid UTF-8") =>
+ {
+ Self::InvalidUtf8
+ }
+ _ => Self::Other,
+ }
+ }
+}
+
+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 => 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(error: PackageError) -> Self {
+ Self::Package(error)
+ }
+}
+
+impl From<FileError> for EcoString {
+ fn from(error: FileError) -> Self {
+ eco_format!("{error}")
+ }
+}
+
+/// A result type with a package-related error.
+pub type PackageResult<T> = Result<T, PackageError>;
+
+/// An error that occured while trying to load a package.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum PackageError {
+ /// The specified package does not exist.
+ NotFound(PackageSpec),
+ /// Failed to retrieve the package through the network.
+ NetworkFailed,
+ /// The package archive was malformed.
+ MalformedArchive,
+ /// Another error.
+ Other,
+}
+
+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::NetworkFailed => f.pad("failed to load package (network failed)"),
+ Self::MalformedArchive => f.pad("failed to load package (archive malformed)"),
+ Self::Other => f.pad("failed to load package"),
+ }
+ }
+}
+
+impl From<PackageError> for EcoString {
+ fn from(error: PackageError) -> Self {
+ eco_format!("{error}")
+ }
+}
+/// 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")
+ }
+ _ => eco_format!("failed to parse {format}"),
+ }
+}
diff --git a/crates/typst/src/doc.rs b/crates/typst/src/doc.rs
new file mode 100644
index 00000000..de16cece
--- /dev/null
+++ b/crates/typst/src/doc.rs
@@ -0,0 +1,719 @@
+//! Finished documents.
+
+use std::fmt::{self, Debug, Formatter};
+use std::num::NonZeroUsize;
+use std::ops::Range;
+use std::str::FromStr;
+use std::sync::Arc;
+
+use ecow::EcoString;
+
+use crate::eval::{cast, dict, Dict, Value};
+use crate::font::Font;
+use crate::geom::{
+ self, rounded_rect, Abs, Align, Axes, Color, Corners, Dir, Em, Geometry, Length,
+ Numeric, Paint, Point, Rel, RgbaColor, Shape, Sides, Size, Stroke, Transform,
+};
+use crate::image::Image;
+use crate::model::{Content, Location, MetaElem, StyleChain};
+use crate::syntax::Span;
+
+/// A finished document with metadata and page frames.
+#[derive(Debug, Default, Clone, Hash)]
+pub struct Document {
+ /// The page frames.
+ pub pages: Vec<Frame>,
+ /// The document's title.
+ pub title: Option<EcoString>,
+ /// The document's author.
+ pub author: Vec<EcoString>,
+}
+
+/// 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<Vec<(Point, FrameItem)>>,
+}
+
+/// Constructor, accessors and setters.
+impl Frame {
+ /// Create a new, empty frame.
+ ///
+ /// Panics the size is not finite.
+ #[track_caller]
+ pub fn new(size: Size) -> Self {
+ assert!(size.is_finite());
+ Self { size, baseline: None, items: Arc::new(vec![]) }
+ }
+
+ /// 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 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, items: FrameItem) {
+ Arc::make_mut(&mut self.items).insert(layer, (pos, items));
+ }
+
+ /// Add an item at a position in the background.
+ pub fn prepend(&mut self, pos: Point, item: FrameItem) {
+ Arc::make_mut(&mut self.items).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 {
+ 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) {
+ // 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);
+ }
+ 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_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(vec![]);
+ }
+ }
+
+ /// Resize the frame to a new size, distributing new space according to the
+ /// given alignments.
+ pub fn resize(&mut self, target: Size, aligns: Axes<Align>) {
+ if self.size != target {
+ let offset = Point::new(
+ aligns.x.position(target.x - self.size.x),
+ aligns.y.position(target.y - self.size.y),
+ );
+ self.size = target;
+ self.translate(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) {
+ *point += offset;
+ }
+ }
+ }
+
+ /// Attach the metadata from this style chain to the frame.
+ pub fn meta(&mut self, styles: StyleChain, force: bool) {
+ if force || !self.is_empty() {
+ self.meta_iter(MetaElem::data_in(styles));
+ }
+ }
+
+ /// Attach metadata from an iterator.
+ pub fn meta_iter(&mut self, iter: impl IntoIterator<Item = Meta>) {
+ let mut hide = false;
+ for meta in iter {
+ if matches!(meta, Meta::Hide) {
+ hide = true;
+ } else {
+ self.prepend(Point::zero(), FrameItem::Meta(meta, self.size));
+ }
+ }
+ if hide {
+ Arc::make_mut(&mut self.items).retain(|(_, item)| {
+ matches!(item, FrameItem::Group(_) | FrameItem::Meta(Meta::Elem(_), _))
+ });
+ }
+ }
+
+ /// Add a background fill.
+ pub fn fill(&mut self, fill: Paint) {
+ self.prepend(
+ Point::zero(),
+ FrameItem::Shape(Geometry::Rect(self.size()).filled(fill), Span::detached()),
+ );
+ }
+
+ /// Add a fill and stroke with optional radius and outset to the frame.
+ pub fn fill_and_stroke(
+ &mut self,
+ fill: Option<Paint>,
+ stroke: Sides<Option<Stroke>>,
+ outset: Sides<Rel<Abs>>,
+ radius: Corners<Rel<Abs>>,
+ span: Span,
+ ) {
+ let outset = outset.relative_to(self.size());
+ let size = self.size() + outset.sum_by_axis();
+ let pos = Point::new(-outset.left, -outset.top);
+ let radius = radius.map(|side| side.relative_to(size.x.min(size.y) / 2.0));
+ self.prepend_multiple(
+ rounded_rect(size, radius, fill, stroke)
+ .into_iter()
+ .map(|x| (pos, FrameItem::Shape(x, span))),
+ )
+ }
+
+ /// 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 its size.
+ pub fn clip(&mut self) {
+ if !self.is_empty() {
+ self.group(|g| g.clips = true);
+ }
+ }
+
+ /// 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::new(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 debug(mut self) -> Self {
+ self.insert(
+ 0,
+ Point::zero(),
+ FrameItem::Shape(
+ Geometry::Rect(self.size)
+ .filled(RgbaColor { a: 100, ..Color::TEAL.to_rgba() }.into()),
+ Span::detached(),
+ ),
+ );
+ self.insert(
+ 1,
+ Point::with_y(self.baseline()),
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(self.size.x)).stroked(Stroke {
+ paint: Color::RED.into(),
+ thickness: Abs::pt(1.0),
+ ..Stroke::default()
+ }),
+ Span::detached(),
+ ),
+ );
+ self
+ }
+
+ /// 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(
+ geom::ellipse(Size::splat(2.0 * radius), Some(Color::GREEN.into()), None),
+ 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(Stroke {
+ paint: Color::GREEN.into(),
+ thickness: Abs::pt(1.0),
+ ..Stroke::default()
+ }),
+ 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 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),
+ /// Meta information and the region it applies to.
+ Meta(Meta, Size),
+}
+
+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::Meta(meta, _) => write!(f, "{meta:?}"),
+ }
+ }
+}
+
+/// 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 clips: bool,
+}
+
+impl GroupItem {
+ /// Create a new group with default settings.
+ pub fn new(frame: Frame) -> Self {
+ Self {
+ frame,
+ transform: Transform::identity(),
+ clips: false,
+ }
+ }
+}
+
+impl Debug for GroupItem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Group ")?;
+ self.frame.fmt(f)
+ }
+}
+
+/// 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,
+ /// The natural language of the text.
+ pub lang: Lang,
+ /// The item's plain text.
+ pub text: EcoString,
+ /// The glyphs.
+ 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.
+ 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)
+ }
+}
+
+/// 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 CHINESE: Self = Self(*b"zh ", 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 FILIPINO: Self = Self(*b"tl ", 2);
+ pub const FRENCH: Self = Self(*b"fr ", 2);
+ pub const GERMAN: Self = Self(*b"de ", 2);
+ pub const ITALIAN: Self = Self(*b"it ", 2);
+ pub const JAPANESE: Self = Self(*b"ja ", 2);
+ 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 RUSSIAN: Self = Self(*b"ru ", 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 => Self::from_str(&string)?,
+}
+
+/// 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)?,
+}
+
+/// Meta information that isn't visible or renderable.
+#[derive(Clone, PartialEq, Hash)]
+pub enum Meta {
+ /// An internal or external link to a destination.
+ Link(Destination),
+ /// An identifiable element that produces something within the area this
+ /// metadata is attached to.
+ Elem(Content),
+ /// The numbering of the current page.
+ PageNumbering(Value),
+ /// Indicates that content should be hidden. This variant doesn't appear
+ /// in the final frames as it is removed alongside the content that should
+ /// be hidden.
+ Hide,
+}
+
+cast! {
+ type Meta: "meta",
+}
+
+impl Debug for Meta {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Link(dest) => write!(f, "Link({dest:?})"),
+ Self::Elem(content) => write!(f, "Elem({:?})", content.func()),
+ Self::PageNumbering(value) => write!(f, "PageNumbering({value:?})"),
+ Self::Hide => f.pad("Hide"),
+ }
+ }
+}
+
+/// A link destination.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum Destination {
+ /// A link to a URL.
+ Url(EcoString),
+ /// A link to a point on a page.
+ Position(Position),
+ /// An unresolved link to a location in the document.
+ Location(Location),
+}
+
+cast! {
+ Destination,
+ self => match self {
+ Self::Url(v) => v.into_value(),
+ Self::Position(v) => v.into_value(),
+ Self::Location(v) => v.into_value(),
+ },
+ v: EcoString => Self::Url(v),
+ v: Position => Self::Position(v),
+ v: Location => Self::Location(v),
+}
+
+/// 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,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::util::option_eq;
+
+ #[test]
+ fn test_region_option_eq() {
+ let region = Some(Region([b'U', b'S']));
+ assert!(option_eq(region, "US"));
+ assert!(!option_eq(region, "AB"));
+ }
+
+ #[test]
+ fn test_document_is_send() {
+ fn ensure_send<T: Send>() {}
+ ensure_send::<Document>();
+ }
+}
diff --git a/crates/typst/src/eval/args.rs b/crates/typst/src/eval/args.rs
new file mode 100644
index 00000000..da29eeaf
--- /dev/null
+++ b/crates/typst/src/eval/args.rs
@@ -0,0 +1,216 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::{eco_format, EcoVec};
+
+use super::{Array, Dict, FromValue, IntoValue, Str, Value};
+use crate::diag::{bail, At, SourceResult};
+use crate::syntax::{Span, Spanned};
+use crate::util::pretty_array_like;
+
+/// Evaluated arguments to a function.
+#[derive(Clone, PartialEq, Hash)]
+pub struct Args {
+ /// The span of the whole argument list.
+ pub span: Span,
+ /// The positional and named arguments.
+ pub items: EcoVec<Arg>,
+}
+
+/// An argument to a function call: `12` or `draw: false`.
+#[derive(Clone, PartialEq, Hash)]
+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 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 }
+ }
+
+ /// 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.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![];
+ while let Some(value) = self.find()? {
+ list.push(value);
+ }
+ 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(())
+ }
+
+ /// Extract the positional arguments as an array.
+ pub fn to_pos(&self) -> Array {
+ self.items
+ .iter()
+ .filter(|item| item.name.is_none())
+ .map(|item| item.value.v.clone())
+ .collect()
+ }
+
+ /// Extract the named arguments as a dictionary.
+ 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 {
+ let pieces: Vec<_> =
+ self.items.iter().map(|arg| eco_format!("{arg:?}")).collect();
+ f.write_str(&pretty_array_like(&pieces, false))
+ }
+}
+
+impl Debug for Arg {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if let Some(name) = &self.name {
+ f.write_str(name)?;
+ f.write_str(": ")?;
+ }
+ Debug::fmt(&self.value.v, f)
+ }
+}
diff --git a/crates/typst/src/eval/array.rs b/crates/typst/src/eval/array.rs
new file mode 100644
index 00000000..a7a1387b
--- /dev/null
+++ b/crates/typst/src/eval/array.rs
@@ -0,0 +1,508 @@
+use std::cmp::Ordering;
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Add, AddAssign};
+
+use ecow::{eco_format, EcoString, EcoVec};
+
+use super::{ops, Args, CastInfo, FromValue, Func, IntoValue, Reflect, Value, Vm};
+use crate::diag::{At, SourceResult, StrResult};
+use crate::syntax::Span;
+use crate::util::pretty_array_like;
+
+/// Create a new [`Array`] from values.
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __array {
+ ($value:expr; $count:expr) => {
+ $crate::eval::Array::from($crate::eval::eco_vec![
+ $crate::eval::IntoValue::into_value($value);
+ $count
+ ])
+ };
+
+ ($($value:expr),* $(,)?) => {
+ $crate::eval::Array::from($crate::eval::eco_vec![$(
+ $crate::eval::IntoValue::into_value($value)
+ ),*])
+ };
+}
+
+#[doc(inline)]
+pub use crate::__array as array;
+use crate::eval::ops::{add, mul};
+#[doc(hidden)]
+pub use ecow::eco_vec;
+
+/// A reference counted array with value semantics.
+#[derive(Default, Clone, PartialEq, Hash)]
+pub struct Array(EcoVec<Value>);
+
+impl Array {
+ /// Create a new, empty array.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Return `true` if the length is 0.
+ pub fn is_empty(&self) -> bool {
+ self.0.len() == 0
+ }
+
+ /// The length of the array.
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// The first value in the array.
+ pub fn first(&self) -> StrResult<&Value> {
+ self.0.first().ok_or_else(array_is_empty)
+ }
+
+ /// 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)
+ }
+
+ /// The last value in the array.
+ pub fn last(&self) -> StrResult<&Value> {
+ self.0.last().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)
+ }
+
+ /// Borrow the value at the given index.
+ pub fn at<'a>(
+ &'a self,
+ index: i64,
+ default: Option<&'a Value>,
+ ) -> StrResult<&'a Value> {
+ self.locate(index)
+ .and_then(|i| self.0.get(i))
+ .or(default)
+ .ok_or_else(|| out_of_bounds_no_default(index, self.len()))
+ }
+
+ /// 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(index)
+ .and_then(move |i| self.0.make_mut().get_mut(i))
+ .ok_or_else(|| out_of_bounds_no_default(index, len))
+ }
+
+ /// Push a value to the end of the array.
+ pub fn push(&mut self, value: Value) {
+ self.0.push(value);
+ }
+
+ /// Remove the last value in the array.
+ pub fn pop(&mut self) -> StrResult<Value> {
+ self.0.pop().ok_or_else(array_is_empty)
+ }
+
+ /// Insert a value at the specified index.
+ pub fn insert(&mut self, index: i64, value: Value) -> StrResult<()> {
+ let len = self.len();
+ let i = self
+ .locate(index)
+ .filter(|&i| i <= self.0.len())
+ .ok_or_else(|| out_of_bounds(index, len))?;
+
+ self.0.insert(i, value);
+ Ok(())
+ }
+
+ /// Remove and return the value at the specified index.
+ pub fn remove(&mut self, index: i64) -> StrResult<Value> {
+ let len = self.len();
+ let i = self
+ .locate(index)
+ .filter(|&i| i < self.0.len())
+ .ok_or_else(|| out_of_bounds(index, len))?;
+
+ Ok(self.0.remove(i))
+ }
+
+ /// Extract a contiguous subregion of the array.
+ pub fn slice(&self, start: i64, end: Option<i64>) -> StrResult<Self> {
+ let len = self.len();
+ let start = self
+ .locate(start)
+ .filter(|&start| start <= self.0.len())
+ .ok_or_else(|| out_of_bounds(start, len))?;
+
+ let end = end.unwrap_or(self.len() as i64);
+ let end = self
+ .locate(end)
+ .filter(|&end| end <= self.0.len())
+ .ok_or_else(|| out_of_bounds(end, len))?
+ .max(start);
+
+ Ok(self.0[start..end].into())
+ }
+
+ /// Whether the array contains a specific value.
+ pub fn contains(&self, value: &Value) -> bool {
+ self.0.contains(value)
+ }
+
+ /// Return the first matching item.
+ pub fn find(&self, vm: &mut Vm, func: Func) -> SourceResult<Option<Value>> {
+ for item in self.iter() {
+ let args = Args::new(func.span(), [item.clone()]);
+ if func.call_vm(vm, args)?.cast::<bool>().at(func.span())? {
+ return Ok(Some(item.clone()));
+ }
+ }
+ Ok(None)
+ }
+
+ /// Return the index of the first matching item.
+ pub fn position(&self, vm: &mut Vm, func: Func) -> SourceResult<Option<i64>> {
+ for (i, item) in self.iter().enumerate() {
+ let args = Args::new(func.span(), [item.clone()]);
+ if func.call_vm(vm, args)?.cast::<bool>().at(func.span())? {
+ return Ok(Some(i as i64));
+ }
+ }
+
+ Ok(None)
+ }
+
+ /// Return a new array with only those items for which the function returns
+ /// true.
+ pub fn filter(&self, vm: &mut Vm, func: Func) -> SourceResult<Self> {
+ let mut kept = EcoVec::new();
+ for item in self.iter() {
+ let args = Args::new(func.span(), [item.clone()]);
+ if func.call_vm(vm, args)?.cast::<bool>().at(func.span())? {
+ kept.push(item.clone())
+ }
+ }
+ Ok(kept.into())
+ }
+
+ /// Transform each item in the array with a function.
+ pub fn map(&self, vm: &mut Vm, func: Func) -> SourceResult<Self> {
+ self.iter()
+ .map(|item| {
+ let args = Args::new(func.span(), [item.clone()]);
+ func.call_vm(vm, args)
+ })
+ .collect()
+ }
+
+ /// Fold all of the array's items into one with a function.
+ pub fn fold(&self, vm: &mut Vm, init: Value, func: Func) -> SourceResult<Value> {
+ let mut acc = init;
+ for item in self.iter() {
+ let args = Args::new(func.span(), [acc, item.clone()]);
+ acc = func.call_vm(vm, args)?;
+ }
+ Ok(acc)
+ }
+
+ /// Calculates the sum of the array's items
+ pub fn sum(&self, default: Option<Value>, span: Span) -> SourceResult<Value> {
+ let mut acc = self
+ .first()
+ .map(|x| x.clone())
+ .or_else(|_| {
+ default.ok_or_else(|| {
+ eco_format!("cannot calculate sum of empty array with no default")
+ })
+ })
+ .at(span)?;
+ for i in self.iter().skip(1) {
+ acc = add(acc, i.clone()).at(span)?;
+ }
+ Ok(acc)
+ }
+
+ /// Calculates the product of the array's items
+ pub fn product(&self, default: Option<Value>, span: Span) -> SourceResult<Value> {
+ let mut acc = self
+ .first()
+ .map(|x| x.clone())
+ .or_else(|_| {
+ default.ok_or_else(|| {
+ eco_format!("cannot calculate product of empty array with no default")
+ })
+ })
+ .at(span)?;
+ for i in self.iter().skip(1) {
+ acc = mul(acc, i.clone()).at(span)?;
+ }
+ Ok(acc)
+ }
+
+ /// Whether any item matches.
+ pub fn any(&self, vm: &mut Vm, func: Func) -> SourceResult<bool> {
+ for item in self.iter() {
+ let args = Args::new(func.span(), [item.clone()]);
+ if func.call_vm(vm, args)?.cast::<bool>().at(func.span())? {
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+ }
+
+ /// Whether all items match.
+ pub fn all(&self, vm: &mut Vm, func: Func) -> SourceResult<bool> {
+ for item in self.iter() {
+ let args = Args::new(func.span(), [item.clone()]);
+ if !func.call_vm(vm, args)?.cast::<bool>().at(func.span())? {
+ return Ok(false);
+ }
+ }
+
+ Ok(true)
+ }
+
+ /// Return a new array with all items from this and nested arrays.
+ pub fn flatten(&self) -> Self {
+ let mut flat = EcoVec::with_capacity(self.0.len());
+ for item in self.iter() {
+ if let Value::Array(nested) = item {
+ flat.extend(nested.flatten().into_iter());
+ } else {
+ flat.push(item.clone());
+ }
+ }
+ flat.into()
+ }
+
+ /// Returns a new array with reversed order.
+ pub fn rev(&self) -> Self {
+ self.0.iter().cloned().rev().collect()
+ }
+
+ /// Split all values in the array.
+ pub fn split(&self, at: Value) -> Array {
+ self.as_slice()
+ .split(|value| *value == at)
+ .map(|subslice| Value::Array(subslice.iter().cloned().collect()))
+ .collect()
+ }
+
+ /// Join all values in the array, optionally with separator and last
+ /// separator (between the final two items).
+ pub fn join(&self, sep: Option<Value>, mut last: Option<Value>) -> StrResult<Value> {
+ let len = self.0.len();
+ let sep = sep.unwrap_or(Value::None);
+
+ let mut result = Value::None;
+ for (i, value) in self.iter().cloned().enumerate() {
+ if i > 0 {
+ if i + 1 == len && last.is_some() {
+ result = ops::join(result, last.take().unwrap())?;
+ } else {
+ result = ops::join(result, sep.clone())?;
+ }
+ }
+
+ result = ops::join(result, value)?;
+ }
+
+ Ok(result)
+ }
+
+ /// Zips the array with another array. If the two arrays are of unequal length, it will only
+ /// zip up until the last element of the smaller array and the remaining elements will be
+ /// ignored. The return value is an array where each element is yet another array of size 2.
+ pub fn zip(&self, other: Array) -> Array {
+ self.iter()
+ .zip(other)
+ .map(|(first, second)| array![first.clone(), second].into_value())
+ .collect()
+ }
+
+ /// Return a sorted version of this array, optionally by a given key function.
+ ///
+ /// Returns an error if two values could not be compared or if the key function (if given)
+ /// yields an error.
+ pub fn sorted(
+ &self,
+ vm: &mut Vm,
+ span: Span,
+ key: Option<Func>,
+ ) -> SourceResult<Self> {
+ let mut result = Ok(());
+ let mut vec = self.0.clone();
+ 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_vm(vm, Args::new(f.span(), [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)) => {
+ typst::eval::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())
+ }
+
+ /// Repeat this array `n` times.
+ pub fn repeat(&self, n: i64) -> StrResult<Self> {
+ let count = usize::try_from(n)
+ .ok()
+ .and_then(|n| self.0.len().checked_mul(n))
+ .ok_or_else(|| format!("cannot repeat this array {} times", n))?;
+
+ Ok(self.iter().cloned().cycle().take(count).collect())
+ }
+
+ /// 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()
+ }
+
+ /// Resolve an index.
+ fn locate(&self, index: i64) -> Option<usize> {
+ usize::try_from(if index >= 0 {
+ index
+ } else {
+ (self.len() as i64).checked_add(index)?
+ })
+ .ok()
+ }
+
+ /// Enumerate all items in the array.
+ pub fn enumerate(&self) -> Self {
+ self.iter()
+ .enumerate()
+ .map(|(i, value)| array![i, value.clone()].into_value())
+ .collect()
+ }
+}
+
+impl Debug for Array {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let pieces: Vec<_> = self.iter().map(|value| eco_format!("{value:?}")).collect();
+ f.write_str(&pretty_array_like(&pieces, self.len() == 1))
+ }
+}
+
+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: Array) {
+ 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 describe() -> CastInfo {
+ Array::describe()
+ }
+
+ 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: FromValue> FromValue for Vec<T> {
+ fn from_value(value: Value) -> StrResult<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/src/eval/auto.rs b/crates/typst/src/eval/auto.rs
new file mode 100644
index 00000000..e73b3f33
--- /dev/null
+++ b/crates/typst/src/eval/auto.rs
@@ -0,0 +1,39 @@
+use std::fmt::{self, Debug, Formatter};
+
+use super::{CastInfo, FromValue, IntoValue, Reflect, Value};
+use crate::diag::StrResult;
+
+/// A value that indicates a smart default.
+#[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) -> StrResult<Self> {
+ match value {
+ Value::Auto => Ok(Self),
+ _ => Err(Self::error(&value)),
+ }
+ }
+}
+
+impl Reflect for AutoValue {
+ fn describe() -> CastInfo {
+ CastInfo::Type("auto")
+ }
+
+ fn castable(value: &Value) -> bool {
+ matches!(value, Value::Auto)
+ }
+}
+
+impl Debug for AutoValue {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("auto")
+ }
+}
diff --git a/crates/typst/src/eval/cast.rs b/crates/typst/src/eval/cast.rs
new file mode 100644
index 00000000..917972ed
--- /dev/null
+++ b/crates/typst/src/eval/cast.rs
@@ -0,0 +1,316 @@
+pub use typst_macros::{cast, Cast};
+
+use std::fmt::Write;
+use std::ops::Add;
+
+use ecow::EcoString;
+
+use super::Value;
+use crate::diag::{At, SourceResult, StrResult};
+use crate::syntax::{Span, Spanned};
+use crate::util::separated_list;
+
+/// 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 the acceptable values for this type.
+ fn describe() -> 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 inacceptable value.
+ ///
+ /// ```
+ /// # use typst::eval::{Int, Reflect, Value};
+ /// assert_eq!(
+ /// <Int as Reflect>::error(Value::None),
+ /// "expected integer, found none",
+ /// );
+ /// ```
+ fn error(found: &Value) -> EcoString {
+ Self::describe().error(found)
+ }
+}
+
+impl Reflect for Value {
+ fn describe() -> CastInfo {
+ CastInfo::Any
+ }
+
+ fn castable(_: &Value) -> bool {
+ true
+ }
+}
+
+impl<T: Reflect> Reflect for Spanned<T> {
+ fn describe() -> CastInfo {
+ T::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for StrResult<T> {
+ fn describe() -> CastInfo {
+ T::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for SourceResult<T> {
+ fn describe() -> CastInfo {
+ T::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for &T {
+ fn describe() -> CastInfo {
+ T::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+}
+
+impl<T: Reflect> Reflect for &mut T {
+ fn describe() -> CastInfo {
+ T::describe()
+ }
+
+ 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<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 SourceResult<T> {
+ fn into_result(self, _: Span) -> SourceResult<Value> {
+ self.map(IntoValue::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) -> StrResult<Self>;
+}
+
+impl FromValue for Value {
+ fn from_value(value: Value) -> StrResult<Self> {
+ Ok(value)
+ }
+}
+
+impl<T: FromValue> FromValue<Spanned<Value>> for T {
+ fn from_value(value: Spanned<Value>) -> StrResult<Self> {
+ T::from_value(value.v)
+ }
+}
+
+impl<T: FromValue> FromValue<Spanned<Value>> for Spanned<T> {
+ fn from_value(value: Spanned<Value>) -> StrResult<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, Hash, PartialEq, 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(&'static str),
+ /// 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) -> EcoString {
+ fn accumulate(
+ info: &CastInfo,
+ found: &Value,
+ parts: &mut Vec<EcoString>,
+ matching_type: &mut bool,
+ ) {
+ match info {
+ CastInfo::Any => parts.push("anything".into()),
+ CastInfo::Value(value, _) => {
+ parts.push(value.repr().into());
+ if value.type_name() == found.type_name() {
+ *matching_type = true;
+ }
+ }
+ CastInfo::Type(ty) => parts.push((*ty).into()),
+ CastInfo::Union(options) => {
+ for option in options {
+ accumulate(option, found, parts, matching_type);
+ }
+ }
+ }
+ }
+
+ let mut matching_type = false;
+ let mut parts = vec![];
+ accumulate(self, found, &mut parts, &mut matching_type);
+
+ let mut msg = String::from("expected ");
+ if parts.is_empty() {
+ msg.push_str(" nothing");
+ }
+
+ msg.push_str(&separated_list(&parts, "or"));
+
+ if !matching_type {
+ msg.push_str(", found ");
+ msg.push_str(found.type_name());
+ }
+ if_chain::if_chain! {
+ if let Value::Int(i) = found;
+ if parts.iter().any(|p| p == "length");
+ if !matching_type;
+ then {
+ write!(msg, ": a length needs a unit - did you mean {i}pt?").unwrap();
+ }
+ };
+
+ msg.into()
+ }
+}
+
+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 a variadic argument.
+pub trait Variadics {
+ /// The contained type.
+ type Inner;
+}
+
+impl<T> Variadics for Vec<T> {
+ type Inner = T;
+}
+
+/// An uninhabitable type.
+pub enum Never {}
+
+impl Reflect for Never {
+ fn describe() -> 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) -> StrResult<Self> {
+ Err(Self::error(&value))
+ }
+}
diff --git a/crates/typst/src/eval/datetime.rs b/crates/typst/src/eval/datetime.rs
new file mode 100644
index 00000000..f3c4a5a1
--- /dev/null
+++ b/crates/typst/src/eval/datetime.rs
@@ -0,0 +1,201 @@
+use std::fmt;
+use std::fmt::{Debug, Formatter};
+use std::hash::Hash;
+
+use ecow::{eco_format, EcoString, EcoVec};
+use time::error::{Format, InvalidFormatDescription};
+use time::{format_description, PrimitiveDateTime};
+
+use crate::eval::cast;
+use crate::util::pretty_array_like;
+
+/// A datetime object that represents either a date, a time or a combination of
+/// both.
+#[derive(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 {
+ /// Display the date and/or time in a certain format.
+ pub fn display(&self, pattern: Option<EcoString>) -> Result<EcoString, EcoString> {
+ let pattern = pattern.as_ref().map(EcoString::as_str).unwrap_or(match self {
+ Datetime::Date(_) => "[year]-[month]-[day]",
+ Datetime::Time(_) => "[hour]:[minute]:[second]",
+ Datetime::Datetime(_) => "[year]-[month]-[day] [hour]:[minute]:[second]",
+ });
+
+ let format = format_description::parse(pattern)
+ .map_err(format_time_invalid_format_description_error)?;
+
+ let formatted_result = match self {
+ Datetime::Date(date) => date.format(&format),
+ Datetime::Time(time) => time.format(&format),
+ Datetime::Datetime(datetime) => datetime.format(&format),
+ }
+ .map(EcoString::from);
+
+ formatted_result.map_err(format_time_format_error)
+ }
+
+ /// Return the year of the datetime, if existing.
+ pub fn year(&self) -> Option<i32> {
+ match self {
+ Datetime::Date(date) => Some(date.year()),
+ Datetime::Time(_) => None,
+ Datetime::Datetime(datetime) => Some(datetime.year()),
+ }
+ }
+
+ /// Return the month of the datetime, if existing.
+ pub fn month(&self) -> Option<u8> {
+ match self {
+ Datetime::Date(date) => Some(date.month().into()),
+ Datetime::Time(_) => None,
+ Datetime::Datetime(datetime) => Some(datetime.month().into()),
+ }
+ }
+
+ /// Return the weekday of the datetime, if existing.
+ pub fn weekday(&self) -> Option<u8> {
+ match self {
+ Datetime::Date(date) => Some(date.weekday().number_from_monday()),
+ Datetime::Time(_) => None,
+ Datetime::Datetime(datetime) => Some(datetime.weekday().number_from_monday()),
+ }
+ }
+
+ /// Return the day of the datetime, if existing.
+ pub fn day(&self) -> Option<u8> {
+ match self {
+ Datetime::Date(date) => Some(date.day()),
+ Datetime::Time(_) => None,
+ Datetime::Datetime(datetime) => Some(datetime.day()),
+ }
+ }
+
+ /// Return the hour of the datetime, if existing.
+ pub fn hour(&self) -> Option<u8> {
+ match self {
+ Datetime::Date(_) => None,
+ Datetime::Time(time) => Some(time.hour()),
+ Datetime::Datetime(datetime) => Some(datetime.hour()),
+ }
+ }
+
+ /// Return the minute of the datetime, if existing.
+ pub fn minute(&self) -> Option<u8> {
+ match self {
+ Datetime::Date(_) => None,
+ Datetime::Time(time) => Some(time.minute()),
+ Datetime::Datetime(datetime) => Some(datetime.minute()),
+ }
+ }
+
+ /// Return the second of the datetime, if existing.
+ pub fn second(&self) -> Option<u8> {
+ match self {
+ Datetime::Date(_) => None,
+ Datetime::Time(time) => Some(time.second()),
+ Datetime::Datetime(datetime) => Some(datetime.second()),
+ }
+ }
+
+ /// 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)))
+ }
+}
+
+impl Debug for Datetime {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let year = self.year().map(|y| eco_format!("year: {y}"));
+ let month = self.month().map(|m| eco_format!("month: {m}"));
+ let day = self.day().map(|d| eco_format!("day: {d}"));
+ let hour = self.hour().map(|h| eco_format!("hour: {h}"));
+ let minute = self.minute().map(|m| eco_format!("minute: {m}"));
+ let second = self.second().map(|s| eco_format!("second: {s}"));
+ let filtered = [year, month, day, hour, minute, second]
+ .into_iter()
+ .flatten()
+ .collect::<EcoVec<_>>();
+
+ write!(f, "datetime{}", &pretty_array_like(&filtered, false))
+ }
+}
+
+cast! {
+ type Datetime: "datetime",
+}
+
+/// 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),
+ _ => "failed to format datetime in the requested format".into(),
+ }
+}
+
+/// 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)
+ }
+ _ => "failed to parse datetime format".into(),
+ }
+}
diff --git a/crates/typst/src/eval/dict.rs b/crates/typst/src/eval/dict.rs
new file mode 100644
index 00000000..3e6233ae
--- /dev/null
+++ b/crates/typst/src/eval/dict.rs
@@ -0,0 +1,235 @@
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::ops::{Add, AddAssign};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+
+use super::{array, Array, Str, Value};
+use crate::diag::StrResult;
+use crate::syntax::is_ident;
+use crate::util::{pretty_array_like, separated_list, ArcExt};
+
+/// 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::eval::IndexMap::new();
+ $(map.insert($key.into(), $crate::eval::IntoValue::into_value($value));)*
+ $crate::eval::Dict::from(map)
+ }};
+}
+
+#[doc(inline)]
+pub use crate::__dict as dict;
+
+#[doc(inline)]
+pub use indexmap::IndexMap;
+
+/// A reference-counted dictionary with value semantics.
+#[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()
+ }
+
+ /// The number of pairs in the dictionary.
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Borrow the value the given `key` maps to,
+ pub fn at<'a>(
+ &'a self,
+ key: &str,
+ default: Option<&'a Value>,
+ ) -> StrResult<&'a Value> {
+ self.0.get(key).or(default).ok_or_else(|| missing_key_no_default(key))
+ }
+
+ /// Mutably borrow the value the given `key` maps to.
+ pub fn at_mut(&mut self, key: &str) -> StrResult<&mut Value> {
+ Arc::make_mut(&mut self.0)
+ .get_mut(key)
+ .ok_or_else(|| missing_key_no_default(key))
+ }
+
+ /// 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)
+ .remove(key)
+ .ok_or_else(|| eco_format!("missing key: {:?}", Str::from(key)))
+ }
+
+ /// Whether the dictionary contains a specific key.
+ pub fn contains(&self, key: &str) -> bool {
+ self.0.contains_key(key)
+ }
+
+ /// Insert a mapping from the given `key` to the given `value`.
+ pub fn insert(&mut self, key: Str, value: Value) {
+ Arc::make_mut(&mut self.0).insert(key, value);
+ }
+
+ /// Remove a mapping by `key` and return the value.
+ pub fn remove(&mut self, key: &str) -> StrResult<Value> {
+ match Arc::make_mut(&mut self.0).shift_remove(key) {
+ Some(value) => Ok(value),
+ None => Err(missing_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();
+ }
+ }
+
+ /// Return the keys of the dictionary as an array.
+ pub fn keys(&self) -> Array {
+ self.0.keys().cloned().map(Value::Str).collect()
+ }
+
+ /// Return the values of the dictionary as an array.
+ pub fn values(&self) -> Array {
+ self.0.values().cloned().collect()
+ }
+
+ /// Return the values of the dictionary as an array of pairs (arrays of
+ /// length two).
+ pub fn pairs(&self) -> Array {
+ self.0
+ .iter()
+ .map(|(k, v)| Value::Array(array![k.clone(), v.clone()]))
+ .collect()
+ }
+
+ /// Iterate over pairs of references to the contained keys and values.
+ pub fn iter(&self) -> indexmap::map::Iter<Str, Value> {
+ self.0.iter()
+ }
+
+ /// Return an "unexpected key" error if there is any remaining pair.
+ pub fn finish(&self, expected: &[&str]) -> StrResult<()> {
+ if let Some((key, _)) = self.iter().next() {
+ let parts: Vec<_> = expected.iter().map(|s| eco_format!("\"{s}\"")).collect();
+ let mut msg = format!("unexpected key {key:?}, valid keys are ");
+ msg.push_str(&separated_list(&parts, "and"));
+ return Err(msg.into());
+ }
+ Ok(())
+ }
+}
+
+impl Debug for Dict {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if self.is_empty() {
+ return f.write_str("(:)");
+ }
+
+ let pieces: Vec<_> = self
+ .iter()
+ .map(|(key, value)| {
+ if is_ident(key) {
+ eco_format!("{key}: {value:?}")
+ } else {
+ eco_format!("{key:?}: {value:?}")
+ }
+ })
+ .collect();
+
+ f.write_str(&pretty_array_like(&pieces, false))
+ }
+}
+
+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 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 {:?}", Str::from(key))
+}
+
+/// The missing key access error message when no default was fiven.
+#[cold]
+fn missing_key_no_default(key: &str) -> EcoString {
+ eco_format!(
+ "dictionary does not contain key {:?} \
+ and no default value was specified",
+ Str::from(key)
+ )
+}
diff --git a/crates/typst/src/eval/func.rs b/crates/typst/src/eval/func.rs
new file mode 100644
index 00000000..22f948ce
--- /dev/null
+++ b/crates/typst/src/eval/func.rs
@@ -0,0 +1,643 @@
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+use comemo::{Prehashed, Tracked, TrackedMut};
+use ecow::eco_format;
+use once_cell::sync::Lazy;
+
+use super::{
+ cast, Args, CastInfo, Eval, FlowEvent, IntoValue, Route, Scope, Scopes, Tracer,
+ Value, Vm,
+};
+use crate::diag::{bail, SourceResult, StrResult};
+use crate::file::FileId;
+use crate::model::{DelayedErrors, ElemFunc, Introspector, Locator, Vt};
+use crate::syntax::ast::{self, AstNode, Expr, Ident};
+use crate::syntax::{Span, SyntaxNode};
+use crate::World;
+
+/// An evaluatable 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 NativeFunc),
+ /// A function for an element.
+ Elem(ElemFunc),
+ /// A user-defined closure.
+ Closure(Arc<Prehashed<Closure>>),
+ /// A nested function with pre-applied arguments.
+ With(Arc<(Func, Args)>),
+}
+
+impl Func {
+ /// The name of the function.
+ pub fn name(&self) -> Option<&str> {
+ match &self.repr {
+ Repr::Native(native) => Some(native.info.name),
+ Repr::Elem(func) => Some(func.info().name),
+ Repr::Closure(closure) => closure.name.as_deref(),
+ Repr::With(arc) => arc.0.name(),
+ }
+ }
+
+ /// Extract details the function.
+ pub fn info(&self) -> Option<&FuncInfo> {
+ match &self.repr {
+ Repr::Native(native) => Some(&native.info),
+ Repr::Elem(func) => Some(func.info()),
+ Repr::Closure(_) => None,
+ Repr::With(arc) => arc.0.info(),
+ }
+ }
+
+ /// 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
+ }
+
+ /// Call the function with the given arguments.
+ pub fn call_vm(&self, vm: &mut Vm, mut args: Args) -> SourceResult<Value> {
+ let _span = tracing::info_span!(
+ "call",
+ name = self.name().unwrap_or("<anon>"),
+ file = 0,
+ );
+
+ match &self.repr {
+ Repr::Native(native) => {
+ let value = (native.func)(vm, &mut args)?;
+ args.finish()?;
+ Ok(value)
+ }
+ Repr::Elem(func) => {
+ let value = func.construct(vm, &mut args)?;
+ args.finish()?;
+ Ok(Value::Content(value))
+ }
+ Repr::Closure(closure) => {
+ // Determine the route inside the closure.
+ let fresh = Route::new(closure.location);
+ let route =
+ if vm.location.is_detached() { fresh.track() } else { vm.route };
+
+ Closure::call(
+ self,
+ vm.world(),
+ route,
+ vm.vt.introspector,
+ vm.vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vm.vt.delayed),
+ TrackedMut::reborrow_mut(&mut vm.vt.tracer),
+ vm.depth + 1,
+ args,
+ )
+ }
+ Repr::With(arc) => {
+ args.items = arc.1.items.iter().cloned().chain(args.items).collect();
+ arc.0.call_vm(vm, args)
+ }
+ }
+ }
+
+ /// Call the function with a Vt.
+ #[tracing::instrument(skip_all)]
+ pub fn call_vt<T: IntoValue>(
+ &self,
+ vt: &mut Vt,
+ args: impl IntoIterator<Item = T>,
+ ) -> SourceResult<Value> {
+ let route = Route::default();
+ let scopes = Scopes::new(None);
+ let mut locator = Locator::chained(vt.locator.track());
+ let vt = Vt {
+ world: vt.world,
+ introspector: vt.introspector,
+ locator: &mut locator,
+ delayed: TrackedMut::reborrow_mut(&mut vt.delayed),
+ tracer: TrackedMut::reborrow_mut(&mut vt.tracer),
+ };
+ let mut vm = Vm::new(vt, route.track(), FileId::detached(), scopes);
+ let args = Args::new(self.span(), args);
+ self.call_vm(&mut vm, args)
+ }
+
+ /// Apply the given arguments to the function.
+ pub fn with(self, args: Args) -> Self {
+ let span = self.span;
+ Self { repr: Repr::With(Arc::new((self, args))), span }
+ }
+
+ /// Extract the element function, if it is one.
+ pub fn element(&self) -> Option<ElemFunc> {
+ match self.repr {
+ Repr::Elem(func) => Some(func),
+ _ => None,
+ }
+ }
+
+ /// Get a field from this function's scope, if possible.
+ pub fn get(&self, field: &str) -> StrResult<&Value> {
+ match &self.repr {
+ Repr::Native(func) => func.info.scope.get(field).ok_or_else(|| {
+ eco_format!(
+ "function `{}` does not contain field `{}`",
+ func.info.name,
+ field
+ )
+ }),
+ Repr::Elem(func) => func.info().scope.get(field).ok_or_else(|| {
+ eco_format!(
+ "function `{}` does not contain field `{}`",
+ func.name(),
+ field
+ )
+ }),
+ Repr::Closure(_) => {
+ Err(eco_format!("cannot access fields on user-defined functions"))
+ }
+ Repr::With(arc) => arc.0.get(field),
+ }
+ }
+}
+
+impl Debug for Func {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self.name() {
+ Some(name) => write!(f, "{name}"),
+ None => f.write_str("(..) => .."),
+ }
+ }
+}
+
+impl PartialEq for Func {
+ fn eq(&self, other: &Self) -> bool {
+ self.repr == other.repr
+ }
+}
+
+impl From<Repr> for Func {
+ fn from(repr: Repr) -> Self {
+ Self { repr, span: Span::detached() }
+ }
+}
+
+impl From<ElemFunc> for Func {
+ fn from(func: ElemFunc) -> Self {
+ Repr::Elem(func).into()
+ }
+}
+
+/// A Typst function defined by a native Rust function.
+pub struct NativeFunc {
+ /// The function's implementation.
+ pub func: fn(&mut Vm, &mut Args) -> SourceResult<Value>,
+ /// Details about the function.
+ pub info: Lazy<FuncInfo>,
+}
+
+impl PartialEq for NativeFunc {
+ fn eq(&self, other: &Self) -> bool {
+ self.func as usize == other.func as usize
+ }
+}
+
+impl Eq for NativeFunc {}
+
+impl Hash for NativeFunc {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ (self.func as usize).hash(state);
+ }
+}
+
+impl From<&'static NativeFunc> for Func {
+ fn from(native: &'static NativeFunc) -> Self {
+ Repr::Native(native).into()
+ }
+}
+
+cast! {
+ &'static NativeFunc,
+ self => Value::Func(self.into()),
+}
+
+/// Details about a function.
+#[derive(Debug, Clone)]
+pub struct FuncInfo {
+ /// The function's name.
+ pub name: &'static str,
+ /// The display name of the function.
+ pub display: &'static str,
+ /// A string of search keywords.
+ pub keywords: Option<&'static str>,
+ /// Which category the function is part of.
+ pub category: &'static str,
+ /// Documentation for the function.
+ pub docs: &'static str,
+ /// Details about the function's parameters.
+ pub params: Vec<ParamInfo>,
+ /// Valid values for the return value.
+ pub returns: CastInfo,
+ /// The function's own scope of fields and sub-functions.
+ pub scope: Scope,
+}
+
+impl FuncInfo {
+ /// Get the parameter info for a parameter with the given name
+ pub fn param(&self, name: &str) -> Option<&ParamInfo> {
+ self.params.iter().find(|param| param.name == name)
+ }
+}
+
+/// Describes a named parameter.
+#[derive(Debug, Clone)]
+pub struct ParamInfo {
+ /// The parameter's name.
+ pub name: &'static str,
+ /// Documentation for the parameter.
+ pub docs: &'static str,
+ /// Valid values for the parameter.
+ pub cast: 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(Hash)]
+pub(super) struct Closure {
+ /// The source file where the closure was defined.
+ pub location: FileId,
+ /// The name of the closure.
+ pub name: Option<Ident>,
+ /// Captured values from outer scopes.
+ pub captured: Scope,
+ /// The list of parameters.
+ pub params: Vec<Param>,
+ /// The expression the closure should evaluate to.
+ pub body: Expr,
+}
+
+/// A closure parameter.
+#[derive(Hash)]
+pub enum Param {
+ /// A positional parameter: `x`.
+ Pos(ast::Pattern),
+ /// A named parameter with a default value: `draw: false`.
+ Named(Ident, Value),
+ /// An argument sink: `..args`.
+ Sink(Option<Ident>),
+}
+
+impl Closure {
+ /// Call the function in the context with the arguments.
+ #[comemo::memoize]
+ #[tracing::instrument(skip_all)]
+ #[allow(clippy::too_many_arguments)]
+ fn call(
+ this: &Func,
+ world: Tracked<dyn World + '_>,
+ route: Tracked<Route>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ depth: usize,
+ mut args: Args,
+ ) -> SourceResult<Value> {
+ let closure = match &this.repr {
+ Repr::Closure(closure) => closure,
+ _ => panic!("`this` must be a closure"),
+ };
+
+ // Don't leak the scopes from the call site. Instead, we use the scope
+ // of captured variables we collected earlier.
+ let mut scopes = Scopes::new(None);
+ scopes.top = closure.captured.clone();
+
+ // Prepare VT.
+ let mut locator = Locator::chained(locator);
+ let vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+
+ // Prepare VM.
+ let mut vm = Vm::new(vt, route, closure.location, scopes);
+ vm.depth = depth;
+
+ // Provide the closure itself for recursive calls.
+ if let Some(name) = &closure.name {
+ vm.define(name.clone(), Value::Func(this.clone()));
+ }
+
+ // Parse the arguments according to the parameter list.
+ let num_pos_params =
+ closure.params.iter().filter(|p| matches!(p, Param::Pos(_))).count();
+ let num_pos_args = args.to_pos().len();
+ let sink_size = num_pos_args.checked_sub(num_pos_params);
+
+ let mut sink = None;
+ let mut sink_pos_values = None;
+ for p in &closure.params {
+ match p {
+ Param::Pos(pattern) => match pattern {
+ ast::Pattern::Normal(ast::Expr::Ident(ident)) => {
+ vm.define(ident.clone(), args.expect::<Value>(ident)?)
+ }
+ ast::Pattern::Normal(_) => unreachable!(),
+ _ => {
+ pattern.define(
+ &mut vm,
+ args.expect::<Value>("pattern parameter")?,
+ )?;
+ }
+ },
+ Param::Sink(ident) => {
+ sink = ident.clone();
+ if let Some(sink_size) = sink_size {
+ sink_pos_values = Some(args.consume(sink_size)?);
+ }
+ }
+ Param::Named(ident, default) => {
+ let value =
+ args.named::<Value>(ident)?.unwrap_or_else(|| default.clone());
+ vm.define(ident.clone(), value);
+ }
+ }
+ }
+
+ if let Some(sink) = sink {
+ let mut remaining_args = args.take();
+ if let Some(sink_pos_values) = sink_pos_values {
+ remaining_args.items.extend(sink_pos_values);
+ }
+ vm.define(sink, remaining_args);
+ }
+
+ // Ensure all arguments have been used.
+ args.finish()?;
+
+ // Handle control flow.
+ let result = closure.body.eval(&mut vm);
+ match vm.flow {
+ Some(FlowEvent::Return(_, Some(explicit))) => return Ok(explicit),
+ Some(FlowEvent::Return(_, None)) => {}
+ Some(flow) => bail!(flow.forbidden()),
+ None => {}
+ }
+
+ result
+ }
+}
+
+impl From<Closure> for Func {
+ fn from(closure: Closure) -> Self {
+ Repr::Closure(Arc::new(Prehashed::new(closure))).into()
+ }
+}
+
+cast! {
+ Closure,
+ self => Value::Func(self.into()),
+}
+
+/// A visitor that determines which variables to capture for a closure.
+pub(super) struct CapturesVisitor<'a> {
+ external: &'a Scopes<'a>,
+ internal: Scopes<'a>,
+ captures: Scope,
+}
+
+impl<'a> CapturesVisitor<'a> {
+ /// Create a new visitor for the given external scopes.
+ pub fn new(external: &'a Scopes) -> Self {
+ Self {
+ external,
+ internal: Scopes::new(None),
+ captures: Scope::new(),
+ }
+ }
+
+ /// Return the scope of captured variables.
+ pub fn finish(self) -> Scope {
+ self.captures
+ }
+
+ /// Visit any node and collect all captured variables.
+ #[tracing::instrument(skip_all)]
+ pub fn visit(&mut self, node: &SyntaxNode) {
+ match node.cast() {
+ // Every identifier is a potential variable that we need to capture.
+ // Identifiers that shouldn't count as captures because they
+ // actually bind a new name are handled below (individually through
+ // the expressions that contain them).
+ Some(ast::Expr::Ident(ident)) => self.capture(ident),
+ Some(ast::Expr::MathIdent(ident)) => self.capture_in_math(ident),
+
+ // Code and content blocks create a scope.
+ Some(ast::Expr::Code(_) | ast::Expr::Content(_)) => {
+ self.internal.enter();
+ for child in node.children() {
+ self.visit(child);
+ }
+ self.internal.exit();
+ }
+
+ // A closure contains parameter bindings, which are bound before the
+ // body is evaluated. Care must be taken so that the default values
+ // of named parameters cannot access previous parameter bindings.
+ Some(ast::Expr::Closure(expr)) => {
+ for param in expr.params().children() {
+ if let ast::Param::Named(named) = param {
+ self.visit(named.expr().as_untyped());
+ }
+ }
+
+ self.internal.enter();
+ if let Some(name) = expr.name() {
+ self.bind(name);
+ }
+
+ for param in expr.params().children() {
+ match param {
+ ast::Param::Pos(pattern) => {
+ for ident in pattern.idents() {
+ self.bind(ident);
+ }
+ }
+ ast::Param::Named(named) => self.bind(named.name()),
+ ast::Param::Sink(spread) => {
+ self.bind(spread.name().unwrap_or_default())
+ }
+ }
+ }
+
+ self.visit(expr.body().as_untyped());
+ self.internal.exit();
+ }
+
+ // A let expression contains a binding, but that binding is only
+ // active after the body is evaluated.
+ Some(ast::Expr::Let(expr)) => {
+ if let Some(init) = expr.init() {
+ self.visit(init.as_untyped());
+ }
+
+ for ident in expr.kind().idents() {
+ self.bind(ident);
+ }
+ }
+
+ // A for loop contains one or two bindings in its pattern. These are
+ // active after the iterable is evaluated but before the body is
+ // evaluated.
+ Some(ast::Expr::For(expr)) => {
+ self.visit(expr.iter().as_untyped());
+ self.internal.enter();
+
+ let pattern = expr.pattern();
+ for ident in pattern.idents() {
+ self.bind(ident);
+ }
+
+ self.visit(expr.body().as_untyped());
+ self.internal.exit();
+ }
+
+ // An import contains items, but these are active only after the
+ // path is evaluated.
+ Some(ast::Expr::Import(expr)) => {
+ self.visit(expr.source().as_untyped());
+ if let Some(ast::Imports::Items(items)) = expr.imports() {
+ for item in items {
+ self.bind(item);
+ }
+ }
+ }
+
+ // Everything else is traversed from left to right.
+ _ => {
+ for child in node.children() {
+ self.visit(child);
+ }
+ }
+ }
+ }
+
+ /// Bind a new internal variable.
+ fn bind(&mut self, ident: ast::Ident) {
+ self.internal.top.define(ident.take(), Value::None);
+ }
+
+ /// Capture a variable if it isn't internal.
+ fn capture(&mut self, ident: ast::Ident) {
+ if self.internal.get(&ident).is_err() {
+ if let Ok(value) = self.external.get(&ident) {
+ self.captures.define_captured(ident.take(), value.clone());
+ }
+ }
+ }
+
+ /// Capture a variable in math mode if it isn't internal.
+ fn capture_in_math(&mut self, ident: ast::MathIdent) {
+ if self.internal.get(&ident).is_err() {
+ if let Ok(value) = self.external.get_in_math(&ident) {
+ self.captures.define_captured(ident.take(), value.clone());
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::syntax::parse;
+
+ #[track_caller]
+ fn test(text: &str, result: &[&str]) {
+ let mut scopes = Scopes::new(None);
+ scopes.top.define("f", 0);
+ scopes.top.define("x", 0);
+ scopes.top.define("y", 0);
+ scopes.top.define("z", 0);
+
+ let mut visitor = CapturesVisitor::new(&scopes);
+ let root = parse(text);
+ visitor.visit(&root);
+
+ let captures = visitor.finish();
+ let mut names: Vec<_> = captures.iter().map(|(k, _)| k).collect();
+ names.sort();
+
+ assert_eq!(names, result);
+ }
+
+ #[test]
+ fn test_captures() {
+ // Let binding and function definition.
+ test("#let x = x", &["x"]);
+ test("#let x; #(x + y)", &["y"]);
+ test("#let f(x, y) = x + y", &[]);
+ test("#let f(x, y) = f", &[]);
+ test("#let f = (x, y) => f", &["f"]);
+
+ // Closure with different kinds of params.
+ test("#((x, y) => x + z)", &["z"]);
+ test("#((x: y, z) => x + z)", &["y"]);
+ test("#((..x) => x + y)", &["y"]);
+ test("#((x, y: x + z) => x + y)", &["x", "z"]);
+ test("#{x => x; x}", &["x"]);
+
+ // Show rule.
+ test("#show y: x => x", &["y"]);
+ test("#show y: x => x + z", &["y", "z"]);
+ test("#show x: x => x", &["x"]);
+
+ // For loop.
+ test("#for x in y { x + z }", &["y", "z"]);
+ test("#for (x, y) in y { x + y }", &["y"]);
+ test("#for x in y {} #x", &["x", "y"]);
+
+ // Import.
+ test("#import z: x, y", &["z"]);
+ test("#import x + y: x, y, z", &["x", "y"]);
+
+ // Blocks.
+ test("#{ let x = 1; { let y = 2; y }; x + y }", &["y"]);
+ test("#[#let x = 1]#x", &["x"]);
+ }
+}
diff --git a/crates/typst/src/eval/int.rs b/crates/typst/src/eval/int.rs
new file mode 100644
index 00000000..4e081617
--- /dev/null
+++ b/crates/typst/src/eval/int.rs
@@ -0,0 +1,81 @@
+use std::num::{NonZeroI64, NonZeroIsize, NonZeroU64, NonZeroUsize};
+
+use super::{cast, Value};
+
+macro_rules! signed_int {
+ ($($ty:ty)*) => {
+ $(cast! {
+ $ty,
+ self => Value::Int(self as i64),
+ v: i64 => v.try_into().map_err(|_| "number too large")?,
+ })*
+ }
+}
+
+macro_rules! unsigned_int {
+ ($($ty:ty)*) => {
+ $(cast! {
+ $ty,
+ self => Value::Int(self as i64),
+ v: i64 => v.try_into().map_err(|_| {
+ if v < 0 {
+ "number must be at least zero"
+ } else {
+ "number too large"
+ }
+ })?,
+ })*
+ }
+}
+
+macro_rules! signed_nonzero {
+ ($($ty:ty)*) => {
+ $(cast! {
+ $ty,
+ self => Value::Int(self.get() as i64),
+ v: i64 => v
+ .try_into()
+ .ok()
+ .and_then($ty::new)
+ .ok_or_else(|| if v == 0 {
+ "number must not be zero"
+ } else {
+ "number too large"
+ })?,
+ })*
+ }
+}
+
+macro_rules! unsigned_nonzero {
+ ($($ty:ty)*) => {
+ $(cast! {
+ $ty,
+ self => Value::Int(self.get() as i64),
+ v: i64 => v
+ .try_into()
+ .ok()
+ .and_then($ty::new)
+ .ok_or_else(|| if v <= 0 {
+ "number must be positive"
+ } else {
+ "number too large"
+ })?,
+ })*
+ }
+}
+
+signed_int! {
+ i8 i16 i32 isize
+}
+
+unsigned_int! {
+ u8 u16 u32 u64 usize
+}
+
+signed_nonzero! {
+ NonZeroI64 NonZeroIsize
+}
+
+unsigned_nonzero! {
+ NonZeroU64 NonZeroUsize
+}
diff --git a/crates/typst/src/eval/library.rs b/crates/typst/src/eval/library.rs
new file mode 100644
index 00000000..1b05de83
--- /dev/null
+++ b/crates/typst/src/eval/library.rs
@@ -0,0 +1,182 @@
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::num::NonZeroUsize;
+
+use comemo::Tracked;
+use ecow::EcoString;
+use std::sync::OnceLock;
+
+use super::{Args, Dynamic, Module, Value, Vm};
+use crate::diag::SourceResult;
+use crate::doc::Document;
+use crate::geom::{Abs, Dir};
+use crate::model::{Content, ElemFunc, Introspector, Label, StyleChain, Styles, Vt};
+use crate::syntax::Span;
+use crate::util::hash128;
+
+/// Definition of Typst's standard library.
+#[derive(Debug, Clone, Hash)]
+pub struct Library {
+ /// The scope containing definitions that are available everywhere.
+ pub global: Module,
+ /// The scope containing definitions available in math mode.
+ pub math: Module,
+ /// The default properties for page size, font selection and so on.
+ pub styles: Styles,
+ /// Defines which standard library items fulfill which syntactical roles.
+ pub items: LangItems,
+}
+
+/// Definition of library items the language is aware of.
+#[derive(Clone)]
+pub struct LangItems {
+ /// The root layout function.
+ pub layout:
+ fn(vt: &mut Vt, content: &Content, styles: StyleChain) -> SourceResult<Document>,
+ /// Access the em size.
+ pub em: fn(StyleChain) -> Abs,
+ /// Access the text direction.
+ pub dir: fn(StyleChain) -> Dir,
+ /// Whitespace.
+ pub space: fn() -> Content,
+ /// A forced line break: `\`.
+ pub linebreak: fn() -> Content,
+ /// Plain text without markup.
+ pub text: fn(text: EcoString) -> Content,
+ /// The text function.
+ pub text_func: ElemFunc,
+ /// Get the string if this is a text element.
+ pub text_str: fn(&Content) -> Option<EcoString>,
+ /// A smart quote: `'` or `"`.
+ pub smart_quote: fn(double: bool) -> Content,
+ /// A paragraph break.
+ pub parbreak: fn() -> Content,
+ /// Strong content: `*Strong*`.
+ pub strong: fn(body: Content) -> Content,
+ /// Emphasized content: `_Emphasized_`.
+ pub emph: fn(body: Content) -> Content,
+ /// Raw text with optional syntax highlighting: `` `...` ``.
+ pub raw: fn(text: EcoString, tag: Option<EcoString>, block: bool) -> Content,
+ /// The language names and tags supported by raw text.
+ pub raw_languages: fn() -> Vec<(&'static str, Vec<&'static str>)>,
+ /// A hyperlink: `https://typst.org`.
+ pub link: fn(url: EcoString) -> Content,
+ /// A reference: `@target`, `@target[..]`.
+ pub reference: fn(target: Label, supplement: Option<Content>) -> Content,
+ /// The keys contained in the bibliography and short descriptions of them.
+ #[allow(clippy::type_complexity)]
+ pub bibliography_keys:
+ fn(introspector: Tracked<Introspector>) -> Vec<(EcoString, Option<EcoString>)>,
+ /// A section heading: `= Introduction`.
+ pub heading: fn(level: NonZeroUsize, body: Content) -> Content,
+ /// The heading function.
+ pub heading_func: ElemFunc,
+ /// An item in a bullet list: `- ...`.
+ pub list_item: fn(body: Content) -> Content,
+ /// An item in an enumeration (numbered list): `+ ...` or `1. ...`.
+ pub enum_item: fn(number: Option<usize>, body: Content) -> Content,
+ /// An item in a term list: `/ Term: Details`.
+ pub term_item: fn(term: Content, description: Content) -> Content,
+ /// A mathematical equation: `$x$`, `$ x^2 $`.
+ pub equation: fn(body: Content, block: bool) -> Content,
+ /// An alignment point in math: `&`.
+ pub math_align_point: fn() -> Content,
+ /// Matched delimiters in math: `[x + y]`.
+ pub math_delimited: fn(open: Content, body: Content, close: Content) -> Content,
+ /// A base with optional attachments in math: `a_1^2`.
+ #[allow(clippy::type_complexity)]
+ pub math_attach: fn(
+ base: Content,
+ // Positioned smartly.
+ t: Option<Content>,
+ b: Option<Content>,
+ // Fixed positions.
+ tl: Option<Content>,
+ bl: Option<Content>,
+ tr: Option<Content>,
+ br: Option<Content>,
+ ) -> Content,
+ /// A base with an accent: `arrow(x)`.
+ pub math_accent: fn(base: Content, accent: char) -> Content,
+ /// A fraction in math: `x/2`.
+ pub math_frac: fn(num: Content, denom: Content) -> Content,
+ /// A root in math: `√x`, `∛x` or `∜x`.
+ pub math_root: fn(index: Option<Content>, radicand: Content) -> Content,
+ /// Dispatch a method on a library value.
+ pub library_method: fn(
+ vm: &mut Vm,
+ dynamic: &Dynamic,
+ method: &str,
+ args: Args,
+ span: Span,
+ ) -> SourceResult<Value>,
+}
+
+impl Debug for LangItems {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("LangItems { .. }")
+ }
+}
+
+impl Hash for LangItems {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ (self.layout as usize).hash(state);
+ (self.em as usize).hash(state);
+ (self.dir as usize).hash(state);
+ self.space.hash(state);
+ self.linebreak.hash(state);
+ self.text.hash(state);
+ self.text_func.hash(state);
+ (self.text_str as usize).hash(state);
+ self.smart_quote.hash(state);
+ self.parbreak.hash(state);
+ self.strong.hash(state);
+ self.emph.hash(state);
+ self.raw.hash(state);
+ self.raw_languages.hash(state);
+ self.link.hash(state);
+ self.reference.hash(state);
+ (self.bibliography_keys as usize).hash(state);
+ self.heading.hash(state);
+ self.heading_func.hash(state);
+ self.list_item.hash(state);
+ self.enum_item.hash(state);
+ self.term_item.hash(state);
+ self.equation.hash(state);
+ self.math_align_point.hash(state);
+ self.math_delimited.hash(state);
+ self.math_attach.hash(state);
+ self.math_accent.hash(state);
+ self.math_frac.hash(state);
+ self.math_root.hash(state);
+ (self.library_method as usize).hash(state);
+ }
+}
+
+/// Global storage for lang items.
+#[doc(hidden)]
+pub static LANG_ITEMS: OnceLock<LangItems> = OnceLock::new();
+
+/// Set the lang items.
+///
+/// This is a hack :(
+///
+/// Passing the lang items everywhere they are needed (especially text related
+/// things) is very painful. By storing them globally, in theory, we break
+/// incremental, but only when different sets of lang items are used in the same
+/// program. For this reason, if this function is called multiple times, the
+/// items must be the same (and this is enforced).
+pub fn set_lang_items(items: LangItems) {
+ if let Err(items) = LANG_ITEMS.set(items) {
+ let first = hash128(LANG_ITEMS.get().unwrap());
+ let second = hash128(&items);
+ assert_eq!(first, second, "set differing lang items");
+ }
+}
+
+/// Access a lang item.
+macro_rules! item {
+ ($name:ident) => {
+ $crate::eval::LANG_ITEMS.get().unwrap().$name
+ };
+}
diff --git a/crates/typst/src/eval/methods.rs b/crates/typst/src/eval/methods.rs
new file mode 100644
index 00000000..62ac4095
--- /dev/null
+++ b/crates/typst/src/eval/methods.rs
@@ -0,0 +1,373 @@
+//! Methods on values.
+
+use ecow::EcoString;
+
+use super::{Args, IntoValue, Str, Value, Vm};
+use crate::diag::{At, SourceResult};
+use crate::eval::Datetime;
+use crate::model::{Location, Selector};
+use crate::syntax::Span;
+
+/// Call a method on a value.
+pub fn call(
+ vm: &mut Vm,
+ value: Value,
+ method: &str,
+ mut args: Args,
+ span: Span,
+) -> SourceResult<Value> {
+ let name = value.type_name();
+ let missing = || Err(missing_method(name, method)).at(span);
+
+ let output = match value {
+ Value::Color(color) => match method {
+ "lighten" => color.lighten(args.expect("amount")?).into_value(),
+ "darken" => color.darken(args.expect("amount")?).into_value(),
+ "negate" => color.negate().into_value(),
+ _ => return missing(),
+ },
+
+ Value::Str(string) => match method {
+ "len" => string.len().into_value(),
+ "first" => string.first().at(span)?.into_value(),
+ "last" => string.last().at(span)?.into_value(),
+ "at" => {
+ let index = args.expect("index")?;
+ let default = args.named::<EcoString>("default")?;
+ string.at(index, default.as_deref()).at(span)?.into_value()
+ }
+ "slice" => {
+ let start = args.expect("start")?;
+ let mut end = args.eat()?;
+ if end.is_none() {
+ end = args.named("count")?.map(|c: i64| start + c);
+ }
+ string.slice(start, end).at(span)?.into_value()
+ }
+ "clusters" => string.clusters().into_value(),
+ "codepoints" => string.codepoints().into_value(),
+ "contains" => string.contains(args.expect("pattern")?).into_value(),
+ "starts-with" => string.starts_with(args.expect("pattern")?).into_value(),
+ "ends-with" => string.ends_with(args.expect("pattern")?).into_value(),
+ "find" => string.find(args.expect("pattern")?).into_value(),
+ "position" => string.position(args.expect("pattern")?).into_value(),
+ "match" => string.match_(args.expect("pattern")?).into_value(),
+ "matches" => string.matches(args.expect("pattern")?).into_value(),
+ "replace" => {
+ let pattern = args.expect("pattern")?;
+ let with = args.expect("string or function")?;
+ let count = args.named("count")?;
+ string.replace(vm, pattern, with, count)?.into_value()
+ }
+ "trim" => {
+ let pattern = args.eat()?;
+ let at = args.named("at")?;
+ let repeat = args.named("repeat")?.unwrap_or(true);
+ string.trim(pattern, at, repeat).into_value()
+ }
+ "split" => string.split(args.eat()?).into_value(),
+ _ => return missing(),
+ },
+
+ Value::Content(content) => match method {
+ "func" => content.func().into_value(),
+ "has" => content.has(&args.expect::<EcoString>("field")?).into_value(),
+ "at" => content
+ .at(&args.expect::<EcoString>("field")?, args.named("default")?)
+ .at(span)?,
+ "fields" => content.dict().into_value(),
+ "location" => content
+ .location()
+ .ok_or("this method can only be called on content returned by query(..)")
+ .at(span)?
+ .into_value(),
+ _ => return missing(),
+ },
+
+ Value::Array(array) => match method {
+ "len" => array.len().into_value(),
+ "first" => array.first().at(span)?.clone(),
+ "last" => array.last().at(span)?.clone(),
+ "at" => array
+ .at(args.expect("index")?, args.named("default")?.as_ref())
+ .at(span)?
+ .clone(),
+ "slice" => {
+ let start = args.expect("start")?;
+ let mut end = args.eat()?;
+ if end.is_none() {
+ end = args.named("count")?.map(|c: i64| start + c);
+ }
+ array.slice(start, end).at(span)?.into_value()
+ }
+ "contains" => array.contains(&args.expect("value")?).into_value(),
+ "find" => array.find(vm, args.expect("function")?)?.into_value(),
+ "position" => array.position(vm, args.expect("function")?)?.into_value(),
+ "filter" => array.filter(vm, args.expect("function")?)?.into_value(),
+ "map" => array.map(vm, args.expect("function")?)?.into_value(),
+ "fold" => {
+ array.fold(vm, args.expect("initial value")?, args.expect("function")?)?
+ }
+ "sum" => array.sum(args.named("default")?, span)?,
+ "product" => array.product(args.named("default")?, span)?,
+ "any" => array.any(vm, args.expect("function")?)?.into_value(),
+ "all" => array.all(vm, args.expect("function")?)?.into_value(),
+ "flatten" => array.flatten().into_value(),
+ "rev" => array.rev().into_value(),
+ "split" => array.split(args.expect("separator")?).into_value(),
+ "join" => {
+ let sep = args.eat()?;
+ let last = args.named("last")?;
+ array.join(sep, last).at(span)?
+ }
+ "sorted" => array.sorted(vm, span, args.named("key")?)?.into_value(),
+ "zip" => array.zip(args.expect("other")?).into_value(),
+ "enumerate" => array.enumerate().into_value(),
+ _ => return missing(),
+ },
+
+ Value::Dict(dict) => match method {
+ "len" => dict.len().into_value(),
+ "at" => dict
+ .at(&args.expect::<Str>("key")?, args.named("default")?.as_ref())
+ .at(span)?
+ .clone(),
+ "keys" => dict.keys().into_value(),
+ "values" => dict.values().into_value(),
+ "pairs" => dict.pairs().into_value(),
+ _ => return missing(),
+ },
+
+ Value::Func(func) => match method {
+ "with" => func.with(args.take()).into_value(),
+ "where" => {
+ let fields = args.to_named();
+ args.items.retain(|arg| arg.name.is_none());
+ func.element()
+ .ok_or("`where()` can only be called on element functions")
+ .at(span)?
+ .where_(fields)
+ .into_value()
+ }
+ _ => return missing(),
+ },
+
+ Value::Args(args) => match method {
+ "pos" => args.to_pos().into_value(),
+ "named" => args.to_named().into_value(),
+ _ => return missing(),
+ },
+
+ Value::Dyn(dynamic) => {
+ if let Some(location) = dynamic.downcast::<Location>() {
+ match method {
+ "page" => vm.vt.introspector.page(*location).into_value(),
+ "position" => vm.vt.introspector.position(*location).into_value(),
+ "page-numbering" => vm.vt.introspector.page_numbering(*location),
+ _ => return missing(),
+ }
+ } else if let Some(selector) = dynamic.downcast::<Selector>() {
+ match method {
+ "or" => selector.clone().or(args.all::<Selector>()?).into_value(),
+ "and" => selector.clone().and(args.all::<Selector>()?).into_value(),
+ "before" => {
+ let location = args.expect::<Selector>("selector")?;
+ let inclusive =
+ args.named_or_find::<bool>("inclusive")?.unwrap_or(true);
+ selector.clone().before(location, inclusive).into_value()
+ }
+ "after" => {
+ let location = args.expect::<Selector>("selector")?;
+ let inclusive =
+ args.named_or_find::<bool>("inclusive")?.unwrap_or(true);
+ selector.clone().after(location, inclusive).into_value()
+ }
+ _ => return missing(),
+ }
+ } else if let Some(&datetime) = dynamic.downcast::<Datetime>() {
+ match method {
+ "display" => {
+ datetime.display(args.eat()?).at(args.span)?.into_value()
+ }
+ "year" => datetime.year().into_value(),
+ "month" => datetime.month().into_value(),
+ "weekday" => datetime.weekday().into_value(),
+ "day" => datetime.day().into_value(),
+ "hour" => datetime.hour().into_value(),
+ "minute" => datetime.minute().into_value(),
+ "second" => datetime.second().into_value(),
+ _ => return missing(),
+ }
+ } else {
+ return (vm.items.library_method)(vm, &dynamic, method, args, span);
+ }
+ }
+
+ _ => return missing(),
+ };
+
+ args.finish()?;
+ Ok(output)
+}
+
+/// Call a mutating method on a value.
+pub fn call_mut(
+ value: &mut Value,
+ method: &str,
+ mut args: Args,
+ span: Span,
+) -> SourceResult<Value> {
+ let name = value.type_name();
+ let missing = || Err(missing_method(name, method)).at(span);
+ let mut output = Value::None;
+
+ match value {
+ Value::Array(array) => match method {
+ "push" => array.push(args.expect("value")?),
+ "pop" => output = array.pop().at(span)?,
+ "insert" => {
+ array.insert(args.expect("index")?, args.expect("value")?).at(span)?
+ }
+ "remove" => output = array.remove(args.expect("index")?).at(span)?,
+ _ => return missing(),
+ },
+
+ Value::Dict(dict) => match method {
+ "insert" => dict.insert(args.expect::<Str>("key")?, args.expect("value")?),
+ "remove" => {
+ output = dict.remove(&args.expect::<EcoString>("key")?).at(span)?
+ }
+ _ => return missing(),
+ },
+
+ _ => return missing(),
+ }
+
+ args.finish()?;
+ Ok(output)
+}
+
+/// Call an accessor method on a value.
+pub fn call_access<'a>(
+ value: &'a mut Value,
+ method: &str,
+ mut args: Args,
+ span: Span,
+) -> SourceResult<&'a mut Value> {
+ let name = value.type_name();
+ let missing = || Err(missing_method(name, method)).at(span);
+
+ let slot = match value {
+ Value::Array(array) => match method {
+ "first" => array.first_mut().at(span)?,
+ "last" => array.last_mut().at(span)?,
+ "at" => array.at_mut(args.expect("index")?).at(span)?,
+ _ => return missing(),
+ },
+ Value::Dict(dict) => match method {
+ "at" => dict.at_mut(&args.expect::<Str>("key")?).at(span)?,
+ _ => return missing(),
+ },
+ _ => return missing(),
+ };
+
+ args.finish()?;
+ Ok(slot)
+}
+
+/// Whether a specific method is mutating.
+pub fn is_mutating(method: &str) -> bool {
+ matches!(method, "push" | "pop" | "insert" | "remove")
+}
+
+/// Whether a specific method is an accessor.
+pub fn is_accessor(method: &str) -> bool {
+ matches!(method, "first" | "last" | "at")
+}
+
+/// The missing method error message.
+#[cold]
+fn missing_method(type_name: &str, method: &str) -> String {
+ format!("type {type_name} has no method `{method}`")
+}
+
+/// List the available methods for a type and whether they take arguments.
+pub fn methods_on(type_name: &str) -> &[(&'static str, bool)] {
+ match type_name {
+ "color" => &[("lighten", true), ("darken", true), ("negate", false)],
+ "string" => &[
+ ("len", false),
+ ("at", true),
+ ("clusters", false),
+ ("codepoints", false),
+ ("contains", true),
+ ("ends-with", true),
+ ("find", true),
+ ("first", false),
+ ("last", false),
+ ("match", true),
+ ("matches", true),
+ ("position", true),
+ ("replace", true),
+ ("slice", true),
+ ("split", true),
+ ("starts-with", true),
+ ("trim", true),
+ ],
+ "content" => &[
+ ("func", false),
+ ("has", true),
+ ("at", true),
+ ("fields", false),
+ ("location", false),
+ ],
+ "array" => &[
+ ("all", true),
+ ("any", true),
+ ("at", true),
+ ("contains", true),
+ ("filter", true),
+ ("find", true),
+ ("first", false),
+ ("flatten", false),
+ ("fold", true),
+ ("insert", true),
+ ("split", true),
+ ("join", true),
+ ("last", false),
+ ("len", false),
+ ("map", true),
+ ("pop", false),
+ ("position", true),
+ ("push", true),
+ ("remove", true),
+ ("rev", false),
+ ("slice", true),
+ ("sorted", false),
+ ("enumerate", false),
+ ("zip", true),
+ ],
+ "dictionary" => &[
+ ("at", true),
+ ("insert", true),
+ ("keys", false),
+ ("len", false),
+ ("pairs", false),
+ ("remove", true),
+ ("values", false),
+ ],
+ "function" => &[("where", true), ("with", true)],
+ "arguments" => &[("named", false), ("pos", false)],
+ "location" => &[("page", false), ("position", false), ("page-numbering", false)],
+ "selector" => &[("or", true), ("and", true), ("before", true), ("after", true)],
+ "counter" => &[
+ ("display", true),
+ ("at", true),
+ ("final", true),
+ ("step", true),
+ ("update", true),
+ ],
+ "state" => &[("display", true), ("at", true), ("final", true), ("update", true)],
+ _ => &[],
+ }
+}
diff --git a/crates/typst/src/eval/mod.rs b/crates/typst/src/eval/mod.rs
new file mode 100644
index 00000000..fe28e3f3
--- /dev/null
+++ b/crates/typst/src/eval/mod.rs
@@ -0,0 +1,1908 @@
+//! Evaluation of markup into modules.
+
+#[macro_use]
+mod library;
+#[macro_use]
+mod cast;
+#[macro_use]
+mod array;
+#[macro_use]
+mod dict;
+#[macro_use]
+mod str;
+#[macro_use]
+mod value;
+mod args;
+mod auto;
+mod datetime;
+mod func;
+mod int;
+mod methods;
+mod module;
+mod none;
+pub mod ops;
+mod scope;
+mod symbol;
+
+#[doc(hidden)]
+pub use {
+ self::library::LANG_ITEMS,
+ ecow::{eco_format, eco_vec},
+ indexmap::IndexMap,
+ once_cell::sync::Lazy,
+};
+
+#[doc(inline)]
+pub use typst_macros::{func, symbols};
+
+pub use self::args::{Arg, Args};
+pub use self::array::{array, Array};
+pub use self::auto::AutoValue;
+pub use self::cast::{
+ cast, Cast, CastInfo, FromValue, IntoResult, IntoValue, Never, Reflect, Variadics,
+};
+pub use self::datetime::Datetime;
+pub use self::dict::{dict, Dict};
+pub use self::func::{Func, FuncInfo, NativeFunc, Param, ParamInfo};
+pub use self::library::{set_lang_items, LangItems, Library};
+pub use self::methods::methods_on;
+pub use self::module::Module;
+pub use self::none::NoneValue;
+pub use self::scope::{Scope, Scopes};
+pub use self::str::{format_str, Regex, Str};
+pub use self::symbol::Symbol;
+pub use self::value::{Dynamic, Type, Value};
+
+use std::collections::HashSet;
+use std::mem;
+use std::path::Path;
+
+use comemo::{Track, Tracked, TrackedMut, Validate};
+use ecow::{EcoString, EcoVec};
+use unicode_segmentation::UnicodeSegmentation;
+
+use self::func::{CapturesVisitor, Closure};
+use crate::diag::{
+ bail, error, At, SourceError, SourceResult, StrResult, Trace, Tracepoint,
+};
+use crate::file::{FileId, PackageManifest, PackageSpec};
+use crate::model::{
+ Content, DelayedErrors, Introspector, Label, Locator, Recipe, ShowableSelector,
+ Styles, Transform, Unlabellable, Vt,
+};
+use crate::syntax::ast::{self, AstNode};
+use crate::syntax::{parse_code, Source, Span, Spanned, SyntaxKind, SyntaxNode};
+use crate::World;
+
+const MAX_ITERATIONS: usize = 10_000;
+const MAX_CALL_DEPTH: usize = 64;
+
+/// Evaluate a source file and return the resulting module.
+#[comemo::memoize]
+#[tracing::instrument(skip(world, route, tracer, source))]
+pub fn eval(
+ world: Tracked<dyn World + '_>,
+ route: Tracked<Route>,
+ tracer: TrackedMut<Tracer>,
+ source: &Source,
+) -> SourceResult<Module> {
+ // Prevent cyclic evaluation.
+ let id = source.id();
+ if route.contains(id) {
+ panic!("Tried to cyclicly evaluate {}", id.path().display());
+ }
+
+ // Hook up the lang items.
+ let library = world.library();
+ set_lang_items(library.items.clone());
+
+ // Prepare VT.
+ let mut locator = Locator::default();
+ let introspector = Introspector::default();
+ let mut delayed = DelayedErrors::default();
+ let vt = Vt {
+ world,
+ introspector: introspector.track(),
+ locator: &mut locator,
+ delayed: delayed.track_mut(),
+ tracer,
+ };
+
+ // Prepare VM.
+ let route = Route::insert(route, id);
+ let scopes = Scopes::new(Some(library));
+ let mut vm = Vm::new(vt, route.track(), id, scopes);
+ let root = match source.root().cast::<ast::Markup>() {
+ Some(markup) if vm.traced.is_some() => markup,
+ _ => source.ast()?,
+ };
+
+ // Evaluate the module.
+ let result = root.eval(&mut vm);
+
+ // Handle control flow.
+ if let Some(flow) = vm.flow {
+ bail!(flow.forbidden());
+ }
+
+ // Assemble the module.
+ let name = id.path().file_stem().unwrap_or_default().to_string_lossy();
+ Ok(Module::new(name).with_scope(vm.scopes.top).with_content(result?))
+}
+
+/// Evaluate a string as code and return the resulting value.
+///
+/// Everything in the output is associated with the given `span`.
+#[comemo::memoize]
+pub fn eval_string(
+ world: Tracked<dyn World + '_>,
+ code: &str,
+ span: Span,
+) -> SourceResult<Value> {
+ let mut root = parse_code(code);
+ root.synthesize(span);
+
+ let errors = root.errors();
+ if !errors.is_empty() {
+ return Err(Box::new(errors));
+ }
+
+ // Prepare VT.
+ let mut tracer = Tracer::default();
+ let mut locator = Locator::default();
+ let mut delayed = DelayedErrors::default();
+ let introspector = Introspector::default();
+ let vt = Vt {
+ world,
+ introspector: introspector.track(),
+ locator: &mut locator,
+ delayed: delayed.track_mut(),
+ tracer: tracer.track_mut(),
+ };
+
+ // Prepare VM.
+ let route = Route::default();
+ let id = FileId::detached();
+ let scopes = Scopes::new(Some(world.library()));
+ let mut vm = Vm::new(vt, route.track(), id, scopes);
+
+ // Evaluate the code.
+ let code = root.cast::<ast::Code>().unwrap();
+ let result = code.eval(&mut vm);
+
+ // Handle control flow.
+ if let Some(flow) = vm.flow {
+ bail!(flow.forbidden());
+ }
+
+ result
+}
+
+/// A virtual machine.
+///
+/// Holds the state needed to [evaluate](eval) Typst sources. A new
+/// virtual machine is created for each module evaluation and function call.
+pub struct Vm<'a> {
+ /// The underlying virtual typesetter.
+ pub vt: Vt<'a>,
+ /// The language items.
+ items: LangItems,
+ /// The route of source ids the VM took to reach its current location.
+ route: Tracked<'a, Route<'a>>,
+ /// The current location.
+ location: FileId,
+ /// A control flow event that is currently happening.
+ flow: Option<FlowEvent>,
+ /// The stack of scopes.
+ scopes: Scopes<'a>,
+ /// The current call depth.
+ depth: usize,
+ /// A span that is currently traced.
+ traced: Option<Span>,
+}
+
+impl<'a> Vm<'a> {
+ /// Create a new virtual machine.
+ fn new(
+ vt: Vt<'a>,
+ route: Tracked<'a, Route>,
+ location: FileId,
+ scopes: Scopes<'a>,
+ ) -> Self {
+ let traced = vt.tracer.span(location);
+ let items = vt.world.library().items.clone();
+ Self {
+ vt,
+ items,
+ route,
+ location,
+ flow: None,
+ scopes,
+ depth: 0,
+ traced,
+ }
+ }
+
+ /// Access the underlying world.
+ pub fn world(&self) -> Tracked<'a, dyn World + 'a> {
+ self.vt.world
+ }
+
+ /// The location to which paths are relative currently.
+ pub fn location(&self) -> FileId {
+ self.location
+ }
+
+ /// Define a variable in the current scope.
+ #[tracing::instrument(skip_all)]
+ pub fn define(&mut self, var: ast::Ident, value: impl IntoValue) {
+ let value = value.into_value();
+ if self.traced == Some(var.span()) {
+ self.vt.tracer.trace(value.clone());
+ }
+ self.scopes.top.define(var.take(), value);
+ }
+}
+
+/// A control flow event that occurred during evaluation.
+#[derive(Debug, Clone, PartialEq)]
+pub enum FlowEvent {
+ /// Stop iteration in a loop.
+ Break(Span),
+ /// Skip the remainder of the current iteration in a loop.
+ Continue(Span),
+ /// Stop execution of a function early, optionally returning an explicit
+ /// value.
+ Return(Span, Option<Value>),
+}
+
+impl FlowEvent {
+ /// Return an error stating that this control flow is forbidden.
+ pub fn forbidden(&self) -> SourceError {
+ match *self {
+ Self::Break(span) => {
+ error!(span, "cannot break outside of loop")
+ }
+ Self::Continue(span) => {
+ error!(span, "cannot continue outside of loop")
+ }
+ Self::Return(span, _) => {
+ error!(span, "cannot return outside of function")
+ }
+ }
+ }
+}
+
+/// A route of source ids.
+#[derive(Default)]
+pub struct Route<'a> {
+ // 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>>,
+ id: Option<FileId>,
+}
+
+impl<'a> Route<'a> {
+ /// Create a new route with just one entry.
+ pub fn new(id: FileId) -> Self {
+ Self { id: Some(id), outer: None }
+ }
+
+ /// Insert a new id into the route.
+ ///
+ /// You must guarantee that `outer` lives longer than the resulting
+ /// route is ever used.
+ pub fn insert(outer: Tracked<'a, Self>, id: FileId) -> Self {
+ Route { outer: Some(outer), id: Some(id) }
+ }
+
+ /// Start tracking this locator.
+ ///
+ /// 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() => outer,
+ _ => Track::track(self),
+ }
+ }
+}
+
+#[comemo::track]
+impl<'a> Route<'a> {
+ /// Whether the given id is part of the route.
+ fn contains(&self, id: FileId) -> bool {
+ self.id == Some(id) || self.outer.map_or(false, |outer| outer.contains(id))
+ }
+}
+
+/// Traces which values existed for an expression at a span.
+#[derive(Default, Clone)]
+pub struct Tracer {
+ span: Option<Span>,
+ values: Vec<Value>,
+}
+
+impl Tracer {
+ /// The maximum number of traced items.
+ pub const MAX: usize = 10;
+
+ /// Create a new tracer, possibly with a span under inspection.
+ pub fn new(span: Option<Span>) -> Self {
+ Self { span, values: vec![] }
+ }
+
+ /// Get the traced values.
+ pub fn finish(self) -> Vec<Value> {
+ self.values
+ }
+}
+
+#[comemo::track]
+impl Tracer {
+ /// The traced span if it is part of the given source file.
+ fn span(&self, id: FileId) -> Option<Span> {
+ if self.span.map(Span::id) == Some(id) {
+ self.span
+ } else {
+ None
+ }
+ }
+
+ /// Trace a value for the span.
+ fn trace(&mut self, v: Value) {
+ if self.values.len() < Self::MAX {
+ self.values.push(v);
+ }
+ }
+}
+
+/// Evaluate an expression.
+pub(super) trait Eval {
+ /// The output of evaluating the expression.
+ type Output;
+
+ /// Evaluate the expression to the output value.
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output>;
+}
+
+impl Eval for ast::Markup {
+ type Output = Content;
+
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ eval_markup(vm, &mut self.exprs())
+ }
+}
+
+/// Evaluate a stream of markup.
+fn eval_markup(
+ vm: &mut Vm,
+ exprs: &mut impl Iterator<Item = ast::Expr>,
+) -> SourceResult<Content> {
+ let flow = vm.flow.take();
+ let mut seq = Vec::with_capacity(exprs.size_hint().1.unwrap_or_default());
+
+ while let Some(expr) = exprs.next() {
+ match expr {
+ ast::Expr::Set(set) => {
+ let styles = set.eval(vm)?;
+ if vm.flow.is_some() {
+ break;
+ }
+
+ seq.push(eval_markup(vm, exprs)?.styled_with_map(styles))
+ }
+ ast::Expr::Show(show) => {
+ let recipe = show.eval(vm)?;
+ if vm.flow.is_some() {
+ break;
+ }
+
+ let tail = eval_markup(vm, exprs)?;
+ seq.push(tail.styled_with_recipe(vm, recipe)?)
+ }
+ expr => match expr.eval(vm)? {
+ Value::Label(label) => {
+ if let Some(elem) =
+ seq.iter_mut().rev().find(|node| !node.can::<dyn Unlabellable>())
+ {
+ *elem = mem::take(elem).labelled(label);
+ }
+ }
+ value => seq.push(value.display().spanned(expr.span())),
+ },
+ }
+
+ if vm.flow.is_some() {
+ break;
+ }
+ }
+
+ if flow.is_some() {
+ vm.flow = flow;
+ }
+
+ Ok(Content::sequence(seq))
+}
+
+impl Eval for ast::Expr {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Expr::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let span = self.span();
+ let forbidden = |name| {
+ error!(span, "{} is only allowed directly in code and content blocks", name)
+ };
+
+ let v = match self {
+ Self::Text(v) => v.eval(vm).map(Value::Content),
+ Self::Space(v) => v.eval(vm).map(Value::Content),
+ Self::Linebreak(v) => v.eval(vm).map(Value::Content),
+ Self::Parbreak(v) => v.eval(vm).map(Value::Content),
+ Self::Escape(v) => v.eval(vm),
+ Self::Shorthand(v) => v.eval(vm),
+ Self::SmartQuote(v) => v.eval(vm).map(Value::Content),
+ Self::Strong(v) => v.eval(vm).map(Value::Content),
+ Self::Emph(v) => v.eval(vm).map(Value::Content),
+ Self::Raw(v) => v.eval(vm).map(Value::Content),
+ Self::Link(v) => v.eval(vm).map(Value::Content),
+ Self::Label(v) => v.eval(vm),
+ Self::Ref(v) => v.eval(vm).map(Value::Content),
+ Self::Heading(v) => v.eval(vm).map(Value::Content),
+ Self::List(v) => v.eval(vm).map(Value::Content),
+ Self::Enum(v) => v.eval(vm).map(Value::Content),
+ Self::Term(v) => v.eval(vm).map(Value::Content),
+ Self::Equation(v) => v.eval(vm).map(Value::Content),
+ Self::Math(v) => v.eval(vm).map(Value::Content),
+ Self::MathIdent(v) => v.eval(vm),
+ Self::MathAlignPoint(v) => v.eval(vm).map(Value::Content),
+ Self::MathDelimited(v) => v.eval(vm).map(Value::Content),
+ Self::MathAttach(v) => v.eval(vm).map(Value::Content),
+ Self::MathFrac(v) => v.eval(vm).map(Value::Content),
+ Self::MathRoot(v) => v.eval(vm).map(Value::Content),
+ Self::Ident(v) => v.eval(vm),
+ Self::None(v) => v.eval(vm),
+ Self::Auto(v) => v.eval(vm),
+ Self::Bool(v) => v.eval(vm),
+ Self::Int(v) => v.eval(vm),
+ Self::Float(v) => v.eval(vm),
+ Self::Numeric(v) => v.eval(vm),
+ Self::Str(v) => v.eval(vm),
+ Self::Code(v) => v.eval(vm),
+ Self::Content(v) => v.eval(vm).map(Value::Content),
+ Self::Array(v) => v.eval(vm).map(Value::Array),
+ Self::Dict(v) => v.eval(vm).map(Value::Dict),
+ Self::Parenthesized(v) => v.eval(vm),
+ Self::FieldAccess(v) => v.eval(vm),
+ Self::FuncCall(v) => v.eval(vm),
+ Self::Closure(v) => v.eval(vm),
+ Self::Unary(v) => v.eval(vm),
+ Self::Binary(v) => v.eval(vm),
+ Self::Let(v) => v.eval(vm),
+ Self::DestructAssign(v) => v.eval(vm),
+ Self::Set(_) => bail!(forbidden("set")),
+ Self::Show(_) => bail!(forbidden("show")),
+ Self::Conditional(v) => v.eval(vm),
+ Self::While(v) => v.eval(vm),
+ Self::For(v) => v.eval(vm),
+ Self::Import(v) => v.eval(vm),
+ Self::Include(v) => v.eval(vm).map(Value::Content),
+ Self::Break(v) => v.eval(vm),
+ Self::Continue(v) => v.eval(vm),
+ Self::Return(v) => v.eval(vm),
+ }?
+ .spanned(span);
+
+ if vm.traced == Some(span) {
+ vm.vt.tracer.trace(v.clone());
+ }
+
+ Ok(v)
+ }
+}
+
+impl ast::Expr {
+ fn eval_display(&self, vm: &mut Vm) -> SourceResult<Content> {
+ Ok(self.eval(vm)?.display().spanned(self.span()))
+ }
+}
+
+impl Eval for ast::Text {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Text::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.text)(self.get().clone()))
+ }
+}
+
+impl Eval for ast::Space {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Space::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.space)())
+ }
+}
+
+impl Eval for ast::Linebreak {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Linebreak::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.linebreak)())
+ }
+}
+
+impl Eval for ast::Parbreak {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Parbreak::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.parbreak)())
+ }
+}
+
+impl Eval for ast::Escape {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Escape::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Symbol(Symbol::new(self.get())))
+ }
+}
+
+impl Eval for ast::Shorthand {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Shorthand::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Symbol(Symbol::new(self.get())))
+ }
+}
+
+impl Eval for ast::SmartQuote {
+ type Output = Content;
+
+ #[tracing::instrument(name = "SmartQuote::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.smart_quote)(self.double()))
+ }
+}
+
+impl Eval for ast::Strong {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Strong::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.strong)(self.body().eval(vm)?))
+ }
+}
+
+impl Eval for ast::Emph {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Emph::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.emph)(self.body().eval(vm)?))
+ }
+}
+
+impl Eval for ast::Raw {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Raw::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let text = self.text();
+ let lang = self.lang().map(Into::into);
+ let block = self.block();
+ Ok((vm.items.raw)(text, lang, block))
+ }
+}
+
+impl Eval for ast::Link {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Link::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.link)(self.get().clone()))
+ }
+}
+
+impl Eval for ast::Label {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Label::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Label(Label(self.get().into())))
+ }
+}
+
+impl Eval for ast::Ref {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Ref::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let label = Label(self.target().into());
+ let supplement = self.supplement().map(|block| block.eval(vm)).transpose()?;
+ Ok((vm.items.reference)(label, supplement))
+ }
+}
+
+impl Eval for ast::Heading {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Heading::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let level = self.level();
+ let body = self.body().eval(vm)?;
+ Ok((vm.items.heading)(level, body))
+ }
+}
+
+impl Eval for ast::ListItem {
+ type Output = Content;
+
+ #[tracing::instrument(name = "ListItem::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.list_item)(self.body().eval(vm)?))
+ }
+}
+
+impl Eval for ast::EnumItem {
+ type Output = Content;
+
+ #[tracing::instrument(name = "EnumItem::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let number = self.number();
+ let body = self.body().eval(vm)?;
+ Ok((vm.items.enum_item)(number, body))
+ }
+}
+
+impl Eval for ast::TermItem {
+ type Output = Content;
+
+ #[tracing::instrument(name = "TermItem::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let term = self.term().eval(vm)?;
+ let description = self.description().eval(vm)?;
+ Ok((vm.items.term_item)(term, description))
+ }
+}
+
+impl Eval for ast::Equation {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Equation::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let body = self.body().eval(vm)?;
+ let block = self.block();
+ Ok((vm.items.equation)(body, block))
+ }
+}
+
+impl Eval for ast::Math {
+ type Output = Content;
+
+ #[tracing::instrument(name = "Math::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Content::sequence(
+ self.exprs()
+ .map(|expr| expr.eval_display(vm))
+ .collect::<SourceResult<Vec<_>>>()?,
+ ))
+ }
+}
+
+impl Eval for ast::MathIdent {
+ type Output = Value;
+
+ #[tracing::instrument(name = "MathIdent::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ vm.scopes.get_in_math(self).cloned().at(self.span())
+ }
+}
+
+impl Eval for ast::MathAlignPoint {
+ type Output = Content;
+
+ #[tracing::instrument(name = "MathAlignPoint::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ Ok((vm.items.math_align_point)())
+ }
+}
+
+impl Eval for ast::MathDelimited {
+ type Output = Content;
+
+ #[tracing::instrument(name = "MathDelimited::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let open = self.open().eval_display(vm)?;
+ let body = self.body().eval(vm)?;
+ let close = self.close().eval_display(vm)?;
+ Ok((vm.items.math_delimited)(open, body, close))
+ }
+}
+
+impl Eval for ast::MathAttach {
+ type Output = Content;
+
+ #[tracing::instrument(name = "MathAttach::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let base = self.base().eval_display(vm)?;
+ let top = self.top().map(|expr| expr.eval_display(vm)).transpose()?;
+ let bottom = self.bottom().map(|expr| expr.eval_display(vm)).transpose()?;
+ Ok((vm.items.math_attach)(base, top, bottom, None, None, None, None))
+ }
+}
+
+impl Eval for ast::MathFrac {
+ type Output = Content;
+
+ #[tracing::instrument(name = "MathFrac::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let num = self.num().eval_display(vm)?;
+ let denom = self.denom().eval_display(vm)?;
+ Ok((vm.items.math_frac)(num, denom))
+ }
+}
+
+impl Eval for ast::MathRoot {
+ type Output = Content;
+
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let index = self.index().map(|i| (vm.items.text)(eco_format!("{i}")));
+ let radicand = self.radicand().eval_display(vm)?;
+ Ok((vm.items.math_root)(index, radicand))
+ }
+}
+
+impl Eval for ast::Ident {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Ident::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ vm.scopes.get(self).cloned().at(self.span())
+ }
+}
+
+impl Eval for ast::None {
+ type Output = Value;
+
+ #[tracing::instrument(name = "None::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::None)
+ }
+}
+
+impl Eval for ast::Auto {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Auto::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Auto)
+ }
+}
+
+impl Eval for ast::Bool {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Bool::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Bool(self.get()))
+ }
+}
+
+impl Eval for ast::Int {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Int::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Int(self.get()))
+ }
+}
+
+impl Eval for ast::Float {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Float::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Float(self.get()))
+ }
+}
+
+impl Eval for ast::Numeric {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Numeric::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::numeric(self.get()))
+ }
+}
+
+impl Eval for ast::Str {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Str::eval", skip_all)]
+ fn eval(&self, _: &mut Vm) -> SourceResult<Self::Output> {
+ Ok(Value::Str(self.get().into()))
+ }
+}
+
+impl Eval for ast::CodeBlock {
+ type Output = Value;
+
+ #[tracing::instrument(name = "CodeBlock::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ vm.scopes.enter();
+ let output = self.body().eval(vm)?;
+ vm.scopes.exit();
+ Ok(output)
+ }
+}
+
+impl Eval for ast::Code {
+ type Output = Value;
+
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ eval_code(vm, &mut self.exprs())
+ }
+}
+
+/// Evaluate a stream of expressions.
+fn eval_code(
+ vm: &mut Vm,
+ exprs: &mut impl Iterator<Item = ast::Expr>,
+) -> SourceResult<Value> {
+ let flow = vm.flow.take();
+ let mut output = Value::None;
+
+ while let Some(expr) = exprs.next() {
+ let span = expr.span();
+ let value = match expr {
+ ast::Expr::Set(set) => {
+ let styles = set.eval(vm)?;
+ if vm.flow.is_some() {
+ break;
+ }
+
+ let tail = eval_code(vm, exprs)?.display();
+ Value::Content(tail.styled_with_map(styles))
+ }
+ ast::Expr::Show(show) => {
+ let recipe = show.eval(vm)?;
+ if vm.flow.is_some() {
+ break;
+ }
+
+ let tail = eval_code(vm, exprs)?.display();
+ Value::Content(tail.styled_with_recipe(vm, recipe)?)
+ }
+ _ => expr.eval(vm)?,
+ };
+
+ output = ops::join(output, value).at(span)?;
+
+ if vm.flow.is_some() {
+ break;
+ }
+ }
+
+ if flow.is_some() {
+ vm.flow = flow;
+ }
+
+ Ok(output)
+}
+
+impl Eval for ast::ContentBlock {
+ type Output = Content;
+
+ #[tracing::instrument(name = "ContentBlock::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ vm.scopes.enter();
+ let content = self.body().eval(vm)?;
+ vm.scopes.exit();
+ Ok(content)
+ }
+}
+
+impl Eval for ast::Parenthesized {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Parenthesized::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ self.expr().eval(vm)
+ }
+}
+
+impl Eval for ast::Array {
+ type Output = Array;
+
+ #[tracing::instrument(skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let items = self.items();
+
+ let mut vec = EcoVec::with_capacity(items.size_hint().0);
+ for item in items {
+ match item {
+ ast::ArrayItem::Pos(expr) => vec.push(expr.eval(vm)?),
+ ast::ArrayItem::Spread(expr) => match expr.eval(vm)? {
+ Value::None => {}
+ Value::Array(array) => vec.extend(array.into_iter()),
+ v => bail!(expr.span(), "cannot spread {} into array", v.type_name()),
+ },
+ }
+ }
+
+ Ok(vec.into())
+ }
+}
+
+impl Eval for ast::Dict {
+ type Output = Dict;
+
+ #[tracing::instrument(skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let mut map = indexmap::IndexMap::new();
+
+ for item in self.items() {
+ match item {
+ ast::DictItem::Named(named) => {
+ map.insert(named.name().take().into(), named.expr().eval(vm)?);
+ }
+ ast::DictItem::Keyed(keyed) => {
+ map.insert(keyed.key().get().into(), keyed.expr().eval(vm)?);
+ }
+ ast::DictItem::Spread(expr) => match expr.eval(vm)? {
+ Value::None => {}
+ Value::Dict(dict) => map.extend(dict.into_iter()),
+ v => bail!(
+ expr.span(),
+ "cannot spread {} into dictionary",
+ v.type_name()
+ ),
+ },
+ }
+ }
+
+ Ok(map.into())
+ }
+}
+
+impl Eval for ast::Unary {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Unary::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let value = self.expr().eval(vm)?;
+ let result = match self.op() {
+ ast::UnOp::Pos => ops::pos(value),
+ ast::UnOp::Neg => ops::neg(value),
+ ast::UnOp::Not => ops::not(value),
+ };
+ result.at(self.span())
+ }
+}
+
+impl Eval for ast::Binary {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Binary::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ match self.op() {
+ ast::BinOp::Add => self.apply(vm, ops::add),
+ ast::BinOp::Sub => self.apply(vm, ops::sub),
+ ast::BinOp::Mul => self.apply(vm, ops::mul),
+ ast::BinOp::Div => self.apply(vm, ops::div),
+ ast::BinOp::And => self.apply(vm, ops::and),
+ ast::BinOp::Or => self.apply(vm, ops::or),
+ ast::BinOp::Eq => self.apply(vm, ops::eq),
+ ast::BinOp::Neq => self.apply(vm, ops::neq),
+ ast::BinOp::Lt => self.apply(vm, ops::lt),
+ ast::BinOp::Leq => self.apply(vm, ops::leq),
+ ast::BinOp::Gt => self.apply(vm, ops::gt),
+ ast::BinOp::Geq => self.apply(vm, ops::geq),
+ ast::BinOp::In => self.apply(vm, ops::in_),
+ ast::BinOp::NotIn => self.apply(vm, ops::not_in),
+ ast::BinOp::Assign => self.assign(vm, |_, b| Ok(b)),
+ ast::BinOp::AddAssign => self.assign(vm, ops::add),
+ ast::BinOp::SubAssign => self.assign(vm, ops::sub),
+ ast::BinOp::MulAssign => self.assign(vm, ops::mul),
+ ast::BinOp::DivAssign => self.assign(vm, ops::div),
+ }
+ }
+}
+
+impl ast::Binary {
+ /// Apply a basic binary operation.
+ fn apply(
+ &self,
+ vm: &mut Vm,
+ op: fn(Value, Value) -> StrResult<Value>,
+ ) -> SourceResult<Value> {
+ let lhs = self.lhs().eval(vm)?;
+
+ // Short-circuit boolean operations.
+ if (self.op() == ast::BinOp::And && lhs == Value::Bool(false))
+ || (self.op() == ast::BinOp::Or && lhs == Value::Bool(true))
+ {
+ return Ok(lhs);
+ }
+
+ let rhs = self.rhs().eval(vm)?;
+ op(lhs, rhs).at(self.span())
+ }
+
+ /// Apply an assignment operation.
+ fn assign(
+ &self,
+ vm: &mut Vm,
+ op: fn(Value, Value) -> StrResult<Value>,
+ ) -> SourceResult<Value> {
+ let rhs = self.rhs().eval(vm)?;
+ let lhs = self.lhs();
+
+ // An assignment to a dictionary field is different from a normal access
+ // since it can create the field instead of just modifying it.
+ if self.op() == ast::BinOp::Assign {
+ if let ast::Expr::FieldAccess(access) = &lhs {
+ let dict = access.access_dict(vm)?;
+ dict.insert(access.field().take().into(), rhs);
+ return Ok(Value::None);
+ }
+ }
+
+ let location = self.lhs().access(vm)?;
+ let lhs = std::mem::take(&mut *location);
+ *location = op(lhs, rhs).at(self.span())?;
+ Ok(Value::None)
+ }
+}
+
+impl Eval for ast::FieldAccess {
+ type Output = Value;
+
+ #[tracing::instrument(name = "FieldAccess::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let value = self.target().eval(vm)?;
+ let field = self.field();
+ value.field(&field).at(field.span())
+ }
+}
+
+impl Eval for ast::FuncCall {
+ type Output = Value;
+
+ #[tracing::instrument(name = "FuncCall::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let span = self.span();
+ if vm.depth >= MAX_CALL_DEPTH {
+ bail!(span, "maximum function call depth exceeded");
+ }
+
+ let callee = self.callee();
+ let in_math = in_math(&callee);
+ let callee_span = callee.span();
+ let args = self.args();
+
+ // Try to evaluate as a method call. This is possible if the callee is a
+ // field access and does not evaluate to a module.
+ let (callee, mut args) = if let ast::Expr::FieldAccess(access) = callee {
+ let target = access.target();
+ let field = access.field();
+ let field_span = field.span();
+ let field = field.take();
+ let point = || Tracepoint::Call(Some(field.clone()));
+ if methods::is_mutating(&field) {
+ let args = args.eval(vm)?;
+ let target = target.access(vm)?;
+
+ // Prioritize a function's own methods (with, where) over its
+ // fields. This is fine as we define each field of a function,
+ // if it has any.
+ // ('methods_on' will be empty for Symbol and Module - their
+ // method calls always refer to their fields.)
+ if !matches!(target, Value::Symbol(_) | Value::Module(_) | Value::Func(_))
+ || methods_on(target.type_name()).iter().any(|(m, _)| m == &field)
+ {
+ return methods::call_mut(target, &field, args, span).trace(
+ vm.world(),
+ point,
+ span,
+ );
+ }
+ (target.field(&field).at(field_span)?, args)
+ } else {
+ let target = target.eval(vm)?;
+ let args = args.eval(vm)?;
+
+ if !matches!(target, Value::Symbol(_) | Value::Module(_) | Value::Func(_))
+ || methods_on(target.type_name()).iter().any(|(m, _)| m == &field)
+ {
+ return methods::call(vm, target, &field, args, span).trace(
+ vm.world(),
+ point,
+ span,
+ );
+ }
+ (target.field(&field).at(field_span)?, args)
+ }
+ } else {
+ (callee.eval(vm)?, args.eval(vm)?)
+ };
+
+ // Handle math special cases for non-functions:
+ // Combining accent symbols apply themselves while everything else
+ // simply displays the arguments verbatim.
+ if in_math && !matches!(callee, Value::Func(_)) {
+ if let Value::Symbol(sym) = &callee {
+ let c = sym.get();
+ if let Some(accent) = Symbol::combining_accent(c) {
+ let base = args.expect("base")?;
+ args.finish()?;
+ return Ok(Value::Content((vm.items.math_accent)(base, accent)));
+ }
+ }
+ let mut body = Content::empty();
+ for (i, arg) in args.all::<Content>()?.into_iter().enumerate() {
+ if i > 0 {
+ body += (vm.items.text)(','.into());
+ }
+ body += arg;
+ }
+ return Ok(Value::Content(
+ callee.display().spanned(callee_span)
+ + (vm.items.math_delimited)(
+ (vm.items.text)('('.into()),
+ body,
+ (vm.items.text)(')'.into()),
+ ),
+ ));
+ }
+
+ let callee = callee.cast::<Func>().at(callee_span)?;
+ let point = || Tracepoint::Call(callee.name().map(Into::into));
+ let f = || callee.call_vm(vm, args).trace(vm.world(), point, span);
+
+ // Stacker is broken on WASM.
+ #[cfg(target_arch = "wasm32")]
+ return f();
+
+ #[cfg(not(target_arch = "wasm32"))]
+ stacker::maybe_grow(32 * 1024, 2 * 1024 * 1024, f)
+ }
+}
+
+fn in_math(expr: &ast::Expr) -> bool {
+ match expr {
+ ast::Expr::MathIdent(_) => true,
+ ast::Expr::FieldAccess(access) => in_math(&access.target()),
+ _ => false,
+ }
+}
+
+impl Eval for ast::Args {
+ type Output = Args;
+
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let mut items = EcoVec::new();
+
+ for arg in self.items() {
+ let span = arg.span();
+ match arg {
+ ast::Arg::Pos(expr) => {
+ items.push(Arg {
+ span,
+ name: None,
+ value: Spanned::new(expr.eval(vm)?, expr.span()),
+ });
+ }
+ ast::Arg::Named(named) => {
+ items.push(Arg {
+ span,
+ name: Some(named.name().take().into()),
+ value: Spanned::new(named.expr().eval(vm)?, named.expr().span()),
+ });
+ }
+ ast::Arg::Spread(expr) => match expr.eval(vm)? {
+ Value::None => {}
+ Value::Array(array) => {
+ items.extend(array.into_iter().map(|value| Arg {
+ span,
+ name: None,
+ value: Spanned::new(value, span),
+ }));
+ }
+ Value::Dict(dict) => {
+ items.extend(dict.into_iter().map(|(key, value)| Arg {
+ span,
+ name: Some(key),
+ value: Spanned::new(value, span),
+ }));
+ }
+ Value::Args(args) => items.extend(args.items),
+ v => bail!(expr.span(), "cannot spread {}", v.type_name()),
+ },
+ }
+ }
+
+ Ok(Args { span: self.span(), items })
+ }
+}
+
+impl Eval for ast::Closure {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Closure::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ // The closure's name is defined by its let binding if there's one.
+ let name = self.name();
+
+ // Collect captured variables.
+ let captured = {
+ let mut visitor = CapturesVisitor::new(&vm.scopes);
+ visitor.visit(self.as_untyped());
+ visitor.finish()
+ };
+
+ // Collect parameters and an optional sink parameter.
+ let mut params = Vec::new();
+ for param in self.params().children() {
+ match param {
+ ast::Param::Pos(pattern) => params.push(Param::Pos(pattern)),
+ ast::Param::Named(named) => {
+ params.push(Param::Named(named.name(), named.expr().eval(vm)?));
+ }
+ ast::Param::Sink(spread) => params.push(Param::Sink(spread.name())),
+ }
+ }
+
+ // Define the closure.
+ let closure = Closure {
+ location: vm.location,
+ name,
+ captured,
+ params,
+ body: self.body(),
+ };
+
+ Ok(Value::Func(Func::from(closure).spanned(self.params().span())))
+ }
+}
+
+impl ast::Pattern {
+ fn destruct_array<F>(
+ &self,
+ vm: &mut Vm,
+ value: Array,
+ f: F,
+ destruct: &ast::Destructuring,
+ ) -> SourceResult<Value>
+ where
+ F: Fn(&mut Vm, ast::Expr, Value) -> SourceResult<Value>,
+ {
+ let mut i = 0;
+ let len = value.as_slice().len();
+ for p in destruct.bindings() {
+ match p {
+ ast::DestructuringKind::Normal(expr) => {
+ let Ok(v) = value.at(i as i64, None) else {
+ bail!(expr.span(), "not enough elements to destructure");
+ };
+ f(vm, expr, v.clone())?;
+ i += 1;
+ }
+ ast::DestructuringKind::Sink(spread) => {
+ let sink_size = (1 + len).checked_sub(destruct.bindings().count());
+ let sink = sink_size.and_then(|s| value.as_slice().get(i..i + s));
+ if let (Some(sink_size), Some(sink)) = (sink_size, sink) {
+ if let Some(expr) = spread.expr() {
+ f(vm, expr, Value::Array(sink.into()))?;
+ }
+ i += sink_size;
+ } else {
+ bail!(self.span(), "not enough elements to destructure")
+ }
+ }
+ ast::DestructuringKind::Named(named) => {
+ bail!(named.span(), "cannot destructure named elements from an array")
+ }
+ ast::DestructuringKind::Placeholder(underscore) => {
+ if i < len {
+ i += 1
+ } else {
+ bail!(underscore.span(), "not enough elements to destructure")
+ }
+ }
+ }
+ }
+ if i < len {
+ bail!(self.span(), "too many elements to destructure");
+ }
+
+ Ok(Value::None)
+ }
+
+ fn destruct_dict<F>(
+ &self,
+ vm: &mut Vm,
+ dict: Dict,
+ f: F,
+ destruct: &ast::Destructuring,
+ ) -> SourceResult<Value>
+ where
+ F: Fn(&mut Vm, ast::Expr, Value) -> SourceResult<Value>,
+ {
+ let mut sink = None;
+ let mut used = HashSet::new();
+ for p in destruct.bindings() {
+ match p {
+ ast::DestructuringKind::Normal(ast::Expr::Ident(ident)) => {
+ let v = dict
+ .at(&ident, None)
+ .map_err(|_| "destructuring key not found in dictionary")
+ .at(ident.span())?;
+ f(vm, ast::Expr::Ident(ident.clone()), v.clone())?;
+ used.insert(ident.take());
+ }
+ ast::DestructuringKind::Sink(spread) => sink = spread.expr(),
+ ast::DestructuringKind::Named(named) => {
+ let name = named.name();
+ let v = dict
+ .at(&name, None)
+ .map_err(|_| "destructuring key not found in dictionary")
+ .at(name.span())?;
+ f(vm, named.expr(), v.clone())?;
+ used.insert(name.take());
+ }
+ ast::DestructuringKind::Placeholder(_) => {}
+ ast::DestructuringKind::Normal(expr) => {
+ bail!(expr.span(), "expected key, found expression");
+ }
+ }
+ }
+
+ if let Some(expr) = sink {
+ let mut sink = Dict::new();
+ for (key, value) in dict {
+ if !used.contains(key.as_str()) {
+ sink.insert(key, value);
+ }
+ }
+ f(vm, expr, Value::Dict(sink))?;
+ }
+
+ Ok(Value::None)
+ }
+
+ /// Destruct the given value into the pattern and apply the function to each binding.
+ #[tracing::instrument(skip_all)]
+ fn apply<T>(&self, vm: &mut Vm, value: Value, f: T) -> SourceResult<Value>
+ where
+ T: Fn(&mut Vm, ast::Expr, Value) -> SourceResult<Value>,
+ {
+ match self {
+ ast::Pattern::Normal(expr) => {
+ f(vm, expr.clone(), value)?;
+ Ok(Value::None)
+ }
+ ast::Pattern::Placeholder(_) => Ok(Value::None),
+ ast::Pattern::Destructuring(destruct) => match value {
+ Value::Array(value) => self.destruct_array(vm, value, f, destruct),
+ Value::Dict(value) => self.destruct_dict(vm, value, f, destruct),
+ _ => bail!(self.span(), "cannot destructure {}", value.type_name()),
+ },
+ }
+ }
+
+ /// Destruct the value into the pattern by binding.
+ pub fn define(&self, vm: &mut Vm, value: Value) -> SourceResult<Value> {
+ self.apply(vm, value, |vm, expr, value| match expr {
+ ast::Expr::Ident(ident) => {
+ vm.define(ident, value);
+ Ok(Value::None)
+ }
+ _ => bail!(expr.span(), "nested patterns are currently not supported"),
+ })
+ }
+
+ /// Destruct the value into the pattern by assignment.
+ pub fn assign(&self, vm: &mut Vm, value: Value) -> SourceResult<Value> {
+ self.apply(vm, value, |vm, expr, value| {
+ let location = expr.access(vm)?;
+ *location = value;
+ Ok(Value::None)
+ })
+ }
+}
+
+impl Eval for ast::LetBinding {
+ type Output = Value;
+
+ #[tracing::instrument(name = "LetBinding::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let value = match self.init() {
+ Some(expr) => expr.eval(vm)?,
+ None => Value::None,
+ };
+
+ match self.kind() {
+ ast::LetBindingKind::Normal(pattern) => pattern.define(vm, value),
+ ast::LetBindingKind::Closure(ident) => {
+ vm.define(ident, value);
+ Ok(Value::None)
+ }
+ }
+ }
+}
+
+impl Eval for ast::DestructAssignment {
+ type Output = Value;
+
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let value = self.value().eval(vm)?;
+ self.pattern().assign(vm, value)?;
+ Ok(Value::None)
+ }
+}
+
+impl Eval for ast::SetRule {
+ type Output = Styles;
+
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ if let Some(condition) = self.condition() {
+ if !condition.eval(vm)?.cast::<bool>().at(condition.span())? {
+ return Ok(Styles::new());
+ }
+ }
+
+ let target = self.target();
+ let target = target
+ .eval(vm)?
+ .cast::<Func>()
+ .and_then(|func| {
+ func.element().ok_or_else(|| {
+ "only element functions can be used in set rules".into()
+ })
+ })
+ .at(target.span())?;
+ let args = self.args().eval(vm)?;
+ Ok(target.set(args)?.spanned(self.span()))
+ }
+}
+
+impl Eval for ast::ShowRule {
+ type Output = Recipe;
+
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let selector = self
+ .selector()
+ .map(|sel| sel.eval(vm)?.cast::<ShowableSelector>().at(sel.span()))
+ .transpose()?
+ .map(|selector| selector.0);
+
+ let transform = self.transform();
+ let span = transform.span();
+
+ let transform = match transform {
+ ast::Expr::Set(set) => Transform::Style(set.eval(vm)?),
+ expr => expr.eval(vm)?.cast::<Transform>().at(span)?,
+ };
+
+ Ok(Recipe { span, selector, transform })
+ }
+}
+
+impl Eval for ast::Conditional {
+ type Output = Value;
+
+ #[tracing::instrument(name = "Conditional::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let condition = self.condition();
+ if condition.eval(vm)?.cast::<bool>().at(condition.span())? {
+ self.if_body().eval(vm)
+ } else if let Some(else_body) = self.else_body() {
+ else_body.eval(vm)
+ } else {
+ Ok(Value::None)
+ }
+ }
+}
+
+impl Eval for ast::WhileLoop {
+ type Output = Value;
+
+ #[tracing::instrument(name = "WhileLoop::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let flow = vm.flow.take();
+ let mut output = Value::None;
+ let mut i = 0;
+
+ let condition = self.condition();
+ let body = self.body();
+
+ while condition.eval(vm)?.cast::<bool>().at(condition.span())? {
+ if i == 0
+ && is_invariant(condition.as_untyped())
+ && !can_diverge(body.as_untyped())
+ {
+ bail!(condition.span(), "condition is always true");
+ } else if i >= MAX_ITERATIONS {
+ bail!(self.span(), "loop seems to be infinite");
+ }
+
+ let value = body.eval(vm)?;
+ output = ops::join(output, value).at(body.span())?;
+
+ match vm.flow {
+ Some(FlowEvent::Break(_)) => {
+ vm.flow = None;
+ break;
+ }
+ Some(FlowEvent::Continue(_)) => vm.flow = None,
+ Some(FlowEvent::Return(..)) => break,
+ None => {}
+ }
+
+ i += 1;
+ }
+
+ if flow.is_some() {
+ vm.flow = flow;
+ }
+
+ Ok(output)
+ }
+}
+
+/// Whether the expression always evaluates to the same value.
+fn is_invariant(expr: &SyntaxNode) -> bool {
+ match expr.cast() {
+ Some(ast::Expr::Ident(_)) => false,
+ Some(ast::Expr::MathIdent(_)) => false,
+ Some(ast::Expr::FieldAccess(access)) => {
+ is_invariant(access.target().as_untyped())
+ }
+ Some(ast::Expr::FuncCall(call)) => {
+ is_invariant(call.callee().as_untyped())
+ && is_invariant(call.args().as_untyped())
+ }
+ _ => expr.children().all(is_invariant),
+ }
+}
+
+/// Whether the expression contains a break or return.
+fn can_diverge(expr: &SyntaxNode) -> bool {
+ matches!(expr.kind(), SyntaxKind::Break | SyntaxKind::Return)
+ || expr.children().any(can_diverge)
+}
+
+impl Eval for ast::ForLoop {
+ type Output = Value;
+
+ #[tracing::instrument(name = "ForLoop::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let flow = vm.flow.take();
+ let mut output = Value::None;
+
+ macro_rules! iter {
+ (for $pat:ident in $iter:expr) => {{
+ vm.scopes.enter();
+
+ #[allow(unused_parens)]
+ for value in $iter {
+ $pat.define(vm, value.into_value())?;
+
+ let body = self.body();
+ let value = body.eval(vm)?;
+ output = ops::join(output, value).at(body.span())?;
+
+ match vm.flow {
+ Some(FlowEvent::Break(_)) => {
+ vm.flow = None;
+ break;
+ }
+ Some(FlowEvent::Continue(_)) => vm.flow = None,
+ Some(FlowEvent::Return(..)) => break,
+ None => {}
+ }
+ }
+
+ vm.scopes.exit();
+ }};
+ }
+
+ let iter = self.iter().eval(vm)?;
+ let pattern = self.pattern();
+
+ match (&pattern, iter.clone()) {
+ (ast::Pattern::Normal(_), Value::Str(string)) => {
+ // Iterate over graphemes of string.
+ iter!(for pattern in string.as_str().graphemes(true));
+ }
+ (_, Value::Dict(dict)) => {
+ // Iterate over pairs of dict.
+ iter!(for pattern in dict.pairs());
+ }
+ (_, Value::Array(array)) => {
+ // Iterate over values of array.
+ iter!(for pattern in array);
+ }
+ (ast::Pattern::Normal(_), _) => {
+ bail!(self.iter().span(), "cannot loop over {}", iter.type_name());
+ }
+ (_, _) => {
+ bail!(pattern.span(), "cannot destructure values of {}", iter.type_name())
+ }
+ }
+
+ if flow.is_some() {
+ vm.flow = flow;
+ }
+
+ Ok(output)
+ }
+}
+
+/// Applies imports from `import` to the current scope.
+fn apply_imports<V: IntoValue>(
+ imports: Option<ast::Imports>,
+ vm: &mut Vm,
+ source_value: V,
+ name: impl Fn(&V) -> EcoString,
+ scope: impl Fn(&V) -> &Scope,
+) -> SourceResult<()> {
+ match imports {
+ None => {
+ vm.scopes.top.define(name(&source_value), source_value);
+ }
+ Some(ast::Imports::Wildcard) => {
+ for (var, value) in scope(&source_value).iter() {
+ vm.scopes.top.define(var.clone(), value.clone());
+ }
+ }
+ Some(ast::Imports::Items(idents)) => {
+ let mut errors = vec![];
+ let scope = scope(&source_value);
+ for ident in idents {
+ if let Some(value) = scope.get(&ident) {
+ vm.define(ident, value.clone());
+ } else {
+ errors.push(error!(ident.span(), "unresolved import"));
+ }
+ }
+ if !errors.is_empty() {
+ return Err(Box::new(errors));
+ }
+ }
+ }
+
+ Ok(())
+}
+
+impl Eval for ast::ModuleImport {
+ type Output = Value;
+
+ #[tracing::instrument(name = "ModuleImport::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let span = self.source().span();
+ let source = self.source().eval(vm)?;
+ if let Value::Func(func) = source {
+ if func.info().is_none() {
+ bail!(span, "cannot import from user-defined functions");
+ }
+ apply_imports(
+ self.imports(),
+ vm,
+ func,
+ |func| func.info().unwrap().name.into(),
+ |func| &func.info().unwrap().scope,
+ )?;
+ } else {
+ let module = import(vm, source, span, true)?;
+ apply_imports(
+ self.imports(),
+ vm,
+ module,
+ |module| module.name().clone(),
+ |module| module.scope(),
+ )?;
+ }
+
+ Ok(Value::None)
+ }
+}
+
+impl Eval for ast::ModuleInclude {
+ type Output = Content;
+
+ #[tracing::instrument(name = "ModuleInclude::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let span = self.source().span();
+ let source = self.source().eval(vm)?;
+ let module = import(vm, source, span, false)?;
+ Ok(module.content())
+ }
+}
+
+/// Process an import of a module relative to the current location.
+fn import(
+ vm: &mut Vm,
+ source: Value,
+ span: Span,
+ accept_functions: bool,
+) -> SourceResult<Module> {
+ let path = match source {
+ Value::Str(path) => path,
+ Value::Module(module) => return Ok(module),
+ v => {
+ if accept_functions {
+ bail!(span, "expected path, module or function, found {}", v.type_name())
+ } else {
+ bail!(span, "expected path or module, found {}", v.type_name())
+ }
+ }
+ };
+
+ // Handle package and file imports.
+ let path = path.as_str();
+ if path.starts_with('@') {
+ let spec = path.parse::<PackageSpec>().at(span)?;
+ import_package(vm, spec, span)
+ } else {
+ import_file(vm, path, span)
+ }
+}
+
+/// Import an external package.
+fn import_package(vm: &mut Vm, spec: PackageSpec, span: Span) -> SourceResult<Module> {
+ // Evaluate the manifest.
+ let manifest_id = FileId::new(Some(spec.clone()), Path::new("/typst.toml"));
+ let bytes = vm.world().file(manifest_id).at(span)?;
+ let manifest = PackageManifest::parse(&bytes).at(span)?;
+ manifest.validate(&spec).at(span)?;
+
+ // Evaluate the entry point.
+ let entrypoint_id = manifest_id.join(&manifest.package.entrypoint).at(span)?;
+ let source = vm.world().source(entrypoint_id).at(span)?;
+ let point = || Tracepoint::Import;
+ Ok(eval(vm.world(), vm.route, TrackedMut::reborrow_mut(&mut vm.vt.tracer), &source)
+ .trace(vm.world(), point, span)?
+ .with_name(manifest.package.name))
+}
+
+/// Import a file from a path.
+fn import_file(vm: &mut Vm, path: &str, span: Span) -> SourceResult<Module> {
+ // Load the source file.
+ let world = vm.world();
+ let id = vm.location().join(path).at(span)?;
+ let source = world.source(id).at(span)?;
+
+ // Prevent cyclic importing.
+ if vm.route.contains(source.id()) {
+ bail!(span, "cyclic import");
+ }
+
+ // Evaluate the file.
+ let point = || Tracepoint::Import;
+ eval(world, vm.route, TrackedMut::reborrow_mut(&mut vm.vt.tracer), &source)
+ .trace(world, point, span)
+}
+
+impl Eval for ast::LoopBreak {
+ type Output = Value;
+
+ #[tracing::instrument(name = "LoopBreak::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ if vm.flow.is_none() {
+ vm.flow = Some(FlowEvent::Break(self.span()));
+ }
+ Ok(Value::None)
+ }
+}
+
+impl Eval for ast::LoopContinue {
+ type Output = Value;
+
+ #[tracing::instrument(name = "LoopContinue::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ if vm.flow.is_none() {
+ vm.flow = Some(FlowEvent::Continue(self.span()));
+ }
+ Ok(Value::None)
+ }
+}
+
+impl Eval for ast::FuncReturn {
+ type Output = Value;
+
+ #[tracing::instrument(name = "FuncReturn::eval", skip_all)]
+ fn eval(&self, vm: &mut Vm) -> SourceResult<Self::Output> {
+ let value = self.body().map(|body| body.eval(vm)).transpose()?;
+ if vm.flow.is_none() {
+ vm.flow = Some(FlowEvent::Return(self.span(), value));
+ }
+ Ok(Value::None)
+ }
+}
+
+/// Access an expression mutably.
+trait Access {
+ /// Access the value.
+ fn access<'a>(&self, vm: &'a mut Vm) -> SourceResult<&'a mut Value>;
+}
+
+impl Access for ast::Expr {
+ fn access<'a>(&self, vm: &'a mut Vm) -> SourceResult<&'a mut Value> {
+ match self {
+ Self::Ident(v) => v.access(vm),
+ Self::Parenthesized(v) => v.access(vm),
+ Self::FieldAccess(v) => v.access(vm),
+ Self::FuncCall(v) => v.access(vm),
+ _ => {
+ let _ = self.eval(vm)?;
+ bail!(self.span(), "cannot mutate a temporary value");
+ }
+ }
+ }
+}
+
+impl Access for ast::Ident {
+ fn access<'a>(&self, vm: &'a mut Vm) -> SourceResult<&'a mut Value> {
+ let span = self.span();
+ let value = vm.scopes.get_mut(self).at(span)?;
+ if vm.traced == Some(span) {
+ vm.vt.tracer.trace(value.clone());
+ }
+ Ok(value)
+ }
+}
+
+impl Access for ast::Parenthesized {
+ fn access<'a>(&self, vm: &'a mut Vm) -> SourceResult<&'a mut Value> {
+ self.expr().access(vm)
+ }
+}
+
+impl Access for ast::FieldAccess {
+ fn access<'a>(&self, vm: &'a mut Vm) -> SourceResult<&'a mut Value> {
+ self.access_dict(vm)?.at_mut(&self.field().take()).at(self.span())
+ }
+}
+
+impl ast::FieldAccess {
+ fn access_dict<'a>(&self, vm: &'a mut Vm) -> SourceResult<&'a mut Dict> {
+ match self.target().access(vm)? {
+ Value::Dict(dict) => Ok(dict),
+ value => bail!(
+ self.target().span(),
+ "expected dictionary, found {}",
+ value.type_name(),
+ ),
+ }
+ }
+}
+
+impl Access for ast::FuncCall {
+ fn access<'a>(&self, vm: &'a mut Vm) -> SourceResult<&'a mut Value> {
+ if let ast::Expr::FieldAccess(access) = self.callee() {
+ let method = access.field().take();
+ if methods::is_accessor(&method) {
+ let span = self.span();
+ let world = vm.world();
+ let args = self.args().eval(vm)?;
+ let value = access.target().access(vm)?;
+ let result = methods::call_access(value, &method, args, span);
+ let point = || Tracepoint::Call(Some(method.clone()));
+ return result.trace(world, point, span);
+ }
+ }
+
+ let _ = self.eval(vm)?;
+ bail!(self.span(), "cannot mutate a temporary value");
+ }
+}
diff --git a/crates/typst/src/eval/module.rs b/crates/typst/src/eval/module.rs
new file mode 100644
index 00000000..0bc6bf38
--- /dev/null
+++ b/crates/typst/src/eval/module.rs
@@ -0,0 +1,98 @@
+use std::fmt::{self, Debug, Formatter};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+
+use super::{Content, Scope, Value};
+use crate::diag::StrResult;
+
+/// An evaluated module, ready for importing or typesetting.
+///
+/// Values of this type are cheap to clone and hash.
+#[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(Clone, Hash)]
+struct Repr {
+ /// The top-level definitions that were bound in this module.
+ scope: Scope,
+ /// The module's layoutable contents.
+ content: Content,
+}
+
+impl Module {
+ /// Create a new module.
+ pub fn new(name: impl Into<EcoString>) -> Self {
+ Self {
+ name: name.into(),
+ inner: Arc::new(Repr { scope: Scope::new(), content: Content::empty() }),
+ }
+ }
+
+ /// 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
+ }
+
+ /// 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 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 get(&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 {
+ write!(f, "<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/src/eval/none.rs b/crates/typst/src/eval/none.rs
new file mode 100644
index 00000000..ab7644a7
--- /dev/null
+++ b/crates/typst/src/eval/none.rs
@@ -0,0 +1,74 @@
+use std::fmt::{self, Debug, Formatter};
+
+use super::{cast, CastInfo, FromValue, IntoValue, Reflect, Value};
+use crate::diag::StrResult;
+
+/// A value that indicates the absence of any other value.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct NoneValue;
+
+impl Reflect for NoneValue {
+ fn describe() -> CastInfo {
+ CastInfo::Type("none")
+ }
+
+ 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) -> StrResult<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")
+ }
+}
+
+cast! {
+ (),
+ self => Value::None,
+ _: NoneValue => (),
+}
+
+impl<T: Reflect> Reflect for Option<T> {
+ fn describe() -> CastInfo {
+ T::describe() + NoneValue::describe()
+ }
+
+ 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) -> StrResult<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/src/eval/ops.rs b/crates/typst/src/eval/ops.rs
new file mode 100644
index 00000000..0880a87e
--- /dev/null
+++ b/crates/typst/src/eval/ops.rs
@@ -0,0 +1,429 @@
+//! Operations on values.
+
+use std::cmp::Ordering;
+use std::fmt::Debug;
+
+use ecow::eco_format;
+
+use super::{format_str, Regex, Value};
+use crate::diag::{bail, StrResult};
+use crate::geom::{Axes, Axis, GenAlign, Length, Numeric, PartialStroke, Rel, Smart};
+use Value::*;
+
+/// Bail with a type mismatch error.
+macro_rules! mismatch {
+ ($fmt:expr, $($value:expr),* $(,)?) => {
+ return Err(eco_format!($fmt, $($value.type_name()),*))
+ };
+}
+
+/// Join a value with another value.
+pub fn join(lhs: Value, rhs: Value) -> StrResult<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}")),
+ (Content(a), Content(b)) => Content(a + b),
+ (Content(a), Symbol(b)) => Content(a + item!(text)(b.get().into())),
+ (Content(a), Str(b)) => Content(a + item!(text)(b.into())),
+ (Str(a), Content(b)) => Content(item!(text)(a.into()) + b),
+ (Symbol(a), Content(b)) => Content(item!(text)(a.get().into()) + b),
+ (Array(a), Array(b)) => Array(a + b),
+ (Dict(a), Dict(b)) => Dict(a + b),
+ (a, b) => mismatch!("cannot join {} with {}", a, b),
+ })
+}
+
+/// Apply the unary plus operator to a value.
+pub fn pos(value: Value) -> StrResult<Value> {
+ Ok(match value {
+ Int(v) => Int(v),
+ Float(v) => Float(v),
+ Length(v) => Length(v),
+ Angle(v) => Angle(v),
+ Ratio(v) => Ratio(v),
+ Relative(v) => Relative(v),
+ Fraction(v) => Fraction(v),
+ v => mismatch!("cannot apply '+' to {}", v),
+ })
+}
+
+/// Compute the negation of a value.
+pub fn neg(value: Value) -> StrResult<Value> {
+ Ok(match value {
+ Int(v) => Int(v.checked_neg().ok_or("value is too large")?),
+ Float(v) => Float(-v),
+ Length(v) => Length(-v),
+ Angle(v) => Angle(-v),
+ Ratio(v) => Ratio(-v),
+ Relative(v) => Relative(-v),
+ Fraction(v) => Fraction(-v),
+ v => mismatch!("cannot apply '-' to {}", v),
+ })
+}
+
+/// Compute the sum of two values.
+pub fn add(lhs: Value, rhs: Value) -> StrResult<Value> {
+ Ok(match (lhs, rhs) {
+ (a, None) => a,
+ (None, b) => b,
+
+ (Int(a), Int(b)) => Int(a.checked_add(b).ok_or("value is 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),
+
+ (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}")),
+ (Content(a), Content(b)) => Content(a + b),
+ (Content(a), Symbol(b)) => Content(a + item!(text)(b.get().into())),
+ (Content(a), Str(b)) => Content(a + item!(text)(b.into())),
+ (Str(a), Content(b)) => Content(item!(text)(a.into()) + b),
+ (Symbol(a), Content(b)) => Content(item!(text)(a.get().into()) + b),
+
+ (Array(a), Array(b)) => Array(a + b),
+ (Dict(a), Dict(b)) => Dict(a + b),
+
+ (Color(color), Length(thickness)) | (Length(thickness), Color(color)) => {
+ Value::dynamic(PartialStroke {
+ paint: Smart::Custom(color.into()),
+ thickness: Smart::Custom(thickness),
+ ..PartialStroke::default()
+ })
+ }
+
+ (Dyn(a), Dyn(b)) => {
+ // 1D alignments can be summed into 2D alignments.
+ if let (Some(&a), Some(&b)) =
+ (a.downcast::<GenAlign>(), b.downcast::<GenAlign>())
+ {
+ if a.axis() == b.axis() {
+ return Err(eco_format!("cannot add two {:?} alignments", a.axis()));
+ }
+
+ return Ok(Value::dynamic(match a.axis() {
+ Axis::X => Axes { x: a, y: b },
+ Axis::Y => Axes { x: b, y: a },
+ }));
+ };
+
+ 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) -> StrResult<Value> {
+ Ok(match (lhs, rhs) {
+ (Int(a), Int(b)) => Int(a.checked_sub(b).ok_or("value is 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),
+
+ (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),
+
+ (a, b) => mismatch!("cannot subtract {1} from {0}", a, b),
+ })
+}
+
+/// Compute the product of two values.
+pub fn mul(lhs: Value, rhs: Value) -> StrResult<Value> {
+ Ok(match (lhs, rhs) {
+ (Int(a), Int(b)) => Int(a.checked_mul(b).ok_or("value is 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),
+
+ (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(b)?),
+ (Int(a), Str(b)) => Str(b.repeat(a)?),
+ (Array(a), Int(b)) => Array(a.repeat(b)?),
+ (Int(a), Array(b)) => Array(b.repeat(a)?),
+ (Content(a), b @ Int(_)) => Content(a.repeat(b.cast()?)),
+ (a @ Int(_), Content(b)) => Content(b.repeat(a.cast()?)),
+
+ (a, b) => mismatch!("cannot multiply {} with {}", a, b),
+ })
+}
+
+/// Compute the quotient of two values.
+pub fn div(lhs: Value, rhs: Value) -> StrResult<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),
+
+ (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),
+
+ (a, b) => mismatch!("cannot divide {} by {}", a, b),
+ })
+}
+
+/// Whether a value is a numeric zero.
+fn is_zero(v: &Value) -> bool {
+ match *v {
+ Int(v) => v == 0,
+ Float(v) => v == 0.0,
+ 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(),
+ _ => 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) -> StrResult<Value> {
+ match value {
+ Bool(b) => Ok(Bool(!b)),
+ v => mismatch!("cannot apply 'not' to {}", v),
+ }
+}
+
+/// Compute the logical "and" of two values.
+pub fn and(lhs: Value, rhs: Value) -> StrResult<Value> {
+ match (lhs, rhs) {
+ (Bool(a), Bool(b)) => Ok(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) -> StrResult<Value> {
+ match (lhs, rhs) {
+ (Bool(a), Bool(b)) => Ok(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) -> StrResult<Value> {
+ Ok(Bool(equal(&lhs, &rhs)))
+}
+
+/// Compute whether two values are unequal.
+pub fn neq(lhs: Value, rhs: Value) -> StrResult<Value> {
+ Ok(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) -> StrResult<Value> {
+ let ordering = compare(&lhs, &rhs)?;
+ Ok(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 {
+ 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,
+ (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,
+ (Str(a), Str(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,
+ (Module(a), Module(b)) => a == b,
+ (Dyn(a), Dyn(b)) => a == b,
+
+ // Some technically different things should compare equal.
+ (&Int(a), &Float(b)) => a as f64 == b,
+ (&Float(a), &Int(b)) => a == b as f64,
+ (&Length(a), &Relative(b)) => a == b.abs && b.rel.is_zero(),
+ (&Ratio(a), &Relative(b)) => a == b.rel && b.abs.is_zero(),
+ (&Relative(a), &Length(b)) => a.abs == b && a.rel.is_zero(),
+ (&Relative(a), &Ratio(b)) => a.rel == b && a.abs.is_zero(),
+
+ _ => false,
+ }
+}
+
+/// Compare two values.
+pub fn compare(lhs: &Value, rhs: &Value) -> StrResult<Ordering> {
+ 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)?,
+ (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),
+ (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))?,
+ (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),
+
+ _ => mismatch!("cannot compare {} and {}", lhs, rhs),
+ })
+}
+
+/// Try to compare two values.
+fn try_cmp_values<T: PartialOrd + Debug>(a: &T, b: &T) -> StrResult<Ordering> {
+ a.partial_cmp(b)
+ .ok_or_else(|| eco_format!("cannot compare {:?} with {:?}", a, b))
+}
+
+/// Test whether one value is "in" another one.
+pub fn in_(lhs: Value, rhs: Value) -> StrResult<Value> {
+ if let Some(b) = contains(&lhs, &rhs) {
+ Ok(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) -> StrResult<Value> {
+ if let Some(b) = contains(&lhs, &rhs) {
+ Ok(Bool(!b))
+ } else {
+ mismatch!("cannot apply 'not in' to {} and {}", lhs, rhs)
+ }
+}
+
+/// Test for containment.
+pub fn contains(lhs: &Value, rhs: &Value) -> Option<bool> {
+ 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)),
+ _ => Option::None,
+ }
+}
diff --git a/crates/typst/src/eval/scope.rs b/crates/typst/src/eval/scope.rs
new file mode 100644
index 00000000..f3e13715
--- /dev/null
+++ b/crates/typst/src/eval/scope.rs
@@ -0,0 +1,178 @@
+use std::collections::BTreeMap;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+
+use ecow::{eco_format, EcoString};
+
+use super::{IntoValue, Library, Value};
+use crate::diag::{bail, StrResult};
+
+/// 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) -> StrResult<&Value> {
+ std::iter::once(&self.top)
+ .chain(self.scopes.iter().rev())
+ .chain(self.base.map(|base| base.global.scope()))
+ .find_map(|scope| scope.get(var))
+ .ok_or_else(|| unknown_variable(var))
+ }
+
+ /// Try to access a variable immutably in math.
+ pub fn get_in_math(&self, var: &str) -> StrResult<&Value> {
+ std::iter::once(&self.top)
+ .chain(self.scopes.iter().rev())
+ .chain(self.base.map(|base| base.math.scope()))
+ .find_map(|scope| scope.get(var))
+ .ok_or_else(|| eco_format!("unknown variable: {}", var))
+ }
+
+ /// Try to access a variable mutably.
+ pub fn get_mut(&mut self, var: &str) -> StrResult<&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(_) => eco_format!("cannot mutate a constant: {}", var),
+ _ => unknown_variable(var),
+ }
+ })?
+ }
+}
+
+/// The error message when a variable is not found.
+#[cold]
+fn unknown_variable(var: &str) -> EcoString {
+ if var.contains('-') {
+ eco_format!("unknown variable: {} - if you meant to use subtraction, try adding spaces around the minus sign.", var)
+ } else {
+ eco_format!("unknown variable: {}", var)
+ }
+}
+
+/// A map from binding names to values.
+#[derive(Default, Clone, Hash)]
+pub struct Scope(BTreeMap<EcoString, Slot>, bool);
+
+impl Scope {
+ /// Create a new empty scope.
+ pub fn new() -> Self {
+ Self(BTreeMap::new(), false)
+ }
+
+ /// Create a new scope with duplication prevention.
+ pub fn deduplicating() -> Self {
+ Self(BTreeMap::new(), true)
+ }
+
+ /// Bind a value to a name.
+ #[track_caller]
+ pub fn define(&mut self, name: impl Into<EcoString>, value: impl IntoValue) {
+ let name = name.into();
+
+ #[cfg(debug_assertions)]
+ if self.1 && self.0.contains_key(&name) {
+ panic!("duplicate definition: {name}");
+ }
+
+ self.0.insert(name, Slot::new(value.into_value(), Kind::Normal));
+ }
+
+ /// Define a captured, immutable binding.
+ pub fn define_captured(&mut self, var: impl Into<EcoString>, value: impl IntoValue) {
+ self.0
+ .insert(var.into(), Slot::new(value.into_value(), Kind::Captured));
+ }
+
+ /// Try to access a variable immutably.
+ pub fn get(&self, var: &str) -> Option<&Value> {
+ self.0.get(var).map(Slot::read)
+ }
+
+ /// Try to access a variable mutably.
+ pub fn get_mut(&mut self, var: &str) -> Option<StrResult<&mut Value>> {
+ self.0.get_mut(var).map(Slot::write)
+ }
+
+ /// Iterate over all definitions.
+ pub fn iter(&self) -> impl Iterator<Item = (&EcoString, &Value)> {
+ self.0.iter().map(|(k, v)| (k, v.read()))
+ }
+}
+
+impl Debug for Scope {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Scope ")?;
+ f.debug_map()
+ .entries(self.0.iter().map(|(k, v)| (k, v.read())))
+ .finish()
+ }
+}
+
+/// 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,
+}
+
+/// 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,
+}
+
+impl Slot {
+ /// Create a new slot.
+ fn new(value: Value, kind: Kind) -> Self {
+ Self { value, kind }
+ }
+
+ /// 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 => {
+ bail!("variables from outside the function are read-only and cannot be modified")
+ }
+ }
+ }
+}
diff --git a/crates/typst/src/eval/str.rs b/crates/typst/src/eval/str.rs
new file mode 100644
index 00000000..f5e5ab00
--- /dev/null
+++ b/crates/typst/src/eval/str.rs
@@ -0,0 +1,620 @@
+use std::borrow::{Borrow, Cow};
+use std::fmt::{self, Debug, Display, Formatter, Write};
+use std::hash::{Hash, Hasher};
+use std::ops::{Add, AddAssign, Deref, Range};
+
+use ecow::EcoString;
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::{cast, dict, Args, Array, Dict, Func, IntoValue, Value, Vm};
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::geom::GenAlign;
+
+/// Create a new [`Str`] from a format string.
+#[macro_export]
+#[doc(hidden)]
+macro_rules! __format_str {
+ ($($tts:tt)*) => {{
+ $crate::eval::Str::from($crate::eval::eco_format!($($tts)*))
+ }};
+}
+
+#[doc(inline)]
+pub use crate::__format_str as format_str;
+#[doc(hidden)]
+pub use ecow::eco_format;
+
+/// An immutable reference counted string.
+#[derive(Default, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+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.len() == 0
+ }
+
+ /// The length of the string in bytes.
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// A string slice containing the entire string.
+ pub fn as_str(&self) -> &str {
+ self
+ }
+
+ /// Extract the first grapheme cluster.
+ pub fn first(&self) -> StrResult<Self> {
+ self.0
+ .graphemes(true)
+ .next()
+ .map(Into::into)
+ .ok_or_else(string_is_empty)
+ }
+
+ /// Extract the last grapheme cluster.
+ pub fn last(&self) -> StrResult<Self> {
+ self.0
+ .graphemes(true)
+ .next_back()
+ .map(Into::into)
+ .ok_or_else(string_is_empty)
+ }
+
+ /// Extract the grapheme cluster at the given index.
+ pub fn at<'a>(&'a self, index: i64, default: Option<&'a str>) -> StrResult<Self> {
+ let len = self.len();
+ let grapheme = self
+ .locate_opt(index)?
+ .and_then(|i| self.0[i..].graphemes(true).next())
+ .or(default)
+ .ok_or_else(|| no_default_and_out_of_bounds(index, len))?;
+ Ok(grapheme.into())
+ }
+
+ /// Extract a contiguous substring.
+ pub fn slice(&self, start: i64, end: Option<i64>) -> StrResult<Self> {
+ let start = self.locate(start)?;
+ let end = self.locate(end.unwrap_or(self.len() as i64))?.max(start);
+ Ok(self.0[start..end].into())
+ }
+
+ /// The grapheme clusters the string consists of.
+ pub fn clusters(&self) -> Array {
+ self.as_str().graphemes(true).map(|s| Value::Str(s.into())).collect()
+ }
+
+ /// The codepoints the string consists of.
+ pub fn codepoints(&self) -> Array {
+ self.chars().map(|c| Value::Str(c.into())).collect()
+ }
+
+ /// Whether the given pattern exists in this string.
+ pub fn contains(&self, pattern: StrPattern) -> bool {
+ match pattern {
+ StrPattern::Str(pat) => self.0.contains(pat.as_str()),
+ StrPattern::Regex(re) => re.is_match(self),
+ }
+ }
+
+ /// Whether this string begins with the given pattern.
+ pub fn starts_with(&self, pattern: StrPattern) -> bool {
+ match pattern {
+ StrPattern::Str(pat) => self.0.starts_with(pat.as_str()),
+ StrPattern::Regex(re) => re.find(self).map_or(false, |m| m.start() == 0),
+ }
+ }
+
+ /// Whether this string ends with the given pattern.
+ pub fn ends_with(&self, pattern: StrPattern) -> bool {
+ match pattern {
+ StrPattern::Str(pat) => self.0.ends_with(pat.as_str()),
+ StrPattern::Regex(re) => {
+ re.find_iter(self).last().map_or(false, |m| m.end() == self.0.len())
+ }
+ }
+ }
+
+ /// The text of the pattern's first match in this string.
+ pub fn find(&self, pattern: StrPattern) -> Option<Self> {
+ 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()),
+ }
+ }
+
+ /// The position of the pattern's first match in this string.
+ pub fn position(&self, pattern: StrPattern) -> Option<i64> {
+ match pattern {
+ StrPattern::Str(pat) => self.0.find(pat.as_str()).map(|i| i as i64),
+ StrPattern::Regex(re) => re.find(self).map(|m| m.start() as i64),
+ }
+ }
+
+ /// The start and, text and capture groups (if any) of the first match of
+ /// the pattern in this string.
+ pub fn match_(&self, 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),
+ }
+ }
+
+ /// The start, end, text and capture groups (if any) of all matches of the
+ /// pattern in this string.
+ pub fn matches(&self, 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(),
+ }
+ }
+
+ /// Split this string at whitespace or a specific pattern.
+ pub fn split(&self, 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()
+ }
+ }
+ }
+
+ /// Trim either whitespace or the given pattern at both or just one side of
+ /// the string. If `repeat` is true, the pattern is trimmed repeatedly
+ /// instead of just once. Repeat must only be given in combination with a
+ /// pattern.
+ pub fn trim(
+ &self,
+ pattern: Option<StrPattern>,
+ at: Option<StrSide>,
+ repeat: bool,
+ ) -> Self {
+ 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 = 0;
+ 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 == m.start();
+
+ // As long as we're consecutive and still trimming at the
+ // start, trim.
+ start &= 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 = m.end();
+ }
+
+ // Is the last match directly at the end?
+ if last < s.len() {
+ range.end = s.len();
+ }
+
+ &s[range.start..range.start.max(range.end)]
+ }
+ };
+
+ trimmed.into()
+ }
+
+ /// 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.
+ pub fn replace(
+ &self,
+ vm: &mut Vm,
+ pattern: StrPattern,
+ with: Replacement,
+ count: Option<usize>,
+ ) -> SourceResult<Self> {
+ // 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 &with {
+ Replacement::Str(s) => output.push_str(s),
+ Replacement::Func(func) => {
+ let args = Args::new(func.span(), [dict]);
+ let piece = func.call_vm(vm, args)?.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())
+ }
+
+ /// Repeat the string a number of times.
+ pub fn repeat(&self, n: i64) -> StrResult<Self> {
+ let n = usize::try_from(n)
+ .ok()
+ .and_then(|n| self.0.len().checked_mul(n).map(|_| n))
+ .ok_or_else(|| format!("cannot repeat this string {} times", n))?;
+
+ Ok(Self(self.0.repeat(n)))
+ }
+
+ /// Resolve an index, if it is within bounds.
+ /// Errors on invalid char boundaries.
+ 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.map_or(false, |i| !self.0.is_char_boundary(i)) {
+ return Err(not_a_char_boundary(index));
+ }
+
+ Ok(resolved)
+ }
+
+ /// 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()))
+ }
+}
+
+/// 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()
+}
+
+/// 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>(),
+ }
+}
+
+impl Deref for Str {
+ type Target = str;
+
+ fn deref(&self) -> &str {
+ &self.0
+ }
+}
+
+impl Display for Str {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(self)
+ }
+}
+
+impl Debug for Str {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_char('"')?;
+ for c in self.chars() {
+ match c {
+ '\0' => f.write_str("\\u{0}")?,
+ '\'' => f.write_str("'")?,
+ '"' => f.write_str(r#"\""#)?,
+ _ => Display::fmt(&c.escape_debug(), f)?,
+ }
+ }
+ f.write_char('"')
+ }
+}
+
+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! {
+ String,
+ self => Value::Str(self.into()),
+ v: Str => v.into(),
+}
+
+/// A regular expression.
+#[derive(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}"))
+ }
+}
+
+impl Deref for Regex {
+ type Target = regex::Regex;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl Debug for Regex {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "regex({:?})", self.0.as_str())
+ }
+}
+
+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);
+ }
+}
+
+cast! {
+ type Regex: "regular expression",
+}
+
+/// 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,
+ text: Str => Self::Str(text),
+ regex: Regex => Self::Regex(regex),
+}
+
+/// 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,
+ align: GenAlign => match align {
+ GenAlign::Start => Self::Start,
+ GenAlign::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,
+ text: Str => Self::Str(text),
+ func: Func => Self::Func(func)
+}
diff --git a/crates/typst/src/eval/symbol.rs b/crates/typst/src/eval/symbol.rs
new file mode 100644
index 00000000..0925202e
--- /dev/null
+++ b/crates/typst/src/eval/symbol.rs
@@ -0,0 +1,210 @@
+use std::cmp::Reverse;
+use std::collections::BTreeSet;
+use std::fmt::{self, Debug, Display, Formatter, Write};
+use std::sync::Arc;
+
+use ecow::EcoString;
+
+use crate::diag::{bail, StrResult};
+
+/// A symbol, possibly with variants.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub struct Symbol(Repr);
+
+/// The internal representation.
+#[derive(Clone, Eq, PartialEq, Hash)]
+enum Repr {
+ Single(char),
+ Const(&'static [(&'static str, char)]),
+ Multi(Arc<(List, EcoString)>),
+}
+
+/// A collection of symbols.
+#[derive(Clone, Eq, PartialEq, Hash)]
+enum List {
+ Static(&'static [(&'static str, char)]),
+ Runtime(Box<[(EcoString, char)]>),
+}
+
+impl Symbol {
+ /// Create a new symbol from a single character.
+ pub const fn new(c: char) -> Self {
+ Self(Repr::Single(c))
+ }
+
+ /// Create a symbol with a static variant list.
+ #[track_caller]
+ pub const fn list(list: &'static [(&'static str, char)]) -> 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, char)]>) -> Self {
+ debug_assert!(!list.is_empty());
+ Self(Repr::Multi(Arc::new((List::Runtime(list), EcoString::new()))))
+ }
+
+ /// Get the symbol's text.
+ pub fn get(&self) -> char {
+ match &self.0 {
+ Repr::Single(c) => *c,
+ Repr::Const(_) => find(self.variants(), "").unwrap(),
+ Repr::Multi(arc) => find(self.variants(), &arc.1).unwrap(),
+ }
+ }
+
+ /// 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, char)> {
+ 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()
+ }
+
+ /// Normalize an accent to a combining one.
+ pub fn combining_accent(c: char) -> Option<char> {
+ Some(match c {
+ '\u{0300}' | '`' => '\u{0300}',
+ '\u{0301}' | '´' => '\u{0301}',
+ '\u{0302}' | '^' | 'ˆ' => '\u{0302}',
+ '\u{0303}' | '~' | '∼' | '˜' => '\u{0303}',
+ '\u{0304}' | '¯' => '\u{0304}',
+ '\u{0305}' | '-' | '‾' | '−' => '\u{0305}',
+ '\u{0306}' | '˘' => '\u{0306}',
+ '\u{0307}' | '.' | '˙' | '⋅' => '\u{0307}',
+ '\u{0308}' | '¨' => '\u{0308}',
+ '\u{20db}' => '\u{20db}',
+ '\u{20dc}' => '\u{20dc}',
+ '\u{030a}' | '∘' | '○' => '\u{030a}',
+ '\u{030b}' | '˝' => '\u{030b}',
+ '\u{030c}' | 'ˇ' => '\u{030c}',
+ '\u{20d6}' | '←' => '\u{20d6}',
+ '\u{20d7}' | '→' | '⟶' => '\u{20d7}',
+ _ => return None,
+ })
+ }
+}
+
+impl Debug for Symbol {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_char(self.get())
+ }
+}
+
+impl Display for Symbol {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_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()),
+ }
+ }
+}
+
+/// Iterator over variants.
+enum Variants<'a> {
+ Single(std::option::IntoIter<char>),
+ Static(std::slice::Iter<'static, (&'static str, char)>),
+ Runtime(std::slice::Iter<'a, (EcoString, char)>),
+}
+
+impl<'a> Iterator for Variants<'a> {
+ type Item = (&'a str, char);
+
+ 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, char)>,
+ modifiers: &str,
+) -> Option<char> {
+ 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/src/eval/value.rs b/crates/typst/src/eval/value.rs
new file mode 100644
index 00000000..b1782cab
--- /dev/null
+++ b/crates/typst/src/eval/value.rs
@@ -0,0 +1,461 @@
+use std::any::Any;
+use std::cmp::Ordering;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+use ecow::eco_format;
+use siphasher::sip128::{Hasher128, SipHasher13};
+
+use super::{
+ cast, format_str, ops, Args, Array, CastInfo, Content, Dict, FromValue, Func,
+ IntoValue, Module, Reflect, Str, Symbol,
+};
+use crate::diag::StrResult;
+use crate::geom::{Abs, Angle, Color, Em, Fr, Length, Ratio, Rel};
+use crate::model::{Label, Styles};
+use crate::syntax::{ast, Span};
+use crate::util::Bytes;
+
+/// 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 symbol: `arrow.l`.
+ Symbol(Symbol),
+ /// A string: `"string"`.
+ Str(Str),
+ /// Raw bytes.
+ Bytes(Bytes),
+ /// A label: `<intro>`.
+ Label(Label),
+ /// 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 module.
+ Module(Module),
+ /// A dynamic value.
+ Dyn(Dynamic),
+}
+
+impl Value {
+ /// Create a new dynamic value.
+ pub fn dynamic<T>(any: T) -> Self
+ where
+ T: Type + Debug + 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::Length(unit) => Abs::with_unit(v, unit).into_value(),
+ ast::Unit::Angle(unit) => Angle::with_unit(v, unit).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 name of the stored value's type.
+ pub fn type_name(&self) -> &'static str {
+ match self {
+ Self::None => "none",
+ Self::Auto => "auto",
+ Self::Bool(_) => bool::TYPE_NAME,
+ Self::Int(_) => i64::TYPE_NAME,
+ Self::Float(_) => f64::TYPE_NAME,
+ Self::Length(_) => Length::TYPE_NAME,
+ Self::Angle(_) => Angle::TYPE_NAME,
+ Self::Ratio(_) => Ratio::TYPE_NAME,
+ Self::Relative(_) => Rel::<Length>::TYPE_NAME,
+ Self::Fraction(_) => Fr::TYPE_NAME,
+ Self::Color(_) => Color::TYPE_NAME,
+ Self::Symbol(_) => Symbol::TYPE_NAME,
+ Self::Str(_) => Str::TYPE_NAME,
+ Self::Bytes(_) => Bytes::TYPE_NAME,
+ Self::Label(_) => Label::TYPE_NAME,
+ Self::Content(_) => Content::TYPE_NAME,
+ Self::Styles(_) => Styles::TYPE_NAME,
+ Self::Array(_) => Array::TYPE_NAME,
+ Self::Dict(_) => Dict::TYPE_NAME,
+ Self::Func(_) => Func::TYPE_NAME,
+ Self::Args(_) => Args::TYPE_NAME,
+ Self::Module(_) => Module::TYPE_NAME,
+ Self::Dyn(v) => v.type_name(),
+ }
+ }
+
+ /// Try to cast the value into a specific type.
+ pub fn cast<T: FromValue>(self) -> StrResult<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::Dict(dict) => dict.at(field, None).cloned(),
+ Self::Content(content) => content.at(field, None),
+ Self::Module(module) => module.get(field).cloned(),
+ Self::Func(func) => func.get(field).cloned(),
+ v => Err(eco_format!("cannot access fields on type {}", v.type_name())),
+ }
+ }
+
+ /// Return the debug representation of the value.
+ pub fn repr(&self) -> Str {
+ format_str!("{:?}", self)
+ }
+
+ /// 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,
+ }
+ }
+
+ /// Return the display representation of the value.
+ pub fn display(self) -> Content {
+ match self {
+ Self::None => Content::empty(),
+ Self::Int(v) => item!(text)(eco_format!("{}", v)),
+ Self::Float(v) => item!(text)(eco_format!("{}", v)),
+ Self::Str(v) => item!(text)(v.into()),
+ Self::Symbol(v) => item!(text)(v.get().into()),
+ Self::Content(v) => v,
+ Self::Func(_) => Content::empty(),
+ Self::Module(module) => module.content(),
+ _ => item!(raw)(self.repr().into(), Some("typc".into()), false),
+ }
+ }
+
+ /// Try to extract documentation for the value.
+ pub fn docs(&self) -> Option<&'static str> {
+ match self {
+ Self::Func(func) => func.info().map(|info| info.docs),
+ _ => None,
+ }
+ }
+}
+
+impl Debug for Value {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::None => f.pad("none"),
+ Self::Auto => f.pad("auto"),
+ 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::Symbol(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::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::Module(v) => Debug::fmt(v, f),
+ Self::Dyn(v) => Debug::fmt(v, f),
+ }
+ }
+}
+
+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::Symbol(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::Array(v) => v.hash(state),
+ Self::Dict(v) => v.hash(state),
+ Self::Func(v) => v.hash(state),
+ Self::Args(v) => v.hash(state),
+ Self::Module(v) => v.hash(state),
+ Self::Dyn(v) => v.hash(state),
+ }
+ }
+}
+
+/// A dynamic value.
+#[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: Type + Debug + PartialEq + Hash + Sync + Send + 'static,
+ {
+ Self(Arc::new(any))
+ }
+
+ /// Whether the wrapped type is `T`.
+ pub fn is<T: Type + 'static>(&self) -> bool {
+ (*self.0).as_any().is::<T>()
+ }
+
+ /// Try to downcast to a reference to a specific type.
+ pub fn downcast<T: Type + 'static>(&self) -> Option<&T> {
+ (*self.0).as_any().downcast_ref()
+ }
+
+ /// The name of the stored value's type.
+ pub fn type_name(&self) -> &'static str {
+ self.0.dyn_type_name()
+ }
+}
+
+impl Debug for Dynamic {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ Debug::fmt(&self.0, f)
+ }
+}
+
+impl PartialEq for Dynamic {
+ fn eq(&self, other: &Self) -> bool {
+ self.0.dyn_eq(other)
+ }
+}
+
+cast! {
+ Dynamic,
+ self => Value::Dyn(self),
+}
+
+trait Bounds: Debug + Sync + Send + 'static {
+ fn as_any(&self) -> &dyn Any;
+ fn dyn_eq(&self, other: &Dynamic) -> bool;
+ fn dyn_type_name(&self) -> &'static str;
+ fn hash128(&self) -> u128;
+}
+
+impl<T> Bounds for T
+where
+ T: Type + Debug + 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_type_name(&self) -> &'static str {
+ T::TYPE_NAME
+ }
+
+ #[tracing::instrument(skip_all)]
+ fn hash128(&self) -> u128 {
+ // Also hash the TypeId since values with different types but
+ // equal data should be different.
+ let mut state = SipHasher13::new();
+ self.type_id().hash(&mut state);
+ self.hash(&mut state);
+ state.finish128().as_u128()
+ }
+}
+
+impl Hash for dyn Bounds {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ state.write_u128(self.hash128());
+ }
+}
+
+/// The type of a value.
+pub trait Type {
+ /// The name of the type.
+ const TYPE_NAME: &'static str;
+}
+
+/// Implement traits for primitives.
+macro_rules! primitive {
+ (
+ $ty:ty: $name:literal, $variant:ident
+ $(, $other:ident$(($binding:ident))? => $out:expr)*
+ ) => {
+ impl Type for $ty {
+ const TYPE_NAME: &'static str = $name;
+ }
+
+ impl Reflect for $ty {
+ fn describe() -> CastInfo {
+ CastInfo::Type(Self::TYPE_NAME)
+ }
+
+ 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) -> StrResult<Self> {
+ match value {
+ Value::$variant(v) => Ok(v),
+ $(Value::$other$(($binding))? => Ok($out),)*
+ v => Err(eco_format!(
+ "expected {}, found {}",
+ Self::TYPE_NAME,
+ v.type_name(),
+ )),
+ }
+ }
+ }
+ };
+
+ (@$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! { Symbol: "symbol", Symbol }
+primitive! {
+ Str: "string",
+ Str,
+ Symbol(symbol) => symbol.get().into()
+}
+primitive! { Bytes: "bytes", Bytes }
+primitive! { Label: "label", Label }
+primitive! { Content: "content",
+ Content,
+ None => Content::empty(),
+ Symbol(v) => item!(text)(v.get().into()),
+ Str(v) => item!(text)(v.into())
+}
+primitive! { Styles: "styles", Styles }
+primitive! { Array: "array", Array }
+primitive! { Dict: "dictionary", Dict }
+primitive! { Func: "function", Func }
+primitive! { Args: "arguments", Args }
+primitive! { Module: "module", Module }
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::eval::{array, dict};
+ use crate::geom::RgbaColor;
+
+ #[track_caller]
+ fn test(value: impl IntoValue, exp: &str) {
+ assert_eq!(format!("{:?}", value.into_value()), exp);
+ }
+
+ #[test]
+ fn test_value_debug() {
+ // Primitives.
+ test(Value::None, "none");
+ 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");
+ test(Color::Rgba(RgbaColor::new(1, 1, 1, 0xff)), "rgb(\"#010101\")");
+
+ // 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/src/export/mod.rs b/crates/typst/src/export/mod.rs
new file mode 100644
index 00000000..eb0731a9
--- /dev/null
+++ b/crates/typst/src/export/mod.rs
@@ -0,0 +1,7 @@
+//! Exporting into external formats.
+
+mod pdf;
+mod render;
+
+pub use self::pdf::pdf;
+pub use self::render::render;
diff --git a/crates/typst/src/export/pdf/font.rs b/crates/typst/src/export/pdf/font.rs
new file mode 100644
index 00000000..f0676d8f
--- /dev/null
+++ b/crates/typst/src/export/pdf/font.rs
@@ -0,0 +1,204 @@
+use std::collections::BTreeMap;
+
+use ecow::{eco_format, EcoString};
+use pdf_writer::types::{CidFontType, FontFlags, SystemInfo, UnicodeCmap};
+use pdf_writer::{Filter, Finish, Name, Rect, Str};
+use ttf_parser::{name_id, GlyphId, Tag};
+use unicode_general_category::GeneralCategory;
+
+use super::{deflate, EmExt, PdfContext, RefExt};
+use crate::font::Font;
+use crate::util::{Bytes, SliceExt};
+
+const CMAP_NAME: Name = Name(b"Custom");
+const SYSTEM_INFO: SystemInfo = SystemInfo {
+ registry: Str(b"Adobe"),
+ ordering: Str(b"Identity"),
+ supplement: 0,
+};
+
+/// Embed all used fonts into the PDF.
+#[tracing::instrument(skip_all)]
+pub fn write_fonts(ctx: &mut PdfContext) {
+ for font in ctx.font_map.items() {
+ let type0_ref = ctx.alloc.bump();
+ let cid_ref = ctx.alloc.bump();
+ let descriptor_ref = ctx.alloc.bump();
+ let cmap_ref = ctx.alloc.bump();
+ let data_ref = ctx.alloc.bump();
+ ctx.font_refs.push(type0_ref);
+
+ let glyph_set = ctx.glyph_sets.get_mut(font).unwrap();
+ let metrics = font.metrics();
+ let ttf = font.ttf();
+
+ let postscript_name = font
+ .find_name(name_id::POST_SCRIPT_NAME)
+ .unwrap_or_else(|| "unknown".to_string());
+
+ let base_font = eco_format!("ABCDEF+{}", postscript_name);
+ let base_font = Name(base_font.as_bytes());
+
+ // Write the base font object referencing the CID font.
+ ctx.writer
+ .type0_font(type0_ref)
+ .base_font(base_font)
+ .encoding_predefined(Name(b"Identity-H"))
+ .descendant_font(cid_ref)
+ .to_unicode(cmap_ref);
+
+ // Check for the presence of CFF outlines to select the correct
+ // CID-Font subtype.
+ let subtype = match ttf
+ .raw_face()
+ .table(Tag::from_bytes(b"CFF "))
+ .or(ttf.raw_face().table(Tag::from_bytes(b"CFF2")))
+ {
+ Some(_) => CidFontType::Type0,
+ None => CidFontType::Type2,
+ };
+
+ // Write the CID font referencing the font descriptor.
+ let mut cid = ctx.writer.cid_font(cid_ref);
+ cid.subtype(subtype);
+ cid.base_font(base_font);
+ cid.system_info(SYSTEM_INFO);
+ cid.font_descriptor(descriptor_ref);
+ cid.default_width(0.0);
+
+ if subtype == CidFontType::Type2 {
+ cid.cid_to_gid_map_predefined(Name(b"Identity"));
+ }
+
+ // Extract the widths of all glyphs.
+ let num_glyphs = ttf.number_of_glyphs();
+ let mut widths = vec![0.0; num_glyphs as usize];
+ for &g in glyph_set.keys() {
+ let x = ttf.glyph_hor_advance(GlyphId(g)).unwrap_or(0);
+ widths[g as usize] = font.to_em(x).to_font_units();
+ }
+
+ // Write all non-zero glyph widths.
+ let mut first = 0;
+ let mut width_writer = cid.widths();
+ for (w, group) in widths.group_by_key(|&w| w) {
+ let end = first + group.len();
+ if w != 0.0 {
+ let last = end - 1;
+ width_writer.same(first as u16, last as u16, w);
+ }
+ first = end;
+ }
+
+ width_writer.finish();
+ cid.finish();
+
+ let mut flags = FontFlags::empty();
+ flags.set(FontFlags::SERIF, postscript_name.contains("Serif"));
+ flags.set(FontFlags::FIXED_PITCH, ttf.is_monospaced());
+ flags.set(FontFlags::ITALIC, ttf.is_italic());
+ flags.insert(FontFlags::SYMBOLIC);
+ flags.insert(FontFlags::SMALL_CAP);
+
+ let global_bbox = ttf.global_bounding_box();
+ let bbox = Rect::new(
+ font.to_em(global_bbox.x_min).to_font_units(),
+ font.to_em(global_bbox.y_min).to_font_units(),
+ font.to_em(global_bbox.x_max).to_font_units(),
+ font.to_em(global_bbox.y_max).to_font_units(),
+ );
+
+ let italic_angle = ttf.italic_angle().unwrap_or(0.0);
+ let ascender = metrics.ascender.to_font_units();
+ let descender = metrics.descender.to_font_units();
+ let cap_height = metrics.cap_height.to_font_units();
+ let stem_v = 10.0 + 0.244 * (f32::from(ttf.weight().to_number()) - 50.0);
+
+ // Write the font descriptor (contains metrics about the font).
+ let mut font_descriptor = ctx.writer.font_descriptor(descriptor_ref);
+ font_descriptor
+ .name(base_font)
+ .flags(flags)
+ .bbox(bbox)
+ .italic_angle(italic_angle)
+ .ascent(ascender)
+ .descent(descender)
+ .cap_height(cap_height)
+ .stem_v(stem_v);
+
+ match subtype {
+ CidFontType::Type0 => font_descriptor.font_file3(data_ref),
+ CidFontType::Type2 => font_descriptor.font_file2(data_ref),
+ };
+
+ font_descriptor.finish();
+
+ // Write the /ToUnicode character map, which maps glyph ids back to
+ // unicode codepoints to enable copying out of the PDF.
+ let cmap = create_cmap(ttf, glyph_set);
+ ctx.writer.cmap(cmap_ref, &cmap.finish());
+
+ // Subset and write the font's bytes.
+ let glyphs: Vec<_> = glyph_set.keys().copied().collect();
+ let data = subset_font(font, &glyphs);
+ let mut stream = ctx.writer.stream(data_ref, &data);
+ stream.filter(Filter::FlateDecode);
+
+ if subtype == CidFontType::Type0 {
+ stream.pair(Name(b"Subtype"), Name(b"CIDFontType0C"));
+ }
+
+ stream.finish();
+ }
+}
+
+/// Subset a font to the given glyphs.
+#[comemo::memoize]
+fn subset_font(font: &Font, glyphs: &[u16]) -> Bytes {
+ let data = font.data();
+ let profile = subsetter::Profile::pdf(glyphs);
+ let subsetted = subsetter::subset(data, font.index(), profile);
+ let data = subsetted.as_deref().unwrap_or(data);
+ deflate(data).into()
+}
+
+/// Create a /ToUnicode CMap.
+fn create_cmap(
+ ttf: &ttf_parser::Face,
+ glyph_set: &mut BTreeMap<u16, EcoString>,
+) -> UnicodeCmap {
+ // For glyphs that have codepoints mapping to in the font's cmap table, we
+ // prefer them over pre-existing text mappings from the document. Only
+ // things that don't have a corresponding codepoint (or only a private-use
+ // one) like the "Th" in Linux Libertine get the text of their first
+ // occurrences in the document instead.
+ for subtable in ttf.tables().cmap.into_iter().flat_map(|table| table.subtables) {
+ if !subtable.is_unicode() {
+ continue;
+ }
+
+ subtable.codepoints(|n| {
+ let Some(c) = std::char::from_u32(n) else { return };
+ if unicode_general_category::get_general_category(c)
+ == GeneralCategory::PrivateUse
+ {
+ return;
+ }
+
+ let Some(GlyphId(g)) = ttf.glyph_index(c) else { return };
+ if glyph_set.contains_key(&g) {
+ glyph_set.insert(g, c.into());
+ }
+ });
+ }
+
+ // Produce a reverse mapping from glyphs to unicode strings.
+ let mut cmap = UnicodeCmap::new(CMAP_NAME, SYSTEM_INFO);
+ for (&g, text) in glyph_set.iter() {
+ if !text.is_empty() {
+ cmap.pair_with_multiple(g, text.chars());
+ }
+ }
+
+ cmap
+}
diff --git a/crates/typst/src/export/pdf/image.rs b/crates/typst/src/export/pdf/image.rs
new file mode 100644
index 00000000..48472d9f
--- /dev/null
+++ b/crates/typst/src/export/pdf/image.rs
@@ -0,0 +1,143 @@
+use std::io::Cursor;
+
+use image::{DynamicImage, GenericImageView, Rgba};
+use pdf_writer::{Filter, Finish};
+
+use super::{deflate, PdfContext, RefExt};
+use crate::image::{DecodedImage, Image, RasterFormat};
+use crate::util::Bytes;
+
+/// Embed all used images into the PDF.
+#[tracing::instrument(skip_all)]
+pub fn write_images(ctx: &mut PdfContext) {
+ for image in ctx.image_map.items() {
+ let image_ref = ctx.alloc.bump();
+ let icc_ref = ctx.alloc.bump();
+ ctx.image_refs.push(image_ref);
+
+ let width = image.width();
+ let height = image.height();
+
+ // Add the primary image.
+ // TODO: Error if image could not be encoded.
+ match image.decoded().as_ref() {
+ DecodedImage::Raster(dynamic, icc, _) => {
+ // TODO: Error if image could not be encoded.
+ let (data, filter, has_color) = encode_image(image);
+ let mut image = ctx.writer.image_xobject(image_ref, &data);
+ image.filter(filter);
+ image.width(width as i32);
+ image.height(height as i32);
+ image.bits_per_component(8);
+
+ let space = image.color_space();
+ if icc.is_some() {
+ space.icc_based(icc_ref);
+ } else if has_color {
+ space.device_rgb();
+ } else {
+ space.device_gray();
+ }
+
+ // Add a second gray-scale image containing the alpha values if
+ // this image has an alpha channel.
+ if dynamic.color().has_alpha() {
+ let (alpha_data, alpha_filter) = encode_alpha(dynamic);
+ let mask_ref = ctx.alloc.bump();
+ image.s_mask(mask_ref);
+ image.finish();
+
+ let mut mask = ctx.writer.image_xobject(mask_ref, &alpha_data);
+ mask.filter(alpha_filter);
+ mask.width(width as i32);
+ mask.height(height as i32);
+ mask.color_space().device_gray();
+ mask.bits_per_component(8);
+ } else {
+ image.finish();
+ }
+
+ if let Some(icc) = icc {
+ let compressed = deflate(&icc.0);
+ let mut stream = ctx.writer.icc_profile(icc_ref, &compressed);
+ stream.filter(Filter::FlateDecode);
+ if has_color {
+ stream.n(3);
+ stream.alternate().srgb();
+ } else {
+ stream.n(1);
+ stream.alternate().d65_gray();
+ }
+ }
+ }
+ DecodedImage::Svg(svg) => {
+ let next_ref = svg2pdf::convert_tree_into(
+ svg,
+ svg2pdf::Options::default(),
+ &mut ctx.writer,
+ image_ref,
+ );
+ ctx.alloc = next_ref;
+ }
+ }
+ }
+}
+
+/// Encode an image with a suitable filter and return the data, filter and
+/// whether the image has color.
+///
+/// Skips the alpha channel as that's encoded separately.
+#[comemo::memoize]
+#[tracing::instrument(skip_all)]
+fn encode_image(image: &Image) -> (Bytes, Filter, bool) {
+ let decoded = image.decoded();
+ let (dynamic, format) = match decoded.as_ref() {
+ DecodedImage::Raster(dynamic, _, format) => (dynamic, *format),
+ _ => panic!("can only encode raster image"),
+ };
+
+ match (format, dynamic) {
+ // 8-bit gray JPEG.
+ (RasterFormat::Jpg, DynamicImage::ImageLuma8(_)) => {
+ let mut data = Cursor::new(vec![]);
+ dynamic.write_to(&mut data, image::ImageFormat::Jpeg).unwrap();
+ (data.into_inner().into(), Filter::DctDecode, false)
+ }
+
+ // 8-bit RGB JPEG (CMYK JPEGs get converted to RGB earlier).
+ (RasterFormat::Jpg, DynamicImage::ImageRgb8(_)) => {
+ let mut data = Cursor::new(vec![]);
+ dynamic.write_to(&mut data, image::ImageFormat::Jpeg).unwrap();
+ (data.into_inner().into(), Filter::DctDecode, true)
+ }
+
+ // TODO: Encode flate streams with PNG-predictor?
+
+ // 8-bit gray PNG.
+ (RasterFormat::Png, DynamicImage::ImageLuma8(luma)) => {
+ let data = deflate(luma.as_raw());
+ (data.into(), Filter::FlateDecode, false)
+ }
+
+ // Anything else (including Rgb(a) PNGs).
+ (_, buf) => {
+ let (width, height) = buf.dimensions();
+ let mut pixels = Vec::with_capacity(3 * width as usize * height as usize);
+ for (_, _, Rgba([r, g, b, _])) in buf.pixels() {
+ pixels.push(r);
+ pixels.push(g);
+ pixels.push(b);
+ }
+
+ let data = deflate(&pixels);
+ (data.into(), Filter::FlateDecode, true)
+ }
+ }
+}
+
+/// Encode an image's alpha channel if present.
+#[tracing::instrument(skip_all)]
+fn encode_alpha(dynamic: &DynamicImage) -> (Vec<u8>, Filter) {
+ let pixels: Vec<_> = dynamic.pixels().map(|(_, _, Rgba([_, _, _, a]))| a).collect();
+ (deflate(&pixels), Filter::FlateDecode)
+}
diff --git a/crates/typst/src/export/pdf/mod.rs b/crates/typst/src/export/pdf/mod.rs
new file mode 100644
index 00000000..48485862
--- /dev/null
+++ b/crates/typst/src/export/pdf/mod.rs
@@ -0,0 +1,235 @@
+//! Exporting into PDF documents.
+
+mod font;
+mod image;
+mod outline;
+mod page;
+
+use std::cmp::Eq;
+use std::collections::{BTreeMap, HashMap};
+use std::hash::Hash;
+
+use ecow::EcoString;
+use pdf_writer::types::Direction;
+use pdf_writer::{Finish, Name, PdfWriter, Ref, TextStr};
+use xmp_writer::{LangId, RenditionClass, XmpWriter};
+
+use self::page::Page;
+use crate::doc::{Document, Lang};
+use crate::font::Font;
+use crate::geom::{Abs, Dir, Em};
+use crate::image::Image;
+use crate::model::Introspector;
+
+/// Export a document into a PDF file.
+///
+/// Returns the raw bytes making up the PDF file.
+#[tracing::instrument(skip_all)]
+pub fn pdf(document: &Document) -> Vec<u8> {
+ let mut ctx = PdfContext::new(document);
+ page::construct_pages(&mut ctx, &document.pages);
+ font::write_fonts(&mut ctx);
+ image::write_images(&mut ctx);
+ page::write_page_tree(&mut ctx);
+ write_catalog(&mut ctx);
+ ctx.writer.finish()
+}
+
+/// Identifies the color space definitions.
+const SRGB: Name<'static> = Name(b"srgb");
+const D65_GRAY: Name<'static> = Name(b"d65gray");
+
+/// Context for exporting a whole PDF document.
+pub struct PdfContext<'a> {
+ document: &'a Document,
+ introspector: Introspector,
+ writer: PdfWriter,
+ pages: Vec<Page>,
+ page_heights: Vec<f32>,
+ alloc: Ref,
+ page_tree_ref: Ref,
+ font_refs: Vec<Ref>,
+ image_refs: Vec<Ref>,
+ page_refs: Vec<Ref>,
+ font_map: Remapper<Font>,
+ image_map: Remapper<Image>,
+ /// For each font a mapping from used glyphs to their text representation.
+ /// May contain multiple chars in case of ligatures or similar things. The
+ /// same glyph can have a different text representation within one document,
+ /// then we just save the first one. The resulting strings are used for the
+ /// PDF's /ToUnicode map for glyphs that don't have an entry in the font's
+ /// cmap. This is important for copy-paste and searching.
+ glyph_sets: HashMap<Font, BTreeMap<u16, EcoString>>,
+ languages: HashMap<Lang, usize>,
+}
+
+impl<'a> PdfContext<'a> {
+ fn new(document: &'a Document) -> Self {
+ let mut alloc = Ref::new(1);
+ let page_tree_ref = alloc.bump();
+ Self {
+ document,
+ introspector: Introspector::new(&document.pages),
+ writer: PdfWriter::new(),
+ pages: vec![],
+ page_heights: vec![],
+ alloc,
+ page_tree_ref,
+ page_refs: vec![],
+ font_refs: vec![],
+ image_refs: vec![],
+ font_map: Remapper::new(),
+ image_map: Remapper::new(),
+ glyph_sets: HashMap::new(),
+ languages: HashMap::new(),
+ }
+ }
+}
+
+/// Write the document catalog.
+#[tracing::instrument(skip_all)]
+fn write_catalog(ctx: &mut PdfContext) {
+ let lang = ctx
+ .languages
+ .iter()
+ .max_by_key(|(&lang, &count)| (count, lang))
+ .map(|(&k, _)| k);
+
+ let dir = if lang.map(Lang::dir) == Some(Dir::RTL) {
+ Direction::R2L
+ } else {
+ Direction::L2R
+ };
+
+ // Write the outline tree.
+ let outline_root_id = outline::write_outline(ctx);
+
+ // Write the document information.
+ let mut info = ctx.writer.document_info(ctx.alloc.bump());
+ let mut xmp = XmpWriter::new();
+ if let Some(title) = &ctx.document.title {
+ info.title(TextStr(title));
+ xmp.title([(None, title.as_str())]);
+ }
+
+ let authors = &ctx.document.author;
+ if !authors.is_empty() {
+ info.author(TextStr(&authors.join(", ")));
+ xmp.creator(authors.iter().map(|s| s.as_str()));
+ }
+ info.creator(TextStr("Typst"));
+ info.finish();
+ xmp.creator_tool("Typst");
+ xmp.num_pages(ctx.document.pages.len() as u32);
+ xmp.format("application/pdf");
+ xmp.language(ctx.languages.keys().map(|lang| LangId(lang.as_str())));
+ xmp.rendition_class(RenditionClass::Proof);
+ xmp.pdf_version("1.7");
+
+ let xmp_buf = xmp.finish(None);
+ let meta_ref = ctx.alloc.bump();
+ let mut meta_stream = ctx.writer.stream(meta_ref, xmp_buf.as_bytes());
+ meta_stream.pair(Name(b"Type"), Name(b"Metadata"));
+ meta_stream.pair(Name(b"Subtype"), Name(b"XML"));
+ meta_stream.finish();
+
+ // Write the document catalog.
+ let mut catalog = ctx.writer.catalog(ctx.alloc.bump());
+ catalog.pages(ctx.page_tree_ref);
+ catalog.viewer_preferences().direction(dir);
+ catalog.pair(Name(b"Metadata"), meta_ref);
+
+ if let Some(outline_root_id) = outline_root_id {
+ catalog.outlines(outline_root_id);
+ }
+
+ if let Some(lang) = lang {
+ catalog.lang(TextStr(lang.as_str()));
+ }
+}
+
+/// Compress data with the DEFLATE algorithm.
+#[tracing::instrument(skip_all)]
+fn deflate(data: &[u8]) -> Vec<u8> {
+ const COMPRESSION_LEVEL: u8 = 6;
+ miniz_oxide::deflate::compress_to_vec_zlib(data, COMPRESSION_LEVEL)
+}
+
+/// Assigns new, consecutive PDF-internal indices to items.
+struct Remapper<T> {
+ /// Forwards from the items to the pdf indices.
+ to_pdf: HashMap<T, usize>,
+ /// Backwards from the pdf indices to the items.
+ to_items: Vec<T>,
+}
+
+impl<T> Remapper<T>
+where
+ T: Eq + Hash + Clone,
+{
+ fn new() -> Self {
+ Self { to_pdf: HashMap::new(), to_items: vec![] }
+ }
+
+ fn insert(&mut self, item: T) {
+ let to_layout = &mut self.to_items;
+ self.to_pdf.entry(item.clone()).or_insert_with(|| {
+ let pdf_index = to_layout.len();
+ to_layout.push(item);
+ pdf_index
+ });
+ }
+
+ fn map(&self, item: T) -> usize {
+ self.to_pdf[&item]
+ }
+
+ fn pdf_indices<'a>(
+ &'a self,
+ refs: &'a [Ref],
+ ) -> impl Iterator<Item = (Ref, usize)> + 'a {
+ refs.iter().copied().zip(0..self.to_pdf.len())
+ }
+
+ fn items(&self) -> impl Iterator<Item = &T> + '_ {
+ self.to_items.iter()
+ }
+}
+
+/// Additional methods for [`Abs`].
+trait AbsExt {
+ /// Convert an to a number of points.
+ fn to_f32(self) -> f32;
+}
+
+impl AbsExt for Abs {
+ fn to_f32(self) -> f32 {
+ self.to_pt() as f32
+ }
+}
+
+/// Additional methods for [`Em`].
+trait EmExt {
+ /// Convert an em length to a number of PDF font units.
+ fn to_font_units(self) -> f32;
+}
+
+impl EmExt for Em {
+ fn to_font_units(self) -> f32 {
+ 1000.0 * self.get() as f32
+ }
+}
+
+/// Additional methods for [`Ref`].
+trait RefExt {
+ /// Bump the reference up by one and return the previous one.
+ fn bump(&mut self) -> Self;
+}
+
+impl RefExt for Ref {
+ fn bump(&mut self) -> Self {
+ let prev = *self;
+ *self = Self::new(prev.get() + 1);
+ prev
+ }
+}
diff --git a/crates/typst/src/export/pdf/outline.rs b/crates/typst/src/export/pdf/outline.rs
new file mode 100644
index 00000000..539647eb
--- /dev/null
+++ b/crates/typst/src/export/pdf/outline.rs
@@ -0,0 +1,127 @@
+use std::num::NonZeroUsize;
+
+use pdf_writer::{Finish, Ref, TextStr};
+
+use super::{AbsExt, PdfContext, RefExt};
+use crate::geom::Abs;
+use crate::model::Content;
+
+/// Construct the outline for the document.
+#[tracing::instrument(skip_all)]
+pub fn write_outline(ctx: &mut PdfContext) -> Option<Ref> {
+ let mut tree: Vec<HeadingNode> = vec![];
+ for heading in ctx.introspector.query(&item!(heading_func).select()) {
+ let leaf = HeadingNode::leaf((*heading).clone());
+
+ let mut children = &mut tree;
+ while children.last().map_or(false, |last| last.level < leaf.level) {
+ children = &mut children.last_mut().unwrap().children;
+ }
+
+ children.push(leaf);
+ }
+
+ if tree.is_empty() {
+ return None;
+ }
+
+ let root_id = ctx.alloc.bump();
+ let start_ref = ctx.alloc;
+ let len = tree.len();
+
+ let mut prev_ref = None;
+ for (i, node) in tree.iter().enumerate() {
+ prev_ref = Some(write_outline_item(ctx, node, root_id, prev_ref, i + 1 == len));
+ }
+
+ ctx.writer
+ .outline(root_id)
+ .first(start_ref)
+ .last(Ref::new(ctx.alloc.get() - 1))
+ .count(tree.len() as i32);
+
+ Some(root_id)
+}
+
+/// A heading in the outline panel.
+#[derive(Debug, Clone)]
+struct HeadingNode {
+ element: Content,
+ level: NonZeroUsize,
+ children: Vec<HeadingNode>,
+}
+
+impl HeadingNode {
+ fn leaf(element: Content) -> Self {
+ HeadingNode {
+ level: element.expect_field::<NonZeroUsize>("level"),
+ element,
+ children: Vec::new(),
+ }
+ }
+
+ fn len(&self) -> usize {
+ 1 + self.children.iter().map(Self::len).sum::<usize>()
+ }
+}
+
+/// Write an outline item and all its children.
+#[tracing::instrument(skip_all)]
+fn write_outline_item(
+ ctx: &mut PdfContext,
+ node: &HeadingNode,
+ parent_ref: Ref,
+ prev_ref: Option<Ref>,
+ is_last: bool,
+) -> Ref {
+ let id = ctx.alloc.bump();
+ let next_ref = Ref::new(id.get() + node.len() as i32);
+
+ let mut outline = ctx.writer.outline_item(id);
+ outline.parent(parent_ref);
+
+ if !is_last {
+ outline.next(next_ref);
+ }
+
+ if let Some(prev_rev) = prev_ref {
+ outline.prev(prev_rev);
+ }
+
+ if !node.children.is_empty() {
+ let current_child = Ref::new(id.get() + 1);
+ outline.first(current_child);
+ outline.last(Ref::new(next_ref.get() - 1));
+ outline.count(-(node.children.len() as i32));
+ }
+
+ let body = node.element.expect_field::<Content>("body");
+ outline.title(TextStr(body.plain_text().trim()));
+
+ let loc = node.element.location().unwrap();
+ let pos = ctx.introspector.position(loc);
+ let index = pos.page.get() - 1;
+ if let Some(&height) = ctx.page_heights.get(index) {
+ let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero());
+ outline.dest().page(ctx.page_refs[index]).xyz(
+ pos.point.x.to_f32(),
+ height - y.to_f32(),
+ None,
+ );
+ }
+
+ outline.finish();
+
+ let mut prev_ref = None;
+ for (i, child) in node.children.iter().enumerate() {
+ prev_ref = Some(write_outline_item(
+ ctx,
+ child,
+ id,
+ prev_ref,
+ i + 1 == node.children.len(),
+ ));
+ }
+
+ id
+}
diff --git a/crates/typst/src/export/pdf/page.rs b/crates/typst/src/export/pdf/page.rs
new file mode 100644
index 00000000..22e590d5
--- /dev/null
+++ b/crates/typst/src/export/pdf/page.rs
@@ -0,0 +1,565 @@
+use ecow::eco_format;
+use pdf_writer::types::{
+ ActionType, AnnotationType, ColorSpaceOperand, LineCapStyle, LineJoinStyle,
+};
+use pdf_writer::writers::ColorSpace;
+use pdf_writer::{Content, Filter, Finish, Name, Rect, Ref, Str};
+
+use super::{deflate, AbsExt, EmExt, PdfContext, RefExt, D65_GRAY, SRGB};
+use crate::doc::{Destination, Frame, FrameItem, GroupItem, Meta, TextItem};
+use crate::font::Font;
+use crate::geom::{
+ self, Abs, Color, Em, Geometry, LineCap, LineJoin, Numeric, Paint, Point, Ratio,
+ Shape, Size, Stroke, Transform,
+};
+use crate::image::Image;
+
+/// Construct page objects.
+#[tracing::instrument(skip_all)]
+pub fn construct_pages(ctx: &mut PdfContext, frames: &[Frame]) {
+ for frame in frames {
+ construct_page(ctx, frame);
+ }
+}
+
+/// Construct a page object.
+#[tracing::instrument(skip_all)]
+pub fn construct_page(ctx: &mut PdfContext, frame: &Frame) {
+ let page_ref = ctx.alloc.bump();
+ ctx.page_refs.push(page_ref);
+ ctx.page_heights.push(frame.height().to_f32());
+
+ let mut ctx = PageContext {
+ parent: ctx,
+ page_ref,
+ content: Content::new(),
+ state: State::default(),
+ saves: vec![],
+ bottom: 0.0,
+ links: vec![],
+ };
+
+ let size = frame.size();
+
+ // Make the coordinate system start at the top-left.
+ ctx.bottom = size.y.to_f32();
+ ctx.transform(Transform {
+ sx: Ratio::one(),
+ ky: Ratio::zero(),
+ kx: Ratio::zero(),
+ sy: Ratio::new(-1.0),
+ tx: Abs::zero(),
+ ty: size.y,
+ });
+
+ // Encode the page into the content stream.
+ write_frame(&mut ctx, frame);
+
+ let page = Page {
+ size,
+ content: ctx.content,
+ id: ctx.page_ref,
+ links: ctx.links,
+ };
+
+ ctx.parent.pages.push(page);
+}
+
+/// Write the page tree.
+#[tracing::instrument(skip_all)]
+pub fn write_page_tree(ctx: &mut PdfContext) {
+ for page in std::mem::take(&mut ctx.pages).into_iter() {
+ write_page(ctx, page);
+ }
+
+ let mut pages = ctx.writer.pages(ctx.page_tree_ref);
+ pages
+ .count(ctx.page_refs.len() as i32)
+ .kids(ctx.page_refs.iter().copied());
+
+ let mut resources = pages.resources();
+ let mut spaces = resources.color_spaces();
+ spaces.insert(SRGB).start::<ColorSpace>().srgb();
+ spaces.insert(D65_GRAY).start::<ColorSpace>().d65_gray();
+ spaces.finish();
+
+ let mut fonts = resources.fonts();
+ for (font_ref, f) in ctx.font_map.pdf_indices(&ctx.font_refs) {
+ let name = eco_format!("F{}", f);
+ fonts.pair(Name(name.as_bytes()), font_ref);
+ }
+
+ fonts.finish();
+
+ let mut images = resources.x_objects();
+ for (image_ref, im) in ctx.image_map.pdf_indices(&ctx.image_refs) {
+ let name = eco_format!("Im{}", im);
+ images.pair(Name(name.as_bytes()), image_ref);
+ }
+
+ images.finish();
+ resources.finish();
+ pages.finish();
+}
+
+/// Write a page tree node.
+#[tracing::instrument(skip_all)]
+fn write_page(ctx: &mut PdfContext, page: Page) {
+ let content_id = ctx.alloc.bump();
+
+ let mut page_writer = ctx.writer.page(page.id);
+ page_writer.parent(ctx.page_tree_ref);
+
+ let w = page.size.x.to_f32();
+ let h = page.size.y.to_f32();
+ page_writer.media_box(Rect::new(0.0, 0.0, w, h));
+ page_writer.contents(content_id);
+
+ let mut annotations = page_writer.annotations();
+ for (dest, rect) in page.links {
+ let mut annotation = annotations.push();
+ annotation.subtype(AnnotationType::Link).rect(rect);
+ annotation.border(0.0, 0.0, 0.0, None);
+
+ let pos = match dest {
+ Destination::Url(uri) => {
+ annotation
+ .action()
+ .action_type(ActionType::Uri)
+ .uri(Str(uri.as_bytes()));
+ continue;
+ }
+ Destination::Position(pos) => pos,
+ Destination::Location(loc) => ctx.introspector.position(loc),
+ };
+
+ let index = pos.page.get() - 1;
+ let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero());
+ if let Some(&height) = ctx.page_heights.get(index) {
+ annotation
+ .action()
+ .action_type(ActionType::GoTo)
+ .destination()
+ .page(ctx.page_refs[index])
+ .xyz(pos.point.x.to_f32(), height - y.to_f32(), None);
+ }
+ }
+
+ annotations.finish();
+ page_writer.finish();
+
+ let data = page.content.finish();
+ let data = deflate(&data);
+ ctx.writer.stream(content_id, &data).filter(Filter::FlateDecode);
+}
+
+/// Data for an exported page.
+pub struct Page {
+ /// The indirect object id of the page.
+ pub id: Ref,
+ /// The page's dimensions.
+ pub size: Size,
+ /// The page's content stream.
+ pub content: Content,
+ /// Links in the PDF coordinate system.
+ pub links: Vec<(Destination, Rect)>,
+}
+
+/// An exporter for the contents of a single PDF page.
+struct PageContext<'a, 'b> {
+ parent: &'a mut PdfContext<'b>,
+ page_ref: Ref,
+ content: Content,
+ state: State,
+ saves: Vec<State>,
+ bottom: f32,
+ links: Vec<(Destination, Rect)>,
+}
+
+/// A simulated graphics state used to deduplicate graphics state changes and
+/// keep track of the current transformation matrix for link annotations.
+#[derive(Debug, Default, Clone)]
+struct State {
+ transform: Transform,
+ font: Option<(Font, Abs)>,
+ fill: Option<Paint>,
+ fill_space: Option<Name<'static>>,
+ stroke: Option<Stroke>,
+ stroke_space: Option<Name<'static>>,
+}
+
+impl PageContext<'_, '_> {
+ fn save_state(&mut self) {
+ self.saves.push(self.state.clone());
+ self.content.save_state();
+ }
+
+ fn restore_state(&mut self) {
+ self.content.restore_state();
+ self.state = self.saves.pop().expect("missing state save");
+ }
+
+ fn transform(&mut self, transform: Transform) {
+ let Transform { sx, ky, kx, sy, tx, ty } = transform;
+ self.state.transform = self.state.transform.pre_concat(transform);
+ self.content.transform([
+ sx.get() as _,
+ ky.get() as _,
+ kx.get() as _,
+ sy.get() as _,
+ tx.to_f32(),
+ ty.to_f32(),
+ ]);
+ }
+
+ fn set_font(&mut self, font: &Font, size: Abs) {
+ if self.state.font.as_ref().map(|(f, s)| (f, *s)) != Some((font, size)) {
+ self.parent.font_map.insert(font.clone());
+ let name = eco_format!("F{}", self.parent.font_map.map(font.clone()));
+ self.content.set_font(Name(name.as_bytes()), size.to_f32());
+ self.state.font = Some((font.clone(), size));
+ }
+ }
+
+ fn set_fill(&mut self, fill: &Paint) {
+ if self.state.fill.as_ref() != Some(fill) {
+ let f = |c| c as f32 / 255.0;
+ let Paint::Solid(color) = fill;
+ match color {
+ Color::Luma(c) => {
+ self.set_fill_color_space(D65_GRAY);
+ self.content.set_fill_gray(f(c.0));
+ }
+ Color::Rgba(c) => {
+ self.set_fill_color_space(SRGB);
+ self.content.set_fill_color([f(c.r), f(c.g), f(c.b)]);
+ }
+ Color::Cmyk(c) => {
+ self.reset_fill_color_space();
+ self.content.set_fill_cmyk(f(c.c), f(c.m), f(c.y), f(c.k));
+ }
+ }
+ self.state.fill = Some(fill.clone());
+ }
+ }
+
+ fn set_fill_color_space(&mut self, space: Name<'static>) {
+ if self.state.fill_space != Some(space) {
+ self.content.set_fill_color_space(ColorSpaceOperand::Named(space));
+ self.state.fill_space = Some(space);
+ }
+ }
+
+ fn reset_fill_color_space(&mut self) {
+ self.state.fill_space = None;
+ }
+
+ fn set_stroke(&mut self, stroke: &Stroke) {
+ if self.state.stroke.as_ref() != Some(stroke) {
+ let Stroke {
+ paint,
+ thickness,
+ line_cap,
+ line_join,
+ dash_pattern,
+ miter_limit,
+ } = stroke;
+
+ let f = |c| c as f32 / 255.0;
+ let Paint::Solid(color) = paint;
+ match color {
+ Color::Luma(c) => {
+ self.set_stroke_color_space(D65_GRAY);
+ self.content.set_stroke_gray(f(c.0));
+ }
+ Color::Rgba(c) => {
+ self.set_stroke_color_space(SRGB);
+ self.content.set_stroke_color([f(c.r), f(c.g), f(c.b)]);
+ }
+ Color::Cmyk(c) => {
+ self.reset_stroke_color_space();
+ self.content.set_stroke_cmyk(f(c.c), f(c.m), f(c.y), f(c.k));
+ }
+ }
+
+ self.content.set_line_width(thickness.to_f32());
+ if self.state.stroke.as_ref().map(|s| &s.line_cap) != Some(line_cap) {
+ self.content.set_line_cap(line_cap.into());
+ }
+ if self.state.stroke.as_ref().map(|s| &s.line_join) != Some(line_join) {
+ self.content.set_line_join(line_join.into());
+ }
+ if self.state.stroke.as_ref().map(|s| &s.dash_pattern) != Some(dash_pattern) {
+ if let Some(pattern) = dash_pattern {
+ self.content.set_dash_pattern(
+ pattern.array.iter().map(|l| l.to_f32()),
+ pattern.phase.to_f32(),
+ );
+ } else {
+ self.content.set_dash_pattern([], 0.0);
+ }
+ }
+ if self.state.stroke.as_ref().map(|s| &s.miter_limit) != Some(miter_limit) {
+ self.content.set_miter_limit(miter_limit.0 as f32);
+ }
+ self.state.stroke = Some(stroke.clone());
+ }
+ }
+
+ fn set_stroke_color_space(&mut self, space: Name<'static>) {
+ if self.state.stroke_space != Some(space) {
+ self.content.set_stroke_color_space(ColorSpaceOperand::Named(space));
+ self.state.stroke_space = Some(space);
+ }
+ }
+
+ fn reset_stroke_color_space(&mut self) {
+ self.state.stroke_space = None;
+ }
+}
+
+/// Encode a frame into the content stream.
+fn write_frame(ctx: &mut PageContext, frame: &Frame) {
+ for &(pos, ref item) in frame.items() {
+ let x = pos.x.to_f32();
+ let y = pos.y.to_f32();
+ match item {
+ FrameItem::Group(group) => write_group(ctx, pos, group),
+ FrameItem::Text(text) => write_text(ctx, x, y, text),
+ FrameItem::Shape(shape, _) => write_shape(ctx, x, y, shape),
+ FrameItem::Image(image, size, _) => write_image(ctx, x, y, image, *size),
+ FrameItem::Meta(meta, size) => match meta {
+ Meta::Link(dest) => write_link(ctx, pos, dest, *size),
+ Meta::Elem(_) => {}
+ Meta::Hide => {}
+ Meta::PageNumbering(_) => {}
+ },
+ }
+ }
+}
+
+/// Encode a group into the content stream.
+fn write_group(ctx: &mut PageContext, pos: Point, group: &GroupItem) {
+ let translation = Transform::translate(pos.x, pos.y);
+
+ ctx.save_state();
+ ctx.transform(translation.pre_concat(group.transform));
+
+ if group.clips {
+ let size = group.frame.size();
+ let w = size.x.to_f32();
+ let h = size.y.to_f32();
+ ctx.content.move_to(0.0, 0.0);
+ ctx.content.line_to(w, 0.0);
+ ctx.content.line_to(w, h);
+ ctx.content.line_to(0.0, h);
+ ctx.content.clip_nonzero();
+ ctx.content.end_path();
+ }
+
+ write_frame(ctx, &group.frame);
+ ctx.restore_state();
+}
+
+/// Encode a text run into the content stream.
+fn write_text(ctx: &mut PageContext, x: f32, y: f32, text: &TextItem) {
+ *ctx.parent.languages.entry(text.lang).or_insert(0) += text.glyphs.len();
+
+ let glyph_set = ctx.parent.glyph_sets.entry(text.font.clone()).or_default();
+ for g in &text.glyphs {
+ let segment = &text.text[g.range()];
+ glyph_set.entry(g.id).or_insert_with(|| segment.into());
+ }
+
+ ctx.set_fill(&text.fill);
+ ctx.set_font(&text.font, text.size);
+ ctx.content.begin_text();
+
+ // Positiosn the text.
+ ctx.content.set_text_matrix([1.0, 0.0, 0.0, -1.0, x, y]);
+
+ let mut positioned = ctx.content.show_positioned();
+ let mut items = positioned.items();
+ let mut adjustment = Em::zero();
+ let mut encoded = vec![];
+
+ // Write the glyphs with kerning adjustments.
+ for glyph in &text.glyphs {
+ adjustment += glyph.x_offset;
+
+ if !adjustment.is_zero() {
+ if !encoded.is_empty() {
+ items.show(Str(&encoded));
+ encoded.clear();
+ }
+
+ items.adjust(-adjustment.to_font_units());
+ adjustment = Em::zero();
+ }
+
+ encoded.push((glyph.id >> 8) as u8);
+ encoded.push((glyph.id & 0xff) as u8);
+
+ if let Some(advance) = text.font.advance(glyph.id) {
+ adjustment += glyph.x_advance - advance;
+ }
+
+ adjustment -= glyph.x_offset;
+ }
+
+ if !encoded.is_empty() {
+ items.show(Str(&encoded));
+ }
+
+ items.finish();
+ positioned.finish();
+ ctx.content.end_text();
+}
+
+/// Encode a geometrical shape into the content stream.
+fn write_shape(ctx: &mut PageContext, x: f32, y: f32, shape: &Shape) {
+ let stroke = shape.stroke.as_ref().and_then(|stroke| {
+ if stroke.thickness.to_f32() > 0.0 {
+ Some(stroke)
+ } else {
+ None
+ }
+ });
+
+ if shape.fill.is_none() && stroke.is_none() {
+ return;
+ }
+
+ if let Some(fill) = &shape.fill {
+ ctx.set_fill(fill);
+ }
+
+ if let Some(stroke) = stroke {
+ ctx.set_stroke(stroke);
+ }
+
+ match shape.geometry {
+ Geometry::Line(target) => {
+ let dx = target.x.to_f32();
+ let dy = target.y.to_f32();
+ ctx.content.move_to(x, y);
+ ctx.content.line_to(x + dx, y + dy);
+ }
+ Geometry::Rect(size) => {
+ let w = size.x.to_f32();
+ let h = size.y.to_f32();
+ if w > 0.0 && h > 0.0 {
+ ctx.content.rect(x, y, w, h);
+ }
+ }
+ Geometry::Path(ref path) => {
+ write_path(ctx, x, y, path);
+ }
+ }
+
+ match (&shape.fill, stroke) {
+ (None, None) => unreachable!(),
+ (Some(_), None) => ctx.content.fill_nonzero(),
+ (None, Some(_)) => ctx.content.stroke(),
+ (Some(_), Some(_)) => ctx.content.fill_nonzero_and_stroke(),
+ };
+}
+
+/// Encode a bezier path into the content stream.
+fn write_path(ctx: &mut PageContext, x: f32, y: f32, path: &geom::Path) {
+ for elem in &path.0 {
+ match elem {
+ geom::PathItem::MoveTo(p) => {
+ ctx.content.move_to(x + p.x.to_f32(), y + p.y.to_f32())
+ }
+ geom::PathItem::LineTo(p) => {
+ ctx.content.line_to(x + p.x.to_f32(), y + p.y.to_f32())
+ }
+ geom::PathItem::CubicTo(p1, p2, p3) => ctx.content.cubic_to(
+ x + p1.x.to_f32(),
+ y + p1.y.to_f32(),
+ x + p2.x.to_f32(),
+ y + p2.y.to_f32(),
+ x + p3.x.to_f32(),
+ y + p3.y.to_f32(),
+ ),
+ geom::PathItem::ClosePath => ctx.content.close_path(),
+ };
+ }
+}
+
+/// Encode a vector or raster image into the content stream.
+fn write_image(ctx: &mut PageContext, x: f32, y: f32, image: &Image, size: Size) {
+ ctx.parent.image_map.insert(image.clone());
+ let name = eco_format!("Im{}", ctx.parent.image_map.map(image.clone()));
+ let w = size.x.to_f32();
+ let h = size.y.to_f32();
+ ctx.content.save_state();
+ ctx.content.transform([w, 0.0, 0.0, -h, x, y + h]);
+
+ if let Some(alt) = image.alt() {
+ let mut image_span =
+ ctx.content.begin_marked_content_with_properties(Name(b"Span"));
+ let mut image_alt = image_span.properties();
+ image_alt.pair(Name(b"Alt"), pdf_writer::Str(alt.as_bytes()));
+ image_alt.finish();
+ image_span.finish();
+
+ ctx.content.x_object(Name(name.as_bytes()));
+ ctx.content.end_marked_content();
+ } else {
+ ctx.content.x_object(Name(name.as_bytes()));
+ }
+
+ ctx.content.restore_state();
+}
+
+/// Save a link for later writing in the annotations dictionary.
+fn write_link(ctx: &mut PageContext, pos: Point, dest: &Destination, size: 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();
+
+ // Compute the bounding box of the transformed link.
+ for point in [
+ pos,
+ pos + Point::with_x(size.x),
+ pos + Point::with_y(size.y),
+ pos + size.to_point(),
+ ] {
+ let t = point.transform(ctx.state.transform);
+ min_x.set_min(t.x);
+ min_y.set_min(t.y);
+ max_x.set_max(t.x);
+ max_y.set_max(t.y);
+ }
+
+ let x1 = min_x.to_f32();
+ let x2 = max_x.to_f32();
+ let y1 = max_y.to_f32();
+ let y2 = min_y.to_f32();
+ let rect = Rect::new(x1, y1, x2, y2);
+
+ ctx.links.push((dest.clone(), rect));
+}
+
+impl From<&LineCap> for LineCapStyle {
+ fn from(line_cap: &LineCap) -> Self {
+ match line_cap {
+ LineCap::Butt => LineCapStyle::ButtCap,
+ LineCap::Round => LineCapStyle::RoundCap,
+ LineCap::Square => LineCapStyle::ProjectingSquareCap,
+ }
+ }
+}
+
+impl From<&LineJoin> for LineJoinStyle {
+ fn from(line_join: &LineJoin) -> Self {
+ match line_join {
+ LineJoin::Miter => LineJoinStyle::MiterJoin,
+ LineJoin::Round => LineJoinStyle::RoundJoin,
+ LineJoin::Bevel => LineJoinStyle::BevelJoin,
+ }
+ }
+}
diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs
new file mode 100644
index 00000000..d8115b12
--- /dev/null
+++ b/crates/typst/src/export/render.rs
@@ -0,0 +1,673 @@
+//! Rendering into raster images.
+
+use std::io::Read;
+use std::sync::Arc;
+
+use image::imageops::FilterType;
+use image::{GenericImageView, Rgba};
+use pixglyph::Bitmap;
+use resvg::FitTo;
+use tiny_skia as sk;
+use ttf_parser::{GlyphId, OutlineBuilder};
+use usvg::{NodeExt, TreeParsing};
+
+use crate::doc::{Frame, FrameItem, GroupItem, Meta, TextItem};
+use crate::font::Font;
+use crate::geom::{
+ self, Abs, Color, Geometry, LineCap, LineJoin, Paint, PathItem, Shape, Size, Stroke,
+ Transform,
+};
+use crate::image::{DecodedImage, Image};
+
+/// Export a frame into a raster image.
+///
+/// This renders the frame at the given number of pixels per point and returns
+/// the resulting `tiny-skia` pixel buffer.
+pub fn render(frame: &Frame, pixel_per_pt: f32, fill: Color) -> sk::Pixmap {
+ let size = frame.size();
+ let pxw = (pixel_per_pt * size.x.to_f32()).round().max(1.0) as u32;
+ let pxh = (pixel_per_pt * size.y.to_f32()).round().max(1.0) as u32;
+
+ let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap();
+ canvas.fill(fill.into());
+
+ let ts = sk::Transform::from_scale(pixel_per_pt, pixel_per_pt);
+ render_frame(&mut canvas, ts, None, frame);
+
+ canvas
+}
+
+/// Render a frame into the canvas.
+fn render_frame(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ frame: &Frame,
+) {
+ for (pos, item) in frame.items() {
+ let x = pos.x.to_f32();
+ let y = pos.y.to_f32();
+ let ts = ts.pre_translate(x, y);
+
+ match item {
+ FrameItem::Group(group) => {
+ render_group(canvas, ts, mask, group);
+ }
+ FrameItem::Text(text) => {
+ render_text(canvas, ts, mask, text);
+ }
+ FrameItem::Shape(shape, _) => {
+ render_shape(canvas, ts, mask, shape);
+ }
+ FrameItem::Image(image, size, _) => {
+ render_image(canvas, ts, mask, image, *size);
+ }
+ FrameItem::Meta(meta, _) => match meta {
+ Meta::Link(_) => {}
+ Meta::Elem(_) => {}
+ Meta::PageNumbering(_) => {}
+ Meta::Hide => {}
+ },
+ }
+ }
+}
+
+/// Render a group frame with optional transform and clipping into the canvas.
+fn render_group(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ group: &GroupItem,
+) {
+ let ts = ts.pre_concat(group.transform.into());
+
+ let mut mask = mask;
+ let storage;
+ if group.clips {
+ let size = group.frame.size();
+ let w = size.x.to_f32();
+ let h = size.y.to_f32();
+ if let Some(path) = sk::Rect::from_xywh(0.0, 0.0, w, h)
+ .map(sk::PathBuilder::from_rect)
+ .and_then(|path| path.transform(ts))
+ {
+ if let Some(mask) = mask {
+ let mut mask = mask.clone();
+ mask.intersect_path(
+ &path,
+ sk::FillRule::default(),
+ false,
+ sk::Transform::default(),
+ );
+ storage = mask;
+ } else {
+ let pxw = canvas.width();
+ let pxh = canvas.height();
+ let Some(mut mask) = sk::Mask::new(pxw, pxh) else {
+ // Fails if clipping rect is empty. In that case we just
+ // clip everything by returning.
+ return;
+ };
+
+ mask.fill_path(
+ &path,
+ sk::FillRule::default(),
+ false,
+ sk::Transform::default(),
+ );
+ storage = mask;
+ };
+
+ mask = Some(&storage);
+ }
+ }
+
+ render_frame(canvas, ts, mask, &group.frame);
+}
+
+/// Render a text run into the canvas.
+fn render_text(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ text: &TextItem,
+) {
+ let mut x = 0.0;
+ for glyph in &text.glyphs {
+ let id = GlyphId(glyph.id);
+ let offset = x + glyph.x_offset.at(text.size).to_f32();
+ let ts = ts.pre_translate(offset, 0.0);
+
+ render_svg_glyph(canvas, ts, mask, text, id)
+ .or_else(|| render_bitmap_glyph(canvas, ts, mask, text, id))
+ .or_else(|| render_outline_glyph(canvas, ts, mask, text, id));
+
+ x += glyph.x_advance.at(text.size).to_f32();
+ }
+}
+
+/// Render an SVG glyph into the canvas.
+fn render_svg_glyph(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ text: &TextItem,
+ id: GlyphId,
+) -> Option<()> {
+ let mut data = text.font.ttf().glyph_svg_image(id)?;
+
+ // 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()?;
+ let root = document.root_element();
+
+ // Parse SVG.
+ let opts = usvg::Options::default();
+ let tree = usvg::Tree::from_xmltree(&document, &opts).ok()?;
+ let view_box = tree.view_box.rect;
+
+ // If there's no viewbox defined, use the em square for our scale
+ // transformation ...
+ let upem = text.font.units_per_em() as f32;
+ let (mut width, mut height) = (upem, upem);
+
+ // ... but if there's a viewbox or width, use that.
+ if root.has_attribute("viewBox") || root.has_attribute("width") {
+ width = view_box.width() as f32;
+ }
+
+ // Same as for width.
+ if root.has_attribute("viewBox") || root.has_attribute("height") {
+ height = view_box.height() as f32;
+ }
+
+ let size = text.size.to_f32();
+ let ts = ts.pre_scale(size / width, size / height);
+
+ // Compute the space we need to draw our glyph.
+ // See https://github.com/RazrFalcon/resvg/issues/602 for why
+ // using the svg size is problematic here.
+ let mut bbox = usvg::Rect::new_bbox();
+ for node in tree.root.descendants() {
+ if let Some(rect) = node.calculate_bbox().and_then(|b| b.to_rect()) {
+ bbox = bbox.expand(rect);
+ }
+ }
+
+ let canvas_rect = usvg::ScreenRect::new(0, 0, canvas.width(), canvas.height())?;
+
+ // Compute the bbox after the transform is applied.
+ // We add a nice 5px border along the bounding box to
+ // be on the safe size. We also compute the intersection
+ // with the canvas rectangle
+ let svg_ts = usvg::Transform::new(
+ ts.sx.into(),
+ ts.kx.into(),
+ ts.ky.into(),
+ ts.sy.into(),
+ ts.tx.into(),
+ ts.ty.into(),
+ );
+ let bbox = bbox.transform(&svg_ts)?.to_screen_rect();
+ let bbox = usvg::ScreenRect::new(
+ bbox.left() - 5,
+ bbox.y() - 5,
+ bbox.width() + 10,
+ bbox.height() + 10,
+ )?
+ .fit_to_rect(canvas_rect);
+
+ let mut pixmap = sk::Pixmap::new(bbox.width(), bbox.height())?;
+
+ // We offset our transform so that the pixmap starts at the edge of the bbox.
+ let ts = ts.post_translate(-bbox.left() as f32, -bbox.top() as f32);
+ resvg::render(&tree, FitTo::Original, ts, pixmap.as_mut())?;
+
+ canvas.draw_pixmap(
+ bbox.left(),
+ bbox.top(),
+ pixmap.as_ref(),
+ &sk::PixmapPaint::default(),
+ sk::Transform::identity(),
+ mask,
+ );
+
+ Some(())
+}
+
+/// Render a bitmap glyph into the canvas.
+fn render_bitmap_glyph(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ text: &TextItem,
+ id: GlyphId,
+) -> Option<()> {
+ let size = text.size.to_f32();
+ let ppem = size * ts.sy;
+ let raster = text.font.ttf().glyph_raster_image(id, ppem as u16)?;
+ let image = Image::new(raster.data.into(), raster.format.into(), None).ok()?;
+
+ // FIXME: Vertical alignment isn't quite right for Apple Color Emoji,
+ // and maybe also for Noto Color Emoji. And: Is the size calculation
+ // correct?
+ let h = text.size;
+ let w = (image.width() as f64 / image.height() as f64) * h;
+ let dx = (raster.x as f32) / (image.width() as f32) * size;
+ let dy = (raster.y as f32) / (image.height() as f32) * size;
+ let ts = ts.pre_translate(dx, -size - dy);
+ render_image(canvas, ts, mask, &image, Size::new(w, h))
+}
+
+/// Render an outline glyph into the canvas. This is the "normal" case.
+fn render_outline_glyph(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ text: &TextItem,
+ id: GlyphId,
+) -> Option<()> {
+ let ppem = text.size.to_f32() * ts.sy;
+
+ // Render a glyph directly as a path. This only happens when the fast glyph
+ // rasterization can't be used due to very large text size or weird
+ // scale/skewing transforms.
+ if ppem > 100.0 || ts.kx != 0.0 || ts.ky != 0.0 || ts.sx != ts.sy {
+ let path = {
+ let mut builder = WrappedPathBuilder(sk::PathBuilder::new());
+ text.font.ttf().outline_glyph(id, &mut builder)?;
+ builder.0.finish()?
+ };
+
+ let paint = (&text.fill).into();
+ let rule = sk::FillRule::default();
+
+ // Flip vertically because font design coordinate
+ // system is Y-up.
+ let scale = text.size.to_f32() / text.font.units_per_em() as f32;
+ let ts = ts.pre_scale(scale, -scale);
+ canvas.fill_path(&path, &paint, rule, ts, mask);
+ return Some(());
+ }
+
+ // Rasterize the glyph with `pixglyph`.
+ #[comemo::memoize]
+ fn rasterize(
+ font: &Font,
+ id: GlyphId,
+ x: u32,
+ y: u32,
+ size: u32,
+ ) -> Option<Arc<Bitmap>> {
+ let glyph = pixglyph::Glyph::load(font.ttf(), id)?;
+ Some(Arc::new(glyph.rasterize(
+ f32::from_bits(x),
+ f32::from_bits(y),
+ f32::from_bits(size),
+ )))
+ }
+
+ // Try to retrieve a prepared glyph or prepare it from scratch if it
+ // doesn't exist, yet.
+ let bitmap =
+ rasterize(&text.font, id, ts.tx.to_bits(), ts.ty.to_bits(), ppem.to_bits())?;
+
+ // If we have a clip mask we first render to a pixmap that we then blend
+ // with our canvas
+ if mask.is_some() {
+ let mw = bitmap.width;
+ let mh = bitmap.height;
+
+ let Paint::Solid(color) = text.fill;
+ let c = color.to_rgba();
+
+ // Pad the pixmap with 1 pixel in each dimension so that we do
+ // not get any problem with floating point errors along their border
+ let mut pixmap = sk::Pixmap::new(mw + 2, mh + 2)?;
+ for x in 0..mw {
+ for y in 0..mh {
+ let alpha = bitmap.coverage[(y * mw + x) as usize];
+ let color = sk::ColorU8::from_rgba(c.r, c.g, c.b, alpha).premultiply();
+ pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] = color;
+ }
+ }
+
+ let left = bitmap.left;
+ let top = bitmap.top;
+
+ canvas.draw_pixmap(
+ left - 1,
+ top - 1,
+ pixmap.as_ref(),
+ &sk::PixmapPaint::default(),
+ sk::Transform::identity(),
+ mask,
+ );
+ } else {
+ let cw = canvas.width() as i32;
+ let ch = canvas.height() as i32;
+ let mw = bitmap.width as i32;
+ let mh = bitmap.height as i32;
+
+ // Determine the pixel bounding box that we actually need to draw.
+ let left = bitmap.left;
+ let right = left + mw;
+ let top = bitmap.top;
+ let bottom = top + mh;
+
+ // Premultiply the text color.
+ let Paint::Solid(color) = text.fill;
+ let c = color.to_rgba();
+ let color = sk::ColorU8::from_rgba(c.r, c.g, c.b, 255).premultiply().get();
+
+ // Blend the glyph bitmap with the existing pixels on the canvas.
+ let pixels = bytemuck::cast_slice_mut::<u8, u32>(canvas.data_mut());
+ for x in left.clamp(0, cw)..right.clamp(0, cw) {
+ for y in top.clamp(0, ch)..bottom.clamp(0, ch) {
+ let ai = ((y - top) * mw + (x - left)) as usize;
+ let cov = bitmap.coverage[ai];
+ if cov == 0 {
+ continue;
+ }
+
+ let pi = (y * cw + x) as usize;
+ if cov == 255 {
+ pixels[pi] = color;
+ continue;
+ }
+
+ let applied = alpha_mul(color, cov as u32);
+ pixels[pi] = blend_src_over(applied, pixels[pi]);
+ }
+ }
+ }
+
+ Some(())
+}
+
+/// Render a geometrical shape into the canvas.
+fn render_shape(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ shape: &Shape,
+) -> Option<()> {
+ let path = match shape.geometry {
+ Geometry::Line(target) => {
+ let mut builder = sk::PathBuilder::new();
+ builder.line_to(target.x.to_f32(), target.y.to_f32());
+ builder.finish()?
+ }
+ Geometry::Rect(size) => {
+ let w = size.x.to_f32();
+ let h = size.y.to_f32();
+ let rect = sk::Rect::from_xywh(0.0, 0.0, w, h)?;
+ sk::PathBuilder::from_rect(rect)
+ }
+ Geometry::Path(ref path) => convert_path(path)?,
+ };
+
+ if let Some(fill) = &shape.fill {
+ let mut paint: sk::Paint = fill.into();
+ if matches!(shape.geometry, Geometry::Rect(_)) {
+ paint.anti_alias = false;
+ }
+
+ let rule = sk::FillRule::default();
+ canvas.fill_path(&path, &paint, rule, ts, mask);
+ }
+
+ if let Some(Stroke {
+ paint,
+ thickness,
+ line_cap,
+ line_join,
+ dash_pattern,
+ miter_limit,
+ }) = &shape.stroke
+ {
+ let width = thickness.to_f32();
+
+ // Don't draw zero-pt stroke.
+ if width > 0.0 {
+ let dash = dash_pattern.as_ref().and_then(|pattern| {
+ // tiny-skia only allows dash patterns with an even number of elements,
+ // while pdf allows any number.
+ let pattern_len = pattern.array.len();
+ let len =
+ if pattern_len % 2 == 1 { 2 * pattern_len } else { pattern_len };
+ let dash_array =
+ pattern.array.iter().map(|l| l.to_f32()).cycle().take(len).collect();
+
+ sk::StrokeDash::new(dash_array, pattern.phase.to_f32())
+ });
+ let paint = paint.into();
+ let stroke = sk::Stroke {
+ width,
+ line_cap: line_cap.into(),
+ line_join: line_join.into(),
+ dash,
+ miter_limit: miter_limit.0 as f32,
+ };
+ canvas.stroke_path(&path, &paint, &stroke, ts, mask);
+ }
+ }
+
+ Some(())
+}
+
+/// Convert a Typst path into a tiny-skia path.
+fn convert_path(path: &geom::Path) -> Option<sk::Path> {
+ let mut builder = sk::PathBuilder::new();
+ for elem in &path.0 {
+ match elem {
+ PathItem::MoveTo(p) => {
+ builder.move_to(p.x.to_f32(), p.y.to_f32());
+ }
+ PathItem::LineTo(p) => {
+ builder.line_to(p.x.to_f32(), p.y.to_f32());
+ }
+ PathItem::CubicTo(p1, p2, p3) => {
+ builder.cubic_to(
+ p1.x.to_f32(),
+ p1.y.to_f32(),
+ p2.x.to_f32(),
+ p2.y.to_f32(),
+ p3.x.to_f32(),
+ p3.y.to_f32(),
+ );
+ }
+ PathItem::ClosePath => {
+ builder.close();
+ }
+ };
+ }
+ builder.finish()
+}
+
+/// Render a raster or SVG image into the canvas.
+fn render_image(
+ canvas: &mut sk::Pixmap,
+ ts: sk::Transform,
+ mask: Option<&sk::Mask>,
+ image: &Image,
+ size: Size,
+) -> Option<()> {
+ let view_width = size.x.to_f32();
+ let view_height = size.y.to_f32();
+
+ // For better-looking output, resize `image` to its final size before
+ // painting it to `canvas`. For the math, see:
+ // https://github.com/typst/typst/issues/1404#issuecomment-1598374652
+ let theta = f32::atan2(-ts.kx, ts.sx);
+
+ // To avoid division by 0, choose the one of { sin, cos } that is
+ // further from 0.
+ let prefer_sin = theta.sin().abs() > std::f32::consts::FRAC_1_SQRT_2;
+ let scale_x =
+ f32::abs(if prefer_sin { ts.kx / theta.sin() } else { ts.sx / theta.cos() });
+
+ let aspect = (image.width() as f32) / (image.height() as f32);
+ let w = (scale_x * view_width.max(aspect * view_height)).ceil() as u32;
+ let h = ((w as f32) / aspect).ceil() as u32;
+
+ let pixmap = scaled_texture(image, w, h)?;
+ let paint_scale_x = view_width / pixmap.width() as f32;
+ let paint_scale_y = view_height / pixmap.height() as f32;
+
+ let paint = sk::Paint {
+ shader: sk::Pattern::new(
+ (*pixmap).as_ref(),
+ sk::SpreadMode::Pad,
+ sk::FilterQuality::Nearest,
+ 1.0,
+ sk::Transform::from_scale(paint_scale_x, paint_scale_y),
+ ),
+ ..Default::default()
+ };
+
+ let rect = sk::Rect::from_xywh(0.0, 0.0, view_width, view_height)?;
+ canvas.fill_rect(rect, &paint, ts, mask);
+
+ Some(())
+}
+
+/// Prepare a texture for an image at a scaled size.
+#[comemo::memoize]
+fn scaled_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> {
+ let mut pixmap = sk::Pixmap::new(w, h)?;
+ match image.decoded().as_ref() {
+ DecodedImage::Raster(dynamic, _, _) => {
+ let downscale = w < image.width();
+ let filter =
+ if downscale { FilterType::Lanczos3 } else { FilterType::CatmullRom };
+ let buf = dynamic.resize(w, h, filter);
+ for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) {
+ let Rgba([r, g, b, a]) = src;
+ *dest = sk::ColorU8::from_rgba(r, g, b, a).premultiply();
+ }
+ }
+ DecodedImage::Svg(tree) => {
+ resvg::render(
+ tree,
+ FitTo::Size(w, h),
+ sk::Transform::identity(),
+ pixmap.as_mut(),
+ )?;
+ }
+ }
+ Some(Arc::new(pixmap))
+}
+
+impl From<Transform> for sk::Transform {
+ fn from(transform: Transform) -> Self {
+ let Transform { sx, ky, kx, sy, tx, ty } = transform;
+ sk::Transform::from_row(
+ sx.get() as _,
+ ky.get() as _,
+ kx.get() as _,
+ sy.get() as _,
+ tx.to_f32(),
+ ty.to_f32(),
+ )
+ }
+}
+
+impl From<&Paint> for sk::Paint<'static> {
+ fn from(paint: &Paint) -> Self {
+ let mut sk_paint = sk::Paint::default();
+ let Paint::Solid(color) = *paint;
+ sk_paint.set_color(color.into());
+ sk_paint.anti_alias = true;
+ sk_paint
+ }
+}
+
+impl From<Color> for sk::Color {
+ fn from(color: Color) -> Self {
+ let c = color.to_rgba();
+ sk::Color::from_rgba8(c.r, c.g, c.b, c.a)
+ }
+}
+
+impl From<&LineCap> for sk::LineCap {
+ fn from(line_cap: &LineCap) -> Self {
+ match line_cap {
+ LineCap::Butt => sk::LineCap::Butt,
+ LineCap::Round => sk::LineCap::Round,
+ LineCap::Square => sk::LineCap::Square,
+ }
+ }
+}
+
+impl From<&LineJoin> for sk::LineJoin {
+ fn from(line_join: &LineJoin) -> Self {
+ match line_join {
+ LineJoin::Miter => sk::LineJoin::Miter,
+ LineJoin::Round => sk::LineJoin::Round,
+ LineJoin::Bevel => sk::LineJoin::Bevel,
+ }
+ }
+}
+
+/// Allows to build tiny-skia paths from glyph outlines.
+struct WrappedPathBuilder(sk::PathBuilder);
+
+impl OutlineBuilder for WrappedPathBuilder {
+ fn move_to(&mut self, x: f32, y: f32) {
+ self.0.move_to(x, y);
+ }
+
+ fn line_to(&mut self, x: f32, y: f32) {
+ self.0.line_to(x, y);
+ }
+
+ fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+ self.0.quad_to(x1, y1, x, y);
+ }
+
+ fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+ self.0.cubic_to(x1, y1, x2, y2, x, y);
+ }
+
+ fn close(&mut self) {
+ self.0.close();
+ }
+}
+
+/// Additional methods for [`Length`].
+trait AbsExt {
+ /// Convert to a number of points as f32.
+ fn to_f32(self) -> f32;
+}
+
+impl AbsExt for Abs {
+ fn to_f32(self) -> f32 {
+ self.to_pt() as f32
+ }
+}
+
+// Alpha multiplication and blending are ported from:
+// https://skia.googlesource.com/skia/+/refs/heads/main/include/core/SkColorPriv.h
+
+/// Blends two premulitplied, packed 32-bit RGBA colors. Alpha channel must be
+/// in the 8 high bits.
+fn blend_src_over(src: u32, dst: u32) -> u32 {
+ src + alpha_mul(dst, 256 - (src >> 24))
+}
+
+/// Alpha multiply a color.
+fn alpha_mul(color: u32, scale: u32) -> u32 {
+ let mask = 0xff00ff;
+ let rb = ((color & mask) * scale) >> 8;
+ let ag = ((color >> 8) & mask) * scale;
+ (rb & mask) | (ag & !mask)
+}
diff --git a/crates/typst/src/file.rs b/crates/typst/src/file.rs
new file mode 100644
index 00000000..8aaa746b
--- /dev/null
+++ b/crates/typst/src/file.rs
@@ -0,0 +1,303 @@
+//! File and package management.
+
+use std::collections::HashMap;
+use std::fmt::{self, Debug, Display, Formatter};
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+use std::sync::RwLock;
+
+use ecow::{eco_format, EcoString};
+use once_cell::sync::Lazy;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+use crate::diag::{bail, FileError, StrResult};
+use crate::syntax::is_ident;
+use crate::util::PathExt;
+
+/// The global package-path interner.
+static INTERNER: Lazy<RwLock<Interner>> =
+ Lazy::new(|| RwLock::new(Interner { to_id: HashMap::new(), from_id: Vec::new() }));
+
+/// A package-path interner.
+struct Interner {
+ to_id: HashMap<Pair, FileId>,
+ from_id: Vec<Pair>,
+}
+
+/// An interned pair of a package specification and a path.
+type Pair = &'static (Option<PackageSpec>, PathBuf);
+
+/// Identifies a file.
+///
+/// This type is globally interned and thus cheap to copy, compare, and hash.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct FileId(u16);
+
+impl FileId {
+ /// Create a new interned file specification.
+ ///
+ /// The path must start with a `/` or this function will panic.
+ /// Note that the path is normalized before interning.
+ #[track_caller]
+ pub fn new(package: Option<PackageSpec>, path: &Path) -> Self {
+ assert_eq!(
+ path.components().next(),
+ Some(std::path::Component::RootDir),
+ "file path must be absolute within project or package: {}",
+ path.display(),
+ );
+
+ // Try to find an existing entry that we can reuse.
+ let pair = (package, path.normalize());
+ if let Some(&id) = INTERNER.read().unwrap().to_id.get(&pair) {
+ return id;
+ }
+
+ let mut interner = INTERNER.write().unwrap();
+ let len = interner.from_id.len();
+ if len >= usize::from(u16::MAX) {
+ panic!("too many file specifications");
+ }
+
+ // Create a new entry forever by leaking the pair. We can't leak more
+ // than 2^16 pair (and typically will leak a lot less), so its not a
+ // big deal.
+ let id = FileId(len as u16);
+ let leaked = Box::leak(Box::new(pair));
+ interner.to_id.insert(leaked, id);
+ interner.from_id.push(leaked);
+ id
+ }
+
+ /// Get an id that does not identify any real file.
+ pub const fn detached() -> Self {
+ Self(u16::MAX)
+ }
+
+ /// Whether the id is the detached.
+ pub const fn is_detached(self) -> bool {
+ self.0 == Self::detached().0
+ }
+
+ /// The package the file resides in, if any.
+ pub fn package(&self) -> Option<&'static PackageSpec> {
+ if self.is_detached() {
+ None
+ } else {
+ self.pair().0.as_ref()
+ }
+ }
+
+ /// The absolute and normalized path to the file _within_ the project or
+ /// package.
+ pub fn path(&self) -> &'static Path {
+ if self.is_detached() {
+ Path::new("/detached.typ")
+ } else {
+ &self.pair().1
+ }
+ }
+
+ /// Resolve a file location relative to this file.
+ pub fn join(self, path: &str) -> StrResult<Self> {
+ if self.is_detached() {
+ bail!("cannot access file system from here");
+ }
+
+ let package = self.package().cloned();
+ let base = self.path();
+ Ok(if let Some(parent) = base.parent() {
+ Self::new(package, &parent.join(path))
+ } else {
+ Self::new(package, Path::new(path))
+ })
+ }
+
+ /// Construct from a raw number.
+ pub(crate) const fn from_u16(v: u16) -> Self {
+ Self(v)
+ }
+
+ /// Extract the raw underlying number.
+ pub(crate) const fn as_u16(self) -> u16 {
+ self.0
+ }
+
+ /// Get the static pair.
+ fn pair(&self) -> Pair {
+ INTERNER.read().unwrap().from_id[usize::from(self.0)]
+ }
+}
+
+impl Display for FileId {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ let path = self.path().display();
+ match self.package() {
+ Some(package) => write!(f, "{package}{path}"),
+ None => write!(f, "{path}"),
+ }
+ }
+}
+
+impl Debug for FileId {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ Display::fmt(self, f)
+ }
+}
+
+/// Identifies a package.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct PackageSpec {
+ /// The namespace the package lives in.
+ pub namespace: EcoString,
+ /// The name of the package within its namespace.
+ pub name: EcoString,
+ /// The package's version.
+ pub version: Version,
+}
+
+impl FromStr for PackageSpec {
+ type Err = EcoString;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let mut s = unscanny::Scanner::new(s);
+ if !s.eat_if('@') {
+ bail!("package specification must start with '@'");
+ }
+
+ let namespace = s.eat_until('/');
+ if namespace.is_empty() {
+ bail!("package specification is missing namespace");
+ } else if !is_ident(namespace) {
+ bail!("`{namespace}` is not a valid package namespace");
+ }
+
+ s.eat_if('/');
+
+ let name = s.eat_until(':');
+ if name.is_empty() {
+ bail!("package specification is missing name");
+ } else if !is_ident(name) {
+ bail!("`{name}` is not a valid package name");
+ }
+
+ s.eat_if(':');
+
+ let version = s.after();
+ if version.is_empty() {
+ bail!("package specification is missing version");
+ }
+
+ Ok(Self {
+ namespace: namespace.into(),
+ name: name.into(),
+ version: version.parse()?,
+ })
+ }
+}
+
+impl Display for PackageSpec {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "@{}/{}:{}", self.namespace, self.name, self.version)
+ }
+}
+
+/// A package's version.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Version {
+ /// The package's major version.
+ pub major: u32,
+ /// The package's minor version.
+ pub minor: u32,
+ /// The package's patch version.
+ pub patch: u32,
+}
+
+impl FromStr for Version {
+ type Err = EcoString;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let mut parts = s.split('.');
+ let mut next = |kind| {
+ let Some(part) = parts.next().filter(|s| !s.is_empty()) else {
+ bail!("version number is missing {kind} version");
+ };
+ part.parse::<u32>()
+ .map_err(|_| eco_format!("`{part}` is not a valid {kind} version"))
+ };
+
+ let major = next("major")?;
+ let minor = next("minor")?;
+ let patch = next("patch")?;
+ if let Some(rest) = parts.next() {
+ bail!("version number has unexpected fourth component: `{rest}`");
+ }
+
+ Ok(Self { major, minor, patch })
+ }
+}
+
+impl Display for Version {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
+ }
+}
+
+impl Serialize for Version {
+ fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
+ s.collect_str(self)
+ }
+}
+
+impl<'de> Deserialize<'de> for Version {
+ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
+ let string = EcoString::deserialize(d)?;
+ string.parse().map_err(serde::de::Error::custom)
+ }
+}
+
+/// A parsed package manifest.
+#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
+pub struct PackageManifest {
+ /// Details about the package itself.
+ pub package: PackageInfo,
+}
+
+impl PackageManifest {
+ /// Parse the manifest from raw bytes.
+ pub fn parse(bytes: &[u8]) -> StrResult<Self> {
+ let string = std::str::from_utf8(bytes).map_err(FileError::from)?;
+ toml::from_str(string).map_err(|err| {
+ eco_format!("package manifest is malformed: {}", err.message())
+ })
+ }
+
+ /// Ensure that this manifest is indeed for the specified package.
+ pub fn validate(&self, spec: &PackageSpec) -> StrResult<()> {
+ if self.package.name != spec.name {
+ bail!("package manifest contains mismatched name `{}`", self.package.name);
+ }
+
+ if self.package.version != spec.version {
+ bail!(
+ "package manifest contains mismatched version {}",
+ self.package.version
+ );
+ }
+
+ Ok(())
+ }
+}
+
+/// The `package` key in the manifest.
+///
+/// More fields are specified, but they are not relevant to the compiler.
+#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
+pub struct PackageInfo {
+ /// The name of the package within its namespace.
+ pub name: EcoString,
+ /// The package's version.
+ pub version: Version,
+ /// The path of the entrypoint into the package.
+ pub entrypoint: EcoString,
+}
diff --git a/crates/typst/src/font/book.rs b/crates/typst/src/font/book.rs
new file mode 100644
index 00000000..2b7742bf
--- /dev/null
+++ b/crates/typst/src/font/book.rs
@@ -0,0 +1,546 @@
+use std::cmp::Reverse;
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+use ttf_parser::{name_id, PlatformId, Tag};
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::{Font, FontStretch, FontStyle, FontVariant, FontWeight};
+
+/// Metadata about a collection of fonts.
+#[derive(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 for a collection of fonts.
+ pub fn from_fonts<'a>(fonts: impl IntoIterator<Item = &'a Font>) -> Self {
+ let mut book = Self::new();
+ for font in fonts {
+ book.push(font.info().clone());
+ }
+ book
+ }
+
+ /// 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)
+ }
+
+ /// 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 char ...
+ let c = text.chars().next()?;
+ 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 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| {
+ let ttf = ttf_parser::Face::parse(data, index).ok()?;
+ Self::from_ttf(&ttf)
+ })
+ }
+
+ /// Compute metadata for a single ttf-parser face.
+ pub(super) fn from_ttf(ttf: &ttf_parser::Face) -> Option<Self> {
+ // 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").
+ //
+ // Also, for Noto fonts we use Name ID 4 "Full Name" instead,
+ // because Name ID 1 "Family" sometimes contains "Display" and
+ // sometimes doesn't for the Display variants and that mixes things
+ // up.
+ let family = {
+ let mut family = find_name(ttf, name_id::FAMILY)?;
+ if family.starts_with("Noto")
+ || family.starts_with("NewCM")
+ || family.starts_with("NewComputerModern")
+ {
+ family = find_name(ttf, name_id::FULL_NAME)?;
+ }
+ typographic_family(&family).to_string()
+ };
+
+ let variant = {
+ 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");
+
+ let style = match (italic, oblique) {
+ (false, false) => FontStyle::Normal,
+ (true, _) => FontStyle::Italic,
+ (_, true) => FontStyle::Oblique,
+ };
+
+ let weight = {
+ let mut number = ttf.weight().to_number();
+ if (family.starts_with("NewCM")
+ || family.starts_with("New Computer Modern"))
+ && full.contains("book")
+ {
+ number += 50;
+ }
+ FontWeight::from_number(number)
+ };
+
+ let stretch = 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),
+ })
+ }
+}
+
+/// 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"
+ ];
+
+ let mut extra = [].as_slice();
+ let newcm = family.starts_with("NewCM") || family.starts_with("NewComputerModern");
+ if newcm {
+ extra = &["book"];
+ }
+
+ // 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().chain(extra).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];
+
+ if newcm {
+ family = family.trim_end_matches("10");
+ }
+
+ // Fix bad names.
+ match family {
+ "Noto Sans Symbols2" => "Noto Sans Symbols 2",
+ "NewComputerModern" => "New Computer Modern",
+ "NewComputerModernMono" => "New Computer Modern Mono",
+ "NewComputerModernSans" => "New Computer Modern Sans",
+ "NewComputerModernMath" => "New Computer Modern Math",
+ "NewCMUncial" | "NewComputerModernUncial" => "New Computer Modern Uncial",
+ other => other,
+ }
+}
+
+/// 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(Debug, 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
+ })
+ }
+}
+
+#[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/src/font/mod.rs b/crates/typst/src/font/mod.rs
new file mode 100644
index 00000000..2353e51c
--- /dev/null
+++ b/crates/typst/src/font/mod.rs
@@ -0,0 +1,249 @@
+//! Font handling.
+
+mod book;
+mod variant;
+
+pub use self::book::{Coverage, FontBook, FontFlags, FontInfo};
+pub use self::variant::{FontStretch, FontStyle, FontVariant, FontWeight};
+
+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::eval::Cast;
+use crate::geom::Em;
+use crate::util::Bytes;
+
+/// 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
+ }
+}
+
+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)
+ }
+}
+
+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,
+}
diff --git a/crates/typst/src/font/variant.rs b/crates/typst/src/font/variant.rs
new file mode 100644
index 00000000..d4508a59
--- /dev/null
+++ b/crates/typst/src/font/variant.rs
@@ -0,0 +1,270 @@
+use std::fmt::{self, Debug, Formatter};
+
+use serde::{Deserialize, Serialize};
+
+use crate::eval::{cast, Cast, IntoValue};
+use crate::geom::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
+ }
+}
+
+/// The weight of a font.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct FontWeight(u16);
+
+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.max(100).min(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).max(100).min(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)
+ }
+}
+
+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(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct FontStretch(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().max(0.5).min(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)
+ }
+
+ /// 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 Debug for FontStretch {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.to_ratio().fmt(f)
+ }
+}
+
+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!(format!("{:?}", FontStretch::EXPANDED), "125%")
+ }
+}
diff --git a/crates/typst/src/geom/abs.rs b/crates/typst/src/geom/abs.rs
new file mode 100644
index 00000000..4ca3a9a1
--- /dev/null
+++ b/crates/typst/src/geom/abs.rs
@@ -0,0 +1,266 @@
+use super::*;
+
+/// 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(0.0))
+ }
+
+ /// The infinite length.
+ pub const fn inf() -> Self {
+ Self(Scalar(f64::INFINITY))
+ }
+
+ /// Create an absolute length from a number of raw units.
+ pub const fn raw(raw: f64) -> Self {
+ Self(Scalar(raw))
+ }
+
+ /// Create an absolute length from a value in a unit.
+ pub fn with_unit(val: f64, unit: AbsUnit) -> Self {
+ Self(Scalar(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).0
+ }
+
+ /// 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 + 1e-6 >= 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() < 1e-6
+ }
+
+ /// Perform a checked division by a number, returning zero if the result
+ /// is not finite.
+ pub fn safe_div(self, number: f64) -> Self {
+ let result = self.to_raw() / number;
+ if result.is_finite() {
+ Self::raw(result)
+ } else {
+ Self::zero()
+ }
+ }
+}
+
+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", round_2(self.to_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)
+ }
+}
+
+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()
+ }
+}
+
+assign_impl!(Abs += Abs);
+assign_impl!(Abs -= Abs);
+assign_impl!(Abs *= f64);
+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())
+ }
+}
+
+cast! {
+ Abs,
+ self => Value::Length(self.into()),
+}
+
+/// Different units of absolute measurement.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub enum AbsUnit {
+ /// Points.
+ Pt,
+ /// Millimeters.
+ Mm,
+ /// Centimeters.
+ Cm,
+ /// Inches.
+ In,
+}
+
+impl AbsUnit {
+ /// How many raw units correspond to a value of `1.0` in this unit.
+ fn raw_scale(self) -> f64 {
+ match self {
+ AbsUnit::Pt => 1.0,
+ AbsUnit::Mm => 2.83465,
+ AbsUnit::Cm => 28.3465,
+ AbsUnit::In => 72.0,
+ }
+ }
+}
+
+impl Debug for AbsUnit {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(match self {
+ AbsUnit::Mm => "mm",
+ AbsUnit::Pt => "pt",
+ AbsUnit::Cm => "cm",
+ AbsUnit::In => "in",
+ })
+ }
+}
+
+#[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/src/geom/align.rs b/crates/typst/src/geom/align.rs
new file mode 100644
index 00000000..47acd3a6
--- /dev/null
+++ b/crates/typst/src/geom/align.rs
@@ -0,0 +1,239 @@
+use super::*;
+
+/// Where to align something along an axis.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum Align {
+ /// Align at the left side.
+ Left,
+ /// Align in the horizontal middle.
+ Center,
+ /// Align at the right side.
+ Right,
+ /// Align at the top side.
+ Top,
+ /// Align in the vertical middle.
+ Horizon,
+ /// Align at the bottom side.
+ Bottom,
+}
+
+impl Align {
+ /// Top-left alignment.
+ pub const LEFT_TOP: Axes<Self> = Axes { x: Align::Left, y: Align::Top };
+
+ /// Center-horizon alignment.
+ pub const CENTER_HORIZON: Axes<Self> = Axes { x: Align::Center, y: Align::Horizon };
+
+ /// The axis this alignment belongs to.
+ pub const fn axis(self) -> Axis {
+ match self {
+ Self::Left | Self::Center | Self::Right => Axis::X,
+ Self::Top | Self::Horizon | Self::Bottom => Axis::Y,
+ }
+ }
+
+ /// The inverse alignment.
+ pub const fn inv(self) -> Self {
+ match self {
+ Self::Left => Self::Right,
+ Self::Center => Self::Center,
+ Self::Right => Self::Left,
+ 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::Left | Self::Top => Abs::zero(),
+ Self::Center | Self::Horizon => extent / 2.0,
+ Self::Right | Self::Bottom => extent,
+ }
+ }
+}
+
+impl From<Side> for Align {
+ fn from(side: Side) -> Self {
+ match side {
+ Side::Left => Self::Left,
+ Side::Top => Self::Top,
+ Side::Right => Self::Right,
+ Side::Bottom => Self::Bottom,
+ }
+ }
+}
+
+impl Debug for Align {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(match self {
+ Self::Left => "left",
+ Self::Center => "center",
+ Self::Right => "right",
+ Self::Top => "top",
+ Self::Horizon => "horizon",
+ Self::Bottom => "bottom",
+ })
+ }
+}
+
+/// The generic alignment representation.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum GenAlign {
+ /// Align at the start side of the text direction.
+ Start,
+ /// Align at the end side of the text direction.
+ End,
+ /// Align at a specific alignment.
+ Specific(Align),
+}
+
+impl GenAlign {
+ /// The axis this alignment belongs to.
+ pub const fn axis(self) -> Axis {
+ match self {
+ Self::Start | Self::End => Axis::X,
+ Self::Specific(align) => align.axis(),
+ }
+ }
+}
+
+impl From<Align> for GenAlign {
+ fn from(align: Align) -> Self {
+ Self::Specific(align)
+ }
+}
+
+impl From<HorizontalAlign> for GenAlign {
+ fn from(align: HorizontalAlign) -> Self {
+ align.0
+ }
+}
+
+impl From<VerticalAlign> for GenAlign {
+ fn from(align: VerticalAlign) -> Self {
+ align.0
+ }
+}
+
+impl Debug for GenAlign {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Start => f.pad("start"),
+ Self::End => f.pad("end"),
+ Self::Specific(align) => align.fmt(f),
+ }
+ }
+}
+
+cast! {
+ type GenAlign: "alignment",
+}
+
+cast! {
+ type Axes<GenAlign>: "2d alignment",
+}
+
+cast! {
+ Axes<Align>,
+ self => self.map(GenAlign::from).into_value(),
+}
+
+cast! {
+ Axes<Option<GenAlign>>,
+ self => match (self.x, self.y) {
+ (Some(x), Some(y)) => Axes::new(x, y).into_value(),
+ (Some(x), None) => x.into_value(),
+ (None, Some(y)) => y.into_value(),
+ (None, None) => Value::None,
+ },
+ align: GenAlign => {
+ let mut aligns = Axes::default();
+ aligns.set(align.axis(), Some(align));
+ aligns
+ },
+ aligns: Axes<GenAlign> => aligns.map(Some),
+}
+
+impl From<Axes<GenAlign>> for Axes<Option<GenAlign>> {
+ fn from(axes: Axes<GenAlign>) -> Self {
+ axes.map(Some)
+ }
+}
+
+impl From<Axes<Align>> for Axes<Option<GenAlign>> {
+ fn from(axes: Axes<Align>) -> Self {
+ axes.map(GenAlign::Specific).into()
+ }
+}
+
+impl From<Align> for Axes<Option<GenAlign>> {
+ fn from(align: Align) -> Self {
+ let mut axes = Axes::splat(None);
+ axes.set(align.axis(), Some(align.into()));
+ axes
+ }
+}
+
+impl Resolve for GenAlign {
+ type Output = Align;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ let dir = item!(dir)(styles);
+ match self {
+ Self::Start => dir.start().into(),
+ Self::End => dir.end().into(),
+ Self::Specific(align) => align,
+ }
+ }
+}
+
+impl Fold for GenAlign {
+ type Output = Self;
+
+ fn fold(self, _: Self::Output) -> Self::Output {
+ self
+ }
+}
+
+impl Fold for Align {
+ type Output = Self;
+
+ fn fold(self, _: Self::Output) -> Self::Output {
+ self
+ }
+}
+
+/// Utility struct to restrict a passed alignment value to the horizontal axis
+/// on cast.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct HorizontalAlign(pub GenAlign);
+
+cast! {
+ HorizontalAlign,
+ self => self.0.into_value(),
+ align: GenAlign => {
+ if align.axis() != Axis::X {
+ bail!("alignment must be horizontal");
+ }
+ Self(align)
+ },
+}
+
+/// Utility struct to restrict a passed alignment value to the vertical axis on
+/// cast.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct VerticalAlign(pub GenAlign);
+
+cast! {
+ VerticalAlign,
+ self => self.0.into_value(),
+ align: GenAlign => {
+ if align.axis() != Axis::Y {
+ bail!("alignment must be vertical");
+ }
+ Self(align)
+ },
+}
diff --git a/crates/typst/src/geom/angle.rs b/crates/typst/src/geom/angle.rs
new file mode 100644
index 00000000..c03810d9
--- /dev/null
+++ b/crates/typst/src/geom/angle.rs
@@ -0,0 +1,188 @@
+use super::*;
+
+/// An angle.
+#[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(0.0))
+ }
+
+ /// Create an angle from a number of raw units.
+ pub const fn raw(raw: f64) -> Self {
+ Self(Scalar(raw))
+ }
+
+ /// Create an angle from a value in a unit.
+ pub fn with_unit(val: f64, unit: AngleUnit) -> Self {
+ Self(Scalar(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).0
+ }
+
+ /// Get the value of this angle in a unit.
+ pub fn to_unit(self, unit: AngleUnit) -> f64 {
+ self.to_raw() / unit.raw_scale()
+ }
+
+ /// Convert this to a number of radians.
+ pub fn to_rad(self) -> f64 {
+ self.to_unit(AngleUnit::Rad)
+ }
+
+ /// Convert this to a number of degrees.
+ pub fn to_deg(self) -> f64 {
+ self.to_unit(AngleUnit::Deg)
+ }
+
+ /// 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()
+ }
+}
+
+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", round_2(self.to_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)
+ }
+}
+
+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<f64> for Angle {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self(self.0 / other)
+ }
+}
+
+impl Div for Angle {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.to_raw() / other.to_raw()
+ }
+}
+
+assign_impl!(Angle += Angle);
+assign_impl!(Angle -= Angle);
+assign_impl!(Angle *= f64);
+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(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,
+ }
+ }
+}
+
+impl Debug for AngleUnit {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(match self {
+ Self::Rad => "rad",
+ Self::Deg => "deg",
+ })
+ }
+}
+
+#[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/src/geom/axes.rs b/crates/typst/src/geom/axes.rs
new file mode 100644
index 00000000..059d3bb2
--- /dev/null
+++ b/crates/typst/src/geom/axes.rs
@@ -0,0 +1,305 @@
+use std::any::Any;
+use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Not};
+
+use super::*;
+
+/// 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) }
+ }
+
+ /// 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)
+ }
+
+ /// Filter the individual fields with a mask.
+ pub fn filter(self, mask: Axes<bool>) -> Axes<Option<T>> {
+ Axes {
+ x: if mask.x { Some(self.x) } else { None },
+ y: if mask.y { Some(self.y) } else { None },
+ }
+ }
+}
+
+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<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 let Axes { x: Some(x), y: Some(y) } =
+ self.as_ref().map(|v| (v as &dyn Any).downcast_ref::<GenAlign>())
+ {
+ write!(f, "{:?} + {:?}", x, y)
+ } else 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(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,
+ }
+ }
+}
+
+impl Debug for Axis {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(match self {
+ Self::X => "horizontal",
+ Self::Y => "vertical",
+ })
+ }
+}
+
+impl<T> Axes<Option<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<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"),
+ }
+ },
+}
+
+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))
+ }
+}
+
+impl<T: Fold> Fold for Axes<Option<T>> {
+ type Output = Axes<T::Output>;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ self.zip(outer).map(|(inner, outer)| match inner {
+ Some(value) => value.fold(outer),
+ None => outer,
+ })
+ }
+}
diff --git a/crates/typst/src/geom/color.rs b/crates/typst/src/geom/color.rs
new file mode 100644
index 00000000..c7676c2b
--- /dev/null
+++ b/crates/typst/src/geom/color.rs
@@ -0,0 +1,386 @@
+use std::str::FromStr;
+
+use super::*;
+
+/// A color in a dynamic format.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Color {
+ /// An 8-bit luma color.
+ Luma(LumaColor),
+ /// An 8-bit RGBA color.
+ Rgba(RgbaColor),
+ /// An 8-bit CMYK color.
+ Cmyk(CmykColor),
+}
+
+impl Color {
+ pub const BLACK: Self = Self::Rgba(RgbaColor::new(0x00, 0x00, 0x00, 0xFF));
+ pub const GRAY: Self = Self::Rgba(RgbaColor::new(0xAA, 0xAA, 0xAA, 0xFF));
+ pub const SILVER: Self = Self::Rgba(RgbaColor::new(0xDD, 0xDD, 0xDD, 0xFF));
+ pub const WHITE: Self = Self::Rgba(RgbaColor::new(0xFF, 0xFF, 0xFF, 0xFF));
+ pub const NAVY: Self = Self::Rgba(RgbaColor::new(0x00, 0x1f, 0x3f, 0xFF));
+ pub const BLUE: Self = Self::Rgba(RgbaColor::new(0x00, 0x74, 0xD9, 0xFF));
+ pub const AQUA: Self = Self::Rgba(RgbaColor::new(0x7F, 0xDB, 0xFF, 0xFF));
+ pub const TEAL: Self = Self::Rgba(RgbaColor::new(0x39, 0xCC, 0xCC, 0xFF));
+ pub const EASTERN: Self = Self::Rgba(RgbaColor::new(0x23, 0x9D, 0xAD, 0xFF));
+ pub const PURPLE: Self = Self::Rgba(RgbaColor::new(0xB1, 0x0D, 0xC9, 0xFF));
+ pub const FUCHSIA: Self = Self::Rgba(RgbaColor::new(0xF0, 0x12, 0xBE, 0xFF));
+ pub const MAROON: Self = Self::Rgba(RgbaColor::new(0x85, 0x14, 0x4b, 0xFF));
+ pub const RED: Self = Self::Rgba(RgbaColor::new(0xFF, 0x41, 0x36, 0xFF));
+ pub const ORANGE: Self = Self::Rgba(RgbaColor::new(0xFF, 0x85, 0x1B, 0xFF));
+ pub const YELLOW: Self = Self::Rgba(RgbaColor::new(0xFF, 0xDC, 0x00, 0xFF));
+ pub const OLIVE: Self = Self::Rgba(RgbaColor::new(0x3D, 0x99, 0x70, 0xFF));
+ pub const GREEN: Self = Self::Rgba(RgbaColor::new(0x2E, 0xCC, 0x40, 0xFF));
+ pub const LIME: Self = Self::Rgba(RgbaColor::new(0x01, 0xFF, 0x70, 0xFF));
+
+ /// Convert this color to RGBA.
+ pub fn to_rgba(self) -> RgbaColor {
+ match self {
+ Self::Luma(luma) => luma.to_rgba(),
+ Self::Rgba(rgba) => rgba,
+ Self::Cmyk(cmyk) => cmyk.to_rgba(),
+ }
+ }
+
+ /// Lighten this color by the given factor.
+ pub fn lighten(self, factor: Ratio) -> Self {
+ match self {
+ Self::Luma(luma) => Self::Luma(luma.lighten(factor)),
+ Self::Rgba(rgba) => Self::Rgba(rgba.lighten(factor)),
+ Self::Cmyk(cmyk) => Self::Cmyk(cmyk.lighten(factor)),
+ }
+ }
+
+ /// Darken this color by the given factor.
+ pub fn darken(self, factor: Ratio) -> Self {
+ match self {
+ Self::Luma(luma) => Self::Luma(luma.darken(factor)),
+ Self::Rgba(rgba) => Self::Rgba(rgba.darken(factor)),
+ Self::Cmyk(cmyk) => Self::Cmyk(cmyk.darken(factor)),
+ }
+ }
+
+ /// Negate this color.
+ pub fn negate(self) -> Self {
+ match self {
+ Self::Luma(luma) => Self::Luma(luma.negate()),
+ Self::Rgba(rgba) => Self::Rgba(rgba.negate()),
+ Self::Cmyk(cmyk) => Self::Cmyk(cmyk.negate()),
+ }
+ }
+}
+
+impl Debug for Color {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Luma(c) => Debug::fmt(c, f),
+ Self::Rgba(c) => Debug::fmt(c, f),
+ Self::Cmyk(c) => Debug::fmt(c, f),
+ }
+ }
+}
+
+/// An 8-bit grayscale color.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct LumaColor(pub u8);
+
+impl LumaColor {
+ /// Construct a new luma color.
+ pub const fn new(luma: u8) -> Self {
+ Self(luma)
+ }
+
+ /// Convert to an opque RGBA color.
+ pub const fn to_rgba(self) -> RgbaColor {
+ RgbaColor::new(self.0, self.0, self.0, u8::MAX)
+ }
+
+ /// Convert to CMYK as a fraction of true black.
+ pub fn to_cmyk(self) -> CmykColor {
+ CmykColor::new(
+ round_u8(self.0 as f64 * 0.75),
+ round_u8(self.0 as f64 * 0.68),
+ round_u8(self.0 as f64 * 0.67),
+ round_u8(self.0 as f64 * 0.90),
+ )
+ }
+
+ /// Lighten this color by a factor.
+ pub fn lighten(self, factor: Ratio) -> Self {
+ let inc = round_u8((u8::MAX - self.0) as f64 * factor.get());
+ Self(self.0.saturating_add(inc))
+ }
+
+ /// Darken this color by a factor.
+ pub fn darken(self, factor: Ratio) -> Self {
+ let dec = round_u8(self.0 as f64 * factor.get());
+ Self(self.0.saturating_sub(dec))
+ }
+
+ /// Negate this color.
+ pub fn negate(self) -> Self {
+ Self(u8::MAX - self.0)
+ }
+}
+
+impl Debug for LumaColor {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "luma({})", self.0)
+ }
+}
+
+impl From<LumaColor> for Color {
+ fn from(luma: LumaColor) -> Self {
+ Self::Luma(luma)
+ }
+}
+
+/// An 8-bit RGBA color.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct RgbaColor {
+ /// Red channel.
+ pub r: u8,
+ /// Green channel.
+ pub g: u8,
+ /// Blue channel.
+ pub b: u8,
+ /// Alpha channel.
+ pub a: u8,
+}
+
+impl RgbaColor {
+ /// Construct a new RGBA color.
+ pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
+ Self { r, g, b, a }
+ }
+
+ /// Lighten this color by a factor.
+ ///
+ /// The alpha channel is not affected.
+ pub fn lighten(self, factor: Ratio) -> Self {
+ let lighten =
+ |c: u8| c.saturating_add(round_u8((u8::MAX - c) as f64 * factor.get()));
+ Self {
+ r: lighten(self.r),
+ g: lighten(self.g),
+ b: lighten(self.b),
+ a: self.a,
+ }
+ }
+
+ /// Darken this color by a factor.
+ ///
+ /// The alpha channel is not affected.
+ pub fn darken(self, factor: Ratio) -> Self {
+ let darken = |c: u8| c.saturating_sub(round_u8(c as f64 * factor.get()));
+ Self {
+ r: darken(self.r),
+ g: darken(self.g),
+ b: darken(self.b),
+ a: self.a,
+ }
+ }
+
+ /// Negate this color.
+ ///
+ /// The alpha channel is not affected.
+ pub fn negate(self) -> Self {
+ Self {
+ r: u8::MAX - self.r,
+ g: u8::MAX - self.g,
+ b: u8::MAX - self.b,
+ a: self.a,
+ }
+ }
+}
+
+impl FromStr for RgbaColor {
+ type Err = &'static str;
+
+ /// Constructs a new color from hex strings like the following:
+ /// - `#aef` (shorthand, with leading hashtag),
+ /// - `7a03c2` (without alpha),
+ /// - `abcdefff` (with alpha).
+ ///
+ /// The hashtag 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::new(values[0], values[1], values[2], values[3]))
+ }
+}
+
+impl Debug for RgbaColor {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if f.alternate() {
+ write!(f, "rgba({}, {}, {}, {})", self.r, self.g, self.b, self.a,)?;
+ } else {
+ write!(f, "rgb(\"#{:02x}{:02x}{:02x}", self.r, self.g, self.b)?;
+ if self.a != 255 {
+ write!(f, "{:02x}", self.a)?;
+ }
+ write!(f, "\")")?;
+ }
+ Ok(())
+ }
+}
+
+impl<T: Into<RgbaColor>> From<T> for Color {
+ fn from(rgba: T) -> Self {
+ Self::Rgba(rgba.into())
+ }
+}
+
+cast! {
+ RgbaColor,
+ self => Value::Color(self.into()),
+}
+
+/// An 8-bit CMYK color.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct CmykColor {
+ /// The cyan component.
+ pub c: u8,
+ /// The magenta component.
+ pub m: u8,
+ /// The yellow component.
+ pub y: u8,
+ /// The key (black) component.
+ pub k: u8,
+}
+
+impl CmykColor {
+ /// Construct a new CMYK color.
+ pub const fn new(c: u8, m: u8, y: u8, k: u8) -> Self {
+ Self { c, m, y, k }
+ }
+
+ /// Convert this color to RGBA.
+ pub fn to_rgba(self) -> RgbaColor {
+ let k = self.k as f64 / 255.0;
+ let f = |c| {
+ let c = c as f64 / 255.0;
+ round_u8(255.0 * (1.0 - c) * (1.0 - k))
+ };
+
+ RgbaColor { r: f(self.c), g: f(self.m), b: f(self.y), a: 255 }
+ }
+
+ /// Lighten this color by a factor.
+ pub fn lighten(self, factor: Ratio) -> Self {
+ let lighten = |c: u8| c.saturating_sub(round_u8(c as f64 * factor.get()));
+ Self {
+ c: lighten(self.c),
+ m: lighten(self.m),
+ y: lighten(self.y),
+ k: lighten(self.k),
+ }
+ }
+
+ /// Darken this color by a factor.
+ pub fn darken(self, factor: Ratio) -> Self {
+ let darken =
+ |c: u8| c.saturating_add(round_u8((u8::MAX - c) as f64 * factor.get()));
+ Self {
+ c: darken(self.c),
+ m: darken(self.m),
+ y: darken(self.y),
+ k: darken(self.k),
+ }
+ }
+
+ /// Negate this color.
+ ///
+ /// Does not affect the key component.
+ pub fn negate(self) -> Self {
+ Self {
+ c: u8::MAX - self.c,
+ m: u8::MAX - self.m,
+ y: u8::MAX - self.y,
+ k: self.k,
+ }
+ }
+}
+
+impl Debug for CmykColor {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let g = |c| 100.0 * (c as f64 / 255.0);
+ write!(
+ f,
+ "cmyk({:.1}%, {:.1}%, {:.1}%, {:.1}%)",
+ g(self.c),
+ g(self.m),
+ g(self.y),
+ g(self.k),
+ )
+ }
+}
+
+impl From<CmykColor> for Color {
+ fn from(cmyk: CmykColor) -> Self {
+ Self::Cmyk(cmyk)
+ }
+}
+
+/// Convert to the closest u8.
+fn round_u8(value: f64) -> u8 {
+ value.round() as u8
+}
+
+#[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!(RgbaColor::from_str(hex), Ok(RgbaColor::new(r, g, b, a)));
+ }
+
+ test("f61243ff", 0xf6, 0x12, 0x43, 0xff);
+ test("b3d8b3", 0xb3, 0xd8, 0xb3, 0xff);
+ test("fCd2a9AD", 0xfc, 0xd2, 0xa9, 0xad);
+ test("233", 0x22, 0x33, 0x33, 0xff);
+ test("111b", 0x11, 0x11, 0x11, 0xbb);
+ }
+
+ #[test]
+ fn test_parse_invalid_colors() {
+ #[track_caller]
+ fn test(hex: &str, message: &str) {
+ assert_eq!(RgbaColor::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/src/geom/corners.rs b/crates/typst/src/geom/corners.rs
new file mode 100644
index 00000000..5ee1e063
--- /dev/null
+++ b/crates/typst/src/geom/corners.rs
@@ -0,0 +1,219 @@
+use crate::eval::{CastInfo, FromValue, IntoValue, Reflect};
+
+use super::*;
+
+/// A container with components for the four corners of a rectangle.
+#[derive(Debug, 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> 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,
+ }
+ }
+}
+
+/// 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<T: Reflect> Reflect for Corners<Option<T>> {
+ fn describe() -> CastInfo {
+ T::describe() + Dict::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Dict::castable(value) || T::castable(value)
+ }
+}
+
+impl<T> IntoValue for Corners<T>
+where
+ T: PartialEq + IntoValue,
+{
+ fn into_value(self) -> Value {
+ if self.is_uniform() {
+ return self.top_left.into_value();
+ }
+
+ let mut dict = Dict::new();
+ let mut handle = |key: &str, component: T| {
+ let value = component.into_value();
+ if value != Value::None {
+ dict.insert(key.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) -> StrResult<Self> {
+ let keys = [
+ "top-left",
+ "top-right",
+ "bottom-right",
+ "bottom-left",
+ "left",
+ "top",
+ "right",
+ "bottom",
+ "rest",
+ ];
+
+ if let Value::Dict(dict) = &mut value {
+ if dict.iter().any(|(key, _)| 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(&keys)?;
+ return Ok(corners);
+ }
+ }
+
+ if T::castable(&value) {
+ Ok(Self::splat(Some(T::from_value(value)?)))
+ } 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>> {
+ type Output = Corners<T::Output>;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ self.zip(outer).map(|(inner, outer)| match inner {
+ Some(value) => value.fold(outer),
+ None => outer,
+ })
+ }
+}
diff --git a/crates/typst/src/geom/dir.rs b/crates/typst/src/geom/dir.rs
new file mode 100644
index 00000000..48915471
--- /dev/null
+++ b/crates/typst/src/geom/dir.rs
@@ -0,0 +1,79 @@
+use super::*;
+
+/// The four directions into which content can be laid out.
+#[derive(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 {
+ /// The specific axis this direction belongs to.
+ pub const fn axis(self) -> Axis {
+ match self {
+ Self::LTR | Self::RTL => Axis::X,
+ Self::TTB | Self::BTT => Axis::Y,
+ }
+ }
+
+ /// The side this direction starts at.
+ 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 side this direction ends at.
+ 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.
+ pub const fn inv(self) -> Self {
+ match self {
+ Self::LTR => Self::RTL,
+ Self::RTL => Self::LTR,
+ Self::TTB => Self::BTT,
+ Self::BTT => Self::TTB,
+ }
+ }
+
+ /// 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,
+ }
+ }
+}
+
+impl Debug for Dir {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(match self {
+ Self::LTR => "ltr",
+ Self::RTL => "rtl",
+ Self::TTB => "ttb",
+ Self::BTT => "btt",
+ })
+ }
+}
+
+cast! {
+ type Dir: "direction",
+}
diff --git a/crates/typst/src/geom/ellipse.rs b/crates/typst/src/geom/ellipse.rs
new file mode 100644
index 00000000..ac20ffd3
--- /dev/null
+++ b/crates/typst/src/geom/ellipse.rs
@@ -0,0 +1,22 @@
+use super::*;
+
+/// Produce a shape that approximates an axis-aligned ellipse.
+pub fn ellipse(size: Size, fill: Option<Paint>, stroke: Option<Stroke>) -> Shape {
+ // 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));
+
+ Shape { geometry: Geometry::Path(path), stroke, fill }
+}
diff --git a/crates/typst/src/geom/em.rs b/crates/typst/src/geom/em.rs
new file mode 100644
index 00000000..8dda9ff6
--- /dev/null
+++ b/crates/typst/src/geom/em.rs
@@ -0,0 +1,153 @@
+use super::*;
+
+/// 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(0.0))
+ }
+
+ /// The font size.
+ pub const fn one() -> Self {
+ Self(Scalar(1.0))
+ }
+
+ /// Create a font-relative length.
+ pub const fn new(em: f64) -> Self {
+ Self(Scalar(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(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(result))
+ } else {
+ Self::zero()
+ }
+ }
+
+ /// The number of em units.
+ pub const fn get(self) -> f64 {
+ (self.0).0
+ }
+
+ /// 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 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)
+ }
+}
+
+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()
+ }
+}
+
+assign_impl!(Em += Em);
+assign_impl!(Em -= Em);
+assign_impl!(Em *= f64);
+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(item!(em)(styles))
+ }
+ }
+}
diff --git a/crates/typst/src/geom/fr.rs b/crates/typst/src/geom/fr.rs
new file mode 100644
index 00000000..c602634d
--- /dev/null
+++ b/crates/typst/src/geom/fr.rs
@@ -0,0 +1,119 @@
+use super::*;
+
+/// A fraction of remaining space.
+#[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(0.0))
+ }
+
+ /// Takes up as much space as all other items with this fraction: `1fr`.
+ pub const fn one() -> Self {
+ Self(Scalar(1.0))
+ }
+
+ /// Create a new fraction.
+ pub const fn new(ratio: f64) -> Self {
+ Self(Scalar(ratio))
+ }
+
+ /// Get the underlying number.
+ pub const fn get(self) -> f64 {
+ (self.0).0
+ }
+
+ /// 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", round_2(self.get()))
+ }
+}
+
+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)
+ }
+}
+
+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<f64> for Fr {
+ type Output = Self;
+
+ fn div(self, other: f64) -> Self {
+ Self(self.0 / other)
+ }
+}
+
+impl Div for Fr {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.get() / other.get()
+ }
+}
+
+assign_impl!(Fr += Fr);
+assign_impl!(Fr -= Fr);
+assign_impl!(Fr *= f64);
+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/src/geom/length.rs b/crates/typst/src/geom/length.rs
new file mode 100644
index 00000000..7d0a9841
--- /dev/null
+++ b/crates/typst/src/geom/length.rs
@@ -0,0 +1,128 @@
+use super::*;
+
+/// A size or distance, possibly expressed with contextual units.
+///
+/// Currently supports absolute and font-relative units, but support could quite
+/// easily be extended to other units.
+#[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
+ }
+ }
+}
+
+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 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 }
+ }
+}
+
+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 Div<f64> for Length {
+ type Output = Self;
+
+ fn div(self, rhs: f64) -> Self::Output {
+ Self { abs: self.abs / rhs, em: self.em / rhs }
+ }
+}
+
+assign_impl!(Length += Length);
+assign_impl!(Length -= Length);
+assign_impl!(Length *= f64);
+assign_impl!(Length /= f64);
+
+impl Resolve for Length {
+ type Output = Abs;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.abs + self.em.resolve(styles)
+ }
+}
diff --git a/crates/typst/src/geom/macros.rs b/crates/typst/src/geom/macros.rs
new file mode 100644
index 00000000..b1b50e22
--- /dev/null
+++ b/crates/typst/src/geom/macros.rs
@@ -0,0 +1,47 @@
+/// Implement the `Sub` trait based on existing `Neg` and `Add` impls.
+macro_rules! sub_impl {
+ ($a:ident - $b:ident -> $c:ident) => {
+ impl std::ops::Sub<$b> for $a {
+ type Output = $c;
+
+ fn sub(self, other: $b) -> $c {
+ self + -other
+ }
+ }
+ };
+}
+
+/// Implement an assign trait based on an existing non-assign trait.
+macro_rules! assign_impl {
+ ($a:ident += $b:ident) => {
+ impl std::ops::AddAssign<$b> for $a {
+ fn add_assign(&mut self, other: $b) {
+ *self = *self + other;
+ }
+ }
+ };
+
+ ($a:ident -= $b:ident) => {
+ impl std::ops::SubAssign<$b> for $a {
+ fn sub_assign(&mut self, other: $b) {
+ *self = *self - other;
+ }
+ }
+ };
+
+ ($a:ident *= $b:ident) => {
+ impl std::ops::MulAssign<$b> for $a {
+ fn mul_assign(&mut self, other: $b) {
+ *self = *self * other;
+ }
+ }
+ };
+
+ ($a:ident /= $b:ident) => {
+ impl std::ops::DivAssign<$b> for $a {
+ fn div_assign(&mut self, other: $b) {
+ *self = *self / other;
+ }
+ }
+ };
+}
diff --git a/crates/typst/src/geom/mod.rs b/crates/typst/src/geom/mod.rs
new file mode 100644
index 00000000..b7a7ff40
--- /dev/null
+++ b/crates/typst/src/geom/mod.rs
@@ -0,0 +1,121 @@
+//! Geometrical primitives.
+
+#[macro_use]
+mod macros;
+mod abs;
+mod align;
+mod angle;
+mod axes;
+mod color;
+mod corners;
+mod dir;
+mod ellipse;
+mod em;
+mod fr;
+mod length;
+mod paint;
+mod path;
+mod point;
+mod ratio;
+mod rel;
+mod rounded;
+mod scalar;
+mod shape;
+mod sides;
+mod size;
+mod smart;
+mod stroke;
+mod transform;
+
+pub use self::abs::{Abs, AbsUnit};
+pub use self::align::{Align, GenAlign, HorizontalAlign, VerticalAlign};
+pub use self::angle::{Angle, AngleUnit};
+pub use self::axes::{Axes, Axis};
+pub use self::color::{CmykColor, Color, LumaColor, RgbaColor};
+pub use self::corners::{Corner, Corners};
+pub use self::dir::Dir;
+pub use self::ellipse::ellipse;
+pub use self::em::Em;
+pub use self::fr::Fr;
+pub use self::length::Length;
+pub use self::paint::Paint;
+pub use self::path::{Path, PathItem};
+pub use self::point::Point;
+pub use self::ratio::Ratio;
+pub use self::rel::Rel;
+pub use self::rounded::rounded_rect;
+pub use self::scalar::Scalar;
+pub use self::shape::{Geometry, Shape};
+pub use self::sides::{Side, Sides};
+pub use self::size::Size;
+pub use self::smart::Smart;
+pub use self::stroke::{
+ DashLength, DashPattern, LineCap, LineJoin, PartialStroke, Stroke,
+};
+pub use self::transform::Transform;
+
+use std::cmp::Ordering;
+use std::f64::consts::PI;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::iter::Sum;
+use std::ops::*;
+
+use crate::diag::{bail, StrResult};
+use crate::eval::{array, cast, Array, Dict, Value};
+use crate::model::{Fold, Resolve, StyleChain};
+
+/// Generic access to a structure's components.
+pub trait Get<Index> {
+ /// The structure's component type.
+ type Component;
+
+ /// Borrow the component for the specified index.
+ fn get_ref(&self, index: Index) -> &Self::Component;
+
+ /// Borrow the component for the specified index mutably.
+ fn get_mut(&mut self, index: Index) -> &mut Self::Component;
+
+ /// Convenience method for getting a copy of a component.
+ fn get(self, index: Index) -> Self::Component
+ where
+ Self: Sized,
+ Self::Component: Copy,
+ {
+ *self.get_ref(index)
+ }
+
+ /// Convenience method for setting a component.
+ fn set(&mut self, index: Index, component: Self::Component) {
+ *self.get_mut(index) = component;
+ }
+}
+
+/// A numeric type.
+pub trait Numeric:
+ Sized
+ + Debug
+ + Copy
+ + PartialEq
+ + Neg<Output = Self>
+ + Add<Output = Self>
+ + Sub<Output = Self>
+ + Mul<f64, Output = Self>
+ + Div<f64, Output = Self>
+{
+ /// The identity element for addition.
+ fn zero() -> Self;
+
+ /// Whether `self` is zero.
+ fn is_zero(self) -> bool {
+ self == Self::zero()
+ }
+
+ /// Whether `self` consists only of finite parts.
+ fn is_finite(self) -> bool;
+}
+
+/// Round a float to two decimal places.
+pub fn round_2(value: f64) -> f64 {
+ (value * 100.0).round() / 100.0
+}
diff --git a/crates/typst/src/geom/paint.rs b/crates/typst/src/geom/paint.rs
new file mode 100644
index 00000000..10fa9fde
--- /dev/null
+++ b/crates/typst/src/geom/paint.rs
@@ -0,0 +1,30 @@
+use super::*;
+
+/// How a fill or stroke should be painted.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub enum Paint {
+ /// A solid color.
+ Solid(Color),
+}
+
+impl<T: Into<Color>> From<T> for Paint {
+ fn from(t: T) -> Self {
+ Self::Solid(t.into())
+ }
+}
+
+impl Debug for Paint {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Solid(color) => color.fmt(f),
+ }
+ }
+}
+
+cast! {
+ Paint,
+ self => match self {
+ Self::Solid(color) => Value::Color(color),
+ },
+ color: Color => Self::Solid(color),
+}
diff --git a/crates/typst/src/geom/path.rs b/crates/typst/src/geom/path.rs
new file mode 100644
index 00000000..1c5325a3
--- /dev/null
+++ b/crates/typst/src/geom/path.rs
@@ -0,0 +1,54 @@
+use super::*;
+
+/// 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
+ }
+
+ /// 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);
+ }
+}
diff --git a/crates/typst/src/geom/point.rs b/crates/typst/src/geom/point.rs
new file mode 100644
index 00000000..e7811e1e
--- /dev/null
+++ b/crates/typst/src/geom/point.rs
@@ -0,0 +1,146 @@
+use super::*;
+
+/// 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) }
+ }
+
+ /// 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.
+ 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,
+ )
+ }
+
+ /// 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 }
+ }
+}
+
+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 }
+ }
+}
+
+assign_impl!(Point += Point);
+assign_impl!(Point -= Point);
+assign_impl!(Point *= f64);
+assign_impl!(Point /= f64);
diff --git a/crates/typst/src/geom/ratio.rs b/crates/typst/src/geom/ratio.rs
new file mode 100644
index 00000000..fe87dd6c
--- /dev/null
+++ b/crates/typst/src/geom/ratio.rs
@@ -0,0 +1,133 @@
+use super::*;
+
+/// A ratio of a whole.
+///
+/// _Note_: `50%` is represented as `0.5` here, but stored as `50.0` in the
+/// corresponding [literal](crate::syntax::ast::Numeric).
+#[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(0.0))
+ }
+
+ /// A ratio of `100%` represented as `1.0`.
+ pub const fn one() -> Self {
+ Self(Scalar(1.0))
+ }
+
+ /// Create a new ratio from a value, where `1.0` means `100%`.
+ pub const fn new(ratio: f64) -> Self {
+ Self(Scalar(ratio))
+ }
+
+ /// Get the underlying ratio.
+ pub const fn get(self) -> f64 {
+ (self.0).0
+ }
+
+ /// 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, "{}%", round_2(100.0 * self.get()))
+ }
+}
+
+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)
+ }
+}
+
+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<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()
+ }
+}
+
+impl Div for Ratio {
+ type Output = f64;
+
+ fn div(self, other: Self) -> f64 {
+ self.get() / other.get()
+ }
+}
+
+assign_impl!(Ratio += Ratio);
+assign_impl!(Ratio -= Ratio);
+assign_impl!(Ratio *= Ratio);
+assign_impl!(Ratio *= f64);
+assign_impl!(Ratio /= f64);
diff --git a/crates/typst/src/geom/rel.rs b/crates/typst/src/geom/rel.rs
new file mode 100644
index 00000000..88972222
--- /dev/null
+++ b/crates/typst/src/geom/rel.rs
@@ -0,0 +1,246 @@
+use super::*;
+
+/// A value that is composed of a relative and an absolute part.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Rel<T: Numeric> {
+ /// 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
+ }
+ }
+}
+
+impl<T: Numeric> 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 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 Fold for Rel<Abs> {
+ type Output = Self;
+
+ fn fold(self, _: Self::Output) -> Self::Output {
+ self
+ }
+}
+
+impl Fold for Rel<Length> {
+ type Output = Self;
+
+ fn fold(self, _: Self::Output) -> Self::Output {
+ self
+ }
+}
+
+cast! {
+ Rel<Abs>,
+ self => self.map(Length::from).into_value(),
+}
diff --git a/crates/typst/src/geom/rounded.rs b/crates/typst/src/geom/rounded.rs
new file mode 100644
index 00000000..f1a7ea08
--- /dev/null
+++ b/crates/typst/src/geom/rounded.rs
@@ -0,0 +1,182 @@
+use super::*;
+
+/// Produce shapes that together make up a rounded rectangle.
+pub fn rounded_rect(
+ size: Size,
+ radius: Corners<Abs>,
+ fill: Option<Paint>,
+ stroke: Sides<Option<Stroke>>,
+) -> Vec<Shape> {
+ let mut res = vec![];
+ if fill.is_some() || (stroke.iter().any(Option::is_some) && stroke.is_uniform()) {
+ res.push(Shape {
+ geometry: fill_geometry(size, radius),
+ fill,
+ stroke: if stroke.is_uniform() { stroke.top.clone() } else { None },
+ });
+ }
+
+ if !stroke.is_uniform() {
+ for (path, stroke) in stroke_segments(size, radius, stroke) {
+ if stroke.is_some() {
+ res.push(Shape { geometry: Geometry::Path(path), fill: None, stroke });
+ }
+ }
+ }
+
+ res
+}
+
+/// Output the shape of the rectangle as a path or primitive rectangle,
+/// depending on whether it is rounded.
+fn fill_geometry(size: Size, radius: Corners<Abs>) -> Geometry {
+ if radius.iter().copied().all(Abs::is_zero) {
+ Geometry::Rect(size)
+ } else {
+ let mut paths = stroke_segments(size, radius, Sides::splat(None));
+ assert_eq!(paths.len(), 1);
+ Geometry::Path(paths.pop().unwrap().0)
+ }
+}
+
+/// Output the minimum number of paths along the rectangles border.
+fn stroke_segments(
+ size: Size,
+ radius: Corners<Abs>,
+ stroke: Sides<Option<Stroke>>,
+) -> Vec<(Path, Option<Stroke>)> {
+ let mut res = vec![];
+
+ let mut connection = Connection::default();
+ let mut path = Path::new();
+ let mut always_continuous = true;
+ let max_radius = size.x.min(size.y).max(Abs::zero()) / 2.0;
+
+ for side in [Side::Top, Side::Right, Side::Bottom, Side::Left] {
+ let continuous = stroke.get_ref(side) == stroke.get_ref(side.next_cw());
+ connection = connection.advance(continuous && side != Side::Left);
+ always_continuous &= continuous;
+
+ draw_side(
+ &mut path,
+ side,
+ size,
+ radius.get(side.start_corner()).clamp(Abs::zero(), max_radius),
+ radius.get(side.end_corner()).clamp(Abs::zero(), max_radius),
+ connection,
+ );
+
+ if !continuous {
+ res.push((std::mem::take(&mut path), stroke.get_ref(side).clone()));
+ }
+ }
+
+ if always_continuous {
+ path.close_path();
+ }
+
+ if !path.0.is_empty() {
+ res.push((path, stroke.left));
+ }
+
+ res
+}
+
+/// Draws one side of the rounded rectangle. Will always draw the left arc. The
+/// right arc will be drawn halfway if and only if there is no connection.
+fn draw_side(
+ path: &mut Path,
+ side: Side,
+ size: Size,
+ start_radius: Abs,
+ end_radius: Abs,
+ connection: Connection,
+) {
+ let angle_left = Angle::deg(if connection.prev { 90.0 } else { 45.0 });
+ let angle_right = Angle::deg(if connection.next { 90.0 } else { 45.0 });
+ let length = size.get(side.axis());
+
+ // The arcs for a border of the rectangle along the x-axis, starting at (0,0).
+ let p1 = Point::with_x(start_radius);
+ let mut arc1 = bezier_arc(
+ p1 + Point::new(
+ -angle_left.sin() * start_radius,
+ (1.0 - angle_left.cos()) * start_radius,
+ ),
+ Point::new(start_radius, start_radius),
+ p1,
+ );
+
+ let p2 = Point::with_x(length - end_radius);
+ let mut arc2 = bezier_arc(
+ p2,
+ Point::new(length - end_radius, end_radius),
+ p2 + Point::new(
+ angle_right.sin() * end_radius,
+ (1.0 - angle_right.cos()) * end_radius,
+ ),
+ );
+
+ let transform = match side {
+ Side::Left => Transform::rotate(Angle::deg(-90.0))
+ .post_concat(Transform::translate(Abs::zero(), size.y)),
+ Side::Bottom => Transform::rotate(Angle::deg(180.0))
+ .post_concat(Transform::translate(size.x, size.y)),
+ Side::Right => Transform::rotate(Angle::deg(90.0))
+ .post_concat(Transform::translate(size.x, Abs::zero())),
+ _ => Transform::identity(),
+ };
+
+ arc1 = arc1.map(|x| x.transform(transform));
+ arc2 = arc2.map(|x| x.transform(transform));
+
+ if !connection.prev {
+ path.move_to(if start_radius.is_zero() { arc1[3] } else { arc1[0] });
+ }
+
+ if !start_radius.is_zero() {
+ path.cubic_to(arc1[1], arc1[2], arc1[3]);
+ }
+
+ path.line_to(arc2[0]);
+
+ if !connection.next && !end_radius.is_zero() {
+ path.cubic_to(arc2[1], arc2[2], arc2[3]);
+ }
+}
+
+/// Get the control points for a bezier curve that describes a circular arc for
+/// a start point, an end point and a center of the circle whose arc connects
+/// the two.
+fn bezier_arc(start: Point, center: Point, end: Point) -> [Point; 4] {
+ // https://stackoverflow.com/a/44829356/1567835
+ let a = start - center;
+ let b = end - center;
+
+ let q1 = a.x.to_raw() * a.x.to_raw() + a.y.to_raw() * a.y.to_raw();
+ let q2 = q1 + a.x.to_raw() * b.x.to_raw() + a.y.to_raw() * b.y.to_raw();
+ let k2 = (4.0 / 3.0) * ((2.0 * q1 * q2).sqrt() - q2)
+ / (a.x.to_raw() * b.y.to_raw() - a.y.to_raw() * b.x.to_raw());
+
+ let control_1 = Point::new(center.x + a.x - k2 * a.y, center.y + a.y + k2 * a.x);
+ let control_2 = Point::new(center.x + b.x + k2 * b.y, center.y + b.y - k2 * b.x);
+
+ [start, control_1, control_2, end]
+}
+
+/// Indicates which sides of the border strokes in a 2D polygon are connected to
+/// their neighboring sides.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)]
+struct Connection {
+ prev: bool,
+ next: bool,
+}
+
+impl Connection {
+ /// Advance to the next clockwise side of the polygon. The argument
+ /// indicates whether the border is connected on the right side of the next
+ /// edge.
+ pub fn advance(self, next: bool) -> Self {
+ Self { prev: self.next, next }
+ }
+}
diff --git a/crates/typst/src/geom/scalar.rs b/crates/typst/src/geom/scalar.rs
new file mode 100644
index 00000000..71fb1755
--- /dev/null
+++ b/crates/typst/src/geom/scalar.rs
@@ -0,0 +1,175 @@
+use super::*;
+
+/// A 64-bit float that implements `Eq`, `Ord` and `Hash`.
+///
+/// Panics if it's `NaN` during any of those operations.
+#[derive(Default, Copy, Clone)]
+pub struct Scalar(pub f64);
+
+impl Numeric for Scalar {
+ fn zero() -> Self {
+ Self(0.0)
+ }
+
+ fn is_finite(self) -> bool {
+ self.0.is_finite()
+ }
+}
+
+impl From<f64> for Scalar {
+ fn from(float: f64) -> Self {
+ Self(float)
+ }
+}
+
+impl From<Scalar> for f64 {
+ fn from(scalar: Scalar) -> Self {
+ scalar.0
+ }
+}
+
+impl Debug for Scalar {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl Eq for Scalar {}
+
+impl PartialEq for Scalar {
+ fn eq(&self, other: &Self) -> bool {
+ assert!(!self.0.is_nan() && !other.0.is_nan(), "float is NaN");
+ self.0 == other.0
+ }
+}
+
+impl PartialEq<f64> for Scalar {
+ fn eq(&self, other: &f64) -> bool {
+ self == &Self(*other)
+ }
+}
+
+impl Ord for Scalar {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.partial_cmp(other).expect("float is NaN")
+ }
+}
+
+impl PartialOrd for Scalar {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ self.0.partial_cmp(&other.0)
+ }
+
+ fn lt(&self, other: &Self) -> bool {
+ self.0 < other.0
+ }
+
+ fn le(&self, other: &Self) -> bool {
+ self.0 <= other.0
+ }
+
+ fn gt(&self, other: &Self) -> bool {
+ self.0 > other.0
+ }
+
+ fn ge(&self, other: &Self) -> bool {
+ self.0 >= other.0
+ }
+}
+
+impl Hash for Scalar {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ debug_assert!(!self.0.is_nan(), "float is NaN");
+ self.0.to_bits().hash(state);
+ }
+}
+
+impl Neg for Scalar {
+ type Output = Self;
+
+ fn neg(self) -> Self::Output {
+ Self(-self.0)
+ }
+}
+
+impl<T: Into<Self>> Add<T> for Scalar {
+ type Output = Self;
+
+ fn add(self, rhs: T) -> Self::Output {
+ Self(self.0 + rhs.into().0)
+ }
+}
+
+impl<T: Into<Self>> AddAssign<T> for Scalar {
+ fn add_assign(&mut self, rhs: T) {
+ self.0 += rhs.into().0;
+ }
+}
+
+impl<T: Into<Self>> Sub<T> for Scalar {
+ type Output = Self;
+
+ fn sub(self, rhs: T) -> Self::Output {
+ Self(self.0 - rhs.into().0)
+ }
+}
+
+impl<T: Into<Self>> SubAssign<T> for Scalar {
+ fn sub_assign(&mut self, rhs: T) {
+ self.0 -= rhs.into().0;
+ }
+}
+
+impl<T: Into<Self>> Mul<T> for Scalar {
+ type Output = Self;
+
+ fn mul(self, rhs: T) -> Self::Output {
+ Self(self.0 * rhs.into().0)
+ }
+}
+
+impl<T: Into<Self>> MulAssign<T> for Scalar {
+ fn mul_assign(&mut self, rhs: T) {
+ self.0 *= rhs.into().0;
+ }
+}
+
+impl<T: Into<Self>> Div<T> for Scalar {
+ type Output = Self;
+
+ fn div(self, rhs: T) -> Self::Output {
+ Self(self.0 / rhs.into().0)
+ }
+}
+
+impl<T: Into<Self>> DivAssign<T> for Scalar {
+ fn div_assign(&mut self, rhs: T) {
+ self.0 /= rhs.into().0;
+ }
+}
+
+impl<T: Into<Self>> Rem<T> for Scalar {
+ type Output = Self;
+
+ fn rem(self, rhs: T) -> Self::Output {
+ Self(self.0 % rhs.into().0)
+ }
+}
+
+impl<T: Into<Self>> RemAssign<T> for Scalar {
+ fn rem_assign(&mut self, rhs: T) {
+ self.0 %= rhs.into().0;
+ }
+}
+
+impl Sum for Scalar {
+ fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
+ Self(iter.map(|s| s.0).sum())
+ }
+}
+
+impl<'a> Sum<&'a Self> for Scalar {
+ fn sum<I: Iterator<Item = &'a Self>>(iter: I) -> Self {
+ Self(iter.map(|s| s.0).sum())
+ }
+}
diff --git a/crates/typst/src/geom/shape.rs b/crates/typst/src/geom/shape.rs
new file mode 100644
index 00000000..5658c21f
--- /dev/null
+++ b/crates/typst/src/geom/shape.rs
@@ -0,0 +1,35 @@
+use super::*;
+
+/// 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 border stroke.
+ pub stroke: Option<Stroke>,
+}
+
+/// 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: Paint) -> Shape {
+ Shape { geometry: self, fill: Some(fill), stroke: None }
+ }
+
+ /// Stroke the geometry without a fill.
+ pub fn stroked(self, stroke: Stroke) -> Shape {
+ Shape { geometry: self, fill: None, stroke: Some(stroke) }
+ }
+}
diff --git a/crates/typst/src/geom/sides.rs b/crates/typst/src/geom/sides.rs
new file mode 100644
index 00000000..d4b72a9d
--- /dev/null
+++ b/crates/typst/src/geom/sides.rs
@@ -0,0 +1,268 @@
+use crate::eval::{CastInfo, FromValue, IntoValue, Reflect};
+
+use super::*;
+
+/// A container with left, top, right and bottom components.
+#[derive(Debug, 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),
+ }
+ }
+
+ /// 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 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),
+ }
+ }
+}
+
+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,
+ }
+ }
+}
+
+/// 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,
+ }
+ }
+}
+
+impl<T: Reflect> Reflect for Sides<Option<T>> {
+ fn describe() -> CastInfo {
+ T::describe() + Dict::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Dict::castable(value) || T::castable(value)
+ }
+}
+
+impl<T> IntoValue for Sides<T>
+where
+ T: PartialEq + IntoValue,
+{
+ fn into_value(self) -> Value {
+ if self.is_uniform() {
+ return self.left.into_value();
+ }
+
+ let mut dict = Dict::new();
+ let mut handle = |key: &str, component: T| {
+ let value = component.into_value();
+ if value != Value::None {
+ dict.insert(key.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) -> StrResult<Self> {
+ let keys = ["left", "top", "right", "bottom", "x", "y", "rest"];
+ if let Value::Dict(dict) = &mut value {
+ if dict.iter().any(|(key, _)| 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(&keys)?;
+ return Ok(sides);
+ }
+ }
+
+ if T::castable(&value) {
+ Ok(Self::splat(Some(T::from_value(value)?)))
+ } 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>> {
+ type Output = Sides<T::Output>;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ self.zip(outer).map(|(inner, outer)| match inner {
+ Some(value) => value.fold(outer),
+ None => outer,
+ })
+ }
+}
diff --git a/crates/typst/src/geom/size.rs b/crates/typst/src/geom/size.rs
new file mode 100644
index 00000000..a2e32b77
--- /dev/null
+++ b/crates/typst/src/geom/size.rs
@@ -0,0 +1,78 @@
+use super::*;
+
+/// 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)
+ }
+}
+
+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 }
+ }
+}
+
+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 }
+ }
+}
+
+assign_impl!(Size -= Size);
+assign_impl!(Size += Size);
+assign_impl!(Size *= f64);
+assign_impl!(Size /= f64);
diff --git a/crates/typst/src/geom/smart.rs b/crates/typst/src/geom/smart.rs
new file mode 100644
index 00000000..a6271c20
--- /dev/null
+++ b/crates/typst/src/geom/smart.rs
@@ -0,0 +1,146 @@
+use crate::eval::{AutoValue, CastInfo, FromValue, IntoValue, Reflect};
+
+use super::*;
+
+/// 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(_))
+ }
+
+ /// Returns a reference the contained custom value.
+ /// If the value is [`Smart::Auto`], `None` is returned.
+ pub fn as_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,
+ }
+ }
+
+ /// 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,
+ {
+ self.unwrap_or_else(T::default)
+ }
+}
+
+impl<T> Default for Smart<T> {
+ fn default() -> Self {
+ Self::Auto
+ }
+}
+
+impl<T: Reflect> Reflect for Smart<T> {
+ fn castable(value: &Value) -> bool {
+ AutoValue::castable(value) || T::castable(value)
+ }
+
+ fn describe() -> CastInfo {
+ T::describe() + AutoValue::describe()
+ }
+}
+
+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) -> StrResult<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 for Smart<T>
+where
+ T: Fold,
+ T::Output: Default,
+{
+ type Output = Smart<T::Output>;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ self.map(|inner| inner.fold(outer.unwrap_or_default()))
+ }
+}
diff --git a/crates/typst/src/geom/stroke.rs b/crates/typst/src/geom/stroke.rs
new file mode 100644
index 00000000..66264d5d
--- /dev/null
+++ b/crates/typst/src/geom/stroke.rs
@@ -0,0 +1,387 @@
+use crate::eval::{Cast, FromValue};
+
+use super::*;
+
+/// A stroke of a geometric shape.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Stroke {
+ /// The stroke's paint.
+ pub paint: Paint,
+ /// The stroke's thickness.
+ pub thickness: Abs,
+ /// The stroke's line cap.
+ pub line_cap: LineCap,
+ /// The stroke's line join.
+ pub line_join: LineJoin,
+ /// The stroke's line dash pattern.
+ pub dash_pattern: Option<DashPattern<Abs, Abs>>,
+ /// The miter limit. Defaults to 4.0, same as `tiny-skia`.
+ pub miter_limit: Scalar,
+}
+
+impl Default for Stroke {
+ fn default() -> Self {
+ Self {
+ paint: Paint::Solid(Color::BLACK),
+ thickness: Abs::pt(1.0),
+ line_cap: LineCap::Butt,
+ line_join: LineJoin::Miter,
+ dash_pattern: None,
+ miter_limit: Scalar(4.0),
+ }
+ }
+}
+
+/// A partial stroke representation.
+///
+/// In this representation, both fields are optional so that you can pass either
+/// just a paint (`red`), just a thickness (`0.1em`) or both (`2pt + red`) where
+/// this is expected.
+#[derive(Default, Clone, Eq, PartialEq, Hash)]
+pub struct PartialStroke<T = Length> {
+ /// The stroke's paint.
+ pub paint: Smart<Paint>,
+ /// The stroke's thickness.
+ pub thickness: Smart<T>,
+ /// The stroke's line cap.
+ pub line_cap: Smart<LineCap>,
+ /// The stroke's line join.
+ pub line_join: Smart<LineJoin>,
+ /// The stroke's line dash pattern.
+ pub dash_pattern: Smart<Option<DashPattern<T>>>,
+ /// The miter limit.
+ pub miter_limit: Smart<Scalar>,
+}
+
+impl<T> PartialStroke<T> {
+ /// Map the contained lengths with `f`.
+ pub fn map<F, U>(self, f: F) -> PartialStroke<U>
+ where
+ F: Fn(T) -> U,
+ {
+ PartialStroke {
+ paint: self.paint,
+ thickness: self.thickness.map(&f),
+ line_cap: self.line_cap,
+ line_join: self.line_join,
+ dash_pattern: self.dash_pattern.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 PartialStroke<Abs> {
+ /// Unpack the stroke, filling missing fields from the `default`.
+ pub fn unwrap_or(self, default: Stroke) -> Stroke {
+ let thickness = self.thickness.unwrap_or(default.thickness);
+ let dash_pattern = self
+ .dash_pattern
+ .map(|pattern| {
+ pattern.map(|pattern| DashPattern {
+ array: pattern
+ .array
+ .into_iter()
+ .map(|l| l.finish(thickness))
+ .collect(),
+ phase: pattern.phase,
+ })
+ })
+ .unwrap_or(default.dash_pattern);
+
+ Stroke {
+ paint: self.paint.unwrap_or(default.paint),
+ thickness,
+ line_cap: self.line_cap.unwrap_or(default.line_cap),
+ line_join: self.line_join.unwrap_or(default.line_join),
+ dash_pattern,
+ 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) -> Stroke {
+ self.unwrap_or(Stroke::default())
+ }
+}
+
+impl<T: Debug> Debug for PartialStroke<T> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let Self {
+ paint,
+ thickness,
+ line_cap,
+ line_join,
+ dash_pattern,
+ miter_limit,
+ } = &self;
+ if line_cap.is_auto()
+ && line_join.is_auto()
+ && dash_pattern.is_auto()
+ && miter_limit.is_auto()
+ {
+ match (&self.paint, &self.thickness) {
+ (Smart::Custom(paint), Smart::Custom(thickness)) => {
+ write!(f, "{thickness:?} + {paint:?}")
+ }
+ (Smart::Custom(paint), Smart::Auto) => paint.fmt(f),
+ (Smart::Auto, Smart::Custom(thickness)) => thickness.fmt(f),
+ (Smart::Auto, Smart::Auto) => f.pad("1pt + black"),
+ }
+ } else {
+ write!(f, "(")?;
+ let mut sep = "";
+ if let Smart::Custom(paint) = &paint {
+ write!(f, "{}paint: {:?}", sep, paint)?;
+ sep = ", ";
+ }
+ if let Smart::Custom(thickness) = &thickness {
+ write!(f, "{}thickness: {:?}", sep, thickness)?;
+ sep = ", ";
+ }
+ if let Smart::Custom(cap) = &line_cap {
+ write!(f, "{}cap: {:?}", sep, cap)?;
+ sep = ", ";
+ }
+ if let Smart::Custom(join) = &line_join {
+ write!(f, "{}join: {:?}", sep, join)?;
+ sep = ", ";
+ }
+ if let Smart::Custom(dash) = &dash_pattern {
+ write!(f, "{}dash: {:?}", sep, dash)?;
+ sep = ", ";
+ }
+ if let Smart::Custom(miter_limit) = &miter_limit {
+ write!(f, "{}miter-limit: {:?}", sep, miter_limit)?;
+ }
+ write!(f, ")")?;
+ Ok(())
+ }
+ }
+}
+
+impl Resolve for PartialStroke {
+ type Output = PartialStroke<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ PartialStroke {
+ paint: self.paint,
+ thickness: self.thickness.resolve(styles),
+ line_cap: self.line_cap,
+ line_join: self.line_join,
+ dash_pattern: self.dash_pattern.resolve(styles),
+ miter_limit: self.miter_limit,
+ }
+ }
+}
+
+impl Fold for PartialStroke<Abs> {
+ type Output = Self;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ Self {
+ paint: self.paint.or(outer.paint),
+ thickness: self.thickness.or(outer.thickness),
+ line_cap: self.line_cap.or(outer.line_cap),
+ line_join: self.line_join.or(outer.line_join),
+ dash_pattern: self.dash_pattern.or(outer.dash_pattern),
+ miter_limit: self.miter_limit.or(outer.miter_limit),
+ }
+ }
+}
+
+cast! {
+ type PartialStroke: "stroke",
+ thickness: Length => Self {
+ thickness: Smart::Custom(thickness),
+ ..Default::default()
+ },
+ color: Color => Self {
+ paint: Smart::Custom(color.into()),
+ ..Default::default()
+ },
+ mut dict: Dict => {
+ fn take<T: FromValue>(dict: &mut Dict, key: &str) -> StrResult<Smart<T>> {
+ Ok(dict.take(key).ok().map(T::from_value)
+ .transpose()?.map(Smart::Custom).unwrap_or(Smart::Auto))
+ }
+
+ let paint = take::<Paint>(&mut dict, "paint")?;
+ let thickness = take::<Length>(&mut dict, "thickness")?;
+ let line_cap = take::<LineCap>(&mut dict, "cap")?;
+ let line_join = take::<LineJoin>(&mut dict, "join")?;
+ let dash_pattern = 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,
+ line_cap,
+ line_join,
+ dash_pattern,
+ miter_limit: miter_limit.map(Scalar),
+ }
+ },
+}
+
+cast! {
+ PartialStroke<Abs>,
+ self => self.map(Length::from).into_value(),
+}
+
+/// The line cap of a stroke
+#[derive(Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum LineCap {
+ Butt,
+ Round,
+ Square,
+}
+
+impl Debug for LineCap {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ LineCap::Butt => write!(f, "\"butt\""),
+ LineCap::Round => write!(f, "\"round\""),
+ LineCap::Square => write!(f, "\"square\""),
+ }
+ }
+}
+
+/// The line join of a stroke
+#[derive(Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum LineJoin {
+ Miter,
+ Round,
+ Bevel,
+}
+
+impl Debug for LineJoin {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ LineJoin::Miter => write!(f, "\"miter\""),
+ LineJoin::Round => write!(f, "\"round\""),
+ LineJoin::Bevel => write!(f, "\"bevel\""),
+ }
+ }
+}
+
+/// A line dash pattern.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub struct DashPattern<T = Length, DT = DashLength<T>> {
+ /// The dash array.
+ pub array: Vec<DT>,
+ /// The dash phase.
+ pub phase: T,
+}
+
+impl<T: Debug, DT: Debug> Debug for DashPattern<T, DT> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "(array: (")?;
+ for (i, elem) in self.array.iter().enumerate() {
+ if i == 0 {
+ write!(f, "{:?}", elem)?;
+ } else {
+ write!(f, ", {:?}", elem)?;
+ }
+ }
+ write!(f, "), phase: {:?})", self.phase)?;
+ Ok(())
+ }
+}
+
+impl<T: 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,
+
+ "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 = Length> {
+ LineWidth,
+ Length(T),
+}
+
+impl From<Abs> for DashLength {
+ fn from(l: Abs) -> Self {
+ DashLength::Length(l.into())
+ }
+}
+
+impl<T> DashLength<T> {
+ fn finish(self, line_width: T) -> T {
+ match self {
+ Self::LineWidth => line_width,
+ Self::Length(l) => l,
+ }
+ }
+}
+
+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)),
+ }
+ }
+}
+
+cast! {
+ DashLength,
+ "dot" => Self::LineWidth,
+ v: Length => Self::Length(v),
+}
diff --git a/crates/typst/src/geom/transform.rs b/crates/typst/src/geom/transform.rs
new file mode 100644
index 00000000..1ff1dfdd
--- /dev/null
+++ b/crates/typst/src/geom/transform.rs
@@ -0,0 +1,77 @@
+use super::*;
+
+/// 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()
+ }
+ }
+
+ /// 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)
+ }
+}
+
+impl Default for Transform {
+ fn default() -> Self {
+ Self::identity()
+ }
+}
diff --git a/crates/typst/src/ide/analyze.rs b/crates/typst/src/ide/analyze.rs
new file mode 100644
index 00000000..dad466c1
--- /dev/null
+++ b/crates/typst/src/ide/analyze.rs
@@ -0,0 +1,111 @@
+use comemo::Track;
+use ecow::EcoString;
+
+use crate::doc::Frame;
+use crate::eval::{eval, Module, Route, Tracer, Value};
+use crate::model::{Introspector, Label};
+use crate::syntax::{ast, LinkedNode, Source, SyntaxKind};
+use crate::World;
+
+/// Try to determine a set of possible values for an expression.
+pub fn analyze_expr(world: &(dyn World + 'static), node: &LinkedNode) -> Vec<Value> {
+ match node.cast::<ast::Expr>() {
+ Some(ast::Expr::None(_)) => vec![Value::None],
+ Some(ast::Expr::Auto(_)) => vec![Value::Auto],
+ Some(ast::Expr::Bool(v)) => vec![Value::Bool(v.get())],
+ Some(ast::Expr::Int(v)) => vec![Value::Int(v.get())],
+ Some(ast::Expr::Float(v)) => vec![Value::Float(v.get())],
+ Some(ast::Expr::Numeric(v)) => vec![Value::numeric(v.get())],
+ Some(ast::Expr::Str(v)) => vec![Value::Str(v.get().into())],
+
+ Some(ast::Expr::FieldAccess(access)) => {
+ let Some(child) = node.children().next() else { return vec![] };
+ analyze_expr(world, &child)
+ .into_iter()
+ .filter_map(|target| target.field(&access.field()).ok())
+ .collect()
+ }
+
+ Some(_) => {
+ if let Some(parent) = node.parent() {
+ if parent.kind() == SyntaxKind::FieldAccess && node.index() > 0 {
+ return analyze_expr(world, parent);
+ }
+ }
+
+ let route = Route::default();
+ let mut tracer = Tracer::new(Some(node.span()));
+ typst::eval::eval(
+ world.track(),
+ route.track(),
+ tracer.track_mut(),
+ &world.main(),
+ )
+ .and_then(|module| {
+ typst::model::typeset(
+ world.track(),
+ tracer.track_mut(),
+ &module.content(),
+ )
+ })
+ .ok();
+
+ tracer.finish()
+ }
+
+ _ => vec![],
+ }
+}
+
+/// Try to load a module from the current source file.
+pub fn analyze_import(
+ world: &(dyn World + 'static),
+ source: &Source,
+ path: &str,
+) -> Option<Module> {
+ let route = Route::default();
+ let mut tracer = Tracer::default();
+ let id = source.id().join(path).ok()?;
+ let source = world.source(id).ok()?;
+ eval(world.track(), route.track(), tracer.track_mut(), &source).ok()
+}
+
+/// Find all labels and details for them.
+///
+/// Returns:
+/// - All labels and descriptions for them, if available
+/// - A split offset: All labels before this offset belong to nodes, all after
+/// belong to a bibliography.
+pub fn analyze_labels(
+ world: &(dyn World + 'static),
+ frames: &[Frame],
+) -> (Vec<(Label, Option<EcoString>)>, usize) {
+ let mut output = vec![];
+ let introspector = Introspector::new(frames);
+ let items = &world.library().items;
+
+ // Labels in the document.
+ for elem in introspector.all() {
+ let Some(label) = elem.label().cloned() else { continue };
+ let details = elem
+ .field("caption")
+ .or_else(|| elem.field("body"))
+ .and_then(|field| match field {
+ Value::Content(content) => Some(content),
+ _ => None,
+ })
+ .as_ref()
+ .unwrap_or(elem)
+ .plain_text();
+ output.push((label, Some(details)));
+ }
+
+ let split = output.len();
+
+ // Bibliography keys.
+ for (key, detail) in (items.bibliography_keys)(introspector.track()) {
+ output.push((Label(key), detail));
+ }
+
+ (output, split)
+}
diff --git a/crates/typst/src/ide/complete.rs b/crates/typst/src/ide/complete.rs
new file mode 100644
index 00000000..16cba1bc
--- /dev/null
+++ b/crates/typst/src/ide/complete.rs
@@ -0,0 +1,1201 @@
+use std::collections::{BTreeSet, HashSet};
+
+use ecow::{eco_format, EcoString};
+use if_chain::if_chain;
+use unscanny::Scanner;
+
+use super::analyze::analyze_labels;
+use super::{analyze_expr, analyze_import, plain_docs_sentence, summarize_font_family};
+use crate::doc::Frame;
+use crate::eval::{format_str, methods_on, CastInfo, Library, Scope, Value};
+use crate::syntax::{
+ ast, is_id_continue, is_id_start, is_ident, LinkedNode, Source, SyntaxKind,
+};
+use crate::util::separated_list;
+use crate::World;
+
+/// Autocomplete a cursor position in a source file.
+///
+/// Returns the position from which the completions apply and a list of
+/// completions.
+///
+/// When `explicit` is `true`, the user requested the completion by pressing
+/// control and space or something similar.
+pub fn autocomplete(
+ world: &(dyn World + 'static),
+ frames: &[Frame],
+ source: &Source,
+ cursor: usize,
+ explicit: bool,
+) -> Option<(usize, Vec<Completion>)> {
+ let mut ctx = CompletionContext::new(world, frames, source, cursor, explicit)?;
+
+ let _ = complete_comments(&mut ctx)
+ || complete_field_accesses(&mut ctx)
+ || complete_imports(&mut ctx)
+ || complete_rules(&mut ctx)
+ || complete_params(&mut ctx)
+ || complete_markup(&mut ctx)
+ || complete_math(&mut ctx)
+ || complete_code(&mut ctx);
+
+ Some((ctx.from, ctx.completions))
+}
+
+/// An autocompletion option.
+#[derive(Debug, Clone)]
+pub struct Completion {
+ /// The kind of item this completes to.
+ pub kind: CompletionKind,
+ /// The label the completion is shown with.
+ pub label: EcoString,
+ /// The completed version of the input, possibly described with snippet
+ /// syntax like `${lhs} + ${rhs}`.
+ ///
+ /// Should default to the `label` if `None`.
+ pub apply: Option<EcoString>,
+ /// An optional short description, at most one sentence.
+ pub detail: Option<EcoString>,
+}
+
+/// A kind of item that can be completed.
+#[derive(Debug, Clone)]
+pub enum CompletionKind {
+ /// A syntactical structure.
+ Syntax,
+ /// A function.
+ Func,
+ /// A function parameter.
+ Param,
+ /// A constant.
+ Constant,
+ /// A symbol.
+ Symbol(char),
+}
+
+/// Complete in comments. Or rather, don't!
+fn complete_comments(ctx: &mut CompletionContext) -> bool {
+ matches!(ctx.leaf.kind(), SyntaxKind::LineComment | SyntaxKind::BlockComment)
+}
+
+/// Complete in markup mode.
+fn complete_markup(ctx: &mut CompletionContext) -> bool {
+ // Bail if we aren't even in markup.
+ if !matches!(
+ ctx.leaf.parent_kind(),
+ None | Some(SyntaxKind::Markup) | Some(SyntaxKind::Ref)
+ ) {
+ return false;
+ }
+
+ // Start of an interpolated identifier: "#|".
+ if ctx.leaf.kind() == SyntaxKind::Hashtag {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, true);
+ return true;
+ }
+
+ // An existing identifier: "#pa|".
+ if ctx.leaf.kind() == SyntaxKind::Ident {
+ ctx.from = ctx.leaf.offset();
+ code_completions(ctx, true);
+ return true;
+ }
+
+ // Start of an reference: "@|" or "@he|".
+ if ctx.leaf.kind() == SyntaxKind::RefMarker {
+ ctx.from = ctx.leaf.offset() + 1;
+ ctx.label_completions();
+ return true;
+ }
+
+ // Behind a half-completed binding: "#let x = |".
+ if_chain! {
+ if let Some(prev) = ctx.leaf.prev_leaf();
+ if prev.kind() == SyntaxKind::Eq;
+ if prev.parent_kind() == Some(SyntaxKind::LetBinding);
+ then {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, false);
+ return true;
+ }
+ }
+
+ // Directly after a raw block.
+ let mut s = Scanner::new(ctx.text);
+ s.jump(ctx.leaf.offset());
+ if s.eat_if("```") {
+ s.eat_while('`');
+ let start = s.cursor();
+ if s.eat_if(is_id_start) {
+ s.eat_while(is_id_continue);
+ }
+ if s.cursor() == ctx.cursor {
+ ctx.from = start;
+ ctx.raw_completions();
+ }
+ return true;
+ }
+
+ // Anywhere: "|".
+ if ctx.explicit {
+ ctx.from = ctx.cursor;
+ markup_completions(ctx);
+ return true;
+ }
+
+ false
+}
+
+/// Add completions for markup snippets.
+#[rustfmt::skip]
+fn markup_completions(ctx: &mut CompletionContext) {
+ ctx.snippet_completion(
+ "expression",
+ "#${}",
+ "Variables, function calls, blocks, and more.",
+ );
+
+ ctx.snippet_completion(
+ "linebreak",
+ "\\\n${}",
+ "Inserts a forced linebreak.",
+ );
+
+ ctx.snippet_completion(
+ "strong text",
+ "*${strong}*",
+ "Strongly emphasizes content by increasing the font weight.",
+ );
+
+ ctx.snippet_completion(
+ "emphasized text",
+ "_${emphasized}_",
+ "Emphasizes content by setting it in italic font style.",
+ );
+
+ ctx.snippet_completion(
+ "raw text",
+ "`${text}`",
+ "Displays text verbatim, in monospace.",
+ );
+
+ ctx.snippet_completion(
+ "code listing",
+ "```${lang}\n${code}\n```",
+ "Inserts computer code with syntax highlighting.",
+ );
+
+ ctx.snippet_completion(
+ "hyperlink",
+ "https://${example.com}",
+ "Links to a URL.",
+ );
+
+ ctx.snippet_completion(
+ "label",
+ "<${name}>",
+ "Makes the preceding element referenceable.",
+ );
+
+ ctx.snippet_completion(
+ "reference",
+ "@${name}",
+ "Inserts a reference to a label.",
+ );
+
+ ctx.snippet_completion(
+ "heading",
+ "= ${title}",
+ "Inserts a section heading.",
+ );
+
+ ctx.snippet_completion(
+ "list item",
+ "- ${item}",
+ "Inserts an item of a bullet list.",
+ );
+
+ ctx.snippet_completion(
+ "enumeration item",
+ "+ ${item}",
+ "Inserts an item of a numbered list.",
+ );
+
+ ctx.snippet_completion(
+ "enumeration item (numbered)",
+ "${number}. ${item}",
+ "Inserts an explicitly numbered list item.",
+ );
+
+ ctx.snippet_completion(
+ "term list item",
+ "/ ${term}: ${description}",
+ "Inserts an item of a term list.",
+ );
+
+ ctx.snippet_completion(
+ "math (inline)",
+ "$${x}$",
+ "Inserts an inline-level mathematical equation.",
+ );
+
+ ctx.snippet_completion(
+ "math (block)",
+ "$ ${sum_x^2} $",
+ "Inserts a block-level mathematical equation.",
+ );
+}
+
+/// Complete in math mode.
+fn complete_math(ctx: &mut CompletionContext) -> bool {
+ if !matches!(
+ ctx.leaf.parent_kind(),
+ Some(SyntaxKind::Equation)
+ | Some(SyntaxKind::Math)
+ | Some(SyntaxKind::MathFrac)
+ | Some(SyntaxKind::MathAttach)
+ ) {
+ return false;
+ }
+
+ // Start of an interpolated identifier: "#|".
+ if ctx.leaf.kind() == SyntaxKind::Hashtag {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, true);
+ return true;
+ }
+
+ // Behind existing atom or identifier: "$a|$" or "$abc|$".
+ if matches!(ctx.leaf.kind(), SyntaxKind::Text | SyntaxKind::MathIdent) {
+ ctx.from = ctx.leaf.offset();
+ math_completions(ctx);
+ return true;
+ }
+
+ // Anywhere: "$|$".
+ if ctx.explicit {
+ ctx.from = ctx.cursor;
+ math_completions(ctx);
+ return true;
+ }
+
+ false
+}
+
+/// Add completions for math snippets.
+#[rustfmt::skip]
+fn math_completions(ctx: &mut CompletionContext) {
+ ctx.scope_completions(true, |_| true);
+
+ ctx.snippet_completion(
+ "subscript",
+ "${x}_${2:2}",
+ "Sets something in subscript.",
+ );
+
+ ctx.snippet_completion(
+ "superscript",
+ "${x}^${2:2}",
+ "Sets something in superscript.",
+ );
+
+ ctx.snippet_completion(
+ "fraction",
+ "${x}/${y}",
+ "Inserts a fraction.",
+ );
+}
+
+/// Complete field accesses.
+fn complete_field_accesses(ctx: &mut CompletionContext) -> bool {
+ // Behind an expression plus dot: "emoji.|".
+ if_chain! {
+ if ctx.leaf.kind() == SyntaxKind::Dot
+ || (ctx.leaf.kind() == SyntaxKind::Text
+ && ctx.leaf.text() == ".");
+ if ctx.leaf.range().end == ctx.cursor;
+ if let Some(prev) = ctx.leaf.prev_sibling();
+ if prev.is::<ast::Expr>();
+ if prev.parent_kind() != Some(SyntaxKind::Markup) ||
+ prev.prev_sibling_kind() == Some(SyntaxKind::Hashtag);
+ if let Some(value) = analyze_expr(ctx.world, &prev).into_iter().next();
+ then {
+ ctx.from = ctx.cursor;
+ field_access_completions(ctx, &value);
+ return true;
+ }
+ }
+
+ // Behind a started field access: "emoji.fa|".
+ if_chain! {
+ if ctx.leaf.kind() == SyntaxKind::Ident;
+ if let Some(prev) = ctx.leaf.prev_sibling();
+ if prev.kind() == SyntaxKind::Dot;
+ if let Some(prev_prev) = prev.prev_sibling();
+ if prev_prev.is::<ast::Expr>();
+ if let Some(value) = analyze_expr(ctx.world, &prev_prev).into_iter().next();
+ then {
+ ctx.from = ctx.leaf.offset();
+ field_access_completions(ctx, &value);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for all fields on a value.
+fn field_access_completions(ctx: &mut CompletionContext, value: &Value) {
+ for &(method, args) in methods_on(value.type_name()) {
+ ctx.completions.push(Completion {
+ kind: CompletionKind::Func,
+ label: method.into(),
+ apply: Some(if args {
+ eco_format!("{method}(${{}})")
+ } else {
+ eco_format!("{method}()${{}}")
+ }),
+ detail: None,
+ })
+ }
+
+ match value {
+ Value::Symbol(symbol) => {
+ for modifier in symbol.modifiers() {
+ if let Ok(modified) = symbol.clone().modified(modifier) {
+ ctx.completions.push(Completion {
+ kind: CompletionKind::Symbol(modified.get()),
+ label: modifier.into(),
+ apply: None,
+ detail: None,
+ });
+ }
+ }
+ }
+ Value::Content(content) => {
+ for (name, value) in content.fields() {
+ ctx.value_completion(Some(name.clone()), &value, false, None);
+ }
+ }
+ Value::Dict(dict) => {
+ for (name, value) in dict.iter() {
+ ctx.value_completion(Some(name.clone().into()), value, false, None);
+ }
+ }
+ Value::Module(module) => {
+ for (name, value) in module.scope().iter() {
+ ctx.value_completion(Some(name.clone()), value, true, None);
+ }
+ }
+ Value::Func(func) => {
+ if let Some(info) = func.info() {
+ // Consider all names from the function's scope.
+ for (name, value) in info.scope.iter() {
+ ctx.value_completion(Some(name.clone()), value, true, None);
+ }
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Complete imports.
+fn complete_imports(ctx: &mut CompletionContext) -> bool {
+ // In an import path for a package:
+ // "#import "@|",
+ if_chain! {
+ if matches!(
+ ctx.leaf.parent_kind(),
+ Some(SyntaxKind::ModuleImport | SyntaxKind::ModuleInclude)
+ );
+ if let Some(ast::Expr::Str(str)) = ctx.leaf.cast();
+ if str.get().starts_with('@');
+ then {
+ ctx.from = ctx.leaf.offset();
+ ctx.package_completions();
+ return true;
+ }
+ }
+
+ // Behind an import list:
+ // "#import "path.typ": |",
+ // "#import "path.typ": a, b, |".
+ if_chain! {
+ if let Some(prev) = ctx.leaf.prev_sibling();
+ if let Some(ast::Expr::Import(import)) = prev.cast();
+ if let Some(ast::Imports::Items(items)) = import.imports();
+ if let Some(source) = prev.children().find(|child| child.is::<ast::Expr>());
+ if let Some(value) = analyze_expr(ctx.world, &source).into_iter().next();
+ then {
+ ctx.from = ctx.cursor;
+ import_item_completions(ctx, &items, &value);
+ return true;
+ }
+ }
+
+ // Behind a half-started identifier in an import list:
+ // "#import "path.typ": thi|",
+ if_chain! {
+ if ctx.leaf.kind() == SyntaxKind::Ident;
+ if let Some(parent) = ctx.leaf.parent();
+ if parent.kind() == SyntaxKind::ImportItems;
+ if let Some(grand) = parent.parent();
+ if let Some(ast::Expr::Import(import)) = grand.cast();
+ if let Some(ast::Imports::Items(items)) = import.imports();
+ if let Some(source) = grand.children().find(|child| child.is::<ast::Expr>());
+ if let Some(value) = analyze_expr(ctx.world, &source).into_iter().next();
+ then {
+ ctx.from = ctx.leaf.offset();
+ import_item_completions(ctx, &items, &value);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for all exports of a module.
+fn import_item_completions(
+ ctx: &mut CompletionContext,
+ existing: &[ast::Ident],
+ value: &Value,
+) {
+ let module = match value {
+ Value::Str(path) => match analyze_import(ctx.world, ctx.source, path) {
+ Some(module) => module,
+ None => return,
+ },
+ Value::Module(module) => module.clone(),
+ _ => return,
+ };
+
+ if existing.is_empty() {
+ ctx.snippet_completion("*", "*", "Import everything.");
+ }
+
+ for (name, value) in module.scope().iter() {
+ if existing.iter().all(|ident| ident.as_str() != name) {
+ ctx.value_completion(Some(name.clone()), value, false, None);
+ }
+ }
+}
+
+/// Complete set and show rules.
+fn complete_rules(ctx: &mut CompletionContext) -> bool {
+ // We don't want to complete directly behind the keyword.
+ if !ctx.leaf.kind().is_trivia() {
+ return false;
+ }
+
+ let Some(prev) = ctx.leaf.prev_leaf() else { return false };
+
+ // Behind the set keyword: "set |".
+ if matches!(prev.kind(), SyntaxKind::Set) {
+ ctx.from = ctx.cursor;
+ set_rule_completions(ctx);
+ return true;
+ }
+
+ // Behind the show keyword: "show |".
+ if matches!(prev.kind(), SyntaxKind::Show) {
+ ctx.from = ctx.cursor;
+ show_rule_selector_completions(ctx);
+ return true;
+ }
+
+ // Behind a half-completed show rule: "show strong: |".
+ if_chain! {
+ if let Some(prev) = ctx.leaf.prev_leaf();
+ if matches!(prev.kind(), SyntaxKind::Colon);
+ if matches!(prev.parent_kind(), Some(SyntaxKind::ShowRule));
+ then {
+ ctx.from = ctx.cursor;
+ show_rule_recipe_completions(ctx);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for all functions from the global scope.
+fn set_rule_completions(ctx: &mut CompletionContext) {
+ ctx.scope_completions(true, |value| {
+ matches!(
+ value,
+ Value::Func(func) if func.info().map_or(false, |info| {
+ info.params.iter().any(|param| param.settable)
+ }),
+ )
+ });
+}
+
+/// Add completions for selectors.
+fn show_rule_selector_completions(ctx: &mut CompletionContext) {
+ ctx.scope_completions(
+ false,
+ |value| matches!(value, Value::Func(func) if func.element().is_some()),
+ );
+
+ ctx.enrich("", ": ");
+
+ ctx.snippet_completion(
+ "text selector",
+ "\"${text}\": ${}",
+ "Replace occurrences of specific text.",
+ );
+
+ ctx.snippet_completion(
+ "regex selector",
+ "regex(\"${regex}\"): ${}",
+ "Replace matches of a regular expression.",
+ );
+}
+
+/// Add completions for recipes.
+fn show_rule_recipe_completions(ctx: &mut CompletionContext) {
+ ctx.snippet_completion(
+ "replacement",
+ "[${content}]",
+ "Replace the selected element with content.",
+ );
+
+ ctx.snippet_completion(
+ "replacement (string)",
+ "\"${text}\"",
+ "Replace the selected element with a string of text.",
+ );
+
+ ctx.snippet_completion(
+ "transformation",
+ "element => [${content}]",
+ "Transform the element with a function.",
+ );
+
+ ctx.scope_completions(false, |value| matches!(value, Value::Func(_)));
+}
+
+/// Complete call and set rule parameters.
+fn complete_params(ctx: &mut CompletionContext) -> bool {
+ // Ensure that we are in a function call or set rule's argument list.
+ let (callee, set, args) = if_chain! {
+ if let Some(parent) = ctx.leaf.parent();
+ if let Some(parent) = match parent.kind() {
+ SyntaxKind::Named => parent.parent(),
+ _ => Some(parent),
+ };
+ if let Some(args) = parent.cast::<ast::Args>();
+ if let Some(grand) = parent.parent();
+ if let Some(expr) = grand.cast::<ast::Expr>();
+ let set = matches!(expr, ast::Expr::Set(_));
+ if let Some(ast::Expr::Ident(callee)) = match expr {
+ ast::Expr::FuncCall(call) => Some(call.callee()),
+ ast::Expr::Set(set) => Some(set.target()),
+ _ => None,
+ };
+ then {
+ (callee, set, args)
+ } else {
+ return false;
+ }
+ };
+
+ // Find the piece of syntax that decides what we're completing.
+ let mut deciding = ctx.leaf.clone();
+ while !matches!(
+ deciding.kind(),
+ SyntaxKind::LeftParen | SyntaxKind::Comma | SyntaxKind::Colon
+ ) {
+ let Some(prev) = deciding.prev_leaf() else { break };
+ deciding = prev;
+ }
+
+ // Parameter values: "func(param:|)", "func(param: |)".
+ if_chain! {
+ if deciding.kind() == SyntaxKind::Colon;
+ if let Some(prev) = deciding.prev_leaf();
+ if let Some(param) = prev.cast::<ast::Ident>();
+ then {
+ if let Some(next) = deciding.next_leaf() {
+ ctx.from = ctx.cursor.min(next.offset());
+ }
+
+ named_param_value_completions(ctx, &callee, &param);
+ return true;
+ }
+ }
+
+ // Parameters: "func(|)", "func(hi|)", "func(12,|)".
+ if_chain! {
+ if matches!(deciding.kind(), SyntaxKind::LeftParen | SyntaxKind::Comma);
+ if deciding.kind() != SyntaxKind::Comma || deciding.range().end < ctx.cursor;
+ then {
+ if let Some(next) = deciding.next_leaf() {
+ ctx.from = ctx.cursor.min(next.offset());
+ }
+
+ // Exclude arguments which are already present.
+ let exclude: Vec<_> = args.items().filter_map(|arg| match arg {
+ ast::Arg::Named(named) => Some(named.name()),
+ _ => None,
+ }).collect();
+
+ param_completions(ctx, &callee, set, &exclude);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for the parameters of a function.
+fn param_completions(
+ ctx: &mut CompletionContext,
+ callee: &ast::Ident,
+ set: bool,
+ exclude: &[ast::Ident],
+) {
+ let info = if_chain! {
+ if let Some(Value::Func(func)) = ctx.global.get(callee);
+ if let Some(info) = func.info();
+ then { info }
+ else { return; }
+ };
+
+ for param in &info.params {
+ if exclude.iter().any(|ident| ident.as_str() == param.name) {
+ continue;
+ }
+
+ if set && !param.settable {
+ continue;
+ }
+
+ if param.named {
+ ctx.completions.push(Completion {
+ kind: CompletionKind::Param,
+ label: param.name.into(),
+ apply: Some(eco_format!("{}: ${{}}", param.name)),
+ detail: Some(plain_docs_sentence(param.docs)),
+ });
+ }
+
+ if param.positional {
+ ctx.cast_completions(&param.cast);
+ }
+ }
+
+ if ctx.before.ends_with(',') {
+ ctx.enrich(" ", "");
+ }
+}
+
+/// Add completions for the values of a named function parameter.
+fn named_param_value_completions(
+ ctx: &mut CompletionContext,
+ callee: &ast::Ident,
+ name: &str,
+) {
+ let param = if_chain! {
+ if let Some(Value::Func(func)) = ctx.global.get(callee);
+ if let Some(info) = func.info();
+ if let Some(param) = info.param(name);
+ if param.named;
+ then { param }
+ else { return; }
+ };
+
+ ctx.cast_completions(&param.cast);
+
+ if callee.as_str() == "text" && name == "font" {
+ ctx.font_completions();
+ }
+
+ if ctx.before.ends_with(':') {
+ ctx.enrich(" ", "");
+ }
+}
+
+/// Complete in code mode.
+fn complete_code(ctx: &mut CompletionContext) -> bool {
+ if matches!(
+ ctx.leaf.parent_kind(),
+ None | Some(SyntaxKind::Markup)
+ | Some(SyntaxKind::Math)
+ | Some(SyntaxKind::MathFrac)
+ | Some(SyntaxKind::MathAttach)
+ | Some(SyntaxKind::MathRoot)
+ ) {
+ return false;
+ }
+
+ // An existing identifier: "{ pa| }".
+ if ctx.leaf.kind() == SyntaxKind::Ident {
+ ctx.from = ctx.leaf.offset();
+ code_completions(ctx, false);
+ return true;
+ }
+
+ // Anywhere: "{ | }".
+ // But not within or after an expression.
+ if ctx.explicit
+ && (ctx.leaf.kind().is_trivia()
+ || matches!(ctx.leaf.kind(), SyntaxKind::LeftParen | SyntaxKind::LeftBrace))
+ {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, false);
+ return true;
+ }
+
+ false
+}
+
+/// Add completions for expression snippets.
+#[rustfmt::skip]
+fn code_completions(ctx: &mut CompletionContext, hashtag: bool) {
+ ctx.scope_completions(true, |value| !hashtag || {
+ matches!(value, Value::Symbol(_) | Value::Func(_) | Value::Module(_))
+ });
+
+ ctx.snippet_completion(
+ "function call",
+ "${function}(${arguments})[${body}]",
+ "Evaluates a function.",
+ );
+
+ ctx.snippet_completion(
+ "code block",
+ "{ ${} }",
+ "Inserts a nested code block.",
+ );
+
+ ctx.snippet_completion(
+ "content block",
+ "[${content}]",
+ "Switches into markup mode.",
+ );
+
+ ctx.snippet_completion(
+ "set rule",
+ "set ${}",
+ "Sets style properties on an element.",
+ );
+
+ ctx.snippet_completion(
+ "show rule",
+ "show ${}",
+ "Redefines the look of an element.",
+ );
+
+ ctx.snippet_completion(
+ "show rule (everything)",
+ "show: ${}",
+ "Transforms everything that follows.",
+ );
+
+ ctx.snippet_completion(
+ "let binding",
+ "let ${name} = ${value}",
+ "Saves a value in a variable.",
+ );
+
+ ctx.snippet_completion(
+ "let binding (function)",
+ "let ${name}(${params}) = ${output}",
+ "Defines a function.",
+ );
+
+ ctx.snippet_completion(
+ "if conditional",
+ "if ${1 < 2} {\n\t${}\n}",
+ "Computes or inserts something conditionally.",
+ );
+
+ ctx.snippet_completion(
+ "if-else conditional",
+ "if ${1 < 2} {\n\t${}\n} else {\n\t${}\n}",
+ "Computes or inserts different things based on a condition.",
+ );
+
+ ctx.snippet_completion(
+ "while loop",
+ "while ${1 < 2} {\n\t${}\n}",
+ "Computes or inserts something while a condition is met.",
+ );
+
+ ctx.snippet_completion(
+ "for loop",
+ "for ${value} in ${(1, 2, 3)} {\n\t${}\n}",
+ "Computes or inserts something for each value in a collection.",
+ );
+
+ ctx.snippet_completion(
+ "for loop (with key)",
+ "for ${key}, ${value} in ${(a: 1, b: 2)} {\n\t${}\n}",
+ "Computes or inserts something for each key and value in a collection.",
+ );
+
+ ctx.snippet_completion(
+ "break",
+ "break",
+ "Exits early from a loop.",
+ );
+
+ ctx.snippet_completion(
+ "continue",
+ "continue",
+ "Continues with the next iteration of a loop.",
+ );
+
+ ctx.snippet_completion(
+ "return",
+ "return ${output}",
+ "Returns early from a function.",
+ );
+
+ ctx.snippet_completion(
+ "import (file)",
+ "import \"${file}.typ\": ${items}",
+ "Imports variables from another file.",
+ );
+
+ ctx.snippet_completion(
+ "import (package)",
+ "import \"@${}\": ${items}",
+ "Imports variables from another file.",
+ );
+
+ ctx.snippet_completion(
+ "include (file)",
+ "include \"${file}.typ\"",
+ "Includes content from another file.",
+ );
+
+ ctx.snippet_completion(
+ "include (package)",
+ "include \"@${}\"",
+ "Includes content from another file.",
+ );
+
+ ctx.snippet_completion(
+ "array",
+ "(${1, 2, 3})",
+ "Creates a sequence of values.",
+ );
+
+ ctx.snippet_completion(
+ "dictionary",
+ "(${a: 1, b: 2})",
+ "Creates a mapping from names to value.",
+ );
+
+ if !hashtag {
+ ctx.snippet_completion(
+ "function",
+ "(${params}) => ${output}",
+ "Creates an unnamed function.",
+ );
+ }
+}
+
+/// Context for autocompletion.
+struct CompletionContext<'a> {
+ world: &'a (dyn World + 'static),
+ frames: &'a [Frame],
+ library: &'a Library,
+ source: &'a Source,
+ global: &'a Scope,
+ math: &'a Scope,
+ text: &'a str,
+ before: &'a str,
+ after: &'a str,
+ leaf: LinkedNode<'a>,
+ cursor: usize,
+ explicit: bool,
+ from: usize,
+ completions: Vec<Completion>,
+ seen_casts: HashSet<u128>,
+}
+
+impl<'a> CompletionContext<'a> {
+ /// Create a new autocompletion context.
+ fn new(
+ world: &'a (dyn World + 'static),
+ frames: &'a [Frame],
+ source: &'a Source,
+ cursor: usize,
+ explicit: bool,
+ ) -> Option<Self> {
+ let text = source.text();
+ let library = world.library();
+ let leaf = LinkedNode::new(source.root()).leaf_at(cursor)?;
+ Some(Self {
+ world,
+ frames,
+ library,
+ source,
+ global: library.global.scope(),
+ math: library.math.scope(),
+ text,
+ before: &text[..cursor],
+ after: &text[cursor..],
+ leaf,
+ cursor,
+ explicit,
+ from: cursor,
+ completions: vec![],
+ seen_casts: HashSet::new(),
+ })
+ }
+
+ /// Add a prefix and suffix to all applications.
+ fn enrich(&mut self, prefix: &str, suffix: &str) {
+ for Completion { label, apply, .. } in &mut self.completions {
+ let current = apply.as_ref().unwrap_or(label);
+ *apply = Some(eco_format!("{prefix}{current}{suffix}"));
+ }
+ }
+
+ /// Add a snippet completion.
+ fn snippet_completion(
+ &mut self,
+ label: &'static str,
+ snippet: &'static str,
+ docs: &'static str,
+ ) {
+ self.completions.push(Completion {
+ kind: CompletionKind::Syntax,
+ label: label.into(),
+ apply: Some(snippet.into()),
+ detail: Some(docs.into()),
+ });
+ }
+
+ /// Add completions for all font families.
+ fn font_completions(&mut self) {
+ let equation = self.before[self.cursor.saturating_sub(25)..].contains("equation");
+ for (family, iter) in self.world.book().families() {
+ let detail = summarize_font_family(iter);
+ if !equation || family.contains("Math") {
+ self.value_completion(
+ None,
+ &Value::Str(family.into()),
+ false,
+ Some(detail.as_str()),
+ );
+ }
+ }
+ }
+
+ /// Add completions for all available packages.
+ fn package_completions(&mut self) {
+ for (package, description) in self.world.packages() {
+ self.value_completion(
+ None,
+ &Value::Str(format_str!("{package}")),
+ false,
+ description.as_deref(),
+ );
+ }
+ }
+
+ /// Add completions for raw block tags.
+ fn raw_completions(&mut self) {
+ for (name, mut tags) in (self.library.items.raw_languages)() {
+ let lower = name.to_lowercase();
+ if !tags.contains(&lower.as_str()) {
+ tags.push(lower.as_str());
+ }
+
+ tags.retain(|tag| is_ident(tag));
+ if tags.is_empty() {
+ continue;
+ }
+
+ self.completions.push(Completion {
+ kind: CompletionKind::Constant,
+ label: name.into(),
+ apply: Some(tags[0].into()),
+ detail: Some(separated_list(&tags, " or ").into()),
+ });
+ }
+ }
+
+ /// Add completions for all labels.
+ fn label_completions(&mut self) {
+ for (label, detail) in analyze_labels(self.world, self.frames).0 {
+ self.completions.push(Completion {
+ kind: CompletionKind::Constant,
+ label: label.0,
+ apply: None,
+ detail,
+ });
+ }
+ }
+
+ /// Add a completion for a specific value.
+ fn value_completion(
+ &mut self,
+ label: Option<EcoString>,
+ value: &Value,
+ parens: bool,
+ docs: Option<&str>,
+ ) {
+ let label = label.unwrap_or_else(|| value.repr().into());
+ let mut apply = None;
+
+ if label.starts_with('"') && self.after.starts_with('"') {
+ if let Some(trimmed) = label.strip_suffix('"') {
+ apply = Some(trimmed.into());
+ }
+ }
+
+ let detail = docs.map(Into::into).or_else(|| match value {
+ Value::Symbol(_) => None,
+ Value::Func(func) => func.info().map(|info| plain_docs_sentence(info.docs)),
+ v => {
+ let repr = v.repr();
+ (repr.as_str() != label).then(|| repr.into())
+ }
+ });
+
+ if parens && matches!(value, Value::Func(_)) {
+ apply = Some(eco_format!("{label}(${{}})"));
+ }
+
+ self.completions.push(Completion {
+ kind: match value {
+ Value::Func(_) => CompletionKind::Func,
+ Value::Symbol(s) => CompletionKind::Symbol(s.get()),
+ _ => CompletionKind::Constant,
+ },
+ label,
+ apply,
+ detail,
+ });
+ }
+
+ /// Add completions for a castable.
+ fn cast_completions(&mut self, cast: &'a CastInfo) {
+ // Prevent duplicate completions from appearing.
+ if !self.seen_casts.insert(crate::util::hash128(cast)) {
+ return;
+ }
+
+ match cast {
+ CastInfo::Any => {}
+ CastInfo::Value(value, docs) => {
+ self.value_completion(None, value, true, Some(docs));
+ }
+ CastInfo::Type("none") => self.snippet_completion("none", "none", "Nothing."),
+ CastInfo::Type("auto") => {
+ self.snippet_completion("auto", "auto", "A smart default.");
+ }
+ CastInfo::Type("boolean") => {
+ self.snippet_completion("false", "false", "No / Disabled.");
+ self.snippet_completion("true", "true", "Yes / Enabled.");
+ }
+ CastInfo::Type("color") => {
+ self.snippet_completion(
+ "luma()",
+ "luma(${v})",
+ "A custom grayscale color.",
+ );
+ self.snippet_completion(
+ "rgb()",
+ "rgb(${r}, ${g}, ${b}, ${a})",
+ "A custom RGBA color.",
+ );
+ self.snippet_completion(
+ "cmyk()",
+ "cmyk(${c}, ${m}, ${y}, ${k})",
+ "A custom CMYK color.",
+ );
+ self.scope_completions(false, |value| value.type_name() == "color");
+ }
+ CastInfo::Type("function") => {
+ self.snippet_completion(
+ "function",
+ "(${params}) => ${output}",
+ "A custom function.",
+ );
+ }
+ CastInfo::Type(ty) => {
+ self.completions.push(Completion {
+ kind: CompletionKind::Syntax,
+ label: (*ty).into(),
+ apply: Some(eco_format!("${{{ty}}}")),
+ detail: Some(eco_format!("A value of type {ty}.")),
+ });
+ self.scope_completions(false, |value| value.type_name() == *ty);
+ }
+ CastInfo::Union(union) => {
+ for info in union {
+ self.cast_completions(info);
+ }
+ }
+ }
+ }
+
+ /// Add completions for definitions that are available at the cursor.
+ /// Filters the global/math scope with the given filter.
+ fn scope_completions(&mut self, parens: bool, filter: impl Fn(&Value) -> bool) {
+ let mut defined = BTreeSet::new();
+
+ let mut ancestor = Some(self.leaf.clone());
+ while let Some(node) = &ancestor {
+ let mut sibling = Some(node.clone());
+ while let Some(node) = &sibling {
+ if let Some(v) = node.cast::<ast::LetBinding>() {
+ for ident in v.kind().idents() {
+ defined.insert(ident.take());
+ }
+ }
+ sibling = node.prev_sibling();
+ }
+
+ if let Some(parent) = node.parent() {
+ if let Some(v) = parent.cast::<ast::ForLoop>() {
+ if node.prev_sibling_kind() != Some(SyntaxKind::In) {
+ let pattern = v.pattern();
+ for ident in pattern.idents() {
+ defined.insert(ident.take());
+ }
+ }
+ }
+
+ ancestor = Some(parent.clone());
+ continue;
+ }
+
+ break;
+ }
+
+ let in_math = matches!(
+ self.leaf.parent_kind(),
+ Some(SyntaxKind::Equation)
+ | Some(SyntaxKind::Math)
+ | Some(SyntaxKind::MathFrac)
+ | Some(SyntaxKind::MathAttach)
+ );
+
+ let scope = if in_math { self.math } else { self.global };
+ for (name, value) in scope.iter() {
+ if filter(value) && !defined.contains(name) {
+ self.value_completion(Some(name.clone()), value, parens, None);
+ }
+ }
+
+ for name in defined {
+ if !name.is_empty() {
+ self.completions.push(Completion {
+ kind: CompletionKind::Constant,
+ label: name,
+ apply: None,
+ detail: None,
+ });
+ }
+ }
+ }
+}
diff --git a/crates/typst/src/ide/highlight.rs b/crates/typst/src/ide/highlight.rs
new file mode 100644
index 00000000..2db636e3
--- /dev/null
+++ b/crates/typst/src/ide/highlight.rs
@@ -0,0 +1,430 @@
+use crate::syntax::{ast, LinkedNode, SyntaxKind, SyntaxNode};
+
+/// A syntax highlighting tag.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Tag {
+ /// A line or block comment.
+ Comment,
+ /// Punctuation in code.
+ Punctuation,
+ /// An escape sequence or shorthand.
+ Escape,
+ /// Strong markup.
+ Strong,
+ /// Emphasized markup.
+ Emph,
+ /// A hyperlink.
+ Link,
+ /// Raw text.
+ Raw,
+ /// A label.
+ Label,
+ /// A reference to a label.
+ Ref,
+ /// A section heading.
+ Heading,
+ /// A marker of a list, enumeration, or term list.
+ ListMarker,
+ /// A term in a term list.
+ ListTerm,
+ /// The delimiters of an equation.
+ MathDelimiter,
+ /// An operator with special meaning in an equation.
+ MathOperator,
+ /// A keyword.
+ Keyword,
+ /// An operator in code.
+ Operator,
+ /// A numeric literal.
+ Number,
+ /// A string literal.
+ String,
+ /// A function or method name.
+ Function,
+ /// An interpolated variable in markup or math.
+ Interpolated,
+ /// A syntax error.
+ Error,
+}
+
+impl Tag {
+ /// Return the recommended TextMate grammar scope for the given highlighting
+ /// tag.
+ pub fn tm_scope(&self) -> &'static str {
+ match self {
+ Self::Comment => "comment.typst",
+ Self::Punctuation => "punctuation.typst",
+ Self::Escape => "constant.character.escape.typst",
+ Self::Strong => "markup.bold.typst",
+ Self::Emph => "markup.italic.typst",
+ Self::Link => "markup.underline.link.typst",
+ Self::Raw => "markup.raw.typst",
+ Self::MathDelimiter => "punctuation.definition.math.typst",
+ Self::MathOperator => "keyword.operator.math.typst",
+ Self::Heading => "markup.heading.typst",
+ Self::ListMarker => "punctuation.definition.list.typst",
+ Self::ListTerm => "markup.list.term.typst",
+ Self::Label => "entity.name.label.typst",
+ Self::Ref => "markup.other.reference.typst",
+ Self::Keyword => "keyword.typst",
+ Self::Operator => "keyword.operator.typst",
+ Self::Number => "constant.numeric.typst",
+ Self::String => "string.quoted.double.typst",
+ Self::Function => "entity.name.function.typst",
+ Self::Interpolated => "meta.interpolation.typst",
+ Self::Error => "invalid.typst",
+ }
+ }
+
+ /// The recommended CSS class for the highlighting tag.
+ pub fn css_class(self) -> &'static str {
+ match self {
+ Self::Comment => "typ-comment",
+ Self::Punctuation => "typ-punct",
+ Self::Escape => "typ-escape",
+ Self::Strong => "typ-strong",
+ Self::Emph => "typ-emph",
+ Self::Link => "typ-link",
+ Self::Raw => "typ-raw",
+ Self::Label => "typ-label",
+ Self::Ref => "typ-ref",
+ Self::Heading => "typ-heading",
+ Self::ListMarker => "typ-marker",
+ Self::ListTerm => "typ-term",
+ Self::MathDelimiter => "typ-math-delim",
+ Self::MathOperator => "typ-math-op",
+ Self::Keyword => "typ-key",
+ Self::Operator => "typ-op",
+ Self::Number => "typ-num",
+ Self::String => "typ-str",
+ Self::Function => "typ-func",
+ Self::Interpolated => "typ-pol",
+ Self::Error => "typ-error",
+ }
+ }
+}
+
+/// Determine the highlight tag of a linked syntax node.
+///
+/// Returns `None` if the node should not be highlighted.
+pub fn highlight(node: &LinkedNode) -> Option<Tag> {
+ match node.kind() {
+ SyntaxKind::Markup
+ if node.parent_kind() == Some(SyntaxKind::TermItem)
+ && node.next_sibling_kind() == Some(SyntaxKind::Colon) =>
+ {
+ Some(Tag::ListTerm)
+ }
+ SyntaxKind::Markup => None,
+ SyntaxKind::Text => None,
+ SyntaxKind::Space => None,
+ SyntaxKind::Linebreak => Some(Tag::Escape),
+ SyntaxKind::Parbreak => None,
+ SyntaxKind::Escape => Some(Tag::Escape),
+ SyntaxKind::Shorthand => Some(Tag::Escape),
+ SyntaxKind::SmartQuote => None,
+ SyntaxKind::Strong => Some(Tag::Strong),
+ SyntaxKind::Emph => Some(Tag::Emph),
+ SyntaxKind::Raw => Some(Tag::Raw),
+ SyntaxKind::Link => Some(Tag::Link),
+ SyntaxKind::Label => Some(Tag::Label),
+ SyntaxKind::Ref => Some(Tag::Ref),
+ SyntaxKind::RefMarker => None,
+ SyntaxKind::Heading => Some(Tag::Heading),
+ SyntaxKind::HeadingMarker => None,
+ SyntaxKind::ListItem => None,
+ SyntaxKind::ListMarker => Some(Tag::ListMarker),
+ SyntaxKind::EnumItem => None,
+ SyntaxKind::EnumMarker => Some(Tag::ListMarker),
+ SyntaxKind::TermItem => None,
+ SyntaxKind::TermMarker => Some(Tag::ListMarker),
+ SyntaxKind::Equation => None,
+
+ SyntaxKind::Math => None,
+ SyntaxKind::MathIdent => highlight_ident(node),
+ SyntaxKind::MathAlignPoint => Some(Tag::MathOperator),
+ SyntaxKind::MathDelimited => None,
+ SyntaxKind::MathAttach => None,
+ SyntaxKind::MathFrac => None,
+ SyntaxKind::MathRoot => None,
+
+ SyntaxKind::Hashtag => highlight_hashtag(node),
+ SyntaxKind::LeftBrace => Some(Tag::Punctuation),
+ SyntaxKind::RightBrace => Some(Tag::Punctuation),
+ SyntaxKind::LeftBracket => Some(Tag::Punctuation),
+ SyntaxKind::RightBracket => Some(Tag::Punctuation),
+ SyntaxKind::LeftParen => Some(Tag::Punctuation),
+ SyntaxKind::RightParen => Some(Tag::Punctuation),
+ SyntaxKind::Comma => Some(Tag::Punctuation),
+ SyntaxKind::Semicolon => Some(Tag::Punctuation),
+ SyntaxKind::Colon => Some(Tag::Punctuation),
+ SyntaxKind::Star => match node.parent_kind() {
+ Some(SyntaxKind::Strong) => None,
+ _ => Some(Tag::Operator),
+ },
+ SyntaxKind::Underscore => match node.parent_kind() {
+ Some(SyntaxKind::MathAttach) => Some(Tag::MathOperator),
+ _ => None,
+ },
+ SyntaxKind::Dollar => Some(Tag::MathDelimiter),
+ SyntaxKind::Plus => Some(Tag::Operator),
+ SyntaxKind::Minus => Some(Tag::Operator),
+ SyntaxKind::Slash => Some(match node.parent_kind() {
+ Some(SyntaxKind::MathFrac) => Tag::MathOperator,
+ _ => Tag::Operator,
+ }),
+ SyntaxKind::Hat => Some(Tag::MathOperator),
+ SyntaxKind::Dot => Some(Tag::Punctuation),
+ SyntaxKind::Eq => match node.parent_kind() {
+ Some(SyntaxKind::Heading) => None,
+ _ => Some(Tag::Operator),
+ },
+ SyntaxKind::EqEq => Some(Tag::Operator),
+ SyntaxKind::ExclEq => Some(Tag::Operator),
+ SyntaxKind::Lt => Some(Tag::Operator),
+ SyntaxKind::LtEq => Some(Tag::Operator),
+ SyntaxKind::Gt => Some(Tag::Operator),
+ SyntaxKind::GtEq => Some(Tag::Operator),
+ SyntaxKind::PlusEq => Some(Tag::Operator),
+ SyntaxKind::HyphEq => Some(Tag::Operator),
+ SyntaxKind::StarEq => Some(Tag::Operator),
+ SyntaxKind::SlashEq => Some(Tag::Operator),
+ SyntaxKind::Dots => Some(Tag::Operator),
+ SyntaxKind::Arrow => Some(Tag::Operator),
+ SyntaxKind::Root => Some(Tag::MathOperator),
+
+ SyntaxKind::Not => Some(Tag::Keyword),
+ SyntaxKind::And => Some(Tag::Keyword),
+ SyntaxKind::Or => Some(Tag::Keyword),
+ SyntaxKind::None => Some(Tag::Keyword),
+ SyntaxKind::Auto => Some(Tag::Keyword),
+ SyntaxKind::Let => Some(Tag::Keyword),
+ SyntaxKind::Set => Some(Tag::Keyword),
+ SyntaxKind::Show => Some(Tag::Keyword),
+ SyntaxKind::If => Some(Tag::Keyword),
+ SyntaxKind::Else => Some(Tag::Keyword),
+ SyntaxKind::For => Some(Tag::Keyword),
+ SyntaxKind::In => Some(Tag::Keyword),
+ SyntaxKind::While => Some(Tag::Keyword),
+ SyntaxKind::Break => Some(Tag::Keyword),
+ SyntaxKind::Continue => Some(Tag::Keyword),
+ SyntaxKind::Return => Some(Tag::Keyword),
+ SyntaxKind::Import => Some(Tag::Keyword),
+ SyntaxKind::Include => Some(Tag::Keyword),
+ SyntaxKind::As => Some(Tag::Keyword),
+
+ SyntaxKind::Code => None,
+ SyntaxKind::Ident => highlight_ident(node),
+ SyntaxKind::Bool => Some(Tag::Keyword),
+ SyntaxKind::Int => Some(Tag::Number),
+ SyntaxKind::Float => Some(Tag::Number),
+ SyntaxKind::Numeric => Some(Tag::Number),
+ SyntaxKind::Str => Some(Tag::String),
+ SyntaxKind::CodeBlock => None,
+ SyntaxKind::ContentBlock => None,
+ SyntaxKind::Parenthesized => None,
+ SyntaxKind::Array => None,
+ SyntaxKind::Dict => None,
+ SyntaxKind::Named => None,
+ SyntaxKind::Keyed => None,
+ SyntaxKind::Unary => None,
+ SyntaxKind::Binary => None,
+ SyntaxKind::FieldAccess => None,
+ SyntaxKind::FuncCall => None,
+ SyntaxKind::Args => None,
+ SyntaxKind::Spread => None,
+ SyntaxKind::Closure => None,
+ SyntaxKind::Params => None,
+ SyntaxKind::LetBinding => None,
+ SyntaxKind::SetRule => None,
+ SyntaxKind::ShowRule => None,
+ SyntaxKind::Conditional => None,
+ SyntaxKind::WhileLoop => None,
+ SyntaxKind::ForLoop => None,
+ SyntaxKind::ModuleImport => None,
+ SyntaxKind::ImportItems => None,
+ SyntaxKind::ModuleInclude => None,
+ SyntaxKind::LoopBreak => None,
+ SyntaxKind::LoopContinue => None,
+ SyntaxKind::FuncReturn => None,
+ SyntaxKind::Destructuring => None,
+ SyntaxKind::DestructAssignment => None,
+
+ SyntaxKind::LineComment => Some(Tag::Comment),
+ SyntaxKind::BlockComment => Some(Tag::Comment),
+ SyntaxKind::Error => Some(Tag::Error),
+ SyntaxKind::Eof => None,
+ }
+}
+
+/// Highlight an identifier based on context.
+fn highlight_ident(node: &LinkedNode) -> Option<Tag> {
+ // Are we directly before an argument list?
+ let next_leaf = node.next_leaf();
+ if let Some(next) = &next_leaf {
+ if node.range().end == next.offset()
+ && ((next.kind() == SyntaxKind::LeftParen
+ && matches!(
+ next.parent_kind(),
+ Some(SyntaxKind::Args | SyntaxKind::Params)
+ ))
+ || (next.kind() == SyntaxKind::LeftBracket
+ && next.parent_kind() == Some(SyntaxKind::ContentBlock)))
+ {
+ return Some(Tag::Function);
+ }
+ }
+
+ // Are we in math?
+ if node.kind() == SyntaxKind::MathIdent {
+ return Some(Tag::Interpolated);
+ }
+
+ // Find the first non-field access ancestor.
+ let mut ancestor = node;
+ while ancestor.parent_kind() == Some(SyntaxKind::FieldAccess) {
+ ancestor = ancestor.parent()?;
+ }
+
+ // Are we directly before or behind a show rule colon?
+ if ancestor.parent_kind() == Some(SyntaxKind::ShowRule)
+ && (next_leaf.map(|leaf| leaf.kind()) == Some(SyntaxKind::Colon)
+ || node.prev_leaf().map(|leaf| leaf.kind()) == Some(SyntaxKind::Colon))
+ {
+ return Some(Tag::Function);
+ }
+
+ // Are we (or an ancestor field access) directly after a hashtag.
+ if ancestor.prev_leaf().map(|leaf| leaf.kind()) == Some(SyntaxKind::Hashtag) {
+ return Some(Tag::Interpolated);
+ }
+
+ // Are we behind a dot, that is behind another identifier?
+ let prev = node.prev_leaf()?;
+ if prev.kind() == SyntaxKind::Dot {
+ let prev_prev = prev.prev_leaf()?;
+ if is_ident(&prev_prev) {
+ return highlight_ident(&prev_prev);
+ }
+ }
+
+ None
+}
+
+/// Highlight a hashtag based on context.
+fn highlight_hashtag(node: &LinkedNode) -> Option<Tag> {
+ let next = node.next_sibling()?;
+ let expr = next.cast::<ast::Expr>()?;
+ if !expr.hashtag() {
+ return None;
+ }
+ highlight(&next.leftmost_leaf()?)
+}
+
+/// Whether the node is one of the two identifier nodes.
+fn is_ident(node: &LinkedNode) -> bool {
+ matches!(node.kind(), SyntaxKind::Ident | SyntaxKind::MathIdent)
+}
+
+/// Highlight a node to an HTML `code` element.
+///
+/// This uses these [CSS classes for categories](Tag::css_class).
+pub fn highlight_html(root: &SyntaxNode) -> String {
+ let mut buf = String::from("<code>");
+ let node = LinkedNode::new(root);
+ highlight_html_impl(&mut buf, &node);
+ buf.push_str("</code>");
+ buf
+}
+
+/// Highlight one source node, emitting HTML.
+fn highlight_html_impl(html: &mut String, node: &LinkedNode) {
+ let mut span = false;
+ if let Some(tag) = highlight(node) {
+ if tag != Tag::Error {
+ span = true;
+ html.push_str("<span class=\"");
+ html.push_str(tag.css_class());
+ html.push_str("\">");
+ }
+ }
+
+ let text = node.text();
+ if !text.is_empty() {
+ for c in text.chars() {
+ match c {
+ '<' => html.push_str("&lt;"),
+ '>' => html.push_str("&gt;"),
+ '&' => html.push_str("&amp;"),
+ '\'' => html.push_str("&#39;"),
+ '"' => html.push_str("&quot;"),
+ _ => html.push(c),
+ }
+ }
+ } else {
+ for child in node.children() {
+ highlight_html_impl(html, &child);
+ }
+ }
+
+ if span {
+ html.push_str("</span>");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::ops::Range;
+
+ use super::*;
+ use crate::syntax::Source;
+
+ #[test]
+ fn test_highlighting() {
+ use Tag::*;
+
+ #[track_caller]
+ fn test(text: &str, goal: &[(Range<usize>, Tag)]) {
+ let mut vec = vec![];
+ let source = Source::detached(text);
+ highlight_tree(&mut vec, &LinkedNode::new(source.root()));
+ assert_eq!(vec, goal);
+ }
+
+ fn highlight_tree(tags: &mut Vec<(Range<usize>, Tag)>, node: &LinkedNode) {
+ if let Some(tag) = highlight(node) {
+ tags.push((node.range(), tag));
+ }
+
+ for child in node.children() {
+ highlight_tree(tags, &child);
+ }
+ }
+
+ test("= *AB*", &[(0..6, Heading), (2..6, Strong)]);
+
+ test(
+ "#f(x + 1)",
+ &[
+ (0..1, Function),
+ (1..2, Function),
+ (2..3, Punctuation),
+ (5..6, Operator),
+ (7..8, Number),
+ (8..9, Punctuation),
+ ],
+ );
+
+ test(
+ "#let f(x) = x",
+ &[
+ (0..1, Keyword),
+ (1..4, Keyword),
+ (5..6, Function),
+ (6..7, Punctuation),
+ (8..9, Punctuation),
+ (10..11, Operator),
+ ],
+ );
+ }
+}
diff --git a/crates/typst/src/ide/jump.rs b/crates/typst/src/ide/jump.rs
new file mode 100644
index 00000000..14a82e26
--- /dev/null
+++ b/crates/typst/src/ide/jump.rs
@@ -0,0 +1,173 @@
+use std::num::NonZeroUsize;
+
+use ecow::EcoString;
+
+use crate::doc::{Destination, Frame, FrameItem, Meta, Position};
+use crate::file::FileId;
+use crate::geom::{Geometry, Point, Size};
+use crate::model::Introspector;
+use crate::syntax::{LinkedNode, Source, Span, SyntaxKind};
+use crate::World;
+
+/// Where to [jump](jump_from_click) to.
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum Jump {
+ /// Jump to a position in a source file.
+ Source(FileId, usize),
+ /// Jump to an external URL.
+ Url(EcoString),
+ /// Jump to a point on a page.
+ Position(Position),
+}
+
+impl Jump {
+ fn from_span(world: &dyn World, span: Span) -> Option<Self> {
+ let source = world.source(span.id()).ok()?;
+ let node = source.find(span)?;
+ Some(Self::Source(span.id(), node.offset()))
+ }
+}
+
+/// Determine where to jump to based on a click in a frame.
+pub fn jump_from_click(
+ world: &dyn World,
+ frames: &[Frame],
+ frame: &Frame,
+ click: Point,
+) -> Option<Jump> {
+ let mut introspector = None;
+
+ // Try to find a link first.
+ for (pos, item) in frame.items() {
+ if let FrameItem::Meta(Meta::Link(dest), size) = item {
+ if is_in_rect(*pos, *size, click) {
+ return Some(match dest {
+ Destination::Url(url) => Jump::Url(url.clone()),
+ Destination::Position(pos) => Jump::Position(*pos),
+ Destination::Location(loc) => Jump::Position(
+ introspector
+ .get_or_insert_with(|| Introspector::new(frames))
+ .position(*loc),
+ ),
+ });
+ }
+ }
+ }
+
+ // If there's no link, search for a jump target.
+ for (mut pos, item) in frame.items().rev() {
+ match item {
+ FrameItem::Group(group) => {
+ // TODO: Handle transformation.
+ if let Some(span) =
+ jump_from_click(world, frames, &group.frame, click - pos)
+ {
+ return Some(span);
+ }
+ }
+
+ FrameItem::Text(text) => {
+ for glyph in &text.glyphs {
+ let (span, span_offset) = glyph.span;
+ if span.is_detached() {
+ continue;
+ }
+
+ let width = glyph.x_advance.at(text.size);
+ if is_in_rect(
+ Point::new(pos.x, pos.y - text.size),
+ Size::new(width, text.size),
+ click,
+ ) {
+ let source = world.source(span.id()).ok()?;
+ let node = source.find(span)?;
+ let pos = if node.kind() == SyntaxKind::Text {
+ let range = node.range();
+ let mut offset = range.start + usize::from(span_offset);
+ if (click.x - pos.x) > width / 2.0 {
+ offset += glyph.range().len();
+ }
+ offset.min(range.end)
+ } else {
+ node.offset()
+ };
+ return Some(Jump::Source(source.id(), pos));
+ }
+
+ pos.x += width;
+ }
+ }
+
+ FrameItem::Shape(shape, span) => {
+ let Geometry::Rect(size) = shape.geometry else { continue };
+ if is_in_rect(pos, size, click) {
+ return Jump::from_span(world, *span);
+ }
+ }
+
+ FrameItem::Image(_, size, span) if is_in_rect(pos, *size, click) => {
+ return Jump::from_span(world, *span);
+ }
+
+ _ => {}
+ }
+ }
+
+ None
+}
+
+/// Find the output location in the document for a cursor position.
+pub fn jump_from_cursor(
+ frames: &[Frame],
+ source: &Source,
+ cursor: usize,
+) -> Option<Position> {
+ let node = LinkedNode::new(source.root()).leaf_at(cursor)?;
+ if node.kind() != SyntaxKind::Text {
+ return None;
+ }
+
+ let span = node.span();
+ for (i, frame) in frames.iter().enumerate() {
+ if let Some(pos) = find_in_frame(frame, span) {
+ return Some(Position {
+ page: NonZeroUsize::new(i + 1).unwrap(),
+ point: pos,
+ });
+ }
+ }
+
+ None
+}
+
+/// Find the position of a span in a frame.
+fn find_in_frame(frame: &Frame, span: Span) -> Option<Point> {
+ for (mut pos, item) in frame.items() {
+ if let FrameItem::Group(group) = item {
+ // TODO: Handle transformation.
+ if let Some(point) = find_in_frame(&group.frame, span) {
+ return Some(point + pos);
+ }
+ }
+
+ if let FrameItem::Text(text) = item {
+ for glyph in &text.glyphs {
+ if glyph.span.0 == span {
+ return Some(pos);
+ }
+ pos.x += glyph.x_advance.at(text.size);
+ }
+ }
+ }
+
+ None
+}
+
+/// Whether a rectangle with the given size at the given position contains the
+/// click position.
+fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
+ pos.x <= click.x
+ && pos.x + size.x >= click.x
+ && pos.y <= click.y
+ && pos.y + size.y >= click.y
+}
diff --git a/crates/typst/src/ide/mod.rs b/crates/typst/src/ide/mod.rs
new file mode 100644
index 00000000..4b08b66b
--- /dev/null
+++ b/crates/typst/src/ide/mod.rs
@@ -0,0 +1,97 @@
+//! Capabilities for IDE support.
+
+mod analyze;
+mod complete;
+mod highlight;
+mod jump;
+mod tooltip;
+
+pub use self::analyze::analyze_labels;
+pub use self::complete::{autocomplete, Completion, CompletionKind};
+pub use self::highlight::{highlight, highlight_html, Tag};
+pub use self::jump::{jump_from_click, jump_from_cursor, Jump};
+pub use self::tooltip::{tooltip, Tooltip};
+
+use std::fmt::Write;
+
+use ecow::{eco_format, EcoString};
+
+use self::analyze::*;
+use crate::font::{FontInfo, FontStyle};
+
+/// Extract the first sentence of plain text of a piece of documentation.
+///
+/// Removes Markdown formatting.
+fn plain_docs_sentence(docs: &str) -> EcoString {
+ let mut s = unscanny::Scanner::new(docs);
+ let mut output = EcoString::new();
+ let mut link = false;
+ while let Some(c) = s.eat() {
+ match c {
+ '`' => {
+ let mut raw = s.eat_until('`');
+ if (raw.starts_with('{') && raw.ends_with('}'))
+ || (raw.starts_with('[') && raw.ends_with(']'))
+ {
+ raw = &raw[1..raw.len() - 1];
+ }
+
+ s.eat();
+ output.push('`');
+ output.push_str(raw);
+ output.push('`');
+ }
+ '[' => link = true,
+ ']' if link => {
+ if s.eat_if('(') {
+ s.eat_until(')');
+ s.eat();
+ } else if s.eat_if('[') {
+ s.eat_until(']');
+ s.eat();
+ }
+ link = false
+ }
+ '*' | '_' => {}
+ '.' => {
+ output.push('.');
+ break;
+ }
+ _ => output.push(c),
+ }
+ }
+
+ output
+}
+
+/// Create a short description of a font family.
+fn summarize_font_family<'a>(variants: impl Iterator<Item = &'a FontInfo>) -> EcoString {
+ let mut infos: Vec<_> = variants.collect();
+ infos.sort_by_key(|info| info.variant);
+
+ let mut has_italic = false;
+ let mut min_weight = u16::MAX;
+ let mut max_weight = 0;
+ for info in &infos {
+ let weight = info.variant.weight.to_number();
+ has_italic |= info.variant.style == FontStyle::Italic;
+ min_weight = min_weight.min(weight);
+ max_weight = min_weight.max(weight);
+ }
+
+ let count = infos.len();
+ let s = if count == 1 { "" } else { "s" };
+ let mut detail = eco_format!("{count} variant{s}.");
+
+ if min_weight == max_weight {
+ write!(detail, " Weight {min_weight}.").unwrap();
+ } else {
+ write!(detail, " Weights {min_weight}–{max_weight}.").unwrap();
+ }
+
+ if has_italic {
+ detail.push_str(" Has italics.");
+ }
+
+ detail
+}
diff --git a/crates/typst/src/ide/tooltip.rs b/crates/typst/src/ide/tooltip.rs
new file mode 100644
index 00000000..35125e92
--- /dev/null
+++ b/crates/typst/src/ide/tooltip.rs
@@ -0,0 +1,222 @@
+use std::fmt::Write;
+
+use ecow::{eco_format, EcoString};
+
+use if_chain::if_chain;
+
+use super::analyze::analyze_labels;
+use super::{analyze_expr, plain_docs_sentence, summarize_font_family};
+use crate::doc::Frame;
+use crate::eval::{CastInfo, Tracer, Value};
+use crate::geom::{round_2, Length, Numeric};
+use crate::syntax::{ast, LinkedNode, Source, SyntaxKind};
+use crate::util::pretty_comma_list;
+use crate::World;
+
+/// Describe the item under the cursor.
+pub fn tooltip(
+ world: &(dyn World + 'static),
+ frames: &[Frame],
+ source: &Source,
+ cursor: usize,
+) -> Option<Tooltip> {
+ let leaf = LinkedNode::new(source.root()).leaf_at(cursor)?;
+ if leaf.kind().is_trivia() {
+ return None;
+ }
+
+ named_param_tooltip(world, &leaf)
+ .or_else(|| font_tooltip(world, &leaf))
+ .or_else(|| ref_tooltip(world, frames, &leaf))
+ .or_else(|| expr_tooltip(world, &leaf))
+}
+
+/// A hover tooltip.
+#[derive(Debug, Clone)]
+pub enum Tooltip {
+ /// A string of text.
+ Text(EcoString),
+ /// A string of Typst code.
+ Code(EcoString),
+}
+
+/// Tooltip for a hovered expression.
+fn expr_tooltip(world: &(dyn World + 'static), leaf: &LinkedNode) -> Option<Tooltip> {
+ let mut ancestor = leaf;
+ while !ancestor.is::<ast::Expr>() {
+ ancestor = ancestor.parent()?;
+ }
+
+ let expr = ancestor.cast::<ast::Expr>()?;
+ if !expr.hashtag() && !matches!(expr, ast::Expr::MathIdent(_)) {
+ return None;
+ }
+
+ let values = analyze_expr(world, ancestor);
+
+ if let [value] = values.as_slice() {
+ if let Some(docs) = value.docs() {
+ return Some(Tooltip::Text(plain_docs_sentence(docs)));
+ }
+
+ if let &Value::Length(length) = value {
+ if let Some(tooltip) = length_tooltip(length) {
+ return Some(tooltip);
+ }
+ }
+ }
+
+ if expr.is_literal() {
+ return None;
+ }
+
+ let mut last = None;
+ let mut pieces: Vec<EcoString> = vec![];
+ let mut iter = values.iter();
+ for value in (&mut iter).take(Tracer::MAX - 1) {
+ if let Some((prev, count)) = &mut last {
+ if *prev == value {
+ *count += 1;
+ continue;
+ } else if *count > 1 {
+ write!(pieces.last_mut().unwrap(), " (x{count})").unwrap();
+ }
+ }
+ pieces.push(value.repr().into());
+ last = Some((value, 1));
+ }
+
+ if let Some((_, count)) = last {
+ if count > 1 {
+ write!(pieces.last_mut().unwrap(), " (x{count})").unwrap();
+ }
+ }
+
+ if iter.next().is_some() {
+ pieces.push("...".into());
+ }
+
+ let tooltip = pretty_comma_list(&pieces, false);
+ (!tooltip.is_empty()).then(|| Tooltip::Code(tooltip.into()))
+}
+
+/// Tooltip text for a hovered length.
+fn length_tooltip(length: Length) -> Option<Tooltip> {
+ length.em.is_zero().then(|| {
+ Tooltip::Code(eco_format!(
+ "{}pt = {}mm = {}cm = {}in",
+ round_2(length.abs.to_pt()),
+ round_2(length.abs.to_mm()),
+ round_2(length.abs.to_cm()),
+ round_2(length.abs.to_inches())
+ ))
+ })
+}
+
+/// Tooltip for a hovered reference.
+fn ref_tooltip(
+ world: &(dyn World + 'static),
+ frames: &[Frame],
+ leaf: &LinkedNode,
+) -> Option<Tooltip> {
+ if leaf.kind() != SyntaxKind::RefMarker {
+ return None;
+ }
+
+ let target = leaf.text().trim_start_matches('@');
+ for (label, detail) in analyze_labels(world, frames).0 {
+ if label.0 == target {
+ return Some(Tooltip::Text(detail?));
+ }
+ }
+
+ None
+}
+
+/// Tooltips for components of a named parameter.
+fn named_param_tooltip(
+ world: &(dyn World + 'static),
+ leaf: &LinkedNode,
+) -> Option<Tooltip> {
+ let (info, named) = if_chain! {
+ // Ensure that we are in a named pair in the arguments to a function
+ // call or set rule.
+ if let Some(parent) = leaf.parent();
+ if let Some(named) = parent.cast::<ast::Named>();
+ if let Some(grand) = parent.parent();
+ if matches!(grand.kind(), SyntaxKind::Args);
+ if let Some(grand_grand) = grand.parent();
+ if let Some(expr) = grand_grand.cast::<ast::Expr>();
+ if let Some(ast::Expr::Ident(callee)) = match expr {
+ ast::Expr::FuncCall(call) => Some(call.callee()),
+ ast::Expr::Set(set) => Some(set.target()),
+ _ => None,
+ };
+
+ // Find metadata about the function.
+ if let Some(Value::Func(func)) = world.library().global.scope().get(&callee);
+ if let Some(info) = func.info();
+ then { (info, named) }
+ else { return None; }
+ };
+
+ // Hovering over the parameter name.
+ if_chain! {
+ if leaf.index() == 0;
+ if let Some(ident) = leaf.cast::<ast::Ident>();
+ if let Some(param) = info.param(&ident);
+ then {
+ return Some(Tooltip::Text(plain_docs_sentence(param.docs)));
+ }
+ }
+
+ // Hovering over a string parameter value.
+ if_chain! {
+ if let Some(string) = leaf.cast::<ast::Str>();
+ if let Some(param) = info.param(&named.name());
+ if let Some(docs) = find_string_doc(&param.cast, &string.get());
+ then {
+ return Some(Tooltip::Text(docs.into()));
+ }
+ }
+
+ None
+}
+
+/// Find documentation for a castable string.
+fn find_string_doc(info: &CastInfo, string: &str) -> Option<&'static str> {
+ match info {
+ CastInfo::Value(Value::Str(s), docs) if s.as_str() == string => Some(docs),
+ CastInfo::Union(options) => {
+ options.iter().find_map(|option| find_string_doc(option, string))
+ }
+ _ => None,
+ }
+}
+
+/// Tooltip for font.
+fn font_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
+ if_chain! {
+ // Ensure that we are on top of a string.
+ if let Some(string) = leaf.cast::<ast::Str>();
+ let lower = string.get().to_lowercase();
+
+ // Ensure that we are in the arguments to the text function.
+ if let Some(parent) = leaf.parent();
+ if let Some(named) = parent.cast::<ast::Named>();
+ if named.name().as_str() == "font";
+
+ // Find the font family.
+ if let Some((_, iter)) = world
+ .book()
+ .families()
+ .find(|&(family, _)| family.to_lowercase().as_str() == lower.as_str());
+
+ then {
+ let detail = summarize_font_family(iter);
+ return Some(Tooltip::Text(detail));
+ }
+ };
+
+ None
+}
diff --git a/crates/typst/src/image.rs b/crates/typst/src/image.rs
new file mode 100644
index 00000000..3a245c14
--- /dev/null
+++ b/crates/typst/src/image.rs
@@ -0,0 +1,449 @@
+//! Image handling.
+
+use std::cell::RefCell;
+use std::collections::BTreeMap;
+use std::fmt::{self, Debug, Formatter};
+use std::io;
+use std::sync::Arc;
+
+use comemo::{Prehashed, Track, Tracked};
+use ecow::{EcoString, EcoVec};
+use image::codecs::gif::GifDecoder;
+use image::codecs::jpeg::JpegDecoder;
+use image::codecs::png::PngDecoder;
+use image::io::Limits;
+use image::{ImageDecoder, ImageResult};
+use usvg::{TreeParsing, TreeTextToPath};
+
+use crate::diag::{format_xml_like_error, StrResult};
+use crate::font::Font;
+use crate::geom::Axes;
+use crate::util::Bytes;
+use crate::World;
+
+/// A raster or vector image.
+///
+/// Values of this type are cheap to clone and hash.
+#[derive(Clone, Hash, Eq, PartialEq)]
+pub struct Image(Arc<Prehashed<Repr>>);
+
+/// The internal representation.
+#[derive(Hash)]
+struct Repr {
+ /// The raw, undecoded image data.
+ data: Bytes,
+ /// The format of the encoded `buffer`.
+ format: ImageFormat,
+ /// The size of the image.
+ size: Axes<u32>,
+ /// A loader for fonts referenced by an image (currently, only applies to
+ /// SVG).
+ loader: PreparedLoader,
+ /// A text describing the image.
+ alt: Option<EcoString>,
+}
+
+impl Image {
+ /// Create an image from a buffer and a format.
+ #[comemo::memoize]
+ pub fn new(
+ data: Bytes,
+ format: ImageFormat,
+ alt: Option<EcoString>,
+ ) -> StrResult<Self> {
+ let loader = PreparedLoader::default();
+ let decoded = match format {
+ ImageFormat::Raster(format) => decode_raster(&data, format)?,
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ decode_svg(&data, (&loader as &dyn SvgFontLoader).track())?
+ }
+ };
+
+ Ok(Self(Arc::new(Prehashed::new(Repr {
+ data,
+ format,
+ size: decoded.size(),
+ loader,
+ alt,
+ }))))
+ }
+
+ /// Create a font-dependant image from a buffer and a format.
+ #[comemo::memoize]
+ pub fn with_fonts(
+ data: Bytes,
+ format: ImageFormat,
+ world: Tracked<dyn World + '_>,
+ fallback_family: Option<&str>,
+ alt: Option<EcoString>,
+ ) -> StrResult<Self> {
+ let loader = WorldLoader::new(world, fallback_family);
+ let decoded = match format {
+ ImageFormat::Raster(format) => decode_raster(&data, format)?,
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ decode_svg(&data, (&loader as &dyn SvgFontLoader).track())?
+ }
+ };
+
+ Ok(Self(Arc::new(Prehashed::new(Repr {
+ data,
+ format,
+ size: decoded.size(),
+ loader: loader.into_prepared(),
+ alt,
+ }))))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The format of the image.
+ pub fn format(&self) -> ImageFormat {
+ self.0.format
+ }
+
+ /// The size of the image in pixels.
+ pub fn size(&self) -> Axes<u32> {
+ self.0.size
+ }
+
+ /// The width of the image in pixels.
+ pub fn width(&self) -> u32 {
+ self.size().x
+ }
+
+ /// The height of the image in pixels.
+ pub fn height(&self) -> u32 {
+ self.size().y
+ }
+
+ /// A text describing the image.
+ pub fn alt(&self) -> Option<&str> {
+ self.0.alt.as_deref()
+ }
+
+ /// The decoded version of the image.
+ pub fn decoded(&self) -> Arc<DecodedImage> {
+ match self.format() {
+ ImageFormat::Raster(format) => decode_raster(self.data(), format),
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ decode_svg(self.data(), (&self.0.loader as &dyn SvgFontLoader).track())
+ }
+ }
+ .unwrap()
+ }
+}
+
+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 raster graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+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,
+}
+
+/// A vector graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum VectorFormat {
+ /// The vector graphics format of the web.
+ Svg,
+}
+
+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 From<ttf_parser::RasterImageFormat> for RasterFormat {
+ fn from(format: ttf_parser::RasterImageFormat) -> Self {
+ match format {
+ ttf_parser::RasterImageFormat::PNG => RasterFormat::Png,
+ }
+ }
+}
+
+impl From<ttf_parser::RasterImageFormat> for ImageFormat {
+ fn from(format: ttf_parser::RasterImageFormat) -> Self {
+ Self::Raster(format.into())
+ }
+}
+
+/// A decoded image.
+pub enum DecodedImage {
+ /// A decoded pixel raster with its ICC profile.
+ Raster(image::DynamicImage, Option<IccProfile>, RasterFormat),
+ /// An decoded SVG tree.
+ Svg(usvg::Tree),
+}
+
+impl DecodedImage {
+ /// The size of the image in pixels.
+ pub fn size(&self) -> Axes<u32> {
+ Axes::new(self.width(), self.height())
+ }
+
+ /// The width of the image in pixels.
+ pub fn width(&self) -> u32 {
+ match self {
+ Self::Raster(dynamic, _, _) => dynamic.width(),
+ Self::Svg(tree) => tree.size.width().ceil() as u32,
+ }
+ }
+
+ /// The height of the image in pixels.
+ pub fn height(&self) -> u32 {
+ match self {
+ Self::Raster(dynamic, _, _) => dynamic.height(),
+ Self::Svg(tree) => tree.size.height().ceil() as u32,
+ }
+ }
+}
+
+/// Raw data for of an ICC profile.
+pub struct IccProfile(pub Vec<u8>);
+
+/// Decode a raster image.
+#[comemo::memoize]
+fn decode_raster(data: &Bytes, format: RasterFormat) -> StrResult<Arc<DecodedImage>> {
+ fn decode_with<'a, T: ImageDecoder<'a>>(
+ decoder: ImageResult<T>,
+ ) -> ImageResult<(image::DynamicImage, Option<IccProfile>)> {
+ let mut decoder = decoder?;
+ let icc = decoder.icc_profile().filter(|data| !data.is_empty()).map(IccProfile);
+ decoder.set_limits(Limits::default())?;
+ let dynamic = image::DynamicImage::from_decoder(decoder)?;
+ Ok((dynamic, icc))
+ }
+
+ let cursor = io::Cursor::new(data);
+ let (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)?;
+
+ Ok(Arc::new(DecodedImage::Raster(dynamic, icc, format)))
+}
+
+/// Decode an SVG image.
+#[comemo::memoize]
+fn decode_svg(
+ data: &Bytes,
+ loader: Tracked<dyn SvgFontLoader + '_>,
+) -> StrResult<Arc<DecodedImage>> {
+ // Disable usvg's default to "Times New Roman". Instead, we default to
+ // the empty family and later, when we traverse the SVG, we check for
+ // empty and non-existing family names and replace them with the true
+ // fallback family. This way, we can memoize SVG decoding with and without
+ // fonts if the SVG does not contain text.
+ let opts = usvg::Options { font_family: String::new(), ..Default::default() };
+ let mut tree = usvg::Tree::from_data(data, &opts).map_err(format_usvg_error)?;
+ if tree.has_text_nodes() {
+ let fontdb = load_svg_fonts(&tree, loader);
+ tree.convert_text(&fontdb);
+ }
+ Ok(Arc::new(DecodedImage::Svg(tree)))
+}
+
+/// Discover and load the fonts referenced by an SVG.
+fn load_svg_fonts(
+ tree: &usvg::Tree,
+ loader: Tracked<dyn SvgFontLoader + '_>,
+) -> fontdb::Database {
+ let mut referenced = BTreeMap::<EcoString, bool>::new();
+ let mut fontdb = fontdb::Database::new();
+ let mut load = |family_cased: &str| {
+ let family = EcoString::from(family_cased.trim()).to_lowercase();
+ if let Some(&success) = referenced.get(&family) {
+ return success;
+ }
+
+ // We load all variants for the family, since we don't know which will
+ // be used.
+ let mut success = false;
+ for font in loader.load(&family) {
+ let source = Arc::new(font.data().clone());
+ fontdb.load_font_source(fontdb::Source::Binary(source));
+ success = true;
+ }
+
+ referenced.insert(family, success);
+ success
+ };
+
+ // Load fallback family.
+ let fallback_cased = loader.fallback();
+ if let Some(family_cased) = fallback_cased {
+ load(family_cased);
+ }
+
+ // Find out which font families are referenced by the SVG.
+ traverse_svg(&tree.root, &mut |node| {
+ let usvg::NodeKind::Text(text) = &mut *node.borrow_mut() else { return };
+ for chunk in &mut text.chunks {
+ for span in &mut chunk.spans {
+ for family_cased in &mut span.font.families {
+ if family_cased.is_empty() || !load(family_cased) {
+ let Some(fallback) = fallback_cased else { continue };
+ *family_cased = fallback.into();
+ }
+ }
+ }
+ }
+ });
+
+ fontdb
+}
+
+/// Search for all font families referenced by an SVG.
+fn traverse_svg<F>(node: &usvg::Node, f: &mut F)
+where
+ F: FnMut(&usvg::Node),
+{
+ f(node);
+ for child in node.children() {
+ traverse_svg(&child, f);
+ }
+}
+
+/// Interface for loading fonts for an SVG.
+///
+/// Can be backed by a `WorldLoader` or a `PreparedLoader`. The first is used
+/// when the image is initially decoded. It records all required fonts and
+/// produces a `PreparedLoader` from it. This loader can then be used to
+/// redecode the image with a cache hit from the initial decoding. This way, we
+/// can cheaply access the decoded version of an image.
+///
+/// The alternative would be to store the decoded image directly in the image,
+/// but that would make `Image` not `Send` because `usvg::Tree` is not `Send`.
+/// The current design also has the added benefit that large decoded images can
+/// be evicted if they are not used anymore.
+#[comemo::track]
+trait SvgFontLoader {
+ /// Load all fonts for the given lowercased font family.
+ fn load(&self, lower_family: &str) -> EcoVec<Font>;
+
+ /// The case-sensitive name of the fallback family.
+ fn fallback(&self) -> Option<&str>;
+}
+
+/// Loads fonts for an SVG from a world
+struct WorldLoader<'a> {
+ world: Tracked<'a, dyn World + 'a>,
+ seen: RefCell<BTreeMap<EcoString, EcoVec<Font>>>,
+ fallback_family_cased: Option<String>,
+}
+
+impl<'a> WorldLoader<'a> {
+ fn new(world: Tracked<'a, dyn World + 'a>, fallback_family: Option<&str>) -> Self {
+ // Recover the non-lowercased version of the family because
+ // usvg is case sensitive.
+ let book = world.book();
+ let fallback_family_cased = fallback_family
+ .and_then(|lowercase| book.select_family(lowercase).next())
+ .and_then(|index| book.info(index))
+ .map(|info| info.family.clone());
+
+ Self {
+ world,
+ fallback_family_cased,
+ seen: Default::default(),
+ }
+ }
+
+ fn into_prepared(self) -> PreparedLoader {
+ PreparedLoader {
+ families: self.seen.into_inner(),
+ fallback_family_cased: self.fallback_family_cased,
+ }
+ }
+}
+
+impl SvgFontLoader for WorldLoader<'_> {
+ fn load(&self, family: &str) -> EcoVec<Font> {
+ self.seen
+ .borrow_mut()
+ .entry(family.into())
+ .or_insert_with(|| {
+ self.world
+ .book()
+ .select_family(family)
+ .filter_map(|id| self.world.font(id))
+ .collect()
+ })
+ .clone()
+ }
+
+ fn fallback(&self) -> Option<&str> {
+ self.fallback_family_cased.as_deref()
+ }
+}
+
+/// Loads fonts for an SVG from a prepared list.
+#[derive(Default, Hash)]
+struct PreparedLoader {
+ families: BTreeMap<EcoString, EcoVec<Font>>,
+ fallback_family_cased: Option<String>,
+}
+
+impl SvgFontLoader for PreparedLoader {
+ fn load(&self, family: &str) -> EcoVec<Font> {
+ self.families.get(family).cloned().unwrap_or_default()
+ }
+
+ fn fallback(&self) -> Option<&str> {
+ self.fallback_family_cased.as_deref()
+ }
+}
+
+/// 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(),
+ _ => "failed to decode image".into(),
+ }
+}
+
+/// 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),
+ }
+}
diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs
new file mode 100644
index 00000000..8b3d1d3d
--- /dev/null
+++ b/crates/typst/src/lib.rs
@@ -0,0 +1,147 @@
+//! The compiler for the _Typst_ markup language.
+//!
+//! # Steps
+//! - **Parsing:**
+//! The compiler first transforms a plain string into an iterator of [tokens].
+//! This token stream is [parsed] into a [syntax tree]. The tree itself is
+//! untyped, but the [AST] module provides a typed layer over it.
+//! - **Evaluation:**
+//! The next step is to [evaluate] the markup. This produces a [module],
+//! consisting of a scope of values that were exported by the code and
+//! [content], a hierarchical, styled representation of what was written in
+//! the source file. The elements of the content tree are well structured and
+//! order-independent and thus much better suited for further processing than
+//! the raw markup.
+//! - **Typesetting:**
+//! Next, the content is [typeset] into a [document] containing one [frame]
+//! per page with items at fixed positions.
+//! - **Exporting:**
+//! These frames can finally be exported into an output format (currently
+//! supported are [PDF] and [raster images]).
+//!
+//! [tokens]: syntax::SyntaxKind
+//! [parsed]: syntax::parse
+//! [syntax tree]: syntax::SyntaxNode
+//! [AST]: syntax::ast
+//! [evaluate]: eval::eval
+//! [module]: eval::Module
+//! [content]: model::Content
+//! [typeset]: model::typeset
+//! [document]: doc::Document
+//! [frame]: doc::Frame
+//! [PDF]: export::pdf
+//! [raster images]: export::render
+
+#![recursion_limit = "1000"]
+#![allow(clippy::comparison_chain)]
+
+extern crate self as typst;
+
+#[macro_use]
+pub mod util;
+#[macro_use]
+pub mod diag;
+#[macro_use]
+pub mod eval;
+pub mod doc;
+pub mod export;
+pub mod file;
+pub mod font;
+pub mod geom;
+pub mod ide;
+pub mod image;
+pub mod model;
+pub mod syntax;
+
+use comemo::{Prehashed, Track, TrackedMut};
+use ecow::EcoString;
+
+use crate::diag::{FileResult, SourceResult};
+use crate::doc::Document;
+use crate::eval::{Datetime, Library, Route, Tracer};
+use crate::file::{FileId, PackageSpec};
+use crate::font::{Font, FontBook};
+use crate::syntax::Source;
+use crate::util::Bytes;
+
+/// Compile a source file into a fully layouted document.
+#[tracing::instrument(skip(world))]
+pub fn compile(world: &dyn World) -> SourceResult<Document> {
+ let route = Route::default();
+ let mut tracer = Tracer::default();
+
+ // Call `track` just once to keep comemo's ID stable.
+ let world = world.track();
+ let mut tracer = tracer.track_mut();
+
+ // Evaluate the source file into a module.
+ tracing::info!("Starting evaluation");
+ let module = eval::eval(
+ world,
+ route.track(),
+ TrackedMut::reborrow_mut(&mut tracer),
+ &world.main(),
+ )?;
+
+ // Typeset the module's contents.
+ model::typeset(world, tracer, &module.content())
+}
+
+/// 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. Advanced clients like
+/// language servers can also retain the source files and [edited](Source::edit)
+/// them in-place to benefit from better incremental performance.
+#[comemo::track]
+pub trait World {
+ /// The standard library.
+ fn library(&self) -> &Prehashed<Library>;
+
+ /// Metadata about all known fonts.
+ fn book(&self) -> &Prehashed<FontBook>;
+
+ /// Access the main source file.
+ fn main(&self) -> Source;
+
+ /// Try to access the specified source file.
+ ///
+ /// The returned `Source` file's [id](Source::id) does not have to match the
+ /// given `id`. Due to symlinks, two different file id's can point to the
+ /// same on-disk file. Implementors can deduplicate and return the same
+ /// `Source` if they want to, but do not have to.
+ 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>)] {
+ &[]
+ }
+}
diff --git a/crates/typst/src/model/content.rs b/crates/typst/src/model/content.rs
new file mode 100644
index 00000000..015f8b76
--- /dev/null
+++ b/crates/typst/src/model/content.rs
@@ -0,0 +1,614 @@
+use std::any::TypeId;
+use std::fmt::{self, Debug, Formatter, Write};
+use std::iter::Sum;
+use std::ops::{Add, AddAssign};
+
+use comemo::Prehashed;
+use ecow::{eco_format, EcoString, EcoVec};
+
+use super::{
+ element, Behave, Behaviour, ElemFunc, Element, Fold, Guard, Label, Locatable,
+ Location, Recipe, Selector, Style, Styles, Synthesize,
+};
+use crate::diag::{SourceResult, StrResult};
+use crate::doc::Meta;
+use crate::eval::{Dict, FromValue, IntoValue, Str, Value, Vm};
+use crate::syntax::Span;
+use crate::util::pretty_array_like;
+
+/// Composable representation of styled content.
+#[derive(Clone, Hash)]
+#[allow(clippy::derived_hash_with_manual_eq)]
+pub struct Content {
+ func: ElemFunc,
+ attrs: EcoVec<Attr>,
+}
+
+/// Attributes that can be attached to content.
+#[derive(Debug, Clone, PartialEq, Hash)]
+enum Attr {
+ Span(Span),
+ Field(EcoString),
+ Value(Prehashed<Value>),
+ Child(Prehashed<Content>),
+ Styles(Styles),
+ Prepared,
+ Guard(Guard),
+ Location(Location),
+}
+
+impl Content {
+ /// Create an empty element.
+ pub fn new(func: ElemFunc) -> Self {
+ Self { func, attrs: EcoVec::new() }
+ }
+
+ /// Create empty content.
+ pub fn empty() -> Self {
+ Self::new(SequenceElem::func())
+ }
+
+ /// Create a new sequence element from multiples elements.
+ pub fn sequence(iter: impl IntoIterator<Item = Self>) -> Self {
+ let mut iter = iter.into_iter();
+ let Some(first) = iter.next() else { return Self::empty() };
+ let Some(second) = iter.next() else { return first };
+ let mut content = Content::empty();
+ content.attrs.push(Attr::Child(Prehashed::new(first)));
+ content.attrs.push(Attr::Child(Prehashed::new(second)));
+ content
+ .attrs
+ .extend(iter.map(|child| Attr::Child(Prehashed::new(child))));
+ content
+ }
+
+ /// The element function of the contained content.
+ pub fn func(&self) -> ElemFunc {
+ self.func
+ }
+
+ /// Whether the content is an empty sequence.
+ pub fn is_empty(&self) -> bool {
+ self.is::<SequenceElem>() && self.attrs.is_empty()
+ }
+
+ /// Whether the contained element is of type `T`.
+ pub fn is<T: Element>(&self) -> bool {
+ self.func == T::func()
+ }
+
+ /// Cast to `T` if the contained element is of type `T`.
+ pub fn to<T: Element>(&self) -> Option<&T> {
+ T::unpack(self)
+ }
+
+ /// Access the children if this is a sequence.
+ pub fn to_sequence(&self) -> Option<impl Iterator<Item = &Self>> {
+ if !self.is::<SequenceElem>() {
+ return None;
+ }
+ Some(self.attrs.iter().filter_map(Attr::child))
+ }
+
+ /// Access the child and styles.
+ pub fn to_styled(&self) -> Option<(&Content, &Styles)> {
+ if !self.is::<StyledElem>() {
+ return None;
+ }
+ let child = self.attrs.iter().find_map(Attr::child)?;
+ let styles = self.attrs.iter().find_map(Attr::styles)?;
+ Some((child, styles))
+ }
+
+ /// Whether the contained element has the given capability.
+ pub fn can<C>(&self) -> bool
+ where
+ C: ?Sized + 'static,
+ {
+ (self.func.0.vtable)(TypeId::of::<C>()).is_some()
+ }
+
+ /// Whether the contained element has the given capability.
+ /// Where the capability is given by a `TypeId`.
+ pub fn can_type_id(&self, type_id: TypeId) -> bool {
+ (self.func.0.vtable)(type_id).is_some()
+ }
+
+ /// Cast to a trait object if the contained element has the given
+ /// capability.
+ pub fn with<C>(&self) -> Option<&C>
+ where
+ C: ?Sized + 'static,
+ {
+ let vtable = (self.func.0.vtable)(TypeId::of::<C>())?;
+ let data = self as *const Self as *const ();
+ Some(unsafe { &*crate::util::fat::from_raw_parts(data, vtable) })
+ }
+
+ /// 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,
+ {
+ let vtable = (self.func.0.vtable)(TypeId::of::<C>())?;
+ let data = self as *mut Self as *mut ();
+ Some(unsafe { &mut *crate::util::fat::from_raw_parts_mut(data, vtable) })
+ }
+
+ /// The content's span.
+ pub fn span(&self) -> Span {
+ self.attrs.iter().find_map(Attr::span).unwrap_or(Span::detached())
+ }
+
+ /// Attach a span to the content if it doesn't already have one.
+ pub fn spanned(mut self, span: Span) -> Self {
+ if self.span().is_detached() {
+ self.attrs.push(Attr::Span(span));
+ }
+ self
+ }
+
+ /// Attach a field to the content.
+ pub fn with_field(
+ mut self,
+ name: impl Into<EcoString>,
+ value: impl IntoValue,
+ ) -> Self {
+ self.push_field(name, value);
+ self
+ }
+
+ /// Attach a field to the content.
+ pub fn push_field(&mut self, name: impl Into<EcoString>, value: impl IntoValue) {
+ let name = name.into();
+ if let Some(i) = self.attrs.iter().position(|attr| match attr {
+ Attr::Field(field) => *field == name,
+ _ => false,
+ }) {
+ self.attrs.make_mut()[i + 1] =
+ Attr::Value(Prehashed::new(value.into_value()));
+ } else {
+ self.attrs.push(Attr::Field(name));
+ self.attrs.push(Attr::Value(Prehashed::new(value.into_value())));
+ }
+ }
+
+ /// Access a field on the content.
+ pub fn field(&self, name: &str) -> Option<Value> {
+ if let (Some(iter), "children") = (self.to_sequence(), name) {
+ Some(Value::Array(iter.cloned().map(Value::Content).collect()))
+ } else if let (Some((child, _)), "child") = (self.to_styled(), name) {
+ Some(Value::Content(child.clone()))
+ } else {
+ self.field_ref(name).cloned()
+ }
+ }
+
+ /// Access a field on the content by reference.
+ ///
+ /// Does not include synthesized fields for sequence and styled elements.
+ pub fn field_ref(&self, name: &str) -> Option<&Value> {
+ self.fields_ref()
+ .find(|&(field, _)| field == name)
+ .map(|(_, value)| value)
+ }
+
+ /// Iter over all fields on the content.
+ ///
+ /// Does not include synthesized fields for sequence and styled elements.
+ pub fn fields(&self) -> impl Iterator<Item = (&EcoString, Value)> {
+ static CHILD: EcoString = EcoString::inline("child");
+ static CHILDREN: EcoString = EcoString::inline("children");
+
+ let option = if let Some(iter) = self.to_sequence() {
+ Some((&CHILDREN, Value::Array(iter.cloned().map(Value::Content).collect())))
+ } else if let Some((child, _)) = self.to_styled() {
+ Some((&CHILD, Value::Content(child.clone())))
+ } else {
+ None
+ };
+
+ self.fields_ref()
+ .map(|(name, value)| (name, value.clone()))
+ .chain(option)
+ }
+
+ /// Iter over all fields on the content.
+ ///
+ /// Does not include synthesized fields for sequence and styled elements.
+ pub fn fields_ref(&self) -> impl Iterator<Item = (&EcoString, &Value)> {
+ let mut iter = self.attrs.iter();
+ std::iter::from_fn(move || {
+ let field = iter.find_map(Attr::field)?;
+ let value = iter.next()?.value()?;
+ Some((field, value))
+ })
+ }
+
+ /// Try to access a field on the content as a specified type.
+ pub fn cast_field<T: FromValue>(&self, name: &str) -> Option<T> {
+ match self.field(name) {
+ Some(value) => value.cast().ok(),
+ None => None,
+ }
+ }
+
+ /// Expect a field on the content to exist as a specified type.
+ #[track_caller]
+ pub fn expect_field<T: FromValue>(&self, name: &str) -> T {
+ self.field(name).unwrap().cast().unwrap()
+ }
+
+ /// Whether the content has the specified field.
+ pub fn has(&self, field: &str) -> bool {
+ self.field(field).is_some()
+ }
+
+ /// Borrow the value of the given field.
+ pub fn at(&self, field: &str, default: Option<Value>) -> StrResult<Value> {
+ self.field(field)
+ .or(default)
+ .ok_or_else(|| missing_field_no_default(field))
+ }
+
+ /// Return the fields of the content as a dict.
+ pub fn dict(&self) -> Dict {
+ self.fields()
+ .map(|(key, value)| (key.to_owned().into(), value))
+ .collect()
+ }
+
+ /// The content's label.
+ pub fn label(&self) -> Option<&Label> {
+ match self.field_ref("label")? {
+ Value::Label(label) => Some(label),
+ _ => None,
+ }
+ }
+
+ /// Attach a label to the content.
+ pub fn labelled(self, label: Label) -> Self {
+ self.with_field("label", label)
+ }
+
+ /// Style this content with a style entry.
+ pub fn styled(mut self, style: impl Into<Style>) -> Self {
+ if self.is::<StyledElem>() {
+ let prev =
+ self.attrs.make_mut().iter_mut().find_map(Attr::styles_mut).unwrap();
+ prev.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 self.is::<StyledElem>() {
+ let prev =
+ self.attrs.make_mut().iter_mut().find_map(Attr::styles_mut).unwrap();
+ prev.apply(styles);
+ self
+ } else {
+ let mut content = Content::new(StyledElem::func());
+ content.attrs.push(Attr::Child(Prehashed::new(self)));
+ content.attrs.push(Attr::Styles(styles));
+ content
+ }
+ }
+
+ /// Style this content with a recipe, eagerly applying it if possible.
+ pub fn styled_with_recipe(self, vm: &mut Vm, recipe: Recipe) -> SourceResult<Self> {
+ if recipe.selector.is_none() {
+ recipe.apply_vm(vm, self)
+ } else {
+ Ok(self.styled(recipe))
+ }
+ }
+
+ /// Repeat this content `count` times.
+ pub fn repeat(&self, count: usize) -> Self {
+ Self::sequence(vec![self.clone(); count])
+ }
+
+ /// Disable a show rule recipe.
+ pub fn guarded(mut self, guard: Guard) -> Self {
+ self.attrs.push(Attr::Guard(guard));
+ self
+ }
+
+ /// Check whether a show rule recipe is disabled.
+ pub fn is_guarded(&self, guard: Guard) -> bool {
+ self.attrs.contains(&Attr::Guard(guard))
+ }
+
+ /// Whether no show rule was executed for this content so far.
+ pub fn is_pristine(&self) -> bool {
+ !self.attrs.iter().any(|modifier| matches!(modifier, Attr::Guard(_)))
+ }
+
+ /// Whether this content has already been prepared.
+ pub fn is_prepared(&self) -> bool {
+ self.attrs.contains(&Attr::Prepared)
+ }
+
+ /// Mark this content as prepared.
+ pub fn mark_prepared(&mut self) {
+ self.attrs.push(Attr::Prepared);
+ }
+
+ /// Whether the content needs to be realized specially.
+ pub fn needs_preparation(&self) -> bool {
+ (self.can::<dyn Locatable>()
+ || self.can::<dyn Synthesize>()
+ || self.label().is_some())
+ && !self.is_prepared()
+ }
+
+ /// This content's location in the document flow.
+ pub fn location(&self) -> Option<Location> {
+ self.attrs.iter().find_map(|modifier| match modifier {
+ Attr::Location(location) => Some(*location),
+ _ => None,
+ })
+ }
+
+ /// Attach a location to this content.
+ pub fn set_location(&mut self, location: Location) {
+ self.attrs.push(Attr::Location(location));
+ }
+
+ /// Queries the content tree for all elements that match the given selector.
+ ///
+ /// Elements produced in `show` rules will not be included in the results.
+ #[tracing::instrument(skip_all)]
+ pub fn query(&self, selector: Selector) -> Vec<&Content> {
+ let mut results = Vec::new();
+ self.traverse(&mut |element| {
+ if selector.matches(element) {
+ 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.
+ #[tracing::instrument(skip_all)]
+ pub fn query_first(&self, selector: Selector) -> Option<&Content> {
+ let mut result = None;
+ self.traverse(&mut |element| {
+ if result.is_none() && selector.matches(element) {
+ 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<'a, F>(&'a self, f: &mut F)
+ where
+ F: FnMut(&'a Content),
+ {
+ f(self);
+
+ for attr in &self.attrs {
+ match attr {
+ Attr::Child(child) => child.traverse(f),
+ Attr::Value(value) => walk_value(value, f),
+ _ => {}
+ }
+ }
+
+ /// Walks a given value to find any content that matches the selector.
+ fn walk_value<'a, F>(value: &'a Value, f: &mut F)
+ where
+ F: FnMut(&'a Content),
+ {
+ match value {
+ Value::Content(content) => content.traverse(f),
+ Value::Array(array) => {
+ for value in array {
+ walk_value(value, f);
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+}
+
+impl Debug for Content {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let name = self.func.name();
+ if let Some(text) = item!(text_str)(self) {
+ f.write_char('[')?;
+ f.write_str(&text)?;
+ f.write_char(']')?;
+ return Ok(());
+ } else if name == "space" {
+ return f.write_str("[ ]");
+ }
+
+ let mut pieces: Vec<_> = self
+ .fields()
+ .map(|(name, value)| eco_format!("{name}: {value:?}"))
+ .collect();
+
+ if self.is::<StyledElem>() {
+ pieces.push(EcoString::from(".."));
+ }
+
+ f.write_str(name)?;
+ f.write_str(&pretty_array_like(&pieces, false))
+ }
+}
+
+impl Default for Content {
+ fn default() -> Self {
+ Self::empty()
+ }
+}
+
+impl PartialEq for Content {
+ fn eq(&self, other: &Self) -> bool {
+ if let (Some(left), Some(right)) = (self.to_sequence(), other.to_sequence()) {
+ left.eq(right)
+ } else if let (Some(left), Some(right)) = (self.to_styled(), other.to_styled()) {
+ left == right
+ } else {
+ self.func == other.func && self.fields_ref().eq(other.fields_ref())
+ }
+ }
+}
+
+impl Add for Content {
+ type Output = Self;
+
+ fn add(self, mut rhs: Self) -> Self::Output {
+ let mut lhs = self;
+ match (lhs.is::<SequenceElem>(), rhs.is::<SequenceElem>()) {
+ (true, true) => {
+ lhs.attrs.extend(rhs.attrs);
+ lhs
+ }
+ (true, false) => {
+ lhs.attrs.push(Attr::Child(Prehashed::new(rhs)));
+ lhs
+ }
+ (false, true) => {
+ rhs.attrs.insert(0, Attr::Child(Prehashed::new(lhs)));
+ rhs
+ }
+ (false, false) => Self::sequence([lhs, rhs]),
+ }
+ }
+}
+
+impl AddAssign 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 Attr {
+ fn child(&self) -> Option<&Content> {
+ match self {
+ Self::Child(child) => Some(child),
+ _ => None,
+ }
+ }
+
+ fn styles(&self) -> Option<&Styles> {
+ match self {
+ Self::Styles(styles) => Some(styles),
+ _ => None,
+ }
+ }
+
+ fn styles_mut(&mut self) -> Option<&mut Styles> {
+ match self {
+ Self::Styles(styles) => Some(styles),
+ _ => None,
+ }
+ }
+
+ fn field(&self) -> Option<&EcoString> {
+ match self {
+ Self::Field(field) => Some(field),
+ _ => None,
+ }
+ }
+
+ fn value(&self) -> Option<&Value> {
+ match self {
+ Self::Value(value) => Some(value),
+ _ => None,
+ }
+ }
+
+ fn span(&self) -> Option<Span> {
+ match self {
+ Self::Span(span) => Some(*span),
+ _ => None,
+ }
+ }
+}
+
+/// Display: Sequence
+/// Category: special
+#[element]
+struct SequenceElem {}
+
+/// Display: Sequence
+/// Category: special
+#[element]
+struct StyledElem {}
+
+/// Hosts metadata and ensures metadata is produced even for empty elements.
+///
+/// Display: Meta
+/// Category: special
+#[element(Behave)]
+pub struct MetaElem {
+ /// Metadata that should be attached to all elements affected by this style
+ /// property.
+ #[fold]
+ pub data: Vec<Meta>,
+}
+
+impl Behave for MetaElem {
+ fn behaviour(&self) -> Behaviour {
+ Behaviour::Ignorant
+ }
+}
+
+impl Fold for Vec<Meta> {
+ type Output = Self;
+
+ fn fold(mut self, outer: Self::Output) -> Self::Output {
+ self.extend(outer);
+ self
+ }
+}
+
+/// 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);
+}
+
+/// The missing key access error message when no default value was given.
+#[cold]
+fn missing_field_no_default(key: &str) -> EcoString {
+ eco_format!(
+ "content does not contain field {:?} and \
+ no default value was specified",
+ Str::from(key)
+ )
+}
diff --git a/crates/typst/src/model/element.rs b/crates/typst/src/model/element.rs
new file mode 100644
index 00000000..c673ee41
--- /dev/null
+++ b/crates/typst/src/model/element.rs
@@ -0,0 +1,134 @@
+use std::any::TypeId;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+
+use once_cell::sync::Lazy;
+
+use super::{Content, Selector, Styles};
+use crate::diag::SourceResult;
+use crate::eval::{cast, Args, Dict, Func, FuncInfo, Value, Vm};
+
+/// A document element.
+pub trait Element: Construct + Set + Sized + 'static {
+ /// Pack the element into type-erased content.
+ fn pack(self) -> Content;
+
+ /// Extract this element from type-erased content.
+ fn unpack(content: &Content) -> Option<&Self>;
+
+ /// The element's function.
+ fn func() -> ElemFunc;
+}
+
+/// 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(vm: &mut Vm, args: &mut Args) -> SourceResult<Content>;
+}
+
+/// An element's set rule.
+pub trait Set {
+ /// Parse relevant arguments into style properties for this element.
+ fn set(args: &mut Args) -> SourceResult<Styles>;
+}
+
+/// An element's function.
+#[derive(Copy, Clone)]
+pub struct ElemFunc(pub(super) &'static NativeElemFunc);
+
+impl ElemFunc {
+ /// The function's name.
+ pub fn name(self) -> &'static str {
+ self.0.name
+ }
+
+ /// Apply the given arguments to the function.
+ pub fn with(self, args: Args) -> Func {
+ Func::from(self).with(args)
+ }
+
+ /// Extract details about the function.
+ pub fn info(&self) -> &'static FuncInfo {
+ &self.0.info
+ }
+
+ /// Construct an element.
+ pub fn construct(self, vm: &mut Vm, args: &mut Args) -> SourceResult<Content> {
+ (self.0.construct)(vm, args)
+ }
+
+ /// Whether the contained element has the given capability.
+ pub fn can<C>(&self) -> bool
+ where
+ C: ?Sized + 'static,
+ {
+ (self.0.vtable)(TypeId::of::<C>()).is_some()
+ }
+
+ /// Create a selector for elements of this function.
+ pub fn select(self) -> Selector {
+ Selector::Elem(self, None)
+ }
+
+ /// Create a selector for elements of this function, filtering for those
+ /// whose [fields](super::Content::field) match the given arguments.
+ pub fn where_(self, fields: Dict) -> Selector {
+ Selector::Elem(self, Some(fields))
+ }
+
+ /// Execute the set rule for the element and return the resulting style map.
+ pub fn set(self, mut args: Args) -> SourceResult<Styles> {
+ let styles = (self.0.set)(&mut args)?;
+ args.finish()?;
+ Ok(styles)
+ }
+}
+
+impl Debug for ElemFunc {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad(self.name())
+ }
+}
+
+impl Eq for ElemFunc {}
+
+impl PartialEq for ElemFunc {
+ fn eq(&self, other: &Self) -> bool {
+ std::ptr::eq(self.0, other.0)
+ }
+}
+
+impl Hash for ElemFunc {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ state.write_usize(self.0 as *const _ as usize);
+ }
+}
+
+cast! {
+ ElemFunc,
+ self => Value::Func(self.into()),
+ v: Func => v.element().ok_or("expected element function")?,
+}
+
+impl From<&'static NativeElemFunc> for ElemFunc {
+ fn from(native: &'static NativeElemFunc) -> Self {
+ Self(native)
+ }
+}
+
+/// An element function backed by a Rust type.
+pub struct NativeElemFunc {
+ /// The element's name.
+ pub name: &'static str,
+ /// The element's vtable for capability dispatch.
+ pub vtable: fn(of: TypeId) -> Option<*const ()>,
+ /// The element's constructor.
+ pub construct: fn(&mut Vm, &mut Args) -> SourceResult<Content>,
+ /// The element's set rule.
+ pub set: fn(&mut Args) -> SourceResult<Styles>,
+ /// Details about the function.
+ pub info: Lazy<FuncInfo>,
+}
diff --git a/crates/typst/src/model/introspect.rs b/crates/typst/src/model/introspect.rs
new file mode 100644
index 00000000..42c1a9e1
--- /dev/null
+++ b/crates/typst/src/model/introspect.rs
@@ -0,0 +1,352 @@
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+use std::num::NonZeroUsize;
+
+use comemo::{Prehashed, Track, Tracked, Validate};
+use ecow::EcoVec;
+use indexmap::IndexMap;
+
+use super::{Content, Selector};
+use crate::diag::{bail, StrResult};
+use crate::doc::{Frame, FrameItem, Meta, Position};
+use crate::eval::{cast, Value};
+use crate::geom::{Point, Transform};
+use crate::model::Label;
+use crate::util::NonZeroExt;
+
+/// Identifies the location of an element in the document.
+///
+/// This struct is created by [`Locator::locate`].
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Location {
+ /// The hash of the element.
+ hash: u128,
+ /// An unique number among elements with the same hash. This is the reason
+ /// we need a `Locator` everywhere.
+ disambiguator: usize,
+ /// A synthetic location created from another one. This is used for example
+ /// in bibliography management to create individual linkable locations for
+ /// reference entries from the bibliography's location.
+ variant: usize,
+}
+
+impl Location {
+ /// Produce a variant of this location.
+ pub fn variant(mut self, n: usize) -> Self {
+ self.variant = n;
+ self
+ }
+}
+
+impl Debug for Location {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("..")
+ }
+}
+
+cast! {
+ type Location: "location",
+}
+
+/// Provides locations for elements in the document.
+///
+/// A [`Location`] consists of an element's hash plus a disambiguator. Just the
+/// hash is not enough because we can have multiple equal elements with the same
+/// hash (not a hash collision, just equal elements!). Between these, we
+/// disambiguate with an increasing number. In principle, the disambiguator
+/// could just be counted up. However, counting is an impure operation and as
+/// such we can't count across a memoization boundary. [^1]
+///
+/// Instead, we only mutate within a single "layout run" and combine the results
+/// with disambiguators from an outer tracked locator. Thus, the locators form a
+/// "tracked chain". When a layout run ends, its mutations are discarded and, on
+/// the other side of the memoization boundary, we
+/// [reconstruct](Self::visit_frame) them from the resulting [frames](Frame).
+///
+/// [^1]: Well, we could with [`TrackedMut`](comemo::TrackedMut), but the
+/// overhead is quite high, especially since we need to save & undo the counting
+/// when only measuring.
+#[derive(Default)]
+pub struct Locator<'a> {
+ /// Maps from a hash to the maximum number we've seen for this hash. This
+ /// number becomes the `disambiguator`.
+ hashes: RefCell<HashMap<u128, usize>>,
+ /// An outer `Locator`, from which we can get disambiguator for hashes
+ /// outside of the current "layout run".
+ ///
+ /// 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, <Locator<'static> as Validate>::Constraint>>,
+}
+
+impl<'a> Locator<'a> {
+ /// Create a new locator.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Create a new chained locator.
+ pub fn chained(outer: Tracked<'a, Self>) -> Self {
+ Self { outer: Some(outer), ..Default::default() }
+ }
+
+ /// Start tracking this locator.
+ ///
+ /// 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.hashes.borrow().is_empty() => outer,
+ _ => Track::track(self),
+ }
+ }
+
+ /// Produce a stable identifier for this call site.
+ pub fn locate(&mut self, hash: u128) -> Location {
+ // Get the current disambiguator for this hash.
+ let disambiguator = self.disambiguator_impl(hash);
+
+ // Bump the next disambiguator up by one.
+ self.hashes.borrow_mut().insert(hash, disambiguator + 1);
+
+ // Create the location in its default variant.
+ Location { hash, disambiguator, variant: 0 }
+ }
+
+ /// Advance past a frame.
+ pub fn visit_frame(&mut self, frame: &Frame) {
+ for (_, item) in frame.items() {
+ match item {
+ FrameItem::Group(group) => self.visit_frame(&group.frame),
+ FrameItem::Meta(Meta::Elem(elem), _) => {
+ let mut hashes = self.hashes.borrow_mut();
+ let loc = elem.location().unwrap();
+ let entry = hashes.entry(loc.hash).or_default();
+
+ // Next disambiguator needs to be at least one larger than
+ // the maximum we've seen so far.
+ *entry = (*entry).max(loc.disambiguator + 1);
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Advance past a number of frames.
+ pub fn visit_frames<'b>(&mut self, frames: impl IntoIterator<Item = &'b Frame>) {
+ for frame in frames {
+ self.visit_frame(frame);
+ }
+ }
+
+ /// The current disambiguator for the given hash.
+ fn disambiguator_impl(&self, hash: u128) -> usize {
+ *self
+ .hashes
+ .borrow_mut()
+ .entry(hash)
+ .or_insert_with(|| self.outer.map_or(0, |outer| outer.disambiguator(hash)))
+ }
+}
+
+#[comemo::track]
+impl<'a> Locator<'a> {
+ /// The current disambiguator for the hash.
+ fn disambiguator(&self, hash: u128) -> usize {
+ self.disambiguator_impl(hash)
+ }
+}
+
+/// Can be queried for elements and their positions.
+pub struct Introspector {
+ /// The number of pages in the document.
+ pages: usize,
+ /// All introspectable elements.
+ elems: IndexMap<Location, (Prehashed<Content>, Position)>,
+ /// The page numberings, indexed by page number minus 1.
+ page_numberings: Vec<Value>,
+ /// 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: RefCell<HashMap<u128, EcoVec<Prehashed<Content>>>>,
+}
+
+impl Introspector {
+ /// Create a new introspector.
+ #[tracing::instrument(skip(frames))]
+ pub fn new(frames: &[Frame]) -> Self {
+ let mut introspector = Self {
+ pages: frames.len(),
+ elems: IndexMap::new(),
+ page_numberings: vec![],
+ queries: RefCell::default(),
+ };
+ for (i, frame) in frames.iter().enumerate() {
+ let page = NonZeroUsize::new(1 + i).unwrap();
+ introspector.extract(frame, page, Transform::identity());
+ }
+ introspector
+ }
+
+ /// Extract metadata from a frame.
+ #[tracing::instrument(skip_all)]
+ fn extract(&mut self, 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);
+ self.extract(&group.frame, page, ts);
+ }
+ FrameItem::Meta(Meta::Elem(content), _)
+ if !self.elems.contains_key(&content.location().unwrap()) =>
+ {
+ let pos = pos.transform(ts);
+ let ret = self.elems.insert(
+ content.location().unwrap(),
+ (Prehashed::new(content.clone()), Position { page, point: pos }),
+ );
+ assert!(ret.is_none(), "duplicate locations");
+ }
+ FrameItem::Meta(Meta::PageNumbering(numbering), _) => {
+ self.page_numberings.push(numbering.clone());
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Iterate over all locatable elements.
+ pub fn all(&self) -> impl Iterator<Item = &Prehashed<Content>> + '_ {
+ self.elems.values().map(|(c, _)| c)
+ }
+
+ /// Get an element by its location.
+ fn get(&self, location: &Location) -> Option<&Prehashed<Content>> {
+ self.elems.get(location).map(|(elem, _)| elem)
+ }
+
+ /// Get the index of this element among all.
+ fn index(&self, elem: &Content) -> usize {
+ self.elems
+ .get_index_of(&elem.location().unwrap())
+ .unwrap_or(usize::MAX)
+ }
+}
+
+#[comemo::track]
+impl Introspector {
+ /// Query for all matching elements.
+ pub fn query(&self, selector: &Selector) -> EcoVec<Prehashed<Content>> {
+ let hash = crate::util::hash128(selector);
+ if let Some(output) = self.queries.borrow().get(&hash) {
+ return output.clone();
+ }
+
+ let output = match selector {
+ Selector::Elem(..)
+ | Selector::Label(_)
+ | Selector::Regex(_)
+ | Selector::Can(_)
+ | Selector::Or(_)
+ | Selector::And(_) => {
+ self.all().filter(|elem| selector.matches(elem)).cloned().collect()
+ }
+
+ Selector::Location(location) => {
+ self.get(location).cloned().into_iter().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 list
+ .binary_search_by_key(&self.index(&end), |elem| self.index(elem))
+ {
+ // 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 list
+ .binary_search_by_key(&self.index(&start), |elem| {
+ self.index(elem)
+ }) {
+ // Element itself is contained.
+ Ok(i) => i + !*inclusive as usize,
+ // Element itself is not contained.
+ Err(i) => i,
+ };
+ list = list[split..].into();
+ }
+ list
+ }
+ };
+
+ self.queries.borrow_mut().insert(hash, output.clone());
+ output
+ }
+
+ /// Query for the first element that matches the selector.
+ pub fn query_first(&self, selector: &Selector) -> Option<Prehashed<Content>> {
+ match selector {
+ Selector::Location(location) => self.get(location).cloned(),
+ _ => self.query(selector).first().cloned(),
+ }
+ }
+
+ /// Query for a unique element with the label.
+ pub fn query_label(&self, label: &Label) -> StrResult<Prehashed<Content>> {
+ let mut found = None;
+ for elem in self.all().filter(|elem| elem.label() == Some(label)) {
+ if found.is_some() {
+ bail!("label occurs multiple times in the document");
+ }
+ found = Some(elem.clone());
+ }
+ found.ok_or_else(|| "label does not exist in the document".into())
+ }
+
+ /// The total number pages.
+ pub fn pages(&self) -> NonZeroUsize {
+ NonZeroUsize::new(self.pages).unwrap_or(NonZeroUsize::ONE)
+ }
+
+ /// Gets the page numbering for the given location, if any.
+ pub fn page_numbering(&self, location: Location) -> Value {
+ let page = self.page(location);
+ self.page_numberings.get(page.get() - 1).cloned().unwrap_or_default()
+ }
+
+ /// 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.elems
+ .get(&location)
+ .map(|(_, loc)| *loc)
+ .unwrap_or(Position { page: NonZeroUsize::ONE, point: Point::zero() })
+ }
+}
+
+impl Default for Introspector {
+ fn default() -> Self {
+ Self::new(&[])
+ }
+}
diff --git a/crates/typst/src/model/label.rs b/crates/typst/src/model/label.rs
new file mode 100644
index 00000000..ef8f3edd
--- /dev/null
+++ b/crates/typst/src/model/label.rs
@@ -0,0 +1,16 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::EcoString;
+
+/// A label for an element.
+#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Label(pub EcoString);
+
+impl Debug for Label {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "<{}>", self.0)
+ }
+}
+
+/// Indicates that an element cannot be labelled.
+pub trait Unlabellable {}
diff --git a/crates/typst/src/model/mod.rs b/crates/typst/src/model/mod.rs
new file mode 100644
index 00000000..ee940236
--- /dev/null
+++ b/crates/typst/src/model/mod.rs
@@ -0,0 +1,148 @@
+//! The document model.
+
+mod content;
+mod element;
+mod introspect;
+mod label;
+mod realize;
+mod selector;
+mod styles;
+
+#[doc(inline)]
+pub use typst_macros::element;
+
+pub use self::content::{Content, MetaElem, PlainText};
+pub use self::element::{Construct, ElemFunc, Element, NativeElemFunc, Set};
+pub use self::introspect::{Introspector, Location, Locator};
+pub use self::label::{Label, Unlabellable};
+pub use self::realize::{
+ applicable, realize, Behave, Behaviour, Finalize, Guard, Locatable, Show, Synthesize,
+};
+pub use self::selector::{LocatableSelector, Selector, ShowableSelector};
+pub use self::styles::{
+ Fold, Property, Recipe, Resolve, Style, StyleChain, StyleVec, StyleVecBuilder,
+ Styles, Transform,
+};
+
+use std::mem::ManuallyDrop;
+
+use comemo::{Track, Tracked, TrackedMut, Validate};
+
+use crate::diag::{SourceError, SourceResult};
+use crate::doc::Document;
+use crate::eval::Tracer;
+use crate::World;
+
+/// Typeset content into a fully layouted document.
+#[comemo::memoize]
+#[tracing::instrument(skip(world, tracer, content))]
+pub fn typeset(
+ world: Tracked<dyn World + '_>,
+ mut tracer: TrackedMut<Tracer>,
+ content: &Content,
+) -> SourceResult<Document> {
+ tracing::info!("Starting typesetting");
+
+ let library = world.library();
+ let styles = StyleChain::new(&library.styles);
+
+ let mut iter = 0;
+ let mut document;
+ let mut delayed;
+
+ // We need `ManuallyDrop` until this lands in stable:
+ // https://github.com/rust-lang/rust/issues/70919
+ let mut introspector = ManuallyDrop::new(Introspector::new(&[]));
+
+ // Relayout until all introspections stabilize.
+ // If that doesn't happen within five attempts, we give up.
+ loop {
+ tracing::info!("Layout iteration {iter}");
+
+ delayed = DelayedErrors::default();
+
+ let constraint = <Introspector as Validate>::Constraint::new();
+ let mut locator = Locator::new();
+ let mut vt = Vt {
+ world,
+ tracer: TrackedMut::reborrow_mut(&mut tracer),
+ locator: &mut locator,
+ introspector: introspector.track_with(&constraint),
+ delayed: delayed.track_mut(),
+ };
+
+ // Layout!
+ let result = (library.items.layout)(&mut vt, content, styles)?;
+
+ // Drop the old introspector.
+ ManuallyDrop::into_inner(introspector);
+
+ // Only now assign the document and construct the new introspector.
+ document = result;
+ introspector = ManuallyDrop::new(Introspector::new(&document.pages));
+ iter += 1;
+
+ if iter >= 5 || introspector.validate(&constraint) {
+ break;
+ }
+ }
+
+ // Drop the introspector.
+ ManuallyDrop::into_inner(introspector);
+
+ // Promote delayed errors.
+ if !delayed.0.is_empty() {
+ return Err(Box::new(delayed.0));
+ }
+
+ Ok(document)
+}
+
+/// A virtual typesetter.
+///
+/// Holds the state needed to [typeset] content.
+pub struct Vt<'a> {
+ /// The compilation environment.
+ pub world: Tracked<'a, dyn World + 'a>,
+ /// Provides access to information about the document.
+ pub introspector: Tracked<'a, Introspector>,
+ /// Provides stable identities to elements.
+ pub locator: &'a mut Locator<'a>,
+ /// Delayed errors that do not immediately terminate execution.
+ pub delayed: TrackedMut<'a, DelayedErrors>,
+ /// The tracer for inspection of the values an expression produces.
+ pub tracer: TrackedMut<'a, Tracer>,
+}
+
+impl Vt<'_> {
+ /// Perform a fallible operation that does not immediately terminate further
+ /// execution. Instead it produces a delayed error that is only promoted to
+ /// a fatal one if it remains at the end of the introspection loop.
+ pub fn delayed<F, T>(&mut self, f: F) -> T
+ where
+ F: FnOnce(&mut Self) -> SourceResult<T>,
+ T: Default,
+ {
+ match f(self) {
+ Ok(value) => value,
+ Err(errors) => {
+ for error in *errors {
+ self.delayed.push(error);
+ }
+ T::default()
+ }
+ }
+ }
+}
+
+/// Holds delayed errors.
+#[derive(Default, Clone)]
+pub struct DelayedErrors(Vec<SourceError>);
+
+#[comemo::track]
+impl DelayedErrors {
+ /// Push a delayed error.
+ fn push(&mut self, error: SourceError) {
+ self.0.push(error);
+ }
+}
diff --git a/crates/typst/src/model/realize.rs b/crates/typst/src/model/realize.rs
new file mode 100644
index 00000000..01c46b81
--- /dev/null
+++ b/crates/typst/src/model/realize.rs
@@ -0,0 +1,228 @@
+use super::{Content, ElemFunc, Element, MetaElem, Recipe, Selector, StyleChain, Vt};
+use crate::diag::SourceResult;
+use crate::doc::Meta;
+use crate::util::hash128;
+
+/// Whether the target is affected by show rules in the given style chain.
+pub fn applicable(target: &Content, styles: StyleChain) -> bool {
+ if target.needs_preparation() {
+ return true;
+ }
+
+ if target.can::<dyn Show>() && target.is_pristine() {
+ return true;
+ }
+
+ // Find out how many recipes there are.
+ let mut n = styles.recipes().count();
+
+ // Find out whether any recipe matches and is unguarded.
+ for recipe in styles.recipes() {
+ if recipe.applicable(target) && !target.is_guarded(Guard::Nth(n)) {
+ return true;
+ }
+ n -= 1;
+ }
+
+ false
+}
+
+/// Apply the show rules in the given style chain to a target.
+pub fn realize(
+ vt: &mut Vt,
+ target: &Content,
+ styles: StyleChain,
+) -> SourceResult<Option<Content>> {
+ // Pre-process.
+ if target.needs_preparation() {
+ let mut elem = target.clone();
+ if target.can::<dyn Locatable>() || target.label().is_some() {
+ let location = vt.locator.locate(hash128(target));
+ elem.set_location(location);
+ }
+
+ if let Some(elem) = elem.with_mut::<dyn Synthesize>() {
+ elem.synthesize(vt, styles)?;
+ }
+
+ elem.mark_prepared();
+
+ if elem.location().is_some() {
+ let span = elem.span();
+ let meta = Meta::Elem(elem.clone());
+ return Ok(Some(
+ (elem + MetaElem::new().pack().spanned(span))
+ .styled(MetaElem::set_data(vec![meta])),
+ ));
+ }
+
+ return Ok(Some(elem));
+ }
+
+ // Find out how many recipes there are.
+ let mut n = styles.recipes().count();
+
+ // Find an applicable recipe.
+ let mut realized = None;
+ for recipe in styles.recipes() {
+ let guard = Guard::Nth(n);
+ if recipe.applicable(target) && !target.is_guarded(guard) {
+ if let Some(content) = try_apply(vt, target, recipe, guard)? {
+ realized = Some(content);
+ break;
+ }
+ }
+ n -= 1;
+ }
+
+ // Realize if there was no matching recipe.
+ if let Some(showable) = target.with::<dyn Show>() {
+ let guard = Guard::Base(target.func());
+ if realized.is_none() && !target.is_guarded(guard) {
+ realized = Some(showable.show(vt, styles)?);
+ }
+ }
+
+ // Finalize only if this is the first application for this element.
+ if let Some(elem) = target.with::<dyn Finalize>() {
+ if target.is_pristine() {
+ if let Some(already) = realized {
+ realized = Some(elem.finalize(already, styles));
+ }
+ }
+ }
+
+ Ok(realized)
+}
+
+/// Try to apply a recipe to the target.
+fn try_apply(
+ vt: &mut Vt,
+ target: &Content,
+ recipe: &Recipe,
+ guard: Guard,
+) -> SourceResult<Option<Content>> {
+ match &recipe.selector {
+ Some(Selector::Elem(element, _)) => {
+ if target.func() != *element {
+ return Ok(None);
+ }
+
+ recipe.apply_vt(vt, target.clone().guarded(guard)).map(Some)
+ }
+
+ Some(Selector::Label(label)) => {
+ if target.label() != Some(label) {
+ return Ok(None);
+ }
+
+ recipe.apply_vt(vt, target.clone().guarded(guard)).map(Some)
+ }
+
+ Some(Selector::Regex(regex)) => {
+ let Some(text) = item!(text_str)(target) else {
+ return Ok(None);
+ };
+
+ let make = |s: &str| target.clone().with_field("text", s);
+ let mut result = vec![];
+ let mut cursor = 0;
+
+ for m in regex.find_iter(&text) {
+ let start = m.start();
+ if cursor < start {
+ result.push(make(&text[cursor..start]));
+ }
+
+ let piece = make(m.as_str()).guarded(guard);
+ let transformed = recipe.apply_vt(vt, piece)?;
+ result.push(transformed);
+ cursor = m.end();
+ }
+
+ if result.is_empty() {
+ return Ok(None);
+ }
+
+ if cursor < text.len() {
+ result.push(make(&text[cursor..]));
+ }
+
+ Ok(Some(Content::sequence(result)))
+ }
+
+ // Not supported here.
+ Some(
+ Selector::Or(_)
+ | Selector::And(_)
+ | Selector::Location(_)
+ | Selector::Can(_)
+ | Selector::Before { .. }
+ | Selector::After { .. },
+ ) => Ok(None),
+
+ None => Ok(None),
+ }
+}
+
+/// Makes this element locatable through `vt.locate`.
+pub trait Locatable {}
+
+/// 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, vt: &mut Vt, styles: StyleChain) -> SourceResult<()>;
+}
+
+/// The base recipe for an element.
+pub trait Show {
+ /// Execute the base recipe for this element.
+ fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content>;
+}
+
+/// Post-process an element after it was realized.
+pub trait Finalize {
+ /// 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 finalize(&self, realized: Content, styles: StyleChain) -> Content;
+}
+
+/// How the element interacts with other elements.
+pub trait Behave {
+ /// The element's interaction behaviour.
+ fn behaviour(&self) -> Behaviour;
+
+ /// Whether this weak element is larger than a previous one and thus picked
+ /// as the maximum when the levels are the same.
+ #[allow(unused_variables)]
+ fn larger(&self, prev: &Content) -> bool {
+ false
+ }
+}
+
+/// How an element interacts with other elements in a stream.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub enum Behaviour {
+ /// A weak element which only survives when a supportive element is before
+ /// and after it. Furthermore, per consecutive run of weak elements, only
+ /// one survives: The one with the lowest weakness level (or the larger one
+ /// if there is a tie).
+ Weak(usize),
+ /// An element that enables adjacent weak elements to exist. The default.
+ Supportive,
+ /// An element that destroys adjacent weak elements.
+ Destructive,
+ /// An element that does not interact at all with other elements, having the
+ /// same effect as if it didn't exist.
+ Ignorant,
+}
+
+/// Guards content against being affected by the same show rule multiple times.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Guard {
+ /// The nth recipe from the top of the chain.
+ Nth(usize),
+ /// The [base recipe](Show) for a kind of element.
+ Base(ElemFunc),
+}
diff --git a/crates/typst/src/model/selector.rs b/crates/typst/src/model/selector.rs
new file mode 100644
index 00000000..9723ee4f
--- /dev/null
+++ b/crates/typst/src/model/selector.rs
@@ -0,0 +1,296 @@
+use std::any::{Any, TypeId};
+use std::fmt::{self, Debug, Formatter, Write};
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString, EcoVec};
+
+use super::{Content, ElemFunc, Label, Location};
+use crate::diag::{bail, StrResult};
+use crate::eval::{
+ cast, CastInfo, Dict, FromValue, Func, IntoValue, Reflect, Regex, Value,
+};
+use crate::model::Locatable;
+use crate::util::pretty_array_like;
+
+/// A selector in a show rule.
+#[derive(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(ElemFunc, Option<Dict>),
+ /// 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) -> Self {
+ Self::Regex(Regex::new(&regex::escape(text)).unwrap())
+ }
+
+ /// Define a simple [`Selector::Can`] selector.
+ pub fn can<T: ?Sized + Any>() -> Self {
+ Self::Can(TypeId::of::<T>())
+ }
+
+ /// Transforms this selector and an iterator of other selectors into a
+ /// [`Selector::Or`] selector.
+ pub fn and(self, others: impl IntoIterator<Item = Self>) -> Self {
+ Self::And(others.into_iter().chain(Some(self)).collect())
+ }
+
+ /// Transforms this selector and an iterator of other selectors into a
+ /// [`Selector::And`] selector.
+ pub fn or(self, others: impl IntoIterator<Item = Self>) -> Self {
+ Self::Or(others.into_iter().chain(Some(self)).collect())
+ }
+
+ /// Transforms this selector into a [`Selector::Before`] selector.
+ pub fn before(self, location: impl Into<Self>, inclusive: bool) -> Self {
+ Self::Before {
+ selector: Arc::new(self),
+ end: Arc::new(location.into()),
+ inclusive,
+ }
+ }
+
+ /// Transforms this selector into a [`Selector::After`] selector.
+ pub fn after(self, location: impl Into<Self>, inclusive: bool) -> Self {
+ Self::After {
+ selector: Arc::new(self),
+ start: Arc::new(location.into()),
+ inclusive,
+ }
+ }
+
+ /// Whether the selector matches for the target.
+ pub fn matches(&self, target: &Content) -> bool {
+ match self {
+ Self::Elem(element, dict) => {
+ target.func() == *element
+ && dict
+ .iter()
+ .flat_map(|dict| dict.iter())
+ .all(|(name, value)| target.field_ref(name) == Some(value))
+ }
+ Self::Label(label) => target.label() == Some(label),
+ Self::Regex(regex) => {
+ target.func() == item!(text_func)
+ && item!(text_str)(target).map_or(false, |text| regex.is_match(&text))
+ }
+ Self::Can(cap) => target.can_type_id(*cap),
+ Self::Or(selectors) => selectors.iter().any(move |sel| sel.matches(target)),
+ Self::And(selectors) => selectors.iter().all(move |sel| sel.matches(target)),
+ Self::Location(location) => target.location() == Some(*location),
+ // Not supported here.
+ Self::Before { .. } | Self::After { .. } => false,
+ }
+ }
+}
+
+impl From<Location> for Selector {
+ fn from(value: Location) -> Self {
+ Self::Location(value)
+ }
+}
+
+impl Debug for Selector {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Elem(elem, dict) => {
+ f.write_str(elem.name())?;
+ if let Some(dict) = dict {
+ f.write_str(".where")?;
+ dict.fmt(f)?;
+ }
+ Ok(())
+ }
+ Self::Label(label) => label.fmt(f),
+ Self::Regex(regex) => regex.fmt(f),
+ Self::Can(cap) => cap.fmt(f),
+ Self::Or(selectors) | Self::And(selectors) => {
+ f.write_str(if matches!(self, Self::Or(_)) { "or" } else { "and" })?;
+ let pieces: Vec<_> =
+ selectors.iter().map(|sel| eco_format!("{sel:?}")).collect();
+ f.write_str(&pretty_array_like(&pieces, false))
+ }
+ Self::Location(loc) => loc.fmt(f),
+ Self::Before { selector, end: split, inclusive }
+ | Self::After { selector, start: split, inclusive } => {
+ selector.fmt(f)?;
+
+ if matches!(self, Self::Before { .. }) {
+ f.write_str(".before(")?;
+ } else {
+ f.write_str(".after(")?;
+ }
+
+ split.fmt(f)?;
+ if !*inclusive {
+ f.write_str(", inclusive: false")?;
+ }
+ f.write_char(')')
+ }
+ }
+ }
+}
+
+cast! {
+ type Selector: "selector",
+ func: Func => func
+ .element()
+ .ok_or("only element functions can be used as selectors")?
+ .select(),
+ label: Label => Self::Label(label),
+ text: EcoString => Self::text(&text),
+ 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(Clone, PartialEq, Hash)]
+pub struct LocatableSelector(pub Selector);
+
+impl Reflect for LocatableSelector {
+ fn describe() -> CastInfo {
+ CastInfo::Union(vec![
+ CastInfo::Type("function"),
+ CastInfo::Type("label"),
+ CastInfo::Type("selector"),
+ ])
+ }
+
+ fn castable(value: &Value) -> bool {
+ matches!(value.type_name(), "function" | "label" | "selector")
+ }
+}
+
+impl IntoValue for LocatableSelector {
+ fn into_value(self) -> Value {
+ self.0.into_value()
+ }
+}
+
+impl FromValue for LocatableSelector {
+ fn from_value(value: Value) -> StrResult<Self> {
+ fn validate(selector: &Selector) -> StrResult<()> {
+ match selector {
+ Selector::Elem(elem, _) => {
+ if !elem.can::<dyn Locatable>() {
+ 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))
+ }
+}
+
+/// 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 describe() -> CastInfo {
+ CastInfo::Union(vec![
+ CastInfo::Type("function"),
+ CastInfo::Type("label"),
+ CastInfo::Type("string"),
+ CastInfo::Type("regular expression"),
+ CastInfo::Type("symbol"),
+ CastInfo::Type("selector"),
+ ])
+ }
+
+ fn castable(value: &Value) -> bool {
+ matches!(
+ value.type_name(),
+ "symbol"
+ | "string"
+ | "label"
+ | "function"
+ | "regular expression"
+ | "selector"
+ )
+ }
+}
+
+impl IntoValue for ShowableSelector {
+ fn into_value(self) -> Value {
+ self.0.into_value()
+ }
+}
+
+impl FromValue for ShowableSelector {
+ fn from_value(value: Value) -> StrResult<Self> {
+ fn validate(selector: &Selector) -> StrResult<()> {
+ match selector {
+ Selector::Elem(_, _) => {}
+ Selector::Label(_) => {}
+ Selector::Regex(_) => {}
+ Selector::Or(_)
+ | Selector::And(_)
+ | 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)?;
+ Ok(Self(selector))
+ }
+}
diff --git a/crates/typst/src/model/styles.rs b/crates/typst/src/model/styles.rs
new file mode 100644
index 00000000..23748a3f
--- /dev/null
+++ b/crates/typst/src/model/styles.rs
@@ -0,0 +1,750 @@
+use std::fmt::{self, Debug, Formatter, Write};
+use std::iter;
+use std::mem;
+use std::ptr;
+
+use comemo::Prehashed;
+use ecow::{eco_vec, EcoString, EcoVec};
+
+use super::{Content, ElemFunc, Element, Selector, Vt};
+use crate::diag::{SourceResult, Trace, Tracepoint};
+use crate::eval::{cast, Args, FromValue, Func, IntoValue, Value, Vm};
+use crate::syntax::Span;
+
+/// A list of style properties.
+#[derive(Default, PartialEq, Clone, Hash)]
+pub struct Styles(EcoVec<Prehashed<Style>>);
+
+impl Styles {
+ /// Create a new, empty style list.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Whether this contains no styles.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// 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(Prehashed::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.into_iter());
+ *self = outer;
+ }
+
+ /// Apply one outer styles.
+ pub fn apply_one(&mut self, outer: Style) {
+ self.0.insert(0, Prehashed::new(outer));
+ }
+
+ /// Apply a slice of outer styles.
+ pub fn apply_slice(&mut self, outer: &[Prehashed<Style>]) {
+ self.0 = outer.iter().cloned().chain(mem::take(self).0.into_iter()).collect();
+ }
+
+ /// Add an origin span to all contained properties.
+ pub fn spanned(mut self, span: Span) -> Self {
+ for entry in self.0.make_mut() {
+ entry.update(|entry| {
+ if let Style::Property(property) = entry {
+ property.span = Some(span);
+ }
+ });
+ }
+ self
+ }
+
+ /// Returns `Some(_)` with an optional span if this list contains
+ /// styles for the given element.
+ pub fn interruption<T: Element>(&self) -> Option<Option<Span>> {
+ let func = T::func();
+ self.0.iter().find_map(|entry| match &**entry {
+ Style::Property(property) => property.is_of(func).then_some(property.span),
+ Style::Recipe(recipe) => recipe.is_of(func).then_some(Some(recipe.span)),
+ })
+ }
+}
+
+impl From<Style> for Styles {
+ fn from(entry: Style) -> Self {
+ Self(eco_vec![Prehashed::new(entry)])
+ }
+}
+
+impl Debug for Styles {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("..")
+ }
+}
+
+/// A single style property or recipe.
+#[derive(Clone, PartialEq, Hash)]
+pub enum Style {
+ /// A style property originating from a set rule or constructor.
+ Property(Property),
+ /// A show rule recipe.
+ Recipe(Recipe),
+}
+
+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,
+ }
+ }
+}
+
+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),
+ }
+ }
+}
+
+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, PartialEq, Hash)]
+pub struct Property {
+ /// The element the property belongs to.
+ element: ElemFunc,
+ /// The property's name.
+ name: EcoString,
+ /// The property's value.
+ value: Value,
+ /// The span of the set rule the property stems from.
+ span: Option<Span>,
+}
+
+impl Property {
+ /// Create a new property from a key-value pair.
+ pub fn new(
+ element: ElemFunc,
+ name: impl Into<EcoString>,
+ value: impl IntoValue,
+ ) -> Self {
+ Self {
+ element,
+ name: name.into(),
+ value: value.into_value(),
+ span: None,
+ }
+ }
+
+ /// Whether this property is the given one.
+ pub fn is(&self, element: ElemFunc, name: &str) -> bool {
+ self.element == element && self.name == name
+ }
+
+ /// Whether this property belongs to the given element.
+ pub fn is_of(&self, element: ElemFunc) -> bool {
+ self.element == element
+ }
+}
+
+impl Debug for Property {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "set {}({}: {:?})", self.element.name(), self.name, self.value)?;
+ Ok(())
+ }
+}
+
+/// A show rule recipe.
+#[derive(Clone, PartialEq, Hash)]
+pub struct Recipe {
+ /// The span errors are reported with.
+ pub span: Span,
+ /// Determines whether the recipe applies to an element.
+ pub selector: Option<Selector>,
+ /// The transformation to perform on the match.
+ pub transform: Transform,
+}
+
+impl Recipe {
+ /// Whether this recipe is for the given type of element.
+ pub fn is_of(&self, element: ElemFunc) -> bool {
+ match self.selector {
+ Some(Selector::Elem(own, _)) => own == element,
+ _ => false,
+ }
+ }
+
+ /// Whether the recipe is applicable to the target.
+ pub fn applicable(&self, target: &Content) -> bool {
+ self.selector
+ .as_ref()
+ .map_or(false, |selector| selector.matches(target))
+ }
+
+ /// Apply the recipe to the given content.
+ pub fn apply_vm(&self, vm: &mut Vm, content: Content) -> SourceResult<Content> {
+ match &self.transform {
+ Transform::Content(content) => Ok(content.clone()),
+ Transform::Func(func) => {
+ let args = Args::new(self.span, [Value::Content(content.clone())]);
+ let mut result = func.call_vm(vm, args);
+ // For selector-less show rules, a tracepoint makes no sense.
+ if self.selector.is_some() {
+ let point = || Tracepoint::Show(content.func().name().into());
+ result = result.trace(vm.world(), point, content.span());
+ }
+ Ok(result?.display())
+ }
+ Transform::Style(styles) => Ok(content.styled_with_map(styles.clone())),
+ }
+ }
+
+ /// Apply the recipe to the given content.
+ pub fn apply_vt(&self, vt: &mut Vt, content: Content) -> SourceResult<Content> {
+ match &self.transform {
+ Transform::Content(content) => Ok(content.clone()),
+ Transform::Func(func) => {
+ let mut result = func.call_vt(vt, [Value::Content(content.clone())]);
+ if self.selector.is_some() {
+ let point = || Tracepoint::Show(content.func().name().into());
+ result = result.trace(vt.world, point, content.span());
+ }
+ Ok(result?.display())
+ }
+ Transform::Style(styles) => Ok(content.styled_with_map(styles.clone())),
+ }
+ }
+}
+
+impl Debug for Recipe {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("show")?;
+ if let Some(selector) = &self.selector {
+ f.write_char(' ')?;
+ selector.fmt(f)?;
+ }
+ f.write_str(": ")?;
+ self.transform.fmt(f)
+ }
+}
+
+/// A show rule transformation that can be applied to a match.
+#[derive(Clone, PartialEq, Hash)]
+pub enum Transform {
+ /// Replacement content.
+ Content(Content),
+ /// A function to apply to the match.
+ Func(Func),
+ /// Apply styles to the content.
+ Style(Styles),
+}
+
+impl Debug for Transform {
+ 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! {
+ Transform,
+ 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 [Prehashed<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 style list 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>(&'b self, local: &'b Styles) -> StyleChain<'b> {
+ if local.is_empty() {
+ *self
+ } else {
+ StyleChain { head: &local.0, tail: Some(self) }
+ }
+ }
+
+ /// Cast the first value for the given property in the chain.
+ pub fn get<T: FromValue>(
+ self,
+ func: ElemFunc,
+ name: &'a str,
+ inherent: Option<Value>,
+ default: impl Fn() -> T,
+ ) -> T {
+ self.properties::<T>(func, name, inherent)
+ .next()
+ .unwrap_or_else(default)
+ }
+
+ /// Cast the first value for the given property in the chain.
+ pub fn get_resolve<T: FromValue + Resolve>(
+ self,
+ func: ElemFunc,
+ name: &'a str,
+ inherent: Option<Value>,
+ default: impl Fn() -> T,
+ ) -> T::Output {
+ self.get(func, name, inherent, default).resolve(self)
+ }
+
+ /// Cast the first value for the given property in the chain.
+ pub fn get_fold<T: FromValue + Fold>(
+ self,
+ func: ElemFunc,
+ name: &'a str,
+ inherent: Option<Value>,
+ default: impl Fn() -> T::Output,
+ ) -> T::Output {
+ fn next<T: Fold>(
+ mut values: impl Iterator<Item = T>,
+ _styles: StyleChain,
+ default: &impl Fn() -> T::Output,
+ ) -> T::Output {
+ values
+ .next()
+ .map(|value| value.fold(next(values, _styles, default)))
+ .unwrap_or_else(default)
+ }
+ next(self.properties::<T>(func, name, inherent), self, &default)
+ }
+
+ /// Cast the first value for the given property in the chain.
+ pub fn get_resolve_fold<T>(
+ self,
+ func: ElemFunc,
+ name: &'a str,
+ inherent: Option<Value>,
+ default: impl Fn() -> <T::Output as Fold>::Output,
+ ) -> <T::Output as Fold>::Output
+ where
+ T: FromValue + Resolve,
+ T::Output: Fold,
+ {
+ fn next<T>(
+ mut values: impl Iterator<Item = T>,
+ styles: StyleChain,
+ default: &impl Fn() -> <T::Output as Fold>::Output,
+ ) -> <T::Output as Fold>::Output
+ where
+ T: Resolve,
+ T::Output: Fold,
+ {
+ values
+ .next()
+ .map(|value| value.resolve(styles).fold(next(values, styles, default)))
+ .unwrap_or_else(default)
+ }
+ next(self.properties::<T>(func, name, inherent), self, &default)
+ }
+
+ /// Iterate over all style recipes in the chain.
+ pub fn recipes(self) -> impl Iterator<Item = &'a Recipe> {
+ self.entries().filter_map(Style::recipe)
+ }
+
+ /// Iterate over all values for the given property in the chain.
+ pub fn properties<T: FromValue + 'a>(
+ self,
+ func: ElemFunc,
+ name: &'a str,
+ inherent: Option<Value>,
+ ) -> impl Iterator<Item = T> + '_ {
+ inherent
+ .into_iter()
+ .chain(
+ self.entries()
+ .filter_map(Style::property)
+ .filter(move |property| property.is(func, name))
+ .map(|property| property.value.clone()),
+ )
+ .map(move |value| {
+ value.cast().unwrap_or_else(|err| {
+ panic!("{} (for {}.{})", err, func.name(), name)
+ })
+ })
+ }
+
+ /// Convert to a style map.
+ pub fn to_map(self) -> Styles {
+ let mut suffix = Styles::new();
+ for link in self.links() {
+ suffix.apply_slice(link);
+ }
+ suffix
+ }
+
+ /// Iterate over the entries of the chain.
+ fn entries(self) -> Entries<'a> {
+ Entries { inner: [].as_slice().iter(), links: self.links() }
+ }
+
+ /// Iterate over the links of the chain.
+ fn links(self) -> Links<'a> {
+ Links(Some(self))
+ }
+
+ /// Build owned styles from the suffix (all links beyond the `len`) of the
+ /// chain.
+ fn suffix(self, len: usize) -> Styles {
+ let mut suffix = Styles::new();
+ let take = self.links().count().saturating_sub(len);
+ for link in self.links().take(take) {
+ suffix.apply_slice(link);
+ }
+ suffix
+ }
+
+ /// Remove the last link from the chain.
+ fn pop(&mut self) {
+ *self = self.tail.copied().unwrap_or_default();
+ }
+}
+
+impl Debug for StyleChain<'_> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ for entry in self.entries().collect::<Vec<_>>().into_iter().rev() {
+ writeln!(f, "{:?}", entry)?;
+ }
+ Ok(())
+ }
+}
+
+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,
+ }
+ }
+}
+
+/// An iterator over the entries in a style chain.
+struct Entries<'a> {
+ inner: std::slice::Iter<'a, Prehashed<Style>>,
+ links: Links<'a>,
+}
+
+impl<'a> Iterator for Entries<'a> {
+ type Item = &'a 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.
+struct Links<'a>(Option<StyleChain<'a>>);
+
+impl<'a> Iterator for Links<'a> {
+ type Item = &'a [Prehashed<Style>];
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let StyleChain { head, tail } = self.0?;
+ self.0 = tail.copied();
+ Some(head)
+ }
+}
+
+/// A sequence of items with associated styles.
+#[derive(Clone, Hash)]
+pub struct StyleVec<T> {
+ items: Vec<T>,
+ styles: Vec<(Styles, usize)>,
+}
+
+impl<T> StyleVec<T> {
+ /// Whether there are any items in the sequence.
+ pub fn is_empty(&self) -> bool {
+ self.items.is_empty()
+ }
+
+ /// Number of items in the sequence.
+ pub fn len(&self) -> usize {
+ self.items.len()
+ }
+
+ /// Insert an item in the front. The item will share the style of the
+ /// current first item.
+ ///
+ /// This method has no effect if the vector is empty.
+ pub fn push_front(&mut self, item: T) {
+ if !self.styles.is_empty() {
+ self.items.insert(0, item);
+ self.styles[0].1 += 1;
+ }
+ }
+
+ /// Map the contained items.
+ pub fn map<F, U>(&self, f: F) -> StyleVec<U>
+ where
+ F: FnMut(&T) -> U,
+ {
+ StyleVec {
+ items: self.items.iter().map(f).collect(),
+ styles: self.styles.clone(),
+ }
+ }
+
+ /// Iterate over references to the contained items and associated styles.
+ pub fn iter(&self) -> impl Iterator<Item = (&T, &Styles)> + '_ {
+ self.items().zip(
+ self.styles
+ .iter()
+ .flat_map(|(map, count)| iter::repeat(map).take(*count)),
+ )
+ }
+
+ /// Iterate over the contained items.
+ pub fn items(&self) -> std::slice::Iter<'_, T> {
+ self.items.iter()
+ }
+
+ /// Iterate over the contained style lists. Note that zipping this with
+ /// `items()` does not yield the same result as calling `iter()` because
+ /// this method only returns lists once that are shared by consecutive
+ /// items. This method is designed for use cases where you want to check,
+ /// for example, whether any of the lists fulfills a specific property.
+ pub fn styles(&self) -> impl Iterator<Item = &Styles> {
+ self.styles.iter().map(|(map, _)| map)
+ }
+}
+
+impl StyleVec<Content> {
+ pub fn to_vec(self) -> Vec<Content> {
+ self.items
+ .into_iter()
+ .zip(
+ self.styles
+ .iter()
+ .flat_map(|(map, count)| iter::repeat(map).take(*count)),
+ )
+ .map(|(content, styles)| content.styled_with_map(styles.clone()))
+ .collect()
+ }
+}
+
+impl<T> Default for StyleVec<T> {
+ fn default() -> Self {
+ Self { items: vec![], styles: vec![] }
+ }
+}
+
+impl<T> FromIterator<T> for StyleVec<T> {
+ fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
+ let items: Vec<_> = iter.into_iter().collect();
+ let styles = vec![(Styles::new(), items.len())];
+ Self { items, styles }
+ }
+}
+
+impl<T: Debug> Debug for StyleVec<T> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ f.debug_list()
+ .entries(self.iter().map(|(item, styles)| {
+ crate::util::debug(|f| {
+ styles.fmt(f)?;
+ item.fmt(f)
+ })
+ }))
+ .finish()
+ }
+}
+
+/// Assists in the construction of a [`StyleVec`].
+#[derive(Debug)]
+pub struct StyleVecBuilder<'a, T> {
+ items: Vec<T>,
+ chains: Vec<(StyleChain<'a>, usize)>,
+}
+
+impl<'a, T> StyleVecBuilder<'a, T> {
+ /// Create a new style-vec builder.
+ pub fn new() -> Self {
+ Self { items: vec![], chains: vec![] }
+ }
+
+ /// Whether the builder is empty.
+ pub fn is_empty(&self) -> bool {
+ self.items.is_empty()
+ }
+
+ /// Push a new item into the style vector.
+ pub fn push(&mut self, item: T, styles: StyleChain<'a>) {
+ self.items.push(item);
+
+ if let Some((prev, count)) = self.chains.last_mut() {
+ if *prev == styles {
+ *count += 1;
+ return;
+ }
+ }
+
+ self.chains.push((styles, 1));
+ }
+
+ /// Iterate over the contained items.
+ pub fn elems(&self) -> std::slice::Iter<'_, T> {
+ self.items.iter()
+ }
+
+ /// Finish building, returning a pair of two things:
+ /// - a style vector of items with the non-shared styles
+ /// - a shared prefix chain of styles that apply to all items
+ pub fn finish(self) -> (StyleVec<T>, StyleChain<'a>) {
+ let mut iter = self.chains.iter();
+ let mut trunk = match iter.next() {
+ Some(&(chain, _)) => chain,
+ None => return Default::default(),
+ };
+
+ let mut shared = trunk.links().count();
+ for &(mut chain, _) in iter {
+ let len = chain.links().count();
+ if len < shared {
+ for _ in 0..shared - len {
+ trunk.pop();
+ }
+ shared = len;
+ } else if len > shared {
+ for _ in 0..len - shared {
+ chain.pop();
+ }
+ }
+
+ while shared > 0 && chain != trunk {
+ trunk.pop();
+ chain.pop();
+ shared -= 1;
+ }
+ }
+
+ let styles = self
+ .chains
+ .into_iter()
+ .map(|(chain, count)| (chain.suffix(shared), count))
+ .collect();
+
+ (StyleVec { items: self.items, styles }, trunk)
+ }
+}
+
+impl<'a, T> Default for StyleVecBuilder<'a, T> {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// 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 {
+ /// The type of the folded output.
+ type Output;
+
+ /// Fold this inner value with an outer folded value.
+ fn fold(self, outer: Self::Output) -> Self::Output;
+}
+
+impl<T> Fold for Option<T>
+where
+ T: Fold,
+ T::Output: Default,
+{
+ type Output = Option<T::Output>;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ self.map(|inner| inner.fold(outer.unwrap_or_default()))
+ }
+}
diff --git a/crates/typst/src/syntax/ast.rs b/crates/typst/src/syntax/ast.rs
new file mode 100644
index 00000000..7d5e2989
--- /dev/null
+++ b/crates/typst/src/syntax/ast.rs
@@ -0,0 +1,1994 @@
+//! A typed layer over the untyped syntax tree.
+//!
+//! The AST is rooted in the [`Markup`] node.
+
+use std::num::NonZeroUsize;
+use std::ops::Deref;
+
+use ecow::EcoString;
+use unscanny::Scanner;
+
+use super::{
+ is_id_continue, is_id_start, is_newline, split_newlines, Span, SyntaxKind, SyntaxNode,
+};
+use crate::geom::{AbsUnit, AngleUnit};
+use crate::util::NonZeroExt;
+
+/// A typed AST node.
+pub trait AstNode: Sized {
+ /// Convert a node into its typed variant.
+ fn from_untyped(node: &SyntaxNode) -> Option<Self>;
+
+ /// A reference to the underlying syntax node.
+ fn as_untyped(&self) -> &SyntaxNode;
+
+ /// The source code location.
+ fn span(&self) -> Span {
+ self.as_untyped().span()
+ }
+}
+
+macro_rules! node {
+ ($(#[$attr:meta])* $name:ident) => {
+ #[derive(Debug, Default, Clone, Hash)]
+ #[repr(transparent)]
+ $(#[$attr])*
+ pub struct $name(SyntaxNode);
+
+ impl AstNode for $name {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ if matches!(node.kind(), SyntaxKind::$name) {
+ Some(Self(node.clone()))
+ } else {
+ Option::None
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ &self.0
+ }
+ }
+ };
+}
+
+node! {
+ /// The syntactical root capable of representing a full parsed document.
+ Markup
+}
+
+impl Markup {
+ /// The expressions.
+ pub fn exprs(&self) -> impl DoubleEndedIterator<Item = Expr> + '_ {
+ let mut was_stmt = false;
+ self.0
+ .children()
+ .filter(move |node| {
+ // Ignore newline directly after statements without semicolons.
+ let kind = node.kind();
+ let keep = !was_stmt || node.kind() != SyntaxKind::Space;
+ was_stmt = kind.is_stmt();
+ keep
+ })
+ .filter_map(Expr::cast_with_space)
+ }
+}
+
+/// An expression in markup, math or code.
+#[derive(Debug, Clone, Hash)]
+pub enum Expr {
+ /// Plain text without markup.
+ Text(Text),
+ /// Whitespace in markup or math. Has at most one newline in markup, as more
+ /// indicate a paragraph break.
+ Space(Space),
+ /// A forced line break: `\`.
+ Linebreak(Linebreak),
+ /// A paragraph break, indicated by one or multiple blank lines.
+ Parbreak(Parbreak),
+ /// An escape sequence: `\#`, `\u{1F5FA}`.
+ Escape(Escape),
+ /// A shorthand for a unicode codepoint. For example, `~` for non-breaking
+ /// space or `-?` for a soft hyphen.
+ Shorthand(Shorthand),
+ /// A smart quote: `'` or `"`.
+ SmartQuote(SmartQuote),
+ /// Strong content: `*Strong*`.
+ Strong(Strong),
+ /// Emphasized content: `_Emphasized_`.
+ Emph(Emph),
+ /// Raw text with optional syntax highlighting: `` `...` ``.
+ Raw(Raw),
+ /// A hyperlink: `https://typst.org`.
+ Link(Link),
+ /// A label: `<intro>`.
+ Label(Label),
+ /// A reference: `@target`, `@target[..]`.
+ Ref(Ref),
+ /// A section heading: `= Introduction`.
+ Heading(Heading),
+ /// An item in a bullet list: `- ...`.
+ List(ListItem),
+ /// An item in an enumeration (numbered list): `+ ...` or `1. ...`.
+ Enum(EnumItem),
+ /// An item in a term list: `/ Term: Details`.
+ Term(TermItem),
+ /// A mathematical equation: `$x$`, `$ x^2 $`.
+ Equation(Equation),
+ /// The contents of a mathematical equation: `x^2 + 1`.
+ Math(Math),
+ /// An identifier in math: `pi`.
+ MathIdent(MathIdent),
+ /// An alignment point in math: `&`.
+ MathAlignPoint(MathAlignPoint),
+ /// Matched delimiters in math: `[x + y]`.
+ MathDelimited(MathDelimited),
+ /// A base with optional attachments in math: `a_1^2`.
+ MathAttach(MathAttach),
+ /// A fraction in math: `x/2`.
+ MathFrac(MathFrac),
+ /// A root in math: `√x`, `∛x` or `∜x`.
+ MathRoot(MathRoot),
+ /// An identifier: `left`.
+ Ident(Ident),
+ /// The `none` literal.
+ None(None),
+ /// The `auto` literal.
+ Auto(Auto),
+ /// A boolean: `true`, `false`.
+ Bool(Bool),
+ /// An integer: `120`.
+ Int(Int),
+ /// A floating-point number: `1.2`, `10e-4`.
+ Float(Float),
+ /// A numeric value with a unit: `12pt`, `3cm`, `2em`, `90deg`, `50%`.
+ Numeric(Numeric),
+ /// A quoted string: `"..."`.
+ Str(Str),
+ /// A code block: `{ let x = 1; x + 2 }`.
+ Code(CodeBlock),
+ /// A content block: `[*Hi* there!]`.
+ Content(ContentBlock),
+ /// A grouped expression: `(1 + 2)`.
+ Parenthesized(Parenthesized),
+ /// An array: `(1, "hi", 12cm)`.
+ Array(Array),
+ /// A dictionary: `(thickness: 3pt, pattern: dashed)`.
+ Dict(Dict),
+ /// A unary operation: `-x`.
+ Unary(Unary),
+ /// A binary operation: `a + b`.
+ Binary(Binary),
+ /// A field access: `properties.age`.
+ FieldAccess(FieldAccess),
+ /// An invocation of a function or method: `f(x, y)`.
+ FuncCall(FuncCall),
+ /// A closure: `(x, y) => z`.
+ Closure(Closure),
+ /// A let binding: `let x = 1`.
+ Let(LetBinding),
+ //// A destructuring assignment: `(x, y) = (1, 2)`.
+ DestructAssign(DestructAssignment),
+ /// A set rule: `set text(...)`.
+ Set(SetRule),
+ /// A show rule: `show heading: it => emph(it.body)`.
+ Show(ShowRule),
+ /// An if-else conditional: `if x { y } else { z }`.
+ Conditional(Conditional),
+ /// A while loop: `while x { y }`.
+ While(WhileLoop),
+ /// A for loop: `for x in y { z }`.
+ For(ForLoop),
+ /// A module import: `import "utils.typ": a, b, c`.
+ Import(ModuleImport),
+ /// A module include: `include "chapter1.typ"`.
+ Include(ModuleInclude),
+ /// A break from a loop: `break`.
+ Break(LoopBreak),
+ /// A continue in a loop: `continue`.
+ Continue(LoopContinue),
+ /// A return from a function: `return`, `return x + 1`.
+ Return(FuncReturn),
+}
+
+impl Expr {
+ fn cast_with_space(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Space => node.cast().map(Self::Space),
+ _ => Self::from_untyped(node),
+ }
+ }
+}
+
+impl AstNode for Expr {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Linebreak => node.cast().map(Self::Linebreak),
+ SyntaxKind::Parbreak => node.cast().map(Self::Parbreak),
+ SyntaxKind::Text => node.cast().map(Self::Text),
+ SyntaxKind::Escape => node.cast().map(Self::Escape),
+ SyntaxKind::Shorthand => node.cast().map(Self::Shorthand),
+ SyntaxKind::SmartQuote => node.cast().map(Self::SmartQuote),
+ SyntaxKind::Strong => node.cast().map(Self::Strong),
+ SyntaxKind::Emph => node.cast().map(Self::Emph),
+ SyntaxKind::Raw => node.cast().map(Self::Raw),
+ SyntaxKind::Link => node.cast().map(Self::Link),
+ SyntaxKind::Label => node.cast().map(Self::Label),
+ SyntaxKind::Ref => node.cast().map(Self::Ref),
+ SyntaxKind::Heading => node.cast().map(Self::Heading),
+ SyntaxKind::ListItem => node.cast().map(Self::List),
+ SyntaxKind::EnumItem => node.cast().map(Self::Enum),
+ SyntaxKind::TermItem => node.cast().map(Self::Term),
+ SyntaxKind::Equation => node.cast().map(Self::Equation),
+ SyntaxKind::Math => node.cast().map(Self::Math),
+ SyntaxKind::MathIdent => node.cast().map(Self::MathIdent),
+ SyntaxKind::MathAlignPoint => node.cast().map(Self::MathAlignPoint),
+ SyntaxKind::MathDelimited => node.cast().map(Self::MathDelimited),
+ SyntaxKind::MathAttach => node.cast().map(Self::MathAttach),
+ SyntaxKind::MathFrac => node.cast().map(Self::MathFrac),
+ SyntaxKind::MathRoot => node.cast().map(Self::MathRoot),
+ SyntaxKind::Ident => node.cast().map(Self::Ident),
+ SyntaxKind::None => node.cast().map(Self::None),
+ SyntaxKind::Auto => node.cast().map(Self::Auto),
+ SyntaxKind::Bool => node.cast().map(Self::Bool),
+ SyntaxKind::Int => node.cast().map(Self::Int),
+ SyntaxKind::Float => node.cast().map(Self::Float),
+ SyntaxKind::Numeric => node.cast().map(Self::Numeric),
+ SyntaxKind::Str => node.cast().map(Self::Str),
+ SyntaxKind::CodeBlock => node.cast().map(Self::Code),
+ SyntaxKind::ContentBlock => node.cast().map(Self::Content),
+ SyntaxKind::Parenthesized => node.cast().map(Self::Parenthesized),
+ SyntaxKind::Array => node.cast().map(Self::Array),
+ SyntaxKind::Dict => node.cast().map(Self::Dict),
+ SyntaxKind::Unary => node.cast().map(Self::Unary),
+ SyntaxKind::Binary => node.cast().map(Self::Binary),
+ SyntaxKind::FieldAccess => node.cast().map(Self::FieldAccess),
+ SyntaxKind::FuncCall => node.cast().map(Self::FuncCall),
+ SyntaxKind::Closure => node.cast().map(Self::Closure),
+ SyntaxKind::LetBinding => node.cast().map(Self::Let),
+ SyntaxKind::DestructAssignment => node.cast().map(Self::DestructAssign),
+ SyntaxKind::SetRule => node.cast().map(Self::Set),
+ SyntaxKind::ShowRule => node.cast().map(Self::Show),
+ SyntaxKind::Conditional => node.cast().map(Self::Conditional),
+ SyntaxKind::WhileLoop => node.cast().map(Self::While),
+ SyntaxKind::ForLoop => node.cast().map(Self::For),
+ SyntaxKind::ModuleImport => node.cast().map(Self::Import),
+ SyntaxKind::ModuleInclude => node.cast().map(Self::Include),
+ SyntaxKind::LoopBreak => node.cast().map(Self::Break),
+ SyntaxKind::LoopContinue => node.cast().map(Self::Continue),
+ SyntaxKind::FuncReturn => node.cast().map(Self::Return),
+ _ => Option::None,
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ match self {
+ Self::Text(v) => v.as_untyped(),
+ Self::Space(v) => v.as_untyped(),
+ Self::Linebreak(v) => v.as_untyped(),
+ Self::Parbreak(v) => v.as_untyped(),
+ Self::Escape(v) => v.as_untyped(),
+ Self::Shorthand(v) => v.as_untyped(),
+ Self::SmartQuote(v) => v.as_untyped(),
+ Self::Strong(v) => v.as_untyped(),
+ Self::Emph(v) => v.as_untyped(),
+ Self::Raw(v) => v.as_untyped(),
+ Self::Link(v) => v.as_untyped(),
+ Self::Label(v) => v.as_untyped(),
+ Self::Ref(v) => v.as_untyped(),
+ Self::Heading(v) => v.as_untyped(),
+ Self::List(v) => v.as_untyped(),
+ Self::Enum(v) => v.as_untyped(),
+ Self::Term(v) => v.as_untyped(),
+ Self::Equation(v) => v.as_untyped(),
+ Self::Math(v) => v.as_untyped(),
+ Self::MathIdent(v) => v.as_untyped(),
+ Self::MathAlignPoint(v) => v.as_untyped(),
+ Self::MathDelimited(v) => v.as_untyped(),
+ Self::MathAttach(v) => v.as_untyped(),
+ Self::MathFrac(v) => v.as_untyped(),
+ Self::MathRoot(v) => v.as_untyped(),
+ Self::Ident(v) => v.as_untyped(),
+ Self::None(v) => v.as_untyped(),
+ Self::Auto(v) => v.as_untyped(),
+ Self::Bool(v) => v.as_untyped(),
+ Self::Int(v) => v.as_untyped(),
+ Self::Float(v) => v.as_untyped(),
+ Self::Numeric(v) => v.as_untyped(),
+ Self::Str(v) => v.as_untyped(),
+ Self::Code(v) => v.as_untyped(),
+ Self::Content(v) => v.as_untyped(),
+ Self::Array(v) => v.as_untyped(),
+ Self::Dict(v) => v.as_untyped(),
+ Self::Parenthesized(v) => v.as_untyped(),
+ Self::Unary(v) => v.as_untyped(),
+ Self::Binary(v) => v.as_untyped(),
+ Self::FieldAccess(v) => v.as_untyped(),
+ Self::FuncCall(v) => v.as_untyped(),
+ Self::Closure(v) => v.as_untyped(),
+ Self::Let(v) => v.as_untyped(),
+ Self::DestructAssign(v) => v.as_untyped(),
+ Self::Set(v) => v.as_untyped(),
+ Self::Show(v) => v.as_untyped(),
+ Self::Conditional(v) => v.as_untyped(),
+ Self::While(v) => v.as_untyped(),
+ Self::For(v) => v.as_untyped(),
+ Self::Import(v) => v.as_untyped(),
+ Self::Include(v) => v.as_untyped(),
+ Self::Break(v) => v.as_untyped(),
+ Self::Continue(v) => v.as_untyped(),
+ Self::Return(v) => v.as_untyped(),
+ }
+ }
+}
+
+impl Expr {
+ /// Can this expression be embedded into markup with a hashtag?
+ pub fn hashtag(&self) -> bool {
+ matches!(
+ self,
+ Self::Ident(_)
+ | Self::None(_)
+ | Self::Auto(_)
+ | Self::Bool(_)
+ | Self::Int(_)
+ | Self::Float(_)
+ | Self::Numeric(_)
+ | Self::Str(_)
+ | Self::Code(_)
+ | Self::Content(_)
+ | Self::Array(_)
+ | Self::Dict(_)
+ | Self::Parenthesized(_)
+ | Self::FieldAccess(_)
+ | Self::FuncCall(_)
+ | Self::Let(_)
+ | Self::Set(_)
+ | Self::Show(_)
+ | Self::Conditional(_)
+ | Self::While(_)
+ | Self::For(_)
+ | Self::Import(_)
+ | Self::Include(_)
+ | Self::Break(_)
+ | Self::Continue(_)
+ | Self::Return(_)
+ )
+ }
+
+ /// Is this a literal?
+ pub fn is_literal(&self) -> bool {
+ matches!(
+ self,
+ Self::None(_)
+ | Self::Auto(_)
+ | Self::Bool(_)
+ | Self::Int(_)
+ | Self::Float(_)
+ | Self::Numeric(_)
+ | Self::Str(_)
+ )
+ }
+}
+
+impl Default for Expr {
+ fn default() -> Self {
+ Expr::Space(Space::default())
+ }
+}
+
+node! {
+ /// Plain text without markup.
+ Text
+}
+
+impl Text {
+ /// Get the text.
+ pub fn get(&self) -> &EcoString {
+ self.0.text()
+ }
+}
+
+node! {
+ /// Whitespace in markup or math. Has at most one newline in markup, as more
+ /// indicate a paragraph break.
+ Space
+}
+
+node! {
+ /// A forced line break: `\`.
+ Linebreak
+}
+
+node! {
+ /// A paragraph break, indicated by one or multiple blank lines.
+ Parbreak
+}
+
+node! {
+ /// An escape sequence: `\#`, `\u{1F5FA}`.
+ Escape
+}
+
+impl Escape {
+ /// Get the escaped character.
+ pub fn get(&self) -> char {
+ let mut s = Scanner::new(self.0.text());
+ s.expect('\\');
+ if s.eat_if("u{") {
+ let hex = s.eat_while(char::is_ascii_hexdigit);
+ u32::from_str_radix(hex, 16)
+ .ok()
+ .and_then(std::char::from_u32)
+ .unwrap_or_default()
+ } else {
+ s.eat().unwrap_or_default()
+ }
+ }
+}
+
+node! {
+ /// A shorthand for a unicode codepoint. For example, `~` for a non-breaking
+ /// space or `-?` for a soft hyphen.
+ Shorthand
+}
+
+impl Shorthand {
+ /// A list of all shorthands.
+ pub const LIST: &[(&'static str, char)] = &[
+ // Both.
+ ("...", '…'),
+ // Text only.
+ ("~", '\u{00A0}'),
+ ("--", '\u{2013}'),
+ ("---", '\u{2014}'),
+ ("-?", '\u{00AD}'),
+ // Math only.
+ ("-", '\u{2212}'),
+ ("'", '′'),
+ ("*", '∗'),
+ ("!=", '≠'),
+ (":=", '≔'),
+ ("::=", '⩴'),
+ ("=:", '≕'),
+ ("<<", '≪'),
+ ("<<<", '⋘'),
+ (">>", '≫'),
+ (">>>", '⋙'),
+ ("<=", '≤'),
+ (">=", '≥'),
+ ("->", '→'),
+ ("-->", '⟶'),
+ ("|->", '↦'),
+ (">->", '↣'),
+ ("->>", '↠'),
+ ("<-", '←'),
+ ("<--", '⟵'),
+ ("<-<", '↢'),
+ ("<<-", '↞'),
+ ("<->", '↔'),
+ ("<-->", '⟷'),
+ ("~>", '⇝'),
+ ("~~>", '⟿'),
+ ("<~", '⇜'),
+ ("<~~", '⬳'),
+ ("=>", '⇒'),
+ ("|=>", '⤇'),
+ ("==>", '⟹'),
+ ("<==", '⟸'),
+ ("<=>", '⇔'),
+ ("<==>", '⟺'),
+ ("[|", '⟦'),
+ ("|]", '⟧'),
+ ("||", '‖'),
+ ];
+
+ /// Get the shorthanded character.
+ pub fn get(&self) -> char {
+ let text = self.0.text();
+ Self::LIST
+ .iter()
+ .find(|&&(s, _)| s == text)
+ .map_or_else(char::default, |&(_, c)| c)
+ }
+}
+
+node! {
+ /// A smart quote: `'` or `"`.
+ SmartQuote
+}
+
+impl SmartQuote {
+ /// Whether this is a double quote.
+ pub fn double(&self) -> bool {
+ self.0.text() == "\""
+ }
+}
+
+node! {
+ /// Strong content: `*Strong*`.
+ Strong
+}
+
+impl Strong {
+ /// The contents of the strong node.
+ pub fn body(&self) -> Markup {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// Emphasized content: `_Emphasized_`.
+ Emph
+}
+
+impl Emph {
+ /// The contents of the emphasis node.
+ pub fn body(&self) -> Markup {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// Raw text with optional syntax highlighting: `` `...` ``.
+ Raw
+}
+
+impl Raw {
+ /// The trimmed raw text.
+ pub fn text(&self) -> EcoString {
+ let mut text = self.0.text().as_str();
+ let blocky = text.starts_with("```");
+ text = text.trim_matches('`');
+
+ // Trim tag, one space at the start, and one space at the end if the
+ // last non-whitespace char is a backtick.
+ if blocky {
+ let mut s = Scanner::new(text);
+ if s.eat_if(is_id_start) {
+ s.eat_while(is_id_continue);
+ }
+ text = s.after();
+ text = text.strip_prefix(' ').unwrap_or(text);
+ if text.trim_end().ends_with('`') {
+ text = text.strip_suffix(' ').unwrap_or(text);
+ }
+ }
+
+ // Split into lines.
+ let mut lines = split_newlines(text);
+
+ if blocky {
+ let dedent = lines
+ .iter()
+ .skip(1)
+ .map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
+ .min()
+ .unwrap_or(0);
+
+ // Dedent based on column, but not for the first line.
+ for line in lines.iter_mut().skip(1) {
+ let offset = line.chars().take(dedent).map(char::len_utf8).sum();
+ *line = &line[offset..];
+ }
+
+ let is_whitespace = |line: &&str| line.chars().all(char::is_whitespace);
+
+ // Trims a sequence of whitespace followed by a newline at the start.
+ if lines.first().map_or(false, is_whitespace) {
+ lines.remove(0);
+ }
+
+ // Trims a newline followed by a sequence of whitespace at the end.
+ if lines.last().map_or(false, is_whitespace) {
+ lines.pop();
+ }
+ }
+
+ lines.join("\n").into()
+ }
+
+ /// An optional identifier specifying the language to syntax-highlight in.
+ pub fn lang(&self) -> Option<&str> {
+ let text = self.0.text();
+
+ // Only blocky literals are supposed to contain a language.
+ if !text.starts_with("```") {
+ return Option::None;
+ }
+
+ let inner = text.trim_start_matches('`');
+ let mut s = Scanner::new(inner);
+ s.eat_if(is_id_start).then(|| {
+ s.eat_while(is_id_continue);
+ s.before()
+ })
+ }
+
+ /// Whether the raw text should be displayed in a separate block.
+ pub fn block(&self) -> bool {
+ let text = self.0.text();
+ text.starts_with("```") && text.chars().any(is_newline)
+ }
+}
+
+node! {
+ /// A hyperlink: `https://typst.org`.
+ Link
+}
+
+impl Link {
+ /// Get the URL.
+ pub fn get(&self) -> &EcoString {
+ self.0.text()
+ }
+}
+
+node! {
+ /// A label: `<intro>`.
+ Label
+}
+
+impl Label {
+ /// Get the label's text.
+ pub fn get(&self) -> &str {
+ self.0.text().trim_start_matches('<').trim_end_matches('>')
+ }
+}
+
+node! {
+ /// A reference: `@target`, `@target[..]`.
+ Ref
+}
+
+impl Ref {
+ /// Get the target.
+ pub fn target(&self) -> &str {
+ self.0
+ .children()
+ .find(|node| node.kind() == SyntaxKind::RefMarker)
+ .map(|node| node.text().trim_start_matches('@'))
+ .unwrap_or_default()
+ }
+
+ /// Get the supplement.
+ pub fn supplement(&self) -> Option<ContentBlock> {
+ self.0.cast_last_match()
+ }
+}
+
+node! {
+ /// A section heading: `= Introduction`.
+ Heading
+}
+
+impl Heading {
+ /// The contents of the heading.
+ pub fn body(&self) -> Markup {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The section depth (number of equals signs).
+ pub fn level(&self) -> NonZeroUsize {
+ self.0
+ .children()
+ .find(|node| node.kind() == SyntaxKind::HeadingMarker)
+ .and_then(|node| node.len().try_into().ok())
+ .unwrap_or(NonZeroUsize::ONE)
+ }
+}
+
+node! {
+ /// An item in a bullet list: `- ...`.
+ ListItem
+}
+
+impl ListItem {
+ /// The contents of the list item.
+ pub fn body(&self) -> Markup {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// An item in an enumeration (numbered list): `+ ...` or `1. ...`.
+ EnumItem
+}
+
+impl EnumItem {
+ /// The explicit numbering, if any: `23.`.
+ pub fn number(&self) -> Option<usize> {
+ self.0.children().find_map(|node| match node.kind() {
+ SyntaxKind::EnumMarker => node.text().trim_end_matches('.').parse().ok(),
+ _ => Option::None,
+ })
+ }
+
+ /// The contents of the list item.
+ pub fn body(&self) -> Markup {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// An item in a term list: `/ Term: Details`.
+ TermItem
+}
+
+impl TermItem {
+ /// The term described by the item.
+ pub fn term(&self) -> Markup {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The description of the term.
+ pub fn description(&self) -> Markup {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A mathemathical equation: `$x$`, `$ x^2 $`.
+ Equation
+}
+
+impl Equation {
+ /// The contained math.
+ pub fn body(&self) -> Math {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// Whether the equation should be displayed as a separate block.
+ pub fn block(&self) -> bool {
+ let is_space = |node: Option<&SyntaxNode>| {
+ node.map(SyntaxNode::kind) == Some(SyntaxKind::Space)
+ };
+ is_space(self.0.children().nth(1)) && is_space(self.0.children().nth_back(1))
+ }
+}
+
+node! {
+ /// The contents of a mathematical equation: `x^2 + 1`.
+ Math
+}
+
+impl Math {
+ /// The expressions the mathematical content consists of.
+ pub fn exprs(&self) -> impl DoubleEndedIterator<Item = Expr> + '_ {
+ self.0.children().filter_map(Expr::cast_with_space)
+ }
+}
+
+node! {
+ /// An identifier in math: `pi`.
+ MathIdent
+}
+
+impl MathIdent {
+ /// Get the identifier.
+ pub fn get(&self) -> &EcoString {
+ self.0.text()
+ }
+
+ /// Take out the contained identifier.
+ pub fn take(self) -> EcoString {
+ self.0.into_text()
+ }
+
+ /// Get the identifier as a string slice.
+ pub fn as_str(&self) -> &str {
+ self.get()
+ }
+}
+
+impl Deref for MathIdent {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ self.as_str()
+ }
+}
+
+node! {
+ /// An alignment point in math: `&`.
+ MathAlignPoint
+}
+
+node! {
+ /// Matched delimiters in math: `[x + y]`.
+ MathDelimited
+}
+
+impl MathDelimited {
+ /// The opening delimiter.
+ pub fn open(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The contents, including the delimiters.
+ pub fn body(&self) -> Math {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The closing delimiter.
+ pub fn close(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A base with optional attachments in math: `a_1^2`.
+ MathAttach
+}
+
+impl MathAttach {
+ /// The base, to which things are attached.
+ pub fn base(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The bottom attachment.
+ pub fn bottom(&self) -> Option<Expr> {
+ self.0
+ .children()
+ .skip_while(|node| !matches!(node.kind(), SyntaxKind::Underscore))
+ .find_map(SyntaxNode::cast)
+ }
+
+ /// The top attachment.
+ pub fn top(&self) -> Option<Expr> {
+ self.0
+ .children()
+ .skip_while(|node| !matches!(node.kind(), SyntaxKind::Hat))
+ .find_map(SyntaxNode::cast)
+ }
+}
+
+node! {
+ /// A fraction in math: `x/2`
+ MathFrac
+}
+
+impl MathFrac {
+ /// The numerator.
+ pub fn num(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The denominator.
+ pub fn denom(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A root in math: `√x`, `∛x` or `∜x`.
+ MathRoot
+}
+
+impl MathRoot {
+ /// The index of the root.
+ pub fn index(&self) -> Option<usize> {
+ match self.0.children().next().map(|node| node.text().as_str()) {
+ Some("∜") => Some(4),
+ Some("∛") => Some(3),
+ Some("√") => Option::None,
+ _ => Option::None,
+ }
+ }
+
+ /// The radicand.
+ pub fn radicand(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// An identifier: `it`.
+ Ident
+}
+
+impl Ident {
+ /// Get the identifier.
+ pub fn get(&self) -> &EcoString {
+ self.0.text()
+ }
+
+ /// Take out the contained identifier.
+ pub fn take(self) -> EcoString {
+ self.0.into_text()
+ }
+
+ /// Get the identifier as a string slice.
+ pub fn as_str(&self) -> &str {
+ self.get()
+ }
+}
+
+impl Deref for Ident {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ self.as_str()
+ }
+}
+
+node! {
+ /// The `none` literal.
+ None
+}
+
+node! {
+ /// The `auto` literal.
+ Auto
+}
+
+node! {
+ /// A boolean: `true`, `false`.
+ Bool
+}
+
+impl Bool {
+ /// Get the boolean value.
+ pub fn get(&self) -> bool {
+ self.0.text() == "true"
+ }
+}
+
+node! {
+ /// An integer: `120`.
+ Int
+}
+
+impl Int {
+ /// Get the integer value.
+ pub fn get(&self) -> i64 {
+ let text = self.0.text();
+ if let Some(rest) = text.strip_prefix("0x") {
+ i64::from_str_radix(rest, 16)
+ } else if let Some(rest) = text.strip_prefix("0o") {
+ i64::from_str_radix(rest, 8)
+ } else if let Some(rest) = text.strip_prefix("0b") {
+ i64::from_str_radix(rest, 2)
+ } else {
+ text.parse()
+ }
+ .unwrap_or_default()
+ }
+}
+
+node! {
+ /// A floating-point number: `1.2`, `10e-4`.
+ Float
+}
+
+impl Float {
+ /// Get the floating-point value.
+ pub fn get(&self) -> f64 {
+ self.0.text().parse().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A numeric value with a unit: `12pt`, `3cm`, `2em`, `90deg`, `50%`.
+ Numeric
+}
+
+impl Numeric {
+ /// Get the numeric value and unit.
+ pub fn get(&self) -> (f64, Unit) {
+ let text = self.0.text();
+ let count = text
+ .chars()
+ .rev()
+ .take_while(|c| matches!(c, 'a'..='z' | '%'))
+ .count();
+
+ let split = text.len() - count;
+ let value = text[..split].parse().unwrap_or_default();
+ let unit = match &text[split..] {
+ "pt" => Unit::Length(AbsUnit::Pt),
+ "mm" => Unit::Length(AbsUnit::Mm),
+ "cm" => Unit::Length(AbsUnit::Cm),
+ "in" => Unit::Length(AbsUnit::In),
+ "deg" => Unit::Angle(AngleUnit::Deg),
+ "rad" => Unit::Angle(AngleUnit::Rad),
+ "em" => Unit::Em,
+ "fr" => Unit::Fr,
+ "%" => Unit::Percent,
+ _ => Unit::Percent,
+ };
+
+ (value, unit)
+ }
+}
+
+/// Unit of a numeric value.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Unit {
+ /// An absolute length unit.
+ Length(AbsUnit),
+ /// An angular unit.
+ Angle(AngleUnit),
+ /// Font-relative: `1em` is the same as the font size.
+ Em,
+ /// Fractions: `fr`.
+ Fr,
+ /// Percentage: `%`.
+ Percent,
+}
+
+node! {
+ /// A quoted string: `"..."`.
+ Str
+}
+
+impl Str {
+ /// Get the string value with resolved escape sequences.
+ pub fn get(&self) -> EcoString {
+ let text = self.0.text();
+ let unquoted = &text[1..text.len() - 1];
+ if !unquoted.contains('\\') {
+ return unquoted.into();
+ }
+
+ let mut out = EcoString::with_capacity(unquoted.len());
+ let mut s = Scanner::new(unquoted);
+
+ while let Some(c) = s.eat() {
+ if c != '\\' {
+ out.push(c);
+ continue;
+ }
+
+ let start = s.locate(-1);
+ match s.eat() {
+ Some('\\') => out.push('\\'),
+ Some('"') => out.push('"'),
+ Some('n') => out.push('\n'),
+ Some('r') => out.push('\r'),
+ Some('t') => out.push('\t'),
+ Some('u') if s.eat_if('{') => {
+ let sequence = s.eat_while(char::is_ascii_hexdigit);
+ s.eat_if('}');
+
+ match u32::from_str_radix(sequence, 16)
+ .ok()
+ .and_then(std::char::from_u32)
+ {
+ Some(c) => out.push(c),
+ Option::None => out.push_str(s.from(start)),
+ }
+ }
+ _ => out.push_str(s.from(start)),
+ }
+ }
+
+ out
+ }
+}
+
+node! {
+ /// A code block: `{ let x = 1; x + 2 }`.
+ CodeBlock
+}
+
+impl CodeBlock {
+ /// The contained code.
+ pub fn body(&self) -> Code {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// Code.
+ Code
+}
+
+impl Code {
+ /// The list of expressions contained in the code.
+ pub fn exprs(&self) -> impl DoubleEndedIterator<Item = Expr> + '_ {
+ self.0.children().filter_map(SyntaxNode::cast)
+ }
+}
+
+node! {
+ /// A content block: `[*Hi* there!]`.
+ ContentBlock
+}
+
+impl ContentBlock {
+ /// The contained markup.
+ pub fn body(&self) -> Markup {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A grouped expression: `(1 + 2)`.
+ Parenthesized
+}
+
+impl Parenthesized {
+ /// The wrapped expression.
+ pub fn expr(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// An array: `(1, "hi", 12cm)`.
+ Array
+}
+
+impl Array {
+ /// The array's items.
+ pub fn items(&self) -> impl DoubleEndedIterator<Item = ArrayItem> + '_ {
+ self.0.children().filter_map(SyntaxNode::cast)
+ }
+}
+
+/// An item in an array.
+#[derive(Debug, Clone, Hash)]
+pub enum ArrayItem {
+ /// A bare expression: `12`.
+ Pos(Expr),
+ /// A spread expression: `..things`.
+ Spread(Expr),
+}
+
+impl AstNode for ArrayItem {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Spread => node.cast_first_match().map(Self::Spread),
+ _ => node.cast().map(Self::Pos),
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ match self {
+ Self::Pos(v) => v.as_untyped(),
+ Self::Spread(v) => v.as_untyped(),
+ }
+ }
+}
+
+node! {
+ /// A dictionary: `(thickness: 3pt, pattern: dashed)`.
+ Dict
+}
+
+impl Dict {
+ /// The dictionary's items.
+ pub fn items(&self) -> impl DoubleEndedIterator<Item = DictItem> + '_ {
+ self.0.children().filter_map(SyntaxNode::cast)
+ }
+}
+
+/// An item in an dictionary expression.
+#[derive(Debug, Clone, Hash)]
+pub enum DictItem {
+ /// A named pair: `thickness: 3pt`.
+ Named(Named),
+ /// A keyed pair: `"spacy key": true`.
+ Keyed(Keyed),
+ /// A spread expression: `..things`.
+ Spread(Expr),
+}
+
+impl AstNode for DictItem {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Named => node.cast().map(Self::Named),
+ SyntaxKind::Keyed => node.cast().map(Self::Keyed),
+ SyntaxKind::Spread => node.cast_first_match().map(Self::Spread),
+ _ => Option::None,
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ match self {
+ Self::Named(v) => v.as_untyped(),
+ Self::Keyed(v) => v.as_untyped(),
+ Self::Spread(v) => v.as_untyped(),
+ }
+ }
+}
+
+node! {
+ /// A named pair: `thickness: 3pt`.
+ Named
+}
+
+impl Named {
+ /// The name: `thickness`.
+ pub fn name(&self) -> Ident {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The right-hand side of the pair: `3pt`.
+ pub fn expr(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+
+ /// The right-hand side of the pair as an identifier.
+ pub fn expr_ident(&self) -> Option<Ident> {
+ self.0.cast_last_match()
+ }
+}
+
+node! {
+ /// A keyed pair: `"spacy key": true`.
+ Keyed
+}
+
+impl Keyed {
+ /// The key: `"spacy key"`.
+ pub fn key(&self) -> Str {
+ self.0
+ .children()
+ .find_map(|node| node.cast::<Str>())
+ .unwrap_or_default()
+ }
+
+ /// The right-hand side of the pair: `true`.
+ pub fn expr(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A unary operation: `-x`.
+ Unary
+}
+
+impl Unary {
+ /// The operator: `-`.
+ pub fn op(&self) -> UnOp {
+ self.0
+ .children()
+ .find_map(|node| UnOp::from_kind(node.kind()))
+ .unwrap_or(UnOp::Pos)
+ }
+
+ /// The expression to operate on: `x`.
+ pub fn expr(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+/// A unary operator.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum UnOp {
+ /// The plus operator: `+`.
+ Pos,
+ /// The negation operator: `-`.
+ Neg,
+ /// The boolean `not`.
+ Not,
+}
+
+impl UnOp {
+ /// Try to convert the token into a unary operation.
+ pub fn from_kind(token: SyntaxKind) -> Option<Self> {
+ Some(match token {
+ SyntaxKind::Plus => Self::Pos,
+ SyntaxKind::Minus => Self::Neg,
+ SyntaxKind::Not => Self::Not,
+ _ => return Option::None,
+ })
+ }
+
+ /// The precedence of this operator.
+ pub fn precedence(self) -> usize {
+ match self {
+ Self::Pos | Self::Neg => 7,
+ Self::Not => 4,
+ }
+ }
+
+ /// The string representation of this operation.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Pos => "+",
+ Self::Neg => "-",
+ Self::Not => "not",
+ }
+ }
+}
+
+node! {
+ /// A binary operation: `a + b`.
+ Binary
+}
+
+impl Binary {
+ /// The binary operator: `+`.
+ pub fn op(&self) -> BinOp {
+ let mut not = false;
+ self.0
+ .children()
+ .find_map(|node| match node.kind() {
+ SyntaxKind::Not => {
+ not = true;
+ Option::None
+ }
+ SyntaxKind::In if not => Some(BinOp::NotIn),
+ _ => BinOp::from_kind(node.kind()),
+ })
+ .unwrap_or(BinOp::Add)
+ }
+
+ /// The left-hand side of the operation: `a`.
+ pub fn lhs(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The right-hand side of the operation: `b`.
+ pub fn rhs(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+/// A binary operator.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum BinOp {
+ /// The addition operator: `+`.
+ Add,
+ /// The subtraction operator: `-`.
+ Sub,
+ /// The multiplication operator: `*`.
+ Mul,
+ /// The division operator: `/`.
+ Div,
+ /// The short-circuiting boolean `and`.
+ And,
+ /// The short-circuiting boolean `or`.
+ Or,
+ /// The equality operator: `==`.
+ Eq,
+ /// The inequality operator: `!=`.
+ Neq,
+ /// The less-than operator: `<`.
+ Lt,
+ /// The less-than or equal operator: `<=`.
+ Leq,
+ /// The greater-than operator: `>`.
+ Gt,
+ /// The greater-than or equal operator: `>=`.
+ Geq,
+ /// The assignment operator: `=`.
+ Assign,
+ /// The containment operator: `in`.
+ In,
+ /// The inversed containment operator: `not in`.
+ NotIn,
+ /// The add-assign operator: `+=`.
+ AddAssign,
+ /// The subtract-assign oeprator: `-=`.
+ SubAssign,
+ /// The multiply-assign operator: `*=`.
+ MulAssign,
+ /// The divide-assign operator: `/=`.
+ DivAssign,
+}
+
+impl BinOp {
+ /// Try to convert the token into a binary operation.
+ pub fn from_kind(token: SyntaxKind) -> Option<Self> {
+ Some(match token {
+ SyntaxKind::Plus => Self::Add,
+ SyntaxKind::Minus => Self::Sub,
+ SyntaxKind::Star => Self::Mul,
+ SyntaxKind::Slash => Self::Div,
+ SyntaxKind::And => Self::And,
+ SyntaxKind::Or => Self::Or,
+ SyntaxKind::EqEq => Self::Eq,
+ SyntaxKind::ExclEq => Self::Neq,
+ SyntaxKind::Lt => Self::Lt,
+ SyntaxKind::LtEq => Self::Leq,
+ SyntaxKind::Gt => Self::Gt,
+ SyntaxKind::GtEq => Self::Geq,
+ SyntaxKind::Eq => Self::Assign,
+ SyntaxKind::In => Self::In,
+ SyntaxKind::PlusEq => Self::AddAssign,
+ SyntaxKind::HyphEq => Self::SubAssign,
+ SyntaxKind::StarEq => Self::MulAssign,
+ SyntaxKind::SlashEq => Self::DivAssign,
+ _ => return Option::None,
+ })
+ }
+
+ /// The precedence of this operator.
+ pub fn precedence(self) -> usize {
+ match self {
+ Self::Mul => 6,
+ Self::Div => 6,
+ Self::Add => 5,
+ Self::Sub => 5,
+ Self::Eq => 4,
+ Self::Neq => 4,
+ Self::Lt => 4,
+ Self::Leq => 4,
+ Self::Gt => 4,
+ Self::Geq => 4,
+ Self::In => 4,
+ Self::NotIn => 4,
+ Self::And => 3,
+ Self::Or => 2,
+ Self::Assign => 1,
+ Self::AddAssign => 1,
+ Self::SubAssign => 1,
+ Self::MulAssign => 1,
+ Self::DivAssign => 1,
+ }
+ }
+
+ /// The associativity of this operator.
+ pub fn assoc(self) -> Assoc {
+ match self {
+ Self::Add => Assoc::Left,
+ Self::Sub => Assoc::Left,
+ Self::Mul => Assoc::Left,
+ Self::Div => Assoc::Left,
+ Self::And => Assoc::Left,
+ Self::Or => Assoc::Left,
+ Self::Eq => Assoc::Left,
+ Self::Neq => Assoc::Left,
+ Self::Lt => Assoc::Left,
+ Self::Leq => Assoc::Left,
+ Self::Gt => Assoc::Left,
+ Self::Geq => Assoc::Left,
+ Self::In => Assoc::Left,
+ Self::NotIn => Assoc::Left,
+ Self::Assign => Assoc::Right,
+ Self::AddAssign => Assoc::Right,
+ Self::SubAssign => Assoc::Right,
+ Self::MulAssign => Assoc::Right,
+ Self::DivAssign => Assoc::Right,
+ }
+ }
+
+ /// The string representation of this operation.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Add => "+",
+ Self::Sub => "-",
+ Self::Mul => "*",
+ Self::Div => "/",
+ Self::And => "and",
+ Self::Or => "or",
+ Self::Eq => "==",
+ Self::Neq => "!=",
+ Self::Lt => "<",
+ Self::Leq => "<=",
+ Self::Gt => ">",
+ Self::Geq => ">=",
+ Self::In => "in",
+ Self::NotIn => "not in",
+ Self::Assign => "=",
+ Self::AddAssign => "+=",
+ Self::SubAssign => "-=",
+ Self::MulAssign => "*=",
+ Self::DivAssign => "/=",
+ }
+ }
+}
+
+/// The associativity of a binary operator.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Assoc {
+ /// Left-associative: `a + b + c` is equivalent to `(a + b) + c`.
+ Left,
+ /// Right-associative: `a = b = c` is equivalent to `a = (b = c)`.
+ Right,
+}
+
+node! {
+ /// A field access: `properties.age`.
+ FieldAccess
+}
+
+impl FieldAccess {
+ /// The expression to access the field on.
+ pub fn target(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The name of the field.
+ pub fn field(&self) -> Ident {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// An invocation of a function or method: `f(x, y)`.
+ FuncCall
+}
+
+impl FuncCall {
+ /// The function to call.
+ pub fn callee(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The arguments to the function.
+ pub fn args(&self) -> Args {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A function call's argument list: `(12pt, y)`.
+ Args
+}
+
+impl Args {
+ /// The positional and named arguments.
+ pub fn items(&self) -> impl DoubleEndedIterator<Item = Arg> + '_ {
+ self.0.children().filter_map(SyntaxNode::cast)
+ }
+}
+
+/// An argument to a function call.
+#[derive(Debug, Clone, Hash)]
+pub enum Arg {
+ /// A positional argument: `12`.
+ Pos(Expr),
+ /// A named argument: `draw: false`.
+ Named(Named),
+ /// A spread argument: `..things`.
+ Spread(Expr),
+}
+
+impl AstNode for Arg {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Named => node.cast().map(Self::Named),
+ SyntaxKind::Spread => node.cast_first_match().map(Self::Spread),
+ _ => node.cast().map(Self::Pos),
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ match self {
+ Self::Pos(v) => v.as_untyped(),
+ Self::Named(v) => v.as_untyped(),
+ Self::Spread(v) => v.as_untyped(),
+ }
+ }
+}
+
+node! {
+ /// A closure: `(x, y) => z`.
+ Closure
+}
+
+impl Closure {
+ /// The name of the closure.
+ ///
+ /// This only exists if you use the function syntax sugar: `let f(x) = y`.
+ pub fn name(&self) -> Option<Ident> {
+ self.0.children().next()?.cast()
+ }
+
+ /// The parameter bindings.
+ pub fn params(&self) -> Params {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The body of the closure.
+ pub fn body(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A closure's parameters: `(x, y)`.
+ Params
+}
+
+impl Params {
+ /// The parameter bindings.
+ pub fn children(&self) -> impl DoubleEndedIterator<Item = Param> + '_ {
+ self.0.children().filter_map(SyntaxNode::cast)
+ }
+}
+
+node! {
+ /// A spread: `..x` or `..x.at(0)`.
+ Spread
+}
+
+impl Spread {
+ /// Try to get an identifier.
+ pub fn name(&self) -> Option<Ident> {
+ self.0.cast_first_match()
+ }
+
+ /// Try to get an expression.
+ pub fn expr(&self) -> Option<Expr> {
+ self.0.cast_first_match()
+ }
+}
+
+node! {
+ /// An underscore: `_`
+ Underscore
+}
+
+/// A parameter to a closure.
+#[derive(Debug, Clone, Hash)]
+pub enum Param {
+ /// A positional parameter: `x`.
+ Pos(Pattern),
+ /// A named parameter with a default value: `draw: false`.
+ Named(Named),
+ /// An argument sink: `..args`.
+ Sink(Spread),
+}
+
+impl AstNode for Param {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Named => node.cast().map(Self::Named),
+ SyntaxKind::Spread => node.cast().map(Self::Sink),
+ _ => node.cast().map(Self::Pos),
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ match self {
+ Self::Pos(v) => v.as_untyped(),
+ Self::Named(v) => v.as_untyped(),
+ Self::Sink(v) => v.as_untyped(),
+ }
+ }
+}
+
+node! {
+ /// A destructuring pattern: `x` or `(x, _, ..y)`.
+ Destructuring
+}
+
+impl Destructuring {
+ /// The bindings of the destructuring.
+ pub fn bindings(&self) -> impl Iterator<Item = DestructuringKind> + '_ {
+ self.0.children().filter_map(SyntaxNode::cast)
+ }
+
+ // Returns a list of all identifiers in the pattern.
+ pub fn idents(&self) -> impl Iterator<Item = Ident> + '_ {
+ self.bindings().filter_map(|binding| match binding {
+ DestructuringKind::Normal(Expr::Ident(ident)) => Some(ident),
+ DestructuringKind::Sink(spread) => spread.name(),
+ DestructuringKind::Named(named) => named.expr_ident(),
+ _ => Option::None,
+ })
+ }
+}
+
+/// The kind of an element in a destructuring pattern.
+#[derive(Debug, Clone, Hash)]
+pub enum DestructuringKind {
+ /// An expression: `x`.
+ Normal(Expr),
+ /// An argument sink: `..y`.
+ Sink(Spread),
+ /// Named arguments: `x: 1`.
+ Named(Named),
+ /// A placeholder: `_`.
+ Placeholder(Underscore),
+}
+
+impl AstNode for DestructuringKind {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Named => node.cast().map(Self::Named),
+ SyntaxKind::Spread => node.cast().map(Self::Sink),
+ SyntaxKind::Underscore => node.cast().map(Self::Placeholder),
+ _ => node.cast().map(Self::Normal),
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ match self {
+ Self::Normal(v) => v.as_untyped(),
+ Self::Named(v) => v.as_untyped(),
+ Self::Sink(v) => v.as_untyped(),
+ Self::Placeholder(v) => v.as_untyped(),
+ }
+ }
+}
+
+/// The kind of a pattern.
+#[derive(Debug, Clone, Hash)]
+pub enum Pattern {
+ /// A single expression: `x`.
+ Normal(Expr),
+ /// A placeholder: `_`.
+ Placeholder(Underscore),
+ /// A destructuring pattern: `(x, _, ..y)`.
+ Destructuring(Destructuring),
+}
+
+impl AstNode for Pattern {
+ fn from_untyped(node: &SyntaxNode) -> Option<Self> {
+ match node.kind() {
+ SyntaxKind::Destructuring => node.cast().map(Self::Destructuring),
+ SyntaxKind::Underscore => node.cast().map(Self::Placeholder),
+ _ => node.cast().map(Self::Normal),
+ }
+ }
+
+ fn as_untyped(&self) -> &SyntaxNode {
+ match self {
+ Self::Normal(v) => v.as_untyped(),
+ Self::Destructuring(v) => v.as_untyped(),
+ Self::Placeholder(v) => v.as_untyped(),
+ }
+ }
+}
+
+impl Pattern {
+ // Returns a list of all identifiers in the pattern.
+ pub fn idents(&self) -> Vec<Ident> {
+ match self {
+ Pattern::Normal(Expr::Ident(ident)) => vec![ident.clone()],
+ Pattern::Destructuring(destruct) => destruct.idents().collect(),
+ _ => vec![],
+ }
+ }
+}
+
+impl Default for Pattern {
+ fn default() -> Self {
+ Self::Normal(Expr::default())
+ }
+}
+
+node! {
+ /// A let binding: `let x = 1`.
+ LetBinding
+}
+
+#[derive(Debug)]
+pub enum LetBindingKind {
+ /// A normal binding: `let x = 1`.
+ Normal(Pattern),
+ /// A closure binding: `let f(x) = 1`.
+ Closure(Ident),
+}
+
+impl LetBindingKind {
+ // Returns a list of all identifiers in the pattern.
+ pub fn idents(&self) -> Vec<Ident> {
+ match self {
+ LetBindingKind::Normal(pattern) => pattern.idents(),
+ LetBindingKind::Closure(ident) => {
+ vec![ident.clone()]
+ }
+ }
+ }
+}
+
+impl LetBinding {
+ /// The kind of the let binding.
+ pub fn kind(&self) -> LetBindingKind {
+ match self.0.cast_first_match::<Pattern>() {
+ Some(Pattern::Normal(Expr::Closure(closure))) => {
+ LetBindingKind::Closure(closure.name().unwrap_or_default())
+ }
+ pattern => LetBindingKind::Normal(pattern.unwrap_or_default()),
+ }
+ }
+
+ /// The expression the binding is initialized with.
+ pub fn init(&self) -> Option<Expr> {
+ match self.kind() {
+ LetBindingKind::Normal(Pattern::Normal(_)) => {
+ self.0.children().filter_map(SyntaxNode::cast).nth(1)
+ }
+ LetBindingKind::Normal(_) => self.0.cast_first_match(),
+ LetBindingKind::Closure(_) => self.0.cast_first_match(),
+ }
+ }
+}
+
+node! {
+ /// An assignment expression `(x, y) = (1, 2)`.
+ DestructAssignment
+}
+
+impl DestructAssignment {
+ /// The pattern of the assignment.
+ pub fn pattern(&self) -> Pattern {
+ self.0.cast_first_match::<Pattern>().unwrap_or_default()
+ }
+
+ /// The expression that is assigned.
+ pub fn value(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A set rule: `set text(...)`.
+ SetRule
+}
+
+impl SetRule {
+ /// The function to set style properties for.
+ pub fn target(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The style properties to set.
+ pub fn args(&self) -> Args {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+
+ /// A condition under which the set rule applies.
+ pub fn condition(&self) -> Option<Expr> {
+ self.0
+ .children()
+ .skip_while(|child| child.kind() != SyntaxKind::If)
+ .find_map(SyntaxNode::cast)
+ }
+}
+
+node! {
+ /// A show rule: `show heading: it => emph(it.body)`.
+ ShowRule
+}
+
+impl ShowRule {
+ /// Defines which nodes the show rule applies to.
+ pub fn selector(&self) -> Option<Expr> {
+ self.0
+ .children()
+ .rev()
+ .skip_while(|child| child.kind() != SyntaxKind::Colon)
+ .find_map(SyntaxNode::cast)
+ }
+
+ /// The transformation recipe.
+ pub fn transform(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// An if-else conditional: `if x { y } else { z }`.
+ Conditional
+}
+
+impl Conditional {
+ /// The condition which selects the body to evaluate.
+ pub fn condition(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The expression to evaluate if the condition is true.
+ pub fn if_body(&self) -> Expr {
+ self.0
+ .children()
+ .filter_map(SyntaxNode::cast)
+ .nth(1)
+ .unwrap_or_default()
+ }
+
+ /// The expression to evaluate if the condition is false.
+ pub fn else_body(&self) -> Option<Expr> {
+ self.0.children().filter_map(SyntaxNode::cast).nth(2)
+ }
+}
+
+node! {
+ /// A while loop: `while x { y }`.
+ WhileLoop
+}
+
+impl WhileLoop {
+ /// The condition which selects whether to evaluate the body.
+ pub fn condition(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The expression to evaluate while the condition is true.
+ pub fn body(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A for loop: `for x in y { z }`.
+ ForLoop
+}
+
+impl ForLoop {
+ /// The pattern to assign to.
+ pub fn pattern(&self) -> Pattern {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The expression to iterate over.
+ pub fn iter(&self) -> Expr {
+ self.0
+ .children()
+ .skip_while(|&c| c.kind() != SyntaxKind::In)
+ .find_map(SyntaxNode::cast)
+ .unwrap_or_default()
+ }
+
+ /// The expression to evaluate for each iteration.
+ pub fn body(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A module import: `import "utils.typ": a, b, c`.
+ ModuleImport
+}
+
+impl ModuleImport {
+ /// The module or path from which the items should be imported.
+ pub fn source(&self) -> Expr {
+ self.0.cast_first_match().unwrap_or_default()
+ }
+
+ /// The items to be imported.
+ pub fn imports(&self) -> Option<Imports> {
+ self.0.children().find_map(|node| match node.kind() {
+ SyntaxKind::Star => Some(Imports::Wildcard),
+ SyntaxKind::ImportItems => {
+ let items = node.children().filter_map(SyntaxNode::cast).collect();
+ Some(Imports::Items(items))
+ }
+ _ => Option::None,
+ })
+ }
+}
+
+/// The items that ought to be imported from a file.
+#[derive(Debug, Clone, Hash)]
+pub enum Imports {
+ /// All items in the scope of the file should be imported.
+ Wildcard,
+ /// The specified items from the file should be imported.
+ Items(Vec<Ident>),
+}
+
+node! {
+ /// A module include: `include "chapter1.typ"`.
+ ModuleInclude
+}
+
+impl ModuleInclude {
+ /// The module or path from which the content should be included.
+ pub fn source(&self) -> Expr {
+ self.0.cast_last_match().unwrap_or_default()
+ }
+}
+
+node! {
+ /// A break from a loop: `break`.
+ LoopBreak
+}
+
+node! {
+ /// A continue in a loop: `continue`.
+ LoopContinue
+}
+
+node! {
+ /// A return from a function: `return`, `return x + 1`.
+ FuncReturn
+}
+
+impl FuncReturn {
+ /// The expression to return.
+ pub fn body(&self) -> Option<Expr> {
+ self.0.cast_last_match()
+ }
+}
diff --git a/crates/typst/src/syntax/kind.rs b/crates/typst/src/syntax/kind.rs
new file mode 100644
index 00000000..26e949ca
--- /dev/null
+++ b/crates/typst/src/syntax/kind.rs
@@ -0,0 +1,448 @@
+/// A syntactical building block of a Typst file.
+///
+/// Can be created by the lexer or by the parser.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+#[repr(u8)]
+pub enum SyntaxKind {
+ /// Markup.
+ Markup,
+ /// Plain text without markup.
+ Text,
+ /// Whitespace. Contains at most one newline in markup, as more indicate a
+ /// paragraph break.
+ Space,
+ /// A forced line break: `\`.
+ Linebreak,
+ /// A paragraph break, indicated by one or multiple blank lines.
+ Parbreak,
+ /// An escape sequence: `\#`, `\u{1F5FA}`.
+ Escape,
+ /// A shorthand for a unicode codepoint. For example, `~` for non-breaking
+ /// space or `-?` for a soft hyphen.
+ Shorthand,
+ /// A smart quote: `'` or `"`.
+ SmartQuote,
+ /// Strong content: `*Strong*`.
+ Strong,
+ /// Emphasized content: `_Emphasized_`.
+ Emph,
+ /// Raw text with optional syntax highlighting: `` `...` ``.
+ Raw,
+ /// A hyperlink: `https://typst.org`.
+ Link,
+ /// A label: `<intro>`.
+ Label,
+ /// A reference: `@target`, `@target[..]`.
+ Ref,
+ /// Introduces a reference: `@target`.
+ RefMarker,
+ /// A section heading: `= Introduction`.
+ Heading,
+ /// Introduces a section heading: `=`, `==`, ...
+ HeadingMarker,
+ /// An item in a bullet list: `- ...`.
+ ListItem,
+ /// Introduces a list item: `-`.
+ ListMarker,
+ /// An item in an enumeration (numbered list): `+ ...` or `1. ...`.
+ EnumItem,
+ /// Introduces an enumeration item: `+`, `1.`.
+ EnumMarker,
+ /// An item in a term list: `/ Term: Details`.
+ TermItem,
+ /// Introduces a term item: `/`.
+ TermMarker,
+ /// A mathematical equation: `$x$`, `$ x^2 $`.
+ Equation,
+
+ /// The contents of a mathematical equation: `x^2 + 1`.
+ Math,
+ /// An identifier in math: `pi`.
+ MathIdent,
+ /// An alignment point in math: `&`.
+ MathAlignPoint,
+ /// Matched delimiters in math: `[x + y]`.
+ MathDelimited,
+ /// A base with optional attachments in math: `a_1^2`.
+ MathAttach,
+ /// A fraction in math: `x/2`.
+ MathFrac,
+ /// A root in math: `√x`, `∛x` or `∜x`.
+ MathRoot,
+
+ /// A hashtag that switches into code mode: `#`.
+ Hashtag,
+ /// A left curly brace, starting a code block: `{`.
+ LeftBrace,
+ /// A right curly brace, terminating a code block: `}`.
+ RightBrace,
+ /// A left square bracket, starting a content block: `[`.
+ LeftBracket,
+ /// A right square bracket, terminating a content block: `]`.
+ RightBracket,
+ /// A left round parenthesis, starting a grouped expression, collection,
+ /// argument or parameter list: `(`.
+ LeftParen,
+ /// A right round parenthesis, terminating a grouped expression, collection,
+ /// argument or parameter list: `)`.
+ RightParen,
+ /// A comma separator in a sequence: `,`.
+ Comma,
+ /// A semicolon terminating an expression: `;`.
+ Semicolon,
+ /// A colon between name/key and value in a dictionary, argument or
+ /// parameter list, or between the term and body of a term list term: `:`.
+ Colon,
+ /// The strong text toggle, multiplication operator, and wildcard import
+ /// symbol: `*`.
+ Star,
+ /// Toggles emphasized text and indicates a subscript in math: `_`.
+ Underscore,
+ /// Starts and ends a mathematical equation: `$`.
+ Dollar,
+ /// The unary plus and binary addition operator: `+`.
+ Plus,
+ /// The unary negation and binary subtraction operator: `-`.
+ Minus,
+ /// The division operator and fraction operator in math: `/`.
+ Slash,
+ /// The superscript operator in math: `^`.
+ Hat,
+ /// The field access and method call operator: `.`.
+ Dot,
+ /// The assignment operator: `=`.
+ Eq,
+ /// The equality operator: `==`.
+ EqEq,
+ /// The inequality operator: `!=`.
+ ExclEq,
+ /// The less-than operator: `<`.
+ Lt,
+ /// The less-than or equal operator: `<=`.
+ LtEq,
+ /// The greater-than operator: `>`.
+ Gt,
+ /// The greater-than or equal operator: `>=`.
+ GtEq,
+ /// The add-assign operator: `+=`.
+ PlusEq,
+ /// The subtract-assign operator: `-=`.
+ HyphEq,
+ /// The multiply-assign operator: `*=`.
+ StarEq,
+ /// The divide-assign operator: `/=`.
+ SlashEq,
+ /// The spread operator: `..`.
+ Dots,
+ /// An arrow between a closure's parameters and body: `=>`.
+ Arrow,
+ /// A root: `√`, `∛` or `∜`.
+ Root,
+
+ /// The `not` operator.
+ Not,
+ /// The `and` operator.
+ And,
+ /// The `or` operator.
+ Or,
+ /// The `none` literal.
+ None,
+ /// The `auto` literal.
+ Auto,
+ /// The `let` keyword.
+ Let,
+ /// The `set` keyword.
+ Set,
+ /// The `show` keyword.
+ Show,
+ /// The `if` keyword.
+ If,
+ /// The `else` keyword.
+ Else,
+ /// The `for` keyword.
+ For,
+ /// The `in` keyword.
+ In,
+ /// The `while` keyword.
+ While,
+ /// The `break` keyword.
+ Break,
+ /// The `continue` keyword.
+ Continue,
+ /// The `return` keyword.
+ Return,
+ /// The `import` keyword.
+ Import,
+ /// The `include` keyword.
+ Include,
+ /// The `as` keyword.
+ As,
+
+ /// Code.
+ Code,
+ /// An identifier: `it`.
+ Ident,
+ /// A boolean: `true`, `false`.
+ Bool,
+ /// An integer: `120`.
+ Int,
+ /// A floating-point number: `1.2`, `10e-4`.
+ Float,
+ /// A numeric value with a unit: `12pt`, `3cm`, `2em`, `90deg`, `50%`.
+ Numeric,
+ /// A quoted string: `"..."`.
+ Str,
+ /// A code block: `{ let x = 1; x + 2 }`.
+ CodeBlock,
+ /// A content block: `[*Hi* there!]`.
+ ContentBlock,
+ /// A grouped expression: `(1 + 2)`.
+ Parenthesized,
+ /// An array: `(1, "hi", 12cm)`.
+ Array,
+ /// A dictionary: `(thickness: 3pt, pattern: dashed)`.
+ Dict,
+ /// A named pair: `thickness: 3pt`.
+ Named,
+ /// A keyed pair: `"spacy key": true`.
+ Keyed,
+ /// A unary operation: `-x`.
+ Unary,
+ /// A binary operation: `a + b`.
+ Binary,
+ /// A field access: `properties.age`.
+ FieldAccess,
+ /// An invocation of a function or method: `f(x, y)`.
+ FuncCall,
+ /// A function call's argument list: `(12pt, y)`.
+ Args,
+ /// Spread arguments or an argument sink: `..x`.
+ Spread,
+ /// A closure: `(x, y) => z`.
+ Closure,
+ /// A closure's parameters: `(x, y)`.
+ Params,
+ /// A let binding: `let x = 1`.
+ LetBinding,
+ /// A set rule: `set text(...)`.
+ SetRule,
+ /// A show rule: `show heading: it => emph(it.body)`.
+ ShowRule,
+ /// An if-else conditional: `if x { y } else { z }`.
+ Conditional,
+ /// A while loop: `while x { y }`.
+ WhileLoop,
+ /// A for loop: `for x in y { z }`.
+ ForLoop,
+ /// A module import: `import "utils.typ": a, b, c`.
+ ModuleImport,
+ /// Items to import from a module: `a, b, c`.
+ ImportItems,
+ /// A module include: `include "chapter1.typ"`.
+ ModuleInclude,
+ /// A break from a loop: `break`.
+ LoopBreak,
+ /// A continue in a loop: `continue`.
+ LoopContinue,
+ /// A return from a function: `return`, `return x + 1`.
+ FuncReturn,
+ /// A destructuring pattern: `(x, _, ..y)`.
+ Destructuring,
+ /// A destructuring assignment expression: `(x, y) = (1, 2)`.
+ DestructAssignment,
+
+ /// A line comment: `// ...`.
+ LineComment,
+ /// A block comment: `/* ... */`.
+ BlockComment,
+ /// An invalid sequence of characters.
+ Error,
+ /// The end of the file.
+ Eof,
+}
+
+impl SyntaxKind {
+ /// Is this a bracket, brace, or parenthesis?
+ pub fn is_grouping(self) -> bool {
+ matches!(
+ self,
+ Self::LeftBracket
+ | Self::LeftBrace
+ | Self::LeftParen
+ | Self::RightBracket
+ | Self::RightBrace
+ | Self::RightParen
+ )
+ }
+
+ /// Does this node terminate a preceding expression?
+ pub fn is_terminator(self) -> bool {
+ matches!(
+ self,
+ Self::Eof
+ | Self::Semicolon
+ | Self::RightBrace
+ | Self::RightParen
+ | Self::RightBracket
+ )
+ }
+
+ /// Is this a code or content block.
+ pub fn is_block(self) -> bool {
+ matches!(self, Self::CodeBlock | Self::ContentBlock)
+ }
+
+ /// Does this node need termination through a semicolon or linebreak?
+ pub fn is_stmt(self) -> bool {
+ matches!(
+ self,
+ Self::LetBinding
+ | Self::SetRule
+ | Self::ShowRule
+ | Self::ModuleImport
+ | Self::ModuleInclude
+ )
+ }
+
+ /// Whether this kind of node is automatically skipped by the parser in
+ /// code and math mode.
+ pub fn is_trivia(self) -> bool {
+ matches!(
+ self,
+ Self::Space | Self::Parbreak | Self::LineComment | Self::BlockComment
+ )
+ }
+
+ /// Whether this is an error.
+ pub fn is_error(self) -> bool {
+ self == Self::Error
+ }
+
+ /// A human-readable name for the kind.
+ pub fn name(self) -> &'static str {
+ match self {
+ Self::Markup => "markup",
+ Self::Text => "text",
+ Self::Space => "space",
+ Self::Linebreak => "line break",
+ Self::Parbreak => "paragraph break",
+ Self::Escape => "escape sequence",
+ Self::Shorthand => "shorthand",
+ Self::SmartQuote => "smart quote",
+ Self::Strong => "strong content",
+ Self::Emph => "emphasized content",
+ Self::Raw => "raw block",
+ Self::Link => "link",
+ Self::Label => "label",
+ Self::Ref => "reference",
+ Self::RefMarker => "reference marker",
+ Self::Heading => "heading",
+ Self::HeadingMarker => "heading marker",
+ Self::ListItem => "list item",
+ Self::ListMarker => "list marker",
+ Self::EnumItem => "enum item",
+ Self::EnumMarker => "enum marker",
+ Self::TermItem => "term list item",
+ Self::TermMarker => "term marker",
+ Self::Equation => "equation",
+ Self::Math => "math",
+ Self::MathIdent => "math identifier",
+ Self::MathAlignPoint => "math alignment point",
+ Self::MathDelimited => "delimited math",
+ Self::MathAttach => "math attachments",
+ Self::MathFrac => "math fraction",
+ Self::MathRoot => "math root",
+ Self::Hashtag => "hashtag",
+ Self::LeftBrace => "opening brace",
+ Self::RightBrace => "closing brace",
+ Self::LeftBracket => "opening bracket",
+ Self::RightBracket => "closing bracket",
+ Self::LeftParen => "opening paren",
+ Self::RightParen => "closing paren",
+ Self::Comma => "comma",
+ Self::Semicolon => "semicolon",
+ Self::Colon => "colon",
+ Self::Star => "star",
+ Self::Underscore => "underscore",
+ Self::Dollar => "dollar sign",
+ Self::Plus => "plus",
+ Self::Minus => "minus",
+ Self::Slash => "slash",
+ Self::Hat => "hat",
+ Self::Dot => "dot",
+ Self::Eq => "equals sign",
+ Self::EqEq => "equality operator",
+ Self::ExclEq => "inequality operator",
+ Self::Lt => "less-than operator",
+ Self::LtEq => "less-than or equal operator",
+ Self::Gt => "greater-than operator",
+ Self::GtEq => "greater-than or equal operator",
+ Self::PlusEq => "add-assign operator",
+ Self::HyphEq => "subtract-assign operator",
+ Self::StarEq => "multiply-assign operator",
+ Self::SlashEq => "divide-assign operator",
+ Self::Dots => "dots",
+ Self::Arrow => "arrow",
+ Self::Root => "root",
+ Self::Not => "operator `not`",
+ Self::And => "operator `and`",
+ Self::Or => "operator `or`",
+ Self::None => "`none`",
+ Self::Auto => "`auto`",
+ Self::Let => "keyword `let`",
+ Self::Set => "keyword `set`",
+ Self::Show => "keyword `show`",
+ Self::If => "keyword `if`",
+ Self::Else => "keyword `else`",
+ Self::For => "keyword `for`",
+ Self::In => "keyword `in`",
+ Self::While => "keyword `while`",
+ Self::Break => "keyword `break`",
+ Self::Continue => "keyword `continue`",
+ Self::Return => "keyword `return`",
+ Self::Import => "keyword `import`",
+ Self::Include => "keyword `include`",
+ Self::As => "keyword `as`",
+ Self::Code => "code",
+ Self::Ident => "identifier",
+ Self::Bool => "boolean",
+ Self::Int => "integer",
+ Self::Float => "float",
+ Self::Numeric => "numeric value",
+ Self::Str => "string",
+ Self::CodeBlock => "code block",
+ Self::ContentBlock => "content block",
+ Self::Parenthesized => "group",
+ Self::Array => "array",
+ Self::Dict => "dictionary",
+ Self::Named => "named pair",
+ Self::Keyed => "keyed pair",
+ Self::Unary => "unary expression",
+ Self::Binary => "binary expression",
+ Self::FieldAccess => "field access",
+ Self::FuncCall => "function call",
+ Self::Args => "call arguments",
+ Self::Spread => "spread",
+ Self::Closure => "closure",
+ Self::Params => "closure parameters",
+ Self::LetBinding => "`let` expression",
+ Self::SetRule => "`set` expression",
+ Self::ShowRule => "`show` expression",
+ Self::Conditional => "`if` expression",
+ Self::WhileLoop => "while-loop expression",
+ Self::ForLoop => "for-loop expression",
+ Self::ModuleImport => "`import` expression",
+ Self::ImportItems => "import items",
+ Self::ModuleInclude => "`include` expression",
+ Self::LoopBreak => "`break` expression",
+ Self::LoopContinue => "`continue` expression",
+ Self::FuncReturn => "`return` expression",
+ Self::Destructuring => "destructuring pattern",
+ Self::DestructAssignment => "destructuring assignment expression",
+ Self::LineComment => "line comment",
+ Self::BlockComment => "block comment",
+ Self::Error => "syntax error",
+ Self::Eof => "end of file",
+ }
+ }
+}
diff --git a/crates/typst/src/syntax/lexer.rs b/crates/typst/src/syntax/lexer.rs
new file mode 100644
index 00000000..d95b5b7b
--- /dev/null
+++ b/crates/typst/src/syntax/lexer.rs
@@ -0,0 +1,738 @@
+use ecow::{eco_format, EcoString};
+use unicode_ident::{is_xid_continue, is_xid_start};
+use unicode_segmentation::UnicodeSegmentation;
+use unscanny::Scanner;
+
+use super::SyntaxKind;
+
+/// Splits up a string of source code into tokens.
+#[derive(Clone)]
+pub(super) struct Lexer<'s> {
+ /// The underlying scanner.
+ s: Scanner<'s>,
+ /// The mode the lexer is in. This determines which kinds of tokens it
+ /// produces.
+ mode: LexMode,
+ /// Whether the last token contained a newline.
+ newline: bool,
+ /// An error for the last token.
+ error: Option<EcoString>,
+}
+
+/// What kind of tokens to emit.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub(super) enum LexMode {
+ /// Text and markup.
+ Markup,
+ /// Math atoms, operators, etc.
+ Math,
+ /// Keywords, literals and operators.
+ Code,
+}
+
+impl<'s> Lexer<'s> {
+ /// Create a new lexer with the given mode and a prefix to offset column
+ /// calculations.
+ pub fn new(text: &'s str, mode: LexMode) -> Self {
+ Self {
+ s: Scanner::new(text),
+ mode,
+ newline: false,
+ error: None,
+ }
+ }
+
+ /// Get the current lexing mode.
+ pub fn mode(&self) -> LexMode {
+ self.mode
+ }
+
+ /// Change the lexing mode.
+ pub fn set_mode(&mut self, mode: LexMode) {
+ self.mode = mode;
+ }
+
+ /// The index in the string at which the last token ends and next token
+ /// will start.
+ pub fn cursor(&self) -> usize {
+ self.s.cursor()
+ }
+
+ /// Jump to the given index in the string.
+ pub fn jump(&mut self, index: usize) {
+ self.s.jump(index);
+ }
+
+ /// Whether the last token contained a newline.
+ pub fn newline(&self) -> bool {
+ self.newline
+ }
+
+ /// Take out the last error, if any.
+ pub fn take_error(&mut self) -> Option<EcoString> {
+ self.error.take()
+ }
+}
+
+impl Lexer<'_> {
+ /// Construct a full-positioned syntax error.
+ fn error(&mut self, message: impl Into<EcoString>) -> SyntaxKind {
+ self.error = Some(message.into());
+ SyntaxKind::Error
+ }
+}
+
+/// Shared.
+impl Lexer<'_> {
+ pub fn next(&mut self) -> SyntaxKind {
+ self.newline = false;
+ self.error = None;
+ let start = self.s.cursor();
+ match self.s.eat() {
+ Some(c) if c.is_whitespace() => self.whitespace(start, c),
+ Some('/') if self.s.eat_if('/') => self.line_comment(),
+ Some('/') if self.s.eat_if('*') => self.block_comment(),
+ Some('*') if self.s.eat_if('/') => {
+ self.error("unexpected end of block comment")
+ }
+
+ Some(c) => match self.mode {
+ LexMode::Markup => self.markup(start, c),
+ LexMode::Math => self.math(start, c),
+ LexMode::Code => self.code(start, c),
+ },
+
+ None => SyntaxKind::Eof,
+ }
+ }
+
+ fn whitespace(&mut self, start: usize, c: char) -> SyntaxKind {
+ let more = self.s.eat_while(char::is_whitespace);
+ let newlines = match c {
+ ' ' if more.is_empty() => 0,
+ _ => count_newlines(self.s.from(start)),
+ };
+
+ self.newline = newlines > 0;
+ if self.mode == LexMode::Markup && newlines >= 2 {
+ SyntaxKind::Parbreak
+ } else {
+ SyntaxKind::Space
+ }
+ }
+
+ fn line_comment(&mut self) -> SyntaxKind {
+ self.s.eat_until(is_newline);
+ SyntaxKind::LineComment
+ }
+
+ fn block_comment(&mut self) -> SyntaxKind {
+ let mut state = '_';
+ let mut depth = 1;
+
+ // Find the first `*/` that does not correspond to a nested `/*`.
+ while let Some(c) = self.s.eat() {
+ state = match (state, c) {
+ ('*', '/') => {
+ depth -= 1;
+ if depth == 0 {
+ break;
+ }
+ '_'
+ }
+ ('/', '*') => {
+ depth += 1;
+ '_'
+ }
+ ('/', '/') => {
+ self.line_comment();
+ '_'
+ }
+ _ => c,
+ }
+ }
+
+ SyntaxKind::BlockComment
+ }
+}
+
+/// Markup.
+impl Lexer<'_> {
+ fn markup(&mut self, start: usize, c: char) -> SyntaxKind {
+ match c {
+ '\\' => self.backslash(),
+ '`' => self.raw(),
+ 'h' if self.s.eat_if("ttp://") => self.link(),
+ 'h' if self.s.eat_if("ttps://") => self.link(),
+ '<' if self.s.at(is_id_continue) => self.label(),
+ '@' => self.ref_marker(),
+
+ '.' if self.s.eat_if("..") => SyntaxKind::Shorthand,
+ '-' if self.s.eat_if("--") => SyntaxKind::Shorthand,
+ '-' if self.s.eat_if('-') => SyntaxKind::Shorthand,
+ '-' if self.s.eat_if('?') => SyntaxKind::Shorthand,
+ '*' if !self.in_word() => SyntaxKind::Star,
+ '_' if !self.in_word() => SyntaxKind::Underscore,
+
+ '#' => SyntaxKind::Hashtag,
+ '[' => SyntaxKind::LeftBracket,
+ ']' => SyntaxKind::RightBracket,
+ '\'' => SyntaxKind::SmartQuote,
+ '"' => SyntaxKind::SmartQuote,
+ '$' => SyntaxKind::Dollar,
+ '~' => SyntaxKind::Shorthand,
+ ':' => SyntaxKind::Colon,
+ '=' => {
+ self.s.eat_while('=');
+ if self.space_or_end() {
+ SyntaxKind::HeadingMarker
+ } else {
+ self.text()
+ }
+ }
+ '-' if self.space_or_end() => SyntaxKind::ListMarker,
+ '+' if self.space_or_end() => SyntaxKind::EnumMarker,
+ '/' if self.space_or_end() => SyntaxKind::TermMarker,
+ '0'..='9' => self.numbering(start),
+
+ _ => self.text(),
+ }
+ }
+
+ fn backslash(&mut self) -> SyntaxKind {
+ if self.s.eat_if("u{") {
+ let hex = self.s.eat_while(char::is_ascii_alphanumeric);
+ if !self.s.eat_if('}') {
+ return self.error("unclosed Unicode escape sequence");
+ }
+
+ if u32::from_str_radix(hex, 16)
+ .ok()
+ .and_then(std::char::from_u32)
+ .is_none()
+ {
+ return self.error(eco_format!("invalid Unicode codepoint: {}", hex));
+ }
+
+ return SyntaxKind::Escape;
+ }
+
+ if self.s.done() || self.s.at(char::is_whitespace) {
+ SyntaxKind::Linebreak
+ } else {
+ self.s.eat();
+ SyntaxKind::Escape
+ }
+ }
+
+ fn raw(&mut self) -> SyntaxKind {
+ let mut backticks = 1;
+ while self.s.eat_if('`') {
+ backticks += 1;
+ }
+
+ if backticks == 2 {
+ return SyntaxKind::Raw;
+ }
+
+ let mut found = 0;
+ while found < backticks {
+ match self.s.eat() {
+ Some('`') => found += 1,
+ Some(_) => found = 0,
+ None => break,
+ }
+ }
+
+ if found != backticks {
+ return self.error("unclosed raw text");
+ }
+
+ SyntaxKind::Raw
+ }
+
+ fn link(&mut self) -> SyntaxKind {
+ let mut brackets = Vec::new();
+
+ #[rustfmt::skip]
+ self.s.eat_while(|c: char| {
+ match c {
+ | '0' ..= '9'
+ | 'a' ..= 'z'
+ | 'A' ..= 'Z'
+ | '!' | '#' | '$' | '%' | '&' | '*' | '+'
+ | ',' | '-' | '.' | '/' | ':' | ';' | '='
+ | '?' | '@' | '_' | '~' | '\'' => true,
+ '[' => {
+ brackets.push(SyntaxKind::LeftBracket);
+ true
+ }
+ '(' => {
+ brackets.push(SyntaxKind::LeftParen);
+ true
+ }
+ ']' => brackets.pop() == Some(SyntaxKind::LeftBracket),
+ ')' => brackets.pop() == Some(SyntaxKind::LeftParen),
+ _ => false,
+ }
+ });
+
+ if !brackets.is_empty() {
+ return self.error(
+ "automatic links cannot contain unbalanced brackets, \
+ use the `link` function instead",
+ );
+ }
+
+ // Don't include the trailing characters likely to be part of text.
+ while matches!(self.s.scout(-1), Some('!' | ',' | '.' | ':' | ';' | '?' | '\'')) {
+ self.s.uneat();
+ }
+
+ SyntaxKind::Link
+ }
+
+ fn numbering(&mut self, start: usize) -> SyntaxKind {
+ self.s.eat_while(char::is_ascii_digit);
+
+ let read = self.s.from(start);
+ if self.s.eat_if('.') && self.space_or_end() && read.parse::<usize>().is_ok() {
+ return SyntaxKind::EnumMarker;
+ }
+
+ self.text()
+ }
+
+ fn ref_marker(&mut self) -> SyntaxKind {
+ self.s.eat_while(|c| is_id_continue(c) || matches!(c, ':' | '.'));
+
+ // Don't include the trailing characters likely to be part of text.
+ while matches!(self.s.scout(-1), Some('.' | ':')) {
+ self.s.uneat();
+ }
+
+ SyntaxKind::RefMarker
+ }
+
+ fn label(&mut self) -> SyntaxKind {
+ let label = self.s.eat_while(|c| is_id_continue(c) || matches!(c, ':' | '.'));
+ if label.is_empty() {
+ return self.error("label cannot be empty");
+ }
+
+ if !self.s.eat_if('>') {
+ return self.error("unclosed label");
+ }
+
+ SyntaxKind::Label
+ }
+
+ fn text(&mut self) -> SyntaxKind {
+ macro_rules! table {
+ ($(|$c:literal)*) => {
+ static TABLE: [bool; 128] = {
+ let mut t = [false; 128];
+ $(t[$c as usize] = true;)*
+ t
+ };
+ };
+ }
+
+ table! {
+ | ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r' | '\\' | '/'
+ | '[' | ']' | '{' | '}' | '~' | '-' | '.' | '\'' | '"'
+ | '*' | '_' | ':' | 'h' | '`' | '$' | '<' | '>' | '@' | '#'
+ };
+
+ loop {
+ self.s.eat_until(|c: char| {
+ TABLE.get(c as usize).copied().unwrap_or_else(|| c.is_whitespace())
+ });
+
+ // Continue with the same text node if the thing would become text
+ // anyway.
+ let mut s = self.s;
+ match s.eat() {
+ Some(' ') if s.at(char::is_alphanumeric) => {}
+ Some('/') if !s.at(['/', '*']) => {}
+ Some('-') if !s.at(['-', '?']) => {}
+ Some('.') if !s.at("..") => {}
+ Some('h') if !s.at("ttp://") && !s.at("ttps://") => {}
+ Some('@') if !s.at(is_id_start) => {}
+ _ => break,
+ }
+
+ self.s = s;
+ }
+
+ SyntaxKind::Text
+ }
+
+ fn in_word(&self) -> bool {
+ let alphanum = |c: Option<char>| c.map_or(false, |c| c.is_alphanumeric());
+ let prev = self.s.scout(-2);
+ let next = self.s.peek();
+ alphanum(prev) && alphanum(next)
+ }
+
+ fn space_or_end(&self) -> bool {
+ self.s.done() || self.s.at(char::is_whitespace)
+ }
+}
+
+/// Math.
+impl Lexer<'_> {
+ fn math(&mut self, start: usize, c: char) -> SyntaxKind {
+ match c {
+ '\\' => self.backslash(),
+ '"' => self.string(),
+
+ '-' if self.s.eat_if(">>") => SyntaxKind::Shorthand,
+ '-' if self.s.eat_if('>') => SyntaxKind::Shorthand,
+ '-' if self.s.eat_if("->") => SyntaxKind::Shorthand,
+ ':' if self.s.eat_if('=') => SyntaxKind::Shorthand,
+ ':' if self.s.eat_if(":=") => SyntaxKind::Shorthand,
+ '!' if self.s.eat_if('=') => SyntaxKind::Shorthand,
+ '.' if self.s.eat_if("..") => SyntaxKind::Shorthand,
+ '[' if self.s.eat_if('|') => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("==>") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("-->") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("--") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("-<") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("->") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("<-") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("<<") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("=>") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("==") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if("~~") => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if('=') => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if('<') => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if('-') => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if('~') => SyntaxKind::Shorthand,
+ '>' if self.s.eat_if("->") => SyntaxKind::Shorthand,
+ '>' if self.s.eat_if(">>") => SyntaxKind::Shorthand,
+ '=' if self.s.eat_if("=>") => SyntaxKind::Shorthand,
+ '=' if self.s.eat_if('>') => SyntaxKind::Shorthand,
+ '=' if self.s.eat_if(':') => SyntaxKind::Shorthand,
+ '>' if self.s.eat_if('=') => SyntaxKind::Shorthand,
+ '>' if self.s.eat_if('>') => SyntaxKind::Shorthand,
+ '|' if self.s.eat_if("->") => SyntaxKind::Shorthand,
+ '|' if self.s.eat_if("=>") => SyntaxKind::Shorthand,
+ '|' if self.s.eat_if(']') => SyntaxKind::Shorthand,
+ '|' if self.s.eat_if('|') => SyntaxKind::Shorthand,
+ '~' if self.s.eat_if("~>") => SyntaxKind::Shorthand,
+ '~' if self.s.eat_if('>') => SyntaxKind::Shorthand,
+ '*' | '\'' | '-' => SyntaxKind::Shorthand,
+
+ '#' => SyntaxKind::Hashtag,
+ '_' => SyntaxKind::Underscore,
+ '$' => SyntaxKind::Dollar,
+ '/' => SyntaxKind::Slash,
+ '^' => SyntaxKind::Hat,
+ '&' => SyntaxKind::MathAlignPoint,
+ '√' | '∛' | '∜' => SyntaxKind::Root,
+
+ // Identifiers.
+ c if is_math_id_start(c) && self.s.at(is_math_id_continue) => {
+ self.s.eat_while(is_math_id_continue);
+ SyntaxKind::MathIdent
+ }
+
+ // Other math atoms.
+ _ => self.math_text(start, c),
+ }
+ }
+
+ fn math_text(&mut self, start: usize, c: char) -> SyntaxKind {
+ // Keep numbers and grapheme clusters together.
+ if c.is_numeric() {
+ self.s.eat_while(char::is_numeric);
+ let mut s = self.s;
+ if s.eat_if('.') && !s.eat_while(char::is_numeric).is_empty() {
+ self.s = s;
+ }
+ } else {
+ let len = self
+ .s
+ .get(start..self.s.string().len())
+ .graphemes(true)
+ .next()
+ .map_or(0, str::len);
+ self.s.jump(start + len);
+ }
+ SyntaxKind::Text
+ }
+}
+
+/// Code.
+impl Lexer<'_> {
+ fn code(&mut self, start: usize, c: char) -> SyntaxKind {
+ match c {
+ '`' => self.raw(),
+ '<' if self.s.at(is_id_continue) => self.label(),
+ '0'..='9' => self.number(start, c),
+ '.' if self.s.at(char::is_ascii_digit) => self.number(start, c),
+ '"' => self.string(),
+
+ '=' if self.s.eat_if('=') => SyntaxKind::EqEq,
+ '!' if self.s.eat_if('=') => SyntaxKind::ExclEq,
+ '<' if self.s.eat_if('=') => SyntaxKind::LtEq,
+ '>' if self.s.eat_if('=') => SyntaxKind::GtEq,
+ '+' if self.s.eat_if('=') => SyntaxKind::PlusEq,
+ '-' if self.s.eat_if('=') => SyntaxKind::HyphEq,
+ '*' if self.s.eat_if('=') => SyntaxKind::StarEq,
+ '/' if self.s.eat_if('=') => SyntaxKind::SlashEq,
+ '.' if self.s.eat_if('.') => SyntaxKind::Dots,
+ '=' if self.s.eat_if('>') => SyntaxKind::Arrow,
+
+ '{' => SyntaxKind::LeftBrace,
+ '}' => SyntaxKind::RightBrace,
+ '[' => SyntaxKind::LeftBracket,
+ ']' => SyntaxKind::RightBracket,
+ '(' => SyntaxKind::LeftParen,
+ ')' => SyntaxKind::RightParen,
+ '$' => SyntaxKind::Dollar,
+ ',' => SyntaxKind::Comma,
+ ';' => SyntaxKind::Semicolon,
+ ':' => SyntaxKind::Colon,
+ '.' => SyntaxKind::Dot,
+ '+' => SyntaxKind::Plus,
+ '-' => SyntaxKind::Minus,
+ '*' => SyntaxKind::Star,
+ '/' => SyntaxKind::Slash,
+ '=' => SyntaxKind::Eq,
+ '<' => SyntaxKind::Lt,
+ '>' => SyntaxKind::Gt,
+
+ c if is_id_start(c) => self.ident(start),
+
+ c => self.error(eco_format!("the character `{c}` is not valid in code")),
+ }
+ }
+
+ fn ident(&mut self, start: usize) -> SyntaxKind {
+ self.s.eat_while(is_id_continue);
+ let ident = self.s.from(start);
+
+ let prev = self.s.get(0..start);
+ if !prev.ends_with(['.', '@']) || prev.ends_with("..") {
+ if let Some(keyword) = keyword(ident) {
+ return keyword;
+ }
+ }
+
+ if ident == "_" {
+ SyntaxKind::Underscore
+ } else {
+ SyntaxKind::Ident
+ }
+ }
+
+ fn number(&mut self, mut start: usize, c: char) -> SyntaxKind {
+ // Handle alternative integer bases.
+ let mut base = 10;
+ if c == '0' {
+ if self.s.eat_if('b') {
+ base = 2;
+ } else if self.s.eat_if('o') {
+ base = 8;
+ } else if self.s.eat_if('x') {
+ base = 16;
+ }
+ if base != 10 {
+ start = self.s.cursor();
+ }
+ }
+
+ // Read the first part (integer or fractional depending on `first`).
+ self.s.eat_while(if base == 16 {
+ char::is_ascii_alphanumeric
+ } else {
+ char::is_ascii_digit
+ });
+
+ // Read the fractional part if not already done.
+ // Make sure not to confuse a range for the decimal separator.
+ if c != '.'
+ && !self.s.at("..")
+ && !self.s.scout(1).map_or(false, is_id_start)
+ && self.s.eat_if('.')
+ && base == 10
+ {
+ self.s.eat_while(char::is_ascii_digit);
+ }
+
+ // Read the exponent.
+ if !self.s.at("em") && self.s.eat_if(['e', 'E']) && base == 10 {
+ self.s.eat_if(['+', '-']);
+ self.s.eat_while(char::is_ascii_digit);
+ }
+
+ // Read the suffix.
+ let suffix_start = self.s.cursor();
+ if !self.s.eat_if('%') {
+ self.s.eat_while(char::is_ascii_alphanumeric);
+ }
+
+ let number = self.s.get(start..suffix_start);
+ let suffix = self.s.from(suffix_start);
+
+ let kind = if i64::from_str_radix(number, base).is_ok() {
+ SyntaxKind::Int
+ } else if base == 10 && number.parse::<f64>().is_ok() {
+ SyntaxKind::Float
+ } else {
+ return self.error(match base {
+ 2 => eco_format!("invalid binary number: 0b{}", number),
+ 8 => eco_format!("invalid octal number: 0o{}", number),
+ 16 => eco_format!("invalid hexadecimal number: 0x{}", number),
+ _ => eco_format!("invalid number: {}", number),
+ });
+ };
+
+ if suffix.is_empty() {
+ return kind;
+ }
+
+ if !matches!(
+ suffix,
+ "pt" | "mm" | "cm" | "in" | "deg" | "rad" | "em" | "fr" | "%"
+ ) {
+ return self.error(eco_format!("invalid number suffix: {}", suffix));
+ }
+
+ SyntaxKind::Numeric
+ }
+
+ fn string(&mut self) -> SyntaxKind {
+ let mut escaped = false;
+ self.s.eat_until(|c| {
+ let stop = c == '"' && !escaped;
+ escaped = c == '\\' && !escaped;
+ stop
+ });
+
+ if !self.s.eat_if('"') {
+ return self.error("unclosed string");
+ }
+
+ SyntaxKind::Str
+ }
+}
+
+/// Try to parse an identifier into a keyword.
+fn keyword(ident: &str) -> Option<SyntaxKind> {
+ Some(match ident {
+ "none" => SyntaxKind::None,
+ "auto" => SyntaxKind::Auto,
+ "true" => SyntaxKind::Bool,
+ "false" => SyntaxKind::Bool,
+ "not" => SyntaxKind::Not,
+ "and" => SyntaxKind::And,
+ "or" => SyntaxKind::Or,
+ "let" => SyntaxKind::Let,
+ "set" => SyntaxKind::Set,
+ "show" => SyntaxKind::Show,
+ "if" => SyntaxKind::If,
+ "else" => SyntaxKind::Else,
+ "for" => SyntaxKind::For,
+ "in" => SyntaxKind::In,
+ "while" => SyntaxKind::While,
+ "break" => SyntaxKind::Break,
+ "continue" => SyntaxKind::Continue,
+ "return" => SyntaxKind::Return,
+ "import" => SyntaxKind::Import,
+ "include" => SyntaxKind::Include,
+ "as" => SyntaxKind::As,
+ _ => return None,
+ })
+}
+
+/// Whether this character denotes a newline.
+#[inline]
+pub fn is_newline(character: char) -> bool {
+ matches!(
+ character,
+ // Line Feed, Vertical Tab, Form Feed, Carriage Return.
+ '\n' | '\x0B' | '\x0C' | '\r' |
+ // Next Line, Line Separator, Paragraph Separator.
+ '\u{0085}' | '\u{2028}' | '\u{2029}'
+ )
+}
+
+/// Split text at newlines.
+pub(super) fn split_newlines(text: &str) -> Vec<&str> {
+ let mut s = Scanner::new(text);
+ let mut lines = Vec::new();
+ let mut start = 0;
+ let mut end = 0;
+
+ while let Some(c) = s.eat() {
+ if is_newline(c) {
+ if c == '\r' {
+ s.eat_if('\n');
+ }
+
+ lines.push(&text[start..end]);
+ start = s.cursor();
+ }
+ end = s.cursor();
+ }
+
+ lines.push(&text[start..]);
+ lines
+}
+
+/// Count the number of newlines in text.
+fn count_newlines(text: &str) -> usize {
+ let mut newlines = 0;
+ let mut s = Scanner::new(text);
+ while let Some(c) = s.eat() {
+ if is_newline(c) {
+ if c == '\r' {
+ s.eat_if('\n');
+ }
+ newlines += 1;
+ }
+ }
+ newlines
+}
+
+/// Whether a string is a valid Typst identifier.
+///
+/// In addition to what is specified in the [Unicode Standard][uax31], we allow:
+/// - `_` as a starting character,
+/// - `_` and `-` as continuing characters.
+///
+/// [uax31]: http://www.unicode.org/reports/tr31/
+#[inline]
+pub fn is_ident(string: &str) -> bool {
+ let mut chars = string.chars();
+ chars
+ .next()
+ .map_or(false, |c| is_id_start(c) && chars.all(is_id_continue))
+}
+
+/// Whether a character can start an identifier.
+#[inline]
+pub(crate) fn is_id_start(c: char) -> bool {
+ is_xid_start(c) || c == '_'
+}
+
+/// Whether a character can continue an identifier.
+#[inline]
+pub(crate) fn is_id_continue(c: char) -> bool {
+ is_xid_continue(c) || c == '_' || c == '-'
+}
+
+/// Whether a character can start an identifier in math.
+#[inline]
+fn is_math_id_start(c: char) -> bool {
+ is_xid_start(c)
+}
+
+/// Whether a character can continue an identifier in math.
+#[inline]
+fn is_math_id_continue(c: char) -> bool {
+ is_xid_continue(c) && c != '_'
+}
diff --git a/crates/typst/src/syntax/mod.rs b/crates/typst/src/syntax/mod.rs
new file mode 100644
index 00000000..1ce1e4c0
--- /dev/null
+++ b/crates/typst/src/syntax/mod.rs
@@ -0,0 +1,23 @@
+//! Syntax definition, parsing, and highlighting.
+
+pub mod ast;
+
+mod kind;
+mod lexer;
+mod node;
+mod parser;
+mod reparser;
+mod source;
+mod span;
+
+pub use self::kind::SyntaxKind;
+pub use self::lexer::{is_ident, is_newline};
+pub use self::node::{LinkedChildren, LinkedNode, SyntaxNode};
+pub use self::parser::{parse, parse_code};
+pub use self::source::Source;
+pub use self::span::{Span, Spanned};
+
+pub(crate) use self::lexer::{is_id_continue, is_id_start};
+
+use self::lexer::{split_newlines, LexMode, Lexer};
+use self::parser::{reparse_block, reparse_markup};
diff --git a/crates/typst/src/syntax/node.rs b/crates/typst/src/syntax/node.rs
new file mode 100644
index 00000000..6a66416d
--- /dev/null
+++ b/crates/typst/src/syntax/node.rs
@@ -0,0 +1,889 @@
+use std::fmt::{self, Debug, Display, Formatter};
+use std::ops::{Deref, Range};
+use std::rc::Rc;
+use std::sync::Arc;
+
+use ecow::EcoString;
+
+use super::ast::AstNode;
+use super::{Span, SyntaxKind};
+use crate::diag::SourceError;
+use crate::file::FileId;
+
+/// A node in the untyped syntax tree.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub struct SyntaxNode(Repr);
+
+/// The three internal representations.
+#[derive(Clone, Eq, PartialEq, Hash)]
+enum Repr {
+ /// A leaf node.
+ Leaf(LeafNode),
+ /// A reference-counted inner node.
+ Inner(Arc<InnerNode>),
+ /// An error node.
+ Error(Arc<ErrorNode>),
+}
+
+impl SyntaxNode {
+ /// Create a new leaf node.
+ pub fn leaf(kind: SyntaxKind, text: impl Into<EcoString>) -> Self {
+ Self(Repr::Leaf(LeafNode::new(kind, text)))
+ }
+
+ /// Create a new inner node with children.
+ pub fn inner(kind: SyntaxKind, children: Vec<SyntaxNode>) -> Self {
+ Self(Repr::Inner(Arc::new(InnerNode::new(kind, children))))
+ }
+
+ /// Create a new error node.
+ pub fn error(message: impl Into<EcoString>, text: impl Into<EcoString>) -> Self {
+ Self(Repr::Error(Arc::new(ErrorNode::new(message, text))))
+ }
+
+ /// The type of the node.
+ pub fn kind(&self) -> SyntaxKind {
+ match &self.0 {
+ Repr::Leaf(leaf) => leaf.kind,
+ Repr::Inner(inner) => inner.kind,
+ Repr::Error(_) => SyntaxKind::Error,
+ }
+ }
+
+ /// Return `true` if the length is 0.
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ /// The byte length of the node in the source text.
+ pub fn len(&self) -> usize {
+ match &self.0 {
+ Repr::Leaf(leaf) => leaf.len(),
+ Repr::Inner(inner) => inner.len,
+ Repr::Error(error) => error.len(),
+ }
+ }
+
+ /// The span of the node.
+ pub fn span(&self) -> Span {
+ match &self.0 {
+ Repr::Leaf(leaf) => leaf.span,
+ Repr::Inner(inner) => inner.span,
+ Repr::Error(error) => error.span,
+ }
+ }
+
+ /// The text of the node if it is a leaf node.
+ ///
+ /// Returns the empty string if this is an inner node.
+ pub fn text(&self) -> &EcoString {
+ static EMPTY: EcoString = EcoString::new();
+ match &self.0 {
+ Repr::Leaf(leaf) => &leaf.text,
+ Repr::Error(error) => &error.text,
+ Repr::Inner(_) => &EMPTY,
+ }
+ }
+
+ /// Extract the text from the node.
+ ///
+ /// Builds the string if this is an inner node.
+ pub fn into_text(self) -> EcoString {
+ match self.0 {
+ Repr::Leaf(leaf) => leaf.text,
+ Repr::Error(error) => error.text.clone(),
+ Repr::Inner(node) => {
+ node.children.iter().cloned().map(Self::into_text).collect()
+ }
+ }
+ }
+
+ /// The node's children.
+ pub fn children(&self) -> std::slice::Iter<'_, SyntaxNode> {
+ match &self.0 {
+ Repr::Leaf(_) | Repr::Error(_) => [].iter(),
+ Repr::Inner(inner) => inner.children.iter(),
+ }
+ }
+
+ /// Whether the node can be cast to the given AST node.
+ pub fn is<T: AstNode>(&self) -> bool {
+ self.cast::<T>().is_some()
+ }
+
+ /// Try to convert the node to a typed AST node.
+ pub fn cast<T: AstNode>(&self) -> Option<T> {
+ T::from_untyped(self)
+ }
+
+ /// Cast the first child that can cast to the AST type `T`.
+ pub fn cast_first_match<T: AstNode>(&self) -> Option<T> {
+ self.children().find_map(Self::cast)
+ }
+
+ /// Cast the last child that can cast to the AST type `T`.
+ pub fn cast_last_match<T: AstNode>(&self) -> Option<T> {
+ self.children().rev().find_map(Self::cast)
+ }
+
+ /// Whether the node or its children contain an error.
+ pub fn erroneous(&self) -> bool {
+ match &self.0 {
+ Repr::Leaf(_) => false,
+ Repr::Inner(node) => node.erroneous,
+ Repr::Error(_) => true,
+ }
+ }
+
+ /// The error messages for this node and its descendants.
+ pub fn errors(&self) -> Vec<SourceError> {
+ if !self.erroneous() {
+ return vec![];
+ }
+
+ if let Repr::Error(error) = &self.0 {
+ vec![SourceError::new(error.span, error.message.clone())]
+ } else {
+ self.children()
+ .filter(|node| node.erroneous())
+ .flat_map(|node| node.errors())
+ .collect()
+ }
+ }
+
+ /// Set a synthetic span for the node and all its descendants.
+ pub fn synthesize(&mut self, span: Span) {
+ match &mut self.0 {
+ Repr::Leaf(leaf) => leaf.span = span,
+ Repr::Inner(inner) => Arc::make_mut(inner).synthesize(span),
+ Repr::Error(error) => Arc::make_mut(error).span = span,
+ }
+ }
+}
+
+impl SyntaxNode {
+ /// Mark this node as erroneous.
+ pub(super) fn make_erroneous(&mut self) {
+ if let Repr::Inner(inner) = &mut self.0 {
+ Arc::make_mut(inner).erroneous = true;
+ }
+ }
+
+ /// Convert the child to another kind.
+ #[track_caller]
+ pub(super) fn convert_to_kind(&mut self, kind: SyntaxKind) {
+ debug_assert!(!kind.is_error());
+ match &mut self.0 {
+ Repr::Leaf(leaf) => leaf.kind = kind,
+ Repr::Inner(inner) => Arc::make_mut(inner).kind = kind,
+ Repr::Error(_) => panic!("cannot convert error"),
+ }
+ }
+
+ /// Convert the child to an error.
+ pub(super) fn convert_to_error(&mut self, message: impl Into<EcoString>) {
+ let text = std::mem::take(self).into_text();
+ *self = SyntaxNode::error(message, text);
+ }
+
+ /// Assign spans to each node.
+ #[tracing::instrument(skip_all)]
+ pub(super) fn numberize(
+ &mut self,
+ id: FileId,
+ within: Range<u64>,
+ ) -> NumberingResult {
+ if within.start >= within.end {
+ return Err(Unnumberable);
+ }
+
+ let mid = Span::new(id, (within.start + within.end) / 2);
+ match &mut self.0 {
+ Repr::Leaf(leaf) => leaf.span = mid,
+ Repr::Inner(inner) => Arc::make_mut(inner).numberize(id, None, within)?,
+ Repr::Error(error) => Arc::make_mut(error).span = mid,
+ }
+
+ Ok(())
+ }
+
+ /// Whether this is a leaf node.
+ pub(super) fn is_leaf(&self) -> bool {
+ matches!(self.0, Repr::Leaf(_))
+ }
+
+ /// The number of descendants, including the node itself.
+ pub(super) fn descendants(&self) -> usize {
+ match &self.0 {
+ Repr::Leaf(_) | Repr::Error(_) => 1,
+ Repr::Inner(inner) => inner.descendants,
+ }
+ }
+
+ /// The node's children, mutably.
+ pub(super) fn children_mut(&mut self) -> &mut [SyntaxNode] {
+ match &mut self.0 {
+ Repr::Leaf(_) | Repr::Error(_) => &mut [],
+ Repr::Inner(inner) => &mut Arc::make_mut(inner).children,
+ }
+ }
+
+ /// Replaces a range of children with a replacement.
+ ///
+ /// May have mutated the children if it returns `Err(_)`.
+ pub(super) fn replace_children(
+ &mut self,
+ range: Range<usize>,
+ replacement: Vec<SyntaxNode>,
+ ) -> NumberingResult {
+ if let Repr::Inner(inner) = &mut self.0 {
+ Arc::make_mut(inner).replace_children(range, replacement)?;
+ }
+ Ok(())
+ }
+
+ /// Update this node after changes were made to one of its children.
+ pub(super) fn update_parent(
+ &mut self,
+ prev_len: usize,
+ new_len: usize,
+ prev_descendants: usize,
+ new_descendants: usize,
+ ) {
+ if let Repr::Inner(inner) = &mut self.0 {
+ Arc::make_mut(inner).update_parent(
+ prev_len,
+ new_len,
+ prev_descendants,
+ new_descendants,
+ );
+ }
+ }
+
+ /// The upper bound of assigned numbers in this subtree.
+ pub(super) fn upper(&self) -> u64 {
+ match &self.0 {
+ Repr::Inner(inner) => inner.upper,
+ Repr::Leaf(leaf) => leaf.span.number() + 1,
+ Repr::Error(error) => error.span.number() + 1,
+ }
+ }
+}
+
+impl Debug for SyntaxNode {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match &self.0 {
+ Repr::Inner(node) => node.fmt(f),
+ Repr::Leaf(node) => node.fmt(f),
+ Repr::Error(node) => node.fmt(f),
+ }
+ }
+}
+
+impl Default for SyntaxNode {
+ fn default() -> Self {
+ Self::error("", "")
+ }
+}
+
+/// A leaf node in the untyped syntax tree.
+#[derive(Clone, Eq, PartialEq, Hash)]
+struct LeafNode {
+ /// What kind of node this is (each kind would have its own struct in a
+ /// strongly typed AST).
+ kind: SyntaxKind,
+ /// The source text of the node.
+ text: EcoString,
+ /// The node's span.
+ span: Span,
+}
+
+impl LeafNode {
+ /// Create a new leaf node.
+ #[track_caller]
+ fn new(kind: SyntaxKind, text: impl Into<EcoString>) -> Self {
+ debug_assert!(!kind.is_error());
+ Self { kind, text: text.into(), span: Span::detached() }
+ }
+
+ /// The byte length of the node in the source text.
+ fn len(&self) -> usize {
+ self.text.len()
+ }
+}
+
+impl Debug for LeafNode {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}: {:?}", self.kind, self.text)
+ }
+}
+
+/// An inner node in the untyped syntax tree.
+#[derive(Clone, Eq, PartialEq, Hash)]
+struct InnerNode {
+ /// What kind of node this is (each kind would have its own struct in a
+ /// strongly typed AST).
+ kind: SyntaxKind,
+ /// The byte length of the node in the source.
+ len: usize,
+ /// The node's span.
+ span: Span,
+ /// The number of nodes in the whole subtree, including this node.
+ descendants: usize,
+ /// Whether this node or any of its children are erroneous.
+ erroneous: bool,
+ /// The upper bound of this node's numbering range.
+ upper: u64,
+ /// This node's children, losslessly make up this node.
+ children: Vec<SyntaxNode>,
+}
+
+impl InnerNode {
+ /// Create a new inner node with the given kind and children.
+ #[track_caller]
+ fn new(kind: SyntaxKind, children: Vec<SyntaxNode>) -> Self {
+ debug_assert!(!kind.is_error());
+
+ let mut len = 0;
+ let mut descendants = 1;
+ let mut erroneous = false;
+
+ for child in &children {
+ len += child.len();
+ descendants += child.descendants();
+ erroneous |= child.erroneous();
+ }
+
+ Self {
+ kind,
+ len,
+ span: Span::detached(),
+ descendants,
+ erroneous,
+ upper: 0,
+ children,
+ }
+ }
+
+ /// Set a synthetic span for the node and all its descendants.
+ fn synthesize(&mut self, span: Span) {
+ self.span = span;
+ self.upper = span.number();
+ for child in &mut self.children {
+ child.synthesize(span);
+ }
+ }
+
+ /// Assign span numbers `within` an interval to this node's subtree or just
+ /// a `range` of its children.
+ fn numberize(
+ &mut self,
+ id: FileId,
+ range: Option<Range<usize>>,
+ within: Range<u64>,
+ ) -> NumberingResult {
+ // Determine how many nodes we will number.
+ let descendants = match &range {
+ Some(range) if range.is_empty() => return Ok(()),
+ Some(range) => self.children[range.clone()]
+ .iter()
+ .map(SyntaxNode::descendants)
+ .sum::<usize>(),
+ None => self.descendants,
+ };
+
+ // Determine the distance between two neighbouring assigned numbers. If
+ // possible, we try to fit all numbers into the left half of `within`
+ // so that there is space for future insertions.
+ let space = within.end - within.start;
+ let mut stride = space / (2 * descendants as u64);
+ if stride == 0 {
+ stride = space / self.descendants as u64;
+ if stride == 0 {
+ return Err(Unnumberable);
+ }
+ }
+
+ // Number the node itself.
+ let mut start = within.start;
+ if range.is_none() {
+ let end = start + stride;
+ self.span = Span::new(id, (start + end) / 2);
+ self.upper = within.end;
+ start = end;
+ }
+
+ // Number the children.
+ let len = self.children.len();
+ for child in &mut self.children[range.unwrap_or(0..len)] {
+ let end = start + child.descendants() as u64 * stride;
+ child.numberize(id, start..end)?;
+ start = end;
+ }
+
+ Ok(())
+ }
+
+ /// Replaces a range of children with a replacement.
+ ///
+ /// May have mutated the children if it returns `Err(_)`.
+ fn replace_children(
+ &mut self,
+ mut range: Range<usize>,
+ replacement: Vec<SyntaxNode>,
+ ) -> NumberingResult {
+ let superseded = &self.children[range.clone()];
+
+ // Compute the new byte length.
+ self.len = self.len + replacement.iter().map(SyntaxNode::len).sum::<usize>()
+ - superseded.iter().map(SyntaxNode::len).sum::<usize>();
+
+ // Compute the new number of descendants.
+ self.descendants = self.descendants
+ + replacement.iter().map(SyntaxNode::descendants).sum::<usize>()
+ - superseded.iter().map(SyntaxNode::descendants).sum::<usize>();
+
+ // Determine whether we're still erroneous after the replacement. That's
+ // the case if
+ // - any of the new nodes is erroneous,
+ // - or if we were erroneous before due to a non-superseded node.
+ self.erroneous = replacement.iter().any(SyntaxNode::erroneous)
+ || (self.erroneous
+ && (self.children[..range.start].iter().any(SyntaxNode::erroneous))
+ || self.children[range.end..].iter().any(SyntaxNode::erroneous));
+
+ // Perform the replacement.
+ let replacement_count = replacement.len();
+ self.children.splice(range.clone(), replacement);
+ range.end = range.start + replacement_count;
+
+ // Renumber the new children. Retries until it works, taking
+ // exponentially more children into account.
+ let mut left = 0;
+ let mut right = 0;
+ let max_left = range.start;
+ let max_right = self.children.len() - range.end;
+ loop {
+ let renumber = range.start - left..range.end + right;
+
+ // The minimum assignable number is either
+ // - the upper bound of the node right before the to-be-renumbered
+ // children,
+ // - or this inner node's span number plus one if renumbering starts
+ // at the first child.
+ let start_number = renumber
+ .start
+ .checked_sub(1)
+ .and_then(|i| self.children.get(i))
+ .map_or(self.span.number() + 1, |child| child.upper());
+
+ // The upper bound for renumbering is either
+ // - the span number of the first child after the to-be-renumbered
+ // children,
+ // - or this node's upper bound if renumbering ends behind the last
+ // child.
+ let end_number = self
+ .children
+ .get(renumber.end)
+ .map_or(self.upper, |next| next.span().number());
+
+ // Try to renumber.
+ let within = start_number..end_number;
+ let id = self.span.id();
+ if self.numberize(id, Some(renumber), within).is_ok() {
+ return Ok(());
+ }
+
+ // If it didn't even work with all children, we give up.
+ if left == max_left && right == max_right {
+ return Err(Unnumberable);
+ }
+
+ // Exponential expansion to both sides.
+ left = (left + 1).next_power_of_two().min(max_left);
+ right = (right + 1).next_power_of_two().min(max_right);
+ }
+ }
+
+ /// Update this node after changes were made to one of its children.
+ fn update_parent(
+ &mut self,
+ prev_len: usize,
+ new_len: usize,
+ prev_descendants: usize,
+ new_descendants: usize,
+ ) {
+ self.len = self.len + new_len - prev_len;
+ self.descendants = self.descendants + new_descendants - prev_descendants;
+ self.erroneous = self.children.iter().any(SyntaxNode::erroneous);
+ }
+}
+
+impl Debug for InnerNode {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{:?}: {}", self.kind, self.len)?;
+ if !self.children.is_empty() {
+ f.write_str(" ")?;
+ f.debug_list().entries(&self.children).finish()?;
+ }
+ Ok(())
+ }
+}
+
+/// An error node in the untyped syntax tree.
+#[derive(Clone, Eq, PartialEq, Hash)]
+struct ErrorNode {
+ /// The error message.
+ message: EcoString,
+ /// The source text of the node.
+ text: EcoString,
+ /// The node's span.
+ span: Span,
+}
+
+impl ErrorNode {
+ /// Create new error node.
+ fn new(message: impl Into<EcoString>, text: impl Into<EcoString>) -> Self {
+ Self {
+ message: message.into(),
+ text: text.into(),
+ span: Span::detached(),
+ }
+ }
+
+ /// The byte length of the node in the source text.
+ fn len(&self) -> usize {
+ self.text.len()
+ }
+}
+
+impl Debug for ErrorNode {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Error: {:?} ({})", self.text, self.message)
+ }
+}
+
+/// A syntax node in a context.
+///
+/// Knows its exact offset in the file and provides access to its
+/// children, parent and siblings.
+///
+/// **Note that all sibling and leaf accessors skip over trivia!**
+#[derive(Clone)]
+pub struct LinkedNode<'a> {
+ node: &'a SyntaxNode,
+ parent: Option<Rc<Self>>,
+ index: usize,
+ offset: usize,
+}
+
+impl<'a> LinkedNode<'a> {
+ /// Start a new traversal at a root node.
+ pub fn new(root: &'a SyntaxNode) -> Self {
+ Self { node: root, parent: None, index: 0, offset: 0 }
+ }
+
+ /// Get the contained syntax node.
+ pub fn get(&self) -> &'a SyntaxNode {
+ self.node
+ }
+
+ /// The index of this node in its parent's children list.
+ pub fn index(&self) -> usize {
+ self.index
+ }
+
+ /// The absolute byte offset of this node in the source file.
+ pub fn offset(&self) -> usize {
+ self.offset
+ }
+
+ /// The byte range of this node in the source file.
+ pub fn range(&self) -> Range<usize> {
+ self.offset..self.offset + self.node.len()
+ }
+
+ /// An iterator over this node's children.
+ pub fn children(&self) -> LinkedChildren<'a> {
+ LinkedChildren {
+ parent: Rc::new(self.clone()),
+ iter: self.node.children().enumerate(),
+ front: self.offset,
+ back: self.offset + self.len(),
+ }
+ }
+
+ /// Find a descendant with the given span.
+ pub fn find(&self, span: Span) -> Option<LinkedNode<'a>> {
+ if self.span() == span {
+ return Some(self.clone());
+ }
+
+ if let Repr::Inner(inner) = &self.0 {
+ // The parent of a subtree has a smaller span number than all of its
+ // descendants. Therefore, we can bail out early if the target span's
+ // number is smaller than our number.
+ if span.number() < inner.span.number() {
+ return None;
+ }
+
+ let mut children = self.children().peekable();
+ while let Some(child) = children.next() {
+ // Every node in this child's subtree has a smaller span number than
+ // the next sibling. Therefore we only need to recurse if the next
+ // sibling's span number is larger than the target span's number.
+ if children
+ .peek()
+ .map_or(true, |next| next.span().number() > span.number())
+ {
+ if let Some(found) = child.find(span) {
+ return Some(found);
+ }
+ }
+ }
+ }
+
+ None
+ }
+}
+
+/// Access to parents and siblings.
+impl<'a> LinkedNode<'a> {
+ /// Get this node's parent.
+ pub fn parent(&self) -> Option<&Self> {
+ self.parent.as_deref()
+ }
+
+ /// Get the first previous non-trivia sibling node.
+ pub fn prev_sibling(&self) -> Option<Self> {
+ let parent = self.parent()?;
+ let index = self.index.checked_sub(1)?;
+ let node = parent.node.children().nth(index)?;
+ let offset = self.offset - node.len();
+ let prev = Self { node, parent: self.parent.clone(), index, offset };
+ if prev.kind().is_trivia() {
+ prev.prev_sibling()
+ } else {
+ Some(prev)
+ }
+ }
+
+ /// Get the next non-trivia sibling node.
+ pub fn next_sibling(&self) -> Option<Self> {
+ let parent = self.parent()?;
+ let index = self.index.checked_add(1)?;
+ let node = parent.node.children().nth(index)?;
+ let offset = self.offset + self.node.len();
+ let next = Self { node, parent: self.parent.clone(), index, offset };
+ if next.kind().is_trivia() {
+ next.next_sibling()
+ } else {
+ Some(next)
+ }
+ }
+
+ /// Get the kind of this node's parent.
+ pub fn parent_kind(&self) -> Option<SyntaxKind> {
+ Some(self.parent()?.node.kind())
+ }
+
+ /// Get the kind of this node's first previous non-trivia sibling.
+ pub fn prev_sibling_kind(&self) -> Option<SyntaxKind> {
+ Some(self.prev_sibling()?.node.kind())
+ }
+
+ /// Get the kind of this node's next non-trivia sibling.
+ pub fn next_sibling_kind(&self) -> Option<SyntaxKind> {
+ Some(self.next_sibling()?.node.kind())
+ }
+}
+
+/// Access to leafs.
+impl<'a> LinkedNode<'a> {
+ /// Get the rightmost non-trivia leaf before this node.
+ pub fn prev_leaf(&self) -> Option<Self> {
+ let mut node = self.clone();
+ while let Some(prev) = node.prev_sibling() {
+ if let Some(leaf) = prev.rightmost_leaf() {
+ return Some(leaf);
+ }
+ node = prev;
+ }
+ self.parent()?.prev_leaf()
+ }
+
+ /// Find the leftmost contained non-trivia leaf.
+ pub fn leftmost_leaf(&self) -> Option<Self> {
+ if self.is_leaf() && !self.kind().is_trivia() && !self.kind().is_error() {
+ return Some(self.clone());
+ }
+
+ for child in self.children() {
+ if let Some(leaf) = child.leftmost_leaf() {
+ return Some(leaf);
+ }
+ }
+
+ None
+ }
+
+ /// Get the leaf at the specified byte offset.
+ pub fn leaf_at(&self, cursor: usize) -> Option<Self> {
+ if self.node.children().len() == 0 && cursor <= self.offset + self.len() {
+ return Some(self.clone());
+ }
+
+ let mut offset = self.offset;
+ let count = self.node.children().len();
+ for (i, child) in self.children().enumerate() {
+ let len = child.len();
+ if (offset < cursor && cursor <= offset + len)
+ || (offset == cursor && i + 1 == count)
+ {
+ return child.leaf_at(cursor);
+ }
+ offset += len;
+ }
+
+ None
+ }
+
+ /// Find the rightmost contained non-trivia leaf.
+ pub fn rightmost_leaf(&self) -> Option<Self> {
+ if self.is_leaf() && !self.kind().is_trivia() {
+ return Some(self.clone());
+ }
+
+ for child in self.children().rev() {
+ if let Some(leaf) = child.rightmost_leaf() {
+ return Some(leaf);
+ }
+ }
+
+ None
+ }
+
+ /// Get the leftmost non-trivia leaf after this node.
+ pub fn next_leaf(&self) -> Option<Self> {
+ let mut node = self.clone();
+ while let Some(next) = node.next_sibling() {
+ if let Some(leaf) = next.leftmost_leaf() {
+ return Some(leaf);
+ }
+ node = next;
+ }
+ self.parent()?.next_leaf()
+ }
+}
+
+impl Deref for LinkedNode<'_> {
+ type Target = SyntaxNode;
+
+ fn deref(&self) -> &Self::Target {
+ self.get()
+ }
+}
+
+impl Debug for LinkedNode<'_> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.node.fmt(f)
+ }
+}
+
+/// An iterator over the children of a linked node.
+pub struct LinkedChildren<'a> {
+ parent: Rc<LinkedNode<'a>>,
+ iter: std::iter::Enumerate<std::slice::Iter<'a, SyntaxNode>>,
+ front: usize,
+ back: usize,
+}
+
+impl<'a> Iterator for LinkedChildren<'a> {
+ type Item = LinkedNode<'a>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.iter.next().map(|(index, node)| {
+ let offset = self.front;
+ self.front += node.len();
+ LinkedNode {
+ node,
+ parent: Some(self.parent.clone()),
+ index,
+ offset,
+ }
+ })
+ }
+
+ fn size_hint(&self) -> (usize, Option<usize>) {
+ self.iter.size_hint()
+ }
+}
+
+impl DoubleEndedIterator for LinkedChildren<'_> {
+ fn next_back(&mut self) -> Option<Self::Item> {
+ self.iter.next_back().map(|(index, node)| {
+ self.back -= node.len();
+ LinkedNode {
+ node,
+ parent: Some(self.parent.clone()),
+ index,
+ offset: self.back,
+ }
+ })
+ }
+}
+
+impl ExactSizeIterator for LinkedChildren<'_> {}
+
+/// Result of numbering a node within an interval.
+pub(super) type NumberingResult = Result<(), Unnumberable>;
+
+/// Indicates that a node cannot be numbered within a given interval.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub(super) struct Unnumberable;
+
+impl Display for Unnumberable {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.pad("cannot number within this interval")
+ }
+}
+
+impl std::error::Error for Unnumberable {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::syntax::Source;
+
+ #[test]
+ fn test_linked_node() {
+ let source = Source::detached("#set text(12pt, red)");
+
+ // Find "text".
+ let node = LinkedNode::new(source.root()).leaf_at(7).unwrap();
+ assert_eq!(node.offset(), 5);
+ assert_eq!(node.text(), "text");
+
+ // Go back to "#set". Skips the space.
+ let prev = node.prev_sibling().unwrap();
+ assert_eq!(prev.offset(), 1);
+ assert_eq!(prev.text(), "set");
+ }
+
+ #[test]
+ fn test_linked_node_non_trivia_leaf() {
+ let source = Source::detached("#set fun(12pt, red)");
+ let leaf = LinkedNode::new(source.root()).leaf_at(6).unwrap();
+ let prev = leaf.prev_leaf().unwrap();
+ assert_eq!(leaf.text(), "fun");
+ assert_eq!(prev.text(), "set");
+
+ let source = Source::detached("#let x = 10");
+ let leaf = LinkedNode::new(source.root()).leaf_at(9).unwrap();
+ let prev = leaf.prev_leaf().unwrap();
+ let next = leaf.next_leaf().unwrap();
+ assert_eq!(prev.text(), "=");
+ assert_eq!(leaf.text(), " ");
+ assert_eq!(next.text(), "10");
+ }
+}
diff --git a/crates/typst/src/syntax/parser.rs b/crates/typst/src/syntax/parser.rs
new file mode 100644
index 00000000..54670df5
--- /dev/null
+++ b/crates/typst/src/syntax/parser.rs
@@ -0,0 +1,1643 @@
+use std::collections::HashSet;
+use std::ops::Range;
+
+use ecow::{eco_format, EcoString};
+use unicode_math_class::MathClass;
+
+use super::{ast, is_newline, LexMode, Lexer, SyntaxKind, SyntaxNode};
+
+/// Parse a source file.
+pub fn parse(text: &str) -> SyntaxNode {
+ let mut p = Parser::new(text, 0, LexMode::Markup);
+ markup(&mut p, true, 0, |_| false);
+ p.finish().into_iter().next().unwrap()
+}
+
+/// Parse code directly.
+///
+/// This is only used for syntax highlighting.
+pub fn parse_code(text: &str) -> SyntaxNode {
+ let mut p = Parser::new(text, 0, LexMode::Code);
+ let m = p.marker();
+ p.skip();
+ code_exprs(&mut p, |_| false);
+ p.wrap_skipless(m, SyntaxKind::Code);
+ p.finish().into_iter().next().unwrap()
+}
+
+fn markup(
+ p: &mut Parser,
+ mut at_start: bool,
+ min_indent: usize,
+ mut stop: impl FnMut(&Parser) -> bool,
+) {
+ let m = p.marker();
+ let mut nesting: usize = 0;
+ while !p.eof() {
+ match p.current() {
+ SyntaxKind::LeftBracket => nesting += 1,
+ SyntaxKind::RightBracket if nesting > 0 => nesting -= 1,
+ _ if stop(p) => break,
+ _ => {}
+ }
+
+ if p.newline() {
+ at_start = true;
+ if min_indent > 0 && p.column(p.current_end()) < min_indent {
+ break;
+ }
+ p.eat();
+ continue;
+ }
+
+ let prev = p.prev_end();
+ markup_expr(p, &mut at_start);
+ if !p.progress(prev) {
+ p.unexpected();
+ }
+ }
+ p.wrap(m, SyntaxKind::Markup);
+}
+
+pub(super) fn reparse_markup(
+ text: &str,
+ range: Range<usize>,
+ at_start: &mut bool,
+ nesting: &mut usize,
+ mut stop: impl FnMut(SyntaxKind) -> bool,
+) -> Option<Vec<SyntaxNode>> {
+ let mut p = Parser::new(text, range.start, LexMode::Markup);
+ while !p.eof() && p.current_start() < range.end {
+ match p.current() {
+ SyntaxKind::LeftBracket => *nesting += 1,
+ SyntaxKind::RightBracket if *nesting > 0 => *nesting -= 1,
+ _ if stop(p.current()) => break,
+ _ => {}
+ }
+
+ if p.newline() {
+ *at_start = true;
+ p.eat();
+ continue;
+ }
+
+ let prev = p.prev_end();
+ markup_expr(&mut p, at_start);
+ if !p.progress(prev) {
+ p.unexpected();
+ }
+ }
+ (p.balanced && p.current_start() == range.end).then(|| p.finish())
+}
+
+fn markup_expr(p: &mut Parser, at_start: &mut bool) {
+ match p.current() {
+ SyntaxKind::Space
+ | SyntaxKind::Parbreak
+ | SyntaxKind::LineComment
+ | SyntaxKind::BlockComment => {
+ p.eat();
+ return;
+ }
+
+ SyntaxKind::Text
+ | SyntaxKind::Linebreak
+ | SyntaxKind::Escape
+ | SyntaxKind::Shorthand
+ | SyntaxKind::SmartQuote
+ | SyntaxKind::Raw
+ | SyntaxKind::Link
+ | SyntaxKind::Label => p.eat(),
+
+ SyntaxKind::Hashtag => embedded_code_expr(p),
+ SyntaxKind::Star => strong(p),
+ SyntaxKind::Underscore => emph(p),
+ SyntaxKind::HeadingMarker if *at_start => heading(p),
+ SyntaxKind::ListMarker if *at_start => list_item(p),
+ SyntaxKind::EnumMarker if *at_start => enum_item(p),
+ SyntaxKind::TermMarker if *at_start => term_item(p),
+ SyntaxKind::RefMarker => reference(p),
+ SyntaxKind::Dollar => equation(p),
+
+ SyntaxKind::LeftBracket
+ | SyntaxKind::RightBracket
+ | SyntaxKind::HeadingMarker
+ | SyntaxKind::ListMarker
+ | SyntaxKind::EnumMarker
+ | SyntaxKind::TermMarker
+ | SyntaxKind::Colon => p.convert(SyntaxKind::Text),
+
+ _ => {}
+ }
+
+ *at_start = false;
+}
+
+fn strong(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Star);
+ markup(p, false, 0, |p| {
+ p.at(SyntaxKind::Star)
+ || p.at(SyntaxKind::Parbreak)
+ || p.at(SyntaxKind::RightBracket)
+ });
+ p.expect_closing_delimiter(m, SyntaxKind::Star);
+ p.wrap(m, SyntaxKind::Strong);
+}
+
+fn emph(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Underscore);
+ markup(p, false, 0, |p| {
+ p.at(SyntaxKind::Underscore)
+ || p.at(SyntaxKind::Parbreak)
+ || p.at(SyntaxKind::RightBracket)
+ });
+ p.expect_closing_delimiter(m, SyntaxKind::Underscore);
+ p.wrap(m, SyntaxKind::Emph);
+}
+
+fn heading(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::HeadingMarker);
+ whitespace_line(p);
+ markup(p, false, usize::MAX, |p| {
+ p.at(SyntaxKind::Label)
+ || p.at(SyntaxKind::RightBracket)
+ || (p.at(SyntaxKind::Space) && p.lexer.clone().next() == SyntaxKind::Label)
+ });
+ p.wrap(m, SyntaxKind::Heading);
+}
+
+fn list_item(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::ListMarker);
+ let min_indent = p.column(p.prev_end());
+ whitespace_line(p);
+ markup(p, false, min_indent, |p| p.at(SyntaxKind::RightBracket));
+ p.wrap(m, SyntaxKind::ListItem);
+}
+
+fn enum_item(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::EnumMarker);
+ let min_indent = p.column(p.prev_end());
+ whitespace_line(p);
+ markup(p, false, min_indent, |p| p.at(SyntaxKind::RightBracket));
+ p.wrap(m, SyntaxKind::EnumItem);
+}
+
+fn term_item(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::TermMarker);
+ let min_indent = p.column(p.prev_end());
+ whitespace_line(p);
+ markup(p, false, usize::MAX, |p| {
+ p.at(SyntaxKind::Colon) || p.at(SyntaxKind::RightBracket)
+ });
+ p.expect(SyntaxKind::Colon);
+ whitespace_line(p);
+ markup(p, false, min_indent, |p| p.at(SyntaxKind::RightBracket));
+ p.wrap(m, SyntaxKind::TermItem);
+}
+
+fn reference(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::RefMarker);
+ if p.directly_at(SyntaxKind::LeftBracket) {
+ content_block(p);
+ }
+ p.wrap(m, SyntaxKind::Ref);
+}
+
+fn whitespace_line(p: &mut Parser) {
+ while !p.newline() && p.current().is_trivia() {
+ p.eat();
+ }
+}
+
+fn equation(p: &mut Parser) {
+ let m = p.marker();
+ p.enter(LexMode::Math);
+ p.assert(SyntaxKind::Dollar);
+ math(p, |p| p.at(SyntaxKind::Dollar));
+ p.expect_closing_delimiter(m, SyntaxKind::Dollar);
+ p.exit();
+ p.wrap(m, SyntaxKind::Equation);
+}
+
+fn math(p: &mut Parser, mut stop: impl FnMut(&Parser) -> bool) {
+ let m = p.marker();
+ while !p.eof() && !stop(p) {
+ let prev = p.prev_end();
+ math_expr(p);
+ if !p.progress(prev) {
+ p.unexpected();
+ }
+ }
+ p.wrap(m, SyntaxKind::Math);
+}
+
+fn math_expr(p: &mut Parser) {
+ math_expr_prec(p, 0, SyntaxKind::Eof)
+}
+
+fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) {
+ let m = p.marker();
+ let mut continuable = false;
+ match p.current() {
+ SyntaxKind::Hashtag => embedded_code_expr(p),
+ SyntaxKind::MathIdent => {
+ continuable = true;
+ p.eat();
+ while p.directly_at(SyntaxKind::Text)
+ && p.current_text() == "."
+ && matches!(
+ p.lexer.clone().next(),
+ SyntaxKind::MathIdent | SyntaxKind::Text
+ )
+ {
+ p.convert(SyntaxKind::Dot);
+ p.convert(SyntaxKind::Ident);
+ p.wrap(m, SyntaxKind::FieldAccess);
+ }
+ if min_prec < 3 && p.directly_at(SyntaxKind::Text) && p.current_text() == "("
+ {
+ math_args(p);
+ p.wrap(m, SyntaxKind::FuncCall);
+ continuable = false;
+ }
+ }
+
+ SyntaxKind::Text | SyntaxKind::Shorthand => {
+ continuable = matches!(
+ math_class(p.current_text()),
+ None | Some(MathClass::Alphabetic)
+ );
+ if !maybe_delimited(p, true) {
+ p.eat();
+ }
+ }
+
+ SyntaxKind::Linebreak | SyntaxKind::MathAlignPoint => p.eat(),
+ SyntaxKind::Escape | SyntaxKind::Str => {
+ continuable = true;
+ p.eat();
+ }
+
+ SyntaxKind::Root => {
+ if min_prec < 3 {
+ p.eat();
+ let m2 = p.marker();
+ math_expr_prec(p, 2, stop);
+ math_unparen(p, m2);
+ p.wrap(m, SyntaxKind::MathRoot);
+ }
+ }
+
+ _ => p.expected("expression"),
+ }
+
+ if continuable
+ && min_prec < 3
+ && p.prev_end() == p.current_start()
+ && maybe_delimited(p, false)
+ {
+ p.wrap(m, SyntaxKind::Math);
+ }
+
+ while !p.eof() && !p.at(stop) {
+ if p.directly_at(SyntaxKind::Text) && p.current_text() == "!" {
+ p.eat();
+ p.wrap(m, SyntaxKind::Math);
+ continue;
+ }
+
+ let Some((kind, stop, assoc, mut prec)) = math_op(p.current()) else {
+ break;
+ };
+
+ if prec < min_prec {
+ break;
+ }
+
+ match assoc {
+ ast::Assoc::Left => prec += 1,
+ ast::Assoc::Right => {}
+ }
+
+ if kind == SyntaxKind::MathFrac {
+ math_unparen(p, m);
+ }
+
+ p.eat();
+ let m2 = p.marker();
+ math_expr_prec(p, prec, stop);
+ math_unparen(p, m2);
+
+ if p.eat_if(SyntaxKind::Underscore) || p.eat_if(SyntaxKind::Hat) {
+ let m3 = p.marker();
+ math_expr_prec(p, prec, SyntaxKind::Eof);
+ math_unparen(p, m3);
+ }
+
+ p.wrap(m, kind);
+ }
+}
+
+fn maybe_delimited(p: &mut Parser, allow_fence: bool) -> bool {
+ if allow_fence && math_class(p.current_text()) == Some(MathClass::Fence) {
+ math_delimited(p, MathClass::Fence);
+ true
+ } else if math_class(p.current_text()) == Some(MathClass::Opening) {
+ math_delimited(p, MathClass::Closing);
+ true
+ } else {
+ false
+ }
+}
+
+fn math_delimited(p: &mut Parser, stop: MathClass) {
+ let m = p.marker();
+ p.eat();
+ let m2 = p.marker();
+ while !p.eof() && !p.at(SyntaxKind::Dollar) {
+ let class = math_class(p.current_text());
+ if stop == MathClass::Fence && class == Some(MathClass::Closing) {
+ break;
+ }
+
+ if class == Some(stop) {
+ p.wrap(m2, SyntaxKind::Math);
+ p.eat();
+ p.wrap(m, SyntaxKind::MathDelimited);
+ return;
+ }
+
+ let prev = p.prev_end();
+ math_expr(p);
+ if !p.progress(prev) {
+ p.unexpected();
+ }
+ }
+
+ p.wrap(m, SyntaxKind::Math);
+}
+
+fn math_unparen(p: &mut Parser, m: Marker) {
+ let Some(node) = p.nodes.get_mut(m.0) else { return };
+ if node.kind() != SyntaxKind::MathDelimited {
+ return;
+ }
+
+ if let [first, .., last] = node.children_mut() {
+ if first.text() == "(" && last.text() == ")" {
+ first.convert_to_kind(SyntaxKind::LeftParen);
+ last.convert_to_kind(SyntaxKind::RightParen);
+ }
+ }
+
+ node.convert_to_kind(SyntaxKind::Math);
+}
+
+fn math_class(text: &str) -> Option<MathClass> {
+ match text {
+ "[|" => return Some(MathClass::Opening),
+ "|]" => return Some(MathClass::Closing),
+ "||" => return Some(MathClass::Fence),
+ _ => {}
+ }
+
+ let mut chars = text.chars();
+ chars
+ .next()
+ .filter(|_| chars.next().is_none())
+ .and_then(unicode_math_class::class)
+}
+
+fn math_op(kind: SyntaxKind) -> Option<(SyntaxKind, SyntaxKind, ast::Assoc, usize)> {
+ match kind {
+ SyntaxKind::Underscore => {
+ Some((SyntaxKind::MathAttach, SyntaxKind::Hat, ast::Assoc::Right, 2))
+ }
+ SyntaxKind::Hat => {
+ Some((SyntaxKind::MathAttach, SyntaxKind::Underscore, ast::Assoc::Right, 2))
+ }
+ SyntaxKind::Slash => {
+ Some((SyntaxKind::MathFrac, SyntaxKind::Eof, ast::Assoc::Left, 1))
+ }
+ _ => None,
+ }
+}
+
+fn math_args(p: &mut Parser) {
+ let m = p.marker();
+ p.convert(SyntaxKind::LeftParen);
+
+ let mut namable = true;
+ let mut named = None;
+ let mut has_arrays = false;
+ let mut array = p.marker();
+ let mut arg = p.marker();
+
+ while !p.eof() && !p.at(SyntaxKind::Dollar) {
+ if namable
+ && (p.at(SyntaxKind::MathIdent) || p.at(SyntaxKind::Text))
+ && p.text[p.current_end()..].starts_with(':')
+ {
+ p.convert(SyntaxKind::Ident);
+ p.convert(SyntaxKind::Colon);
+ named = Some(arg);
+ arg = p.marker();
+ array = p.marker();
+ }
+
+ match p.current_text() {
+ ")" => break,
+ ";" => {
+ maybe_wrap_in_math(p, arg, named);
+ p.wrap(array, SyntaxKind::Array);
+ p.convert(SyntaxKind::Semicolon);
+ array = p.marker();
+ arg = p.marker();
+ namable = true;
+ named = None;
+ has_arrays = true;
+ continue;
+ }
+ "," => {
+ maybe_wrap_in_math(p, arg, named);
+ p.convert(SyntaxKind::Comma);
+ arg = p.marker();
+ namable = true;
+ if named.is_some() {
+ array = p.marker();
+ named = None;
+ }
+ continue;
+ }
+ _ => {}
+ }
+
+ let prev = p.prev_end();
+ math_expr(p);
+ if !p.progress(prev) {
+ p.unexpected();
+ }
+
+ namable = false;
+ }
+
+ if arg != p.marker() {
+ maybe_wrap_in_math(p, arg, named);
+ if named.is_some() {
+ array = p.marker();
+ }
+ }
+
+ if has_arrays && array != p.marker() {
+ p.wrap(array, SyntaxKind::Array);
+ }
+
+ if p.at(SyntaxKind::Text) && p.current_text() == ")" {
+ p.convert(SyntaxKind::RightParen);
+ } else {
+ p.expected("closing paren");
+ p.balanced = false;
+ }
+
+ p.wrap(m, SyntaxKind::Args);
+}
+
+fn maybe_wrap_in_math(p: &mut Parser, arg: Marker, named: Option<Marker>) {
+ let exprs = p.post_process(arg).filter(|node| node.is::<ast::Expr>()).count();
+ if exprs != 1 {
+ p.wrap(arg, SyntaxKind::Math);
+ }
+
+ if let Some(m) = named {
+ p.wrap(m, SyntaxKind::Named);
+ }
+}
+
+fn code(p: &mut Parser, stop: impl FnMut(&Parser) -> bool) {
+ let m = p.marker();
+ code_exprs(p, stop);
+ p.wrap(m, SyntaxKind::Code);
+}
+
+fn code_exprs(p: &mut Parser, mut stop: impl FnMut(&Parser) -> bool) {
+ while !p.eof() && !stop(p) {
+ p.stop_at_newline(true);
+ let prev = p.prev_end();
+ code_expr(p);
+ if p.progress(prev) && !p.eof() && !stop(p) && !p.eat_if(SyntaxKind::Semicolon) {
+ p.expected("semicolon or line break");
+ }
+ p.unstop();
+ if !p.progress(prev) && !p.eof() {
+ p.unexpected();
+ }
+ }
+}
+
+fn code_expr(p: &mut Parser) {
+ code_expr_prec(p, false, 0, false)
+}
+
+fn code_expr_or_pattern(p: &mut Parser) {
+ code_expr_prec(p, false, 0, true)
+}
+
+fn embedded_code_expr(p: &mut Parser) {
+ p.stop_at_newline(true);
+ p.enter(LexMode::Code);
+ p.assert(SyntaxKind::Hashtag);
+ p.unskip();
+
+ let stmt = matches!(
+ p.current(),
+ SyntaxKind::Let
+ | SyntaxKind::Set
+ | SyntaxKind::Show
+ | SyntaxKind::Import
+ | SyntaxKind::Include
+ );
+
+ let prev = p.prev_end();
+ code_expr_prec(p, true, 0, false);
+
+ // Consume error for things like `#12p` or `#"abc\"`.
+ if !p.progress(prev) {
+ p.unexpected();
+ }
+
+ let semi =
+ (stmt || p.directly_at(SyntaxKind::Semicolon)) && p.eat_if(SyntaxKind::Semicolon);
+
+ if stmt && !semi && !p.eof() && !p.at(SyntaxKind::RightBracket) {
+ p.expected("semicolon or line break");
+ }
+
+ p.exit();
+ p.unstop();
+}
+
+fn code_expr_prec(
+ p: &mut Parser,
+ atomic: bool,
+ min_prec: usize,
+ allow_destructuring: bool,
+) {
+ let m = p.marker();
+ if let (false, Some(op)) = (atomic, ast::UnOp::from_kind(p.current())) {
+ p.eat();
+ code_expr_prec(p, atomic, op.precedence(), false);
+ p.wrap(m, SyntaxKind::Unary);
+ } else {
+ code_primary(p, atomic, allow_destructuring);
+ }
+
+ loop {
+ if p.directly_at(SyntaxKind::LeftParen) || p.directly_at(SyntaxKind::LeftBracket)
+ {
+ args(p);
+ p.wrap(m, SyntaxKind::FuncCall);
+ continue;
+ }
+
+ let at_field_or_method =
+ p.directly_at(SyntaxKind::Dot) && p.lexer.clone().next() == SyntaxKind::Ident;
+
+ if atomic && !at_field_or_method {
+ break;
+ }
+
+ if p.eat_if(SyntaxKind::Dot) {
+ p.expect(SyntaxKind::Ident);
+ p.wrap(m, SyntaxKind::FieldAccess);
+ continue;
+ }
+
+ let binop =
+ if ast::BinOp::NotIn.precedence() >= min_prec && p.eat_if(SyntaxKind::Not) {
+ if p.at(SyntaxKind::In) {
+ Some(ast::BinOp::NotIn)
+ } else {
+ p.expected("keyword `in`");
+ break;
+ }
+ } else {
+ ast::BinOp::from_kind(p.current())
+ };
+
+ if let Some(op) = binop {
+ let mut prec = op.precedence();
+ if prec < min_prec {
+ break;
+ }
+
+ match op.assoc() {
+ ast::Assoc::Left => prec += 1,
+ ast::Assoc::Right => {}
+ }
+
+ p.eat();
+ code_expr_prec(p, false, prec, false);
+ p.wrap(m, SyntaxKind::Binary);
+ continue;
+ }
+
+ break;
+ }
+}
+
+fn code_primary(p: &mut Parser, atomic: bool, allow_destructuring: bool) {
+ let m = p.marker();
+ match p.current() {
+ SyntaxKind::Ident => {
+ p.eat();
+ if !atomic && p.at(SyntaxKind::Arrow) {
+ p.wrap(m, SyntaxKind::Params);
+ p.assert(SyntaxKind::Arrow);
+ code_expr(p);
+ p.wrap(m, SyntaxKind::Closure);
+ }
+ }
+ SyntaxKind::Underscore if !atomic => {
+ p.eat();
+ if p.at(SyntaxKind::Arrow) {
+ p.wrap(m, SyntaxKind::Params);
+ p.eat();
+ code_expr(p);
+ p.wrap(m, SyntaxKind::Closure);
+ } else if let Some(underscore) = p.node_mut(m) {
+ underscore.convert_to_error("expected expression, found underscore");
+ }
+ }
+
+ SyntaxKind::LeftBrace => code_block(p),
+ SyntaxKind::LeftBracket => content_block(p),
+ SyntaxKind::LeftParen => with_paren(p, allow_destructuring),
+ SyntaxKind::Dollar => equation(p),
+ SyntaxKind::Let => let_binding(p),
+ SyntaxKind::Set => set_rule(p),
+ SyntaxKind::Show => show_rule(p),
+ SyntaxKind::If => conditional(p),
+ SyntaxKind::While => while_loop(p),
+ SyntaxKind::For => for_loop(p),
+ SyntaxKind::Import => module_import(p),
+ SyntaxKind::Include => module_include(p),
+ SyntaxKind::Break => break_stmt(p),
+ SyntaxKind::Continue => continue_stmt(p),
+ SyntaxKind::Return => return_stmt(p),
+
+ SyntaxKind::None
+ | SyntaxKind::Auto
+ | SyntaxKind::Int
+ | SyntaxKind::Float
+ | SyntaxKind::Bool
+ | SyntaxKind::Numeric
+ | SyntaxKind::Str
+ | SyntaxKind::Label
+ | SyntaxKind::Raw => p.eat(),
+
+ _ => p.expected("expression"),
+ }
+}
+
+fn block(p: &mut Parser) {
+ match p.current() {
+ SyntaxKind::LeftBracket => content_block(p),
+ SyntaxKind::LeftBrace => code_block(p),
+ _ => p.expected("block"),
+ }
+}
+
+pub(super) fn reparse_block(text: &str, range: Range<usize>) -> Option<SyntaxNode> {
+ let mut p = Parser::new(text, range.start, LexMode::Code);
+ assert!(p.at(SyntaxKind::LeftBracket) || p.at(SyntaxKind::LeftBrace));
+ block(&mut p);
+ (p.balanced && p.prev_end() == range.end)
+ .then(|| p.finish().into_iter().next().unwrap())
+}
+
+fn code_block(p: &mut Parser) {
+ let m = p.marker();
+ p.enter(LexMode::Code);
+ p.stop_at_newline(false);
+ p.assert(SyntaxKind::LeftBrace);
+ code(p, |p| {
+ p.at(SyntaxKind::RightBrace)
+ || p.at(SyntaxKind::RightBracket)
+ || p.at(SyntaxKind::RightParen)
+ });
+ p.expect_closing_delimiter(m, SyntaxKind::RightBrace);
+ p.exit();
+ p.unstop();
+ p.wrap(m, SyntaxKind::CodeBlock);
+}
+
+fn content_block(p: &mut Parser) {
+ let m = p.marker();
+ p.enter(LexMode::Markup);
+ p.assert(SyntaxKind::LeftBracket);
+ markup(p, true, 0, |p| p.at(SyntaxKind::RightBracket));
+ p.expect_closing_delimiter(m, SyntaxKind::RightBracket);
+ p.exit();
+ p.wrap(m, SyntaxKind::ContentBlock);
+}
+
+fn with_paren(p: &mut Parser, allow_destructuring: bool) {
+ let m = p.marker();
+ let mut kind = collection(p, true);
+ if p.at(SyntaxKind::Arrow) {
+ validate_params_at(p, m);
+ p.wrap(m, SyntaxKind::Params);
+ p.assert(SyntaxKind::Arrow);
+ code_expr(p);
+ kind = SyntaxKind::Closure;
+ } else if p.at(SyntaxKind::Eq) && kind != SyntaxKind::Parenthesized {
+ // TODO: add warning if p.at(SyntaxKind::Eq) && kind == SyntaxKind::Parenthesized
+
+ validate_pattern_at(p, m, false);
+ p.wrap(m, SyntaxKind::Destructuring);
+ p.assert(SyntaxKind::Eq);
+ code_expr(p);
+ kind = SyntaxKind::DestructAssignment;
+ }
+
+ match kind {
+ SyntaxKind::Array if !allow_destructuring => validate_array_at(p, m),
+ SyntaxKind::Dict if !allow_destructuring => validate_dict_at(p, m),
+ SyntaxKind::Parenthesized if !allow_destructuring => {
+ validate_parenthesized_at(p, m)
+ }
+ SyntaxKind::Destructuring if !allow_destructuring => {
+ invalidate_destructuring(p, m)
+ }
+ _ => {}
+ }
+ p.wrap(m, kind);
+}
+
+fn invalidate_destructuring(p: &mut Parser, m: Marker) {
+ let mut collection_kind = Option::None;
+ for child in p.post_process(m) {
+ match child.kind() {
+ SyntaxKind::Named | SyntaxKind::Keyed => match collection_kind {
+ Some(SyntaxKind::Array) => child.convert_to_error(eco_format!(
+ "expected expression, found {}",
+ child.kind().name()
+ )),
+ _ => collection_kind = Some(SyntaxKind::Dict),
+ },
+ SyntaxKind::LeftParen | SyntaxKind::RightParen | SyntaxKind::Comma => {}
+ kind => match collection_kind {
+ Some(SyntaxKind::Dict) => child.convert_to_error(eco_format!(
+ "expected named or keyed pair, found {}",
+ kind.name()
+ )),
+ _ => collection_kind = Some(SyntaxKind::Array),
+ },
+ }
+ }
+}
+
+fn collection(p: &mut Parser, keyed: bool) -> SyntaxKind {
+ p.stop_at_newline(false);
+
+ let m = p.marker();
+ p.assert(SyntaxKind::LeftParen);
+
+ let mut count = 0;
+ let mut parenthesized = true;
+ let mut kind = None;
+ if keyed && p.eat_if(SyntaxKind::Colon) {
+ kind = Some(SyntaxKind::Dict);
+ parenthesized = false;
+ }
+
+ while !p.current().is_terminator() {
+ let prev = p.prev_end();
+ match item(p, keyed) {
+ SyntaxKind::Spread => parenthesized = false,
+ SyntaxKind::Named | SyntaxKind::Keyed => {
+ match kind {
+ Some(SyntaxKind::Array) => kind = Some(SyntaxKind::Destructuring),
+ _ => kind = Some(SyntaxKind::Dict),
+ }
+ parenthesized = false;
+ }
+ SyntaxKind::Int => match kind {
+ Some(SyntaxKind::Array) | None => kind = Some(SyntaxKind::Array),
+ Some(_) => kind = Some(SyntaxKind::Destructuring),
+ },
+ _ if kind.is_none() => kind = Some(SyntaxKind::Array),
+ _ => {}
+ }
+
+ if !p.progress(prev) {
+ p.unexpected();
+ continue;
+ }
+
+ count += 1;
+
+ if p.current().is_terminator() {
+ break;
+ }
+
+ if p.expect(SyntaxKind::Comma) {
+ parenthesized = false;
+ }
+ }
+
+ p.expect_closing_delimiter(m, SyntaxKind::RightParen);
+ p.unstop();
+
+ if parenthesized && count == 1 {
+ SyntaxKind::Parenthesized
+ } else {
+ kind.unwrap_or(SyntaxKind::Array)
+ }
+}
+
+fn item(p: &mut Parser, keyed: bool) -> SyntaxKind {
+ let m = p.marker();
+
+ if p.eat_if(SyntaxKind::Dots) {
+ if p.at(SyntaxKind::Comma) || p.at(SyntaxKind::RightParen) {
+ p.wrap(m, SyntaxKind::Spread);
+ return SyntaxKind::Spread;
+ }
+
+ code_expr(p);
+ p.wrap(m, SyntaxKind::Spread);
+ return SyntaxKind::Spread;
+ }
+
+ if p.at(SyntaxKind::Underscore) {
+ // This is a temporary workaround to fix `v.map(_ => {})`.
+ let mut lexer = p.lexer.clone();
+ let next =
+ std::iter::from_fn(|| Some(lexer.next())).find(|kind| !kind.is_trivia());
+ if next != Some(SyntaxKind::Arrow) {
+ p.eat();
+ return SyntaxKind::Underscore;
+ }
+ }
+
+ code_expr_or_pattern(p);
+
+ if !p.eat_if(SyntaxKind::Colon) {
+ return SyntaxKind::Int;
+ }
+
+ if !p.eat_if(SyntaxKind::Underscore) {
+ code_expr(p);
+ }
+
+ let kind = match p.node(m).map(SyntaxNode::kind) {
+ Some(SyntaxKind::Ident) => SyntaxKind::Named,
+ Some(SyntaxKind::Str) if keyed => SyntaxKind::Keyed,
+ _ => {
+ for child in p.post_process(m) {
+ if child.kind() == SyntaxKind::Colon {
+ break;
+ }
+
+ let mut message = EcoString::from("expected identifier");
+ if keyed {
+ message.push_str(" or string");
+ }
+ message.push_str(", found ");
+ message.push_str(child.kind().name());
+ child.convert_to_error(message);
+ }
+ SyntaxKind::Named
+ }
+ };
+
+ p.wrap(m, kind);
+ kind
+}
+
+fn args(p: &mut Parser) {
+ if !p.at(SyntaxKind::LeftParen) && !p.at(SyntaxKind::LeftBracket) {
+ p.expected("argument list");
+ }
+
+ let m = p.marker();
+ if p.at(SyntaxKind::LeftParen) {
+ collection(p, false);
+ validate_args_at(p, m);
+ }
+
+ while p.directly_at(SyntaxKind::LeftBracket) {
+ content_block(p);
+ }
+
+ p.wrap(m, SyntaxKind::Args);
+}
+
+enum PatternKind {
+ Ident,
+ Placeholder,
+ Destructuring,
+}
+
+fn pattern(p: &mut Parser) -> PatternKind {
+ let m = p.marker();
+ if p.at(SyntaxKind::LeftParen) {
+ let kind = collection(p, false);
+ validate_pattern_at(p, m, true);
+
+ if kind == SyntaxKind::Parenthesized {
+ PatternKind::Ident
+ } else {
+ p.wrap(m, SyntaxKind::Destructuring);
+ PatternKind::Destructuring
+ }
+ } else if p.eat_if(SyntaxKind::Underscore) {
+ PatternKind::Placeholder
+ } else {
+ p.expect(SyntaxKind::Ident);
+ PatternKind::Ident
+ }
+}
+
+fn let_binding(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Let);
+
+ let m2 = p.marker();
+ let mut closure = false;
+ let mut destructuring = false;
+ match pattern(p) {
+ PatternKind::Ident => {
+ closure = p.directly_at(SyntaxKind::LeftParen);
+ if closure {
+ let m3 = p.marker();
+ collection(p, false);
+ validate_params_at(p, m3);
+ p.wrap(m3, SyntaxKind::Params);
+ }
+ }
+ PatternKind::Placeholder => {}
+ PatternKind::Destructuring => destructuring = true,
+ }
+
+ let f = if closure || destructuring { Parser::expect } else { Parser::eat_if };
+ if f(p, SyntaxKind::Eq) {
+ code_expr(p);
+ }
+
+ if closure {
+ p.wrap(m2, SyntaxKind::Closure);
+ }
+
+ p.wrap(m, SyntaxKind::LetBinding);
+}
+
+fn set_rule(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Set);
+
+ let m2 = p.marker();
+ p.expect(SyntaxKind::Ident);
+ while p.eat_if(SyntaxKind::Dot) {
+ p.expect(SyntaxKind::Ident);
+ p.wrap(m2, SyntaxKind::FieldAccess);
+ }
+
+ args(p);
+ if p.eat_if(SyntaxKind::If) {
+ code_expr(p);
+ }
+ p.wrap(m, SyntaxKind::SetRule);
+}
+
+fn show_rule(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Show);
+ p.unskip();
+ let m2 = p.marker();
+ p.skip();
+
+ if !p.at(SyntaxKind::Colon) {
+ code_expr(p);
+ }
+
+ if p.eat_if(SyntaxKind::Colon) {
+ code_expr(p);
+ } else {
+ p.expected_at(m2, "colon");
+ }
+
+ p.wrap(m, SyntaxKind::ShowRule);
+}
+
+fn conditional(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::If);
+ code_expr(p);
+ block(p);
+ if p.eat_if(SyntaxKind::Else) {
+ if p.at(SyntaxKind::If) {
+ conditional(p);
+ } else {
+ block(p);
+ }
+ }
+ p.wrap(m, SyntaxKind::Conditional);
+}
+
+fn while_loop(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::While);
+ code_expr(p);
+ block(p);
+ p.wrap(m, SyntaxKind::WhileLoop);
+}
+
+fn for_loop(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::For);
+ pattern(p);
+ if p.at(SyntaxKind::Comma) {
+ p.expected("keyword `in` - did you mean to use a destructuring pattern?");
+ if !p.eat_if(SyntaxKind::Ident) {
+ p.eat_if(SyntaxKind::Underscore);
+ }
+ p.eat_if(SyntaxKind::In);
+ } else {
+ p.expect(SyntaxKind::In);
+ }
+ code_expr(p);
+ block(p);
+ p.wrap(m, SyntaxKind::ForLoop);
+}
+
+fn module_import(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Import);
+ code_expr(p);
+ if p.eat_if(SyntaxKind::Colon) && !p.eat_if(SyntaxKind::Star) {
+ import_items(p);
+ }
+ p.wrap(m, SyntaxKind::ModuleImport);
+}
+
+fn import_items(p: &mut Parser) {
+ let m = p.marker();
+ while !p.eof() && !p.at(SyntaxKind::Semicolon) {
+ if !p.eat_if(SyntaxKind::Ident) {
+ p.unexpected();
+ }
+ if p.current().is_terminator() {
+ break;
+ }
+ p.expect(SyntaxKind::Comma);
+ }
+ p.wrap(m, SyntaxKind::ImportItems);
+}
+
+fn module_include(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Include);
+ code_expr(p);
+ p.wrap(m, SyntaxKind::ModuleInclude);
+}
+
+fn break_stmt(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Break);
+ p.wrap(m, SyntaxKind::LoopBreak);
+}
+
+fn continue_stmt(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Continue);
+ p.wrap(m, SyntaxKind::LoopContinue);
+}
+
+fn return_stmt(p: &mut Parser) {
+ let m = p.marker();
+ p.assert(SyntaxKind::Return);
+ if !p.current().is_terminator() && !p.at(SyntaxKind::Comma) {
+ code_expr(p);
+ }
+ p.wrap(m, SyntaxKind::FuncReturn);
+}
+
+fn validate_parenthesized_at(p: &mut Parser, m: Marker) {
+ for child in p.post_process(m) {
+ let kind = child.kind();
+ match kind {
+ SyntaxKind::Array => validate_array(child.children_mut().iter_mut()),
+ SyntaxKind::Dict => validate_dict(child.children_mut().iter_mut()),
+ SyntaxKind::Underscore => {
+ child.convert_to_error(eco_format!(
+ "expected expression, found {}",
+ kind.name()
+ ));
+ }
+ _ => {}
+ }
+ }
+}
+
+fn validate_array_at(p: &mut Parser, m: Marker) {
+ validate_array(p.post_process(m))
+}
+
+fn validate_array<'a>(children: impl Iterator<Item = &'a mut SyntaxNode>) {
+ for child in children {
+ let kind = child.kind();
+ match kind {
+ SyntaxKind::Array => validate_array(child.children_mut().iter_mut()),
+ SyntaxKind::Dict => validate_dict(child.children_mut().iter_mut()),
+ SyntaxKind::Named | SyntaxKind::Keyed | SyntaxKind::Underscore => {
+ child.convert_to_error(eco_format!(
+ "expected expression, found {}",
+ kind.name()
+ ));
+ }
+ _ => {}
+ }
+ }
+}
+
+fn validate_dict_at(p: &mut Parser, m: Marker) {
+ validate_dict(p.post_process(m))
+}
+
+fn validate_dict<'a>(children: impl Iterator<Item = &'a mut SyntaxNode>) {
+ let mut used = HashSet::new();
+ for child in children {
+ match child.kind() {
+ SyntaxKind::Named | SyntaxKind::Keyed => {
+ let Some(first) = child.children_mut().first_mut() else { continue };
+ let key = match first.cast::<ast::Str>() {
+ Some(str) => str.get(),
+ None => first.text().clone(),
+ };
+
+ if !used.insert(key.clone()) {
+ first.convert_to_error(eco_format!("duplicate key: {key}"));
+ child.make_erroneous();
+ }
+ }
+ SyntaxKind::Spread => {}
+ SyntaxKind::LeftParen
+ | SyntaxKind::RightParen
+ | SyntaxKind::Comma
+ | SyntaxKind::Colon => {}
+ kind => {
+ child.convert_to_error(eco_format!(
+ "expected named or keyed pair, found {}",
+ kind.name()
+ ));
+ }
+ }
+ }
+}
+
+fn validate_params_at(p: &mut Parser, m: Marker) {
+ let mut used_spread = false;
+ let mut used = HashSet::new();
+ for child in p.post_process(m) {
+ match child.kind() {
+ SyntaxKind::Ident => {
+ if !used.insert(child.text().clone()) {
+ child.convert_to_error(eco_format!(
+ "duplicate parameter: {}",
+ child.text()
+ ));
+ }
+ }
+ SyntaxKind::Named => {
+ let Some(within) = child.children_mut().first_mut() else { return };
+ if !used.insert(within.text().clone()) {
+ within.convert_to_error(eco_format!(
+ "duplicate parameter: {}",
+ within.text()
+ ));
+ child.make_erroneous();
+ }
+ }
+ SyntaxKind::Spread => {
+ let Some(within) = child.children_mut().last_mut() else { continue };
+ if used_spread {
+ child.convert_to_error("only one argument sink is allowed");
+ continue;
+ }
+ used_spread = true;
+ if within.kind() == SyntaxKind::Dots {
+ continue;
+ } else if within.kind() != SyntaxKind::Ident {
+ within.convert_to_error(eco_format!(
+ "expected identifier, found {}",
+ within.kind().name(),
+ ));
+ child.make_erroneous();
+ continue;
+ }
+ if !used.insert(within.text().clone()) {
+ within.convert_to_error(eco_format!(
+ "duplicate parameter: {}",
+ within.text()
+ ));
+ child.make_erroneous();
+ }
+ }
+ SyntaxKind::Array | SyntaxKind::Dict | SyntaxKind::Destructuring => {
+ validate_pattern(child.children_mut().iter_mut(), &mut used, false);
+ child.convert_to_kind(SyntaxKind::Destructuring);
+ }
+ SyntaxKind::LeftParen
+ | SyntaxKind::RightParen
+ | SyntaxKind::Comma
+ | SyntaxKind::Underscore => {}
+ kind => {
+ child.convert_to_error(eco_format!(
+ "expected identifier, named pair or argument sink, found {}",
+ kind.name()
+ ));
+ }
+ }
+ }
+}
+
+fn validate_args_at(p: &mut Parser, m: Marker) {
+ let mut used = HashSet::new();
+ for child in p.post_process(m) {
+ if child.kind() == SyntaxKind::Named {
+ let Some(within) = child.children_mut().first_mut() else { return };
+ if !used.insert(within.text().clone()) {
+ within.convert_to_error(eco_format!(
+ "duplicate argument: {}",
+ within.text()
+ ));
+ child.make_erroneous();
+ }
+ } else if child.kind() == SyntaxKind::Underscore {
+ child.convert_to_error("unexpected underscore");
+ }
+ }
+}
+
+fn validate_pattern_at(p: &mut Parser, m: Marker, forbid_expressions: bool) {
+ let mut used = HashSet::new();
+ validate_pattern(p.post_process(m), &mut used, forbid_expressions);
+}
+
+fn validate_pattern<'a>(
+ children: impl Iterator<Item = &'a mut SyntaxNode>,
+ used: &mut HashSet<EcoString>,
+ forbid_expressions: bool,
+) {
+ let mut used_spread = false;
+ for child in children {
+ match child.kind() {
+ SyntaxKind::Ident => {
+ if !used.insert(child.text().clone()) {
+ child.convert_to_error(
+ "at most one binding per identifier is allowed",
+ );
+ }
+ }
+ SyntaxKind::Spread => {
+ let Some(within) = child.children_mut().last_mut() else { continue };
+ if used_spread {
+ child.convert_to_error("at most one destructuring sink is allowed");
+ continue;
+ }
+ used_spread = true;
+
+ if within.kind() == SyntaxKind::Dots {
+ continue;
+ } else if forbid_expressions && within.kind() != SyntaxKind::Ident {
+ within.convert_to_error(eco_format!(
+ "expected identifier, found {}",
+ within.kind().name(),
+ ));
+ child.make_erroneous();
+ continue;
+ }
+
+ if !used.insert(within.text().clone()) {
+ within.convert_to_error(
+ "at most one binding per identifier is allowed",
+ );
+ child.make_erroneous();
+ }
+ }
+ SyntaxKind::Named => {
+ let Some(within) = child.children_mut().first_mut() else { return };
+ if !used.insert(within.text().clone()) {
+ within.convert_to_error(
+ "at most one binding per identifier is allowed",
+ );
+ child.make_erroneous();
+ }
+
+ if forbid_expressions {
+ let Some(within) = child.children_mut().last_mut() else { return };
+ if within.kind() != SyntaxKind::Ident
+ && within.kind() != SyntaxKind::Underscore
+ {
+ within.convert_to_error(eco_format!(
+ "expected identifier, found {}",
+ within.kind().name(),
+ ));
+ child.make_erroneous();
+ }
+ }
+ }
+ SyntaxKind::LeftParen
+ | SyntaxKind::RightParen
+ | SyntaxKind::Comma
+ | SyntaxKind::Underscore => {}
+ kind => {
+ if forbid_expressions {
+ child.convert_to_error(eco_format!(
+ "expected identifier or destructuring sink, found {}",
+ kind.name()
+ ));
+ }
+ }
+ }
+ }
+}
+
+/// Manages parsing of a stream of tokens.
+struct Parser<'s> {
+ text: &'s str,
+ lexer: Lexer<'s>,
+ prev_end: usize,
+ current_start: usize,
+ current: SyntaxKind,
+ modes: Vec<LexMode>,
+ nodes: Vec<SyntaxNode>,
+ stop_at_newline: Vec<bool>,
+ balanced: bool,
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+struct Marker(usize);
+
+impl<'s> Parser<'s> {
+ fn new(text: &'s str, offset: usize, mode: LexMode) -> Self {
+ let mut lexer = Lexer::new(text, mode);
+ lexer.jump(offset);
+ let current = lexer.next();
+ Self {
+ lexer,
+ text,
+ prev_end: offset,
+ current_start: offset,
+ current,
+ modes: vec![],
+ nodes: vec![],
+ stop_at_newline: vec![],
+ balanced: true,
+ }
+ }
+
+ fn finish(self) -> Vec<SyntaxNode> {
+ self.nodes
+ }
+
+ fn prev_end(&self) -> usize {
+ self.prev_end
+ }
+
+ fn current(&self) -> SyntaxKind {
+ self.current
+ }
+
+ fn current_start(&self) -> usize {
+ self.current_start
+ }
+
+ fn current_end(&self) -> usize {
+ self.lexer.cursor()
+ }
+
+ fn current_text(&self) -> &'s str {
+ &self.text[self.current_start..self.current_end()]
+ }
+
+ fn at(&self, kind: SyntaxKind) -> bool {
+ self.current == kind
+ }
+
+ #[track_caller]
+ fn assert(&mut self, kind: SyntaxKind) {
+ assert_eq!(self.current, kind);
+ self.eat();
+ }
+
+ fn eof(&self) -> bool {
+ self.at(SyntaxKind::Eof)
+ }
+
+ fn directly_at(&self, kind: SyntaxKind) -> bool {
+ self.current == kind && self.prev_end == self.current_start
+ }
+
+ fn eat_if(&mut self, kind: SyntaxKind) -> bool {
+ let at = self.at(kind);
+ if at {
+ self.eat();
+ }
+ at
+ }
+
+ fn convert(&mut self, kind: SyntaxKind) {
+ self.current = kind;
+ self.eat();
+ }
+
+ fn newline(&mut self) -> bool {
+ self.lexer.newline()
+ }
+
+ fn column(&self, at: usize) -> usize {
+ self.text[..at].chars().rev().take_while(|&c| !is_newline(c)).count()
+ }
+
+ fn marker(&self) -> Marker {
+ Marker(self.nodes.len())
+ }
+
+ fn node(&self, m: Marker) -> Option<&SyntaxNode> {
+ self.nodes.get(m.0)
+ }
+
+ fn node_mut(&mut self, m: Marker) -> Option<&mut SyntaxNode> {
+ self.nodes.get_mut(m.0)
+ }
+
+ fn post_process(&mut self, m: Marker) -> impl Iterator<Item = &mut SyntaxNode> {
+ self.nodes[m.0..]
+ .iter_mut()
+ .filter(|child| !child.kind().is_error() && !child.kind().is_trivia())
+ }
+
+ fn wrap(&mut self, m: Marker, kind: SyntaxKind) {
+ self.unskip();
+ self.wrap_skipless(m, kind);
+ self.skip();
+ }
+
+ fn wrap_skipless(&mut self, m: Marker, kind: SyntaxKind) {
+ let from = m.0.min(self.nodes.len());
+ let children = self.nodes.drain(from..).collect();
+ self.nodes.push(SyntaxNode::inner(kind, children));
+ }
+
+ fn progress(&self, offset: usize) -> bool {
+ offset < self.prev_end
+ }
+
+ fn enter(&mut self, mode: LexMode) {
+ self.modes.push(self.lexer.mode());
+ self.lexer.set_mode(mode);
+ }
+
+ fn exit(&mut self) {
+ let mode = self.modes.pop().unwrap();
+ if mode != self.lexer.mode() {
+ self.unskip();
+ self.lexer.set_mode(mode);
+ self.lexer.jump(self.current_start);
+ self.lex();
+ self.skip();
+ }
+ }
+
+ fn stop_at_newline(&mut self, stop: bool) {
+ self.stop_at_newline.push(stop);
+ }
+
+ fn unstop(&mut self) {
+ self.unskip();
+ self.stop_at_newline.pop();
+ self.lexer.jump(self.prev_end);
+ self.lex();
+ self.skip();
+ }
+
+ fn eat(&mut self) {
+ self.save();
+ self.lex();
+ self.skip();
+ }
+
+ fn skip(&mut self) {
+ if self.lexer.mode() != LexMode::Markup {
+ while self.current.is_trivia() {
+ self.save();
+ self.lex();
+ }
+ }
+ }
+
+ fn unskip(&mut self) {
+ if self.lexer.mode() != LexMode::Markup && self.prev_end != self.current_start {
+ while self.nodes.last().map_or(false, |last| last.kind().is_trivia()) {
+ self.nodes.pop();
+ }
+
+ self.lexer.jump(self.prev_end);
+ self.lex();
+ }
+ }
+
+ fn save(&mut self) {
+ let text = self.current_text();
+ if self.at(SyntaxKind::Error) {
+ let message = self.lexer.take_error().unwrap();
+ self.nodes.push(SyntaxNode::error(message, text));
+ } else {
+ self.nodes.push(SyntaxNode::leaf(self.current, text));
+ }
+
+ if self.lexer.mode() == LexMode::Markup || !self.current.is_trivia() {
+ self.prev_end = self.current_end();
+ }
+ }
+
+ fn lex(&mut self) {
+ self.current_start = self.lexer.cursor();
+ self.current = self.lexer.next();
+ if self.lexer.mode() == LexMode::Code
+ && self.lexer.newline()
+ && self.stop_at_newline.last().copied().unwrap_or(false)
+ && !matches!(self.lexer.clone().next(), SyntaxKind::Else | SyntaxKind::Dot)
+ {
+ self.current = SyntaxKind::Eof;
+ }
+ }
+
+ fn expect(&mut self, kind: SyntaxKind) -> bool {
+ let at = self.at(kind);
+ if at {
+ self.eat();
+ } else {
+ self.balanced &= !kind.is_grouping();
+ self.expected(kind.name());
+ }
+ at
+ }
+
+ fn expect_closing_delimiter(&mut self, open: Marker, kind: SyntaxKind) {
+ if !self.eat_if(kind) {
+ self.nodes[open.0].convert_to_error("unclosed delimiter");
+ }
+ }
+
+ fn expected(&mut self, thing: &str) {
+ self.unskip();
+ if self
+ .nodes
+ .last()
+ .map_or(true, |child| child.kind() != SyntaxKind::Error)
+ {
+ let message = eco_format!("expected {}", thing);
+ self.nodes.push(SyntaxNode::error(message, ""));
+ }
+ self.skip();
+ }
+
+ fn expected_at(&mut self, m: Marker, thing: &str) {
+ let message = eco_format!("expected {}", thing);
+ let error = SyntaxNode::error(message, "");
+ self.nodes.insert(m.0, error);
+ }
+
+ fn unexpected(&mut self) {
+ self.unskip();
+ while self
+ .nodes
+ .last()
+ .map_or(false, |child| child.kind() == SyntaxKind::Error && child.is_empty())
+ {
+ self.nodes.pop();
+ }
+ self.skip();
+
+ let kind = self.current;
+ let offset = self.nodes.len();
+ self.eat();
+ self.balanced &= !kind.is_grouping();
+
+ if !kind.is_error() {
+ self.nodes[offset]
+ .convert_to_error(eco_format!("unexpected {}", kind.name()));
+ }
+ }
+}
diff --git a/crates/typst/src/syntax/reparser.rs b/crates/typst/src/syntax/reparser.rs
new file mode 100644
index 00000000..a4186fa7
--- /dev/null
+++ b/crates/typst/src/syntax/reparser.rs
@@ -0,0 +1,322 @@
+use std::ops::Range;
+
+use super::{
+ is_newline, parse, reparse_block, reparse_markup, Span, SyntaxKind, SyntaxNode,
+};
+
+/// Refresh the given syntax node with as little parsing as possible.
+///
+/// Takes the new text, the range in the old text that was replaced and the
+/// length of the replacement and returns the range in the new text that was
+/// ultimately reparsed.
+///
+/// The high-level API for this function is
+/// [`Source::edit`](super::Source::edit).
+pub fn reparse(
+ root: &mut SyntaxNode,
+ text: &str,
+ replaced: Range<usize>,
+ replacement_len: usize,
+) -> Range<usize> {
+ try_reparse(text, replaced, replacement_len, None, root, 0).unwrap_or_else(|| {
+ let id = root.span().id();
+ *root = parse(text);
+ root.numberize(id, Span::FULL).unwrap();
+ 0..text.len()
+ })
+}
+
+/// Try to reparse inside the given node.
+fn try_reparse(
+ text: &str,
+ replaced: Range<usize>,
+ replacement_len: usize,
+ parent_kind: Option<SyntaxKind>,
+ node: &mut SyntaxNode,
+ offset: usize,
+) -> Option<Range<usize>> {
+ // The range of children which overlap with the edit.
+ #[allow(clippy::reversed_empty_ranges)]
+ let mut overlap = usize::MAX..0;
+ let mut cursor = offset;
+ let node_kind = node.kind();
+
+ for (i, child) in node.children_mut().iter_mut().enumerate() {
+ let prev_range = cursor..cursor + child.len();
+ let prev_len = child.len();
+ let prev_desc = child.descendants();
+
+ // Does the child surround the edit?
+ // If so, try to reparse within it or itself.
+ if !child.is_leaf() && includes(&prev_range, &replaced) {
+ let new_len = prev_len + replacement_len - replaced.len();
+ let new_range = cursor..cursor + new_len;
+
+ // Try to reparse within the child.
+ if let Some(range) = try_reparse(
+ text,
+ replaced.clone(),
+ replacement_len,
+ Some(node_kind),
+ child,
+ cursor,
+ ) {
+ assert_eq!(child.len(), new_len);
+ let new_desc = child.descendants();
+ node.update_parent(prev_len, new_len, prev_desc, new_desc);
+ return Some(range);
+ }
+
+ // If the child is a block, try to reparse the block.
+ if child.kind().is_block() {
+ if let Some(newborn) = reparse_block(text, new_range.clone()) {
+ return node
+ .replace_children(i..i + 1, vec![newborn])
+ .is_ok()
+ .then_some(new_range);
+ }
+ }
+ }
+
+ // Does the child overlap with the edit?
+ if overlaps(&prev_range, &replaced) {
+ overlap.start = overlap.start.min(i);
+ overlap.end = i + 1;
+ }
+
+ // Is the child beyond the edit?
+ if replaced.end < cursor {
+ break;
+ }
+
+ cursor += child.len();
+ }
+
+ // Try to reparse a range of markup expressions within markup. This is only
+ // possible if the markup is top-level or contained in a block, not if it is
+ // contained in things like headings or lists because too much can go wrong
+ // with indent and line breaks.
+ if overlap.is_empty()
+ || node.kind() != SyntaxKind::Markup
+ || !matches!(parent_kind, None | Some(SyntaxKind::ContentBlock))
+ {
+ return None;
+ }
+
+ let children = node.children_mut();
+
+ // Reparse a segment. Retries until it works, taking exponentially more
+ // children into account.
+ let mut expansion = 1;
+ loop {
+ // Add slack in both directions.
+ let mut start = overlap.start.saturating_sub(expansion.max(2));
+ let mut end = (overlap.end + expansion).min(children.len());
+
+ // Expand to the left.
+ while start > 0 && expand(&children[start]) {
+ start -= 1;
+ }
+
+ // Expand to the right.
+ while end < children.len() && expand(&children[end]) {
+ end += 1;
+ }
+
+ // Also take hashtag.
+ if start > 0 && children[start - 1].kind() == SyntaxKind::Hashtag {
+ start -= 1;
+ }
+
+ // Synthesize what `at_start` and `nesting` would be at the start of the
+ // reparse.
+ let mut prefix_len = 0;
+ let mut nesting = 0;
+ let mut at_start = true;
+ for child in &children[..start] {
+ prefix_len += child.len();
+ next_at_start(child, &mut at_start);
+ next_nesting(child, &mut nesting);
+ }
+
+ // Determine what `at_start` will have to be at the end of the reparse.
+ let mut prev_len = 0;
+ let mut prev_at_start_after = at_start;
+ let mut prev_nesting_after = nesting;
+ for child in &children[start..end] {
+ prev_len += child.len();
+ next_at_start(child, &mut prev_at_start_after);
+ next_nesting(child, &mut prev_nesting_after);
+ }
+
+ // Determine the range in the new text that we want to reparse.
+ let shifted = offset + prefix_len;
+ let new_len = prev_len + replacement_len - replaced.len();
+ let new_range = shifted..shifted + new_len;
+ let at_end = end == children.len();
+
+ // Stop parsing early if this kind is encountered.
+ let stop_kind = match parent_kind {
+ Some(_) => SyntaxKind::RightBracket,
+ None => SyntaxKind::Eof,
+ };
+
+ // Reparse!
+ let reparsed = reparse_markup(
+ text,
+ new_range.clone(),
+ &mut at_start,
+ &mut nesting,
+ |kind| kind == stop_kind,
+ );
+
+ if let Some(newborns) = reparsed {
+ // If more children follow, at_start must match its previous value.
+ // Similarly, if we children follow or we not top-level the nesting
+ // must match its previous value.
+ if (at_end || at_start == prev_at_start_after)
+ && ((at_end && parent_kind.is_none()) || nesting == prev_nesting_after)
+ {
+ return node
+ .replace_children(start..end, newborns)
+ .is_ok()
+ .then_some(new_range);
+ }
+ }
+
+ // If it didn't even work with all children, we give up.
+ if start == 0 && at_end {
+ break;
+ }
+
+ // Exponential expansion to both sides.
+ expansion *= 2;
+ }
+
+ None
+}
+
+/// Whether the inner range is fully contained in the outer one (no touching).
+fn includes(outer: &Range<usize>, inner: &Range<usize>) -> bool {
+ outer.start < inner.start && outer.end > inner.end
+}
+
+/// Whether the first and second range overlap or touch.
+fn overlaps(first: &Range<usize>, second: &Range<usize>) -> bool {
+ (first.start <= second.start && second.start <= first.end)
+ || (second.start <= first.start && first.start <= second.end)
+}
+
+/// Whether the selection should be expanded beyond a node of this kind.
+fn expand(node: &SyntaxNode) -> bool {
+ let kind = node.kind();
+ kind.is_trivia()
+ || kind.is_error()
+ || kind == SyntaxKind::Semicolon
+ || node.text() == "/"
+ || node.text() == ":"
+}
+
+/// Whether `at_start` would still be true after this node given the
+/// previous value of the property.
+fn next_at_start(node: &SyntaxNode, at_start: &mut bool) {
+ let kind = node.kind();
+ if kind.is_trivia() {
+ *at_start |= kind == SyntaxKind::Parbreak
+ || (kind == SyntaxKind::Space && node.text().chars().any(is_newline));
+ } else {
+ *at_start = false;
+ }
+}
+
+/// Update `nesting` based on the node.
+fn next_nesting(node: &SyntaxNode, nesting: &mut usize) {
+ if node.kind() == SyntaxKind::Text {
+ match node.text().as_str() {
+ "[" => *nesting += 1,
+ "]" if *nesting > 0 => *nesting -= 1,
+ _ => {}
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::ops::Range;
+
+ use super::super::{parse, Source, Span};
+
+ #[track_caller]
+ fn test(prev: &str, range: Range<usize>, with: &str, incremental: bool) {
+ let mut source = Source::detached(prev);
+ let prev = source.root().clone();
+ let range = source.edit(range, with);
+ let mut found = source.root().clone();
+ let mut expected = parse(source.text());
+ found.synthesize(Span::detached());
+ expected.synthesize(Span::detached());
+ if found != expected {
+ eprintln!("source: {:?}", source.text());
+ eprintln!("previous: {prev:#?}");
+ eprintln!("expected: {expected:#?}");
+ eprintln!("found: {found:#?}");
+ panic!("test failed");
+ }
+ if incremental {
+ assert_ne!(source.len_bytes(), range.len(), "should have been incremental");
+ } else {
+ assert_eq!(
+ source.len_bytes(),
+ range.len(),
+ "shouldn't have been incremental"
+ );
+ }
+ }
+
+ #[test]
+ fn test_reparse_markup() {
+ test("abc~def~gh~", 5..6, "+", true);
+ test("~~~~~~~", 3..4, "A", true);
+ test("abc~~", 1..2, "", true);
+ test("#var. hello", 5..6, " ", false);
+ test("#var;hello", 9..10, "a", false);
+ test("https:/world", 7..7, "/", false);
+ test("hello world", 7..12, "walkers", false);
+ test("some content", 0..12, "", false);
+ test("", 0..0, "do it", false);
+ test("a d e", 1..3, " b c d", false);
+ test("~*~*~", 2..2, "*", false);
+ test("::1\n2. a\n3", 7..7, "4", true);
+ test("* #{1+2} *", 6..7, "3", true);
+ test("#{(0, 1, 2)}", 6..7, "11pt", true);
+ test("\n= A heading", 4..4, "n evocative", false);
+ test("#call() abc~d", 7..7, "[]", true);
+ test("a your thing a", 6..7, "a", false);
+ test("#grid(columns: (auto, 1fr, 40%))", 16..20, "4pt", false);
+ test("abc\n= a heading\njoke", 3..4, "\nmore\n\n", true);
+ test("#show f: a => b..", 16..16, "c", false);
+ test("#for", 4..4, "//", false);
+ test("a\n#let \nb", 7..7, "i", true);
+ test(r"#{{let x = z}; a = 1} b", 7..7, "//", false);
+ test(r#"a ```typst hello```"#, 16..17, "", false);
+ }
+
+ #[test]
+ fn test_reparse_block() {
+ test("Hello #{ x + 1 }!", 9..10, "abc", true);
+ test("A#{}!", 3..3, "\"", false);
+ test("#{ [= x] }!", 5..5, "=", true);
+ test("#[[]]", 3..3, "\\", true);
+ test("#[[ab]]", 4..5, "\\", true);
+ test("#{}}", 2..2, "{", false);
+ test("A: #[BC]", 6..6, "{", true);
+ test("A: #[BC]", 6..6, "#{", true);
+ test("A: #[BC]", 6..6, "#{}", true);
+ test("#{\"ab\"}A", 5..5, "c", true);
+ test("#{\"ab\"}A", 5..6, "c", false);
+ test("a#[]b", 3..3, "#{", true);
+ test("a#{call(); abc}b", 8..8, "[]", true);
+ test("a #while x {\n g(x) \n} b", 12..12, "//", true);
+ test("a#[]b", 3..3, "[hey]", true);
+ }
+}
diff --git a/crates/typst/src/syntax/source.rs b/crates/typst/src/syntax/source.rs
new file mode 100644
index 00000000..2f3e4144
--- /dev/null
+++ b/crates/typst/src/syntax/source.rs
@@ -0,0 +1,421 @@
+//! Source file management.
+
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::ops::Range;
+use std::sync::Arc;
+
+use comemo::Prehashed;
+
+use super::ast::Markup;
+use super::reparser::reparse;
+use super::{is_newline, parse, LinkedNode, Span, SyntaxNode};
+use crate::diag::SourceResult;
+use crate::file::FileId;
+use crate::util::StrExt;
+
+/// A source file.
+///
+/// All line and column indices start at zero, just like byte indices. Only for
+/// user-facing display, you should add 1 to them.
+///
+/// Values of this type are cheap to clone and hash.
+#[derive(Clone)]
+pub struct Source(Arc<Repr>);
+
+/// The internal representation.
+#[derive(Clone)]
+struct Repr {
+ id: FileId,
+ text: Prehashed<String>,
+ root: Prehashed<SyntaxNode>,
+ lines: Vec<Line>,
+}
+
+impl Source {
+ /// Create a new source file.
+ #[tracing::instrument(skip_all)]
+ pub fn new(id: FileId, text: String) -> Self {
+ let mut root = parse(&text);
+ root.numberize(id, Span::FULL).unwrap();
+ Self(Arc::new(Repr {
+ id,
+ lines: lines(&text),
+ text: Prehashed::new(text),
+ root: Prehashed::new(root),
+ }))
+ }
+
+ /// Create a source file without a real id and path, usually for testing.
+ pub fn detached(text: impl Into<String>) -> Self {
+ Self::new(FileId::detached(), text.into())
+ }
+
+ /// Create a source file with the same synthetic span for all nodes.
+ pub fn synthesized(text: String, span: Span) -> Self {
+ let mut root = parse(&text);
+ root.synthesize(span);
+ Self(Arc::new(Repr {
+ id: FileId::detached(),
+ lines: lines(&text),
+ text: Prehashed::new(text),
+ root: Prehashed::new(root),
+ }))
+ }
+
+ /// The root node of the file's untyped syntax tree.
+ pub fn root(&self) -> &SyntaxNode {
+ &self.0.root
+ }
+
+ /// The root node of the file's typed abstract syntax tree.
+ pub fn ast(&self) -> SourceResult<Markup> {
+ let errors = self.root().errors();
+ if errors.is_empty() {
+ Ok(self.root().cast().expect("root node must be markup"))
+ } else {
+ Err(Box::new(errors))
+ }
+ }
+
+ /// The id of the source file.
+ pub fn id(&self) -> FileId {
+ self.0.id
+ }
+
+ /// The whole source as a string slice.
+ pub fn text(&self) -> &str {
+ &self.0.text
+ }
+
+ /// Slice out the part of the source code enclosed by the range.
+ pub fn get(&self, range: Range<usize>) -> Option<&str> {
+ self.text().get(range)
+ }
+
+ /// Fully replace the source text.
+ pub fn replace(&mut self, text: String) {
+ let inner = Arc::make_mut(&mut self.0);
+ inner.text = Prehashed::new(text);
+ inner.lines = lines(&inner.text);
+ let mut root = parse(&inner.text);
+ root.numberize(inner.id, Span::FULL).unwrap();
+ inner.root = Prehashed::new(root);
+ }
+
+ /// Edit the source file by replacing the given range.
+ ///
+ /// Returns the range in the new source that was ultimately reparsed.
+ ///
+ /// The method panics if the `replace` range is out of bounds.
+ #[track_caller]
+ pub fn edit(&mut self, replace: Range<usize>, with: &str) -> Range<usize> {
+ let start_byte = replace.start;
+ let start_utf16 = self.byte_to_utf16(start_byte).unwrap();
+ let line = self.byte_to_line(start_byte).unwrap();
+
+ let inner = Arc::make_mut(&mut self.0);
+
+ // Update the text itself.
+ inner.text.update(|text| text.replace_range(replace.clone(), with));
+
+ // Remove invalidated line starts.
+ inner.lines.truncate(line + 1);
+
+ // Handle adjoining of \r and \n.
+ if inner.text[..start_byte].ends_with('\r') && with.starts_with('\n') {
+ inner.lines.pop();
+ }
+
+ // Recalculate the line starts after the edit.
+ inner.lines.extend(lines_from(
+ start_byte,
+ start_utf16,
+ &inner.text[start_byte..],
+ ));
+
+ // Incrementally reparse the replaced range.
+ inner
+ .root
+ .update(|root| reparse(root, &inner.text, replace, with.len()))
+ }
+
+ /// Get the length of the file in UTF-8 encoded bytes.
+ pub fn len_bytes(&self) -> usize {
+ self.text().len()
+ }
+
+ /// Get the length of the file in UTF-16 code units.
+ pub fn len_utf16(&self) -> usize {
+ let last = self.0.lines.last().unwrap();
+ last.utf16_idx + self.0.text[last.byte_idx..].len_utf16()
+ }
+
+ /// Get the length of the file in lines.
+ pub fn len_lines(&self) -> usize {
+ self.0.lines.len()
+ }
+
+ /// Find the node with the given span.
+ ///
+ /// Returns `None` if the span does not point into this source file.
+ pub fn find(&self, span: Span) -> Option<LinkedNode<'_>> {
+ LinkedNode::new(self.root()).find(span)
+ }
+
+ /// Return the index of the UTF-16 code unit at the byte index.
+ pub fn byte_to_utf16(&self, byte_idx: usize) -> Option<usize> {
+ let line_idx = self.byte_to_line(byte_idx)?;
+ let line = self.0.lines.get(line_idx)?;
+ let head = self.0.text.get(line.byte_idx..byte_idx)?;
+ Some(line.utf16_idx + head.len_utf16())
+ }
+
+ /// Return the index of the line that contains the given byte index.
+ pub fn byte_to_line(&self, byte_idx: usize) -> Option<usize> {
+ (byte_idx <= self.0.text.len()).then(|| {
+ match self.0.lines.binary_search_by_key(&byte_idx, |line| line.byte_idx) {
+ Ok(i) => i,
+ Err(i) => i - 1,
+ }
+ })
+ }
+
+ /// Return the index of the column at the byte index.
+ ///
+ /// The column is defined as the number of characters in the line before the
+ /// byte index.
+ pub fn byte_to_column(&self, byte_idx: usize) -> Option<usize> {
+ let line = self.byte_to_line(byte_idx)?;
+ let start = self.line_to_byte(line)?;
+ let head = self.get(start..byte_idx)?;
+ Some(head.chars().count())
+ }
+
+ /// Return the byte index at the UTF-16 code unit.
+ pub fn utf16_to_byte(&self, utf16_idx: usize) -> Option<usize> {
+ let line = self.0.lines.get(
+ match self.0.lines.binary_search_by_key(&utf16_idx, |line| line.utf16_idx) {
+ Ok(i) => i,
+ Err(i) => i - 1,
+ },
+ )?;
+
+ let mut k = line.utf16_idx;
+ for (i, c) in self.0.text[line.byte_idx..].char_indices() {
+ if k >= utf16_idx {
+ return Some(line.byte_idx + i);
+ }
+ k += c.len_utf16();
+ }
+
+ (k == utf16_idx).then_some(self.0.text.len())
+ }
+
+ /// Return the byte position at which the given line starts.
+ pub fn line_to_byte(&self, line_idx: usize) -> Option<usize> {
+ self.0.lines.get(line_idx).map(|line| line.byte_idx)
+ }
+
+ /// Return the range which encloses the given line.
+ pub fn line_to_range(&self, line_idx: usize) -> Option<Range<usize>> {
+ let start = self.line_to_byte(line_idx)?;
+ let end = self.line_to_byte(line_idx + 1).unwrap_or(self.0.text.len());
+ Some(start..end)
+ }
+
+ /// Return the byte index of the given (line, column) pair.
+ ///
+ /// The column defines the number of characters to go beyond the start of
+ /// the line.
+ pub fn line_column_to_byte(
+ &self,
+ line_idx: usize,
+ column_idx: usize,
+ ) -> Option<usize> {
+ let range = self.line_to_range(line_idx)?;
+ let line = self.get(range.clone())?;
+ let mut chars = line.chars();
+ for _ in 0..column_idx {
+ chars.next();
+ }
+ Some(range.start + (line.len() - chars.as_str().len()))
+ }
+}
+
+impl Debug for Source {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Source({})", self.id().path().display())
+ }
+}
+
+impl Hash for Source {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.0.id.hash(state);
+ self.0.text.hash(state);
+ self.0.root.hash(state);
+ }
+}
+
+impl AsRef<str> for Source {
+ fn as_ref(&self) -> &str {
+ self.text()
+ }
+}
+
+/// Metadata about a line.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+struct Line {
+ /// The UTF-8 byte offset where the line starts.
+ byte_idx: usize,
+ /// The UTF-16 codepoint offset where the line starts.
+ utf16_idx: usize,
+}
+
+/// Create a line vector.
+fn lines(text: &str) -> Vec<Line> {
+ std::iter::once(Line { byte_idx: 0, utf16_idx: 0 })
+ .chain(lines_from(0, 0, text))
+ .collect()
+}
+
+/// Compute a line iterator from an offset.
+fn lines_from(
+ byte_offset: usize,
+ utf16_offset: usize,
+ text: &str,
+) -> impl Iterator<Item = Line> + '_ {
+ let mut s = unscanny::Scanner::new(text);
+ let mut utf16_idx = utf16_offset;
+
+ std::iter::from_fn(move || {
+ s.eat_until(|c: char| {
+ utf16_idx += c.len_utf16();
+ is_newline(c)
+ });
+
+ if s.done() {
+ return None;
+ }
+
+ if s.eat() == Some('\r') && s.eat_if('\n') {
+ utf16_idx += 1;
+ }
+
+ Some(Line { byte_idx: byte_offset + s.cursor(), utf16_idx })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const TEST: &str = "ä\tcde\nf💛g\r\nhi\rjkl";
+
+ #[test]
+ fn test_source_file_new() {
+ let source = Source::detached(TEST);
+ assert_eq!(
+ source.0.lines,
+ [
+ Line { byte_idx: 0, utf16_idx: 0 },
+ Line { byte_idx: 7, utf16_idx: 6 },
+ Line { byte_idx: 15, utf16_idx: 12 },
+ Line { byte_idx: 18, utf16_idx: 15 },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_source_file_pos_to_line() {
+ let source = Source::detached(TEST);
+ assert_eq!(source.byte_to_line(0), Some(0));
+ assert_eq!(source.byte_to_line(2), Some(0));
+ assert_eq!(source.byte_to_line(6), Some(0));
+ assert_eq!(source.byte_to_line(7), Some(1));
+ assert_eq!(source.byte_to_line(8), Some(1));
+ assert_eq!(source.byte_to_line(12), Some(1));
+ assert_eq!(source.byte_to_line(21), Some(3));
+ assert_eq!(source.byte_to_line(22), None);
+ }
+
+ #[test]
+ fn test_source_file_pos_to_column() {
+ let source = Source::detached(TEST);
+ assert_eq!(source.byte_to_column(0), Some(0));
+ assert_eq!(source.byte_to_column(2), Some(1));
+ assert_eq!(source.byte_to_column(6), Some(5));
+ assert_eq!(source.byte_to_column(7), Some(0));
+ assert_eq!(source.byte_to_column(8), Some(1));
+ assert_eq!(source.byte_to_column(12), Some(2));
+ }
+
+ #[test]
+ fn test_source_file_utf16() {
+ #[track_caller]
+ fn roundtrip(source: &Source, byte_idx: usize, utf16_idx: usize) {
+ let middle = source.byte_to_utf16(byte_idx).unwrap();
+ let result = source.utf16_to_byte(middle).unwrap();
+ assert_eq!(middle, utf16_idx);
+ assert_eq!(result, byte_idx);
+ }
+
+ let source = Source::detached(TEST);
+ roundtrip(&source, 0, 0);
+ roundtrip(&source, 2, 1);
+ roundtrip(&source, 3, 2);
+ roundtrip(&source, 8, 7);
+ roundtrip(&source, 12, 9);
+ roundtrip(&source, 21, 18);
+ assert_eq!(source.byte_to_utf16(22), None);
+ assert_eq!(source.utf16_to_byte(19), None);
+ }
+
+ #[test]
+ fn test_source_file_roundtrip() {
+ #[track_caller]
+ fn roundtrip(source: &Source, byte_idx: usize) {
+ let line = source.byte_to_line(byte_idx).unwrap();
+ let column = source.byte_to_column(byte_idx).unwrap();
+ let result = source.line_column_to_byte(line, column).unwrap();
+ assert_eq!(result, byte_idx);
+ }
+
+ let source = Source::detached(TEST);
+ roundtrip(&source, 0);
+ roundtrip(&source, 7);
+ roundtrip(&source, 12);
+ roundtrip(&source, 21);
+ }
+
+ #[test]
+ fn test_source_file_edit() {
+ // This tests only the non-parser parts. The reparsing itself is
+ // tested separately.
+ #[track_caller]
+ fn test(prev: &str, range: Range<usize>, with: &str, after: &str) {
+ let mut source = Source::detached(prev);
+ let result = Source::detached(after);
+ source.edit(range, with);
+ assert_eq!(source.text(), result.text());
+ assert_eq!(source.0.lines, result.0.lines);
+ }
+
+ // Test inserting at the beginning.
+ test("abc\n", 0..0, "hi\n", "hi\nabc\n");
+ test("\nabc", 0..0, "hi\r", "hi\r\nabc");
+
+ // Test editing in the middle.
+ test(TEST, 4..16, "❌", "ä\tc❌i\rjkl");
+
+ // Test appending.
+ test("abc\ndef", 7..7, "hi", "abc\ndefhi");
+ test("abc\ndef\n", 8..8, "hi", "abc\ndef\nhi");
+
+ // Test appending with adjoining \r and \n.
+ test("abc\ndef\r", 8..8, "\nghi", "abc\ndef\r\nghi");
+
+ // Test removing everything.
+ test(TEST, 0..21, "", "");
+ }
+}
diff --git a/crates/typst/src/syntax/span.rs b/crates/typst/src/syntax/span.rs
new file mode 100644
index 00000000..5c220252
--- /dev/null
+++ b/crates/typst/src/syntax/span.rs
@@ -0,0 +1,148 @@
+use std::fmt::{self, Debug, Formatter};
+use std::num::NonZeroU64;
+use std::ops::Range;
+
+use super::Source;
+use crate::file::FileId;
+use crate::World;
+
+/// A unique identifier for a syntax node.
+///
+/// This is used throughout the compiler to track which source section an error
+/// or element stems from. Can be [mapped back](Self::range) to a byte range for
+/// user facing display.
+///
+/// During editing, the span values stay mostly stable, even for nodes behind an
+/// insertion. This is not true for simple ranges as they would shift. Spans can
+/// be used as inputs to memoized functions without hurting cache performance
+/// when text is inserted somewhere in the document other than the end.
+///
+/// Span ids are ordered in the syntax tree to enable quickly finding the node
+/// with some id:
+/// - The id of a parent is always smaller than the ids of any of its children.
+/// - The id of a node is always greater than any id in the subtrees of any left
+/// sibling and smaller than any id in the subtrees of any right sibling.
+///
+/// This type takes up 8 bytes and is null-optimized (i.e. `Option<Span>` also
+/// takes 8 bytes).
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Span(NonZeroU64);
+
+impl Span {
+ /// The full range of numbers available for span numbering.
+ pub const FULL: Range<u64> = 2..(1 << Self::BITS);
+ const DETACHED: u64 = 1;
+
+ // Data layout:
+ // | 16 bits source id | 48 bits number |
+ const BITS: usize = 48;
+
+ /// Create a new span from a source id and a unique number.
+ ///
+ /// Panics if the `number` is not contained in `FULL`.
+ #[track_caller]
+ pub const fn new(id: FileId, number: u64) -> Self {
+ assert!(
+ Self::FULL.start <= number && number < Self::FULL.end,
+ "span number outside valid range"
+ );
+
+ Self::pack(id, number)
+ }
+
+ /// A span that does not point into any source file.
+ pub const fn detached() -> Self {
+ Self::pack(FileId::detached(), Self::DETACHED)
+ }
+
+ /// Pack the components into a span.
+ #[track_caller]
+ const fn pack(id: FileId, number: u64) -> Span {
+ let bits = ((id.as_u16() as u64) << Self::BITS) | number;
+ match NonZeroU64::new(bits) {
+ Some(v) => Self(v),
+ None => panic!("span encoding is zero"),
+ }
+ }
+
+ /// The id of the source file the span points into.
+ pub const fn id(self) -> FileId {
+ FileId::from_u16((self.0.get() >> Self::BITS) as u16)
+ }
+
+ /// The unique number of the span within its source file.
+ pub const fn number(self) -> u64 {
+ self.0.get() & ((1 << Self::BITS) - 1)
+ }
+
+ /// Whether the span is detached.
+ pub const fn is_detached(self) -> bool {
+ self.id().is_detached()
+ }
+
+ /// Get the byte range for this span.
+ #[track_caller]
+ pub fn range(self, world: &dyn World) -> Range<usize> {
+ let source = world
+ .source(self.id())
+ .expect("span does not point into any source file");
+ self.range_in(&source)
+ }
+
+ /// Get the byte range for this span in the given source file.
+ #[track_caller]
+ pub fn range_in(self, source: &Source) -> Range<usize> {
+ source
+ .find(self)
+ .expect("span does not point into this source file")
+ .range()
+ }
+}
+
+/// A value with a span locating it in the source code.
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Spanned<T> {
+ /// The spanned value.
+ pub v: T,
+ /// The value's location in source code.
+ pub span: Span,
+}
+
+impl<T> Spanned<T> {
+ /// Create a new instance from a value and its span.
+ pub fn new(v: T, span: Span) -> Self {
+ Self { v, span }
+ }
+
+ /// Convert from `&Spanned<T>` to `Spanned<&T>`
+ pub fn as_ref(&self) -> Spanned<&T> {
+ Spanned { v: &self.v, span: self.span }
+ }
+
+ /// Map the value using a function.
+ pub fn map<F, U>(self, f: F) -> Spanned<U>
+ where
+ F: FnOnce(T) -> U,
+ {
+ Spanned { v: f(self.v), span: self.span }
+ }
+}
+
+impl<T: Debug> Debug for Spanned<T> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.v.fmt(f)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{FileId, Span};
+
+ #[test]
+ fn test_span_encoding() {
+ let id = FileId::from_u16(5);
+ let span = Span::new(id, 10);
+ assert_eq!(span.id(), id);
+ assert_eq!(span.number(), 10);
+ }
+}
diff --git a/crates/typst/src/util/bytes.rs b/crates/typst/src/util/bytes.rs
new file mode 100644
index 00000000..9165467b
--- /dev/null
+++ b/crates/typst/src/util/bytes.rs
@@ -0,0 +1,59 @@
+use std::borrow::Cow;
+use std::fmt::{self, Debug, Formatter};
+use std::ops::Deref;
+use std::sync::Arc;
+
+use comemo::Prehashed;
+
+/// A shared byte buffer that is cheap to clone and hash.
+#[derive(Clone, Hash, Eq, PartialEq)]
+pub struct Bytes(Arc<Prehashed<Cow<'static, [u8]>>>);
+
+impl Bytes {
+ /// Create a buffer from a static byte slice.
+ pub fn from_static(slice: &'static [u8]) -> Self {
+ Self(Arc::new(Prehashed::new(Cow::Borrowed(slice))))
+ }
+
+ /// 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()
+ }
+}
+
+impl From<&[u8]> for Bytes {
+ fn from(slice: &[u8]) -> Self {
+ Self(Arc::new(Prehashed::new(slice.to_vec().into())))
+ }
+}
+
+impl From<Vec<u8>> for Bytes {
+ fn from(vec: Vec<u8>) -> Self {
+ Self(Arc::new(Prehashed::new(vec.into())))
+ }
+}
+
+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 Debug for Bytes {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "bytes({})", self.len())
+ }
+}
diff --git a/crates/typst/src/util/fat.rs b/crates/typst/src/util/fat.rs
new file mode 100644
index 00000000..d3c9bb20
--- /dev/null
+++ b/crates/typst/src/util/fat.rs
@@ -0,0 +1,55 @@
+//! Fat pointer handling.
+//!
+//! This assumes the memory representation of fat pointers. Although it is not
+//! guaranteed by Rust, it's improbable that it will change. Still, when the
+//! pointer metadata APIs are stable, we should definitely move to them:
+//! <https://github.com/rust-lang/rust/issues/81513>
+
+use std::alloc::Layout;
+use std::mem;
+
+/// Create a fat pointer from a data address and a vtable address.
+///
+/// # Safety
+/// Must only be called when `T` is a `dyn Trait`. The data address must point
+/// to a value whose type implements the trait of `T` and the `vtable` must have
+/// been extracted with [`vtable`].
+#[track_caller]
+pub unsafe fn from_raw_parts<T: ?Sized>(data: *const (), vtable: *const ()) -> *const T {
+ let fat = FatPointer { data, vtable };
+ debug_assert_eq!(Layout::new::<*const T>(), Layout::new::<FatPointer>());
+ mem::transmute_copy::<FatPointer, *const T>(&fat)
+}
+
+/// Create a mutable fat pointer from a data address and a vtable address.
+///
+/// # Safety
+/// Must only be called when `T` is a `dyn Trait`. The data address must point
+/// to a value whose type implements the trait of `T` and the `vtable` must have
+/// been extracted with [`vtable`].
+#[track_caller]
+pub unsafe fn from_raw_parts_mut<T: ?Sized>(data: *mut (), vtable: *const ()) -> *mut T {
+ let fat = FatPointer { data, vtable };
+ debug_assert_eq!(Layout::new::<*mut T>(), Layout::new::<FatPointer>());
+ mem::transmute_copy::<FatPointer, *mut T>(&fat)
+}
+
+/// Extract the address to a trait object's vtable.
+///
+/// # Safety
+/// Must only be called when `T` is a `dyn Trait`.
+#[track_caller]
+pub unsafe fn vtable<T: ?Sized>(ptr: *const T) -> *const () {
+ debug_assert_eq!(Layout::new::<*const T>(), Layout::new::<FatPointer>());
+ mem::transmute_copy::<*const T, FatPointer>(&ptr).vtable
+}
+
+/// The memory representation of a trait object pointer.
+///
+/// Although this is not guaranteed by Rust, it's improbable that it will
+/// change.
+#[repr(C)]
+struct FatPointer {
+ data: *const (),
+ vtable: *const (),
+}
diff --git a/crates/typst/src/util/mod.rs b/crates/typst/src/util/mod.rs
new file mode 100644
index 00000000..05914b04
--- /dev/null
+++ b/crates/typst/src/util/mod.rs
@@ -0,0 +1,268 @@
+//! Utilities.
+
+pub mod fat;
+
+mod bytes;
+
+pub use bytes::Bytes;
+
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+use std::num::NonZeroUsize;
+use std::path::{Component, Path, PathBuf};
+use std::sync::Arc;
+
+use siphasher::sip128::{Hasher128, SipHasher13};
+
+/// Turn a closure into a struct implementing [`Debug`].
+pub fn debug<F>(f: F) -> impl Debug
+where
+ F: Fn(&mut Formatter) -> fmt::Result,
+{
+ struct Wrapper<F>(F);
+
+ impl<F> Debug for Wrapper<F>
+ where
+ F: Fn(&mut Formatter) -> fmt::Result,
+ {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ self.0(f)
+ }
+ }
+
+ Wrapper(f)
+}
+
+/// Calculate a 128-bit siphash of a value.
+pub fn hash128<T: Hash + ?Sized>(value: &T) -> u128 {
+ let mut state = SipHasher13::new();
+ value.hash(&mut state);
+ state.finish128().as_u128()
+}
+
+/// An extra constant for [`NonZeroUsize`].
+pub trait NonZeroExt {
+ /// The number `1`.
+ const ONE: Self;
+}
+
+impl NonZeroExt for NonZeroUsize {
+ const ONE: Self = match Self::new(1) {
+ Some(v) => v,
+ None => unreachable!(),
+ };
+}
+
+/// Extra methods for [`str`].
+pub trait StrExt {
+ /// The number of code units this string would use if it was encoded in
+ /// UTF16. This runs in linear time.
+ fn len_utf16(&self) -> usize;
+}
+
+impl StrExt for str {
+ fn len_utf16(&self) -> usize {
+ self.chars().map(char::len_utf16).sum()
+ }
+}
+
+/// Extra methods for [`Arc`].
+pub trait ArcExt<T> {
+ /// Takes the inner value if there is exactly one strong reference and
+ /// clones it otherwise.
+ fn take(self) -> T;
+}
+
+impl<T: Clone> ArcExt<T> for Arc<T> {
+ fn take(self) -> T {
+ match Arc::try_unwrap(self) {
+ Ok(v) => v,
+ Err(rc) => (*rc).clone(),
+ }
+ }
+}
+
+/// Extra methods for [`[T]`](slice).
+pub trait SliceExt<T> {
+ /// Split a slice into consecutive runs with the same key and yield for
+ /// each such run the key and the slice of elements with that key.
+ fn group_by_key<K, F>(&self, f: F) -> GroupByKey<'_, T, F>
+ where
+ F: FnMut(&T) -> K,
+ K: PartialEq;
+}
+
+impl<T> SliceExt<T> for [T] {
+ fn group_by_key<K, F>(&self, f: F) -> GroupByKey<'_, T, F> {
+ GroupByKey { slice: self, f }
+ }
+}
+
+/// This struct is created by [`SliceExt::group_by_key`].
+pub struct GroupByKey<'a, T, F> {
+ slice: &'a [T],
+ f: F,
+}
+
+impl<'a, T, K, F> Iterator for GroupByKey<'a, T, F>
+where
+ F: FnMut(&T) -> K,
+ K: PartialEq,
+{
+ type Item = (K, &'a [T]);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let mut iter = self.slice.iter();
+ let key = (self.f)(iter.next()?);
+ let count = 1 + iter.take_while(|t| (self.f)(t) == key).count();
+ let (head, tail) = self.slice.split_at(count);
+ self.slice = tail;
+ Some((key, head))
+ }
+}
+
+/// Extra methods for [`Path`].
+pub trait PathExt {
+ /// Lexically normalize a path.
+ fn normalize(&self) -> PathBuf;
+
+ /// Treat `self` as a virtual root relative to which the `path` is resolved.
+ ///
+ /// Returns `None` if the path lexically escapes the root. The path
+ /// might still escape through symlinks.
+ fn join_rooted(&self, path: &Path) -> Option<PathBuf>;
+}
+
+impl PathExt for Path {
+ fn normalize(&self) -> PathBuf {
+ let mut out = PathBuf::new();
+ for component in self.components() {
+ match component {
+ Component::CurDir => {}
+ Component::ParentDir => match out.components().next_back() {
+ Some(Component::Normal(_)) => {
+ out.pop();
+ }
+ _ => out.push(component),
+ },
+ Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
+ out.push(component)
+ }
+ }
+ }
+ if out.as_os_str().is_empty() {
+ out.push(Component::CurDir);
+ }
+ out
+ }
+
+ fn join_rooted(&self, path: &Path) -> Option<PathBuf> {
+ let mut parts: Vec<_> = self.components().collect();
+ let root = parts.len();
+ for component in path.components() {
+ match component {
+ Component::Prefix(_) => return None,
+ Component::RootDir => parts.truncate(root),
+ Component::CurDir => {}
+ Component::ParentDir => {
+ if parts.len() <= root {
+ return None;
+ }
+ parts.pop();
+ }
+ Component::Normal(_) => parts.push(component),
+ }
+ }
+ if parts.len() < root {
+ return None;
+ }
+ Some(parts.into_iter().collect())
+ }
+}
+
+/// Format 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
+}
+
+/// Format 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
+}
+
+/// Format 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
+}
+
+/// Check if the [`Option`]-wrapped L is same to R.
+pub fn option_eq<L, R>(left: Option<L>, other: R) -> bool
+where
+ L: PartialEq<R>,
+{
+ left.map(|v| v == other).unwrap_or(false)
+}