summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPgBiel <9021226+PgBiel@users.noreply.github.com>2024-03-06 05:41:16 -0300
committerGitHub <noreply@github.com>2024-03-06 08:41:16 +0000
commit898367f096fef507488438e00caae8c4ea1d0ff4 (patch)
treedf1911b8ed79fac943e3b4edcc54feb80a94bf90
parent5b2ffd9dd0612d391326e2dd5b5a4a026dec80eb (diff)
Repeatable Table Headers [More Flexible Tables Pt.5a] (#3545)
-rw-r--r--crates/typst/src/layout/grid/layout.rs1017
-rw-r--r--crates/typst/src/layout/grid/lines.rs103
-rw-r--r--crates/typst/src/layout/grid/mod.rs185
-rw-r--r--crates/typst/src/layout/grid/rowspans.rs450
-rw-r--r--crates/typst/src/model/bibliography.rs20
-rw-r--r--crates/typst/src/model/table.rs190
-rw-r--r--tests/ref/layout/grid-headers-1.pngbin0 -> 126196 bytes
-rw-r--r--tests/ref/layout/grid-headers-2.pngbin0 -> 113070 bytes
-rw-r--r--tests/ref/layout/grid-headers-3.pngbin0 -> 102354 bytes
-rw-r--r--tests/ref/layout/grid-headers-4.pngbin0 -> 36452 bytes
-rw-r--r--tests/ref/layout/grid-rowspan-basic.pngbin94597 -> 98653 bytes
-rw-r--r--tests/ref/layout/grid-rtl.pngbin72464 -> 97027 bytes
-rw-r--r--tests/typ/layout/grid-headers-1.typ162
-rw-r--r--tests/typ/layout/grid-headers-2.typ52
-rw-r--r--tests/typ/layout/grid-headers-3.typ35
-rw-r--r--tests/typ/layout/grid-headers-4.typ58
-rw-r--r--tests/typ/layout/grid-rowspan-basic.typ21
-rw-r--r--tests/typ/layout/grid-rtl.typ15
18 files changed, 1725 insertions, 583 deletions
diff --git a/crates/typst/src/layout/grid/layout.rs b/crates/typst/src/layout/grid/layout.rs
index a27e4269..c18a5035 100644
--- a/crates/typst/src/layout/grid/layout.rs
+++ b/crates/typst/src/layout/grid/layout.rs
@@ -242,9 +242,15 @@ impl Entry {
}
}
+/// A repeatable grid header. Starts at the first row.
+pub(super) struct Header {
+ /// The index after the last row included in this header.
+ pub(super) end: usize,
+}
+
/// A grid item, possibly affected by automatic cell positioning. Can be either
/// a line or a cell.
-pub enum GridItem<T: ResolvableCell> {
+pub enum ResolvableGridItem<T: ResolvableCell> {
/// A horizontal line in the grid.
HLine {
/// The row above which the horizontal line is drawn.
@@ -275,6 +281,12 @@ pub enum GridItem<T: ResolvableCell> {
Cell(T),
}
+/// Any grid child, which can be either a header or an item.
+pub enum ResolvableGridChild<T: ResolvableCell, I> {
+ Header { repeat: bool, span: Span, items: I },
+ Item(ResolvableGridItem<T>),
+}
+
/// Used for cell-like elements which are aware of their final properties in
/// the table, and may have property overrides.
pub trait ResolvableCell {
@@ -327,6 +339,8 @@ pub struct CellGrid {
/// Gutter rows are not included.
/// Contains up to 'rows_without_gutter.len() + 1' vectors of lines.
pub(super) hlines: Vec<Vec<Line>>,
+ /// The repeatable header of this grid.
+ pub(super) header: Option<Header>,
/// Whether this grid has gutters.
pub(super) has_gutter: bool,
}
@@ -339,7 +353,7 @@ impl CellGrid {
cells: impl IntoIterator<Item = Cell>,
) -> Self {
let entries = cells.into_iter().map(Entry::Cell).collect();
- Self::new_internal(tracks, gutter, vec![], vec![], entries)
+ Self::new_internal(tracks, gutter, vec![], vec![], None, entries)
}
/// Resolves and positions all cells in the grid before creating it.
@@ -349,10 +363,10 @@ impl CellGrid {
/// must implement Default in order to fill positions in the grid which
/// weren't explicitly specified by the user with empty cells.
#[allow(clippy::too_many_arguments)]
- pub fn resolve<T, I>(
+ pub fn resolve<T, C, I>(
tracks: Axes<&[Sizing]>,
gutter: Axes<&[Sizing]>,
- items: I,
+ children: C,
fill: &Celled<Option<Paint>>,
align: &Celled<Smart<Alignment>>,
inset: &Celled<Sides<Option<Rel<Length>>>>,
@@ -363,8 +377,9 @@ impl CellGrid {
) -> SourceResult<Self>
where
T: ResolvableCell + Default,
- I: IntoIterator<Item = GridItem<T>>,
- I::IntoIter: ExactSizeIterator,
+ I: Iterator<Item = ResolvableGridItem<T>>,
+ C: IntoIterator<Item = ResolvableGridChild<T, I>>,
+ C::IntoIter: ExactSizeIterator,
{
// Number of content columns: Always at least one.
let c = tracks.x.len().max(1);
@@ -380,6 +395,9 @@ impl CellGrid {
let mut pending_vlines: Vec<(Span, Line)> = vec![];
let has_gutter = gutter.any(|tracks| !tracks.is_empty());
+ let mut header: Option<Header> = None;
+ let mut repeat_header = false;
+
// Resolve the breakability of a cell, based on whether or not it spans
// an auto row.
let resolve_breakable = |y, rowspan| {
@@ -411,213 +429,361 @@ impl CellGrid {
let mut auto_index: usize = 0;
// We have to rebuild the grid to account for arbitrary positions.
- // Create at least 'items.len()' positions, since there could be at
- // least 'items.len()' cells (if no explicit lines were specified),
+ // Create at least 'children.len()' positions, since there could be at
+ // least 'children.len()' cells (if no explicit lines were specified),
// even though some of them might be placed in arbitrary positions and
// thus cause the grid to expand.
// Additionally, make sure we allocate up to the next multiple of 'c',
// since each row will have 'c' cells, even if the last few cells
// weren't explicitly specified by the user.
// We apply '% c' twice so that the amount of cells potentially missing
- // is zero when 'items.len()' is already a multiple of 'c' (thus
- // 'items.len() % c' would be zero).
- let items = items.into_iter();
- let Some(item_count) = items.len().checked_add((c - items.len() % c) % c) else {
+ // is zero when 'children.len()' is already a multiple of 'c' (thus
+ // 'children.len() % c' would be zero).
+ let children = children.into_iter();
+ let Some(child_count) = children.len().checked_add((c - children.len() % c) % c)
+ else {
bail!(span, "too many cells or lines were given")
};
- let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(item_count);
- for item in items {
- let cell = match item {
- GridItem::HLine { y, start, end, stroke, span, position } => {
- let y = y.unwrap_or_else(|| {
- // When no 'y' is specified for the hline, we place it
- // under the latest automatically positioned cell.
- // The current value of the auto index is always the
- // index of the latest automatically positioned cell
- // placed plus one (that's what we do in
- // 'resolve_cell_position'), so we subtract 1 to get
- // that cell's index, and place the hline below its
- // row. The exception is when the auto_index is 0,
- // meaning no automatically positioned cell was placed
- // yet. In that case, we place the hline at the top of
- // the table.
- auto_index
- .checked_sub(1)
- .map_or(0, |last_auto_index| last_auto_index / c + 1)
- });
- if end.is_some_and(|end| end.get() < start) {
- bail!(span, "line cannot end before it starts");
- }
- let line = Line { index: y, start, end, stroke, position };
-
- // Since the amount of rows is dynamic, delay placing
- // hlines until after all cells were placed so we can
- // properly verify if they are valid. Note that we can't
- // place hlines even if we already know they would be in a
- // valid row, since it's possible that we pushed pending
- // hlines in the same row as this one in previous
- // iterations, and we need to ensure that hlines from
- // previous iterations are pushed to the final vector of
- // hlines first - the order of hlines must be kept, as this
- // matters when determining which one "wins" in case of
- // conflict. Pushing the current hline before we push
- // pending hlines later would change their order!
- pending_hlines.push((span, line));
- continue;
- }
- GridItem::VLine { x, start, end, stroke, span, position } => {
- let x = x.unwrap_or_else(|| {
- // When no 'x' is specified for the vline, we place it
- // after the latest automatically positioned cell.
- // The current value of the auto index is always the
- // index of the latest automatically positioned cell
- // placed plus one (that's what we do in
- // 'resolve_cell_position'), so we subtract 1 to get
- // that cell's index, and place the vline after its
- // column. The exception is when the auto_index is 0,
- // meaning no automatically positioned cell was placed
- // yet. In that case, we place the vline to the left of
- // the table.
- auto_index
- .checked_sub(1)
- .map_or(0, |last_auto_index| last_auto_index % c + 1)
- });
- if end.is_some_and(|end| end.get() < start) {
- bail!(span, "line cannot end before it starts");
+ let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(child_count);
+ for child in children {
+ let mut is_header = false;
+ let mut header_start = usize::MAX;
+ let mut header_end = 0;
+ let mut header_span = Span::detached();
+ let mut min_auto_index = 0;
+
+ let (header_items, simple_item) = match child {
+ ResolvableGridChild::Header { repeat, span, items, .. } => {
+ if header.is_some() {
+ bail!(span, "cannot have more than one header");
}
- let line = Line { index: x, start, end, stroke, position };
- // For consistency with hlines, we only push vlines to the
- // final vector of vlines after processing every cell.
- pending_vlines.push((span, line));
- continue;
- }
- GridItem::Cell(cell) => cell,
- };
- let cell_span = cell.span();
- // Let's calculate the cell's final position based on its
- // requested position.
- let resolved_index = {
- let cell_x = cell.x(styles);
- let cell_y = cell.y(styles);
- resolve_cell_position(cell_x, cell_y, &resolved_cells, &mut auto_index, c)
- .at(cell_span)?
- };
- let x = resolved_index % c;
- let y = resolved_index / c;
- let colspan = cell.colspan(styles).get();
- let rowspan = cell.rowspan(styles).get();
+ is_header = true;
+ header_span = span;
+ repeat_header = repeat;
- if colspan > c - x {
- bail!(
- cell_span,
- "cell's colspan would cause it to exceed the available column(s)";
- hint: "try placing the cell in another position or reducing its colspan"
- )
- }
+ // If any cell in the header is automatically positioned,
+ // have it skip to the next row. This is to avoid having a
+ // header after a partially filled row just add cells to
+ // that row instead of starting a new one.
+ // FIXME: Revise this approach when headers can start from
+ // arbitrary rows.
+ min_auto_index = auto_index.next_multiple_of(c);
- let Some(largest_index) = c
- .checked_mul(rowspan - 1)
- .and_then(|full_rowspan_offset| {
- resolved_index.checked_add(full_rowspan_offset)
- })
- .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1))
- else {
- bail!(
- cell_span,
- "cell would span an exceedingly large position";
- hint: "try reducing the cell's rowspan or colspan"
- )
+ (Some(items), None)
+ }
+ ResolvableGridChild::Item(item) => (None, Some(item)),
};
+ let items = header_items.into_iter().flatten().chain(simple_item.into_iter());
+ for item in items {
+ let cell = match item {
+ ResolvableGridItem::HLine {
+ y,
+ start,
+ end,
+ stroke,
+ span,
+ position,
+ } => {
+ let y = y.unwrap_or_else(|| {
+ // When no 'y' is specified for the hline, we place
+ // it under the latest automatically positioned
+ // cell.
+ // The current value of the auto index is always
+ // the index of the latest automatically positioned
+ // cell placed plus one (that's what we do in
+ // 'resolve_cell_position'), so we subtract 1 to
+ // get that cell's index, and place the hline below
+ // its row. The exception is when the auto_index is
+ // 0, meaning no automatically positioned cell was
+ // placed yet. In that case, we place the hline at
+ // the top of the table.
+ //
+ // Exceptionally, the hline will be placed before
+ // the minimum auto index if the current auto index
+ // from previous iterations is smaller than the
+ // minimum it should have for the current grid
+ // child. Effectively, this means that a hline at
+ // the start of a header will always appear above
+ // that header's first row.
+ auto_index
+ .max(min_auto_index)
+ .checked_sub(1)
+ .map_or(0, |last_auto_index| last_auto_index / c + 1)
+ });
+ if end.is_some_and(|end| end.get() < start) {
+ bail!(span, "line cannot end before it starts");
+ }
+ let line = Line { index: y, start, end, stroke, position };
+
+ // Since the amount of rows is dynamic, delay placing
+ // hlines until after all cells were placed so we can
+ // properly verify if they are valid. Note that we
+ // can't place hlines even if we already know they
+ // would be in a valid row, since it's possible that we
+ // pushed pending hlines in the same row as this one in
+ // previous iterations, and we need to ensure that
+ // hlines from previous iterations are pushed to the
+ // final vector of hlines first - the order of hlines
+ // must be kept, as this matters when determining which
+ // one "wins" in case of conflict. Pushing the current
+ // hline before we push pending hlines later would
+ // change their order!
+ pending_hlines.push((span, line));
+ continue;
+ }
+ ResolvableGridItem::VLine {
+ x,
+ start,
+ end,
+ stroke,
+ span,
+ position,
+ } => {
+ let x = x.unwrap_or_else(|| {
+ // When no 'x' is specified for the vline, we place
+ // it after the latest automatically positioned
+ // cell.
+ // The current value of the auto index is always
+ // the index of the latest automatically positioned
+ // cell placed plus one (that's what we do in
+ // 'resolve_cell_position'), so we subtract 1 to
+ // get that cell's index, and place the vline after
+ // its column. The exception is when the auto_index
+ // is 0, meaning no automatically positioned cell
+ // was placed yet. In that case, we place the vline
+ // to the left of the table.
+ //
+ // Exceptionally, a vline is also placed to the
+ // left of the table if the current auto index from
+ // past iterations is smaller than the minimum auto
+ // index. For example, this means that a vline at
+ // the beginning of a header will be placed to its
+ // left rather than after the previous
+ // automatically positioned cell.
+ auto_index
+ .checked_sub(1)
+ .filter(|last_auto_index| {
+ last_auto_index >= &min_auto_index
+ })
+ .map_or(0, |last_auto_index| last_auto_index % c + 1)
+ });
+ if end.is_some_and(|end| end.get() < start) {
+ bail!(span, "line cannot end before it starts");
+ }
+ let line = Line { index: x, start, end, stroke, position };
- // Let's resolve the cell so it can determine its own fields
- // based on its final position.
- let cell = cell.resolve_cell(
- x,
- y,
- &fill.resolve(engine, styles, x, y)?,
- align.resolve(engine, styles, x, y)?,
- inset.resolve(engine, styles, x, y)?,
- stroke.resolve(engine, styles, x, y)?,
- resolve_breakable(y, rowspan),
- styles,
- );
+ // For consistency with hlines, we only push vlines to
+ // the final vector of vlines after processing every
+ // cell.
+ pending_vlines.push((span, line));
+ continue;
+ }
+ ResolvableGridItem::Cell(cell) => cell,
+ };
+ let cell_span = cell.span();
+ // Let's calculate the cell's final position based on its
+ // requested position.
+ let resolved_index = {
+ let cell_x = cell.x(styles);
+ let cell_y = cell.y(styles);
+ resolve_cell_position(
+ cell_x,
+ cell_y,
+ &resolved_cells,
+ &mut auto_index,
+ min_auto_index,
+ c,
+ )
+ .at(cell_span)?
+ };
+ let x = resolved_index % c;
+ let y = resolved_index / c;
+ let colspan = cell.colspan(styles).get();
+ let rowspan = cell.rowspan(styles).get();
+
+ if colspan > c - x {
+ bail!(
+ cell_span,
+ "cell's colspan would cause it to exceed the available column(s)";
+ hint: "try placing the cell in another position or reducing its colspan"
+ )
+ }
- if largest_index >= resolved_cells.len() {
- // Ensure the length of the vector of resolved cells is always
- // a multiple of 'c' by pushing full rows every time. Here, we
- // add enough absent positions (later converted to empty cells)
- // to ensure the last row in the new vector length is
- // completely filled. This is necessary so that those
- // positions, even if not explicitly used at the end, are
- // eventually susceptible to show rules and receive grid
- // styling, as they will be resolved as empty cells in a second
- // loop below.
- let Some(new_len) = largest_index
- .checked_add(1)
- .and_then(|new_len| new_len.checked_add((c - new_len % c) % c))
+ let Some(largest_index) = c
+ .checked_mul(rowspan - 1)
+ .and_then(|full_rowspan_offset| {
+ resolved_index.checked_add(full_rowspan_offset)
+ })
+ .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1))
else {
- bail!(cell_span, "cell position too large")
+ bail!(
+ cell_span,
+ "cell would span an exceedingly large position";
+ hint: "try reducing the cell's rowspan or colspan"
+ )
};
- // Here, the cell needs to be placed in a position which
- // doesn't exist yet in the grid (out of bounds). We will add
- // enough absent positions for this to be possible. They must
- // be absent as no cells actually occupy them (they can be
- // overridden later); however, if no cells occupy them as we
- // finish building the grid, then such positions will be
- // replaced by empty cells.
- resolved_cells.resize(new_len, None);
- }
-
- // The vector is large enough to contain the cell, so we can just
- // index it directly to access the position it will be placed in.
- // However, we still need to ensure we won't try to place a cell
- // where there already is one.
- let slot = &mut resolved_cells[resolved_index];
- if slot.is_some() {
- bail!(
- cell_span,
- "attempted to place a second cell at column {x}, row {y}";
- hint: "try specifying your cells in a different order"
+ // Let's resolve the cell so it can determine its own fields
+ // based on its final position.
+ let cell = cell.resolve_cell(
+ x,
+ y,
+ &fill.resolve(engine, styles, x, y)?,
+ align.resolve(engine, styles, x, y)?,
+ inset.resolve(engine, styles, x, y)?,
+ stroke.resolve(engine, styles, x, y)?,
+ resolve_breakable(y, rowspan),
+ styles,
);
- }
- *slot = Some(Entry::Cell(cell));
+ if largest_index >= resolved_cells.len() {
+ // Ensure the length of the vector of resolved cells is
+ // always a multiple of 'c' by pushing full rows every
+ // time. Here, we add enough absent positions (later
+ // converted to empty cells) to ensure the last row in the
+ // new vector length is completely filled. This is
+ // necessary so that those positions, even if not
+ // explicitly used at the end, are eventually susceptible
+ // to show rules and receive grid styling, as they will be
+ // resolved as empty cells in a second loop below.
+ let Some(new_len) = largest_index
+ .checked_add(1)
+ .and_then(|new_len| new_len.checked_add((c - new_len % c) % c))
+ else {
+ bail!(cell_span, "cell position too large")
+ };
+
+ // Here, the cell needs to be placed in a position which
+ // doesn't exist yet in the grid (out of bounds). We will
+ // add enough absent positions for this to be possible.
+ // They must be absent as no cells actually occupy them
+ // (they can be overridden later); however, if no cells
+ // occupy them as we finish building the grid, then such
+ // positions will be replaced by empty cells.
+ resolved_cells.resize(new_len, None);
+ }
- // Now, if the cell spans more than one row or column, we fill the
- // spanned positions in the grid with Entry::Merged pointing to the
- // original cell as its parent.
- for rowspan_offset in 0..rowspan {
- let spanned_y = y + rowspan_offset;
- let first_row_index = resolved_index + c * rowspan_offset;
- for (colspan_offset, slot) in
- resolved_cells[first_row_index..][..colspan].iter_mut().enumerate()
- {
- let spanned_x = x + colspan_offset;
- if spanned_x == x && spanned_y == y {
- // This is the parent cell.
- continue;
- }
- if slot.is_some() {
- bail!(
- cell_span,
- "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}";
- hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan"
- )
+ // The vector is large enough to contain the cell, so we can
+ // just index it directly to access the position it will be
+ // placed in. However, we still need to ensure we won't try to
+ // place a cell where there already is one.
+ let slot = &mut resolved_cells[resolved_index];
+ if slot.is_some() {
+ bail!(
+ cell_span,
+ "attempted to place a second cell at column {x}, row {y}";
+ hint: "try specifying your cells in a different order"
+ );
+ }
+
+ *slot = Some(Entry::Cell(cell));
+
+ // Now, if the cell spans more than one row or column, we fill
+ // the spanned positions in the grid with Entry::Merged
+ // pointing to the original cell as its parent.
+ for rowspan_offset in 0..rowspan {
+ let spanned_y = y + rowspan_offset;
+ let first_row_index = resolved_index + c * rowspan_offset;
+ for (colspan_offset, slot) in resolved_cells[first_row_index..]
+ [..colspan]
+ .iter_mut()
+ .enumerate()
+ {
+ let spanned_x = x + colspan_offset;
+ if spanned_x == x && spanned_y == y {
+ // This is the parent cell.
+ continue;
+ }
+ if slot.is_some() {
+ bail!(
+ cell_span,
+ "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}";
+ hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan"
+ )
+ }
+ *slot = Some(Entry::Merged { parent: resolved_index });
}
- *slot = Some(Entry::Merged { parent: resolved_index });
}
+
+ if is_header {
+ // Ensure each cell in a header is fully contained within
+ // the header.
+ header_start = header_start.min(y);
+ header_end = header_end.max(y + rowspan);
+ }
+ }
+
+ if is_header {
+ if header_start == usize::MAX {
+ // Empty header: consider the header to be one row after
+ // the latest auto index.
+ header_start = auto_index.next_multiple_of(c) / c;
+ header_end = header_start + 1;
+ }
+
+ if header_start != 0 {
+ bail!(
+ header_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
+ // and the header has fully expanded in the fixup loop
+ // below.
+ end: header_end,
+ });
+
+ // Next automatically positioned cell goes under this header.
+ // FIXME: Consider only doing this if the header has any fully
+ // automatically positioned cells. Otherwise,
+ // `resolve_cell_position` should be smart enough to skip
+ // upcoming headers.
+ // Additionally, consider that cells with just an 'x' override
+ // could end up going too far back and making previous
+ // non-header rows into header rows (maybe they should be
+ // placed at the first row that is fully empty or something).
+ // Nothing we can do when both 'x' and 'y' were overridden, of
+ // course.
+ // None of the above are concerns for now, as headers must
+ // start at the first row.
+ auto_index = auto_index.max(c * header_end);
}
}
- // Replace absent entries by resolved empty cells, and produce a vector
- // of 'Entry' from 'Option<Entry>' (final step).
+ // Fixup phase (final step in cell grid generation):
+ // 1. Replace absent entries by resolved empty cells, and produce a
+ // vector of 'Entry' from 'Option<Entry>'.
+ // 2. If any cells were added to the header's rows after the header's
+ // creation, ensure the header expands enough to accommodate them
+ // across all of their spanned rows.
let resolved_cells = resolved_cells
.into_iter()
.enumerate()
.map(|(i, cell)| {
if let Some(cell) = cell {
+ if let Some((parent_cell, header)) =
+ cell.as_cell().zip(header.as_mut())
+ {
+ let y = i / c;
+ if y < header.end {
+ // Ensure the header expands enough such that all
+ // cells inside it, even those added later, are
+ // fully contained within the header.
+ // FIXME: check if start < y < end when start can
+ // be != 0.
+ // FIXME: when start can be != 0, decide what
+ // happens when a cell after the header placed
+ // above it tries to span the header (either error
+ // or expand upwards).
+ header.end = header.end.max(y + parent_cell.rowspan.get());
+ }
+ }
+
Ok(cell)
} else {
let x = i % c;
@@ -722,7 +888,36 @@ impl CellGrid {
vlines[x].push(line);
}
- Ok(Self::new_internal(tracks, gutter, vlines, hlines, resolved_cells))
+ // No point in storing the header if it shouldn't be repeated.
+ let header = header.filter(|_| repeat_header).map(|mut header| {
+ // Repeat the gutter below a header (hence why we don't
+ // subtract 1 from the gutter case).
+ // Don't do this if there are no rows under the header.
+ if has_gutter {
+ // - 'header.end' is always 'last y + 1'. The header stops
+ // before that row.
+ // - Therefore, '2 * header.end' will be 2 * (last y + 1),
+ // which is the adjusted index of the row before which the
+ // header stops, meaning it will still stop right before it
+ // even with gutter thanks to the multiplication below.
+ // - This means that it will span all rows up to
+ // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates to
+ // the index of the gutter row right below the header, which is
+ // what we want (that gutter spacing should be repeated across
+ // pages to maintain uniformity).
+ header.end *= 2;
+
+ // If the header occupies the entire grid, ensure we don't
+ // include an extra gutter row when it doesn't exist, since
+ // the last row of the header is at the very bottom, therefore
+ // '2 * last y + 1' is not a valid index.
+ let row_amount = (2 * row_amount).saturating_sub(1);
+ header.end = header.end.min(row_amount);
+ }
+ header
+ });
+
+ Ok(Self::new_internal(tracks, gutter, vlines, hlines, header, resolved_cells))
}
/// Generates the cell grid, given the tracks and resolved entries.
@@ -731,6 +926,7 @@ impl CellGrid {
gutter: Axes<&[Sizing]>,
vlines: Vec<Vec<Line>>,
hlines: Vec<Vec<Line>>,
+ header: Option<Header>,
entries: Vec<Entry>,
) -> Self {
let mut cols = vec![];
@@ -777,7 +973,15 @@ impl CellGrid {
rows.pop();
}
- Self { cols, rows, entries, vlines, hlines, has_gutter }
+ Self {
+ cols,
+ rows,
+ entries,
+ vlines,
+ hlines,
+ header,
+ has_gutter,
+ }
}
/// Get the grid entry in column `x` and row `y`.
@@ -904,11 +1108,18 @@ impl CellGrid {
/// positions, the `auto_index` counter (determines the position of the next
/// `(auto, auto)` cell) and the amount of columns in the grid, returns the
/// final index of this cell in the vector of resolved cells.
+///
+/// The `min_auto_index` parameter is used to bump the auto index to that value
+/// if it is currently smaller than it and a cell requests fully automatic
+/// positioning. Useful with headers: if a cell in a header has automatic
+/// positioning, it should start at the header's first row, and not at the end
+/// of the previous row.
fn resolve_cell_position(
cell_x: Smart<usize>,
cell_y: Smart<usize>,
resolved_cells: &[Option<Entry>],
auto_index: &mut usize,
+ min_auto_index: usize,
columns: usize,
) -> HintedStrResult<usize> {
// Translates a (x, y) position to the equivalent index in the final cell vector.
@@ -924,7 +1135,7 @@ fn resolve_cell_position(
(Smart::Auto, Smart::Auto) => {
// Let's find the first available position starting from the
// automatic position counter, searching in row-major order.
- let mut resolved_index = *auto_index;
+ let mut resolved_index = min_auto_index.max(*auto_index);
while let Some(Some(_)) = resolved_cells.get(resolved_index) {
// Skip any non-absent cell positions (`Some(None)`) to
// determine where this cell will be placed. An out of bounds
@@ -1021,6 +1232,13 @@ pub struct GridLayouter<'a> {
pub(super) finished: Vec<Frame>,
/// Whether this is an RTL grid.
pub(super) is_rtl: bool,
+ /// The simulated header height.
+ /// This field is reset in `layout_header` and properly updated by
+ /// `layout_auto_row` and `layout_relative_row`, and should not be read
+ /// before all header rows are fully laid out. It is usually fine because
+ /// header rows themselves are unbreakable, and unbreakable rows do not
+ /// need to read this field at all.
+ pub(super) header_height: Abs,
/// The span of the grid element.
pub(super) span: Span,
}
@@ -1046,6 +1264,16 @@ pub(super) enum Row {
Fr(Fr, usize),
}
+impl Row {
+ /// Returns the `y` index of this row.
+ fn index(&self) -> usize {
+ match self {
+ Self::Frame(_, y, _) => *y,
+ Self::Fr(_, y) => *y,
+ }
+ }
+}
+
impl<'a> GridLayouter<'a> {
/// Create a new grid layouter.
///
@@ -1074,6 +1302,7 @@ impl<'a> GridLayouter<'a> {
initial: regions.size,
finished: vec![],
is_rtl: TextElem::dir_in(styles) == Dir::RTL,
+ header_height: Abs::zero(),
span,
}
}
@@ -1083,31 +1312,17 @@ impl<'a> GridLayouter<'a> {
self.measure_columns(engine)?;
for y in 0..self.grid.rows.len() {
- // Skip to next region if current one is full, but only for content
- // rows, not for gutter rows, and only if we aren't laying out an
- // unbreakable group of rows.
- let is_content_row = !self.grid.is_gutter_track(y);
- if self.unbreakable_rows_left == 0 && self.regions.is_full() && is_content_row
- {
- self.finish_region(engine)?;
- }
-
- if is_content_row {
- // Gutter rows have no rowspans or possibly unbreakable cells.
- self.check_for_rowspans(y);
- self.check_for_unbreakable_rows(y, engine)?;
- }
-
- // Don't layout gutter rows at the top of a region.
- if is_content_row || !self.lrows.is_empty() {
- match self.grid.rows[y] {
- Sizing::Auto => self.layout_auto_row(engine, y)?,
- Sizing::Rel(v) => self.layout_relative_row(engine, v, y)?,
- Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)),
+ if let Some(header) = &self.grid.header {
+ if y < header.end {
+ if y == 0 {
+ self.layout_header(header, engine)?;
+ }
+ // Skip header rows during normal layout.
+ continue;
}
}
- self.unbreakable_rows_left = self.unbreakable_rows_left.saturating_sub(1);
+ self.layout_row(y, engine)?;
}
self.finish_region(engine)?;
@@ -1130,6 +1345,36 @@ impl<'a> GridLayouter<'a> {
self.render_fills_strokes()
}
+ /// Layout the given row.
+ fn layout_row(&mut self, y: usize, engine: &mut Engine) -> SourceResult<()> {
+ // Skip to next region if current one is full, but only for content
+ // rows, not for gutter rows, and only if we aren't laying out an
+ // unbreakable group of rows.
+ let is_content_row = !self.grid.is_gutter_track(y);
+ if self.unbreakable_rows_left == 0 && self.regions.is_full() && is_content_row {
+ self.finish_region(engine)?;
+ }
+
+ if is_content_row {
+ // Gutter rows have no rowspans or possibly unbreakable cells.
+ self.check_for_rowspans(y);
+ self.check_for_unbreakable_rows(y, engine)?;
+ }
+
+ // Don't layout gutter rows at the top of a region.
+ if is_content_row || !self.lrows.is_empty() {
+ match self.grid.rows[y] {
+ Sizing::Auto => self.layout_auto_row(engine, y)?,
+ Sizing::Rel(v) => self.layout_relative_row(engine, v, y)?,
+ Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)),
+ }
+ }
+
+ self.unbreakable_rows_left = self.unbreakable_rows_left.saturating_sub(1);
+
+ Ok(())
+ }
+
/// Add lines and backgrounds.
fn render_fills_strokes(mut self) -> SourceResult<Fragment> {
let mut finished = std::mem::take(&mut self.finished);
@@ -1150,11 +1395,34 @@ impl<'a> GridLayouter<'a> {
// in quadratic complexity.
let mut lines = vec![];
+ // Which line position to look for in the list of lines for a
+ // track, such that placing lines with those positions will
+ // correspond to placing them before the given track index.
+ //
+ // If the index represents a gutter track, this means the list of
+ // lines will actually correspond to the list of lines in the
+ // previous index, so we must look for lines positioned after the
+ // previous index, and not before, to determine which lines should
+ // be placed before gutter.
+ //
+ // Note that the maximum index is always an odd number when
+ // there's gutter, so we must check for it to ensure we don't give
+ // it the same treatment as a line before a gutter track.
+ let expected_line_position = |index, is_max_index: bool| {
+ if self.grid.is_gutter_track(index) && !is_max_index {
+ LinePosition::After
+ } else {
+ LinePosition::Before
+ }
+ };
+
// Render vertical lines.
// Render them first so horizontal lines have priority later.
for (x, dx) in points(self.rcols.iter().copied()).enumerate() {
let dx = if self.is_rtl { self.width - dx } else { dx };
let is_end_border = x == self.grid.cols.len();
+ let expected_vline_position = expected_line_position(x, is_end_border);
+
let vlines_at_column = self
.grid
.vlines
@@ -1183,8 +1451,10 @@ impl<'a> GridLayouter<'a> {
// lines before it, not after).
x / 2
})
- .map(Vec::as_slice)
- .unwrap_or(&[]);
+ .into_iter()
+ .flatten()
+ .filter(|line| line.position == expected_vline_position);
+
let tracks = rows.iter().map(|row| (row.y, row.height));
// Determine all different line segments we have to draw in
@@ -1198,7 +1468,6 @@ impl<'a> GridLayouter<'a> {
tracks,
x,
vlines_at_column,
- is_end_border,
vline_stroke_at_row,
)
.map(|segment| {
@@ -1234,35 +1503,124 @@ impl<'a> GridLayouter<'a> {
.map(|piece| piece.y)
.chain(std::iter::once(self.grid.rows.len()));
- let mut prev_y = None;
- for (y, dy) in hline_indices.zip(hline_offsets) {
- let is_bottom_border = y == self.grid.rows.len();
- let hlines_at_row = self
- .grid
+ // Converts a row to the corresponding index in the vector of
+ // hlines.
+ let hline_index_of_row = |y: usize| {
+ if !self.grid.has_gutter {
+ y
+ } else if y == self.grid.rows.len() {
+ y / 2 + 1
+ } else {
+ // Check the vlines loop for an explanation regarding
+ // these index operations.
+ y / 2
+ }
+ };
+
+ let get_hlines_at = |y| {
+ self.grid
.hlines
- .get(if !self.grid.has_gutter {
- y
- } else if is_bottom_border {
- y / 2 + 1
- } else {
- // Check the vlines loop for an explanation regarding
- // these index operations.
- y / 2
- })
+ .get(hline_index_of_row(y))
.map(Vec::as_slice)
.unwrap_or(&[])
+ };
+
+ let mut prev_y = None;
+ for (y, dy) in hline_indices.zip(hline_offsets) {
+ // Position of lines below the row index in the previous iteration.
+ let expected_prev_line_position = prev_y
+ .map(|prev_y| {
+ expected_line_position(
+ prev_y + 1,
+ prev_y + 1 == self.grid.rows.len(),
+ )
+ })
+ .unwrap_or(LinePosition::Before);
+
+ // If some grid rows were omitted between the previous resolved
+ // row and the current one, we ensure lines below the previous
+ // row don't "disappear" and are considered, albeit with less
+ // priority. However, don't do this when we're below a header,
+ // as it must have more priority instead of less, so it is
+ // chained later instead of before.
+ let prev_lines = prev_y
+ .filter(|prev_y| {
+ prev_y + 1 != y
+ && !self
+ .grid
+ .header
+ .as_ref()
+ .is_some_and(|header| prev_y + 1 == header.end)
+ })
+ .map(|prev_y| get_hlines_at(prev_y + 1))
+ .unwrap_or(&[]);
+
+ let expected_hline_position =
+ expected_line_position(y, y == self.grid.rows.len());
+
+ let hlines_at_y = get_hlines_at(y)
.iter()
- .chain(if prev_y.is_none() && y != 0 {
- // For lines at the top of the region, give priority to
- // the lines at the top border.
- self.grid.hlines.first().map(Vec::as_slice).unwrap_or(&[])
+ .filter(|line| line.position == expected_hline_position);
+
+ let top_border_hlines = if prev_y.is_none() && y != 0 {
+ // For lines at the top of the region, give priority to
+ // the lines at the top border.
+ get_hlines_at(0)
+ } else {
+ &[]
+ };
+
+ // The header lines, if any, will correspond to the lines under
+ // the previous row, so they function similarly to 'prev_lines'.
+ let expected_header_line_position = expected_prev_line_position;
+ let header_hlines = if let Some((header, prev_y)) =
+ self.grid.header.as_ref().zip(prev_y)
+ {
+ if prev_y + 1 != y
+ && prev_y + 1 == header.end
+ && !self.grid.has_gutter
+ {
+ // For lines below a header, give priority to the
+ // lines originally below the header rather than
+ // the lines of what's below the repeated header.
+ // However, no need to do that when we're laying
+ // out the header for the first time, since the
+ // lines being normally laid out then will be
+ // precisely the lines below the header.
+ //
+ // Additionally, we don't append header lines when
+ // gutter is enabled, since, in that case, there will
+ // be a gutter row between header and content, so no
+ // lines should overlap.
+ get_hlines_at(header.end)
} else {
- // When not at the top of the region, no border lines
- // to consider.
- // When at the top of the region but at the first row,
- // its own lines are already the border lines.
&[]
- });
+ }
+ } else {
+ &[]
+ };
+
+ // The effective hlines to be considered at this row index are
+ // chained in order of increasing priority:
+ // 1. Lines from the row right above us, if needed;
+ // 2. Lines from the current row (usually, only those are
+ // present);
+ // 3. Lines from the top border (above the top cells, hence
+ // 'before' position only);
+ // 4. Lines from the header above us, if present.
+ let hlines_at_row =
+ prev_lines
+ .iter()
+ .filter(|line| line.position == expected_prev_line_position)
+ .chain(hlines_at_y)
+ .chain(
+ top_border_hlines
+ .iter()
+ .filter(|line| line.position == LinePosition::Before),
+ )
+ .chain(header_hlines.iter().filter(|line| {
+ line.position == expected_header_line_position
+ }));
let tracks = self.rcols.iter().copied().enumerate();
@@ -1287,7 +1645,6 @@ impl<'a> GridLayouter<'a> {
tracks,
y,
hlines_at_row,
- is_bottom_border,
|grid, y, x, stroke| {
hline_stroke_at_column(
grid,
@@ -1695,19 +2052,29 @@ impl<'a> GridLayouter<'a> {
if let &[first] = resolved.as_slice() {
let frame = self.layout_single_row(engine, first, y)?;
self.push_row(frame, y, true);
+
+ if self.grid.header.as_ref().is_some_and(|header| y < header.end) {
+ // Add to header height.
+ self.header_height += first;
+ }
+
return Ok(());
}
// Expand all but the last region.
// Skip the first region if the space is eaten up by an fr row.
let len = resolved.len();
- for (region, target) in self
+ for ((i, region), target) in self
.regions
.iter()
+ .enumerate()
.zip(&mut resolved[..len - 1])
.skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize)
{
- target.set_max(region.y);
+ // Subtract header height from the region height when it's not the
+ // first.
+ target
+ .set_max(region.y - if i > 0 { self.header_height } else { Abs::zero() });
}
// Layout into multiple regions.
@@ -1809,6 +2176,8 @@ impl<'a> GridLayouter<'a> {
pod.size = size;
pod.backlog = backlog;
pod.full = measurement_data.full;
+ pod.last = measurement_data.last;
+
pod
};
@@ -1908,12 +2277,19 @@ impl<'a> GridLayouter<'a> {
let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
let frame = self.layout_single_row(engine, resolved, y)?;
+ if self.grid.header.as_ref().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
- // row group.
+ // row group. We use 'in_last_with_offset' so our 'in_last' call
+ // properly considers that a header would be added on each region
+ // break.
let height = frame.height();
while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(height)
- && !self.regions.in_last()
+ && !in_last_with_offset(self.regions, self.header_height)
{
self.finish_region(engine)?;
@@ -2036,13 +2412,26 @@ impl<'a> GridLayouter<'a> {
/// Finish rows for one region.
pub(super) fn finish_region(&mut self, engine: &mut Engine) -> SourceResult<()> {
- if self.lrows.last().is_some_and(|row| {
- let (Row::Frame(_, y, _) | Row::Fr(_, y)) = row;
- self.grid.is_gutter_track(*y)
- }) {
+ if self
+ .lrows
+ .last()
+ .is_some_and(|row| self.grid.is_gutter_track(row.index()))
+ {
// Remove the last row in the region if it is a gutter row.
self.lrows.pop().unwrap();
}
+
+ if let Some(header) = &self.grid.header {
+ if self.grid.rows.len() > header.end
+ && self.lrows.last().is_some_and(|row| row.index() < header.end)
+ && !in_last_with_offset(self.regions, self.header_height)
+ {
+ // Header would be alone in this region, but there are more
+ // rows beyond the header. Push an empty region.
+ self.lrows.clear();
+ }
+ }
+
// Determine the height of existing rows in the region.
let mut used = Abs::zero();
let mut fr = Fr::zero();
@@ -2085,6 +2474,9 @@ impl<'a> GridLayouter<'a> {
.rowspans
.iter_mut()
.filter(|rowspan| (rowspan.y..rowspan.y + rowspan.rowspan).contains(&y))
+ .filter(|rowspan| {
+ rowspan.max_resolved_row.map_or(true, |max_row| y > max_row)
+ })
{
// If the first region wasn't defined yet, it will have the the
// initial value of usize::MAX, so we can set it to the current
@@ -2111,39 +2503,47 @@ impl<'a> GridLayouter<'a> {
// Ensure that, in this region, the rowspan will span at least
// this row.
*rowspan.heights.last_mut().unwrap() += height;
+
+ if is_last {
+ // Do not extend the rowspan through this row again, even
+ // if it is repeated in a future region.
+ rowspan.max_resolved_row = Some(y);
+ }
}
- // Layout any rowspans which end at this row, but only if this is
- // this row's last frame (to avoid having the rowspan stop being
- // laid out at the first frame of the row).
- if is_last {
- // We use a for loop over indices to avoid borrow checking
- // problems (we need to mutate the rowspans vector, so we can't
- // have an iterator actively borrowing it). We keep a separate
- // 'i' variable so we can step the counter back after removing
- // a rowspan (see explanation below).
- let mut i = 0;
- while let Some(rowspan) = self.rowspans.get(i) {
- if rowspan.y + rowspan.rowspan <= y + 1 {
- // Rowspan ends at this or an earlier row, so we take
- // it from the rowspans vector and lay it out.
- // It's safe to pass the current region as a possible
- // region for the rowspan to be laid out in, even if
- // the rowspan's last row was at an earlier region,
- // because the rowspan won't have an entry for this
- // region in its 'heights' vector if it doesn't span
- // any rows in this region.
- //
- // Here we don't advance the index counter ('i') because
- // a new element we haven't checked yet in this loop
- // will take the index of the now removed element, so
- // we have to check the same index again in the next
- // iteration.
- let rowspan = self.rowspans.remove(i);
- self.layout_rowspan(rowspan, Some(&mut output), engine)?;
- } else {
- i += 1;
- }
+ // We use a for loop over indices to avoid borrow checking
+ // problems (we need to mutate the rowspans vector, so we can't
+ // have an iterator actively borrowing it). We keep a separate
+ // 'i' variable so we can step the counter back after removing
+ // a rowspan (see explanation below).
+ let mut i = 0;
+ while let Some(rowspan) = self.rowspans.get(i) {
+ // Layout any rowspans which end at this row, but only if this is
+ // this row's last frame (to avoid having the rowspan stop being
+ // laid out at the first frame of the row).
+ // Any rowspans ending before this row are laid out even
+ // on this row's first frame.
+ if rowspan.y + rowspan.rowspan < y + 1
+ || rowspan.y + rowspan.rowspan == y + 1 && is_last
+ {
+ // Rowspan ends at this or an earlier row, so we take
+ // it from the rowspans vector and lay it out.
+ // It's safe to pass the current region as a possible
+ // region for the rowspan to be laid out in, even if
+ // the rowspan's last row was at an earlier region,
+ // because the rowspan won't have an entry for this
+ // region in its 'heights' vector if it doesn't span
+ // any rows in this region.
+ //
+ // Here we don't advance the index counter ('i') because
+ // a new element we haven't checked yet in this loop
+ // will take the index of the now removed element, so
+ // we have to check the same index again in the next
+ // iteration.
+ let rowspan = self.rowspans.remove(i);
+ self.layout_rowspan(rowspan, Some((&mut output, &rrows)), engine)?;
+ } else {
+ i += 1;
}
}
@@ -2152,13 +2552,72 @@ impl<'a> GridLayouter<'a> {
pos.y += height;
}
+ self.finish_region_internal(output, rrows);
+
+ if let Some(header) = &self.grid.header {
+ // Add a header to the new region.
+ self.layout_header(header, engine)?;
+ }
+
+ Ok(())
+ }
+
+ /// Advances to the next region, registering the finished output and
+ /// resolved rows for the current region in the appropriate vectors.
+ fn finish_region_internal(&mut self, output: Frame, resolved_rows: Vec<RowPiece>) {
self.finished.push(output);
- self.rrows.push(rrows);
+ self.rrows.push(resolved_rows);
self.regions.next();
self.initial = self.regions.size;
+ }
+
+ /// Layouts the header's rows.
+ /// Skips regions as necessary.
+ fn layout_header(
+ &mut self,
+ header: &Header,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ let header_rows = self.simulate_header(header, &self.regions, engine)?;
+ while self.unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(header_rows.height)
+ && !self.regions.in_last()
+ {
+ // Advance regions without any output until we can place the
+ // header.
+ self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]);
+ }
+
+ // Reset the header height for this region.
+ self.header_height = Abs::zero();
+ // Header is unbreakable.
+ // Thus, no risk of 'finish_region' being recursively called from
+ // within 'layout_row'.
+ self.unbreakable_rows_left += header.end;
+ for y in 0..header.end {
+ self.layout_row(y, engine)?;
+ }
Ok(())
}
+
+ /// Simulate the header's group of rows.
+ pub(super) fn simulate_header(
+ &self,
+ header: &Header,
+ regions: &Regions<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<UnbreakableRowGroup> {
+ // Note that we assume the invariant that any rowspan in a header is
+ // fully contained within that header. Therefore, there won't be any
+ // unbreakable rowspans exceeding the header's rows, and we can safely
+ // assume that the amount of unbreakable rows following the first row
+ // in the header will be precisely the rows in the header.
+ let header_row_group =
+ self.simulate_unbreakable_row_group(0, Some(header.end), regions, engine)?;
+
+ Ok(header_row_group)
+ }
}
/// Turn an iterator of extents into an iterator of offsets before, in between,
@@ -2172,3 +2631,13 @@ pub(super) fn points(
offset
})
}
+
+/// Checks if the first region of a sequence of regions is the last usable
+/// region, assuming that the last region will always be occupied by some
+/// specific offset height, even after calling `.next()`, due to some
+/// additional logic which adds content automatically on each region turn (in
+/// our case, headers).
+pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool {
+ regions.backlog.is_empty()
+ && regions.last.map_or(true, |height| regions.size.y + offset == height)
+}
diff --git a/crates/typst/src/layout/grid/lines.rs b/crates/typst/src/layout/grid/lines.rs
index 7084c71a..b659ba77 100644
--- a/crates/typst/src/layout/grid/lines.rs
+++ b/crates/typst/src/layout/grid/lines.rs
@@ -40,7 +40,7 @@ pub struct Line {
/// its index. This is mostly only relevant when gutter is used, since, then,
/// the position after a track is not the same as before the next
/// non-gutter track.
-#[derive(PartialEq, Eq)]
+#[derive(Copy, Clone, PartialEq, Eq)]
pub enum LinePosition {
/// The line should be drawn before its track (e.g. hline on top of a row).
Before,
@@ -122,7 +122,6 @@ pub(super) fn generate_line_segments<'grid, F, I, L>(
tracks: I,
index: usize,
lines: L,
- is_max_index: bool,
line_stroke_at_track: F,
) -> impl Iterator<Item = LineSegment> + 'grid
where
@@ -154,22 +153,6 @@ where
// How much to multiply line indices by to account for gutter.
let gutter_factor = if grid.has_gutter { 2 } else { 1 };
- // Which line position to look for in the given list of lines.
- //
- // If the index represents a gutter track, this means the list of lines
- // parameter will actually correspond to the list of lines in the previous
- // index, so we must look for lines positioned after the previous index,
- // and not before, to determine which lines should be placed in gutter.
- //
- // Note that the maximum index is always an odd number when there's gutter,
- // so we must check for it to ensure we don't give it the same treatment as
- // a line before a gutter track.
- let expected_line_position = if grid.is_gutter_track(index) && !is_max_index {
- LinePosition::After
- } else {
- LinePosition::Before
- };
-
// Create an iterator of line segments, which will go through each track,
// from start to finish, to create line segments and extend them until they
// are interrupted and thus yielded through the iterator. We then repeat
@@ -210,20 +193,18 @@ where
let mut line_strokes = lines
.clone()
.filter(|line| {
- line.position == expected_line_position
- && line
- .end
- .map(|end| {
- // Subtract 1 from end index so we stop at the last
- // cell before it (don't cross one extra gutter).
- let end = if grid.has_gutter {
- 2 * end.get() - 1
- } else {
- end.get()
- };
- (gutter_factor * line.start..end).contains(&track)
- })
- .unwrap_or_else(|| track >= gutter_factor * line.start)
+ line.end
+ .map(|end| {
+ // Subtract 1 from end index so we stop at the last
+ // cell before it (don't cross one extra gutter).
+ let end = if grid.has_gutter {
+ 2 * end.get() - 1
+ } else {
+ end.get()
+ };
+ (gutter_factor * line.start..end).contains(&track)
+ })
+ .unwrap_or_else(|| track >= gutter_factor * line.start)
})
.map(|line| line.stroke.clone());
@@ -554,9 +535,25 @@ pub(super) fn hline_stroke_at_column(
StrokePriority::GridStroke
};
+ // Top border stroke and header stroke are generally prioritized, unless
+ // they don't have explicit hline overrides and one or more user-provided
+ // hlines would appear at the same position, which then are prioritized.
+ let top_stroke_comes_from_header =
+ grid.header
+ .as_ref()
+ .zip(local_top_y)
+ .is_some_and(|(header, local_top_y)| {
+ // Ensure the row above us is a repeated header.
+ // FIXME: Make this check more robust when headers at arbitrary
+ // positions are added.
+ local_top_y + 1 == header.end && y != header.end
+ });
+
let (prioritized_cell_stroke, deprioritized_cell_stroke) =
if !use_bottom_border_stroke
- && (use_top_border_stroke || top_cell_prioritized && !bottom_cell_prioritized)
+ && (use_top_border_stroke
+ || top_stroke_comes_from_header
+ || top_cell_prioritized && !bottom_cell_prioritized)
{
// Top border must always be prioritized, even if it did not
// request for that explicitly.
@@ -660,6 +657,7 @@ mod test {
},
vec![],
vec![],
+ None,
entries,
)
}
@@ -723,15 +721,8 @@ mod test {
let tracks = rows.iter().map(|row| (row.y, row.height));
assert_eq!(
expected_splits,
- &generate_line_segments(
- &grid,
- tracks,
- x,
- &[],
- x == grid.cols.len(),
- vline_stroke_at_row
- )
- .collect::<Vec<_>>(),
+ &generate_line_segments(&grid, tracks, x, &[], vline_stroke_at_row)
+ .collect::<Vec<_>>(),
);
}
}
@@ -955,15 +946,8 @@ mod test {
let tracks = rows.iter().map(|row| (row.y, row.height));
assert_eq!(
expected_splits,
- &generate_line_segments(
- &grid,
- tracks,
- x,
- &[],
- x == grid.cols.len(),
- vline_stroke_at_row
- )
- .collect::<Vec<_>>(),
+ &generate_line_segments(&grid, tracks, x, &[], vline_stroke_at_row)
+ .collect::<Vec<_>>(),
);
}
}
@@ -1144,7 +1128,6 @@ mod test {
position: LinePosition::After
},
],
- x == grid.cols.len(),
vline_stroke_at_row
)
.collect::<Vec<_>>(),
@@ -1211,6 +1194,7 @@ mod test {
},
vec![],
vec![],
+ None,
entries,
)
}
@@ -1297,22 +1281,17 @@ mod test {
let tracks = columns.iter().copied().enumerate();
assert_eq!(
expected_splits,
- &generate_line_segments(
- &grid,
- tracks,
- y,
- &[],
- y == grid.rows.len(),
- |grid, y, x, stroke| hline_stroke_at_column(
+ &generate_line_segments(&grid, tracks, y, &[], |grid, y, x, stroke| {
+ hline_stroke_at_column(
grid,
&rows,
y.checked_sub(1),
true,
y,
x,
- stroke
+ stroke,
)
- )
+ })
.collect::<Vec<_>>(),
);
}
@@ -1496,7 +1475,6 @@ mod test {
position: LinePosition::After
},
],
- y == grid.rows.len(),
|grid, y, x, stroke| hline_stroke_at_column(
grid,
&rows,
@@ -1542,7 +1520,6 @@ mod test {
columns.iter().copied().enumerate(),
4,
&[],
- 4 == grid.rows.len(),
|grid, y, x, stroke| hline_stroke_at_column(
grid,
&rows,
diff --git a/crates/typst/src/layout/grid/mod.rs b/crates/typst/src/layout/grid/mod.rs
index 6675f3e0..0f431bc6 100644
--- a/crates/typst/src/layout/grid/mod.rs
+++ b/crates/typst/src/layout/grid/mod.rs
@@ -2,13 +2,16 @@ mod layout;
mod lines;
mod rowspans;
-pub use self::layout::{Cell, CellGrid, Celled, GridItem, GridLayouter, ResolvableCell};
+pub use self::layout::{
+ Cell, CellGrid, Celled, GridLayouter, ResolvableCell, ResolvableGridChild,
+ ResolvableGridItem,
+};
pub use self::lines::LinePosition;
use std::num::NonZeroUsize;
use std::sync::Arc;
-use ecow::eco_format;
+use ecow::{eco_format, EcoString};
use smallvec::{smallvec, SmallVec};
use crate::diag::{bail, SourceResult, StrResult, Trace, Tracepoint};
@@ -20,7 +23,7 @@ use crate::layout::{
Abs, AlignElem, Alignment, Axes, Dir, Fragment, LayoutMultiple, Length,
OuterHAlignment, OuterVAlignment, Regions, Rel, Sides, Sizing,
};
-use crate::model::{TableCell, TableHLine, TableVLine};
+use crate::model::{TableCell, TableHLine, TableHeader, TableVLine};
use crate::syntax::Span;
use crate::text::TextElem;
use crate::util::NonZeroExt;
@@ -293,6 +296,9 @@ impl GridElem {
#[elem]
type GridVLine;
+
+ #[elem]
+ type GridHeader;
}
impl LayoutMultiple for Packed<GridElem> {
@@ -316,43 +322,20 @@ impl LayoutMultiple for Packed<GridElem> {
let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice());
// Use trace to link back to the grid when a specific cell errors
let tracepoint = || Tracepoint::Call(Some(eco_format!("grid")));
- let items = self.children().iter().map(|child| match child {
- GridChild::HLine(hline) => GridItem::HLine {
- y: hline.y(styles),
- start: hline.start(styles),
- end: hline.end(styles),
- stroke: hline.stroke(styles),
- span: hline.span(),
- position: match hline.position(styles) {
- OuterVAlignment::Top => LinePosition::Before,
- OuterVAlignment::Bottom => LinePosition::After,
- },
+ let children = self.children().iter().map(|child| match child {
+ GridChild::Header(header) => ResolvableGridChild::Header {
+ repeat: header.repeat(styles),
+ span: header.span(),
+ items: header.children().iter().map(|child| child.to_resolvable(styles)),
},
- GridChild::VLine(vline) => GridItem::VLine {
- x: vline.x(styles),
- start: vline.start(styles),
- end: vline.end(styles),
- stroke: vline.stroke(styles),
- span: vline.span(),
- position: match vline.position(styles) {
- OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => {
- LinePosition::After
- }
- OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => {
- LinePosition::Before
- }
- OuterHAlignment::Start | OuterHAlignment::Left => {
- LinePosition::Before
- }
- OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After,
- },
- },
- GridChild::Cell(cell) => GridItem::Cell(cell.clone()),
+ GridChild::Item(item) => {
+ ResolvableGridChild::Item(item.to_resolvable(styles))
+ }
});
let grid = CellGrid::resolve(
tracks,
gutter,
- items,
+ children,
fill,
align,
&inset,
@@ -385,52 +368,136 @@ cast! {
/// Any child of a grid element.
#[derive(Debug, PartialEq, Clone, Hash)]
pub enum GridChild {
+ Header(Packed<GridHeader>),
+ Item(GridItem),
+}
+
+cast! {
+ GridChild,
+ self => match self {
+ Self::Header(header) => header.into_value(),
+ Self::Item(item) => item.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for GridChild {
+ type Error = EcoString;
+ fn try_from(value: Content) -> StrResult<Self> {
+ if value.is::<TableHeader>() {
+ bail!("cannot use `table.header` as a grid header; use `grid.header` instead")
+ }
+
+ value
+ .into_packed::<GridHeader>()
+ .map(Self::Header)
+ .or_else(|value| GridItem::try_from(value).map(Self::Item))
+ }
+}
+
+/// A grid item, which is the basic unit of grid specification.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum GridItem {
HLine(Packed<GridHLine>),
VLine(Packed<GridVLine>),
Cell(Packed<GridCell>),
}
+impl GridItem {
+ fn to_resolvable(&self, styles: StyleChain) -> ResolvableGridItem<Packed<GridCell>> {
+ match self {
+ Self::HLine(hline) => ResolvableGridItem::HLine {
+ y: hline.y(styles),
+ start: hline.start(styles),
+ end: hline.end(styles),
+ stroke: hline.stroke(styles),
+ span: hline.span(),
+ position: match hline.position(styles) {
+ OuterVAlignment::Top => LinePosition::Before,
+ OuterVAlignment::Bottom => LinePosition::After,
+ },
+ },
+ Self::VLine(vline) => ResolvableGridItem::VLine {
+ x: vline.x(styles),
+ start: vline.start(styles),
+ end: vline.end(styles),
+ stroke: vline.stroke(styles),
+ span: vline.span(),
+ position: match vline.position(styles) {
+ OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::After
+ }
+ OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::Before
+ }
+ OuterHAlignment::Start | OuterHAlignment::Left => {
+ LinePosition::Before
+ }
+ OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After,
+ },
+ },
+ Self::Cell(cell) => ResolvableGridItem::Cell(cell.clone()),
+ }
+ }
+}
+
cast! {
- GridChild,
+ GridItem,
self => match self {
Self::HLine(hline) => hline.into_value(),
Self::VLine(vline) => vline.into_value(),
Self::Cell(cell) => cell.into_value(),
},
v: Content => {
- if v.is::<TableCell>() {
- bail!(
- "cannot use `table.cell` as a grid cell; use `grid.cell` instead"
- );
+ v.try_into()?
+ }
+}
+
+impl TryFrom<Content> for GridItem {
+ type Error = EcoString;
+ fn try_from(value: Content) -> StrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!("cannot place a grid header within another header");
}
- if v.is::<TableHLine>() {
- bail!(
- "cannot use `table.hline` as a grid line; use `grid.hline` instead"
- );
+ if value.is::<TableHeader>() {
+ bail!("cannot place a table header within another header");
}
- if v.is::<TableVLine>() {
- bail!(
- "cannot use `table.vline` as a grid line; use `grid.vline` instead"
- );
+ if value.is::<TableCell>() {
+ bail!("cannot use `table.cell` as a grid cell; use `grid.cell` instead");
+ }
+ if value.is::<TableHLine>() {
+ bail!("cannot use `table.hline` as a grid line; use `grid.hline` instead");
+ }
+ if value.is::<TableVLine>() {
+ bail!("cannot use `table.vline` as a grid line; use `grid.vline` instead");
}
- v.into()
- }
-}
-impl From<Content> for GridChild {
- fn from(value: Content) -> Self {
- value
+ Ok(value
.into_packed::<GridHLine>()
- .map(GridChild::HLine)
- .or_else(|value| value.into_packed::<GridVLine>().map(GridChild::VLine))
- .or_else(|value| value.into_packed::<GridCell>().map(GridChild::Cell))
+ .map(Self::HLine)
+ .or_else(|value| value.into_packed::<GridVLine>().map(Self::VLine))
+ .or_else(|value| value.into_packed::<GridCell>().map(Self::Cell))
.unwrap_or_else(|value| {
let span = value.span();
- GridChild::Cell(Packed::new(GridCell::new(value)).spanned(span))
- })
+ Self::Cell(Packed::new(GridCell::new(value)).spanned(span))
+ }))
}
}
+/// A repeatable grid header.
+#[elem(name = "header", title = "Grid Header")]
+pub struct GridHeader {
+ /// Whether this header should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the header.
+ #[variadic]
+ pub children: Vec<GridItem>,
+}
+
/// A horizontal line in the grid.
///
/// Overrides any per-cell stroke, including stroke specified through the
diff --git a/crates/typst/src/layout/grid/rowspans.rs b/crates/typst/src/layout/grid/rowspans.rs
index be63da5c..d33b7943 100644
--- a/crates/typst/src/layout/grid/rowspans.rs
+++ b/crates/typst/src/layout/grid/rowspans.rs
@@ -6,7 +6,7 @@ use crate::layout::{
};
use crate::util::MaybeReverseIter;
-use super::layout::{points, Row};
+use super::layout::{in_last_with_offset, points, Row, RowPiece};
/// All information needed to layout a single rowspan.
pub(super) struct Rowspan {
@@ -27,6 +27,13 @@ pub(super) struct Rowspan {
pub(super) region_full: Abs,
/// The vertical space available for this rowspan in each region.
pub(super) heights: Vec<Abs>,
+ /// The index of the largest resolved spanned row so far.
+ /// Once a spanned row is resolved and its height added to `heights`, this
+ /// number is increased. Older rows, even if repeated through e.g. a
+ /// header, will no longer contribute height to this rowspan.
+ ///
+ /// This is `None` if no spanned rows were resolved in `finish_region` yet.
+ pub(super) max_resolved_row: Option<usize>,
}
/// The output of the simulation of an unbreakable row group.
@@ -44,9 +51,14 @@ pub(super) struct CellMeasurementData<'layouter> {
/// The available width for the cell across all regions.
pub(super) width: Abs,
/// The available height for the cell in its first region.
+ /// Infinite when the auto row is unbreakable.
pub(super) height: Abs,
/// The backlog of heights available for the cell in later regions.
+ ///
/// When this is `None`, the `custom_backlog` field should be used instead.
+ /// That's because, otherwise, this field would have to contain a reference
+ /// to the `custom_backlog` field, which isn't possible in Rust without
+ /// resorting to unsafe hacks.
pub(super) backlog: Option<&'layouter [Abs]>,
/// If the backlog needs to be built from scratch instead of reusing the
/// one at the current region, which is the case of a multi-region rowspan
@@ -54,7 +66,11 @@ pub(super) struct CellMeasurementData<'layouter> {
/// backlog), then this vector will store the new backlog.
pub(super) custom_backlog: Vec<Abs>,
/// The full height of the first region of the cell.
+ /// Infinite when the auto row is unbreakable.
pub(super) full: Abs,
+ /// The height of the last repeated region to use in the measurement pod,
+ /// if any.
+ pub(super) last: Option<Abs>,
/// The total height of previous rows spanned by the cell in the current
/// region (so far).
pub(super) height_in_this_region: Abs,
@@ -65,9 +81,10 @@ pub(super) struct CellMeasurementData<'layouter> {
impl<'a> GridLayouter<'a> {
/// Layout a rowspan over the already finished regions, plus the current
- /// region, if it wasn't finished yet (because we're being called from
- /// `finish_region`, but note that this function is also called once after
- /// all regions are finished, in which case `current_region` is `None`).
+ /// region's frame and resolved rows, if it wasn't finished yet (because
+ /// we're being called from `finish_region`, but note that this function is
+ /// also called once after all regions are finished, in which case
+ /// `current_region_data` is `None`).
///
/// 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
@@ -75,7 +92,7 @@ impl<'a> GridLayouter<'a> {
pub(super) fn layout_rowspan(
&mut self,
rowspan_data: Rowspan,
- current_region: Option<&mut Frame>,
+ current_region_data: Option<(&mut Frame, &[RowPiece])>,
engine: &mut Engine,
) -> SourceResult<()> {
let Rowspan {
@@ -97,22 +114,42 @@ impl<'a> GridLayouter<'a> {
pod.backlog = backlog;
// Push the layouted frames directly into the finished frames.
- // At first, we draw the rowspan starting at its expected offset
- // in the first region.
- let mut pos = Point::new(dx, dy);
let fragment = cell.layout(engine, self.styles, pod)?;
- for (finished, frame) in self
+ let (current_region, current_rrows) = current_region_data.unzip();
+ for ((i, finished), frame) in self
.finished
.iter_mut()
.chain(current_region.into_iter())
.skip(first_region)
+ .enumerate()
.zip(fragment)
{
- finished.push_frame(pos, frame);
+ let dy = if i == 0 {
+ // At first, we draw the rowspan starting at its expected
+ // vertical offset in the first region.
+ dy
+ } else {
+ // The rowspan continuation starts after the header (thus,
+ // at a position after the sum of the laid out header
+ // rows).
+ if let Some(header) = &self.grid.header {
+ let header_rows = self
+ .rrows
+ .get(i)
+ .map(Vec::as_slice)
+ .or(current_rrows)
+ .unwrap_or(&[])
+ .iter()
+ .take_while(|row| row.y < header.end);
+
+ header_rows.map(|row| row.height).sum()
+ } else {
+ // Without a header, start at the very top of the region.
+ Abs::zero()
+ }
+ };
- // From the second region onwards, the rowspan's continuation
- // starts at the very top.
- pos.y = Abs::zero();
+ finished.push_frame(Point::new(dx, dy), frame);
}
Ok(())
@@ -141,6 +178,7 @@ impl<'a> GridLayouter<'a> {
first_region: usize::MAX,
region_full: Abs::zero(),
heights: vec![],
+ max_resolved_row: None,
});
}
}
@@ -156,11 +194,17 @@ impl<'a> GridLayouter<'a> {
engine: &mut Engine,
) -> SourceResult<()> {
if self.unbreakable_rows_left == 0 {
- let row_group =
- self.simulate_unbreakable_row_group(current_row, &self.regions, engine)?;
+ let row_group = self.simulate_unbreakable_row_group(
+ current_row,
+ None,
+ &self.regions,
+ engine,
+ )?;
// Skip to fitting region.
- while !self.regions.size.y.fits(row_group.height) && !self.regions.in_last() {
+ while !self.regions.size.y.fits(row_group.height)
+ && !in_last_with_offset(self.regions, self.header_height)
+ {
self.finish_region(engine)?;
}
self.unbreakable_rows_left = row_group.rows.len();
@@ -170,23 +214,30 @@ impl<'a> GridLayouter<'a> {
}
/// Simulates a group of unbreakable rows, starting with the index of the
- /// first row in the group. Keeps adding rows to the group until none have
- /// unbreakable cells in common.
+ /// first row in the group. If `amount_unbreakable_rows` is `None`, keeps
+ /// adding rows to the group until none have unbreakable cells in common.
+ /// Otherwise, adds specifically the given amount of rows to the group.
///
/// This is used to figure out how much height the next unbreakable row
/// group (if any) needs.
pub(super) fn simulate_unbreakable_row_group(
&self,
first_row: usize,
+ amount_unbreakable_rows: Option<usize>,
regions: &Regions<'_>,
engine: &mut Engine,
) -> SourceResult<UnbreakableRowGroup> {
let mut row_group = UnbreakableRowGroup::default();
- let mut unbreakable_rows_left = 0;
+ let mut unbreakable_rows_left = amount_unbreakable_rows.unwrap_or(0);
for (y, row) in self.grid.rows.iter().enumerate().skip(first_row) {
- let additional_unbreakable_rows = self.check_for_unbreakable_cells(y);
- unbreakable_rows_left =
- unbreakable_rows_left.max(additional_unbreakable_rows);
+ if amount_unbreakable_rows.is_none() {
+ // When we don't set a fixed amount of unbreakable rows,
+ // determine the amount based on the rowspan of unbreakable
+ // cells in rows.
+ let additional_unbreakable_rows = self.check_for_unbreakable_cells(y);
+ unbreakable_rows_left =
+ unbreakable_rows_left.max(additional_unbreakable_rows);
+ }
if unbreakable_rows_left == 0 {
// This check is in case the first row does not have any
// unbreakable cells. Therefore, no unbreakable row group
@@ -254,10 +305,37 @@ impl<'a> GridLayouter<'a> {
let rowspan = self.grid.effective_rowspan_of_cell(cell);
// This variable is used to construct a custom backlog if the cell
- // is a rowspan. When measuring, we join the heights from previous
- // regions to the current backlog to form the rowspan's expected
- // backlog.
- let mut rowspan_backlog: Vec<Abs> = vec![];
+ // is a rowspan, or if headers are used. When measuring, we join
+ // the heights from previous regions to the current backlog to form
+ // a rowspan's expected backlog. We also subtract the header's
+ // height from all regions.
+ let mut custom_backlog: Vec<Abs> = vec![];
+
+ // This function is used to subtract the expected header height from
+ // each upcoming region size in the current backlog and last region.
+ let mut subtract_header_height_from_regions = || {
+ // Only breakable auto rows need to update their backlogs based
+ // on the presence of a header, given that unbreakable auto
+ // rows don't depend on the backlog, as they only span one
+ // region.
+ if breakable && self.grid.header.is_some() {
+ // Subtract header height from all upcoming regions when
+ // measuring the cell, including the last repeated region.
+ //
+ // This will update the 'custom_backlog' vector with the
+ // updated heights of the upcoming regions.
+ let mapped_regions = self.regions.map(&mut custom_backlog, |size| {
+ Size::new(size.x, size.y - self.header_height)
+ });
+
+ // Callees must use the custom backlog instead of the current
+ // backlog, so we return 'None'.
+ return (None, mapped_regions.last);
+ }
+
+ // No need to change the backlog or last region.
+ (Some(self.regions.backlog), self.regions.last)
+ };
// Each declaration, from top to bottom:
// 1. The height available to the cell in the first region.
@@ -266,25 +344,34 @@ impl<'a> GridLayouter<'a> {
// 2. The backlog of upcoming region heights to specify as
// available to the cell.
// 3. The full height of the first region of the cell.
- // 4. The total height of the cell covered by previously spanned
+ // 4. Height of the last repeated region to use in the measurement pod.
+ // 5. The total height of the cell covered by previously spanned
// rows in this region. This is used by rowspans to be able to tell
// how much the auto row needs to expand.
- // 5. The amount of frames laid out by this cell in previous
+ // 6. The amount of frames laid out by this cell in previous
// regions. When the cell isn't a rowspan, this is always zero.
// These frames are skipped after measuring.
- let (height, backlog, full, height_in_this_region, frames_in_previous_regions);
+ let height;
+ let backlog;
+ let full;
+ let last;
+ let height_in_this_region;
+ let frames_in_previous_regions;
+
if rowspan == 1 {
// Not a rowspan, so the cell only occupies this row. Therefore:
// 1. When we measure the cell below, use the available height
// remaining in the region as the height it has available.
// However, if the auto row is unbreakable, measure with infinite
// height instead to see how much content expands.
- // 2. Also use the region's backlog when measuring.
+ // 2. Use the region's backlog and last region when measuring,
+ // however subtract the expected header height from each upcoming
+ // size, if there is a header.
// 3. Use the same full region height.
// 4. No height occupied by this cell in this region so far.
// 5. Yes, this cell started in this region.
height = if breakable { self.regions.size.y } else { Abs::inf() };
- backlog = Some(self.regions.backlog);
+ (backlog, last) = subtract_header_height_from_regions();
full = if breakable { self.regions.full } else { Abs::inf() };
height_in_this_region = Abs::zero();
frames_in_previous_regions = 0;
@@ -339,21 +426,25 @@ impl<'a> GridLayouter<'a> {
.iter()
.copied()
.chain(std::iter::once(if breakable {
- self.initial.y
+ self.initial.y - self.header_height
} else {
// When measuring unbreakable auto rows, infinite
// height is available for content to expand.
Abs::inf()
}));
- rowspan_backlog = if breakable {
+ custom_backlog = if breakable {
// This auto row is breakable. Therefore, join the
// rowspan's already laid out heights with the current
// region's height and current backlog to ensure a good
// level of accuracy in the measurements.
- heights_up_to_current_region
- .chain(self.regions.backlog.iter().copied())
- .collect::<Vec<_>>()
+ let backlog = self
+ .regions
+ .backlog
+ .iter()
+ .map(|&size| size - self.header_height);
+
+ heights_up_to_current_region.chain(backlog).collect::<Vec<_>>()
} else {
// No extra backlog if this is an unbreakable auto row.
// Ensure, when measuring, that the rowspan can be laid
@@ -365,6 +456,7 @@ impl<'a> GridLayouter<'a> {
height = *rowspan_height;
backlog = None;
full = rowspan_full;
+ last = self.regions.last.map(|size| size - self.header_height);
} else {
// The rowspan started in the current region, as its vector
// of heights in regions is currently empty.
@@ -380,7 +472,7 @@ impl<'a> GridLayouter<'a> {
} else {
Abs::inf()
};
- backlog = Some(self.regions.backlog);
+ (backlog, last) = subtract_header_height_from_regions();
full = if breakable { self.regions.full } else { Abs::inf() };
frames_in_previous_regions = 0;
}
@@ -391,8 +483,9 @@ impl<'a> GridLayouter<'a> {
width,
height,
backlog,
- custom_backlog: rowspan_backlog,
+ custom_backlog,
full,
+ last,
height_in_this_region,
frames_in_previous_regions,
}
@@ -561,7 +654,13 @@ impl<'a> GridLayouter<'a> {
// expand) because we popped the last resolved size from the
// resolved vector, above.
simulated_regions.next();
+
+ // Subtract the initial header height, since that's the height we
+ // used when subtracting from the region backlog's heights while
+ // measuring cells.
+ simulated_regions.size.y -= self.header_height;
}
+
if let Some(original_last_resolved_size) = last_resolved_size {
// We're now at the (current) last region of this auto row.
// Consider resolved height as already taken space.
@@ -689,87 +788,18 @@ impl<'a> GridLayouter<'a> {
// which, when used and combined with upcoming spanned rows, covers all
// of the requested rowspan height, we give up.
for _attempt in 0..5 {
- let mut regions = simulated_regions;
- let mut total_spanned_height = Abs::zero();
- let mut unbreakable_rows_left = unbreakable_rows_left;
-
- // Height of the latest spanned gutter row.
- // Zero if it was removed.
- let mut latest_spanned_gutter_height = Abs::zero();
- let spanned_rows = &self.grid.rows[y + 1..=max_spanned_row];
- for (offset, row) in spanned_rows.iter().enumerate() {
- if (total_spanned_height + amount_to_grow).fits(requested_rowspan_height)
- {
- // Stop the simulation, as the combination of upcoming
- // spanned rows (so far) and the current amount the auto
- // row expands by has already fully covered the height the
- // rowspans need.
- break;
- }
- let spanned_y = y + 1 + offset;
- let is_gutter = self.grid.is_gutter_track(spanned_y);
-
- if unbreakable_rows_left == 0 {
- // Simulate unbreakable row groups, and skip regions until
- // they fit. There is no risk of infinite recursion, as
- // no auto rows participate in the simulation, so the
- // unbreakable row group simulator won't recursively call
- // 'measure_auto_row' or (consequently) this function.
- let row_group =
- self.simulate_unbreakable_row_group(spanned_y, &regions, engine)?;
- while !regions.size.y.fits(row_group.height) && !regions.in_last() {
- total_spanned_height -= latest_spanned_gutter_height;
- latest_spanned_gutter_height = Abs::zero();
- regions.next();
- }
+ let rowspan_simulator =
+ RowspanSimulator::new(simulated_regions, self.header_height);
- unbreakable_rows_left = row_group.rows.len();
- }
-
- match row {
- // Fixed-size spanned rows are what we are interested in.
- // They contribute a fixed amount of height to our rowspan.
- Sizing::Rel(v) => {
- let height = v.resolve(self.styles).relative_to(regions.base().y);
- total_spanned_height += height;
- if is_gutter {
- latest_spanned_gutter_height = height;
- }
-
- let mut skipped_region = false;
- while unbreakable_rows_left == 0
- && !regions.size.y.fits(height)
- && !regions.in_last()
- {
- // A row was pushed to the next region. Therefore,
- // the immediately preceding gutter row is removed.
- total_spanned_height -= latest_spanned_gutter_height;
- latest_spanned_gutter_height = Abs::zero();
- skipped_region = true;
- regions.next();
- }
-
- if !skipped_region || !is_gutter {
- // No gutter at the top of a new region, so don't
- // account for it if we just skipped a region.
- regions.size.y -= height;
- }
- }
- Sizing::Auto => {
- // We only simulate for rowspans which end at the
- // current auto row. Therefore, there won't be any
- // further auto rows.
- unreachable!();
- }
- // For now, we ignore fractional rows on simulation.
- Sizing::Fr(_) if is_gutter => {
- latest_spanned_gutter_height = Abs::zero();
- }
- Sizing::Fr(_) => {}
- }
-
- unbreakable_rows_left = unbreakable_rows_left.saturating_sub(1);
- }
+ let total_spanned_height = rowspan_simulator.simulate_rowspan_layout(
+ y,
+ max_spanned_row,
+ amount_to_grow,
+ requested_rowspan_height,
+ unbreakable_rows_left,
+ self,
+ engine,
+ )?;
// If the total height spanned by upcoming spanned rows plus the
// current amount we predict the auto row will have to grow (from
@@ -841,6 +871,7 @@ impl<'a> GridLayouter<'a> {
{
extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero());
simulated_regions.next();
+ simulated_regions.size.y -= self.header_height;
}
simulated_regions.size.y -= extra_amount_to_grow;
}
@@ -850,6 +881,189 @@ impl<'a> GridLayouter<'a> {
}
}
+/// Auxiliary structure holding state during rowspan simulation.
+struct RowspanSimulator<'a> {
+ /// The state of regions during the simulation.
+ regions: Regions<'a>,
+ /// The height of the header in the currently simulated region.
+ header_height: Abs,
+ /// The total spanned height so far in the simulation.
+ total_spanned_height: Abs,
+ /// Height of the latest spanned gutter row in the simulation.
+ /// Zero if it was removed.
+ latest_spanned_gutter_height: Abs,
+}
+
+impl<'a> RowspanSimulator<'a> {
+ /// Creates new rowspan simulation state with the given regions and initial
+ /// header height. Other fields should always start as zero.
+ fn new(regions: Regions<'a>, header_height: Abs) -> Self {
+ Self {
+ regions,
+ header_height,
+ total_spanned_height: Abs::zero(),
+ latest_spanned_gutter_height: Abs::zero(),
+ }
+ }
+
+ /// Calculates the total spanned height of the rowspan.
+ /// Stops calculating if, at any point in the simulation, the value of
+ /// `total_spanned_height + amount_to_grow` becomes larger than
+ /// `requested_rowspan_height`, as the results are not going to become any
+ /// more useful after that point.
+ #[allow(clippy::too_many_arguments)]
+ fn simulate_rowspan_layout(
+ mut self,
+ y: usize,
+ max_spanned_row: usize,
+ amount_to_grow: Abs,
+ requested_rowspan_height: Abs,
+ mut unbreakable_rows_left: usize,
+ layouter: &GridLayouter<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<Abs> {
+ let spanned_rows = &layouter.grid.rows[y + 1..=max_spanned_row];
+ for (offset, row) in spanned_rows.iter().enumerate() {
+ if (self.total_spanned_height + amount_to_grow).fits(requested_rowspan_height)
+ {
+ // Stop the simulation, as the combination of upcoming
+ // spanned rows (so far) and the current amount the auto
+ // row expands by has already fully covered the height the
+ // rowspans need.
+ return Ok(self.total_spanned_height);
+ }
+ let spanned_y = y + 1 + offset;
+ let is_gutter = layouter.grid.is_gutter_track(spanned_y);
+
+ if unbreakable_rows_left == 0 {
+ // Simulate unbreakable row groups, and skip regions until
+ // they fit. There is no risk of infinite recursion, as
+ // no auto rows participate in the simulation, so the
+ // unbreakable row group simulator won't recursively call
+ // 'measure_auto_row' or (consequently) this function.
+ let row_group = layouter.simulate_unbreakable_row_group(
+ spanned_y,
+ None,
+ &self.regions,
+ engine,
+ )?;
+ while !self.regions.size.y.fits(row_group.height)
+ && !in_last_with_offset(self.regions, self.header_height)
+ {
+ self.finish_region(layouter, engine)?;
+ }
+
+ unbreakable_rows_left = row_group.rows.len();
+ }
+
+ match row {
+ // Fixed-size spanned rows are what we are interested in.
+ // They contribute a fixed amount of height to our rowspan.
+ Sizing::Rel(v) => {
+ let height =
+ v.resolve(layouter.styles).relative_to(self.regions.base().y);
+ self.total_spanned_height += height;
+ if is_gutter {
+ self.latest_spanned_gutter_height = height;
+ }
+
+ let mut skipped_region = false;
+ while unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(height)
+ && !in_last_with_offset(self.regions, self.header_height)
+ {
+ self.finish_region(layouter, engine)?;
+
+ skipped_region = true;
+ }
+
+ if !skipped_region || !is_gutter {
+ // No gutter at the top of a new region, so don't
+ // account for it if we just skipped a region.
+ self.regions.size.y -= height;
+ }
+ }
+ Sizing::Auto => {
+ // We only simulate for rowspans which end at the
+ // current auto row. Therefore, there won't be any
+ // further auto rows.
+ unreachable!();
+ }
+ // For now, we ignore fractional rows on simulation.
+ Sizing::Fr(_) if is_gutter => {
+ self.latest_spanned_gutter_height = Abs::zero();
+ }
+ Sizing::Fr(_) => {}
+ }
+
+ unbreakable_rows_left = unbreakable_rows_left.saturating_sub(1);
+ }
+
+ Ok(self.total_spanned_height)
+ }
+
+ fn simulate_header_layout(
+ &mut self,
+ layouter: &GridLayouter<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ if let Some(header) = &layouter.grid.header {
+ // We can't just use the initial header height on each
+ // region, because header height might vary depending
+ // on region size if it contains rows with relative
+ // lengths. Therefore, we re-simulate headers on each
+ // new region.
+ // It's true that, when measuring cells, we reduce each
+ // height in the backlog to consider the initial header
+ // height; however, our simulation checks what happens
+ // AFTER the auto row, so we can just use the original
+ // backlog from `self.regions`.
+ let header_row_group =
+ layouter.simulate_header(header, &self.regions, engine)?;
+ let mut skipped_region = false;
+
+ // Skip until we reach a fitting region for this header.
+ while !self.regions.size.y.fits(header_row_group.height)
+ && !self.regions.in_last()
+ {
+ self.regions.next();
+ skipped_region = true;
+ }
+
+ self.header_height = if skipped_region {
+ // Simulate headers again, at the new region, as
+ // the full region height may change.
+ layouter.simulate_header(header, &self.regions, engine)?.height
+ } else {
+ header_row_group.height
+ };
+
+ // Consume the header's height from the new region,
+ // but don't consider it spanned. The rowspan
+ // does not go over the header (as an invariant,
+ // any rowspans spanning a header row are fully
+ // contained within that header's rows).
+ self.regions.size.y -= self.header_height;
+ }
+
+ Ok(())
+ }
+
+ fn finish_region(
+ &mut self,
+ layouter: &GridLayouter<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ // If a row was pushed to the next region, the immediately
+ // preceding gutter row is removed.
+ self.total_spanned_height -= self.latest_spanned_gutter_height;
+ self.latest_spanned_gutter_height = Abs::zero();
+ self.regions.next();
+
+ self.simulate_header_layout(layouter, engine)
+ }
+}
+
/// Subtracts some size from the end of a vector of sizes.
/// For example, subtracting 5pt from \[2pt, 1pt, 3pt\] will result in \[1pt\].
fn subtract_end_sizes(sizes: &mut Vec<Abs>, mut subtract: Abs) {
diff --git a/crates/typst/src/model/bibliography.rs b/crates/typst/src/model/bibliography.rs
index e3464026..576d1171 100644
--- a/crates/typst/src/model/bibliography.rs
+++ b/crates/typst/src/model/bibliography.rs
@@ -29,8 +29,8 @@ use crate::foundations::{
};
use crate::introspection::{Introspector, Locatable, Location};
use crate::layout::{
- BlockElem, Em, GridCell, GridChild, GridElem, HElem, PadElem, Sizing, TrackSizings,
- VElem,
+ BlockElem, Em, GridCell, GridChild, GridElem, GridItem, HElem, PadElem, Sizing,
+ TrackSizings, VElem,
};
use crate::model::{
CitationForm, CiteGroup, Destination, FootnoteElem, HeadingElem, LinkElem, ParElem,
@@ -238,13 +238,13 @@ impl Show for Packed<BibliographyElem> {
if references.iter().any(|(prefix, _)| prefix.is_some()) {
let mut cells = vec![];
for (prefix, reference) in references {
- cells.push(GridChild::Cell(
+ cells.push(GridChild::Item(GridItem::Cell(
Packed::new(GridCell::new(prefix.clone().unwrap_or_default()))
.spanned(span),
- ));
- cells.push(GridChild::Cell(
+ )));
+ cells.push(GridChild::Item(GridItem::Cell(
Packed::new(GridCell::new(reference.clone())).spanned(span),
- ));
+ )));
}
seq.push(VElem::new(row_gutter).with_weakness(3).pack());
@@ -948,8 +948,12 @@ impl ElemRenderer<'_> {
if let Some(prefix) = suf_prefix {
const COLUMN_GUTTER: Em = Em::new(0.65);
content = GridElem::new(vec![
- GridChild::Cell(Packed::new(GridCell::new(prefix)).spanned(self.span)),
- GridChild::Cell(Packed::new(GridCell::new(content)).spanned(self.span)),
+ GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(prefix)).spanned(self.span),
+ )),
+ GridChild::Item(GridItem::Cell(
+ Packed::new(GridCell::new(content)).spanned(self.span),
+ )),
])
.with_columns(TrackSizings(smallvec![Sizing::Auto; 2]))
.with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()]))
diff --git a/crates/typst/src/model/table.rs b/crates/typst/src/model/table.rs
index 2285e9a7..250a527c 100644
--- a/crates/typst/src/model/table.rs
+++ b/crates/typst/src/model/table.rs
@@ -1,18 +1,18 @@
use std::num::NonZeroUsize;
use std::sync::Arc;
-use ecow::eco_format;
+use ecow::{eco_format, EcoString};
-use crate::diag::{bail, SourceResult, Trace, Tracepoint};
+use crate::diag::{bail, SourceResult, StrResult, Trace, Tracepoint};
use crate::engine::Engine;
use crate::foundations::{
cast, elem, scope, Content, Fold, Packed, Show, Smart, StyleChain,
};
use crate::layout::{
show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Dir, Fragment,
- GridCell, GridHLine, GridItem, GridLayouter, GridVLine, LayoutMultiple, Length,
- LinePosition, OuterHAlignment, OuterVAlignment, Regions, Rel, ResolvableCell, Sides,
- TrackSizings,
+ GridCell, GridHLine, GridHeader, GridLayouter, GridVLine, LayoutMultiple, Length,
+ LinePosition, OuterHAlignment, OuterVAlignment, Regions, Rel, ResolvableCell,
+ ResolvableGridChild, ResolvableGridItem, Sides, TrackSizings,
};
use crate::model::Figurable;
use crate::syntax::Span;
@@ -221,6 +221,9 @@ impl TableElem {
#[elem]
type TableVLine;
+
+ #[elem]
+ type TableHeader;
}
impl LayoutMultiple for Packed<TableElem> {
@@ -244,43 +247,20 @@ impl LayoutMultiple for Packed<TableElem> {
let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice());
// Use trace to link back to the table when a specific cell errors
let tracepoint = || Tracepoint::Call(Some(eco_format!("table")));
- let items = self.children().iter().map(|child| match child {
- TableChild::HLine(hline) => GridItem::HLine {
- y: hline.y(styles),
- start: hline.start(styles),
- end: hline.end(styles),
- stroke: hline.stroke(styles),
- span: hline.span(),
- position: match hline.position(styles) {
- OuterVAlignment::Top => LinePosition::Before,
- OuterVAlignment::Bottom => LinePosition::After,
- },
- },
- TableChild::VLine(vline) => GridItem::VLine {
- x: vline.x(styles),
- start: vline.start(styles),
- end: vline.end(styles),
- stroke: vline.stroke(styles),
- span: vline.span(),
- position: match vline.position(styles) {
- OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => {
- LinePosition::After
- }
- OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => {
- LinePosition::Before
- }
- OuterHAlignment::Start | OuterHAlignment::Left => {
- LinePosition::Before
- }
- OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After,
- },
+ let children = self.children().iter().map(|child| match child {
+ TableChild::Header(header) => ResolvableGridChild::Header {
+ repeat: header.repeat(styles),
+ span: header.span(),
+ items: header.children().iter().map(|child| child.to_resolvable(styles)),
},
- TableChild::Cell(cell) => GridItem::Cell(cell.clone()),
+ TableChild::Item(item) => {
+ ResolvableGridChild::Item(item.to_resolvable(styles))
+ }
});
let grid = CellGrid::resolve(
tracks,
gutter,
- items,
+ children,
fill,
align,
&inset,
@@ -338,52 +318,140 @@ impl Figurable for Packed<TableElem> {}
/// Any child of a table element.
#[derive(Debug, PartialEq, Clone, Hash)]
pub enum TableChild {
+ Header(Packed<TableHeader>),
+ Item(TableItem),
+}
+
+cast! {
+ TableChild,
+ self => match self {
+ Self::Header(header) => header.into_value(),
+ Self::Item(item) => item.into_value(),
+ },
+ v: Content => {
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for TableChild {
+ type Error = EcoString;
+
+ fn try_from(value: Content) -> StrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!(
+ "cannot use `grid.header` as a table header; use `table.header` instead"
+ )
+ }
+
+ value
+ .into_packed::<TableHeader>()
+ .map(Self::Header)
+ .or_else(|value| TableItem::try_from(value).map(Self::Item))
+ }
+}
+
+/// A table item, which is the basic unit of table specification.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum TableItem {
HLine(Packed<TableHLine>),
VLine(Packed<TableVLine>),
Cell(Packed<TableCell>),
}
+impl TableItem {
+ fn to_resolvable(&self, styles: StyleChain) -> ResolvableGridItem<Packed<TableCell>> {
+ match self {
+ Self::HLine(hline) => ResolvableGridItem::HLine {
+ y: hline.y(styles),
+ start: hline.start(styles),
+ end: hline.end(styles),
+ stroke: hline.stroke(styles),
+ span: hline.span(),
+ position: match hline.position(styles) {
+ OuterVAlignment::Top => LinePosition::Before,
+ OuterVAlignment::Bottom => LinePosition::After,
+ },
+ },
+ Self::VLine(vline) => ResolvableGridItem::VLine {
+ x: vline.x(styles),
+ start: vline.start(styles),
+ end: vline.end(styles),
+ stroke: vline.stroke(styles),
+ span: vline.span(),
+ position: match vline.position(styles) {
+ OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::After
+ }
+ OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::Before
+ }
+ OuterHAlignment::Start | OuterHAlignment::Left => {
+ LinePosition::Before
+ }
+ OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After,
+ },
+ },
+ Self::Cell(cell) => ResolvableGridItem::Cell(cell.clone()),
+ }
+ }
+}
+
cast! {
- TableChild,
+ TableItem,
self => match self {
Self::HLine(hline) => hline.into_value(),
Self::VLine(vline) => vline.into_value(),
Self::Cell(cell) => cell.into_value(),
},
v: Content => {
- if v.is::<GridCell>() {
- bail!(
- "cannot use `grid.cell` as a table cell; use `table.cell` instead"
- );
+ v.try_into()?
+ },
+}
+
+impl TryFrom<Content> for TableItem {
+ type Error = EcoString;
+
+ fn try_from(value: Content) -> StrResult<Self> {
+ if value.is::<GridHeader>() {
+ bail!("cannot place a grid header within another header");
}
- if v.is::<GridHLine>() {
- bail!(
- "cannot use `grid.hline` as a table line; use `table.hline` instead"
- );
+ if value.is::<TableHeader>() {
+ bail!("cannot place a table header within another header");
}
- if v.is::<GridVLine>() {
- bail!(
- "cannot use `grid.vline` as a table line; use `table.vline` instead"
- );
+ if value.is::<GridCell>() {
+ bail!("cannot use `grid.cell` as a table cell; use `table.cell` instead");
+ }
+ if value.is::<GridHLine>() {
+ bail!("cannot use `grid.hline` as a table line; use `table.hline` instead");
+ }
+ if value.is::<GridVLine>() {
+ bail!("cannot use `grid.vline` as a table line; use `table.vline` instead");
}
- v.into()
- }
-}
-impl From<Content> for TableChild {
- fn from(value: Content) -> Self {
- value
+ Ok(value
.into_packed::<TableHLine>()
- .map(TableChild::HLine)
- .or_else(|value| value.into_packed::<TableVLine>().map(TableChild::VLine))
- .or_else(|value| value.into_packed::<TableCell>().map(TableChild::Cell))
+ .map(Self::HLine)
+ .or_else(|value| value.into_packed::<TableVLine>().map(Self::VLine))
+ .or_else(|value| value.into_packed::<TableCell>().map(Self::Cell))
.unwrap_or_else(|value| {
let span = value.span();
- TableChild::Cell(Packed::new(TableCell::new(value)).spanned(span))
- })
+ Self::Cell(Packed::new(TableCell::new(value)).spanned(span))
+ }))
}
}
+/// A repeatable table header.
+#[elem(name = "header", title = "Table Header")]
+pub struct TableHeader {
+ /// Whether this header should be repeated across pages.
+ #[default(true)]
+ pub repeat: bool,
+
+ /// The cells and lines within the header.
+ #[variadic]
+ pub children: Vec<TableItem>,
+}
+
/// A horizontal line in the table. See the docs for
/// [`grid.hline`]($grid.hline) for more information regarding how to use this
/// element's fields.
diff --git a/tests/ref/layout/grid-headers-1.png b/tests/ref/layout/grid-headers-1.png
new file mode 100644
index 00000000..7ae2d8d3
--- /dev/null
+++ b/tests/ref/layout/grid-headers-1.png
Binary files differ
diff --git a/tests/ref/layout/grid-headers-2.png b/tests/ref/layout/grid-headers-2.png
new file mode 100644
index 00000000..3dbc07c8
--- /dev/null
+++ b/tests/ref/layout/grid-headers-2.png
Binary files differ
diff --git a/tests/ref/layout/grid-headers-3.png b/tests/ref/layout/grid-headers-3.png
new file mode 100644
index 00000000..9ee77d50
--- /dev/null
+++ b/tests/ref/layout/grid-headers-3.png
Binary files differ
diff --git a/tests/ref/layout/grid-headers-4.png b/tests/ref/layout/grid-headers-4.png
new file mode 100644
index 00000000..e60877d8
--- /dev/null
+++ b/tests/ref/layout/grid-headers-4.png
Binary files differ
diff --git a/tests/ref/layout/grid-rowspan-basic.png b/tests/ref/layout/grid-rowspan-basic.png
index 966c8fd9..783991b3 100644
--- a/tests/ref/layout/grid-rowspan-basic.png
+++ b/tests/ref/layout/grid-rowspan-basic.png
Binary files differ
diff --git a/tests/ref/layout/grid-rtl.png b/tests/ref/layout/grid-rtl.png
index 3cf0b9aa..d628ee8a 100644
--- a/tests/ref/layout/grid-rtl.png
+++ b/tests/ref/layout/grid-rtl.png
Binary files differ
diff --git a/tests/typ/layout/grid-headers-1.typ b/tests/typ/layout/grid-headers-1.typ
new file mode 100644
index 00000000..f1afe70e
--- /dev/null
+++ b/tests/typ/layout/grid-headers-1.typ
@@ -0,0 +1,162 @@
+#set page(width: auto, height: 12em)
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..range(0, 6).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+)
+
+---
+// Disable repetition
+#set page(width: auto, height: 12em)
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow),
+ repeat: false
+ ),
+ ..range(0, 6).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+)
+
+---
+#set page(width: auto, height: 12em)
+#table(
+ columns: 5,
+ align: center + horizon,
+ gutter: 3pt,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow),
+ ),
+ ..range(0, 6).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+)
+
+---
+// Relative lengths
+#set page(height: 10em)
+#table(
+ rows: (30%, 30%, auto),
+ table.header(
+ [*A*],
+ [*B*]
+ ),
+ [C],
+ [C]
+)
+
+---
+#grid(
+ grid.cell(y: 1)[a],
+ grid.header(grid.cell(y: 0)[b]),
+ grid.cell(y: 2)[c]
+)
+
+---
+// When the header is the last grid child, it shouldn't include the gutter row
+// after it, because there is none.
+#grid(
+ columns: 2,
+ gutter: 3pt,
+ grid.header(
+ [a], [b],
+ [c], [d]
+ )
+)
+
+---
+#set page(height: 14em)
+#let t(n) = table(
+ columns: 3,
+ align: center + horizon,
+ gutter: 3pt,
+ table.header(
+ table.cell(colspan: 3)[*Cool Zone #n*],
+ [*Name*], [*Num*], [*Data*]
+ ),
+ ..range(0, 5).map(i => ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456])).flatten()
+)
+#grid(
+ gutter: 3pt,
+ t(0),
+ t(1)
+)
+
+---
+// Test line positioning in header
+#table(
+ columns: 3,
+ stroke: none,
+ table.hline(stroke: red, end: 2),
+ table.vline(stroke: red, end: 3),
+ table.header(
+ table.hline(stroke: aqua, start: 2),
+ table.vline(stroke: aqua, start: 3), [*A*], table.hline(stroke: orange), table.vline(stroke: orange), [*B*],
+ [*C*], [*D*]
+ ),
+ [a], [b],
+ [c], [d],
+ [e], [f]
+)
+
+---
+// Error: 3:3-3:19 header must start at the first row
+// Hint: 3:3-3:19 remove any rows before the header
+#grid(
+ [a],
+ grid.header([b])
+)
+
+---
+// Error: 4:3-4:19 header must start at the first row
+// Hint: 4:3-4:19 remove any rows before the header
+#grid(
+ columns: 2,
+ [a],
+ grid.header([b])
+)
+
+---
+// Error: 3:3-3:19 cannot have more than one header
+#grid(
+ grid.header([a]),
+ grid.header([b]),
+ [a],
+)
+
+---
+// Error: 2:3-2:20 cannot use `table.header` as a grid header; use `grid.header` instead
+#grid(
+ table.header([a]),
+ [a],
+)
+
+---
+// Error: 2:3-2:19 cannot use `grid.header` as a table header; use `table.header` instead
+#table(
+ grid.header([a]),
+ [a],
+)
+
+---
+// Error: 14-28 cannot place a grid header within another header
+#grid.header(grid.header[a])
+
+---
+// Error: 14-29 cannot place a table header within another header
+#grid.header(table.header[a])
+
+---
+// Error: 15-29 cannot place a grid header within another header
+#table.header(grid.header[a])
+
+---
+// Error: 15-30 cannot place a table header within another header
+#table.header(table.header[a])
diff --git a/tests/typ/layout/grid-headers-2.typ b/tests/typ/layout/grid-headers-2.typ
new file mode 100644
index 00000000..75c9b330
--- /dev/null
+++ b/tests/typ/layout/grid-headers-2.typ
@@ -0,0 +1,52 @@
+#set page(height: 15em)
+#table(
+ rows: (auto, 2.5em, auto),
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ block(width: 2em, height: 20em, fill: red)
+)
+
+---
+// Rowspan sizing algorithm doesn't do the best job at non-contiguous content
+// ATM.
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto, 5em),
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(40))
+)
+
+---
+// Rowspan sizing algorithm doesn't do the best job at non-contiguous content
+// ATM.
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto, 5em),
+ gutter: 3pt,
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(40))
+)
+
+---
+// This should look right
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto),
+ gutter: 3pt,
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(40))
+)
diff --git a/tests/typ/layout/grid-headers-3.typ b/tests/typ/layout/grid-headers-3.typ
new file mode 100644
index 00000000..e7437cf7
--- /dev/null
+++ b/tests/typ/layout/grid-headers-3.typ
@@ -0,0 +1,35 @@
+// Test lack of space for header + text.
+#set page(height: 9em)
+
+#table(
+ rows: (auto, 2.5em, auto, auto, 10em),
+ gutter: 3pt,
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(80))
+)
+
+---
+// Orphan header prevention test
+#set page(height: 12em)
+#v(8em)
+#grid(
+ columns: 3,
+ grid.header(
+ [*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
+ [*Header*], [*Header* #v(0.1em)]
+ ),
+ ..([Test], [Test], [Test]) * 20
+)
+
+---
+// Empty header should just be a repeated blank row
+#set page(height: 12em)
+#table(
+ columns: 4,
+ align: center + horizon,
+ table.header(),
+ ..range(0, 4).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789])).flatten()
+)
diff --git a/tests/typ/layout/grid-headers-4.typ b/tests/typ/layout/grid-headers-4.typ
new file mode 100644
index 00000000..13fd41dd
--- /dev/null
+++ b/tests/typ/layout/grid-headers-4.typ
@@ -0,0 +1,58 @@
+// When a header has a rowspan with an empty row, it should be displayed
+// properly
+#set page(height: 10em)
+
+#let count = counter("g")
+#table(
+ rows: (auto, 2em, auto, auto),
+ table.header(
+ [eeec],
+ table.cell(rowspan: 2, count.step() + count.display()),
+ ),
+ [d],
+ block(width: 5em, fill: yellow, lorem(15)),
+ [d]
+)
+#count.display()
+
+---
+// Ensure header expands to fit cell placed in it after its declaration
+#set page(height: 10em)
+#table(
+ columns: 2,
+ table.header(
+ [a], [b],
+ [c],
+ ),
+ table.cell(x: 1, y: 1, rowspan: 2, lorem(80))
+)
+
+---
+// Nested table with header should repeat both headers
+#set page(height: 10em)
+#table(
+ table.header(
+ [a]
+ ),
+ table(
+ table.header(
+ [b]
+ ),
+ [a\ b\ c\ d]
+ )
+)
+
+---
+#set page(height: 12em)
+#table(
+ table.header(
+ table(
+ table.header(
+ [b]
+ ),
+ [c],
+ [d]
+ )
+ ),
+ [a\ b]
+)
diff --git a/tests/typ/layout/grid-rowspan-basic.typ b/tests/typ/layout/grid-rowspan-basic.typ
index 49164fa6..1cc7289b 100644
--- a/tests/typ/layout/grid-rowspan-basic.typ
+++ b/tests/typ/layout/grid-rowspan-basic.typ
@@ -209,3 +209,24 @@
grid.cell(y: 6, breakable: false)[m], grid.cell(y: 6, breakable: true)[n],
grid.cell(y: 7, breakable: false)[o], grid.cell(y: 7, breakable: true)[p], grid.cell(y: 7, breakable: auto)[q]
)
+
+---
+#table(
+ columns: 2,
+ table.cell(stroke: (bottom: red))[a], [b],
+ table.hline(stroke: green),
+ table.cell(stroke: (top: yellow, left: green, right: aqua, bottom: blue), colspan: 1, rowspan: 2)[d], table.cell(colspan: 1, rowspan: 2)[e],
+ [f],
+ [g]
+)
+
+---
+#table(
+ columns: 2,
+ gutter: 3pt,
+ table.cell(stroke: (bottom: red))[a], [b],
+ table.hline(stroke: green),
+ table.cell(stroke: (top: yellow, left: green, right: aqua, bottom: blue), colspan: 1, rowspan: 2)[d], table.cell(colspan: 1, rowspan: 2)[e],
+ [f],
+ [g]
+)
diff --git a/tests/typ/layout/grid-rtl.typ b/tests/typ/layout/grid-rtl.typ
index be9fac51..cea67d96 100644
--- a/tests/typ/layout/grid-rtl.typ
+++ b/tests/typ/layout/grid-rtl.typ
@@ -178,3 +178,18 @@
[e],
[f]
)
+
+---
+// Headers
+#set page(height: 15em)
+#set text(dir: rtl)
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*N1*], table.cell(stroke: aqua)[*N2*], [*D1*], [*D2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..range(0, 10).map(i => ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+)