diff --git a/crates/typst/src/layout/flow.rs b/crates/typst/src/layout/flow.rs
index a1f1402e..e15159e1 100644
--- a/crates/typst/src/layout/flow.rs
+++ b/crates/typst/src/layout/flow.rs
@@ -14,10 +14,12 @@ use crate::foundations::{
use crate::introspection::{Locator, SplitLocator, Tag, TagElem};
use crate::layout::{
Abs, AlignElem, Axes, BlockElem, ColbreakElem, FixedAlignment, FlushElem, Fr,
- Fragment, Frame, FrameItem, PlaceElem, Point, Regions, Rel, Size, Spacing, VElem,
+ Fragment, Frame, FrameItem, PlaceElem, Point, Ratio, Regions, Rel, Size, Spacing,
+ VElem,
};
use crate::model::{FootnoteElem, FootnoteEntry, ParElem};
use crate::realize::StyleVec;
+use crate::text::TextElem;
use crate::utils::Numeric;
/// Arranges spacing, paragraphs and block-level elements into a flow.
@@ -278,6 +280,7 @@ impl<'a, 'e> FlowLayouter<'a, 'e> {
// Fetch properties.
let align = AlignElem::alignment_in(styles).resolve(styles);
let leading = ParElem::leading_in(styles);
+ let costs = TextElem::costs_in(styles);
// Layout the paragraph into lines. This only depends on the base size,
// not on the Y position.
@@ -305,12 +308,51 @@ impl<'a, 'e> FlowLayouter<'a, 'e> {
}
}
+ // Determine whether to prevent widow and orphans.
+ let len = lines.len();
+ let prevent_orphans =
+ costs.orphan() > Ratio::zero() && len >= 2 && !lines[1].is_empty();
+ let prevent_widows =
+ costs.widow() > Ratio::zero() && len >= 2 && !lines[len - 2].is_empty();
+ let prevent_all = len == 3 && prevent_orphans && prevent_widows;
+
+ // Store the heights of lines at the edges because we'll potentially
+ // need these later when `lines` is already moved.
+ let height_at = |i| lines.get(i).map(Frame::height).unwrap_or_default();
+ let front_1 = height_at(0);
+ let front_2 = height_at(1);
+ let back_2 = height_at(len.saturating_sub(2));
+ let back_1 = height_at(len.saturating_sub(1));
+
// Layout the lines.
for (i, mut frame) in lines.into_iter().enumerate() {
if i > 0 {
self.handle_item(FlowItem::Absolute(leading, true))?;
}
+ // To prevent widows and orphans, we require enough space for
+ // - all lines if it's just three
+ // - the first two lines if we're at the first line
+ // - the last two lines if we're at the second to last line
+ let needed = if prevent_all && i == 0 {
+ front_1 + leading + front_2 + leading + back_1
+ } else if prevent_orphans && i == 0 {
+ front_1 + leading + front_2
+ } else if prevent_widows && i >= 2 && i + 2 == len {
+ back_2 + leading + back_1
+ } else {
+ frame.height()
+ };
+
+ // If the line(s) don't fit into this region, but they do fit into
+ // the next, then advance.
+ if !self.regions.in_last()
+ && !self.regions.size.y.fits(needed)
+ && self.regions.iter().nth(1).is_some_and(|region| region.y.fits(needed))
+ {
+ self.finish_region(false)?;
+ }
+
self.drain_tag(&mut frame);
self.handle_item(FlowItem::Frame {
frame,
diff --git a/crates/typst/src/layout/inline/collect.rs b/crates/typst/src/layout/inline/collect.rs
index b6a847f5..5021dc55 100644
--- a/crates/typst/src/layout/inline/collect.rs
+++ b/crates/typst/src/layout/inline/collect.rs
@@ -128,11 +128,11 @@ pub fn collect<'a>(
let mut iter = children.chain(styles).peekable();
let mut locator = locator.split();
+ let outer_dir = TextElem::dir_in(*styles);
let first_line_indent = ParElem::first_line_indent_in(*styles);
if !first_line_indent.is_zero()
&& consecutive
- && AlignElem::alignment_in(*styles).resolve(*styles).x
- == TextElem::dir_in(*styles).start().into()
+ && AlignElem::alignment_in(*styles).resolve(*styles).x == outer_dir.start().into()
{
collector.push_item(Item::Absolute(first_line_indent.resolve(*styles), false));
collector.spans.push(1, Span::detached());
@@ -144,8 +144,6 @@ pub fn collect<'a>(
collector.spans.push(1, Span::detached());
}
- let outer_dir = TextElem::dir_in(*styles);
-
while let Some((child, styles)) = iter.next() {
let prev_len = collector.full.len();
diff --git a/crates/typst/src/layout/inline/finalize.rs b/crates/typst/src/layout/inline/finalize.rs
index c8ba4729..03493af5 100644
--- a/crates/typst/src/layout/inline/finalize.rs
+++ b/crates/typst/src/layout/inline/finalize.rs
@@ -1,5 +1,4 @@
use super::*;
-use crate::layout::{Abs, Frame, Point};
use crate::utils::Numeric;
/// Turns the selected lines into frames.
@@ -26,38 +25,9 @@ pub fn finalize(
// Stack the lines into one frame per region.
let shrink = ParElem::shrink_in(styles);
- let mut frames: Vec = lines
+ lines
.iter()
.map(|line| commit(engine, p, line, width, region.y, shrink))
- .collect::>()?;
-
- // Positive ratios enable prevention, while zero and negative ratios disable
- // it.
- if p.costs.orphan().get() > 0.0 {
- // Prevent orphans.
- if frames.len() >= 2 && !frames[1].is_empty() {
- let second = frames.remove(1);
- let first = &mut frames[0];
- merge(first, second, p.leading);
- }
- }
- if p.costs.widow().get() > 0.0 {
- // Prevent widows.
- let len = frames.len();
- if len >= 2 && !frames[len - 2].is_empty() {
- let second = frames.pop().unwrap();
- let first = frames.last_mut().unwrap();
- merge(first, second, p.leading);
- }
- }
-
- Ok(Fragment::frames(frames))
-}
-
-/// Merge two line frames
-fn merge(first: &mut Frame, second: Frame, leading: Abs) {
- let offset = first.height() + leading;
- let total = offset + second.height();
- first.push_frame(Point::with_y(offset), second);
- first.size_mut().y = total;
+ .collect::>()
+ .map(Fragment::frames)
}
diff --git a/crates/typst/src/layout/inline/prepare.rs b/crates/typst/src/layout/inline/prepare.rs
index 59682b2c..9e73af66 100644
--- a/crates/typst/src/layout/inline/prepare.rs
+++ b/crates/typst/src/layout/inline/prepare.rs
@@ -43,8 +43,6 @@ pub struct Preparation<'a> {
pub cjk_latin_spacing: bool,
/// Whether font fallback is enabled for this paragraph.
pub fallback: bool,
- /// The leading of the paragraph.
- pub leading: Abs,
/// How to determine line breaks.
pub linebreaks: Smart,
/// The text size.
@@ -136,7 +134,6 @@ pub fn prepare<'a>(
hang: ParElem::hanging_indent_in(styles),
cjk_latin_spacing,
fallback: TextElem::fallback_in(styles),
- leading: ParElem::leading_in(styles),
linebreaks: ParElem::linebreaks_in(styles),
size: TextElem::size_in(styles),
})
diff --git a/tests/ref/flow-widow-forced.png b/tests/ref/flow-widow-forced.png
new file mode 100644
index 00000000..98a953af
Binary files /dev/null and b/tests/ref/flow-widow-forced.png differ
diff --git a/tests/ref/grid-header-and-footer-lack-of-space.png b/tests/ref/grid-header-and-footer-lack-of-space.png
index 78705776..303c6f31 100644
Binary files a/tests/ref/grid-header-and-footer-lack-of-space.png and b/tests/ref/grid-header-and-footer-lack-of-space.png differ
diff --git a/tests/ref/grid-header-lack-of-space.png b/tests/ref/grid-header-lack-of-space.png
index 4d2b483f..8b222174 100644
Binary files a/tests/ref/grid-header-lack-of-space.png and b/tests/ref/grid-header-lack-of-space.png differ
diff --git a/tests/ref/grid-rowspan-split-9.png b/tests/ref/grid-rowspan-split-9.png
index 5346be71..8d878c28 100644
Binary files a/tests/ref/grid-rowspan-split-9.png and b/tests/ref/grid-rowspan-split-9.png differ
diff --git a/tests/ref/issue-1445-widow-orphan-unnecessary-skip.png b/tests/ref/issue-1445-widow-orphan-unnecessary-skip.png
new file mode 100644
index 00000000..7cd7888d
Binary files /dev/null and b/tests/ref/issue-1445-widow-orphan-unnecessary-skip.png differ
diff --git a/tests/ref/issue-multiple-footnote-in-one-line.png b/tests/ref/issue-multiple-footnote-in-one-line.png
index 1d8c017d..cdb83af2 100644
Binary files a/tests/ref/issue-multiple-footnote-in-one-line.png and b/tests/ref/issue-multiple-footnote-in-one-line.png differ
diff --git a/tests/suite/layout/flow/orphan.typ b/tests/suite/layout/flow/orphan.typ
index 70eac731..bd938d96 100644
--- a/tests/suite/layout/flow/orphan.typ
+++ b/tests/suite/layout/flow/orphan.typ
@@ -29,3 +29,14 @@ This is the start and it goes on.
// All three lines go to the next page.
#set text(olive)
#lorem(10)
+
+--- flow-widow-forced ---
+// Ensure that a widow is allowed when the three lines don't all fit.
+#set page(height: 50pt)
+#lorem(10)
+
+--- issue-1445-widow-orphan-unnecessary-skip ---
+// Ensure that widow/orphan prevention doesn't unnecessarily move things
+// to another page.
+#set page(width: 16cm)
+#block(height: 30pt, fill: aqua, columns(2, lorem(19)))
diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ
index c9c95e13..c3b92997 100644
--- a/tests/suite/layout/grid/headers.typ
+++ b/tests/suite/layout/grid/headers.typ
@@ -220,7 +220,7 @@
--- grid-header-lack-of-space ---
// Test lack of space for header + text.
-#set page(height: 9em)
+#set page(height: 8em)
#table(
rows: (auto, 2.5em, auto, auto, 10em),
diff --git a/tests/suite/model/footnote.typ b/tests/suite/model/footnote.typ
index d72ca25a..99372551 100644
--- a/tests/suite/model/footnote.typ
+++ b/tests/suite/model/footnote.typ
@@ -163,11 +163,10 @@ Ref @fn
--- issue-multiple-footnote-in-one-line ---
// Test that the logic that keeps footnote entry together with
// their markers also works for multiple footnotes in a single
-// line or frame (here, there are two lines, but they are one
-// unit due to orphan prevention).
+// line.
#set page(height: 100pt)
-#v(40pt)
-A #footnote[a] \
+#v(50pt)
+A #footnote[a]
B #footnote[b]
--- issue-1433-footnote-in-list ---