diff --git a/crates/typst/src/layout/grid.rs b/crates/typst/src/layout/grid.rs index 606aa4a7..bbe2ea21 100644 --- a/crates/typst/src/layout/grid.rs +++ b/crates/typst/src/layout/grid.rs @@ -2,18 +2,20 @@ use std::num::NonZeroUsize; use smallvec::{smallvec, SmallVec}; -use crate::diag::{bail, SourceResult, StrResult}; +use crate::diag::{bail, At, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ - cast, elem, Array, Content, NativeElement, Resolve, StyleChain, Value, + cast, elem, Array, CastInfo, Content, FromValue, Func, IntoValue, NativeElement, + Reflect, Resolve, Smart, StyleChain, Value, }; use crate::layout::{ - Abs, Axes, Dir, Fr, Fragment, Frame, Layout, Length, Point, Regions, Rel, Size, - Sizing, + Abs, Align, AlignElem, Axes, Dir, Fr, Fragment, Frame, FrameItem, Layout, Length, + Point, Regions, Rel, Sides, Size, Sizing, }; use crate::syntax::Span; use crate::text::TextElem; use crate::util::Numeric; +use crate::visualize::{FixedStroke, Geometry, Paint, Stroke}; /// Arranges content in a grid. /// @@ -118,6 +120,81 @@ pub struct GridElem { #[borrowed] pub row_gutter: TrackSizings, + /// How to fill the cells. + /// + /// This can be a color or a function that returns a color. The function is + /// passed the cells' column and row index, starting at zero. This can be + /// used to implement striped grids. + /// + /// ```example + /// #grid( + /// fill: (col, row) => if calc.even(col + row) { luma(240) } else { white }, + /// align: center + horizon, + /// columns: 4, + /// [X], [O], [X], [O], + /// [O], [X], [O], [X], + /// [X], [O], [X], [O], + /// [O], [X], [O], [X] + /// ) + /// ``` + #[borrowed] + pub fill: Celled>, + + /// How to align the cells' content. + /// + /// This can either be a single alignment, an array of alignments + /// (corresponding to each column) or a function that returns an alignment. + /// The function is passed the cells' column and row index, starting at zero. + /// If set to `{auto}`, the outer alignment is used. + /// + /// ```example + /// #grid( + /// columns: 3, + /// align: (x, y) => (left, center, right).at(x), + /// [Hello], [Hello], [Hello], + /// [A], [B], [C], + /// ) + /// ``` + #[borrowed] + pub align: Celled>, + + /// How to [stroke]($stroke) the cells. + /// + /// 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/). + #[resolve] + #[fold] + pub stroke: Option, + + /// How much to pad the cells' content. + /// + /// ```example + /// #grid( + /// inset: 10pt, + /// fill: (_, row) => (red, blue).at(row), + /// [Hello], + /// [World], + /// ) + /// + /// #grid( + /// columns: 2, + /// inset: ( + /// x: 20pt, + /// y: 10pt, + /// ), + /// fill: (col, _) => (red, blue).at(col), + /// [Hello], + /// [World], + /// ) + /// ``` + #[fold] + #[default(Sides::splat(Abs::pt(0.0).into()))] + pub inset: Sides>>, + /// The contents of the grid cells. /// /// The cells are populated in row-major order. @@ -133,16 +210,27 @@ impl Layout for GridElem { styles: StyleChain, regions: Regions, ) -> SourceResult { + let inset = self.inset(styles); + let align = self.align(styles); let columns = self.columns(styles); let rows = self.rows(styles); 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 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()); + let cells = + apply_align_inset_to_cells(engine, &tracks, &self.children, align, inset)?; // Prepare grid layout by unifying content and gutter tracks. let layouter = GridLayouter::new( - Axes::new(&columns.0, &rows.0), - Axes::new(&column_gutter.0, &row_gutter.0), - &self.children, + tracks, + gutter, + &cells, + fill, + &stroke, regions, styles, self.span(), @@ -153,6 +241,31 @@ impl Layout for GridElem { } } +pub fn apply_align_inset_to_cells( + engine: &mut Engine, + tracks: &Axes<&[Sizing]>, + cells: &[Content], + align: &Celled>, + inset: Sides>, +) -> SourceResult> { + let cols = tracks.x.len().max(1); + cells + .iter() + .enumerate() + .map(|(i, child)| { + let mut child = child.clone().padded(inset); + + let x = i % cols; + let y = i / cols; + if let Smart::Custom(alignment) = align.resolve(engine, x, y)? { + child = child.styled(AlignElem::set_alignment(alignment)); + } + + Ok(child) + }) + .collect() +} + /// Track sizing definitions. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] pub struct TrackSizings(pub SmallVec<[Sizing; 4]>); @@ -165,6 +278,75 @@ cast! { values: Array => Self(values.into_iter().map(Value::cast).collect::>()?), } +/// A value that can be configured per cell. +#[derive(Debug, Clone, PartialEq, Hash)] +pub enum Celled { + /// A bare value, the same for all cells. + Value(T), + /// A closure mapping from cell coordinates to a value. + Func(Func), + /// An array of alignment values corresponding to each column. + Array(Vec), +} + +impl Celled { + /// Resolve the value based on the cell position. + pub fn resolve(&self, engine: &mut Engine, x: usize, y: usize) -> SourceResult { + Ok(match self { + Self::Value(value) => value.clone(), + Self::Func(func) => func.call(engine, [x, y])?.cast().at(func.span())?, + Self::Array(array) => x + .checked_rem(array.len()) + .and_then(|i| array.get(i)) + .cloned() + .unwrap_or_default(), + }) + } +} + +impl Default for Celled { + fn default() -> Self { + Self::Value(T::default()) + } +} + +impl Reflect for Celled { + fn input() -> CastInfo { + T::input() + Array::input() + Func::input() + } + + fn output() -> CastInfo { + T::output() + Array::output() + Func::output() + } + + fn castable(value: &Value) -> bool { + Array::castable(value) || Func::castable(value) || T::castable(value) + } +} + +impl IntoValue for Celled { + fn into_value(self) -> Value { + match self { + Self::Value(value) => value.into_value(), + Self::Func(func) => func.into_value(), + Self::Array(arr) => arr.into_value(), + } + } +} + +impl FromValue for Celled { + fn from_value(value: Value) -> StrResult { + match value { + Value::Func(v) => Ok(Self::Func(v)), + Value::Array(array) => Ok(Self::Array( + array.into_iter().map(T::from_value).collect::>()?, + )), + v if T::castable(&v) => Ok(Self::Value(T::from_value(v)?)), + v => Err(Self::error(&v)), + } + } +} + /// Performs grid layout. pub struct GridLayouter<'a> { /// The grid cells. @@ -177,6 +359,12 @@ pub struct GridLayouter<'a> { cols: Vec, /// The row tracks including gutter tracks. rows: Vec, + // How to fill the cells. + #[allow(dead_code)] + fill: &'a Celled>, + // How to stroke the cells. + #[allow(dead_code)] + stroke: &'a Option, /// The regions to layout children into. regions: Regions<'a>, /// The inherited styles. @@ -230,10 +418,13 @@ 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( tracks: Axes<&[Sizing]>, gutter: Axes<&[Sizing]>, cells: &'a [Content], + fill: &'a Celled>, + stroke: &'a Option, regions: Regions<'a>, styles: StyleChain<'a>, span: Span, @@ -298,6 +489,8 @@ impl<'a> GridLayouter<'a> { is_rtl, has_gutter, rows, + fill, + stroke, regions, styles, rcols: vec![Abs::zero(); cols.len()], @@ -331,6 +524,10 @@ impl<'a> GridLayouter<'a> { self.finish_region(engine)?; + if self.stroke.is_some() || !matches!(self.fill, Celled::Value(None)) { + self.render_fills_strokes(engine)?; + } + Ok(GridLayout { fragment: Fragment::frames(self.finished), cols: self.rcols, @@ -338,6 +535,59 @@ impl<'a> GridLayouter<'a> { }) } + /// Add lines and backgrounds. + fn render_fills_strokes(&mut self, engine: &mut Engine) -> SourceResult<()> { + for (frame, rows) in self.finished.iter_mut().zip(&self.rrows) { + if self.rcols.is_empty() || rows.is_empty() { + 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), + FrameItem::Shape(hline, self.span), + ); + } + + // Render vertical lines. + for offset in points(self.rcols.iter().copied()) { + let target = Point::with_y(frame.height() + thickness); + let vline = Geometry::Line(target).stroked(stroke.clone()); + frame.prepend( + Point::new(offset, -half), + FrameItem::Shape(vline, self.span), + ); + } + } + + // Render cell backgrounds. + let mut dx = Abs::zero(); + for (x, &col) in self.rcols.iter().enumerate() { + let mut dy = Abs::zero(); + for row in rows { + if let Some(fill) = self.fill.resolve(engine, x, row.y)? { + let pos = Point::new(dx, dy); + let size = Size::new(col, row.height); + let rect = Geometry::Rect(size).filled(fill); + frame.prepend(pos, FrameItem::Shape(rect, self.span)); + } + dy += row.height; + } + dx += col; + } + } + + Ok(()) + } + /// Determine all column sizes. #[tracing::instrument(name = "GridLayouter::measure_columns", skip_all)] fn measure_columns(&mut self, engine: &mut Engine) -> SourceResult<()> { @@ -743,3 +993,13 @@ impl<'a> GridLayouter<'a> { } } } + +/// Turn an iterator of extents into an iterator of offsets before, in between, +/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm]. +fn points(extents: impl IntoIterator) -> impl Iterator { + let mut offset = Abs::zero(); + std::iter::once(Abs::zero()).chain(extents).map(move |extent| { + offset += extent; + offset + }) +} diff --git a/crates/typst/src/model/enum.rs b/crates/typst/src/model/enum.rs index 0d81b16a..1d37f89c 100644 --- a/crates/typst/src/model/enum.rs +++ b/crates/typst/src/model/enum.rs @@ -6,8 +6,8 @@ use crate::foundations::{ cast, elem, scope, Array, Content, Fold, NativeElement, Smart, StyleChain, }; use crate::layout::{ - Align, Axes, BlockElem, Em, Fragment, GridLayouter, HAlign, Layout, Length, Regions, - Sizing, Spacing, VAlign, + Align, Axes, BlockElem, Celled, Em, Fragment, GridLayouter, HAlign, Layout, Length, + Regions, Sizing, Spacing, VAlign, }; use crate::model::{Numbering, NumberingPattern, ParElem}; use crate::text::TextElem; @@ -266,6 +266,8 @@ impl Layout for EnumElem { number = number.saturating_add(1); } + let fill = Celled::Value(None); + let stroke = None; let layouter = GridLayouter::new( Axes::with_x(&[ Sizing::Rel(indent.into()), @@ -275,6 +277,8 @@ impl Layout for EnumElem { ]), Axes::with_y(&[gutter.into()]), &cells, + &fill, + &stroke, regions, styles, self.span(), diff --git a/crates/typst/src/model/list.rs b/crates/typst/src/model/list.rs index afbf9472..a0a2609c 100644 --- a/crates/typst/src/model/list.rs +++ b/crates/typst/src/model/list.rs @@ -5,8 +5,8 @@ use crate::foundations::{ Value, }; use crate::layout::{ - Axes, BlockElem, Em, Fragment, GridLayouter, HAlign, Layout, Length, Regions, Sizing, - Spacing, VAlign, + Axes, BlockElem, Celled, Em, Fragment, GridLayouter, HAlign, Layout, Length, Regions, + Sizing, Spacing, VAlign, }; use crate::model::ParElem; use crate::text::TextElem; @@ -166,6 +166,8 @@ impl Layout for ListElem { cells.push(item.body().clone().styled(Self::set_depth(Depth))); } + let fill = Celled::Value(None); + let stroke = None; let layouter = GridLayouter::new( Axes::with_x(&[ Sizing::Rel(indent.into()), @@ -175,6 +177,8 @@ impl Layout for ListElem { ]), Axes::with_y(&[gutter.into()]), &cells, + &fill, + &stroke, regions, styles, self.span(), diff --git a/crates/typst/src/model/table.rs b/crates/typst/src/model/table.rs index b1e938ad..5b7715ab 100644 --- a/crates/typst/src/model/table.rs +++ b/crates/typst/src/model/table.rs @@ -1,16 +1,13 @@ -use crate::diag::{At, SourceResult, StrResult}; +use crate::diag::SourceResult; use crate::engine::Engine; -use crate::foundations::{ - elem, Array, CastInfo, Content, FromValue, Func, IntoValue, NativeElement, Reflect, - Smart, StyleChain, Value, -}; +use crate::foundations::{elem, Content, NativeElement, Smart, StyleChain}; use crate::layout::{ - Abs, Align, AlignElem, Axes, Fragment, FrameItem, GridLayouter, Layout, Length, - Point, Regions, Rel, Sides, Size, TrackSizings, + apply_align_inset_to_cells, Abs, Align, Axes, Celled, Fragment, GridLayouter, Layout, + Length, Regions, Rel, Sides, TrackSizings, }; use crate::model::Figurable; use crate::text::{Lang, LocalName, Region}; -use crate::visualize::{Geometry, Paint, Stroke}; +use crate::visualize::{Paint, Stroke}; /// A table of items. /// @@ -169,166 +166,27 @@ impl Layout for TableElem { let rows = self.rows(styles); let column_gutter = self.column_gutter(styles); let row_gutter = self.row_gutter(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()); - let cols = tracks.x.len().max(1); - let cells: Vec<_> = self - .children() - .iter() - .enumerate() - .map(|(i, child)| { - let mut child = child.clone().padded(inset); - - let x = i % cols; - let y = i / cols; - if let Smart::Custom(alignment) = align.resolve(engine, x, y)? { - child = child.styled(AlignElem::set_alignment(alignment)); - } - - Ok(child) - }) - .collect::>()?; - let fill = self.fill(styles); let stroke = self.stroke(styles).map(Stroke::unwrap_or_default); + 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()); + let cells = + apply_align_inset_to_cells(engine, &tracks, self.children(), align, inset)?; + // Prepare grid layout by unifying content and gutter tracks. - let layouter = - GridLayouter::new(tracks, gutter, &cells, regions, styles, self.span()); + let layouter = GridLayouter::new( + tracks, + gutter, + &cells, + fill, + &stroke, + regions, + styles, + self.span(), + ); - // Measure the columns and layout the grid row-by-row. - let mut layout = layouter.layout(engine)?; - - // Add lines and backgrounds. - for (frame, rows) in layout.fragment.iter_mut().zip(&layout.rows) { - if layout.cols.is_empty() || rows.is_empty() { - continue; - } - - // Render table lines. - if let Some(stroke) = &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), - FrameItem::Shape(hline, self.span()), - ); - } - - // Render vertical lines. - for offset in points(layout.cols.iter().copied()) { - let target = Point::with_y(frame.height() + thickness); - let vline = Geometry::Line(target).stroked(stroke.clone()); - frame.prepend( - Point::new(offset, -half), - FrameItem::Shape(vline, self.span()), - ); - } - } - - // Render cell backgrounds. - let mut dx = Abs::zero(); - for (x, &col) in layout.cols.iter().enumerate() { - let mut dy = Abs::zero(); - for row in rows { - if let Some(fill) = fill.resolve(engine, x, row.y)? { - let pos = Point::new(dx, dy); - let size = Size::new(col, row.height); - let rect = Geometry::Rect(size).filled(fill); - frame.prepend(pos, FrameItem::Shape(rect, self.span())); - } - dy += row.height; - } - dx += col; - } - } - - Ok(layout.fragment) - } -} - -/// Turn an iterator of extents into an iterator of offsets before, in between, -/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm]. -fn points(extents: impl IntoIterator) -> impl Iterator { - let mut offset = Abs::zero(); - std::iter::once(Abs::zero()).chain(extents).map(move |extent| { - offset += extent; - offset - }) -} - -/// A value that can be configured per cell. -#[derive(Debug, Clone, PartialEq, Hash)] -pub enum Celled { - /// A bare value, the same for all cells. - Value(T), - /// A closure mapping from cell coordinates to a value. - Func(Func), - /// An array of alignment values corresponding to each column. - Array(Vec), -} - -impl Celled { - /// Resolve the value based on the cell position. - pub fn resolve(&self, engine: &mut Engine, x: usize, y: usize) -> SourceResult { - Ok(match self { - Self::Value(value) => value.clone(), - Self::Func(func) => func.call(engine, [x, y])?.cast().at(func.span())?, - Self::Array(array) => x - .checked_rem(array.len()) - .and_then(|i| array.get(i)) - .cloned() - .unwrap_or_default(), - }) - } -} - -impl Default for Celled { - fn default() -> Self { - Self::Value(T::default()) - } -} - -impl Reflect for Celled { - fn input() -> CastInfo { - T::input() + Array::input() + Func::input() - } - - fn output() -> CastInfo { - T::output() + Array::output() + Func::output() - } - - fn castable(value: &Value) -> bool { - Array::castable(value) || Func::castable(value) || T::castable(value) - } -} - -impl IntoValue for Celled { - fn into_value(self) -> Value { - match self { - Self::Value(value) => value.into_value(), - Self::Func(func) => func.into_value(), - Self::Array(arr) => arr.into_value(), - } - } -} - -impl FromValue for Celled { - fn from_value(value: Value) -> StrResult { - match value { - Value::Func(v) => Ok(Self::Func(v)), - Value::Array(array) => Ok(Self::Array( - array.into_iter().map(T::from_value).collect::>()?, - )), - v if T::castable(&v) => Ok(Self::Value(T::from_value(v)?)), - v => Err(Self::error(&v)), - } + Ok(layouter.layout(engine)?.fragment) } } diff --git a/tests/ref/layout/grid-styling.png b/tests/ref/layout/grid-styling.png new file mode 100644 index 00000000..ae5c0519 Binary files /dev/null and b/tests/ref/layout/grid-styling.png differ diff --git a/tests/typ/layout/grid-styling.typ b/tests/typ/layout/grid-styling.typ new file mode 100644 index 00000000..577e15c4 --- /dev/null +++ b/tests/typ/layout/grid-styling.typ @@ -0,0 +1,89 @@ +// Test grid styling options. + +--- +#set page(height: 70pt) +#set grid(fill: (x, y) => if calc.even(x + y) { rgb("aaa") }) + +#grid( + columns: (1fr,) * 3, + stroke: 2pt + rgb("333"), + [A], [B], [C], [], [], [D \ E \ F \ \ \ G], [H], +) + +--- +#grid(columns: 3, stroke: none, fill: green, [A], [B], [C]) + +--- +// Test general alignment. +#grid( + columns: 3, + align: left, + [Hello], [Hello], [Hello], + [A], [B], [C], +) + +// Test alignment with a function. +#grid( + columns: 3, + align: (x, y) => (left, center, right).at(x), + [Hello], [Hello], [Hello], + [A], [B], [C], +) + +// Test alignment with array. +#grid( + columns: (1fr, 1fr, 1fr), + align: (left, center, right), + [A], [B], [C] +) + +// Test empty array. +#set align(center) +#grid( + columns: (1fr, 1fr, 1fr), + align: (), + [A], [B], [C] +) + +a + +--- +// Test inset. +#grid( + columns: (1fr,) * 3, + stroke: 2pt + rgb("333"), + inset: 5pt, + [A], [B], [C], [], [], [D \ E \ F \ \ \ G], [H], +) + +#grid( + columns: 3, + inset: 10pt, + fill: blue, + [A], [B], [C] +) + +#grid( + columns: 3, + inset: (y: 10pt), + [A], [B], [C] +) + +#grid( + columns: 3, + inset: (left: 20pt, rest: 10pt), + stroke: 3pt + red, + [A], [B], [C] +) + +#grid( + columns: 2, + inset: ( + left: 20pt, + right: 5pt, + top: 10pt, + bottom: 3pt, + ), + [A], + [B], +)