diff --git a/crates/typst-library/src/layout/flow.rs b/crates/typst-library/src/layout/flow.rs index accd092a6..ce276f6d3 100644 --- a/crates/typst-library/src/layout/flow.rs +++ b/crates/typst-library/src/layout/flow.rs @@ -71,14 +71,14 @@ 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(vt)?; } } else { bail!(child.span(), "unexpected flow child"); } } - layouter.finish() + layouter.finish(vt) } } @@ -99,6 +99,8 @@ struct FlowLayouter<'a> { last_was_par: bool, /// Spacing and layouted blocks for the current region. items: Vec, + /// A queue of floats. + pending_floats: Vec, /// Whether we have any footnotes in the current region. has_footnotes: bool, /// Footnote configuration. @@ -126,7 +128,7 @@ enum FlowItem { /// (to keep it together with its footnotes). Frame { frame: Frame, aligns: Axes, sticky: bool, movable: bool }, /// An absolutely placed frame. - Placed(Frame), + Placed { frame: Frame, y_align: Smart>, float: bool, clearance: Abs }, /// A footnote frame (can also be the separator). Footnote(Frame), } @@ -136,7 +138,7 @@ impl FlowItem { fn height(&self) -> Abs { match self { Self::Absolute(v, _) => *v, - Self::Fractional(_) | Self::Placed(_) => Abs::zero(), + Self::Fractional(_) | Self::Placed { .. } => Abs::zero(), Self::Frame { frame, .. } | Self::Footnote(frame) => frame.height(), } } @@ -159,6 +161,7 @@ impl<'a> FlowLayouter<'a> { initial: regions.size, last_was_par: false, items: vec![], + pending_floats: vec![], has_footnotes: false, footnote_config: FootnoteConfig { separator: FootnoteEntry::separator_in(styles), @@ -216,7 +219,7 @@ impl<'a> FlowLayouter<'a> { 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(vt)?; for item in carry { self.layout_item(vt, item)?; } @@ -262,17 +265,28 @@ impl<'a> FlowLayouter<'a> { block: &Content, styles: StyleChain, ) -> SourceResult<()> { - // Placed elements that are out of flow produce placed items which - // aren't aligned later. + // Handle placed elements. 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(vt, FlowItem::Placed(frame))?; - return Ok(()); - } - } else if self.regions.is_full() { + let float = placed.float(styles); + let clearance = placed.clearance(styles); + let y_align = placed.alignment(styles).map(|align| align.y.resolve(styles)); + let frame = placed.layout_inner(vt, styles, self.regions)?.into_frame(); + let item = FlowItem::Placed { frame, y_align, float, clearance }; + return self.layout_item(vt, item); + } + + // Temporarily delegerate rootness to the columns. + let is_root = self.root; + if is_root && block.is::() { + self.root = false; + self.regions.root = true; + } + + let mut notes = Vec::new(); + + if self.regions.is_full() { // Skip directly if region is already full. - self.finish_region()?; + self.finish_region(vt)?; } // How to align the block. @@ -285,17 +299,9 @@ impl<'a> FlowLayouter<'a> { } .resolve(styles); - // Temporarily delegerate rootness to the columns. - let is_root = self.root; - if is_root && block.is::() { - self.root = false; - self.regions.root = true; - } - // Layout the block itself. let sticky = BlockElem::sticky_in(styles); let fragment = block.layout(vt, styles, self.regions)?; - let mut notes = Vec::new(); for (i, frame) in fragment.into_iter().enumerate() { // Find footnotes in the frame. @@ -304,19 +310,14 @@ impl<'a> FlowLayouter<'a> { } if i > 0 { - self.finish_region()?; + self.finish_region(vt)?; } - self.layout_item( - vt, - FlowItem::Frame { frame, aligns, sticky, movable: false }, - )?; + let item = FlowItem::Frame { frame, aligns, sticky, movable: false }; + self.layout_item(vt, item)?; } - if self.root && !self.handle_footnotes(vt, &mut notes, false, false)? { - self.finish_region()?; - self.handle_footnotes(vt, &mut notes, false, true)?; - } + self.try_handle_footnotes(vt, notes)?; self.root = is_root; self.regions.root = false; @@ -327,7 +328,7 @@ impl<'a> FlowLayouter<'a> { /// Layout a finished frame. #[tracing::instrument(name = "FlowLayouter::layout_item", skip_all)] - fn layout_item(&mut self, vt: &mut Vt, item: FlowItem) -> SourceResult<()> { + fn layout_item(&mut self, vt: &mut Vt, mut item: FlowItem) -> SourceResult<()> { match item { FlowItem::Absolute(v, weak) => { if weak @@ -342,27 +343,68 @@ impl<'a> FlowLayouter<'a> { } FlowItem::Fractional(_) => {} FlowItem::Frame { ref frame, movable, .. } => { - let size = frame.size(); - if !self.regions.size.y.fits(size.y) && !self.regions.in_last() { - self.finish_region()?; + let height = frame.height(); + if !self.regions.size.y.fits(height) && !self.regions.in_last() { + self.finish_region(vt)?; } - self.regions.size.y -= size.y; + self.regions.size.y -= height; if self.root && movable { let mut notes = Vec::new(); find_footnotes(&mut notes, frame); self.items.push(item); if !self.handle_footnotes(vt, &mut notes, true, false)? { let item = self.items.pop(); - self.finish_region()?; + self.finish_region(vt)?; self.items.extend(item); - self.regions.size.y -= size.y; + self.regions.size.y -= height; self.handle_footnotes(vt, &mut notes, true, true)?; } return Ok(()); } } - FlowItem::Placed(_) => {} + FlowItem::Placed { float: false, .. } => {} + FlowItem::Placed { + ref mut frame, + ref mut y_align, + float: true, + clearance, + .. + } => { + // If the float doesn't fit, queue it for the next region. + if !self.regions.size.y.fits(frame.height() + clearance) + && !self.regions.in_last() + { + self.pending_floats.push(item); + return Ok(()); + } + + // Select the closer placement, top or bottom. + if y_align.is_auto() { + let ratio = (self.regions.size.y + - (frame.height() + clearance) / 2.0) + / self.regions.full; + let better_align = + if ratio <= 0.5 { Align::Bottom } else { Align::Top }; + *y_align = Smart::Custom(Some(better_align)); + } + + // Add some clearance so that the float doesn't touch the main + // content. + frame.size_mut().y += clearance; + if *y_align == Smart::Custom(Some(Align::Bottom)) { + frame.translate(Point::with_y(clearance)); + } + + self.regions.size.y -= frame.height(); + + // Find footnotes in the frame. + if self.root { + let mut notes = vec![]; + find_footnotes(&mut notes, frame); + self.try_handle_footnotes(vt, notes)?; + } + } FlowItem::Footnote(_) => {} } @@ -371,7 +413,7 @@ impl<'a> FlowLayouter<'a> { } /// Finish the frame for one region. - fn finish_region(&mut self) -> SourceResult<()> { + fn finish_region(&mut self, vt: &mut Vt) -> SourceResult<()> { // Trim weak spacing. while self .items @@ -385,25 +427,32 @@ impl<'a> FlowLayouter<'a> { let mut fr = Fr::zero(); let mut used = Size::zero(); let mut footnote_height = Abs::zero(); + let mut float_top_height = Abs::zero(); + let mut float_bottom_height = Abs::zero(); let mut first_footnote = true; for item in &self.items { match item { FlowItem::Absolute(v, _) => used.y += *v, FlowItem::Fractional(v) => fr += *v, FlowItem::Frame { frame, .. } => { - let size = frame.size(); - used.y += size.y; - used.x.set_max(size.x); + used.y += frame.height(); + used.x.set_max(frame.width()); } - FlowItem::Placed(_) => {} + FlowItem::Placed { float: false, .. } => {} + FlowItem::Placed { frame, float: true, y_align, .. } => match y_align { + Smart::Custom(Some(Align::Top)) => float_top_height += frame.height(), + Smart::Custom(Some(Align::Bottom)) => { + float_bottom_height += frame.height() + } + _ => {} + }, FlowItem::Footnote(frame) => { - let size = frame.size(); - footnote_height += size.y; + footnote_height += frame.height(); if !first_footnote { footnote_height += self.footnote_config.gap; } first_footnote = false; - used.x.set_max(size.x); + used.x.set_max(frame.width()); } } } @@ -418,9 +467,11 @@ impl<'a> FlowLayouter<'a> { } let mut output = Frame::new(size); - let mut offset = Abs::zero(); let mut ruler = Align::Top; - let mut footnote_offset = size.y - footnote_height; + let mut float_top_offset = Abs::zero(); + let mut offset = float_top_height; + let mut float_bottom_offset = Abs::zero(); + let mut footnote_offset = Abs::zero(); // Place all frames. for item in self.items.drain(..) { @@ -440,13 +491,37 @@ 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, y_align, float, .. } => { + let y = if float { + match y_align { + Smart::Custom(Some(Align::Top)) => { + let y = float_top_offset; + float_top_offset += frame.height(); + y + } + Smart::Custom(Some(Align::Bottom)) => { + let y = size.y - footnote_height - float_bottom_height + + float_bottom_offset; + float_bottom_offset += frame.height(); + y + } + _ => offset + ruler.position(size.y - used.y), + } + } else { + match y_align { + Smart::Custom(Some(align)) => { + align.position(size.y - frame.height()) + } + _ => offset + ruler.position(size.y - used.y), + } + }; + + output.push_frame(Point::with_y(y), frame); } - FlowItem::Placed(frame) => { - output.push_frame(Point::zero(), frame); + FlowItem::Footnote(frame) => { + let y = size.y - footnote_height + footnote_offset; + footnote_offset += frame.height() + self.footnote_config.gap; + output.push_frame(Point::with_y(y), frame); } } } @@ -456,23 +531,45 @@ impl<'a> FlowLayouter<'a> { self.regions.next(); self.initial = self.regions.size; self.has_footnotes = false; + + // Try to place floats. + for item in mem::take(&mut self.pending_floats) { + self.layout_item(vt, item)?; + } + Ok(()) } /// Finish layouting and return the resulting fragment. - fn finish(mut self) -> SourceResult { + fn finish(mut self, vt: &mut Vt) -> SourceResult { if self.expand.y { while !self.regions.backlog.is_empty() { - self.finish_region()?; + self.finish_region(vt)?; } } - self.finish_region()?; + self.finish_region(vt)?; + while !self.items.is_empty() { + self.finish_region(vt)?; + } + Ok(Fragment::frames(self.finished)) } } impl FlowLayouter<'_> { + fn try_handle_footnotes( + &mut self, + vt: &mut Vt, + mut notes: Vec, + ) -> SourceResult<()> { + if self.root && !self.handle_footnotes(vt, &mut notes, false, false)? { + self.finish_region(vt)?; + self.handle_footnotes(vt, &mut notes, false, true)?; + } + Ok(()) + } + /// Processes all footnotes in the frame. #[tracing::instrument(skip_all)] fn handle_footnotes( @@ -525,7 +622,7 @@ impl FlowLayouter<'_> { for (i, frame) in frames.into_iter().enumerate() { find_footnotes(notes, &frame); if i > 0 { - self.finish_region()?; + self.finish_region(vt)?; self.layout_footnote_separator(vt)?; self.regions.size.y -= self.footnote_config.gap; } diff --git a/crates/typst-library/src/layout/place.rs b/crates/typst-library/src/layout/place.rs index 6602948cf..6f2681c10 100644 --- a/crates/typst-library/src/layout/place.rs +++ b/crates/typst-library/src/layout/place.rs @@ -27,12 +27,43 @@ use crate::prelude::*; pub struct PlaceElem { /// Relative to which position in the parent container to place the content. /// - /// When an axis of the page is `{auto}` sized, all alignments relative to that - /// axis will be ignored, instead, the item will be placed in the origin of the - /// axis. + /// Cannot be `{auto}` if `float` is `{false}` and must be either + /// `{auto}`, `{top}`, or `{bottom}` if `float` is `{true}`. + /// + /// When an axis of the page is `{auto}` sized, all alignments relative to + /// that axis will be ignored, instead, the item will be placed in the + /// origin of the axis. #[positional] - #[default(Axes::with_x(Some(GenAlign::Start)))] - pub alignment: Axes>, + #[default(Smart::Custom(Axes::with_x(Some(GenAlign::Start))))] + pub alignment: Smart>>, + + /// Whether the placed element has floating layout. + /// + /// Floating elements are positioned at the top or bottom of the page, + /// displacing in-flow content. + /// + /// ```example + /// #set page(height: 150pt) + /// #let note(where, body) = place( + /// center + where, + /// float: true, + /// clearance: 6pt, + /// rect(body), + /// ) + /// + /// #lorem(10) + /// #note(bottom)[Bottom 1] + /// #note(bottom)[Bottom 2] + /// #lorem(40) + /// #note(top)[Top] + /// #lorem(10) + /// ``` + pub float: bool, + + /// The amount of clearance the placed element has in a floating layout. + #[default(Em::new(1.5).into())] + #[resolve] + pub clearance: Length, /// The horizontal displacement of the placed content. /// @@ -61,22 +92,7 @@ impl Layout for PlaceElem { styles: StyleChain, regions: Regions, ) -> SourceResult { - let out_of_flow = self.out_of_flow(styles); - - // The pod is the base area of the region because for absolute - // placement we don't really care about the already used area. - let pod = { - let finite = regions.base().map(Abs::is_finite); - let expand = finite & (regions.expand | out_of_flow); - Regions::one(regions.base(), expand) - }; - - let child = self - .body() - .moved(Axes::new(self.dx(styles), self.dy(styles))) - .aligned(self.alignment(styles)); - - let mut frame = child.layout(vt, styles, pod)?.into_frame(); + let mut frame = self.layout_inner(vt, styles, regions)?.into_frame(); // If expansion is off, zero all sizes so that we don't take up any // space in our parent. Otherwise, respect the expand settings. @@ -88,11 +104,48 @@ impl Layout for PlaceElem { } impl PlaceElem { - /// Whether this element wants to be placed relative to its its parent's - /// base origin. Instead of relative to the parent's current flow/cursor - /// position. - pub fn out_of_flow(&self, styles: StyleChain) -> bool { - self.alignment(styles).y.is_some() + /// Layout without zeroing the frame size. + pub fn layout_inner( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + // The pod is the base area of the region because for absolute + // placement we don't really care about the already used area. + let base = regions.base(); + let expand = + Axes::new(base.x.is_finite(), base.y.is_finite() && !self.float(styles)); + + let pod = Regions::one(base, expand); + + let float = self.float(styles); + let alignment = self.alignment(styles); + if float + && !matches!( + alignment, + Smart::Auto + | Smart::Custom(Axes { + y: Some(GenAlign::Specific(Align::Top | Align::Bottom)), + .. + }) + ) + { + bail!(self.span(), "floating placement must be `auto`, `top`, or `bottom`"); + } else if !float && alignment.is_auto() { + return Err("automatic positioning is only available for floating placement") + .hint("you can enable floating placement with `place(float: true, ..)`") + .at(self.span()); + } + + let child = self + .body() + .moved(Axes::new(self.dx(styles), self.dy(styles))) + .aligned( + alignment.unwrap_or_else(|| Axes::with_x(Some(Align::Center.into()))), + ); + + child.layout(vt, styles, pod) } } diff --git a/crates/typst-library/src/meta/figure.rs b/crates/typst-library/src/meta/figure.rs index be66a481c..d1c58cd0f 100644 --- a/crates/typst-library/src/meta/figure.rs +++ b/crates/typst-library/src/meta/figure.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use super::{ Count, Counter, CounterKey, CounterUpdate, LocalName, Numbering, NumberingPattern, }; -use crate::layout::{BlockElem, VElem}; +use crate::layout::{BlockElem, PlaceElem, VElem}; use crate::meta::{Outlinable, Refable, Supplement}; use crate::prelude::*; use crate::text::TextElem; @@ -72,7 +72,7 @@ use crate::visualize::ImageElem; /// /// If your figure is too large and its contents are breakable across pages /// (e.g. if it contains a large table), then you can make the figure breakable -/// across pages as well by using `#show figure: set block(breakable: true)` +/// across pages as well by using `[#show figure: set block(breakable: true)]` /// (see the [block]($func/block) documentation for more information). /// /// Display: Figure @@ -83,29 +83,49 @@ pub struct FigureElem { #[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. + /// + /// ```example + /// #set page(height: 200pt) + /// + /// = Introduction + /// #figure( + /// placement: bottom, + /// caption: [A glacier], + /// image("glacier.jpg", width: 60%), + /// ) + /// #lorem(60) + /// ``` + pub placement: Option>, + /// The figure's caption. pub caption: Option, - /// The caption's position. - /// - /// You can set the caption position to `{top}` or `{bottom}`, defaults to - /// `{bottom}`. + /// The caption's position. Either `{top}` or `{bottom}`. /// /// ```example /// #figure( /// table(columns: 2)[A][B], - // caption: [I'm up here], + /// caption: [I'm up here], + /// caption-pos: top, /// ) + /// /// #figure( /// table(columns: 2)[A][B], /// caption: [I'm down here], - /// caption-pos: bottom, /// ) /// ``` #[default(VerticalAlign(GenAlign::Specific(Align::Bottom)))] pub caption_pos: VerticalAlign, - /// The kind of the figure this is. + /// The kind of figure this is. /// /// If set to `{auto}`, the figure will try to automatically determine its /// kind. All figures of the same kind share a common counter. @@ -247,6 +267,7 @@ impl Synthesize for FigureElem { }), ))); + self.push_placement(self.placement(styles)); self.push_caption_pos(caption_pos); self.push_caption(self.caption(styles)); self.push_kind(Smart::Custom(kind)); @@ -278,10 +299,22 @@ impl Show for FigureElem { }; // Wrap the contents in a block. - Ok(BlockElem::new() + realized = BlockElem::new() .with_body(Some(realized)) .pack() - .aligned(Axes::with_x(Some(Align::Center.into())))) + .aligned(Axes::with_x(Some(Align::Center.into()))); + + // Wrap in a float. + if let Some(align) = self.placement(styles) { + realized = PlaceElem::new(realized) + .with_float(true) + .with_alignment(align.map(|VerticalAlign(align)| { + Axes::new(Some(Align::Center.into()), Some(align)) + })) + .pack(); + } + + Ok(realized) } } diff --git a/crates/typst/src/diag.rs b/crates/typst/src/diag.rs index b5995be4d..08d3d5281 100644 --- a/crates/typst/src/diag.rs +++ b/crates/typst/src/diag.rs @@ -215,9 +215,15 @@ pub trait Hint { fn hint(self, hint: impl Into) -> HintedStrResult; } -impl Hint for StrResult { +impl Hint for Result +where + S: Into, +{ fn hint(self, hint: impl Into) -> HintedStrResult { - self.map_err(|message| HintedString { message, hints: vec![hint.into()] }) + self.map_err(|message| HintedString { + message: message.into(), + hints: vec![hint.into()], + }) } } diff --git a/crates/typst/src/doc.rs b/crates/typst/src/doc.rs index de16cece4..8532934cc 100644 --- a/crates/typst/src/doc.rs +++ b/crates/typst/src/doc.rs @@ -338,6 +338,12 @@ impl Frame { impl Frame { /// Add a full size aqua background and a red baseline for debugging. pub fn debug(mut self) -> Self { + self.debug_in_place(); + self + } + + /// Debug in place. + pub fn debug_in_place(&mut self) { self.insert( 0, Point::zero(), @@ -359,7 +365,6 @@ impl Frame { Span::detached(), ), ); - self } /// Add a green marker at a position for debugging. diff --git a/tests/ref/layout/place-float-auto.png b/tests/ref/layout/place-float-auto.png new file mode 100644 index 000000000..f2e4ee92e Binary files /dev/null and b/tests/ref/layout/place-float-auto.png differ diff --git a/tests/ref/layout/place-float-figure.png b/tests/ref/layout/place-float-figure.png new file mode 100644 index 000000000..2cecb5ffe Binary files /dev/null and b/tests/ref/layout/place-float-figure.png differ diff --git a/tests/typ/layout/place-float-auto.typ b/tests/typ/layout/place-float-auto.typ new file mode 100644 index 000000000..799c9fc74 --- /dev/null +++ b/tests/typ/layout/place-float-auto.typ @@ -0,0 +1,19 @@ +// Test floating placement. + +--- +#set page(height: 140pt) +#set place(clearance: 5pt) +#lorem(6) +#place(auto, float: true, rect[A]) +#place(auto, float: true, rect[B]) +#place(auto, float: true, rect[C]) +#place(auto, float: true, rect[D]) + +--- +// Error: 2-20 automatic positioning is only available for floating placement +// Hint: 2-20 you can enable floating placement with `place(float: true, ..)` +#place(auto)[Hello] + +--- +// Error: 2-45 floating placement must be `auto`, `top`, or `bottom` +#place(center + horizon, float: true)[Hello] diff --git a/tests/typ/layout/place-float-figure.typ b/tests/typ/layout/place-float-figure.typ new file mode 100644 index 000000000..7256a4ebf --- /dev/null +++ b/tests/typ/layout/place-float-figure.typ @@ -0,0 +1,21 @@ +// Test floating figures. + +--- +#set page(height: 250pt, width: 150pt) + += Introduction +#lorem(10) #footnote[Lots of Latin] + +#figure( + placement: bottom, + caption: [A glacier #footnote[Lots of Ice]], + image("/files/glacier.jpg", width: 80%), +) + +#lorem(40) + +#figure( + placement: top, + caption: [An important], + image("/files/diagram.svg", width: 80%), +)