summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPgBiel <9021226+PgBiel@users.noreply.github.com>2025-06-10 11:41:16 -0300
committerGitHub <noreply@github.com>2025-06-10 14:41:16 +0000
commit7c7b962b98a09c1baabdd03ff4ccad8f6d817b37 (patch)
treec44c9b82e3c7ec7bf8feb44cedeae9fa71a107cb
parenta18ca3481da17a4de1cc7f9890f0c61efb480655 (diff)
Table multiple headers and subheaders (#6168)
-rw-r--r--crates/typst-layout/src/grid/layouter.rs598
-rw-r--r--crates/typst-layout/src/grid/lines.rs29
-rw-r--r--crates/typst-layout/src/grid/repeated.rs476
-rw-r--r--crates/typst-layout/src/grid/rowspans.rs245
-rw-r--r--crates/typst-library/src/foundations/int.rs17
-rw-r--r--crates/typst-library/src/layout/grid/mod.rs13
-rw-r--r--crates/typst-library/src/layout/grid/resolve.rs489
-rw-r--r--crates/typst-library/src/model/table.rs70
-rw-r--r--crates/typst-syntax/src/span.rs13
-rw-r--r--crates/typst-utils/src/lib.rs11
-rw-r--r--crates/typst-utils/src/pico.rs5
-rw-r--r--tests/ref/grid-footer-non-repeatable-unbreakable.pngbin0 -> 365 bytes
-rw-r--r--tests/ref/grid-footer-repeatable-unbreakable.pngbin0 -> 340 bytes
-rw-r--r--tests/ref/grid-header-and-large-auto-contiguous.pngbin0 -> 894 bytes
-rw-r--r--tests/ref/grid-header-and-rowspan-contiguous-1.pngbin0 -> 815 bytes
-rw-r--r--tests/ref/grid-header-and-rowspan-contiguous-2.pngbin0 -> 815 bytes
-rw-r--r--tests/ref/grid-header-multiple.pngbin0 -> 214 bytes
-rw-r--r--tests/ref/grid-header-non-repeating-orphan-prevention.pngbin0 -> 453 bytes
-rw-r--r--tests/ref/grid-header-not-at-first-row-two-columns.pngbin0 -> 176 bytes
-rw-r--r--tests/ref/grid-header-not-at-first-row.pngbin0 -> 176 bytes
-rw-r--r--tests/ref/grid-header-not-at-the-top.pngbin0 -> 605 bytes
-rw-r--r--tests/ref/grid-header-replace-doesnt-fit.pngbin0 -> 559 bytes
-rw-r--r--tests/ref/grid-header-replace-orphan.pngbin0 -> 559 bytes
-rw-r--r--tests/ref/grid-header-replace.pngbin0 -> 692 bytes
-rw-r--r--tests/ref/grid-header-skip.pngbin0 -> 432 bytes
-rw-r--r--tests/ref/grid-header-too-large-non-repeating-orphan.pngbin0 -> 372 bytes
-rw-r--r--tests/ref/grid-header-too-large-repeating-orphan-not-at-first-row.pngbin0 -> 398 bytes
-rw-r--r--tests/ref/grid-header-too-large-repeating-orphan-with-footer.pngbin0 -> 576 bytes
-rw-r--r--tests/ref/grid-header-too-large-repeating-orphan.pngbin0 -> 321 bytes
-rw-r--r--tests/ref/grid-subheaders-alone-no-orphan-prevention.pngbin0 -> 254 bytes
-rw-r--r--tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.pngbin0 -> 378 bytes
-rw-r--r--tests/ref/grid-subheaders-alone-with-footer.pngbin0 -> 319 bytes
-rw-r--r--tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.pngbin0 -> 382 bytes
-rw-r--r--tests/ref/grid-subheaders-alone-with-gutter-no-orphan-prevention.pngbin0 -> 254 bytes
-rw-r--r--tests/ref/grid-subheaders-alone.pngbin0 -> 256 bytes
-rw-r--r--tests/ref/grid-subheaders-basic-non-consecutive-with-footer.pngbin0 -> 279 bytes
-rw-r--r--tests/ref/grid-subheaders-basic-non-consecutive.pngbin0 -> 256 bytes
-rw-r--r--tests/ref/grid-subheaders-basic-replace.pngbin0 -> 321 bytes
-rw-r--r--tests/ref/grid-subheaders-basic-with-footer.pngbin0 -> 256 bytes
-rw-r--r--tests/ref/grid-subheaders-basic.pngbin0 -> 210 bytes
-rw-r--r--tests/ref/grid-subheaders-colorful.pngbin0 -> 11005 bytes
-rw-r--r--tests/ref/grid-subheaders-demo.pngbin0 -> 5064 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.pngbin0 -> 1207 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-row-right-after.pngbin0 -> 1127 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-row-with-footer.pngbin0 -> 1345 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-row.pngbin0 -> 1173 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-rowspan-gutter.pngbin0 -> 1560 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-rowspan-right-after.pngbin0 -> 1421 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-rowspan-with-footer.pngbin0 -> 1190 bytes
-rw-r--r--tests/ref/grid-subheaders-multi-page-rowspan.pngbin0 -> 1048 bytes
-rw-r--r--tests/ref/grid-subheaders-non-repeat-replace.pngbin0 -> 878 bytes
-rw-r--r--tests/ref/grid-subheaders-non-repeat.pngbin0 -> 614 bytes
-rw-r--r--tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.pngbin0 -> 410 bytes
-rw-r--r--tests/ref/grid-subheaders-non-repeating-orphan-prevention.pngbin0 -> 347 bytes
-rw-r--r--tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.pngbin0 -> 895 bytes
-rw-r--r--tests/ref/grid-subheaders-non-repeating-replace-orphan.pngbin0 -> 964 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-gutter.pngbin0 -> 503 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-non-consecutive.pngbin0 -> 599 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.pngbin0 -> 877 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-double-orphan.pngbin0 -> 950 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-child.pngbin0 -> 806 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-gutter.pngbin0 -> 758 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-gutter.pngbin0 -> 782 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-multiple-levels.pngbin0 -> 877 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-orphan.pngbin0 -> 939 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-short-lived.pngbin0 -> 795 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.pngbin0 -> 961 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace-with-footer.pngbin0 -> 992 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-replace.pngbin0 -> 953 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-short-lived-also-replaces.pngbin0 -> 899 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat-with-footer.pngbin0 -> 584 bytes
-rw-r--r--tests/ref/grid-subheaders-repeat.pngbin0 -> 472 bytes
-rw-r--r--tests/ref/grid-subheaders-repeating-orphan-prevention.pngbin0 -> 347 bytes
-rw-r--r--tests/ref/grid-subheaders-short-lived-no-orphan-prevention.pngbin0 -> 287 bytes
-rw-r--r--tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.pngbin0 -> 460 bytes
-rw-r--r--tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.pngbin0 -> 542 bytes
-rw-r--r--tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.pngbin0 -> 525 bytes
-rw-r--r--tests/ref/grid-subheaders-too-large-repeating-orphan-before-relative.pngbin0 -> 437 bytes
-rw-r--r--tests/ref/html/multi-header-inside-table.html69
-rw-r--r--tests/ref/html/multi-header-table.html49
-rw-r--r--tests/ref/issue-5359-column-override-stays-inside-header.pngbin0 -> 674 bytes
-rw-r--r--tests/suite/layout/grid/footers.typ23
-rw-r--r--tests/suite/layout/grid/headers.typ171
-rw-r--r--tests/suite/layout/grid/html.typ75
-rw-r--r--tests/suite/layout/grid/subheaders.typ602
85 files changed, 2444 insertions, 511 deletions
diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs
index 99b85edd..42fe38db 100644
--- a/crates/typst-layout/src/grid/layouter.rs
+++ b/crates/typst-layout/src/grid/layouter.rs
@@ -3,7 +3,9 @@ use std::fmt::Debug;
use typst_library::diag::{bail, SourceResult};
use typst_library::engine::Engine;
use typst_library::foundations::{Resolve, StyleChain};
-use typst_library::layout::grid::resolve::{Cell, CellGrid, LinePosition, Repeatable};
+use typst_library::layout::grid::resolve::{
+ Cell, CellGrid, Header, LinePosition, Repeatable,
+};
use typst_library::layout::{
Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel,
Size, Sizing,
@@ -30,10 +32,8 @@ pub struct GridLayouter<'a> {
pub(super) rcols: Vec<Abs>,
/// The sum of `rcols`.
pub(super) width: Abs,
- /// Resolve row sizes, by region.
+ /// Resolved row sizes, by region.
pub(super) rrows: Vec<Vec<RowPiece>>,
- /// Rows in the current region.
- pub(super) lrows: Vec<Row>,
/// The amount of unbreakable rows remaining to be laid out in the
/// current unbreakable row group. While this is positive, no region breaks
/// should occur.
@@ -41,24 +41,155 @@ pub struct GridLayouter<'a> {
/// Rowspans not yet laid out because not all of their spanned rows were
/// laid out yet.
pub(super) rowspans: Vec<Rowspan>,
- /// The initial size of the current region before we started subtracting.
- pub(super) initial: Size,
+ /// Grid layout state for the current region.
+ pub(super) current: Current,
/// Frames for finished regions.
pub(super) finished: Vec<Frame>,
+ /// The amount and height of header rows on each finished region.
+ pub(super) finished_header_rows: Vec<FinishedHeaderRowInfo>,
/// Whether this is an RTL grid.
pub(super) is_rtl: bool,
- /// The simulated header height.
- /// This field is reset in `layout_header` and properly updated by
+ /// Currently repeating headers, one per level. Sorted by increasing
+ /// levels.
+ ///
+ /// Note that some levels may be absent, in particular level 0, which does
+ /// not exist (so all levels are >= 1).
+ pub(super) repeating_headers: Vec<&'a Header>,
+ /// Headers, repeating or not, awaiting their first successful layout.
+ /// Sorted by increasing levels.
+ pub(super) pending_headers: &'a [Repeatable<Header>],
+ /// Next headers to be processed.
+ pub(super) upcoming_headers: &'a [Repeatable<Header>],
+ /// State of the row being currently laid out.
+ ///
+ /// This is kept as a field to avoid passing down too many parameters from
+ /// `layout_row` into called functions, which would then have to pass them
+ /// down to `push_row`, which reads these values.
+ pub(super) row_state: RowState,
+ /// The span of the grid element.
+ pub(super) span: Span,
+}
+
+/// Grid layout state for the current region. This should be reset or updated
+/// on each region break.
+pub(super) struct Current {
+ /// The initial size of the current region before we started subtracting.
+ pub(super) initial: Size,
+ /// The height of the region after repeated headers were placed and footers
+ /// prepared. This also includes pending repeating headers from the start,
+ /// even if they were not repeated yet, since they will be repeated in the
+ /// next region anyway (bar orphan prevention).
+ ///
+ /// This is used to quickly tell if any additional space in the region has
+ /// been occupied since then, meaning that additional space will become
+ /// available after a region break (see
+ /// [`GridLayouter::may_progress_with_repeats`]).
+ pub(super) initial_after_repeats: Abs,
+ /// Whether `layouter.regions.may_progress()` was `true` at the top of the
+ /// region.
+ pub(super) could_progress_at_top: bool,
+ /// Rows in the current region.
+ pub(super) lrows: Vec<Row>,
+ /// The amount of repeated header rows at the start of the current region.
+ /// Thus, excludes rows from pending headers (which were placed for the
+ /// first time).
+ ///
+ /// Note that `repeating_headers` and `pending_headers` can change if we
+ /// find a new header inside the region (not at the top), so this field
+ /// is required to access information from the top of the region.
+ ///
+ /// This information is used on finish region to calculate the total height
+ /// of resolved header rows at the top of the region, which is used by
+ /// multi-page rowspans so they can properly skip the header rows at the
+ /// top of each region during layout.
+ pub(super) repeated_header_rows: usize,
+ /// The end bound of the row range of the last repeating header at the
+ /// start of the region.
+ ///
+ /// The last row might have disappeared from layout due to being empty, so
+ /// this is how we can become aware of where the last header ends without
+ /// having to check the vector of rows. Line layout uses this to determine
+ /// when to prioritize the last lines under a header.
+ ///
+ /// A value of zero indicates no repeated headers were placed.
+ pub(super) last_repeated_header_end: usize,
+ /// Stores the length of `lrows` before a sequence of rows equipped with
+ /// orphan prevention was laid out. In this case, if no more rows without
+ /// orphan prevention are laid out after those rows before the region ends,
+ /// the rows will be removed, and there may be an attempt to place them
+ /// again in the new region. Effectively, this is the mechanism used for
+ /// orphan prevention of rows.
+ ///
+ /// At the moment, this is only used by repeated headers (they aren't laid
+ /// out if alone in the region) and by new headers, which are moved to the
+ /// `pending_headers` vector and so will automatically be placed again
+ /// until they fit and are not orphans in at least one region (or exactly
+ /// one, for non-repeated headers).
+ pub(super) lrows_orphan_snapshot: Option<usize>,
+ /// The height of effectively repeating headers, that is, ignoring
+ /// non-repeating pending headers, in the current region.
+ ///
+ /// This is used by multi-page auto rows so they can inform cell layout on
+ /// how much space should be taken by headers if they break across regions.
+ /// In particular, non-repeating headers only occupy the initial region,
+ /// but disappear on new regions, so they can be ignored.
+ ///
+ /// This field is reset on each new region and properly updated by
/// `layout_auto_row` and `layout_relative_row`, and should not be read
/// before all header rows are fully laid out. It is usually fine because
/// header rows themselves are unbreakable, and unbreakable rows do not
/// need to read this field at all.
- pub(super) header_height: Abs,
+ ///
+ /// This height is not only computed at the beginning of the region. It is
+ /// updated whenever a new header is found, subtracting the height of
+ /// headers which stopped repeating and adding the height of all new
+ /// headers.
+ pub(super) repeating_header_height: Abs,
+ /// The height for each repeating header that was placed in this region.
+ /// Note that this includes headers not at the top of the region, before
+ /// their first repetition (pending headers), and excludes headers removed
+ /// by virtue of a new, conflicting header being found (short-lived
+ /// headers).
+ ///
+ /// This is used to know how much to update `repeating_header_height` by
+ /// when finding a new header and causing existing repeating headers to
+ /// stop.
+ pub(super) repeating_header_heights: Vec<Abs>,
/// The simulated footer height for this region.
+ ///
/// The simulation occurs before any rows are laid out for a region.
pub(super) footer_height: Abs,
- /// The span of the grid element.
- pub(super) span: Span,
+}
+
+/// Data about the row being laid out right now.
+#[derive(Debug, Default)]
+pub(super) struct RowState {
+ /// If this is `Some`, this will be updated by the currently laid out row's
+ /// height if it is auto or relative. This is used for header height
+ /// calculation.
+ pub(super) current_row_height: Option<Abs>,
+ /// This is `true` when laying out non-short lived headers and footers.
+ /// That is, headers and footers which are not immediately followed or
+ /// preceded (respectively) by conflicting headers and footers of same or
+ /// lower level, or the end or start of the table (respectively), which
+ /// would cause them to never repeat, even once.
+ ///
+ /// If this is `false`, the next row to be laid out will remove an active
+ /// orphan snapshot and will flush pending headers, as there is no risk
+ /// that they will be orphans anymore.
+ pub(super) in_active_repeatable: bool,
+}
+
+/// Data about laid out repeated header rows for a specific finished region.
+#[derive(Debug, Default)]
+pub(super) struct FinishedHeaderRowInfo {
+ /// The amount of repeated headers at the top of the region.
+ pub(super) repeated_amount: usize,
+ /// The end bound of the row range of the last repeated header at the top
+ /// of the region.
+ pub(super) last_repeated_header_end: usize,
+ /// The total height of repeated headers at the top of the region.
+ pub(super) repeated_height: Abs,
}
/// Details about a resulting row piece.
@@ -114,14 +245,27 @@ impl<'a> GridLayouter<'a> {
rcols: vec![Abs::zero(); grid.cols.len()],
width: Abs::zero(),
rrows: vec![],
- lrows: vec![],
unbreakable_rows_left: 0,
rowspans: vec![],
- initial: regions.size,
finished: vec![],
+ finished_header_rows: vec![],
is_rtl: TextElem::dir_in(styles) == Dir::RTL,
- header_height: Abs::zero(),
- footer_height: Abs::zero(),
+ repeating_headers: vec![],
+ upcoming_headers: &grid.headers,
+ pending_headers: Default::default(),
+ row_state: RowState::default(),
+ current: Current {
+ initial: regions.size,
+ initial_after_repeats: regions.size.y,
+ could_progress_at_top: regions.may_progress(),
+ lrows: vec![],
+ repeated_header_rows: 0,
+ last_repeated_header_end: 0,
+ lrows_orphan_snapshot: None,
+ repeating_header_height: Abs::zero(),
+ repeating_header_heights: vec![],
+ footer_height: Abs::zero(),
+ },
span,
}
}
@@ -130,38 +274,57 @@ impl<'a> GridLayouter<'a> {
pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> {
self.measure_columns(engine)?;
- if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
- // Ensure rows in the first region will be aware of the possible
- // presence of the footer.
- self.prepare_footer(footer, engine, 0)?;
- if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) {
- // No repeatable header, so we won't subtract it later.
- self.regions.size.y -= self.footer_height;
+ if let Some(footer) = &self.grid.footer {
+ if footer.repeated {
+ // Ensure rows in the first region will be aware of the
+ // possible presence of the footer.
+ self.prepare_footer(footer, engine, 0)?;
+ self.regions.size.y -= self.current.footer_height;
+ self.current.initial_after_repeats = self.regions.size.y;
}
}
- for y in 0..self.grid.rows.len() {
- if let Some(Repeatable::Repeated(header)) = &self.grid.header {
- if y < header.end {
- if y == 0 {
- self.layout_header(header, engine, 0)?;
- self.regions.size.y -= self.footer_height;
- }
+ let mut y = 0;
+ let mut consecutive_header_count = 0;
+ while y < self.grid.rows.len() {
+ if let Some(next_header) = self.upcoming_headers.get(consecutive_header_count)
+ {
+ if next_header.range.contains(&y) {
+ self.place_new_headers(&mut consecutive_header_count, engine)?;
+ y = next_header.range.end;
+
// Skip header rows during normal layout.
continue;
}
}
- if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
- if y >= footer.start {
+ if let Some(footer) = &self.grid.footer {
+ if footer.repeated && y >= footer.start {
if y == footer.start {
self.layout_footer(footer, engine, self.finished.len())?;
+ self.flush_orphans();
}
+ y = footer.end;
continue;
}
}
self.layout_row(y, engine, 0)?;
+
+ // After the first non-header row is placed, pending headers are no
+ // longer orphans and can repeat, so we move them to repeating
+ // headers.
+ //
+ // Note that this is usually done in `push_row`, since the call to
+ // `layout_row` above might trigger region breaks (for multi-page
+ // auto rows), whereas this needs to be called as soon as any part
+ // of a row is laid out. However, it's possible a row has no
+ // visible output and thus does not push any rows even though it
+ // was successfully laid out, in which case we additionally flush
+ // here just in case.
+ self.flush_orphans();
+
+ y += 1;
}
self.finish_region(engine, true)?;
@@ -184,13 +347,47 @@ impl<'a> GridLayouter<'a> {
self.render_fills_strokes()
}
- /// Layout the given row.
+ /// Layout a row with a certain initial state, returning the final state.
+ #[inline]
+ pub(super) fn layout_row_with_state(
+ &mut self,
+ y: usize,
+ engine: &mut Engine,
+ disambiguator: usize,
+ initial_state: RowState,
+ ) -> SourceResult<RowState> {
+ // Keep a copy of the previous value in the stack, as this function can
+ // call itself recursively (e.g. if a region break is triggered and a
+ // header is placed), so we shouldn't outright overwrite it, but rather
+ // save and later restore the state when back to this call.
+ let previous = std::mem::replace(&mut self.row_state, initial_state);
+
+ // Keep it as a separate function to allow inlining the return below,
+ // as it's usually not needed.
+ self.layout_row_internal(y, engine, disambiguator)?;
+
+ Ok(std::mem::replace(&mut self.row_state, previous))
+ }
+
+ /// Layout the given row with the default row state.
+ #[inline]
pub(super) fn layout_row(
&mut self,
y: usize,
engine: &mut Engine,
disambiguator: usize,
) -> SourceResult<()> {
+ self.layout_row_with_state(y, engine, disambiguator, RowState::default())?;
+ Ok(())
+ }
+
+ /// Layout the given row using the current state.
+ pub(super) fn layout_row_internal(
+ &mut self,
+ y: usize,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<()> {
// Skip to next region if current one is full, but only for content
// rows, not for gutter rows, and only if we aren't laying out an
// unbreakable group of rows.
@@ -206,13 +403,18 @@ impl<'a> GridLayouter<'a> {
}
// Don't layout gutter rows at the top of a region.
- if is_content_row || !self.lrows.is_empty() {
+ if is_content_row || !self.current.lrows.is_empty() {
match self.grid.rows[y] {
Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?,
Sizing::Rel(v) => {
self.layout_relative_row(engine, disambiguator, v, y)?
}
- Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y, disambiguator)),
+ Sizing::Fr(v) => {
+ if !self.row_state.in_active_repeatable {
+ self.flush_orphans();
+ }
+ self.current.lrows.push(Row::Fr(v, y, disambiguator))
+ }
}
}
@@ -225,8 +427,13 @@ impl<'a> GridLayouter<'a> {
fn render_fills_strokes(mut self) -> SourceResult<Fragment> {
let mut finished = std::mem::take(&mut self.finished);
let frame_amount = finished.len();
- for ((frame_index, frame), rows) in
- finished.iter_mut().enumerate().zip(&self.rrows)
+ for (((frame_index, frame), rows), finished_header_rows) in
+ finished.iter_mut().enumerate().zip(&self.rrows).zip(
+ self.finished_header_rows
+ .iter()
+ .map(Some)
+ .chain(std::iter::repeat(None)),
+ )
{
if self.rcols.is_empty() || rows.is_empty() {
continue;
@@ -347,7 +554,8 @@ impl<'a> GridLayouter<'a> {
let hline_indices = rows
.iter()
.map(|piece| piece.y)
- .chain(std::iter::once(self.grid.rows.len()));
+ .chain(std::iter::once(self.grid.rows.len()))
+ .enumerate();
// Converts a row to the corresponding index in the vector of
// hlines.
@@ -372,7 +580,7 @@ impl<'a> GridLayouter<'a> {
};
let mut prev_y = None;
- for (y, dy) in hline_indices.zip(hline_offsets) {
+ for ((i, y), dy) in hline_indices.zip(hline_offsets) {
// Position of lines below the row index in the previous iteration.
let expected_prev_line_position = prev_y
.map(|prev_y| {
@@ -383,47 +591,40 @@ impl<'a> GridLayouter<'a> {
})
.unwrap_or(LinePosition::Before);
- // FIXME: In the future, directly specify in 'self.rrows' when
- // we place a repeated header rather than its original rows.
- // That would let us remove most of those verbose checks, both
- // in 'lines.rs' and here. Those checks also aren't fully
- // accurate either, since they will also trigger when some rows
- // have been removed between the header and what's below it.
- let is_under_repeated_header = self
- .grid
- .header
- .as_ref()
- .and_then(Repeatable::as_repeated)
- .zip(prev_y)
- .is_some_and(|(header, prev_y)| {
- // Note: 'y == header.end' would mean we're right below
- // the NON-REPEATED header, so that case should return
- // false.
- prev_y < header.end && y > header.end
- });
+ // Header's lines at the bottom have priority when repeated.
+ // This will store the end bound of the last header if the
+ // current iteration is calculating lines under it.
+ let last_repeated_header_end_above = match finished_header_rows {
+ Some(info) if prev_y.is_some() && i == info.repeated_amount => {
+ Some(info.last_repeated_header_end)
+ }
+ _ => None,
+ };
// If some grid rows were omitted between the previous resolved
// row and the current one, we ensure lines below the previous
// row don't "disappear" and are considered, albeit with less
// priority. However, don't do this when we're below a header,
// as it must have more priority instead of less, so it is
- // chained later instead of before. The exception is when the
+ // chained later instead of before (stored in the
+ // 'header_hlines' variable below). The exception is when the
// last row in the header is removed, in which case we append
// both the lines under the row above us and also (later) the
// lines under the header's (removed) last row.
- let prev_lines = prev_y
- .filter(|prev_y| {
- prev_y + 1 != y
- && (!is_under_repeated_header
- || self
- .grid
- .header
- .as_ref()
- .and_then(Repeatable::as_repeated)
- .is_some_and(|header| prev_y + 1 != header.end))
- })
- .map(|prev_y| get_hlines_at(prev_y + 1))
- .unwrap_or(&[]);
+ let prev_lines = match prev_y {
+ Some(prev_y)
+ if prev_y + 1 != y
+ && last_repeated_header_end_above.is_none_or(
+ |last_repeated_header_end| {
+ prev_y + 1 != last_repeated_header_end
+ },
+ ) =>
+ {
+ get_hlines_at(prev_y + 1)
+ }
+
+ _ => &[],
+ };
let expected_hline_position =
expected_line_position(y, y == self.grid.rows.len());
@@ -441,15 +642,13 @@ impl<'a> GridLayouter<'a> {
};
let mut expected_header_line_position = LinePosition::Before;
- let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) =
- self.grid.header.as_ref().zip(prev_y)
- {
- if is_under_repeated_header
- && (!self.grid.has_gutter
+ let header_hlines = match (last_repeated_header_end_above, prev_y) {
+ (Some(header_end_above), Some(prev_y))
+ if !self.grid.has_gutter
|| matches!(
self.grid.rows[prev_y],
Sizing::Rel(length) if length.is_zero()
- ))
+ ) =>
{
// For lines below a header, give priority to the
// lines originally below the header rather than
@@ -468,15 +667,13 @@ impl<'a> GridLayouter<'a> {
// column-gutter is specified, for example. In that
// case, we still repeat the line under the gutter.
expected_header_line_position = expected_line_position(
- header.end,
- header.end == self.grid.rows.len(),
+ header_end_above,
+ header_end_above == self.grid.rows.len(),
);
- get_hlines_at(header.end)
- } else {
- &[]
+ get_hlines_at(header_end_above)
}
- } else {
- &[]
+
+ _ => &[],
};
// The effective hlines to be considered at this row index are
@@ -529,6 +726,7 @@ impl<'a> GridLayouter<'a> {
grid,
rows,
local_top_y,
+ last_repeated_header_end_above,
in_last_region,
y,
x,
@@ -941,15 +1139,9 @@ impl<'a> GridLayouter<'a> {
let frame = self.layout_single_row(engine, disambiguator, first, y)?;
self.push_row(frame, y, true);
- if self
- .grid
- .header
- .as_ref()
- .and_then(Repeatable::as_repeated)
- .is_some_and(|header| y < header.end)
- {
- // Add to header height.
- self.header_height += first;
+ if let Some(row_height) = &mut self.row_state.current_row_height {
+ // Add to header height, as we are in a header row.
+ *row_height += first;
}
return Ok(());
@@ -958,19 +1150,21 @@ impl<'a> GridLayouter<'a> {
// Expand all but the last region.
// Skip the first region if the space is eaten up by an fr row.
let len = resolved.len();
- for ((i, region), target) in self
- .regions
- .iter()
- .enumerate()
- .zip(&mut resolved[..len - 1])
- .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize)
+ for ((i, region), target) in
+ self.regions
+ .iter()
+ .enumerate()
+ .zip(&mut resolved[..len - 1])
+ .skip(self.current.lrows.iter().any(|row| matches!(row, Row::Fr(..)))
+ as usize)
{
// Subtract header and footer heights from the region height when
- // it's not the first.
+ // it's not the first. Ignore non-repeating headers as they only
+ // appear on the first region by definition.
target.set_max(
region.y
- if i > 0 {
- self.header_height + self.footer_height
+ self.current.repeating_header_height + self.current.footer_height
} else {
Abs::zero()
},
@@ -1181,25 +1375,19 @@ impl<'a> GridLayouter<'a> {
let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
let frame = self.layout_single_row(engine, disambiguator, resolved, y)?;
- if self
- .grid
- .header
- .as_ref()
- .and_then(Repeatable::as_repeated)
- .is_some_and(|header| y < header.end)
- {
- // Add to header height.
- self.header_height += resolved;
+ if let Some(row_height) = &mut self.row_state.current_row_height {
+ // Add to header height, as we are in a header row.
+ *row_height += resolved;
}
// Skip to fitting region, but only if we aren't part of an unbreakable
- // row group. We use 'in_last_with_offset' so our 'in_last' call
- // properly considers that a header and a footer would be added on each
- // region break.
+ // row group. We use 'may_progress_with_repeats' to stop trying if we
+ // would skip to a region with the same height and where the same
+ // headers would be repeated.
let height = frame.height();
while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(height)
- && !in_last_with_offset(self.regions, self.header_height + self.footer_height)
+ && self.may_progress_with_repeats()
{
self.finish_region(engine, false)?;
@@ -1323,8 +1511,13 @@ impl<'a> GridLayouter<'a> {
/// will be pushed for this particular row. It can be `false` for rows
/// spanning multiple regions.
fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) {
+ if !self.row_state.in_active_repeatable {
+ // There is now a row after the rows equipped with orphan
+ // prevention, so no need to keep moving them anymore.
+ self.flush_orphans();
+ }
self.regions.size.y -= frame.height();
- self.lrows.push(Row::Frame(frame, y, is_last));
+ self.current.lrows.push(Row::Frame(frame, y, is_last));
}
/// Finish rows for one region.
@@ -1333,68 +1526,73 @@ impl<'a> GridLayouter<'a> {
engine: &mut Engine,
last: bool,
) -> SourceResult<()> {
+ // The latest rows have orphan prevention (headers) and no other rows
+ // were placed, so remove those rows and try again in a new region,
+ // unless this is the last region.
+ if let Some(orphan_snapshot) = self.current.lrows_orphan_snapshot.take() {
+ if !last {
+ self.current.lrows.truncate(orphan_snapshot);
+ self.current.repeated_header_rows =
+ self.current.repeated_header_rows.min(orphan_snapshot);
+
+ if orphan_snapshot == 0 {
+ // Removed all repeated headers.
+ self.current.last_repeated_header_end = 0;
+ }
+ }
+ }
+
if self
+ .current
.lrows
.last()
.is_some_and(|row| self.grid.is_gutter_track(row.index()))
{
// Remove the last row in the region if it is a gutter row.
- self.lrows.pop().unwrap();
+ self.current.lrows.pop().unwrap();
+ self.current.repeated_header_rows =
+ self.current.repeated_header_rows.min(self.current.lrows.len());
}
- // If no rows other than the footer have been laid out so far, and
- // there are rows beside the footer, then don't lay it out at all.
- // This check doesn't apply, and is thus overridden, when there is a
- // header.
- let mut footer_would_be_orphan = self.lrows.is_empty()
- && !in_last_with_offset(
- self.regions,
- self.header_height + self.footer_height,
- )
- && self
- .grid
- .footer
- .as_ref()
- .and_then(Repeatable::as_repeated)
- .is_some_and(|footer| footer.start != 0);
-
- if let Some(Repeatable::Repeated(header)) = &self.grid.header {
- if self.grid.rows.len() > header.end
- && self
- .grid
- .footer
- .as_ref()
- .and_then(Repeatable::as_repeated)
- .is_none_or(|footer| footer.start != header.end)
- && self.lrows.last().is_some_and(|row| row.index() < header.end)
- && !in_last_with_offset(
- self.regions,
- self.header_height + self.footer_height,
- )
- {
- // Header and footer would be alone in this region, but there are more
- // rows beyond the header and the footer. Push an empty region.
- self.lrows.clear();
- footer_would_be_orphan = true;
- }
- }
+ // If no rows other than the footer have been laid out so far
+ // (e.g. due to header orphan prevention), and there are rows
+ // beside the footer, then don't lay it out at all.
+ //
+ // It is worth noting that the footer is made non-repeatable at
+ // the grid resolving stage if it is short-lived, that is, if
+ // it is at the start of the table (or right after headers at
+ // the start of the table).
+ //
+ // TODO(subfooters): explicitly check for short-lived footers.
+ // TODO(subfooters): widow prevention for non-repeated footers with a
+ // similar mechanism / when implementing multiple footers.
+ let footer_would_be_widow = matches!(&self.grid.footer, Some(footer) if footer.repeated)
+ && self.current.lrows.is_empty()
+ && self.current.could_progress_at_top;
let mut laid_out_footer_start = None;
- if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
- // Don't layout the footer if it would be alone with the header in
- // the page, and don't layout it twice.
- if !footer_would_be_orphan
- && self.lrows.iter().all(|row| row.index() < footer.start)
- {
- laid_out_footer_start = Some(footer.start);
- self.layout_footer(footer, engine, self.finished.len())?;
+ if !footer_would_be_widow {
+ if let Some(footer) = &self.grid.footer {
+ // Don't layout the footer if it would be alone with the header
+ // in the page (hence the widow check), and don't layout it
+ // twice (check below).
+ //
+ // TODO(subfooters): this check can be replaced by a vector of
+ // repeating footers in the future, and/or some "pending
+ // footers" vector for footers we're about to place.
+ if footer.repeated
+ && self.current.lrows.iter().all(|row| row.index() < footer.start)
+ {
+ laid_out_footer_start = Some(footer.start);
+ self.layout_footer(footer, engine, self.finished.len())?;
+ }
}
}
// Determine the height of existing rows in the region.
let mut used = Abs::zero();
let mut fr = Fr::zero();
- for row in &self.lrows {
+ for row in &self.current.lrows {
match row {
Row::Frame(frame, _, _) => used += frame.height(),
Row::Fr(v, _, _) => fr += *v,
@@ -1403,9 +1601,9 @@ impl<'a> GridLayouter<'a> {
// Determine the size of the grid in this region, expanding fully if
// there are fr rows.
- let mut size = Size::new(self.width, used).min(self.initial);
- if fr.get() > 0.0 && self.initial.y.is_finite() {
- size.y = self.initial.y;
+ let mut size = Size::new(self.width, used).min(self.current.initial);
+ if fr.get() > 0.0 && self.current.initial.y.is_finite() {
+ size.y = self.current.initial.y;
}
// The frame for the region.
@@ -1413,9 +1611,10 @@ impl<'a> GridLayouter<'a> {
let mut pos = Point::zero();
let mut rrows = vec![];
let current_region = self.finished.len();
+ let mut repeated_header_row_height = Abs::zero();
// Place finished rows and layout fractional rows.
- for row in std::mem::take(&mut self.lrows) {
+ for (i, row) in std::mem::take(&mut self.current.lrows).into_iter().enumerate() {
let (frame, y, is_last) = match row {
Row::Frame(frame, y, is_last) => (frame, y, is_last),
Row::Fr(v, y, disambiguator) => {
@@ -1426,6 +1625,9 @@ impl<'a> GridLayouter<'a> {
};
let height = frame.height();
+ if i < self.current.repeated_header_rows {
+ repeated_header_row_height += height;
+ }
// Ensure rowspans which span this row will have enough space to
// be laid out over it later.
@@ -1504,7 +1706,11 @@ impl<'a> GridLayouter<'a> {
// we have to check the same index again in the next
// iteration.
let rowspan = self.rowspans.remove(i);
- self.layout_rowspan(rowspan, Some((&mut output, &rrows)), engine)?;
+ self.layout_rowspan(
+ rowspan,
+ Some((&mut output, repeated_header_row_height)),
+ engine,
+ )?;
} else {
i += 1;
}
@@ -1515,21 +1721,40 @@ impl<'a> GridLayouter<'a> {
pos.y += height;
}
- self.finish_region_internal(output, rrows);
+ self.finish_region_internal(
+ output,
+ rrows,
+ FinishedHeaderRowInfo {
+ repeated_amount: self.current.repeated_header_rows,
+ last_repeated_header_end: self.current.last_repeated_header_end,
+ repeated_height: repeated_header_row_height,
+ },
+ );
if !last {
+ self.current.repeated_header_rows = 0;
+ self.current.last_repeated_header_end = 0;
+ self.current.repeating_header_height = Abs::zero();
+ self.current.repeating_header_heights.clear();
+
let disambiguator = self.finished.len();
- if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
+ if let Some(footer) =
+ self.grid.footer.as_ref().and_then(Repeatable::as_repeated)
+ {
self.prepare_footer(footer, engine, disambiguator)?;
}
- if let Some(Repeatable::Repeated(header)) = &self.grid.header {
- // Add a header to the new region.
- self.layout_header(header, engine, disambiguator)?;
- }
-
// Ensure rows don't try to overrun the footer.
- self.regions.size.y -= self.footer_height;
+ // Note that header layout will only subtract this again if it has
+ // to skip regions to fit headers, so there is no risk of
+ // subtracting this twice.
+ self.regions.size.y -= self.current.footer_height;
+ self.current.initial_after_repeats = self.regions.size.y;
+
+ if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() {
+ // Add headers to the new region.
+ self.layout_active_headers(engine)?;
+ }
}
Ok(())
@@ -1541,11 +1766,26 @@ impl<'a> GridLayouter<'a> {
&mut self,
output: Frame,
resolved_rows: Vec<RowPiece>,
+ header_row_info: FinishedHeaderRowInfo,
) {
self.finished.push(output);
self.rrows.push(resolved_rows);
self.regions.next();
- self.initial = self.regions.size;
+ self.current.initial = self.regions.size;
+
+ // Repeats haven't been laid out yet, so in the meantime, this will
+ // represent the initial height after repeats laid out so far, and will
+ // be gradually updated when preparing footers and repeating headers.
+ self.current.initial_after_repeats = self.current.initial.y;
+
+ self.current.could_progress_at_top = self.regions.may_progress();
+
+ if !self.grid.headers.is_empty() {
+ self.finished_header_rows.push(header_row_info);
+ }
+
+ // Ensure orphan prevention is handled before resolving rows.
+ debug_assert!(self.current.lrows_orphan_snapshot.is_none());
}
}
@@ -1560,13 +1800,3 @@ pub(super) fn points(
offset
})
}
-
-/// Checks if the first region of a sequence of regions is the last usable
-/// region, assuming that the last region will always be occupied by some
-/// specific offset height, even after calling `.next()`, due to some
-/// additional logic which adds content automatically on each region turn (in
-/// our case, headers).
-pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool {
- regions.backlog.is_empty()
- && regions.last.is_none_or(|height| regions.size.y + offset == height)
-}
diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs
index 7549673f..d5da7e26 100644
--- a/crates/typst-layout/src/grid/lines.rs
+++ b/crates/typst-layout/src/grid/lines.rs
@@ -391,10 +391,12 @@ pub fn vline_stroke_at_row(
///
/// This function assumes columns are sorted by increasing `x`, and rows are
/// sorted by increasing `y`.
+#[allow(clippy::too_many_arguments)]
pub fn hline_stroke_at_column(
grid: &CellGrid,
rows: &[RowPiece],
local_top_y: Option<usize>,
+ header_end_above: Option<usize>,
in_last_region: bool,
y: usize,
x: usize,
@@ -499,17 +501,15 @@ pub fn hline_stroke_at_column(
// Top border stroke and header stroke are generally prioritized, unless
// they don't have explicit hline overrides and one or more user-provided
// hlines would appear at the same position, which then are prioritized.
- let top_stroke_comes_from_header = grid
- .header
- .as_ref()
- .and_then(Repeatable::as_repeated)
- .zip(local_top_y)
- .is_some_and(|(header, local_top_y)| {
- // Ensure the row above us is a repeated header.
- // FIXME: Make this check more robust when headers at arbitrary
- // positions are added.
- local_top_y < header.end && y > header.end
- });
+ let top_stroke_comes_from_header = header_end_above.zip(local_top_y).is_some_and(
+ |(last_repeated_header_end, local_top_y)| {
+ // Check if the last repeated header row is above this line.
+ //
+ // Note that `y == last_repeated_header_end` is impossible for a
+ // strictly repeated header (not in its original position).
+ local_top_y < last_repeated_header_end && y > last_repeated_header_end
+ },
+ );
// Prioritize the footer's top stroke as well where applicable.
let bottom_stroke_comes_from_footer = grid
@@ -637,7 +637,7 @@ mod test {
},
vec![],
vec![],
- None,
+ vec![],
None,
entries,
)
@@ -1175,7 +1175,7 @@ mod test {
},
vec![],
vec![],
- None,
+ vec![],
None,
entries,
)
@@ -1268,6 +1268,7 @@ mod test {
grid,
&rows,
y.checked_sub(1),
+ None,
true,
y,
x,
@@ -1461,6 +1462,7 @@ mod test {
grid,
&rows,
y.checked_sub(1),
+ None,
true,
y,
x,
@@ -1506,6 +1508,7 @@ mod test {
grid,
&rows,
if y == 4 { Some(2) } else { y.checked_sub(1) },
+ None,
true,
y,
x,
diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs
index 22d2a09e..8db33df5 100644
--- a/crates/typst-layout/src/grid/repeated.rs
+++ b/crates/typst-layout/src/grid/repeated.rs
@@ -1,57 +1,446 @@
+use std::ops::Deref;
+
use typst_library::diag::SourceResult;
use typst_library::engine::Engine;
use typst_library::layout::grid::resolve::{Footer, Header, Repeatable};
use typst_library::layout::{Abs, Axes, Frame, Regions};
-use super::layouter::GridLayouter;
+use super::layouter::{GridLayouter, RowState};
use super::rowspans::UnbreakableRowGroup;
-impl GridLayouter<'_> {
- /// Layouts the header's rows.
- /// Skips regions as necessary.
- pub fn layout_header(
+impl<'a> GridLayouter<'a> {
+ /// Checks whether a region break could help a situation where we're out of
+ /// space for the next row. The criteria are:
+ ///
+ /// 1. If we could progress at the top of the region, that indicates the
+ /// region has a backlog, or (if we're at the first region) a region break
+ /// is at all possible (`regions.last` is `Some()`), so that's sufficient.
+ ///
+ /// 2. Otherwise, we may progress if another region break is possible
+ /// (`regions.last` is still `Some()`) and non-repeating rows have been
+ /// placed, since that means the space they occupy will be available in the
+ /// next region.
+ #[inline]
+ pub fn may_progress_with_repeats(&self) -> bool {
+ // TODO(subfooters): check below isn't enough to detect non-repeating
+ // footers... we can also change 'initial_after_repeats' to stop being
+ // calculated if there were any non-repeating footers.
+ self.current.could_progress_at_top
+ || self.regions.last.is_some()
+ && self.regions.size.y != self.current.initial_after_repeats
+ }
+
+ pub fn place_new_headers(
+ &mut self,
+ consecutive_header_count: &mut usize,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ *consecutive_header_count += 1;
+ let (consecutive_headers, new_upcoming_headers) =
+ self.upcoming_headers.split_at(*consecutive_header_count);
+
+ if new_upcoming_headers.first().is_some_and(|next_header| {
+ consecutive_headers.last().is_none_or(|latest_header| {
+ !latest_header.short_lived
+ && next_header.range.start == latest_header.range.end
+ }) && !next_header.short_lived
+ }) {
+ // More headers coming, so wait until we reach them.
+ return Ok(());
+ }
+
+ self.upcoming_headers = new_upcoming_headers;
+ *consecutive_header_count = 0;
+
+ let [first_header, ..] = consecutive_headers else {
+ self.flush_orphans();
+ return Ok(());
+ };
+
+ // Assuming non-conflicting headers sorted by increasing y, this must
+ // be the header with the lowest level (sorted by increasing levels).
+ let first_level = first_header.level;
+
+ // Stop repeating conflicting headers, even if the new headers are
+ // short-lived or won't repeat.
+ //
+ // If we go to a new region before the new headers fit alongside their
+ // children (or in general, for short-lived), the old headers should
+ // not be displayed anymore.
+ let first_conflicting_pos =
+ self.repeating_headers.partition_point(|h| h.level < first_level);
+ self.repeating_headers.truncate(first_conflicting_pos);
+
+ // Ensure upcoming rows won't see that these headers will occupy any
+ // space in future regions anymore.
+ for removed_height in
+ self.current.repeating_header_heights.drain(first_conflicting_pos..)
+ {
+ self.current.repeating_header_height -= removed_height;
+ }
+
+ // Layout short-lived headers immediately.
+ if consecutive_headers.last().is_some_and(|h| h.short_lived) {
+ // No chance of orphans as we're immediately placing conflicting
+ // headers afterwards, which basically are not headers, for all intents
+ // and purposes. It is therefore guaranteed that all new headers have
+ // been placed at least once.
+ self.flush_orphans();
+
+ // Layout each conflicting header independently, without orphan
+ // prevention (as they don't go into 'pending_headers').
+ // These headers are short-lived as they are immediately followed by a
+ // header of the same or lower level, such that they never actually get
+ // to repeat.
+ self.layout_new_headers(consecutive_headers, true, engine)?;
+ } else {
+ // Let's try to place pending headers at least once.
+ // This might be a waste as we could generate an orphan and thus have
+ // to try to place old and new headers all over again, but that happens
+ // for every new region anyway, so it's rather unavoidable.
+ let snapshot_created =
+ self.layout_new_headers(consecutive_headers, false, engine)?;
+
+ // Queue the new headers for layout. They will remain in this
+ // vector due to orphan prevention.
+ //
+ // After the first subsequent row is laid out, move to repeating, as
+ // it's then confirmed the headers won't be moved due to orphan
+ // prevention anymore.
+ self.pending_headers = consecutive_headers;
+
+ if !snapshot_created {
+ // Region probably couldn't progress.
+ //
+ // Mark new pending headers as final and ensure there isn't a
+ // snapshot.
+ self.flush_orphans();
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Lays out rows belonging to a header, returning the calculated header
+ /// height only for that header. Indicates to the laid out rows that they
+ /// should inform their laid out heights if appropriate (auto or fixed
+ /// size rows only).
+ #[inline]
+ fn layout_header_rows(
&mut self,
header: &Header,
engine: &mut Engine,
disambiguator: usize,
- ) -> SourceResult<()> {
- let header_rows =
- self.simulate_header(header, &self.regions, engine, disambiguator)?;
+ as_short_lived: bool,
+ ) -> SourceResult<Abs> {
+ let mut header_height = Abs::zero();
+ for y in header.range.clone() {
+ header_height += self
+ .layout_row_with_state(
+ y,
+ engine,
+ disambiguator,
+ RowState {
+ current_row_height: Some(Abs::zero()),
+ in_active_repeatable: !as_short_lived,
+ },
+ )?
+ .current_row_height
+ .unwrap_or_default();
+ }
+ Ok(header_height)
+ }
+
+ /// This function should be called each time an additional row has been
+ /// laid out in a region to indicate that orphan prevention has succeeded.
+ ///
+ /// It removes the current orphan snapshot and flushes pending headers,
+ /// such that a non-repeating header won't try to be laid out again
+ /// anymore, and a repeating header will begin to be part of
+ /// `repeating_headers`.
+ pub fn flush_orphans(&mut self) {
+ self.current.lrows_orphan_snapshot = None;
+ self.flush_pending_headers();
+ }
+
+ /// Indicates all currently pending headers have been successfully placed
+ /// once, since another row has been placed after them, so they are
+ /// certainly not orphans.
+ pub fn flush_pending_headers(&mut self) {
+ if self.pending_headers.is_empty() {
+ return;
+ }
+
+ for header in self.pending_headers {
+ if header.repeated {
+ // Vector remains sorted by increasing levels:
+ // - 'pending_headers' themselves are sorted, since we only
+ // push non-mutually-conflicting headers at a time.
+ // - Before pushing new pending headers in
+ // 'layout_new_pending_headers', we truncate repeating headers
+ // to remove anything with the same or higher levels as the
+ // first pending header.
+ // - Assuming it was sorted before, that truncation only keeps
+ // elements with a lower level.
+ // - Therefore, by pushing this header to the end, it will have
+ // a level larger than all the previous headers, and is thus
+ // in its 'correct' position.
+ self.repeating_headers.push(header);
+ }
+ }
+
+ self.pending_headers = Default::default();
+ }
+
+ /// Lays out the rows of repeating and pending headers at the top of the
+ /// region.
+ ///
+ /// Assumes the footer height for the current region has already been
+ /// calculated. Skips regions as necessary to fit all headers and all
+ /// footers.
+ pub fn layout_active_headers(&mut self, engine: &mut Engine) -> SourceResult<()> {
+ // Generate different locations for content in headers across its
+ // repetitions by assigning a unique number for each one.
+ let disambiguator = self.finished.len();
+
+ let header_height = self.simulate_header_height(
+ self.repeating_headers
+ .iter()
+ .copied()
+ .chain(self.pending_headers.iter().map(Repeatable::deref)),
+ &self.regions,
+ engine,
+ disambiguator,
+ )?;
+
+ // We already take the footer into account below.
+ // While skipping regions, footer height won't be automatically
+ // re-calculated until the end.
let mut skipped_region = false;
while self.unbreakable_rows_left == 0
- && !self.regions.size.y.fits(header_rows.height + self.footer_height)
- && self.regions.may_progress()
+ && !self.regions.size.y.fits(header_height)
+ && self.may_progress_with_repeats()
{
// Advance regions without any output until we can place the
// header and the footer.
- self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]);
+ self.finish_region_internal(
+ Frame::soft(Axes::splat(Abs::zero())),
+ vec![],
+ Default::default(),
+ );
+
+ // TODO(layout model): re-calculate heights of headers and footers
+ // on each region if 'full' changes? (Assuming height doesn't
+ // change for now...)
+ //
+ // Would remove the footer height update below (move it here).
skipped_region = true;
- }
- // Reset the header height for this region.
- // It will be re-calculated when laying out each header row.
- self.header_height = Abs::zero();
+ self.regions.size.y -= self.current.footer_height;
+ self.current.initial_after_repeats = self.regions.size.y;
+ }
- if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
- if skipped_region {
+ if let Some(footer) = &self.grid.footer {
+ if footer.repeated && skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
- self.footer_height = self
+ self.regions.size.y += self.current.footer_height;
+ self.current.footer_height = self
.simulate_footer(footer, &self.regions, engine, disambiguator)?
.height;
+ self.regions.size.y -= self.current.footer_height;
}
}
- // Header is unbreakable.
+ let repeating_header_rows =
+ total_header_row_count(self.repeating_headers.iter().copied());
+
+ let pending_header_rows =
+ total_header_row_count(self.pending_headers.iter().map(Repeatable::deref));
+
+ // Group of headers is unbreakable.
// Thus, no risk of 'finish_region' being recursively called from
// within 'layout_row'.
- self.unbreakable_rows_left += header.end;
- for y in 0..header.end {
- self.layout_row(y, engine, disambiguator)?;
+ self.unbreakable_rows_left += repeating_header_rows + pending_header_rows;
+
+ self.current.last_repeated_header_end =
+ self.repeating_headers.last().map(|h| h.range.end).unwrap_or_default();
+
+ // Reset the header height for this region.
+ // It will be re-calculated when laying out each header row.
+ self.current.repeating_header_height = Abs::zero();
+ self.current.repeating_header_heights.clear();
+
+ debug_assert!(self.current.lrows.is_empty());
+ debug_assert!(self.current.lrows_orphan_snapshot.is_none());
+ let may_progress = self.may_progress_with_repeats();
+
+ if may_progress {
+ // Enable orphan prevention for headers at the top of the region.
+ // Otherwise, we will flush pending headers below, after laying
+ // them out.
+ //
+ // It is very rare for this to make a difference as we're usually
+ // at the 'last' region after the first skip, at which the snapshot
+ // is handled by 'layout_new_headers'. Either way, we keep this
+ // here for correctness.
+ self.current.lrows_orphan_snapshot = Some(self.current.lrows.len());
+ }
+
+ // Use indices to avoid double borrow. We don't mutate headers in
+ // 'layout_row' so this is fine.
+ let mut i = 0;
+ while let Some(&header) = self.repeating_headers.get(i) {
+ let header_height =
+ self.layout_header_rows(header, engine, disambiguator, false)?;
+ self.current.repeating_header_height += header_height;
+
+ // We assume that this vector will be sorted according
+ // to increasing levels like 'repeating_headers' and
+ // 'pending_headers' - and, in particular, their union, as this
+ // vector is pushed repeating heights from both.
+ //
+ // This is guaranteed by:
+ // 1. We always push pending headers after repeating headers,
+ // as we assume they don't conflict because we remove
+ // conflicting repeating headers when pushing a new pending
+ // header.
+ //
+ // 2. We push in the same order as each.
+ //
+ // 3. This vector is also modified when pushing a new pending
+ // header, where we remove heights for conflicting repeating
+ // headers which have now stopped repeating. They are always at
+ // the end and new pending headers respect the existing sort,
+ // so the vector will remain sorted.
+ self.current.repeating_header_heights.push(header_height);
+
+ i += 1;
+ }
+
+ self.current.repeated_header_rows = self.current.lrows.len();
+ self.current.initial_after_repeats = self.regions.size.y;
+
+ let mut has_non_repeated_pending_header = false;
+ for header in self.pending_headers {
+ if !header.repeated {
+ self.current.initial_after_repeats = self.regions.size.y;
+ has_non_repeated_pending_header = true;
+ }
+ let header_height =
+ self.layout_header_rows(header, engine, disambiguator, false)?;
+ if header.repeated {
+ self.current.repeating_header_height += header_height;
+ self.current.repeating_header_heights.push(header_height);
+ }
+ }
+
+ if !has_non_repeated_pending_header {
+ self.current.initial_after_repeats = self.regions.size.y;
}
+
+ if !may_progress {
+ // Flush pending headers immediately, as placing them again later
+ // won't help.
+ self.flush_orphans();
+ }
+
Ok(())
}
+ /// Lays out headers found for the first time during row layout.
+ ///
+ /// If 'short_lived' is true, these headers are immediately followed by
+ /// a conflicting header, so it is assumed they will not be pushed to
+ /// pending headers.
+ ///
+ /// Returns whether orphan prevention was successfully setup, or couldn't
+ /// due to short-lived headers or the region couldn't progress.
+ pub fn layout_new_headers(
+ &mut self,
+ headers: &'a [Repeatable<Header>],
+ short_lived: bool,
+ engine: &mut Engine,
+ ) -> SourceResult<bool> {
+ // At first, only consider the height of the given headers. However,
+ // for upcoming regions, we will have to consider repeating headers as
+ // well.
+ let header_height = self.simulate_header_height(
+ headers.iter().map(Repeatable::deref),
+ &self.regions,
+ engine,
+ 0,
+ )?;
+
+ while self.unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(header_height)
+ && self.may_progress_with_repeats()
+ {
+ // Note that, after the first region skip, the new headers will go
+ // at the top of the region, but after the repeating headers that
+ // remained (which will be automatically placed in 'finish_region').
+ self.finish_region(engine, false)?;
+ }
+
+ // Remove new headers at the end of the region if the upcoming row
+ // doesn't fit.
+ // TODO(subfooters): what if there is a footer right after it?
+ let should_snapshot = !short_lived
+ && self.current.lrows_orphan_snapshot.is_none()
+ && self.may_progress_with_repeats();
+
+ if should_snapshot {
+ // If we don't enter this branch while laying out non-short lived
+ // headers, that means we will have to immediately flush pending
+ // headers and mark them as final, since trying to place them in
+ // the next page won't help get more space.
+ self.current.lrows_orphan_snapshot = Some(self.current.lrows.len());
+ }
+
+ let mut at_top = self.regions.size.y == self.current.initial_after_repeats;
+
+ self.unbreakable_rows_left +=
+ total_header_row_count(headers.iter().map(Repeatable::deref));
+
+ for header in headers {
+ let header_height = self.layout_header_rows(header, engine, 0, false)?;
+
+ // Only store this header height if it is actually going to
+ // become a pending header. Otherwise, pretend it's not a
+ // header... This is fine for consumers of 'header_height' as
+ // it is guaranteed this header won't appear in a future
+ // region, so multi-page rows and cells can effectively ignore
+ // this header.
+ if !short_lived && header.repeated {
+ self.current.repeating_header_height += header_height;
+ self.current.repeating_header_heights.push(header_height);
+ if at_top {
+ self.current.initial_after_repeats = self.regions.size.y;
+ }
+ } else {
+ at_top = false;
+ }
+ }
+
+ Ok(should_snapshot)
+ }
+
+ /// Calculates the total expected height of several headers.
+ pub fn simulate_header_height<'h: 'a>(
+ &self,
+ headers: impl IntoIterator<Item = &'h Header>,
+ regions: &Regions<'_>,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<Abs> {
+ let mut height = Abs::zero();
+ for header in headers {
+ height +=
+ self.simulate_header(header, regions, engine, disambiguator)?.height;
+ }
+ Ok(height)
+ }
+
/// Simulate the header's group of rows.
pub fn simulate_header(
&self,
@@ -66,8 +455,8 @@ impl GridLayouter<'_> {
// assume that the amount of unbreakable rows following the first row
// in the header will be precisely the rows in the header.
self.simulate_unbreakable_row_group(
- 0,
- Some(header.end),
+ header.range.start,
+ Some(header.range.end - header.range.start),
regions,
engine,
disambiguator,
@@ -91,11 +480,22 @@ impl GridLayouter<'_> {
{
// Advance regions without any output until we can place the
// footer.
- self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]);
+ self.finish_region_internal(
+ Frame::soft(Axes::splat(Abs::zero())),
+ vec![],
+ Default::default(),
+ );
skipped_region = true;
}
- self.footer_height = if skipped_region {
+ // TODO(subfooters): Consider resetting header height etc. if we skip
+ // region. (Maybe move that step to `finish_region_internal`.)
+ //
+ // That is unnecessary at the moment as 'prepare_footers' is only
+ // called at the start of the region, so header height is always zero
+ // and no headers were placed so far, but what about when we can have
+ // footers in the middle of the region? Let's think about this then.
+ self.current.footer_height = if skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
self.simulate_footer(footer, &self.regions, engine, disambiguator)?
@@ -118,12 +518,22 @@ impl GridLayouter<'_> {
// Ensure footer rows have their own height available.
// Won't change much as we're creating an unbreakable row group
// anyway, so this is mostly for correctness.
- self.regions.size.y += self.footer_height;
+ self.regions.size.y += self.current.footer_height;
+ let repeats = self.grid.footer.as_ref().is_some_and(|f| f.repeated);
let footer_len = self.grid.rows.len() - footer.start;
self.unbreakable_rows_left += footer_len;
+
for y in footer.start..self.grid.rows.len() {
- self.layout_row(y, engine, disambiguator)?;
+ self.layout_row_with_state(
+ y,
+ engine,
+ disambiguator,
+ RowState {
+ in_active_repeatable: repeats,
+ ..Default::default()
+ },
+ )?;
}
Ok(())
@@ -144,10 +554,18 @@ impl GridLayouter<'_> {
// in the footer will be precisely the rows in the footer.
self.simulate_unbreakable_row_group(
footer.start,
- Some(self.grid.rows.len() - footer.start),
+ Some(footer.end - footer.start),
regions,
engine,
disambiguator,
)
}
}
+
+/// The total amount of rows in the given list of headers.
+#[inline]
+pub fn total_header_row_count<'h>(
+ headers: impl IntoIterator<Item = &'h Header>,
+) -> usize {
+ headers.into_iter().map(|h| h.range.end - h.range.start).sum()
+}
diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs
index 5ab0417d..02ea1481 100644
--- a/crates/typst-layout/src/grid/rowspans.rs
+++ b/crates/typst-layout/src/grid/rowspans.rs
@@ -4,7 +4,7 @@ use typst_library::foundations::Resolve;
use typst_library::layout::grid::resolve::Repeatable;
use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing};
-use super::layouter::{in_last_with_offset, points, Row, RowPiece};
+use super::layouter::{points, Row};
use super::{layout_cell, Cell, GridLayouter};
/// All information needed to layout a single rowspan.
@@ -90,10 +90,10 @@ pub struct CellMeasurementData<'layouter> {
impl GridLayouter<'_> {
/// Layout a rowspan over the already finished regions, plus the current
- /// region's frame and resolved rows, if it wasn't finished yet (because
- /// we're being called from `finish_region`, but note that this function is
- /// also called once after all regions are finished, in which case
- /// `current_region_data` is `None`).
+ /// region's frame and height of resolved header rows, if it wasn't
+ /// finished yet (because we're being called from `finish_region`, but note
+ /// that this function is also called once after all regions are finished,
+ /// in which case `current_region_data` is `None`).
///
/// We need to do this only once we already know the heights of all
/// spanned rows, which is only possible after laying out the last row
@@ -101,7 +101,7 @@ impl GridLayouter<'_> {
pub fn layout_rowspan(
&mut self,
rowspan_data: Rowspan,
- current_region_data: Option<(&mut Frame, &[RowPiece])>,
+ current_region_data: Option<(&mut Frame, Abs)>,
engine: &mut Engine,
) -> SourceResult<()> {
let Rowspan {
@@ -146,11 +146,31 @@ impl GridLayouter<'_> {
// Push the layouted frames directly into the finished frames.
let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?;
- let (current_region, current_rrows) = current_region_data.unzip();
- for ((i, finished), frame) in self
+ let (current_region, current_header_row_height) = current_region_data.unzip();
+
+ // Clever trick to process finished header rows:
+ // - If there are grid headers, the vector will be filled with one
+ // finished header row height per region, so, chaining with the height
+ // for the current one, we get the header row height for each region.
+ //
+ // - But if there are no grid headers, the vector will be empty, so in
+ // theory the regions and resolved header row heights wouldn't match.
+ // But that's fine - 'current_header_row_height' can only be either
+ // 'Some(zero)' or 'None' in such a case, and for all other rows we
+ // append infinite zeros. That is, in such a case, the resolved header
+ // row height is always zero, so that's our fallback.
+ let finished_header_rows = self
+ .finished_header_rows
+ .iter()
+ .map(|info| info.repeated_height)
+ .chain(current_header_row_height)
+ .chain(std::iter::repeat(Abs::zero()));
+
+ for ((i, (finished, header_dy)), frame) in self
.finished
.iter_mut()
.chain(current_region.into_iter())
+ .zip(finished_header_rows)
.skip(first_region)
.enumerate()
.zip(fragment)
@@ -162,22 +182,9 @@ impl GridLayouter<'_> {
} else {
// The rowspan continuation starts after the header (thus,
// at a position after the sum of the laid out header
- // rows).
- if let Some(Repeatable::Repeated(header)) = &self.grid.header {
- let header_rows = self
- .rrows
- .get(i)
- .map(Vec::as_slice)
- .or(current_rrows)
- .unwrap_or(&[])
- .iter()
- .take_while(|row| row.y < header.end);
-
- header_rows.map(|row| row.height).sum()
- } else {
- // Without a header, start at the very top of the region.
- Abs::zero()
- }
+ // rows). Without a header, this is zero, so the rowspan can
+ // start at the very top of the region as usual.
+ header_dy
};
finished.push_frame(Point::new(dx, dy), frame);
@@ -231,15 +238,13 @@ impl GridLayouter<'_> {
// current row is dynamic and depends on the amount of upcoming
// unbreakable cells (with or without a rowspan setting).
let mut amount_unbreakable_rows = None;
- if let Some(Repeatable::NotRepeated(header)) = &self.grid.header {
- if current_row < header.end {
- // Non-repeated header, so keep it unbreakable.
- amount_unbreakable_rows = Some(header.end);
- }
- }
- if let Some(Repeatable::NotRepeated(footer)) = &self.grid.footer {
- if current_row >= footer.start {
+ if let Some(footer) = &self.grid.footer {
+ if !footer.repeated && current_row >= footer.start {
// Non-repeated footer, so keep it unbreakable.
+ //
+ // TODO(subfooters): This will become unnecessary
+ // once non-repeated footers are treated differently and
+ // have widow prevention.
amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start);
}
}
@@ -254,10 +259,7 @@ impl GridLayouter<'_> {
// Skip to fitting region.
while !self.regions.size.y.fits(row_group.height)
- && !in_last_with_offset(
- self.regions,
- self.header_height + self.footer_height,
- )
+ && self.may_progress_with_repeats()
{
self.finish_region(engine, false)?;
}
@@ -396,16 +398,29 @@ impl GridLayouter<'_> {
// auto rows don't depend on the backlog, as they only span one
// region.
if breakable
- && (matches!(self.grid.header, Some(Repeatable::Repeated(_)))
- || matches!(self.grid.footer, Some(Repeatable::Repeated(_))))
+ && (!self.repeating_headers.is_empty()
+ || !self.pending_headers.is_empty()
+ || matches!(&self.grid.footer, Some(footer) if footer.repeated))
{
// Subtract header and footer height from all upcoming regions
// when measuring the cell, including the last repeated region.
//
// This will update the 'custom_backlog' vector with the
// updated heights of the upcoming regions.
+ //
+ // We predict that header height will only include that of
+ // repeating headers, as we can assume non-repeating headers in
+ // the first region have been successfully placed, unless
+ // something didn't fit on the first region of the auto row,
+ // but we will only find that out after measurement, and if
+ // that happens, we discard the measurement and try again.
let mapped_regions = self.regions.map(&mut custom_backlog, |size| {
- Size::new(size.x, size.y - self.header_height - self.footer_height)
+ Size::new(
+ size.x,
+ size.y
+ - self.current.repeating_header_height
+ - self.current.footer_height,
+ )
});
// Callees must use the custom backlog instead of the current
@@ -459,6 +474,7 @@ impl GridLayouter<'_> {
// Height of the rowspan covered by spanned rows in the current
// region.
let laid_out_height: Abs = self
+ .current
.lrows
.iter()
.filter_map(|row| match row {
@@ -506,7 +522,12 @@ impl GridLayouter<'_> {
.iter()
.copied()
.chain(std::iter::once(if breakable {
- self.initial.y - self.header_height - self.footer_height
+ // Here we are calculating the available height for a
+ // rowspan from the top of the current region, so
+ // we have to use initial header heights (note that
+ // header height can change in the middle of the
+ // region).
+ self.current.initial_after_repeats
} else {
// When measuring unbreakable auto rows, infinite
// height is available for content to expand.
@@ -518,11 +539,13 @@ impl GridLayouter<'_> {
// rowspan's already laid out heights with the current
// region's height and current backlog to ensure a good
// level of accuracy in the measurements.
- let backlog = self
- .regions
- .backlog
- .iter()
- .map(|&size| size - self.header_height - self.footer_height);
+ //
+ // Assume only repeating headers will survive starting at
+ // the next region.
+ let backlog = self.regions.backlog.iter().map(|&size| {
+ size - self.current.repeating_header_height
+ - self.current.footer_height
+ });
heights_up_to_current_region.chain(backlog).collect::<Vec<_>>()
} else {
@@ -536,10 +559,10 @@ impl GridLayouter<'_> {
height = *rowspan_height;
backlog = None;
full = rowspan_full;
- last = self
- .regions
- .last
- .map(|size| size - self.header_height - self.footer_height);
+ last = self.regions.last.map(|size| {
+ size - self.current.repeating_header_height
+ - self.current.footer_height
+ });
} else {
// The rowspan started in the current region, as its vector
// of heights in regions is currently empty.
@@ -741,10 +764,11 @@ impl GridLayouter<'_> {
simulated_regions.next();
disambiguator += 1;
- // Subtract the initial header and footer height, since that's the
- // height we used when subtracting from the region backlog's
+ // Subtract the repeating header and footer height, since that's
+ // the height we used when subtracting from the region backlog's
// heights while measuring cells.
- simulated_regions.size.y -= self.header_height + self.footer_height;
+ simulated_regions.size.y -=
+ self.current.repeating_header_height + self.current.footer_height;
}
if let Some(original_last_resolved_size) = last_resolved_size {
@@ -876,12 +900,8 @@ impl GridLayouter<'_> {
// which, when used and combined with upcoming spanned rows, covers all
// of the requested rowspan height, we give up.
for _attempt in 0..5 {
- let rowspan_simulator = RowspanSimulator::new(
- disambiguator,
- simulated_regions,
- self.header_height,
- self.footer_height,
- );
+ let rowspan_simulator =
+ RowspanSimulator::new(disambiguator, simulated_regions, &self.current);
let total_spanned_height = rowspan_simulator.simulate_rowspan_layout(
y,
@@ -963,7 +983,8 @@ impl GridLayouter<'_> {
{
extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero());
simulated_regions.next();
- simulated_regions.size.y -= self.header_height + self.footer_height;
+ simulated_regions.size.y -=
+ self.current.repeating_header_height + self.current.footer_height;
disambiguator += 1;
}
simulated_regions.size.y -= extra_amount_to_grow;
@@ -980,10 +1001,17 @@ struct RowspanSimulator<'a> {
finished: usize,
/// The state of regions during the simulation.
regions: Regions<'a>,
- /// The height of the header in the currently simulated region.
+ /// The total height of headers in the currently simulated region.
header_height: Abs,
- /// The height of the footer in the currently simulated region.
+ /// The total height of footers in the currently simulated region.
footer_height: Abs,
+ /// Whether `self.regions.may_progress()` was `true` at the top of the
+ /// region, indicating we can progress anywhere in the current region,
+ /// even right after a repeated header.
+ could_progress_at_top: bool,
+ /// Available height after laying out repeated headers at the top of the
+ /// currently simulated region.
+ initial_after_repeats: Abs,
/// The total spanned height so far in the simulation.
total_spanned_height: Abs,
/// Height of the latest spanned gutter row in the simulation.
@@ -997,14 +1025,19 @@ impl<'a> RowspanSimulator<'a> {
fn new(
finished: usize,
regions: Regions<'a>,
- header_height: Abs,
- footer_height: Abs,
+ current: &super::layouter::Current,
) -> Self {
Self {
finished,
regions,
- header_height,
- footer_height,
+ // There can be no new headers or footers within a multi-page
+ // rowspan, since headers and footers are unbreakable, so
+ // assuming the repeating header height and footer height
+ // won't change is safe.
+ header_height: current.repeating_header_height,
+ footer_height: current.footer_height,
+ could_progress_at_top: current.could_progress_at_top,
+ initial_after_repeats: current.initial_after_repeats,
total_spanned_height: Abs::zero(),
latest_spanned_gutter_height: Abs::zero(),
}
@@ -1053,10 +1086,7 @@ impl<'a> RowspanSimulator<'a> {
0,
)?;
while !self.regions.size.y.fits(row_group.height)
- && !in_last_with_offset(
- self.regions,
- self.header_height + self.footer_height,
- )
+ && self.may_progress_with_repeats()
{
self.finish_region(layouter, engine)?;
}
@@ -1078,10 +1108,7 @@ impl<'a> RowspanSimulator<'a> {
let mut skipped_region = false;
while unbreakable_rows_left == 0
&& !self.regions.size.y.fits(height)
- && !in_last_with_offset(
- self.regions,
- self.header_height + self.footer_height,
- )
+ && self.may_progress_with_repeats()
{
self.finish_region(layouter, engine)?;
@@ -1127,23 +1154,37 @@ impl<'a> RowspanSimulator<'a> {
// our simulation checks what happens AFTER the auto row, so we can
// just use the original backlog from `self.regions`.
let disambiguator = self.finished;
- let header_height =
- if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
- layouter
- .simulate_header(header, &self.regions, engine, disambiguator)?
- .height
- } else {
- Abs::zero()
- };
- let footer_height =
- if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer {
- layouter
- .simulate_footer(footer, &self.regions, engine, disambiguator)?
- .height
- } else {
- Abs::zero()
- };
+ let (repeating_headers, header_height) = if !layouter.repeating_headers.is_empty()
+ || !layouter.pending_headers.is_empty()
+ {
+ // Only repeating headers have survived after the first region
+ // break.
+ let repeating_headers = layouter.repeating_headers.iter().copied().chain(
+ layouter.pending_headers.iter().filter_map(Repeatable::as_repeated),
+ );
+
+ let header_height = layouter.simulate_header_height(
+ repeating_headers.clone(),
+ &self.regions,
+ engine,
+ disambiguator,
+ )?;
+
+ (Some(repeating_headers), header_height)
+ } else {
+ (None, Abs::zero())
+ };
+
+ let footer_height = if let Some(footer) =
+ layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated)
+ {
+ layouter
+ .simulate_footer(footer, &self.regions, engine, disambiguator)?
+ .height
+ } else {
+ Abs::zero()
+ };
let mut skipped_region = false;
@@ -1156,19 +1197,24 @@ impl<'a> RowspanSimulator<'a> {
skipped_region = true;
}
- if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
+ if let Some(repeating_headers) = repeating_headers {
self.header_height = if skipped_region {
// Simulate headers again, at the new region, as
// the full region height may change.
- layouter
- .simulate_header(header, &self.regions, engine, disambiguator)?
- .height
+ layouter.simulate_header_height(
+ repeating_headers,
+ &self.regions,
+ engine,
+ disambiguator,
+ )?
} else {
header_height
};
}
- if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer {
+ if let Some(footer) =
+ layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated)
+ {
self.footer_height = if skipped_region {
// Simulate footers again, at the new region, as
// the full region height may change.
@@ -1185,6 +1231,7 @@ impl<'a> RowspanSimulator<'a> {
// header or footer (as an invariant, any rowspans spanning any header
// or footer rows are fully contained within that header's or footer's rows).
self.regions.size.y -= self.header_height + self.footer_height;
+ self.initial_after_repeats = self.regions.size.y;
Ok(())
}
@@ -1201,8 +1248,18 @@ impl<'a> RowspanSimulator<'a> {
self.regions.next();
self.finished += 1;
+ self.could_progress_at_top = self.regions.may_progress();
self.simulate_header_footer_layout(layouter, engine)
}
+
+ /// Similar to [`GridLayouter::may_progress_with_repeats`] but for rowspan
+ /// simulation.
+ #[inline]
+ fn may_progress_with_repeats(&self) -> bool {
+ self.could_progress_at_top
+ || self.regions.last.is_some()
+ && self.regions.size.y != self.initial_after_repeats
+ }
}
/// Subtracts some size from the end of a vector of sizes.
diff --git a/crates/typst-library/src/foundations/int.rs b/crates/typst-library/src/foundations/int.rs
index 83a89bf8..f65641ff 100644
--- a/crates/typst-library/src/foundations/int.rs
+++ b/crates/typst-library/src/foundations/int.rs
@@ -1,4 +1,6 @@
-use std::num::{NonZeroI64, NonZeroIsize, NonZeroU64, NonZeroUsize, ParseIntError};
+use std::num::{
+ NonZeroI64, NonZeroIsize, NonZeroU32, NonZeroU64, NonZeroUsize, ParseIntError,
+};
use ecow::{eco_format, EcoString};
use smallvec::SmallVec;
@@ -482,3 +484,16 @@ cast! {
"number too large"
})?,
}
+
+cast! {
+ NonZeroU32,
+ self => Value::Int(self.get() as _),
+ v: i64 => v
+ .try_into()
+ .and_then(|v: u32| v.try_into())
+ .map_err(|_| if v <= 0 {
+ "number must be positive"
+ } else {
+ "number too large"
+ })?,
+}
diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs
index 369df11e..52621c64 100644
--- a/crates/typst-library/src/layout/grid/mod.rs
+++ b/crates/typst-library/src/layout/grid/mod.rs
@@ -1,6 +1,6 @@
pub mod resolve;
-use std::num::NonZeroUsize;
+use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::Arc;
use comemo::Track;
@@ -468,6 +468,17 @@ pub struct GridHeader {
#[default(true)]
pub repeat: bool,
+ /// The level of the header. Must not be zero.
+ ///
+ /// This allows repeating multiple headers at once. Headers with different
+ /// levels can repeat together, as long as they have ascending levels.
+ ///
+ /// Notably, when a header with a lower level starts repeating, all higher
+ /// or equal level headers stop repeating (they are "replaced" by the new
+ /// header).
+ #[default(NonZeroU32::ONE)]
+ pub level: NonZeroU32,
+
/// The cells and lines within the header.
#[variadic]
pub children: Vec<GridItem>,
diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs
index bad25b47..baf6b738 100644
--- a/crates/typst-library/src/layout/grid/resolve.rs
+++ b/crates/typst-library/src/layout/grid/resolve.rs
@@ -1,5 +1,5 @@
-use std::num::NonZeroUsize;
-use std::ops::Range;
+use std::num::{NonZeroU32, NonZeroUsize};
+use std::ops::{Deref, DerefMut, Range};
use std::sync::Arc;
use ecow::eco_format;
@@ -48,6 +48,7 @@ pub fn grid_to_cellgrid<'a>(
let children = elem.children.iter().map(|child| match child {
GridChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles),
+ level: header.level(styles),
span: header.span(),
items: header.children.iter().map(resolve_item),
},
@@ -101,6 +102,7 @@ pub fn table_to_cellgrid<'a>(
let children = elem.children.iter().map(|child| match child {
TableChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles),
+ level: header.level(styles),
span: header.span(),
items: header.children.iter().map(resolve_item),
},
@@ -426,8 +428,20 @@ pub struct Line {
/// A repeatable grid header. Starts at the first row.
#[derive(Debug)]
pub struct Header {
- /// The index after the last row included in this header.
- pub end: usize,
+ /// The range of rows included in this header.
+ pub range: Range<usize>,
+ /// The header's level.
+ ///
+ /// Higher level headers repeat together with lower level headers. If a
+ /// lower level header stops repeating, all higher level headers do as
+ /// well.
+ pub level: u32,
+ /// Whether this header cannot be repeated nor should have orphan
+ /// prevention because it would be about to cease repetition, either
+ /// because it is followed by headers of conflicting levels, or because
+ /// it is at the end of the table (possibly followed by some footers at the
+ /// end).
+ pub short_lived: bool,
}
/// A repeatable grid footer. Stops at the last row.
@@ -435,32 +449,56 @@ pub struct Header {
pub struct Footer {
/// The first row included in this footer.
pub start: usize,
+ /// The index after the last row included in this footer.
+ pub end: usize,
+ /// The footer's level.
+ ///
+ /// Used similarly to header level.
+ pub level: u32,
+}
+
+impl Footer {
+ /// The footer's range of included rows.
+ #[inline]
+ pub fn range(&self) -> Range<usize> {
+ self.start..self.end
+ }
}
-/// A possibly repeatable grid object.
+/// A possibly repeatable grid child (header or footer).
+///
/// It still exists even when not repeatable, but must not have additional
/// considerations by grid layout, other than for consistency (such as making
/// a certain group of rows unbreakable).
-pub enum Repeatable<T> {
- Repeated(T),
- NotRepeated(T),
+pub struct Repeatable<T> {
+ inner: T,
+
+ /// Whether the user requested the child to repeat.
+ pub repeated: bool,
}
-impl<T> Repeatable<T> {
- /// Gets the value inside this repeatable, regardless of whether
- /// it repeats.
- pub fn unwrap(&self) -> &T {
- match self {
- Self::Repeated(repeated) => repeated,
- Self::NotRepeated(not_repeated) => not_repeated,
- }
+impl<T> Deref for Repeatable<T> {
+ type Target = T;
+
+ fn deref(&self) -> &Self::Target {
+ &self.inner
+ }
+}
+
+impl<T> DerefMut for Repeatable<T> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.inner
}
+}
+impl<T> Repeatable<T> {
/// Returns `Some` if the value is repeated, `None` otherwise.
+ #[inline]
pub fn as_repeated(&self) -> Option<&T> {
- match self {
- Self::Repeated(repeated) => Some(repeated),
- Self::NotRepeated(_) => None,
+ if self.repeated {
+ Some(&self.inner)
+ } else {
+ None
}
}
}
@@ -617,7 +655,7 @@ impl<'a> Entry<'a> {
/// Any grid child, which can be either a header or an item.
pub enum ResolvableGridChild<T: ResolvableCell, I> {
- Header { repeat: bool, span: Span, items: I },
+ Header { repeat: bool, level: NonZeroU32, span: Span, items: I },
Footer { repeat: bool, span: Span, items: I },
Item(ResolvableGridItem<T>),
}
@@ -638,8 +676,8 @@ pub struct CellGrid<'a> {
/// Gutter rows are not included.
/// Contains up to 'rows_without_gutter.len() + 1' vectors of lines.
pub hlines: Vec<Vec<Line>>,
- /// The repeatable header of this grid.
- pub header: Option<Repeatable<Header>>,
+ /// The repeatable headers of this grid.
+ pub headers: Vec<Repeatable<Header>>,
/// The repeatable footer of this grid.
pub footer: Option<Repeatable<Footer>>,
/// Whether this grid has gutters.
@@ -654,7 +692,7 @@ impl<'a> CellGrid<'a> {
cells: impl IntoIterator<Item = Cell<'a>>,
) -> Self {
let entries = cells.into_iter().map(Entry::Cell).collect();
- Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries)
+ Self::new_internal(tracks, gutter, vec![], vec![], vec![], None, entries)
}
/// Generates the cell grid, given the tracks and resolved entries.
@@ -663,7 +701,7 @@ impl<'a> CellGrid<'a> {
gutter: Axes<&[Sizing]>,
vlines: Vec<Vec<Line>>,
hlines: Vec<Vec<Line>>,
- header: Option<Repeatable<Header>>,
+ headers: Vec<Repeatable<Header>>,
footer: Option<Repeatable<Footer>>,
entries: Vec<Entry<'a>>,
) -> Self {
@@ -717,7 +755,7 @@ impl<'a> CellGrid<'a> {
entries,
vlines,
hlines,
- header,
+ headers,
footer,
has_gutter,
}
@@ -852,6 +890,11 @@ impl<'a> CellGrid<'a> {
self.cols.len()
}
}
+
+ #[inline]
+ pub fn has_repeated_headers(&self) -> bool {
+ self.headers.iter().any(|h| h.repeated)
+ }
}
/// Resolves and positions all cells in the grid before creating it.
@@ -937,6 +980,12 @@ struct RowGroupData {
span: Span,
kind: RowGroupKind,
+ /// Whether this header or footer may repeat.
+ repeat: bool,
+
+ /// Level of this header or footer.
+ repeatable_level: NonZeroU32,
+
/// Start of the range of indices of hlines at the top of the row group.
/// This is always the first index after the last hline before we started
/// building the row group - any upcoming hlines would appear at least at
@@ -984,14 +1033,17 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
let mut pending_vlines: Vec<(Span, Line)> = vec![];
let has_gutter = self.gutter.any(|tracks| !tracks.is_empty());
- let mut header: Option<Header> = None;
- let mut repeat_header = false;
+ let mut headers: Vec<Repeatable<Header>> = vec![];
// Stores where the footer is supposed to end, its span, and the
// actual footer structure.
let mut footer: Option<(usize, Span, Footer)> = None;
let mut repeat_footer = false;
+ // If true, there has been at least one cell besides headers and
+ // footers. When false, footers at the end are forced to not repeat.
+ let mut at_least_one_cell = false;
+
// We can't just use the cell's index in the 'cells' vector to
// determine its automatic position, since cells could have arbitrary
// positions, so the position of a cell in 'cells' can differ from its
@@ -1008,6 +1060,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// automatically-positioned cell.
let mut auto_index: usize = 0;
+ // The next header after the latest auto-positioned cell. This is used
+ // to avoid checking for collision with headers that were already
+ // skipped.
+ let mut next_header = 0;
+
// We have to rebuild the grid to account for fixed cell positions.
//
// Create at least 'children.len()' positions, since there could be at
@@ -1028,12 +1085,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
columns,
&mut pending_hlines,
&mut pending_vlines,
- &mut header,
- &mut repeat_header,
+ &mut headers,
&mut footer,
&mut repeat_footer,
&mut auto_index,
+ &mut next_header,
&mut resolved_cells,
+ &mut at_least_one_cell,
child,
)?;
}
@@ -1049,13 +1107,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
row_amount,
)?;
- let (header, footer) = self.finalize_headers_and_footers(
+ let footer = self.finalize_headers_and_footers(
has_gutter,
- header,
- repeat_header,
+ &mut headers,
footer,
repeat_footer,
row_amount,
+ at_least_one_cell,
)?;
Ok(CellGrid::new_internal(
@@ -1063,7 +1121,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
self.gutter,
vlines,
hlines,
- header,
+ headers,
footer,
resolved_cells,
))
@@ -1083,12 +1141,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
columns: usize,
pending_hlines: &mut Vec<(Span, Line, bool)>,
pending_vlines: &mut Vec<(Span, Line)>,
- header: &mut Option<Header>,
- repeat_header: &mut bool,
+ headers: &mut Vec<Repeatable<Header>>,
footer: &mut Option<(usize, Span, Footer)>,
repeat_footer: &mut bool,
auto_index: &mut usize,
+ next_header: &mut usize,
resolved_cells: &mut Vec<Option<Entry<'x>>>,
+ at_least_one_cell: &mut bool,
child: ResolvableGridChild<T, I>,
) -> SourceResult<()>
where
@@ -1112,7 +1171,32 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// position than it would usually be if it would be in a non-empty
// row, so we must step a local index inside headers and footers
// instead, and use a separate counter outside them.
- let mut local_auto_index = *auto_index;
+ let local_auto_index = if matches!(child, ResolvableGridChild::Item(_)) {
+ auto_index
+ } else {
+ // Although 'usize' is Copy, we need to be explicit here that we
+ // aren't reborrowing the original auto index but rather making a
+ // mutable copy of it using 'clone'.
+ &mut (*auto_index).clone()
+ };
+
+ // NOTE: usually, if 'next_header' were to be updated inside a row
+ // group (indicating a header was skipped by a cell), that would
+ // indicate a collision between the row group and that header, which
+ // is an error. However, the exception is for the first auto cell of
+ // the row group, which may skip headers while searching for a position
+ // where to begin the row group in the first place.
+ //
+ // Therefore, we cannot safely share the counter in the row group with
+ // the counter used by auto cells outside, as it might update it in a
+ // valid situation, whereas it must not, since its auto cells use a
+ // different auto index counter and will have seen different headers,
+ // so we copy the next header counter while inside a row group.
+ let local_next_header = if matches!(child, ResolvableGridChild::Item(_)) {
+ next_header
+ } else {
+ &mut (*next_header).clone()
+ };
// The first row in which this table group can fit.
//
@@ -1123,23 +1207,19 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
let mut first_available_row = 0;
let (header_footer_items, simple_item) = match child {
- ResolvableGridChild::Header { repeat, span, items, .. } => {
- if header.is_some() {
- bail!(span, "cannot have more than one header");
- }
-
+ ResolvableGridChild::Header { repeat, level, span, items, .. } => {
row_group_data = Some(RowGroupData {
range: None,
span,
kind: RowGroupKind::Header,
+ repeat,
+ repeatable_level: level,
top_hlines_start: pending_hlines.len(),
top_hlines_end: None,
});
- *repeat_header = repeat;
-
first_available_row =
- find_next_empty_row(resolved_cells, local_auto_index, columns);
+ find_next_empty_row(resolved_cells, *local_auto_index, columns);
// If any cell in the header is automatically positioned,
// have it skip to the next empty row. This is to avoid
@@ -1150,7 +1230,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// latest auto-position cell, since each auto-position cell
// always occupies the first available position after the
// previous one. Therefore, this will be >= auto_index.
- local_auto_index = first_available_row * columns;
+ *local_auto_index = first_available_row * columns;
(Some(items), None)
}
@@ -1162,21 +1242,27 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
row_group_data = Some(RowGroupData {
range: None,
span,
+ repeat,
kind: RowGroupKind::Footer,
+ repeatable_level: NonZeroU32::ONE,
top_hlines_start: pending_hlines.len(),
top_hlines_end: None,
});
- *repeat_footer = repeat;
-
first_available_row =
- find_next_empty_row(resolved_cells, local_auto_index, columns);
+ find_next_empty_row(resolved_cells, *local_auto_index, columns);
- local_auto_index = first_available_row * columns;
+ *local_auto_index = first_available_row * columns;
(Some(items), None)
}
- ResolvableGridChild::Item(item) => (None, Some(item)),
+ ResolvableGridChild::Item(item) => {
+ if matches!(item, ResolvableGridItem::Cell(_)) {
+ *at_least_one_cell = true;
+ }
+
+ (None, Some(item))
+ }
};
let items = header_footer_items.into_iter().flatten().chain(simple_item);
@@ -1191,7 +1277,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// gutter.
skip_auto_index_through_fully_merged_rows(
resolved_cells,
- &mut local_auto_index,
+ local_auto_index,
columns,
);
@@ -1266,7 +1352,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// automatically positioned cell. Same for footers.
local_auto_index
.checked_sub(1)
- .filter(|_| local_auto_index > first_available_row * columns)
+ .filter(|_| *local_auto_index > first_available_row * columns)
.map_or(0, |last_auto_index| last_auto_index % columns + 1)
});
if end.is_some_and(|end| end.get() < start) {
@@ -1295,10 +1381,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
cell_y,
colspan,
rowspan,
- header.as_ref(),
+ headers,
footer.as_ref(),
resolved_cells,
- &mut local_auto_index,
+ local_auto_index,
+ local_next_header,
first_available_row,
columns,
row_group_data.is_some(),
@@ -1350,7 +1437,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
);
if top_hlines_end.is_none()
- && local_auto_index > first_available_row * columns
+ && *local_auto_index > first_available_row * columns
{
// Auto index was moved, so upcoming auto-pos hlines should
// no longer appear at the top.
@@ -1437,7 +1524,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
None => {
// Empty header/footer: consider the header/footer to be
// at the next empty row after the latest auto index.
- local_auto_index = first_available_row * columns;
+ *local_auto_index = first_available_row * columns;
let group_start = first_available_row;
let group_end = group_start + 1;
@@ -1454,8 +1541,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// 'find_next_empty_row' will skip through any existing headers
// and footers without having to loop through them each time.
// Cells themselves, unfortunately, still have to.
- assert!(resolved_cells[local_auto_index].is_none());
- resolved_cells[local_auto_index] =
+ assert!(resolved_cells[*local_auto_index].is_none());
+ resolved_cells[*local_auto_index] =
Some(Entry::Cell(self.resolve_cell(
T::default(),
0,
@@ -1483,21 +1570,38 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
match row_group.kind {
RowGroupKind::Header => {
- if group_range.start != 0 {
- bail!(
- row_group.span,
- "header must start at the first row";
- hint: "remove any rows before the header"
- );
- }
-
- *header = Some(Header {
- // Later on, we have to correct this number in case there
+ let data = Header {
+ // Later on, we have to correct this range in case there
// is gutter. But only once all cells have been analyzed
// and the header has fully expanded in the fixup loop
// below.
- end: group_range.end,
- });
+ range: group_range,
+
+ level: row_group.repeatable_level.get(),
+
+ // This can only change at a later iteration, if we
+ // find a conflicting header or footer right away.
+ short_lived: false,
+ };
+
+ // Mark consecutive headers right before this one as short
+ // lived if they would have a higher or equal level, as
+ // then they would immediately stop repeating during
+ // layout.
+ let mut consecutive_header_start = data.range.start;
+ for conflicting_header in
+ headers.iter_mut().rev().take_while(move |h| {
+ let conflicts = h.range.end == consecutive_header_start
+ && h.level >= data.level;
+
+ consecutive_header_start = h.range.start;
+ conflicts
+ })
+ {
+ conflicting_header.short_lived = true;
+ }
+
+ headers.push(Repeatable { inner: data, repeated: row_group.repeat });
}
RowGroupKind::Footer => {
@@ -1514,15 +1618,14 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// before the footer might not be included as part of
// the footer if it is contained within the header.
start: group_range.start,
+ end: group_range.end,
+ level: 1,
},
));
+
+ *repeat_footer = row_group.repeat;
}
}
- } else {
- // The child was a single cell outside headers or footers.
- // Therefore, 'local_auto_index' for this table child was
- // simply an alias for 'auto_index', so we update it as needed.
- *auto_index = local_auto_index;
}
Ok(())
@@ -1689,47 +1792,64 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
fn finalize_headers_and_footers(
&self,
has_gutter: bool,
- header: Option<Header>,
- repeat_header: bool,
+ headers: &mut [Repeatable<Header>],
footer: Option<(usize, Span, Footer)>,
repeat_footer: bool,
row_amount: usize,
- ) -> SourceResult<(Option<Repeatable<Header>>, Option<Repeatable<Footer>>)> {
- let header = header
- .map(|mut header| {
- // Repeat the gutter below a header (hence why we don't
- // subtract 1 from the gutter case).
- // Don't do this if there are no rows under the header.
- if has_gutter {
- // - 'header.end' is always 'last y + 1'. The header stops
- // before that row.
- // - Therefore, '2 * header.end' will be 2 * (last y + 1),
- // which is the adjusted index of the row before which the
- // header stops, meaning it will still stop right before it
- // even with gutter thanks to the multiplication below.
- // - This means that it will span all rows up to
- // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates
- // to the index of the gutter row right below the header,
- // which is what we want (that gutter spacing should be
- // repeated across pages to maintain uniformity).
- header.end *= 2;
-
- // If the header occupies the entire grid, ensure we don't
- // include an extra gutter row when it doesn't exist, since
- // the last row of the header is at the very bottom,
- // therefore '2 * last y + 1' is not a valid index.
- let row_amount = (2 * row_amount).saturating_sub(1);
- header.end = header.end.min(row_amount);
- }
- header
- })
- .map(|header| {
- if repeat_header {
- Repeatable::Repeated(header)
- } else {
- Repeatable::NotRepeated(header)
- }
- });
+ at_least_one_cell: bool,
+ ) -> SourceResult<Option<Repeatable<Footer>>> {
+ // Mark consecutive headers right before the end of the table, or the
+ // final footer, as short lived, given that there are no normal rows
+ // after them, so repeating them is pointless.
+ //
+ // It is important to do this BEFORE we update header and footer ranges
+ // due to gutter below as 'row_amount' doesn't consider gutter.
+ //
+ // TODO(subfooters): take the last footer if it is at the end and
+ // backtrack through consecutive footers until the first one in the
+ // sequence is found. If there is no footer at the end, there are no
+ // haeders to turn short-lived.
+ let mut consecutive_header_start =
+ footer.as_ref().map(|(_, _, f)| f.start).unwrap_or(row_amount);
+ for header_at_the_end in headers.iter_mut().rev().take_while(move |h| {
+ let at_the_end = h.range.end == consecutive_header_start;
+
+ consecutive_header_start = h.range.start;
+ at_the_end
+ }) {
+ header_at_the_end.short_lived = true;
+ }
+
+ // Repeat the gutter below a header (hence why we don't
+ // subtract 1 from the gutter case).
+ // Don't do this if there are no rows under the header.
+ if has_gutter {
+ for header in &mut *headers {
+ // Index of first y is doubled, as each row before it
+ // receives a gutter row below.
+ header.range.start *= 2;
+
+ // - 'header.end' is always 'last y + 1'. The header stops
+ // before that row.
+ // - Therefore, '2 * header.end' will be 2 * (last y + 1),
+ // which is the adjusted index of the row before which the
+ // header stops, meaning it will still stop right before it
+ // even with gutter thanks to the multiplication below.
+ // - This means that it will span all rows up to
+ // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates
+ // to the index of the gutter row right below the header,
+ // which is what we want (that gutter spacing should be
+ // repeated across pages to maintain uniformity).
+ header.range.end *= 2;
+
+ // If the header occupies the entire grid, ensure we don't
+ // include an extra gutter row when it doesn't exist, since
+ // the last row of the header is at the very bottom,
+ // therefore '2 * last y + 1' is not a valid index.
+ let row_amount = (2 * row_amount).saturating_sub(1);
+ header.range.end = header.range.end.min(row_amount);
+ }
+ }
let footer = footer
.map(|(footer_end, footer_span, mut footer)| {
@@ -1737,8 +1857,17 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
bail!(footer_span, "footer must end at the last row");
}
- let header_end =
- header.as_ref().map(Repeatable::unwrap).map(|header| header.end);
+ // TODO(subfooters): will need a global slice of headers and
+ // footers for when we have multiple footers
+ // Alternatively, never include the gutter in the footer's
+ // range and manually add it later on layout. This would allow
+ // laying out the gutter as part of both the header and footer,
+ // and, if the page only has headers, the gutter row below the
+ // header is automatically removed (as it becomes the last), so
+ // only the gutter above the footer is kept, ensuring the same
+ // gutter row isn't laid out two times in a row. When laying
+ // out the footer for real, the mechanism can be disabled.
+ let last_header_end = headers.last().map(|header| header.range.end);
if has_gutter {
// Convert the footer's start index to post-gutter coordinates.
@@ -1747,23 +1876,38 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// Include the gutter right before the footer, unless there is
// none, or the gutter is already included in the header (no
// rows between the header and the footer).
- if header_end != Some(footer.start) {
+ if last_header_end != Some(footer.start) {
footer.start = footer.start.saturating_sub(1);
}
+
+ // Adapt footer end but DO NOT include the gutter below it,
+ // if it exists. Calculation:
+ // - Starts as 'last y + 1'.
+ // - The result will be
+ // 2 * (last_y + 1) - 1 = 2 * last_y + 1,
+ // which is the new index of the last footer row plus one,
+ // meaning we do exclude any gutter below this way.
+ //
+ // It also keeps us within the total amount of rows, so we
+ // don't need to '.min()' later.
+ footer.end = (2 * footer.end).saturating_sub(1);
}
Ok(footer)
})
.transpose()?
.map(|footer| {
- if repeat_footer {
- Repeatable::Repeated(footer)
- } else {
- Repeatable::NotRepeated(footer)
+ // Don't repeat footers when the table only has headers and
+ // footers.
+ // TODO(subfooters): Switch this to marking the last N
+ // consecutive footers as short lived.
+ Repeatable {
+ inner: footer,
+ repeated: repeat_footer && at_least_one_cell,
}
});
- Ok((header, footer))
+ Ok(footer)
}
/// Resolves the cell's fields based on grid-wide properties.
@@ -1934,28 +2078,28 @@ fn expand_row_group(
/// Check if a cell's fixed row would conflict with a header or footer.
fn check_for_conflicting_cell_row(
- header: Option<&Header>,
+ headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>,
cell_y: usize,
rowspan: usize,
) -> HintedStrResult<()> {
- if let Some(header) = header {
- // TODO: check start (right now zero, always satisfied)
- if cell_y < header.end {
- bail!(
- "cell would conflict with header spanning the same position";
- hint: "try moving the cell or the header"
- );
- }
+ // NOTE: y + rowspan >, not >=, header.start, to check if the rowspan
+ // enters the header. For example, consider a rowspan of 1: if
+ // `y + 1 = header.start` holds, that means `y < header.start`, and it
+ // only occupies one row (`y`), so the cell is actually not in
+ // conflict.
+ if headers
+ .iter()
+ .any(|header| cell_y < header.range.end && cell_y + rowspan > header.range.start)
+ {
+ bail!(
+ "cell would conflict with header spanning the same position";
+ hint: "try moving the cell or the header"
+ );
}
- if let Some((footer_end, _, footer)) = footer {
- // NOTE: y + rowspan >, not >=, footer.start, to check if the rowspan
- // enters the footer. For example, consider a rowspan of 1: if
- // `y + 1 = footer.start` holds, that means `y < footer.start`, and it
- // only occupies one row (`y`), so the cell is actually not in
- // conflict.
- if cell_y < *footer_end && cell_y + rowspan > footer.start {
+ if let Some((_, _, footer)) = footer {
+ if cell_y < footer.end && cell_y + rowspan > footer.start {
bail!(
"cell would conflict with footer spanning the same position";
hint: "try reducing the cell's rowspan or moving the footer"
@@ -1981,10 +2125,11 @@ fn resolve_cell_position(
cell_y: Smart<usize>,
colspan: usize,
rowspan: usize,
- header: Option<&Header>,
+ headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>,
resolved_cells: &[Option<Entry>],
auto_index: &mut usize,
+ next_header: &mut usize,
first_available_row: usize,
columns: usize,
in_row_group: bool,
@@ -2005,12 +2150,14 @@ fn resolve_cell_position(
// Note that the counter ignores any cells with fixed positions,
// but automatically-positioned cells will avoid conflicts by
// simply skipping existing cells, headers and footers.
- let resolved_index = find_next_available_position::<false>(
- header,
+ let resolved_index = find_next_available_position(
+ headers,
footer,
resolved_cells,
columns,
*auto_index,
+ next_header,
+ false,
)?;
// Ensure the next cell with automatic position will be
@@ -2046,7 +2193,7 @@ fn resolve_cell_position(
// footer (but only if it isn't already in one, otherwise there
// will already be a separate check).
if !in_row_group {
- check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?;
+ check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?;
}
cell_index(cell_x, cell_y)
@@ -2063,12 +2210,28 @@ fn resolve_cell_position(
// requested column ('Some(None)') or an out of bounds position
// ('None'), in which case we'd create a new row to place this
// cell in.
- find_next_available_position::<true>(
- header,
+ find_next_available_position(
+ headers,
footer,
resolved_cells,
columns,
initial_index,
+ // Make our own copy of the 'next_header' counter, since it
+ // should only be updated by auto cells. However, we cannot
+ // start with the same value as we are searching from the
+ // start, and not from 'auto_index', so auto cells might
+ // have skipped some headers already which this cell will
+ // also need to skip.
+ //
+ // We could, in theory, keep a separate 'next_header'
+ // counter for cells with fixed columns. But then we would
+ // need one for every column, and much like how there isn't
+ // an index counter for each column either, the potential
+ // speed gain seems less relevant for a less used feature.
+ // Still, it is something to consider for the future if
+ // this turns out to be a bottleneck in important cases.
+ &mut 0,
+ true,
)
}
}
@@ -2078,7 +2241,7 @@ fn resolve_cell_position(
// footer (but only if it isn't already in one, otherwise there
// will already be a separate check).
if !in_row_group {
- check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?;
+ check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?;
}
// Let's find the first column which has that row available.
@@ -2110,13 +2273,18 @@ fn resolve_cell_position(
/// Finds the first available position after the initial index in the resolved
/// grid of cells. Skips any non-absent positions (positions which already
/// have cells specified by the user) as well as any headers and footers.
+///
+/// When `skip_rows` is true, one row is skipped on each iteration, preserving
+/// the column. That is used to find a position for a fixed column cell.
#[inline]
-fn find_next_available_position<const SKIP_ROWS: bool>(
- header: Option<&Header>,
+fn find_next_available_position(
+ headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>,
resolved_cells: &[Option<Entry<'_>>],
columns: usize,
initial_index: usize,
+ next_header: &mut usize,
+ skip_rows: bool,
) -> HintedStrResult<usize> {
let mut resolved_index = initial_index;
@@ -2126,7 +2294,7 @@ fn find_next_available_position<const SKIP_ROWS: bool>(
// determine where this cell will be placed. An out of
// bounds position (thus `None`) is also a valid new
// position (only requires expanding the vector).
- if SKIP_ROWS {
+ if skip_rows {
// Skip one row at a time (cell chose its column, so we don't
// change it).
resolved_index =
@@ -2139,24 +2307,33 @@ fn find_next_available_position<const SKIP_ROWS: bool>(
// would become impractically large before this overflows.
resolved_index += 1;
}
- } else if let Some(header) =
- header.filter(|header| resolved_index < header.end * columns)
+ } else if let Some(header) = headers
+ .get(*next_header)
+ .filter(|header| resolved_index >= header.range.start * columns)
{
// Skip header (can't place a cell inside it from outside it).
- resolved_index = header.end * columns;
-
- if SKIP_ROWS {
- // Ensure the cell's chosen column is kept after the
- // header.
- resolved_index += initial_index % columns;
+ // No changes needed if we already passed this header (which
+ // also triggers this branch) - in that case, we only update the
+ // counter.
+ if resolved_index < header.range.end * columns {
+ resolved_index = header.range.end * columns;
+
+ if skip_rows {
+ // Ensure the cell's chosen column is kept after the
+ // header.
+ resolved_index += initial_index % columns;
+ }
}
+
+ // From now on, only check the headers afterwards.
+ *next_header += 1;
} else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| {
resolved_index >= footer.start * columns && resolved_index < *end * columns
}) {
// Skip footer, for the same reason.
resolved_index = *footer_end * columns;
- if SKIP_ROWS {
+ if skip_rows {
resolved_index += initial_index % columns;
}
} else {
diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs
index 37323089..dcc77b0d 100644
--- a/crates/typst-library/src/model/table.rs
+++ b/crates/typst-library/src/model/table.rs
@@ -1,4 +1,4 @@
-use std::num::NonZeroUsize;
+use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::Arc;
use typst_utils::NonZeroExt;
@@ -292,16 +292,61 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content {
elem(tag::tr, Content::sequence(row))
};
+ // TODO(subfooters): similarly to headers, take consecutive footers from
+ // the end for 'tfoot'.
let footer = grid.footer.map(|ft| {
- let rows = rows.drain(ft.unwrap().start..);
+ let rows = rows.drain(ft.start..);
elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row))))
});
- let header = grid.header.map(|hd| {
- let rows = rows.drain(..hd.unwrap().end);
- elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))
- });
- let mut body = Content::sequence(rows.into_iter().map(|row| tr(tag::td, row)));
+ // Store all consecutive headers at the start in 'thead'. All remaining
+ // headers are just 'th' rows across the table body.
+ let mut consecutive_header_end = 0;
+ let first_mid_table_header = grid
+ .headers
+ .iter()
+ .take_while(|hd| {
+ let is_consecutive = hd.range.start == consecutive_header_end;
+ consecutive_header_end = hd.range.end;
+
+ is_consecutive
+ })
+ .count();
+
+ let (y_offset, header) = if first_mid_table_header > 0 {
+ let removed_header_rows =
+ grid.headers.get(first_mid_table_header - 1).unwrap().range.end;
+ let rows = rows.drain(..removed_header_rows);
+
+ (
+ removed_header_rows,
+ Some(elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))),
+ )
+ } else {
+ (0, None)
+ };
+
+ // TODO: Consider improving accessibility properties of multi-level headers
+ // inside tables in the future, e.g. indicating which columns they are
+ // relative to and so on. See also:
+ // https://www.w3.org/WAI/tutorials/tables/multi-level/
+ let mut next_header = first_mid_table_header;
+ let mut body =
+ Content::sequence(rows.into_iter().enumerate().map(|(relative_y, row)| {
+ let y = relative_y + y_offset;
+ if let Some(current_header) =
+ grid.headers.get(next_header).filter(|h| h.range.contains(&y))
+ {
+ if y + 1 == current_header.range.end {
+ next_header += 1;
+ }
+
+ tr(tag::th, row)
+ } else {
+ tr(tag::td, row)
+ }
+ }));
+
if header.is_some() || footer.is_some() {
body = elem(tag::tbody, body);
}
@@ -492,6 +537,17 @@ pub struct TableHeader {
#[default(true)]
pub repeat: bool,
+ /// The level of the header. Must not be zero.
+ ///
+ /// This allows repeating multiple headers at once. Headers with different
+ /// levels can repeat together, as long as they have ascending levels.
+ ///
+ /// Notably, when a header with a lower level starts repeating, all higher
+ /// or equal level headers stop repeating (they are "replaced" by the new
+ /// header).
+ #[default(NonZeroU32::ONE)]
+ pub level: NonZeroU32,
+
/// The cells and lines within the header.
#[variadic]
pub children: Vec<TableItem>,
diff --git a/crates/typst-syntax/src/span.rs b/crates/typst-syntax/src/span.rs
index 3618b8f2..b383ec27 100644
--- a/crates/typst-syntax/src/span.rs
+++ b/crates/typst-syntax/src/span.rs
@@ -71,10 +71,7 @@ impl Span {
/// Create a span that does not point into any file.
pub const fn detached() -> Self {
- match NonZeroU64::new(Self::DETACHED) {
- Some(v) => Self(v),
- None => unreachable!(),
- }
+ Self(NonZeroU64::new(Self::DETACHED).unwrap())
}
/// Create a new span from a file id and a number.
@@ -111,11 +108,9 @@ impl Span {
/// Pack a file ID and the low bits into a span.
const fn pack(id: FileId, low: u64) -> Self {
let bits = ((id.into_raw().get() as u64) << Self::FILE_ID_SHIFT) | low;
- match NonZeroU64::new(bits) {
- Some(v) => Self(v),
- // The file ID is non-zero.
- None => unreachable!(),
- }
+
+ // The file ID is non-zero.
+ Self(NonZeroU64::new(bits).unwrap())
}
/// Whether the span is detached.
diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs
index b346a809..abe6423d 100644
--- a/crates/typst-utils/src/lib.rs
+++ b/crates/typst-utils/src/lib.rs
@@ -26,7 +26,7 @@ pub use once_cell;
use std::fmt::{Debug, Formatter};
use std::hash::Hash;
use std::iter::{Chain, Flatten, Rev};
-use std::num::NonZeroUsize;
+use std::num::{NonZeroU32, NonZeroUsize};
use std::ops::{Add, Deref, Div, Mul, Neg, Sub};
use std::sync::Arc;
@@ -66,10 +66,11 @@ pub trait NonZeroExt {
}
impl NonZeroExt for NonZeroUsize {
- const ONE: Self = match Self::new(1) {
- Some(v) => v,
- None => unreachable!(),
- };
+ const ONE: Self = Self::new(1).unwrap();
+}
+
+impl NonZeroExt for NonZeroU32 {
+ const ONE: Self = Self::new(1).unwrap();
}
/// Extra methods for [`Arc`].
diff --git a/crates/typst-utils/src/pico.rs b/crates/typst-utils/src/pico.rs
index 2c80d37d..ce43667e 100644
--- a/crates/typst-utils/src/pico.rs
+++ b/crates/typst-utils/src/pico.rs
@@ -95,10 +95,7 @@ impl PicoStr {
}
};
- match NonZeroU64::new(value) {
- Some(value) => Ok(Self(value)),
- None => unreachable!(),
- }
+ Ok(Self(NonZeroU64::new(value).unwrap()))
}
/// Resolve to a decoded string.
diff --git a/tests/ref/grid-footer-non-repeatable-unbreakable.png b/tests/ref/grid-footer-non-repeatable-unbreakable.png
new file mode 100644
index 00000000..59d72201
--- /dev/null
+++ b/tests/ref/grid-footer-non-repeatable-unbreakable.png
Binary files differ
diff --git a/tests/ref/grid-footer-repeatable-unbreakable.png b/tests/ref/grid-footer-repeatable-unbreakable.png
new file mode 100644
index 00000000..0fa30f77
--- /dev/null
+++ b/tests/ref/grid-footer-repeatable-unbreakable.png
Binary files differ
diff --git a/tests/ref/grid-header-and-large-auto-contiguous.png b/tests/ref/grid-header-and-large-auto-contiguous.png
new file mode 100644
index 00000000..2dbaba23
--- /dev/null
+++ b/tests/ref/grid-header-and-large-auto-contiguous.png
Binary files differ
diff --git a/tests/ref/grid-header-and-rowspan-contiguous-1.png b/tests/ref/grid-header-and-rowspan-contiguous-1.png
new file mode 100644
index 00000000..7cf2cb9c
--- /dev/null
+++ b/tests/ref/grid-header-and-rowspan-contiguous-1.png
Binary files differ
diff --git a/tests/ref/grid-header-and-rowspan-contiguous-2.png b/tests/ref/grid-header-and-rowspan-contiguous-2.png
new file mode 100644
index 00000000..29bc411d
--- /dev/null
+++ b/tests/ref/grid-header-and-rowspan-contiguous-2.png
Binary files differ
diff --git a/tests/ref/grid-header-multiple.png b/tests/ref/grid-header-multiple.png
new file mode 100644
index 00000000..199cc051
--- /dev/null
+++ b/tests/ref/grid-header-multiple.png
Binary files differ
diff --git a/tests/ref/grid-header-non-repeating-orphan-prevention.png b/tests/ref/grid-header-non-repeating-orphan-prevention.png
new file mode 100644
index 00000000..d0dbc597
--- /dev/null
+++ b/tests/ref/grid-header-non-repeating-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-header-not-at-first-row-two-columns.png b/tests/ref/grid-header-not-at-first-row-two-columns.png
new file mode 100644
index 00000000..21ee5f93
--- /dev/null
+++ b/tests/ref/grid-header-not-at-first-row-two-columns.png
Binary files differ
diff --git a/tests/ref/grid-header-not-at-first-row.png b/tests/ref/grid-header-not-at-first-row.png
new file mode 100644
index 00000000..21ee5f93
--- /dev/null
+++ b/tests/ref/grid-header-not-at-first-row.png
Binary files differ
diff --git a/tests/ref/grid-header-not-at-the-top.png b/tests/ref/grid-header-not-at-the-top.png
new file mode 100644
index 00000000..96f9adcd
--- /dev/null
+++ b/tests/ref/grid-header-not-at-the-top.png
Binary files differ
diff --git a/tests/ref/grid-header-replace-doesnt-fit.png b/tests/ref/grid-header-replace-doesnt-fit.png
new file mode 100644
index 00000000..a06ce0e9
--- /dev/null
+++ b/tests/ref/grid-header-replace-doesnt-fit.png
Binary files differ
diff --git a/tests/ref/grid-header-replace-orphan.png b/tests/ref/grid-header-replace-orphan.png
new file mode 100644
index 00000000..9d116169
--- /dev/null
+++ b/tests/ref/grid-header-replace-orphan.png
Binary files differ
diff --git a/tests/ref/grid-header-replace.png b/tests/ref/grid-header-replace.png
new file mode 100644
index 00000000..dafecd7f
--- /dev/null
+++ b/tests/ref/grid-header-replace.png
Binary files differ
diff --git a/tests/ref/grid-header-skip.png b/tests/ref/grid-header-skip.png
new file mode 100644
index 00000000..9c4f294d
--- /dev/null
+++ b/tests/ref/grid-header-skip.png
Binary files differ
diff --git a/tests/ref/grid-header-too-large-non-repeating-orphan.png b/tests/ref/grid-header-too-large-non-repeating-orphan.png
new file mode 100644
index 00000000..a4e7843b
--- /dev/null
+++ b/tests/ref/grid-header-too-large-non-repeating-orphan.png
Binary files differ
diff --git a/tests/ref/grid-header-too-large-repeating-orphan-not-at-first-row.png b/tests/ref/grid-header-too-large-repeating-orphan-not-at-first-row.png
new file mode 100644
index 00000000..754509f3
--- /dev/null
+++ b/tests/ref/grid-header-too-large-repeating-orphan-not-at-first-row.png
Binary files differ
diff --git a/tests/ref/grid-header-too-large-repeating-orphan-with-footer.png b/tests/ref/grid-header-too-large-repeating-orphan-with-footer.png
new file mode 100644
index 00000000..8fff7726
--- /dev/null
+++ b/tests/ref/grid-header-too-large-repeating-orphan-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-header-too-large-repeating-orphan.png b/tests/ref/grid-header-too-large-repeating-orphan.png
new file mode 100644
index 00000000..76d54c34
--- /dev/null
+++ b/tests/ref/grid-header-too-large-repeating-orphan.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-alone-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-no-orphan-prevention.png
new file mode 100644
index 00000000..17bf3fe4
--- /dev/null
+++ b/tests/ref/grid-subheaders-alone-no-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png
new file mode 100644
index 00000000..9b083f37
--- /dev/null
+++ b/tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-alone-with-footer.png b/tests/ref/grid-subheaders-alone-with-footer.png
new file mode 100644
index 00000000..41bf88bc
--- /dev/null
+++ b/tests/ref/grid-subheaders-alone-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.png
new file mode 100644
index 00000000..7d6cc45e
--- /dev/null
+++ b/tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-alone-with-gutter-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-with-gutter-no-orphan-prevention.png
new file mode 100644
index 00000000..17bf3fe4
--- /dev/null
+++ b/tests/ref/grid-subheaders-alone-with-gutter-no-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-alone.png b/tests/ref/grid-subheaders-alone.png
new file mode 100644
index 00000000..0e05dda8
--- /dev/null
+++ b/tests/ref/grid-subheaders-alone.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png b/tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png
new file mode 100644
index 00000000..6f2a57be
--- /dev/null
+++ b/tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-basic-non-consecutive.png b/tests/ref/grid-subheaders-basic-non-consecutive.png
new file mode 100644
index 00000000..9f3f8440
--- /dev/null
+++ b/tests/ref/grid-subheaders-basic-non-consecutive.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-basic-replace.png b/tests/ref/grid-subheaders-basic-replace.png
new file mode 100644
index 00000000..2b3baa37
--- /dev/null
+++ b/tests/ref/grid-subheaders-basic-replace.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-basic-with-footer.png b/tests/ref/grid-subheaders-basic-with-footer.png
new file mode 100644
index 00000000..52165614
--- /dev/null
+++ b/tests/ref/grid-subheaders-basic-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-basic.png b/tests/ref/grid-subheaders-basic.png
new file mode 100644
index 00000000..5a646807
--- /dev/null
+++ b/tests/ref/grid-subheaders-basic.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-colorful.png b/tests/ref/grid-subheaders-colorful.png
new file mode 100644
index 00000000..38bb4ad8
--- /dev/null
+++ b/tests/ref/grid-subheaders-colorful.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-demo.png b/tests/ref/grid-subheaders-demo.png
new file mode 100644
index 00000000..ec1cd998
--- /dev/null
+++ b/tests/ref/grid-subheaders-demo.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png b/tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png
new file mode 100644
index 00000000..119a2c22
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-row-right-after.png b/tests/ref/grid-subheaders-multi-page-row-right-after.png
new file mode 100644
index 00000000..c9e30869
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-row-right-after.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-row-with-footer.png b/tests/ref/grid-subheaders-multi-page-row-with-footer.png
new file mode 100644
index 00000000..a440eac4
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-row-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-row.png b/tests/ref/grid-subheaders-multi-page-row.png
new file mode 100644
index 00000000..637ca3fb
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-row.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-gutter.png b/tests/ref/grid-subheaders-multi-page-rowspan-gutter.png
new file mode 100644
index 00000000..53beeb02
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-rowspan-gutter.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-right-after.png b/tests/ref/grid-subheaders-multi-page-rowspan-right-after.png
new file mode 100644
index 00000000..5fe1eacf
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-rowspan-right-after.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png b/tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png
new file mode 100644
index 00000000..b9104667
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-multi-page-rowspan.png b/tests/ref/grid-subheaders-multi-page-rowspan.png
new file mode 100644
index 00000000..342b0569
--- /dev/null
+++ b/tests/ref/grid-subheaders-multi-page-rowspan.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-non-repeat-replace.png b/tests/ref/grid-subheaders-non-repeat-replace.png
new file mode 100644
index 00000000..e9c254b4
--- /dev/null
+++ b/tests/ref/grid-subheaders-non-repeat-replace.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-non-repeat.png b/tests/ref/grid-subheaders-non-repeat.png
new file mode 100644
index 00000000..030f1baa
--- /dev/null
+++ b/tests/ref/grid-subheaders-non-repeat.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png b/tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png
new file mode 100644
index 00000000..3db97f78
--- /dev/null
+++ b/tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-non-repeating-orphan-prevention.png b/tests/ref/grid-subheaders-non-repeating-orphan-prevention.png
new file mode 100644
index 00000000..0f37c5d1
--- /dev/null
+++ b/tests/ref/grid-subheaders-non-repeating-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.png b/tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.png
new file mode 100644
index 00000000..6685125a
--- /dev/null
+++ b/tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-non-repeating-replace-orphan.png b/tests/ref/grid-subheaders-non-repeating-replace-orphan.png
new file mode 100644
index 00000000..ffa465c4
--- /dev/null
+++ b/tests/ref/grid-subheaders-non-repeating-replace-orphan.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-gutter.png b/tests/ref/grid-subheaders-repeat-gutter.png
new file mode 100644
index 00000000..f8f7380e
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-gutter.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-non-consecutive.png b/tests/ref/grid-subheaders-repeat-non-consecutive.png
new file mode 100644
index 00000000..2e0fe236
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-non-consecutive.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.png b/tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.png
new file mode 100644
index 00000000..df984bd6
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-double-orphan.png b/tests/ref/grid-subheaders-repeat-replace-double-orphan.png
new file mode 100644
index 00000000..e340e681
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-double-orphan.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-child.png b/tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-child.png
new file mode 100644
index 00000000..80f9b12b
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-child.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-gutter.png b/tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-gutter.png
new file mode 100644
index 00000000..38521e44
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-gutter.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-gutter.png b/tests/ref/grid-subheaders-repeat-replace-gutter.png
new file mode 100644
index 00000000..e87e3b5b
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-gutter.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-multiple-levels.png b/tests/ref/grid-subheaders-repeat-replace-multiple-levels.png
new file mode 100644
index 00000000..d6f691e4
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-multiple-levels.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-orphan.png b/tests/ref/grid-subheaders-repeat-replace-orphan.png
new file mode 100644
index 00000000..c28e9d4f
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-orphan.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-short-lived.png b/tests/ref/grid-subheaders-repeat-replace-short-lived.png
new file mode 100644
index 00000000..d041888c
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-short-lived.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png b/tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png
new file mode 100644
index 00000000..b50fae71
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace-with-footer.png b/tests/ref/grid-subheaders-repeat-replace-with-footer.png
new file mode 100644
index 00000000..7191bfb6
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-replace.png b/tests/ref/grid-subheaders-repeat-replace.png
new file mode 100644
index 00000000..9fe72940
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-replace.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-short-lived-also-replaces.png b/tests/ref/grid-subheaders-repeat-short-lived-also-replaces.png
new file mode 100644
index 00000000..788837c7
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-short-lived-also-replaces.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat-with-footer.png b/tests/ref/grid-subheaders-repeat-with-footer.png
new file mode 100644
index 00000000..39f8465e
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat-with-footer.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeat.png b/tests/ref/grid-subheaders-repeat.png
new file mode 100644
index 00000000..c57ed769
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeat.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-repeating-orphan-prevention.png b/tests/ref/grid-subheaders-repeating-orphan-prevention.png
new file mode 100644
index 00000000..0f37c5d1
--- /dev/null
+++ b/tests/ref/grid-subheaders-repeating-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-short-lived-no-orphan-prevention.png b/tests/ref/grid-subheaders-short-lived-no-orphan-prevention.png
new file mode 100644
index 00000000..56067052
--- /dev/null
+++ b/tests/ref/grid-subheaders-short-lived-no-orphan-prevention.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png
new file mode 100644
index 00000000..c7d632ad
--- /dev/null
+++ b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png
new file mode 100644
index 00000000..324787b2
--- /dev/null
+++ b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.png b/tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.png
new file mode 100644
index 00000000..de77beb2
--- /dev/null
+++ b/tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.png
Binary files differ
diff --git a/tests/ref/grid-subheaders-too-large-repeating-orphan-before-relative.png b/tests/ref/grid-subheaders-too-large-repeating-orphan-before-relative.png
new file mode 100644
index 00000000..dfcac850
--- /dev/null
+++ b/tests/ref/grid-subheaders-too-large-repeating-orphan-before-relative.png
Binary files differ
diff --git a/tests/ref/html/multi-header-inside-table.html b/tests/ref/html/multi-header-inside-table.html
new file mode 100644
index 00000000..a4a61a69
--- /dev/null
+++ b/tests/ref/html/multi-header-inside-table.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ </head>
+ <body>
+ <table>
+ <thead>
+ <tr>
+ <th>First</th>
+ <th>Header</th>
+ </tr>
+ <tr>
+ <th>Second</th>
+ <th>Header</th>
+ </tr>
+ <tr>
+ <th>Level 2</th>
+ <th>Header</th>
+ </tr>
+ <tr>
+ <th>Level 3</th>
+ <th>Header</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>Body</td>
+ <td>Cells</td>
+ </tr>
+ <tr>
+ <td>Yet</td>
+ <td>More</td>
+ </tr>
+ <tr>
+ <th>Level 2</th>
+ <th>Header Inside</th>
+ </tr>
+ <tr>
+ <th>Level 3</th>
+ <th></th>
+ </tr>
+ <tr>
+ <td>Even</td>
+ <td>More</td>
+ </tr>
+ <tr>
+ <td>Body</td>
+ <td>Cells</td>
+ </tr>
+ <tr>
+ <th>One Last Header</th>
+ <th>For Good Measure</th>
+ </tr>
+ </tbody>
+ <tfoot>
+ <tr>
+ <td>Footer</td>
+ <td>Row</td>
+ </tr>
+ <tr>
+ <td>Ending</td>
+ <td>Table</td>
+ </tr>
+ </tfoot>
+ </table>
+ </body>
+</html>
diff --git a/tests/ref/html/multi-header-table.html b/tests/ref/html/multi-header-table.html
new file mode 100644
index 00000000..8a34ac17
--- /dev/null
+++ b/tests/ref/html/multi-header-table.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ </head>
+ <body>
+ <table>
+ <thead>
+ <tr>
+ <th>First</th>
+ <th>Header</th>
+ </tr>
+ <tr>
+ <th>Second</th>
+ <th>Header</th>
+ </tr>
+ <tr>
+ <th>Level 2</th>
+ <th>Header</th>
+ </tr>
+ <tr>
+ <th>Level 3</th>
+ <th>Header</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>Body</td>
+ <td>Cells</td>
+ </tr>
+ <tr>
+ <td>Yet</td>
+ <td>More</td>
+ </tr>
+ </tbody>
+ <tfoot>
+ <tr>
+ <td>Footer</td>
+ <td>Row</td>
+ </tr>
+ <tr>
+ <td>Ending</td>
+ <td>Table</td>
+ </tr>
+ </tfoot>
+ </table>
+ </body>
+</html>
diff --git a/tests/ref/issue-5359-column-override-stays-inside-header.png b/tests/ref/issue-5359-column-override-stays-inside-header.png
new file mode 100644
index 00000000..8339a409
--- /dev/null
+++ b/tests/ref/issue-5359-column-override-stays-inside-header.png
Binary files differ
diff --git a/tests/suite/layout/grid/footers.typ b/tests/suite/layout/grid/footers.typ
index f7f1deb0..c0b03f50 100644
--- a/tests/suite/layout/grid/footers.typ
+++ b/tests/suite/layout/grid/footers.typ
@@ -389,6 +389,29 @@
table.footer[a][b][c]
)
+--- grid-footer-repeatable-unbreakable ---
+#set page(height: 8em, width: auto)
+#table(
+ [h],
+ table.footer(
+ [a],
+ [b],
+ [c],
+ )
+)
+
+--- grid-footer-non-repeatable-unbreakable ---
+#set page(height: 8em, width: auto)
+#table(
+ [h],
+ table.footer(
+ [a],
+ [b],
+ [c],
+ repeat: false,
+ )
+)
+
--- grid-footer-stroke-edge-cases ---
// Test footer stroke priority edge case
#set page(height: 10em)
diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ
index 229bce61..ea222ee8 100644
--- a/tests/suite/layout/grid/headers.typ
+++ b/tests/suite/layout/grid/headers.typ
@@ -118,30 +118,81 @@
)
--- grid-header-not-at-first-row ---
-// Error: 3:3-3:19 header must start at the first row
-// Hint: 3:3-3:19 remove any rows before the header
#grid(
[a],
grid.header([b])
)
--- grid-header-not-at-first-row-two-columns ---
-// Error: 4:3-4:19 header must start at the first row
-// Hint: 4:3-4:19 remove any rows before the header
#grid(
columns: 2,
[a],
grid.header([b])
)
---- grow-header-multiple ---
-// Error: 3:3-3:19 cannot have more than one header
+--- grid-header-multiple ---
#grid(
grid.header([a]),
grid.header([b]),
[a],
)
+--- grid-header-skip ---
+#grid(
+ columns: 2,
+ [x], [y],
+ grid.header([a]),
+ grid.header([b]),
+ grid.cell(x: 1)[c], [d],
+ grid.header([e]),
+ [f], grid.cell(x: 1)[g]
+)
+
+--- grid-header-too-large-non-repeating-orphan ---
+#set page(height: 8em)
+#grid(
+ grid.header(
+ [a\ ] * 5,
+ repeat: false,
+ ),
+ [b]
+)
+
+--- grid-header-too-large-repeating-orphan ---
+#set page(height: 8em)
+#grid(
+ grid.header(
+ [a\ ] * 5,
+ repeat: true,
+ ),
+ [b]
+)
+
+--- grid-header-too-large-repeating-orphan-with-footer ---
+#set page(height: 8em)
+#grid(
+ grid.header(
+ [a\ ] * 5,
+ repeat: true,
+ ),
+ [b],
+ grid.footer(
+ [c],
+ repeat: true,
+ )
+)
+
+--- grid-header-too-large-repeating-orphan-not-at-first-row ---
+#set page(height: 8em)
+#grid(
+ [b],
+ grid.header(
+ [a\ ] * 5,
+ repeat: true,
+ ),
+ [c],
+)
+
--- table-header-in-grid ---
// Error: 2:3-2:20 cannot use `table.header` as a grid header
// Hint: 2:3-2:20 use `grid.header` instead
@@ -228,6 +279,51 @@
table.cell(rowspan: 3, lines(15))
)
+--- grid-header-and-rowspan-contiguous-1 ---
+// Block should occupy all space
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto),
+ gutter: 3pt,
+ inset: 0pt,
+ table.header(
+ [*H*],
+ [*W*]
+ ),
+ table.cell(rowspan: 3, block(height: 2.5em + 2em + 20em, width: 100%, fill: red))
+)
+
+--- grid-header-and-rowspan-contiguous-2 ---
+// Block should occupy all space
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 10em, 5em, auto),
+ gutter: 3pt,
+ inset: 0pt,
+ table.header(
+ [*H*],
+ [*W*]
+ ),
+ table.cell(rowspan: 3, block(height: 2.5em + 2em + 20em, width: 100%, fill: red))
+)
+
+--- grid-header-and-large-auto-contiguous ---
+// Block should occupy all space
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 4.5em, auto),
+ gutter: 3pt,
+ inset: 0pt,
+ table.header(
+ [*H*],
+ [*W*]
+ ),
+ block(height: 2.5em + 2em + 20em, width: 100%, fill: red)
+)
+
--- grid-header-lack-of-space ---
// Test lack of space for header + text.
#set page(height: 8em)
@@ -255,6 +351,17 @@
..([Test], [Test], [Test]) * 20
)
+--- grid-header-non-repeating-orphan-prevention ---
+#set page(height: 5em)
+#v(2em)
+#grid(
+ grid.header(repeat: false)[*Abc*],
+ [a],
+ [b],
+ [c],
+ [d]
+)
+
--- grid-header-empty ---
// Empty header should just be a repeated blank row
#set page(height: 12em)
@@ -339,6 +446,56 @@
[a\ b]
)
+--- grid-header-not-at-the-top ---
+#set page(height: 5em)
+#v(2em)
+#grid(
+ [a],
+ [b],
+ grid.header[*Abc*],
+ [d],
+ [e],
+ [f],
+)
+
+--- grid-header-replace ---
+#set page(height: 5em)
+#v(1.5em)
+#grid(
+ grid.header[*Abc*],
+ [a],
+ [b],
+ grid.header[*Def*],
+ [d],
+ [e],
+ [f],
+)
+
+--- grid-header-replace-orphan ---
+#set page(height: 5em)
+#grid(
+ grid.header[*Abc*],
+ [a],
+ [b],
+ grid.header[*Def*],
+ [d],
+ [e],
+ [f],
+)
+
+--- grid-header-replace-doesnt-fit ---
+#set page(height: 5em)
+#v(0.8em)
+#grid(
+ grid.header[*Abc*],
+ [a],
+ [b],
+ grid.header[*Def*],
+ [d],
+ [e],
+ [f],
+)
+
--- grid-header-stroke-edge-cases ---
// Test header stroke priority edge case (last header row removed)
#set page(height: 8em)
@@ -463,8 +620,6 @@
#table(
columns: 3,
[Outside],
- // Error: 1:3-4:4 header must start at the first row
- // Hint: 1:3-4:4 remove any rows before the header
table.header(
[A], table.cell(x: 1)[B], [C],
table.cell(x: 1)[D],
diff --git a/tests/suite/layout/grid/html.typ b/tests/suite/layout/grid/html.typ
index 10345cb0..cf98d4bc 100644
--- a/tests/suite/layout/grid/html.typ
+++ b/tests/suite/layout/grid/html.typ
@@ -57,3 +57,78 @@
[d], [e], [f],
[g], [h], [i]
)
+
+--- multi-header-table html ---
+#table(
+ columns: 2,
+
+ table.header(
+ [First], [Header]
+ ),
+ table.header(
+ [Second], [Header]
+ ),
+ table.header(
+ [Level 2], [Header],
+ level: 2,
+ ),
+ table.header(
+ [Level 3], [Header],
+ level: 3,
+ ),
+
+ [Body], [Cells],
+ [Yet], [More],
+
+ table.footer(
+ [Footer], [Row],
+ [Ending], [Table],
+ ),
+)
+
+--- multi-header-inside-table html ---
+#table(
+ columns: 2,
+
+ table.header(
+ [First], [Header]
+ ),
+ table.header(
+ [Second], [Header]
+ ),
+ table.header(
+ [Level 2], [Header],
+ level: 2,
+ ),
+ table.header(
+ [Level 3], [Header],
+ level: 3,
+ ),
+
+ [Body], [Cells],
+ [Yet], [More],
+
+ table.header(
+ [Level 2], [Header Inside],
+ level: 2,
+ ),
+ table.header(
+ [Level 3],
+ level: 3,
+ ),
+
+ [Even], [More],
+ [Body], [Cells],
+
+ table.header(
+ [One Last Header],
+ [For Good Measure],
+ repeat: false,
+ level: 4,
+ ),
+
+ table.footer(
+ [Footer], [Row],
+ [Ending], [Table],
+ ),
+)
diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ
new file mode 100644
index 00000000..56bed6a5
--- /dev/null
+++ b/tests/suite/layout/grid/subheaders.typ
@@ -0,0 +1,602 @@
+--- grid-subheaders-demo ---
+#set page(height: 15.2em)
+#table(
+ columns: 2,
+ align: center,
+ table.header(
+ table.cell(colspan: 2)[*Regional User Data*],
+ ),
+ table.header(
+ level: 2,
+ table.cell(colspan: 2)[*Germany*],
+ [*Username*], [*Joined*]
+ ),
+ [john123], [2024],
+ [rob8], [2025],
+ [joe1], [2025],
+ [joe2], [2025],
+ [martha], [2025],
+ [pear], [2025],
+ table.header(
+ level: 2,
+ table.cell(colspan: 2)[*United States*],
+ [*Username*], [*Joined*]
+ ),
+ [cool4], [2023],
+ [roger], [2023],
+ [bigfan55], [2022]
+)
+
+--- grid-subheaders-colorful ---
+#set page(width: auto, height: 12em)
+#let rows(n) = {
+ range(n).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+}
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ ),
+ table.header(
+ level: 2,
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..rows(2),
+ table.header(
+ level: 2,
+ table.cell(stroke: red)[*New Name*], table.cell(stroke: aqua, colspan: 4)[*Other Data*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..rows(3)
+)
+
+--- grid-subheaders-basic ---
+#grid(
+ grid.header([a]),
+ grid.header(level: 2, [b]),
+ [c]
+)
+
+--- grid-subheaders-basic-non-consecutive ---
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+)
+
+--- grid-subheaders-basic-replace ---
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+ grid.header(level: 2, [c]),
+ [z],
+)
+
+--- grid-subheaders-basic-with-footer ---
+#grid(
+ grid.header([a]),
+ grid.header(level: 2, [b]),
+ [c],
+ grid.footer([d])
+)
+
+--- grid-subheaders-basic-non-consecutive-with-footer ---
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+ grid.footer([f])
+)
+
+--- grid-subheaders-repeat ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ grid.header(level: 2, [b]),
+ ..([c],) * 10,
+)
+
+--- grid-subheaders-repeat-non-consecutive ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 10,
+)
+
+--- grid-subheaders-repeat-with-footer ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [m],
+ grid.header(level: 2, [b]),
+ ..([c],) * 10,
+ grid.footer([f])
+)
+
+--- grid-subheaders-repeat-gutter ---
+// Gutter below the header is also repeated
+#set page(height: 8em)
+#grid(
+ inset: (bottom: 0.5pt),
+ stroke: (bottom: 1pt),
+ gutter: (1pt, 6pt, 1pt),
+ grid.header([a]),
+ grid.header(level: 2, [b]),
+ ..([c],) * 10,
+)
+
+--- grid-subheaders-repeat-replace ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 10,
+ grid.header(level: 2, [c]),
+ ..([z],) * 10,
+)
+
+--- grid-subheaders-repeat-replace-multiple-levels ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ grid.header(level: 3, [c]),
+ ..([y],) * 10,
+ grid.header(level: 2, [d]),
+ ..([z],) * 6,
+)
+
+--- grid-subheaders-repeat-replace-gutter ---
+#set page(height: 8em)
+#grid(
+ gutter: 3pt,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 8,
+ grid.header(level: 2, [c]),
+ ..([z],) * 4,
+)
+
+--- grid-subheaders-repeat-replace-orphan ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 12,
+ grid.header(level: 2, [c]),
+ ..([z],) * 10,
+)
+
+--- grid-subheaders-repeat-replace-double-orphan ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 11,
+ grid.header(level: 2, [c]),
+ grid.header(level: 3, [d]),
+ ..([z],) * 10,
+)
+
+--- grid-subheaders-repeat-replace-gutter-orphan-at-child ---
+#set page(height: 8em)
+#grid(
+ gutter: 3pt,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 9,
+ grid.header(level: 2, [c]),
+ [z \ z],
+ ..([z],) * 3,
+)
+
+--- grid-subheaders-repeat-replace-gutter-orphan-at-gutter ---
+#set page(height: 8em)
+#grid(
+ gutter: 3pt,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 9,
+ box(height: 3pt),
+ grid.header(level: 2, [c]),
+ ..([z],) * 4,
+)
+
+--- grid-subheaders-repeat-replace-didnt-fit-once ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 10,
+ grid.header(level: 2, [c\ c\ c]),
+ ..([z],) * 4,
+)
+
+--- grid-subheaders-repeat-replace-with-footer ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ grid.header(level: 3, [c]),
+ ..([y],) * 10,
+ grid.header(level: 2, [d]),
+ ..([z],) * 6,
+ grid.footer([f])
+)
+
+--- grid-subheaders-repeat-replace-with-footer-orphan ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 10,
+ grid.header(level: 2, [c]),
+ ..([z],) * 10,
+ grid.footer([f])
+)
+
+--- grid-subheaders-repeat-replace-short-lived ---
+// No orphan prevention for short-lived headers
+// (followed by replacing headers).
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ grid.header(level: 2, [b]),
+ grid.header(level: 2, [c]),
+ grid.header(level: 2, [d]),
+ grid.header(level: 2, [e]),
+ grid.header(level: 2, [f]),
+ grid.header(level: 2, [g]),
+ grid.header(level: 2, [h]),
+ grid.header(level: 2, [i]),
+ grid.header(level: 2, [j]),
+ grid.header(level: 3, [k]),
+ ..([z],) * 10,
+)
+
+--- grid-subheaders-repeat-short-lived-also-replaces ---
+// Short-lived subheaders must still replace their conflicting predecessors.
+#set page(height: 8em)
+#grid(
+ // This has to go
+ grid.header(level: 3, [a]),
+ [w],
+ grid.header(level: 2, [b]),
+ grid.header(level: 2, [c]),
+ grid.header(level: 2, [d]),
+ grid.header(level: 2, [e]),
+ grid.header(level: 2, [f]),
+ grid.header(level: 2, [g]),
+ grid.header(level: 2, [h]),
+ grid.header(level: 2, [i]),
+ grid.header(level: 2, [j]),
+ grid.header(level: 3, [k]),
+ ..([z],) * 10,
+)
+
+--- grid-subheaders-multi-page-row ---
+#set page(height: 8em)
+#grid(
+ columns: 2,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+ grid.header(level: 3, [c]),
+ [a], [b],
+ grid.cell(
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [y],
+ ..([z],) * 10,
+)
+
+--- grid-subheaders-non-repeat ---
+#set page(height: 8em)
+#grid(
+ grid.header(repeat: false, [a]),
+ [x],
+ grid.header(level: 2, repeat: false, [b]),
+ ..([y],) * 10,
+)
+
+--- grid-subheaders-non-repeat-replace ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ grid.header(level: 3, [c]),
+ ..([y],) * 9,
+ grid.header(level: 2, repeat: false, [d]),
+ ..([z],) * 6,
+)
+
+--- grid-subheaders-non-repeating-replace-orphan ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 12,
+ grid.header(level: 2, repeat: false, [c]),
+ ..([z],) * 10,
+)
+
+--- grid-subheaders-non-repeating-replace-didnt-fit-once ---
+#set page(height: 8em)
+#grid(
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ ..([y],) * 10,
+ grid.header(level: 2, repeat: false, [c\ c\ c]),
+ ..([z],) * 4,
+)
+
+--- grid-subheaders-multi-page-rowspan ---
+#set page(height: 8em)
+#grid(
+ columns: 2,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+ grid.header(level: 3, [c]),
+ [z], [z],
+ grid.cell(
+ rowspan: 5,
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [cell],
+ [cell]
+)
+
+--- grid-subheaders-multi-page-row-right-after ---
+#set page(height: 8em)
+#grid(
+ columns: 1,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ grid.header(level: 3, [c]),
+ grid.cell(
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [done.],
+ [done.]
+)
+
+--- grid-subheaders-multi-page-rowspan-right-after ---
+#set page(height: 8em)
+#grid(
+ columns: 2,
+ grid.header([a]),
+ [x], [y],
+ grid.header(level: 2, [b]),
+ grid.header(level: 3, [c]),
+ grid.cell(
+ rowspan: 5,
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [cell],
+ [cell],
+ grid.cell(x: 0)[done.],
+ grid.cell(x: 0)[done.]
+)
+
+--- grid-subheaders-multi-page-row-with-footer ---
+#set page(height: 8em)
+#grid(
+ columns: 2,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+ grid.header(level: 3, [c]),
+ [a], [b],
+ grid.cell(
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [y],
+ ..([z],) * 10,
+ grid.footer([f])
+)
+
+--- grid-subheaders-multi-page-rowspan-with-footer ---
+#set page(height: 8em)
+#grid(
+ columns: 2,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+ grid.header(level: 3, [c]),
+ [z], [z],
+ grid.cell(
+ rowspan: 5,
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [cell],
+ [cell],
+ grid.footer([f])
+)
+
+--- grid-subheaders-multi-page-row-right-after-with-footer ---
+#set page(height: 8em)
+#grid(
+ columns: 1,
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ grid.header(level: 3, [c]),
+ grid.cell(
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [done.],
+ [done.],
+ grid.footer([f])
+)
+
+--- grid-subheaders-multi-page-rowspan-gutter ---
+#set page(height: 9em)
+#grid(
+ columns: 2,
+ column-gutter: 4pt,
+ row-gutter: (0pt, 4pt, 8pt, 4pt),
+ inset: (bottom: 0.5pt),
+ stroke: (bottom: 1pt),
+ grid.header([a]),
+ [x],
+ grid.header(level: 2, [b]),
+ [y],
+ grid.header(level: 3, [c]),
+ [z], [z],
+ grid.cell(
+ rowspan: 5,
+ block(fill: red, width: 1.5em, height: 6.4em)
+ ),
+ [cell],
+ [cell],
+ [a\ b],
+ grid.cell(x: 0)[end],
+)
+
+--- grid-subheaders-non-repeating-header-before-multi-page-row ---
+#set page(height: 6em)
+#grid(
+ grid.header(repeat: false, [h]),
+ [row #colbreak() row]
+)
+
+
+--- grid-subheaders-short-lived-no-orphan-prevention ---
+// No orphan prevention for short-lived headers.
+#set page(height: 8em)
+#v(5em)
+#grid(
+ grid.header(level: 2, [b]),
+ grid.header(level: 2, [c]),
+ [d]
+)
+
+--- grid-subheaders-repeating-orphan-prevention ---
+#set page(height: 8em)
+#v(4.5em)
+#grid(
+ grid.header(repeat: true, level: 2, [L2]),
+ grid.header(repeat: true, level: 4, [L4]),
+ [a]
+)
+
+--- grid-subheaders-non-repeating-orphan-prevention ---
+#set page(height: 8em)
+#v(4.5em)
+#grid(
+ grid.header(repeat: false, level: 2, [L2]),
+ grid.header(repeat: false, level: 4, [L4]),
+ [a]
+)
+
+--- grid-subheaders-alone ---
+#table(
+ table.header([a]),
+ table.header(level: 2, [b]),
+)
+
+--- grid-subheaders-alone-no-orphan-prevention ---
+#set page(height: 5.3em)
+#v(2em)
+#grid(
+ grid.header([L1]),
+ grid.header(level: 2, [L2]),
+)
+
+--- grid-subheaders-alone-with-gutter-no-orphan-prevention ---
+#set page(height: 5.3em)
+#v(2em)
+#grid(
+ gutter: 3pt,
+ grid.header([L1]),
+ grid.header(level: 2, [L2]),
+)
+
+--- grid-subheaders-alone-with-footer ---
+#table(
+ table.header([a]),
+ table.header(level: 2, [b]),
+ table.footer([c])
+)
+
+--- grid-subheaders-alone-with-footer-no-orphan-prevention ---
+#set page(height: 5.3em)
+#table(
+ table.header([L1]),
+ table.header(level: 2, [L2]),
+ table.footer([a])
+)
+
+--- grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention ---
+#set page(height: 5.5em)
+#table(
+ gutter: 4pt,
+ table.header([L1]),
+ table.header(level: 2, [L2]),
+ table.footer([a])
+)
+
+--- grid-subheaders-too-large-non-repeating-orphan-before-auto ---
+#set page(height: 8em)
+#grid(
+ grid.header([1]),
+ grid.header([a\ ] * 2, level: 2, repeat: false),
+ grid.header([2], level: 3),
+ [b\ b\ b],
+)
+
+--- grid-subheaders-too-large-repeating-orphan-before-auto ---
+#set page(height: 8em)
+#grid(
+ grid.header([1]),
+ grid.header([a\ ] * 2, level: 2, repeat: true),
+ grid.header([2], level: 3),
+ rect(width: 10pt, height: 3em, fill: red),
+)
+
+--- grid-subheaders-too-large-repeating-orphan-before-relative ---
+#set page(height: 8em)
+#grid(
+ rows: (auto, auto, auto, 3em),
+ grid.header([1]),
+ grid.header([a\ ] * 2, level: 2, repeat: true),
+ grid.header([2], level: 3),
+ rect(width: 10pt, height: 3em, fill: red),
+)
+
+--- grid-subheaders-too-large-non-repeating-orphan-before-relative ---
+#set page(height: 8em)
+#grid(
+ rows: (auto, auto, auto, 3em),
+ grid.header([1]),
+ grid.header([a\ ] * 2, level: 2, repeat: false),
+ grid.header([2], level: 3),
+ rect(width: 10pt, height: 3em, fill: red),
+)