diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs index 2c14f7a37..4385efffb 100644 --- a/crates/typst-layout/src/flow/collect.rs +++ b/crates/typst-layout/src/flow/collect.rs @@ -14,7 +14,7 @@ use typst_library::introspection::{ use typst_library::layout::{ Abs, AlignElem, Alignment, Axes, BlockElem, ColbreakElem, FixedAlignment, FlushElem, Fr, Fragment, Frame, PagebreakElem, PlaceElem, PlacementScope, Ratio, Region, - Regions, Rel, Size, Sizing, Spacing, VElem, + Regions, Rel, Size, Sizing, Spacing, Sticky, VElem, }; use typst_library::model::ParElem; use typst_library::routines::{Pair, Routines}; @@ -375,7 +375,7 @@ pub struct LineChild { #[derive(Debug)] pub struct SingleChild<'a> { pub align: Axes, - pub sticky: bool, + pub sticky: Option, pub alone: bool, pub fr: Option, elem: &'a Packed, @@ -441,7 +441,7 @@ fn layout_single_impl( #[derive(Debug)] pub struct MultiChild<'a> { pub align: Axes, - pub sticky: bool, + pub sticky: Option, alone: bool, elem: &'a Packed, styles: StyleChain<'a>, diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs index f504d22e7..169e71f99 100644 --- a/crates/typst-layout/src/flow/distribute.rs +++ b/crates/typst-layout/src/flow/distribute.rs @@ -1,6 +1,7 @@ use typst_library::introspection::Tag; use typst_library::layout::{ Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, Point, Region, Regions, Rel, Size, + Sticky, }; use typst_utils::Numeric; @@ -244,7 +245,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { return Err(Stop::Finish(false)); } - self.frame(line.frame.clone(), line.align, false, false) + self.frame(line.frame.clone(), line.align, None, false) } /// Processes an unbreakable block. @@ -307,7 +308,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // Lay out the spilled remains. let align = spill.align(); let (frame, spill) = spill.layout(self.composer.engine, self.regions)?; - self.frame(frame, align, false, true)?; + self.frame(frame, align, None, true)?; // If there's still more, save it into the `spill` and finish the // region. @@ -324,10 +325,28 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { &mut self, frame: Frame, align: Axes, - sticky: bool, + sticky: Option, breakable: bool, ) -> FlowResult<()> { - if sticky { + // Check if the successor frame has sticky: above + let has_sticky_successor = self + .composer + .work + .children + .iter() + .skip(1) + .find_map(|child| match child { + Child::Single(single) => Some( + single.sticky.as_ref().map(|s| s.is_sticky_above()).unwrap_or(false), + ), + Child::Multi(multi) => Some( + multi.sticky.as_ref().map(|s| s.is_sticky_above()).unwrap_or(false), + ), + _ => None, + }) + .unwrap_or(false); + + let mut stick_to_successor = || { // If the frame is sticky and we haven't remembered a preceding // sticky element, make a checkpoint which we can restore should we // end on this sticky element. @@ -350,12 +369,22 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { { self.sticky = Some(self.snapshot()); } - } else if !frame.is_empty() { - // If the frame isn't sticky, we can forget a previous snapshot. We - // interrupt a group of sticky blocks, if there was one, so we reset - // the saved stickable check for the next group of sticky blocks. - self.sticky = None; - self.stickable = None; + }; + + match sticky { + Some(Sticky::Below) => { + stick_to_successor(); + } + _ if has_sticky_successor => { + stick_to_successor(); + } + _ => { + // Only clear the snapshot if this frame isn't empty + if !frame.is_empty() { + self.sticky = None; + self.stickable = None; + } + } } // Handle footnotes. @@ -443,7 +472,13 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // the flow, restore the saved checkpoint to move the sticky // suffix to the next region. if let Some(snapshot) = self.sticky.take() { - self.restore(snapshot) + // Only restore snapshot if there's no spill + // If a sticky breakable element can be spilled to the following page, + // it's fine to put some of it on this page, since the next page will still + // have the next element on the same page as *some* of the spilled content. + if self.composer.work.spill.is_none() { + self.restore(snapshot) + } } } diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs index 725f177b7..64d025804 100644 --- a/crates/typst-library/src/layout/container.rs +++ b/crates/typst-library/src/layout/container.rs @@ -1,7 +1,7 @@ use crate::diag::{bail, SourceResult}; use crate::engine::Engine; use crate::foundations::{ - cast, elem, Args, AutoValue, Construct, Content, NativeElement, Packed, Smart, + cast, elem, Args, AutoValue, Cast, Construct, Content, NativeElement, Packed, Smart, StyleChain, Value, }; use crate::introspection::Locator; @@ -179,6 +179,27 @@ pub enum InlineItem { Frame(Frame), } +/// Defines how a block sticks to adjacent content. +#[derive(Debug, Cast, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Sticky { + /// Block sticks to the content below it. + Below, + /// Makes the block above stick to this block. + Above, + /// Makes the block above and below stick to this block. + Both, +} + +impl Sticky { + pub fn is_sticky_above(self) -> bool { + matches!(self, Self::Above | Self::Both) + } + + pub fn is_sticky_below(self) -> bool { + matches!(self, Self::Below | Self::Both) + } +} + /// A block-level container. /// /// Such a container can be used to separate content, size it, and give it a @@ -338,23 +359,29 @@ pub struct BlockElem { #[default(false)] pub clip: bool, - /// Whether this block must stick to the following one, with no break in - /// between. + /// If and how this block must stick to the blocks surrounding it, with no + /// break in between. /// - /// This is, by default, set on heading blocks to prevent orphaned headings - /// at the bottom of the page. + /// This is, by default, set to "below" on heading blocks to prevent + /// orphaned headings at the bottom of the page. /// /// ```example /// >>> #set page(height: 140pt) /// // Disable stickiness of headings. - /// #show heading: set block(sticky: false) + /// #show heading: set block(sticky: none) /// #lorem(20) /// /// = Chapter /// #lorem(10) + /// + /// #table( + /// columns: 2, + /// [A], [B], + /// [C], [D], + /// ) + /// #block(sticky: "above")[The above table shows that...] /// ``` - #[default(false)] - pub sticky: bool, + pub sticky: Option, /// The contents of the block. #[positional] diff --git a/crates/typst-library/src/model/heading.rs b/crates/typst-library/src/model/heading.rs index 00931c815..bb8f8042a 100644 --- a/crates/typst-library/src/model/heading.rs +++ b/crates/typst-library/src/model/heading.rs @@ -13,7 +13,9 @@ use crate::html::{attr, tag, HtmlElem}; use crate::introspection::{ Count, Counter, CounterUpdate, Locatable, Locator, LocatorLink, }; -use crate::layout::{Abs, Axes, BlockBody, BlockElem, Em, HElem, Length, Region, Sides}; +use crate::layout::{ + Abs, Axes, BlockBody, BlockElem, Em, HElem, Length, Region, Sides, Sticky, +}; use crate::model::{Numbering, Outlinable, Refable, Supplement}; use crate::text::{FontWeight, LocalName, SpaceElem, TextElem, TextSize}; @@ -323,7 +325,7 @@ impl ShowSet for Packed { 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.set(BlockElem::set_sticky(Some(Sticky::Below))); out } } diff --git a/tests/ref/block-sticky-above.png b/tests/ref/block-sticky-above.png new file mode 100644 index 000000000..92370fdc5 Binary files /dev/null and b/tests/ref/block-sticky-above.png differ diff --git a/tests/suite/layout/container.typ b/tests/suite/layout/container.typ index f15ddfe4a..f3135b809 100644 --- a/tests/suite/layout/container.typ +++ b/tests/suite/layout/container.typ @@ -155,17 +155,17 @@ Paragraph --- block-sticky --- #set page(height: 100pt) #lines(3) -#block(sticky: true)[D] -#block(sticky: true)[E] +#block(sticky: "below")[D] +#block(sticky: "below")[E] F --- block-sticky-alone --- #set page(height: 50pt) -#block(sticky: true)[A] +#block(sticky: "below")[A] --- block-sticky-many --- #set page(height: 80pt) -#set block(sticky: true) +#set block(breakable: false, sticky: "below") #block[A] #block[B] #block[C] @@ -176,16 +176,22 @@ E --- block-sticky-colbreak --- A -#block(sticky: true)[B] +#block(sticky: "below")[B] #colbreak() C --- block-sticky-breakable --- // Ensure that sticky blocks are still breakable. #set page(height: 60pt) -#block(sticky: true, lines(4)) +#block(sticky: "below", lines(4)) E +--- block-sticky-above --- +#set page(height: 50pt) +#block(height: 15pt)[A] +#block(height: 5pt, breakable: false)[B] +#block(height: 5pt, sticky: "above")[C] + --- box-clip-rect --- // Test box clipping with a rectangle Hello #box(width: 1em, height: 1em, clip: false)[#rect(width: 3em, height: 3em, fill: red)] @@ -291,7 +297,7 @@ Paragraph #set page(height: 3cm) #v(1.6cm) #block(height: 2cm, breakable: true)[ - #block(sticky: true)[*A*] + #block(sticky: "below")[*A*] b ] @@ -300,7 +306,7 @@ Paragraph #set page(height: 3cm) #v(2cm) -#block(sticky: true)[*A*] +#block(sticky: "below")[*A*] b @@ -308,7 +314,7 @@ b #set page(height: 3cm) #v(2cm, weak: true) -#block(sticky: true)[*A*] +#block(sticky: "below")[*A*] b