Table multiple headers and subheaders (#6168)

This commit is contained in:
PgBiel 2025-06-10 11:41:16 -03:00 committed by GitHub
parent a18ca3481d
commit 7c7b962b98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 2453 additions and 520 deletions

View File

@ -3,7 +3,9 @@ use std::fmt::Debug;
use typst_library::diag::{bail, SourceResult}; use typst_library::diag::{bail, SourceResult};
use typst_library::engine::Engine; use typst_library::engine::Engine;
use typst_library::foundations::{Resolve, StyleChain}; 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::{ use typst_library::layout::{
Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel, Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel,
Size, Sizing, Size, Sizing,
@ -30,10 +32,8 @@ pub struct GridLayouter<'a> {
pub(super) rcols: Vec<Abs>, pub(super) rcols: Vec<Abs>,
/// The sum of `rcols`. /// The sum of `rcols`.
pub(super) width: Abs, pub(super) width: Abs,
/// Resolve row sizes, by region. /// Resolved row sizes, by region.
pub(super) rrows: Vec<Vec<RowPiece>>, 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 /// The amount of unbreakable rows remaining to be laid out in the
/// current unbreakable row group. While this is positive, no region breaks /// current unbreakable row group. While this is positive, no region breaks
/// should occur. /// should occur.
@ -41,24 +41,155 @@ pub struct GridLayouter<'a> {
/// Rowspans not yet laid out because not all of their spanned rows were /// Rowspans not yet laid out because not all of their spanned rows were
/// laid out yet. /// laid out yet.
pub(super) rowspans: Vec<Rowspan>, pub(super) rowspans: Vec<Rowspan>,
/// The initial size of the current region before we started subtracting. /// Grid layout state for the current region.
pub(super) initial: Size, pub(super) current: Current,
/// Frames for finished regions. /// Frames for finished regions.
pub(super) finished: Vec<Frame>, 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. /// Whether this is an RTL grid.
pub(super) is_rtl: bool, pub(super) is_rtl: bool,
/// The simulated header height. /// Currently repeating headers, one per level. Sorted by increasing
/// This field is reset in `layout_header` and properly updated by /// 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 /// `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 /// before all header rows are fully laid out. It is usually fine because
/// header rows themselves are unbreakable, and unbreakable rows do not /// header rows themselves are unbreakable, and unbreakable rows do not
/// need to read this field at all. /// 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 simulated footer height for this region.
///
/// The simulation occurs before any rows are laid out for a region. /// The simulation occurs before any rows are laid out for a region.
pub(super) footer_height: Abs, 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. /// Details about a resulting row piece.
@ -114,14 +245,27 @@ impl<'a> GridLayouter<'a> {
rcols: vec![Abs::zero(); grid.cols.len()], rcols: vec![Abs::zero(); grid.cols.len()],
width: Abs::zero(), width: Abs::zero(),
rrows: vec![], rrows: vec![],
lrows: vec![],
unbreakable_rows_left: 0, unbreakable_rows_left: 0,
rowspans: vec![], rowspans: vec![],
initial: regions.size,
finished: vec![], finished: vec![],
finished_header_rows: vec![],
is_rtl: TextElem::dir_in(styles) == Dir::RTL, is_rtl: TextElem::dir_in(styles) == Dir::RTL,
header_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(), footer_height: Abs::zero(),
},
span, span,
} }
} }
@ -130,38 +274,57 @@ impl<'a> GridLayouter<'a> {
pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> { pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> {
self.measure_columns(engine)?; self.measure_columns(engine)?;
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { if let Some(footer) = &self.grid.footer {
// Ensure rows in the first region will be aware of the possible if footer.repeated {
// presence of the footer. // Ensure rows in the first region will be aware of the
// possible presence of the footer.
self.prepare_footer(footer, engine, 0)?; self.prepare_footer(footer, engine, 0)?;
if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) { self.regions.size.y -= self.current.footer_height;
// No repeatable header, so we won't subtract it later. self.current.initial_after_repeats = self.regions.size.y;
self.regions.size.y -= self.footer_height;
} }
} }
for y in 0..self.grid.rows.len() { let mut y = 0;
if let Some(Repeatable::Repeated(header)) = &self.grid.header { let mut consecutive_header_count = 0;
if y < header.end { while y < self.grid.rows.len() {
if y == 0 { if let Some(next_header) = self.upcoming_headers.get(consecutive_header_count)
self.layout_header(header, engine, 0)?; {
self.regions.size.y -= self.footer_height; 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. // Skip header rows during normal layout.
continue; continue;
} }
} }
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { if let Some(footer) = &self.grid.footer {
if y >= footer.start { if footer.repeated && y >= footer.start {
if y == footer.start { if y == footer.start {
self.layout_footer(footer, engine, self.finished.len())?; self.layout_footer(footer, engine, self.finished.len())?;
self.flush_orphans();
} }
y = footer.end;
continue; continue;
} }
} }
self.layout_row(y, engine, 0)?; 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)?; self.finish_region(engine, true)?;
@ -184,12 +347,46 @@ impl<'a> GridLayouter<'a> {
self.render_fills_strokes() 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( pub(super) fn layout_row(
&mut self, &mut self,
y: usize, y: usize,
engine: &mut Engine, engine: &mut Engine,
disambiguator: usize, 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<()> { ) -> SourceResult<()> {
// Skip to next region if current one is full, but only for content // 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 // rows, not for gutter rows, and only if we aren't laying out an
@ -206,13 +403,18 @@ impl<'a> GridLayouter<'a> {
} }
// Don't layout gutter rows at the top of a region. // 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] { match self.grid.rows[y] {
Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?, Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?,
Sizing::Rel(v) => { Sizing::Rel(v) => {
self.layout_relative_row(engine, disambiguator, v, y)? 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> { fn render_fills_strokes(mut self) -> SourceResult<Fragment> {
let mut finished = std::mem::take(&mut self.finished); let mut finished = std::mem::take(&mut self.finished);
let frame_amount = finished.len(); let frame_amount = finished.len();
for ((frame_index, frame), rows) in for (((frame_index, frame), rows), finished_header_rows) in
finished.iter_mut().enumerate().zip(&self.rrows) 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() { if self.rcols.is_empty() || rows.is_empty() {
continue; continue;
@ -347,7 +554,8 @@ impl<'a> GridLayouter<'a> {
let hline_indices = rows let hline_indices = rows
.iter() .iter()
.map(|piece| piece.y) .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 // Converts a row to the corresponding index in the vector of
// hlines. // hlines.
@ -372,7 +580,7 @@ impl<'a> GridLayouter<'a> {
}; };
let mut prev_y = None; 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. // Position of lines below the row index in the previous iteration.
let expected_prev_line_position = prev_y let expected_prev_line_position = prev_y
.map(|prev_y| { .map(|prev_y| {
@ -383,47 +591,40 @@ impl<'a> GridLayouter<'a> {
}) })
.unwrap_or(LinePosition::Before); .unwrap_or(LinePosition::Before);
// FIXME: In the future, directly specify in 'self.rrows' when // Header's lines at the bottom have priority when repeated.
// we place a repeated header rather than its original rows. // This will store the end bound of the last header if the
// That would let us remove most of those verbose checks, both // current iteration is calculating lines under it.
// in 'lines.rs' and here. Those checks also aren't fully let last_repeated_header_end_above = match finished_header_rows {
// accurate either, since they will also trigger when some rows Some(info) if prev_y.is_some() && i == info.repeated_amount => {
// have been removed between the header and what's below it. Some(info.last_repeated_header_end)
let is_under_repeated_header = self }
.grid _ => None,
.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
});
// If some grid rows were omitted between the previous resolved // If some grid rows were omitted between the previous resolved
// row and the current one, we ensure lines below the previous // row and the current one, we ensure lines below the previous
// row don't "disappear" and are considered, albeit with less // row don't "disappear" and are considered, albeit with less
// priority. However, don't do this when we're below a header, // priority. However, don't do this when we're below a header,
// as it must have more priority instead of less, so it is // 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 // last row in the header is removed, in which case we append
// both the lines under the row above us and also (later) the // both the lines under the row above us and also (later) the
// lines under the header's (removed) last row. // lines under the header's (removed) last row.
let prev_lines = prev_y let prev_lines = match prev_y {
.filter(|prev_y| { Some(prev_y)
prev_y + 1 != y if prev_y + 1 != y
&& (!is_under_repeated_header && last_repeated_header_end_above.is_none_or(
|| self |last_repeated_header_end| {
.grid prev_y + 1 != last_repeated_header_end
.header },
.as_ref() ) =>
.and_then(Repeatable::as_repeated) {
.is_some_and(|header| prev_y + 1 != header.end)) get_hlines_at(prev_y + 1)
}) }
.map(|prev_y| get_hlines_at(prev_y + 1))
.unwrap_or(&[]); _ => &[],
};
let expected_hline_position = let expected_hline_position =
expected_line_position(y, y == self.grid.rows.len()); 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 mut expected_header_line_position = LinePosition::Before;
let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) = let header_hlines = match (last_repeated_header_end_above, prev_y) {
self.grid.header.as_ref().zip(prev_y) (Some(header_end_above), Some(prev_y))
{ if !self.grid.has_gutter
if is_under_repeated_header
&& (!self.grid.has_gutter
|| matches!( || matches!(
self.grid.rows[prev_y], self.grid.rows[prev_y],
Sizing::Rel(length) if length.is_zero() Sizing::Rel(length) if length.is_zero()
)) ) =>
{ {
// For lines below a header, give priority to the // For lines below a header, give priority to the
// lines originally below the header rather than // lines originally below the header rather than
@ -468,15 +667,13 @@ impl<'a> GridLayouter<'a> {
// column-gutter is specified, for example. In that // column-gutter is specified, for example. In that
// case, we still repeat the line under the gutter. // case, we still repeat the line under the gutter.
expected_header_line_position = expected_line_position( expected_header_line_position = expected_line_position(
header.end, header_end_above,
header.end == self.grid.rows.len(), header_end_above == self.grid.rows.len(),
); );
get_hlines_at(header.end) get_hlines_at(header_end_above)
} else {
&[]
} }
} else {
&[] _ => &[],
}; };
// The effective hlines to be considered at this row index are // The effective hlines to be considered at this row index are
@ -529,6 +726,7 @@ impl<'a> GridLayouter<'a> {
grid, grid,
rows, rows,
local_top_y, local_top_y,
last_repeated_header_end_above,
in_last_region, in_last_region,
y, y,
x, x,
@ -941,15 +1139,9 @@ impl<'a> GridLayouter<'a> {
let frame = self.layout_single_row(engine, disambiguator, first, y)?; let frame = self.layout_single_row(engine, disambiguator, first, y)?;
self.push_row(frame, y, true); self.push_row(frame, y, true);
if self if let Some(row_height) = &mut self.row_state.current_row_height {
.grid // Add to header height, as we are in a header row.
.header *row_height += first;
.as_ref()
.and_then(Repeatable::as_repeated)
.is_some_and(|header| y < header.end)
{
// Add to header height.
self.header_height += first;
} }
return Ok(()); return Ok(());
@ -958,19 +1150,21 @@ impl<'a> GridLayouter<'a> {
// Expand all but the last region. // Expand all but the last region.
// Skip the first region if the space is eaten up by an fr row. // Skip the first region if the space is eaten up by an fr row.
let len = resolved.len(); let len = resolved.len();
for ((i, region), target) in self for ((i, region), target) in
.regions self.regions
.iter() .iter()
.enumerate() .enumerate()
.zip(&mut resolved[..len - 1]) .zip(&mut resolved[..len - 1])
.skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) .skip(self.current.lrows.iter().any(|row| matches!(row, Row::Fr(..)))
as usize)
{ {
// Subtract header and footer heights from the region height when // 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( target.set_max(
region.y region.y
- if i > 0 { - if i > 0 {
self.header_height + self.footer_height self.current.repeating_header_height + self.current.footer_height
} else { } else {
Abs::zero() Abs::zero()
}, },
@ -1181,25 +1375,19 @@ impl<'a> GridLayouter<'a> {
let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
let frame = self.layout_single_row(engine, disambiguator, resolved, y)?; let frame = self.layout_single_row(engine, disambiguator, resolved, y)?;
if self if let Some(row_height) = &mut self.row_state.current_row_height {
.grid // Add to header height, as we are in a header row.
.header *row_height += resolved;
.as_ref()
.and_then(Repeatable::as_repeated)
.is_some_and(|header| y < header.end)
{
// Add to header height.
self.header_height += resolved;
} }
// Skip to fitting region, but only if we aren't part of an unbreakable // 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 // row group. We use 'may_progress_with_repeats' to stop trying if we
// properly considers that a header and a footer would be added on each // would skip to a region with the same height and where the same
// region break. // headers would be repeated.
let height = frame.height(); let height = frame.height();
while self.unbreakable_rows_left == 0 while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(height) && !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)?; 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 /// will be pushed for this particular row. It can be `false` for rows
/// spanning multiple regions. /// spanning multiple regions.
fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { 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.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. /// Finish rows for one region.
@ -1333,68 +1526,73 @@ impl<'a> GridLayouter<'a> {
engine: &mut Engine, engine: &mut Engine,
last: bool, last: bool,
) -> SourceResult<()> { ) -> 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 if self
.current
.lrows .lrows
.last() .last()
.is_some_and(|row| self.grid.is_gutter_track(row.index())) .is_some_and(|row| self.grid.is_gutter_track(row.index()))
{ {
// Remove the last row in the region if it is a gutter row. // 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 // If no rows other than the footer have been laid out so far
// there are rows beside the footer, then don't lay it out at all. // (e.g. due to header orphan prevention), and there are rows
// This check doesn't apply, and is thus overridden, when there is a // beside the footer, then don't lay it out at all.
// header. //
let mut footer_would_be_orphan = self.lrows.is_empty() // It is worth noting that the footer is made non-repeatable at
&& !in_last_with_offset( // the grid resolving stage if it is short-lived, that is, if
self.regions, // it is at the start of the table (or right after headers at
self.header_height + self.footer_height, // the start of the table).
) //
&& self // TODO(subfooters): explicitly check for short-lived footers.
.grid // TODO(subfooters): widow prevention for non-repeated footers with a
.footer // similar mechanism / when implementing multiple footers.
.as_ref() let footer_would_be_widow = matches!(&self.grid.footer, Some(footer) if footer.repeated)
.and_then(Repeatable::as_repeated) && self.current.lrows.is_empty()
.is_some_and(|footer| footer.start != 0); && self.current.could_progress_at_top;
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;
}
}
let mut laid_out_footer_start = None; let mut laid_out_footer_start = None;
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { if !footer_would_be_widow {
// Don't layout the footer if it would be alone with the header in if let Some(footer) = &self.grid.footer {
// the page, and don't layout it twice. // Don't layout the footer if it would be alone with the header
if !footer_would_be_orphan // in the page (hence the widow check), and don't layout it
&& self.lrows.iter().all(|row| row.index() < footer.start) // 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); laid_out_footer_start = Some(footer.start);
self.layout_footer(footer, engine, self.finished.len())?; self.layout_footer(footer, engine, self.finished.len())?;
} }
} }
}
// Determine the height of existing rows in the region. // Determine the height of existing rows in the region.
let mut used = Abs::zero(); let mut used = Abs::zero();
let mut fr = Fr::zero(); let mut fr = Fr::zero();
for row in &self.lrows { for row in &self.current.lrows {
match row { match row {
Row::Frame(frame, _, _) => used += frame.height(), Row::Frame(frame, _, _) => used += frame.height(),
Row::Fr(v, _, _) => fr += *v, 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 // Determine the size of the grid in this region, expanding fully if
// there are fr rows. // there are fr rows.
let mut size = Size::new(self.width, used).min(self.initial); let mut size = Size::new(self.width, used).min(self.current.initial);
if fr.get() > 0.0 && self.initial.y.is_finite() { if fr.get() > 0.0 && self.current.initial.y.is_finite() {
size.y = self.initial.y; size.y = self.current.initial.y;
} }
// The frame for the region. // The frame for the region.
@ -1413,9 +1611,10 @@ impl<'a> GridLayouter<'a> {
let mut pos = Point::zero(); let mut pos = Point::zero();
let mut rrows = vec![]; let mut rrows = vec![];
let current_region = self.finished.len(); let current_region = self.finished.len();
let mut repeated_header_row_height = Abs::zero();
// Place finished rows and layout fractional rows. // 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 { let (frame, y, is_last) = match row {
Row::Frame(frame, y, is_last) => (frame, y, is_last), Row::Frame(frame, y, is_last) => (frame, y, is_last),
Row::Fr(v, y, disambiguator) => { Row::Fr(v, y, disambiguator) => {
@ -1426,6 +1625,9 @@ impl<'a> GridLayouter<'a> {
}; };
let height = frame.height(); 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 // Ensure rowspans which span this row will have enough space to
// be laid out over it later. // 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 // we have to check the same index again in the next
// iteration. // iteration.
let rowspan = self.rowspans.remove(i); 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 { } else {
i += 1; i += 1;
} }
@ -1515,21 +1721,40 @@ impl<'a> GridLayouter<'a> {
pos.y += height; 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 { 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(); 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)?; 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. // 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(()) Ok(())
@ -1541,11 +1766,26 @@ impl<'a> GridLayouter<'a> {
&mut self, &mut self,
output: Frame, output: Frame,
resolved_rows: Vec<RowPiece>, resolved_rows: Vec<RowPiece>,
header_row_info: FinishedHeaderRowInfo,
) { ) {
self.finished.push(output); self.finished.push(output);
self.rrows.push(resolved_rows); self.rrows.push(resolved_rows);
self.regions.next(); 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 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)
}

View File

@ -391,10 +391,12 @@ pub fn vline_stroke_at_row(
/// ///
/// This function assumes columns are sorted by increasing `x`, and rows are /// This function assumes columns are sorted by increasing `x`, and rows are
/// sorted by increasing `y`. /// sorted by increasing `y`.
#[allow(clippy::too_many_arguments)]
pub fn hline_stroke_at_column( pub fn hline_stroke_at_column(
grid: &CellGrid, grid: &CellGrid,
rows: &[RowPiece], rows: &[RowPiece],
local_top_y: Option<usize>, local_top_y: Option<usize>,
header_end_above: Option<usize>,
in_last_region: bool, in_last_region: bool,
y: usize, y: usize,
x: usize, x: usize,
@ -499,17 +501,15 @@ pub fn hline_stroke_at_column(
// Top border stroke and header stroke are generally prioritized, unless // Top border stroke and header stroke are generally prioritized, unless
// they don't have explicit hline overrides and one or more user-provided // they don't have explicit hline overrides and one or more user-provided
// hlines would appear at the same position, which then are prioritized. // hlines would appear at the same position, which then are prioritized.
let top_stroke_comes_from_header = grid let top_stroke_comes_from_header = header_end_above.zip(local_top_y).is_some_and(
.header |(last_repeated_header_end, local_top_y)| {
.as_ref() // Check if the last repeated header row is above this line.
.and_then(Repeatable::as_repeated) //
.zip(local_top_y) // Note that `y == last_repeated_header_end` is impossible for a
.is_some_and(|(header, local_top_y)| { // strictly repeated header (not in its original position).
// Ensure the row above us is a repeated header. local_top_y < last_repeated_header_end && y > last_repeated_header_end
// FIXME: Make this check more robust when headers at arbitrary },
// positions are added. );
local_top_y < header.end && y > header.end
});
// Prioritize the footer's top stroke as well where applicable. // Prioritize the footer's top stroke as well where applicable.
let bottom_stroke_comes_from_footer = grid let bottom_stroke_comes_from_footer = grid
@ -637,7 +637,7 @@ mod test {
}, },
vec![], vec![],
vec![], vec![],
None, vec![],
None, None,
entries, entries,
) )
@ -1175,7 +1175,7 @@ mod test {
}, },
vec![], vec![],
vec![], vec![],
None, vec![],
None, None,
entries, entries,
) )
@ -1268,6 +1268,7 @@ mod test {
grid, grid,
&rows, &rows,
y.checked_sub(1), y.checked_sub(1),
None,
true, true,
y, y,
x, x,
@ -1461,6 +1462,7 @@ mod test {
grid, grid,
&rows, &rows,
y.checked_sub(1), y.checked_sub(1),
None,
true, true,
y, y,
x, x,
@ -1506,6 +1508,7 @@ mod test {
grid, grid,
&rows, &rows,
if y == 4 { Some(2) } else { y.checked_sub(1) }, if y == 4 { Some(2) } else { y.checked_sub(1) },
None,
true, true,
y, y,
x, x,

View File

@ -1,57 +1,446 @@
use std::ops::Deref;
use typst_library::diag::SourceResult; use typst_library::diag::SourceResult;
use typst_library::engine::Engine; use typst_library::engine::Engine;
use typst_library::layout::grid::resolve::{Footer, Header, Repeatable}; use typst_library::layout::grid::resolve::{Footer, Header, Repeatable};
use typst_library::layout::{Abs, Axes, Frame, Regions}; use typst_library::layout::{Abs, Axes, Frame, Regions};
use super::layouter::GridLayouter; use super::layouter::{GridLayouter, RowState};
use super::rowspans::UnbreakableRowGroup; use super::rowspans::UnbreakableRowGroup;
impl GridLayouter<'_> { impl<'a> GridLayouter<'a> {
/// Layouts the header's rows. /// Checks whether a region break could help a situation where we're out of
/// Skips regions as necessary. /// space for the next row. The criteria are:
pub fn layout_header( ///
/// 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, &mut self,
header: &Header, header: &Header,
engine: &mut Engine, engine: &mut Engine,
disambiguator: usize, disambiguator: usize,
) -> SourceResult<()> { as_short_lived: bool,
let header_rows = ) -> SourceResult<Abs> {
self.simulate_header(header, &self.regions, engine, disambiguator)?; 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; let mut skipped_region = false;
while self.unbreakable_rows_left == 0 while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(header_rows.height + self.footer_height) && !self.regions.size.y.fits(header_height)
&& self.regions.may_progress() && self.may_progress_with_repeats()
{ {
// Advance regions without any output until we can place the // Advance regions without any output until we can place the
// header and the footer. // 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; skipped_region = true;
self.regions.size.y -= self.current.footer_height;
self.current.initial_after_repeats = self.regions.size.y;
} }
if let Some(footer) = &self.grid.footer {
if footer.repeated && skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
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;
}
}
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 += 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. // Reset the header height for this region.
// It will be re-calculated when laying out each header row. // It will be re-calculated when laying out each header row.
self.header_height = Abs::zero(); self.current.repeating_header_height = Abs::zero();
self.current.repeating_header_heights.clear();
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { debug_assert!(self.current.lrows.is_empty());
if skipped_region { debug_assert!(self.current.lrows_orphan_snapshot.is_none());
// Simulate the footer again; the region's 'full' might have let may_progress = self.may_progress_with_repeats();
// changed.
self.footer_height = self if may_progress {
.simulate_footer(footer, &self.regions, engine, disambiguator)? // Enable orphan prevention for headers at the top of the region.
.height; // 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);
} }
} }
// Header is unbreakable. if !has_non_repeated_pending_header {
// Thus, no risk of 'finish_region' being recursively called from self.current.initial_after_repeats = self.regions.size.y;
// within 'layout_row'.
self.unbreakable_rows_left += header.end;
for y in 0..header.end {
self.layout_row(y, engine, disambiguator)?;
} }
if !may_progress {
// Flush pending headers immediately, as placing them again later
// won't help.
self.flush_orphans();
}
Ok(()) 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. /// Simulate the header's group of rows.
pub fn simulate_header( pub fn simulate_header(
&self, &self,
@ -66,8 +455,8 @@ impl GridLayouter<'_> {
// assume that the amount of unbreakable rows following the first row // assume that the amount of unbreakable rows following the first row
// in the header will be precisely the rows in the header. // in the header will be precisely the rows in the header.
self.simulate_unbreakable_row_group( self.simulate_unbreakable_row_group(
0, header.range.start,
Some(header.end), Some(header.range.end - header.range.start),
regions, regions,
engine, engine,
disambiguator, disambiguator,
@ -91,11 +480,22 @@ impl GridLayouter<'_> {
{ {
// Advance regions without any output until we can place the // Advance regions without any output until we can place the
// footer. // 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; 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 // Simulate the footer again; the region's 'full' might have
// changed. // changed.
self.simulate_footer(footer, &self.regions, engine, disambiguator)? self.simulate_footer(footer, &self.regions, engine, disambiguator)?
@ -118,12 +518,22 @@ impl GridLayouter<'_> {
// Ensure footer rows have their own height available. // Ensure footer rows have their own height available.
// Won't change much as we're creating an unbreakable row group // Won't change much as we're creating an unbreakable row group
// anyway, so this is mostly for correctness. // 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; let footer_len = self.grid.rows.len() - footer.start;
self.unbreakable_rows_left += footer_len; self.unbreakable_rows_left += footer_len;
for y in footer.start..self.grid.rows.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(()) Ok(())
@ -144,10 +554,18 @@ impl GridLayouter<'_> {
// in the footer will be precisely the rows in the footer. // in the footer will be precisely the rows in the footer.
self.simulate_unbreakable_row_group( self.simulate_unbreakable_row_group(
footer.start, footer.start,
Some(self.grid.rows.len() - footer.start), Some(footer.end - footer.start),
regions, regions,
engine, engine,
disambiguator, 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()
}

View File

@ -4,7 +4,7 @@ use typst_library::foundations::Resolve;
use typst_library::layout::grid::resolve::Repeatable; use typst_library::layout::grid::resolve::Repeatable;
use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; 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}; use super::{layout_cell, Cell, GridLayouter};
/// All information needed to layout a single rowspan. /// All information needed to layout a single rowspan.
@ -90,10 +90,10 @@ pub struct CellMeasurementData<'layouter> {
impl GridLayouter<'_> { impl GridLayouter<'_> {
/// Layout a rowspan over the already finished regions, plus the current /// Layout a rowspan over the already finished regions, plus the current
/// region's frame and resolved rows, if it wasn't finished yet (because /// region's frame and height of resolved header rows, if it wasn't
/// we're being called from `finish_region`, but note that this function is /// finished yet (because we're being called from `finish_region`, but note
/// also called once after all regions are finished, in which case /// that this function is also called once after all regions are finished,
/// `current_region_data` is `None`). /// in which case `current_region_data` is `None`).
/// ///
/// We need to do this only once we already know the heights of all /// 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 /// spanned rows, which is only possible after laying out the last row
@ -101,7 +101,7 @@ impl GridLayouter<'_> {
pub fn layout_rowspan( pub fn layout_rowspan(
&mut self, &mut self,
rowspan_data: Rowspan, rowspan_data: Rowspan,
current_region_data: Option<(&mut Frame, &[RowPiece])>, current_region_data: Option<(&mut Frame, Abs)>,
engine: &mut Engine, engine: &mut Engine,
) -> SourceResult<()> { ) -> SourceResult<()> {
let Rowspan { let Rowspan {
@ -146,11 +146,31 @@ impl GridLayouter<'_> {
// Push the layouted frames directly into the finished frames. // Push the layouted frames directly into the finished frames.
let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?; let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?;
let (current_region, current_rrows) = current_region_data.unzip(); let (current_region, current_header_row_height) = current_region_data.unzip();
for ((i, finished), frame) in self
// 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 .finished
.iter_mut() .iter_mut()
.chain(current_region.into_iter()) .chain(current_region.into_iter())
.zip(finished_header_rows)
.skip(first_region) .skip(first_region)
.enumerate() .enumerate()
.zip(fragment) .zip(fragment)
@ -162,22 +182,9 @@ impl GridLayouter<'_> {
} else { } else {
// The rowspan continuation starts after the header (thus, // The rowspan continuation starts after the header (thus,
// at a position after the sum of the laid out header // at a position after the sum of the laid out header
// rows). // rows). Without a header, this is zero, so the rowspan can
if let Some(Repeatable::Repeated(header)) = &self.grid.header { // start at the very top of the region as usual.
let header_rows = self header_dy
.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()
}
}; };
finished.push_frame(Point::new(dx, dy), frame); 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 // current row is dynamic and depends on the amount of upcoming
// unbreakable cells (with or without a rowspan setting). // unbreakable cells (with or without a rowspan setting).
let mut amount_unbreakable_rows = None; let mut amount_unbreakable_rows = None;
if let Some(Repeatable::NotRepeated(header)) = &self.grid.header { if let Some(footer) = &self.grid.footer {
if current_row < header.end { if !footer.repeated && current_row >= footer.start {
// 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 {
// Non-repeated footer, so keep it unbreakable. // 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); amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start);
} }
} }
@ -254,10 +259,7 @@ impl GridLayouter<'_> {
// Skip to fitting region. // Skip to fitting region.
while !self.regions.size.y.fits(row_group.height) while !self.regions.size.y.fits(row_group.height)
&& !in_last_with_offset( && self.may_progress_with_repeats()
self.regions,
self.header_height + self.footer_height,
)
{ {
self.finish_region(engine, false)?; self.finish_region(engine, false)?;
} }
@ -396,16 +398,29 @@ impl GridLayouter<'_> {
// auto rows don't depend on the backlog, as they only span one // auto rows don't depend on the backlog, as they only span one
// region. // region.
if breakable if breakable
&& (matches!(self.grid.header, Some(Repeatable::Repeated(_))) && (!self.repeating_headers.is_empty()
|| matches!(self.grid.footer, Some(Repeatable::Repeated(_)))) || !self.pending_headers.is_empty()
|| matches!(&self.grid.footer, Some(footer) if footer.repeated))
{ {
// Subtract header and footer height from all upcoming regions // Subtract header and footer height from all upcoming regions
// when measuring the cell, including the last repeated region. // when measuring the cell, including the last repeated region.
// //
// This will update the 'custom_backlog' vector with the // This will update the 'custom_backlog' vector with the
// updated heights of the upcoming regions. // 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| { 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 // 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 // Height of the rowspan covered by spanned rows in the current
// region. // region.
let laid_out_height: Abs = self let laid_out_height: Abs = self
.current
.lrows .lrows
.iter() .iter()
.filter_map(|row| match row { .filter_map(|row| match row {
@ -506,7 +522,12 @@ impl GridLayouter<'_> {
.iter() .iter()
.copied() .copied()
.chain(std::iter::once(if breakable { .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 { } else {
// When measuring unbreakable auto rows, infinite // When measuring unbreakable auto rows, infinite
// height is available for content to expand. // height is available for content to expand.
@ -518,11 +539,13 @@ impl GridLayouter<'_> {
// rowspan's already laid out heights with the current // rowspan's already laid out heights with the current
// region's height and current backlog to ensure a good // region's height and current backlog to ensure a good
// level of accuracy in the measurements. // level of accuracy in the measurements.
let backlog = self //
.regions // Assume only repeating headers will survive starting at
.backlog // the next region.
.iter() let backlog = self.regions.backlog.iter().map(|&size| {
.map(|&size| size - self.header_height - self.footer_height); size - self.current.repeating_header_height
- self.current.footer_height
});
heights_up_to_current_region.chain(backlog).collect::<Vec<_>>() heights_up_to_current_region.chain(backlog).collect::<Vec<_>>()
} else { } else {
@ -536,10 +559,10 @@ impl GridLayouter<'_> {
height = *rowspan_height; height = *rowspan_height;
backlog = None; backlog = None;
full = rowspan_full; full = rowspan_full;
last = self last = self.regions.last.map(|size| {
.regions size - self.current.repeating_header_height
.last - self.current.footer_height
.map(|size| size - self.header_height - self.footer_height); });
} else { } else {
// The rowspan started in the current region, as its vector // The rowspan started in the current region, as its vector
// of heights in regions is currently empty. // of heights in regions is currently empty.
@ -741,10 +764,11 @@ impl GridLayouter<'_> {
simulated_regions.next(); simulated_regions.next();
disambiguator += 1; disambiguator += 1;
// Subtract the initial header and footer height, since that's the // Subtract the repeating header and footer height, since that's
// height we used when subtracting from the region backlog's // the height we used when subtracting from the region backlog's
// heights while measuring cells. // 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 { 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 // which, when used and combined with upcoming spanned rows, covers all
// of the requested rowspan height, we give up. // of the requested rowspan height, we give up.
for _attempt in 0..5 { for _attempt in 0..5 {
let rowspan_simulator = RowspanSimulator::new( let rowspan_simulator =
disambiguator, RowspanSimulator::new(disambiguator, simulated_regions, &self.current);
simulated_regions,
self.header_height,
self.footer_height,
);
let total_spanned_height = rowspan_simulator.simulate_rowspan_layout( let total_spanned_height = rowspan_simulator.simulate_rowspan_layout(
y, y,
@ -963,7 +983,8 @@ impl GridLayouter<'_> {
{ {
extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero()); extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero());
simulated_regions.next(); 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; disambiguator += 1;
} }
simulated_regions.size.y -= extra_amount_to_grow; simulated_regions.size.y -= extra_amount_to_grow;
@ -980,10 +1001,17 @@ struct RowspanSimulator<'a> {
finished: usize, finished: usize,
/// The state of regions during the simulation. /// The state of regions during the simulation.
regions: Regions<'a>, 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, 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, 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. /// The total spanned height so far in the simulation.
total_spanned_height: Abs, total_spanned_height: Abs,
/// Height of the latest spanned gutter row in the simulation. /// Height of the latest spanned gutter row in the simulation.
@ -997,14 +1025,19 @@ impl<'a> RowspanSimulator<'a> {
fn new( fn new(
finished: usize, finished: usize,
regions: Regions<'a>, regions: Regions<'a>,
header_height: Abs, current: &super::layouter::Current,
footer_height: Abs,
) -> Self { ) -> Self {
Self { Self {
finished, finished,
regions, regions,
header_height, // There can be no new headers or footers within a multi-page
footer_height, // 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(), total_spanned_height: Abs::zero(),
latest_spanned_gutter_height: Abs::zero(), latest_spanned_gutter_height: Abs::zero(),
} }
@ -1053,10 +1086,7 @@ impl<'a> RowspanSimulator<'a> {
0, 0,
)?; )?;
while !self.regions.size.y.fits(row_group.height) while !self.regions.size.y.fits(row_group.height)
&& !in_last_with_offset( && self.may_progress_with_repeats()
self.regions,
self.header_height + self.footer_height,
)
{ {
self.finish_region(layouter, engine)?; self.finish_region(layouter, engine)?;
} }
@ -1078,10 +1108,7 @@ impl<'a> RowspanSimulator<'a> {
let mut skipped_region = false; let mut skipped_region = false;
while unbreakable_rows_left == 0 while unbreakable_rows_left == 0
&& !self.regions.size.y.fits(height) && !self.regions.size.y.fits(height)
&& !in_last_with_offset( && self.may_progress_with_repeats()
self.regions,
self.header_height + self.footer_height,
)
{ {
self.finish_region(layouter, engine)?; self.finish_region(layouter, engine)?;
@ -1127,17 +1154,31 @@ impl<'a> RowspanSimulator<'a> {
// our simulation checks what happens AFTER the auto row, so we can // our simulation checks what happens AFTER the auto row, so we can
// just use the original backlog from `self.regions`. // just use the original backlog from `self.regions`.
let disambiguator = self.finished; let disambiguator = self.finished;
let header_height =
if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { let (repeating_headers, header_height) = if !layouter.repeating_headers.is_empty()
layouter || !layouter.pending_headers.is_empty()
.simulate_header(header, &self.regions, engine, disambiguator)? {
.height // 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 { } else {
Abs::zero() (None, Abs::zero())
}; };
let footer_height = let footer_height = if let Some(footer) =
if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated)
{
layouter layouter
.simulate_footer(footer, &self.regions, engine, disambiguator)? .simulate_footer(footer, &self.regions, engine, disambiguator)?
.height .height
@ -1156,19 +1197,24 @@ impl<'a> RowspanSimulator<'a> {
skipped_region = true; 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 { self.header_height = if skipped_region {
// Simulate headers again, at the new region, as // Simulate headers again, at the new region, as
// the full region height may change. // the full region height may change.
layouter layouter.simulate_header_height(
.simulate_header(header, &self.regions, engine, disambiguator)? repeating_headers,
.height &self.regions,
engine,
disambiguator,
)?
} else { } else {
header_height 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 { self.footer_height = if skipped_region {
// Simulate footers again, at the new region, as // Simulate footers again, at the new region, as
// the full region height may change. // 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 // 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). // 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.regions.size.y -= self.header_height + self.footer_height;
self.initial_after_repeats = self.regions.size.y;
Ok(()) Ok(())
} }
@ -1201,8 +1248,18 @@ impl<'a> RowspanSimulator<'a> {
self.regions.next(); self.regions.next();
self.finished += 1; self.finished += 1;
self.could_progress_at_top = self.regions.may_progress();
self.simulate_header_footer_layout(layouter, engine) 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. /// Subtracts some size from the end of a vector of sizes.

View File

@ -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 ecow::{eco_format, EcoString};
use smallvec::SmallVec; use smallvec::SmallVec;
@ -482,3 +484,16 @@ cast! {
"number too large" "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"
})?,
}

View File

@ -1,6 +1,6 @@
pub mod resolve; pub mod resolve;
use std::num::NonZeroUsize; use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::Arc; use std::sync::Arc;
use comemo::Track; use comemo::Track;
@ -468,6 +468,17 @@ pub struct GridHeader {
#[default(true)] #[default(true)]
pub repeat: bool, 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. /// The cells and lines within the header.
#[variadic] #[variadic]
pub children: Vec<GridItem>, pub children: Vec<GridItem>,

View File

@ -1,5 +1,5 @@
use std::num::NonZeroUsize; use std::num::{NonZeroU32, NonZeroUsize};
use std::ops::Range; use std::ops::{Deref, DerefMut, Range};
use std::sync::Arc; use std::sync::Arc;
use ecow::eco_format; use ecow::eco_format;
@ -48,6 +48,7 @@ pub fn grid_to_cellgrid<'a>(
let children = elem.children.iter().map(|child| match child { let children = elem.children.iter().map(|child| match child {
GridChild::Header(header) => ResolvableGridChild::Header { GridChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles), repeat: header.repeat(styles),
level: header.level(styles),
span: header.span(), span: header.span(),
items: header.children.iter().map(resolve_item), 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 { let children = elem.children.iter().map(|child| match child {
TableChild::Header(header) => ResolvableGridChild::Header { TableChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles), repeat: header.repeat(styles),
level: header.level(styles),
span: header.span(), span: header.span(),
items: header.children.iter().map(resolve_item), items: header.children.iter().map(resolve_item),
}, },
@ -426,8 +428,20 @@ pub struct Line {
/// A repeatable grid header. Starts at the first row. /// A repeatable grid header. Starts at the first row.
#[derive(Debug)] #[derive(Debug)]
pub struct Header { pub struct Header {
/// The index after the last row included in this header. /// The range of rows included in this header.
pub end: usize, 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. /// A repeatable grid footer. Stops at the last row.
@ -435,32 +449,56 @@ pub struct Header {
pub struct Footer { pub struct Footer {
/// The first row included in this footer. /// The first row included in this footer.
pub start: usize, 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,
} }
/// A possibly repeatable grid object. impl Footer {
/// The footer's range of included rows.
#[inline]
pub fn range(&self) -> Range<usize> {
self.start..self.end
}
}
/// A possibly repeatable grid child (header or footer).
///
/// It still exists even when not repeatable, but must not have additional /// It still exists even when not repeatable, but must not have additional
/// considerations by grid layout, other than for consistency (such as making /// considerations by grid layout, other than for consistency (such as making
/// a certain group of rows unbreakable). /// a certain group of rows unbreakable).
pub enum Repeatable<T> { pub struct Repeatable<T> {
Repeated(T), inner: T,
NotRepeated(T),
/// Whether the user requested the child to repeat.
pub repeated: bool,
}
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> { 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,
}
}
/// Returns `Some` if the value is repeated, `None` otherwise. /// Returns `Some` if the value is repeated, `None` otherwise.
#[inline]
pub fn as_repeated(&self) -> Option<&T> { pub fn as_repeated(&self) -> Option<&T> {
match self { if self.repeated {
Self::Repeated(repeated) => Some(repeated), Some(&self.inner)
Self::NotRepeated(_) => None, } else {
None
} }
} }
} }
@ -617,7 +655,7 @@ impl<'a> Entry<'a> {
/// Any grid child, which can be either a header or an item. /// Any grid child, which can be either a header or an item.
pub enum ResolvableGridChild<T: ResolvableCell, I> { 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 }, Footer { repeat: bool, span: Span, items: I },
Item(ResolvableGridItem<T>), Item(ResolvableGridItem<T>),
} }
@ -638,8 +676,8 @@ pub struct CellGrid<'a> {
/// Gutter rows are not included. /// Gutter rows are not included.
/// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines.
pub hlines: Vec<Vec<Line>>, pub hlines: Vec<Vec<Line>>,
/// The repeatable header of this grid. /// The repeatable headers of this grid.
pub header: Option<Repeatable<Header>>, pub headers: Vec<Repeatable<Header>>,
/// The repeatable footer of this grid. /// The repeatable footer of this grid.
pub footer: Option<Repeatable<Footer>>, pub footer: Option<Repeatable<Footer>>,
/// Whether this grid has gutters. /// Whether this grid has gutters.
@ -654,7 +692,7 @@ impl<'a> CellGrid<'a> {
cells: impl IntoIterator<Item = Cell<'a>>, cells: impl IntoIterator<Item = Cell<'a>>,
) -> Self { ) -> Self {
let entries = cells.into_iter().map(Entry::Cell).collect(); 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. /// Generates the cell grid, given the tracks and resolved entries.
@ -663,7 +701,7 @@ impl<'a> CellGrid<'a> {
gutter: Axes<&[Sizing]>, gutter: Axes<&[Sizing]>,
vlines: Vec<Vec<Line>>, vlines: Vec<Vec<Line>>,
hlines: Vec<Vec<Line>>, hlines: Vec<Vec<Line>>,
header: Option<Repeatable<Header>>, headers: Vec<Repeatable<Header>>,
footer: Option<Repeatable<Footer>>, footer: Option<Repeatable<Footer>>,
entries: Vec<Entry<'a>>, entries: Vec<Entry<'a>>,
) -> Self { ) -> Self {
@ -717,7 +755,7 @@ impl<'a> CellGrid<'a> {
entries, entries,
vlines, vlines,
hlines, hlines,
header, headers,
footer, footer,
has_gutter, has_gutter,
} }
@ -852,6 +890,11 @@ impl<'a> CellGrid<'a> {
self.cols.len() 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. /// Resolves and positions all cells in the grid before creating it.
@ -937,6 +980,12 @@ struct RowGroupData {
span: Span, span: Span,
kind: RowGroupKind, 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. /// 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 /// 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 /// 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 mut pending_vlines: Vec<(Span, Line)> = vec![];
let has_gutter = self.gutter.any(|tracks| !tracks.is_empty()); let has_gutter = self.gutter.any(|tracks| !tracks.is_empty());
let mut header: Option<Header> = None; let mut headers: Vec<Repeatable<Header>> = vec![];
let mut repeat_header = false;
// Stores where the footer is supposed to end, its span, and the // Stores where the footer is supposed to end, its span, and the
// actual footer structure. // actual footer structure.
let mut footer: Option<(usize, Span, Footer)> = None; let mut footer: Option<(usize, Span, Footer)> = None;
let mut repeat_footer = false; 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 // We can't just use the cell's index in the 'cells' vector to
// determine its automatic position, since cells could have arbitrary // determine its automatic position, since cells could have arbitrary
// positions, so the position of a cell in 'cells' can differ from its // positions, so the position of a cell in 'cells' can differ from its
@ -1008,6 +1060,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// automatically-positioned cell. // automatically-positioned cell.
let mut auto_index: usize = 0; 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. // We have to rebuild the grid to account for fixed cell positions.
// //
// Create at least 'children.len()' positions, since there could be at // Create at least 'children.len()' positions, since there could be at
@ -1028,12 +1085,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
columns, columns,
&mut pending_hlines, &mut pending_hlines,
&mut pending_vlines, &mut pending_vlines,
&mut header, &mut headers,
&mut repeat_header,
&mut footer, &mut footer,
&mut repeat_footer, &mut repeat_footer,
&mut auto_index, &mut auto_index,
&mut next_header,
&mut resolved_cells, &mut resolved_cells,
&mut at_least_one_cell,
child, child,
)?; )?;
} }
@ -1049,13 +1107,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
row_amount, row_amount,
)?; )?;
let (header, footer) = self.finalize_headers_and_footers( let footer = self.finalize_headers_and_footers(
has_gutter, has_gutter,
header, &mut headers,
repeat_header,
footer, footer,
repeat_footer, repeat_footer,
row_amount, row_amount,
at_least_one_cell,
)?; )?;
Ok(CellGrid::new_internal( Ok(CellGrid::new_internal(
@ -1063,7 +1121,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
self.gutter, self.gutter,
vlines, vlines,
hlines, hlines,
header, headers,
footer, footer,
resolved_cells, resolved_cells,
)) ))
@ -1083,12 +1141,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
columns: usize, columns: usize,
pending_hlines: &mut Vec<(Span, Line, bool)>, pending_hlines: &mut Vec<(Span, Line, bool)>,
pending_vlines: &mut Vec<(Span, Line)>, pending_vlines: &mut Vec<(Span, Line)>,
header: &mut Option<Header>, headers: &mut Vec<Repeatable<Header>>,
repeat_header: &mut bool,
footer: &mut Option<(usize, Span, Footer)>, footer: &mut Option<(usize, Span, Footer)>,
repeat_footer: &mut bool, repeat_footer: &mut bool,
auto_index: &mut usize, auto_index: &mut usize,
next_header: &mut usize,
resolved_cells: &mut Vec<Option<Entry<'x>>>, resolved_cells: &mut Vec<Option<Entry<'x>>>,
at_least_one_cell: &mut bool,
child: ResolvableGridChild<T, I>, child: ResolvableGridChild<T, I>,
) -> SourceResult<()> ) -> SourceResult<()>
where where
@ -1112,7 +1171,32 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// position than it would usually be if it would be in a non-empty // 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 // row, so we must step a local index inside headers and footers
// instead, and use a separate counter outside them. // 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. // 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 mut first_available_row = 0;
let (header_footer_items, simple_item) = match child { let (header_footer_items, simple_item) = match child {
ResolvableGridChild::Header { repeat, span, items, .. } => { ResolvableGridChild::Header { repeat, level, span, items, .. } => {
if header.is_some() {
bail!(span, "cannot have more than one header");
}
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
kind: RowGroupKind::Header, kind: RowGroupKind::Header,
repeat,
repeatable_level: level,
top_hlines_start: pending_hlines.len(), top_hlines_start: pending_hlines.len(),
top_hlines_end: None, top_hlines_end: None,
}); });
*repeat_header = repeat;
first_available_row = 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, // If any cell in the header is automatically positioned,
// have it skip to the next empty row. This is to avoid // 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 // latest auto-position cell, since each auto-position cell
// always occupies the first available position after the // always occupies the first available position after the
// previous one. Therefore, this will be >= auto_index. // 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) (Some(items), None)
} }
@ -1162,21 +1242,27 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
repeat,
kind: RowGroupKind::Footer, kind: RowGroupKind::Footer,
repeatable_level: NonZeroU32::ONE,
top_hlines_start: pending_hlines.len(), top_hlines_start: pending_hlines.len(),
top_hlines_end: None, top_hlines_end: None,
}); });
*repeat_footer = repeat;
first_available_row = 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) (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); let items = header_footer_items.into_iter().flatten().chain(simple_item);
@ -1191,7 +1277,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// gutter. // gutter.
skip_auto_index_through_fully_merged_rows( skip_auto_index_through_fully_merged_rows(
resolved_cells, resolved_cells,
&mut local_auto_index, local_auto_index,
columns, columns,
); );
@ -1266,7 +1352,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// automatically positioned cell. Same for footers. // automatically positioned cell. Same for footers.
local_auto_index local_auto_index
.checked_sub(1) .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) .map_or(0, |last_auto_index| last_auto_index % columns + 1)
}); });
if end.is_some_and(|end| end.get() < start) { if end.is_some_and(|end| end.get() < start) {
@ -1295,10 +1381,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
cell_y, cell_y,
colspan, colspan,
rowspan, rowspan,
header.as_ref(), headers,
footer.as_ref(), footer.as_ref(),
resolved_cells, resolved_cells,
&mut local_auto_index, local_auto_index,
local_next_header,
first_available_row, first_available_row,
columns, columns,
row_group_data.is_some(), row_group_data.is_some(),
@ -1350,7 +1437,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
); );
if top_hlines_end.is_none() 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 // Auto index was moved, so upcoming auto-pos hlines should
// no longer appear at the top. // no longer appear at the top.
@ -1437,7 +1524,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
None => { None => {
// Empty header/footer: consider the header/footer to be // Empty header/footer: consider the header/footer to be
// at the next empty row after the latest auto index. // 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_start = first_available_row;
let group_end = group_start + 1; let group_end = group_start + 1;
@ -1454,8 +1541,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// 'find_next_empty_row' will skip through any existing headers // 'find_next_empty_row' will skip through any existing headers
// and footers without having to loop through them each time. // and footers without having to loop through them each time.
// Cells themselves, unfortunately, still have to. // Cells themselves, unfortunately, still have to.
assert!(resolved_cells[local_auto_index].is_none()); assert!(resolved_cells[*local_auto_index].is_none());
resolved_cells[local_auto_index] = resolved_cells[*local_auto_index] =
Some(Entry::Cell(self.resolve_cell( Some(Entry::Cell(self.resolve_cell(
T::default(), T::default(),
0, 0,
@ -1483,21 +1570,38 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
match row_group.kind { match row_group.kind {
RowGroupKind::Header => { RowGroupKind::Header => {
if group_range.start != 0 { let data = Header {
bail!( // Later on, we have to correct this range in case there
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
// is gutter. But only once all cells have been analyzed // is gutter. But only once all cells have been analyzed
// and the header has fully expanded in the fixup loop // and the header has fully expanded in the fixup loop
// below. // 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 => { RowGroupKind::Footer => {
@ -1514,15 +1618,14 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// before the footer might not be included as part of // before the footer might not be included as part of
// the footer if it is contained within the header. // the footer if it is contained within the header.
start: group_range.start, 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(()) Ok(())
@ -1689,18 +1792,43 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
fn finalize_headers_and_footers( fn finalize_headers_and_footers(
&self, &self,
has_gutter: bool, has_gutter: bool,
header: Option<Header>, headers: &mut [Repeatable<Header>],
repeat_header: bool,
footer: Option<(usize, Span, Footer)>, footer: Option<(usize, Span, Footer)>,
repeat_footer: bool, repeat_footer: bool,
row_amount: usize, row_amount: usize,
) -> SourceResult<(Option<Repeatable<Header>>, Option<Repeatable<Footer>>)> { at_least_one_cell: bool,
let header = header ) -> SourceResult<Option<Repeatable<Footer>>> {
.map(|mut header| { // 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 // Repeat the gutter below a header (hence why we don't
// subtract 1 from the gutter case). // subtract 1 from the gutter case).
// Don't do this if there are no rows under the header. // Don't do this if there are no rows under the header.
if has_gutter { 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 // - 'header.end' is always 'last y + 1'. The header stops
// before that row. // before that row.
// - Therefore, '2 * header.end' will be 2 * (last y + 1), // - Therefore, '2 * header.end' will be 2 * (last y + 1),
@ -1712,24 +1840,16 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// to the index of the gutter row right below the header, // to the index of the gutter row right below the header,
// which is what we want (that gutter spacing should be // which is what we want (that gutter spacing should be
// repeated across pages to maintain uniformity). // repeated across pages to maintain uniformity).
header.end *= 2; header.range.end *= 2;
// If the header occupies the entire grid, ensure we don't // If the header occupies the entire grid, ensure we don't
// include an extra gutter row when it doesn't exist, since // include an extra gutter row when it doesn't exist, since
// the last row of the header is at the very bottom, // the last row of the header is at the very bottom,
// therefore '2 * last y + 1' is not a valid index. // therefore '2 * last y + 1' is not a valid index.
let row_amount = (2 * row_amount).saturating_sub(1); let row_amount = (2 * row_amount).saturating_sub(1);
header.end = header.end.min(row_amount); header.range.end = header.range.end.min(row_amount);
} }
header
})
.map(|header| {
if repeat_header {
Repeatable::Repeated(header)
} else {
Repeatable::NotRepeated(header)
} }
});
let footer = footer let footer = footer
.map(|(footer_end, footer_span, mut 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"); bail!(footer_span, "footer must end at the last row");
} }
let header_end = // TODO(subfooters): will need a global slice of headers and
header.as_ref().map(Repeatable::unwrap).map(|header| header.end); // 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 { if has_gutter {
// Convert the footer's start index to post-gutter coordinates. // 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 // Include the gutter right before the footer, unless there is
// none, or the gutter is already included in the header (no // none, or the gutter is already included in the header (no
// rows between the header and the footer). // 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); 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) Ok(footer)
}) })
.transpose()? .transpose()?
.map(|footer| { .map(|footer| {
if repeat_footer { // Don't repeat footers when the table only has headers and
Repeatable::Repeated(footer) // footers.
} else { // TODO(subfooters): Switch this to marking the last N
Repeatable::NotRepeated(footer) // 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. /// 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. /// Check if a cell's fixed row would conflict with a header or footer.
fn check_for_conflicting_cell_row( fn check_for_conflicting_cell_row(
header: Option<&Header>, headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>, footer: Option<&(usize, Span, Footer)>,
cell_y: usize, cell_y: usize,
rowspan: usize, rowspan: usize,
) -> HintedStrResult<()> { ) -> HintedStrResult<()> {
if let Some(header) = header { // NOTE: y + rowspan >, not >=, header.start, to check if the rowspan
// TODO: check start (right now zero, always satisfied) // enters the header. For example, consider a rowspan of 1: if
if cell_y < header.end { // `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!( bail!(
"cell would conflict with header spanning the same position"; "cell would conflict with header spanning the same position";
hint: "try moving the cell or the header" hint: "try moving the cell or the header"
); );
} }
}
if let Some((footer_end, _, footer)) = footer { if let Some((_, _, footer)) = footer {
// NOTE: y + rowspan >, not >=, footer.start, to check if the rowspan if cell_y < footer.end && cell_y + rowspan > footer.start {
// 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 {
bail!( bail!(
"cell would conflict with footer spanning the same position"; "cell would conflict with footer spanning the same position";
hint: "try reducing the cell's rowspan or moving the footer" hint: "try reducing the cell's rowspan or moving the footer"
@ -1981,10 +2125,11 @@ fn resolve_cell_position(
cell_y: Smart<usize>, cell_y: Smart<usize>,
colspan: usize, colspan: usize,
rowspan: usize, rowspan: usize,
header: Option<&Header>, headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>, footer: Option<&(usize, Span, Footer)>,
resolved_cells: &[Option<Entry>], resolved_cells: &[Option<Entry>],
auto_index: &mut usize, auto_index: &mut usize,
next_header: &mut usize,
first_available_row: usize, first_available_row: usize,
columns: usize, columns: usize,
in_row_group: bool, in_row_group: bool,
@ -2005,12 +2150,14 @@ fn resolve_cell_position(
// Note that the counter ignores any cells with fixed positions, // Note that the counter ignores any cells with fixed positions,
// but automatically-positioned cells will avoid conflicts by // but automatically-positioned cells will avoid conflicts by
// simply skipping existing cells, headers and footers. // simply skipping existing cells, headers and footers.
let resolved_index = find_next_available_position::<false>( let resolved_index = find_next_available_position(
header, headers,
footer, footer,
resolved_cells, resolved_cells,
columns, columns,
*auto_index, *auto_index,
next_header,
false,
)?; )?;
// Ensure the next cell with automatic position will be // 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 // footer (but only if it isn't already in one, otherwise there
// will already be a separate check). // will already be a separate check).
if !in_row_group { 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) cell_index(cell_x, cell_y)
@ -2063,12 +2210,28 @@ fn resolve_cell_position(
// requested column ('Some(None)') or an out of bounds position // requested column ('Some(None)') or an out of bounds position
// ('None'), in which case we'd create a new row to place this // ('None'), in which case we'd create a new row to place this
// cell in. // cell in.
find_next_available_position::<true>( find_next_available_position(
header, headers,
footer, footer,
resolved_cells, resolved_cells,
columns, columns,
initial_index, 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 // footer (but only if it isn't already in one, otherwise there
// will already be a separate check). // will already be a separate check).
if !in_row_group { 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. // 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 /// Finds the first available position after the initial index in the resolved
/// grid of cells. Skips any non-absent positions (positions which already /// grid of cells. Skips any non-absent positions (positions which already
/// have cells specified by the user) as well as any headers and footers. /// 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] #[inline]
fn find_next_available_position<const SKIP_ROWS: bool>( fn find_next_available_position(
header: Option<&Header>, headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>, footer: Option<&(usize, Span, Footer)>,
resolved_cells: &[Option<Entry<'_>>], resolved_cells: &[Option<Entry<'_>>],
columns: usize, columns: usize,
initial_index: usize, initial_index: usize,
next_header: &mut usize,
skip_rows: bool,
) -> HintedStrResult<usize> { ) -> HintedStrResult<usize> {
let mut resolved_index = initial_index; 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 // determine where this cell will be placed. An out of
// bounds position (thus `None`) is also a valid new // bounds position (thus `None`) is also a valid new
// position (only requires expanding the vector). // 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 // Skip one row at a time (cell chose its column, so we don't
// change it). // change it).
resolved_index = resolved_index =
@ -2139,24 +2307,33 @@ fn find_next_available_position<const SKIP_ROWS: bool>(
// would become impractically large before this overflows. // would become impractically large before this overflows.
resolved_index += 1; resolved_index += 1;
} }
} else if let Some(header) = } else if let Some(header) = headers
header.filter(|header| resolved_index < header.end * columns) .get(*next_header)
.filter(|header| resolved_index >= header.range.start * columns)
{ {
// Skip header (can't place a cell inside it from outside it). // Skip header (can't place a cell inside it from outside it).
resolved_index = header.end * 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 { if skip_rows {
// Ensure the cell's chosen column is kept after the // Ensure the cell's chosen column is kept after the
// header. // header.
resolved_index += initial_index % columns; 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)| { } else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| {
resolved_index >= footer.start * columns && resolved_index < *end * columns resolved_index >= footer.start * columns && resolved_index < *end * columns
}) { }) {
// Skip footer, for the same reason. // Skip footer, for the same reason.
resolved_index = *footer_end * columns; resolved_index = *footer_end * columns;
if SKIP_ROWS { if skip_rows {
resolved_index += initial_index % columns; resolved_index += initial_index % columns;
} }
} else { } else {

View File

@ -1,4 +1,4 @@
use std::num::NonZeroUsize; use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::Arc; use std::sync::Arc;
use typst_utils::NonZeroExt; use typst_utils::NonZeroExt;
@ -292,16 +292,61 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content {
elem(tag::tr, Content::sequence(row)) 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 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)))) 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() { if header.is_some() || footer.is_some() {
body = elem(tag::tbody, body); body = elem(tag::tbody, body);
} }
@ -492,6 +537,17 @@ pub struct TableHeader {
#[default(true)] #[default(true)]
pub repeat: bool, 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. /// The cells and lines within the header.
#[variadic] #[variadic]
pub children: Vec<TableItem>, pub children: Vec<TableItem>,

View File

@ -71,10 +71,7 @@ impl Span {
/// Create a span that does not point into any file. /// Create a span that does not point into any file.
pub const fn detached() -> Self { pub const fn detached() -> Self {
match NonZeroU64::new(Self::DETACHED) { Self(NonZeroU64::new(Self::DETACHED).unwrap())
Some(v) => Self(v),
None => unreachable!(),
}
} }
/// Create a new span from a file id and a number. /// 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. /// Pack a file ID and the low bits into a span.
const fn pack(id: FileId, low: u64) -> Self { const fn pack(id: FileId, low: u64) -> Self {
let bits = ((id.into_raw().get() as u64) << Self::FILE_ID_SHIFT) | low; 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. // The file ID is non-zero.
None => unreachable!(), Self(NonZeroU64::new(bits).unwrap())
}
} }
/// Whether the span is detached. /// Whether the span is detached.

View File

@ -26,7 +26,7 @@ pub use once_cell;
use std::fmt::{Debug, Formatter}; use std::fmt::{Debug, Formatter};
use std::hash::Hash; use std::hash::Hash;
use std::iter::{Chain, Flatten, Rev}; 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::ops::{Add, Deref, Div, Mul, Neg, Sub};
use std::sync::Arc; use std::sync::Arc;
@ -66,10 +66,11 @@ pub trait NonZeroExt {
} }
impl NonZeroExt for NonZeroUsize { impl NonZeroExt for NonZeroUsize {
const ONE: Self = match Self::new(1) { const ONE: Self = Self::new(1).unwrap();
Some(v) => v, }
None => unreachable!(),
}; impl NonZeroExt for NonZeroU32 {
const ONE: Self = Self::new(1).unwrap();
} }
/// Extra methods for [`Arc`]. /// Extra methods for [`Arc`].

View File

@ -95,10 +95,7 @@ impl PicoStr {
} }
}; };
match NonZeroU64::new(value) { Ok(Self(NonZeroU64::new(value).unwrap()))
Some(value) => Ok(Self(value)),
None => unreachable!(),
}
} }
/// Resolve to a decoded string. /// Resolve to a decoded string.

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

View File

@ -389,6 +389,29 @@
table.footer[a][b][c] 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 --- --- grid-footer-stroke-edge-cases ---
// Test footer stroke priority edge case // Test footer stroke priority edge case
#set page(height: 10em) #set page(height: 10em)

View File

@ -118,30 +118,81 @@
) )
--- grid-header-not-at-first-row --- --- 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( #grid(
[a], [a],
grid.header([b]) grid.header([b])
) )
--- grid-header-not-at-first-row-two-columns --- --- 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( #grid(
columns: 2, columns: 2,
[a], [a],
grid.header([b]) grid.header([b])
) )
--- grow-header-multiple --- --- grid-header-multiple ---
// Error: 3:3-3:19 cannot have more than one header
#grid( #grid(
grid.header([a]), grid.header([a]),
grid.header([b]), grid.header([b]),
[a], [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 --- --- table-header-in-grid ---
// Error: 2:3-2:20 cannot use `table.header` as a grid header // Error: 2:3-2:20 cannot use `table.header` as a grid header
// Hint: 2:3-2:20 use `grid.header` instead // Hint: 2:3-2:20 use `grid.header` instead
@ -228,6 +279,51 @@
table.cell(rowspan: 3, lines(15)) 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 --- --- grid-header-lack-of-space ---
// Test lack of space for header + text. // Test lack of space for header + text.
#set page(height: 8em) #set page(height: 8em)
@ -255,6 +351,17 @@
..([Test], [Test], [Test]) * 20 ..([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 --- --- grid-header-empty ---
// Empty header should just be a repeated blank row // Empty header should just be a repeated blank row
#set page(height: 12em) #set page(height: 12em)
@ -339,6 +446,56 @@
[a\ b] [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 --- --- grid-header-stroke-edge-cases ---
// Test header stroke priority edge case (last header row removed) // Test header stroke priority edge case (last header row removed)
#set page(height: 8em) #set page(height: 8em)
@ -463,8 +620,6 @@
#table( #table(
columns: 3, columns: 3,
[Outside], [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( table.header(
[A], table.cell(x: 1)[B], [C], [A], table.cell(x: 1)[B], [C],
table.cell(x: 1)[D], table.cell(x: 1)[D],

View File

@ -57,3 +57,78 @@
[d], [e], [f], [d], [e], [f],
[g], [h], [i] [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],
),
)

View File

@ -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),
)