diff options
Diffstat (limited to 'crates/typst-library/src/model')
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 + } +} |
