summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPgBiel <9021226+PgBiel@users.noreply.github.com>2024-02-20 12:41:43 -0300
committerGitHub <noreply@github.com>2024-02-20 15:41:43 +0000
commit4873312233f7e8db0dafd803a06d98d108d4a5cf (patch)
treebd90230a5950920ec736fd20502b8e74a000164a
parent96fba58cf203ab4094fa56b9dee1a2137554c0b7 (diff)
Table line customization [More Flexible Tables Pt.4] (#3393)
-rw-r--r--crates/typst/src/foundations/styles.rs28
-rw-r--r--crates/typst/src/foundations/value.rs52
-rw-r--r--crates/typst/src/layout/abs.rs8
-rw-r--r--crates/typst/src/layout/align.rs62
-rw-r--r--crates/typst/src/layout/corners.rs14
-rw-r--r--crates/typst/src/layout/grid/layout.rs855
-rw-r--r--crates/typst/src/layout/grid/lines.rs1096
-rw-r--r--crates/typst/src/layout/grid/mod.rs273
-rw-r--r--crates/typst/src/layout/sides.rs14
-rw-r--r--crates/typst/src/model/bibliography.rs15
-rw-r--r--crates/typst/src/model/enum.rs3
-rw-r--r--crates/typst/src/model/list.rs3
-rw-r--r--crates/typst/src/model/table.rs246
-rw-r--r--tests/ref/layout/grid-colspan.pngbin91434 -> 91276 bytes
-rw-r--r--tests/ref/layout/grid-positioning.pngbin55048 -> 54618 bytes
-rw-r--r--tests/ref/layout/grid-rtl.pngbin41422 -> 44946 bytes
-rw-r--r--tests/ref/layout/grid-stroke.pngbin0 -> 48961 bytes
-rw-r--r--tests/ref/layout/table-cell.pngbin49018 -> 49090 bytes
-rw-r--r--tests/ref/layout/table.pngbin22515 -> 22446 bytes
-rw-r--r--tests/typ/layout/grid-rtl.typ49
-rw-r--r--tests/typ/layout/grid-stroke.typ379
21 files changed, 2674 insertions, 423 deletions
diff --git a/crates/typst/src/foundations/styles.rs b/crates/typst/src/foundations/styles.rs
index 9472e207..fb8e5601 100644
--- a/crates/typst/src/foundations/styles.rs
+++ b/crates/typst/src/foundations/styles.rs
@@ -746,6 +746,34 @@ impl<T, const N: usize> Fold for SmallVec<[T; N]> {
}
}
+/// A variant of fold for foldable optional (`Option<T>`) values where an inner
+/// `None` value isn't respected (contrary to `Option`'s usual `Fold`
+/// implementation, with which folding with an inner `None` always returns
+/// `None`). Instead, when either of the `Option` objects is `None`, the other
+/// one is necessarily returned by `fold_or`. Normal folding still occurs when
+/// both values are `Some`, using `T`'s `Fold` implementation.
+///
+/// This is useful when `None` in a particular context means "unspecified"
+/// rather than "absent", in which case a specified value (`Some`) is chosen
+/// over an unspecified one (`None`), while two specified values are folded
+/// together.
+pub trait AlternativeFold {
+ /// Attempts to fold this inner value with an outer value. However, if
+ /// either value is `None`, returns the other one instead of folding.
+ fn fold_or(self, outer: Self) -> Self;
+}
+
+impl<T: Fold> AlternativeFold for Option<T> {
+ fn fold_or(self, outer: Self) -> Self {
+ match (self, outer) {
+ (Some(inner), Some(outer)) => Some(inner.fold(outer)),
+ // If one of values is `None`, return the other one instead of
+ // folding.
+ (inner, outer) => inner.or(outer),
+ }
+ }
+}
+
/// A type that accumulates depth when folded.
#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
pub struct Depth(pub usize);
diff --git a/crates/typst/src/foundations/value.rs b/crates/typst/src/foundations/value.rs
index 5da915d1..b5f143d2 100644
--- a/crates/typst/src/foundations/value.rs
+++ b/crates/typst/src/foundations/value.rs
@@ -13,13 +13,14 @@ use crate::diag::StrResult;
use crate::eval::ops;
use crate::foundations::{
fields, repr, Args, Array, AutoValue, Bytes, CastInfo, Content, Datetime, Dict,
- Duration, FromValue, Func, IntoValue, Label, Module, NativeElement, NativeType,
- NoneValue, Plugin, Reflect, Repr, Scope, Str, Styles, Type, Version,
+ Duration, Fold, FromValue, Func, IntoValue, Label, Module, NativeElement, NativeType,
+ NoneValue, Plugin, Reflect, Repr, Resolve, Scope, Str, Styles, Type, Version,
};
use crate::layout::{Abs, Angle, Em, Fr, Length, Ratio, Rel};
use crate::symbols::Symbol;
use crate::syntax::{ast, Span};
use crate::text::{RawElem, TextElem};
+use crate::util::ArcExt;
use crate::visualize::{Color, Gradient, Pattern};
/// A computational value.
@@ -668,6 +669,53 @@ primitive! { Type: "type", Type }
primitive! { Module: "module", Module }
primitive! { Plugin: "plugin", Plugin }
+impl<T: Reflect> Reflect for Arc<T> {
+ fn input() -> CastInfo {
+ T::input()
+ }
+
+ fn output() -> CastInfo {
+ T::output()
+ }
+
+ fn castable(value: &Value) -> bool {
+ T::castable(value)
+ }
+
+ fn error(found: &Value) -> EcoString {
+ T::error(found)
+ }
+}
+
+impl<T: Clone + IntoValue> IntoValue for Arc<T> {
+ fn into_value(self) -> Value {
+ Arc::take(self).into_value()
+ }
+}
+
+impl<T: FromValue> FromValue for Arc<T> {
+ fn from_value(value: Value) -> StrResult<Self> {
+ match value {
+ v if T::castable(&v) => Ok(Arc::new(T::from_value(v)?)),
+ _ => Err(Self::error(&value)),
+ }
+ }
+}
+
+impl<T: Clone + Resolve> Resolve for Arc<T> {
+ type Output = Arc<T::Output>;
+
+ fn resolve(self, styles: super::StyleChain) -> Self::Output {
+ Arc::new(Arc::take(self).resolve(styles))
+ }
+}
+
+impl<T: Clone + Fold> Fold for Arc<T> {
+ fn fold(self, outer: Self) -> Self {
+ Arc::new(Arc::take(self).fold(Arc::take(outer)))
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/typst/src/layout/abs.rs b/crates/typst/src/layout/abs.rs
index cb61e1b2..459f0370 100644
--- a/crates/typst/src/layout/abs.rs
+++ b/crates/typst/src/layout/abs.rs
@@ -4,7 +4,7 @@ use std::ops::{Add, Div, Mul, Neg, Rem};
use ecow::EcoString;
-use crate::foundations::{cast, repr, Repr, Value};
+use crate::foundations::{cast, repr, Fold, Repr, Value};
use crate::util::{Numeric, Scalar};
/// An absolute length.
@@ -227,6 +227,12 @@ impl<'a> Sum<&'a Self> for Abs {
}
}
+impl Fold for Abs {
+ fn fold(self, _: Self) -> Self {
+ self
+ }
+}
+
cast! {
Abs,
self => Value::Length(self.into()),
diff --git a/crates/typst/src/layout/align.rs b/crates/typst/src/layout/align.rs
index 934bfa19..c108ec89 100644
--- a/crates/typst/src/layout/align.rs
+++ b/crates/typst/src/layout/align.rs
@@ -321,6 +321,40 @@ cast! {
}
}
+/// A horizontal alignment which only allows `left`/`right` and `start`/`end`,
+/// thus excluding `center`.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum OuterHAlignment {
+ #[default]
+ Start,
+ Left,
+ Right,
+ End,
+}
+
+impl From<OuterHAlignment> for HAlignment {
+ fn from(value: OuterHAlignment) -> Self {
+ match value {
+ OuterHAlignment::Start => Self::Start,
+ OuterHAlignment::Left => Self::Left,
+ OuterHAlignment::Right => Self::Right,
+ OuterHAlignment::End => Self::End,
+ }
+ }
+}
+
+cast! {
+ OuterHAlignment,
+ self => HAlignment::from(self).into_value(),
+ align: Alignment => match align {
+ Alignment::H(HAlignment::Start) => Self::Start,
+ Alignment::H(HAlignment::Left) => Self::Left,
+ Alignment::H(HAlignment::Right) => Self::Right,
+ Alignment::H(HAlignment::End) => Self::End,
+ v => bail!("expected `start`, `left`, `right`, or `end`, found {}", v.repr()),
+ }
+}
+
/// Where to align something vertically.
#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum VAlignment {
@@ -383,6 +417,34 @@ cast! {
}
}
+/// A vertical alignment which only allows `top` and `bottom`, thus excluding
+/// `horizon`.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub enum OuterVAlignment {
+ #[default]
+ Top,
+ Bottom,
+}
+
+impl From<OuterVAlignment> for VAlignment {
+ fn from(value: OuterVAlignment) -> Self {
+ match value {
+ OuterVAlignment::Top => Self::Top,
+ OuterVAlignment::Bottom => Self::Bottom,
+ }
+ }
+}
+
+cast! {
+ OuterVAlignment,
+ self => VAlignment::from(self).into_value(),
+ align: Alignment => match align {
+ Alignment::V(VAlignment::Top) => Self::Top,
+ Alignment::V(VAlignment::Bottom) => Self::Bottom,
+ v => bail!("expected `top` or `bottom`, found {}", v.repr()),
+ }
+}
+
/// A fixed alignment in the global coordinate space.
///
/// For horizontal alignment, start is globally left and for vertical alignment
diff --git a/crates/typst/src/layout/corners.rs b/crates/typst/src/layout/corners.rs
index 85fd0503..3a2262bf 100644
--- a/crates/typst/src/layout/corners.rs
+++ b/crates/typst/src/layout/corners.rs
@@ -2,7 +2,8 @@ use std::fmt::{self, Debug, Formatter};
use crate::diag::StrResult;
use crate::foundations::{
- CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve, StyleChain, Value,
+ AlternativeFold, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve,
+ StyleChain, Value,
};
use crate::layout::Side;
use crate::util::Get;
@@ -240,13 +241,10 @@ impl<T: Resolve> Resolve for Corners<T> {
impl<T: Fold> Fold for Corners<Option<T>> {
fn fold(self, outer: Self) -> Self {
- self.zip(outer).map(|(inner, outer)| match (inner, outer) {
- (Some(inner), Some(outer)) => Some(inner.fold(outer)),
- // Usually, folding an inner `None` with an `outer` preferres the
- // explicit `None`. However, here `None` means unspecified and thus
- // we want `outer`.
- (inner, outer) => inner.or(outer),
- })
+ // Usually, folding an inner `None` with an `outer` preferres the
+ // explicit `None`. However, here `None` means unspecified and thus
+ // we want `outer`, so we use `fold_or` to opt into such behavior.
+ self.zip(outer).map(|(inner, outer)| inner.fold_or(outer))
}
}
diff --git a/crates/typst/src/layout/grid/layout.rs b/crates/typst/src/layout/grid/layout.rs
index c17bbda5..790aedac 100644
--- a/crates/typst/src/layout/grid/layout.rs
+++ b/crates/typst/src/layout/grid/layout.rs
@@ -1,13 +1,20 @@
+use std::fmt::Debug;
+use std::hash::Hash;
use std::num::NonZeroUsize;
+use std::sync::Arc;
use ecow::eco_format;
+use super::lines::{
+ generate_line_segments, hline_stroke_at_column, vline_stroke_at_row, Line,
+ LinePosition, LineSegment,
+};
use crate::diag::{
bail, At, Hint, HintedStrResult, HintedString, SourceResult, StrResult,
};
use crate::engine::Engine;
use crate::foundations::{
- Array, CastInfo, Content, FromValue, Func, IntoValue, Reflect, Resolve, Smart,
+ Array, CastInfo, Content, Fold, FromValue, Func, IntoValue, Reflect, Resolve, Smart,
StyleChain, Value,
};
use crate::layout::{
@@ -17,7 +24,7 @@ use crate::layout::{
use crate::syntax::Span;
use crate::text::TextElem;
use crate::util::{MaybeReverseIter, NonZeroExt, Numeric};
-use crate::visualize::{FixedStroke, Geometry, Paint};
+use crate::visualize::{Geometry, Paint, Stroke};
/// A value that can be configured per cell.
#[derive(Debug, Clone, PartialEq, Hash)]
@@ -88,6 +95,65 @@ impl<T: FromValue> FromValue for Celled<T> {
}
}
+impl<T: Fold> Fold for Celled<T> {
+ fn fold(self, outer: Self) -> Self {
+ match (self, outer) {
+ (Self::Value(inner), Self::Value(outer)) => Self::Value(inner.fold(outer)),
+ (self_, _) => self_,
+ }
+ }
+}
+
+impl<T: Resolve> Resolve for Celled<T> {
+ type Output = ResolvedCelled<T>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ match self {
+ Self::Value(value) => ResolvedCelled(Celled::Value(value.resolve(styles))),
+ Self::Func(func) => ResolvedCelled(Celled::Func(func)),
+ Self::Array(values) => ResolvedCelled(Celled::Array(
+ values.into_iter().map(|value| value.resolve(styles)).collect(),
+ )),
+ }
+ }
+}
+
+/// The result of resolving a Celled's value according to styles.
+/// Holds resolved values which depend on each grid cell's position.
+/// When it is a closure, however, it is only resolved when the closure is
+/// called.
+#[derive(Default, Clone)]
+pub struct ResolvedCelled<T: Resolve>(Celled<T::Output>);
+
+impl<T> ResolvedCelled<T>
+where
+ T: FromValue + Resolve,
+ <T as Resolve>::Output: Default + Clone,
+{
+ /// Resolve the value based on the cell position.
+ pub fn resolve(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ x: usize,
+ y: usize,
+ ) -> SourceResult<T::Output> {
+ Ok(match &self.0 {
+ Celled::Value(value) => value.clone(),
+ Celled::Func(func) => func
+ .call(engine, [x, y])?
+ .cast::<T>()
+ .at(func.span())?
+ .resolve(styles),
+ Celled::Array(array) => x
+ .checked_rem(array.len())
+ .and_then(|i| array.get(i))
+ .cloned()
+ .unwrap_or_default(),
+ })
+ }
+}
+
/// Represents a cell in CellGrid, to be laid out by GridLayouter.
#[derive(Clone)]
pub struct Cell {
@@ -97,12 +163,31 @@ pub struct Cell {
pub fill: Option<Paint>,
/// The amount of columns spanned by the cell.
pub colspan: NonZeroUsize,
+ /// The cell's stroke.
+ ///
+ /// We use an Arc to avoid unnecessary space usage when all sides are the
+ /// same, or when the strokes come from a common source.
+ pub stroke: Sides<Option<Arc<Stroke<Abs>>>>,
+ /// Which stroke sides were explicitly overridden by the cell, over the
+ /// grid's global stroke setting.
+ ///
+ /// This is used to define whether or not this cell's stroke sides should
+ /// have priority over adjacent cells' stroke sides, if those don't
+ /// override their own stroke properties (and thus have less priority when
+ /// defining with which stroke to draw grid lines around this cell).
+ pub stroke_overridden: Sides<bool>,
}
impl From<Content> for Cell {
/// Create a simple cell given its body.
fn from(body: Content) -> Self {
- Self { body, fill: None, colspan: NonZeroUsize::ONE }
+ Self {
+ body,
+ fill: None,
+ colspan: NonZeroUsize::ONE,
+ stroke: Sides::splat(None),
+ stroke_overridden: Sides::splat(false),
+ }
}
}
@@ -119,7 +204,7 @@ impl LayoutMultiple for Cell {
/// A grid entry.
#[derive(Clone)]
-enum Entry {
+pub(super) enum Entry {
/// An entry which holds a cell.
Cell(Cell),
/// An entry which is merged with another cell.
@@ -139,12 +224,46 @@ impl Entry {
}
}
+/// A grid item, possibly affected by automatic cell positioning. Can be either
+/// a line or a cell.
+pub enum GridItem<T: ResolvableCell> {
+ /// A horizontal line in the grid.
+ HLine {
+ /// The row above which the horizontal line is drawn.
+ y: Smart<usize>,
+ start: usize,
+ end: Option<NonZeroUsize>,
+ stroke: Option<Arc<Stroke<Abs>>>,
+ /// The span of the corresponding line element.
+ span: Span,
+ /// The line's position. "before" here means on top of row 'y', while
+ /// "after" means below it.
+ position: LinePosition,
+ },
+ /// A vertical line in the grid.
+ VLine {
+ /// The column before which the vertical line is drawn.
+ x: Smart<usize>,
+ start: usize,
+ end: Option<NonZeroUsize>,
+ stroke: Option<Arc<Stroke<Abs>>>,
+ /// The span of the corresponding line element.
+ span: Span,
+ /// The line's position. "before" here means to the left of column 'x',
+ /// while "after" means to its right (both considering LTR).
+ position: LinePosition,
+ },
+ /// A cell in the grid.
+ Cell(T),
+}
+
/// Used for cell-like elements which are aware of their final properties in
/// the table, and may have property overrides.
pub trait ResolvableCell {
/// Resolves the cell's fields, given its coordinates and default grid-wide
- /// fill, align and inset properties.
+ /// fill, align, inset and stroke properties.
/// Returns a final Cell.
+ #[allow(clippy::too_many_arguments)]
fn resolve_cell(
self,
x: usize,
@@ -152,6 +271,7 @@ pub trait ResolvableCell {
fill: &Option<Paint>,
align: Smart<Alignment>,
inset: Sides<Option<Rel<Length>>>,
+ stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
styles: StyleChain,
) -> Cell;
@@ -171,13 +291,21 @@ pub trait ResolvableCell {
/// A grid of cells, including the columns, rows, and cell data.
pub struct CellGrid {
/// The grid cells.
- entries: Vec<Entry>,
+ pub(super) entries: Vec<Entry>,
/// The column tracks including gutter tracks.
- cols: Vec<Sizing>,
+ pub(super) cols: Vec<Sizing>,
/// The row tracks including gutter tracks.
- rows: Vec<Sizing>,
+ pub(super) rows: Vec<Sizing>,
+ /// The vertical lines before each column, or on the end border.
+ /// Gutter columns are not included.
+ /// Contains up to 'cols_without_gutter.len() + 1' vectors of lines.
+ pub(super) vlines: Vec<Vec<Line>>,
+ /// The horizontal lines on top of each row, or on the bottom border.
+ /// Gutter rows are not included.
+ /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines.
+ pub(super) hlines: Vec<Vec<Line>>,
/// Whether this grid has gutters.
- has_gutter: bool,
+ pub(super) has_gutter: bool,
}
impl CellGrid {
@@ -188,7 +316,7 @@ impl CellGrid {
cells: impl IntoIterator<Item = Cell>,
) -> Self {
let entries = cells.into_iter().map(Entry::Cell).collect();
- Self::new_internal(tracks, gutter, entries)
+ Self::new_internal(tracks, gutter, vec![], vec![], entries)
}
/// Resolves and positions all cells in the grid before creating it.
@@ -198,20 +326,37 @@ 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: ResolvableCell + Clone + Default>(
+ pub fn resolve<T, I>(
tracks: Axes<&[Sizing]>,
gutter: Axes<&[Sizing]>,
- cells: &[T],
+ items: I,
fill: &Celled<Option<Paint>>,
align: &Celled<Smart<Alignment>>,
inset: Sides<Option<Rel<Length>>>,
+ stroke: &ResolvedCelled<Sides<Option<Option<Arc<Stroke>>>>>,
engine: &mut Engine,
styles: StyleChain,
span: Span,
- ) -> SourceResult<Self> {
+ ) -> SourceResult<Self>
+ where
+ T: ResolvableCell + Default,
+ I: IntoIterator<Item = GridItem<T>>,
+ I::IntoIter: ExactSizeIterator,
+ {
// Number of content columns: Always at least one.
let c = tracks.x.len().max(1);
+ // Lists of lines.
+ // Horizontal lines are only pushed later to be able to check for row
+ // validity, since the amount of rows isn't known until all items were
+ // analyzed in the for loop below.
+ // We keep their spans so we can report errors later.
+ let mut pending_hlines: Vec<(Span, Line)> = vec![];
+
+ // For consistency, only push vertical lines later as well.
+ let mut pending_vlines: Vec<(Span, Line)> = vec![];
+ let has_gutter = gutter.any(|tracks| !tracks.is_empty());
+
// We can't just use the cell's index in the 'cells' vector to
// determine its automatic position, since cells could have arbitrary
// positions, so the position of a cell in 'cells' can differ from its
@@ -219,23 +364,92 @@ impl CellGrid {
// Therefore, we use a counter, 'auto_index', to determine the position
// of the next cell with (x: auto, y: auto). It is only stepped when
// a cell with (x: auto, y: auto), usually the vast majority, is found.
- let mut auto_index = 0;
+ let mut auto_index: usize = 0;
// We have to rebuild the grid to account for arbitrary positions.
- // Create at least 'cells.len()' positions, since there will be at
- // least 'cells.len()' cells, even though some of them might be placed
- // in arbitrary positions and thus cause the grid to expand.
+ // Create at least 'items.len()' positions, since there could be at
+ // least 'items.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 'cells.len()' is already a multiple of 'c' (thus
- // 'cells.len() % c' would be zero).
- let Some(cell_count) = cells.len().checked_add((c - cells.len() % c) % c) else {
- bail!(span, "too many cells were given")
+ // 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 {
+ bail!(span, "too many cells or lines were given")
};
- let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(cell_count);
- for cell in cells.iter().cloned() {
+ 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 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.
@@ -273,6 +487,7 @@ impl CellGrid {
&fill.resolve(engine, x, y)?,
align.resolve(engine, x, y)?,
inset,
+ stroke.resolve(engine, styles, x, y)?,
styles,
);
@@ -358,6 +573,7 @@ impl CellGrid {
&fill.resolve(engine, x, y)?,
align.resolve(engine, x, y)?,
inset,
+ stroke.resolve(engine, styles, x, y)?,
styles,
);
Ok(Entry::Cell(new_cell))
@@ -365,13 +581,97 @@ impl CellGrid {
})
.collect::<SourceResult<Vec<Entry>>>()?;
- Ok(Self::new_internal(tracks, gutter, resolved_cells))
+ // Populate the final lists of lines.
+ // For each line type (horizontal or vertical), we keep a vector for
+ // every group of lines with the same index.
+ let mut vlines: Vec<Vec<Line>> = vec![];
+ let mut hlines: Vec<Vec<Line>> = vec![];
+ let row_amount = resolved_cells.len().div_ceil(c);
+
+ for (line_span, line) in pending_hlines {
+ let y = line.index;
+ if y > row_amount {
+ bail!(line_span, "cannot place horizontal line at invalid row {y}");
+ }
+ if y == row_amount && line.position == LinePosition::After {
+ bail!(
+ line_span,
+ "cannot place horizontal line at the 'bottom' position of the bottom border (y = {y})";
+ hint: "set the line's position to 'top' or place it at a smaller 'y' index"
+ );
+ }
+ let line = if line.position == LinePosition::After
+ && (!has_gutter || y + 1 == row_amount)
+ {
+ // Just place the line on top of the next row if
+ // there's no gutter and the line should be placed
+ // after the one with given index.
+ //
+ // Note that placing after the last row is also the same as
+ // just placing on the grid's bottom border, even with
+ // gutter.
+ Line {
+ index: y + 1,
+ position: LinePosition::Before,
+ ..line
+ }
+ } else {
+ line
+ };
+ let y = line.index;
+
+ if hlines.len() <= y {
+ hlines.resize_with(y + 1, Vec::new);
+ }
+ hlines[y].push(line);
+ }
+
+ for (line_span, line) in pending_vlines {
+ let x = line.index;
+ if x > c {
+ bail!(line_span, "cannot place vertical line at invalid column {x}");
+ }
+ if x == c && line.position == LinePosition::After {
+ bail!(
+ line_span,
+ "cannot place vertical line at the 'end' position of the end border (x = {c})";
+ hint: "set the line's position to 'start' or place it at a smaller 'x' index"
+ );
+ }
+ let line =
+ if line.position == LinePosition::After && (!has_gutter || x + 1 == c) {
+ // Just place the line before the next column if
+ // there's no gutter and the line should be placed
+ // after the one with given index.
+ //
+ // Note that placing after the last column is also the
+ // same as just placing on the grid's end border, even
+ // with gutter.
+ Line {
+ index: x + 1,
+ position: LinePosition::Before,
+ ..line
+ }
+ } else {
+ line
+ };
+ let x = line.index;
+
+ if vlines.len() <= x {
+ vlines.resize_with(x + 1, Vec::new);
+ }
+ vlines[x].push(line);
+ }
+
+ Ok(Self::new_internal(tracks, gutter, vlines, hlines, resolved_cells))
}
/// Generates the cell grid, given the tracks and resolved entries.
- fn new_internal(
+ pub(super) fn new_internal(
tracks: Axes<&[Sizing]>,
gutter: Axes<&[Sizing]>,
+ vlines: Vec<Vec<Line>>,
+ hlines: Vec<Vec<Line>>,
entries: Vec<Entry>,
) -> Self {
let mut cols = vec![];
@@ -418,14 +718,14 @@ impl CellGrid {
rows.pop();
}
- Self { cols, rows, entries, has_gutter }
+ Self { cols, rows, entries, vlines, hlines, has_gutter }
}
/// Get the grid entry in column `x` and row `y`.
///
/// Returns `None` if it's a gutter cell.
#[track_caller]
- fn entry(&self, x: usize, y: usize) -> Option<&Entry> {
+ pub(super) fn entry(&self, x: usize, y: usize) -> Option<&Entry> {
assert!(x < self.cols.len());
assert!(y < self.rows.len());
@@ -447,19 +747,29 @@ impl CellGrid {
///
/// Returns `None` if it's a gutter cell or merged position.
#[track_caller]
- fn cell(&self, x: usize, y: usize) -> Option<&Cell> {
+ pub(super) fn cell(&self, x: usize, y: usize) -> Option<&Cell> {
self.entry(x, y).and_then(Entry::as_cell)
}
+ /// Returns the parent cell of the grid entry at the given position.
+ /// - If the entry at the given position is a cell, returns it.
+ /// - If it is a merged cell, returns the parent cell.
+ /// - If it is a gutter cell, returns None.
+ #[track_caller]
+ pub(super) fn parent_cell(&self, x: usize, y: usize) -> Option<&Cell> {
+ self.parent_cell_position(x, y)
+ .and_then(|Axes { x, y }| self.cell(x, y))
+ }
+
/// Returns the position of the parent cell of the grid entry at the given
/// position. It is guaranteed to have a non-gutter, non-merged cell at
/// the returned position, due to how the grid is built.
- /// If the entry at the given position is a cell, returns the given
+ /// - If the entry at the given position is a cell, returns the given
/// position.
- /// If it is a merged cell, returns the parent cell's position.
- /// If it is a gutter cell, returns None.
+ /// - If it is a merged cell, returns the parent cell's position.
+ /// - If it is a gutter cell, returns None.
#[track_caller]
- fn parent_cell_position(&self, x: usize, y: usize) -> Option<Axes<usize>> {
+ pub(super) fn parent_cell_position(&self, x: usize, y: usize) -> Option<Axes<usize>> {
self.entry(x, y).map(|entry| match entry {
Entry::Cell(_) => Axes::new(x, y),
Entry::Merged { parent } => {
@@ -571,8 +881,6 @@ fn resolve_cell_position(
pub struct GridLayouter<'a> {
/// The grid of cells.
grid: &'a CellGrid,
- // How to stroke the cells.
- stroke: &'a Option<FixedStroke>,
/// The regions to layout children into.
regions: Regions<'a>,
/// The inherited styles.
@@ -617,10 +925,8 @@ impl<'a> GridLayouter<'a> {
/// Create a new grid layouter.
///
/// This prepares grid layout by unifying content and gutter tracks.
- #[allow(clippy::too_many_arguments)]
pub fn new(
grid: &'a CellGrid,
- stroke: &'a Option<FixedStroke>,
regions: Regions<'a>,
styles: StyleChain<'a>,
span: Span,
@@ -632,7 +938,6 @@ impl<'a> GridLayouter<'a> {
Self {
grid,
- stroke,
regions,
styles,
rcols: vec![Abs::zero(); grid.cols.len()],
@@ -677,42 +982,161 @@ impl<'a> GridLayouter<'a> {
continue;
}
- // Render table lines.
- if let Some(stroke) = self.stroke {
- let thickness = stroke.thickness;
- let half = thickness / 2.0;
-
- // Render horizontal lines.
- for offset in points(rows.iter().map(|piece| piece.height)) {
- let target = Point::with_x(frame.width() + thickness);
- let hline = Geometry::Line(target).stroked(stroke.clone());
- frame.prepend(
- Point::new(-half, offset),
+ // Render grid lines.
+ // We collect lines into a vector before rendering so we can sort
+ // them based on thickness, such that the lines with largest
+ // thickness are drawn on top; and also so we can prepend all of
+ // them at once in the frame, as calling prepend() for each line,
+ // and thus pushing all frame items forward each time, would result
+ // in quadratic complexity.
+ let mut lines = vec![];
+
+ // 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 vlines_at_column = self
+ .grid
+ .vlines
+ .get(if !self.grid.has_gutter {
+ x
+ } else if is_end_border {
+ // The end border has its own vector of lines, but
+ // dividing it by 2 and flooring would give us the
+ // vector of lines with the index of the last column.
+ // Add 1 so we get the border's lines.
+ x / 2 + 1
+ } else {
+ // If x is a gutter column, this will round down to the
+ // index of the previous content column, which is
+ // intentional - the only lines which can appear before
+ // a gutter column are lines for the previous column
+ // marked with "LinePosition::After". Therefore, we get
+ // the previous column's lines. Worry not, as
+ // 'generate_line_segments' will correctly filter lines
+ // based on their LinePosition for us.
+ //
+ // If x is a content column, this will correctly return
+ // its index before applying gutters, so nothing
+ // special here (lines with "LinePosition::After" would
+ // then be ignored for this column, as we are drawing
+ // lines before it, not after).
+ x / 2
+ })
+ .map(|vlines| &**vlines)
+ .unwrap_or(&[]);
+ let tracks = rows.iter().map(|row| (row.y, row.height));
+
+ // Determine all different line segments we have to draw in
+ // this column, and convert them to points and shapes.
+ //
+ // Even a single, uniform line might generate more than one
+ // segment, if it happens to cross a colspan (over which it
+ // must not be drawn).
+ let segments = generate_line_segments(
+ self.grid,
+ tracks,
+ x,
+ vlines_at_column,
+ is_end_border,
+ vline_stroke_at_row,
+ )
+ .map(|segment| {
+ let LineSegment { stroke, offset: dy, length, priority } = segment;
+ let stroke = (*stroke).clone().unwrap_or_default();
+ let thickness = stroke.thickness;
+ let half = thickness / 2.0;
+ let target = Point::with_y(length + thickness);
+ let vline = Geometry::Line(target).stroked(stroke);
+ (
+ thickness,
+ priority,
+ Point::new(dx, dy - half),
+ FrameItem::Shape(vline, self.span),
+ )
+ });
+
+ lines.extend(segments);
+ }
+
+ // Render horizontal lines.
+ // They are rendered second as they default to appearing on top.
+ // First, calculate their offsets from the top of the frame.
+ let hline_offsets = points(rows.iter().map(|piece| piece.height));
+
+ // Additionally, determine their indices (the indices of the
+ // rows they are drawn on top of). In principle, this will
+ // correspond to the rows' indices directly, except for the
+ // first and last hlines, which must be 0 and (amount of rows)
+ // respectively, as they are always drawn (due to being part of
+ // the table's border).
+ let hline_indices = std::iter::once(0)
+ .chain(rows.iter().map(|piece| piece.y).skip(1))
+ .chain(std::iter::once(self.grid.rows.len()));
+
+ for (y, dy) in hline_indices.zip(hline_offsets) {
+ let is_bottom_border = y == self.grid.rows.len();
+ let hlines_at_row = 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
+ })
+ .map(|hlines| &**hlines)
+ .unwrap_or(&[]);
+ let tracks = self.rcols.iter().copied().enumerate();
+
+ // Determine all different line segments we have to draw in
+ // this row, and convert them to points and shapes.
+ let segments = generate_line_segments(
+ self.grid,
+ tracks,
+ y,
+ hlines_at_row,
+ is_bottom_border,
+ hline_stroke_at_column,
+ )
+ .map(|segment| {
+ let LineSegment { stroke, offset: dx, length, priority } = segment;
+ let stroke = (*stroke).clone().unwrap_or_default();
+ let thickness = stroke.thickness;
+ let half = thickness / 2.0;
+ let dx = if self.is_rtl { self.width - dx - length } else { dx };
+ let target = Point::with_x(length + thickness);
+ let hline = Geometry::Line(target).stroked(stroke);
+ (
+ thickness,
+ priority,
+ Point::new(dx - half, dy),
FrameItem::Shape(hline, self.span),
- );
- }
+ )
+ });
- // Render vertical lines.
- for (x, dx) in points(self.rcols.iter().copied()).enumerate() {
- let dx = if self.is_rtl { self.width - dx } else { dx };
- // We want each vline to span the entire table (start
- // at y = 0, end after all rows).
- // We use 'split_vline' to split the vline such that it
- // is not drawn above colspans.
- for (dy, length) in
- split_vline(self.grid, rows, x, 0, self.grid.rows.len())
- {
- let target = Point::with_y(length + thickness);
- let vline = Geometry::Line(target).stroked(stroke.clone());
- frame.prepend(
- Point::new(dx, dy - half),
- FrameItem::Shape(vline, self.span),
- );
- }
- }
+ // Draw later (after we sort all lines below.)
+ lines.extend(segments);
}
+ // Sort by increasing thickness, so that we draw larger strokes
+ // on top. When the thickness is the same, sort by priority.
+ //
+ // Sorting by thickness avoids layering problems where a smaller
+ // hline appears "inside" a larger vline. When both have the same
+ // size, hlines are drawn on top (since the sort is stable, and
+ // they are pushed later).
+ lines.sort_by_key(|(thickness, priority, ..)| (*thickness, *priority));
+
// Render cell backgrounds.
+ // We collect them into a vector so they can all be prepended at
+ // once to the frame, together with lines.
+ let mut fills = vec![];
+
// Reverse with RTL so that later columns start first.
let mut dx = Abs::zero();
for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
@@ -736,13 +1160,22 @@ impl<'a> GridLayouter<'a> {
let pos = Point::new(dx + offset, dy);
let size = Size::new(width, row.height);
let rect = Geometry::Rect(size).filled(fill);
- frame.prepend(pos, FrameItem::Shape(rect, self.span));
+ fills.push((pos, FrameItem::Shape(rect, self.span)));
}
}
dy += row.height;
}
dx += col;
}
+
+ // Now we render each fill and stroke by prepending to the frame,
+ // such that both appear below cell contents. Fills come first so
+ // that they appear below lines.
+ frame.prepend_multiple(
+ fills
+ .into_iter()
+ .chain(lines.into_iter().map(|(_, _, point, shape)| (point, shape))),
+ );
}
Ok(Fragment::frames(finished))
@@ -1234,287 +1667,3 @@ fn points(extents: impl IntoIterator<Item = Abs>) -> impl Iterator<Item = Abs> {
offset
})
}
-
-/// Given the 'x' of the column right after the vline (or cols.len() at the
-/// border) and its start..end range of rows, alongside the rows for the
-/// current region, splits the vline into contiguous parts to draw, including
-/// the height of the vline in each part. This will go through each row and
-/// interrupt the current vline to be drawn when a colspan is detected, or the
-/// end of the row range (or of the region) is reached.
-/// The idea is to not draw vlines over colspans.
-/// This will return the start offsets and lengths of each final segment of
-/// this vline. The offsets are relative to the top of the first row.
-/// Note that this assumes that rows are sorted according to ascending 'y'.
-fn split_vline(
- grid: &CellGrid,
- rows: &[RowPiece],
- x: usize,
- start: usize,
- end: usize,
-) -> impl IntoIterator<Item = (Abs, Abs)> {
- // Each segment of this vline that should be drawn.
- // The last element in the vector below is the currently drawn segment.
- // That is, the last segment will be expanded until interrupted.
- let mut drawn_vlines = vec![];
- // Whether the latest vline segment is complete, because we hit a row we
- // should skip while drawing the vline. Starts at true so we push
- // the first segment to the vector.
- let mut interrupted = true;
- // How far down from the first row have we gone so far.
- // Used to determine the positions at which to draw each segment.
- let mut offset = Abs::zero();
-
- // We start drawing at the first suitable row, and keep going down
- // (increasing y) expanding the last segment until we hit a row on top of
- // which we shouldn't draw, which is skipped, leading to the creation of a
- // new vline segment later if a suitable row is found, restarting the
- // cycle.
- for row in rows.iter().take_while(|row| row.y < end) {
- if should_draw_vline_at_row(grid, x, row.y, start, end) {
- if interrupted {
- // Last segment was interrupted by a colspan, or there are no
- // segments yet.
- // Create a new segment to draw. We start spanning this row.
- drawn_vlines.push((offset, row.height));
- interrupted = false;
- } else {
- // Extend the current segment so it covers at least this row
- // as well.
- // The vector can't be empty if interrupted is false.
- let current_segment = drawn_vlines.last_mut().unwrap();
- current_segment.1 += row.height;
- }
- } else {
- interrupted = true;
- }
- offset += row.height;
- }
-
- drawn_vlines
-}
-
-/// Returns 'true' if the vline right before column 'x', given its start..end
-/// range of rows, should be drawn when going through row 'y'.
-/// That only occurs if the row is within its start..end range, and if it
-/// wouldn't go through a colspan.
-fn should_draw_vline_at_row(
- grid: &CellGrid,
- x: usize,
- y: usize,
- start: usize,
- end: usize,
-) -> bool {
- if !(start..end).contains(&y) {
- // Row is out of range for this line
- return false;
- }
- if x == 0 || x == grid.cols.len() {
- // Border vline. Always drawn.
- return true;
- }
- // When the vline isn't at the border, we need to check if a colspan would
- // be present between columns 'x' and 'x-1' at row 'y', and thus overlap
- // with the line.
- // To do so, we analyze the cell right after this vline. If it is merged
- // with a cell before this line (parent_x < x) which is at this row or
- // above it (parent_y <= y), this means it would overlap with the vline,
- // so the vline must not be drawn at this row.
- let first_adjacent_cell = if grid.has_gutter {
- // Skip the gutters, if x or y represent gutter tracks.
- // We would then analyze the cell one column after (if at a gutter
- // column), and/or one row below (if at a gutter row), in order to
- // check if it would be merged with a cell before the vline.
- (x + x % 2, y + y % 2)
- } else {
- (x, y)
- };
- let Axes { x: parent_x, y: parent_y } = grid
- .parent_cell_position(first_adjacent_cell.0, first_adjacent_cell.1)
- .unwrap();
-
- parent_x >= x || parent_y > y
-}
-
-#[cfg(test)]
-mod test {
- use super::*;
-
- fn sample_cell() -> Cell {
- Cell {
- body: Content::default(),
- fill: None,
- colspan: NonZeroUsize::ONE,
- }
- }
-
- fn cell_with_colspan(colspan: usize) -> Cell {
- Cell {
- body: Content::default(),
- fill: None,
- colspan: NonZeroUsize::try_from(colspan).unwrap(),
- }
- }
-
- fn sample_grid(gutters: bool) -> CellGrid {
- const COLS: usize = 4;
- const ROWS: usize = 6;
- let entries = vec![
- // row 0
- Entry::Cell(sample_cell()),
- Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(2)),
- Entry::Merged { parent: 2 },
- // row 1
- Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(3)),
- Entry::Merged { parent: 5 },
- Entry::Merged { parent: 5 },
- // row 2
- Entry::Merged { parent: 4 },
- Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(2)),
- Entry::Merged { parent: 10 },
- // row 3
- Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(3)),
- Entry::Merged { parent: 13 },
- Entry::Merged { parent: 13 },
- // row 4
- Entry::Cell(sample_cell()),
- Entry::Merged { parent: 13 },
- Entry::Merged { parent: 13 },
- Entry::Merged { parent: 13 },
- // row 5
- Entry::Cell(sample_cell()),
- Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(2)),
- Entry::Merged { parent: 22 },
- ];
- CellGrid::new_internal(
- Axes::with_x(&[Sizing::Auto; COLS]),
- if gutters {
- Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1])
- } else {
- Axes::default()
- },
- entries,
- )
- }
-
- #[test]
- fn test_vline_splitting_without_gutter() {
- let grid = sample_grid(false);
- let rows = &[
- RowPiece { height: Abs::pt(1.0), y: 0 },
- RowPiece { height: Abs::pt(2.0), y: 1 },
- RowPiece { height: Abs::pt(4.0), y: 2 },
- RowPiece { height: Abs::pt(8.0), y: 3 },
- RowPiece { height: Abs::pt(16.0), y: 4 },
- RowPiece { height: Abs::pt(32.0), y: 5 },
- ];
- let expected_vline_splits = &[
- vec![(Abs::pt(0.), Abs::pt(1. + 2. + 4. + 8. + 16. + 32.))],
- vec![(Abs::pt(0.), Abs::pt(1. + 2. + 4. + 8. + 16. + 32.))],
- // interrupted a few times by colspans
- vec![
- (Abs::pt(0.), Abs::pt(1.)),
- (Abs::pt(1. + 2.), Abs::pt(4.)),
- (Abs::pt(1. + 2. + 4. + 8. + 16.), Abs::pt(32.)),
- ],
- // interrupted every time by colspans
- vec![],
- vec![(Abs::pt(0.), Abs::pt(1. + 2. + 4. + 8. + 16. + 32.))],
- ];
- for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
- assert_eq!(
- expected_splits,
- &split_vline(&grid, rows, x, 0, 6).into_iter().collect::<Vec<_>>(),
- );
- }
- }
-
- #[test]
- fn test_vline_splitting_with_gutter() {
- let grid = sample_grid(true);
- let rows = &[
- RowPiece { height: Abs::pt(1.0), y: 0 },
- RowPiece { height: Abs::pt(2.0), y: 1 },
- RowPiece { height: Abs::pt(4.0), y: 2 },
- RowPiece { height: Abs::pt(8.0), y: 3 },
- RowPiece { height: Abs::pt(16.0), y: 4 },
- RowPiece { height: Abs::pt(32.0), y: 5 },
- RowPiece { height: Abs::pt(64.0), y: 6 },
- RowPiece { height: Abs::pt(128.0), y: 7 },
- RowPiece { height: Abs::pt(256.0), y: 8 },
- RowPiece { height: Abs::pt(512.0), y: 9 },
- RowPiece { height: Abs::pt(1024.0), y: 10 },
- ];
- let expected_vline_splits = &[
- // left border
- vec![(
- Abs::pt(0.),
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.),
- )],
- // gutter line below
- vec![(
- Abs::pt(0.),
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.),
- )],
- vec![(
- Abs::pt(0.),
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.),
- )],
- // gutter line below
- // the two lines below are interrupted multiple times by colspans
- vec![
- (Abs::pt(0.), Abs::pt(1. + 2.)),
- (Abs::pt(1. + 2. + 4.), Abs::pt(8. + 16. + 32.)),
- (
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
- Abs::pt(512. + 1024.),
- ),
- ],
- vec![
- (Abs::pt(0.), Abs::pt(1. + 2.)),
- (Abs::pt(1. + 2. + 4.), Abs::pt(8. + 16. + 32.)),
- (
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
- Abs::pt(512. + 1024.),
- ),
- ],
- // gutter line below
- // the two lines below can only cross certain gutter rows, because
- // all non-gutter cells in the following column are merged with
- // cells from the previous column.
- vec![
- (Abs::pt(1.), Abs::pt(2.)),
- (Abs::pt(1. + 2. + 4.), Abs::pt(8.)),
- (Abs::pt(1. + 2. + 4. + 8. + 16.), Abs::pt(32.)),
- (
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
- Abs::pt(512.),
- ),
- ],
- vec![
- (Abs::pt(1.), Abs::pt(2.)),
- (Abs::pt(1. + 2. + 4.), Abs::pt(8.)),
- (Abs::pt(1. + 2. + 4. + 8. + 16.), Abs::pt(32.)),
- (
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
- Abs::pt(512.),
- ),
- ],
- // right border
- vec![(
- Abs::pt(0.),
- Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.),
- )],
- ];
- for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
- assert_eq!(
- expected_splits,
- &split_vline(&grid, rows, x, 0, 11).into_iter().collect::<Vec<_>>(),
- );
- }
- }
-}
diff --git a/crates/typst/src/layout/grid/lines.rs b/crates/typst/src/layout/grid/lines.rs
new file mode 100644
index 00000000..9a4da26d
--- /dev/null
+++ b/crates/typst/src/layout/grid/lines.rs
@@ -0,0 +1,1096 @@
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use super::layout::CellGrid;
+use crate::foundations::{AlternativeFold, Fold};
+use crate::layout::{Abs, Axes};
+use crate::visualize::Stroke;
+
+/// Represents an explicit grid line (horizontal or vertical) specified by the
+/// user.
+pub struct Line {
+ /// The index of the track after this line. This will be the index of the
+ /// row a horizontal line is above of, or of the column right after a
+ /// vertical line.
+ ///
+ /// Must be within `0..=tracks.len()` (where `tracks` is either `grid.cols`
+ /// or `grid.rows`, ignoring gutter tracks, as appropriate).
+ pub index: usize,
+ /// The index of the track at which this line starts being drawn.
+ /// This is the first column a horizontal line appears in, or the first row
+ /// a vertical line appears in.
+ ///
+ /// Must be within `0..tracks.len()` minus gutter tracks.
+ pub start: usize,
+ /// The index after the last track through which the line is drawn.
+ /// Thus, the line is drawn through tracks `start..end` (note that `end` is
+ /// exclusive).
+ ///
+ /// Must be within `1..=tracks.len()` minus gutter tracks.
+ /// `None` indicates the line should go all the way to the end.
+ pub end: Option<NonZeroUsize>,
+ /// The line's stroke. This is `None` when the line is explicitly used to
+ /// override a previously specified line.
+ pub stroke: Option<Arc<Stroke<Abs>>>,
+ /// The line's position in relation to the track with its index.
+ pub position: LinePosition,
+}
+
+/// Indicates whether the line should be drawn before or after the track with
+/// 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)]
+pub enum LinePosition {
+ /// The line should be drawn before its track (e.g. hline on top of a row).
+ Before,
+ /// The line should be drawn after its track (e.g. hline below a row).
+ After,
+}
+
+/// Indicates which priority a particular grid line segment should have, based
+/// on the highest priority configuration that defined the segment's stroke.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub(super) enum StrokePriority {
+ /// The stroke of the segment was derived solely from the grid's global
+ /// stroke setting, so it should have the lowest priority.
+ GridStroke = 0,
+ /// The segment's stroke was derived (even if partially) from a cell's
+ /// stroke override, so it should have priority over non-overridden cell
+ /// strokes and be drawn on top of them (when they have the same
+ /// thickness).
+ CellStroke = 1,
+ /// The segment's stroke was derived from a user's explicitly placed line
+ /// (hline or vline), and thus should have maximum priority, drawn on top
+ /// of any cell strokes (when they have the same thickness).
+ ExplicitLine = 2,
+}
+
+/// Data for a particular line segment in the grid as generated by
+/// 'generate_line_segments'.
+#[derive(Debug, PartialEq, Eq)]
+pub(super) struct LineSegment {
+ /// The stroke with which to draw this segment.
+ pub(super) stroke: Arc<Stroke<Abs>>,
+ /// The offset of this segment since the beginning of its axis.
+ /// For a vertical line segment, this is the offset since the top of the
+ /// table in the current page; for a horizontal line segment, this is the
+ /// offset since the start border of the table.
+ pub(super) offset: Abs,
+ /// The length of this segment.
+ pub(super) length: Abs,
+ /// The segment's drawing priority, indicating on top of which other
+ /// segments this one should be drawn.
+ pub(super) priority: StrokePriority,
+}
+
+/// Generates the segments of lines that should be drawn alongside a certain
+/// axis in the grid, going through the given tracks (orthogonal to the lines).
+/// Each returned segment contains its stroke, its offset from the start, and
+/// its length.
+///
+/// Accepts, as parameters, the index of the lines that should be produced
+/// (for example, the column at which vertical lines will be drawn); a list of
+/// user-specified lines with the same index (the `lines` parameter); whether
+/// the given index corresponds to the maximum index for the line's axis; and a
+/// function which returns the final stroke that should be used for each track
+/// the line goes through, alongside the priority of the returned stroke (its
+/// parameters are the grid, the index of the line to be drawn, the number of
+/// the track to draw at and the stroke of the user hline/vline override at
+/// this index to fold with, if any). Contiguous segments with the same stroke
+/// and priority are joined together automatically.
+///
+/// The function should return 'None' for positions at which the line would
+/// otherwise cross a merged cell (for example, a vline could cross a colspan),
+/// in which case a new segment should be drawn after the merged cell(s), even
+/// if it would have the same stroke as the previous one.
+///
+/// Regarding priority, the function should return a priority of ExplicitLine
+/// when the user-defined line's stroke at the current position isn't None
+/// (note that it is passed by parameter to the function). When it is None, the
+/// function should return a priority of CellStroke if the stroke returned was
+/// given or affected by a per-cell override of the grid's global stroke.
+/// When that isn't the case, the returned stroke was entirely provided by the
+/// grid's global stroke, and thus a priority of GridStroke should be returned.
+///
+/// Note that we assume that the tracks are sorted according to ascending
+/// number, and they must be iterable over pairs of (number, size). For
+/// vertical lines, for instance, 'tracks' would describe the rows in the
+/// current region, as pairs (row index, row height).
+pub(super) fn generate_line_segments<'grid, F, I>(
+ grid: &'grid CellGrid,
+ tracks: I,
+ index: usize,
+ lines: &'grid [Line],
+ is_max_index: bool,
+ line_stroke_at_track: F,
+) -> impl Iterator<Item = LineSegment> + 'grid
+where
+ F: Fn(
+ &CellGrid,
+ usize,
+ usize,
+ Option<Arc<Stroke<Abs>>>,
+ ) -> Option<(Arc<Stroke<Abs>>, StrokePriority)>
+ + 'grid,
+ I: IntoIterator<Item = (usize, Abs)>,
+ I::IntoIter: 'grid,
+{
+ // The segment currently being drawn.
+ //
+ // It is extended for each consecutive track through which the line would
+ // be drawn with the same stroke and priority.
+ //
+ // Starts as None to force us to create a new segment as soon as we find
+ // the first track through which we should draw.
+ let mut current_segment: Option<LineSegment> = None;
+
+ // How far from the start (before the first track) have we gone so far.
+ // Used to determine the positions at which to draw each segment.
+ let mut offset = Abs::zero();
+
+ // 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.has_gutter && index % 2 == 1 && !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
+ // the process, picking up from the track after the one at which we had
+ // an interruption, until we have gone through all tracks.
+ //
+ // When going through each track, we check if the current segment would be
+ // interrupted, either because, at this track, we hit a merged cell over
+ // which we shouldn't draw, or because the line would have a different
+ // stroke or priority at this point (so we have to start a new segment). If
+ // so, the current segment is yielded and its variable is either set to
+ // 'None' (if no segment should be drawn at the point of interruption,
+ // meaning we might have to create a new segment later) or to the new
+ // segment (if we're starting to draw a segment with a different stroke or
+ // priority than before).
+ // Otherwise (if the current segment should span the current track), it is
+ // simply extended (or a new one is created, if it is 'None'), and no value
+ // is yielded for the current track, since the segment isn't yet complete
+ // (the next tracks might extend it further before it is interrupted and
+ // yielded). That is, we yield each segment only when it is interrupted,
+ // since then we will know its final length for sure.
+ //
+ // After the loop is done (and thus we went through all tracks), we
+ // interrupt the current segment one last time, to ensure the final segment
+ // is always interrupted and yielded, if it wasn't interrupted earlier.
+ let mut tracks = tracks.into_iter();
+ std::iter::from_fn(move || {
+ // Each time this closure runs, we advance the track iterator as much
+ // as possible before returning because the current segment was
+ // interrupted. The for loop is resumed from where it stopped at the
+ // next call due to that, ensuring we go through all tracks and then
+ // stop.
+ for (track, size) in &mut tracks {
+ // Get the expected line stroke at this track by folding the
+ // strokes of each user-specified line (with priority to the
+ // user-specified line specified last).
+ let stroke = lines
+ .iter()
+ .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)
+ })
+ .map(|line| line.stroke.clone())
+ .fold(None, |acc, line_stroke| line_stroke.fold(acc));
+
+ // The function shall determine if it is appropriate to draw
+ // the line at this position or not (i.e. whether or not it
+ // would cross a merged cell), and, if so, the final stroke it
+ // should have (because cells near this position could have
+ // stroke overrides, which have priority and should be folded
+ // with the stroke obtained above).
+ //
+ // If we are currently already drawing a segment and the function
+ // indicates we should, at this track, draw some other segment
+ // (with a different stroke or priority), or even no segment at
+ // all, we interrupt and yield the current segment (which was drawn
+ // up to the previous track) by returning it wrapped in 'Some()'
+ // (which indicates, in the context of 'std::iter::from_fn', that
+ // our iterator isn't over yet, and this should be its next value).
+ if let Some((stroke, priority)) =
+ line_stroke_at_track(grid, index, track, stroke)
+ {
+ // We should draw at this position. Let's check if we were
+ // already drawing in the previous position.
+ if let Some(current_segment) = &mut current_segment {
+ // We are currently building a segment. Let's check if
+ // we should extend it to this track as well.
+ if current_segment.stroke == stroke
+ && current_segment.priority == priority
+ {
+ // Extend the current segment so it covers at least
+ // this track as well, since we should use the same
+ // stroke as in the previous one when a line goes
+ // through this track, with the same priority.
+ current_segment.length += size;
+ } else {
+ // We got a different stroke or priority now, so create
+ // a new segment with the new stroke and spanning the
+ // current track. Yield the old segment, as it was
+ // interrupted and is thus complete.
+ let new_segment =
+ LineSegment { stroke, offset, length: size, priority };
+ let old_segment = std::mem::replace(current_segment, new_segment);
+ offset += size;
+ return Some(old_segment);
+ }
+ } else {
+ // We should draw here, but there is no segment
+ // currently being drawn, either because the last
+ // position had a merged cell, had a stroke
+ // of 'None', or because this is the first track.
+ // Create a new segment to draw. We start spanning this
+ // track.
+ current_segment =
+ Some(LineSegment { stroke, offset, length: size, priority });
+ }
+ } else if let Some(old_segment) = current_segment.take() {
+ // We shouldn't draw here (stroke of None), so we yield the
+ // current segment, as it was interrupted.
+ offset += size;
+ return Some(old_segment);
+ }
+ // Either the current segment is None (meaning we didn't start
+ // drawing a segment yet since the last yielded one), so we keep
+ // searching for a track where we should draw one; or the current
+ // segment is Some but wasn't interrupted at this track, so we keep
+ // looping through the following tracks until it is interrupted,
+ // or we reach the end.
+ offset += size;
+ }
+
+ // Reached the end of all tracks, so we interrupt and finish
+ // the current segment. Note that, on future calls to this
+ // closure, the current segment will necessarily be 'None',
+ // so the iterator will necessarily end (that is, we will return None)
+ // after this.
+ current_segment.take()
+ })
+}
+
+/// Returns the correct stroke with which to draw a vline right before column
+/// 'x' when going through row 'y', given the stroke of the user-specified line
+/// at this position, if any. Also returns the stroke's drawing priority, which
+/// depends on its source.
+///
+/// If the vline would go through a colspan, returns None (shouldn't be drawn).
+/// If the one (when at the border) or two (otherwise) cells to the left and
+/// right of the vline have right and left stroke overrides, respectively,
+/// then the cells' stroke overrides are folded together with the vline's
+/// stroke (with priority to the vline's stroke, followed by the right cell's
+/// stroke, and, finally, the left cell's) and returned. If only one of the two
+/// cells around the vline (if there are two) has an override, that cell's
+/// stroke is given priority when folding. If, however, the cells around the
+/// vline at this row do not have any stroke overrides, then the vline's own
+/// stroke, as defined by user-specified lines (if any), is returned.
+///
+/// The priority associated with the returned stroke follows the rules
+/// described in the docs for 'generate_line_segment'.
+pub(super) fn vline_stroke_at_row(
+ grid: &CellGrid,
+ x: usize,
+ y: usize,
+ stroke: Option<Arc<Stroke<Abs>>>,
+) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> {
+ if x != 0 && x != grid.cols.len() {
+ // When the vline isn't at the border, we need to check if a colspan would
+ // be present between columns 'x' and 'x-1' at row 'y', and thus overlap
+ // with the line.
+ // To do so, we analyze the cell right after this vline. If it is merged
+ // with a cell before this line (parent_x < x) which is at this row or
+ // above it (parent_y <= y), this means it would overlap with the vline,
+ // so the vline must not be drawn at this row.
+ let first_adjacent_cell = if grid.has_gutter {
+ // Skip the gutters, if x or y represent gutter tracks.
+ // We would then analyze the cell one column after (if at a gutter
+ // column), and/or one row below (if at a gutter row), in order to
+ // check if it would be merged with a cell before the vline.
+ (x + x % 2, y + y % 2)
+ } else {
+ (x, y)
+ };
+ let Axes { x: parent_x, y: parent_y } = grid
+ .parent_cell_position(first_adjacent_cell.0, first_adjacent_cell.1)
+ .unwrap();
+
+ if parent_x < x && parent_y <= y {
+ // There is a colspan cell going through this vline's position,
+ // so don't draw it here.
+ return None;
+ }
+ }
+
+ let (left_cell_stroke, left_cell_prioritized) = x
+ .checked_sub(1)
+ .and_then(|left_x| grid.parent_cell(left_x, y))
+ .map(|left_cell| {
+ (left_cell.stroke.right.clone(), left_cell.stroke_overridden.right)
+ })
+ .unwrap_or((None, false));
+
+ let (right_cell_stroke, right_cell_prioritized) = if x < grid.cols.len() {
+ grid.parent_cell(x, y)
+ .map(|right_cell| {
+ (right_cell.stroke.left.clone(), right_cell.stroke_overridden.left)
+ })
+ .unwrap_or((None, false))
+ } else {
+ (None, false)
+ };
+
+ let priority = if stroke.is_some() {
+ StrokePriority::ExplicitLine
+ } else if left_cell_prioritized || right_cell_prioritized {
+ StrokePriority::CellStroke
+ } else {
+ StrokePriority::GridStroke
+ };
+
+ let (prioritized_cell_stroke, deprioritized_cell_stroke) =
+ if left_cell_prioritized && !right_cell_prioritized {
+ (left_cell_stroke, right_cell_stroke)
+ } else {
+ // When both cells' strokes have the same priority, we default to
+ // prioritizing the right cell's left stroke.
+ (right_cell_stroke, left_cell_stroke)
+ };
+
+ // When both cells specify a stroke for this line segment, fold
+ // both strokes, with priority to either the one prioritized cell,
+ // or to the right cell's left stroke in case of a tie. But when one of
+ // them doesn't specify a stroke, the other cell's stroke should be used
+ // instead, regardless of priority (hence the usage of 'fold_or').
+ let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke);
+
+ // Fold the line stroke and folded cell strokes, if possible.
+ // Give priority to the explicit line stroke.
+ // Otherwise, use whichever of the two isn't 'none' or unspecified.
+ let final_stroke = stroke.fold_or(cell_stroke);
+
+ final_stroke.zip(Some(priority))
+}
+
+/// Returns the correct stroke with which to draw a hline on top of row 'y'
+/// when going through column 'x', given the stroke of the user-specified line
+/// at this position, if any. Also returns the stroke's drawing priority, which
+/// depends on its source.
+///
+/// If the one (when at the border) or two (otherwise) cells above and below
+/// the hline have bottom and top stroke overrides, respectively, then the
+/// cells' stroke overrides are folded together with the hline's stroke (with
+/// priority to hline's stroke, followed by the bottom cell's stroke, and,
+/// finally, the top cell's) and returned. If only one of the two cells around
+/// the vline (if there are two) has an override, that cell's stroke is given
+/// priority when folding. If, however, the cells around the hline at this
+/// column do not have any stroke overrides, then the hline's own stroke, as
+/// defined by user-specified lines (if any), is directly returned.
+///
+/// The priority associated with the returned stroke follows the rules
+/// described in the docs for 'generate_line_segment'.
+pub(super) fn hline_stroke_at_column(
+ grid: &CellGrid,
+ y: usize,
+ x: usize,
+ stroke: Option<Arc<Stroke<Abs>>>,
+) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> {
+ // There are no rowspans yet, so no need to add a check here. The line will
+ // always be drawn, if it has a stroke.
+ let cell_x = if grid.has_gutter {
+ // Skip the gutter column this hline is in.
+ // This is because positions above and below it, even if gutter, could
+ // be part of a colspan, so we have to check the following cell.
+ // However, this is only valid if we're not in a gutter row.
+ x + x % 2
+ } else {
+ x
+ };
+
+ let (top_cell_stroke, top_cell_prioritized) = y
+ .checked_sub(1)
+ .and_then(|top_y| {
+ // Let's find the parent cell of the position above us, in order
+ // to take its bottom stroke, even when we're below gutter.
+ grid.parent_cell_position(cell_x, top_y)
+ })
+ .filter(|Axes { x: parent_x, .. }| {
+ // Only use the stroke of the cell above us but one column to the
+ // right if it is merged with a cell before this line's column.
+ // If the position above us is a simple non-merged cell, or the
+ // parent of a colspan, this will also evaluate to true.
+ parent_x <= &x
+ })
+ .map(|Axes { x: parent_x, y: parent_y }| {
+ let top_cell = grid.cell(parent_x, parent_y).unwrap();
+ (top_cell.stroke.bottom.clone(), top_cell.stroke_overridden.bottom)
+ })
+ .unwrap_or((None, false));
+
+ let (bottom_cell_stroke, bottom_cell_prioritized) = if y < grid.rows.len() {
+ // Let's find the parent cell of the position below us, in order
+ // to take its top stroke, even when we're above gutter.
+ grid.parent_cell_position(cell_x, y)
+ .filter(|Axes { x: parent_x, .. }| {
+ // Only use the stroke of the cell below us but one column to the
+ // right if it is merged with a cell before this line's column.
+ // If the position below us is a simple non-merged cell, or the
+ // parent of a colspan, this will also evaluate to true.
+ parent_x <= &x
+ })
+ .map(|Axes { x: parent_x, y: parent_y }| {
+ let bottom_cell = grid.cell(parent_x, parent_y).unwrap();
+ (bottom_cell.stroke.top.clone(), bottom_cell.stroke_overridden.top)
+ })
+ .unwrap_or((None, false))
+ } else {
+ // No cell below the bottom border.
+ (None, false)
+ };
+
+ let priority = if stroke.is_some() {
+ StrokePriority::ExplicitLine
+ } else if top_cell_prioritized || bottom_cell_prioritized {
+ StrokePriority::CellStroke
+ } else {
+ StrokePriority::GridStroke
+ };
+
+ let (prioritized_cell_stroke, deprioritized_cell_stroke) =
+ if top_cell_prioritized && !bottom_cell_prioritized {
+ (top_cell_stroke, bottom_cell_stroke)
+ } else {
+ // When both cells' strokes have the same priority, we default to
+ // prioritizing the bottom cell's top stroke.
+ (bottom_cell_stroke, top_cell_stroke)
+ };
+
+ // When both cells specify a stroke for this line segment, fold
+ // both strokes, with priority to either the one prioritized cell,
+ // or to the bottom cell's top stroke in case of a tie. But when one of
+ // them doesn't specify a stroke, the other cell's stroke should be used
+ // instead, regardless of priority (hence the usage of 'fold_or').
+ let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke);
+
+ // Fold the line stroke and folded cell strokes, if possible.
+ // Give priority to the explicit line stroke.
+ // Otherwise, use whichever of the two isn't 'none' or unspecified.
+ let final_stroke = stroke.fold_or(cell_stroke);
+
+ final_stroke.zip(Some(priority))
+}
+
+#[cfg(test)]
+mod test {
+ use super::super::layout::{Entry, RowPiece};
+ use super::*;
+ use crate::foundations::Content;
+ use crate::layout::{Cell, Sides, Sizing};
+ use crate::util::NonZeroExt;
+
+ fn sample_cell() -> Cell {
+ Cell {
+ body: Content::default(),
+ fill: None,
+ colspan: NonZeroUsize::ONE,
+ stroke: Sides::splat(Some(Arc::new(Stroke::default()))),
+ stroke_overridden: Sides::splat(false),
+ }
+ }
+
+ fn cell_with_colspan(colspan: usize) -> Cell {
+ Cell {
+ body: Content::default(),
+ fill: None,
+ colspan: NonZeroUsize::try_from(colspan).unwrap(),
+ stroke: Sides::splat(Some(Arc::new(Stroke::default()))),
+ stroke_overridden: Sides::splat(false),
+ }
+ }
+
+ fn sample_grid(gutters: bool) -> CellGrid {
+ const COLS: usize = 4;
+ const ROWS: usize = 6;
+ let entries = vec![
+ // row 0
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan(2)),
+ Entry::Merged { parent: 2 },
+ // row 1
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan(3)),
+ Entry::Merged { parent: 5 },
+ Entry::Merged { parent: 5 },
+ // row 2
+ Entry::Merged { parent: 4 },
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan(2)),
+ Entry::Merged { parent: 10 },
+ // row 3
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan(3)),
+ Entry::Merged { parent: 13 },
+ Entry::Merged { parent: 13 },
+ // row 4
+ Entry::Cell(sample_cell()),
+ Entry::Merged { parent: 13 },
+ Entry::Merged { parent: 13 },
+ Entry::Merged { parent: 13 },
+ // row 5
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan(2)),
+ Entry::Merged { parent: 22 },
+ ];
+ CellGrid::new_internal(
+ Axes::with_x(&[Sizing::Auto; COLS]),
+ if gutters {
+ Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1])
+ } else {
+ Axes::default()
+ },
+ vec![],
+ vec![],
+ entries,
+ )
+ }
+
+ #[test]
+ fn test_vline_splitting_without_gutter() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid(false);
+ let rows = &[
+ RowPiece { height: Abs::pt(1.0), y: 0 },
+ RowPiece { height: Abs::pt(2.0), y: 1 },
+ RowPiece { height: Abs::pt(4.0), y: 2 },
+ RowPiece { height: Abs::pt(8.0), y: 3 },
+ RowPiece { height: Abs::pt(16.0), y: 4 },
+ RowPiece { height: Abs::pt(32.0), y: 5 },
+ ];
+ let expected_vline_splits = &[
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ priority: StrokePriority::GridStroke,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted a few times by colspans
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16.),
+ length: Abs::pt(32.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // interrupted every time by colspans
+ vec![],
+ vec![LineSegment {
+ stroke,
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ priority: StrokePriority::GridStroke,
+ }],
+ ];
+ for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
+ 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<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_vline_splitting_with_gutter_and_per_cell_stroke() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid(true);
+ let rows = &[
+ RowPiece { height: Abs::pt(1.0), y: 0 },
+ RowPiece { height: Abs::pt(2.0), y: 1 },
+ RowPiece { height: Abs::pt(4.0), y: 2 },
+ RowPiece { height: Abs::pt(8.0), y: 3 },
+ RowPiece { height: Abs::pt(16.0), y: 4 },
+ RowPiece { height: Abs::pt(32.0), y: 5 },
+ RowPiece { height: Abs::pt(64.0), y: 6 },
+ RowPiece { height: Abs::pt(128.0), y: 7 },
+ RowPiece { height: Abs::pt(256.0), y: 8 },
+ RowPiece { height: Abs::pt(512.0), y: 9 },
+ RowPiece { height: Abs::pt(1024.0), y: 10 },
+ ];
+ // Stroke is per-cell so we skip gutter
+ let expected_vline_splits = &[
+ // left border
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
+ length: Abs::pt(256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // gutter line below
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
+ length: Abs::pt(256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
+ length: Abs::pt(256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // gutter line below
+ // the two lines below are interrupted multiple times by colspans
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // gutter line below
+ // the two lines below can only cross certain gutter rows, because
+ // all non-gutter cells in the following column are merged with
+ // cells from the previous column.
+ vec![],
+ vec![],
+ // right border
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
+ length: Abs::pt(256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ ];
+ for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
+ 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<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_vline_splitting_with_gutter_and_explicit_vlines() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid(true);
+ let rows = &[
+ RowPiece { height: Abs::pt(1.0), y: 0 },
+ RowPiece { height: Abs::pt(2.0), y: 1 },
+ RowPiece { height: Abs::pt(4.0), y: 2 },
+ RowPiece { height: Abs::pt(8.0), y: 3 },
+ RowPiece { height: Abs::pt(16.0), y: 4 },
+ RowPiece { height: Abs::pt(32.0), y: 5 },
+ RowPiece { height: Abs::pt(64.0), y: 6 },
+ RowPiece { height: Abs::pt(128.0), y: 7 },
+ RowPiece { height: Abs::pt(256.0), y: 8 },
+ RowPiece { height: Abs::pt(512.0), y: 9 },
+ RowPiece { height: Abs::pt(1024.0), y: 10 },
+ ];
+ let expected_vline_splits = &[
+ // left border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // the two lines below are interrupted multiple times by colspans
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512. + 1024.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512. + 1024.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // gutter line below
+ // the two lines below can only cross certain gutter rows, because
+ // all non-gutter cells in the following column are merged with
+ // cells from the previous column.
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16.),
+ length: Abs::pt(32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16.),
+ length: Abs::pt(32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // right border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ ];
+ for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
+ let tracks = rows.iter().map(|row| (row.y, row.height));
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(
+ &grid,
+ tracks,
+ x,
+ &[
+ Line {
+ index: x,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::Before
+ },
+ Line {
+ index: x,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::After
+ },
+ ],
+ x == grid.cols.len(),
+ vline_stroke_at_row
+ )
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+}
diff --git a/crates/typst/src/layout/grid/mod.rs b/crates/typst/src/layout/grid/mod.rs
index ee2eeecf..0ddecd84 100644
--- a/crates/typst/src/layout/grid/mod.rs
+++ b/crates/typst/src/layout/grid/mod.rs
@@ -1,8 +1,11 @@
mod layout;
+mod lines;
-pub use self::layout::{Cell, CellGrid, Celled, GridLayouter, ResolvableCell};
+pub use self::layout::{Cell, CellGrid, Celled, GridItem, GridLayouter, ResolvableCell};
+pub use self::lines::LinePosition;
use std::num::NonZeroUsize;
+use std::sync::Arc;
use ecow::eco_format;
use smallvec::{smallvec, SmallVec};
@@ -13,10 +16,11 @@ use crate::foundations::{
cast, elem, scope, Array, Content, Fold, Packed, Show, Smart, StyleChain, Value,
};
use crate::layout::{
- AlignElem, Alignment, Axes, Fragment, LayoutMultiple, Length, Regions, Rel, Sides,
- Sizing,
+ Abs, AlignElem, Alignment, Axes, Dir, Fragment, LayoutMultiple, Length,
+ OuterHAlignment, OuterVAlignment, Regions, Rel, Sides, Sizing,
};
use crate::syntax::Span;
+use crate::text::TextElem;
use crate::util::NonZeroExt;
use crate::visualize::{Paint, Stroke};
@@ -235,12 +239,14 @@ pub struct GridElem {
/// Grids have no strokes by default, which can be changed by setting this
/// option to the desired stroke.
///
- /// _Note:_ Richer stroke customization for individual cells is not yet
- /// implemented, but will be in the future. In the meantime, you can use the
- /// third-party [tablex library](https://github.com/PgBiel/typst-tablex/).
+ /// If it is necessary to place lines which can cross spacing between cells
+ /// produced by the `gutter` option, or to override the stroke between
+ /// multiple specific cells, consider specifying one or more of
+ /// [`grid.hline`]($grid.hline) and [`grid.vline`]($grid.vline) alongside
+ /// your grid cells.
#[resolve]
#[fold]
- pub stroke: Option<Stroke>,
+ pub stroke: Celled<Sides<Option<Option<Arc<Stroke>>>>>,
/// How much to pad the cells' content.
///
@@ -266,17 +272,25 @@ pub struct GridElem {
#[fold]
pub inset: Sides<Option<Rel<Length>>>,
- /// The contents of the grid cells.
+ /// The contents of the grid cells, plus any extra grid lines specified
+ /// with the [`grid.hline`]($grid.hline) and [`grid.vline`]($grid.vline)
+ /// elements.
///
/// The cells are populated in row-major order.
#[variadic]
- pub children: Vec<Packed<GridCell>>,
+ pub children: Vec<GridChild>,
}
#[scope]
impl GridElem {
#[elem]
type GridCell;
+
+ #[elem]
+ type GridHLine;
+
+ #[elem]
+ type GridVLine;
}
impl LayoutMultiple for Packed<GridElem> {
@@ -294,26 +308,60 @@ impl LayoutMultiple for Packed<GridElem> {
let column_gutter = self.column_gutter(styles);
let row_gutter = self.row_gutter(styles);
let fill = self.fill(styles);
- let stroke = self.stroke(styles).map(Stroke::unwrap_or_default);
+ let stroke = self.stroke(styles);
let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice());
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,
+ },
+ },
+ 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()),
+ });
let grid = CellGrid::resolve(
tracks,
gutter,
- self.children(),
+ items,
fill,
align,
inset,
+ &stroke,
engine,
styles,
self.span(),
)
.trace(engine.world, tracepoint, self.span())?;
- let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span());
+ let layouter = GridLayouter::new(&grid, regions, styles, self.span());
// Measure the columns and layout the grid row-by-row.
layouter.layout(engine)
@@ -332,6 +380,151 @@ cast! {
values: Array => Self(values.into_iter().map(Value::cast).collect::<StrResult<_>>()?),
}
+/// Any child of a grid element.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum GridChild {
+ HLine(Packed<GridHLine>),
+ VLine(Packed<GridVLine>),
+ Cell(Packed<GridCell>),
+}
+
+cast! {
+ GridChild,
+ self => match self {
+ Self::HLine(hline) => hline.into_value(),
+ Self::VLine(vline) => vline.into_value(),
+ Self::Cell(cell) => cell.into_value(),
+ },
+ v: Content => v.into(),
+}
+
+impl From<Content> for GridChild {
+ fn from(value: Content) -> Self {
+ 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))
+ .unwrap_or_else(|value| {
+ let span = value.span();
+ GridChild::Cell(Packed::new(GridCell::new(value)).spanned(span))
+ })
+ }
+}
+
+/// A horizontal line in the grid.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// grid's `stroke` field. Can cross spacing between cells created through
+/// the grid's `column-gutter` option.
+#[elem(name = "hline", title = "Grid Horizontal Line")]
+pub struct GridHLine {
+ /// The row above which the horizontal line is placed (zero-indexed).
+ /// If the `position` field is set to `{bottom}`, the line is placed below
+ /// the row with the given index instead (see that field's docs for
+ /// details).
+ ///
+ /// Specifying `{auto}` causes the line to be placed at the row below the
+ /// last automatically positioned cell (that is, cell without coordinate
+ /// overrides) before the line among the grid's children. If there is no
+ /// such cell before the line, it is placed at the top of the grid (row 0).
+ /// Note that specifying for this option exactly the total amount of rows
+ /// in the grid causes this horizontal line to override the bottom border
+ /// of the grid, while a value of 0 overrides the top border.
+ pub y: Smart<usize>,
+
+ /// The column at which the horizontal line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The column before which the horizontal line ends (zero-indexed,
+ /// exclusive).
+ /// Therefore, the horizontal line will be drawn up to and across column
+ /// `end - 1`.
+ ///
+ /// A value equal to `{none}` or to the amount of columns causes it to
+ /// extend all the way towards the end of the grid.
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` interrupts previous hlines placed across this
+ /// line's range, but does not affect per-cell stroke or vlines.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its row (`y`) - either
+ /// `{top}` to draw above it or `{bottom}` to draw below it.
+ ///
+ /// This setting is only relevant when row gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `y` field by
+ /// one instead), since then the position below a row becomes different
+ /// from the position above the next row due to the spacing between both.
+ #[default(OuterVAlignment::Top)]
+ pub position: OuterVAlignment,
+}
+
+/// A vertical line in the grid.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// grid's `stroke` field. Can cross spacing between cells created through
+/// the grid's `row-gutter` option.
+#[elem(name = "vline", title = "Grid Vertical Line")]
+pub struct GridVLine {
+ /// The column before which the horizontal line is placed (zero-indexed).
+ /// If the `position` field is set to `{end}`, the line is placed after the
+ /// column with the given index instead (see that field's docs for
+ /// details).
+ ///
+ /// Specifying `{auto}` causes the line to be placed at the column after
+ /// the last automatically positioned cell (that is, cell without
+ /// coordinate overrides) before the line among the grid's children. If
+ /// there is no such cell before the line, it is placed before the grid's
+ /// first column (column 0).
+ /// Note that specifying for this option exactly the total amount of
+ /// columns in the grid causes this vertical line to override the end
+ /// border of the grid (right in LTR, left in RTL), while a value of 0
+ /// overrides the start border (left in LTR, right in RTL).
+ pub x: Smart<usize>,
+
+ /// The row at which the vertical line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The row on top of which the vertical line ends (zero-indexed,
+ /// exclusive).
+ /// Therefore, the vertical line will be drawn up to and across row
+ /// `end - 1`.
+ ///
+ /// A value equal to `{none}` or to the amount of rows causes it to extend
+ /// all the way towards the bottom of the grid.
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` interrupts previous vlines placed across this
+ /// line's range, but does not affect per-cell stroke or hlines.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its column (`x`) -
+ /// either `{start}` to draw before it or `{end}` to draw after it.
+ ///
+ /// The values `{left}` and `{right}` are also accepted, but discouraged as
+ /// they cause your grid to be inconsistent between left-to-right and
+ /// right-to-left documents.
+ ///
+ /// This setting is only relevant when column gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `x` field by
+ /// one instead), since then the position after a column becomes different
+ /// from the position before the next column due to the spacing between
+ /// both.
+ #[default(OuterHAlignment::Start)]
+ pub position: OuterHAlignment,
+}
+
/// A cell in the grid. Use this to either override grid properties for a
/// particular cell, or in show rules to apply certain styles to multiple cells
/// at once.
@@ -383,7 +576,7 @@ cast! {
pub struct GridCell {
/// The cell's body.
#[required]
- body: Content,
+ pub body: Content,
/// The cell's column (zero-indexed).
/// This field may be used in show rules to style a cell depending on its
@@ -408,7 +601,7 @@ pub struct GridCell {
/// [1], grid.cell(x: 3)[4], [2],
/// )
/// ```
- x: Smart<usize>,
+ pub x: Smart<usize>,
/// The cell's row (zero-indexed).
/// This field may be used in show rules to style a cell depending on its
@@ -427,20 +620,25 @@ pub struct GridCell {
/// [A], grid.cell(y: 1)[B], grid.cell(y: 1)[C], grid.cell(y: 2)[D]
/// )
/// ```
- y: Smart<usize>,
+ pub y: Smart<usize>,
/// The amount of columns spanned by this cell.
#[default(NonZeroUsize::ONE)]
- colspan: NonZeroUsize,
+ pub colspan: NonZeroUsize,
/// The cell's fill override.
- fill: Smart<Option<Paint>>,
+ pub fill: Smart<Option<Paint>>,
/// The cell's alignment override.
- align: Smart<Alignment>,
+ pub align: Smart<Alignment>,
/// The cell's inset override.
- inset: Smart<Sides<Option<Rel<Length>>>>,
+ pub inset: Smart<Sides<Option<Rel<Length>>>>,
+
+ /// The cell's stroke override.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
}
cast! {
@@ -462,11 +660,27 @@ impl ResolvableCell for Packed<GridCell> {
fill: &Option<Paint>,
align: Smart<Alignment>,
inset: Sides<Option<Rel<Length>>>,
+ stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
styles: StyleChain,
) -> Cell {
let cell = &mut *self;
let colspan = cell.colspan(styles);
let fill = cell.fill(styles).unwrap_or_else(|| fill.clone());
+
+ let cell_stroke = cell.stroke(styles);
+ let stroke_overridden =
+ cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_))));
+
+ // Using a typical 'Sides' fold, an unspecified side loses to a
+ // specified side. Additionally, when both are specified, an inner
+ // None wins over the outer Some, and vice-versa. When both are
+ // specified and Some, fold occurs, which, remarkably, leads to an Arc
+ // clone.
+ //
+ // In the end, we flatten because, for layout purposes, an unspecified
+ // cell stroke is the same as specifying 'none', so we equate the two
+ // concepts.
+ let stroke = cell_stroke.fold(stroke).map(Option::flatten);
cell.push_x(Smart::Custom(x));
cell.push_y(Smart::Custom(y));
cell.push_fill(Smart::Custom(fill.clone()));
@@ -482,7 +696,26 @@ impl ResolvableCell for Packed<GridCell> {
cell.push_inset(Smart::Custom(
cell.inset(styles).map_or(inset, |inner| inner.fold(inset)),
));
- Cell { body: self.pack(), fill, colspan }
+ cell.push_stroke(
+ // Here we convert the resolved stroke to a regular stroke, however
+ // with resolved units (that is, 'em' converted to absolute units).
+ // We also convert any stroke unspecified by both the cell and the
+ // outer stroke ('None' in the folded stroke) to 'none', that is,
+ // all sides are present in the resulting Sides object accessible
+ // by show rules on grid cells.
+ stroke.as_ref().map(|side| {
+ Some(side.as_ref().map(|cell_stroke| {
+ Arc::new((**cell_stroke).clone().map(Length::from))
+ }))
+ }),
+ );
+ Cell {
+ body: self.pack(),
+ fill,
+ colspan,
+ stroke,
+ stroke_overridden,
+ }
}
fn x(&self, styles: StyleChain) -> Smart<usize> {
diff --git a/crates/typst/src/layout/sides.rs b/crates/typst/src/layout/sides.rs
index bb35ffb3..119d7f19 100644
--- a/crates/typst/src/layout/sides.rs
+++ b/crates/typst/src/layout/sides.rs
@@ -3,7 +3,8 @@ use std::ops::Add;
use crate::diag::{bail, StrResult};
use crate::foundations::{
- cast, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve, StyleChain, Value,
+ cast, AlternativeFold, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve,
+ StyleChain, Value,
};
use crate::layout::{Abs, Alignment, Axes, Axis, Corner, Rel, Size};
use crate::util::Get;
@@ -245,13 +246,10 @@ impl<T: Resolve> Resolve for Sides<T> {
impl<T: Fold> Fold for Sides<Option<T>> {
fn fold(self, outer: Self) -> Self {
- self.zip(outer).map(|(inner, outer)| match (inner, outer) {
- (Some(inner), Some(outer)) => Some(inner.fold(outer)),
- // Usually, folding an inner `None` with an `outer` preferres the
- // explicit `None`. However, here `None` means unspecified and thus
- // we want `outer`.
- (inner, outer) => inner.or(outer),
- })
+ // Usually, folding an inner `None` with an `outer` preferres the
+ // explicit `None`. However, here `None` means unspecified and thus
+ // we want `outer`, so we use `fold_or` to opt into such behavior.
+ self.zip(outer).map(|(inner, outer)| inner.fold_or(outer))
}
}
diff --git a/crates/typst/src/model/bibliography.rs b/crates/typst/src/model/bibliography.rs
index 84ab2fff..ca7695d9 100644
--- a/crates/typst/src/model/bibliography.rs
+++ b/crates/typst/src/model/bibliography.rs
@@ -29,7 +29,8 @@ use crate::foundations::{
};
use crate::introspection::{Introspector, Locatable, Location};
use crate::layout::{
- BlockElem, Em, GridCell, GridElem, HElem, PadElem, Sizing, TrackSizings, VElem,
+ BlockElem, Em, GridCell, GridChild, GridElem, HElem, PadElem, Sizing, TrackSizings,
+ VElem,
};
use crate::model::{
CitationForm, CiteGroup, Destination, FootnoteElem, HeadingElem, LinkElem, ParElem,
@@ -237,11 +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(
+ cells.push(GridChild::Cell(
Packed::new(GridCell::new(prefix.clone().unwrap_or_default()))
.spanned(span),
- );
- cells.push(Packed::new(GridCell::new(reference.clone())).spanned(span));
+ ));
+ cells.push(GridChild::Cell(
+ Packed::new(GridCell::new(reference.clone())).spanned(span),
+ ));
}
seq.push(VElem::new(row_gutter).with_weakness(3).pack());
@@ -945,8 +948,8 @@ impl ElemRenderer<'_> {
if let Some(prefix) = suf_prefix {
const COLUMN_GUTTER: Em = Em::new(0.65);
content = GridElem::new(vec![
- Packed::new(GridCell::new(prefix)).spanned(self.span),
- Packed::new(GridCell::new(content)).spanned(self.span),
+ GridChild::Cell(Packed::new(GridCell::new(prefix)).spanned(self.span)),
+ GridChild::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/enum.rs b/crates/typst/src/model/enum.rs
index bb009df7..6677913b 100644
--- a/crates/typst/src/model/enum.rs
+++ b/crates/typst/src/model/enum.rs
@@ -271,7 +271,6 @@ impl LayoutMultiple for Packed<EnumElem> {
number = number.saturating_add(1);
}
- let stroke = None;
let grid = CellGrid::new(
Axes::with_x(&[
Sizing::Rel(indent.into()),
@@ -282,7 +281,7 @@ impl LayoutMultiple for Packed<EnumElem> {
Axes::with_y(&[gutter.into()]),
cells,
);
- let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span());
+ let layouter = GridLayouter::new(&grid, regions, styles, self.span());
layouter.layout(engine)
}
diff --git a/crates/typst/src/model/list.rs b/crates/typst/src/model/list.rs
index 6de78bbd..12a698d1 100644
--- a/crates/typst/src/model/list.rs
+++ b/crates/typst/src/model/list.rs
@@ -168,7 +168,6 @@ impl LayoutMultiple for Packed<ListElem> {
));
}
- let stroke = None;
let grid = CellGrid::new(
Axes::with_x(&[
Sizing::Rel(indent.into()),
@@ -179,7 +178,7 @@ impl LayoutMultiple for Packed<ListElem> {
Axes::with_y(&[gutter.into()]),
cells,
);
- let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span());
+ let layouter = GridLayouter::new(&grid, regions, styles, self.span());
layouter.layout(engine)
}
diff --git a/crates/typst/src/model/table.rs b/crates/typst/src/model/table.rs
index 256459c7..ae4dbce7 100644
--- a/crates/typst/src/model/table.rs
+++ b/crates/typst/src/model/table.rs
@@ -1,4 +1,5 @@
use std::num::NonZeroUsize;
+use std::sync::Arc;
use ecow::eco_format;
@@ -8,12 +9,13 @@ use crate::foundations::{
cast, elem, scope, Content, Fold, Packed, Show, Smart, StyleChain,
};
use crate::layout::{
- show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Fragment, GridLayouter,
- LayoutMultiple, Length, Regions, Rel, ResolvableCell, Sides, TrackSizings,
+ show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Dir, Fragment,
+ GridItem, GridLayouter, LayoutMultiple, Length, LinePosition, OuterHAlignment,
+ OuterVAlignment, Regions, Rel, ResolvableCell, Sides, TrackSizings,
};
use crate::model::Figurable;
use crate::syntax::Span;
-use crate::text::{Lang, LocalName, Region};
+use crate::text::{Lang, LocalName, Region, TextElem};
use crate::util::NonZeroExt;
use crate::visualize::{Paint, Stroke};
@@ -166,13 +168,17 @@ pub struct TableElem {
///
/// Strokes can be disabled by setting this to `{none}`.
///
- /// _Note:_ Richer stroke customization for individual cells is not yet
- /// implemented, but will be in the future. In the meantime, you can use the
- /// third-party [tablex library](https://github.com/PgBiel/typst-tablex/).
+ /// If it is necessary to place lines which can cross spacing between cells
+ /// produced by the `gutter` option, or to override the stroke between
+ /// multiple specific cells, consider specifying one or more of
+ /// [`table.hline`]($table.hline) and [`table.vline`]($table.vline) alongside
+ /// your table cells.
+ ///
+ /// See the [grid documentation]($grid) for more information on stroke.
#[resolve]
#[fold]
- #[default(Some(Stroke::default()))]
- pub stroke: Option<Stroke>,
+ #[default(Celled::Value(Sides::splat(Some(Some(Arc::new(Stroke::default()))))))]
+ pub stroke: Celled<Sides<Option<Option<Arc<Stroke>>>>>,
/// How much to pad the cells' content.
///
@@ -197,15 +203,23 @@ pub struct TableElem {
#[default(Sides::splat(Some(Abs::pt(5.0).into())))]
pub inset: Sides<Option<Rel<Length>>>,
- /// The contents of the table cells.
+ /// The contents of the table cells, plus any extra table lines specified
+ /// with the [`table.hline`]($table.hline) and
+ /// [`table.vline`]($table.vline) elements.
#[variadic]
- pub children: Vec<Packed<TableCell>>,
+ pub children: Vec<TableChild>,
}
#[scope]
impl TableElem {
#[elem]
type TableCell;
+
+ #[elem]
+ type TableHLine;
+
+ #[elem]
+ type TableVLine;
}
impl LayoutMultiple for Packed<TableElem> {
@@ -223,26 +237,60 @@ impl LayoutMultiple for Packed<TableElem> {
let column_gutter = self.column_gutter(styles);
let row_gutter = self.row_gutter(styles);
let fill = self.fill(styles);
- let stroke = self.stroke(styles).map(Stroke::unwrap_or_default);
+ let stroke = self.stroke(styles);
let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice());
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,
+ },
+ },
+ TableChild::Cell(cell) => GridItem::Cell(cell.clone()),
+ });
let grid = CellGrid::resolve(
tracks,
gutter,
- self.children(),
+ items,
fill,
align,
inset,
+ &stroke,
engine,
styles,
self.span(),
)
.trace(engine.world, tracepoint, self.span())?;
- let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span());
+ let layouter = GridLayouter::new(&grid, regions, styles, self.span());
layouter.layout(engine)
}
}
@@ -286,6 +334,122 @@ impl LocalName for Packed<TableElem> {
impl Figurable for Packed<TableElem> {}
+/// Any child of a table element.
+#[derive(Debug, PartialEq, Clone, Hash)]
+pub enum TableChild {
+ HLine(Packed<TableHLine>),
+ VLine(Packed<TableVLine>),
+ Cell(Packed<TableCell>),
+}
+
+cast! {
+ TableChild,
+ self => match self {
+ Self::HLine(hline) => hline.into_value(),
+ Self::VLine(vline) => vline.into_value(),
+ Self::Cell(cell) => cell.into_value(),
+ },
+ v: Content => v.into(),
+}
+
+impl From<Content> for TableChild {
+ fn from(value: Content) -> Self {
+ 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))
+ .unwrap_or_else(|value| {
+ let span = value.span();
+ TableChild::Cell(Packed::new(TableCell::new(value)).spanned(span))
+ })
+ }
+}
+
+/// 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.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// table's `stroke` field. Can cross spacing between cells created through
+/// the table's `column-gutter` option.
+#[elem(name = "hline", title = "Table Horizontal Line")]
+pub struct TableHLine {
+ /// The row above which the horizontal line is placed (zero-indexed).
+ /// Functions identically to the `y` field in [`grid.hline`]($grid.hline).
+ pub y: Smart<usize>,
+
+ /// The column at which the horizontal line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The column before which the horizontal line ends (zero-indexed,
+ /// exclusive).
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` interrupts previous hlines placed across this
+ /// line's range, but does not affect per-cell stroke or vlines.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its row (`y`) - either
+ /// `{top}` to draw above it or `{bottom}` to draw below it.
+ ///
+ /// This setting is only relevant when row gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `y` field by
+ /// one instead), since then the position below a row becomes different
+ /// from the position above the next row due to the spacing between both.
+ #[default(OuterVAlignment::Top)]
+ pub position: OuterVAlignment,
+}
+
+/// A vertical line in the table. See the docs for [`grid.vline`]($grid.vline)
+/// for more information regarding how to use this element's fields.
+///
+/// Overrides any per-cell stroke, including stroke specified through the
+/// table's `stroke` field. Can cross spacing between cells created through
+/// the table's `row-gutter` option.
+#[elem(name = "vline", title = "Table Vertical Line")]
+pub struct TableVLine {
+ /// The column before which the horizontal line is placed (zero-indexed).
+ /// Functions identically to the `x` field in [`grid.vline`]($grid.vline).
+ pub x: Smart<usize>,
+
+ /// The row at which the vertical line starts (zero-indexed, inclusive).
+ pub start: usize,
+
+ /// The row on top of which the vertical line ends (zero-indexed,
+ /// exclusive).
+ pub end: Option<NonZeroUsize>,
+
+ /// The line's stroke.
+ ///
+ /// Specifying `{none}` interrupts previous vlines placed across this
+ /// line's range, but does not affect per-cell stroke or hlines.
+ #[resolve]
+ #[fold]
+ #[default(Some(Arc::new(Stroke::default())))]
+ pub stroke: Option<Arc<Stroke>>,
+
+ /// The position at which the line is placed, given its column (`x`) -
+ /// either `{start}` to draw before it or `{end}` to draw after it.
+ ///
+ /// The values `{left}` and `{right}` are also accepted, but discouraged as
+ /// they cause your table to be inconsistent between left-to-right and
+ /// right-to-left documents.
+ ///
+ /// This setting is only relevant when column gutter is enabled (and
+ /// shouldn't be used otherwise - prefer just increasing the `x` field by
+ /// one instead), since then the position after a column becomes different
+ /// from the position before the next column due to the spacing between
+ /// both.
+ #[default(OuterHAlignment::Start)]
+ pub position: OuterHAlignment,
+}
+
/// A cell in the table. Use this to either override table properties for a
/// particular cell, or in show rules to apply certain styles to multiple cells
/// at once.
@@ -336,28 +500,33 @@ impl Figurable for Packed<TableElem> {}
pub struct TableCell {
/// The cell's body.
#[required]
- body: Content,
+ pub body: Content,
/// The cell's column (zero-indexed).
/// Functions identically to the `x` field in [`grid.cell`]($grid.cell).
- x: Smart<usize>,
+ pub x: Smart<usize>,
/// The cell's row (zero-indexed).
/// Functions identically to the `y` field in [`grid.cell`]($grid.cell).
- y: Smart<usize>,
+ pub y: Smart<usize>,
/// The cell's fill override.
- fill: Smart<Option<Paint>>,
+ pub fill: Smart<Option<Paint>>,
/// The amount of columns spanned by this cell.
#[default(NonZeroUsize::ONE)]
- colspan: NonZeroUsize,
+ pub colspan: NonZeroUsize,
/// The cell's alignment override.
- align: Smart<Alignment>,
+ pub align: Smart<Alignment>,
/// The cell's inset override.
- inset: Smart<Sides<Option<Rel<Length>>>>,
+ pub inset: Smart<Sides<Option<Rel<Length>>>>,
+
+ /// The cell's stroke override.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
}
cast! {
@@ -379,11 +548,27 @@ impl ResolvableCell for Packed<TableCell> {
fill: &Option<Paint>,
align: Smart<Alignment>,
inset: Sides<Option<Rel<Length>>>,
+ stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
styles: StyleChain,
) -> Cell {
let cell = &mut *self;
let colspan = cell.colspan(styles);
let fill = cell.fill(styles).unwrap_or_else(|| fill.clone());
+
+ let cell_stroke = cell.stroke(styles);
+ let stroke_overridden =
+ cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_))));
+
+ // Using a typical 'Sides' fold, an unspecified side loses to a
+ // specified side. Additionally, when both are specified, an inner
+ // None wins over the outer Some, and vice-versa. When both are
+ // specified and Some, fold occurs, which, remarkably, leads to an Arc
+ // clone.
+ //
+ // In the end, we flatten because, for layout purposes, an unspecified
+ // cell stroke is the same as specifying 'none', so we equate the two
+ // concepts.
+ let stroke = cell_stroke.fold(stroke).map(Option::flatten);
cell.push_x(Smart::Custom(x));
cell.push_y(Smart::Custom(y));
cell.push_fill(Smart::Custom(fill.clone()));
@@ -399,7 +584,26 @@ impl ResolvableCell for Packed<TableCell> {
cell.push_inset(Smart::Custom(
cell.inset(styles).map_or(inset, |inner| inner.fold(inset)),
));
- Cell { body: self.pack(), fill, colspan }
+ cell.push_stroke(
+ // Here we convert the resolved stroke to a regular stroke, however
+ // with resolved units (that is, 'em' converted to absolute units).
+ // We also convert any stroke unspecified by both the cell and the
+ // outer stroke ('None' in the folded stroke) to 'none', that is,
+ // all sides are present in the resulting Sides object accessible
+ // by show rules on table cells.
+ stroke.as_ref().map(|side| {
+ Some(side.as_ref().map(|cell_stroke| {
+ Arc::new((**cell_stroke).clone().map(Length::from))
+ }))
+ }),
+ );
+ Cell {
+ body: self.pack(),
+ fill,
+ colspan,
+ stroke,
+ stroke_overridden,
+ }
}
fn x(&self, styles: StyleChain) -> Smart<usize> {
diff --git a/tests/ref/layout/grid-colspan.png b/tests/ref/layout/grid-colspan.png
index 46577c62..e16ca347 100644
--- a/tests/ref/layout/grid-colspan.png
+++ b/tests/ref/layout/grid-colspan.png
Binary files differ
diff --git a/tests/ref/layout/grid-positioning.png b/tests/ref/layout/grid-positioning.png
index 5d60c8b7..cac93f40 100644
--- a/tests/ref/layout/grid-positioning.png
+++ b/tests/ref/layout/grid-positioning.png
Binary files differ
diff --git a/tests/ref/layout/grid-rtl.png b/tests/ref/layout/grid-rtl.png
index a1bfad56..f81e992e 100644
--- a/tests/ref/layout/grid-rtl.png
+++ b/tests/ref/layout/grid-rtl.png
Binary files differ
diff --git a/tests/ref/layout/grid-stroke.png b/tests/ref/layout/grid-stroke.png
new file mode 100644
index 00000000..c31519e6
--- /dev/null
+++ b/tests/ref/layout/grid-stroke.png
Binary files differ
diff --git a/tests/ref/layout/table-cell.png b/tests/ref/layout/table-cell.png
index 8e91e645..647a2e10 100644
--- a/tests/ref/layout/table-cell.png
+++ b/tests/ref/layout/table-cell.png
Binary files differ
diff --git a/tests/ref/layout/table.png b/tests/ref/layout/table.png
index b6b31eb1..f2a9d104 100644
--- a/tests/ref/layout/table.png
+++ b/tests/ref/layout/table.png
Binary files differ
diff --git a/tests/typ/layout/grid-rtl.typ b/tests/typ/layout/grid-rtl.typ
index 33a68888..dcac9810 100644
--- a/tests/typ/layout/grid-rtl.typ
+++ b/tests/typ/layout/grid-rtl.typ
@@ -88,3 +88,52 @@
[e], [g], grid.cell(colspan: 2)[eee\ e\ e\ e],
grid.cell(colspan: 4)[eeee e e e]
)
+
+---
+// Test left and right for vlines in RTL
+#set text(dir: rtl)
+#grid(
+ columns: 3,
+ inset: 5pt,
+ grid.vline(stroke: red, position: left), grid.vline(stroke: green, position: right), [a],
+ grid.vline(stroke: red, position: left), grid.vline(stroke: 2pt, position: right), [b],
+ grid.vline(stroke: red, position: left), grid.vline(stroke: 2pt, position: right), [c],
+ grid.vline(stroke: aqua, position: right)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [b],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [c],
+ grid.vline(stroke: 2pt, position: right)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ grid.vline(stroke: green, position: start), grid.vline(stroke: red, position: end), [a],
+ grid.vline(stroke: 2pt, position: start), grid.vline(stroke: red, position: end), [b],
+ grid.vline(stroke: 2pt, position: start), grid.vline(stroke: red, position: end), [c],
+ grid.vline(stroke: 2pt, position: start)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ grid.vline(stroke: green, position: start), grid.vline(stroke: red, position: end), [a],
+ grid.vline(stroke: blue, position: start), grid.vline(stroke: red, position: end), [b],
+ grid.vline(stroke: blue, position: start), grid.vline(stroke: red, position: end), [c],
+ grid.vline(stroke: 2pt, position: start)
+)
+
+---
+// Error: 3:8-3:34 cannot place vertical line at the 'end' position of the end border (x = 1)
+// Hint: 3:8-3:34 set the line's position to 'start' or place it at a smaller 'x' index
+#set text(dir: rtl)
+#grid(
+ [a], grid.vline(position: left)
+)
diff --git a/tests/typ/layout/grid-stroke.typ b/tests/typ/layout/grid-stroke.typ
new file mode 100644
index 00000000..87389ad5
--- /dev/null
+++ b/tests/typ/layout/grid-stroke.typ
@@ -0,0 +1,379 @@
+#let double-line = pattern(size: (1.5pt, 1.5pt), {
+ place(line(stroke: .6pt, start: (0%, 50%), end: (100%, 50%)))
+})
+
+#table(
+ stroke: (_, y) => if y != 1 { (bottom: black) },
+ columns: 3,
+ table.cell(colspan: 3, align: center)[*Epic Table*],
+ align(center)[*Name*], align(center)[*Age*], align(center)[*Data*],
+ table.hline(stroke: (paint: double-line, thickness: 2pt)),
+ [John], [30], [None],
+ [Martha], [20], [A],
+ [Joseph], [35], [D]
+)
+
+---
+// Test folding
+#set grid(stroke: red)
+#set grid(stroke: 5pt)
+
+#grid(
+ inset: 10pt,
+ columns: 2,
+ stroke: stroke(dash: "loosely-dotted"),
+ grid.vline(start: 2, end: 3, stroke: (paint: green, dash: none)),
+ [a], [b],
+ grid.hline(end: 1, stroke: blue),
+ [c], [d],
+ [e], grid.cell(stroke: aqua)[f]
+)
+
+---
+// Test set rules on cells and folding
+#set table.cell(stroke: 4pt)
+#set table.cell(stroke: blue)
+#set table.hline(stroke: red)
+#set table.hline(stroke: 0.75pt)
+#set table.vline(stroke: 0.75pt)
+#set table.vline(stroke: aqua)
+
+#table(
+ columns: 3,
+ gutter: 3pt,
+ inset: 5pt,
+ [a], [b], table.vline(position: end), [c],
+ [d], [e], [f],
+ table.hline(position: bottom),
+ [g], [h], [i],
+)
+
+---
+// Test stroke field on cell show rules
+#set grid.cell(stroke: (x: 4pt))
+#set grid.cell(stroke: (x: blue))
+#show grid.cell: it => {
+ test(it.stroke, (left: stroke(paint: blue, thickness: 4pt, dash: "loosely-dotted"), right: blue + 4pt, top: stroke(thickness: 1pt), bottom: none))
+ it
+}
+#grid(
+ stroke: (left: (dash: "loosely-dotted")),
+ inset: 5pt,
+ grid.hline(stroke: red),
+ grid.cell(stroke: (top: 1pt))[a], grid.vline(stroke: yellow),
+)
+
+---
+#table(
+ columns: 3,
+ [a], table.cell(colspan: 2)[b c],
+ table.cell(stroke: blue)[d], [e], [f],
+ [g], [h], table.cell(stroke: (left: yellow, top: green, right: aqua, bottom: red))[i],
+ [j], [k], [l],
+ table.cell(stroke: 3pt)[m], [n], table.cell(stroke: (dash: "loosely-dotted"))[o],
+)
+
+---
+// Test per-column stroke array
+#let t = table(
+ columns: 3,
+ stroke: (red, blue, green),
+ [a], [b], [c],
+ [d], [e], [f],
+ [h], [i], [j],
+)
+#t
+#set text(dir: rtl)
+#t
+
+---
+#grid(
+ columns: 3,
+ inset: 3pt,
+ stroke: (x, _) => (right: (5pt, (dash: "dotted")).at(calc.rem(x, 2)), bottom: (dash: "densely-dotted")),
+ grid.vline(x: 0, stroke: red),
+ grid.vline(x: 1, stroke: red),
+ grid.vline(x: 2, stroke: red),
+ grid.vline(x: 3, stroke: red),
+ grid.hline(y: 0, end: 1, stroke: blue),
+ grid.hline(y: 1, end: 1, stroke: blue),
+ grid.cell[a],
+ [b], [c]
+)
+
+---
+#set page(height: 5em)
+#table(
+ columns: 3,
+ inset: 3pt,
+ table.hline(y: 0, end: none, stroke: 3pt + blue),
+ table.vline(x: 0, end: none, stroke: 3pt + green),
+ table.hline(y: 5, end: none, stroke: 3pt + red),
+ table.vline(x: 3, end: none, stroke: 3pt + yellow),
+ [a], [b], [c],
+ [a], [b], [c],
+ [a], [b], [c],
+ [a], [b], [c],
+ [a], [b], [c],
+)
+
+---
+// Automatically positioned lines
+// Plus stroke thickness ordering
+#table(
+ columns: 3,
+ table.hline(stroke: red + 5pt),
+ table.vline(stroke: blue + 5pt),
+ table.vline(stroke: 2pt),
+ [a],
+ table.vline(x: 1, stroke: aqua + 5pt),
+ [b],
+ table.vline(stroke: aqua + 5pt),
+ [c],
+ table.vline(stroke: yellow + 5.2pt),
+ table.hline(stroke: green + 5pt),
+ [a], [b], [c],
+ [a], table.hline(stroke: green + 2pt), table.vline(stroke: 2pt), [b], [c],
+)
+
+---
+// Line specification order priority
+// The last line should be blue, not red.
+// The middle line should have disappeared.
+#grid(
+ columns: 2,
+ inset: 2pt,
+ grid.hline(y: 2, stroke: red + 5pt),
+ grid.vline(),
+ [a], [b],
+ grid.hline(stroke: red),
+ grid.hline(stroke: none),
+ [c], grid.cell(stroke: (top: aqua))[d],
+ grid.hline(stroke: blue),
+)
+
+---
+// Position: bottom and position: end with gutter should have a visible effect
+// of moving the lines after the next track.
+#table(
+ columns: 3,
+ gutter: 3pt,
+ stroke: blue,
+ table.hline(end: 2, stroke: red),
+ table.hline(end: 2, stroke: aqua, position: bottom),
+ table.vline(end: 2, stroke: green), [a], table.vline(end: 2, stroke: green), table.vline(end: 2, position: end, stroke: orange), [b], table.vline(end: 2, stroke: aqua, position: end), table.vline(end: 2, stroke: green), [c], table.vline(end: 2, stroke: green),
+ [d], [e], [f],
+ table.hline(end: 2, stroke: red),
+ [g], [h], [ie],
+ table.hline(end: 2, stroke: green),
+)
+
+---
+// Using position: bottom and position: end without gutter should be the same
+// as placing a line after the next track.
+#table(
+ columns: 3,
+ stroke: blue,
+ table.hline(end: 2, stroke: red),
+ table.hline(end: 2, stroke: aqua, position: bottom),
+ table.vline(end: 2, stroke: green), [a], table.vline(end: 2, stroke: green), [b], table.vline(end: 2, stroke: aqua, position: end), table.vline(end: 2, stroke: green), [c], table.vline(end: 2, stroke: green),
+ table.hline(end: 2, stroke: 5pt),
+ [d], [e], [f],
+ table.hline(end: 2, stroke: red),
+ [g], [h], [i],
+ table.hline(end: 2, stroke: red),
+)
+
+---
+// Test left and right for grid vlines.
+#grid(
+ columns: 3,
+ inset: 5pt,
+ grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a],
+ grid.vline(stroke: 2pt, position: left), grid.vline(stroke: red, position: right), [b],
+ grid.vline(stroke: 2pt, position: left), grid.vline(stroke: red, position: right), [c],
+ grid.vline(stroke: 2pt, position: left)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [b],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [c],
+ grid.vline(stroke: 2pt, position: left)
+)
+
+---
+// Test left and right for table vlines.
+#table(
+ columns: 3,
+ inset: 5pt,
+ table.vline(stroke: green, position: left), table.vline(stroke: red, position: right), [a],
+ table.vline(stroke: 2pt, position: left), table.vline(stroke: red, position: right), [b],
+ table.vline(stroke: 2pt, position: left), table.vline(stroke: red, position: right), [c],
+ table.vline(stroke: 2pt, position: left)
+)
+
+#table(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ table.vline(stroke: green, position: left), table.vline(stroke: red, position: right), [a],
+ table.vline(stroke: blue, position: left), table.vline(stroke: red, position: right), [b],
+ table.vline(stroke: blue, position: left), table.vline(stroke: red, position: right), [c],
+ table.vline(stroke: 2pt, position: left)
+)
+
+---
+// Hlines and vlines should always appear on top of cell strokes.
+#table(
+ columns: 3,
+ stroke: aqua,
+ table.vline(stroke: red, position: end), [a], table.vline(stroke: red), [b], [c],
+ table.cell(stroke: blue)[d], [e], [f],
+ table.hline(stroke: red),
+ [g], table.cell(stroke: blue)[h], [i],
+)
+
+#table(
+ columns: 3,
+ gutter: 3pt,
+ stroke: aqua,
+ table.vline(stroke: red, position: end), [a], table.vline(stroke: red), [b], [c],
+ table.cell(stroke: blue)[d], [e], [f],
+ table.hline(stroke: red),
+ [g], table.cell(stroke: blue)[h], [i],
+)
+
+---
+// Ensure cell stroke overrides always appear on top.
+#table(
+ columns: 2,
+ stroke: black,
+ table.cell(stroke: red)[a], [b],
+ [c], [d],
+)
+
+#table(
+ columns: 2,
+ table.cell(stroke: red)[a], [b],
+ [c], [d],
+)
+
+---
+// Error: 7:3-7:32 cannot place horizontal line at the 'bottom' position of the bottom border (y = 2)
+// Hint: 7:3-7:32 set the line's position to 'top' or place it at a smaller 'y' index
+#table(
+ columns: 2,
+ [a], [b],
+ [c], [d],
+ table.hline(stroke: aqua),
+ table.hline(position: top),
+ table.hline(position: bottom)
+)
+
+---
+// Error: 8:3-8:32 cannot place horizontal line at the 'bottom' position of the bottom border (y = 2)
+// Hint: 8:3-8:32 set the line's position to 'top' or place it at a smaller 'y' index
+#table(
+ columns: 2,
+ gutter: 3pt,
+ [a], [b],
+ [c], [d], table.vline(stroke: red),
+ table.hline(stroke: aqua),
+ table.hline(position: top),
+ table.hline(position: bottom)
+)
+
+---
+// Error: 6:3-6:28 cannot place vertical line at the 'end' position of the end border (x = 2)
+// Hint: 6:3-6:28 set the line's position to 'start' or place it at a smaller 'x' index
+#grid(
+ columns: 2,
+ [a], [b],
+ grid.vline(stroke: aqua),
+ grid.vline(position: start),
+ grid.vline(position: end)
+)
+
+---
+// Error: 7:3-7:28 cannot place vertical line at the 'end' position of the end border (x = 2)
+// Hint: 7:3-7:28 set the line's position to 'start' or place it at a smaller 'x' index
+#grid(
+ columns: 2,
+ gutter: 3pt,
+ [a], [b],
+ grid.vline(stroke: aqua),
+ grid.vline(position: start),
+ grid.vline(position: end)
+)
+
+---
+// Error: 4:3-4:19 cannot place horizontal line at invalid row 3
+#grid(
+ [a],
+ [b],
+ grid.hline(y: 3)
+)
+
+---
+// Error: 5:3-5:19 cannot place horizontal line at invalid row 3
+#grid(
+ gutter: 3pt,
+ [a],
+ [b],
+ grid.hline(y: 3)
+)
+
+---
+// Error: 4:3-4:20 cannot place vertical line at invalid column 3
+#table(
+ columns: 2,
+ [a], [b],
+ table.vline(x: 3)
+)
+
+---
+// Error: 5:3-5:20 cannot place vertical line at invalid column 3
+#table(
+ columns: 2,
+ gutter: 3pt,
+ [a], [b],
+ table.vline(x: 3)
+)
+
+---
+// Error: 3:3-3:31 line cannot end before it starts
+#grid(
+ columns: 3,
+ grid.hline(start: 2, end: 1),
+ [a], [b], [c],
+)
+
+---
+// Error: 3:3-3:32 line cannot end before it starts
+#table(
+ columns: 3,
+ table.vline(start: 2, end: 1),
+ [a], [b], [c],
+ [d], [e], [f],
+ [g], [h], [i],
+)
+
+---
+// Error: 24-31 expected `top` or `bottom`, found horizon
+#table.hline(position: horizon)
+
+---
+// Error: 24-30 expected `start`, `left`, `right`, or `end`, found center
+#table.vline(position: center)
+
+---
+// Error: 24-29 expected `top` or `bottom`, found right
+#table.hline(position: right)
+
+---
+// Error: 24-27 expected `start`, `left`, `right`, or `end`, found top
+#table.vline(position: top)