diff --git a/docs/src/general/changelog.md b/docs/src/general/changelog.md index 43270fb99..d75f77911 100644 --- a/docs/src/general/changelog.md +++ b/docs/src/general/changelog.md @@ -6,6 +6,13 @@ description: | # Changelog ## Unreleased +- Footnotes + - Implemented support for footnotes + - The [`footnote`]($func/footnote) function inserts a footnote + - The [`footnote.entry`]($func/footnote.entry) function can be used to + customize the footnote listing + - The `{"chicago-notes"}` [citation style]($func/cite.style) is now available + - Documentation - Added [guide for LaTeX users]($guides/guide-for-latex-users) - Now shows default values for optional arguments diff --git a/library/src/layout/columns.rs b/library/src/layout/columns.rs index 06e6a7e07..984c9673a 100644 --- a/library/src/layout/columns.rs +++ b/library/src/layout/columns.rs @@ -83,6 +83,7 @@ impl Layout for ColumnsElem { backlog: &backlog, last: regions.last, expand: Axes::new(true, regions.expand.y), + root: regions.root, }; // Layout the children. diff --git a/library/src/layout/flow.rs b/library/src/layout/flow.rs index 64d1d509d..a21f81424 100644 --- a/library/src/layout/flow.rs +++ b/library/src/layout/flow.rs @@ -1,4 +1,9 @@ -use super::{AlignElem, BlockElem, ColbreakElem, ParElem, PlaceElem, Spacing, VElem}; +use std::mem; + +use super::{ + AlignElem, BlockElem, ColbreakElem, ColumnsElem, ParElem, PlaceElem, Spacing, VElem, +}; +use crate::meta::{FootnoteElem, FootnoteEntry}; use crate::prelude::*; use crate::visualize::{ CircleElem, EllipseElem, ImageElem, PathElem, PolygonElem, RectElem, SquareElem, @@ -26,7 +31,7 @@ impl Layout for FlowElem { styles: StyleChain, regions: Regions, ) -> SourceResult { - let mut layouter = FlowLayouter::new(regions); + let mut layouter = FlowLayouter::new(regions, styles); for mut child in &self.children() { let outer = styles; @@ -37,7 +42,7 @@ impl Layout for FlowElem { } if let Some(elem) = child.to::() { - layouter.layout_spacing(elem, styles); + layouter.layout_spacing(vt, elem, styles)?; } else if let Some(elem) = child.to::() { layouter.layout_par(vt, elem, styles)?; } else if child.is::() @@ -63,21 +68,25 @@ impl Layout for FlowElem { } else if child.is::() { if !layouter.regions.backlog.is_empty() || layouter.regions.last.is_some() { - layouter.finish_region(); + layouter.finish_region()?; } } else { bail!(child.span(), "unexpected flow child"); } } - Ok(layouter.finish()) + layouter.finish() } } /// Performs flow layout. struct FlowLayouter<'a> { + /// Whether this is the root flow. + root: bool, /// The regions to layout children into. regions: Regions<'a>, + /// The shared styles. + styles: StyleChain<'a>, /// Whether the flow should expand to fill the region. expand: Axes, /// The initial size of `regions.size` that was available before we started @@ -85,12 +94,23 @@ struct FlowLayouter<'a> { initial: Size, /// Whether the last block was a paragraph. last_was_par: bool, - /// Spacing and layouted blocks. + /// Spacing and layouted blocks for the current region. items: Vec, + /// Whether we have any footnotes in the current region. + has_footnotes: bool, + /// Footnote configuration. + footnote_config: FootnoteConfig, /// Finished frames for previous regions. finished: Vec, } +/// Cached footnote configuration. +struct FootnoteConfig { + separator: Content, + clearance: Abs, + gap: Abs, +} + /// A prepared item in a flow layout. #[derive(Debug)] enum FlowItem { @@ -102,36 +122,55 @@ enum FlowItem { Frame(Frame, Axes, bool), /// An absolutely placed frame. Placed(Frame), + /// A footnote frame (can also be the separator). + Footnote(Frame), } impl<'a> FlowLayouter<'a> { /// Create a new flow layouter. - fn new(mut regions: Regions<'a>) -> Self { + fn new(mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self { let expand = regions.expand; - // Disable vertical expansion for children. + // Disable vertical expansion & root for children. regions.expand.y = false; + let root = mem::replace(&mut regions.root, false); Self { + root, regions, + styles, expand, initial: regions.size, last_was_par: false, items: vec![], + has_footnotes: false, + footnote_config: FootnoteConfig { + separator: FootnoteEntry::separator_in(styles), + clearance: FootnoteEntry::clearance_in(styles), + gap: FootnoteEntry::gap_in(styles), + }, finished: vec![], } } /// Layout vertical spacing. #[tracing::instrument(name = "FlowLayouter::layout_spacing", skip_all)] - fn layout_spacing(&mut self, v: &VElem, styles: StyleChain) { - self.layout_item(match v.amount() { - Spacing::Rel(rel) => FlowItem::Absolute( - rel.resolve(styles).relative_to(self.initial.y), - v.weakness(styles) > 0, - ), - Spacing::Fr(fr) => FlowItem::Fractional(fr), - }); + fn layout_spacing( + &mut self, + vt: &mut Vt, + v: &VElem, + styles: StyleChain, + ) -> SourceResult<()> { + self.layout_item( + vt, + match v.amount() { + Spacing::Rel(rel) => FlowItem::Absolute( + rel.resolve(styles).relative_to(self.initial.y), + v.weakness(styles) > 0, + ), + Spacing::Fr(fr) => FlowItem::Fractional(fr), + }, + ) } /// Layout a paragraph. @@ -145,7 +184,7 @@ impl<'a> FlowLayouter<'a> { let aligns = AlignElem::alignment_in(styles).resolve(styles); let leading = ParElem::leading_in(styles); let consecutive = self.last_was_par; - let frames = par + let lines = par .layout(vt, styles, consecutive, self.regions.base(), self.regions.expand.x)? .into_frames(); @@ -158,26 +197,25 @@ impl<'a> FlowLayouter<'a> { } } - if let [first, ..] = frames.as_slice() { + if let Some(first) = lines.first() { if !self.regions.size.y.fits(first.height()) && !self.regions.in_last() { let carry: Vec<_> = self.items.drain(sticky..).collect(); - self.finish_region(); + self.finish_region()?; for item in carry { - self.layout_item(item); + self.layout_item(vt, item)?; } } } - for (i, frame) in frames.into_iter().enumerate() { + for (i, frame) in lines.into_iter().enumerate() { if i > 0 { - self.layout_item(FlowItem::Absolute(leading, true)); + self.layout_item(vt, FlowItem::Absolute(leading, true))?; } - self.layout_item(FlowItem::Frame(frame, aligns, false)); + self.layout_item(vt, FlowItem::Frame(frame, aligns, false))?; } self.last_was_par = true; - Ok(()) } @@ -193,7 +231,7 @@ impl<'a> FlowLayouter<'a> { let sticky = BlockElem::sticky_in(styles); let pod = Regions::one(self.regions.base(), Axes::splat(false)); let frame = content.layout(vt, styles, pod)?.into_frame(); - self.layout_item(FlowItem::Frame(frame, aligns, sticky)); + self.layout_item(vt, FlowItem::Frame(frame, aligns, sticky))?; self.last_was_par = false; Ok(()) } @@ -210,7 +248,7 @@ impl<'a> FlowLayouter<'a> { if let Some(placed) = block.to::() { if placed.out_of_flow(styles) { let frame = block.layout(vt, styles, self.regions)?.into_frame(); - self.layout_item(FlowItem::Placed(frame)); + self.layout_item(vt, FlowItem::Placed(frame))?; return Ok(()); } } @@ -225,16 +263,22 @@ impl<'a> FlowLayouter<'a> { } .resolve(styles); + let is_columns = block.is::(); + // Layout the block itself. let sticky = BlockElem::sticky_in(styles); let fragment = block.layout(vt, styles, self.regions)?; + self.regions.root = self.root && is_columns; + for (i, frame) in fragment.into_iter().enumerate() { if i > 0 { - self.finish_region(); + self.finish_region()?; } - self.layout_item(FlowItem::Frame(frame, aligns, sticky)); + + self.layout_item(vt, FlowItem::Frame(frame, aligns, sticky))?; } + self.regions.root = false; self.last_was_par = false; Ok(()) @@ -242,26 +286,38 @@ impl<'a> FlowLayouter<'a> { /// Layout a finished frame. #[tracing::instrument(name = "FlowLayouter::layout_item", skip_all)] - fn layout_item(&mut self, item: FlowItem) { + fn layout_item(&mut self, vt: &mut Vt, item: FlowItem) -> SourceResult<()> { match item { - FlowItem::Absolute(v, _) => self.regions.size.y -= v, + FlowItem::Absolute(v, weak) => { + if weak + && !self.items.iter().any(|item| matches!(item, FlowItem::Frame(..))) + { + return Ok(()); + } + self.regions.size.y -= v + } FlowItem::Fractional(_) => {} FlowItem::Frame(ref frame, ..) => { let size = frame.size(); if !self.regions.size.y.fits(size.y) && !self.regions.in_last() { - self.finish_region(); + self.finish_region()?; } self.regions.size.y -= size.y; + if self.root { + return self.handle_footnotes(vt, item, size.y); + } } FlowItem::Placed(_) => {} + FlowItem::Footnote(_) => {} } self.items.push(item); + Ok(()) } /// Finish the frame for one region. - fn finish_region(&mut self) { + fn finish_region(&mut self) -> SourceResult<()> { // Trim weak spacing. while self .items @@ -274,6 +330,8 @@ impl<'a> FlowLayouter<'a> { // Determine the used size. let mut fr = Fr::zero(); let mut used = Size::zero(); + let mut footnote_height = Abs::zero(); + let mut first_footnote = true; for item in &self.items { match item { FlowItem::Absolute(v, _) => used.y += *v, @@ -284,19 +342,31 @@ impl<'a> FlowLayouter<'a> { used.x.set_max(size.x); } FlowItem::Placed(_) => {} + FlowItem::Footnote(frame) => { + let size = frame.size(); + footnote_height += size.y; + if !first_footnote { + footnote_height += self.footnote_config.gap; + } + first_footnote = false; + used.x.set_max(size.x); + } } } + used.y += footnote_height; // Determine the size of the flow in this region depending on whether - // the region expands. Also account for fractional spacing. + // the region expands. Also account for fractional spacing and + // footnotes. let mut size = self.expand.select(self.initial, used).min(self.initial); - if fr.get() > 0.0 && self.initial.y.is_finite() { + if (fr.get() > 0.0 || self.has_footnotes) && self.initial.y.is_finite() { size.y = self.initial.y; } let mut output = Frame::new(size); let mut offset = Abs::zero(); let mut ruler = Align::Top; + let mut footnote_offset = size.y - footnote_height; // Place all frames. for item in self.items.drain(..) { @@ -316,6 +386,11 @@ impl<'a> FlowLayouter<'a> { offset += frame.height(); output.push_frame(pos, frame); } + FlowItem::Footnote(frame) => { + let pos = Point::with_y(footnote_offset); + footnote_offset += frame.height() + self.footnote_config.gap; + output.push_frame(pos, frame); + } FlowItem::Placed(frame) => { output.push_frame(Point::zero(), frame); } @@ -326,17 +401,137 @@ impl<'a> FlowLayouter<'a> { self.finished.push(output); self.regions.next(); self.initial = self.regions.size; + self.has_footnotes = false; + Ok(()) } /// Finish layouting and return the resulting fragment. - fn finish(mut self) -> Fragment { + fn finish(mut self) -> SourceResult { if self.expand.y { while !self.regions.backlog.is_empty() { - self.finish_region(); + self.finish_region()?; } } - self.finish_region(); - Fragment::frames(self.finished) + self.finish_region()?; + Ok(Fragment::frames(self.finished)) + } +} + +impl FlowLayouter<'_> { + /// Processes all footnotes in the frame. + #[tracing::instrument(skip_all)] + fn handle_footnotes( + &mut self, + vt: &mut Vt, + item: FlowItem, + height: Abs, + ) -> SourceResult<()> { + // Find footnotes in the frame. + let mut notes = Vec::new(); + if let FlowItem::Frame(frame, ..) = &item { + find_footnotes(&mut notes, frame); + } + + self.items.push(item); + + // No new footnotes. + if notes.is_empty() { + return Ok(()); + } + + // The currently handled footnote. + let mut k = 0; + + // Whether we can still skip one region to ensure that the footnote + // and its entry are on the same page. + let mut can_skip = true; + + // Process footnotes. + 'outer: while k < notes.len() { + let had_footnotes = self.has_footnotes; + if !self.has_footnotes { + self.layout_footnote_separator(vt)?; + } + + self.regions.size.y -= self.footnote_config.gap; + let frames = FootnoteEntry::new(notes[k].clone()) + .pack() + .layout(vt, self.styles, self.regions.with_root(false))? + .into_frames(); + + // If the entries didn't fit, undo the separator layout, move the + // item into the next region (to keep footnote and entry together) + // and try again. + if can_skip && frames.first().map_or(false, Frame::is_empty) { + // Remove separator + if !had_footnotes { + self.items.pop(); + } + let item = self.items.pop(); + self.finish_region()?; + self.items.extend(item); + self.regions.size.y -= height; + can_skip = false; + continue 'outer; + } + + let prev = notes.len(); + for (i, frame) in frames.into_iter().enumerate() { + find_footnotes(&mut notes, &frame); + if i > 0 { + self.finish_region()?; + self.layout_footnote_separator(vt)?; + self.regions.size.y -= self.footnote_config.gap; + } + self.regions.size.y -= frame.height(); + self.items.push(FlowItem::Footnote(frame)); + } + + k += 1; + + // Process the nested notes before dealing with further notes. + let nested = notes.len() - prev; + if nested > 0 { + notes[k..].rotate_right(nested); + } + } + + Ok(()) + } + + /// Layout and save the footnote separator, typically a line. + #[tracing::instrument(skip_all)] + fn layout_footnote_separator(&mut self, vt: &mut Vt) -> SourceResult<()> { + let expand = Axes::new(self.regions.expand.x, false); + let pod = Regions::one(self.regions.base(), expand); + let separator = &self.footnote_config.separator; + + let mut frame = separator.layout(vt, self.styles, pod)?.into_frame(); + frame.size_mut().y += self.footnote_config.clearance; + frame.translate(Point::with_y(self.footnote_config.clearance)); + + self.has_footnotes = true; + self.regions.size.y -= frame.height(); + self.items.push(FlowItem::Footnote(frame)); + + Ok(()) + } +} + +/// Finds all footnotes in the frame. +#[tracing::instrument(skip_all)] +fn find_footnotes(notes: &mut Vec, frame: &Frame) { + for (_, item) in frame.items() { + match item { + FrameItem::Group(group) => find_footnotes(notes, &group.frame), + FrameItem::Meta(Meta::Elem(content), _) + if !notes.iter().any(|note| note.0.location() == content.location()) => + { + let Some(footnote) = content.to::() else { continue }; + notes.push(footnote.clone()); + } + _ => {} + } } } diff --git a/library/src/layout/page.rs b/library/src/layout/page.rs index cfddc446f..6510bd58c 100644 --- a/library/src/layout/page.rs +++ b/library/src/layout/page.rs @@ -315,9 +315,11 @@ impl PageElem { child = ColumnsElem::new(child).with_count(columns).pack(); } - // Layout the child. let area = size - margin.sum_by_axis(); - let regions = Regions::repeat(area, area.map(Abs::is_finite)); + let mut regions = Regions::repeat(area, area.map(Abs::is_finite)); + regions.root = true; + + // Layout the child. let mut fragment = child.layout(vt, styles, regions)?; let fill = self.fill(styles); diff --git a/library/src/layout/par.rs b/library/src/layout/par.rs index f0dcbb11f..e056b1c91 100644 --- a/library/src/layout/par.rs +++ b/library/src/layout/par.rs @@ -569,6 +569,10 @@ fn collect<'a>( } Segment::Text(full.len() - prev) } else if let Some(elem) = child.to::() { + if elem.amount().is_zero() { + continue; + } + full.push(SPACING_REPLACE); Segment::Spacing(elem.amount()) } else if let Some(elem) = child.to::() { diff --git a/library/src/layout/regions.rs b/library/src/layout/regions.rs index 5a4db1782..6dd549b07 100644 --- a/library/src/layout/regions.rs +++ b/library/src/layout/regions.rs @@ -17,6 +17,11 @@ pub struct Regions<'a> { /// Whether elements should expand to fill the regions instead of shrinking /// to fit the content. pub expand: Axes, + /// Whether these are the root regions or direct descendants. + /// + /// True for the padded page regions and columns directly in the page, + /// false otherwise. + pub root: bool, } impl Regions<'_> { @@ -28,6 +33,7 @@ impl Regions<'_> { backlog: &[], last: None, expand, + root: false, } } @@ -39,6 +45,7 @@ impl Regions<'_> { backlog: &[], last: Some(size.y), expand, + root: false, } } @@ -67,6 +74,7 @@ impl Regions<'_> { backlog, last: self.last.map(|y| f(Size::new(x, y)).y), expand: self.expand, + root: false, } } @@ -82,6 +90,11 @@ impl Regions<'_> { self.backlog.is_empty() && self.last.map_or(true, |height| self.size.y == height) } + /// The same regions, but with different `root` configuration. + pub fn with_root(self, root: bool) -> Self { + Self { root, ..self } + } + /// Advance to the next region if there is any. pub fn next(&mut self) { if let Some(height) = self diff --git a/library/src/layout/spacing.rs b/library/src/layout/spacing.rs index 42d2b4431..588f9f290 100644 --- a/library/src/layout/spacing.rs +++ b/library/src/layout/spacing.rs @@ -177,6 +177,14 @@ impl Spacing { pub fn is_fractional(self) -> bool { matches!(self, Self::Fr(_)) } + + /// Whether the spacing is actually no spacing. + pub fn is_zero(&self) -> bool { + match self { + Self::Rel(rel) => rel.is_zero(), + Self::Fr(fr) => fr.is_zero(), + } + } } impl From for Spacing { diff --git a/library/src/meta/bibliography.rs b/library/src/meta/bibliography.rs index cdb6a5d69..2bf0e6f4f 100644 --- a/library/src/meta/bibliography.rs +++ b/library/src/meta/bibliography.rs @@ -11,7 +11,7 @@ use typst::util::option_eq; use super::{LinkElem, LocalName, RefElem}; use crate::layout::{BlockElem, GridElem, ParElem, Sizing, TrackSizings, VElem}; -use crate::meta::HeadingElem; +use crate::meta::{FootnoteElem, HeadingElem}; use crate::prelude::*; use crate::text::TextElem; @@ -243,6 +243,9 @@ pub enum BibliographyStyle { /// The Chicago Author Date style. Based on the 17th edition of the Chicago /// Manual of Style, Chapter 15. ChicagoAuthorDate, + /// The Chicago Notes style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 14. + ChicagoNotes, /// The style of the Institute of Electrical and Electronics Engineers. /// Based on the 2018 IEEE Reference Guide. Ieee, @@ -257,6 +260,7 @@ impl BibliographyStyle { match self { Self::Apa => CitationStyle::ChicagoAuthorDate, Self::ChicagoAuthorDate => CitationStyle::ChicagoAuthorDate, + Self::ChicagoNotes => CitationStyle::ChicagoNotes, Self::Ieee => CitationStyle::Numerical, Self::Mla => CitationStyle::ChicagoAuthorDate, } @@ -385,7 +389,10 @@ pub enum CitationStyle { /// The Chicago Author Date style. Based on the 17th edition of the Chicago /// Manual of Style, Chapter 15. ChicagoAuthorDate, - /// The Chicago-like author-title format. Results could look like this: + /// The Chicago Notes style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 14. + ChicagoNotes, + /// A Chicago-like author-title format. Results could look like this: /// Prokopov, “It Is Fast or It Is Wrong”. ChicagoAuthorTitle, } @@ -487,6 +494,7 @@ fn create( CitationStyle::ChicagoAuthorDate => { Box::new(style::ChicagoAuthorDate::new()) } + CitationStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()), CitationStyle::ChicagoAuthorTitle => { Box::new(style::AuthorTitle::new()) } @@ -537,6 +545,10 @@ fn create( }; } + if style == CitationStyle::ChicagoNotes { + content = FootnoteElem::new(content).pack(); + } + (location, Some(content)) }) .collect(); @@ -544,6 +556,7 @@ fn create( let bibliography_style: Box = match style { BibliographyStyle::Apa => Box::new(style::Apa::new()), BibliographyStyle::ChicagoAuthorDate => Box::new(style::ChicagoAuthorDate::new()), + BibliographyStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()), BibliographyStyle::Ieee => Box::new(style::Ieee::new()), BibliographyStyle::Mla => Box::new(style::Mla::new()), }; @@ -552,24 +565,18 @@ fn create( .bibliography(&*bibliography_style, None) .into_iter() .map(|reference| { - // Make link from citation to here work. - let backlink = { - let mut content = Content::empty(); - content.set_location(ref_location(reference.entry)); - MetaElem::set_data(vec![Meta::Elem(content)]) - }; - + let backlink = ref_location(reference.entry); let prefix = reference.prefix.map(|prefix| { // Format and link to first citation. let bracketed = prefix.with_default_brackets(&*citation_style); format_display_string(&bracketed, None, span) .linked(Destination::Location(ids[reference.entry.key()])) - .styled(backlink.clone()) + .backlinked(backlink) }); let mut reference = format_display_string(&reference.display, None, span); if prefix.is_none() { - reference = reference.styled(backlink); + reference = reference.backlinked(backlink); } (prefix, reference) diff --git a/library/src/meta/footnote.rs b/library/src/meta/footnote.rs new file mode 100644 index 000000000..22de91c3f --- /dev/null +++ b/library/src/meta/footnote.rs @@ -0,0 +1,217 @@ +use std::str::FromStr; + +use super::{Counter, Numbering, NumberingPattern}; +use crate::layout::{HElem, ParElem}; +use crate::prelude::*; +use crate::text::{SuperElem, TextElem, TextSize}; +use crate::visualize::LineElem; + +/// A footnote. +/// +/// Include additional remarks and references on the same page with footnotes. A +/// footnote will insert a superscript number that links to the note at the +/// bottom of the page. Notes are numbered sequentially throughout your document +/// and can break across multiple pages. +/// +/// To customize the appearance of the entry in the footnote listing, see +/// [`footnote.entry`]($func/footnote.entry). The footnote itself is realized as +/// a normal superscript, so you can use a set rule on the +/// [`super`]($func/super) function to customize it. +/// +/// ## Example { #example } +/// ```example +/// Check the docs for more details. +/// #footnote[https://typst.app/docs] +/// ``` +/// +/// The footnote automatically attaches itself to the preceding word, even if +/// there is a space before it in the markup. To force space, you can use the +/// string `[#" "]` or explicit [horizontal spacing]($func/h). +/// +/// Display: Footnote +/// Category: meta +#[element(Locatable, Synthesize, Show)] +#[scope( + scope.define("entry", FootnoteEntry::func()); + scope +)] +pub struct FootnoteElem { + /// How to number footnotes. + /// + /// By default, the footnote numbering continues throughout your document. + /// If you prefer per-page footnote numbering, you can reset the footnote + /// [counter]($func/counter) in the page [header]($func/page.header). In the + /// future, there might be a simpler way to achieve this. + /// + /// ```example + /// #set footnote(numbering: "*") + /// + /// Footnotes: + /// #footnote[Star], + /// #footnote[Dagger] + /// ``` + #[default(Numbering::Pattern(NumberingPattern::from_str("1").unwrap()))] + pub numbering: Numbering, + + /// The content to put into the footnote. + #[required] + pub body: Content, +} + +impl Synthesize for FootnoteElem { + fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + self.push_numbering(self.numbering(styles)); + Ok(()) + } +} + +impl Show for FootnoteElem { + #[tracing::instrument(name = "FootnoteElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult { + let loc = self.0.location().unwrap(); + let numbering = self.numbering(styles); + let counter = Counter::of(Self::func()); + let num = counter.at(vt, loc)?.display(vt, &numbering)?; + let sup = SuperElem::new(num).pack(); + let hole = HElem::new(Abs::zero().into()).with_weak(true).pack(); + let loc = self.0.location().unwrap().variant(1); + Ok(hole + sup.linked(Destination::Location(loc))) + } +} + +/// An entry in a footnote list. +/// +/// This function is not intended to be called directly. Instead, it is used +/// in set and show rules to customize footnote listings. +/// +/// ## Example { #example } +/// ```example +/// #show footnote.entry: set text(red) +/// +/// My footnote listing +/// #footnote[It's down here] +/// has red text! +/// ``` +/// +/// Display: Footnote Entry +/// Category: meta +#[element(Show, Finalize)] +pub struct FootnoteEntry { + /// The footnote for this entry. It's location can be used to determine + /// the footnote counter state. + /// + /// ```example + /// #show footnote.entry: it => { + /// let loc = it.note.location() + /// numbering( + /// "1: ", + /// ..counter(footnote).at(loc), + /// ) + /// it.note.body + /// } + /// + /// Customized #footnote[Hello] + /// listing #footnote[World! 🌏] + /// ``` + #[required] + pub note: FootnoteElem, + + /// The separator between the document body and the footnote listing. + /// + /// ```example + /// #set footnote.entry( + /// separator: repeat[.] + /// ) + /// + /// Testing a different separator. + /// #footnote[ + /// Unconventional, but maybe + /// not that bad? + /// ] + /// ``` + #[default( + LineElem::new() + .with_length(Ratio::new(0.3).into()) + .with_stroke(PartialStroke { + thickness: Smart::Custom(Abs::pt(0.5).into()), + ..Default::default() + }) + .pack() + )] + pub separator: Content, + + /// The amount of clearance between the document body and the separator. + /// + /// ```example + /// #set footnote.entry(clearance: 3em) + /// + /// Footnotes also need ... + /// #footnote[ + /// ... some space to breathe. + /// ] + /// ``` + #[default(Em::new(1.0).into())] + #[resolve] + pub clearance: Length, + + /// The gap between footnote entries. + /// + /// ```example + /// #set footnote.entry(gap: 0.8em) + /// + /// Footnotes: + /// #footnote[Spaced], + /// #footnote[Apart] + /// ``` + #[default(Em::new(0.5).into())] + #[resolve] + pub gap: Length, + + /// The indent of each footnote entry. + /// + /// ```example + /// #set footnote.entry(indent: 0em) + /// + /// Footnotes: + /// #footnote[No], + /// #footnote[Indent] + /// ``` + #[default(Em::new(1.0).into())] + pub indent: Length, +} + +impl Show for FootnoteEntry { + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult { + let note = self.note(); + let number_gap = Em::new(0.05); + let numbering = note.numbering(StyleChain::default()); + let counter = Counter::of(FootnoteElem::func()); + let loc = note.0.location().unwrap(); + let num = counter.at(vt, loc)?.display(vt, &numbering)?; + let sup = SuperElem::new(num) + .pack() + .linked(Destination::Location(loc)) + .backlinked(loc.variant(1)); + Ok(Content::sequence([ + HElem::new(self.indent(styles).into()).pack(), + sup, + HElem::new(number_gap.into()).with_weak(true).pack(), + note.body(), + ])) + } +} + +impl Finalize for FootnoteEntry { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + let text_size = Em::new(0.85); + let leading = Em::new(0.5); + realized + .styled(ParElem::set_leading(leading.into())) + .styled(TextElem::set_size(TextSize(text_size.into()))) + } +} + +cast_from_value! { + FootnoteElem, + v: Content => v.to::().cloned().unwrap_or_else(|| Self::new(v.clone())), +} diff --git a/library/src/meta/mod.rs b/library/src/meta/mod.rs index 61028bf51..0cbbafffb 100644 --- a/library/src/meta/mod.rs +++ b/library/src/meta/mod.rs @@ -5,6 +5,7 @@ mod context; mod counter; mod document; mod figure; +mod footnote; mod heading; mod link; mod numbering; @@ -18,6 +19,7 @@ pub use self::context::*; pub use self::counter::*; pub use self::document::*; pub use self::figure::*; +pub use self::footnote::*; pub use self::heading::*; pub use self::link::*; pub use self::numbering::*; @@ -36,6 +38,7 @@ pub(super) fn define(global: &mut Scope) { global.define("outline", OutlineElem::func()); global.define("heading", HeadingElem::func()); global.define("figure", FigureElem::func()); + global.define("footnote", FootnoteElem::func()); global.define("cite", CiteElem::func()); global.define("bibliography", BibliographyElem::func()); global.define("locate", locate); diff --git a/library/src/shared/ext.rs b/library/src/shared/ext.rs index 72a82749b..d7c80a308 100644 --- a/library/src/shared/ext.rs +++ b/library/src/shared/ext.rs @@ -18,6 +18,11 @@ pub trait ContentExt { /// Link the content somewhere. fn linked(self, dest: Destination) -> Self; + /// Make the content linkable by `.linked(Destination::Location(loc))`. + /// + /// Should be used in combination with [`Location::variant`]. + fn backlinked(self, loc: Location) -> Self; + /// Set alignments for this content. fn aligned(self, aligns: Axes>) -> Self; @@ -45,6 +50,12 @@ impl ContentExt for Content { self.styled(MetaElem::set_data(vec![Meta::Link(dest)])) } + fn backlinked(self, loc: Location) -> Self { + let mut backlink = Content::empty(); + backlink.set_location(loc); + self.styled(MetaElem::set_data(vec![Meta::Elem(backlink)])) + } + fn aligned(self, aligns: Axes>) -> Self { self.styled(AlignElem::set_alignment(aligns)) } diff --git a/src/doc.rs b/src/doc.rs index c21b6546e..7b7700964 100644 --- a/src/doc.rs +++ b/src/doc.rs @@ -608,7 +608,7 @@ cast_to_value! { } /// Meta information that isn't visible or renderable. -#[derive(Debug, Clone, PartialEq, Hash)] +#[derive(Clone, PartialEq, Hash)] pub enum Meta { /// An internal or external link to a destination. Link(Destination), @@ -623,6 +623,17 @@ pub enum Meta { Hide, } +impl Debug for Meta { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Link(dest) => write!(f, "Link({dest:?})"), + Self::Elem(content) => write!(f, "Elem({:?})", content.func()), + Self::PageNumbering(value) => write!(f, "PageNumbering({value:?})"), + Self::Hide => f.pad("Hide"), + } + } +} + cast_from_value! { Meta: "meta", } diff --git a/src/geom/length.rs b/src/geom/length.rs index f70ea2638..9d6552dac 100644 --- a/src/geom/length.rs +++ b/src/geom/length.rs @@ -80,12 +80,6 @@ impl From for Length { } } -impl From for Rel { - fn from(abs: Abs) -> Self { - Rel::from(Length::from(abs)) - } -} - impl Neg for Length { type Output = Self; diff --git a/src/geom/rel.rs b/src/geom/rel.rs index aaa784f9c..cf1e73efd 100644 --- a/src/geom/rel.rs +++ b/src/geom/rel.rs @@ -73,6 +73,18 @@ impl Debug for Rel { } } +impl From for Rel { + fn from(abs: Abs) -> Self { + Rel::from(Length::from(abs)) + } +} + +impl From for Rel { + fn from(em: Em) -> Self { + Rel::from(Length::from(em)) + } +} + impl From for Rel { fn from(abs: T) -> Self { Self { rel: Ratio::zero(), abs } diff --git a/tests/ref/meta/cite-footnote.png b/tests/ref/meta/cite-footnote.png new file mode 100644 index 000000000..067d2c2ea Binary files /dev/null and b/tests/ref/meta/cite-footnote.png differ diff --git a/tests/ref/meta/footnote-break.png b/tests/ref/meta/footnote-break.png new file mode 100644 index 000000000..625305c88 Binary files /dev/null and b/tests/ref/meta/footnote-break.png differ diff --git a/tests/ref/meta/footnote-container.png b/tests/ref/meta/footnote-container.png new file mode 100644 index 000000000..9327e7eec Binary files /dev/null and b/tests/ref/meta/footnote-container.png differ diff --git a/tests/ref/meta/footnote-invariant.png b/tests/ref/meta/footnote-invariant.png new file mode 100644 index 000000000..66b411825 Binary files /dev/null and b/tests/ref/meta/footnote-invariant.png differ diff --git a/tests/ref/meta/footnote.png b/tests/ref/meta/footnote.png new file mode 100644 index 000000000..4c67bbd78 Binary files /dev/null and b/tests/ref/meta/footnote.png differ diff --git a/tests/typ/meta/cite-footnote.typ b/tests/typ/meta/cite-footnote.typ new file mode 100644 index 000000000..03984752e --- /dev/null +++ b/tests/typ/meta/cite-footnote.typ @@ -0,0 +1,5 @@ +Hello @netwok +And again: @netwok + +#pagebreak() +#bibliography("/works.bib", style: "chicago-notes") diff --git a/tests/typ/meta/footnote-break.typ b/tests/typ/meta/footnote-break.typ new file mode 100644 index 000000000..9e213aeb7 --- /dev/null +++ b/tests/typ/meta/footnote-break.typ @@ -0,0 +1,16 @@ +// Test footnotes that break across pages. + +--- +#set page(height: 200pt) + +#lorem(5) +#footnote[ // 1 + A simple footnote. + #footnote[Well, not that simple ...] // 2 +] +#lorem(15) +#footnote[Another footnote: #lorem(30)] // 3 +#lorem(15) +#footnote[My fourth footnote: #lorem(50)] // 4 +#lorem(15) +#footnote[And a final footnote.] // 5 diff --git a/tests/typ/meta/footnote-container.typ b/tests/typ/meta/footnote-container.typ new file mode 100644 index 000000000..fa2461622 --- /dev/null +++ b/tests/typ/meta/footnote-container.typ @@ -0,0 +1,32 @@ +// Test footnotes in containers. + +--- +// Test footnote in caption. +Read the docs #footnote[https://typst.app/docs]! +#figure( + image("/graph.png", width: 70%), + caption: [ + A graph #footnote[A _graph_ is a structure with nodes and edges.] + ] +) +More #footnote[just for ...] footnotes #footnote[... testing. :)] + +--- +// Test duplicate footnotes. +#let lang = footnote[Languages.] +#let nums = footnote[Numbers.] + +/ "Hello": A word #lang +/ "123": A number #nums + +- "Hello" #lang +- "123" #nums + ++ "Hello" #lang ++ "123" #nums + +#table( + columns: 2, + [Hello], [A word #lang], + [123], [A number #nums], +) diff --git a/tests/typ/meta/footnote-invariant.typ b/tests/typ/meta/footnote-invariant.typ new file mode 100644 index 000000000..e4d6ded18 --- /dev/null +++ b/tests/typ/meta/footnote-invariant.typ @@ -0,0 +1,9 @@ +// Ensure that a footnote and the first line of its entry +// always end up on the same page. + +--- +#set page(height: 120pt) + +#lorem(13) + +There #footnote(lorem(20)) diff --git a/tests/typ/meta/footnote.typ b/tests/typ/meta/footnote.typ new file mode 100644 index 000000000..8f56fea2c --- /dev/null +++ b/tests/typ/meta/footnote.typ @@ -0,0 +1,34 @@ +// Test footnotes. + +--- +#footnote[Hi] + +--- +// Test space collapsing before footnote. +A#footnote[A] \ +A #footnote[A] + +--- +// Test nested footnotes. +First \ +Second #footnote[A, #footnote[B, #footnote[C]]] \ +Third #footnote[D, #footnote[E]] \ +Fourth + +--- +// Currently, numbers a bit out of order if a nested footnote ends up in the +// same frame as another one. :( +#footnote[A, #footnote[B]], #footnote[C] + +--- +// Test customization. +#show footnote: set text(red) +#show footnote.entry: set text(8pt, style: "italic") +#set footnote.entry( + indent: 0pt, + gap: 0.6em, + clearance: 0.3em, + separator: repeat[.], +) + +Beautiful footnotes. #footnote[Wonderful, aren't they?]