summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/model
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/model')
-rw-r--r--crates/typst-library/src/model/bibliography.rs1101
-rw-r--r--crates/typst-library/src/model/cite.rs175
-rw-r--r--crates/typst-library/src/model/document.rs145
-rw-r--r--crates/typst-library/src/model/emph.rs41
-rw-r--r--crates/typst-library/src/model/enum.rs271
-rw-r--r--crates/typst-library/src/model/figure.rs641
-rw-r--r--crates/typst-library/src/model/footnote.rs326
-rw-r--r--crates/typst-library/src/model/heading.rs351
-rw-r--r--crates/typst-library/src/model/link.rs212
-rw-r--r--crates/typst-library/src/model/list.rs246
-rw-r--r--crates/typst-library/src/model/mod.rs75
-rw-r--r--crates/typst-library/src/model/numbering.rs658
-rw-r--r--crates/typst-library/src/model/outline.rs547
-rw-r--r--crates/typst-library/src/model/par.rs431
-rw-r--r--crates/typst-library/src/model/quote.rs237
-rw-r--r--crates/typst-library/src/model/reference.rs307
-rw-r--r--crates/typst-library/src/model/strong.rs48
-rw-r--r--crates/typst-library/src/model/table.rs724
-rw-r--r--crates/typst-library/src/model/terms.rs204
19 files changed, 6740 insertions, 0 deletions
diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs
new file mode 100644
index 00000000..d11055b9
--- /dev/null
+++ b/crates/typst-library/src/model/bibliography.rs
@@ -0,0 +1,1101 @@
+use std::collections::HashMap;
+use std::ffi::OsStr;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::num::NonZeroUsize;
+use std::path::Path;
+use std::sync::Arc;
+
+use comemo::Tracked;
+use ecow::{eco_format, EcoString, EcoVec};
+use hayagriva::archive::ArchivedStyle;
+use hayagriva::io::BibLaTeXError;
+use hayagriva::{
+ citationberg, BibliographyDriver, BibliographyRequest, CitationItem, CitationRequest,
+ SpecificLocator,
+};
+use indexmap::IndexMap;
+use once_cell::sync::Lazy;
+use smallvec::{smallvec, SmallVec};
+use typed_arena::Arena;
+use typst_syntax::{Span, Spanned};
+use typst_utils::{LazyHash, NonZeroExt, PicoStr};
+
+use crate::diag::{bail, error, At, FileError, HintedStrResult, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, ty, Args, Array, Bytes, CastInfo, Content, FromValue, IntoValue, Label,
+ NativeElement, Packed, Reflect, Repr, Scope, Show, ShowSet, Smart, Str, StyleChain,
+ Styles, Synthesize, Type, Value,
+};
+use crate::introspection::{Introspector, Locatable, Location};
+use crate::layout::{
+ BlockBody, BlockElem, Em, GridCell, GridChild, GridElem, GridItem, HElem, PadElem,
+ Sizing, TrackSizings, VElem,
+};
+use crate::model::{
+ CitationForm, CiteGroup, Destination, FootnoteElem, HeadingElem, LinkElem, ParElem,
+ Url,
+};
+use crate::routines::{EvalMode, Routines};
+use crate::text::{
+ FontStyle, Lang, LocalName, Region, SubElem, SuperElem, TextElem, WeightDelta,
+};
+use crate::World;
+
+/// A bibliography / reference listing.
+///
+/// You can create a new bibliography by calling this function with a path
+/// to a bibliography file in either one of two formats:
+///
+/// - A Hayagriva `.yml` file. Hayagriva is a new bibliography file format
+/// designed for use with Typst. Visit its
+/// [documentation](https://github.com/typst/hayagriva/blob/main/docs/file-format.md)
+/// for more details.
+/// - A BibLaTeX `.bib` file.
+///
+/// As soon as you add a bibliography somewhere in your document, you can start
+/// citing things with reference syntax (`[@key]`) or explicit calls to the
+/// [citation]($cite) function (`[#cite(<key>)]`). The bibliography will only
+/// show entries for works that were referenced in the document.
+///
+/// # Styles
+/// Typst offers a wide selection of built-in
+/// [citation and bibliography styles]($bibliography.style). Beyond those, you
+/// can add and use custom [CSL](https://citationstyles.org/) (Citation Style
+/// Language) files. Wondering which style to use? Here are some good defaults
+/// based on what discipline you're working in:
+///
+/// | Fields | Typical Styles |
+/// |-----------------|--------------------------------------------------------|
+/// | Engineering, IT | `{"ieee"}` |
+/// | Psychology, Life Sciences | `{"apa"}` |
+/// | Social sciences | `{"chicago-author-date"}` |
+/// | Humanities | `{"mla"}`, `{"chicago-notes"}`, `{"harvard-cite-them-right"}` |
+/// | Economics | `{"harvard-cite-them-right"}` |
+/// | Physics | `{"american-physics-society"}` |
+///
+/// # Example
+/// ```example
+/// This was already noted by
+/// pirates long ago. @arrgh
+///
+/// Multiple sources say ...
+/// @arrgh @netwok.
+///
+/// #bibliography("works.bib")
+/// ```
+#[elem(Locatable, Synthesize, Show, ShowSet, LocalName)]
+pub struct BibliographyElem {
+ /// Path(s) to Hayagriva `.yml` and/or BibLaTeX `.bib` files.
+ #[required]
+ #[parse(
+ let (paths, bibliography) = Bibliography::parse(engine, args)?;
+ paths
+ )]
+ pub path: BibliographyPaths,
+
+ /// The title of the bibliography.
+ ///
+ /// - When set to `{auto}`, an appropriate title for the
+ /// [text language]($text.lang) will be used. This is the default.
+ /// - When set to `{none}`, the bibliography will not have a title.
+ /// - A custom title can be set by passing content.
+ ///
+ /// The bibliography's heading will not be numbered by default, but you can
+ /// force it to be with a show-set rule:
+ /// `{show bibliography: set heading(numbering: "1.")}`
+ pub title: Smart<Option<Content>>,
+
+ /// Whether to include all works from the given bibliography files, even
+ /// those that weren't cited in the document.
+ ///
+ /// To selectively add individual cited works without showing them, you can
+ /// also use the `cite` function with [`form`]($cite.form) set to `{none}`.
+ #[default(false)]
+ pub full: bool,
+
+ /// The bibliography style.
+ ///
+ /// Should be either one of the built-in styles (see below) or a path to
+ /// a [CSL file](https://citationstyles.org/). Some of the styles listed
+ /// below appear twice, once with their full name and once with a short
+ /// alias.
+ #[parse(CslStyle::parse(engine, args)?)]
+ #[default(CslStyle::from_name("ieee").unwrap())]
+ pub style: CslStyle,
+
+ /// The loaded bibliography.
+ #[internal]
+ #[required]
+ #[parse(bibliography)]
+ pub bibliography: Bibliography,
+
+ /// The language setting where the bibliography is.
+ #[internal]
+ #[synthesized]
+ pub lang: Lang,
+
+ /// The region setting where the bibliography is.
+ #[internal]
+ #[synthesized]
+ pub region: Option<Region>,
+}
+
+/// A list of bibliography file paths.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct BibliographyPaths(Vec<EcoString>);
+
+cast! {
+ BibliographyPaths,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
+
+impl BibliographyElem {
+ /// Find the document's bibliography.
+ pub fn find(introspector: Tracked<Introspector>) -> StrResult<Packed<Self>> {
+ let query = introspector.query(&Self::elem().select());
+ let mut iter = query.iter();
+ let Some(elem) = iter.next() else {
+ bail!("the document does not contain a bibliography");
+ };
+
+ if iter.next().is_some() {
+ bail!("multiple bibliographies are not yet supported");
+ }
+
+ Ok(elem.to_packed::<Self>().unwrap().clone())
+ }
+
+ /// Whether the bibliography contains the given key.
+ pub fn has(engine: &Engine, key: impl Into<PicoStr>) -> bool {
+ let key = key.into();
+ engine
+ .introspector
+ .query(&Self::elem().select())
+ .iter()
+ .any(|elem| elem.to_packed::<Self>().unwrap().bibliography().has(key))
+ }
+
+ /// Find all bibliography keys.
+ pub fn keys(
+ introspector: Tracked<Introspector>,
+ ) -> Vec<(EcoString, Option<EcoString>)> {
+ let mut vec = vec![];
+ for elem in introspector.query(&Self::elem().select()).iter() {
+ let this = elem.to_packed::<Self>().unwrap();
+ for entry in this.bibliography().entries() {
+ let key = entry.key().into();
+ let detail = entry.title().map(|title| title.value.to_str().into());
+ vec.push((key, detail))
+ }
+ }
+ vec
+ }
+}
+
+impl Synthesize for Packed<BibliographyElem> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let elem = self.as_mut();
+ elem.push_lang(TextElem::lang_in(styles));
+ elem.push_region(TextElem::region_in(styles));
+ Ok(())
+ }
+}
+
+impl Show for Packed<BibliographyElem> {
+ #[typst_macros::time(name = "bibliography", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ const COLUMN_GUTTER: Em = Em::new(0.65);
+ const INDENT: Em = Em::new(1.5);
+
+ let mut seq = vec![];
+ if let Some(title) = self.title(styles).unwrap_or_else(|| {
+ Some(TextElem::packed(Self::local_name_in(styles)).spanned(self.span()))
+ }) {
+ seq.push(
+ HeadingElem::new(title)
+ .with_depth(NonZeroUsize::ONE)
+ .pack()
+ .spanned(self.span()),
+ );
+ }
+
+ let span = self.span();
+ let works = Works::generate(engine).at(span)?;
+ let references = works
+ .references
+ .as_ref()
+ .ok_or("CSL style is not suitable for bibliographies")
+ .at(span)?;
+
+ let row_gutter = ParElem::spacing_in(styles);
+ let row_gutter_elem = VElem::new(row_gutter.into()).with_weak(true).pack();
+
+ if references.iter().any(|(prefix, _)| prefix.is_some()) {
+ let mut cells = vec![];
+ for (prefix, reference) in references {
+ cells.push(GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(prefix.clone().unwrap_or_default()))
+ .spanned(span),
+ )));
+ cells.push(GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(reference.clone())).spanned(span),
+ )));
+ }
+ seq.push(
+ GridElem::new(cells)
+ .with_columns(TrackSizings(smallvec![Sizing::Auto; 2]))
+ .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()]))
+ .with_row_gutter(TrackSizings(smallvec![row_gutter.into()]))
+ .pack()
+ .spanned(self.span()),
+ );
+ } else {
+ for (i, (_, reference)) in references.iter().enumerate() {
+ if i > 0 {
+ seq.push(row_gutter_elem.clone());
+ }
+ seq.push(reference.clone());
+ }
+ }
+
+ let mut content = Content::sequence(seq);
+ if works.hanging_indent {
+ content = content.styled(ParElem::set_hanging_indent(INDENT.into()));
+ }
+
+ Ok(content)
+ }
+}
+
+impl ShowSet for Packed<BibliographyElem> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ const INDENT: Em = Em::new(1.0);
+ let mut out = Styles::new();
+ out.set(HeadingElem::set_numbering(None));
+ out.set(PadElem::set_left(INDENT.into()));
+ out
+ }
+}
+
+impl LocalName for Packed<BibliographyElem> {
+ const KEY: &'static str = "bibliography";
+}
+
+/// A loaded bibliography.
+#[derive(Clone, PartialEq)]
+pub struct Bibliography {
+ map: Arc<IndexMap<PicoStr, hayagriva::Entry>>,
+ hash: u128,
+}
+
+impl Bibliography {
+ /// Parse the bibliography argument.
+ fn parse(
+ engine: &mut Engine,
+ args: &mut Args,
+ ) -> SourceResult<(BibliographyPaths, Bibliography)> {
+ let Spanned { v: paths, span } =
+ args.expect::<Spanned<BibliographyPaths>>("path to bibliography file")?;
+
+ // Load bibliography files.
+ let data = paths
+ .0
+ .iter()
+ .map(|path| {
+ let id = span.resolve_path(path).at(span)?;
+ engine.world.file(id).at(span)
+ })
+ .collect::<SourceResult<Vec<Bytes>>>()?;
+
+ // Parse.
+ let bibliography = Self::load(&paths, &data).at(span)?;
+
+ Ok((paths, bibliography))
+ }
+
+ /// Load bibliography entries from paths.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load bibliography")]
+ fn load(paths: &BibliographyPaths, data: &[Bytes]) -> StrResult<Bibliography> {
+ let mut map = IndexMap::new();
+ let mut duplicates = Vec::<EcoString>::new();
+
+ // We might have multiple bib/yaml files
+ for (path, bytes) in paths.0.iter().zip(data) {
+ let src = std::str::from_utf8(bytes).map_err(FileError::from)?;
+
+ let ext = Path::new(path.as_str())
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default();
+
+ let library = match ext.to_lowercase().as_str() {
+ "yml" | "yaml" => hayagriva::io::from_yaml_str(src)
+ .map_err(|err| eco_format!("failed to parse YAML ({err})"))?,
+ "bib" => hayagriva::io::from_biblatex_str(src)
+ .map_err(|errors| format_biblatex_error(path, src, errors))?,
+ _ => bail!("unknown bibliography format (must be .yml/.yaml or .bib)"),
+ };
+
+ for entry in library {
+ match map.entry(entry.key().into()) {
+ indexmap::map::Entry::Vacant(vacant) => {
+ vacant.insert(entry);
+ }
+ indexmap::map::Entry::Occupied(_) => {
+ duplicates.push(entry.key().into());
+ }
+ }
+ }
+ }
+
+ if !duplicates.is_empty() {
+ bail!("duplicate bibliography keys: {}", duplicates.join(", "));
+ }
+
+ Ok(Bibliography {
+ map: Arc::new(map),
+ hash: typst_utils::hash128(data),
+ })
+ }
+
+ fn has(&self, key: impl Into<PicoStr>) -> bool {
+ self.map.contains_key(&key.into())
+ }
+
+ fn entries(&self) -> impl Iterator<Item = &hayagriva::Entry> {
+ self.map.values()
+ }
+}
+
+impl Debug for Bibliography {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.debug_set().entries(self.map.keys()).finish()
+ }
+}
+
+impl Hash for Bibliography {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ self.hash.hash(state);
+ }
+}
+
+/// Format a BibLaTeX loading error.
+fn format_biblatex_error(path: &str, src: &str, errors: Vec<BibLaTeXError>) -> EcoString {
+ let Some(error) = errors.first() else {
+ return eco_format!("failed to parse BibLaTeX file ({path})");
+ };
+
+ let (span, msg) = match error {
+ BibLaTeXError::Parse(error) => (&error.span, error.kind.to_string()),
+ BibLaTeXError::Type(error) => (&error.span, error.kind.to_string()),
+ };
+ let line = src.get(..span.start).unwrap_or_default().lines().count();
+ eco_format!("failed to parse BibLaTeX file ({path}:{line}: {msg})")
+}
+
+/// A loaded CSL style.
+#[ty(cast)]
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub struct CslStyle {
+ name: Option<EcoString>,
+ style: Arc<LazyHash<citationberg::IndependentStyle>>,
+}
+
+impl CslStyle {
+ /// Parse the style argument.
+ pub fn parse(engine: &mut Engine, args: &mut Args) -> SourceResult<Option<CslStyle>> {
+ let Some(Spanned { v: string, span }) =
+ args.named::<Spanned<EcoString>>("style")?
+ else {
+ return Ok(None);
+ };
+
+ Ok(Some(Self::parse_impl(engine, &string, span).at(span)?))
+ }
+
+ /// Parse the style argument with `Smart`.
+ pub fn parse_smart(
+ engine: &mut Engine,
+ args: &mut Args,
+ ) -> SourceResult<Option<Smart<CslStyle>>> {
+ let Some(Spanned { v: smart, span }) =
+ args.named::<Spanned<Smart<EcoString>>>("style")?
+ else {
+ return Ok(None);
+ };
+
+ Ok(Some(match smart {
+ Smart::Auto => Smart::Auto,
+ Smart::Custom(string) => {
+ Smart::Custom(Self::parse_impl(engine, &string, span).at(span)?)
+ }
+ }))
+ }
+
+ /// Parse internally.
+ fn parse_impl(engine: &mut Engine, string: &str, span: Span) -> StrResult<CslStyle> {
+ let ext = Path::new(string)
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default()
+ .to_lowercase();
+
+ if ext == "csl" {
+ let id = span.resolve_path(string)?;
+ let data = engine.world.file(id)?;
+ CslStyle::from_data(&data)
+ } else {
+ CslStyle::from_name(string)
+ }
+ }
+
+ /// Load a built-in CSL style.
+ #[comemo::memoize]
+ pub fn from_name(name: &str) -> StrResult<CslStyle> {
+ match hayagriva::archive::ArchivedStyle::by_name(name).map(ArchivedStyle::get) {
+ Some(citationberg::Style::Independent(style)) => Ok(Self {
+ name: Some(name.into()),
+ style: Arc::new(LazyHash::new(style)),
+ }),
+ _ => bail!("unknown style: `{name}`"),
+ }
+ }
+
+ /// Load a CSL style from file contents.
+ #[comemo::memoize]
+ pub fn from_data(data: &Bytes) -> StrResult<CslStyle> {
+ let text = std::str::from_utf8(data.as_slice()).map_err(FileError::from)?;
+ citationberg::IndependentStyle::from_xml(text)
+ .map(|style| Self { name: None, style: Arc::new(LazyHash::new(style)) })
+ .map_err(|err| eco_format!("failed to load CSL style ({err})"))
+ }
+
+ /// Get the underlying independent style.
+ pub fn get(&self) -> &citationberg::IndependentStyle {
+ self.style.as_ref()
+ }
+}
+
+// This Reflect impl is technically a bit wrong because it doesn't say what
+// FromValue and IntoValue really do. Instead, it says what the `style` argument
+// on `bibliography` and `cite` expect (through manual parsing).
+impl Reflect for CslStyle {
+ #[comemo::memoize]
+ fn input() -> CastInfo {
+ let ty = std::iter::once(CastInfo::Type(Type::of::<Str>()));
+ let options = hayagriva::archive::ArchivedStyle::all().iter().map(|name| {
+ CastInfo::Value(name.names()[0].into_value(), name.display_name())
+ });
+ CastInfo::Union(ty.chain(options).collect())
+ }
+
+ fn output() -> CastInfo {
+ EcoString::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ if let Value::Dyn(dynamic) = &value {
+ if dynamic.is::<Self>() {
+ return true;
+ }
+ }
+
+ false
+ }
+}
+
+impl FromValue for CslStyle {
+ fn from_value(value: Value) -> HintedStrResult<Self> {
+ if let Value::Dyn(dynamic) = &value {
+ if let Some(concrete) = dynamic.downcast::<Self>() {
+ return Ok(concrete.clone());
+ }
+ }
+
+ Err(<Self as Reflect>::error(&value))
+ }
+}
+
+impl IntoValue for CslStyle {
+ fn into_value(self) -> Value {
+ Value::dynamic(self)
+ }
+}
+
+impl Repr for CslStyle {
+ fn repr(&self) -> EcoString {
+ self.name
+ .as_ref()
+ .map(|name| name.repr())
+ .unwrap_or_else(|| "..".into())
+ }
+}
+
+/// Fully formatted citations and references, generated once (through
+/// memoization) for the whole document. This setup is necessary because
+/// citation formatting is inherently stateful and we need access to all
+/// citations to do it.
+pub(super) struct Works {
+ /// Maps from the location of a citation group to its rendered content.
+ pub citations: HashMap<Location, SourceResult<Content>>,
+ /// Lists all references in the bibliography, with optional prefix, or
+ /// `None` if the citation style can't be used for bibliographies.
+ pub references: Option<Vec<(Option<Content>, Content)>>,
+ /// Whether the bibliography should have hanging indent.
+ pub hanging_indent: bool,
+}
+
+impl Works {
+ /// Generate all citations and the whole bibliography.
+ pub fn generate(engine: &Engine) -> StrResult<Arc<Works>> {
+ Self::generate_impl(engine.routines, engine.world, engine.introspector)
+ }
+
+ /// The internal implementation of [`Works::generate`].
+ #[comemo::memoize]
+ fn generate_impl(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ ) -> StrResult<Arc<Works>> {
+ let mut generator = Generator::new(routines, world, introspector)?;
+ let rendered = generator.drive();
+ let works = generator.display(&rendered)?;
+ Ok(Arc::new(works))
+ }
+}
+
+/// Context for generating the bibliography.
+struct Generator<'a> {
+ /// The routines that is used to evaluate mathematical material in citations.
+ routines: &'a Routines,
+ /// The world that is used to evaluate mathematical material in citations.
+ world: Tracked<'a, dyn World + 'a>,
+ /// The document's bibliography.
+ bibliography: Packed<BibliographyElem>,
+ /// The document's citation groups.
+ groups: EcoVec<Content>,
+ /// Details about each group that are accumulated while driving hayagriva's
+ /// bibliography driver and needed when processing hayagriva's output.
+ infos: Vec<GroupInfo>,
+ /// Citations with unresolved keys.
+ failures: HashMap<Location, SourceResult<Content>>,
+}
+
+/// Details about a group of merged citations. All citations are put into groups
+/// of adjacent ones (e.g., `@foo @bar` will merge into a group of length two).
+/// Even single citations will be put into groups of length ones.
+struct GroupInfo {
+ /// The group's location.
+ location: Location,
+ /// The group's span.
+ span: Span,
+ /// Whether the group should be displayed in a footnote.
+ footnote: bool,
+ /// Details about the groups citations.
+ subinfos: SmallVec<[CiteInfo; 1]>,
+}
+
+/// Details about a citation item in a request.
+struct CiteInfo {
+ /// The citation's key.
+ key: Label,
+ /// The citation's supplement.
+ supplement: Option<Content>,
+ /// Whether this citation was hidden.
+ hidden: bool,
+}
+
+impl<'a> Generator<'a> {
+ /// Create a new generator.
+ fn new(
+ routines: &'a Routines,
+ world: Tracked<'a, dyn World + 'a>,
+ introspector: Tracked<Introspector>,
+ ) -> StrResult<Self> {
+ let bibliography = BibliographyElem::find(introspector)?;
+ let groups = introspector.query(&CiteGroup::elem().select());
+ let infos = Vec::with_capacity(groups.len());
+ Ok(Self {
+ routines,
+ world,
+ bibliography,
+ groups,
+ infos,
+ failures: HashMap::new(),
+ })
+ }
+
+ /// Drives hayagriva's citation driver.
+ fn drive(&mut self) -> hayagriva::Rendered {
+ static LOCALES: Lazy<Vec<citationberg::Locale>> =
+ Lazy::new(hayagriva::archive::locales);
+
+ let database = self.bibliography.bibliography();
+ let bibliography_style = self.bibliography.style(StyleChain::default());
+ let styles = Arena::new();
+
+ // Process all citation groups.
+ let mut driver = BibliographyDriver::new();
+ for elem in &self.groups {
+ let group = elem.to_packed::<CiteGroup>().unwrap();
+ let location = elem.location().unwrap();
+ let children = group.children();
+
+ // Groups should never be empty.
+ let Some(first) = children.first() else { continue };
+
+ let mut subinfos = SmallVec::with_capacity(children.len());
+ let mut items = Vec::with_capacity(children.len());
+ let mut errors = EcoVec::new();
+ let mut normal = true;
+
+ // Create infos and items for each child in the group.
+ for child in children {
+ let key = *child.key();
+ let Some(entry) = database.map.get(&key.into_inner()) else {
+ errors.push(error!(
+ child.span(),
+ "key `{}` does not exist in the bibliography",
+ key.as_str()
+ ));
+ continue;
+ };
+
+ let supplement = child.supplement(StyleChain::default());
+ let locator = supplement.as_ref().map(|_| {
+ SpecificLocator(
+ citationberg::taxonomy::Locator::Custom,
+ hayagriva::LocatorPayload::Transparent,
+ )
+ });
+
+ let mut hidden = false;
+ let special_form = match child.form(StyleChain::default()) {
+ None => {
+ hidden = true;
+ None
+ }
+ Some(CitationForm::Normal) => None,
+ Some(CitationForm::Prose) => Some(hayagriva::CitePurpose::Prose),
+ Some(CitationForm::Full) => Some(hayagriva::CitePurpose::Full),
+ Some(CitationForm::Author) => Some(hayagriva::CitePurpose::Author),
+ Some(CitationForm::Year) => Some(hayagriva::CitePurpose::Year),
+ };
+
+ normal &= special_form.is_none();
+ subinfos.push(CiteInfo { key, supplement, hidden });
+ items.push(CitationItem::new(entry, locator, None, hidden, special_form));
+ }
+
+ if !errors.is_empty() {
+ self.failures.insert(location, Err(errors));
+ continue;
+ }
+
+ let style = match first.style(StyleChain::default()) {
+ Smart::Auto => &bibliography_style.style,
+ Smart::Custom(style) => styles.alloc(style.style),
+ };
+
+ self.infos.push(GroupInfo {
+ location,
+ subinfos,
+ span: first.span(),
+ footnote: normal
+ && style.settings.class == citationberg::StyleClass::Note,
+ });
+
+ driver.citation(CitationRequest::new(
+ items,
+ style,
+ Some(locale(
+ first.lang().copied().unwrap_or(Lang::ENGLISH),
+ first.region().copied().flatten(),
+ )),
+ &LOCALES,
+ None,
+ ));
+ }
+
+ let locale = locale(
+ self.bibliography.lang().copied().unwrap_or(Lang::ENGLISH),
+ self.bibliography.region().copied().flatten(),
+ );
+
+ // Add hidden items for everything if we should print the whole
+ // bibliography.
+ if self.bibliography.full(StyleChain::default()) {
+ for entry in database.map.values() {
+ driver.citation(CitationRequest::new(
+ vec![CitationItem::new(entry, None, None, true, None)],
+ bibliography_style.get(),
+ Some(locale.clone()),
+ &LOCALES,
+ None,
+ ));
+ }
+ }
+
+ driver.finish(BibliographyRequest {
+ style: bibliography_style.get(),
+ locale: Some(locale),
+ locale_files: &LOCALES,
+ })
+ }
+
+ /// Displays hayagriva's output as content for the citations and references.
+ fn display(&mut self, rendered: &hayagriva::Rendered) -> StrResult<Works> {
+ let citations = self.display_citations(rendered)?;
+ let references = self.display_references(rendered)?;
+ let hanging_indent =
+ rendered.bibliography.as_ref().is_some_and(|b| b.hanging_indent);
+ Ok(Works { citations, references, hanging_indent })
+ }
+
+ /// Display the citation groups.
+ fn display_citations(
+ &mut self,
+ rendered: &hayagriva::Rendered,
+ ) -> StrResult<HashMap<Location, SourceResult<Content>>> {
+ // Determine for each citation key where in the bibliography it is,
+ // so that we can link there.
+ let mut links = HashMap::new();
+ if let Some(bibliography) = &rendered.bibliography {
+ let location = self.bibliography.location().unwrap();
+ for (k, item) in bibliography.items.iter().enumerate() {
+ links.insert(item.key.as_str(), location.variant(k + 1));
+ }
+ }
+
+ let mut output = std::mem::take(&mut self.failures);
+ for (info, citation) in self.infos.iter().zip(&rendered.citations) {
+ let supplement = |i: usize| info.subinfos.get(i)?.supplement.clone();
+ let link = |i: usize| links.get(info.subinfos.get(i)?.key.as_str()).copied();
+
+ let renderer = ElemRenderer {
+ routines: self.routines,
+ world: self.world,
+ span: info.span,
+ supplement: &supplement,
+ link: &link,
+ };
+
+ let content = if info.subinfos.iter().all(|sub| sub.hidden) {
+ Content::empty()
+ } else {
+ let mut content = renderer.display_elem_children(
+ &citation.citation,
+ &mut None,
+ true,
+ )?;
+
+ if info.footnote {
+ content = FootnoteElem::with_content(content).pack();
+ }
+
+ content
+ };
+
+ output.insert(info.location, Ok(content));
+ }
+
+ Ok(output)
+ }
+
+ /// Display the bibliography references.
+ #[allow(clippy::type_complexity)]
+ fn display_references(
+ &self,
+ rendered: &hayagriva::Rendered,
+ ) -> StrResult<Option<Vec<(Option<Content>, Content)>>> {
+ let Some(rendered) = &rendered.bibliography else { return Ok(None) };
+
+ // Determine for each citation key where it first occurred, so that we
+ // can link there.
+ let mut first_occurrences = HashMap::new();
+ for info in &self.infos {
+ for subinfo in &info.subinfos {
+ let key = subinfo.key.as_str();
+ first_occurrences.entry(key).or_insert(info.location);
+ }
+ }
+
+ // The location of the bibliography.
+ let location = self.bibliography.location().unwrap();
+
+ let mut output = vec![];
+ for (k, item) in rendered.items.iter().enumerate() {
+ let renderer = ElemRenderer {
+ routines: self.routines,
+ world: self.world,
+ span: self.bibliography.span(),
+ supplement: &|_| None,
+ link: &|_| None,
+ };
+
+ // Each reference is assigned a manually created well-known location
+ // that is derived from the bibliography's location. This way,
+ // citations can link to them.
+ let backlink = location.variant(k + 1);
+
+ // Render the first field.
+ let mut prefix = item
+ .first_field
+ .as_ref()
+ .map(|elem| {
+ let mut content =
+ renderer.display_elem_child(elem, &mut None, false)?;
+ if let Some(location) = first_occurrences.get(item.key.as_str()) {
+ let dest = Destination::Location(*location);
+ content = content.linked(dest);
+ }
+ StrResult::Ok(content)
+ })
+ .transpose()?;
+
+ // Render the main reference content.
+ let mut reference =
+ renderer.display_elem_children(&item.content, &mut prefix, false)?;
+
+ // Attach a backlink to either the prefix or the reference so that
+ // we can link to the bibliography entry.
+ prefix.as_mut().unwrap_or(&mut reference).set_location(backlink);
+
+ output.push((prefix, reference));
+ }
+
+ Ok(Some(output))
+ }
+}
+
+/// Renders hayagriva elements into content.
+struct ElemRenderer<'a> {
+ /// The routines that is used to evaluate mathematical material in citations.
+ routines: &'a Routines,
+ /// The world that is used to evaluate mathematical material.
+ world: Tracked<'a, dyn World + 'a>,
+ /// The span that is attached to all of the resulting content.
+ span: Span,
+ /// Resolves the supplement of i-th citation in the request.
+ supplement: &'a dyn Fn(usize) -> Option<Content>,
+ /// Resolves where the i-th citation in the request should link to.
+ link: &'a dyn Fn(usize) -> Option<Location>,
+}
+
+impl ElemRenderer<'_> {
+ /// Display rendered hayagriva elements.
+ ///
+ /// The `prefix` can be a separate content storage where `left-margin`
+ /// elements will be accumulated into.
+ ///
+ /// `is_citation` dictates whether whitespace at the start of the citation
+ /// will be eliminated. Some CSL styles yield whitespace at the start of
+ /// their citations, which should instead be handled by Typst.
+ fn display_elem_children(
+ &self,
+ elems: &hayagriva::ElemChildren,
+ prefix: &mut Option<Content>,
+ is_citation: bool,
+ ) -> StrResult<Content> {
+ Ok(Content::sequence(
+ elems
+ .0
+ .iter()
+ .enumerate()
+ .map(|(i, elem)| {
+ self.display_elem_child(elem, prefix, is_citation && i == 0)
+ })
+ .collect::<StrResult<Vec<_>>>()?,
+ ))
+ }
+
+ /// Display a rendered hayagriva element.
+ fn display_elem_child(
+ &self,
+ elem: &hayagriva::ElemChild,
+ prefix: &mut Option<Content>,
+ trim_start: bool,
+ ) -> StrResult<Content> {
+ Ok(match elem {
+ hayagriva::ElemChild::Text(formatted) => {
+ self.display_formatted(formatted, trim_start)
+ }
+ hayagriva::ElemChild::Elem(elem) => self.display_elem(elem, prefix)?,
+ hayagriva::ElemChild::Markup(markup) => self.display_math(markup),
+ hayagriva::ElemChild::Link { text, url } => self.display_link(text, url)?,
+ hayagriva::ElemChild::Transparent { cite_idx, format } => {
+ self.display_transparent(*cite_idx, format)
+ }
+ })
+ }
+
+ /// Display a block-level element.
+ fn display_elem(
+ &self,
+ elem: &hayagriva::Elem,
+ prefix: &mut Option<Content>,
+ ) -> StrResult<Content> {
+ use citationberg::Display;
+
+ let block_level = matches!(elem.display, Some(Display::Block | Display::Indent));
+
+ let mut suf_prefix = None;
+ let mut content = self.display_elem_children(
+ &elem.children,
+ if block_level { &mut suf_prefix } else { prefix },
+ false,
+ )?;
+
+ if let Some(prefix) = suf_prefix {
+ const COLUMN_GUTTER: Em = Em::new(0.65);
+ content = GridElem::new(vec![
+ GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(prefix)).spanned(self.span),
+ )),
+ GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(content)).spanned(self.span),
+ )),
+ ])
+ .with_columns(TrackSizings(smallvec![Sizing::Auto; 2]))
+ .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()]))
+ .pack()
+ .spanned(self.span);
+ }
+
+ match elem.display {
+ Some(Display::Block) => {
+ content = BlockElem::new()
+ .with_body(Some(BlockBody::Content(content)))
+ .pack()
+ .spanned(self.span);
+ }
+ Some(Display::Indent) => {
+ content = PadElem::new(content).pack().spanned(self.span);
+ }
+ Some(Display::LeftMargin) => {
+ *prefix.get_or_insert_with(Default::default) += content;
+ return Ok(Content::empty());
+ }
+ _ => {}
+ }
+
+ if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta {
+ if let Some(location) = (self.link)(i) {
+ let dest = Destination::Location(location);
+ content = content.linked(dest);
+ }
+ }
+
+ Ok(content)
+ }
+
+ /// Display math.
+ fn display_math(&self, math: &str) -> Content {
+ (self.routines.eval_string)(
+ self.routines,
+ self.world,
+ math,
+ self.span,
+ EvalMode::Math,
+ Scope::new(),
+ )
+ .map(Value::display)
+ .unwrap_or_else(|_| TextElem::packed(math).spanned(self.span))
+ }
+
+ /// Display a link.
+ fn display_link(&self, text: &hayagriva::Formatted, url: &str) -> StrResult<Content> {
+ let dest = Destination::Url(Url::new(url)?);
+ Ok(LinkElem::new(dest.into(), self.display_formatted(text, false))
+ .pack()
+ .spanned(self.span))
+ }
+
+ /// Display transparent pass-through content.
+ fn display_transparent(&self, i: usize, format: &hayagriva::Formatting) -> Content {
+ let content = (self.supplement)(i).unwrap_or_default();
+ apply_formatting(content, format)
+ }
+
+ /// Display formatted hayagriva text as content.
+ fn display_formatted(
+ &self,
+ formatted: &hayagriva::Formatted,
+ trim_start: bool,
+ ) -> Content {
+ let formatted_text = if trim_start {
+ formatted.text.trim_start()
+ } else {
+ formatted.text.as_str()
+ };
+
+ let content = TextElem::packed(formatted_text).spanned(self.span);
+ apply_formatting(content, &formatted.formatting)
+ }
+}
+
+/// Applies formatting to content.
+fn apply_formatting(mut content: Content, format: &hayagriva::Formatting) -> Content {
+ match format.font_style {
+ citationberg::FontStyle::Normal => {}
+ citationberg::FontStyle::Italic => {
+ content = content.styled(TextElem::set_style(FontStyle::Italic));
+ }
+ }
+
+ match format.font_variant {
+ citationberg::FontVariant::Normal => {}
+ citationberg::FontVariant::SmallCaps => {
+ content = content.styled(TextElem::set_smallcaps(true));
+ }
+ }
+
+ match format.font_weight {
+ citationberg::FontWeight::Normal => {}
+ citationberg::FontWeight::Bold => {
+ content = content.styled(TextElem::set_delta(WeightDelta(300)));
+ }
+ citationberg::FontWeight::Light => {
+ content = content.styled(TextElem::set_delta(WeightDelta(-100)));
+ }
+ }
+
+ match format.text_decoration {
+ citationberg::TextDecoration::None => {}
+ citationberg::TextDecoration::Underline => {
+ content = content.underlined();
+ }
+ }
+
+ let span = content.span();
+ match format.vertical_align {
+ citationberg::VerticalAlign::None => {}
+ citationberg::VerticalAlign::Baseline => {}
+ citationberg::VerticalAlign::Sup => {
+ // Add zero-width weak spacing to make the superscript "sticky".
+ content = HElem::hole().pack() + SuperElem::new(content).pack().spanned(span);
+ }
+ citationberg::VerticalAlign::Sub => {
+ content = HElem::hole().pack() + SubElem::new(content).pack().spanned(span);
+ }
+ }
+
+ content
+}
+
+/// Create a locale code from language and optionally region.
+fn locale(lang: Lang, region: Option<Region>) -> citationberg::LocaleCode {
+ let mut value = String::with_capacity(5);
+ value.push_str(lang.as_str());
+ if let Some(region) = region {
+ value.push('-');
+ value.push_str(region.as_str())
+ }
+ citationberg::LocaleCode(value)
+}
diff --git a/crates/typst-library/src/model/cite.rs b/crates/typst-library/src/model/cite.rs
new file mode 100644
index 00000000..ac0cfa79
--- /dev/null
+++ b/crates/typst-library/src/model/cite.rs
@@ -0,0 +1,175 @@
+use crate::diag::{error, At, HintedString, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Cast, Content, Label, Packed, Show, Smart, StyleChain, Synthesize,
+};
+use crate::introspection::Locatable;
+use crate::model::bibliography::Works;
+use crate::model::CslStyle;
+use crate::text::{Lang, Region, TextElem};
+
+/// Cite a work from the bibliography.
+///
+/// Before you starting citing, you need to add a [bibliography] somewhere in
+/// your document.
+///
+/// # Example
+/// ```example
+/// This was already noted by
+/// pirates long ago. @arrgh
+///
+/// Multiple sources say ...
+/// @arrgh @netwok.
+///
+/// You can also call `cite`
+/// explicitly. #cite(<arrgh>)
+///
+/// #bibliography("works.bib")
+/// ```
+///
+/// If your source name contains certain characters such as slashes, which are
+/// not recognized by the `<>` syntax, you can explicitly call `label` instead.
+///
+/// ```typ
+/// Computer Modern is an example of a modernist serif typeface.
+/// #cite(label("DBLP:books/lib/Knuth86a")).
+/// >>> #bibliography("works.bib")
+/// ```
+///
+/// # Syntax
+/// This function indirectly has dedicated syntax. [References]($ref) can be
+/// used to cite works from the bibliography. The label then corresponds to the
+/// citation key.
+#[elem(Synthesize)]
+pub struct CiteElem {
+ /// The citation key that identifies the entry in the bibliography that
+ /// shall be cited, as a label.
+ ///
+ /// ```example
+ /// // All the same
+ /// @netwok \
+ /// #cite(<netwok>) \
+ /// #cite(label("netwok"))
+ /// >>> #set text(0pt)
+ /// >>> #bibliography("works.bib", style: "apa")
+ /// ```
+ #[required]
+ pub key: Label,
+
+ /// A supplement for the citation such as page or chapter number.
+ ///
+ /// In reference syntax, the supplement can be added in square brackets:
+ ///
+ /// ```example
+ /// This has been proven. @distress[p.~7]
+ ///
+ /// #bibliography("works.bib")
+ /// ```
+ pub supplement: Option<Content>,
+
+ /// The kind of citation to produce. Different forms are useful in different
+ /// scenarios: A normal citation is useful as a source at the end of a
+ /// sentence, while a "prose" citation is more suitable for inclusion in the
+ /// flow of text.
+ ///
+ /// If set to `{none}`, the cited work is included in the bibliography, but
+ /// nothing will be displayed.
+ ///
+ /// ```example
+ /// #cite(<netwok>, form: "prose")
+ /// show the outsized effects of
+ /// pirate life on the human psyche.
+ /// >>> #set text(0pt)
+ /// >>> #bibliography("works.bib", style: "apa")
+ /// ```
+ #[default(Some(CitationForm::Normal))]
+ pub form: Option<CitationForm>,
+
+ /// The citation style.
+ ///
+ /// Should be either `{auto}`, one of the built-in styles (see below) or a
+ /// path to a [CSL file](https://citationstyles.org/). Some of the styles
+ /// listed below appear twice, once with their full name and once with a
+ /// short alias.
+ ///
+ /// When set to `{auto}`, automatically use the
+ /// [bibliography's style]($bibliography.style) for the citations.
+ #[parse(CslStyle::parse_smart(engine, args)?)]
+ pub style: Smart<CslStyle>,
+
+ /// The text language setting where the citation is.
+ #[internal]
+ #[synthesized]
+ pub lang: Lang,
+
+ /// The text region setting where the citation is.
+ #[internal]
+ #[synthesized]
+ pub region: Option<Region>,
+}
+
+impl Synthesize for Packed<CiteElem> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let elem = self.as_mut();
+ elem.push_lang(TextElem::lang_in(styles));
+ elem.push_region(TextElem::region_in(styles));
+ Ok(())
+ }
+}
+
+cast! {
+ CiteElem,
+ v: Content => v.unpack::<Self>().map_err(|_| "expected citation")?,
+}
+
+/// The form of the citation.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum CitationForm {
+ /// Display in the standard way for the active style.
+ #[default]
+ Normal,
+ /// Produces a citation that is suitable for inclusion in a sentence.
+ Prose,
+ /// Mimics a bibliography entry, with full information about the cited work.
+ Full,
+ /// Shows only the cited work's author(s).
+ Author,
+ /// Shows only the cited work's year.
+ Year,
+}
+
+/// A group of citations.
+///
+/// This is automatically created from adjacent citations during show rule
+/// application.
+#[elem(Locatable, Show)]
+pub struct CiteGroup {
+ /// The citations.
+ #[required]
+ pub children: Vec<Packed<CiteElem>>,
+}
+
+impl Show for Packed<CiteGroup> {
+ #[typst_macros::time(name = "cite", span = self.span())]
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ let location = self.location().unwrap();
+ let span = self.span();
+ Works::generate(engine)
+ .at(span)?
+ .citations
+ .get(&location)
+ .cloned()
+ .ok_or_else(failed_to_format_citation)
+ .at(span)?
+ }
+}
+
+/// The error message when a citation wasn't found in the pre-formatted list.
+#[cold]
+fn failed_to_format_citation() -> HintedString {
+ error!(
+ "cannot format citation in isolation";
+ hint: "check whether this citation is measured \
+ without being inserted into the document"
+ )
+}
diff --git a/crates/typst-library/src/model/document.rs b/crates/typst-library/src/model/document.rs
new file mode 100644
index 00000000..b693d785
--- /dev/null
+++ b/crates/typst-library/src/model/document.rs
@@ -0,0 +1,145 @@
+use ecow::EcoString;
+
+use crate::diag::{bail, HintedStrResult, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Args, Array, Construct, Content, Datetime, Fields, Smart, StyleChain,
+ Styles, Value,
+};
+use crate::introspection::Introspector;
+use crate::layout::Page;
+
+/// The root element of a document and its metadata.
+///
+/// All documents are automatically wrapped in a `document` element. You cannot
+/// create a document element yourself. This function is only used with
+/// [set rules]($styling/#set-rules) to specify document metadata. Such a set
+/// rule must not occur inside of any layout container.
+///
+/// ```example
+/// #set document(title: [Hello])
+///
+/// This has no visible output, but
+/// embeds metadata into the PDF!
+/// ```
+///
+/// Note that metadata set with this function is not rendered within the
+/// document. Instead, it is embedded in the compiled PDF file.
+#[elem(Construct)]
+pub struct DocumentElem {
+ /// The document's title. This is often rendered as the title of the
+ /// PDF viewer window.
+ ///
+ /// While this can be arbitrary content, PDF viewers only support plain text
+ /// titles, so the conversion might be lossy.
+ #[ghost]
+ pub title: Option<Content>,
+
+ /// The document's authors.
+ #[ghost]
+ pub author: Author,
+
+ /// The document's keywords.
+ #[ghost]
+ pub keywords: Keywords,
+
+ /// The document's creation date.
+ ///
+ /// If this is `{auto}` (default), Typst uses the current date and time.
+ /// Setting it to `{none}` prevents Typst from embedding any creation date
+ /// into the PDF metadata.
+ ///
+ /// The year component must be at least zero in order to be embedded into a
+ /// PDF.
+ ///
+ /// If you want to create byte-by-byte reproducible PDFs, set this to
+ /// something other than `{auto}`.
+ #[ghost]
+ pub date: Smart<Option<Datetime>>,
+}
+
+impl Construct for DocumentElem {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "can only be used in set rules")
+ }
+}
+
+/// A list of authors.
+#[derive(Debug, Default, Clone, PartialEq, Hash)]
+pub struct Author(Vec<EcoString>);
+
+cast! {
+ Author,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
+
+/// A list of keywords.
+#[derive(Debug, Default, Clone, PartialEq, Hash)]
+pub struct Keywords(Vec<EcoString>);
+
+cast! {
+ Keywords,
+ self => self.0.into_value(),
+ v: EcoString => Self(vec![v]),
+ v: Array => Self(v.into_iter().map(Value::cast).collect::<HintedStrResult<_>>()?),
+}
+
+/// A finished document with metadata and page frames.
+#[derive(Debug, Default, Clone)]
+pub struct Document {
+ /// The document's finished pages.
+ pub pages: Vec<Page>,
+ /// Details about the document.
+ pub info: DocumentInfo,
+ /// Provides the ability to execute queries on the document.
+ pub introspector: Introspector,
+}
+
+/// Details about the document.
+#[derive(Debug, Default, Clone, PartialEq, Hash)]
+pub struct DocumentInfo {
+ /// The document's title.
+ pub title: Option<EcoString>,
+ /// The document's author.
+ pub author: Vec<EcoString>,
+ /// The document's keywords.
+ pub keywords: Vec<EcoString>,
+ /// The document's creation date.
+ pub date: Smart<Option<Datetime>>,
+}
+
+impl DocumentInfo {
+ /// Populate this document info with details from the given styles.
+ ///
+ /// Document set rules are a bit special, so we need to do this manually.
+ pub fn populate(&mut self, styles: &Styles) {
+ let chain = StyleChain::new(styles);
+ let has = |field| styles.has::<DocumentElem>(field as _);
+ if has(<DocumentElem as Fields>::Enum::Title) {
+ self.title =
+ DocumentElem::title_in(chain).map(|content| content.plain_text());
+ }
+ if has(<DocumentElem as Fields>::Enum::Author) {
+ self.author = DocumentElem::author_in(chain).0;
+ }
+ if has(<DocumentElem as Fields>::Enum::Keywords) {
+ self.keywords = DocumentElem::keywords_in(chain).0;
+ }
+ if has(<DocumentElem as Fields>::Enum::Date) {
+ self.date = DocumentElem::date_in(chain);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_document_is_send_and_sync() {
+ fn ensure_send_and_sync<T: Send + Sync>() {}
+ ensure_send_and_sync::<Document>();
+ }
+}
diff --git a/crates/typst-library/src/model/emph.rs b/crates/typst-library/src/model/emph.rs
new file mode 100644
index 00000000..e36e5ef7
--- /dev/null
+++ b/crates/typst-library/src/model/emph.rs
@@ -0,0 +1,41 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, StyleChain};
+use crate::text::{ItalicToggle, TextElem};
+
+/// Emphasizes content by toggling italics.
+///
+/// - If the current [text style]($text.style) is `{"normal"}`, this turns it
+/// into `{"italic"}`.
+/// - If it is already `{"italic"}` or `{"oblique"}`, it turns it back to
+/// `{"normal"}`.
+///
+/// # Example
+/// ```example
+/// This is _emphasized._ \
+/// This is #emph[too.]
+///
+/// #show emph: it => {
+/// text(blue, it.body)
+/// }
+///
+/// This is _emphasized_ differently.
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: To emphasize content, simply
+/// enclose it in underscores (`_`). Note that this only works at word
+/// boundaries. To emphasize part of a word, you have to use the function.
+#[elem(title = "Emphasis", keywords = ["italic"], Show)]
+pub struct EmphElem {
+ /// The content to emphasize.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<EmphElem> {
+ #[typst_macros::time(name = "emph", span = self.span())]
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().clone().styled(TextElem::set_emph(ItalicToggle(true))))
+ }
+}
diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs
new file mode 100644
index 00000000..bac792d3
--- /dev/null
+++ b/crates/typst-library/src/model/enum.rs
@@ -0,0 +1,271 @@
+use std::str::FromStr;
+
+use smallvec::SmallVec;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Array, Content, NativeElement, Packed, Show, Smart, StyleChain,
+ Styles,
+};
+use crate::layout::{Alignment, BlockElem, Em, HAlignment, Length, VAlignment, VElem};
+use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern, ParElem};
+
+/// A numbered list.
+///
+/// Displays a sequence of items vertically and numbers them consecutively.
+///
+/// # Example
+/// ```example
+/// Automatically numbered:
+/// + Preparations
+/// + Analysis
+/// + Conclusions
+///
+/// Manually numbered:
+/// 2. What is the first step?
+/// 5. I am confused.
+/// + Moving on ...
+///
+/// Multiple lines:
+/// + This enum item has multiple
+/// lines because the next line
+/// is indented.
+///
+/// Function call.
+/// #enum[First][Second]
+/// ```
+///
+/// You can easily switch all your enumerations to a different numbering style
+/// with a set rule.
+/// ```example
+/// #set enum(numbering: "a)")
+///
+/// + Starting off ...
+/// + Don't forget step two
+/// ```
+///
+/// You can also use [`enum.item`]($enum.item) to programmatically customize the
+/// number of each item in the enumeration:
+///
+/// ```example
+/// #enum(
+/// enum.item(1)[First step],
+/// enum.item(5)[Fifth step],
+/// enum.item(10)[Tenth step]
+/// )
+/// ```
+///
+/// # Syntax
+/// This functions also has dedicated syntax:
+///
+/// - Starting a line with a plus sign creates an automatically numbered
+/// enumeration item.
+/// - Starting a line with a number followed by a dot creates an explicitly
+/// numbered enumeration item.
+///
+/// Enumeration items can contain multiple paragraphs and other block-level
+/// content. All content that is indented more than an item's marker becomes
+/// part of that item.
+#[elem(scope, title = "Numbered List", Show)]
+pub struct EnumElem {
+ /// Defines the default [spacing]($enum.spacing) of the enumeration. If it
+ /// is `{false}`, the items are spaced apart with
+ /// [paragraph spacing]($par.spacing). If it is `{true}`, they use
+ /// [paragraph leading]($par.leading) instead. This makes the list more
+ /// compact, which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`. The markup-defined tightness cannot be
+ /// overridden with set rules.
+ ///
+ /// ```example
+ /// + If an enum has a lot of text, and
+ /// maybe other inline content, it
+ /// should not be tight anymore.
+ ///
+ /// + To make an enum wide, simply
+ /// insert a blank line between the
+ /// items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// How to number the enumeration. Accepts a
+ /// [numbering pattern or function]($numbering).
+ ///
+ /// If the numbering pattern contains multiple counting symbols, they apply
+ /// to nested enums. If given a function, the function receives one argument
+ /// if `full` is `{false}` and multiple arguments if `full` is `{true}`.
+ ///
+ /// ```example
+ /// #set enum(numbering: "1.a)")
+ /// + Different
+ /// + Numbering
+ /// + Nested
+ /// + Items
+ /// + Style
+ ///
+ /// #set enum(numbering: n => super[#n])
+ /// + Superscript
+ /// + Numbering!
+ /// ```
+ #[default(Numbering::Pattern(NumberingPattern::from_str("1.").unwrap()))]
+ #[borrowed]
+ pub numbering: Numbering,
+
+ /// Which number to start the enumeration with.
+ ///
+ /// ```example
+ /// #enum(
+ /// start: 3,
+ /// [Skipping],
+ /// [Ahead],
+ /// )
+ /// ```
+ #[default(1)]
+ pub start: usize,
+
+ /// Whether to display the full numbering, including the numbers of
+ /// all parent enumerations.
+ ///
+ ///
+ /// ```example
+ /// #set enum(numbering: "1.a)", full: true)
+ /// + Cook
+ /// + Heat water
+ /// + Add ingredients
+ /// + Eat
+ /// ```
+ #[default(false)]
+ pub full: bool,
+
+ /// The indentation of each item.
+ #[resolve]
+ pub indent: Length,
+
+ /// The space between the numbering and the body of each item.
+ #[resolve]
+ #[default(Em::new(0.5).into())]
+ pub body_indent: Length,
+
+ /// The spacing between the items of the enumeration.
+ ///
+ /// If set to `{auto}`, uses paragraph [`leading`]($par.leading) for tight
+ /// enumerations and paragraph [`spacing`]($par.spacing) for wide
+ /// (non-tight) enumerations.
+ pub spacing: Smart<Length>,
+
+ /// The alignment that enum numbers should have.
+ ///
+ /// By default, this is set to `{end + top}`, which aligns enum numbers
+ /// towards end of the current text direction (in left-to-right script,
+ /// for example, this is the same as `{right}`) and at the top of the line.
+ /// The choice of `{end}` for horizontal alignment of enum numbers is
+ /// usually preferred over `{start}`, as numbers then grow away from the
+ /// text instead of towards it, avoiding certain visual issues. This option
+ /// lets you override this behaviour, however. (Also to note is that the
+ /// [unordered list]($list) uses a different method for this, by giving the
+ /// `marker` content an alignment directly.).
+ ///
+ /// ````example
+ /// #set enum(number-align: start + bottom)
+ ///
+ /// Here are some powers of two:
+ /// 1. One
+ /// 2. Two
+ /// 4. Four
+ /// 8. Eight
+ /// 16. Sixteen
+ /// 32. Thirty two
+ /// ````
+ #[default(HAlignment::End + VAlignment::Top)]
+ pub number_align: Alignment,
+
+ /// The numbered list's items.
+ ///
+ /// When using the enum syntax, adjacent items are automatically collected
+ /// into enumerations, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for phase in (
+ /// "Launch",
+ /// "Orbit",
+ /// "Descent",
+ /// ) [+ #phase]
+ /// ```
+ #[variadic]
+ pub children: Vec<Packed<EnumItem>>,
+
+ /// The numbers of parent items.
+ #[internal]
+ #[fold]
+ #[ghost]
+ pub parents: SmallVec<[usize; 4]>,
+}
+
+#[scope]
+impl EnumElem {
+ #[elem]
+ type EnumItem;
+}
+
+impl Show for Packed<EnumElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized =
+ BlockElem::multi_layouter(self.clone(), engine.routines.layout_enum)
+ .pack()
+ .spanned(self.span());
+
+ if self.tight(styles) {
+ let leading = ParElem::leading_in(styles);
+ let spacing =
+ VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
+ realized = spacing + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+/// An enumeration item.
+#[elem(name = "item", title = "Numbered List Item")]
+pub struct EnumItem {
+ /// The item's number.
+ #[positional]
+ pub number: Option<usize>,
+
+ /// The item's body.
+ #[required]
+ pub body: Content,
+}
+
+cast! {
+ EnumItem,
+ array: Array => {
+ let mut iter = array.into_iter();
+ let (number, body) = match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => (a.cast()?, b.cast()?),
+ _ => bail!("array must contain exactly two entries"),
+ };
+ Self::new(body).with_number(number)
+ },
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::new),
+}
+
+impl ListLike for EnumElem {
+ type Item = EnumItem;
+
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self {
+ Self::new(children).with_tight(tight)
+ }
+}
+
+impl ListItemLike for EnumItem {
+ fn styled(mut item: Packed<Self>, styles: Styles) -> Packed<Self> {
+ item.body.style_in_place(styles);
+ item
+ }
+}
diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs
new file mode 100644
index 00000000..abdf2a4e
--- /dev/null
+++ b/crates/typst-library/src/model/figure.rs
@@ -0,0 +1,641 @@
+use std::borrow::Cow;
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use ecow::EcoString;
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, select_where, Content, Element, NativeElement, Packed, Selector,
+ Show, ShowSet, Smart, StyleChain, Styles, Synthesize,
+};
+use crate::introspection::{
+ Count, Counter, CounterKey, CounterUpdate, Locatable, Location,
+};
+use crate::layout::{
+ AlignElem, Alignment, BlockBody, BlockElem, Em, HAlignment, Length, OuterVAlignment,
+ PlaceElem, PlacementScope, VAlignment, VElem,
+};
+use crate::model::{Numbering, NumberingPattern, Outlinable, Refable, Supplement};
+use crate::text::{Lang, Region, TextElem};
+use crate::visualize::ImageElem;
+
+/// A figure with an optional caption.
+///
+/// Automatically detects its kind to select the correct counting track. For
+/// example, figures containing images will be numbered separately from figures
+/// containing tables.
+///
+/// # Examples
+/// The example below shows a basic figure with an image:
+/// ```example
+/// @glacier shows a glacier. Glaciers
+/// are complex systems.
+///
+/// #figure(
+/// image("glacier.jpg", width: 80%),
+/// caption: [A curious figure.],
+/// ) <glacier>
+/// ```
+///
+/// You can also insert [tables]($table) into figures to give them a caption.
+/// The figure will detect this and automatically use a separate counter.
+///
+/// ```example
+/// #figure(
+/// table(
+/// columns: 4,
+/// [t], [1], [2], [3],
+/// [y], [0.3s], [0.4s], [0.8s],
+/// ),
+/// caption: [Timing results],
+/// )
+/// ```
+///
+/// This behaviour can be overridden by explicitly specifying the figure's
+/// `kind`. All figures of the same kind share a common counter.
+///
+/// # Figure behaviour
+/// By default, figures are placed within the flow of content. To make them
+/// float to the top or bottom of the page, you can use the
+/// [`placement`]($figure.placement) argument.
+///
+/// If your figure is too large and its contents are breakable across pages
+/// (e.g. if it contains a large table), then you can make the figure itself
+/// breakable across pages as well with this show rule:
+/// ```typ
+/// #show figure: set block(breakable: true)
+/// ```
+///
+/// See the [block]($block.breakable) documentation for more information about
+/// breakable and non-breakable blocks.
+///
+/// # Caption customization
+/// You can modify the appearance of the figure's caption with its associated
+/// [`caption`]($figure.caption) function. In the example below, we emphasize
+/// all captions:
+///
+/// ```example
+/// #show figure.caption: emph
+///
+/// #figure(
+/// rect[Hello],
+/// caption: [I am emphasized!],
+/// )
+/// ```
+///
+/// By using a [`where`]($function.where) selector, we can scope such rules to
+/// specific kinds of figures. For example, to position the caption above
+/// tables, but keep it below for all other kinds of figures, we could write the
+/// following show-set rule:
+///
+/// ```example
+/// #show figure.where(
+/// kind: table
+/// ): set figure.caption(position: top)
+///
+/// #figure(
+/// table(columns: 2)[A][B][C][D],
+/// caption: [I'm up here],
+/// )
+/// ```
+#[elem(scope, Locatable, Synthesize, Count, Show, ShowSet, Refable, Outlinable)]
+pub struct FigureElem {
+ /// The content of the figure. Often, an [image].
+ #[required]
+ pub body: Content,
+
+ /// The figure's placement on the page.
+ ///
+ /// - `{none}`: The figure stays in-flow exactly where it was specified
+ /// like other content.
+ /// - `{auto}`: The figure picks `{top}` or `{bottom}` depending on which
+ /// is closer.
+ /// - `{top}`: The figure floats to the top of the page.
+ /// - `{bottom}`: The figure floats to the bottom of the page.
+ ///
+ /// The gap between the main flow content and the floating figure is
+ /// controlled by the [`clearance`]($place.clearance) argument on the
+ /// `place` function.
+ ///
+ /// ```example
+ /// #set page(height: 200pt)
+ ///
+ /// = Introduction
+ /// #figure(
+ /// placement: bottom,
+ /// caption: [A glacier],
+ /// image("glacier.jpg", width: 60%),
+ /// )
+ /// #lorem(60)
+ /// ```
+ pub placement: Option<Smart<VAlignment>>,
+
+ /// Relative to which containing scope the figure is placed.
+ ///
+ /// Set this to `{"parent"}` to create a full-width figure in a two-column
+ /// document.
+ ///
+ /// Has no effect if `placement` is `{none}`.
+ ///
+ /// ```example
+ /// #set page(height: 250pt, columns: 2)
+ ///
+ /// = Introduction
+ /// #figure(
+ /// placement: bottom,
+ /// scope: "parent",
+ /// caption: [A glacier],
+ /// image("glacier.jpg", width: 60%),
+ /// )
+ /// #lorem(60)
+ /// ```
+ pub scope: PlacementScope,
+
+ /// The figure's caption.
+ pub caption: Option<Packed<FigureCaption>>,
+
+ /// The kind of figure this is.
+ ///
+ /// All figures of the same kind share a common counter.
+ ///
+ /// If set to `{auto}`, the figure will try to automatically determine its
+ /// kind based on the type of its body. Automatically detected kinds are
+ /// [tables]($table) and [code]($raw). In other cases, the inferred kind is
+ /// that of an [image].
+ ///
+ /// Setting this to something other than `{auto}` will override the
+ /// automatic detection. This can be useful if
+ /// - you wish to create a custom figure type that is not an
+ /// [image], a [table] or [code]($raw),
+ /// - you want to force the figure to use a specific counter regardless of
+ /// its content.
+ ///
+ /// You can set the kind to be an element function or a string. If you set
+ /// it to an element function other than [`{table}`]($table), [`{raw}`](raw)
+ /// or [`{image}`](image), you will need to manually specify the figure's
+ /// supplement.
+ ///
+ /// ```example
+ /// #figure(
+ /// circle(radius: 10pt),
+ /// caption: [A curious atom.],
+ /// kind: "atom",
+ /// supplement: [Atom],
+ /// )
+ /// ```
+ pub kind: Smart<FigureKind>,
+
+ /// The figure's supplement.
+ ///
+ /// If set to `{auto}`, the figure will try to automatically determine the
+ /// correct supplement based on the `kind` and the active
+ /// [text language]($text.lang). If you are using a custom figure type, you
+ /// will need to manually specify the supplement.
+ ///
+ /// If a function is specified, it is passed the first descendant of the
+ /// specified `kind` (typically, the figure's body) and should return
+ /// content.
+ ///
+ /// ```example
+ /// #figure(
+ /// [The contents of my figure!],
+ /// caption: [My custom figure],
+ /// supplement: [Bar],
+ /// kind: "foo",
+ /// )
+ /// ```
+ #[borrowed]
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// How to number the figure. Accepts a
+ /// [numbering pattern or function]($numbering).
+ #[default(Some(NumberingPattern::from_str("1").unwrap().into()))]
+ #[borrowed]
+ pub numbering: Option<Numbering>,
+
+ /// The vertical gap between the body and caption.
+ #[default(Em::new(0.65).into())]
+ pub gap: Length,
+
+ /// Whether the figure should appear in an [`outline`] of figures.
+ #[default(true)]
+ pub outlined: bool,
+
+ /// Convenience field to get access to the counter for this figure.
+ ///
+ /// The counter only depends on the `kind`:
+ /// - For (tables)[@table]: `{counter(figure.where(kind: table))}`
+ /// - For (images)[@image]: `{counter(figure.where(kind: image))}`
+ /// - For a custom kind: `{counter(figure.where(kind: kind))}`
+ ///
+ /// These are the counters you'll need to modify if you want to skip a
+ /// number or reset the counter.
+ #[synthesized]
+ pub counter: Option<Counter>,
+}
+
+#[scope]
+impl FigureElem {
+ #[elem]
+ type FigureCaption;
+}
+
+impl Synthesize for Packed<FigureElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let span = self.span();
+ let location = self.location();
+ let elem = self.as_mut();
+ let numbering = elem.numbering(styles);
+
+ // Determine the figure's kind.
+ let kind = elem.kind(styles).unwrap_or_else(|| {
+ elem.body()
+ .query_first(Selector::can::<dyn Figurable>())
+ .map(|elem| FigureKind::Elem(elem.func()))
+ .unwrap_or_else(|| FigureKind::Elem(ImageElem::elem()))
+ });
+
+ // Resolve the supplement.
+ let supplement = match elem.supplement(styles).as_ref() {
+ Smart::Auto => {
+ // Default to the local name for the kind, if available.
+ let name = match &kind {
+ FigureKind::Elem(func) => func
+ .local_name(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ )
+ .map(TextElem::packed),
+ FigureKind::Name(_) => None,
+ };
+
+ if numbering.is_some() && name.is_none() {
+ bail!(span, "please specify the figure's supplement")
+ }
+
+ Some(name.unwrap_or_default())
+ }
+ Smart::Custom(None) => None,
+ Smart::Custom(Some(supplement)) => {
+ // Resolve the supplement with the first descendant of the kind or
+ // just the body, if none was found.
+ let descendant = match kind {
+ FigureKind::Elem(func) => elem
+ .body()
+ .query_first(Selector::Elem(func, None))
+ .map(Cow::Owned),
+ FigureKind::Name(_) => None,
+ };
+
+ let target = descendant.unwrap_or_else(|| Cow::Borrowed(elem.body()));
+ Some(supplement.resolve(engine, styles, [target])?)
+ }
+ };
+
+ // Construct the figure's counter.
+ let counter = Counter::new(CounterKey::Selector(
+ select_where!(FigureElem, Kind => kind.clone()),
+ ));
+
+ // Fill the figure's caption.
+ let mut caption = elem.caption(styles);
+ if let Some(caption) = &mut caption {
+ caption.push_kind(kind.clone());
+ caption.push_supplement(supplement.clone());
+ caption.push_numbering(numbering.clone());
+ caption.push_counter(Some(counter.clone()));
+ caption.push_figure_location(location);
+ }
+
+ elem.push_kind(Smart::Custom(kind));
+ elem.push_supplement(Smart::Custom(supplement.map(Supplement::Content)));
+ elem.push_counter(Some(counter));
+ elem.push_caption(caption);
+
+ Ok(())
+ }
+}
+
+impl Show for Packed<FigureElem> {
+ #[typst_macros::time(name = "figure", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+
+ // Build the caption, if any.
+ if let Some(caption) = self.caption(styles) {
+ let v = VElem::new(self.gap(styles).into()).with_weak(true).pack();
+ realized = match caption.position(styles) {
+ OuterVAlignment::Top => caption.pack() + v + realized,
+ OuterVAlignment::Bottom => realized + v + caption.pack(),
+ };
+ }
+
+ // Wrap the contents in a block.
+ realized = BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(self.span());
+
+ // Wrap in a float.
+ if let Some(align) = self.placement(styles) {
+ realized = PlaceElem::new(realized)
+ .with_alignment(align.map(|align| HAlignment::Center + align))
+ .with_scope(self.scope(styles))
+ .with_float(true)
+ .pack()
+ .spanned(self.span());
+ } else if self.scope(styles) == PlacementScope::Parent {
+ bail!(
+ self.span(),
+ "parent-scoped placement is only available for floating figures";
+ hint: "you can enable floating placement with `figure(placement: auto, ..)`"
+ );
+ }
+
+ Ok(realized)
+ }
+}
+
+impl ShowSet for Packed<FigureElem> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ // Still allows breakable figures with
+ // `show figure: set block(breakable: true)`.
+ let mut map = Styles::new();
+ map.set(BlockElem::set_breakable(false));
+ map.set(AlignElem::set_alignment(Alignment::CENTER));
+ map
+ }
+}
+
+impl Count for Packed<FigureElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ // If the figure is numbered, step the counter by one.
+ // This steps the `counter(figure)` which is global to all numbered figures.
+ self.numbering()
+ .is_some()
+ .then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+impl Refable for Packed<FigureElem> {
+ fn supplement(&self) -> Content {
+ // After synthesis, this should always be custom content.
+ match (**self).supplement(StyleChain::default()).as_ref() {
+ Smart::Custom(Some(Supplement::Content(content))) => content.clone(),
+ _ => Content::empty(),
+ }
+ }
+
+ fn counter(&self) -> Counter {
+ (**self)
+ .counter()
+ .cloned()
+ .flatten()
+ .unwrap_or_else(|| Counter::of(FigureElem::elem()))
+ }
+
+ fn numbering(&self) -> Option<&Numbering> {
+ (**self).numbering(StyleChain::default()).as_ref()
+ }
+}
+
+impl Outlinable for Packed<FigureElem> {
+ fn outline(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<Option<Content>> {
+ if !self.outlined(StyleChain::default()) {
+ return Ok(None);
+ }
+
+ let Some(caption) = self.caption(StyleChain::default()) else {
+ return Ok(None);
+ };
+
+ let mut realized = caption.body().clone();
+ if let (
+ Smart::Custom(Some(Supplement::Content(mut supplement))),
+ Some(Some(counter)),
+ Some(numbering),
+ ) = (
+ (**self).supplement(StyleChain::default()).clone(),
+ (**self).counter(),
+ self.numbering(),
+ ) {
+ let numbers = counter.display_at_loc(
+ engine,
+ self.location().unwrap(),
+ styles,
+ numbering,
+ )?;
+
+ if !supplement.is_empty() {
+ supplement += TextElem::packed('\u{a0}');
+ }
+
+ let separator = caption.get_separator(StyleChain::default());
+
+ realized = supplement + numbers + separator + caption.body();
+ }
+
+ Ok(Some(realized))
+ }
+}
+
+/// The caption of a figure. This element can be used in set and show rules to
+/// customize the appearance of captions for all figures or figures of a
+/// specific kind.
+///
+/// In addition to its `pos` and `body`, the `caption` also provides the
+/// figure's `kind`, `supplement`, `counter`, and `numbering` as fields. These
+/// parts can be used in [`where`]($function.where) selectors and show rules to
+/// build a completely custom caption.
+///
+/// ```example
+/// #show figure.caption: emph
+///
+/// #figure(
+/// rect[Hello],
+/// caption: [A rectangle],
+/// )
+/// ```
+#[elem(name = "caption", Synthesize, Show)]
+pub struct FigureCaption {
+ /// The caption's position in the figure. Either `{top}` or `{bottom}`.
+ ///
+ /// ```example
+ /// #show figure.where(
+ /// kind: table
+ /// ): set figure.caption(position: top)
+ ///
+ /// #figure(
+ /// table(columns: 2)[A][B],
+ /// caption: [I'm up here],
+ /// )
+ ///
+ /// #figure(
+ /// rect[Hi],
+ /// caption: [I'm down here],
+ /// )
+ ///
+ /// #figure(
+ /// table(columns: 2)[A][B],
+ /// caption: figure.caption(
+ /// position: bottom,
+ /// [I'm down here too!]
+ /// )
+ /// )
+ /// ```
+ #[default(OuterVAlignment::Bottom)]
+ pub position: OuterVAlignment,
+
+ /// The separator which will appear between the number and body.
+ ///
+ /// If set to `{auto}`, the separator will be adapted to the current
+ /// [language]($text.lang) and [region]($text.region).
+ ///
+ /// ```example
+ /// #set figure.caption(separator: [ --- ])
+ ///
+ /// #figure(
+ /// rect[Hello],
+ /// caption: [A rectangle],
+ /// )
+ /// ```
+ pub separator: Smart<Content>,
+
+ /// The caption's body.
+ ///
+ /// Can be used alongside `kind`, `supplement`, `counter`, `numbering`, and
+ /// `location` to completely customize the caption.
+ ///
+ /// ```example
+ /// #show figure.caption: it => [
+ /// #underline(it.body) |
+ /// #it.supplement
+ /// #context it.counter.display(it.numbering)
+ /// ]
+ ///
+ /// #figure(
+ /// rect[Hello],
+ /// caption: [A rectangle],
+ /// )
+ /// ```
+ #[required]
+ pub body: Content,
+
+ /// The figure's supplement.
+ #[synthesized]
+ pub kind: FigureKind,
+
+ /// The figure's supplement.
+ #[synthesized]
+ pub supplement: Option<Content>,
+
+ /// How to number the figure.
+ #[synthesized]
+ pub numbering: Option<Numbering>,
+
+ /// The counter for the figure.
+ #[synthesized]
+ pub counter: Option<Counter>,
+
+ /// The figure's location.
+ #[internal]
+ #[synthesized]
+ pub figure_location: Option<Location>,
+}
+
+impl FigureCaption {
+ /// Gets the default separator in the given language and (optionally)
+ /// region.
+ fn local_separator(lang: Lang, _: Option<Region>) -> &'static str {
+ match lang {
+ Lang::CHINESE => "\u{2003}",
+ Lang::FRENCH => ".\u{a0}– ",
+ Lang::RUSSIAN => ". ",
+ Lang::ENGLISH | _ => ": ",
+ }
+ }
+
+ fn get_separator(&self, styles: StyleChain) -> Content {
+ self.separator(styles).unwrap_or_else(|| {
+ TextElem::packed(Self::local_separator(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ ))
+ })
+ }
+}
+
+impl Synthesize for Packed<FigureCaption> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let elem = self.as_mut();
+ elem.push_separator(Smart::Custom(elem.get_separator(styles)));
+ Ok(())
+ }
+}
+
+impl Show for Packed<FigureCaption> {
+ #[typst_macros::time(name = "figure.caption", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+
+ if let (
+ Some(Some(mut supplement)),
+ Some(Some(numbering)),
+ Some(Some(counter)),
+ Some(Some(location)),
+ ) = (
+ self.supplement().cloned(),
+ self.numbering(),
+ self.counter(),
+ self.figure_location(),
+ ) {
+ let numbers = counter.display_at_loc(engine, *location, styles, numbering)?;
+ if !supplement.is_empty() {
+ supplement += TextElem::packed('\u{a0}');
+ }
+ realized = supplement + numbers + self.get_separator(styles) + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+cast! {
+ FigureCaption,
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::new),
+}
+
+/// The `kind` parameter of a [`FigureElem`].
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum FigureKind {
+ /// The kind is an element function.
+ Elem(Element),
+ /// The kind is a name.
+ Name(EcoString),
+}
+
+cast! {
+ FigureKind,
+ self => match self {
+ Self::Elem(v) => v.into_value(),
+ Self::Name(v) => v.into_value(),
+ },
+ v: Element => Self::Elem(v),
+ v: EcoString => Self::Name(v),
+}
+
+/// An element that can be auto-detected in a figure.
+///
+/// This trait is used to determine the type of a figure.
+pub trait Figurable {}
diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs
new file mode 100644
index 00000000..d9971dd1
--- /dev/null
+++ b/crates/typst-library/src/model/footnote.rs
@@ -0,0 +1,326 @@
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Content, Label, NativeElement, Packed, Show, ShowSet, Smart,
+ StyleChain, Styles,
+};
+use crate::introspection::{Count, Counter, CounterUpdate, Locatable, Location};
+use crate::layout::{Abs, Em, HElem, Length, Ratio};
+use crate::model::{Destination, Numbering, NumberingPattern, ParElem};
+use crate::text::{SuperElem, TextElem, TextSize};
+use crate::visualize::{LineElem, Stroke};
+
+/// A footnote.
+///
+/// Includes additional remarks and references on the same page with footnotes.
+/// A footnote will insert a superscript number that links to the note at the
+/// bottom of the page. Notes are numbered sequentially throughout your document
+/// and can break across multiple pages.
+///
+/// To customize the appearance of the entry in the footnote listing, see
+/// [`footnote.entry`]($footnote.entry). The footnote itself is realized as a
+/// normal superscript, so you can use a set rule on the [`super`] function to
+/// customize it. You can also apply a show rule to customize only the footnote
+/// marker (superscript number) in the running text.
+///
+/// # Example
+/// ```example
+/// Check the docs for more details.
+/// #footnote[https://typst.app/docs]
+/// ```
+///
+/// The footnote automatically attaches itself to the preceding word, even if
+/// there is a space before it in the markup. To force space, you can use the
+/// string `[#" "]` or explicit [horizontal spacing]($h).
+///
+/// By giving a label to a footnote, you can have multiple references to it.
+///
+/// ```example
+/// You can edit Typst documents online.
+/// #footnote[https://typst.app/app] <fn>
+/// Checkout Typst's website. @fn
+/// And the online app. #footnote(<fn>)
+/// ```
+///
+/// _Note:_ Set and show rules in the scope where `footnote` is called may not
+/// apply to the footnote's content. See [here][issue] for more information.
+///
+/// [issue]: https://github.com/typst/typst/issues/1467#issuecomment-1588799440
+#[elem(scope, Locatable, Show, Count)]
+pub struct FootnoteElem {
+ /// How to number footnotes.
+ ///
+ /// By default, the footnote numbering continues throughout your document.
+ /// If you prefer per-page footnote numbering, you can reset the footnote
+ /// [counter] in the page [header]($page.header). In the future, there might
+ /// be a simpler way to achieve this.
+ ///
+ /// ```example
+ /// #set footnote(numbering: "*")
+ ///
+ /// Footnotes:
+ /// #footnote[Star],
+ /// #footnote[Dagger]
+ /// ```
+ #[borrowed]
+ #[default(Numbering::Pattern(NumberingPattern::from_str("1").unwrap()))]
+ pub numbering: Numbering,
+
+ /// The content to put into the footnote. Can also be the label of another
+ /// footnote this one should point to.
+ #[required]
+ pub body: FootnoteBody,
+}
+
+#[scope]
+impl FootnoteElem {
+ #[elem]
+ type FootnoteEntry;
+}
+
+impl FootnoteElem {
+ /// Creates a new footnote that the passed content as its body.
+ pub fn with_content(content: Content) -> Self {
+ Self::new(FootnoteBody::Content(content))
+ }
+
+ /// Creates a new footnote referencing the footnote with the specified label.
+ pub fn with_label(label: Label) -> Self {
+ Self::new(FootnoteBody::Reference(label))
+ }
+
+ /// Creates a new footnote referencing the footnote with the specified label,
+ /// with the other fields from the current footnote cloned.
+ pub fn into_ref(&self, label: Label) -> Self {
+ Self {
+ body: FootnoteBody::Reference(label),
+ ..self.clone()
+ }
+ }
+
+ /// Tests if this footnote is a reference to another footnote.
+ pub fn is_ref(&self) -> bool {
+ matches!(self.body(), FootnoteBody::Reference(_))
+ }
+
+ /// Returns the content of the body of this footnote if it is not a ref.
+ pub fn body_content(&self) -> Option<&Content> {
+ match self.body() {
+ FootnoteBody::Content(content) => Some(content),
+ _ => None,
+ }
+ }
+}
+
+impl Packed<FootnoteElem> {
+ /// Returns the location of the definition of this footnote.
+ pub fn declaration_location(&self, engine: &Engine) -> StrResult<Location> {
+ match self.body() {
+ FootnoteBody::Reference(label) => {
+ let element = engine.introspector.query_label(*label)?;
+ let footnote = element
+ .to_packed::<FootnoteElem>()
+ .ok_or("referenced element should be a footnote")?;
+ if self.location() == footnote.location() {
+ bail!("footnote cannot reference itself");
+ }
+ footnote.declaration_location(engine)
+ }
+ _ => Ok(self.location().unwrap()),
+ }
+ }
+}
+
+impl Show for Packed<FootnoteElem> {
+ #[typst_macros::time(name = "footnote", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let span = self.span();
+ let loc = self.declaration_location(engine).at(span)?;
+ let numbering = self.numbering(styles);
+ let counter = Counter::of(FootnoteElem::elem());
+ let num = counter.display_at_loc(engine, loc, styles, numbering)?;
+ let sup = SuperElem::new(num).pack().spanned(span);
+ let loc = loc.variant(1);
+ // Add zero-width weak spacing to make the footnote "sticky".
+ Ok(HElem::hole().pack() + sup.linked(Destination::Location(loc)))
+ }
+}
+
+impl Count for Packed<FootnoteElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ (!self.is_ref()).then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+/// The body of a footnote can be either some content or a label referencing
+/// another footnote.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum FootnoteBody {
+ Content(Content),
+ Reference(Label),
+}
+
+cast! {
+ FootnoteBody,
+ self => match self {
+ Self::Content(v) => v.into_value(),
+ Self::Reference(v) => v.into_value(),
+ },
+ v: Content => Self::Content(v),
+ v: Label => Self::Reference(v),
+}
+
+/// An entry in a footnote list.
+///
+/// This function is not intended to be called directly. Instead, it is used in
+/// set and show rules to customize footnote listings.
+///
+/// ```example
+/// #show footnote.entry: set text(red)
+///
+/// My footnote listing
+/// #footnote[It's down here]
+/// has red text!
+/// ```
+///
+/// _Note:_ Footnote entry properties must be uniform across each page run (a
+/// page run is a sequence of pages without an explicit pagebreak in between).
+/// For this reason, set and show rules for footnote entries should be defined
+/// before any page content, typically at the very start of the document.
+#[elem(name = "entry", title = "Footnote Entry", Show, ShowSet)]
+pub struct FootnoteEntry {
+ /// The footnote for this entry. It's location can be used to determine
+ /// the footnote counter state.
+ ///
+ /// ```example
+ /// #show footnote.entry: it => {
+ /// let loc = it.note.location()
+ /// numbering(
+ /// "1: ",
+ /// ..counter(footnote).at(loc),
+ /// )
+ /// it.note.body
+ /// }
+ ///
+ /// Customized #footnote[Hello]
+ /// listing #footnote[World! 🌏]
+ /// ```
+ #[required]
+ pub note: Packed<FootnoteElem>,
+
+ /// The separator between the document body and the footnote listing.
+ ///
+ /// ```example
+ /// #set footnote.entry(
+ /// separator: repeat[.]
+ /// )
+ ///
+ /// Testing a different separator.
+ /// #footnote[
+ /// Unconventional, but maybe
+ /// not that bad?
+ /// ]
+ /// ```
+ #[default(
+ LineElem::new()
+ .with_length(Ratio::new(0.3).into())
+ .with_stroke(Stroke {
+ thickness: Smart::Custom(Abs::pt(0.5).into()),
+ ..Default::default()
+ })
+ .pack()
+ )]
+ pub separator: Content,
+
+ /// The amount of clearance between the document body and the separator.
+ ///
+ /// ```example
+ /// #set footnote.entry(clearance: 3em)
+ ///
+ /// Footnotes also need ...
+ /// #footnote[
+ /// ... some space to breathe.
+ /// ]
+ /// ```
+ #[default(Em::new(1.0).into())]
+ #[resolve]
+ pub clearance: Length,
+
+ /// The gap between footnote entries.
+ ///
+ /// ```example
+ /// #set footnote.entry(gap: 0.8em)
+ ///
+ /// Footnotes:
+ /// #footnote[Spaced],
+ /// #footnote[Apart]
+ /// ```
+ #[default(Em::new(0.5).into())]
+ #[resolve]
+ pub gap: Length,
+
+ /// The indent of each footnote entry.
+ ///
+ /// ```example
+ /// #set footnote.entry(indent: 0em)
+ ///
+ /// Footnotes:
+ /// #footnote[No],
+ /// #footnote[Indent]
+ /// ```
+ #[default(Em::new(1.0).into())]
+ pub indent: Length,
+}
+
+impl Show for Packed<FootnoteEntry> {
+ #[typst_macros::time(name = "footnote.entry", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let span = self.span();
+ let note = self.note();
+ let number_gap = Em::new(0.05);
+ let default = StyleChain::default();
+ let numbering = note.numbering(default);
+ let counter = Counter::of(FootnoteElem::elem());
+ let Some(loc) = note.location() else {
+ bail!(
+ span, "footnote entry must have a location";
+ hint: "try using a query or a show rule to customize the footnote instead"
+ );
+ };
+
+ let num = counter.display_at_loc(engine, loc, styles, numbering)?;
+ let sup = SuperElem::new(num)
+ .pack()
+ .spanned(span)
+ .linked(Destination::Location(loc))
+ .located(loc.variant(1));
+
+ Ok(Content::sequence([
+ HElem::new(self.indent(styles).into()).pack(),
+ sup,
+ HElem::new(number_gap.into()).with_weak(true).pack(),
+ note.body_content().unwrap().clone(),
+ ]))
+ }
+}
+
+impl ShowSet for Packed<FootnoteEntry> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ let text_size = Em::new(0.85);
+ let leading = Em::new(0.5);
+ let mut out = Styles::new();
+ out.set(ParElem::set_leading(leading.into()));
+ out.set(TextElem::set_size(TextSize(text_size.into())));
+ out
+ }
+}
+
+cast! {
+ FootnoteElem,
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::with_content)
+}
diff --git a/crates/typst-library/src/model/heading.rs b/crates/typst-library/src/model/heading.rs
new file mode 100644
index 00000000..269e95eb
--- /dev/null
+++ b/crates/typst-library/src/model/heading.rs
@@ -0,0 +1,351 @@
+use std::num::NonZeroUsize;
+
+use typst_utils::NonZeroExt;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, Content, NativeElement, Packed, Resolve, Show, ShowSet, Smart, StyleChain,
+ Styles, Synthesize,
+};
+use crate::introspection::{
+ Count, Counter, CounterUpdate, Locatable, Locator, LocatorLink,
+};
+use crate::layout::{Abs, Axes, BlockBody, BlockElem, Em, HElem, Length, Region};
+use crate::model::{Numbering, Outlinable, ParElem, Refable, Supplement};
+use crate::text::{FontWeight, LocalName, SpaceElem, TextElem, TextSize};
+
+/// A section heading.
+///
+/// With headings, you can structure your document into sections. Each heading
+/// has a _level,_ which starts at one and is unbounded upwards. This level
+/// indicates the logical role of the following content (section, subsection,
+/// etc.) A top-level heading indicates a top-level section of the document
+/// (not the document's title).
+///
+/// Typst can automatically number your headings for you. To enable numbering,
+/// specify how you want your headings to be numbered with a
+/// [numbering pattern or function]($numbering).
+///
+/// Independently of the numbering, Typst can also automatically generate an
+/// [outline] of all headings for you. To exclude one or more headings from this
+/// outline, you can set the `outlined` parameter to `{false}`.
+///
+/// # Example
+/// ```example
+/// #set heading(numbering: "1.a)")
+///
+/// = Introduction
+/// In recent years, ...
+///
+/// == Preliminaries
+/// To start, ...
+/// ```
+///
+/// # Syntax
+/// Headings have dedicated syntax: They can be created by starting a line with
+/// one or multiple equals signs, followed by a space. The number of equals
+/// signs determines the heading's logical nesting depth. The `{offset}` field
+/// can be set to configure the starting depth.
+#[elem(Locatable, Synthesize, Count, Show, ShowSet, LocalName, Refable, Outlinable)]
+pub struct HeadingElem {
+ /// The absolute nesting depth of the heading, starting from one. If set
+ /// to `{auto}`, it is computed from `{offset + depth}`.
+ ///
+ /// This is primarily useful for usage in [show rules]($styling/#show-rules)
+ /// (either with [`where`]($function.where) selectors or by accessing the
+ /// level directly on a shown heading).
+ ///
+ /// ```example
+ /// #show heading.where(level: 2): set text(red)
+ ///
+ /// = Level 1
+ /// == Level 2
+ ///
+ /// #set heading(offset: 1)
+ /// = Also level 2
+ /// == Level 3
+ /// ```
+ pub level: Smart<NonZeroUsize>,
+
+ /// The relative nesting depth of the heading, starting from one. This is
+ /// combined with `{offset}` to compute the actual `{level}`.
+ ///
+ /// This is set by the heading syntax, such that `[== Heading]` creates a
+ /// heading with logical depth of 2, but actual level `{offset + 2}`. If you
+ /// construct a heading manually, you should typically prefer this over
+ /// setting the absolute level.
+ #[default(NonZeroUsize::ONE)]
+ pub depth: NonZeroUsize,
+
+ /// The starting offset of each heading's `{level}`, used to turn its
+ /// relative `{depth}` into its absolute `{level}`.
+ ///
+ /// ```example
+ /// = Level 1
+ ///
+ /// #set heading(offset: 1, numbering: "1.1")
+ /// = Level 2
+ ///
+ /// #heading(offset: 2, depth: 2)[
+ /// I'm level 4
+ /// ]
+ /// ```
+ #[default(0)]
+ pub offset: usize,
+
+ /// How to number the heading. Accepts a
+ /// [numbering pattern or function]($numbering).
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.a.")
+ ///
+ /// = A section
+ /// == A subsection
+ /// === A sub-subsection
+ /// ```
+ #[borrowed]
+ pub numbering: Option<Numbering>,
+
+ /// A supplement for the heading.
+ ///
+ /// For references to headings, this is added before the referenced number.
+ ///
+ /// If a function is specified, it is passed the referenced heading and
+ /// should return content.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.", supplement: [Chapter])
+ ///
+ /// = Introduction <intro>
+ /// In @intro, we see how to turn
+ /// Sections into Chapters. And
+ /// in @intro[Part], it is done
+ /// manually.
+ /// ```
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// Whether the heading should appear in the [outline].
+ ///
+ /// Note that this property, if set to `{true}`, ensures the heading is also
+ /// shown as a bookmark in the exported PDF's outline (when exporting to
+ /// PDF). To change that behavior, use the `bookmarked` property.
+ ///
+ /// ```example
+ /// #outline()
+ ///
+ /// #heading[Normal]
+ /// This is a normal heading.
+ ///
+ /// #heading(outlined: false)[Hidden]
+ /// This heading does not appear
+ /// in the outline.
+ /// ```
+ #[default(true)]
+ pub outlined: bool,
+
+ /// Whether the heading should appear as a bookmark in the exported PDF's
+ /// outline. Doesn't affect other export formats, such as PNG.
+ ///
+ /// The default value of `{auto}` indicates that the heading will only
+ /// appear in the exported PDF's outline if its `outlined` property is set
+ /// to `{true}`, that is, if it would also be listed in Typst's [outline].
+ /// Setting this property to either `{true}` (bookmark) or `{false}` (don't
+ /// bookmark) bypasses that behavior.
+ ///
+ /// ```example
+ /// #heading[Normal heading]
+ /// This heading will be shown in
+ /// the PDF's bookmark outline.
+ ///
+ /// #heading(bookmarked: false)[Not bookmarked]
+ /// This heading won't be
+ /// bookmarked in the resulting
+ /// PDF.
+ /// ```
+ #[default(Smart::Auto)]
+ pub bookmarked: Smart<bool>,
+
+ /// The indent all but the first line of a heading should have.
+ ///
+ /// The default value of `{auto}` indicates that the subsequent heading
+ /// lines will be indented based on the width of the numbering.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.")
+ /// #heading[A very, very, very, very, very, very long heading]
+ /// ```
+ #[default(Smart::Auto)]
+ pub hanging_indent: Smart<Length>,
+
+ /// The heading's title.
+ #[required]
+ pub body: Content,
+}
+
+impl HeadingElem {
+ pub fn resolve_level(&self, styles: StyleChain) -> NonZeroUsize {
+ self.level(styles).unwrap_or_else(|| {
+ NonZeroUsize::new(self.offset(styles) + self.depth(styles).get())
+ .expect("overflow to 0 on NoneZeroUsize + usize")
+ })
+ }
+}
+
+impl Synthesize for Packed<HeadingElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let supplement = match (**self).supplement(styles) {
+ Smart::Auto => TextElem::packed(Self::local_name_in(styles)),
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => {
+ supplement.resolve(engine, styles, [self.clone().pack()])?
+ }
+ };
+
+ let elem = self.as_mut();
+ elem.push_level(Smart::Custom(elem.resolve_level(styles)));
+ elem.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
+ Ok(())
+ }
+}
+
+impl Show for Packed<HeadingElem> {
+ #[typst_macros::time(name = "heading", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ const SPACING_TO_NUMBERING: Em = Em::new(0.3);
+
+ let span = self.span();
+ let mut realized = self.body().clone();
+
+ let hanging_indent = self.hanging_indent(styles);
+ let mut indent = match hanging_indent {
+ Smart::Custom(length) => length.resolve(styles),
+ Smart::Auto => Abs::zero(),
+ };
+
+ if let Some(numbering) = (**self).numbering(styles).as_ref() {
+ let location = self.location().unwrap();
+ let numbering = Counter::of(HeadingElem::elem())
+ .display_at_loc(engine, location, styles, numbering)?
+ .spanned(span);
+
+ if hanging_indent.is_auto() {
+ let pod = Region::new(Axes::splat(Abs::inf()), Axes::splat(false));
+
+ // We don't have a locator for the numbering here, so we just
+ // use the measurement infrastructure for now.
+ let link = LocatorLink::measure(location);
+ let size = (engine.routines.layout_frame)(
+ engine,
+ &numbering,
+ Locator::link(&link),
+ styles,
+ pod,
+ )?
+ .size();
+
+ indent = size.x + SPACING_TO_NUMBERING.resolve(styles);
+ }
+
+ realized = numbering
+ + HElem::new(SPACING_TO_NUMBERING.into()).with_weak(true).pack()
+ + realized;
+ }
+
+ if indent != Abs::zero() {
+ realized = realized.styled(ParElem::set_hanging_indent(indent.into()));
+ }
+
+ Ok(BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(span))
+ }
+}
+
+impl ShowSet for Packed<HeadingElem> {
+ fn show_set(&self, styles: StyleChain) -> Styles {
+ let level = (**self).resolve_level(styles).get();
+ let scale = match level {
+ 1 => 1.4,
+ 2 => 1.2,
+ _ => 1.0,
+ };
+
+ let size = Em::new(scale);
+ let above = Em::new(if level == 1 { 1.8 } else { 1.44 }) / scale;
+ let below = Em::new(0.75) / scale;
+
+ let mut out = Styles::new();
+ out.set(TextElem::set_size(TextSize(size.into())));
+ out.set(TextElem::set_weight(FontWeight::BOLD));
+ out.set(BlockElem::set_above(Smart::Custom(above.into())));
+ out.set(BlockElem::set_below(Smart::Custom(below.into())));
+ out.set(BlockElem::set_sticky(true));
+ out
+ }
+}
+
+impl Count for Packed<HeadingElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ (**self)
+ .numbering(StyleChain::default())
+ .is_some()
+ .then(|| CounterUpdate::Step((**self).resolve_level(StyleChain::default())))
+ }
+}
+
+impl Refable for Packed<HeadingElem> {
+ fn supplement(&self) -> Content {
+ // After synthesis, this should always be custom content.
+ match (**self).supplement(StyleChain::default()) {
+ Smart::Custom(Some(Supplement::Content(content))) => content,
+ _ => Content::empty(),
+ }
+ }
+
+ fn counter(&self) -> Counter {
+ Counter::of(HeadingElem::elem())
+ }
+
+ fn numbering(&self) -> Option<&Numbering> {
+ (**self).numbering(StyleChain::default()).as_ref()
+ }
+}
+
+impl Outlinable for Packed<HeadingElem> {
+ fn outline(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<Option<Content>> {
+ if !self.outlined(StyleChain::default()) {
+ return Ok(None);
+ }
+
+ let mut content = self.body().clone();
+ if let Some(numbering) = (**self).numbering(StyleChain::default()).as_ref() {
+ let numbers = Counter::of(HeadingElem::elem()).display_at_loc(
+ engine,
+ self.location().unwrap(),
+ styles,
+ numbering,
+ )?;
+ content = numbers + SpaceElem::shared().clone() + content;
+ };
+
+ Ok(Some(content))
+ }
+
+ fn level(&self) -> NonZeroUsize {
+ (**self).resolve_level(StyleChain::default())
+ }
+}
+
+impl LocalName for Packed<HeadingElem> {
+ const KEY: &'static str = "heading";
+}
diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs
new file mode 100644
index 00000000..31c65a1d
--- /dev/null
+++ b/crates/typst-library/src/model/link.rs
@@ -0,0 +1,212 @@
+use std::ops::Deref;
+
+use ecow::{eco_format, EcoString};
+use smallvec::SmallVec;
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, Label, Packed, Repr, Show, Smart, StyleChain,
+};
+use crate::introspection::Location;
+use crate::layout::Position;
+use crate::text::{Hyphenate, TextElem};
+
+/// Links to a URL or a location in the document.
+///
+/// By default, links are not styled any different from normal text. However,
+/// you can easily apply a style of your choice with a show rule.
+///
+/// # Example
+/// ```example
+/// #show link: underline
+///
+/// https://example.com \
+///
+/// #link("https://example.com") \
+/// #link("https://example.com")[
+/// See example.com
+/// ]
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: Text that starts with `http://` or
+/// `https://` is automatically turned into a link.
+#[elem(Show)]
+pub struct LinkElem {
+ /// The destination the link points to.
+ ///
+ /// - To link to web pages, `dest` should be a valid URL string. If the URL
+ /// is in the `mailto:` or `tel:` scheme and the `body` parameter is
+ /// omitted, the email address or phone number will be the link's body,
+ /// without the scheme.
+ ///
+ /// - To link to another part of the document, `dest` can take one of three
+ /// forms:
+ /// - A [label] attached to an element. If you also want automatic text
+ /// for the link based on the element, consider using a
+ /// [reference]($ref) instead.
+ ///
+ /// - A [`location`] (typically retrieved from [`here`], [`locate`] or
+ /// [`query`]).
+ ///
+ /// - A dictionary with a `page` key of type [integer]($int) and `x` and
+ /// `y` coordinates of type [length]. Pages are counted from one, and
+ /// the coordinates are relative to the page's top left corner.
+ ///
+ /// ```example
+ /// = Introduction <intro>
+ /// #link("mailto:hello@typst.app") \
+ /// #link(<intro>)[Go to intro] \
+ /// #link((page: 1, x: 0pt, y: 0pt))[
+ /// Go to top
+ /// ]
+ /// ```
+ #[required]
+ #[parse(
+ let dest = args.expect::<LinkTarget>("destination")?;
+ dest.clone()
+ )]
+ pub dest: LinkTarget,
+
+ /// The content that should become a link.
+ ///
+ /// If `dest` is an URL string, the parameter can be omitted. In this case,
+ /// the URL will be shown as the link.
+ #[required]
+ #[parse(match &dest {
+ LinkTarget::Dest(Destination::Url(url)) => match args.eat()? {
+ Some(body) => body,
+ None => body_from_url(url),
+ },
+ _ => args.expect("body")?,
+ })]
+ pub body: Content,
+
+ /// This style is set on the content contained in the `link` element.
+ #[internal]
+ #[ghost]
+ pub dests: SmallVec<[Destination; 1]>,
+}
+
+impl LinkElem {
+ /// Create a link element from a URL with its bare text.
+ pub fn from_url(url: Url) -> Self {
+ let body = body_from_url(&url);
+ Self::new(LinkTarget::Dest(Destination::Url(url)), body)
+ }
+}
+
+impl Show for Packed<LinkElem> {
+ #[typst_macros::time(name = "link", span = self.span())]
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ let body = self.body().clone();
+ let linked = match self.dest() {
+ LinkTarget::Dest(dest) => body.linked(dest.clone()),
+ LinkTarget::Label(label) => {
+ let elem = engine.introspector.query_label(*label).at(self.span())?;
+ let dest = Destination::Location(elem.location().unwrap());
+ body.clone().linked(dest)
+ }
+ };
+
+ Ok(linked.styled(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))))
+ }
+}
+
+fn body_from_url(url: &Url) -> Content {
+ let mut text = url.as_str();
+ for prefix in ["mailto:", "tel:"] {
+ text = text.trim_start_matches(prefix);
+ }
+ let shorter = text.len() < url.len();
+ TextElem::packed(if shorter { text.into() } else { (**url).clone() })
+}
+
+/// A target where a link can go.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum LinkTarget {
+ Dest(Destination),
+ Label(Label),
+}
+
+cast! {
+ LinkTarget,
+ self => match self {
+ Self::Dest(v) => v.into_value(),
+ Self::Label(v) => v.into_value(),
+ },
+ v: Destination => Self::Dest(v),
+ v: Label => Self::Label(v),
+}
+
+impl From<Destination> for LinkTarget {
+ fn from(dest: Destination) -> Self {
+ Self::Dest(dest)
+ }
+}
+
+/// A link destination.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum Destination {
+ /// A link to a URL.
+ Url(Url),
+ /// A link to a point on a page.
+ Position(Position),
+ /// An unresolved link to a location in the document.
+ Location(Location),
+}
+
+impl Destination {}
+
+impl Repr for Destination {
+ fn repr(&self) -> EcoString {
+ eco_format!("{self:?}")
+ }
+}
+
+cast! {
+ Destination,
+ self => match self {
+ Self::Url(v) => v.into_value(),
+ Self::Position(v) => v.into_value(),
+ Self::Location(v) => v.into_value(),
+ },
+ v: Url => Self::Url(v),
+ v: Position => Self::Position(v),
+ v: Location => Self::Location(v),
+}
+
+/// A uniform resource locator with a maximum length.
+#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Url(EcoString);
+
+impl Url {
+ /// Create a URL from a string, checking the maximum length.
+ pub fn new(url: impl Into<EcoString>) -> StrResult<Self> {
+ let url = url.into();
+ if url.len() > 8000 {
+ bail!("URL is too long")
+ }
+ Ok(Self(url))
+ }
+
+ /// Extract the underlying [`EcoString`].
+ pub fn into_inner(self) -> EcoString {
+ self.0
+ }
+}
+
+impl Deref for Url {
+ type Target = EcoString;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+cast! {
+ Url,
+ self => self.0.into_value(),
+ v: EcoString => Self::new(v)?,
+}
diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs
new file mode 100644
index 00000000..18bddd10
--- /dev/null
+++ b/crates/typst-library/src/model/list.rs
@@ -0,0 +1,246 @@
+use comemo::Track;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Array, Content, Context, Depth, Func, NativeElement, Packed, Show,
+ Smart, StyleChain, Styles, Value,
+};
+use crate::layout::{BlockElem, Em, Length, VElem};
+use crate::model::ParElem;
+use crate::text::TextElem;
+
+/// A bullet list.
+///
+/// Displays a sequence of items vertically, with each item introduced by a
+/// marker.
+///
+/// # Example
+/// ```example
+/// Normal list.
+/// - Text
+/// - Math
+/// - Layout
+/// - ...
+///
+/// Multiple lines.
+/// - This list item spans multiple
+/// lines because it is indented.
+///
+/// Function call.
+/// #list(
+/// [Foundations],
+/// [Calculate],
+/// [Construct],
+/// [Data Loading],
+/// )
+/// ```
+///
+/// # Syntax
+/// This functions also has dedicated syntax: Start a line with a hyphen,
+/// followed by a space to create a list item. A list item can contain multiple
+/// paragraphs and other block-level content. All content that is indented
+/// more than an item's marker becomes part of that item.
+#[elem(scope, title = "Bullet List", Show)]
+pub struct ListElem {
+ /// Defines the default [spacing]($list.spacing) of the list. If it is
+ /// `{false}`, the items are spaced apart with
+ /// [paragraph spacing]($par.spacing). If it is `{true}`, they use
+ /// [paragraph leading]($par.leading) instead. This makes the list more
+ /// compact, which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`. The markup-defined tightness cannot be
+ /// overridden with set rules.
+ ///
+ /// ```example
+ /// - If a list has a lot of text, and
+ /// maybe other inline content, it
+ /// should not be tight anymore.
+ ///
+ /// - To make a list wide, simply insert
+ /// a blank line between the items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// The marker which introduces each item.
+ ///
+ /// Instead of plain content, you can also pass an array with multiple
+ /// markers that should be used for nested lists. If the list nesting depth
+ /// exceeds the number of markers, the markers are cycled. For total
+ /// control, you may pass a function that maps the list's nesting depth
+ /// (starting from `{0}`) to a desired marker.
+ ///
+ /// ```example
+ /// #set list(marker: [--])
+ /// - A more classic list
+ /// - With en-dashes
+ ///
+ /// #set list(marker: ([•], [--]))
+ /// - Top-level
+ /// - Nested
+ /// - Items
+ /// - Items
+ /// ```
+ #[borrowed]
+ #[default(ListMarker::Content(vec![
+ // These are all available in the default font, vertically centered, and
+ // roughly of the same size (with the last one having slightly lower
+ // weight because it is not filled).
+ TextElem::packed('\u{2022}'), // Bullet
+ TextElem::packed('\u{2023}'), // Triangular Bullet
+ TextElem::packed('\u{2013}'), // En-dash
+ ]))]
+ pub marker: ListMarker,
+
+ /// The indent of each item.
+ #[resolve]
+ pub indent: Length,
+
+ /// The spacing between the marker and the body of each item.
+ #[resolve]
+ #[default(Em::new(0.5).into())]
+ pub body_indent: Length,
+
+ /// The spacing between the items of the list.
+ ///
+ /// If set to `{auto}`, uses paragraph [`leading`]($par.leading) for tight
+ /// lists and paragraph [`spacing`]($par.spacing) for wide (non-tight)
+ /// lists.
+ pub spacing: Smart<Length>,
+
+ /// The bullet list's children.
+ ///
+ /// When using the list syntax, adjacent items are automatically collected
+ /// into lists, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for letter in "ABC" [
+ /// - Letter #letter
+ /// ]
+ /// ```
+ #[variadic]
+ pub children: Vec<Packed<ListItem>>,
+
+ /// The nesting depth.
+ #[internal]
+ #[fold]
+ #[ghost]
+ pub depth: Depth,
+}
+
+#[scope]
+impl ListElem {
+ #[elem]
+ type ListItem;
+}
+
+impl Show for Packed<ListElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized =
+ BlockElem::multi_layouter(self.clone(), engine.routines.layout_list)
+ .pack()
+ .spanned(self.span());
+
+ if self.tight(styles) {
+ let leading = ParElem::leading_in(styles);
+ let spacing =
+ VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
+ realized = spacing + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+/// A bullet list item.
+#[elem(name = "item", title = "Bullet List Item")]
+pub struct ListItem {
+ /// The item's body.
+ #[required]
+ pub body: Content,
+}
+
+cast! {
+ ListItem,
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::new)
+}
+
+/// A list's marker.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum ListMarker {
+ Content(Vec<Content>),
+ Func(Func),
+}
+
+impl ListMarker {
+ /// Resolve the marker for the given depth.
+ pub fn resolve(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ depth: usize,
+ ) -> SourceResult<Content> {
+ Ok(match self {
+ Self::Content(list) => {
+ list.get(depth % list.len()).cloned().unwrap_or_default()
+ }
+ Self::Func(func) => func
+ .call(engine, Context::new(None, Some(styles)).track(), [depth])?
+ .display(),
+ })
+ }
+}
+
+cast! {
+ ListMarker,
+ self => match self {
+ Self::Content(vec) => if vec.len() == 1 {
+ vec.into_iter().next().unwrap().into_value()
+ } else {
+ vec.into_value()
+ },
+ Self::Func(func) => func.into_value(),
+ },
+ v: Content => Self::Content(vec![v]),
+ array: Array => {
+ if array.is_empty() {
+ bail!("array must contain at least one marker");
+ }
+ Self::Content(array.into_iter().map(Value::display).collect())
+ },
+ v: Func => Self::Func(v),
+}
+
+/// A list, enum, or term list.
+pub trait ListLike: NativeElement {
+ /// The kind of list item this list is composed of.
+ type Item: ListItemLike;
+
+ /// Create this kind of list from its children and tightness.
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self;
+}
+
+/// A list item, enum item, or term list item.
+pub trait ListItemLike: NativeElement {
+ /// Apply styles to the element's body.
+ fn styled(item: Packed<Self>, styles: Styles) -> Packed<Self>;
+}
+
+impl ListLike for ListElem {
+ type Item = ListItem;
+
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self {
+ Self::new(children).with_tight(tight)
+ }
+}
+
+impl ListItemLike for ListItem {
+ fn styled(mut item: Packed<Self>, styles: Styles) -> Packed<Self> {
+ item.body.style_in_place(styles);
+ item
+ }
+}
diff --git a/crates/typst-library/src/model/mod.rs b/crates/typst-library/src/model/mod.rs
new file mode 100644
index 00000000..7dad51c3
--- /dev/null
+++ b/crates/typst-library/src/model/mod.rs
@@ -0,0 +1,75 @@
+//! Structuring elements that define the document model.
+
+mod bibliography;
+mod cite;
+mod document;
+mod emph;
+#[path = "enum.rs"]
+mod enum_;
+mod figure;
+mod footnote;
+mod heading;
+mod link;
+mod list;
+#[path = "numbering.rs"]
+mod numbering_;
+mod outline;
+mod par;
+mod quote;
+mod reference;
+mod strong;
+mod table;
+mod terms;
+
+pub use self::bibliography::*;
+pub use self::cite::*;
+pub use self::document::*;
+pub use self::emph::*;
+pub use self::enum_::*;
+pub use self::figure::*;
+pub use self::footnote::*;
+pub use self::heading::*;
+pub use self::link::*;
+pub use self::list::*;
+pub use self::numbering_::*;
+pub use self::outline::*;
+pub use self::par::*;
+pub use self::quote::*;
+pub use self::reference::*;
+pub use self::strong::*;
+pub use self::table::*;
+pub use self::terms::*;
+
+use crate::foundations::{category, Category, Scope};
+
+/// Document structuring.
+///
+/// Here, you can find functions to structure your document and interact with
+/// that structure. This includes section headings, figures, bibliography
+/// management, cross-referencing and more.
+#[category]
+pub static MODEL: Category;
+
+/// Hook up all `model` definitions.
+pub fn define(global: &mut Scope) {
+ global.category(MODEL);
+ global.define_elem::<DocumentElem>();
+ global.define_elem::<RefElem>();
+ global.define_elem::<LinkElem>();
+ global.define_elem::<OutlineElem>();
+ global.define_elem::<HeadingElem>();
+ global.define_elem::<FigureElem>();
+ global.define_elem::<FootnoteElem>();
+ global.define_elem::<QuoteElem>();
+ global.define_elem::<CiteElem>();
+ global.define_elem::<BibliographyElem>();
+ global.define_elem::<EnumElem>();
+ global.define_elem::<ListElem>();
+ global.define_elem::<ParbreakElem>();
+ global.define_elem::<ParElem>();
+ global.define_elem::<TableElem>();
+ global.define_elem::<TermsElem>();
+ global.define_elem::<EmphElem>();
+ global.define_elem::<StrongElem>();
+ global.define_func::<numbering>();
+}
diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs
new file mode 100644
index 00000000..f0aa06e5
--- /dev/null
+++ b/crates/typst-library/src/model/numbering.rs
@@ -0,0 +1,658 @@
+use std::str::FromStr;
+
+use chinese_number::{
+ from_usize_to_chinese_ten_thousand as usize_to_chinese, ChineseCase, ChineseVariant,
+};
+use comemo::Tracked;
+use ecow::{eco_format, EcoString, EcoVec};
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{cast, func, Context, Func, Str, Value};
+use crate::text::Case;
+
+/// Applies a numbering to a sequence of numbers.
+///
+/// A numbering defines how a sequence of numbers should be displayed as
+/// content. It is defined either through a pattern string or an arbitrary
+/// function.
+///
+/// A numbering pattern consists of counting symbols, for which the actual
+/// number is substituted, their prefixes, and one suffix. The prefixes and the
+/// suffix are repeated as-is.
+///
+/// # Example
+/// ```example
+/// #numbering("1.1)", 1, 2, 3) \
+/// #numbering("1.a.i", 1, 2) \
+/// #numbering("I – 1", 12, 2) \
+/// #numbering(
+/// (..nums) => nums
+/// .pos()
+/// .map(str)
+/// .join(".") + ")",
+/// 1, 2, 3,
+/// )
+/// ```
+///
+/// # Numbering patterns and numbering functions
+/// There are multiple instances where you can provide a numbering pattern or
+/// function in Typst. For example, when defining how to number
+/// [headings]($heading) or [figures]($figure). Every time, the expected format
+/// is the same as the one described below for the
+/// [`numbering`]($numbering.numbering) parameter.
+///
+/// The following example illustrates that a numbering function is just a
+/// regular [function] that accepts numbers and returns [`content`].
+/// ```example
+/// #let unary(.., last) = "|" * last
+/// #set heading(numbering: unary)
+/// = First heading
+/// = Second heading
+/// = Third heading
+/// ```
+#[func]
+pub fn numbering(
+ /// The engine.
+ engine: &mut Engine,
+ /// The callsite context.
+ context: Tracked<Context>,
+ /// Defines how the numbering works.
+ ///
+ /// **Counting symbols** are `1`, `a`, `A`, `i`, `I`, `一`, `壹`, `あ`, `い`,
+ /// `ア`, `イ`, `א`, `가`, `ㄱ`, `*`, `①`, and `⓵`. They are replaced by the
+ /// number in the sequence, preserving the original case.
+ ///
+ /// The `*` character means that symbols should be used to count, in the
+ /// order of `*`, `†`, `‡`, `§`, `¶`, `‖`. If there are more than six
+ /// items, the number is represented using repeated symbols.
+ ///
+ /// **Suffixes** are all characters after the last counting symbol. They are
+ /// repeated as-is at the end of any rendered number.
+ ///
+ /// **Prefixes** are all characters that are neither counting symbols nor
+ /// suffixes. They are repeated as-is at in front of their rendered
+ /// equivalent of their counting symbol.
+ ///
+ /// This parameter can also be an arbitrary function that gets each number
+ /// as an individual argument. When given a function, the `numbering`
+ /// function just forwards the arguments to that function. While this is not
+ /// particularly useful in itself, it means that you can just give arbitrary
+ /// numberings to the `numbering` function without caring whether they are
+ /// defined as a pattern or function.
+ numbering: Numbering,
+ /// The numbers to apply the numbering to. Must be positive.
+ ///
+ /// If `numbering` is a pattern and more numbers than counting symbols are
+ /// given, the last counting symbol with its prefix is repeated.
+ #[variadic]
+ numbers: Vec<usize>,
+) -> SourceResult<Value> {
+ numbering.apply(engine, context, &numbers)
+}
+
+/// How to number a sequence of things.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Numbering {
+ /// A pattern with prefix, numbering, lower / upper case and suffix.
+ Pattern(NumberingPattern),
+ /// A closure mapping from an item's number to content.
+ Func(Func),
+}
+
+impl Numbering {
+ /// Apply the pattern to the given numbers.
+ pub fn apply(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ numbers: &[usize],
+ ) -> SourceResult<Value> {
+ Ok(match self {
+ Self::Pattern(pattern) => Value::Str(pattern.apply(numbers).into()),
+ Self::Func(func) => func.call(engine, context, numbers.iter().copied())?,
+ })
+ }
+
+ /// Trim the prefix suffix if this is a pattern.
+ pub fn trimmed(mut self) -> Self {
+ if let Self::Pattern(pattern) = &mut self {
+ pattern.trimmed = true;
+ }
+ self
+ }
+}
+
+impl From<NumberingPattern> for Numbering {
+ fn from(pattern: NumberingPattern) -> Self {
+ Self::Pattern(pattern)
+ }
+}
+
+cast! {
+ Numbering,
+ self => match self {
+ Self::Pattern(pattern) => pattern.into_value(),
+ Self::Func(func) => func.into_value(),
+ },
+ v: NumberingPattern => Self::Pattern(v),
+ v: Func => Self::Func(v),
+}
+
+/// How to turn a number into text.
+///
+/// A pattern consists of a prefix, followed by one of `1`, `a`, `A`, `i`, `I`,
+/// `一`, `壹`, `あ`, `い`, `ア`, `イ`, `א`, `가`, `ㄱ`, `*`, `①`, or `⓵`, and then a
+/// suffix.
+///
+/// Examples of valid patterns:
+/// - `1)`
+/// - `a.`
+/// - `(I)`
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct NumberingPattern {
+ pub pieces: EcoVec<(EcoString, NumberingKind)>,
+ pub suffix: EcoString,
+ trimmed: bool,
+}
+
+impl NumberingPattern {
+ /// Apply the pattern to the given number.
+ pub fn apply(&self, numbers: &[usize]) -> EcoString {
+ let mut fmt = EcoString::new();
+ let mut numbers = numbers.iter();
+
+ for (i, ((prefix, kind), &n)) in self.pieces.iter().zip(&mut numbers).enumerate()
+ {
+ if i > 0 || !self.trimmed {
+ fmt.push_str(prefix);
+ }
+ fmt.push_str(&kind.apply(n));
+ }
+
+ for ((prefix, kind), &n) in self.pieces.last().into_iter().cycle().zip(numbers) {
+ if prefix.is_empty() {
+ fmt.push_str(&self.suffix);
+ } else {
+ fmt.push_str(prefix);
+ }
+ fmt.push_str(&kind.apply(n));
+ }
+
+ if !self.trimmed {
+ fmt.push_str(&self.suffix);
+ }
+
+ fmt
+ }
+
+ /// Apply only the k-th segment of the pattern to a number.
+ pub fn apply_kth(&self, k: usize, number: usize) -> EcoString {
+ let mut fmt = EcoString::new();
+ if let Some((prefix, _)) = self.pieces.first() {
+ fmt.push_str(prefix);
+ }
+ if let Some((_, kind)) = self
+ .pieces
+ .iter()
+ .chain(self.pieces.last().into_iter().cycle())
+ .nth(k)
+ {
+ fmt.push_str(&kind.apply(number));
+ }
+ fmt.push_str(&self.suffix);
+ fmt
+ }
+
+ /// How many counting symbols this pattern has.
+ pub fn pieces(&self) -> usize {
+ self.pieces.len()
+ }
+}
+
+impl FromStr for NumberingPattern {
+ type Err = &'static str;
+
+ fn from_str(pattern: &str) -> Result<Self, Self::Err> {
+ let mut pieces = EcoVec::new();
+ let mut handled = 0;
+
+ for (i, c) in pattern.char_indices() {
+ let Some(kind) = NumberingKind::from_char(c) else {
+ continue;
+ };
+
+ let prefix = pattern[handled..i].into();
+ pieces.push((prefix, kind));
+ handled = c.len_utf8() + i;
+ }
+
+ let suffix = pattern[handled..].into();
+ if pieces.is_empty() {
+ return Err("invalid numbering pattern");
+ }
+
+ Ok(Self { pieces, suffix, trimmed: false })
+ }
+}
+
+cast! {
+ NumberingPattern,
+ self => {
+ let mut pat = EcoString::new();
+ for (prefix, kind) in &self.pieces {
+ pat.push_str(prefix);
+ pat.push(kind.to_char());
+ }
+ pat.push_str(&self.suffix);
+ pat.into_value()
+ },
+ v: Str => v.parse()?,
+}
+
+/// Different kinds of numberings.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum NumberingKind {
+ /// Arabic numerals (1, 2, 3, etc.).
+ Arabic,
+ /// Lowercase Latin letters (a, b, c, etc.). Items beyond z use base-26.
+ LowerLatin,
+ /// Uppercase Latin letters (A, B, C, etc.). Items beyond Z use base-26.
+ UpperLatin,
+ /// Lowercase Roman numerals (i, ii, iii, etc.).
+ LowerRoman,
+ /// Uppercase Roman numerals (I, II, III, etc.).
+ UpperRoman,
+ /// Paragraph/note-like symbols: *, †, ‡, §, ¶, and ‖. Further items use repeated symbols.
+ Symbol,
+ /// Hebrew numerals, including Geresh/Gershayim.
+ Hebrew,
+ /// Simplified Chinese standard numerals. This corresponds to the
+ /// `ChineseCase::Lower` variant.
+ LowerSimplifiedChinese,
+ /// Simplified Chinese "banknote" numerals. This corresponds to the
+ /// `ChineseCase::Upper` variant.
+ UpperSimplifiedChinese,
+ // TODO: Pick the numbering pattern based on languages choice.
+ // As the first character of Simplified and Traditional Chinese numbering
+ // are the same, we are unable to determine if the context requires
+ // Simplified or Traditional by only looking at this character.
+ #[allow(unused)]
+ /// Traditional Chinese standard numerals. This corresponds to the
+ /// `ChineseCase::Lower` variant.
+ LowerTraditionalChinese,
+ #[allow(unused)]
+ /// Traditional Chinese "banknote" numerals. This corresponds to the
+ /// `ChineseCase::Upper` variant.
+ UpperTraditionalChinese,
+ /// Hiragana in the gojūon order. Includes n but excludes wi and we.
+ HiraganaAiueo,
+ /// Hiragana in the iroha order. Includes wi and we but excludes n.
+ HiraganaIroha,
+ /// Katakana in the gojūon order. Includes n but excludes wi and we.
+ KatakanaAiueo,
+ /// Katakana in the iroha order. Includes wi and we but excludes n.
+ KatakanaIroha,
+ /// Korean jamo (ㄱ, ㄴ, ㄷ, etc.).
+ KoreanJamo,
+ /// Korean syllables (가, 나, 다, etc.).
+ KoreanSyllable,
+ /// Eastern Arabic numerals, used in some Arabic-speaking countries.
+ EasternArabic,
+ /// The variant of Eastern Arabic numerals used in Persian and Urdu.
+ EasternArabicPersian,
+ /// Devanagari numerals.
+ DevanagariNumber,
+ /// Bengali numerals.
+ BengaliNumber,
+ /// Bengali letters (ক, খ, গ, ...কক, কখ etc.).
+ BengaliLetter,
+ /// Circled numbers (①, ②, ③, etc.), up to 50.
+ CircledNumber,
+ /// Double-circled numbers (⓵, ⓶, ⓷, etc.), up to 10.
+ DoubleCircledNumber,
+}
+
+impl NumberingKind {
+ /// Create a numbering kind from a representative character.
+ pub fn from_char(c: char) -> Option<Self> {
+ Some(match c {
+ '1' => NumberingKind::Arabic,
+ 'a' => NumberingKind::LowerLatin,
+ 'A' => NumberingKind::UpperLatin,
+ 'i' => NumberingKind::LowerRoman,
+ 'I' => NumberingKind::UpperRoman,
+ '*' => NumberingKind::Symbol,
+ 'א' => NumberingKind::Hebrew,
+ '一' => NumberingKind::LowerSimplifiedChinese,
+ '壹' => NumberingKind::UpperSimplifiedChinese,
+ 'あ' => NumberingKind::HiraganaAiueo,
+ 'い' => NumberingKind::HiraganaIroha,
+ 'ア' => NumberingKind::KatakanaAiueo,
+ 'イ' => NumberingKind::KatakanaIroha,
+ 'ㄱ' => NumberingKind::KoreanJamo,
+ '가' => NumberingKind::KoreanSyllable,
+ '\u{0661}' => NumberingKind::EasternArabic,
+ '\u{06F1}' => NumberingKind::EasternArabicPersian,
+ '\u{0967}' => NumberingKind::DevanagariNumber,
+ '\u{09E7}' => NumberingKind::BengaliNumber,
+ '\u{0995}' => NumberingKind::BengaliLetter,
+ '①' => NumberingKind::CircledNumber,
+ '⓵' => NumberingKind::DoubleCircledNumber,
+ _ => return None,
+ })
+ }
+
+ /// The representative character for this numbering kind.
+ pub fn to_char(self) -> char {
+ match self {
+ Self::Arabic => '1',
+ Self::LowerLatin => 'a',
+ Self::UpperLatin => 'A',
+ Self::LowerRoman => 'i',
+ Self::UpperRoman => 'I',
+ Self::Symbol => '*',
+ Self::Hebrew => 'א',
+ Self::LowerSimplifiedChinese | Self::LowerTraditionalChinese => '一',
+ Self::UpperSimplifiedChinese | Self::UpperTraditionalChinese => '壹',
+ Self::HiraganaAiueo => 'あ',
+ Self::HiraganaIroha => 'い',
+ Self::KatakanaAiueo => 'ア',
+ Self::KatakanaIroha => 'イ',
+ Self::KoreanJamo => 'ㄱ',
+ Self::KoreanSyllable => '가',
+ Self::EasternArabic => '\u{0661}',
+ Self::EasternArabicPersian => '\u{06F1}',
+ Self::DevanagariNumber => '\u{0967}',
+ Self::BengaliNumber => '\u{09E7}',
+ Self::BengaliLetter => '\u{0995}',
+ Self::CircledNumber => '①',
+ Self::DoubleCircledNumber => '⓵',
+ }
+ }
+
+ /// Apply the numbering to the given number.
+ pub fn apply(self, n: usize) -> EcoString {
+ match self {
+ Self::Arabic => eco_format!("{n}"),
+ Self::LowerRoman => roman_numeral(n, Case::Lower),
+ Self::UpperRoman => roman_numeral(n, Case::Upper),
+ Self::Symbol => {
+ if n == 0 {
+ return '-'.into();
+ }
+
+ const SYMBOLS: &[char] = &['*', '†', '‡', '§', '¶', '‖'];
+ let symbol = SYMBOLS[(n - 1) % SYMBOLS.len()];
+ let amount = ((n - 1) / SYMBOLS.len()) + 1;
+ std::iter::repeat(symbol).take(amount).collect()
+ }
+ Self::Hebrew => hebrew_numeral(n),
+
+ Self::LowerLatin => zeroless(
+ [
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
+ 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ ],
+ n,
+ ),
+ Self::UpperLatin => zeroless(
+ [
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N',
+ 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ ],
+ n,
+ ),
+ Self::HiraganaAiueo => zeroless(
+ [
+ 'あ', 'い', 'う', 'え', 'お', 'か', 'き', 'く', 'け', 'こ', 'さ',
+ 'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'て', 'と', 'な', 'に',
+ 'ぬ', 'ね', 'の', 'は', 'ひ', 'ふ', 'へ', 'ほ', 'ま', 'み', 'む',
+ 'め', 'も', 'や', 'ゆ', 'よ', 'ら', 'り', 'る', 'れ', 'ろ', 'わ',
+ 'を', 'ん',
+ ],
+ n,
+ ),
+ Self::HiraganaIroha => zeroless(
+ [
+ 'い', 'ろ', 'は', 'に', 'ほ', 'へ', 'と', 'ち', 'り', 'ぬ', 'る',
+ 'を', 'わ', 'か', 'よ', 'た', 'れ', 'そ', 'つ', 'ね', 'な', 'ら',
+ 'む', 'う', 'ゐ', 'の', 'お', 'く', 'や', 'ま', 'け', 'ふ', 'こ',
+ 'え', 'て', 'あ', 'さ', 'き', 'ゆ', 'め', 'み', 'し', 'ゑ', 'ひ',
+ 'も', 'せ', 'す',
+ ],
+ n,
+ ),
+ Self::KatakanaAiueo => zeroless(
+ [
+ 'ア', 'イ', 'ウ', 'エ', 'オ', 'カ', 'キ', 'ク', 'ケ', 'コ', 'サ',
+ 'シ', 'ス', 'セ', 'ソ', 'タ', 'チ', 'ツ', 'テ', 'ト', 'ナ', 'ニ',
+ 'ヌ', 'ネ', 'ノ', 'ハ', 'ヒ', 'フ', 'ヘ', 'ホ', 'マ', 'ミ', 'ム',
+ 'メ', 'モ', 'ヤ', 'ユ', 'ヨ', 'ラ', 'リ', 'ル', 'レ', 'ロ', 'ワ',
+ 'ヲ', 'ン',
+ ],
+ n,
+ ),
+ Self::KatakanaIroha => zeroless(
+ [
+ 'イ', 'ロ', 'ハ', 'ニ', 'ホ', 'ヘ', 'ト', 'チ', 'リ', 'ヌ', 'ル',
+ 'ヲ', 'ワ', 'カ', 'ヨ', 'タ', 'レ', 'ソ', 'ツ', 'ネ', 'ナ', 'ラ',
+ 'ム', 'ウ', 'ヰ', 'ノ', 'オ', 'ク', 'ヤ', 'マ', 'ケ', 'フ', 'コ',
+ 'エ', 'テ', 'ア', 'サ', 'キ', 'ユ', 'メ', 'ミ', 'シ', 'ヱ', 'ヒ',
+ 'モ', 'セ', 'ス',
+ ],
+ n,
+ ),
+ Self::KoreanJamo => zeroless(
+ [
+ 'ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ',
+ 'ㅌ', 'ㅍ', 'ㅎ',
+ ],
+ n,
+ ),
+ Self::KoreanSyllable => zeroless(
+ [
+ '가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카',
+ '타', '파', '하',
+ ],
+ n,
+ ),
+ Self::BengaliLetter => zeroless(
+ [
+ 'ক', 'খ', 'গ', 'ঘ', 'ঙ', 'চ', 'ছ', 'জ', 'ঝ', 'ঞ', 'ট', 'ঠ', 'ড', 'ঢ',
+ 'ণ', 'ত', 'থ', 'দ', 'ধ', 'ন', 'প', 'ফ', 'ব', 'ভ', 'ম', 'য', 'র', 'ল',
+ 'শ', 'ষ', 'স', 'হ',
+ ],
+ n,
+ ),
+ Self::CircledNumber => zeroless(
+ [
+ '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭',
+ '⑮', '⑯', '⑰', '⑱', '⑲', '⑳', '㉑', '㉒', '㉓', '㉔', '㉕', '㉖',
+ '㉗', '㉘', '㉙', '㉚', '㉛', '㉜', '㉝', '㉞', '㉟', '㊱', '㊲',
+ '㊳', '㊴', '㊵', '㊶', '㊷', '㊸', '㊹', '㊺', '㊻', '㊼', '㊽',
+ '㊾', '㊿',
+ ],
+ n,
+ ),
+ Self::DoubleCircledNumber => {
+ zeroless(['⓵', '⓶', '⓷', '⓸', '⓹', '⓺', '⓻', '⓼', '⓽', '⓾'], n)
+ }
+
+ Self::LowerSimplifiedChinese => {
+ usize_to_chinese(ChineseVariant::Simple, ChineseCase::Lower, n).into()
+ }
+ Self::UpperSimplifiedChinese => {
+ usize_to_chinese(ChineseVariant::Simple, ChineseCase::Upper, n).into()
+ }
+ Self::LowerTraditionalChinese => {
+ usize_to_chinese(ChineseVariant::Traditional, ChineseCase::Lower, n)
+ .into()
+ }
+ Self::UpperTraditionalChinese => {
+ usize_to_chinese(ChineseVariant::Traditional, ChineseCase::Upper, n)
+ .into()
+ }
+
+ Self::EasternArabic => decimal('\u{0660}', n),
+ Self::EasternArabicPersian => decimal('\u{06F0}', n),
+ Self::DevanagariNumber => decimal('\u{0966}', n),
+ Self::BengaliNumber => decimal('\u{09E6}', n),
+ }
+ }
+}
+
+fn hebrew_numeral(mut n: usize) -> EcoString {
+ if n == 0 {
+ return '-'.into();
+ }
+ let mut fmt = EcoString::new();
+ 'outer: for (name, value) in [
+ ('ת', 400),
+ ('ש', 300),
+ ('ר', 200),
+ ('ק', 100),
+ ('צ', 90),
+ ('פ', 80),
+ ('ע', 70),
+ ('ס', 60),
+ ('נ', 50),
+ ('מ', 40),
+ ('ל', 30),
+ ('כ', 20),
+ ('י', 10),
+ ('ט', 9),
+ ('ח', 8),
+ ('ז', 7),
+ ('ו', 6),
+ ('ה', 5),
+ ('ד', 4),
+ ('ג', 3),
+ ('ב', 2),
+ ('א', 1),
+ ] {
+ while n >= value {
+ match n {
+ 15 => fmt.push_str("ט״ו"),
+ 16 => fmt.push_str("ט״ז"),
+ _ => {
+ let append_geresh = n == value && fmt.is_empty();
+ if n == value && !fmt.is_empty() {
+ fmt.push('״');
+ }
+ fmt.push(name);
+ if append_geresh {
+ fmt.push('׳');
+ }
+
+ n -= value;
+ continue;
+ }
+ }
+ break 'outer;
+ }
+ }
+ fmt
+}
+
+fn roman_numeral(mut n: usize, case: Case) -> EcoString {
+ if n == 0 {
+ return match case {
+ Case::Lower => 'n'.into(),
+ Case::Upper => 'N'.into(),
+ };
+ }
+
+ // Adapted from Yann Villessuzanne's roman.rs under the
+ // Unlicense, at https://github.com/linfir/roman.rs/
+ let mut fmt = EcoString::new();
+ for &(name, value) in &[
+ ("M̅", 1000000),
+ ("D̅", 500000),
+ ("C̅", 100000),
+ ("L̅", 50000),
+ ("X̅", 10000),
+ ("V̅", 5000),
+ ("I̅V̅", 4000),
+ ("M", 1000),
+ ("CM", 900),
+ ("D", 500),
+ ("CD", 400),
+ ("C", 100),
+ ("XC", 90),
+ ("L", 50),
+ ("XL", 40),
+ ("X", 10),
+ ("IX", 9),
+ ("V", 5),
+ ("IV", 4),
+ ("I", 1),
+ ] {
+ while n >= value {
+ n -= value;
+ for c in name.chars() {
+ match case {
+ Case::Lower => fmt.extend(c.to_lowercase()),
+ Case::Upper => fmt.push(c),
+ }
+ }
+ }
+ }
+
+ fmt
+}
+
+/// Stringify a number using a base-N counting system with no zero digit.
+///
+/// This is best explained by example. Suppose our digits are 'A', 'B', and 'C'.
+/// We would get the following:
+///
+/// ```text
+/// 1 => "A"
+/// 2 => "B"
+/// 3 => "C"
+/// 4 => "AA"
+/// 5 => "AB"
+/// 6 => "AC"
+/// 7 => "BA"
+/// 8 => "BB"
+/// 9 => "BC"
+/// 10 => "CA"
+/// 11 => "CB"
+/// 12 => "CC"
+/// 13 => "AAA"
+/// etc.
+/// ```
+///
+/// You might be familiar with this scheme from the way spreadsheet software
+/// tends to label its columns.
+fn zeroless<const N_DIGITS: usize>(
+ alphabet: [char; N_DIGITS],
+ mut n: usize,
+) -> EcoString {
+ if n == 0 {
+ return '-'.into();
+ }
+ let mut cs = EcoString::new();
+ while n > 0 {
+ n -= 1;
+ cs.push(alphabet[n % N_DIGITS]);
+ n /= N_DIGITS;
+ }
+ cs.chars().rev().collect()
+}
+
+/// Stringify a number using a base-10 counting system with a zero digit.
+///
+/// This function assumes that the digits occupy contiguous codepoints.
+fn decimal(start: char, mut n: usize) -> EcoString {
+ if n == 0 {
+ return start.into();
+ }
+ let mut cs = EcoString::new();
+ while n > 0 {
+ cs.push(char::from_u32((start as u32) + ((n % 10) as u32)).unwrap());
+ n /= 10;
+ }
+ cs.chars().rev().collect()
+}
diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs
new file mode 100644
index 00000000..85257c2c
--- /dev/null
+++ b/crates/typst-library/src/model/outline.rs
@@ -0,0 +1,547 @@
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use comemo::Track;
+use typst_syntax::Span;
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, select_where, Content, Context, Func, LocatableSelector,
+ NativeElement, Packed, Show, ShowSet, Smart, StyleChain, Styles,
+};
+use crate::introspection::{Counter, CounterKey, Locatable};
+use crate::layout::{
+ BoxElem, Dir, Em, Fr, HElem, HideElem, Length, Rel, RepeatElem, Spacing,
+};
+use crate::model::{
+ Destination, HeadingElem, NumberingPattern, ParElem, ParbreakElem, Refable,
+};
+use crate::text::{LinebreakElem, LocalName, SpaceElem, TextElem};
+
+/// A table of contents, figures, or other elements.
+///
+/// This function generates a list of all occurrences of an element in the
+/// document, up to a given depth. The element's numbering and page number will
+/// be displayed in the outline alongside its title or caption. By default this
+/// generates a table of contents.
+///
+/// # Example
+/// ```example
+/// #outline()
+///
+/// = Introduction
+/// #lorem(5)
+///
+/// = Prior work
+/// #lorem(10)
+/// ```
+///
+/// # Alternative outlines
+/// By setting the `target` parameter, the outline can be used to generate a
+/// list of other kinds of elements than headings. In the example below, we list
+/// all figures containing images by setting `target` to `{figure.where(kind:
+/// image)}`. We could have also set it to just `figure`, but then the list
+/// would also include figures containing tables or other material. For more
+/// details on the `where` selector, [see here]($function.where).
+///
+/// ```example
+/// #outline(
+/// title: [List of Figures],
+/// target: figure.where(kind: image),
+/// )
+///
+/// #figure(
+/// image("tiger.jpg"),
+/// caption: [A nice figure!],
+/// )
+/// ```
+///
+/// # Styling the outline
+/// The outline element has several options for customization, such as its
+/// `title` and `indent` parameters. If desired, however, it is possible to have
+/// more control over the outline's look and style through the
+/// [`outline.entry`]($outline.entry) element.
+#[elem(scope, keywords = ["Table of Contents"], Show, ShowSet, LocalName)]
+pub struct OutlineElem {
+ /// The title of the outline.
+ ///
+ /// - When set to `{auto}`, an appropriate title for the
+ /// [text language]($text.lang) will be used. This is the default.
+ /// - When set to `{none}`, the outline will not have a title.
+ /// - A custom title can be set by passing content.
+ ///
+ /// The outline's heading will not be numbered by default, but you can
+ /// force it to be with a show-set rule:
+ /// `{show outline: set heading(numbering: "1.")}`
+ pub title: Smart<Option<Content>>,
+
+ /// The type of element to include in the outline.
+ ///
+ /// To list figures containing a specific kind of element, like a table, you
+ /// can write `{figure.where(kind: table)}`.
+ ///
+ /// ```example
+ /// #outline(
+ /// title: [List of Tables],
+ /// target: figure.where(kind: table),
+ /// )
+ ///
+ /// #figure(
+ /// table(
+ /// columns: 4,
+ /// [t], [1], [2], [3],
+ /// [y], [0.3], [0.7], [0.5],
+ /// ),
+ /// caption: [Experiment results],
+ /// )
+ /// ```
+ #[default(LocatableSelector(select_where!(HeadingElem, Outlined => true)))]
+ #[borrowed]
+ pub target: LocatableSelector,
+
+ /// The maximum level up to which elements are included in the outline. When
+ /// this argument is `{none}`, all elements are included.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.")
+ /// #outline(depth: 2)
+ ///
+ /// = Yes
+ /// Top-level section.
+ ///
+ /// == Still
+ /// Subsection.
+ ///
+ /// === Nope
+ /// Not included.
+ /// ```
+ pub depth: Option<NonZeroUsize>,
+
+ /// How to indent the outline's entries.
+ ///
+ /// - `{none}`: No indent
+ /// - `{auto}`: Indents the numbering of the nested entry with the title of
+ /// its parent entry. This only has an effect if the entries are numbered
+ /// (e.g., via [heading numbering]($heading.numbering)).
+ /// - [Relative length]($relative): Indents the item by this length
+ /// multiplied by its nesting level. Specifying `{2em}`, for instance,
+ /// would indent top-level headings (not nested) by `{0em}`, second level
+ /// headings by `{2em}` (nested once), third-level headings by `{4em}`
+ /// (nested twice) and so on.
+ /// - [Function]($function): You can completely customize this setting with
+ /// a function. That function receives the nesting level as a parameter
+ /// (starting at 0 for top-level headings/elements) and can return a
+ /// relative length or content making up the indent. For example,
+ /// `{n => n * 2em}` would be equivalent to just specifying `{2em}`, while
+ /// `{n => [→ ] * n}` would indent with one arrow per nesting level.
+ ///
+ /// *Migration hints:* Specifying `{true}` (equivalent to `{auto}`) or
+ /// `{false}` (equivalent to `{none}`) for this option is deprecated and
+ /// will be removed in a future release.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.a.")
+ ///
+ /// #outline(
+ /// title: [Contents (Automatic)],
+ /// indent: auto,
+ /// )
+ ///
+ /// #outline(
+ /// title: [Contents (Length)],
+ /// indent: 2em,
+ /// )
+ ///
+ /// #outline(
+ /// title: [Contents (Function)],
+ /// indent: n => [→ ] * n,
+ /// )
+ ///
+ /// = About ACME Corp.
+ /// == History
+ /// === Origins
+ /// #lorem(10)
+ ///
+ /// == Products
+ /// #lorem(10)
+ /// ```
+ #[default(None)]
+ #[borrowed]
+ pub indent: Option<Smart<OutlineIndent>>,
+
+ /// Content to fill the space between the title and the page number. Can be
+ /// set to `{none}` to disable filling.
+ ///
+ /// ```example
+ /// #outline(fill: line(length: 100%))
+ ///
+ /// = A New Beginning
+ /// ```
+ #[default(Some(RepeatElem::new(TextElem::packed(".")).pack()))]
+ pub fill: Option<Content>,
+}
+
+#[scope]
+impl OutlineElem {
+ #[elem]
+ type OutlineEntry;
+}
+
+impl Show for Packed<OutlineElem> {
+ #[typst_macros::time(name = "outline", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut seq = vec![ParbreakElem::shared().clone()];
+ // Build the outline title.
+ if let Some(title) = self.title(styles).unwrap_or_else(|| {
+ Some(TextElem::packed(Self::local_name_in(styles)).spanned(self.span()))
+ }) {
+ seq.push(
+ HeadingElem::new(title)
+ .with_depth(NonZeroUsize::ONE)
+ .pack()
+ .spanned(self.span()),
+ );
+ }
+
+ let indent = self.indent(styles);
+ let depth = self.depth(styles).unwrap_or(NonZeroUsize::new(usize::MAX).unwrap());
+
+ let mut ancestors: Vec<&Content> = vec![];
+ let elems = engine.introspector.query(&self.target(styles).0);
+
+ for elem in &elems {
+ let Some(entry) = OutlineEntry::from_outlinable(
+ engine,
+ self.span(),
+ elem.clone(),
+ self.fill(styles),
+ styles,
+ )?
+ else {
+ continue;
+ };
+
+ let level = entry.level();
+ if depth < *level {
+ continue;
+ }
+
+ // Deals with the ancestors of the current element.
+ // This is only applicable for elements with a hierarchy/level.
+ while ancestors
+ .last()
+ .and_then(|ancestor| ancestor.with::<dyn Outlinable>())
+ .is_some_and(|last| last.level() >= *level)
+ {
+ ancestors.pop();
+ }
+
+ OutlineIndent::apply(
+ indent,
+ engine,
+ &ancestors,
+ &mut seq,
+ styles,
+ self.span(),
+ )?;
+
+ // Add the overridable outline entry, followed by a line break.
+ seq.push(entry.pack());
+ seq.push(LinebreakElem::shared().clone());
+
+ ancestors.push(elem);
+ }
+
+ seq.push(ParbreakElem::shared().clone());
+
+ Ok(Content::sequence(seq))
+ }
+}
+
+impl ShowSet for Packed<OutlineElem> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ let mut out = Styles::new();
+ out.set(HeadingElem::set_outlined(false));
+ out.set(HeadingElem::set_numbering(None));
+ out.set(ParElem::set_first_line_indent(Em::new(0.0).into()));
+ out
+ }
+}
+
+impl LocalName for Packed<OutlineElem> {
+ const KEY: &'static str = "outline";
+}
+
+/// Marks an element as being able to be outlined. This is used to implement the
+/// `#outline()` element.
+pub trait Outlinable: Refable {
+ /// Produce an outline item for this element.
+ fn outline(
+ &self,
+ engine: &mut Engine,
+
+ styles: StyleChain,
+ ) -> SourceResult<Option<Content>>;
+
+ /// Returns the nesting level of this element.
+ fn level(&self) -> NonZeroUsize {
+ NonZeroUsize::ONE
+ }
+}
+
+/// Defines how an outline is indented.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum OutlineIndent {
+ Bool(bool),
+ Rel(Rel<Length>),
+ Func(Func),
+}
+
+impl OutlineIndent {
+ fn apply(
+ indent: &Option<Smart<Self>>,
+ engine: &mut Engine,
+ ancestors: &Vec<&Content>,
+ seq: &mut Vec<Content>,
+ styles: StyleChain,
+ span: Span,
+ ) -> SourceResult<()> {
+ match indent {
+ // 'none' | 'false' => no indenting
+ None | Some(Smart::Custom(OutlineIndent::Bool(false))) => {}
+
+ // 'auto' | 'true' => use numbering alignment for indenting
+ Some(Smart::Auto | Smart::Custom(OutlineIndent::Bool(true))) => {
+ // Add hidden ancestors numberings to realize the indent.
+ let mut hidden = Content::empty();
+ for ancestor in ancestors {
+ let ancestor_outlinable = ancestor.with::<dyn Outlinable>().unwrap();
+
+ if let Some(numbering) = ancestor_outlinable.numbering() {
+ let numbers = ancestor_outlinable.counter().display_at_loc(
+ engine,
+ ancestor.location().unwrap(),
+ styles,
+ numbering,
+ )?;
+
+ hidden += numbers + SpaceElem::shared().clone();
+ };
+ }
+
+ if !ancestors.is_empty() {
+ seq.push(HideElem::new(hidden).pack());
+ seq.push(SpaceElem::shared().clone());
+ }
+ }
+
+ // Length => indent with some fixed spacing per level
+ Some(Smart::Custom(OutlineIndent::Rel(length))) => {
+ seq.push(
+ HElem::new(Spacing::Rel(*length)).pack().repeat(ancestors.len()),
+ );
+ }
+
+ // Function => call function with the current depth and take
+ // the returned content
+ Some(Smart::Custom(OutlineIndent::Func(func))) => {
+ let depth = ancestors.len();
+ let LengthOrContent(content) = func
+ .call(engine, Context::new(None, Some(styles)).track(), [depth])?
+ .cast()
+ .at(span)?;
+ if !content.is_empty() {
+ seq.push(content);
+ }
+ }
+ };
+
+ Ok(())
+ }
+}
+
+cast! {
+ OutlineIndent,
+ self => match self {
+ Self::Bool(v) => v.into_value(),
+ Self::Rel(v) => v.into_value(),
+ Self::Func(v) => v.into_value()
+ },
+ v: bool => OutlineIndent::Bool(v),
+ v: Rel<Length> => OutlineIndent::Rel(v),
+ v: Func => OutlineIndent::Func(v),
+}
+
+struct LengthOrContent(Content);
+
+cast! {
+ LengthOrContent,
+ v: Rel<Length> => Self(HElem::new(Spacing::Rel(v)).pack()),
+ v: Content => Self(v),
+}
+
+/// Represents each entry line in an outline, including the reference to the
+/// outlined element, its page number, and the filler content between both.
+///
+/// This element is intended for use with show rules to control the appearance
+/// of outlines. To customize an entry's line, you can build it from scratch by
+/// accessing the `level`, `element`, `body`, `fill` and `page` fields on the
+/// entry.
+///
+/// ```example
+/// #set heading(numbering: "1.")
+///
+/// #show outline.entry.where(
+/// level: 1
+/// ): it => {
+/// v(12pt, weak: true)
+/// strong(it)
+/// }
+///
+/// #outline(indent: auto)
+///
+/// = Introduction
+/// = Background
+/// == History
+/// == State of the Art
+/// = Analysis
+/// == Setup
+/// ```
+#[elem(name = "entry", title = "Outline Entry", Show)]
+pub struct OutlineEntry {
+ /// The nesting level of this outline entry. Starts at `{1}` for top-level
+ /// entries.
+ #[required]
+ pub level: NonZeroUsize,
+
+ /// The element this entry refers to. Its location will be available
+ /// through the [`location`]($content.location) method on content
+ /// and can be [linked]($link) to.
+ #[required]
+ pub element: Content,
+
+ /// The content which is displayed in place of the referred element at its
+ /// entry in the outline. For a heading, this would be its number followed
+ /// by the heading's title, for example.
+ #[required]
+ pub body: Content,
+
+ /// The content used to fill the space between the element's outline and
+ /// its page number, as defined by the outline element this entry is
+ /// located in. When `{none}`, empty space is inserted in that gap instead.
+ ///
+ /// Note that, when using show rules to override outline entries, it is
+ /// recommended to wrap the filling content in a [`box`] with fractional
+ /// width. For example, `{box(width: 1fr, repeat[-])}` would show precisely
+ /// as many `-` characters as necessary to fill a particular gap.
+ #[required]
+ pub fill: Option<Content>,
+
+ /// The page number of the element this entry links to, formatted with the
+ /// numbering set for the referenced page.
+ #[required]
+ pub page: Content,
+}
+
+impl OutlineEntry {
+ /// Generates an OutlineEntry from the given element, if possible (errors if
+ /// the element does not implement `Outlinable`). If the element should not
+ /// be outlined (e.g. heading with 'outlined: false'), does not generate an
+ /// entry instance (returns `Ok(None)`).
+ fn from_outlinable(
+ engine: &mut Engine,
+ span: Span,
+ elem: Content,
+ fill: Option<Content>,
+ styles: StyleChain,
+ ) -> SourceResult<Option<Self>> {
+ let Some(outlinable) = elem.with::<dyn Outlinable>() else {
+ bail!(span, "cannot outline {}", elem.func().name());
+ };
+
+ let Some(body) = outlinable.outline(engine, styles)? else {
+ return Ok(None);
+ };
+
+ let location = elem.location().unwrap();
+ let page_numbering = engine
+ .introspector
+ .page_numbering(location)
+ .cloned()
+ .unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into());
+
+ let page = Counter::new(CounterKey::Page).display_at_loc(
+ engine,
+ location,
+ styles,
+ &page_numbering,
+ )?;
+
+ Ok(Some(Self::new(outlinable.level(), elem, body, fill, page)))
+ }
+}
+
+impl Show for Packed<OutlineEntry> {
+ #[typst_macros::time(name = "outline.entry", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut seq = vec![];
+ let elem = self.element();
+
+ // In case a user constructs an outline entry with an arbitrary element.
+ let Some(location) = elem.location() else {
+ if elem.can::<dyn Locatable>() && elem.can::<dyn Outlinable>() {
+ bail!(
+ self.span(), "{} must have a location", elem.func().name();
+ hint: "try using a query or a show rule to customize the outline.entry instead",
+ )
+ } else {
+ bail!(self.span(), "cannot outline {}", elem.func().name())
+ }
+ };
+
+ // Isolate the entry body in RTL because the page number is typically
+ // LTR. I'm not sure whether LTR should conceptually also be isolated,
+ // but in any case we don't do it for now because the text shaping
+ // pipeline does tend to choke a bit on default ignorables (in
+ // particular the CJK-Latin spacing).
+ //
+ // See also:
+ // - https://github.com/typst/typst/issues/4476
+ // - https://github.com/typst/typst/issues/5176
+ let rtl = TextElem::dir_in(styles) == Dir::RTL;
+ if rtl {
+ // "Right-to-Left Embedding"
+ seq.push(TextElem::packed("\u{202B}"));
+ }
+
+ seq.push(self.body().clone().linked(Destination::Location(location)));
+
+ if rtl {
+ // "Pop Directional Formatting"
+ seq.push(TextElem::packed("\u{202C}"));
+ }
+
+ // Add filler symbols between the section name and page number.
+ if let Some(filler) = self.fill() {
+ seq.push(SpaceElem::shared().clone());
+ seq.push(
+ BoxElem::new()
+ .with_body(Some(filler.clone()))
+ .with_width(Fr::one().into())
+ .pack()
+ .spanned(self.span()),
+ );
+ seq.push(SpaceElem::shared().clone());
+ } else {
+ seq.push(HElem::new(Fr::one().into()).pack());
+ }
+
+ // Add the page number.
+ let page = self.page().clone().linked(Destination::Location(location));
+ seq.push(page);
+
+ Ok(Content::sequence(seq))
+ }
+}
diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs
new file mode 100644
index 00000000..a43499d8
--- /dev/null
+++ b/crates/typst-library/src/model/par.rs
@@ -0,0 +1,431 @@
+use std::fmt::{self, Debug, Formatter};
+
+use typst_utils::singleton;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, scope, Args, Cast, Construct, Content, NativeElement, Packed, Set, Smart,
+ StyleVec, Unlabellable,
+};
+use crate::introspection::{Count, CounterUpdate, Locatable};
+use crate::layout::{Em, HAlignment, Length, OuterHAlignment};
+use crate::model::Numbering;
+
+/// Arranges text, spacing and inline-level elements into a paragraph.
+///
+/// Although this function is primarily used in set rules to affect paragraph
+/// properties, it can also be used to explicitly render its argument onto a
+/// paragraph of its own.
+///
+/// # Example
+/// ```example
+/// #set par(
+/// first-line-indent: 1em,
+/// spacing: 0.65em,
+/// justify: true,
+/// )
+///
+/// We proceed by contradiction.
+/// Suppose that there exists a set
+/// of positive integers $a$, $b$, and
+/// $c$ that satisfies the equation
+/// $a^n + b^n = c^n$ for some
+/// integer value of $n > 2$.
+///
+/// Without loss of generality,
+/// let $a$ be the smallest of the
+/// three integers. Then, we ...
+/// ```
+#[elem(scope, title = "Paragraph", Debug, Construct)]
+pub struct ParElem {
+ /// The spacing between lines.
+ ///
+ /// Leading defines the spacing between the [bottom edge]($text.bottom-edge)
+ /// of one line and the [top edge]($text.top-edge) of the following line. By
+ /// default, these two properties are up to the font, but they can also be
+ /// configured manually with a text set rule.
+ ///
+ /// By setting top edge, bottom edge, and leading, you can also configure a
+ /// consistent baseline-to-baseline distance. You could, for instance, set
+ /// the leading to `{1em}`, the top-edge to `{0.8em}`, and the bottom-edge
+ /// to `{-0.2em}` to get a baseline gap of exactly `{2em}`. The exact
+ /// distribution of the top- and bottom-edge values affects the bounds of
+ /// the first and last line.
+ #[resolve]
+ #[ghost]
+ #[default(Em::new(0.65).into())]
+ pub leading: Length,
+
+ /// The spacing between paragraphs.
+ ///
+ /// Just like leading, this defines the spacing between the bottom edge of a
+ /// paragraph's last line and the top edge of the next paragraph's first
+ /// line.
+ ///
+ /// When a paragraph is adjacent to a [`block`] that is not a paragraph,
+ /// that block's [`above`]($block.above) or [`below`]($block.below) property
+ /// takes precedence over the paragraph spacing. Headings, for instance,
+ /// reduce the spacing below them by default for a better look.
+ #[resolve]
+ #[ghost]
+ #[default(Em::new(1.2).into())]
+ pub spacing: Length,
+
+ /// Whether to justify text in its line.
+ ///
+ /// Hyphenation will be enabled for justified paragraphs if the
+ /// [text function's `hyphenate` property]($text.hyphenate) is set to
+ /// `{auto}` and the current language is known.
+ ///
+ /// Note that the current [alignment]($align.alignment) still has an effect
+ /// on the placement of the last line except if it ends with a
+ /// [justified line break]($linebreak.justify).
+ #[ghost]
+ #[default(false)]
+ pub justify: bool,
+
+ /// How to determine line breaks.
+ ///
+ /// When this property is set to `{auto}`, its default value, optimized line
+ /// breaks will be used for justified paragraphs. Enabling optimized line
+ /// breaks for ragged paragraphs may also be worthwhile to improve the
+ /// appearance of the text.
+ ///
+ /// ```example
+ /// #set page(width: 207pt)
+ /// #set par(linebreaks: "simple")
+ /// Some texts feature many longer
+ /// words. Those are often exceedingly
+ /// challenging to break in a visually
+ /// pleasing way.
+ ///
+ /// #set par(linebreaks: "optimized")
+ /// Some texts feature many longer
+ /// words. Those are often exceedingly
+ /// challenging to break in a visually
+ /// pleasing way.
+ /// ```
+ #[ghost]
+ pub linebreaks: Smart<Linebreaks>,
+
+ /// The indent the first line of a paragraph should have.
+ ///
+ /// Only the first line of a consecutive paragraph will be indented (not
+ /// the first one in a block or on the page).
+ ///
+ /// By typographic convention, paragraph breaks are indicated either by some
+ /// space between paragraphs or by indented first lines. Consider reducing
+ /// the [paragraph spacing]($block.spacing) to the [`leading`]($par.leading)
+ /// when using this property (e.g. using `[#set par(spacing: 0.65em)]`).
+ #[ghost]
+ pub first_line_indent: Length,
+
+ /// The indent all but the first line of a paragraph should have.
+ #[ghost]
+ #[resolve]
+ pub hanging_indent: Length,
+
+ /// Indicates whether an overflowing line should be shrunk.
+ ///
+ /// This property is set to `false` on raw blocks, because shrinking a line
+ /// could visually break the indentation.
+ #[ghost]
+ #[internal]
+ #[default(true)]
+ pub shrink: bool,
+
+ /// The contents of the paragraph.
+ #[external]
+ #[required]
+ pub body: Content,
+
+ /// The paragraph's children.
+ #[internal]
+ #[variadic]
+ pub children: StyleVec,
+}
+
+#[scope]
+impl ParElem {
+ #[elem]
+ type ParLine;
+}
+
+impl Construct for ParElem {
+ fn construct(engine: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ // The paragraph constructor is special: It doesn't create a paragraph
+ // element. Instead, it just ensures that the passed content lives in a
+ // separate paragraph and styles it.
+ let styles = Self::set(engine, args)?;
+ let body = args.expect::<Content>("body")?;
+ Ok(Content::sequence([
+ ParbreakElem::shared().clone(),
+ body.styled_with_map(styles),
+ ParbreakElem::shared().clone(),
+ ]))
+ }
+}
+
+impl Debug for ParElem {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "Par ")?;
+ self.children.fmt(f)
+ }
+}
+
+/// How to determine line breaks in a paragraph.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Linebreaks {
+ /// Determine the line breaks in a simple first-fit style.
+ Simple,
+ /// Optimize the line breaks for the whole paragraph.
+ ///
+ /// Typst will try to produce more evenly filled lines of text by
+ /// considering the whole paragraph when calculating line breaks.
+ Optimized,
+}
+
+/// A paragraph break.
+///
+/// This starts a new paragraph. Especially useful when used within code like
+/// [for loops]($scripting/#loops). Multiple consecutive
+/// paragraph breaks collapse into a single one.
+///
+/// # Example
+/// ```example
+/// #for i in range(3) {
+/// [Blind text #i: ]
+/// lorem(5)
+/// parbreak()
+/// }
+/// ```
+///
+/// # Syntax
+/// Instead of calling this function, you can insert a blank line into your
+/// markup to create a paragraph break.
+#[elem(title = "Paragraph Break", Unlabellable)]
+pub struct ParbreakElem {}
+
+impl ParbreakElem {
+ /// Get the globally shared paragraph element.
+ pub fn shared() -> &'static Content {
+ singleton!(Content, ParbreakElem::new().pack())
+ }
+}
+
+impl Unlabellable for Packed<ParbreakElem> {}
+
+/// A paragraph line.
+///
+/// This element is exclusively used for line number configuration through set
+/// rules and cannot be placed.
+///
+/// The [`numbering`]($par.line.numbering) option is used to enable line
+/// numbers by specifying a numbering format.
+///
+/// ```example
+/// >>> #set page(margin: (left: 3em))
+/// #set par.line(numbering: "1")
+///
+/// Roses are red. \
+/// Violets are blue. \
+/// Typst is there for you.
+/// ```
+///
+/// The `numbering` option takes either a predefined
+/// [numbering pattern]($numbering) or a function returning styled content. You
+/// can disable line numbers for text inside certain elements by setting the
+/// numbering to `{none}` using show-set rules.
+///
+/// ```example
+/// >>> #set page(margin: (left: 3em))
+/// // Styled red line numbers.
+/// #set par.line(
+/// numbering: n => text(red)[#n]
+/// )
+///
+/// // Disable numbers inside figures.
+/// #show figure: set par.line(
+/// numbering: none
+/// )
+///
+/// Roses are red. \
+/// Violets are blue.
+///
+/// #figure(
+/// caption: [Without line numbers.]
+/// )[
+/// Lorem ipsum \
+/// dolor sit amet
+/// ]
+///
+/// The text above is a sample \
+/// originating from distant times.
+/// ```
+///
+/// This element exposes further options which may be used to control other
+/// aspects of line numbering, such as its [alignment]($par.line.number-align)
+/// or [margin]($par.line.number-margin). In addition, you can control whether
+/// the numbering is reset on each page through the
+/// [`numbering-scope`]($par.line.numbering-scope) option.
+#[elem(name = "line", title = "Paragraph Line", keywords = ["line numbering"], Construct, Locatable)]
+pub struct ParLine {
+ /// How to number each line. Accepts a
+ /// [numbering pattern or function]($numbering).
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(numbering: "I")
+ ///
+ /// Roses are red. \
+ /// Violets are blue. \
+ /// Typst is there for you.
+ /// ```
+ #[ghost]
+ pub numbering: Option<Numbering>,
+
+ /// The alignment of line numbers associated with each line.
+ ///
+ /// The default of `{auto}` indicates a smart default where numbers grow
+ /// horizontally away from the text, considering the margin they're in and
+ /// the current text direction.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(
+ /// numbering: "I",
+ /// number-align: left,
+ /// )
+ ///
+ /// Hello world! \
+ /// Today is a beautiful day \
+ /// For exploring the world.
+ /// ```
+ #[ghost]
+ pub number_align: Smart<HAlignment>,
+
+ /// The margin at which line numbers appear.
+ ///
+ /// _Note:_ In a multi-column document, the line numbers for paragraphs
+ /// inside the last column will always appear on the `{end}` margin (right
+ /// margin for left-to-right text and left margin for right-to-left),
+ /// regardless of this configuration. That behavior cannot be changed at
+ /// this moment.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (right: 3em))
+ /// #set par.line(
+ /// numbering: "1",
+ /// number-margin: right,
+ /// )
+ ///
+ /// = Report
+ /// - Brightness: Dark, yet darker
+ /// - Readings: Negative
+ /// ```
+ #[ghost]
+ #[default(OuterHAlignment::Start)]
+ pub number_margin: OuterHAlignment,
+
+ /// The distance between line numbers and text.
+ ///
+ /// The default value of `{auto}` results in a clearance that is adaptive to
+ /// the page width and yields reasonable results in most cases.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(
+ /// numbering: "1",
+ /// number-clearance: 4pt,
+ /// )
+ ///
+ /// Typesetting \
+ /// Styling \
+ /// Layout
+ /// ```
+ #[ghost]
+ #[default]
+ pub number_clearance: Smart<Length>,
+
+ /// Controls when to reset line numbering.
+ ///
+ /// _Note:_ The line numbering scope must be uniform across each page run (a
+ /// page run is a sequence of pages without an explicit pagebreak in
+ /// between). For this reason, set rules for it should be defined before any
+ /// page content, typically at the very start of the document.
+ ///
+ /// ```example
+ /// >>> #set page(margin: (left: 3em))
+ /// #set par.line(
+ /// numbering: "1",
+ /// numbering-scope: "page",
+ /// )
+ ///
+ /// First line \
+ /// Second line
+ /// #pagebreak()
+ /// First line again \
+ /// Second line again
+ /// ```
+ #[ghost]
+ #[default(LineNumberingScope::Document)]
+ pub numbering_scope: LineNumberingScope,
+}
+
+impl Construct for ParLine {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+/// Possible line numbering scope options, indicating how often the line number
+/// counter should be reset.
+///
+/// Note that, currently, manually resetting the line number counter is not
+/// supported.
+#[derive(Debug, Cast, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum LineNumberingScope {
+ /// Indicates that the line number counter spans the whole document, i.e.,
+ /// it's never automatically reset.
+ Document,
+ /// Indicates that the line number counter should be reset at the start of
+ /// every new page.
+ Page,
+}
+
+/// A marker used to indicate the presence of a line.
+///
+/// This element is added to each line in a paragraph and later searched to
+/// find out where to add line numbers.
+#[elem(Construct, Locatable, Count)]
+pub struct ParLineMarker {
+ #[internal]
+ #[required]
+ pub numbering: Numbering,
+
+ #[internal]
+ #[required]
+ pub number_align: Smart<HAlignment>,
+
+ #[internal]
+ #[required]
+ pub number_margin: OuterHAlignment,
+
+ #[internal]
+ #[required]
+ pub number_clearance: Smart<Length>,
+}
+
+impl Construct for ParLineMarker {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl Count for Packed<ParLineMarker> {
+ fn update(&self) -> Option<CounterUpdate> {
+ // The line counter must be updated manually by the root flow.
+ None
+ }
+}
diff --git a/crates/typst-library/src/model/quote.rs b/crates/typst-library/src/model/quote.rs
new file mode 100644
index 00000000..110825f1
--- /dev/null
+++ b/crates/typst-library/src/model/quote.rs
@@ -0,0 +1,237 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, Depth, Label, NativeElement, Packed, Show, ShowSet, Smart,
+ StyleChain, Styles,
+};
+use crate::introspection::Locatable;
+use crate::layout::{
+ Alignment, BlockBody, BlockElem, Em, HElem, PadElem, Spacing, VElem,
+};
+use crate::model::{CitationForm, CiteElem};
+use crate::text::{SmartQuoteElem, SmartQuotes, SpaceElem, TextElem};
+
+/// Displays a quote alongside an optional attribution.
+///
+/// # Example
+/// ```example
+/// Plato is often misquoted as the author of #quote[I know that I know
+/// nothing], however, this is a derivation form his original quote:
+///
+/// #set quote(block: true)
+///
+/// #quote(attribution: [Plato])[
+/// ... ἔοικα γοῦν τούτου γε σμικρῷ τινι αὐτῷ τούτῳ σοφώτερος εἶναι, ὅτι
+/// ἃ μὴ οἶδα οὐδὲ οἴομαι εἰδέναι.
+/// ]
+/// #quote(attribution: [from the Henry Cary literal translation of 1897])[
+/// ... I seem, then, in just this little thing to be wiser than this man at
+/// any rate, that what I do not know I do not think I know either.
+/// ]
+/// ```
+///
+/// By default block quotes are padded left and right by `{1em}`, alignment and
+/// padding can be controlled with show rules:
+/// ```example
+/// #set quote(block: true)
+/// #show quote: set align(center)
+/// #show quote: set pad(x: 5em)
+///
+/// #quote[
+/// You cannot pass... I am a servant of the Secret Fire, wielder of the
+/// flame of Anor. You cannot pass. The dark fire will not avail you,
+/// flame of Udûn. Go back to the Shadow! You cannot pass.
+/// ]
+/// ```
+#[elem(Locatable, ShowSet, Show)]
+pub struct QuoteElem {
+ /// Whether this is a block quote.
+ ///
+ /// ```example
+ /// An inline citation would look like
+ /// this: #quote(
+ /// attribution: [René Descartes]
+ /// )[
+ /// cogito, ergo sum
+ /// ], and a block equation like this:
+ /// #quote(
+ /// block: true,
+ /// attribution: [JFK]
+ /// )[
+ /// Ich bin ein Berliner.
+ /// ]
+ /// ```
+ block: bool,
+
+ /// Whether double quotes should be added around this quote.
+ ///
+ /// The double quotes used are inferred from the `quotes` property on
+ /// [smartquote], which is affected by the `lang` property on [text].
+ ///
+ /// - `{true}`: Wrap this quote in double quotes.
+ /// - `{false}`: Do not wrap this quote in double quotes.
+ /// - `{auto}`: Infer whether to wrap this quote in double quotes based on
+ /// the `block` property. If `block` is `{false}`, double quotes are
+ /// automatically added.
+ ///
+ /// ```example
+ /// #set text(lang: "de")
+ ///
+ /// Ein deutsch-sprechender Author
+ /// zitiert unter umständen JFK:
+ /// #quote[Ich bin ein Berliner.]
+ ///
+ /// #set text(lang: "en")
+ ///
+ /// And an english speaking one may
+ /// translate the quote:
+ /// #quote[I am a Berliner.]
+ /// ```
+ quotes: Smart<bool>,
+
+ /// The attribution of this quote, usually the author or source. Can be a
+ /// label pointing to a bibliography entry or any content. By default only
+ /// displayed for block quotes, but can be changed using a `{show}` rule.
+ ///
+ /// ```example
+ /// #quote(attribution: [René Descartes])[
+ /// cogito, ergo sum
+ /// ]
+ ///
+ /// #show quote.where(block: false): it => {
+ /// ["] + h(0pt, weak: true) + it.body + h(0pt, weak: true) + ["]
+ /// if it.attribution != none [ (#it.attribution)]
+ /// }
+ ///
+ /// #quote(
+ /// attribution: link("https://typst.app/home")[typst.com]
+ /// )[
+ /// Compose papers faster
+ /// ]
+ ///
+ /// #set quote(block: true)
+ ///
+ /// #quote(attribution: <tolkien54>)[
+ /// You cannot pass... I am a servant
+ /// of the Secret Fire, wielder of the
+ /// flame of Anor. You cannot pass. The
+ /// dark fire will not avail you, flame
+ /// of Udûn. Go back to the Shadow! You
+ /// cannot pass.
+ /// ]
+ ///
+ /// #bibliography("works.bib", style: "apa")
+ /// ```
+ #[borrowed]
+ attribution: Option<Attribution>,
+
+ /// The quote.
+ #[required]
+ body: Content,
+
+ /// The nesting depth.
+ #[internal]
+ #[fold]
+ #[ghost]
+ depth: Depth,
+}
+
+/// Attribution for a [quote](QuoteElem).
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Attribution {
+ Content(Content),
+ Label(Label),
+}
+
+cast! {
+ Attribution,
+ self => match self {
+ Self::Content(content) => content.into_value(),
+ Self::Label(label) => label.into_value(),
+ },
+ content: Content => Self::Content(content),
+ label: Label => Self::Label(label),
+}
+
+impl Show for Packed<QuoteElem> {
+ #[typst_macros::time(name = "quote", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+ let block = self.block(styles);
+
+ if self.quotes(styles) == Smart::Custom(true) || !block {
+ let quotes = SmartQuotes::get(
+ SmartQuoteElem::quotes_in(styles),
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ SmartQuoteElem::alternative_in(styles),
+ );
+
+ // Alternate between single and double quotes.
+ let Depth(depth) = QuoteElem::depth_in(styles);
+ let double = depth % 2 == 0;
+
+ // Add zero-width weak spacing to make the quotes "sticky".
+ let hole = HElem::hole().pack();
+ realized = Content::sequence([
+ TextElem::packed(quotes.open(double)),
+ hole.clone(),
+ realized,
+ hole,
+ TextElem::packed(quotes.close(double)),
+ ])
+ .styled(QuoteElem::set_depth(Depth(1)));
+ }
+
+ if block {
+ realized = BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(self.span());
+
+ if let Some(attribution) = self.attribution(styles).as_ref() {
+ let mut seq = vec![TextElem::packed('—'), SpaceElem::shared().clone()];
+
+ match attribution {
+ Attribution::Content(content) => {
+ seq.push(content.clone());
+ }
+ Attribution::Label(label) => {
+ seq.push(
+ CiteElem::new(*label)
+ .with_form(Some(CitationForm::Prose))
+ .pack()
+ .spanned(self.span()),
+ );
+ }
+ }
+
+ // Use v(0.9em, weak: true) bring the attribution closer to the
+ // quote.
+ let gap = Spacing::Rel(Em::new(0.9).into());
+ let v = VElem::new(gap).with_weak(true).pack();
+ realized += v + Content::sequence(seq).aligned(Alignment::END);
+ }
+
+ realized = PadElem::new(realized).pack();
+ } else if let Some(Attribution::Label(label)) = self.attribution(styles) {
+ realized += SpaceElem::shared().clone()
+ + CiteElem::new(*label).pack().spanned(self.span());
+ }
+
+ Ok(realized)
+ }
+}
+
+impl ShowSet for Packed<QuoteElem> {
+ fn show_set(&self, styles: StyleChain) -> Styles {
+ let mut out = Styles::new();
+ if self.block(styles) {
+ out.set(PadElem::set_left(Em::new(1.0).into()));
+ out.set(PadElem::set_right(Em::new(1.0).into()));
+ out.set(BlockElem::set_above(Smart::Custom(Em::new(2.4).into())));
+ out.set(BlockElem::set_below(Smart::Custom(Em::new(1.8).into())));
+ }
+ out
+ }
+}
diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs
new file mode 100644
index 00000000..bc1919d6
--- /dev/null
+++ b/crates/typst-library/src/model/reference.rs
@@ -0,0 +1,307 @@
+use comemo::Track;
+use ecow::eco_format;
+
+use crate::diag::{bail, At, Hint, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, Content, Context, Func, IntoValue, Label, NativeElement, Packed, Show,
+ Smart, StyleChain, Synthesize,
+};
+use crate::introspection::{Counter, Locatable};
+use crate::math::EquationElem;
+use crate::model::{
+ BibliographyElem, CiteElem, Destination, Figurable, FootnoteElem, Numbering,
+};
+use crate::text::TextElem;
+
+/// A reference to a label or bibliography.
+///
+/// Produces a textual reference to a label. For example, a reference to a
+/// heading will yield an appropriate string such as "Section 1" for a reference
+/// to the first heading. The references are also links to the respective
+/// element. Reference syntax can also be used to [cite] from a bibliography.
+///
+/// Referenceable elements include [headings]($heading), [figures]($figure),
+/// [equations]($math.equation), and [footnotes]($footnote). To create a custom
+/// referenceable element like a theorem, you can create a figure of a custom
+/// [`kind`]($figure.kind) and write a show rule for it. In the future, there
+/// might be a more direct way to define a custom referenceable element.
+///
+/// If you just want to link to a labelled element and not get an automatic
+/// textual reference, consider using the [`link`] function instead.
+///
+/// # Example
+/// ```example
+/// #set heading(numbering: "1.")
+/// #set math.equation(numbering: "(1)")
+///
+/// = Introduction <intro>
+/// Recent developments in
+/// typesetting software have
+/// rekindled hope in previously
+/// frustrated researchers. @distress
+/// As shown in @results, we ...
+///
+/// = Results <results>
+/// We discuss our approach in
+/// comparison with others.
+///
+/// == Performance <perf>
+/// @slow demonstrates what slow
+/// software looks like.
+/// $ T(n) = O(2^n) $ <slow>
+///
+/// #bibliography("works.bib")
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: A reference to a label can be
+/// created by typing an `@` followed by the name of the label (e.g.
+/// `[= Introduction <intro>]` can be referenced by typing `[@intro]`).
+///
+/// To customize the supplement, add content in square brackets after the
+/// reference: `[@intro[Chapter]]`.
+///
+/// # Customization
+/// If you write a show rule for references, you can access the referenced
+/// element through the `element` field of the reference. The `element` may
+/// be `{none}` even if it exists if Typst hasn't discovered it yet, so you
+/// always need to handle that case in your code.
+///
+/// ```example
+/// #set heading(numbering: "1.")
+/// #set math.equation(numbering: "(1)")
+///
+/// #show ref: it => {
+/// let eq = math.equation
+/// let el = it.element
+/// if el != none and el.func() == eq {
+/// // Override equation references.
+/// link(el.location(),numbering(
+/// el.numbering,
+/// ..counter(eq).at(el.location())
+/// ))
+/// } else {
+/// // Other references as usual.
+/// it
+/// }
+/// }
+///
+/// = Beginnings <beginning>
+/// In @beginning we prove @pythagoras.
+/// $ a^2 + b^2 = c^2 $ <pythagoras>
+/// ```
+#[elem(title = "Reference", Synthesize, Locatable, Show)]
+pub struct RefElem {
+ /// The target label that should be referenced.
+ ///
+ /// Can be a label that is defined in the document or an entry from the
+ /// [`bibliography`].
+ #[required]
+ pub target: Label,
+
+ /// A supplement for the reference.
+ ///
+ /// For references to headings or figures, this is added before the
+ /// referenced number. For citations, this can be used to add a page number.
+ ///
+ /// If a function is specified, it is passed the referenced element and
+ /// should return content.
+ ///
+ /// ```example
+ /// #set heading(numbering: "1.")
+ /// #set ref(supplement: it => {
+ /// if it.func() == heading {
+ /// "Chapter"
+ /// } else {
+ /// "Thing"
+ /// }
+ /// })
+ ///
+ /// = Introduction <intro>
+ /// In @intro, we see how to turn
+ /// Sections into Chapters. And
+ /// in @intro[Part], it is done
+ /// manually.
+ /// ```
+ #[borrowed]
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// A synthesized citation.
+ #[synthesized]
+ pub citation: Option<Packed<CiteElem>>,
+
+ /// The referenced element.
+ #[synthesized]
+ pub element: Option<Content>,
+}
+
+impl Synthesize for Packed<RefElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let citation = to_citation(self, engine, styles)?;
+
+ let elem = self.as_mut();
+ elem.push_citation(Some(citation));
+ elem.push_element(None);
+
+ let target = *elem.target();
+ if !BibliographyElem::has(engine, target) {
+ if let Ok(found) = engine.introspector.query_label(target).cloned() {
+ elem.push_element(Some(found));
+ return Ok(());
+ }
+ }
+
+ Ok(())
+ }
+}
+
+impl Show for Packed<RefElem> {
+ #[typst_macros::time(name = "ref", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let target = *self.target();
+ let elem = engine.introspector.query_label(target);
+ let span = self.span();
+
+ if BibliographyElem::has(engine, target) {
+ if elem.is_ok() {
+ bail!(span, "label occurs in the document and its bibliography");
+ }
+
+ return Ok(to_citation(self, engine, styles)?.pack().spanned(span));
+ }
+
+ let elem = elem.at(span)?;
+
+ if let Some(footnote) = elem.to_packed::<FootnoteElem>() {
+ return Ok(footnote.into_ref(target).pack().spanned(span));
+ }
+
+ let elem = elem.clone();
+ let refable = elem
+ .with::<dyn Refable>()
+ .ok_or_else(|| {
+ if elem.can::<dyn Figurable>() {
+ eco_format!(
+ "cannot reference {} directly, try putting it into a figure",
+ elem.func().name()
+ )
+ } else {
+ eco_format!("cannot reference {}", elem.func().name())
+ }
+ })
+ .at(span)?;
+
+ let numbering = refable
+ .numbering()
+ .ok_or_else(|| {
+ eco_format!("cannot reference {} without numbering", elem.func().name())
+ })
+ .hint(eco_format!(
+ "you can enable {} numbering with `#set {}(numbering: \"1.\")`",
+ elem.func().name(),
+ if elem.func() == EquationElem::elem() {
+ "math.equation"
+ } else {
+ elem.func().name()
+ }
+ ))
+ .at(span)?;
+
+ let loc = elem.location().unwrap();
+ let numbers = refable.counter().display_at_loc(
+ engine,
+ loc,
+ styles,
+ &numbering.clone().trimmed(),
+ )?;
+
+ let supplement = match self.supplement(styles).as_ref() {
+ Smart::Auto => refable.supplement(),
+ Smart::Custom(None) => Content::empty(),
+ Smart::Custom(Some(supplement)) => {
+ supplement.resolve(engine, styles, [elem])?
+ }
+ };
+
+ let mut content = numbers;
+ if !supplement.is_empty() {
+ content = supplement + TextElem::packed("\u{a0}") + content;
+ }
+
+ Ok(content.linked(Destination::Location(loc)))
+ }
+}
+
+/// Turn a reference into a citation.
+fn to_citation(
+ reference: &Packed<RefElem>,
+ engine: &mut Engine,
+ styles: StyleChain,
+) -> SourceResult<Packed<CiteElem>> {
+ let mut elem = Packed::new(CiteElem::new(*reference.target()).with_supplement(
+ match reference.supplement(styles).clone() {
+ Smart::Custom(Some(Supplement::Content(content))) => Some(content),
+ _ => None,
+ },
+ ));
+
+ if let Some(loc) = reference.location() {
+ elem.set_location(loc);
+ }
+
+ elem.synthesize(engine, styles)?;
+
+ Ok(elem)
+}
+
+/// Additional content for a reference.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Supplement {
+ Content(Content),
+ Func(Func),
+}
+
+impl Supplement {
+ /// Tries to resolve the supplement into its content.
+ pub fn resolve<T: IntoValue>(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ args: impl IntoIterator<Item = T>,
+ ) -> SourceResult<Content> {
+ Ok(match self {
+ Supplement::Content(content) => content.clone(),
+ Supplement::Func(func) => func
+ .call(engine, Context::new(None, Some(styles)).track(), args)?
+ .display(),
+ })
+ }
+}
+
+cast! {
+ Supplement,
+ self => match self {
+ Self::Content(v) => v.into_value(),
+ Self::Func(v) => v.into_value(),
+ },
+ v: Content => Self::Content(v),
+ v: Func => Self::Func(v),
+}
+
+/// Marks an element as being able to be referenced. This is used to implement
+/// the `@ref` element.
+pub trait Refable {
+ /// The supplement, if not overridden by the reference.
+ fn supplement(&self) -> Content;
+
+ /// Returns the counter of this element.
+ fn counter(&self) -> Counter;
+
+ /// Returns the numbering of this element.
+ fn numbering(&self) -> Option<&Numbering>;
+}
diff --git a/crates/typst-library/src/model/strong.rs b/crates/typst-library/src/model/strong.rs
new file mode 100644
index 00000000..0e23179e
--- /dev/null
+++ b/crates/typst-library/src/model/strong.rs
@@ -0,0 +1,48 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, Packed, Show, StyleChain};
+use crate::text::{TextElem, WeightDelta};
+
+/// Strongly emphasizes content by increasing the font weight.
+///
+/// Increases the current font weight by a given `delta`.
+///
+/// # Example
+/// ```example
+/// This is *strong.* \
+/// This is #strong[too.] \
+///
+/// #show strong: set text(red)
+/// And this is *evermore.*
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: To strongly emphasize content,
+/// simply enclose it in stars/asterisks (`*`). Note that this only works at
+/// word boundaries. To strongly emphasize part of a word, you have to use the
+/// function.
+#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"], Show)]
+pub struct StrongElem {
+ /// The delta to apply on the font weight.
+ ///
+ /// ```example
+ /// #set strong(delta: 0)
+ /// No *effect!*
+ /// ```
+ #[default(300)]
+ pub delta: i64,
+
+ /// The content to strongly emphasize.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for Packed<StrongElem> {
+ #[typst_macros::time(name = "strong", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self
+ .body()
+ .clone()
+ .styled(TextElem::set_delta(WeightDelta(self.delta(styles)))))
+ }
+}
diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs
new file mode 100644
index 00000000..7dfaf45d
--- /dev/null
+++ b/crates/typst-library/src/model/table.rs
@@ -0,0 +1,724 @@
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, HintedStrResult, HintedString, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Content, NativeElement, Packed, Show, Smart, StyleChain,
+};
+use crate::layout::{
+ show_grid_cell, Abs, Alignment, BlockElem, Celled, GridCell, GridFooter, GridHLine,
+ GridHeader, GridVLine, Length, OuterHAlignment, OuterVAlignment, Rel, Sides,
+ TrackSizings,
+};
+use crate::model::Figurable;
+use crate::text::LocalName;
+use crate::visualize::{Paint, Stroke};
+
+/// A table of items.
+///
+/// Tables are used to arrange content in cells. Cells can contain arbitrary
+/// content, including multiple paragraphs and are specified in row-major order.
+/// For a hands-on explanation of all the ways you can use and customize tables
+/// in Typst, check out the [table guide]($guides/table-guide).
+///
+/// Because tables are just grids with different defaults for some cell
+/// properties (notably `stroke` and `inset`), refer to the [grid
+/// documentation]($grid) for more information on how to size the table tracks
+/// and specify the cell appearance properties.
+///
+/// If you are unsure whether you should be using a table or a grid, consider
+/// whether the content you are arranging semantically belongs together as a set
+/// of related data points or similar or whether you are just want to enhance
+/// your presentation by arranging unrelated content in a grid. In the former
+/// case, a table is the right choice, while in the latter case, a grid is more
+/// appropriate. Furthermore, Typst will annotate its output in the future such
+/// that screenreaders will announce content in `table` as tabular while a
+/// grid's content will be announced no different than multiple content blocks
+/// in the document flow.
+///
+/// Note that, to override a particular cell's properties or apply show rules on
+/// table cells, you can use the [`table.cell`]($table.cell) element. See its
+/// documentation for more information.
+///
+/// Although the `table` and the `grid` share most properties, set and show
+/// rules on one of them do not affect the other.
+///
+/// To give a table a caption and make it [referenceable]($ref), put it into a
+/// [figure].
+///
+/// # Example
+///
+/// The example below demonstrates some of the most common table options.
+/// ```example
+/// #table(
+/// columns: (1fr, auto, auto),
+/// inset: 10pt,
+/// align: horizon,
+/// table.header(
+/// [], [*Volume*], [*Parameters*],
+/// ),
+/// image("cylinder.svg"),
+/// $ pi h (D^2 - d^2) / 4 $,
+/// [
+/// $h$: height \
+/// $D$: outer radius \
+/// $d$: inner radius
+/// ],
+/// image("tetrahedron.svg"),
+/// $ sqrt(2) / 12 a^3 $,
+/// [$a$: edge length]
+/// )
+/// ```
+///
+/// Much like with grids, you can use [`table.cell`]($table.cell) to customize
+/// the appearance and the position of each cell.
+///
+/// ```example
+/// >>> #set page(width: auto)
+/// >>> #set text(font: "IBM Plex Sans")
+/// >>> #let gray = rgb("#565565")
+/// >>>
+/// #set table(
+/// stroke: none,
+/// gutter: 0.2em,
+/// fill: (x, y) =>
+/// if x == 0 or y == 0 { gray },
+/// inset: (right: 1.5em),
+/// )
+///
+/// #show table.cell: it => {
+/// if it.x == 0 or it.y == 0 {
+/// set text(white)
+/// strong(it)
+/// } else if it.body == [] {
+/// // Replace empty cells with 'N/A'
+/// pad(..it.inset)[_N/A_]
+/// } else {
+/// it
+/// }
+/// }
+///
+/// #let a = table.cell(
+/// fill: green.lighten(60%),
+/// )[A]
+/// #let b = table.cell(
+/// fill: aqua.lighten(60%),
+/// )[B]
+///
+/// #table(
+/// columns: 4,
+/// [], [Exam 1], [Exam 2], [Exam 3],
+///
+/// [John], [], a, [],
+/// [Mary], [], a, a,
+/// [Robert], b, a, b,
+/// )
+/// ```
+#[elem(scope, Show, LocalName, Figurable)]
+pub struct TableElem {
+ /// The column sizes. See the [grid documentation]($grid) for more
+ /// information on track sizing.
+ #[borrowed]
+ pub columns: TrackSizings,
+
+ /// The row sizes. See the [grid documentation]($grid) for more information
+ /// on track sizing.
+ #[borrowed]
+ pub rows: TrackSizings,
+
+ /// The gaps between rows and columns. This is a shorthand for setting
+ /// `column-gutter` and `row-gutter` to the same value. See the [grid
+ /// documentation]($grid) for more information on gutters.
+ #[external]
+ pub gutter: TrackSizings,
+
+ /// The gaps between columns. Takes precedence over `gutter`. See the
+ /// [grid documentation]($grid) for more information on gutters.
+ #[borrowed]
+ #[parse(
+ let gutter = args.named("gutter")?;
+ args.named("column-gutter")?.or_else(|| gutter.clone())
+ )]
+ pub column_gutter: TrackSizings,
+
+ /// The gaps between rows. Takes precedence over `gutter`. See the
+ /// [grid documentation]($grid) for more information on gutters.
+ #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))]
+ #[borrowed]
+ pub row_gutter: TrackSizings,
+
+ /// How to fill the cells.
+ ///
+ /// This can be a color or a function that returns a color. The function
+ /// receives the cells' column and row indices, starting from zero. This can
+ /// be used to implement striped tables.
+ ///
+ /// ```example
+ /// #table(
+ /// fill: (x, _) =>
+ /// if calc.odd(x) { luma(240) }
+ /// else { white },
+ /// align: (x, y) =>
+ /// if y == 0 { center }
+ /// else if x == 0 { left }
+ /// else { right },
+ /// columns: 4,
+ /// [], [*Q1*], [*Q2*], [*Q3*],
+ /// [Revenue:], [1000 €], [2000 €], [3000 €],
+ /// [Expenses:], [500 €], [1000 €], [1500 €],
+ /// [Profit:], [500 €], [1000 €], [1500 €],
+ /// )
+ /// ```
+ #[borrowed]
+ pub fill: Celled<Option<Paint>>,
+
+ /// How to align the cells' content.
+ ///
+ /// This can either be a single alignment, an array of alignments
+ /// (corresponding to each column) or a function that returns an alignment.
+ /// The function receives the cells' column and row indices, starting from
+ /// zero. If set to `{auto}`, the outer alignment is used.
+ ///
+ /// ```example
+ /// #table(
+ /// columns: 3,
+ /// align: (left, center, right),
+ /// [Hello], [Hello], [Hello],
+ /// [A], [B], [C],
+ /// )
+ /// ```
+ #[borrowed]
+ pub align: Celled<Smart<Alignment>>,
+
+ /// How to [stroke] the cells.
+ ///
+ /// Strokes can be disabled by setting this to `{none}`.
+ ///
+ /// If it is necessary to place lines which can cross spacing between cells
+ /// produced by the `gutter` option, or to override the stroke between
+ /// multiple specific cells, consider specifying one or more of
+ /// [`table.hline`]($table.hline) and [`table.vline`]($table.vline)
+ /// alongside your table cells.
+ ///
+ /// See the [grid documentation]($grid.stroke) for more information on
+ /// strokes.
+ #[resolve]
+ #[fold]
+ #[default(Celled::Value(Sides::splat(Some(Some(Arc::new(Stroke::default()))))))]
+ pub stroke: Celled<Sides<Option<Option<Arc<Stroke>>>>>,
+
+ /// How much to pad the cells' content.
+ ///
+ /// ```example
+ /// #table(
+ /// inset: 10pt,
+ /// [Hello],
+ /// [World],
+ /// )
+ ///
+ /// #table(
+ /// columns: 2,
+ /// inset: (
+ /// x: 20pt,
+ /// y: 10pt,
+ /// ),
+ /// [Hello],
+ /// [World],
+ /// )
+ /// ```
+ #[fold]
+ #[default(Celled::Value(Sides::splat(Some(Abs::pt(5.0).into()))))]
+ pub inset: Celled<Sides<Option<Rel<Length>>>>,
+
+ /// The contents of the table cells, plus any extra table lines specified
+ /// with the [`table.hline`]($table.hline) and
+ /// [`table.vline`]($table.vline) elements.
+ #[variadic]
+ pub children: Vec<TableChild>,
+}
+
+#[scope]
+impl TableElem {
+ #[elem]
+ type TableCell;
+
+ #[elem]
+ type TableHLine;
+
+ #[elem]
+ type TableVLine;
+
+ #[elem]
+ type TableHeader;
+
+ #[elem]
+ type TableFooter;
+}
+
+impl Show for Packed<TableElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::multi_layouter(self.clone(), engine.routines.layout_table)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+impl LocalName for Packed<TableElem> {
+ const KEY: &'static str = "table";
+}
+
+impl Figurable for Packed<TableElem> {}
+
+/// Any child of a table element.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum TableChild {
+ Header(Packed<TableHeader>),
+ Footer(Packed<TableFooter>),
+ Item(TableItem),
+}
+
+cast! {
+ TableChild,
+ self => match self {
+ Self::Header(header) => header.into_value(),
+ Self::Footer(footer) => footer.into_value(),
+ Self::Item(item) => item.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for TableChild {
+ type Error = HintedString;
+
+ fn try_from(value: Content) -> HintedStrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!(
+ "cannot use `grid.header` as a table header";
+ hint: "use `table.header` instead"
+ )
+ }
+ if value.is::<GridFooter>() {
+ bail!(
+ "cannot use `grid.footer` as a table footer";
+ hint: "use `table.footer` instead"
+ )
+ }
+
+ value
+ .into_packed::<TableHeader>()
+ .map(Self::Header)
+ .or_else(|value| value.into_packed::<TableFooter>().map(Self::Footer))
+ .or_else(|value| TableItem::try_from(value).map(Self::Item))
+ }
+}
+
+/// A table item, which is the basic unit of table specification.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum TableItem {
+ HLine(Packed<TableHLine>),
+ VLine(Packed<TableVLine>),
+ Cell(Packed<TableCell>),
+}
+
+cast! {
+ TableItem,
+ self => match self {
+ Self::HLine(hline) => hline.into_value(),
+ Self::VLine(vline) => vline.into_value(),
+ Self::Cell(cell) => cell.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for TableItem {
+ type Error = HintedString;
+
+ fn try_from(value: Content) -> HintedStrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!("cannot place a grid header within another header or footer");
+ }
+ if value.is::<TableHeader>() {
+ bail!("cannot place a table header within another header or footer");
+ }
+ if value.is::<GridFooter>() {
+ bail!("cannot place a grid footer within another footer or header");
+ }
+ if value.is::<TableFooter>() {
+ bail!("cannot place a table footer within another footer or header");
+ }
+ if value.is::<GridCell>() {
+ bail!(
+ "cannot use `grid.cell` as a table cell";
+ hint: "use `table.cell` instead"
+ );
+ }
+ if value.is::<GridHLine>() {
+ bail!(
+ "cannot use `grid.hline` as a table line";
+ hint: "use `table.hline` instead"
+ );
+ }
+ if value.is::<GridVLine>() {
+ bail!(
+ "cannot use `grid.vline` as a table line";
+ hint: "use `table.vline` instead"
+ );
+ }
+
+ Ok(value
+ .into_packed::<TableHLine>()
+ .map(Self::HLine)
+ .or_else(|value| value.into_packed::<TableVLine>().map(Self::VLine))
+ .or_else(|value| value.into_packed::<TableCell>().map(Self::Cell))
+ .unwrap_or_else(|value| {
+ let span = value.span();
+ Self::Cell(Packed::new(TableCell::new(value)).spanned(span))
+ }))
+ }
+}
+
+/// A repeatable table header.
+///
+/// You should wrap your tables' heading rows in this function even if you do not
+/// plan to wrap your table across pages because Typst will use this function to
+/// attach accessibility metadata to tables in the future and ensure universal
+/// access to your document.
+///
+/// You can use the `repeat` parameter to control whether your table's header
+/// will be repeated across pages.
+///
+/// ```example
+/// #set page(height: 11.5em)
+/// #set table(
+/// fill: (x, y) =>
+/// if x == 0 or y == 0 {
+/// gray.lighten(40%)
+/// },
+/// align: right,
+/// )
+///
+/// #show table.cell.where(x: 0): strong
+/// #show table.cell.where(y: 0): strong
+///
+/// #table(
+/// columns: 4,
+/// table.header(
+/// [], [Blue chip],
+/// [Fresh IPO], [Penny st'k],
+/// ),
+/// table.cell(
+/// rowspan: 6,
+/// align: horizon,
+/// rotate(-90deg, reflow: true)[
+/// *USD / day*
+/// ],
+/// ),
+/// [0.20], [104], [5],
+/// [3.17], [108], [4],
+/// [1.59], [84], [1],
+/// [0.26], [98], [15],
+/// [0.01], [195], [4],
+/// [7.34], [57], [2],
+/// )
+/// ```
+#[elem(name = "header", title = "Table Header")]
+pub struct TableHeader {
+ /// Whether this header should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the header.
+ #[variadic]
+ pub children: Vec<TableItem>,
+}
+
+/// A repeatable table footer.
+///
+/// Just like the [`table.header`]($table.header) element, the footer can repeat
+/// itself on every page of the table. This is useful for improving legibility
+/// by adding the column labels in both the header and footer of a large table,
+/// totals, or other information that should be visible on every page.
+///
+/// No other table cells may be placed after the footer.
+#[elem(name = "footer", title = "Table Footer")]
+pub struct TableFooter {
+ /// Whether this footer should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the footer.
+ #[variadic]
+ pub children: Vec<TableItem>,
+}
+
+/// A horizontal line in the table.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// table's `stroke` field. Can cross spacing between cells created through the
+/// table's [`column-gutter`]($table.column-gutter) option.
+///
+/// Use this function instead of the table's `stroke` field if you want to
+/// manually place a horizontal line at a specific position in a single table.
+/// Consider using [table's `stroke`]($table.stroke) field or [`table.cell`'s
+/// `stroke`]($table.cell.stroke) field instead if the line you want to place is
+/// part of all your tables' designs.
+///
+/// ```example
+/// #set table.hline(stroke: .6pt)
+///
+/// #table(
+/// stroke: none,
+/// columns: (auto, 1fr),
+/// [09:00], [Badge pick up],
+/// [09:45], [Opening Keynote],
+/// [10:30], [Talk: Typst's Future],
+/// [11:15], [Session: Good PRs],
+/// table.hline(start: 1),
+/// [Noon], [_Lunch break_],
+/// table.hline(start: 1),
+/// [14:00], [Talk: Tracked Layout],
+/// [15:00], [Talk: Automations],
+/// [16:00], [Workshop: Tables],
+/// table.hline(),
+/// [19:00], [Day 1 Attendee Mixer],
+/// )
+/// ```
+#[elem(name = "hline", title = "Table Horizontal Line")]
+pub struct TableHLine {
+ /// The row above which the horizontal line is placed (zero-indexed).
+ /// Functions identically to the `y` field in [`grid.hline`]($grid.hline.y).
+ pub y: Smart<usize>,
+
+ /// The column at which the horizontal line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The column before which the horizontal line ends (zero-indexed,
+ /// exclusive).
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` removes any lines previously placed across this
+ /// line's range, including hlines or per-cell stroke below it.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its row (`y`) - either
+ /// `{top}` to draw above it or `{bottom}` to draw below it.
+ ///
+ /// This setting is only relevant when row gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `y` field by
+ /// one instead), since then the position below a row becomes different
+ /// from the position above the next row due to the spacing between both.
+ #[default(OuterVAlignment::Top)]
+ pub position: OuterVAlignment,
+}
+
+/// A vertical line in the table. See the docs for [`grid.vline`]($grid.vline)
+/// for more information regarding how to use this element's fields.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// table's `stroke` field. Can cross spacing between cells created through the
+/// table's [`row-gutter`]($table.row-gutter) option.
+///
+/// Similar to [`table.hline`]($table.hline), use this function if you want to
+/// manually place a vertical line at a specific position in a single table and
+/// use the [table's `stroke`]($table.stroke) field or [`table.cell`'s
+/// `stroke`]($table.cell.stroke) field instead if the line you want to place is
+/// part of all your tables' designs.
+#[elem(name = "vline", title = "Table Vertical Line")]
+pub struct TableVLine {
+ /// The column before which the horizontal line is placed (zero-indexed).
+ /// Functions identically to the `x` field in [`grid.vline`]($grid.vline).
+ pub x: Smart<usize>,
+
+ /// The row at which the vertical line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The row on top of which the vertical line ends (zero-indexed,
+ /// exclusive).
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` removes any lines previously placed across this
+ /// line's range, including vlines or per-cell stroke below it.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its column (`x`) -
+ /// either `{start}` to draw before it or `{end}` to draw after it.
+ ///
+ /// The values `{left}` and `{right}` are also accepted, but discouraged as
+ /// they cause your table to be inconsistent between left-to-right and
+ /// right-to-left documents.
+ ///
+ /// This setting is only relevant when column gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `x` field by
+ /// one instead), since then the position after a column becomes different
+ /// from the position before the next column due to the spacing between
+ /// both.
+ #[default(OuterHAlignment::Start)]
+ pub position: OuterHAlignment,
+}
+
+/// A cell in the table. Use this to position a cell manually or to apply
+/// styling. To do the latter, you can either use the function to override the
+/// properties for a particular cell, or use it in show rules to apply certain
+/// styles to multiple cells at once.
+///
+/// Perhaps the most important use case of `{table.cell}` is to make a cell span
+/// multiple columns and/or rows with the `colspan` and `rowspan` fields.
+///
+/// ```example
+/// >>> #set page(width: auto)
+/// #show table.cell.where(y: 0): strong
+/// #set table(
+/// stroke: (x, y) => if y == 0 {
+/// (bottom: 0.7pt + black)
+/// },
+/// align: (x, y) => (
+/// if x > 0 { center }
+/// else { left }
+/// )
+/// )
+///
+/// #table(
+/// columns: 3,
+/// table.header(
+/// [Substance],
+/// [Subcritical °C],
+/// [Supercritical °C],
+/// ),
+/// [Hydrochloric Acid],
+/// [12.0], [92.1],
+/// [Sodium Myreth Sulfate],
+/// [16.6], [104],
+/// [Potassium Hydroxide],
+/// table.cell(colspan: 2)[24.7],
+/// )
+/// ```
+///
+/// For example, you can override the fill, alignment or inset for a single
+/// cell:
+///
+/// ```example
+/// >>> #set page(width: auto)
+/// // You can also import those.
+/// #import table: cell, header
+///
+/// #table(
+/// columns: 2,
+/// align: center,
+/// header(
+/// [*Trip progress*],
+/// [*Itinerary*],
+/// ),
+/// cell(
+/// align: right,
+/// fill: fuchsia.lighten(80%),
+/// [🚗],
+/// ),
+/// [Get in, folks!],
+/// [🚗], [Eat curbside hotdog],
+/// cell(align: left)[🌴🚗],
+/// cell(
+/// inset: 0.06em,
+/// text(1.62em)[🛖🌅🌊],
+/// ),
+/// )
+/// ```
+///
+/// You may also apply a show rule on `table.cell` to style all cells at once.
+/// Combined with selectors, this allows you to apply styles based on a cell's
+/// position:
+///
+/// ```example
+/// #show table.cell.where(x: 0): strong
+///
+/// #table(
+/// columns: 3,
+/// gutter: 3pt,
+/// [Name], [Age], [Strength],
+/// [Hannes], [36], [Grace],
+/// [Irma], [50], [Resourcefulness],
+/// [Vikram], [49], [Perseverance],
+/// )
+/// ```
+#[elem(name = "cell", title = "Table Cell", Show)]
+pub struct TableCell {
+ /// The cell's body.
+ #[required]
+ pub body: Content,
+
+ /// The cell's column (zero-indexed).
+ /// Functions identically to the `x` field in [`grid.cell`]($grid.cell).
+ pub x: Smart<usize>,
+
+ /// The cell's row (zero-indexed).
+ /// Functions identically to the `y` field in [`grid.cell`]($grid.cell).
+ pub y: Smart<usize>,
+
+ /// The amount of columns spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ pub colspan: NonZeroUsize,
+
+ /// The amount of rows spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ pub rowspan: NonZeroUsize,
+
+ /// The cell's [fill]($table.fill) override.
+ pub fill: Smart<Option<Paint>>,
+
+ /// The cell's [alignment]($table.align) override.
+ pub align: Smart<Alignment>,
+
+ /// The cell's [inset]($table.inset) override.
+ pub inset: Smart<Sides<Option<Rel<Length>>>>,
+
+ /// The cell's [stroke]($table.stroke) override.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
+
+ /// Whether rows spanned by this cell can be placed in different pages.
+ /// When equal to `{auto}`, a cell spanning only fixed-size rows is
+ /// unbreakable, while a cell spanning at least one `{auto}`-sized row is
+ /// breakable.
+ pub breakable: Smart<bool>,
+}
+
+cast! {
+ TableCell,
+ v: Content => v.into(),
+}
+
+impl Show for Packed<TableCell> {
+ fn show(&self, _engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ show_grid_cell(self.body().clone(), self.inset(styles), self.align(styles))
+ }
+}
+
+impl Default for Packed<TableCell> {
+ fn default() -> Self {
+ Packed::new(TableCell::new(Content::default()))
+ }
+}
+
+impl From<Content> for TableCell {
+ fn from(value: Content) -> Self {
+ #[allow(clippy::unwrap_or_default)]
+ value.unpack::<Self>().unwrap_or_else(Self::new)
+ }
+}
diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs
new file mode 100644
index 00000000..036a03e2
--- /dev/null
+++ b/crates/typst-library/src/model/terms.rs
@@ -0,0 +1,204 @@
+use typst_utils::Numeric;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, Array, Content, NativeElement, Packed, Show, Smart, StyleChain,
+ Styles,
+};
+use crate::layout::{Dir, Em, HElem, Length, Sides, StackChild, StackElem, VElem};
+use crate::model::{ListItemLike, ListLike, ParElem};
+use crate::text::TextElem;
+
+/// A list of terms and their descriptions.
+///
+/// Displays a sequence of terms and their descriptions vertically. When the
+/// descriptions span over multiple lines, they use hanging indent to
+/// communicate the visual hierarchy.
+///
+/// # Example
+/// ```example
+/// / Ligature: A merged glyph.
+/// / Kerning: A spacing adjustment
+/// between two adjacent letters.
+/// ```
+///
+/// # Syntax
+/// This function also has dedicated syntax: Starting a line with a slash,
+/// followed by a term, a colon and a description creates a term list item.
+#[elem(scope, title = "Term List", Show)]
+pub struct TermsElem {
+ /// Defines the default [spacing]($terms.spacing) of the term list. If it is
+ /// `{false}`, the items are spaced apart with
+ /// [paragraph spacing]($par.spacing). If it is `{true}`, they use
+ /// [paragraph leading]($par.leading) instead. This makes the list more
+ /// compact, which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`. The markup-defined tightness cannot be
+ /// overridden with set rules.
+ ///
+ /// ```example
+ /// / Fact: If a term list has a lot
+ /// of text, and maybe other inline
+ /// content, it should not be tight
+ /// anymore.
+ ///
+ /// / Tip: To make it wide, simply
+ /// insert a blank line between the
+ /// items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// The separator between the item and the description.
+ ///
+ /// If you want to just separate them with a certain amount of space, use
+ /// `{h(2cm, weak: true)}` as the separator and replace `{2cm}` with your
+ /// desired amount of space.
+ ///
+ /// ```example
+ /// #set terms(separator: [: ])
+ ///
+ /// / Colon: A nice separator symbol.
+ /// ```
+ #[default(HElem::new(Em::new(0.6).into()).with_weak(true).pack())]
+ #[borrowed]
+ pub separator: Content,
+
+ /// The indentation of each item.
+ pub indent: Length,
+
+ /// The hanging indent of the description.
+ ///
+ /// This is in addition to the whole item's `indent`.
+ ///
+ /// ```example
+ /// #set terms(hanging-indent: 0pt)
+ /// / Term: This term list does not
+ /// make use of hanging indents.
+ /// ```
+ #[default(Em::new(2.0).into())]
+ pub hanging_indent: Length,
+
+ /// The spacing between the items of the term list.
+ ///
+ /// If set to `{auto}`, uses paragraph [`leading`]($par.leading) for tight
+ /// term lists and paragraph [`spacing`]($par.spacing) for wide
+ /// (non-tight) term lists.
+ pub spacing: Smart<Length>,
+
+ /// The term list's children.
+ ///
+ /// When using the term list syntax, adjacent items are automatically
+ /// collected into term lists, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for (year, product) in (
+ /// "1978": "TeX",
+ /// "1984": "LaTeX",
+ /// "2019": "Typst",
+ /// ) [/ #product: Born in #year.]
+ /// ```
+ #[variadic]
+ pub children: Vec<Packed<TermItem>>,
+}
+
+#[scope]
+impl TermsElem {
+ #[elem]
+ type TermItem;
+}
+
+impl Show for Packed<TermsElem> {
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let separator = self.separator(styles);
+ let indent = self.indent(styles);
+ let hanging_indent = self.hanging_indent(styles);
+ let gutter = self.spacing(styles).unwrap_or_else(|| {
+ if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ ParElem::spacing_in(styles).into()
+ }
+ });
+
+ let pad = hanging_indent + indent;
+ let unpad = (!hanging_indent.is_zero())
+ .then(|| HElem::new((-hanging_indent).into()).pack());
+
+ let mut children = vec![];
+ for child in self.children().iter() {
+ let mut seq = vec![];
+ seq.extend(unpad.clone());
+ seq.push(child.term().clone().strong());
+ seq.push((*separator).clone());
+ seq.push(child.description().clone());
+ children.push(StackChild::Block(Content::sequence(seq)));
+ }
+
+ let mut padding = Sides::default();
+ if TextElem::dir_in(styles) == Dir::LTR {
+ padding.left = pad.into();
+ } else {
+ padding.right = pad.into();
+ }
+
+ let mut realized = StackElem::new(children)
+ .with_spacing(Some(gutter.into()))
+ .pack()
+ .padded(padding);
+
+ if self.tight(styles) {
+ let leading = ParElem::leading_in(styles);
+ let spacing =
+ VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
+ realized = spacing + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+/// A term list item.
+#[elem(name = "item", title = "Term List Item")]
+pub struct TermItem {
+ /// The term described by the list item.
+ #[required]
+ pub term: Content,
+
+ /// The description of the term.
+ #[required]
+ pub description: Content,
+}
+
+cast! {
+ TermItem,
+ array: Array => {
+ let mut iter = array.into_iter();
+ let (term, description) = match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => (a.cast()?, b.cast()?),
+ _ => bail!("array must contain exactly two entries"),
+ };
+ Self::new(term, description)
+ },
+ v: Content => v.unpack::<Self>().map_err(|_| "expected term item or array")?,
+}
+
+impl ListLike for TermsElem {
+ type Item = TermItem;
+
+ fn create(children: Vec<Packed<Self::Item>>, tight: bool) -> Self {
+ Self::new(children).with_tight(tight)
+ }
+}
+
+impl ListItemLike for TermItem {
+ fn styled(mut item: Packed<Self>, styles: Styles) -> Packed<Self> {
+ item.term.style_in_place(styles.clone());
+ item.description.style_in_place(styles);
+ item
+ }
+}