diff --git a/library/src/layout/container.rs b/library/src/layout/container.rs index 1c1f87629..a77e02498 100644 --- a/library/src/layout/container.rs +++ b/library/src/layout/container.rs @@ -71,6 +71,9 @@ impl BlockNode { /// The spacing between this and the following block. #[property(skip)] pub const BELOW: VNode = VNode::block_spacing(Em::new(1.2).into()); + /// Whether this block must stick to the following one. + #[property(skip)] + pub const STICKY: bool = false; fn construct(_: &Vm, args: &mut Args) -> SourceResult { Ok(Self(args.eat()?.unwrap_or_default()).pack()) diff --git a/library/src/layout/flow.rs b/library/src/layout/flow.rs index fd3e5fc70..07c3e0121 100644 --- a/library/src/layout/flow.rs +++ b/library/src/layout/flow.rs @@ -1,6 +1,6 @@ -use typst::model::{Property, Style}; +use typst::model::Style; -use super::{AlignNode, ColbreakNode, PlaceNode, Spacing, VNode}; +use super::{AlignNode, BlockNode, ColbreakNode, PlaceNode, Spacing, VNode}; use crate::prelude::*; use crate::text::ParNode; @@ -9,10 +9,14 @@ use crate::text::ParNode; /// This node is reponsible for layouting both the top-level content flow and /// the contents of boxes. #[derive(Hash)] -pub struct FlowNode(pub StyleVec); +pub struct FlowNode(pub StyleVec, pub bool); #[node(Layout)] -impl FlowNode {} +impl FlowNode { + fn construct(_: &Vm, args: &mut Args) -> SourceResult { + Ok(BlockNode(args.expect("body")?).pack()) + } +} impl Layout for FlowNode { fn layout( @@ -21,16 +25,20 @@ impl Layout for FlowNode { styles: StyleChain, regions: &Regions, ) -> SourceResult { - let mut layouter = FlowLayouter::new(regions); + let mut layouter = FlowLayouter::new(regions, self.1); for (child, map) in self.0.iter() { let styles = styles.chain(&map); if let Some(&node) = child.to::() { layouter.layout_spacing(node.amount, styles); + } else if let Some(node) = child.to::() { + let barrier = Style::Barrier(child.id()); + let styles = styles.chain_one(&barrier); + layouter.layout_par(world, node, styles)?; } else if child.has::() { layouter.layout_block(world, child, styles)?; } else if child.is::() { - layouter.finish_region(); + layouter.finish_region(false); } else { panic!("unexpected flow child: {child:?}"); } @@ -49,6 +57,8 @@ impl Debug for FlowNode { /// Performs flow layout. struct FlowLayouter { + /// Whether this is a root page-level flow. + root: bool, /// The regions to layout children into. regions: Regions, /// Whether the flow should expand to fill the region. @@ -56,12 +66,8 @@ struct FlowLayouter { /// The full size of `regions.size` that was available before we started /// subtracting. full: Size, - /// The size used by the frames for the current region. - used: Size, - /// The sum of fractions in the current region. - fr: Fr, /// Whether the last block was a paragraph. - last_block_was_par: bool, + last_was_par: bool, /// Spacing and layouted blocks. items: Vec, /// Finished frames for previous regions. @@ -69,20 +75,23 @@ struct FlowLayouter { } /// A prepared item in a flow layout. +#[derive(Debug)] enum FlowItem { /// Absolute spacing between other items. Absolute(Abs), + /// Leading between paragraph lines. + Leading(Abs), /// Fractional spacing between other items. Fractional(Fr), /// A frame for a layouted block and how to align it. - Frame(Frame, Axes), + Frame(Frame, Axes, bool), /// An absolutely placed frame. Placed(Frame), } impl FlowLayouter { /// Create a new flow layouter. - fn new(regions: &Regions) -> Self { + fn new(regions: &Regions, root: bool) -> Self { let expand = regions.expand; let full = regions.first; @@ -91,33 +100,53 @@ impl FlowLayouter { regions.expand.y = false; Self { + root, regions, expand, full, - used: Size::zero(), - fr: Fr::zero(), - last_block_was_par: false, + last_was_par: false, items: vec![], finished: vec![], } } - /// Actually layout the spacing. + /// Layout vertical spacing. fn layout_spacing(&mut self, spacing: Spacing, styles: StyleChain) { - match spacing { + self.layout_item(match spacing { Spacing::Relative(v) => { - // Resolve the spacing and limit it to the remaining space. - let resolved = v.resolve(styles).relative_to(self.full.y); - let limited = resolved.min(self.regions.first.y); - self.regions.first.y -= limited; - self.used.y += limited; - self.items.push(FlowItem::Absolute(resolved)); + FlowItem::Absolute(v.resolve(styles).relative_to(self.full.y)) } - Spacing::Fractional(v) => { - self.items.push(FlowItem::Fractional(v)); - self.fr += v; + Spacing::Fractional(v) => FlowItem::Fractional(v), + }); + } + + /// Layout a paragraph. + fn layout_par( + &mut self, + world: Tracked, + par: &ParNode, + styles: StyleChain, + ) -> SourceResult<()> { + let aligns = Axes::new(styles.get(ParNode::ALIGN), Align::Top); + let leading = styles.get(ParNode::LEADING); + let consecutive = self.last_was_par; + let fragment = par.layout(world, styles, &self.regions, consecutive)?; + let len = fragment.len(); + + for (i, frame) in fragment.into_iter().enumerate() { + if i > 0 { + self.layout_item(FlowItem::Leading(leading)); } + + // Prevent widows and orphans. + let border = (i == 0 && len >= 2) || i + 2 == len; + let sticky = self.root && !frame.is_empty() && border; + self.layout_item(FlowItem::Frame(frame, aligns, sticky)); } + + self.last_was_par = true; + + Ok(()) } /// Layout a block. @@ -127,17 +156,12 @@ impl FlowLayouter { block: &Content, styles: StyleChain, ) -> SourceResult<()> { - // Don't even try layouting into a full region. - if self.regions.is_full() { - self.finish_region(); - } - // Placed nodes that are out of flow produce placed items which aren't // aligned later. if let Some(placed) = block.to::() { if placed.out_of_flow() { let frame = block.layout(world, styles, &self.regions)?.into_frame(); - self.items.push(FlowItem::Placed(frame)); + self.layout_item(FlowItem::Placed(frame)); return Ok(()); } } @@ -155,47 +179,87 @@ impl FlowLayouter { .unwrap_or(Align::Top), ); - // Disable paragraph indent if this is not a consecutive paragraph. - let reset; - let is_par = block.is::(); - let mut chained = styles; - if !self.last_block_was_par && is_par && !styles.get(ParNode::INDENT).is_zero() { - let property = Property::new(ParNode::INDENT, Length::zero()); - reset = Style::Property(property); - chained = styles.chain_one(&reset); - } - // Layout the block itself. - let fragment = block.layout(world, chained, &self.regions)?; - let len = fragment.len(); - for (i, frame) in fragment.into_iter().enumerate() { - // Grow our size, shrink the region and save the frame for later. - let size = frame.size(); - self.used.y += size.y; - self.used.x.set_max(size.x); - self.regions.first.y -= size.y; - self.items.push(FlowItem::Frame(frame, aligns)); - - if i + 1 < len { - self.finish_region(); - } + let sticky = styles.get(BlockNode::STICKY); + let fragment = block.layout(world, styles, &self.regions)?; + for frame in fragment { + self.layout_item(FlowItem::Frame(frame, aligns, sticky)); } - self.last_block_was_par = is_par; + self.last_was_par = false; Ok(()) } + /// Layout a finished frame. + fn layout_item(&mut self, item: FlowItem) { + match item { + FlowItem::Absolute(v) | FlowItem::Leading(v) => self.regions.first.y -= v, + FlowItem::Fractional(_) => {} + FlowItem::Frame(ref frame, ..) => { + let size = frame.size(); + if !self.regions.first.y.fits(size.y) + && !self.regions.in_last() + && self.items.iter().any(|item| !matches!(item, FlowItem::Leading(_))) + { + self.finish_region(true); + } + + self.regions.first.y -= size.y; + } + FlowItem::Placed(_) => {} + } + + self.items.push(item); + } + /// Finish the frame for one region. - fn finish_region(&mut self) { + fn finish_region(&mut self, something_follows: bool) { + let mut end = self.items.len(); + if something_follows { + for (i, item) in self.items.iter().enumerate().rev() { + match *item { + FlowItem::Absolute(_) + | FlowItem::Leading(_) + | FlowItem::Fractional(_) => {} + FlowItem::Frame(.., true) => end = i, + _ => break, + } + } + if end == 0 { + return; + } + } + + let carry: Vec<_> = self.items.drain(end..).collect(); + + while let Some(FlowItem::Leading(_)) = self.items.last() { + self.items.pop(); + } + + let mut fr = Fr::zero(); + let mut used = Size::zero(); + for item in &self.items { + match *item { + FlowItem::Absolute(v) | FlowItem::Leading(v) => used.y += v, + FlowItem::Fractional(v) => fr += v, + FlowItem::Frame(ref frame, ..) => { + let size = frame.size(); + used.y += size.y; + used.x.set_max(size.x); + } + FlowItem::Placed(_) => {} + } + } + // Determine the size of the flow in this region dependening on whether // the region expands. - let mut size = self.expand.select(self.full, self.used); + let mut size = self.expand.select(self.full, used); // Account for fractional spacing in the size calculation. - let remaining = self.full.y - self.used.y; - if self.fr.get() > 0.0 && self.full.y.is_finite() { - self.used.y = self.full.y; + let remaining = self.full.y - used.y; + if fr.get() > 0.0 && self.full.y.is_finite() { + used.y = self.full.y; size.y = self.full.y; } @@ -206,16 +270,16 @@ impl FlowLayouter { // Place all frames. for item in self.items.drain(..) { match item { - FlowItem::Absolute(v) => { + FlowItem::Absolute(v) | FlowItem::Leading(v) => { offset += v; } FlowItem::Fractional(v) => { - offset += v.share(self.fr, remaining); + offset += v.share(fr, remaining); } - FlowItem::Frame(frame, aligns) => { + FlowItem::Frame(frame, aligns, _) => { ruler = ruler.max(aligns.y); let x = aligns.x.position(size.x - frame.width()); - let y = offset + ruler.position(size.y - self.used.y); + let y = offset + ruler.position(size.y - used.y); let pos = Point::new(x, y); offset += frame.height(); output.push_frame(pos, frame); @@ -227,22 +291,24 @@ impl FlowLayouter { } // Advance to the next region. + self.finished.push(output); self.regions.next(); self.full = self.regions.first; - self.used = Size::zero(); - self.fr = Fr::zero(); - self.finished.push(output); + + for item in carry { + self.layout_item(item); + } } /// Finish layouting and return the resulting fragment. fn finish(mut self) -> Fragment { if self.expand.y { while !self.regions.backlog.is_empty() { - self.finish_region(); + self.finish_region(false); } } - self.finish_region(); + self.finish_region(false); Fragment::frames(self.finished) } } diff --git a/library/src/layout/mod.rs b/library/src/layout/mod.rs index 3481a6bdf..7edc88ad5 100644 --- a/library/src/layout/mod.rs +++ b/library/src/layout/mod.rs @@ -227,7 +227,7 @@ fn realize_block<'a>( builder.accept(content, styles)?; builder.interrupt_par()?; let (children, shared) = builder.flow.0.finish(); - Ok((FlowNode(children).pack(), shared)) + Ok((FlowNode(children, false).pack(), shared)) } /// Builds a document or a flow node from content. @@ -400,10 +400,10 @@ impl<'a> Builder<'a> { self.interrupt_par()?; let Some(doc) = &mut self.doc else { return Ok(()) }; if !self.flow.0.is_empty() || (doc.keep_next && styles.is_some()) { - let (flow, shared) = mem::take(&mut self.flow).finish(); + let (flow, shared) = mem::take(&mut self.flow).0.finish(); let styles = if shared == StyleChain::default() { styles.unwrap() } else { shared }; - let page = PageNode(flow).pack(); + let page = PageNode(FlowNode(flow, true).pack()).pack(); let stored = self.scratch.content.alloc(page); self.accept(stored, styles)?; } @@ -461,7 +461,7 @@ impl<'a> FlowBuilder<'a> { return true; } - if content.has::() { + if content.has::() || content.is::() { let is_tight_list = if let Some(node) = content.to::() { node.tight } else if let Some(node) = content.to::() { @@ -488,11 +488,6 @@ impl<'a> FlowBuilder<'a> { false } - - fn finish(self) -> (Content, StyleChain<'a>) { - let (flow, shared) = self.0.finish(); - (FlowNode(flow).pack(), shared) - } } /// Accepts paragraph content. diff --git a/library/src/structure/heading.rs b/library/src/structure/heading.rs index 063f3c977..b251f27b2 100644 --- a/library/src/structure/heading.rs +++ b/library/src/structure/heading.rs @@ -56,6 +56,7 @@ impl Finalize for HeadingNode { map.set(TextNode::WEIGHT, FontWeight::BOLD); map.set(BlockNode::ABOVE, VNode::block_around(above.into())); map.set(BlockNode::BELOW, VNode::block_around(below.into())); + map.set(BlockNode::STICKY, true); realized.styled_with_map(map) } } diff --git a/library/src/text/par.rs b/library/src/text/par.rs index 9dc878739..3c722d84b 100644 --- a/library/src/text/par.rs +++ b/library/src/text/par.rs @@ -15,7 +15,7 @@ use crate::prelude::*; #[derive(Hash)] pub struct ParNode(pub StyleVec); -#[node(Layout)] +#[node] impl ParNode { /// The indent the first line of a consecutive paragraph should have. #[property(resolve)] @@ -33,7 +33,7 @@ impl ParNode { fn construct(_: &Vm, args: &mut Args) -> SourceResult { // The paragraph constructor is special: It doesn't create a paragraph - // node. Instead, it just ensures that the passed content lives is in a + // node. Instead, it just ensures that the passed content lives in a // separate paragraph and styles it. Ok(Content::sequence(vec![ ParbreakNode.pack(), @@ -43,15 +43,18 @@ impl ParNode { } } -impl Layout for ParNode { - fn layout( +impl ParNode { + /// Layout the paragraph into a collection of lines. + #[comemo::memoize] + pub fn layout( &self, world: Tracked, styles: StyleChain, regions: &Regions, + consecutive: bool, ) -> SourceResult { // Collect all text into one string for BiDi analysis. - let (text, segments) = collect(self, &styles); + let (text, segments) = collect(self, &styles, consecutive); // Perform BiDi analysis and then prepare paragraph layout by building a // representation on which we can do line breaking without layouting @@ -62,7 +65,7 @@ impl Layout for ParNode { let lines = linebreak(&p, regions.first.x); // Stack the lines into one frame per region. - stack(&p, &lines, regions) + finalize(&p, &lines, regions) } } @@ -177,8 +180,6 @@ struct Preparation<'a> { hyphenate: Option, /// The text language if it's the same for all children. lang: Option, - /// The resolved leading between lines. - leading: Abs, /// The paragraph's resolved alignment. align: Align, /// Whether to justify the paragraph. @@ -393,30 +394,33 @@ impl<'a> Line<'a> { fn collect<'a>( par: &'a ParNode, styles: &'a StyleChain<'a>, + consecutive: bool, ) -> (String, Vec<(Segment<'a>, StyleChain<'a>)>) { let mut full = String::new(); let mut quoter = Quoter::new(); let mut segments = vec![]; let mut iter = par.0.iter().peekable(); - let indent = styles.get(ParNode::INDENT); - if !indent.is_zero() - && par - .0 - .items() - .find_map(|child| { - if child.is::() || child.is::() { - Some(true) - } else if child.has::() { - Some(false) - } else { - None - } - }) - .unwrap_or_default() - { - full.push(SPACING_REPLACE); - segments.push((Segment::Spacing(indent.into()), *styles)); + if consecutive { + let indent = styles.get(ParNode::INDENT); + if !indent.is_zero() + && par + .0 + .items() + .find_map(|child| { + if child.is::() || child.is::() { + Some(true) + } else if child.has::() { + Some(false) + } else { + None + } + }) + .unwrap_or_default() + { + full.push(SPACING_REPLACE); + segments.push((Segment::Spacing(indent.into()), *styles)); + } } while let Some((child, map)) = iter.next() { @@ -549,7 +553,6 @@ fn prepare<'a>( styles, hyphenate: shared_get(styles, &par.0, TextNode::HYPHENATE), lang: shared_get(styles, &par.0, TextNode::LANG), - leading: styles.get(ParNode::LEADING), align: styles.get(ParNode::ALIGN), justify: styles.get(ParNode::JUSTIFY), }) @@ -1013,7 +1016,11 @@ fn line<'a>( } /// Combine layouted lines into one frame per region. -fn stack(p: &Preparation, lines: &[Line], regions: &Regions) -> SourceResult { +fn finalize( + p: &Preparation, + lines: &[Line], + regions: &Regions, +) -> SourceResult { // Determine the paragraph's width: Full width of the region if we // should expand or there's fractional spacing, fit-to-width otherwise. let mut width = regions.first.x; @@ -1021,47 +1028,16 @@ fn stack(p: &Preparation, lines: &[Line], regions: &Regions) -> SourceResult>() + .map(Fragment::frames) } /// Commit to a line and build its frame. -fn commit( - p: &Preparation, - line: &Line, - regions: &Regions, - width: Abs, -) -> SourceResult { +fn commit(p: &Preparation, line: &Line, base: Size, width: Abs) -> SourceResult { let mut remaining = width - line.width; let mut offset = Abs::zero(); @@ -1137,8 +1113,8 @@ fn commit( Item::Repeat(repeat, styles) => { let before = offset; let fill = Fr::one().share(fr, remaining); - let size = Size::new(fill, regions.base.y); - let pod = Regions::one(size, regions.base, Axes::new(false, false)); + let size = Size::new(fill, base.y); + let pod = Regions::one(size, base, Axes::new(false, false)); let frame = repeat.layout(p.world, *styles, &pod)?.into_frame(); let width = frame.width(); let count = (fill / width).floor(); diff --git a/src/doc.rs b/src/doc.rs index 5d395be47..93cae90f1 100644 --- a/src/doc.rs +++ b/src/doc.rs @@ -32,7 +32,7 @@ pub struct Metadata { } /// A partial layout result. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Fragment(Vec); impl Fragment { @@ -60,6 +60,11 @@ impl Fragment { self.0.into_iter().next().unwrap() } + /// Extract the frames. + pub fn into_frames(self) -> Vec { + self.0 + } + /// Iterate over the contained frames. pub fn iter(&self) -> std::slice::Iter { self.0.iter() @@ -71,6 +76,15 @@ impl Fragment { } } +impl Debug for Fragment { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self.0.as_slice() { + [frame] => frame.fmt(f), + frames => frames.fmt(f), + } + } +} + impl IntoIterator for Fragment { type Item = Frame; type IntoIter = std::vec::IntoIter; @@ -121,6 +135,11 @@ impl Frame { Self { size, baseline: None, elements: Arc::new(vec![]) } } + /// Whether the frame contains no elements. + pub fn is_empty(&self) -> bool { + self.elements.is_empty() + } + /// The size of the frame. pub fn size(&self) -> Size { self.size diff --git a/tests/ref/layout/grid-3.png b/tests/ref/layout/grid-3.png index c17a5873e..1bb762928 100644 Binary files a/tests/ref/layout/grid-3.png and b/tests/ref/layout/grid-3.png differ diff --git a/tests/ref/layout/orphan-heading.png b/tests/ref/layout/orphan-heading.png new file mode 100644 index 000000000..aa5a78385 Binary files /dev/null and b/tests/ref/layout/orphan-heading.png differ diff --git a/tests/ref/layout/orphan-widow.png b/tests/ref/layout/orphan-widow.png new file mode 100644 index 000000000..8fc523a6e Binary files /dev/null and b/tests/ref/layout/orphan-widow.png differ diff --git a/tests/typ/graphics/image.typ b/tests/typ/graphics/image.typ index 3c906d4ff..e97365cd1 100644 --- a/tests/typ/graphics/image.typ +++ b/tests/typ/graphics/image.typ @@ -38,7 +38,7 @@ --- // Does not fit to remaining height of page. #set page(height: 60pt) -Stuff \ +Stuff #parbreak() Stuff #image("/res/rhino.png") diff --git a/tests/typ/graphics/shape-aspect.typ b/tests/typ/graphics/shape-aspect.typ index 464d1827f..f2dd9b517 100644 --- a/tests/typ/graphics/shape-aspect.typ +++ b/tests/typ/graphics/shape-aspect.typ @@ -36,7 +36,7 @@ --- // Test square that is overflowing due to its aspect ratio. #set page(width: 40pt, height: 20pt, margin: 5pt) -#square(width: 100%) +#square(width: 100%) #parbreak() #square(width: 100%)[Hello] --- diff --git a/tests/typ/layout/columns.typ b/tests/typ/layout/columns.typ index 1e77e6bc7..315393a0f 100644 --- a/tests/typ/layout/columns.typ +++ b/tests/typ/layout/columns.typ @@ -52,8 +52,8 @@ a page for a test but it does get the job done. #set page(height: 3.25cm, width: 7.05cm, columns: 3) #set columns(gutter: 30pt) -#rect(width: 100%, height: 2.5cm, fill: conifer) -#rect(width: 100%, height: 2cm, fill: eastern) +#rect(width: 100%, height: 2.5cm, fill: conifer) #parbreak() +#rect(width: 100%, height: 2cm, fill: eastern) #parbreak() #circle(fill: eastern) --- diff --git a/tests/typ/layout/grid-3.typ b/tests/typ/layout/grid-3.typ index 6b7dc47fd..c55f22a6e 100644 --- a/tests/typ/layout/grid-3.typ +++ b/tests/typ/layout/grid-3.typ @@ -59,21 +59,3 @@ [rofl], [E\ ]*4, ) - ---- -// Test partition of `fr` units before and after multi-region layout. -#set page(width: 5cm, height: 4cm) -#grid( - columns: 2 * (1fr,), - rows: (1fr, 2fr, auto, 1fr, 1cm), - row-gutter: 10pt, - rect(fill: rgb("ff0000"))[No height], - [foo], - rect(fill: rgb("fc0030"))[Still no height], - [bar], - [The nature of being itself is in question. Am I One? What is being alive?], - [baz], - [The answer], - [42], - [Other text of interest], -) diff --git a/tests/typ/layout/orphan-heading.typ b/tests/typ/layout/orphan-heading.typ new file mode 100644 index 000000000..ef3de8859 --- /dev/null +++ b/tests/typ/layout/orphan-heading.typ @@ -0,0 +1,8 @@ +// Test that a heading doesn't become an orphan. + +--- +#set page(height: 100pt) +#lorem(12) + += Introduction +This is the start and it goes on. diff --git a/tests/typ/layout/orphan-widow.typ b/tests/typ/layout/orphan-widow.typ new file mode 100644 index 000000000..445b44e3e --- /dev/null +++ b/tests/typ/layout/orphan-widow.typ @@ -0,0 +1,23 @@ +// Test widow and orphan prevention. + +--- +#set page("a8", height: 150pt) +#set text(weight: 700) + +// Fits fully onto the first page. +#set text(blue) +#lorem(27) + +// The first line would fit, but is moved to the second page. +#lorem(20) + +// The second-to-last line is moved to the third page so that the last is isn't +// as lonely. +#set text(maroon) +#lorem(11) + +#lorem(13) + +// All three lines go to the next page. +#set text(olive) +#lorem(10)