diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 4365b5006..5eb2a6549 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -6,7 +6,7 @@ use std::num::NonZeroUsize; use std::path::Path; use std::sync::{Arc, LazyLock}; -use comemo::Tracked; +use comemo::{Track, Tracked}; use ecow::{eco_format, EcoString, EcoVec}; use hayagriva::archive::ArchivedStyle; use hayagriva::io::BibLaTeXError; @@ -16,16 +16,16 @@ use hayagriva::{ }; use indexmap::IndexMap; use smallvec::{smallvec, SmallVec}; -use typst_macros::{cast, scope}; +use typst_macros::{cast, func, scope}; use typst_syntax::{Span, Spanned}; use typst_utils::{Get, ManuallyHash, NonZeroExt, PicoStr}; use crate::diag::{bail, error, At, FileError, HintedStrResult, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ - elem, Bytes, CastInfo, Content, Derived, FromValue, IntoValue, Label, NativeElement, - OneOrMultiple, Packed, Reflect, Scope, Show, ShowSet, Smart, StyleChain, Styles, - Synthesize, Value, + elem, select_where, Args, Bytes, CastInfo, Construct, Content, Context, Derived, + FromValue, IntoValue, Label, NativeElement, OneOrMultiple, Packed, Reflect, Resolve, + Scope, Show, ShowSet, Smart, StyleChain, Styles, Synthesize, Value, }; use crate::introspection::{Introspector, Locatable, Location, Locator, LocatorLink}; use crate::layout::{ @@ -209,8 +209,6 @@ impl Synthesize for Packed { impl Show for Packed { #[typst_macros::time(name = "bibliography", span = self.span())] fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult { - const INDENT: Em = Em::new(1.5); - let span = self.span(); let mut seq = vec![]; @@ -233,11 +231,10 @@ impl Show for Packed { .at(span)?; for (key, prefix, reference) in references { - let indent = if works.hanging_indent { Some(INDENT.into()) } else { None }; let entry = BibliographyEntry::new(*key, reference.clone()) .with_prefix(prefix.clone()) - .with_indent(indent) .pack() + .styled(BibliographyEntry::set_hanging_indent(works.hanging_indent)) .spanned(span); seq.push(entry); @@ -346,8 +343,10 @@ pub struct BibliographyEntry { /// Optional prefix for citation styles which use them, e.g., IEEE. pub prefix: Option, - /// Whether the citation style has a hanging indent. - pub indent: Option>, + /// Whether the bibliography has a hanging indent. + #[ghost] + #[internal] + pub hanging_indent: bool, /// Lets bibliography entries access the bibliography they are part of. This is a bit /// of a hack and should be superseded by a proper ancestry mechanism. @@ -357,64 +356,87 @@ pub struct BibliographyEntry { } #[scope] -impl BibliographyEntry {} +impl BibliographyEntry { + /// A helper function for producing an indented entry layout: Lays out a + /// prefix and the rest of the entry in an indent-aware way. + /// + /// If the bibliography style has a prefix, such as `[1]` within IEEE, the + /// inner content of all entries is aligned with the prefix that has the + /// biggest width, leaving at least `gap` space between the prefix and + /// inner parts. + /// + /// If the bibliography style is non-prefix based, `prefix` is `none` and + /// the entries are laid out as block elements consisting of `inner`, with + /// an optional inset if the style specifies a hanging indent. + #[func(contextual)] + pub fn indented( + &self, + engine: &mut Engine, + context: Tracked, + span: Span, + /// The `prefix` is aligned with the `inner` content of entries, + /// such that it acts like a grid of prefix + entry. + prefix: Option, + /// The formatted inner content of the entry. + /// + /// In the default show rule, this is just `it.body`, but it can be + /// freely customized. + inner: Content, + /// The gap between the prefix and the inner entry body. + #[named] + #[default(Em::new(0.65).into())] + gap: Length, + ) -> SourceResult { + let styles = context.styles().at(span)?; + if let Some(prefix) = prefix { + let bibliography = Self::parent_in(styles) + .ok_or("must be called within the context of a bibliography") + .at(span)?; + let bibliography_loc = bibliography.location().unwrap(); + let prefix_width = measure_prefix(engine, &prefix, bibliography_loc, styles)?; -impl Show for Packed { - #[typst_macros::time(name = "bibliography.entry", span = self.span())] - fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult { - const COLUMN_GUTTER: Em = Em::new(0.65); - let span = self.span(); + let prefix_inset = prefix_width + gap.resolve(styles); + let hanging_indent = compute_auto_indent( + engine.introspector, + bibliography_loc, + styles, + Some(prefix_inset), + ) + .unwrap_or_default(); - // FIXME: calculate width of longest prefix somehow (+ gap) and use that instead. - if self.prefix(styles).is_some() { - let prefix = self.prefix(styles).unwrap(); - let body = self.body.clone(); + // Save information about our prefix that other bibliography entries + // can query for (within `compute_auto_indent`) to align themselves. + let mut seq = Vec::with_capacity(5); + seq.push(PrefixInfo::new(bibliography_loc, prefix_inset).pack()); - let prefix_width = { - let layout_frame = engine.routines.layout_frame; - let bibliography = BibliographyEntry::parent_in(styles) - .ok_or("must be called within the context of a bibliography") - .at(span)?; - let bibliography_loc = bibliography.location().unwrap(); - - let pod = crate::layout::Region::new( - Axes::splat(Abs::inf()), - Axes::splat(false), - ); - let link = LocatorLink::measure(bibliography_loc); - let frame = - layout_frame(engine, &prefix, Locator::link(&link), styles, pod)?; - - frame.width() - }; - let hanging_indent = - prefix_width + COLUMN_GUTTER.at(TextElem::size_in(styles)); - let inset = Sides::default() - .with(TextElem::dir_in(styles).start(), Some(Rel::from(hanging_indent))); - - let body_seq = Content::sequence([ + seq.extend([ HElem::new((-hanging_indent).into()).pack(), prefix, HElem::new((hanging_indent - prefix_width).into()).pack(), - body, + inner, ]); + let inset = Sides::default() + .with(TextElem::dir_in(styles).start(), Some(Rel::from(hanging_indent))); + Ok(BlockElem::new() .with_inset(inset) - .with_body(Some(BlockBody::Content(body_seq))) + .with_body(Some(BlockBody::Content(Content::sequence(seq)))) .pack() .spanned(span)) } else { - let block = if let Some(indent) = self.indent(styles) { - let body = HElem::new(Spacing::Rel(-indent)).pack() + self.body.clone(); - let inset = - Sides::default().with(TextElem::dir_in(styles).start(), Some(indent)); + let block = if Self::hanging_indent_in(styles) { + const INDENT: Em = Em::new(1.5); + + let body = HElem::new(Spacing::Rel((-INDENT).into())).pack() + inner; + let inset = Sides::default() + .with(TextElem::dir_in(styles).start(), Some(INDENT.into())); BlockElem::new() .with_body(Some(BlockBody::Content(body))) .with_inset(inset) } else { - BlockElem::new().with_body(Some(BlockBody::Content(self.body.clone()))) + BlockElem::new().with_body(Some(BlockBody::Content(inner))) }; Ok(block.pack().spanned(span)) @@ -422,11 +444,93 @@ impl Show for Packed { } } +impl Show for Packed { + #[typst_macros::time(name = "bibliography.entry", span = self.span())] + fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult { + let span = self.span(); + let context = Context::new(None, Some(styles)); + let context = context.track(); + + let prefix = self.prefix(styles); + let body = self.body.clone(); + + self.indented(engine, context, span, prefix, body, Em::new(0.65).into()) + } +} + cast! { BibliographyEntry, v: Content => v.unpack::().map_err(|_| "expected bibliography entry")? } +/// Measures the width of a prefix. +fn measure_prefix( + engine: &mut Engine, + prefix: &Content, + loc: Location, + styles: StyleChain, +) -> SourceResult { + let pod = crate::layout::Region::new(Axes::splat(Abs::inf()), Axes::splat(false)); + let link = LocatorLink::measure(loc); + Ok((engine.routines.layout_frame)(engine, prefix, Locator::link(&link), styles, pod)? + .width()) +} + +/// Compute the hanging indent for a prefix-based bibliography +/// entry with the given prefix inset. +fn compute_auto_indent( + introspector: Tracked, + outline_loc: Location, + styles: StyleChain, + prefix_inset: Option, +) -> Option { + let indent = query_prefix_width(introspector, outline_loc); + let fallback = Em::new(1.2).resolve(styles); + prefix_inset.map(|p| p.max(indent.unwrap_or(fallback))) +} + +/// Determines the maximum prefix inset (prefix width + gap) +/// for the bibliography with the given `loc`. +#[comemo::memoize] +fn query_prefix_width( + introspector: Tracked, + bibliography_loc: Location, +) -> Option { + let mut width = None; + let elems = introspector.query(&select_where!(PrefixInfo, Key => bibliography_loc)); + for elem in &elems { + let info = elem.to_packed::().unwrap(); + width.get_or_insert(info.inset).set_max(info.inset); + } + width +} + +/// Helper type for introspection-based prefix alignment. +#[elem(Construct, Locatable, Show)] +struct PrefixInfo { + /// The location of the bibliography this prefix is part of. This is used to + /// scope prefix computations to a specific bibliography. + #[required] + key: Location, + + /// The width of the prefix, including the gap. + #[required] + #[internal] + inset: Abs, +} + +impl Construct for PrefixInfo { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually"); + } +} + +impl Show for Packed { + fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult { + Ok(Content::empty()) + } +} + /// Decode on library from one data source. fn decode_library(source: &DataSource, data: &Bytes) -> StrResult { let src = data.as_str().map_err(FileError::from)?;