From 20179bbe7a20d0599a17db93dcd9b0bfc9d78f8a Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 3 Mar 2025 19:59:41 -0300 Subject: [PATCH 01/82] add levels and ranges to headers and footers --- .../typst-library/src/layout/grid/resolve.rs | 64 +++++++++++++++---- crates/typst-library/src/model/table.rs | 4 +- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index bad25b474..d49b81e0e 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -426,8 +426,24 @@ pub struct Line { /// A repeatable grid header. Starts at the first row. #[derive(Debug)] pub struct Header { + /// The first row included in this header. + pub start: usize, /// The index after the last row included in this header. pub end: usize, + /// The header's level. + /// + /// Higher level headers repeat together with lower level headers. If a + /// lower level header stops repeating, all higher level headers do as + /// well. + pub level: u32, +} + +impl Header { + /// The header's range of included rows. + #[inline] + pub fn range(&self) -> Range { + self.start..self.end + } } /// A repeatable grid footer. Stops at the last row. @@ -435,6 +451,20 @@ pub struct Header { pub struct Footer { /// The first row included in this footer. pub start: usize, + /// The index after the last row included in this footer. + pub end: usize, + /// The footer's level. + /// + /// Used similarly to header level. + pub level: u32, +} + +impl Footer { + /// The footer's range of included rows. + #[inline] + pub fn range(&self) -> Range { + self.start..self.end + } } /// A possibly repeatable grid object. @@ -638,10 +668,10 @@ pub struct CellGrid<'a> { /// Gutter rows are not included. /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. pub hlines: Vec>, - /// The repeatable header of this grid. - pub header: Option>, /// The repeatable footer of this grid. pub footer: Option>, + /// The repeatable headers of this grid. + pub headers: Vec>, /// Whether this grid has gutters. pub has_gutter: bool, } @@ -717,8 +747,8 @@ impl<'a> CellGrid<'a> { entries, vlines, hlines, - header, footer, + headers: header.into_iter().collect(), has_gutter, } } @@ -852,6 +882,11 @@ impl<'a> CellGrid<'a> { self.cols.len() } } + + #[inline] + pub fn has_repeated_headers(&self) -> bool { + self.headers.iter().any(|h| matches!(h, Repeatable::Repeated(_))) + } } /// Resolves and positions all cells in the grid before creating it. @@ -1492,11 +1527,15 @@ impl<'x> CellGridResolver<'_, '_, 'x> { } *header = Some(Header { + start: group_range.start, + // Later on, we have to correct this number in case there // is gutter. But only once all cells have been analyzed // and the header has fully expanded in the fixup loop // below. end: group_range.end, + + level: 1, }); } @@ -1514,6 +1553,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // before the footer might not be included as part of // the footer if it is contained within the header. start: group_range.start, + end: group_range.end, + level: 1, }, )); } @@ -1940,8 +1981,12 @@ fn check_for_conflicting_cell_row( rowspan: usize, ) -> HintedStrResult<()> { if let Some(header) = header { - // TODO: check start (right now zero, always satisfied) - if cell_y < header.end { + // NOTE: y + rowspan >, not >=, header.start, to check if the rowspan + // enters the header. For example, consider a rowspan of 1: if + // `y + 1 = header.start` holds, that means `y < header.start`, and it + // only occupies one row (`y`), so the cell is actually not in + // conflict. + if cell_y < header.end && cell_y + rowspan > header.start { bail!( "cell would conflict with header spanning the same position"; hint: "try moving the cell or the header" @@ -1949,13 +1994,8 @@ fn check_for_conflicting_cell_row( } } - if let Some((footer_end, _, footer)) = footer { - // NOTE: y + rowspan >, not >=, footer.start, to check if the rowspan - // enters the footer. For example, consider a rowspan of 1: if - // `y + 1 = footer.start` holds, that means `y < footer.start`, and it - // only occupies one row (`y`), so the cell is actually not in - // conflict. - if cell_y < *footer_end && cell_y + rowspan > footer.start { + if let Some((_, _, footer)) = footer { + if cell_y < footer.end && cell_y + rowspan > footer.start { bail!( "cell would conflict with footer spanning the same position"; hint: "try reducing the cell's rowspan or moving the footer" diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 6f4461bd4..921fe5079 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -296,7 +296,9 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content { let rows = rows.drain(ft.unwrap().start..); elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) }); - let header = grid.header.map(|hd| { + // TODO: Headers and footers in arbitrary positions + // Right now, only those at either end are accepted + let header = grid.headers.first().filter(|h| h.unwrap().start == 0).map(|hd| { let rows = rows.drain(..hd.unwrap().end); elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row)))) }); From 4bd3abf44d3bab8c92c0125d35d2ae92ed277a7e Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 3 Mar 2025 19:59:41 -0300 Subject: [PATCH 02/82] initial multi heading progress pending headers change --- crates/typst-layout/src/grid/layouter.rs | 61 ++++++++--- crates/typst-layout/src/grid/repeated.rs | 132 ++++++++++++++++++++--- crates/typst-layout/src/grid/rowspans.rs | 3 + 3 files changed, 170 insertions(+), 26 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index dc9e2238d..1832c54fa 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -3,7 +3,9 @@ use std::fmt::Debug; use typst_library::diag::{bail, SourceResult}; use typst_library::engine::Engine; use typst_library::foundations::{Resolve, StyleChain}; -use typst_library::layout::grid::resolve::{Cell, CellGrid, LinePosition, Repeatable}; +use typst_library::layout::grid::resolve::{ + Cell, CellGrid, Header, LinePosition, Repeatable, +}; use typst_library::layout::{ Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel, Size, Sizing, @@ -47,6 +49,18 @@ pub struct GridLayouter<'a> { pub(super) finished: Vec, /// Whether this is an RTL grid. pub(super) is_rtl: bool, + /// Currently repeating headers, one per level. + /// Sorted by increasing levels. + /// + /// Note that some levels may be absent, in particular level 0, which does + /// not exist (so the first level is >= 1). + pub(super) repeating_headers: Vec<&'a Header>, + /// End of sequence of consecutive compatible headers found so far. + /// This is one position after the last index in `upcoming_headers`, so `0` + /// indicates no pending headers. + /// Sorted by increasing levels. + pub(super) pending_header_end: usize, + pub(super) upcoming_headers: &'a [Repeatable
], /// The simulated header height. /// This field is reset in `layout_header` and properly updated by /// `layout_auto_row` and `layout_relative_row`, and should not be read @@ -120,6 +134,7 @@ impl<'a> GridLayouter<'a> { initial: regions.size, finished: vec![], is_rtl: TextElem::dir_in(styles) == Dir::RTL, + upcoming_headers: &grid.headers, header_height: Abs::zero(), footer_height: Abs::zero(), span, @@ -140,15 +155,31 @@ impl<'a> GridLayouter<'a> { } } - for y in 0..self.grid.rows.len() { - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - if y < header.end { - if y == 0 { - self.layout_header(header, engine, 0)?; - self.regions.size.y -= self.footer_height; + let mut y = 0; + while y < self.grid.rows.len() { + if let Some(first_header) = self.upcoming_headers.first() { + if first_header.unwrap().range().contains(&y) { + self.bump_pending_headers(); + + if self.peek_upcoming_header().is_none_or(|h| { + h.unwrap().start > y + 1 + || h.unwrap().level <= first_header.unwrap().level + }) { + // Next row either isn't a header. or is in a + // conflicting one, which is the sign that we need to go. + self.layout_headers(next_header, engine, 0)?; } + y = first_header.end; // Skip header rows during normal layout. continue; + + self.bump_repeating_headers(); + if let Repeatable::Repeated(next_header) = first_header { + if y == next_header.start { + self.layout_headers(next_header, engine, 0)?; + self.regions.size.y -= self.footer_height; + } + } } } @@ -162,6 +193,8 @@ impl<'a> GridLayouter<'a> { } self.layout_row(y, engine, 0)?; + + y += 1; } self.finish_region(engine, true)?; @@ -953,7 +986,9 @@ impl<'a> GridLayouter<'a> { .and_then(Repeatable::as_repeated) .is_some_and(|header| y < header.end) { - // Add to header height. + // Add to header height, as we are in a header row. + // TODO: Should we only bump from upcoming_headers to + // repeating_headers AFTER the header height calculation? self.header_height += first; } @@ -1370,15 +1405,15 @@ impl<'a> GridLayouter<'a> { .and_then(Repeatable::as_repeated) .is_some_and(|footer| footer.start != 0); - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - if self.grid.rows.len() > header.end + if let Some(last_header) = self.repeating_headers.last() { + if self.grid.rows.len() > last_header.end && self .grid .footer .as_ref() .and_then(Repeatable::as_repeated) .is_none_or(|footer| footer.start != header.end) - && self.lrows.last().is_some_and(|row| row.index() < header.end) + && self.lrows.last().is_some_and(|row| row.index() < last_header.end) && !in_last_with_offset( self.regions, self.header_height + self.footer_height, @@ -1535,9 +1570,9 @@ impl<'a> GridLayouter<'a> { self.prepare_footer(footer, engine, disambiguator)?; } - if let Some(Repeatable::Repeated(header)) = &self.grid.header { + if !self.repeating_headers.is_empty() { // Add a header to the new region. - self.layout_header(header, engine, disambiguator)?; + self.layout_headers(engine, disambiguator)?; } // Ensure rows don't try to overrun the footer. diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 22d2a09ef..fc364aad0 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -1,3 +1,5 @@ +use std::ops::ControlFlow; + use typst_library::diag::SourceResult; use typst_library::engine::Engine; use typst_library::layout::grid::resolve::{Footer, Header, Repeatable}; @@ -6,20 +8,100 @@ use typst_library::layout::{Abs, Axes, Frame, Regions}; use super::layouter::GridLayouter; use super::rowspans::UnbreakableRowGroup; -impl GridLayouter<'_> { - /// Layouts the header's rows. - /// Skips regions as necessary. - pub fn layout_header( +impl<'a> GridLayouter<'a> { + #[inline] + fn pending_headers(&self) -> &'a [Repeatable
] { + &self.upcoming_headers[..self.pending_header_end] + } + + #[inline] + pub fn bump_pending_headers(&mut self) { + debug_assert!(!self.upcoming_headers.is_empty()); + self.pending_header_end += 1; + } + + #[inline] + pub fn peek_upcoming_header(&self) -> Option<&'a Repeatable
> { + self.upcoming_headers.get(self.pending_header_end) + } + + pub fn flush_pending_headers(&mut self) { + debug_assert!(!self.upcoming_headers.is_empty()); + debug_assert!(self.pending_header_end > 0); + let headers = self.pending_headers(); + + let [first_header, ..] = headers else { + return; + }; + + self.repeating_headers.truncate( + self.repeating_headers + .partition_point(|h| h.level < first_header.unwrap().level), + ); + + for header in self.pending_headers() { + if let Repeatable::Repeated(header) = header { + // Vector remains sorted by increasing levels: + // - It was sorted before, so the truncation above only keeps + // elements with a lower level. + // - Therefore, by pushing this header to the end, it will have + // a level larger than all the previous headers, and is thus + // in its 'correct' position. + self.repeating_headers.push(header); + } + } + + self.upcoming_headers = self + .upcoming_headers + .get(self.pending_header_end..) + .unwrap_or_default(); + + self.pending_header_end = 0; + } + + pub fn bump_repeating_headers(&mut self) { + debug_assert!(!self.upcoming_headers.is_empty()); + + let [next_header, ..] = self.upcoming_headers else { + return; + }; + + // Keep only lower level headers. Assume sorted by increasing levels. + self.repeating_headers.truncate( + self.repeating_headers + .partition_point(|h| h.level < next_header.unwrap().level), + ); + + if let Repeatable::Repeated(next_header) = next_header { + // Vector remains sorted by increasing levels: + // - It was sorted before, so the truncation above only keeps + // elements with a lower level. + // - Therefore, by pushing this header to the end, it will have + // a level larger than all the previous headers, and is thus + // in its 'correct' position. + self.repeating_headers.push(next_header); + } + + // Laying out the next header now. + self.upcoming_headers = self.upcoming_headers.get(1..).unwrap_or_default(); + } + + /// Layouts the headers' rows. + /// + /// Assumes the footer height for the current region has already been + /// calculated. Skips regions as necessary to fit all headers and all + /// footers. + pub fn layout_headers( &mut self, - header: &Header, + headers: &[&Header], engine: &mut Engine, disambiguator: usize, ) -> SourceResult<()> { - let header_rows = - self.simulate_header(header, &self.regions, engine, disambiguator)?; + let header_height = + self.simulate_header_height(&self.regions, engine, disambiguator)?; let mut skipped_region = false; while self.unbreakable_rows_left == 0 - && !self.regions.size.y.fits(header_rows.height + self.footer_height) + && !self.regions.size.y.fits(header_height + self.footer_height) && self.regions.may_progress() { // Advance regions without any output until we can place the @@ -42,16 +124,34 @@ impl GridLayouter<'_> { } } - // Header is unbreakable. + // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - self.unbreakable_rows_left += header.end; - for y in 0..header.end { - self.layout_row(y, engine, disambiguator)?; + self.unbreakable_rows_left += total_header_row_count(headers); + for header in headers { + for y in header.range() { + self.layout_row(y, engine, disambiguator)?; + } } Ok(()) } + /// Calculates the total expected height of several headers. + pub fn simulate_header_height( + &self, + headers: &[&Header], + regions: &Regions<'_>, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult { + let mut height = Abs::zero(); + for header in headers { + height += + self.simulate_header(header, regions, engine, disambiguator)?.height; + } + Ok(height) + } + /// Simulate the header's group of rows. pub fn simulate_header( &self, @@ -66,7 +166,7 @@ impl GridLayouter<'_> { // assume that the amount of unbreakable rows following the first row // in the header will be precisely the rows in the header. self.simulate_unbreakable_row_group( - 0, + header.start, Some(header.end), regions, engine, @@ -151,3 +251,9 @@ impl GridLayouter<'_> { ) } } + +/// The total amount of rows in the given list of headers. +#[inline] +pub fn total_header_row_count(headers: &[&Header]) -> usize { + headers.iter().map(|h| h.end - h.start).sum() +} diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 21992ed02..a99d38fa8 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -160,6 +160,9 @@ impl GridLayouter<'_> { // at a position after the sum of the laid out header // rows). if let Some(Repeatable::Repeated(header)) = &self.grid.header { + // TODO: Need a way to distinguish header 'rrows' for each + // region, as this calculation - i.e., header height at + // each region - will change depending on'i'. let header_rows = self .rrows .get(i) From b420588c1910d56aa2386f8bd26cf2939efd1e79 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 3 Mar 2025 19:59:41 -0300 Subject: [PATCH 03/82] more header changes --- crates/typst-layout/src/grid/layouter.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 1832c54fa..e55a2f716 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -134,7 +134,9 @@ impl<'a> GridLayouter<'a> { initial: regions.size, finished: vec![], is_rtl: TextElem::dir_in(styles) == Dir::RTL, + repeating_headers: vec![], upcoming_headers: &grid.headers, + pending_header_end: 0, header_height: Abs::zero(), footer_height: Abs::zero(), span, @@ -149,8 +151,12 @@ impl<'a> GridLayouter<'a> { // Ensure rows in the first region will be aware of the possible // presence of the footer. self.prepare_footer(footer, engine, 0)?; - if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) { - // No repeatable header, so we won't subtract it later. + if !matches!( + self.grid.headers.first(), + Some(Repeatable::Repeated(Header { start: 0, .. })) + ) { + // No repeatable header at the very beginning, so we won't + // subtract it later. self.regions.size.y -= self.footer_height; } } From 6029e6f3bb5823f7fa89480d2e5fdc9e5dc618e9 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 4 Apr 2025 00:58:49 -0300 Subject: [PATCH 04/82] begin placing new headers Considerations: - Need to change layout headers algorithm to 1. Place those headers 2. But in a new region, also place other repeating headers 3. Keep footer height up-to-date without much intervention --- crates/typst-layout/src/grid/layouter.rs | 43 ++++++------- crates/typst-layout/src/grid/repeated.rs | 77 ++++++++++++++++++++---- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index e55a2f716..c2e66badb 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -55,11 +55,9 @@ pub struct GridLayouter<'a> { /// Note that some levels may be absent, in particular level 0, which does /// not exist (so the first level is >= 1). pub(super) repeating_headers: Vec<&'a Header>, - /// End of sequence of consecutive compatible headers found so far. - /// This is one position after the last index in `upcoming_headers`, so `0` - /// indicates no pending headers. + /// Headers, repeating or not, awaiting their first successful layout. /// Sorted by increasing levels. - pub(super) pending_header_end: usize, + pub(super) pending_headers: &'a [Repeatable
], pub(super) upcoming_headers: &'a [Repeatable
], /// The simulated header height. /// This field is reset in `layout_header` and properly updated by @@ -136,7 +134,7 @@ impl<'a> GridLayouter<'a> { is_rtl: TextElem::dir_in(styles) == Dir::RTL, repeating_headers: vec![], upcoming_headers: &grid.headers, - pending_header_end: 0, + pending_headers: Default::default(), header_height: Abs::zero(), footer_height: Abs::zero(), span, @@ -151,31 +149,34 @@ impl<'a> GridLayouter<'a> { // Ensure rows in the first region will be aware of the possible // presence of the footer. self.prepare_footer(footer, engine, 0)?; - if !matches!( - self.grid.headers.first(), - Some(Repeatable::Repeated(Header { start: 0, .. })) - ) { - // No repeatable header at the very beginning, so we won't - // subtract it later. - self.regions.size.y -= self.footer_height; - } + self.regions.size.y -= self.footer_height; } let mut y = 0; + let mut consecutive_header_count = 0; while y < self.grid.rows.len() { - if let Some(first_header) = self.upcoming_headers.first() { + if let Some(first_header) = + self.upcoming_headers.get(consecutive_header_count) + { if first_header.unwrap().range().contains(&y) { - self.bump_pending_headers(); + consecutive_header_count += 1; - if self.peek_upcoming_header().is_none_or(|h| { - h.unwrap().start > y + 1 - || h.unwrap().level <= first_header.unwrap().level - }) { + if self.upcoming_headers.get(consecutive_header_count).is_none_or( + |h| { + h.unwrap().start > y + 1 + || h.unwrap().level <= first_header.unwrap().level + }, + ) { // Next row either isn't a header. or is in a // conflicting one, which is the sign that we need to go. - self.layout_headers(next_header, engine, 0)?; + self.place_new_headers( + first_header, + consecutive_header_count, + engine, + ); + consecutive_header_count = 0; } - y = first_header.end; + y = first_header.unwrap().end; // Skip header rows during normal layout. continue; diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index fc364aad0..550a18d68 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -1,4 +1,3 @@ -use std::ops::ControlFlow; use typst_library::diag::SourceResult; use typst_library::engine::Engine; @@ -9,20 +8,74 @@ use super::layouter::GridLayouter; use super::rowspans::UnbreakableRowGroup; impl<'a> GridLayouter<'a> { - #[inline] - fn pending_headers(&self) -> &'a [Repeatable
] { - &self.upcoming_headers[..self.pending_header_end] + pub fn place_new_headers( + &mut self, + first_header: &Repeatable
, + consecutive_header_count: usize, + engine: &mut Engine, + ) { + // Next row either isn't a header. or is in a + // conflicting one, which is the sign that we need to go. + let (consecutive_headers, new_upcoming_headers) = + self.upcoming_headers.split_at(consecutive_header_count); + self.upcoming_headers = new_upcoming_headers; + + let (non_conflicting_headers, conflicting_headers) = match self + .upcoming_headers + .get(consecutive_header_count) + .map(Repeatable::unwrap) + { + Some(next_header) if next_header.level <= first_header.unwrap().level => { + // All immediately conflicting headers will + // be placed as normal rows. + consecutive_headers.split_at( + consecutive_headers + .partition_point(|h| next_header.level > h.unwrap().level), + ) + } + _ => (consecutive_headers, Default::default()), + }; + + self.layout_new_pending_headers(non_conflicting_headers, engine); + + self.layout_headers(non_conflicting_headers, engine, 0)?; + for conflicting_header in conflicting_headers { + self.simulate(); + self.layout_headers(headers, engine, disambiguator) + } } - #[inline] - pub fn bump_pending_headers(&mut self) { - debug_assert!(!self.upcoming_headers.is_empty()); - self.pending_header_end += 1; - } + /// Queues new pending headers for layout. Headers remain pending until + /// they are successfully laid out in some page once. Then, they will be + /// moved to `repeating_headers`, at which point it is safe to stop them + /// from repeating at any time. + fn layout_new_pending_headers( + &mut self, + headers: &'a [Repeatable
], + engine: &mut Engine, + ) { + let [first_header, ..] = headers else { + return; + }; + // Assuming non-conflicting headers sorted by increasing y, this must + // be the header with the lowest level (sorted by increasing levels). + let first_level = first_header.unwrap().level; - #[inline] - pub fn peek_upcoming_header(&self) -> Option<&'a Repeatable
> { - self.upcoming_headers.get(self.pending_header_end) + // Stop repeating conflicting headers. + // If we go to a new region before the pending headers fit alongside + // their children, the old headers should not be displayed anymore. + self.repeating_headers + .truncate(self.repeating_headers.partition_point(|h| h.level < first_level)); + + // Let's try to place them at least once. + // This might be a waste as we could generate an orphan and thus have + // to try to place old and new headers all over again, but that happens + // for every new region anyway, so it's rather unavoidable. + self.layout_headers(headers.iter().map(Repeatable::unwrap), true, engine); + + // After the first subsequent row is laid out, move to repeating, as + // it's then confirmed the headers won't be moved due to orphan + // prevention anymore. } pub fn flush_pending_headers(&mut self) { From 0143d0775b9d51a9b19195d57d91fb2b2896b2f0 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 4 Apr 2025 00:58:49 -0300 Subject: [PATCH 05/82] begin refactoring layout_headers - May have to include ALL headers when skipping a region... - Approach too naive --- crates/typst-layout/src/grid/repeated.rs | 85 +++++++++++++++++++----- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 550a18d68..2ff6198dc 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -1,3 +1,4 @@ +use std::ops::Deref; use typst_library::diag::SourceResult; use typst_library::engine::Engine; @@ -146,12 +147,26 @@ impl<'a> GridLayouter<'a> { /// footers. pub fn layout_headers( &mut self, - headers: &[&Header], + headers: impl Clone + IntoIterator, + include_repeating: bool, engine: &mut Engine, - disambiguator: usize, ) -> SourceResult<()> { - let header_height = - self.simulate_header_height(&self.regions, engine, disambiguator)?; + // Generate different locations for content in headers across its + // repetitions by assigning a unique number for each one. + let disambiguator = self.finished.len(); + // At first, only consider the height of the given headers. However, + // for upcoming regions, we will have to consider repeating headers as + // well. + let mut header_height = self.simulate_header_height( + headers.clone(), + &self.regions, + engine, + disambiguator, + )?; + + // We already take the footer into account below. + // While skipping regions, footer height won't be automatically + // re-calculated until the end. let mut skipped_region = false; while self.unbreakable_rows_left == 0 && !self.regions.size.y.fits(header_height + self.footer_height) @@ -161,26 +176,58 @@ impl<'a> GridLayouter<'a> { // header and the footer. self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); skipped_region = true; + + header_height = if include_repeating { + // Laying out pending headers, so we have to consider the + // combined height of already repeating headers as well. + self.simulate_header_height( + self.repeating_headers.iter().map(|h| *h).chain(headers.clone()), + &self.regions, + engine, + disambiguator, + )? + } else { + self.simulate_header_height( + headers.clone(), + &self.regions, + engine, + disambiguator, + )? + }; + + // Simulate the footer again; the region's 'full' might have + // changed. + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + self.footer_height = self + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height; + } + + // Ensure we also take the footer into account for remaining space. + self.regions.size.y -= self.footer_height; } // Reset the header height for this region. // It will be re-calculated when laying out each header row. self.header_height = Abs::zero(); - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - if skipped_region { - // Simulate the footer again; the region's 'full' might have - // changed. - self.footer_height = self - .simulate_footer(footer, &self.regions, engine, disambiguator)? - .height; - } - } + let trivial_vector = vec![]; + let repeating_header_prefix = + if include_repeating { &self.repeating_headers } else { &trivial_vector }; // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - self.unbreakable_rows_left += total_header_row_count(headers); + self.unbreakable_rows_left += + total_header_row_count(repeating_header_prefix.iter().map(Deref::deref)) + + total_header_row_count(headers.clone()); + let mut i = 0; + while let Some(header) = repeating_header_prefix.get(i) { + for y in header.range() { + self.layout_row(y, engine, disambiguator)?; + } + i += 1; + } for header in headers { for y in header.range() { self.layout_row(y, engine, disambiguator)?; @@ -190,9 +237,9 @@ impl<'a> GridLayouter<'a> { } /// Calculates the total expected height of several headers. - pub fn simulate_header_height( + pub fn simulate_header_height<'h: 'a>( &self, - headers: &[&Header], + headers: impl IntoIterator, regions: &Regions<'_>, engine: &mut Engine, disambiguator: usize, @@ -307,6 +354,8 @@ impl<'a> GridLayouter<'a> { /// The total amount of rows in the given list of headers. #[inline] -pub fn total_header_row_count(headers: &[&Header]) -> usize { - headers.iter().map(|h| h.end - h.start).sum() +pub fn total_header_row_count<'h>( + headers: impl IntoIterator, +) -> usize { + headers.into_iter().map(|h| h.end - h.start).sum() } From af35e287aff761008073b2574bcdeae578b9a90d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:26:11 -0300 Subject: [PATCH 06/82] finish layout_headers refactor - Subtract footer height in it already - Still need to fix finish region --- crates/typst-layout/src/grid/layouter.rs | 10 +-- crates/typst-layout/src/grid/repeated.rs | 92 ++++++++++++++---------- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index c2e66badb..6a1eb9614 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -173,20 +173,12 @@ impl<'a> GridLayouter<'a> { first_header, consecutive_header_count, engine, - ); + )?; consecutive_header_count = 0; } y = first_header.unwrap().end; // Skip header rows during normal layout. continue; - - self.bump_repeating_headers(); - if let Repeatable::Repeated(next_header) = first_header { - if y == next_header.start { - self.layout_headers(next_header, engine, 0)?; - self.regions.size.y -= self.footer_height; - } - } } } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 2ff6198dc..9e9e69d8d 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -14,7 +14,7 @@ impl<'a> GridLayouter<'a> { first_header: &Repeatable
, consecutive_header_count: usize, engine: &mut Engine, - ) { + ) -> SourceResult<()> { // Next row either isn't a header. or is in a // conflicting one, which is the sign that we need to go. let (consecutive_headers, new_upcoming_headers) = @@ -39,11 +39,20 @@ impl<'a> GridLayouter<'a> { self.layout_new_pending_headers(non_conflicting_headers, engine); - self.layout_headers(non_conflicting_headers, engine, 0)?; + self.layout_headers( + non_conflicting_headers.into_iter().map(Repeatable::unwrap), + true, + engine, + )?; for conflicting_header in conflicting_headers { - self.simulate(); - self.layout_headers(headers, engine, disambiguator) + self.layout_headers( + std::iter::once(conflicting_header.unwrap()), + true, + engine, + )? } + + Ok(()) } /// Queues new pending headers for layout. Headers remain pending until @@ -169,40 +178,29 @@ impl<'a> GridLayouter<'a> { // re-calculated until the end. let mut skipped_region = false; while self.unbreakable_rows_left == 0 - && !self.regions.size.y.fits(header_height + self.footer_height) + && !self.regions.size.y.fits(header_height) && self.regions.may_progress() { // Advance regions without any output until we can place the // header and the footer. self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); - skipped_region = true; - header_height = if include_repeating { - // Laying out pending headers, so we have to consider the - // combined height of already repeating headers as well. - self.simulate_header_height( - self.repeating_headers.iter().map(|h| *h).chain(headers.clone()), - &self.regions, - engine, - disambiguator, - )? - } else { - self.simulate_header_height( - headers.clone(), - &self.regions, - engine, - disambiguator, - )? - }; - - // Simulate the footer again; the region's 'full' might have - // changed. - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - self.footer_height = self - .simulate_footer(footer, &self.regions, engine, disambiguator)? - .height; + // TODO: re-calculate heights of headers and footers on each region + // if 'full'changes? (Assuming height doesn't change for now...) + if include_repeating && !skipped_region { + header_height = + // Laying out pending headers, so we have to consider the + // combined height of already repeating headers as well. + self.simulate_header_height( + self.repeating_headers.iter().map(|h| *h).chain(headers.clone()), + &self.regions, + engine, + disambiguator, + )?; } + skipped_region = true; + // Ensure we also take the footer into account for remaining space. self.regions.size.y -= self.footer_height; } @@ -211,23 +209,43 @@ impl<'a> GridLayouter<'a> { // It will be re-calculated when laying out each header row. self.header_height = Abs::zero(); - let trivial_vector = vec![]; - let repeating_header_prefix = - if include_repeating { &self.repeating_headers } else { &trivial_vector }; + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + if skipped_region { + // Simulate the footer again; the region's 'full' might have + // changed. + // TODO: maybe this should go in the loop, a bit hacky as is... + self.regions.size.y += self.footer_height; + self.footer_height = self + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height; + self.regions.size.y -= self.footer_height; + } + } // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - self.unbreakable_rows_left += - total_header_row_count(repeating_header_prefix.iter().map(Deref::deref)) - + total_header_row_count(headers.clone()); + self.unbreakable_rows_left += total_header_row_count(headers.clone()); + + // Need to relayout ALL headers if we skip a region, not only the + // provided headers. + // TODO: maybe extract this into a function to share code with multiple + // footers. + if include_repeating && skipped_region { + self.unbreakable_rows_left += + total_header_row_count(self.repeating_headers.iter().map(Deref::deref)); + } + + // Use indices to avoid double borrow. We don't mutate headers in + // 'layout_row' so this is fine. let mut i = 0; - while let Some(header) = repeating_header_prefix.get(i) { + while let Some(&header) = self.repeating_headers.get(i) { for y in header.range() { self.layout_row(y, engine, disambiguator)?; } i += 1; } + for header in headers { for y in header.range() { self.layout_row(y, engine, disambiguator)?; From 63055c8c83d05f04fe24a6a202345cd395fc39ac Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 4 Apr 2025 21:22:12 -0300 Subject: [PATCH 07/82] use headers to layout enum on layout_headers - This will allow us to tell layout_headers to just layout headers that are already repeating, plus pending headers which are waiting for layout. --- crates/typst-layout/src/grid/repeated.rs | 111 +++++++++++++++-------- 1 file changed, 75 insertions(+), 36 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 9e9e69d8d..9968234c4 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -8,6 +8,11 @@ use typst_library::layout::{Abs, Axes, Frame, Regions}; use super::layouter::GridLayouter; use super::rowspans::UnbreakableRowGroup; +pub enum HeadersToLayout<'a> { + RepeatingAndPending, + NewHeaders(&'a [Repeatable
]), +} + impl<'a> GridLayouter<'a> { pub fn place_new_headers( &mut self, @@ -39,15 +44,16 @@ impl<'a> GridLayouter<'a> { self.layout_new_pending_headers(non_conflicting_headers, engine); - self.layout_headers( - non_conflicting_headers.into_iter().map(Repeatable::unwrap), - true, - engine, - )?; - for conflicting_header in conflicting_headers { + // Layout each conflicting header independently, without orphan + // prevention (as they don't go into 'pending_headers'). + // These headers are short-lived as they are immediately followed by a + // header of the same or lower level, such that they never actually get + // to repeat. + for conflicting_header in conflicting_headers.chunks_exact(1) { self.layout_headers( - std::iter::once(conflicting_header.unwrap()), - true, + // Using 'chunks_exact", we pass a slice of length one instead + // of a reference for type consistency. + HeadersToLayout::NewHeaders(conflicting_header), engine, )? } @@ -81,11 +87,12 @@ impl<'a> GridLayouter<'a> { // This might be a waste as we could generate an orphan and thus have // to try to place old and new headers all over again, but that happens // for every new region anyway, so it's rather unavoidable. - self.layout_headers(headers.iter().map(Repeatable::unwrap), true, engine); + self.layout_headers(HeadersToLayout::NewHeaders(headers), engine); // After the first subsequent row is laid out, move to repeating, as // it's then confirmed the headers won't be moved due to orphan // prevention anymore. + self.pending_headers = headers; } pub fn flush_pending_headers(&mut self) { @@ -156,22 +163,33 @@ impl<'a> GridLayouter<'a> { /// footers. pub fn layout_headers( &mut self, - headers: impl Clone + IntoIterator, - include_repeating: bool, + headers: HeadersToLayout<'a>, engine: &mut Engine, ) -> SourceResult<()> { // Generate different locations for content in headers across its // repetitions by assigning a unique number for each one. let disambiguator = self.finished.len(); + // At first, only consider the height of the given headers. However, // for upcoming regions, we will have to consider repeating headers as // well. - let mut header_height = self.simulate_header_height( - headers.clone(), - &self.regions, - engine, - disambiguator, - )?; + let mut header_height = match headers { + HeadersToLayout::RepeatingAndPending => self.simulate_header_height( + self.repeating_headers + .iter() + .map(Deref::deref) + .chain(self.pending_headers.into_iter().map(Repeatable::unwrap)), + &self.regions, + engine, + disambiguator, + )?, + HeadersToLayout::NewHeaders(headers) => self.simulate_header_height( + headers.into_iter().map(Repeatable::unwrap), + &self.regions, + engine, + disambiguator, + )?, + }; // We already take the footer into account below. // While skipping regions, footer height won't be automatically @@ -187,16 +205,22 @@ impl<'a> GridLayouter<'a> { // TODO: re-calculate heights of headers and footers on each region // if 'full'changes? (Assuming height doesn't change for now...) - if include_repeating && !skipped_region { - header_height = - // Laying out pending headers, so we have to consider the - // combined height of already repeating headers as well. + if !skipped_region { + if let HeadersToLayout::NewHeaders(headers) = headers { + header_height = + // Laying out new headers, so we have to consider the + // combined height of already repeating headers as well + // when beginning a new region. self.simulate_header_height( - self.repeating_headers.iter().map(|h| *h).chain(headers.clone()), + self.repeating_headers + .iter() + .map(|h| *h) + .chain(self.pending_headers.into_iter().chain(headers).map(Repeatable::unwrap)), &self.regions, engine, disambiguator, )?; + } } skipped_region = true; @@ -225,32 +249,47 @@ impl<'a> GridLayouter<'a> { // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - self.unbreakable_rows_left += total_header_row_count(headers.clone()); + if let HeadersToLayout::NewHeaders(headers) = headers { + // Do this before laying out repeating and pending headers from a + // new region to make sure row code is aware that all of those + // headers should stay together! + self.unbreakable_rows_left += + total_header_row_count(headers.into_iter().map(Repeatable::unwrap)); + } // Need to relayout ALL headers if we skip a region, not only the // provided headers. // TODO: maybe extract this into a function to share code with multiple // footers. - if include_repeating && skipped_region { + if matches!(headers, HeadersToLayout::RepeatingAndPending) || skipped_region { self.unbreakable_rows_left += total_header_row_count(self.repeating_headers.iter().map(Deref::deref)); - } - // Use indices to avoid double borrow. We don't mutate headers in - // 'layout_row' so this is fine. - let mut i = 0; - while let Some(&header) = self.repeating_headers.get(i) { - for y in header.range() { - self.layout_row(y, engine, disambiguator)?; + // Use indices to avoid double borrow. We don't mutate headers in + // 'layout_row' so this is fine. + let mut i = 0; + while let Some(&header) = self.repeating_headers.get(i) { + for y in header.range() { + self.layout_row(y, engine, disambiguator)?; + } + i += 1; } - i += 1; - } - for header in headers { - for y in header.range() { - self.layout_row(y, engine, disambiguator)?; + for header in self.pending_headers { + for y in header.unwrap().range() { + self.layout_row(y, engine, disambiguator)?; + } } } + + if let HeadersToLayout::NewHeaders(headers) = headers { + for header in headers { + for y in header.unwrap().range() { + self.layout_row(y, engine, disambiguator)?; + } + } + } + Ok(()) } From e586cffa6c928774643d273ce63b8e15994e96b0 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 4 Apr 2025 21:30:04 -0300 Subject: [PATCH 08/82] finish region adjustments - flush pending headers - properly layout headers at region start --- crates/typst-layout/src/grid/layouter.rs | 81 ++++++++++++++---------- crates/typst-layout/src/grid/repeated.rs | 30 +++------ 2 files changed, 57 insertions(+), 54 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 6a1eb9614..8bdbda6e8 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -15,6 +15,7 @@ use typst_library::visualize::Geometry; use typst_syntax::Span; use typst_utils::{MaybeReverseIter, Numeric}; +use super::repeated::HeadersToLayout; use super::{ generate_line_segments, hline_stroke_at_column, layout_cell, vline_stroke_at_row, LineSegment, Rowspan, UnbreakableRowGroup, @@ -1388,30 +1389,19 @@ impl<'a> GridLayouter<'a> { self.lrows.pop().unwrap(); } - // If no rows other than the footer have been laid out so far, and - // there are rows beside the footer, then don't lay it out at all. - // This check doesn't apply, and is thus overridden, when there is a - // header. - let mut footer_would_be_orphan = self.lrows.is_empty() - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) - && self - .grid - .footer - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|footer| footer.start != 0); - - if let Some(last_header) = self.repeating_headers.last() { + let footer_would_be_widow = if let Some(last_header) = self + .pending_headers + .last() + .map(Repeatable::unwrap) + .or_else(|| self.repeating_headers.last().map(|h| *h)) + { if self.grid.rows.len() > last_header.end && self .grid .footer .as_ref() .and_then(Repeatable::as_repeated) - .is_none_or(|footer| footer.start != header.end) + .is_none_or(|footer| footer.start != last_header.end) && self.lrows.last().is_some_and(|row| row.index() < last_header.end) && !in_last_with_offset( self.regions, @@ -1421,19 +1411,41 @@ impl<'a> GridLayouter<'a> { // Header and footer would be alone in this region, but there are more // rows beyond the header and the footer. Push an empty region. self.lrows.clear(); - footer_would_be_orphan = true; + true + } else { + false } - } + } else if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + // If no rows other than the footer have been laid out so far, and + // there are rows beside the footer, then don't lay it out at all. + // (Similar check from above, but for the case without headers.) + // TODO: widow prevention for non-repeated footers with a similar + // mechanism / when implementing multiple footers. + self.lrows.is_empty() + && !in_last_with_offset( + self.regions, + self.header_height + self.footer_height, + ) + && footer.start != 0 + } else { + false + }; let mut laid_out_footer_start = None; - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - // Don't layout the footer if it would be alone with the header in - // the page, and don't layout it twice. - if !footer_would_be_orphan - && self.lrows.iter().all(|row| row.index() < footer.start) - { - laid_out_footer_start = Some(footer.start); - self.layout_footer(footer, engine, self.finished.len())?; + if !footer_would_be_widow { + // Did not trigger automatic header orphan / footer widow check. + // This means pending headers have successfully been placed once + // without hitting orphan prevention, so they may now be moved into + // repeating headers. + self.flush_pending_headers(); + + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + // Don't layout the footer if it would be alone with the header in + // the page (hence the widow check), and don't layout it twice. + if self.lrows.iter().all(|row| row.index() < footer.start) { + laid_out_footer_start = Some(footer.start); + self.layout_footer(footer, engine, self.finished.len())?; + } } } @@ -1569,13 +1581,16 @@ impl<'a> GridLayouter<'a> { self.prepare_footer(footer, engine, disambiguator)?; } - if !self.repeating_headers.is_empty() { - // Add a header to the new region. - self.layout_headers(engine, disambiguator)?; - } - // Ensure rows don't try to overrun the footer. + // Note that header layout will only subtract this again if it has + // to skip regions to fit headers, so there is no risk of + // subtracting this twice. self.regions.size.y -= self.footer_height; + + if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() { + // Add headers to the new region. + self.layout_headers(HeadersToLayout::RepeatingAndPending, engine)?; + } } Ok(()) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 9968234c4..fac2efa31 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -96,23 +96,16 @@ impl<'a> GridLayouter<'a> { } pub fn flush_pending_headers(&mut self) { - debug_assert!(!self.upcoming_headers.is_empty()); - debug_assert!(self.pending_header_end > 0); - let headers = self.pending_headers(); - - let [first_header, ..] = headers else { - return; - }; - - self.repeating_headers.truncate( - self.repeating_headers - .partition_point(|h| h.level < first_header.unwrap().level), - ); - - for header in self.pending_headers() { + for header in self.pending_headers { if let Repeatable::Repeated(header) = header { // Vector remains sorted by increasing levels: - // - It was sorted before, so the truncation above only keeps + // - 'pending_headers' themselves are sorted, since we only + // push non-mutually-conflicting headers at a time. + // - Before pushing new pending headers in + // 'layout_new_pending_headers', we truncate repeating headers + // to remove anything with the same or higher levels as the + // first pending header. + // - Assuming it was sorted before, that truncation only keeps // elements with a lower level. // - Therefore, by pushing this header to the end, it will have // a level larger than all the previous headers, and is thus @@ -121,12 +114,7 @@ impl<'a> GridLayouter<'a> { } } - self.upcoming_headers = self - .upcoming_headers - .get(self.pending_header_end..) - .unwrap_or_default(); - - self.pending_header_end = 0; + self.pending_headers = Default::default(); } pub fn bump_repeating_headers(&mut self) { From 1301b901b7d1a2d0fa202ccab6342b38ce919a79 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sat, 5 Apr 2025 00:54:30 -0300 Subject: [PATCH 09/82] count header rows in each region - Currently also includes non-repeatable header row heights, which will lead to bugs on rowspans; needs fix --- crates/typst-layout/src/grid/layouter.rs | 72 +++++++++++++++--------- crates/typst-layout/src/grid/repeated.rs | 23 +++++++- crates/typst-layout/src/grid/rowspans.rs | 58 ++++++++++--------- 3 files changed, 95 insertions(+), 58 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 8bdbda6e8..c0ea83727 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -46,8 +46,21 @@ pub struct GridLayouter<'a> { pub(super) rowspans: Vec, /// The initial size of the current region before we started subtracting. pub(super) initial: Size, + /// The amount of header rows at the start of the current region. Note that + /// `repeating_headers` and `pending_headers` can change if we find a new + /// header inside the region, so this has to be stored separately for each + /// new region - we can't just reuse those. + /// + /// This is used for orphan prevention checks (if there are no rows other + /// than header rows upon finishing a region, we'd have an orphan). In + /// addition, this information is stored for each finished region so + /// rowspans placed later can know which rows to skip at the top of the + /// region when spanning more than one page. + pub(super) current_header_rows: usize, /// Frames for finished regions. pub(super) finished: Vec, + /// The height of header rows on each finished region. + pub(super) finished_header_rows: Vec, /// Whether this is an RTL grid. pub(super) is_rtl: bool, /// Currently repeating headers, one per level. @@ -131,7 +144,9 @@ impl<'a> GridLayouter<'a> { unbreakable_rows_left: 0, rowspans: vec![], initial: regions.size, + current_header_rows: 0, finished: vec![], + finished_header_rows: vec![], is_rtl: TextElem::dir_in(styles) == Dir::RTL, repeating_headers: vec![], upcoming_headers: &grid.headers, @@ -979,16 +994,8 @@ impl<'a> GridLayouter<'a> { let frame = self.layout_single_row(engine, disambiguator, first, y)?; self.push_row(frame, y, true); - if self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| y < header.end) - { + if self.lrows.len() < self.current_header_rows { // Add to header height, as we are in a header row. - // TODO: Should we only bump from upcoming_headers to - // repeating_headers AFTER the header height calculation? self.header_height += first; } @@ -1221,14 +1228,9 @@ impl<'a> GridLayouter<'a> { let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); let frame = self.layout_single_row(engine, disambiguator, resolved, y)?; - if self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| y < header.end) - { - // Add to header height. + if self.lrows.len() < self.current_header_rows { + // Add to header height (not all headers were laid out yet, so this + // must be a repeated or pending header at the top of the region). self.header_height += resolved; } @@ -1389,20 +1391,20 @@ impl<'a> GridLayouter<'a> { self.lrows.pop().unwrap(); } - let footer_would_be_widow = if let Some(last_header) = self - .pending_headers - .last() - .map(Repeatable::unwrap) - .or_else(|| self.repeating_headers.last().map(|h| *h)) + let footer_would_be_widow = if let Some(last_header_row) = self + .current_header_rows + .checked_sub(1) + .and_then(|last_header_index| self.lrows.get(last_header_index)) { - if self.grid.rows.len() > last_header.end + let last_header_end = last_header_row.index(); + if self.grid.rows.len() > last_header_end && self .grid .footer .as_ref() .and_then(Repeatable::as_repeated) - .is_none_or(|footer| footer.start != last_header.end) - && self.lrows.last().is_some_and(|row| row.index() < last_header.end) + .is_none_or(|footer| footer.start != last_header_end) + && self.lrows.last().is_some_and(|row| row.index() < last_header_end) && !in_last_with_offset( self.regions, self.header_height + self.footer_height, @@ -1471,9 +1473,10 @@ impl<'a> GridLayouter<'a> { let mut pos = Point::zero(); let mut rrows = vec![]; let current_region = self.finished.len(); + let mut header_row_height = Abs::zero(); // Place finished rows and layout fractional rows. - for row in std::mem::take(&mut self.lrows) { + for (i, row) in std::mem::take(&mut self.lrows).into_iter().enumerate() { let (frame, y, is_last) = match row { Row::Frame(frame, y, is_last) => (frame, y, is_last), Row::Fr(v, y, disambiguator) => { @@ -1484,6 +1487,9 @@ impl<'a> GridLayouter<'a> { }; let height = frame.height(); + if i < self.current_header_rows { + header_row_height += height; + } // Ensure rowspans which span this row will have enough space to // be laid out over it later. @@ -1562,7 +1568,11 @@ impl<'a> GridLayouter<'a> { // we have to check the same index again in the next // iteration. let rowspan = self.rowspans.remove(i); - self.layout_rowspan(rowspan, Some((&mut output, &rrows)), engine)?; + self.layout_rowspan( + rowspan, + Some((&mut output, header_row_height)), + engine, + )?; } else { i += 1; } @@ -1573,9 +1583,10 @@ impl<'a> GridLayouter<'a> { pos.y += height; } - self.finish_region_internal(output, rrows); + self.finish_region_internal(output, rrows, header_row_height); if !last { + self.current_header_rows = 0; let disambiguator = self.finished.len(); if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { self.prepare_footer(footer, engine, disambiguator)?; @@ -1602,11 +1613,16 @@ impl<'a> GridLayouter<'a> { &mut self, output: Frame, resolved_rows: Vec, + header_row_height: Abs, ) { self.finished.push(output); self.rrows.push(resolved_rows); self.regions.next(); self.initial = self.regions.size; + + if !self.grid.headers.is_empty() { + self.finished_header_rows.push(header_row_height); + } } } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index fac2efa31..6ff625c32 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -189,7 +189,11 @@ impl<'a> GridLayouter<'a> { { // Advance regions without any output until we can place the // header and the footer. - self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); + self.finish_region_internal( + Frame::soft(Axes::splat(Abs::zero())), + vec![], + Abs::zero(), + ); // TODO: re-calculate heights of headers and footers on each region // if 'full'changes? (Assuming height doesn't change for now...) @@ -250,9 +254,18 @@ impl<'a> GridLayouter<'a> { // TODO: maybe extract this into a function to share code with multiple // footers. if matches!(headers, HeadersToLayout::RepeatingAndPending) || skipped_region { - self.unbreakable_rows_left += + let repeating_header_rows = total_header_row_count(self.repeating_headers.iter().map(Deref::deref)); + let pending_header_rows = total_header_row_count( + self.pending_headers.into_iter().map(Repeatable::unwrap), + ); + + // Include both repeating and pending header rows as this number is + // used for orphan prevention. + self.current_header_rows = repeating_header_rows + pending_header_rows; + self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; + // Use indices to avoid double borrow. We don't mutate headers in // 'layout_row' so this is fine. let mut i = 0; @@ -336,7 +349,11 @@ impl<'a> GridLayouter<'a> { { // Advance regions without any output until we can place the // footer. - self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); + self.finish_region_internal( + Frame::soft(Axes::splat(Abs::zero())), + vec![], + Abs::zero(), + ); skipped_region = true; } diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index a99d38fa8..2c970784c 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -5,7 +5,7 @@ use typst_library::layout::grid::resolve::Repeatable; use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; use typst_utils::MaybeReverseIter; -use super::layouter::{in_last_with_offset, points, Row, RowPiece}; +use super::layouter::{in_last_with_offset, points, Row}; use super::{layout_cell, Cell, GridLayouter}; /// All information needed to layout a single rowspan. @@ -87,10 +87,10 @@ pub struct CellMeasurementData<'layouter> { impl GridLayouter<'_> { /// Layout a rowspan over the already finished regions, plus the current - /// region's frame and resolved rows, if it wasn't finished yet (because - /// we're being called from `finish_region`, but note that this function is - /// also called once after all regions are finished, in which case - /// `current_region_data` is `None`). + /// region's frame and height of resolved header rows, if it wasn't + /// finished yet (because we're being called from `finish_region`, but note + /// that this function is also called once after all regions are finished, + /// in which case `current_region_data` is `None`). /// /// We need to do this only once we already know the heights of all /// spanned rows, which is only possible after laying out the last row @@ -98,7 +98,7 @@ impl GridLayouter<'_> { pub fn layout_rowspan( &mut self, rowspan_data: Rowspan, - current_region_data: Option<(&mut Frame, &[RowPiece])>, + current_region_data: Option<(&mut Frame, Abs)>, engine: &mut Engine, ) -> SourceResult<()> { let Rowspan { @@ -142,11 +142,31 @@ impl GridLayouter<'_> { // Push the layouted frames directly into the finished frames. let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?; - let (current_region, current_rrows) = current_region_data.unzip(); - for ((i, finished), frame) in self + let (current_region, current_header_row_height) = current_region_data.unzip(); + + // Clever trick to process finished header rows: + // - If there are grid headers, the vector will be filled with one + // finished header row height per region, so, chaining with the height + // for the current one, we get the header row height for each region. + // + // - But if there are no grid headers, the vector will be empty, so in + // theory the regions and resolved header row heights wouldn't match. + // But that's fine - 'current_header_row_height' can only be either + // 'Some(zero)' or 'None' in such a case, and for all other rows we + // append infinite zeros. That is, in such a case, the resolved header + // row height is always zero, so that's our fallback. + let finished_header_rows = self + .finished_header_rows + .iter() + .copied() + .chain(current_header_row_height) + .chain(std::iter::repeat(Abs::zero())); + + for ((i, (finished, header_dy)), frame) in self .finished .iter_mut() .chain(current_region.into_iter()) + .zip(finished_header_rows) .skip(first_region) .enumerate() .zip(fragment) @@ -158,25 +178,9 @@ impl GridLayouter<'_> { } else { // The rowspan continuation starts after the header (thus, // at a position after the sum of the laid out header - // rows). - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - // TODO: Need a way to distinguish header 'rrows' for each - // region, as this calculation - i.e., header height at - // each region - will change depending on'i'. - let header_rows = self - .rrows - .get(i) - .map(Vec::as_slice) - .or(current_rrows) - .unwrap_or(&[]) - .iter() - .take_while(|row| row.y < header.end); - - header_rows.map(|row| row.height).sum() - } else { - // Without a header, start at the very top of the region. - Abs::zero() - } + // rows). Without a header, this is zero, so the rowspan can + // start at the very top of the region as usual. + header_dy }; finished.push_frame(Point::new(dx, dy), frame); From f21dc8b7e2de62d3439ce2c72a6589e8f02a41ab Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sat, 5 Apr 2025 01:45:50 -0300 Subject: [PATCH 10/82] a footer todo --- crates/typst-layout/src/grid/repeated.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 6ff625c32..bd4b32b60 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -357,6 +357,10 @@ impl<'a> GridLayouter<'a> { skipped_region = true; } + // TODO: Consider resetting header height etc. if we skip region. + // That is unnecessary at the moment as 'prepare_footers' is only + // called at the start of the region, but what about when we can have + // footers in the middle of the region? Let's think about this then. self.footer_height = if skipped_region { // Simulate the footer again; the region's 'full' might have // changed. From ecc93297f858b28c01eb12970e1d16c039268575 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sat, 5 Apr 2025 01:45:50 -0300 Subject: [PATCH 11/82] initial attempt on repeating header height - Faulty, as we have to update it when pushing new pending headers too - Need to have a vector associating a height to each row... --- crates/typst-layout/src/grid/layouter.rs | 12 ++++++++++++ crates/typst-layout/src/grid/repeated.rs | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index c0ea83727..232e57418 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -80,6 +80,14 @@ pub struct GridLayouter<'a> { /// header rows themselves are unbreakable, and unbreakable rows do not /// need to read this field at all. pub(super) header_height: Abs, + /// The height of effectively repeating headers, that is, ignoring + /// non-repeating pending headers. + /// + /// This is used by multi-page auto rows so they can inform cell layout on + /// how much space should be taken by headers if they break across regions. + /// In particular, non-repeating headers only occupy the initial region, + /// but disappear on new regions, so they can be ignored. + pub(super) repeating_header_height: Abs, /// The simulated footer height for this region. /// The simulation occurs before any rows are laid out for a region. pub(super) footer_height: Abs, @@ -152,6 +160,7 @@ impl<'a> GridLayouter<'a> { upcoming_headers: &grid.headers, pending_headers: Default::default(), header_height: Abs::zero(), + repeating_header_height: Abs::zero(), footer_height: Abs::zero(), span, } @@ -1587,6 +1596,9 @@ impl<'a> GridLayouter<'a> { if !last { self.current_header_rows = 0; + self.header_height = Abs::zero(); + self.repeating_header_height = Abs::zero(); + let disambiguator = self.finished.len(); if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { self.prepare_footer(footer, engine, disambiguator)?; diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index bd4b32b60..f89dc1ddd 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -224,6 +224,7 @@ impl<'a> GridLayouter<'a> { // Reset the header height for this region. // It will be re-calculated when laying out each header row. self.header_height = Abs::zero(); + self.repeating_header_height = Abs::zero(); if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { if skipped_region { @@ -276,10 +277,16 @@ impl<'a> GridLayouter<'a> { i += 1; } + // All rows so far were repeating headers at the top of the region. + self.repeating_header_height = self.header_height; for header in self.pending_headers { + let header_height = self.header_height; for y in header.unwrap().range() { self.layout_row(y, engine, disambiguator)?; } + if matches!(header, Repeatable::Repeated(_)) { + self.repeating_header_height += self.header_height - header_height; + } } } From d172eccfd968bc9cd164e8eff1d9f8f7da706c5d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 00:17:52 -0300 Subject: [PATCH 12/82] store height of each repeating header So we can update in the middle of the region --- crates/typst-layout/src/grid/layouter.rs | 24 +++- crates/typst-layout/src/grid/repeated.rs | 147 +++++++++++++++++++---- 2 files changed, 139 insertions(+), 32 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 232e57418..c75bf0d3e 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -73,6 +73,16 @@ pub struct GridLayouter<'a> { /// Sorted by increasing levels. pub(super) pending_headers: &'a [Repeatable
], pub(super) upcoming_headers: &'a [Repeatable
], + /// The height for each repeating header that was placed in this region. + /// Note that this includes headers not at the top of the region (pending + /// headers), and excludes headers removed by virtue of a new, conflicting + /// header being found. + pub(super) repeating_header_heights: Vec, + /// If this is `Some`, this will receive the currently laid out row's + /// height if it is auto or relative. This is used for header height + /// calculation. + /// TODO: consider refactoring this into something nicer. + pub(super) current_row_height: Option, /// The simulated header height. /// This field is reset in `layout_header` and properly updated by /// `layout_auto_row` and `layout_relative_row`, and should not be read @@ -158,7 +168,9 @@ impl<'a> GridLayouter<'a> { is_rtl: TextElem::dir_in(styles) == Dir::RTL, repeating_headers: vec![], upcoming_headers: &grid.headers, + repeating_header_heights: vec![], pending_headers: Default::default(), + current_row_height: None, header_height: Abs::zero(), repeating_header_height: Abs::zero(), footer_height: Abs::zero(), @@ -1003,9 +1015,9 @@ impl<'a> GridLayouter<'a> { let frame = self.layout_single_row(engine, disambiguator, first, y)?; self.push_row(frame, y, true); - if self.lrows.len() < self.current_header_rows { + if let Some(row_height) = &mut self.current_row_height { // Add to header height, as we are in a header row. - self.header_height += first; + *row_height += first; } return Ok(()); @@ -1237,10 +1249,9 @@ impl<'a> GridLayouter<'a> { let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); let frame = self.layout_single_row(engine, disambiguator, resolved, y)?; - if self.lrows.len() < self.current_header_rows { - // Add to header height (not all headers were laid out yet, so this - // must be a repeated or pending header at the top of the region). - self.header_height += resolved; + if let Some(row_height) = &mut self.current_row_height { + // Add to header height, as we are in a header row. + *row_height += resolved; } // Skip to fitting region, but only if we aren't part of an unbreakable @@ -1598,6 +1609,7 @@ impl<'a> GridLayouter<'a> { self.current_header_rows = 0; self.header_height = Abs::zero(); self.repeating_header_height = Abs::zero(); + self.repeating_header_heights.clear(); let disambiguator = self.finished.len(); if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index f89dc1ddd..30fbd9988 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -10,7 +10,17 @@ use super::rowspans::UnbreakableRowGroup; pub enum HeadersToLayout<'a> { RepeatingAndPending, - NewHeaders(&'a [Repeatable
]), + NewHeaders { + headers: &'a [Repeatable
], + + /// Whether this new header will become a pending header. If false, we + /// assume it simply won't repeat and so its header height is ignored. + /// Later on, cells can assume that this header won't occupy any height + /// in a future region, and indeed, since it won't be pending, it won't + /// have orphan prevention, so it will be placed immediately and stay + /// where it is. + short_lived: bool, + }, } impl<'a> GridLayouter<'a> { @@ -53,7 +63,12 @@ impl<'a> GridLayouter<'a> { self.layout_headers( // Using 'chunks_exact", we pass a slice of length one instead // of a reference for type consistency. - HeadersToLayout::NewHeaders(conflicting_header), + // In addition, this is the only place where we layout + // short-lived headers. + HeadersToLayout::NewHeaders { + headers: conflicting_header, + short_lived: true, + }, engine, )? } @@ -61,6 +76,40 @@ impl<'a> GridLayouter<'a> { Ok(()) } + /// Lays out a row while indicating that it should store its persistent + /// height as a header row, which will be its height if relative or auto, + /// or zero otherwise (fractional). + #[inline] + fn layout_header_row( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult> { + let previous_row_height = + std::mem::replace(&mut self.current_row_height, Some(Abs::zero())); + self.layout_row(y, engine, disambiguator)?; + + Ok(std::mem::replace(&mut self.current_row_height, previous_row_height)) + } + + /// Lays out rows belonging to a header, returning the calculated header + /// height only for that header. + #[inline] + fn layout_header_rows( + &mut self, + header: &Header, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult { + let mut header_height = Abs::zero(); + for y in header.range() { + header_height += + self.layout_header_row(y, engine, disambiguator)?.unwrap_or_default(); + } + Ok(header_height) + } + /// Queues new pending headers for layout. Headers remain pending until /// they are successfully laid out in some page once. Then, they will be /// moved to `repeating_headers`, at which point it is safe to stop them @@ -80,14 +129,26 @@ impl<'a> GridLayouter<'a> { // Stop repeating conflicting headers. // If we go to a new region before the pending headers fit alongside // their children, the old headers should not be displayed anymore. - self.repeating_headers - .truncate(self.repeating_headers.partition_point(|h| h.level < first_level)); + let first_conflicting_pos = + self.repeating_headers.partition_point(|h| h.level < first_level); + self.repeating_headers.truncate(first_conflicting_pos); + + // Ensure upcoming rows won't see that these headers will occupy any + // space in future regions anymore. + for removed_height in self.repeating_header_heights.drain(first_conflicting_pos..) + { + self.header_height -= removed_height; + self.repeating_header_height -= removed_height; + } // Let's try to place them at least once. // This might be a waste as we could generate an orphan and thus have // to try to place old and new headers all over again, but that happens // for every new region anyway, so it's rather unavoidable. - self.layout_headers(HeadersToLayout::NewHeaders(headers), engine); + self.layout_headers( + HeadersToLayout::NewHeaders { headers, short_lived: false }, + engine, + ); // After the first subsequent row is laid out, move to repeating, as // it's then confirmed the headers won't be moved due to orphan @@ -171,7 +232,7 @@ impl<'a> GridLayouter<'a> { engine, disambiguator, )?, - HeadersToLayout::NewHeaders(headers) => self.simulate_header_height( + HeadersToLayout::NewHeaders { headers, .. } => self.simulate_header_height( headers.into_iter().map(Repeatable::unwrap), &self.regions, engine, @@ -198,7 +259,7 @@ impl<'a> GridLayouter<'a> { // TODO: re-calculate heights of headers and footers on each region // if 'full'changes? (Assuming height doesn't change for now...) if !skipped_region { - if let HeadersToLayout::NewHeaders(headers) = headers { + if let HeadersToLayout::NewHeaders { headers, .. } = headers { header_height = // Laying out new headers, so we have to consider the // combined height of already repeating headers as well @@ -221,11 +282,6 @@ impl<'a> GridLayouter<'a> { self.regions.size.y -= self.footer_height; } - // Reset the header height for this region. - // It will be re-calculated when laying out each header row. - self.header_height = Abs::zero(); - self.repeating_header_height = Abs::zero(); - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { if skipped_region { // Simulate the footer again; the region's 'full' might have @@ -242,7 +298,7 @@ impl<'a> GridLayouter<'a> { // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - if let HeadersToLayout::NewHeaders(headers) = headers { + if let HeadersToLayout::NewHeaders { headers, .. } = headers { // Do this before laying out repeating and pending headers from a // new region to make sure row code is aware that all of those // headers should stay together! @@ -267,33 +323,72 @@ impl<'a> GridLayouter<'a> { self.current_header_rows = repeating_header_rows + pending_header_rows; self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; + // Reset the header height for this region. + // It will be re-calculated when laying out each header row. + self.header_height = Abs::zero(); + self.repeating_header_height = Abs::zero(); + self.repeating_header_heights.clear(); + // Use indices to avoid double borrow. We don't mutate headers in // 'layout_row' so this is fine. let mut i = 0; while let Some(&header) = self.repeating_headers.get(i) { - for y in header.range() { - self.layout_row(y, engine, disambiguator)?; - } + let header_height = + self.layout_header_rows(header, engine, disambiguator)?; + self.header_height += header_height; + self.repeating_header_height += header_height; + + // We assume that this vector will be sorted according + // to increasing levels like 'repeating_headers' and + // 'pending_headers' - and, in particular, their union, as this + // vector is pushed repeating heights from both. + // + // This is guaranteed by: + // 1. We always push pending headers after repeating headers, + // as we assume they don't conflict because we remove + // conflicting repeating headers when pushing a new pending + // header. + // + // 2. We push in the same order as each. + // + // 3. This vector is also modified when pushing a new pending + // header, where we remove heights for conflicting repeating + // headers which have now stopped repeating. They are always at + // the end and new pending headers respect the existing sort, + // so the vector will remain sorted. + self.repeating_header_heights.push(header_height); + i += 1; } - // All rows so far were repeating headers at the top of the region. - self.repeating_header_height = self.header_height; for header in self.pending_headers { - let header_height = self.header_height; - for y in header.unwrap().range() { - self.layout_row(y, engine, disambiguator)?; - } + let header_height = + self.layout_header_rows(header.unwrap(), engine, disambiguator)?; + self.header_height += header_height; if matches!(header, Repeatable::Repeated(_)) { - self.repeating_header_height += self.header_height - header_height; + self.repeating_header_height += header_height; + self.repeating_header_heights.push(header_height); } } } - if let HeadersToLayout::NewHeaders(headers) = headers { + if let HeadersToLayout::NewHeaders { headers, short_lived } = headers { for header in headers { - for y in header.unwrap().range() { - self.layout_row(y, engine, disambiguator)?; + let header_height = + self.layout_header_rows(header.unwrap(), engine, disambiguator)?; + + // Only store this header height if it is actually going to + // become a pending header. Otherwise, pretend it's not a + // header... This is fine for consumers of 'header_height' as + // it is guaranteed this header won't appear in a future + // region, so multi-page rows and cells can effectively ignore + // this header. + if !short_lived { + self.header_height += header_height; + if matches!(header, Repeatable::Repeated(_)) { + self.repeating_header_height = header_height; + self.repeating_header_heights.push(header_height); + } } } } From f9569efc4083851cd0355dce68a18d4d624c82f7 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 01:30:27 -0300 Subject: [PATCH 13/82] adapt usages of header_height to repeating_header_height - Document unchanged usages --- crates/typst-layout/src/grid/layouter.rs | 16 +++++- crates/typst-layout/src/grid/rowspans.rs | 66 +++++++++++++++++++----- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index c75bf0d3e..cc957eb2c 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1034,11 +1034,12 @@ impl<'a> GridLayouter<'a> { .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) { // Subtract header and footer heights from the region height when - // it's not the first. + // it's not the first. Ignore non-repeating headers as they only + // appear on the first region by definition. target.set_max( region.y - if i > 0 { - self.header_height + self.footer_height + self.repeating_header_height + self.footer_height } else { Abs::zero() }, @@ -1258,6 +1259,10 @@ impl<'a> GridLayouter<'a> { // row group. We use 'in_last_with_offset' so our 'in_last' call // properly considers that a header and a footer would be added on each // region break. + // + // See 'check_for_unbreakable_rows' as for why we're using + // 'header_height' to predict header height and not + // 'repeating_header_height'. let height = frame.height(); while self.unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) @@ -1427,6 +1432,10 @@ impl<'a> GridLayouter<'a> { && self.lrows.last().is_some_and(|row| row.index() < last_header_end) && !in_last_with_offset( self.regions, + // Since we're trying to find a region where to place all + // repeating + pending headers, it makes sense to use + // 'header_height' and include even non-repeating pending + // headers for this check. self.header_height + self.footer_height, ) { @@ -1446,6 +1455,9 @@ impl<'a> GridLayouter<'a> { self.lrows.is_empty() && !in_last_with_offset( self.regions, + // This header height isn't doing much as we just confirmed + // that there are no headers in this region, but let's keep + // it here for correctness. It will add zero anyway. self.header_height + self.footer_height, ) && footer.start != 0 diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 2c970784c..770d564d1 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -261,6 +261,11 @@ impl GridLayouter<'_> { while !self.regions.size.y.fits(row_group.height) && !in_last_with_offset( self.regions, + // Note that we consider that the exact same headers and footers will be + // added if we skip like this (blocking other rows from being laid out) + // due to orphan/widow prevention, which explains the usage of + // 'header_height' (include non-repeating but pending headers) rather + // than 'repeating_header_height'. self.header_height + self.footer_height, ) { @@ -409,8 +414,18 @@ impl GridLayouter<'_> { // // This will update the 'custom_backlog' vector with the // updated heights of the upcoming regions. + // + // We predict that header height will only include that of + // repeating headers, as we can assume non-repeating headers in + // the first region have been successfully placed, unless + // something didn't fit on the first region of the auto row, + // but we will only find that out after measurement, and if + // that happens, we discard the measurement and try again. let mapped_regions = self.regions.map(&mut custom_backlog, |size| { - Size::new(size.x, size.y - self.header_height - self.footer_height) + Size::new( + size.x, + size.y - self.repeating_header_height - self.footer_height, + ) }); // Callees must use the custom backlog instead of the current @@ -511,7 +526,26 @@ impl GridLayouter<'_> { .iter() .copied() .chain(std::iter::once(if breakable { - self.initial.y - self.header_height - self.footer_height + // Here we are calculating the available height for a + // rowspan from the top of the current region, so + // we have to use initial header heights (note that + // header height can change in the middle of the + // region). + // TODO: maybe cache this + // NOTE: it is safe to access 'lrows' here since + // 'breakable' can only be true outside of headers + // and unbreakable rows in general, so there is no risk + // of accessing an incomplete list of rows. + let initial_header_height = self.lrows + [..self.current_header_rows] + .iter() + .map(|row| match row { + Row::Frame(frame, _, _) => frame.height(), + Row::Fr(_, _, _) => Abs::zero(), + }) + .sum(); + + self.initial.y - initial_header_height - self.footer_height } else { // When measuring unbreakable auto rows, infinite // height is available for content to expand. @@ -523,11 +557,12 @@ impl GridLayouter<'_> { // rowspan's already laid out heights with the current // region's height and current backlog to ensure a good // level of accuracy in the measurements. - let backlog = self - .regions - .backlog - .iter() - .map(|&size| size - self.header_height - self.footer_height); + // + // Assume only repeating headers will survive starting at + // the next region. + let backlog = self.regions.backlog.iter().map(|&size| { + size - self.repeating_header_height - self.footer_height + }); heights_up_to_current_region.chain(backlog).collect::>() } else { @@ -544,7 +579,7 @@ impl GridLayouter<'_> { last = self .regions .last - .map(|size| size - self.header_height - self.footer_height); + .map(|size| size - self.repeating_header_height - self.footer_height); } else { // The rowspan started in the current region, as its vector // of heights in regions is currently empty. @@ -746,10 +781,10 @@ impl GridLayouter<'_> { simulated_regions.next(); disambiguator += 1; - // Subtract the initial header and footer height, since that's the - // height we used when subtracting from the region backlog's + // Subtract the repeating header and footer height, since that's + // the height we used when subtracting from the region backlog's // heights while measuring cells. - simulated_regions.size.y -= self.header_height + self.footer_height; + simulated_regions.size.y -= self.repeating_header_height + self.footer_height; } if let Some(original_last_resolved_size) = last_resolved_size { @@ -884,7 +919,11 @@ impl GridLayouter<'_> { let rowspan_simulator = RowspanSimulator::new( disambiguator, simulated_regions, - self.header_height, + // There can be no new headers or footers within a multi-page + // rowspan, since headers and footers are unbreakable, so + // assuming the repeating header height and footer height + // won't change is safe. + self.repeating_header_height, self.footer_height, ); @@ -968,7 +1007,8 @@ impl GridLayouter<'_> { { extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero()); simulated_regions.next(); - simulated_regions.size.y -= self.header_height + self.footer_height; + simulated_regions.size.y -= + self.repeating_header_height + self.footer_height; disambiguator += 1; } simulated_regions.size.y -= extra_amount_to_grow; From c04dedf470602bbf1e4f18183feada203ad214d5 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 01:30:27 -0300 Subject: [PATCH 14/82] fix grid.header errors in rowspans --- crates/typst-layout/src/grid/rowspans.rs | 53 ++++++++++++++++-------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 770d564d1..2e210811f 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -236,15 +236,12 @@ impl GridLayouter<'_> { // current row is dynamic and depends on the amount of upcoming // unbreakable cells (with or without a rowspan setting). let mut amount_unbreakable_rows = None; - if let Some(Repeatable::NotRepeated(header)) = &self.grid.header { - if current_row < header.end { - // Non-repeated header, so keep it unbreakable. - amount_unbreakable_rows = Some(header.end); - } - } if let Some(Repeatable::NotRepeated(footer)) = &self.grid.footer { if current_row >= footer.start { // Non-repeated footer, so keep it unbreakable. + // TODO: This will become unnecessary once non-repeated + // footers are treated differently and have widow + // prevention. amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start); } } @@ -406,7 +403,8 @@ impl GridLayouter<'_> { // auto rows don't depend on the backlog, as they only span one // region. if breakable - && (matches!(self.grid.header, Some(Repeatable::Repeated(_))) + && (!self.repeating_headers.is_empty() + || !self.pending_headers.is_empty() || matches!(self.grid.footer, Some(Repeatable::Repeated(_)))) { // Subtract header and footer height from all upcoming regions @@ -1172,14 +1170,30 @@ impl<'a> RowspanSimulator<'a> { // our simulation checks what happens AFTER the auto row, so we can // just use the original backlog from `self.regions`. let disambiguator = self.finished; - let header_height = - if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { + + let (repeating_headers, header_height) = if !layouter.repeating_headers.is_empty() + || !layouter.pending_headers.is_empty() + { + // Only repeating headers have survived after the first region + // break. + let repeating_headers = layouter.repeating_headers.iter().map(|h| *h).chain( layouter - .simulate_header(header, &self.regions, engine, disambiguator)? - .height - } else { - Abs::zero() - }; + .pending_headers + .into_iter() + .filter_map(Repeatable::as_repeated), + ); + + let header_height = layouter.simulate_header_height( + repeating_headers.clone(), + &self.regions, + engine, + disambiguator, + )?; + + (Some(repeating_headers), header_height) + } else { + (None, Abs::zero()) + }; let footer_height = if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { @@ -1201,13 +1215,16 @@ impl<'a> RowspanSimulator<'a> { skipped_region = true; } - if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { + if let Some(repeating_headers) = repeating_headers { self.header_height = if skipped_region { // Simulate headers again, at the new region, as // the full region height may change. - layouter - .simulate_header(header, &self.regions, engine, disambiguator)? - .height + layouter.simulate_header_height( + repeating_headers, + &self.regions, + engine, + disambiguator, + )? } else { header_height }; From 59dc458188377139c7ec9b4769aeffafdc054299 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 03:02:37 -0300 Subject: [PATCH 15/82] fix line layout with missing header bottoms --- crates/typst-layout/src/grid/layouter.rs | 111 ++++++++++++++--------- crates/typst-layout/src/grid/lines.rs | 15 ++- crates/typst-layout/src/grid/repeated.rs | 8 +- crates/typst-layout/src/grid/rowspans.rs | 2 +- 4 files changed, 80 insertions(+), 56 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index cc957eb2c..600e051b5 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -57,10 +57,20 @@ pub struct GridLayouter<'a> { /// rowspans placed later can know which rows to skip at the top of the /// region when spanning more than one page. pub(super) current_header_rows: usize, + /// Similar to the above, but stopping after the last repeated row at the + /// top. + pub(super) current_repeating_header_rows: usize, + /// The end bound of the last repeating header at the start of the region. + /// The last row might have disappeared due to being empty, so this is how + /// we can become aware of that. Line layout uses this to determine when to + /// prioritize the last lines under a header. + /// + /// A value of zero indicates no headers were placed. + pub(super) current_last_repeated_header_end: usize, /// Frames for finished regions. pub(super) finished: Vec, - /// The height of header rows on each finished region. - pub(super) finished_header_rows: Vec, + /// The amount and height of header rows on each finished region. + pub(super) finished_header_rows: Vec, /// Whether this is an RTL grid. pub(super) is_rtl: bool, /// Currently repeating headers, one per level. @@ -105,6 +115,15 @@ pub struct GridLayouter<'a> { pub(super) span: Span, } +#[derive(Debug, Default)] +pub(super) struct FinishedHeaderRowInfo { + /// The amount of repeated headers at the top of the region. + pub(super) repeated: usize, + /// The end bound of the last repeated header at the top of the region. + pub(super) last_repeated_header_end: usize, + pub(super) height: Abs, +} + /// Details about a resulting row piece. #[derive(Debug)] pub struct RowPiece { @@ -163,6 +182,8 @@ impl<'a> GridLayouter<'a> { rowspans: vec![], initial: regions.size, current_header_rows: 0, + current_repeating_header_rows: 0, + current_last_repeated_header_end: 0, finished: vec![], finished_header_rows: vec![], is_rtl: TextElem::dir_in(styles) == Dir::RTL, @@ -294,8 +315,13 @@ impl<'a> GridLayouter<'a> { fn render_fills_strokes(mut self) -> SourceResult { let mut finished = std::mem::take(&mut self.finished); let frame_amount = finished.len(); - for ((frame_index, frame), rows) in - finished.iter_mut().enumerate().zip(&self.rrows) + for (((frame_index, frame), rows), finished_header_rows) in + finished.iter_mut().enumerate().zip(&self.rrows).zip( + self.finished_header_rows + .iter() + .map(Some) + .chain(std::iter::repeat(None)), + ) { if self.rcols.is_empty() || rows.is_empty() { continue; @@ -416,7 +442,8 @@ impl<'a> GridLayouter<'a> { let hline_indices = rows .iter() .map(|piece| piece.y) - .chain(std::iter::once(self.grid.rows.len())); + .chain(std::iter::once(self.grid.rows.len())) + .enumerate(); // Converts a row to the corresponding index in the vector of // hlines. @@ -441,7 +468,7 @@ impl<'a> GridLayouter<'a> { }; let mut prev_y = None; - for (y, dy) in hline_indices.zip(hline_offsets) { + for ((i, y), dy) in hline_indices.zip(hline_offsets) { // Position of lines below the row index in the previous iteration. let expected_prev_line_position = prev_y .map(|prev_y| { @@ -452,24 +479,10 @@ impl<'a> GridLayouter<'a> { }) .unwrap_or(LinePosition::Before); - // FIXME: In the future, directly specify in 'self.rrows' when - // we place a repeated header rather than its original rows. - // That would let us remove most of those verbose checks, both - // in 'lines.rs' and here. Those checks also aren't fully - // accurate either, since they will also trigger when some rows - // have been removed between the header and what's below it. - let is_under_repeated_header = self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .zip(prev_y) - .is_some_and(|(header, prev_y)| { - // Note: 'y == header.end' would mean we're right below - // the NON-REPEATED header, so that case should return - // false. - prev_y < header.end && y > header.end - }); + // Header's lines have priority when repeated. + let end_under_repeated_header = finished_header_rows + .filter(|info| prev_y.is_some() && i == info.repeated) + .map(|info| info.last_repeated_header_end); // If some grid rows were omitted between the previous resolved // row and the current one, we ensure lines below the previous @@ -483,13 +496,11 @@ impl<'a> GridLayouter<'a> { let prev_lines = prev_y .filter(|prev_y| { prev_y + 1 != y - && (!is_under_repeated_header - || self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| prev_y + 1 != header.end)) + && end_under_repeated_header.is_none_or( + |last_repeated_header_end| { + prev_y + 1 != last_repeated_header_end + }, + ) }) .map(|prev_y| get_hlines_at(prev_y + 1)) .unwrap_or(&[]); @@ -510,15 +521,14 @@ impl<'a> GridLayouter<'a> { }; let mut expected_header_line_position = LinePosition::Before; - let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) = - self.grid.header.as_ref().zip(prev_y) + let header_hlines = if let Some((under_header_end, prev_y)) = + end_under_repeated_header.zip(prev_y) { - if is_under_repeated_header - && (!self.grid.has_gutter - || matches!( - self.grid.rows[prev_y], - Sizing::Rel(length) if length.is_zero() - )) + if !self.grid.has_gutter + || matches!( + self.grid.rows[prev_y], + Sizing::Rel(length) if length.is_zero() + ) { // For lines below a header, give priority to the // lines originally below the header rather than @@ -537,10 +547,10 @@ impl<'a> GridLayouter<'a> { // column-gutter is specified, for example. In that // case, we still repeat the line under the gutter. expected_header_line_position = expected_line_position( - header.end, - header.end == self.grid.rows.len(), + under_header_end, + under_header_end == self.grid.rows.len(), ); - get_hlines_at(header.end) + get_hlines_at(under_header_end) } else { &[] } @@ -598,6 +608,7 @@ impl<'a> GridLayouter<'a> { grid, rows, local_top_y, + end_under_repeated_header, in_last_region, y, x, @@ -1615,10 +1626,20 @@ impl<'a> GridLayouter<'a> { pos.y += height; } - self.finish_region_internal(output, rrows, header_row_height); + self.finish_region_internal( + output, + rrows, + FinishedHeaderRowInfo { + repeated: self.current_repeating_header_rows, + last_repeated_header_end: self.current_last_repeated_header_end, + height: header_row_height, + }, + ); if !last { self.current_header_rows = 0; + self.current_repeating_header_rows = 0; + self.current_last_repeated_header_end = 0; self.header_height = Abs::zero(); self.repeating_header_height = Abs::zero(); self.repeating_header_heights.clear(); @@ -1649,7 +1670,7 @@ impl<'a> GridLayouter<'a> { &mut self, output: Frame, resolved_rows: Vec, - header_row_height: Abs, + header_row_info: FinishedHeaderRowInfo, ) { self.finished.push(output); self.rrows.push(resolved_rows); @@ -1657,7 +1678,7 @@ impl<'a> GridLayouter<'a> { self.initial = self.regions.size; if !self.grid.headers.is_empty() { - self.finished_header_rows.push(header_row_height); + self.finished_header_rows.push(header_row_info); } } } diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs index 7549673f1..f0da6fb97 100644 --- a/crates/typst-layout/src/grid/lines.rs +++ b/crates/typst-layout/src/grid/lines.rs @@ -395,6 +395,7 @@ pub fn hline_stroke_at_column( grid: &CellGrid, rows: &[RowPiece], local_top_y: Option, + end_under_repeated_header: Option, in_last_region: bool, y: usize, x: usize, @@ -499,16 +500,11 @@ pub fn hline_stroke_at_column( // Top border stroke and header stroke are generally prioritized, unless // they don't have explicit hline overrides and one or more user-provided // hlines would appear at the same position, which then are prioritized. - let top_stroke_comes_from_header = grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) + let top_stroke_comes_from_header = end_under_repeated_header .zip(local_top_y) - .is_some_and(|(header, local_top_y)| { + .is_some_and(|(last_repeated_header_end, local_top_y)| { // Ensure the row above us is a repeated header. - // FIXME: Make this check more robust when headers at arbitrary - // positions are added. - local_top_y < header.end && y > header.end + local_top_y < last_repeated_header_end && y > last_repeated_header_end }); // Prioritize the footer's top stroke as well where applicable. @@ -1268,6 +1264,7 @@ mod test { grid, &rows, y.checked_sub(1), + None, true, y, x, @@ -1461,6 +1458,7 @@ mod test { grid, &rows, y.checked_sub(1), + None, true, y, x, @@ -1506,6 +1504,7 @@ mod test { grid, &rows, if y == 4 { Some(2) } else { y.checked_sub(1) }, + None, true, y, x, diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 30fbd9988..fddb04f3e 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -253,7 +253,7 @@ impl<'a> GridLayouter<'a> { self.finish_region_internal( Frame::soft(Axes::splat(Abs::zero())), vec![], - Abs::zero(), + Default::default(), ); // TODO: re-calculate heights of headers and footers on each region @@ -321,8 +321,12 @@ impl<'a> GridLayouter<'a> { // Include both repeating and pending header rows as this number is // used for orphan prevention. self.current_header_rows = repeating_header_rows + pending_header_rows; + self.current_repeating_header_rows = repeating_header_rows; self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; + self.current_last_repeated_header_end = + self.repeating_headers.last().map(|h| h.end).unwrap_or_default(); + // Reset the header height for this region. // It will be re-calculated when laying out each header row. self.header_height = Abs::zero(); @@ -454,7 +458,7 @@ impl<'a> GridLayouter<'a> { self.finish_region_internal( Frame::soft(Axes::splat(Abs::zero())), vec![], - Abs::zero(), + Default::default(), ); skipped_region = true; } diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 2e210811f..42cb77962 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -158,7 +158,7 @@ impl GridLayouter<'_> { let finished_header_rows = self .finished_header_rows .iter() - .copied() + .map(|info| info.height) .chain(current_header_row_height) .chain(std::iter::repeat(Abs::zero())); From 27557ee155c31ea998d470eac74989c91de078df Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 03:43:51 -0300 Subject: [PATCH 16/82] fix accidental error dropping in pending header layout --- crates/typst-layout/src/grid/repeated.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index fddb04f3e..1f677e8ad 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -52,7 +52,7 @@ impl<'a> GridLayouter<'a> { _ => (consecutive_headers, Default::default()), }; - self.layout_new_pending_headers(non_conflicting_headers, engine); + self.layout_new_pending_headers(non_conflicting_headers, engine)?; // Layout each conflicting header independently, without orphan // prevention (as they don't go into 'pending_headers'). @@ -118,9 +118,9 @@ impl<'a> GridLayouter<'a> { &mut self, headers: &'a [Repeatable
], engine: &mut Engine, - ) { + ) -> SourceResult<()> { let [first_header, ..] = headers else { - return; + return Ok(()); }; // Assuming non-conflicting headers sorted by increasing y, this must // be the header with the lowest level (sorted by increasing levels). @@ -148,12 +148,14 @@ impl<'a> GridLayouter<'a> { self.layout_headers( HeadersToLayout::NewHeaders { headers, short_lived: false }, engine, - ); + )?; // After the first subsequent row is laid out, move to repeating, as // it's then confirmed the headers won't be moved due to orphan // prevention anymore. self.pending_headers = headers; + + Ok(()) } pub fn flush_pending_headers(&mut self) { From 054b3b89d1e5c5ff460d4f799f804d221b8873e5 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 03:44:55 -0300 Subject: [PATCH 17/82] remove bump repeating headers this has been done on pending header layout now --- crates/typst-layout/src/grid/repeated.rs | 27 ------------------------ 1 file changed, 27 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 1f677e8ad..0809d0b7b 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -180,33 +180,6 @@ impl<'a> GridLayouter<'a> { self.pending_headers = Default::default(); } - pub fn bump_repeating_headers(&mut self) { - debug_assert!(!self.upcoming_headers.is_empty()); - - let [next_header, ..] = self.upcoming_headers else { - return; - }; - - // Keep only lower level headers. Assume sorted by increasing levels. - self.repeating_headers.truncate( - self.repeating_headers - .partition_point(|h| h.level < next_header.unwrap().level), - ); - - if let Repeatable::Repeated(next_header) = next_header { - // Vector remains sorted by increasing levels: - // - It was sorted before, so the truncation above only keeps - // elements with a lower level. - // - Therefore, by pushing this header to the end, it will have - // a level larger than all the previous headers, and is thus - // in its 'correct' position. - self.repeating_headers.push(next_header); - } - - // Laying out the next header now. - self.upcoming_headers = self.upcoming_headers.get(1..).unwrap_or_default(); - } - /// Layouts the headers' rows. /// /// Assumes the footer height for the current region has already been From 75403f86a9729d40947eeff7e175afb9fe19c77b Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 03:51:06 -0300 Subject: [PATCH 18/82] flush pending headers as soon as possible - dont wait until the end of the region, as a header can start and end in the same region (i.e. never repeat). --- crates/typst-layout/src/grid/layouter.rs | 6 ++++++ crates/typst-layout/src/grid/repeated.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 600e051b5..5afc423ad 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -244,6 +244,7 @@ impl<'a> GridLayouter<'a> { if y >= footer.start { if y == footer.start { self.layout_footer(footer, engine, self.finished.len())?; + self.flush_pending_headers(); } continue; } @@ -251,6 +252,11 @@ impl<'a> GridLayouter<'a> { self.layout_row(y, engine, 0)?; + // After the first non-header row is placed, pending headers are no + // longer orphans and can repeat, so we move them to repeating + // headers. + self.flush_pending_headers(); + y += 1; } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 0809d0b7b..b64301b07 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -73,6 +73,14 @@ impl<'a> GridLayouter<'a> { )? } + // No chance of orphans as we're immediately placing conflicting + // headers afterwards, which basically are not headers, for all intents + // and purposes. It is therefore guaranteed that all new headers have + // been placed at least once. + if !conflicting_headers.is_empty() { + self.flush_pending_headers(); + } + Ok(()) } @@ -122,6 +130,13 @@ impl<'a> GridLayouter<'a> { let [first_header, ..] = headers else { return Ok(()); }; + + // Should be impossible to have two consecutive chunks of pending + // headers since they are always as long as possible, only being + // interrupted by direct conflict between consecutive headers, in which + // case we flush pending headers immediately. + assert!(self.pending_headers.is_empty()); + // Assuming non-conflicting headers sorted by increasing y, this must // be the header with the lowest level (sorted by increasing levels). let first_level = first_header.unwrap().level; From fbb0306ebcb3de0862ba314f39af335d44fe6adb Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 04:35:42 -0300 Subject: [PATCH 19/82] fix non stopping footer --- crates/typst-layout/src/grid/layouter.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 5afc423ad..14656ba26 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -246,6 +246,7 @@ impl<'a> GridLayouter<'a> { self.layout_footer(footer, engine, self.finished.len())?; self.flush_pending_headers(); } + y = footer.end; continue; } } From e73c561f16a29f1f6177b8184e5b8fe2e0a5f16b Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 13:31:54 -0300 Subject: [PATCH 20/82] fix header height and rows state calculations - remove non-repeating header height on pending flush - use lrows - reset current region dataon orphan prevention row wipe --- crates/typst-layout/src/grid/layouter.rs | 3 +++ crates/typst-layout/src/grid/repeated.rs | 16 +++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 14656ba26..6bdeb90ac 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1460,6 +1460,9 @@ impl<'a> GridLayouter<'a> { // Header and footer would be alone in this region, but there are more // rows beyond the header and the footer. Push an empty region. self.lrows.clear(); + self.current_last_repeated_header_end = 0; + self.current_repeating_header_rows = 0; + self.current_header_rows = 0; true } else { false diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index b64301b07..264210a75 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -152,10 +152,14 @@ impl<'a> GridLayouter<'a> { // space in future regions anymore. for removed_height in self.repeating_header_heights.drain(first_conflicting_pos..) { - self.header_height -= removed_height; self.repeating_header_height -= removed_height; } + // Non-repeating headers stop at the pending stage for orphan + // prevention only. Flushing pending headers, so those will no longer + // appear in a future region. + self.header_height = self.repeating_header_height; + // Let's try to place them at least once. // This might be a waste as we could generate an orphan and thus have // to try to place old and new headers all over again, but that happens @@ -308,10 +312,6 @@ impl<'a> GridLayouter<'a> { self.pending_headers.into_iter().map(Repeatable::unwrap), ); - // Include both repeating and pending header rows as this number is - // used for orphan prevention. - self.current_header_rows = repeating_header_rows + pending_header_rows; - self.current_repeating_header_rows = repeating_header_rows; self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; self.current_last_repeated_header_end = @@ -355,6 +355,8 @@ impl<'a> GridLayouter<'a> { i += 1; } + self.current_repeating_header_rows = self.lrows.len(); + for header in self.pending_headers { let header_height = self.layout_header_rows(header.unwrap(), engine, disambiguator)?; @@ -364,6 +366,10 @@ impl<'a> GridLayouter<'a> { self.repeating_header_heights.push(header_height); } } + + // Include both repeating and pending header rows as this number is + // used for orphan prevention. + self.current_header_rows = self.lrows.len(); } if let HeadersToLayout::NewHeaders { headers, short_lived } = headers { From fe08df8ee66530e679751e55198376b8fe08371c Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 14:01:25 -0300 Subject: [PATCH 21/82] fix consecutive header row check check if there is another header at the end, not if at the next row --- crates/typst-layout/src/grid/layouter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 6bdeb90ac..08c44f3a2 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -221,7 +221,7 @@ impl<'a> GridLayouter<'a> { if self.upcoming_headers.get(consecutive_header_count).is_none_or( |h| { - h.unwrap().start > y + 1 + h.unwrap().start > first_header.unwrap().end || h.unwrap().level <= first_header.unwrap().level }, ) { From 0c6fae92f03e737ef2fc19322514cf06a6e912ec Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 14:01:25 -0300 Subject: [PATCH 22/82] fix orphan prevention - Include new headers at the start as part of the region's header rows - Check if header rows are all rows --- crates/typst-layout/src/grid/layouter.rs | 2 +- crates/typst-layout/src/grid/repeated.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 08c44f3a2..20766bb38 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1447,7 +1447,7 @@ impl<'a> GridLayouter<'a> { .as_ref() .and_then(Repeatable::as_repeated) .is_none_or(|footer| footer.start != last_header_end) - && self.lrows.last().is_some_and(|row| row.index() < last_header_end) + && self.lrows.len() == self.current_header_rows && !in_last_with_offset( self.regions, // Since we're trying to find a region where to place all diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 264210a75..33534e0f2 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -373,6 +373,7 @@ impl<'a> GridLayouter<'a> { } if let HeadersToLayout::NewHeaders { headers, short_lived } = headers { + let placing_at_the_start = skipped_region || self.lrows.is_empty(); for header in headers { let header_height = self.layout_header_rows(header.unwrap(), engine, disambiguator)?; @@ -391,6 +392,11 @@ impl<'a> GridLayouter<'a> { } } } + + if placing_at_the_start { + // Track header rows at the start of the region. + self.current_header_rows = self.lrows.len(); + } } Ok(()) From 6b133dca3f7a51179b7f852632f36ca4e2fbde73 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 14:59:58 -0300 Subject: [PATCH 23/82] fix gutter popping edge case - With a header at the end, decrease the header row count for orphan prevention. --- crates/typst-layout/src/grid/layouter.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 20766bb38..6b6d7cae0 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1432,6 +1432,13 @@ impl<'a> GridLayouter<'a> { { // Remove the last row in the region if it is a gutter row. self.lrows.pop().unwrap(); + + if self.lrows.len() == self.current_header_rows { + if self.current_header_rows == self.current_repeating_header_rows { + self.current_repeating_header_rows -= 1; + } + self.current_header_rows -= 1; + } } let footer_would_be_widow = if let Some(last_header_row) = self From fa45bf8b55dd2c6762b0133094593fc66a6d3422 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 15:01:39 -0300 Subject: [PATCH 24/82] fix row group indices with gutter in resolve --- crates/typst-library/src/layout/grid/resolve.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index d49b81e0e..a6ca3635a 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1742,6 +1742,10 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // subtract 1 from the gutter case). // Don't do this if there are no rows under the header. if has_gutter { + // Index of first y is doubled, as each row before it + // receives a gutter row below. + header.start *= 2; + // - 'header.end' is always 'last y + 1'. The header stops // before that row. // - Therefore, '2 * header.end' will be 2 * (last y + 1), @@ -1791,6 +1795,18 @@ impl<'x> CellGridResolver<'_, '_, 'x> { if header_end != Some(footer.start) { footer.start = footer.start.saturating_sub(1); } + + // Adapt footer end but DO NOT include the gutter below it, + // if it exists. Calculation: + // - Starts as 'last y + 1'. + // - The result will be + // 2 * (last_y + 1) - 1 = 2 * last_y + 1, + // which is the new index of the last footer row plus one, + // meaning we do exclude any gutter below this way. + // + // It also keeps us within the total amount of rows, so we + // don't need to '.min()' later. + footer.end = (2 * footer.end).saturating_sub(1); } Ok(footer) From c90ec3c4da3c9b06febde6cae446daa3c8dc677b Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 15:22:10 -0300 Subject: [PATCH 25/82] listen to clippy --- crates/typst-layout/src/grid/lines.rs | 1 + crates/typst-layout/src/grid/repeated.rs | 19 ++++++++----------- crates/typst-layout/src/grid/rowspans.rs | 4 ++-- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs index f0da6fb97..2eb8072ee 100644 --- a/crates/typst-layout/src/grid/lines.rs +++ b/crates/typst-layout/src/grid/lines.rs @@ -391,6 +391,7 @@ pub fn vline_stroke_at_row( /// /// This function assumes columns are sorted by increasing `x`, and rows are /// sorted by increasing `y`. +#[allow(clippy::too_many_arguments)] pub fn hline_stroke_at_column( grid: &CellGrid, rows: &[RowPiece], diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 33534e0f2..98eb7f77f 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -1,5 +1,3 @@ -use std::ops::Deref; - use typst_library::diag::SourceResult; use typst_library::engine::Engine; use typst_library::layout::grid::resolve::{Footer, Header, Repeatable}; @@ -220,14 +218,14 @@ impl<'a> GridLayouter<'a> { HeadersToLayout::RepeatingAndPending => self.simulate_header_height( self.repeating_headers .iter() - .map(Deref::deref) - .chain(self.pending_headers.into_iter().map(Repeatable::unwrap)), + .copied() + .chain(self.pending_headers.iter().map(Repeatable::unwrap)), &self.regions, engine, disambiguator, )?, HeadersToLayout::NewHeaders { headers, .. } => self.simulate_header_height( - headers.into_iter().map(Repeatable::unwrap), + headers.iter().map(Repeatable::unwrap), &self.regions, engine, disambiguator, @@ -260,9 +258,8 @@ impl<'a> GridLayouter<'a> { // when beginning a new region. self.simulate_header_height( self.repeating_headers - .iter() - .map(|h| *h) - .chain(self.pending_headers.into_iter().chain(headers).map(Repeatable::unwrap)), + .iter().copied() + .chain(self.pending_headers.iter().chain(headers).map(Repeatable::unwrap)), &self.regions, engine, disambiguator, @@ -297,7 +294,7 @@ impl<'a> GridLayouter<'a> { // new region to make sure row code is aware that all of those // headers should stay together! self.unbreakable_rows_left += - total_header_row_count(headers.into_iter().map(Repeatable::unwrap)); + total_header_row_count(headers.iter().map(Repeatable::unwrap)); } // Need to relayout ALL headers if we skip a region, not only the @@ -306,10 +303,10 @@ impl<'a> GridLayouter<'a> { // footers. if matches!(headers, HeadersToLayout::RepeatingAndPending) || skipped_region { let repeating_header_rows = - total_header_row_count(self.repeating_headers.iter().map(Deref::deref)); + total_header_row_count(self.repeating_headers.iter().copied()); let pending_header_rows = total_header_row_count( - self.pending_headers.into_iter().map(Repeatable::unwrap), + self.pending_headers.iter().map(Repeatable::unwrap), ); self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 42cb77962..497490e7d 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -1176,10 +1176,10 @@ impl<'a> RowspanSimulator<'a> { { // Only repeating headers have survived after the first region // break. - let repeating_headers = layouter.repeating_headers.iter().map(|h| *h).chain( + let repeating_headers = layouter.repeating_headers.iter().copied().chain( layouter .pending_headers - .into_iter() + .iter() .filter_map(Repeatable::as_repeated), ); From 48d0a07ef4de4732235bdc153a3990b045fde8bd Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 15:31:27 -0300 Subject: [PATCH 26/82] formatting --- crates/typst-layout/src/grid/rowspans.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 497490e7d..7ab254d67 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -1177,10 +1177,7 @@ impl<'a> RowspanSimulator<'a> { // Only repeating headers have survived after the first region // break. let repeating_headers = layouter.repeating_headers.iter().copied().chain( - layouter - .pending_headers - .iter() - .filter_map(Repeatable::as_repeated), + layouter.pending_headers.iter().filter_map(Repeatable::as_repeated), ); let header_height = layouter.simulate_header_height( From 5e2241ab65064d9a1a93deab7b92d7f7ec5d6ccd Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 6 Apr 2025 15:17:29 -0300 Subject: [PATCH 27/82] initial subheader resolving and api --- crates/typst-layout/src/grid/lines.rs | 4 +- crates/typst-library/src/foundations/int.rs | 17 ++- crates/typst-library/src/layout/grid/mod.rs | 10 +- .../typst-library/src/layout/grid/resolve.rs | 119 +++++++++--------- crates/typst-library/src/model/table.rs | 10 +- crates/typst-utils/src/lib.rs | 9 +- 6 files changed, 104 insertions(+), 65 deletions(-) diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs index 2eb8072ee..535b901e1 100644 --- a/crates/typst-layout/src/grid/lines.rs +++ b/crates/typst-layout/src/grid/lines.rs @@ -634,7 +634,7 @@ mod test { }, vec![], vec![], - None, + vec![], None, entries, ) @@ -1172,7 +1172,7 @@ mod test { }, vec![], vec![], - None, + vec![], None, entries, ) diff --git a/crates/typst-library/src/foundations/int.rs b/crates/typst-library/src/foundations/int.rs index 83a89bf8a..f65641ff1 100644 --- a/crates/typst-library/src/foundations/int.rs +++ b/crates/typst-library/src/foundations/int.rs @@ -1,4 +1,6 @@ -use std::num::{NonZeroI64, NonZeroIsize, NonZeroU64, NonZeroUsize, ParseIntError}; +use std::num::{ + NonZeroI64, NonZeroIsize, NonZeroU32, NonZeroU64, NonZeroUsize, ParseIntError, +}; use ecow::{eco_format, EcoString}; use smallvec::SmallVec; @@ -482,3 +484,16 @@ cast! { "number too large" })?, } + +cast! { + NonZeroU32, + self => Value::Int(self.get() as _), + v: i64 => v + .try_into() + .and_then(|v: u32| v.try_into()) + .map_err(|_| if v <= 0 { + "number must be positive" + } else { + "number too large" + })?, +} diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index 6616c3311..7ee323967 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -1,6 +1,6 @@ pub mod resolve; -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use comemo::Track; @@ -468,6 +468,14 @@ pub struct GridHeader { #[default(true)] pub repeat: bool, + /// The level of the header. Must not be zero. + /// + /// This is used during repetition multiple headers at once. When a header + /// with a lower level starts repeating, all headers with a lower level stop + /// repeating. + #[default(NonZeroU32::ONE)] + pub level: NonZeroU32, + /// The cells and lines within the header. #[variadic] pub children: Vec, diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index a6ca3635a..3f8006831 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1,4 +1,4 @@ -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::ops::Range; use std::sync::Arc; @@ -48,6 +48,7 @@ pub fn grid_to_cellgrid<'a>( let children = elem.children.iter().map(|child| match child { GridChild::Header(header) => ResolvableGridChild::Header { repeat: header.repeat(styles), + level: header.level(styles), span: header.span(), items: header.children.iter().map(resolve_item), }, @@ -101,6 +102,7 @@ pub fn table_to_cellgrid<'a>( let children = elem.children.iter().map(|child| match child { TableChild::Header(header) => ResolvableGridChild::Header { repeat: header.repeat(styles), + level: header.level(styles), span: header.span(), items: header.children.iter().map(resolve_item), }, @@ -647,7 +649,7 @@ impl<'a> Entry<'a> { /// Any grid child, which can be either a header or an item. pub enum ResolvableGridChild { - Header { repeat: bool, span: Span, items: I }, + Header { repeat: bool, level: NonZeroU32, span: Span, items: I }, Footer { repeat: bool, span: Span, items: I }, Item(ResolvableGridItem), } @@ -668,10 +670,10 @@ pub struct CellGrid<'a> { /// Gutter rows are not included. /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. pub hlines: Vec>, - /// The repeatable footer of this grid. - pub footer: Option>, /// The repeatable headers of this grid. pub headers: Vec>, + /// The repeatable footer of this grid. + pub footer: Option>, /// Whether this grid has gutters. pub has_gutter: bool, } @@ -684,7 +686,7 @@ impl<'a> CellGrid<'a> { cells: impl IntoIterator>, ) -> Self { let entries = cells.into_iter().map(Entry::Cell).collect(); - Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries) + Self::new_internal(tracks, gutter, vec![], vec![], vec![], None, entries) } /// Generates the cell grid, given the tracks and resolved entries. @@ -693,7 +695,7 @@ impl<'a> CellGrid<'a> { gutter: Axes<&[Sizing]>, vlines: Vec>, hlines: Vec>, - header: Option>, + headers: Vec>, footer: Option>, entries: Vec>, ) -> Self { @@ -747,8 +749,8 @@ impl<'a> CellGrid<'a> { entries, vlines, hlines, + headers, footer, - headers: header.into_iter().collect(), has_gutter, } } @@ -972,6 +974,9 @@ struct RowGroupData { span: Span, kind: RowGroupKind, + /// Level of this header or footer. + repeatable_level: NonZeroU32, + /// Start of the range of indices of hlines at the top of the row group. /// This is always the first index after the last hline before we started /// building the row group - any upcoming hlines would appear at least at @@ -1019,7 +1024,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { let mut pending_vlines: Vec<(Span, Line)> = vec![]; let has_gutter = self.gutter.any(|tracks| !tracks.is_empty()); - let mut header: Option
= None; + let mut headers: Vec
= vec![]; let mut repeat_header = false; // Stores where the footer is supposed to end, its span, and the @@ -1063,7 +1068,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { columns, &mut pending_hlines, &mut pending_vlines, - &mut header, + &mut headers, &mut repeat_header, &mut footer, &mut repeat_footer, @@ -1084,9 +1089,9 @@ impl<'x> CellGridResolver<'_, '_, 'x> { row_amount, )?; - let (header, footer) = self.finalize_headers_and_footers( + let (headers, footer) = self.finalize_headers_and_footers( has_gutter, - header, + headers, repeat_header, footer, repeat_footer, @@ -1098,7 +1103,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { self.gutter, vlines, hlines, - header, + headers, footer, resolved_cells, )) @@ -1118,7 +1123,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { columns: usize, pending_hlines: &mut Vec<(Span, Line, bool)>, pending_vlines: &mut Vec<(Span, Line)>, - header: &mut Option
, + headers: &mut Vec
, repeat_header: &mut bool, footer: &mut Option<(usize, Span, Footer)>, repeat_footer: &mut bool, @@ -1158,15 +1163,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> { let mut first_available_row = 0; let (header_footer_items, simple_item) = match child { - ResolvableGridChild::Header { repeat, span, items, .. } => { - if header.is_some() { - bail!(span, "cannot have more than one header"); - } - + ResolvableGridChild::Header { repeat, level, span, items, .. } => { row_group_data = Some(RowGroupData { range: None, span, kind: RowGroupKind::Header, + repeatable_level: level, top_hlines_start: pending_hlines.len(), top_hlines_end: None, }); @@ -1198,6 +1200,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { range: None, span, kind: RowGroupKind::Footer, + repeatable_level: NonZeroU32::ONE, top_hlines_start: pending_hlines.len(), top_hlines_end: None, }); @@ -1330,7 +1333,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { cell_y, colspan, rowspan, - header.as_ref(), + headers, footer.as_ref(), resolved_cells, &mut local_auto_index, @@ -1518,15 +1521,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { match row_group.kind { RowGroupKind::Header => { - if group_range.start != 0 { - bail!( - row_group.span, - "header must start at the first row"; - hint: "remove any rows before the header" - ); - } - - *header = Some(Header { + headers.push(Header { start: group_range.start, // Later on, we have to correct this number in case there @@ -1535,7 +1530,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // below. end: group_range.end, - level: 1, + level: row_group.repeatable_level.get(), }); } @@ -1730,13 +1725,14 @@ impl<'x> CellGridResolver<'_, '_, 'x> { fn finalize_headers_and_footers( &self, has_gutter: bool, - header: Option
, + headers: Vec
, repeat_header: bool, footer: Option<(usize, Span, Footer)>, repeat_footer: bool, row_amount: usize, - ) -> SourceResult<(Option>, Option>)> { - let header = header + ) -> SourceResult<(Vec>, Option>)> { + let headers: Vec> = headers + .into_iter() .map(|mut header| { // Repeat the gutter below a header (hence why we don't // subtract 1 from the gutter case). @@ -1774,7 +1770,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { } else { Repeatable::NotRepeated(header) } - }); + }) + .collect(); let footer = footer .map(|(footer_end, footer_span, mut footer)| { @@ -1782,8 +1779,10 @@ impl<'x> CellGridResolver<'_, '_, 'x> { bail!(footer_span, "footer must end at the last row"); } - let header_end = - header.as_ref().map(Repeatable::unwrap).map(|header| header.end); + // TODO: will need a global slice of headers and footers for + // when we have multiple footers + let last_header_end = + headers.last().map(Repeatable::unwrap).map(|header| header.end); if has_gutter { // Convert the footer's start index to post-gutter coordinates. @@ -1792,7 +1791,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // Include the gutter right before the footer, unless there is // none, or the gutter is already included in the header (no // rows between the header and the footer). - if header_end != Some(footer.start) { + if last_header_end != Some(footer.start) { footer.start = footer.start.saturating_sub(1); } @@ -1820,7 +1819,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { } }); - Ok((header, footer)) + Ok((headers, footer)) } /// Resolves the cell's fields based on grid-wide properties. @@ -1991,23 +1990,25 @@ fn expand_row_group( /// Check if a cell's fixed row would conflict with a header or footer. fn check_for_conflicting_cell_row( - header: Option<&Header>, + headers: &[Header], footer: Option<&(usize, Span, Footer)>, cell_y: usize, rowspan: usize, ) -> HintedStrResult<()> { - if let Some(header) = header { - // NOTE: y + rowspan >, not >=, header.start, to check if the rowspan - // enters the header. For example, consider a rowspan of 1: if - // `y + 1 = header.start` holds, that means `y < header.start`, and it - // only occupies one row (`y`), so the cell is actually not in - // conflict. - if cell_y < header.end && cell_y + rowspan > header.start { - bail!( - "cell would conflict with header spanning the same position"; - hint: "try moving the cell or the header" - ); - } + // TODO: use upcoming headers slice to make this an O(1) check + // NOTE: y + rowspan >, not >=, header.start, to check if the rowspan + // enters the header. For example, consider a rowspan of 1: if + // `y + 1 = header.start` holds, that means `y < header.start`, and it + // only occupies one row (`y`), so the cell is actually not in + // conflict. + if headers + .iter() + .any(|header| cell_y < header.end && cell_y + rowspan > header.start) + { + bail!( + "cell would conflict with header spanning the same position"; + hint: "try moving the cell or the header" + ); } if let Some((_, _, footer)) = footer { @@ -2037,7 +2038,7 @@ fn resolve_cell_position( cell_y: Smart, colspan: usize, rowspan: usize, - header: Option<&Header>, + headers: &[Header], footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option], auto_index: &mut usize, @@ -2062,7 +2063,7 @@ fn resolve_cell_position( // but automatically-positioned cells will avoid conflicts by // simply skipping existing cells, headers and footers. let resolved_index = find_next_available_position::( - header, + headers, footer, resolved_cells, columns, @@ -2102,7 +2103,7 @@ fn resolve_cell_position( // footer (but only if it isn't already in one, otherwise there // will already be a separate check). if !in_row_group { - check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?; + check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?; } cell_index(cell_x, cell_y) @@ -2120,7 +2121,7 @@ fn resolve_cell_position( // ('None'), in which case we'd create a new row to place this // cell in. find_next_available_position::( - header, + headers, footer, resolved_cells, columns, @@ -2134,7 +2135,7 @@ fn resolve_cell_position( // footer (but only if it isn't already in one, otherwise there // will already be a separate check). if !in_row_group { - check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?; + check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?; } // Let's find the first column which has that row available. @@ -2168,7 +2169,7 @@ fn resolve_cell_position( /// have cells specified by the user) as well as any headers and footers. #[inline] fn find_next_available_position( - header: Option<&Header>, + headers: &[Header], footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option>], columns: usize, @@ -2195,9 +2196,9 @@ fn find_next_available_position( // would become impractically large before this overflows. resolved_index += 1; } - } else if let Some(header) = - header.filter(|header| resolved_index < header.end * columns) - { + } else if let Some(header) = headers.iter().find(|header| { + (header.start * columns..header.end * columns).contains(&resolved_index) + }) { // Skip header (can't place a cell inside it from outside it). resolved_index = header.end * columns; diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 921fe5079..86ef59ed1 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -1,4 +1,4 @@ -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use typst_utils::NonZeroExt; @@ -494,6 +494,14 @@ pub struct TableHeader { #[default(true)] pub repeat: bool, + /// The level of the header. Must not be zero. + /// + /// This is used during repetition multiple headers at once. When a header + /// with a lower level starts repeating, all headers with a lower level stop + /// repeating. + #[default(NonZeroU32::ONE)] + pub level: NonZeroU32, + /// The cells and lines within the header. #[variadic] pub children: Vec, diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs index b346a8096..8102e171f 100644 --- a/crates/typst-utils/src/lib.rs +++ b/crates/typst-utils/src/lib.rs @@ -26,7 +26,7 @@ pub use once_cell; use std::fmt::{Debug, Formatter}; use std::hash::Hash; use std::iter::{Chain, Flatten, Rev}; -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::ops::{Add, Deref, Div, Mul, Neg, Sub}; use std::sync::Arc; @@ -72,6 +72,13 @@ impl NonZeroExt for NonZeroUsize { }; } +impl NonZeroExt for NonZeroU32 { + const ONE: Self = match Self::new(1) { + Some(v) => v, + None => unreachable!(), + }; +} + /// Extra methods for [`Arc`]. pub trait ArcExt { /// Takes the inner value if there is exactly one strong reference and From 8e50df544d2ee8e0179af7589d83207fca1fed19 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:43:12 -0300 Subject: [PATCH 28/82] update tests with non-top header --- ...id-header-not-at-first-row-two-columns.png | Bin 0 -> 176 bytes tests/ref/grid-header-not-at-first-row.png | Bin 0 -> 176 bytes ...59-column-override-stays-inside-header.png | Bin 0 -> 674 bytes tests/suite/layout/grid/headers.typ | 6 ----- tests/suite/layout/grid/subheaders.typ | 24 ++++++++++++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 tests/ref/grid-header-not-at-first-row-two-columns.png create mode 100644 tests/ref/grid-header-not-at-first-row.png create mode 100644 tests/ref/issue-5359-column-override-stays-inside-header.png create mode 100644 tests/suite/layout/grid/subheaders.typ diff --git a/tests/ref/grid-header-not-at-first-row-two-columns.png b/tests/ref/grid-header-not-at-first-row-two-columns.png new file mode 100644 index 0000000000000000000000000000000000000000..21ee5f93a421c23e8dda742c4eb3886bb3cac055 GIT binary patch literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=W0VEjA`wR?$RJo^%V@SoVmDLvn{Rnn3D?czT5o{NOk@`w`ewV;>U%57`?8l zf13YSPNG}(`tH3F67v7&-?MiR^N%nU5R)&qn(`}ILFeC>XP5S0yp;(xpGl0F;ZSmDLvn{Rnn3D?czT5o{NOk@`w`ewV;>U%57`?8l zf13YSPNG}(`tH3F67v7&-?MiR^N%nU5R)&qn(`}ILFeC>XP5S0yp;(xpGl0F;ZSJGgviH_kPLjn0MN)2TI4$-6e70 z>7#r6!I#YdY!&|F=x#<{5{GBKPh8?RU|WOV=CnXL)@Xvk zVZQp^pX3dWNIqccSSXSMeKih+W7cE=8*ZY*>Ye(}qEW)Owbf@^^B(}XZ~#~9&^1wfE1?2<*{-}#yy{#8}*&a7nw zgo;~69{9MzQimqm?C86sH28+;fU>o24v4 zETLyi;WXeMRr2sa6^6nTrZA8D4VwpGPtrTzGXMYp07*qo IM6N<$g0lNP@&Et; literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index 229bce614..f46f351e0 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -118,16 +118,12 @@ ) --- grid-header-not-at-first-row --- -// Error: 3:3-3:19 header must start at the first row -// Hint: 3:3-3:19 remove any rows before the header #grid( [a], grid.header([b]) ) --- grid-header-not-at-first-row-two-columns --- -// Error: 4:3-4:19 header must start at the first row -// Hint: 4:3-4:19 remove any rows before the header #grid( columns: 2, [a], @@ -463,8 +459,6 @@ #table( columns: 3, [Outside], - // Error: 1:3-4:4 header must start at the first row - // Hint: 1:3-4:4 remove any rows before the header table.header( [A], table.cell(x: 1)[B], [C], table.cell(x: 1)[D], diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ new file mode 100644 index 000000000..1946719d8 --- /dev/null +++ b/tests/suite/layout/grid/subheaders.typ @@ -0,0 +1,24 @@ +--- grid-subheaders --- +#set page(width: auto, height: 12em) +#let rows(n) = { + range(n).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten() +} +#table( + columns: 5, + align: center + horizon, + table.header( + table.cell(colspan: 5)[*Cool Zone*], + ), + table.header( + level: 2, + table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*], + table.hline(start: 2, end: 3, stroke: yellow) + ), + ..rows(6), + table.header( + level: 2, + table.cell(stroke: red)[*New Name*], table.cell(stroke: aqua, colspan: 4)[*Other Data*], + table.hline(start: 2, end: 3, stroke: yellow) + ), + ..rows(5) +) From 05d4af43b6a3624620a600a1946c3375cd942cf2 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:19:28 -0300 Subject: [PATCH 29/82] only match consecutive conflicting headers --- crates/typst-layout/src/grid/layouter.rs | 54 ++++++++++++++++++------ crates/typst-layout/src/grid/repeated.rs | 19 +++------ 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 6b6d7cae0..b0505bc32 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -219,21 +219,47 @@ impl<'a> GridLayouter<'a> { if first_header.unwrap().range().contains(&y) { consecutive_header_count += 1; - if self.upcoming_headers.get(consecutive_header_count).is_none_or( - |h| { - h.unwrap().start > first_header.unwrap().end - || h.unwrap().level <= first_header.unwrap().level - }, - ) { - // Next row either isn't a header. or is in a - // conflicting one, which is the sign that we need to go. - self.place_new_headers( - first_header, - consecutive_header_count, - engine, - )?; - consecutive_header_count = 0; + // TODO: surely there is a better way to do this + match self.upcoming_headers.get(consecutive_header_count) { + // No more headers, so place the latest headers. + None => { + self.place_new_headers( + consecutive_header_count, + None, + engine, + )?; + consecutive_header_count = 0; + } + // Next header is not consecutive, so place the latest headers. + Some(next_header) + if next_header.unwrap().start > first_header.unwrap().end => + { + self.place_new_headers( + consecutive_header_count, + None, + engine, + )?; + consecutive_header_count = 0; + } + // Next header is consecutive and conflicts with one or + // more of the latest consecutive headers, so we must + // place them before proceeding. + Some(next_header) + if next_header.unwrap().level + <= first_header.unwrap().level => + { + self.place_new_headers( + consecutive_header_count, + Some(next_header), + engine, + )?; + consecutive_header_count = 0; + } + // Next header is a non-conflicting consecutive header. + // Keep collecting more headers. + _ => {} } + y = first_header.unwrap().end; // Skip header rows during normal layout. continue; diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 98eb7f77f..6ba023fe7 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -24,28 +24,21 @@ pub enum HeadersToLayout<'a> { impl<'a> GridLayouter<'a> { pub fn place_new_headers( &mut self, - first_header: &Repeatable
, consecutive_header_count: usize, + conflicting_header: Option<&Repeatable
>, engine: &mut Engine, ) -> SourceResult<()> { - // Next row either isn't a header. or is in a - // conflicting one, which is the sign that we need to go. let (consecutive_headers, new_upcoming_headers) = self.upcoming_headers.split_at(consecutive_header_count); self.upcoming_headers = new_upcoming_headers; - let (non_conflicting_headers, conflicting_headers) = match self - .upcoming_headers - .get(consecutive_header_count) - .map(Repeatable::unwrap) - { - Some(next_header) if next_header.level <= first_header.unwrap().level => { + let (non_conflicting_headers, conflicting_headers) = match conflicting_header { + Some(conflicting_header) => { // All immediately conflicting headers will // be placed as normal rows. - consecutive_headers.split_at( - consecutive_headers - .partition_point(|h| next_header.level > h.unwrap().level), - ) + consecutive_headers.split_at(consecutive_headers.partition_point(|h| { + conflicting_header.unwrap().level > h.unwrap().level + })) } _ => (consecutive_headers, Default::default()), }; From 62c5b551f1c994eec042a5cfc72770b39f5a350d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:29:30 -0300 Subject: [PATCH 30/82] rename 'in_last_with_offset' to 'may_progress_with_offset' --- crates/typst-layout/src/grid/layouter.rs | 25 ++++++++++++++---------- crates/typst-layout/src/grid/rowspans.rs | 8 ++++---- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index b0505bc32..176080b6c 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1300,9 +1300,11 @@ impl<'a> GridLayouter<'a> { } // Skip to fitting region, but only if we aren't part of an unbreakable - // row group. We use 'in_last_with_offset' so our 'in_last' call - // properly considers that a header and a footer would be added on each - // region break. + // row group. We use 'may_progress_with_offset' so our 'may_progress' + // call properly considers that a header and a footer would be added + // on each region break, so we only keep skipping regions until we + // reach one with the same height of the 'last' region (which can be + // endlessly repeated) when subtracting header and footer height. // // See 'check_for_unbreakable_rows' as for why we're using // 'header_height' to predict header height and not @@ -1310,7 +1312,10 @@ impl<'a> GridLayouter<'a> { let height = frame.height(); while self.unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) - && !in_last_with_offset(self.regions, self.header_height + self.footer_height) + && may_progress_with_offset( + self.regions, + self.header_height + self.footer_height, + ) { self.finish_region(engine, false)?; @@ -1481,7 +1486,7 @@ impl<'a> GridLayouter<'a> { .and_then(Repeatable::as_repeated) .is_none_or(|footer| footer.start != last_header_end) && self.lrows.len() == self.current_header_rows - && !in_last_with_offset( + && may_progress_with_offset( self.regions, // Since we're trying to find a region where to place all // repeating + pending headers, it makes sense to use @@ -1507,7 +1512,7 @@ impl<'a> GridLayouter<'a> { // TODO: widow prevention for non-repeated footers with a similar // mechanism / when implementing multiple footers. self.lrows.is_empty() - && !in_last_with_offset( + && may_progress_with_offset( self.regions, // This header height isn't doing much as we just confirmed // that there are no headers in this region, but let's keep @@ -1738,12 +1743,12 @@ pub(super) fn points( }) } -/// Checks if the first region of a sequence of regions is the last usable +/// Checks if the first region of a sequence of regions is not the last usable /// region, assuming that the last region will always be occupied by some /// specific offset height, even after calling `.next()`, due to some /// additional logic which adds content automatically on each region turn (in /// our case, headers). -pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool { - regions.backlog.is_empty() - && regions.last.is_none_or(|height| regions.size.y + offset == height) +pub(super) fn may_progress_with_offset(regions: Regions<'_>, offset: Abs) -> bool { + !regions.backlog.is_empty() + || regions.last.is_some_and(|height| regions.size.y + offset != height) } diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 7ab254d67..dd3d6beff 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -5,7 +5,7 @@ use typst_library::layout::grid::resolve::Repeatable; use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; use typst_utils::MaybeReverseIter; -use super::layouter::{in_last_with_offset, points, Row}; +use super::layouter::{may_progress_with_offset, points, Row}; use super::{layout_cell, Cell, GridLayouter}; /// All information needed to layout a single rowspan. @@ -256,7 +256,7 @@ impl GridLayouter<'_> { // Skip to fitting region. while !self.regions.size.y.fits(row_group.height) - && !in_last_with_offset( + && may_progress_with_offset( self.regions, // Note that we consider that the exact same headers and footers will be // added if we skip like this (blocking other rows from being laid out) @@ -1096,7 +1096,7 @@ impl<'a> RowspanSimulator<'a> { 0, )?; while !self.regions.size.y.fits(row_group.height) - && !in_last_with_offset( + && may_progress_with_offset( self.regions, self.header_height + self.footer_height, ) @@ -1121,7 +1121,7 @@ impl<'a> RowspanSimulator<'a> { let mut skipped_region = false; while unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) - && !in_last_with_offset( + && may_progress_with_offset( self.regions, self.header_height + self.footer_height, ) From f3ae2930423bb1607dcfc4d2bf5947dd2f4235d3 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:34:08 -0300 Subject: [PATCH 31/82] proper region progress check for headers --- crates/typst-layout/src/grid/repeated.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 6ba023fe7..dc6cabc84 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -3,7 +3,7 @@ use typst_library::engine::Engine; use typst_library::layout::grid::resolve::{Footer, Header, Repeatable}; use typst_library::layout::{Abs, Axes, Frame, Regions}; -use super::layouter::GridLayouter; +use super::layouter::{may_progress_with_offset, GridLayouter}; use super::rowspans::UnbreakableRowGroup; pub enum HeadersToLayout<'a> { @@ -231,7 +231,16 @@ impl<'a> GridLayouter<'a> { let mut skipped_region = false; while self.unbreakable_rows_left == 0 && !self.regions.size.y.fits(header_height) - && self.regions.may_progress() + && may_progress_with_offset( + self.regions, + // - Assume footer height already starts subtracted from the + // first region's size; + // - On each iteration, we subtract footer height from the + // available size for consistency with the first region, so we + // need to consider the footer when evaluating if skipping yet + // another region would make a difference. + self.footer_height, + ) { // Advance regions without any output until we can place the // header and the footer. @@ -262,7 +271,6 @@ impl<'a> GridLayouter<'a> { skipped_region = true; - // Ensure we also take the footer into account for remaining space. self.regions.size.y -= self.footer_height; } From e142bb4e8f9d30a647d393475146f1a7a5582fe4 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:41:15 -0300 Subject: [PATCH 32/82] grid header multiple fixed --- tests/ref/grid-header-multiple.png | Bin 0 -> 214 bytes tests/suite/layout/grid/headers.typ | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 tests/ref/grid-header-multiple.png diff --git a/tests/ref/grid-header-multiple.png b/tests/ref/grid-header-multiple.png new file mode 100644 index 0000000000000000000000000000000000000000..199cc051f210366c1c60dad0655d36c730ce804b GIT binary patch literal 214 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=P0VEg%8WVp4skxpmjv*Ddl7HAcG$dYm6xi*q zE4Q`cN|KSzzg=4%K5B@l`F8B;6vmA|&bGAPV@@i(`)>C;Al3Q%+@jgci60mKVf4DH z{%QVSIf-uB>$~?#NXY-6f6v}Q%s;|ZKuo^aYRa!<1)YCio?Y5MG1T+V-8-LC9CYl; zUe(-di1?HI&}Kg4#vQf4wa+tdtf+r^>cQ;Ax$EFgn8U!3yk*+FZN_PRAP0N8`njxg HN@xNA-aTI_ literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index f46f351e0..20595c9f8 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -130,8 +130,7 @@ grid.header([b]) ) ---- grow-header-multiple --- -// Error: 3:3-3:19 cannot have more than one header +--- grid-header-multiple --- #grid( grid.header([a]), grid.header([b]), From ce41113d9c87a10439689e45628cb6228b9868a0 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:53:44 -0300 Subject: [PATCH 33/82] initial subheader tests --- tests/ref/grid-subheaders-alone.png | Bin 0 -> 256 bytes .../grid-subheaders-basic-non-consecutive.png | Bin 0 -> 256 bytes tests/ref/grid-subheaders-basic-replace.png | Bin 0 -> 321 bytes tests/ref/grid-subheaders-basic.png | Bin 0 -> 210 bytes ...grid-subheaders-repeat-non-consecutive.png | Bin 0 -> 599 bytes tests/ref/grid-subheaders-repeat.png | Bin 0 -> 472 bytes tests/suite/layout/grid/subheaders.typ | 100 ++++++++++++++++++ 7 files changed, 100 insertions(+) create mode 100644 tests/ref/grid-subheaders-alone.png create mode 100644 tests/ref/grid-subheaders-basic-non-consecutive.png create mode 100644 tests/ref/grid-subheaders-basic-replace.png create mode 100644 tests/ref/grid-subheaders-basic.png create mode 100644 tests/ref/grid-subheaders-repeat-non-consecutive.png create mode 100644 tests/ref/grid-subheaders-repeat.png diff --git a/tests/ref/grid-subheaders-alone.png b/tests/ref/grid-subheaders-alone.png new file mode 100644 index 0000000000000000000000000000000000000000..0e05dda8313abdee8f06f1e824d9a2d31e83cb2d GIT binary patch literal 256 zcmeAS@N?(olHy`uVBq!ia0vp^6+mpt0VEjKpDfq|q>g*KIEGZ*O8#N*(2#iLQDC>b zuAG~gzek1x+r`f>Qwy7%ZH)}H7fwIQ`&C28axzeMYsHL+Kc$(6k1}rj@uqjRCUerq z*__;a*^fNhb+`O3kka~|sU-R_YjvfEM{5$`IqrzdlEk}>f;amDof}`7h@6IpzTb@X* z6H9Mh_kNXKe4~+t(9ill5`M(dH{j43l5lALX)`G6Cc@Pgg&ebxsLQ E0Q2l`5C8xG literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-basic-non-consecutive.png b/tests/ref/grid-subheaders-basic-non-consecutive.png new file mode 100644 index 0000000000000000000000000000000000000000..9f3f84402e95d3be405d26fb2737808e5d0f3a87 GIT binary patch literal 256 zcmV+b0ssDqP)-V)2!xokcfC zRH}Y@_s_l817YzRt)KA&6VwaCo(xRMb;{}NfLOdY^M%&6fv`9??#H!917Y!rd%i^a_bKQ_ky8vu*{8Gl*!Wk7Tnl9y5j29i=DFC~jXl4OvQ@>Yo9p+qSRilUyL z;~ospFZrJv_|*5D|2ap(Kuba}!2}ab@E7p25?Vmu$!2ARz}Hk!4S|DQP6dIN;f`zj zSEu^K@w*EGZphwdOzNWH$RO}oIf1}E+gY|ozz)-EeL%n?Cup2Tx=bAd8<}MQN?neF zV9yi*&+O?k{kcSeBl1_%EC66Z@u)r^sgs!kW{>aE0|sV(hJo8d47>}JRuFjM;T;gz z*8*K2(HMI};G4$VphrR!xE4*gA@E$#?lGyeIZSF!&$1A>pD*NToCqeE;2(fL8PQ`V Tbe%SA00000NkvXXu0mjfM4^Wm literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-basic.png b/tests/ref/grid-subheaders-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..5a64680757e4120bc89ebd153bf23a5593c53c8c GIT binary patch literal 210 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=P0VEg%8WVp4shOTGjv*Ddl7HAcG$dYm6xi*q zE4Q`cN|KSzzg=4%K5B@l`F8B;6vmA|&bGAPV@@i(`)>C;Al3Q%+@jgci60mKVf4DH z{%QVSIf-uB>$~?#NXY-6f6v}Q%s;|ZKuo^aYRa!<1)YCio?Y567-pS2YkRGOj@^ZS z`>P#v^zIosINpu_^#5r?#2@4JN#`?WXTuyIVa~u{{%wZoLVd6IAjf*T`njxgN@xNA Dt|(dA literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-non-consecutive.png b/tests/ref/grid-subheaders-repeat-non-consecutive.png new file mode 100644 index 0000000000000000000000000000000000000000..2e0fe2364549f3c44f6b9758a32e010c55566fcf GIT binary patch literal 599 zcmV-d0;v6oP)c=3GC1Lxs6?;3+vjbOoo1q)sV{I~1U zTLd2YFm@P$OUd+31WsfMQ3M{nJalGaUA@)c3X7B93AiUzX=Fy7-uu-M_*G;CfwS?M z&^Q5~JmAKs2>4}_nIZ6=wr&RAZ-<^iqwc&4!ROu(aB%!)r@pyX1-?@2`Wrm}`*8Vw z85wnJTZbX=+=eLzwhP9eq85=uM@CErc)k) zd*TlaffqObVPH!d0{={WX5ecLE_>b+PaL+)M`Vvr3_*v)L@3X|7BdE(d&Iy&tWy3# zz&C2EgQp1C>VMs+6uXbJ0C*_5fV0 zvs{3T`d}Vv1cLeu16$M>*mW^*pV8Lq({txa)NE%S9;f4SJ002ovPDHLkV1j*%2t@z@ literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat.png b/tests/ref/grid-subheaders-repeat.png new file mode 100644 index 0000000000000000000000000000000000000000..c57ed769f17649b26a22109beed932cd9a175131 GIT binary patch literal 472 zcmeAS@N?(olHy`uVBq!ia0vp^6+pa@14uAv+Nr!`U|^i@>EaktaqI1^y3Z>(pqI-8r>T6bvt3=Lc=VQDc;LQr%Oa-}0WFU%I5@N}YMLUJ z#iE+A@yS=N7@3+Do9AqGPi&tH@cmI{iV{53!YQowhx>;9l2-yho?V;Ua6neqo~d!V zzEmK`8kh6W-wSM*%P{xVYnRX5%=~iv+YRD%q%8&3$SOQ|H9_{j%Yl4Zb&i|&SFV_@ zxMAu<9hNwaMz^`aOL!Z18^20D;kWgR!G`%J#}q$QInGo3Fo*G0a`pjD=Y+!Vdnd%I zr(bV9@HHi0ib;Q7qubt1TCp5|go1tT&irTL+f>EHBpsdP{Gf$XYOCbuY=H>BOWzfD z``x>$%~Cg~MymP1%is$urke=dd2#)l!}49KK)K4h4F^*a6?i47>JYD@<);T3K0RTMC B$)*4R literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index 1946719d8..bdb687e35 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -1,3 +1,92 @@ +--- grid-subheaders-basic --- +#grid( + grid.header( + [a] + ), + grid.header( + level: 2, + [b] + ), + [c] +) + +--- grid-subheaders-basic-non-consecutive --- +#grid( + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + [y], +) + +--- grid-subheaders-basic-replace --- +#grid( + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + [y], + grid.header( + level: 2, + [c] + ), + [z], +) + +--- grid-subheaders-repeat --- +#set page(height: 8em) +#grid( + grid.header( + [a] + ), + grid.header( + level: 2, + [b] + ), + ..([c],) * 10, +) + +--- grid-subheaders-repeat-non-consecutive --- +#set page(height: 8em) +#grid( + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + ..([y],) * 10, +) + +--- grid-subheaders-repeat-replace --- +#set page(height: 8em) +#grid( + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + ..([y],) * 10, + grid.header( + level: 2, + [c] + ), + ..([z],) * 10, +) + --- grid-subheaders --- #set page(width: auto, height: 12em) #let rows(n) = { @@ -22,3 +111,14 @@ ), ..rows(5) ) + +--- grid-subheaders-alone --- +#table( + table.header( + [a] + ), + table.header( + level: 2, + [b] + ), +) From 3361b3714d66bbe1f8ee3494b0535a2ad8551e5b Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:38:03 -0300 Subject: [PATCH 34/82] use different disambiguators when skipping region --- crates/typst-layout/src/grid/repeated.rs | 27 +++++++++++++----------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index dc6cabc84..4c21a545b 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -202,7 +202,7 @@ impl<'a> GridLayouter<'a> { ) -> SourceResult<()> { // Generate different locations for content in headers across its // repetitions by assigning a unique number for each one. - let disambiguator = self.finished.len(); + let mut disambiguator = self.finished.len(); // At first, only consider the height of the given headers. However, // for upcoming regions, we will have to consider repeating headers as @@ -254,18 +254,21 @@ impl<'a> GridLayouter<'a> { // if 'full'changes? (Assuming height doesn't change for now...) if !skipped_region { if let HeadersToLayout::NewHeaders { headers, .. } = headers { + // Update disambiguator as we are re-measuring headers + // which were already laid out. + disambiguator = self.finished.len(); header_height = - // Laying out new headers, so we have to consider the - // combined height of already repeating headers as well - // when beginning a new region. - self.simulate_header_height( - self.repeating_headers - .iter().copied() - .chain(self.pending_headers.iter().chain(headers).map(Repeatable::unwrap)), - &self.regions, - engine, - disambiguator, - )?; + // Laying out new headers, so we have to consider the + // combined height of already repeating headers as well + // when beginning a new region. + self.simulate_header_height( + self.repeating_headers + .iter().copied() + .chain(self.pending_headers.iter().chain(headers).map(Repeatable::unwrap)), + &self.regions, + engine, + disambiguator, + )?; } } From 9331ae8e4be2b11e6482e7048027703a507d63cd Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Apr 2025 00:00:01 -0300 Subject: [PATCH 35/82] fix measuring range that was stupid. fixes header which never fits. --- crates/typst-layout/src/grid/repeated.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 4c21a545b..82d83efa7 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -434,7 +434,7 @@ impl<'a> GridLayouter<'a> { // in the header will be precisely the rows in the header. self.simulate_unbreakable_row_group( header.start, - Some(header.end), + Some(header.end - header.start), regions, engine, disambiguator, @@ -519,7 +519,7 @@ impl<'a> GridLayouter<'a> { // in the footer will be precisely the rows in the footer. self.simulate_unbreakable_row_group( footer.start, - Some(self.grid.rows.len() - footer.start), + Some(footer.end - footer.start), regions, engine, disambiguator, From b06f469ad6437bd69b62b8529d8f8db942a0746e Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Apr 2025 00:01:41 -0300 Subject: [PATCH 36/82] these tests now pass --- tests/ref/grid-subheaders-repeat-replace.png | Bin 0 -> 953 bytes tests/ref/grid-subheaders.png | Bin 0 -> 19127 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/ref/grid-subheaders-repeat-replace.png create mode 100644 tests/ref/grid-subheaders.png diff --git a/tests/ref/grid-subheaders-repeat-replace.png b/tests/ref/grid-subheaders-repeat-replace.png new file mode 100644 index 0000000000000000000000000000000000000000..9fe729401af3dd9e8c9fb298a5f7b7baadd159e9 GIT binary patch literal 953 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 VRk~+OfFDw^nIWu=X@+jG{{VfL^osxh literal 0 HcmV?d00001 From 553bf2476ea1589256eec342effa4b32d21c03c1 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Apr 2025 00:02:16 -0300 Subject: [PATCH 37/82] multiple level replace test --- ...headers-repeat-replace-multiple-levels.png | Bin 0 -> 913 bytes tests/suite/layout/grid/subheaders.typ | 23 ++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/ref/grid-subheaders-repeat-replace-multiple-levels.png diff --git a/tests/ref/grid-subheaders-repeat-replace-multiple-levels.png b/tests/ref/grid-subheaders-repeat-replace-multiple-levels.png new file mode 100644 index 0000000000000000000000000000000000000000..08fdc580802f4d151036ad6927d97d14b1945ccc GIT binary patch literal 913 zcmV;C18)3@P)fNy4qtD6Y;O&$?J;B)#844h6`Q=qACE(5^@3kcYH<4LpJ-drIJykB6-fWZ0| z+J?ZlOrSNMIKDIlfoY}HEwAI$^-O$ikN~hPj^+j-Q+;NofgrFsr3V93OaKFi>j?sX zzUsojx+HN}DFI;dXt2i#Sz~RxfgrHs&@u+5>x6;V2QhGOYT%;<0T0AV(~l4^#cvrT z2;8Y`BM2Ni{1XFHH9=svb^`;KUlh%S5&(9`(C7!q8t0CHE{QdT1p`wmVc_pCFt9b% z@7+VdkD~*_Dg;c&JWYNGT(2_{1oj6FPEz z8Uxo{;gF0&G7iZ&B;$~bLoyD@I3(kcj6*UG$^IFp6y+i)B+H#b!1l}bd**6l!B0yHvmx+SAju7Zspw@G0?%go?m^(?t`4)jj+4#M+5>@2j>;wo zOz$oc1P&!TZ4mf*mXOIm%p(G~l~$-B@S=2f0|HZR9YJ9Gd8&oL;|)TCyms}rWo7{a z*GyFCAaHmmIShd*ZOs9JO=ez>`m_Xr_f7|UA#iuO nP|R;Gh8bp Date: Tue, 8 Apr 2025 19:13:21 -0300 Subject: [PATCH 38/82] a dangerous missing plus --- crates/typst-layout/src/grid/repeated.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 82d83efa7..e1324fe64 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -388,7 +388,7 @@ impl<'a> GridLayouter<'a> { if !short_lived { self.header_height += header_height; if matches!(header, Repeatable::Repeated(_)) { - self.repeating_header_height = header_height; + self.repeating_header_height += header_height; self.repeating_header_heights.push(header_height); } } From cefa7fc72c5d8ee68422717ed7b6f5115d22773e Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:00:58 -0300 Subject: [PATCH 39/82] separate layout_active_headers and new_headers - Active headers will push empty regions when skipping regions, whereas new_headers may be called from a non-empty region, so we need to finish the region in full. The code separation also makes it all clearer. --- crates/typst-layout/src/grid/repeated.rs | 304 +++++++++++------------ 1 file changed, 143 insertions(+), 161 deletions(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index e1324fe64..068a80cea 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -6,21 +6,6 @@ use typst_library::layout::{Abs, Axes, Frame, Regions}; use super::layouter::{may_progress_with_offset, GridLayouter}; use super::rowspans::UnbreakableRowGroup; -pub enum HeadersToLayout<'a> { - RepeatingAndPending, - NewHeaders { - headers: &'a [Repeatable
], - - /// Whether this new header will become a pending header. If false, we - /// assume it simply won't repeat and so its header height is ignored. - /// Later on, cells can assume that this header won't occupy any height - /// in a future region, and indeed, since it won't be pending, it won't - /// have orphan prevention, so it will be placed immediately and stay - /// where it is. - short_lived: bool, - }, -} - impl<'a> GridLayouter<'a> { pub fn place_new_headers( &mut self, @@ -51,15 +36,13 @@ impl<'a> GridLayouter<'a> { // header of the same or lower level, such that they never actually get // to repeat. for conflicting_header in conflicting_headers.chunks_exact(1) { - self.layout_headers( + self.layout_new_headers( // Using 'chunks_exact", we pass a slice of length one instead // of a reference for type consistency. // In addition, this is the only place where we layout // short-lived headers. - HeadersToLayout::NewHeaders { - headers: conflicting_header, - short_lived: true, - }, + conflicting_header, + true, engine, )? } @@ -155,10 +138,7 @@ impl<'a> GridLayouter<'a> { // This might be a waste as we could generate an orphan and thus have // to try to place old and new headers all over again, but that happens // for every new region anyway, so it's rather unavoidable. - self.layout_headers( - HeadersToLayout::NewHeaders { headers, short_lived: false }, - engine, - )?; + self.layout_new_headers(headers, false, engine)?; // After the first subsequent row is laid out, move to repeating, as // it's then confirmed the headers won't be moved due to orphan @@ -190,40 +170,26 @@ impl<'a> GridLayouter<'a> { self.pending_headers = Default::default(); } - /// Layouts the headers' rows. + /// Lays out the rows of repeating and pending headers at the top of the + /// region. /// /// Assumes the footer height for the current region has already been /// calculated. Skips regions as necessary to fit all headers and all /// footers. - pub fn layout_headers( - &mut self, - headers: HeadersToLayout<'a>, - engine: &mut Engine, - ) -> SourceResult<()> { + pub fn layout_active_headers(&mut self, engine: &mut Engine) -> SourceResult<()> { // Generate different locations for content in headers across its // repetitions by assigning a unique number for each one. - let mut disambiguator = self.finished.len(); + let disambiguator = self.finished.len(); - // At first, only consider the height of the given headers. However, - // for upcoming regions, we will have to consider repeating headers as - // well. - let mut header_height = match headers { - HeadersToLayout::RepeatingAndPending => self.simulate_header_height( - self.repeating_headers - .iter() - .copied() - .chain(self.pending_headers.iter().map(Repeatable::unwrap)), - &self.regions, - engine, - disambiguator, - )?, - HeadersToLayout::NewHeaders { headers, .. } => self.simulate_header_height( - headers.iter().map(Repeatable::unwrap), - &self.regions, - engine, - disambiguator, - )?, - }; + let header_height = self.simulate_header_height( + self.repeating_headers + .iter() + .copied() + .chain(self.pending_headers.iter().map(Repeatable::unwrap)), + &self.regions, + engine, + disambiguator, + )?; // We already take the footer into account below. // While skipping regions, footer height won't be automatically @@ -251,27 +217,7 @@ impl<'a> GridLayouter<'a> { ); // TODO: re-calculate heights of headers and footers on each region - // if 'full'changes? (Assuming height doesn't change for now...) - if !skipped_region { - if let HeadersToLayout::NewHeaders { headers, .. } = headers { - // Update disambiguator as we are re-measuring headers - // which were already laid out. - disambiguator = self.finished.len(); - header_height = - // Laying out new headers, so we have to consider the - // combined height of already repeating headers as well - // when beginning a new region. - self.simulate_header_height( - self.repeating_headers - .iter().copied() - .chain(self.pending_headers.iter().chain(headers).map(Repeatable::unwrap)), - &self.regions, - engine, - disambiguator, - )?; - } - } - + // if 'full' changes? (Assuming height doesn't change for now...) skipped_region = true; self.regions.size.y -= self.footer_height; @@ -290,114 +236,150 @@ impl<'a> GridLayouter<'a> { } } + let repeating_header_rows = + total_header_row_count(self.repeating_headers.iter().copied()); + + let pending_header_rows = + total_header_row_count(self.pending_headers.iter().map(Repeatable::unwrap)); + // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - if let HeadersToLayout::NewHeaders { headers, .. } = headers { - // Do this before laying out repeating and pending headers from a - // new region to make sure row code is aware that all of those - // headers should stay together! - self.unbreakable_rows_left += - total_header_row_count(headers.iter().map(Repeatable::unwrap)); + self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; + + self.current_last_repeated_header_end = + self.repeating_headers.last().map(|h| h.end).unwrap_or_default(); + + // Reset the header height for this region. + // It will be re-calculated when laying out each header row. + self.header_height = Abs::zero(); + self.repeating_header_height = Abs::zero(); + self.repeating_header_heights.clear(); + + // Use indices to avoid double borrow. We don't mutate headers in + // 'layout_row' so this is fine. + let mut i = 0; + while let Some(&header) = self.repeating_headers.get(i) { + let header_height = self.layout_header_rows(header, engine, disambiguator)?; + self.header_height += header_height; + self.repeating_header_height += header_height; + + // We assume that this vector will be sorted according + // to increasing levels like 'repeating_headers' and + // 'pending_headers' - and, in particular, their union, as this + // vector is pushed repeating heights from both. + // + // This is guaranteed by: + // 1. We always push pending headers after repeating headers, + // as we assume they don't conflict because we remove + // conflicting repeating headers when pushing a new pending + // header. + // + // 2. We push in the same order as each. + // + // 3. This vector is also modified when pushing a new pending + // header, where we remove heights for conflicting repeating + // headers which have now stopped repeating. They are always at + // the end and new pending headers respect the existing sort, + // so the vector will remain sorted. + self.repeating_header_heights.push(header_height); + + i += 1; } - // Need to relayout ALL headers if we skip a region, not only the - // provided headers. - // TODO: maybe extract this into a function to share code with multiple - // footers. - if matches!(headers, HeadersToLayout::RepeatingAndPending) || skipped_region { - let repeating_header_rows = - total_header_row_count(self.repeating_headers.iter().copied()); + self.current_repeating_header_rows = self.lrows.len(); - let pending_header_rows = total_header_row_count( - self.pending_headers.iter().map(Repeatable::unwrap), - ); - - self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; - - self.current_last_repeated_header_end = - self.repeating_headers.last().map(|h| h.end).unwrap_or_default(); - - // Reset the header height for this region. - // It will be re-calculated when laying out each header row. - self.header_height = Abs::zero(); - self.repeating_header_height = Abs::zero(); - self.repeating_header_heights.clear(); - - // Use indices to avoid double borrow. We don't mutate headers in - // 'layout_row' so this is fine. - let mut i = 0; - while let Some(&header) = self.repeating_headers.get(i) { - let header_height = - self.layout_header_rows(header, engine, disambiguator)?; - self.header_height += header_height; + for header in self.pending_headers { + let header_height = + self.layout_header_rows(header.unwrap(), engine, disambiguator)?; + self.header_height += header_height; + if matches!(header, Repeatable::Repeated(_)) { self.repeating_header_height += header_height; - - // We assume that this vector will be sorted according - // to increasing levels like 'repeating_headers' and - // 'pending_headers' - and, in particular, their union, as this - // vector is pushed repeating heights from both. - // - // This is guaranteed by: - // 1. We always push pending headers after repeating headers, - // as we assume they don't conflict because we remove - // conflicting repeating headers when pushing a new pending - // header. - // - // 2. We push in the same order as each. - // - // 3. This vector is also modified when pushing a new pending - // header, where we remove heights for conflicting repeating - // headers which have now stopped repeating. They are always at - // the end and new pending headers respect the existing sort, - // so the vector will remain sorted. self.repeating_header_heights.push(header_height); - - i += 1; } + } - self.current_repeating_header_rows = self.lrows.len(); + // Include both repeating and pending header rows as this number is + // used for orphan prevention. + self.current_header_rows = self.lrows.len(); - for header in self.pending_headers { - let header_height = - self.layout_header_rows(header.unwrap(), engine, disambiguator)?; + Ok(()) + } + + /// Lays out headers found for the first time during row layout. + /// + /// If 'short_lived' is true, these headers are immediately followed by + /// a conflicting header, so it is assumed they will not be pushed to + /// pending headers. + pub fn layout_new_headers( + &mut self, + headers: &'a [Repeatable
], + short_lived: bool, + engine: &mut Engine, + ) -> SourceResult<()> { + // At first, only consider the height of the given headers. However, + // for upcoming regions, we will have to consider repeating headers as + // well. + let header_height = self.simulate_header_height( + headers.iter().map(Repeatable::unwrap), + &self.regions, + engine, + 0, + )?; + + // We already take the footer into account below. + // While skipping regions, footer height won't be automatically + // re-calculated until the end. + let mut skipped_region = false; + + // TODO: remove this 'unbreakable rows left check', + // consider if we can already be in an unbreakable row group? + while self.unbreakable_rows_left == 0 + && !self.regions.size.y.fits(header_height) + && may_progress_with_offset( + self.regions, + // 'finish_region' will place currently active headers and + // footers again. We assume previous pending headers have + // already been flushed, so in principle + // 'header_height == repeating_header_height' here + // (there won't be any pending headers at this point, other + // than the ones we are about to place). + self.header_height + self.footer_height, + ) + { + // Note that, after the first region skip, the new headers will go + // at the top of the region, but after the repeating headers that + // remained (which will be automatically placed in 'finish_region'). + self.finish_region(engine, true)?; + skipped_region = true; + } + + self.unbreakable_rows_left += + total_header_row_count(headers.iter().map(Repeatable::unwrap)); + + let placing_at_the_start = + skipped_region || self.lrows.len() == self.current_header_rows; + for header in headers { + let header_height = self.layout_header_rows(header.unwrap(), engine, 0)?; + + // Only store this header height if it is actually going to + // become a pending header. Otherwise, pretend it's not a + // header... This is fine for consumers of 'header_height' as + // it is guaranteed this header won't appear in a future + // region, so multi-page rows and cells can effectively ignore + // this header. + if !short_lived { self.header_height += header_height; if matches!(header, Repeatable::Repeated(_)) { self.repeating_header_height += header_height; self.repeating_header_heights.push(header_height); } } - - // Include both repeating and pending header rows as this number is - // used for orphan prevention. - self.current_header_rows = self.lrows.len(); } - if let HeadersToLayout::NewHeaders { headers, short_lived } = headers { - let placing_at_the_start = skipped_region || self.lrows.is_empty(); - for header in headers { - let header_height = - self.layout_header_rows(header.unwrap(), engine, disambiguator)?; - - // Only store this header height if it is actually going to - // become a pending header. Otherwise, pretend it's not a - // header... This is fine for consumers of 'header_height' as - // it is guaranteed this header won't appear in a future - // region, so multi-page rows and cells can effectively ignore - // this header. - if !short_lived { - self.header_height += header_height; - if matches!(header, Repeatable::Repeated(_)) { - self.repeating_header_height += header_height; - self.repeating_header_heights.push(header_height); - } - } - } - - if placing_at_the_start { - // Track header rows at the start of the region. - self.current_header_rows = self.lrows.len(); - } + if placing_at_the_start { + // Track header rows at the start of the region. + self.current_header_rows = self.lrows.len(); } Ok(()) From 4a83d05625c2a53ba30d442818369a81f7550d6a Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:01:47 -0300 Subject: [PATCH 40/82] switch to active header layout in finish_region --- crates/typst-layout/src/grid/layouter.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 176080b6c..80e1885e1 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -15,7 +15,6 @@ use typst_library::visualize::Geometry; use typst_syntax::Span; use typst_utils::{MaybeReverseIter, Numeric}; -use super::repeated::HeadersToLayout; use super::{ generate_line_segments, hline_stroke_at_column, layout_cell, vline_stroke_at_row, LineSegment, Rowspan, UnbreakableRowGroup, @@ -1705,7 +1704,7 @@ impl<'a> GridLayouter<'a> { if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() { // Add headers to the new region. - self.layout_headers(HeadersToLayout::RepeatingAndPending, engine)?; + self.layout_active_headers(engine)?; } } From f81560af47d8f4e9fb1497be5d851de9bbe20918 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:20:11 -0300 Subject: [PATCH 41/82] fix wrong region skipping --- crates/typst-layout/src/grid/repeated.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 068a80cea..1402ce621 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -350,7 +350,7 @@ impl<'a> GridLayouter<'a> { // Note that, after the first region skip, the new headers will go // at the top of the region, but after the repeating headers that // remained (which will be automatically placed in 'finish_region'). - self.finish_region(engine, true)?; + self.finish_region(engine, false)?; skipped_region = true; } From 40bc08291d63968c7467e253f30e986f006f7892 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:52:58 -0300 Subject: [PATCH 42/82] create lrows orphan snapshot - Indicate some rows at the end cannot be orphans --- crates/typst-layout/src/grid/layouter.rs | 38 +++++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 80e1885e1..76350d099 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -92,6 +92,13 @@ pub struct GridLayouter<'a> { /// calculation. /// TODO: consider refactoring this into something nicer. pub(super) current_row_height: Option, + /// Stores the length of `lrows` before a sequence of trailing rows + /// equipped with orphan prevention were laid out. In this case, if no more + /// rows are laid out after those rows before the region ends, the rows + /// will be removed. For new headers in particular, which use this, those + /// headers will have been moved to the `pending_headers` vector and so + /// will automatically be placed again until they fit. + pub(super) lrows_orphan_snapshot: Option, /// The simulated header height. /// This field is reset in `layout_header` and properly updated by /// `layout_auto_row` and `layout_relative_row`, and should not be read @@ -190,6 +197,7 @@ impl<'a> GridLayouter<'a> { upcoming_headers: &grid.headers, repeating_header_heights: vec![], pending_headers: Default::default(), + lrows_orphan_snapshot: None, current_row_height: None, header_height: Abs::zero(), repeating_header_height: Abs::zero(), @@ -334,7 +342,10 @@ impl<'a> GridLayouter<'a> { Sizing::Rel(v) => { self.layout_relative_row(engine, disambiguator, v, y)? } - Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y, disambiguator)), + Sizing::Fr(v) => { + self.lrows_orphan_snapshot = None; + self.lrows.push(Row::Fr(v, y, disambiguator)) + } } } @@ -1445,6 +1456,9 @@ impl<'a> GridLayouter<'a> { /// will be pushed for this particular row. It can be `false` for rows /// spanning multiple regions. fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { + // There is now a row after the rows equipped with orphan prevention, + // so no need to remove them anymore. + self.lrows_orphan_snapshot = None; self.regions.size.y -= frame.height(); self.lrows.push(Row::Frame(frame, y, is_last)); } @@ -1455,6 +1469,15 @@ impl<'a> GridLayouter<'a> { engine: &mut Engine, last: bool, ) -> SourceResult<()> { + if let Some(orphan_snapshot) = self.lrows_orphan_snapshot.take() { + if !last { + self.lrows.truncate(orphan_snapshot); + self.current_header_rows = self.current_header_rows.min(orphan_snapshot); + self.current_repeating_header_rows = + self.current_repeating_header_rows.min(orphan_snapshot); + } + } + if self .lrows .last() @@ -1462,13 +1485,9 @@ impl<'a> GridLayouter<'a> { { // Remove the last row in the region if it is a gutter row. self.lrows.pop().unwrap(); - - if self.lrows.len() == self.current_header_rows { - if self.current_header_rows == self.current_repeating_header_rows { - self.current_repeating_header_rows -= 1; - } - self.current_header_rows -= 1; - } + self.current_header_rows = self.current_header_rows.min(self.lrows.len()); + self.current_repeating_header_rows = + self.current_repeating_header_rows.min(self.lrows.len()); } let footer_would_be_widow = if let Some(last_header_row) = self @@ -1727,6 +1746,9 @@ impl<'a> GridLayouter<'a> { if !self.grid.headers.is_empty() { self.finished_header_rows.push(header_row_info); } + + // Ensure orphan prevention is handled before resolving rows. + debug_assert!(self.lrows_orphan_snapshot.is_none()); } } From e4bbe471a7b87c8ef5cee23c483538d89e86e6c2 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:55:41 -0300 Subject: [PATCH 43/82] set snapshot when laying out new headers - Still need to detect when this doesn't make sense, e.g. in the last region, or right before a footer... --- crates/typst-layout/src/grid/repeated.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 1402ce621..bd76cdda4 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -357,8 +357,9 @@ impl<'a> GridLayouter<'a> { self.unbreakable_rows_left += total_header_row_count(headers.iter().map(Repeatable::unwrap)); + let initial_row_count = self.lrows.len(); let placing_at_the_start = - skipped_region || self.lrows.len() == self.current_header_rows; + skipped_region || initial_row_count == self.current_header_rows; for header in headers { let header_height = self.layout_header_rows(header.unwrap(), engine, 0)?; @@ -382,6 +383,12 @@ impl<'a> GridLayouter<'a> { self.current_header_rows = self.lrows.len(); } + // Remove new headers at the end of the region if upcoming child doesn't fit. + // TODO: Short lived if footer comes afterwards + if !short_lived { + self.lrows_orphan_snapshot = Some(initial_row_count); + } + Ok(()) } From 14a4714604601b696c3b64240b74b78e9ba8f491 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Apr 2025 00:02:16 -0300 Subject: [PATCH 44/82] add edge case tests for subheaders --- ...-subheaders-multi-page-row-right-after.png | Bin 0 -> 1127 bytes tests/ref/grid-subheaders-multi-page-row.png | Bin 0 -> 1173 bytes ...headers-multi-page-rowspan-right-after.png | Bin 0 -> 1421 bytes .../grid-subheaders-multi-page-rowspan.png | Bin 0 -> 1048 bytes ...bheaders-repeat-replace-didnt-fit-once.png | Bin 0 -> 877 bytes ...ubheaders-repeat-replace-double-orphan.png | Bin 0 -> 950 bytes ...headers-repeat-replace-multiple-levels.png | Bin 913 -> 877 bytes .../grid-subheaders-repeat-replace-orphan.png | Bin 0 -> 939 bytes tests/suite/layout/grid/subheaders.typ | 161 ++++++++++++++++++ 9 files changed, 161 insertions(+) create mode 100644 tests/ref/grid-subheaders-multi-page-row-right-after.png create mode 100644 tests/ref/grid-subheaders-multi-page-row.png create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan-right-after.png create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-double-orphan.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-orphan.png diff --git a/tests/ref/grid-subheaders-multi-page-row-right-after.png b/tests/ref/grid-subheaders-multi-page-row-right-after.png new file mode 100644 index 0000000000000000000000000000000000000000..c9e30869c4111d653e4a9045c70bd03108b62d61 GIT binary patch literal 1127 zcmV-t1ep7YP)StYpW@=;US3|&(b0f_fU>f(o}Qkgqoaq1hu`1dy}i9@X=$mcsc~^} zQ&Ur2U0qsQT5xc1=H}+x+uO>@%0WRvudlDRwzkjD&&kQju&}T^JUo_xJbx{ryc%O(Y~F zsHmu1TwIKdjE;_u@bK^(8ylRQob>ec`#&|xGb-HN+?$)5>pL+X9v)y|V5_UE$H&K@ zprA}lOlD?gjg5_hf`T+OG?|&1XlQ7LhKA?o=T=r$`uh63yu9k_>ZhltuCA_XYHE^_ zlEJ~j_V)HSH#aOSEJj8~Zf*(m{>FMd0mzQ&MbB~XYbaZsX!^8Rc`K6_$&CSjA z_4WMx{Mp&r|3NnM%CzeM00KWrL_t(|+U?rMQ(93R#qkrGf=U-qBm%Z*VlU~v_uhLq zz1oc?LShIO1SyZ2KQ%M%;y&+ZhVxmx?RVbY-MI%uMD#x?!WFNVcJdWSK>wAz6jk@j!N~0d-#oJ(l1jA-jK|*Q{GSV5WLPGXyXoQm+h8bpcev_W?*ZPGC(n)Cxtl%Ed-d8-yL0apRM6e&Y(zYG79Hay tWuLnc^rJn>`vwsa5fKp)5fKr|!yiHa)bcA|uw4KE002ovPDHLkV1i+!I$i(( literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-row.png b/tests/ref/grid-subheaders-multi-page-row.png new file mode 100644 index 0000000000000000000000000000000000000000..637ca3fb10f78af61372f9fdc88a74a7b3a19d0a GIT binary patch literal 1173 zcmV;G1Zw+U3^`T5=5-TeIg!otFrmzSBDnMX%Qxw*NCiHUM@a#U1Q`1ttJ($fC^ z{+7AJoyy9} z)6>(Yrl$7x_NS+(goK3SuUGVVmYinzil$2>{X zDk>^!YHF30l}JcPq@<*Fc6Qg-*I8Lvjg5`7v$Jk)Zg_Zjyu7^c@9$SvSG2UWNl8h> z#KgmX?;}FMc9OG}H3i-(7Y)YR1b`}?D#qvq!3+1c6s{r&$zHit9?(EtDfi%CR5RCwC$+C^7e zVHAbo-56eiyIZMHv`XFGixg^5>PV5|9vqSz>F@3ECf}TM+GIboJ$hF%xGQo%L`46S ziSeo5&@lO{a2p!d^9%N&VJp{@1`UCd^rK-Uk_Voo&MJF0dLmUGNO;)oUI^Q>__kGA z2@wU)XAtF1Rk;NbHS?2*QcxL$h|<=T?k?EeSLPt%W}CO`6{2K7hQo_f~7RLfHG*8rg$F7+b^DPzi^g)#`-N1D$Z^4V|#(8o&n# z!wfUbFvAQpEF0!OfJ8*5BTD$r6A|%{jE7|Z)FIhon$jUzvJ*NS&XA=O{yF=7MduYG zWl1TVp9cl5`Q#!m6#Q7v%0^<@EY5+3RX+}(Vfiz3EG=RW8r&ZT24Uk*iVQQ%FvAQp z%rL_YGt4l<3^U9y!@O=HA|f7=@sNy&h=*i6B;z3&56O5)#zQh5lJSs?hh*_CVooXN z&JS#xQ$*v1H$HR9sJs|4r-+)0`Z9Bhs6C!nYR>o;sID6^r-)$a{ir!*ynSa*8AyO7=9ICyWlkA$U#yen n6frxtTVhOpIwB$>qQmePlc&co57D5?00000NkvXXu0mjfH6mT? literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-right-after.png b/tests/ref/grid-subheaders-multi-page-rowspan-right-after.png new file mode 100644 index 0000000000000000000000000000000000000000..5fe1eacf2221bbddcc5933baeb8cf9ad3ab4767c GIT binary patch literal 1421 zcmV;81#+7khsnpce*x1;Mi;K6nx1OG!va+&hXlVWY{o>-{qobpEczAbrcW!QO ze}8{*adB&FYw79f+S=MyR#u0Hhu`1d%*@P*iHSTsJeir9yu7^d@bImzt;)*Ewzjs< z&(C9HW3H~QK|w)aU|_$$zley4q@<*Fc6OAMlpzLXJ=&tW@b!GOgT9@ z#KgoiGc(}e;Cp*}_xJZCBqZ(a?JX@WJv}}9KQ;99^p1{>oSdA5goL`fy2>*uQBhH* zrl#vVF|4et<>lo~O-=9b?>;_0jEsyXCMHi$Pgz-6(9qCIN=hjyDdXegFE1}HE-uBz z#jC5UprD|$v$MXwz9J$bH#aw2TwGUISA~Uz$H&Lp+uNO;oisEwEG#U7f`YHFuW)d1 zT3TALu&{=PhPb%6o12??d3nLX!A3?#l9G~|nwqDlr)6bjYHDiL)zxHVWWBw;fPjF} z(b1}^s`&W$US3`*Dk@!FT~kw2_V)JZ=;)W1myeH+b8~Y`OG}}lp^%V}=H}+Kw6w{| z$!Te6xw*M=a&mrten&?~ySuyH-QD{7`uX|!pP!#}bacbR!`a!{`}_O+{QUnxHZdQB zasU7W3rR#lRCwC$+E-H(VE~2UV-g_-goHq%lu!h0pkOZu_JY0l-h1zr4$`YAy>}ud zBv}%Fn={-vk{R#z`*1kV#ooTN^9TC-Aj9amV+=A_ zi++wwVm5i;@S4Lg-Jrv{!|a8c?zcG)Ok%pB&z;t&38<=nX$XAvEwT7RU8_Tb;H%v3 zg1Q>)U2S^!+UkJ1bgeD=X!uHo$~0QQmpA{N>fsJkGkm0egzDjTtWzL?MvJt~lEPbd zLxQ!5>1mi{ypSSzOH3OWQ2!x&}l@5 zm&61ib(J6H>Yx=F-Wz30L{^J-Ty%IK0?53Qc2F-mT(Jlfmo5Vsu7}P~V7PN>!X8w% z*S^kv8~DmD-~=Y4B|1pf040hnDPBf=p= z;4&c6tBM`ZABnTq(tVgfnO2>OG3Sn-?X=B_jh|pUHx01XMxf`c`4P3YgLCRmQ z4UJn6dhUEm>LF*+3fsf{JnN?_2r#_Hx*TDa6Ii`|Qdt!pY-vEAgYO$eL_|bHL_|bH bBp-eQltzz?4j@AQ00000NkvXXu0mjf^hMJa literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-rowspan.png b/tests/ref/grid-subheaders-multi-page-rowspan.png new file mode 100644 index 0000000000000000000000000000000000000000..342b05695b9a8dd07766cad52ab259497b9c76da GIT binary patch literal 1048 zcmV+z1n2vSP)&HKtN4RO-xKosHmvplp8R#uyvo8sc)ZEbC0 zVq*IG`p(YImzS4DMn;sBlxu5ih=_>8!^7Iz+9Dz%R8&;U%geH|vWJI<^z`)H+}u)9 zQY9rN?d|Ou85w3~W;r=IKR-X5oSgCT@$m5QiHV8o>gvSA#NpxLRaI3}Q&U}CU1@1) z%F4>Iv9aLb;Ip%{YHDgoNJw~icy4ZPJ3BivGBR;-aX~>rJUl#eb92DJz~|@ZH#awB zWo56gue`jxl9H14_V(}Z@1mljjg5_2Sy|7|&(YD*WMpKyxw&$3a&&ZbpP!%U>FHx* zV@pd*M@L6~etx^VyV%&+p`oGG)zzk^rb0qO{r&xrkdVE-y?c9m`}_OC!otnX&Ft*# zprD}L-QDZ!>)F}aSXfx!-{1WF{BLh>$jHb500175wWk080uf0>K~#9!?b=sck^vaT z@jEsM1SUfi_ipdK_ukufS-C13=ExYb1rzA|cGLxNb=vco+`qH?=zlSe-uD3!5&cgb z4VI74ko_M01P!@FWDXj>E#&8+!5I4dWf}EkTfW#-sLa9qVj&L+{(!G>xiYQK(#aG= zM7DRL%G1J98Y1q+v#1hDnIIy0*BR(Wh41`1h}hNT>fVVe*C3-@LbZwUs8slNuoV@r z#~>BHgW1DSP%goKNGaUB7Ye2h99BvZ$3-? SQMQf%0000ivHFc{^#b(azRflv+zkgid@L+rE zW`P@V!sq6?AJ{te)E^_Jc`JnlIcm1laxjN`eAT`X^!*;|LEd7H8?_G;_4gO&a@MH( zORl`cuwj|^7j*^iyVvrBIArc`>}h=3mhj-CCeNM1pvv4U3>(VV?~!iS`6?Jc-67$3 zqBfgYtQ2#)6)RJ{t@P0YXW6HAO7SviSDURBJkcXPUy7Bfy{z`gfucBr5050+N!Q68E4w#K_lS00gKs+%x<-j0@rr3%+3HyxOJ_AXPSd6}ER1`|#ezu9*r zDvlni4+e#ryTPl6JwS%f3`I%jP5-r2c89TWot(4b(^9=HosHk4`C1$qyB6}Yylxlx zP_poIPg6s^i)~a{tWbZxR-YO#L;I(rFWYp!YKpHAED*n8ut9{AE$o*;!GfA*pm|1Z zDLf}7CK;+EdmNk4qj=N4jd}Xd({hR62=kd?0Ad&eCEY+wkjjs}DNXi%ubQ|Tm7k~a zIX;;7d{WZ^UbYK8w!02Ga_DewKXtp);Ne@o^+^vR-ez#JT;unYVY)8uXj~VZu)X0C zOZ24YGeGjIB`j_xtZO;&wpOU+K=_#jlGfiUk11~W@$35L*A|t%f5SL(`0l=LJg}+2 zNnt~j%txmO>Q|R(-SdlP;y2lP&H2IisFOl0VuzIjn5unv1b(DWU#Y#jtn`AZEqkN2 z%-XX87Q%Yx91=7?w<~PenX`q7X?ari{Yq;=U?>_m8|CSN{FesyGBgZ0gw@Uf0|3Z_ o`WO@(U^!6Ig@%Oizq6mYe_r3Eap7~te^5U1boFyt=akR{0GgD2aR2}S literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-double-orphan.png b/tests/ref/grid-subheaders-repeat-replace-double-orphan.png new file mode 100644 index 0000000000000000000000000000000000000000..e340e6817a8f7fc875ed35f3cc6241b0980b44c3 GIT binary patch literal 950 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!?ODXU(>hx(ZP{ovSFKC0 zG^>r>#ze;QdE(^ze*5$59mOjjF2C{oJO7>=-z$%8POsjqz-a5(!YQogQ^CJs^~ZA- zEOLMCrZP2N*Um~-END!$lVXbIs$O@V?aI1;mhYo~v^GRH-}Rgkczs^9D2I)VC0Ao_ zX7<6$92Sok8SU+9Xy4?*!Zd$n&cOqnlkV_Kl^(Vgm}A}6aQ6B*X0m~H8LN!-B!qs`QW53aHA?ii1~Er!2{f}5<8U78K*lP zcz^gxqdP~0Qh13LOP+7G7{?pyJ+(+b?@N;8E|zrw|G&o<-mfgl}$8;i}8A51m- zH_K~=!NdJcf&ABJ7$hEQk(^|x@_V_{ra2h_9@p5l<2S8|(}g+be$~SHs%GOX+Przb0{B;h1rlS0Z8&E3DlM+CKlRb`2=z$6Rn?B)8;xqB5;CO@DUDykX#2FwZ|CDduOT6F1&vbho&)kLs zOY@KLHWqJPU^79}i`g`9EtYfdn0kLXE?iRQa$wz* z-R50PuO@t0{9w6jrKUi|QQ^&ujjH=E^Eb9nF3)#*aPqqDu>-q2BT9VV3hcP|YXcwC z=`)p$2lhqGW@WPXzoz~%X`g;Uabctx%fDsw;#v+YTFWH*G~JcE(f<02Y^Ln+^|RQH zD{NS{eGLoK@9Rl_1tK1$uNBx~_b*n7rOf)x?8dKUuXe__-n=@0kEXIf#62+%mTmIi z%N!nT3#v^_c>79rcd{^3_Ll8NiXYmvPq8+t-fd~*^(>YYsK~rva!#8E7!#bvDvzHj v`2a%(Yw%%&Csg2aCny@h!D#T%-ex~zL|E^$9WSRbf-0Ec1N>{fNy8{S2q#x`+O0Bz?V#&7&udMra(j8 z@(2VME+F8=o6lO*cIFCW;KM>|76dl6$~FYPV+GCe#F?dG2rMgY`|4Uw-7oy_%nAT@ zB*@$lWT-E!G=B&Lww>?Az!De4z>#`^z~8QWFt9PjKcZCtu>Vx3*A1CtLx)))u*Rs~rIk#w*iL5U?a}nH31!rEM1o96tF61533);C;ge1}?wqw-qUf zUmPc6Um$aQYZSCetZD2RSkeRo|9XXi7t;dXJp}wTHh(yxL%?$Spg916>y1qUfqlo_ z7+A^#0tYRN7W zpa7+|3ItboBH)?C$vi&-{_R;(RJJ?yHx5JK38Pd%VErIysH+OZ4S@r?qBlHqMFlR6 z_4j2f0DoAC^R{^*LtSOEfG$IhZDL?477RQ*je%=27?Lq0V@Sr3j3F6AGKORf$rzF` zBx6X%knE_iGb70BAHswQ6DCZUAsOeGFk!-k8Imz1!+nvgOVbT{t94TlIIygRz`ObL z@esIdPzoUMy+#l`m(Y=vhk(W7{1H;sCSk9%n13MffKP9Oz%r>52<%PvjYHs$2Nu1$ zjy@3-{JgX%7XohuQ}!XS6u*u@;MpACeF(hV-Dy+Ta&nm4dLgjYRoM)I<46T$p8QV07*qoM6N<$f>`2zX8-^I delta 890 zcmV-=1BLwU29XDlB!5^*L_t(|+U?rgOOtUN$8rCMy6djX-DC)MVGv2E;e}C|MX48R zw6bY!nPH4jVWx-{P1BrDbDl88V;-=n=(JFDrqeQ-mfCVSw+?eWJ3Zh2gP?+ag6I4D z2VU?y@cV7gJBmOfieZKsW|-l>fcIM-yoJE??^m)Ru&dgb41a;OqxM7yY%3nQy(X(& z{ly+y-$lTk3V$YMs*M-62?Ec?&qH9da!0X>fNy4qtD6Y;O&$?J;B)#844h6`Q=qAC zE(5^@3kcYH<4LpJ-drIJykB6-fWZ0|+J?ZlOrSNMIKDIlfoY}HEwAI$^-O$ikN~hP zj^+j-Q+;Nofqx*dIi&{!Q%nE@hwBLff4=I%z`7)HSSbNu@o2Ee30Y%pyMZ9ElK?!+$CSOvgM;eh6HzGZF;$9du$~ zDkTUU5Udz@tDC>3GR!c;e*~ijuQ9_6Gt4lDWE_(HS0R}u5fqY5!pfICM-T&_>`vGL z1t_&uAh@~%0nfxw=7)dQfZuF{Yb0{c~kulUUs5x68; z?9G$_uzwWeZS_K?x=JsAK876F#K2TU7kz4#_wq**6l!B0yHvmx+SAju7Z zspw@G0?%go?m^(?t`4)jj+4#M+5>@2j>;woOz$oc1P&!TZ4mf*mXOIm%p(G~l~$-B z@N=Scb^`)aZ5=^i`+2H`z~c=Y2wq<4k0@qAb=pb--Cpip(DQ(RGflX=s=@8iO z+50B1<23THP6vUSh*_Ws2yNNPb`<;Hmkg`($j5!C(wmI*Q z9$3Vg@StF?!-K7}Z%gdZ36wk&#JHhI>&EN0HyigI))2T+n#9L^I``lKZDxrb`j#7V zoDaNDzIt&vN5muZ9xax4=d#2(WUMM18ka^XeCVqaWuCrVYZ{a2$8OW4e|$F|?{;3` zoTPcHtx+}7;KRRco*e&g)wUczB7G7J*crX=Yq12?Hu*M`)YparV#3jkmqEKPh|&|N2OaMa+4wL&Eb^Lx%?s*@B%@e7`Qs&5>fd-WM{( z@xh0%APFY#QG1(fY8V^{vbGA3VG|H8@u_pRl+kx!lZIX6}1VHiNY{VxH3KC;HK?e#; n4qJ-QM*xC>MIV`njxgN@xNAjUttt literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index f26c36d24..19c3409ff 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -110,6 +110,167 @@ ..([z],) * 6, ) +--- grid-subheaders-repeat-replace-orphan --- +#set page(height: 8em) +#grid( + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + ..([y],) * 12, + grid.header( + level: 2, + [c] + ), + ..([z],) * 10, +) + +--- grid-subheaders-repeat-replace-double-orphan --- +#set page(height: 8em) +#grid( + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + ..([y],) * 11, + grid.header( + level: 2, + [c] + ), + grid.header( + level: 3, + [d] + ), + ..([z],) * 10, +) + +--- grid-subheaders-repeat-replace-didnt-fit-once --- +#set page(height: 8em) +#grid( + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + ..([y],) * 10, + grid.header( + level: 2, + [c\ c\ c] + ), + ..([z],) * 4, +) + +--- grid-subheaders-multi-page-row --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + [y], + grid.header( + level: 3, + [c] + ), + [a], [b], + grid.cell( + block(fill: red, width: 1.5em, height: 6.4em) + ), + [y], + ..([z],) * 10, +) + +--- grid-subheaders-multi-page-rowspan --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + [y], + grid.header( + level: 3, + [c] + ), + [z], [z], + grid.cell( + rowspan: 5, + block(fill: red, width: 1.5em, height: 6.4em) + ), + [cell], + [cell] +) + +--- grid-subheaders-multi-page-row-right-after --- +#set page(height: 8em) +#grid( + columns: 1, + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + grid.header( + level: 3, + [c] + ), + grid.cell( + block(fill: red, width: 1.5em, height: 6.4em) + ), + [done.], + [done.] +) + +--- grid-subheaders-multi-page-rowspan-right-after --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header( + [a] + ), + [x], [y], + grid.header( + level: 2, + [b] + ), + grid.header( + level: 3, + [c] + ), + grid.cell( + rowspan: 5, + block(fill: red, width: 1.5em, height: 6.4em) + ), + [cell], + [cell], + grid.cell(x: 0)[done.], + grid.cell(x: 0)[done.] +) + --- grid-subheaders --- #set page(width: auto, height: 12em) #let rows(n) = { From daac9bba76483ec66525f2791fca62ce2e795e67 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 11 Apr 2025 02:52:55 -0300 Subject: [PATCH 45/82] rename and make grid-subheaders test smaller --- tests/ref/grid-subheaders-colorful.png | Bin 0 -> 11005 bytes tests/ref/grid-subheaders.png | Bin 19127 -> 0 bytes tests/suite/layout/grid/subheaders.typ | 50 ++++++++++++------------- 3 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 tests/ref/grid-subheaders-colorful.png delete mode 100644 tests/ref/grid-subheaders.png diff --git a/tests/ref/grid-subheaders-colorful.png b/tests/ref/grid-subheaders-colorful.png new file mode 100644 index 0000000000000000000000000000000000000000..38bb4ad8b88a2f3735b6f7eac0863b2e9102df58 GIT binary patch literal 11005 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 JY8kVz{{uIvZ$tn9 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders.png b/tests/ref/grid-subheaders.png deleted file mode 100644 index 89ccf2a9ad1134c8234a6a95b98ba08767fc535e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19127 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 VRk~+OfFDw^nIWu=X@+jG{{VfL^osxh diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index 19c3409ff..3410104aa 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -1,3 +1,28 @@ +--- grid-subheaders-colorful --- +#set page(width: auto, height: 12em) +#let rows(n) = { + range(n).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten() +} +#table( + columns: 5, + align: center + horizon, + table.header( + table.cell(colspan: 5)[*Cool Zone*], + ), + table.header( + level: 2, + table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*], + table.hline(start: 2, end: 3, stroke: yellow) + ), + ..rows(2), + table.header( + level: 2, + table.cell(stroke: red)[*New Name*], table.cell(stroke: aqua, colspan: 4)[*Other Data*], + table.hline(start: 2, end: 3, stroke: yellow) + ), + ..rows(3) +) + --- grid-subheaders-basic --- #grid( grid.header( @@ -271,31 +296,6 @@ grid.cell(x: 0)[done.] ) ---- grid-subheaders --- -#set page(width: auto, height: 12em) -#let rows(n) = { - range(n).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten() -} -#table( - columns: 5, - align: center + horizon, - table.header( - table.cell(colspan: 5)[*Cool Zone*], - ), - table.header( - level: 2, - table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*], - table.hline(start: 2, end: 3, stroke: yellow) - ), - ..rows(6), - table.header( - level: 2, - table.cell(stroke: red)[*New Name*], table.cell(stroke: aqua, colspan: 4)[*Other Data*], - table.hline(start: 2, end: 3, stroke: yellow) - ), - ..rows(5) -) - --- grid-subheaders-alone --- #table( table.header( From f6bc7f8d450648df0bb7f4a6b3453872eaa69a41 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:16:10 -0300 Subject: [PATCH 46/82] use exclusively snapshots for pending header orphan prevention - delete current_header_rows --- crates/typst-layout/src/grid/layouter.rs | 35 +++++++++++------------- crates/typst-layout/src/grid/repeated.rs | 17 ------------ crates/typst-layout/src/grid/rowspans.rs | 2 +- 3 files changed, 17 insertions(+), 37 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 76350d099..ab416533c 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -45,19 +45,21 @@ pub struct GridLayouter<'a> { pub(super) rowspans: Vec, /// The initial size of the current region before we started subtracting. pub(super) initial: Size, - /// The amount of header rows at the start of the current region. Note that - /// `repeating_headers` and `pending_headers` can change if we find a new - /// header inside the region, so this has to be stored separately for each - /// new region - we can't just reuse those. + /// The amount of repeated header rows at the start of the current region. + /// Note that `repeating_headers` and `pending_headers` can change if we + /// find a new header inside the region (not at the top), so this field + /// is required to access information from the top of the region. /// /// This is used for orphan prevention checks (if there are no rows other - /// than header rows upon finishing a region, we'd have an orphan). In - /// addition, this information is stored for each finished region so - /// rowspans placed later can know which rows to skip at the top of the - /// region when spanning more than one page. - pub(super) current_header_rows: usize, - /// Similar to the above, but stopping after the last repeated row at the - /// top. + /// than repeated header rows upon finishing a region, we'd have an orphan). + /// Note that non-repeated and pending repeated header rows are not included + /// in this number and thus use a separate mechanism for orphan prevention + /// (`lrows_orphan_shapshot` field). + /// + /// In addition, this information is used on finish region to calculate the + /// total height of resolved header rows at the top of the region, which is + /// used by multi-page rowspans so they can properly skip the header rows + /// at the top of the region during layout. pub(super) current_repeating_header_rows: usize, /// The end bound of the last repeating header at the start of the region. /// The last row might have disappeared due to being empty, so this is how @@ -187,7 +189,6 @@ impl<'a> GridLayouter<'a> { unbreakable_rows_left: 0, rowspans: vec![], initial: regions.size, - current_header_rows: 0, current_repeating_header_rows: 0, current_last_repeated_header_end: 0, finished: vec![], @@ -1472,7 +1473,6 @@ impl<'a> GridLayouter<'a> { if let Some(orphan_snapshot) = self.lrows_orphan_snapshot.take() { if !last { self.lrows.truncate(orphan_snapshot); - self.current_header_rows = self.current_header_rows.min(orphan_snapshot); self.current_repeating_header_rows = self.current_repeating_header_rows.min(orphan_snapshot); } @@ -1485,13 +1485,12 @@ impl<'a> GridLayouter<'a> { { // Remove the last row in the region if it is a gutter row. self.lrows.pop().unwrap(); - self.current_header_rows = self.current_header_rows.min(self.lrows.len()); self.current_repeating_header_rows = self.current_repeating_header_rows.min(self.lrows.len()); } let footer_would_be_widow = if let Some(last_header_row) = self - .current_header_rows + .current_repeating_header_rows .checked_sub(1) .and_then(|last_header_index| self.lrows.get(last_header_index)) { @@ -1503,7 +1502,7 @@ impl<'a> GridLayouter<'a> { .as_ref() .and_then(Repeatable::as_repeated) .is_none_or(|footer| footer.start != last_header_end) - && self.lrows.len() == self.current_header_rows + && self.lrows.len() == self.current_repeating_header_rows && may_progress_with_offset( self.regions, // Since we're trying to find a region where to place all @@ -1518,7 +1517,6 @@ impl<'a> GridLayouter<'a> { self.lrows.clear(); self.current_last_repeated_header_end = 0; self.current_repeating_header_rows = 0; - self.current_header_rows = 0; true } else { false @@ -1596,7 +1594,7 @@ impl<'a> GridLayouter<'a> { }; let height = frame.height(); - if i < self.current_header_rows { + if i < self.current_repeating_header_rows { header_row_height += height; } @@ -1703,7 +1701,6 @@ impl<'a> GridLayouter<'a> { ); if !last { - self.current_header_rows = 0; self.current_repeating_header_rows = 0; self.current_last_repeated_header_end = 0; self.header_height = Abs::zero(); diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index bd76cdda4..9800f0204 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -299,10 +299,6 @@ impl<'a> GridLayouter<'a> { } } - // Include both repeating and pending header rows as this number is - // used for orphan prevention. - self.current_header_rows = self.lrows.len(); - Ok(()) } @@ -327,11 +323,6 @@ impl<'a> GridLayouter<'a> { 0, )?; - // We already take the footer into account below. - // While skipping regions, footer height won't be automatically - // re-calculated until the end. - let mut skipped_region = false; - // TODO: remove this 'unbreakable rows left check', // consider if we can already be in an unbreakable row group? while self.unbreakable_rows_left == 0 @@ -351,15 +342,12 @@ impl<'a> GridLayouter<'a> { // at the top of the region, but after the repeating headers that // remained (which will be automatically placed in 'finish_region'). self.finish_region(engine, false)?; - skipped_region = true; } self.unbreakable_rows_left += total_header_row_count(headers.iter().map(Repeatable::unwrap)); let initial_row_count = self.lrows.len(); - let placing_at_the_start = - skipped_region || initial_row_count == self.current_header_rows; for header in headers { let header_height = self.layout_header_rows(header.unwrap(), engine, 0)?; @@ -378,11 +366,6 @@ impl<'a> GridLayouter<'a> { } } - if placing_at_the_start { - // Track header rows at the start of the region. - self.current_header_rows = self.lrows.len(); - } - // Remove new headers at the end of the region if upcoming child doesn't fit. // TODO: Short lived if footer comes afterwards if !short_lived { diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index dd3d6beff..22060c6aa 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -535,7 +535,7 @@ impl GridLayouter<'_> { // and unbreakable rows in general, so there is no risk // of accessing an incomplete list of rows. let initial_header_height = self.lrows - [..self.current_header_rows] + [..self.current_repeating_header_rows] .iter() .map(|row| match row { Row::Frame(frame, _, _) => frame.height(), From fc6a0074d82ee3386719062be1a0df6785566bc9 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 11 Apr 2025 02:50:54 -0300 Subject: [PATCH 47/82] add short lived header test --- ...-subheaders-repeat-replace-short-lived.png | Bin 0 -> 754 bytes tests/suite/layout/grid/subheaders.typ | 49 ++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/ref/grid-subheaders-repeat-replace-short-lived.png diff --git a/tests/ref/grid-subheaders-repeat-replace-short-lived.png b/tests/ref/grid-subheaders-repeat-replace-short-lived.png new file mode 100644 index 0000000000000000000000000000000000000000..410d6b317030a43f4cfecb4924e6bc9c02e2b752 GIT binary patch literal 754 zcmeAS@N?(olHy`uVBq!ia0vp^6+rxn14u9|R*Bodz`(TA)5S5Q;?~<+@!3IzGRHsK zPFuH&V^R067t6MBuk|!HoZ$BG=$rj#CpzVtb#9zAslfFN&*|J$=EckM-uACHo!0R; za%-BN;>@j9XJ-U!siyyWD_9b|!N0(6zxszqbMC+Y@N~}Sb;1``Df`SYNIX=}DE>G7 zxx$AxySKY>?74J#ee;2DC%1?(MQ;^W59X*i#$$E0aC6YX17DqIrJfP(zRlWH&BS4G z&7k;$I@7;xpO!7#^ipJ>C0pZi*+(r0rU_nHTNbIx-FSL!?peIQiSDG>$tr4GXq*HLSOAsQy!$ zKAD&4^v6Z~OxH~oMBDB$Y-sfUy2!|4wzy_odAKcClSQZMrw+%D)8AAZ4{X#e{P!Q^M9=3Bj8dcU z?(G(cxFgqi;H6rj^Mm7Y^TT78{|YwwCdBmJ&stVs$F4Q2of0lC7dc~djbFauz|)#_ z3)UUaeAIOO;DjDUNoS+B6rPh3lLXa(>A~6PogvVdoW?529> Date: Fri, 11 Apr 2025 14:18:29 -0300 Subject: [PATCH 48/82] restore orphan snapshot after pending header relayout --- crates/typst-layout/src/grid/layouter.rs | 6 ------ crates/typst-layout/src/grid/repeated.rs | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index ab416533c..52d42e732 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1542,12 +1542,6 @@ impl<'a> GridLayouter<'a> { let mut laid_out_footer_start = None; if !footer_would_be_widow { - // Did not trigger automatic header orphan / footer widow check. - // This means pending headers have successfully been placed once - // without hitting orphan prevention, so they may now be moved into - // repeating headers. - self.flush_pending_headers(); - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { // Don't layout the footer if it would be alone with the header in // the page (hence the widow check), and don't layout it twice. diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 9800f0204..d638c5afc 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -289,6 +289,12 @@ impl<'a> GridLayouter<'a> { self.current_repeating_header_rows = self.lrows.len(); + if !self.pending_headers.is_empty() { + // Restore snapshot: if pending headers placed again turn out to be + // orphans, remove their rows again. + self.lrows_orphan_snapshot = Some(self.lrows.len()); + } + for header in self.pending_headers { let header_height = self.layout_header_rows(header.unwrap(), engine, disambiguator)?; From 3ae46a94cd16b8190edc6a91b3e5998916ee66ab Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:35:15 -0300 Subject: [PATCH 49/82] add orphan prevention tests --- ...header-non-repeating-orphan-prevention.png | Bin 0 -> 453 bytes ...eaders-non-repeating-orphan-prevention.png | Bin 0 -> 347 bytes ...-subheaders-repeat-replace-short-lived.png | Bin 754 -> 795 bytes ...subheaders-repeating-orphan-prevention.png | Bin 0 -> 347 bytes ...aders-short-lived-no-orphan-prevention.png | Bin 0 -> 287 bytes tests/suite/layout/grid/headers.typ | 11 ++++ tests/suite/layout/grid/subheaders.typ | 52 ++++++++++++++++++ 7 files changed, 63 insertions(+) create mode 100644 tests/ref/grid-header-non-repeating-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-non-repeating-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-repeating-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-short-lived-no-orphan-prevention.png diff --git a/tests/ref/grid-header-non-repeating-orphan-prevention.png b/tests/ref/grid-header-non-repeating-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..d0dbc59748ce0e8462187568367c72b122387107 GIT binary patch literal 453 zcmeAS@N?(olHy`uVBq!ia0vp^6+k?L14uA%c^CCCFfjIbx;TbZ+y*RqZXeQh$X>hg>89Mje8Hk~*2HhJV$}T*Br<=6ar@>~ z4-S5wvi*x}6oaau``TSA=D#^mdr;<-JHPh5wC=B8{arsa*>~p7|F5`7s)pr|a_9g3 z4tb02D}DG_bVi#=y7Q-)poP)YJdsBBIo(skg+8RP+bJ;4|H@!gaQMs)MW*eCg~hw7 zj;b@2A1u{nRBu~W{`t++%zHDHJ`_K%+syIiwu>9fx0V*GeJlF%Iu3ZuPx~3I0`{N{ z?}lWccmIo>d$XkF!7^q^iFe9^7E8iA4JNHQEdv@jsG0~9}=u6{1-oD!M}8ba4!+xb^nhelMp&iDQMw z7f5I5axBW`SY)_j<-(TB-1T>3!D zZqky4@4VGEG^I-(DE=DvRI2a0NOkw@74yn?eoyz`Xa7-d&jrIfw`DzoO0;J`|7Eqg zF?b>G9bZ5HWab@Gb#*e&WDifBd*^&-h@G5uoAueS&AMBqFK)Z?Na^!E^G5Hhw-$5S xc-O_xIFNty;!e((il2MAB4!@I3Z!}U8CPDIc=*0T)i+S6dAjl*Fsymbg(FOtCm_*Y^+BubLu9IVa0OJZT7I0&h6|I)Qett1ov@$ zfgii?b6mTw{WAo#G6)kUOqlRL!11ng;}Ce%J6Q^W7n(crA%AfFpg#`+YxDZ6dR=KBXZPgJVLq`K^dPv|ih$iYBW1r3@VABcX@%`hwp$JeJlrTc2&{PkdenNY zgdp%z<;lV+&{Q9LhJly05I9lYl?j3M_eBGNH5SoA;KzI4=A1^r^74@oSV6b6T~KydFxx6~T)rdea3Wyu*0LfW0{*yZ z-rfX(4~e@90nhB0q2ma+{pgpLiZB39Uq@{cArS1?0DppIi_+lfwP6tae3t($5++QT z@ZVt6;yWfxm@r|6WDLpvtB}ld3KWuw5j55584x_Sj#m+@1jjdn;HzseaKN02fur4p zKWi)iSo}qk9Rh*3XN4iKSLNKbf`ARGuTAQ-l79N6Q1FfO)uj;l)AH^p1eV%|DG2!RbQ%c!E`1;X zfyH1H2<$Hq0|XvwGqovesz1BjV-UD?Sl0-FQ*-$#2rMdJ5CWT3_9GB@DHd5&*4L^3 zk`Xi2&SdFp2rRaGfxwYH$vz0&bJ^6Otf~GUi7PEa;8w%gN(dZ{=f)whl)EtSVDX0v z2t4a{jq?aYm@r|&49PfLgb5QS%#*AF8#JR delta 704 zcmV;x0zdtm2J!`vB!9U{L_t(|+U?q3Yf^C>$MHRuuDS@Ki@NBhwhOIEH!+Y#;l=E~ z&5=TFHi@y4l5j=L49jh01EZ#H?T@Q9mWcgXwPr3MnYB92m{TexXZr0CbW^ZTa6Z2$ z@Zy}`@9^cE^R5%nsv}I8Fk!-f0T=ye=OFOxySGLNywcOJgMYyG$+Q*%TeTA>QwVs* z3_8_uvr@sp^+TFv%oU5<1%i752sqX-bMPku{1bk6T|;92t^$FR z_R3}m+}Sa3wSS6$uQ)(^{B>uO4Fb#Yq7MSsG@x5OyfJ`*OZB2MIJ*@D2hYdU_MIyq zNrVuvVovWVBH(bBXaxcPFa%~HaPrvoNC^Rt2VdOoM8F**m9Z%V9BMw&E(mz4VtfvP zC2SW6oZDRqLExc_zAkkQiSN050Rs2B&$L3|)uN^dfq%sm#lVw>_a`9mhgfuu{}u@o zCQMlV45JqJm@r|&gc*`CB>S&Ivc%!GZ7BpSdig8}_{;VsfxxjXasK{UANb*h3_00*-gf4$O2iw z*zL4H;McXiix61c9)ZB=CUHaH$6lXTU8nj}H1-k#_f9(<5V*Rmt3qJWrxeUQ)(;-r5ZB5$qW7QMd!0000}8ba4!+xb^nhelMp&iDQMw z7f5I5axBW`SY)_j<-(TB-1T>3!D zZqky4@4VGEG^I-(DE=DvRI2a0NOkw@74yn?eoyz`Xa7-d&jrIfw`DzoO0;J`|7Eqg zF?b>G9bZ5HWab@Gb#*e&WDifBd*^&-h@G5uoAueS&AMBqFK)Z?Na^!E^G5Hhw-$5S xc-O_xIFNty;!e((il2MAB4!@I3Z!}U8CPDIc=*0T)i+S6dAjW|_HDUg$ zrM6O4E!P$-GE!QxSL$}q9G+QHSshhPF-PjVyPMDP{bE}5>9e9t-=Xrj2hGL$-^08# zL=7Z(*qRR}pi_HfZCnq2fzhV27KD_l`@#_2KuNq9#f8F)e-*VM_Eyo^-IF|>JTk8cH z=l?%d%Tkxo_Tb&a=NBWH^gf>~d1>8<=?0*o-?{EFxJu3spK@qX8p!9Ku6{1-oD!M< D=nr>z literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index 20595c9f8..882397695 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -250,6 +250,17 @@ ..([Test], [Test], [Test]) * 20 ) +--- grid-header-non-repeating-orphan-prevention --- +#set page(height: 5em) +#v(2em) +#grid( + grid.header(repeat: false)[*Abc*], + [a], + [b], + [c], + [d] +) + --- grid-header-empty --- // Empty header should just be a repeated blank row #set page(height: 12em) diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index 8fc9e4987..5c28f1065 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -197,6 +197,8 @@ ) --- grid-subheaders-repeat-replace-short-lived --- +// No orphan prevention for short-lived headers +// (followed by replacing headers). #set page(height: 8em) #grid( grid.header( @@ -345,6 +347,56 @@ grid.cell(x: 0)[done.] ) +--- grid-subheaders-short-lived-no-orphan-prevention --- +// No orphan prevention for short-lived headers. +#set page(height: 8em) +#v(5em) +#grid( + grid.header( + level: 2, + [b] + ), + grid.header( + level: 2, + [c] + ), + [d] +) + +--- grid-subheaders-repeating-orphan-prevention --- +#set page(height: 8em) +#v(4.5em) +#grid( + grid.header( + repeat: true, + level: 2, + [L2] + ), + grid.header( + repeat: true, + level: 4, + [L4] + ), + [a] +) + +--- grid-subheaders-non-repeating-orphan-prevention --- +#set page(height: 8em) +#v(4.5em) +#grid( + grid.header( + repeat: false, + level: 2, + [L2] + ), + grid.header( + repeat: false, + level: 4, + [L4] + ), + [a] +) + --- grid-subheaders-alone --- #table( table.header( From 6833ea321aff8cc483e6fca293e959489d3e6686 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:49:08 -0300 Subject: [PATCH 50/82] grid header single replacement tests --- tests/ref/grid-header-not-at-the-top.png | Bin 0 -> 605 bytes tests/ref/grid-header-replace-doesnt-fit.png | Bin 0 -> 559 bytes tests/ref/grid-header-replace-orphan.png | Bin 0 -> 559 bytes tests/ref/grid-header-replace.png | Bin 0 -> 692 bytes tests/suite/layout/grid/headers.typ | 50 +++++++++++++++++++ 5 files changed, 50 insertions(+) create mode 100644 tests/ref/grid-header-not-at-the-top.png create mode 100644 tests/ref/grid-header-replace-doesnt-fit.png create mode 100644 tests/ref/grid-header-replace-orphan.png create mode 100644 tests/ref/grid-header-replace.png diff --git a/tests/ref/grid-header-not-at-the-top.png b/tests/ref/grid-header-not-at-the-top.png new file mode 100644 index 0000000000000000000000000000000000000000..96f9adcda23800f4f7d4c736c0519648a38e13bb GIT binary patch literal 605 zcmeAS@N?(olHy`uVBq!ia0vp^6+k?L14uA%c^CCCFfgfjx;TbZ+&ZD-1Lkf5%VV8QpMfvfojI4J8JW2Ml8^|Gccd!?Qs2 z|0~90w#ly^yt(j?zhT;}{ni&sSG{%c5PjoYTWcnGp`_T~HH?cXfc1Vv^Yv%#%iNQq z^W~22(u(kxSh;>OfBK81U78L|KV_^` zuvVJi^}!pfg<;d5M`YToCTxDtdwT2UM{7-0nf25Dm3JJF|Gesw52wX_U6aS}F7JyH zl(_7hnAZ&nkL8T#K!G#6eX+y(&7QV7^*lPVTA_UZHy@wfuuQwXm_0Yn!Fy+dx$^eU zmvlovWL&!bgU8|W^W^&JF&nv*S3O9Vy`2Ajt<&X=*C*En#;#76J#b{*d5eFW{vGkE zj7jy45R55&e)q2Z(WR5;Wj*Kluy}=hSAs>ti92G#Qi@Fc^XE@$be^ur%&yOQ@T&W* m)CUC?rxYBJV(VcpZ$5)_#*EtAZ*=B^(v7F9pUXO@geCx~>kM50 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-replace-doesnt-fit.png b/tests/ref/grid-header-replace-doesnt-fit.png new file mode 100644 index 0000000000000000000000000000000000000000..a06ce0e965d7a7984fd2985dc2af298d7eb4e287 GIT binary patch literal 559 zcmV+~0?_@5P)W-(+^l8q*YA-5)S&7GO~TG;pl^giPGuHN0Jp0~a|PY{R_#4y7QGt4l<48Jz` z9$4vdiic(8!1*t|aM&&j@d1)SIIP+DUcdQ6FSrk(Ewf=4y`P82Zw0}F`fb>>`hByh=Kz>!cD?-q zS)Y0!f#*jpZ6vVYHIP99n>$ZaN#I)5Q1}`J+);=tP{0di01ced-R>uW3s5;tG-{)k z1TIC1n*w$zI7_n5nBVX@V^{q@SOKTC>;2q^7sE) zJ^Lv)8WC9_4z}rh8bpjp9X6Jn_~fUk4Ze0C^vY&v$ya4?GWidEO8ZC4^yy84fo19$4vd ziic(8!1)ioaM&&j^8u1UIIP)tX9XlhjLdhJU!@F)g13`L!c>4{IDS8^OA0jJiGu5v zyltN^AsO(=tc({o>cdQ6FSHNgEwleFdOr@2-wJ{U_1my%_4{U1&jB#e>$ZaN#I)5(El|GxT6qPpnw<302(-@yWLL$7oc*QXw*h630#U2 zHwEl!=$hg4mSKh&W|(1y8D==BV5;zr8D^N_mw*F!9-D4e6m0tt*^+O^Q%p%CO`>4@ zt%B#P_35op3*50(4WwnmqL$kif5%OVg|nh>zH};2v^7sE* zJ^L;<8WC9_4z}r0_&fjr literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-replace.png b/tests/ref/grid-header-replace.png new file mode 100644 index 0000000000000000000000000000000000000000..dafecd7f48f6e81d378d703d92d3ec8f7d558f09 GIT binary patch literal 692 zcmV;l0!#ggP)000003QKZ~0007eNklFkL?*3O2qTwXFILl>^zbcE7k+JzffYRkUEM$-gbuyz<_IxFERQ9)Z_t z|0Z`fjR6lE1~Pwuyzlx;0{6SREhKPipzSybEITKTlE6;g-5o;|Fk+bEMSx+38D@Af z!c>QQ%rL_Ya|Xg02xlORDg)6-L>b77YN7!Ws($%{20rjc`bhJ{Xum-OTY4zq{)28q z>VNgzIV=k9%2t~y19QbAsr9NT7`YelPWf#1UZMkp!K#Zuu@)Zv3ID6j`yPvhPwtAZ zX(=tn-b)Y*=avN(eV-=8!t2h!wK4otSt8$ux=I}EF&owOQ$YBNGafQGj)>g3Y62u% zX7uT45;&s!UO)n0>xG^KHa6qDp8|fn=gyHz3K)sW7=JA?%rL_YGt9pRxW^1L%<#hC zH0B?#ZBP{KU6E(1@XZySg&o($r9K7>D?PJ!O|9tErwrY-MLvqu=g(@V=9u`@XUnOe zA}|^g3a8hWr&>1+POdLul%Wa|_f8KfSCJBtXu$cxv43NM*+hrPfx&TWIj_xLc<*FdBw+u7PFvAQp%rL_l a1^)wiJV+UqAlozm0000 Date: Fri, 11 Apr 2025 19:30:35 -0300 Subject: [PATCH 51/82] add subheaders demo test --- tests/ref/grid-subheaders-demo.png | Bin 0 -> 5064 bytes tests/suite/layout/grid/subheaders.typ | 29 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/ref/grid-subheaders-demo.png diff --git a/tests/ref/grid-subheaders-demo.png b/tests/ref/grid-subheaders-demo.png new file mode 100644 index 0000000000000000000000000000000000000000..ec1cd998c9aa4e74c7ecc16758a7c172d998cebd GIT binary patch literal 5064 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 literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index 5c28f1065..e1c7b20d0 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -1,3 +1,32 @@ +--- grid-subheaders-demo --- +#set page(height: 15.2em) +#table( + columns: 2, + align: center, + table.header( + table.cell(colspan: 2)[*Regional User Data*], + ), + table.header( + level: 2, + table.cell(colspan: 2)[*Germany*], + [*Username*], [*Joined*] + ), + [john123], [2024], + [rob8], [2025], + [joe1], [2025], + [joe2], [2025], + [martha], [2025], + [pear], [2025], + table.header( + level: 2, + table.cell(colspan: 2)[*United States*], + [*Username*], [*Joined*] + ), + [cool4], [2023], + [roger], [2023], + [bigfan55], [2022] +) + --- grid-subheaders-colorful --- #set page(width: auto, height: 12em) #let rows(n) = { From e9e37a313a292b8b2e406c6ed3e71c7ae35e579c Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Thu, 17 Apr 2025 02:44:46 -0300 Subject: [PATCH 52/82] allow breaking apart consecutive subheaders on pathological cases - at the end of grid - right before footer --- crates/typst-layout/src/grid/repeated.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index d638c5afc..0d39659c5 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -18,9 +18,27 @@ impl<'a> GridLayouter<'a> { self.upcoming_headers = new_upcoming_headers; let (non_conflicting_headers, conflicting_headers) = match conflicting_header { + // Headers succeeded by end of grid or footer are short lived and + // can be placed in separate regions (no orphan prevention). + // TODO: do this during grid resolving? + // might be needed for multiple footers. Or maybe not if we check + // "upcoming_footers" (O(1) here), however that looks like. + _ if consecutive_headers + .last() + .is_some_and(|x| x.unwrap().end == self.grid.rows.len()) + || self + .grid + .footer + .as_ref() + .zip(consecutive_headers.last()) + .is_some_and(|(f, h)| f.unwrap().start == h.unwrap().end) => + { + (Default::default(), consecutive_headers) + } + Some(conflicting_header) => { // All immediately conflicting headers will - // be placed as normal rows. + // be laid out without orphan prevention. consecutive_headers.split_at(consecutive_headers.partition_point(|h| { conflicting_header.unwrap().level > h.unwrap().level })) From e76ea64cb01eae1950807b06be8c90a6a5d141a0 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Thu, 17 Apr 2025 02:45:25 -0300 Subject: [PATCH 53/82] add test for orphan prevention of alone subheaders Consecutive subheaders can be broken apart in special cases --- ...rid-subheaders-alone-no-orphan-prevention.png | Bin 0 -> 254 bytes tests/suite/layout/grid/subheaders.typ | 15 +++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/ref/grid-subheaders-alone-no-orphan-prevention.png diff --git a/tests/ref/grid-subheaders-alone-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-no-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..17bf3fe4f5ff3f740c0251c94b008db961dde494 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0vp^6+oQL0VEh^`bt^@siU4Qjv*Dd-rg|eI^-bYdQsTjvu%utTLj)*j)>gF?s!4Xdc&n-PB|t^Z)m(?YVnLS6MQ!L$v)mWNe>R+@o?*I zXhZ_pjQLg8p1bOt4!`mWy0xFRoqf`hANRC= Date: Thu, 17 Apr 2025 14:12:44 -0300 Subject: [PATCH 54/82] add several footer tests --- ...alone-with-footer-no-orphan-prevention.png | Bin 0 -> 377 bytes .../ref/grid-subheaders-alone-with-footer.png | Bin 0 -> 319 bytes ...ders-basic-non-consecutive-with-footer.png | Bin 0 -> 279 bytes .../ref/grid-subheaders-basic-with-footer.png | Bin 0 -> 256 bytes ...multi-page-row-right-after-with-footer.png | Bin 0 -> 1207 bytes ...-subheaders-multi-page-row-with-footer.png | Bin 0 -> 1345 bytes ...headers-multi-page-rowspan-with-footer.png | Bin 0 -> 1190 bytes ...ders-repeat-replace-with-footer-orphan.png | Bin 0 -> 961 bytes ...-subheaders-repeat-replace-with-footer.png | Bin 0 -> 992 bytes .../grid-subheaders-repeat-with-footer.png | Bin 0 -> 584 bytes tests/suite/layout/grid/subheaders.typ | 208 ++++++++++++++++++ 11 files changed, 208 insertions(+) create mode 100644 tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-alone-with-footer.png create mode 100644 tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png create mode 100644 tests/ref/grid-subheaders-basic-with-footer.png create mode 100644 tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png create mode 100644 tests/ref/grid-subheaders-multi-page-row-with-footer.png create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-with-footer.png create mode 100644 tests/ref/grid-subheaders-repeat-with-footer.png diff --git a/tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..74de3e16889d6c7730a17d1eff8e8672e6cf75d5 GIT binary patch literal 377 zcmV-<0fzpGP)a13_8pg>uri+SxV=B&F22IU9ml;BqO=4sFmR0E+f>fl&a(qJwc=Y_%sPN` zzF5oL1IG`3oqdaLLFgKmndnlSD>;YfC0hD_tl*9YY^KF{J=ywk4Hq(ZzAxWL`5rG2 z;Gw~*lT+3vwjIEyC!w{y8_pOw25>vjfq$iI7-Ffg)tx;TbZ+)Dmo@6eEVDc1KY*VFH;MfoNbK^v=>f4%KKGA$Z|4Jc5B6qh(D#7hmSID{PCuDwI*}Y z$Jw0Rd)bdX+I6@5E|AjpweHUAOIse;y)t{F_H+)T2R)0XAHBMC{X|Ev`x!F)!QItW zY$qN$|71@2*!d@YKl`zy<6rai*^V9mzx+G@grmY?ye&tM&*5A8PlBV{e(%mN`CFby ztrJUcUE{#^uJp?$3mzhY<29|TbYb1Z)(Ex>v Mr>mdKI;Vst0B7=#3IG5A literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png b/tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..6f2a57beb3810ea4a91f861eee59c1ddd837d9b0 GIT binary patch literal 279 zcmeAS@N?(olHy`uVBq!ia0vp^6+mpt0VEjKpDfq|r0#jTIEGZ*O8#N*(2#iLQDC>b zuH4p&D@jH=|8{M8_^2VG=G(EWQy4e?INQ>Ck2$IE?z`RZn3FzET{hR%+&yGfUDfyV zd#^Jmew11}|0A>T74F0HczjNlo_hJFA!3jD=hkam8#h>(zdOFw9jKg*KIEGZ*O8#N*(2#iLQDC>b zuH4p&D@jH=|8{M8_^2VG=G(EWQy4e?INQ>Ck2$IE?z`RZfK=!2bBks(Cw^S`htcb* z`ltDSN{ylpKG5-it0WtYvt0}*d6?Fc6d3I^PV3>98tnIZ9I(8TS z?XPyw(Yt5l;CMIw)BmRp5r2%=E4;eJu=LfL!_3Ja*G~f47kxBnF5|`>^>_b20#X$} z|2J~Jl8t*l_Y}KI;oLiAPyc0zr$OEOM~s=_W=GUd;ai98Kwk57^>bP0l+XkK)WLR} literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png b/tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..119a2c22b0e9484962bc258edd4a7fb488f092b5 GIT binary patch literal 1207 zcmV;o1W5adP)FMd#*498kKp7bsczAegYirNX&+F^!$;rvSzP>LnFH%xc zPEJnZ;^I0wI&E!jhlhu>w6vL-ne_DZ;Nalf+uMPGfni}`_xJZ9At5$4Hqg+}jg5`_ z`}^kR=0-+FudlCJSy{os!L_xue0+R4IXQWGdCbhrn3$McTwLep=dG=+*VosMj*i^i z+}hgOS65eqgM+xZxNK}}u&}U7N=k-?hU7Xhuq-7}QBlgu%Duh4X=!Qm^Yg2#tIp2O zh=_>l>gq#7Ln$dK^*%H4@$r(9k`WOR;o;$;qN3m5-!wEdUteG4<>e?SC}w76V`F0` zCMGd4F(4oyH#ax#?(SV(T`VjtJUl!}NlB5Bk!WaWv9Yo6@bIjxta^HSq@<*jl$4*J zpMrvdg@uKqqoZ+gao*nE{QUftm6g=g)KgPaDk>^gR#vB{r#m}4etv$tyStZ{mu_xu zv$M0+)zyiKiE?ssxw*OB-QD^5`RM5A`1tr`Wo1W4M@vgfp`oFVkB_RVs*sS7*x12L?Ck8#&CNkULH73crKP3W+1d5=_5S|;00000_NVdy00MVOL_t(|+U?p^ zS6e|Ch2h;O5~RTzS^~64i%Y%K-QA74QN!J(1q#FejAScQbip_SzXQpr2V_*f|f^iDh9CLqB(V}gh~ zMNvq&d(Sm(gMjLMx2+Nabb83pO-=Y^!r$;w5xVOBM?zuiLkO^!4D=(#j6+1yI+70w z6LFXzVRQsCj6c%SAz|D97-pDZh8bpj{B9(oE#a>a-aHWzhh!X*ZK@$zcvWafW)@q%=no!{39lbMkz4#_wq(<1!r z)=h~p;Jj^yjIaU;1B0Ix_$)aFW8-{&E^L-17qk$6g#~4C2{jG)5(;|oq@+%HnuEgXRjusSNcK>VKl86?OFvXrObbY8b{q9J$u)vwg1< z)|%S#qtJw2frMmPmV%~}2E>9*Q56`AKoe*5=c<%Yqy31ztoeM~W$a%GFG33q#}HOt zSdjh!Z%}zjZHX_$TN4|fUyf5Fikm|p^?_(4aJ_v*RPe>>@VAUzn4~*_$Ox{ zKm%=8uByPrBud+#yXtCjA%uea(7+mGEWY=PedDHBxMvPBW@ifd*hfS}L_|cik$;(T VJ^Ny!_+9`2002ovPDHLkV1fl)U(5gi literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-row-with-footer.png b/tests/ref/grid-subheaders-multi-page-row-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..a440eac40523ab7dc093fe540dee131366f606b1 GIT binary patch literal 1345 zcmV-H1-|-;P)8SpI={J?d|Qbu&~q9)1soH zm6esfy}fO1ZR_jndwY9`h=^ljW6H|PprD|Ff`X=|raL=3<>loW85yjstYTtft*xy< zKtSs1>RMV_tE;QJy1I#piC$h_X=!PwsHiF`Dr#zKzP`RMFE8HS-qzOE*VorsSy_#Z zjq~&KaBy(q;^LN;mU?=6wzjr1GBTW;oEsY(adB}pG&DXwK2A9R(PfScq zl9H0e#l_Cf&QnuUii(O>R#sC@3hDl$3*mgP53@+uPfH zeSKYBU14Ei#Kgo+O-)KlN;Ne#q@<*qo13+@wRU!P;NakfhK6fvYq+?$zrVlZINJvQJfPg|mLYJ49 zrKP2di;K6nx6RGX*x1FM?L_4fAmK|w*n z!otSJ#&2(L$jHb5002(Q6gmI^0~Sd{K~#9!?b=maR8bfO;N`$Dz%UX+t8_Qmq=Jgw z-GzY?N=r*9-5oRjQ!mf3f57(vv);RPwVrb>_Ql={L_~CuY)()7g@(ELg(YZMUJO49 z4UrKpCp1JA9zPZUe^TNKtp^R- zQgPdJD0ti1&C`#;lm9n3q@nF#CJl+u1Buwc0DZK z^+ziZu`?Te!?*a!Sd&n5L`>7(qdUD160TQ`HSaB+=!S%mJeKjO=IF*KE zJ09rpdScx=#oNC}Ms>azQ^88bi$PGZJGrxNhk`%LD>nF-{()I&=kzBI1yYLoy;F4#_wq zk*|Va}C?m{aMj2~P`*|~pI9HeyZ^pwdB-9pYMiEoF ziR)$*F>ZIRnNdb!#HtxZeE-(&*b}oI5Has*Yxa$^gU=x2F?0-m3{un zM$XSbGm6O1+ln`%h_q8dnPxoPqGF0t%_t%+_DZ@LWn8*!Mj4?;H=@iaqM-4$(>LZN zB|}E%W)d{mMxf&zAFicrv_MB{2OqA9h=_=Yh)CCeRs*%|C#X8f00000NkvXXu0mjf D`7EVb literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png b/tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..b91046675c4e7b691ee56ce05c30bed9dd747eb5 GIT binary patch literal 1190 zcmV;X1X=ruP)+1bLv!tUFMdaySsjVespwnw6wH@goKZekNNres;a7Ta&o!3xr2j) z+S=M*Utg7#l`1MKtgNiOyu6>EpOTW2-rnAGb92DJz=wy2ZfKPvGcz;b;NT)6B2-jVA0HpJwY9}EDa*^t@bK{Q@$vNZ^oWRv&d$!1l$2|0Yb7Nm z!^6XyCm?ej80zZk#KgpAW@b4#Ia5LnFYoW~c6N5-*J3Bk=?d|gN^8Nk&V`F2WprE9rq{ha^LPA2Orlxy)dl?xS`}_NXf`aDe=J@#d z($dnmx3`Oni<_I9{QUf~va;*z>p(z2o}QlM$Y8y4#?L_6!U6AVAZ>c~dAnehUId z?%Z}F&jcYNIc^+ptQGCGf~tU^S&+km`M^xbXBK9w-~KOM|I)tLF5eX0?x zs4B`nl~`QZ)AX-%^3lWR2K-0A{)Ru=eSudU65-mN_!(CiPOU}E7=CXKs16&~QK*07 zcD+^|*0loFk1-F`;now$t2gdi^r7LC80c3RhRM+g&1^!rtM)>j#8Yi@;9^{5*f2eY zvL%D^|M>jS;2RvDm^!UG>^K693^>jGs>2=|kmv$%%Aq!F@3A=%_C-QNM`v1^6@~!| z0DlUGvDmfiO2e5LW;iFq*jO)xh$)mog7)%C zh5WJ+S%rkoeOQBpy|s{0SA!}@sNa8}4QYHs&oILbGt4l<3^U9y!wfUbFvA>@aY)7? z8HZ#Xk`WPyWE_$a5phVyAsL5c9FlQJ#vvJpWE_%lNX9qx3^UB|mJeUK|3D;+Ug#kq z-qQ#XiF3tbAH4D`eECu%M05}8`C5dCh=_=Yh$uh&22a+>M5^?6$^ZZW07*qoM6N<$ Eg8dy_>;M1& literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png b/tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png new file mode 100644 index 0000000000000000000000000000000000000000..b50fae716c252da72a6a60daa32ac8eb18113a49 GIT binary patch literal 961 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!h6T9hoLBLtq^Kg($^A(*`qx0=Sn{_68Zv5Er ztnk=jm&HrWES4;@Cai2zO(Z= zB{<({de6eNuQG7v4A%p-AC;II&x>41eBjaflSS%P7rTN*CI=&be0|Kpa=rBjB-y7t z(-37?ZRI|d+2~3oYgOZc)wk0OH!R^`tGnLY$gQujcc~VGnf$cIuoD6ErSt_kax8AP zG`eo@Y_#=fWvbu3siSdo)70;US`1-)yJo$aVClD7QGjtHjbUZEU!1j2O%ZtP9xW%l0Z@ZjkdNuD{f3neCn9?`$%`CI&i%>4{z zTNRysZag`c?jApo_*IOf#T0W zj26jBhAO|;J8in{xM0E>?y!AFZpTPiEckeXNy9<&6!XEKr5rodH@x1|)KIV6xAIn% zht2NQC26Y|ZahDlM+DF zlfeQv?;kqFC(P#O>DzwbbhV>muVYaM&wjwwNflg{mnUj17$Vmff zQ=jzL_=fGvwgcZ**QzTNeAB+!aKO`bfu-E#tJjH3ma!dthlveo$E=~kq}GVeA|99!dh1a_`5^EsNIPUimiBFfFvc^_5vcfjLd##}ac7Fn#2!dI6!wAA@C?kLLk&y*;QlIEJVKrBsj<%+~!kF#Db%~y0vf9fpzF(b`Pbgonv z|ISR^S(9$vDu|lacQ*I?{_+cs?EhMRZ#Vytc%AwB(RKP~+#TfQ54CU#tNoGx5O()( zzd*&O&oMGA+r&d#9TR-#Te2`o%YRumBV_BU{j+VkS99#Rd0n(w^3^JCZKlTK&1#Ap z^bWqV?r+F-3-Lnk}rX1mGZrk-uki&1j(}ByK=d-SsJ^z&OVC|P# z85}V&>e>Q7%6K#S*!$0ab4ZxEIzIe*SytssnbiRd8w?%Pb}2g7maU6X*znFrnoVqf zF3+1R)>VscFb(kq?;PWcNrE`ANdw;%k=J3 zxZ6CX1kR{+0SX1hPR$Mp=l|JDM0Bw*t z-^g3HQ^MlWR!*k#Z&>f{ENQ=&nBcx(1&^F{!r=qQ42|!L0Yk~>N4&wS!{qA7GB5C) zu2X`ht}sUq-}E33n}Bt?;n!EiUF`RBd@$+Y9~+K0TiYrXH@M8w5!j)&$(W02ciptdN1OfL)(}>D$$+IyQb^%L)~6-*EazS_*=D7MG(G5SIQF1|MW^gD|MpdUoVAVH zB$&#rf=UG<+9t6u<+oX|DHJ@8&0V!?on4&B9~*09J|_MA_I1t)C#xQ}9(cU5&syNd zg-c6v%pdDC*X vZL+KfbNStWT8jf+3w9~U*_^^{hw2$)R<{)Guzvj=lyyB_{an^LB{Ts5mQ$mD literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-with-footer.png b/tests/ref/grid-subheaders-repeat-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..39f8465e72743d1cd02666360d20a045f5c8518f GIT binary patch literal 584 zcmV-O0=NB%P)d4jWsoXrOhbLkk*-hhdAIjlSsu+Eecc~su6K9{~pDBl@ z{{$_iPqA+0a7|(8(Eog>{))XCXghgx23#1pNJ=&!uqI<&AlSN#fcGl<$ISN4^^SqV zR-+gK4>;u-0@oR!d%T|HTZF*k9}k*rJNZWa5P0AEUJZep7C@tJ?~n)te#mNc^T%8X z9PPH-Ah0nk9tf*Oofdn1(Cvht# z+fEL<0fFar8G*pHv!GEAmB|?dj%pd@5LgQ27y^fqLjr-<{x4(TmlQD>y!H1E10QS! zc(zEGFk!-9!KlT1OqeiX!VJk6lKrZX%#scY$vm*~MZ0;!z$sH{x1a!JuondDE(E-p z>M46gz)$zSsL9?=v-U*@yxu9@5V&C;H0oZ9+(F<-P2(y Date: Thu, 17 Apr 2025 14:58:37 -0300 Subject: [PATCH 55/82] initial gutter tests --- ...d-subheaders-multi-page-rowspan-gutter.png | Bin 0 -> 1560 bytes tests/ref/grid-subheaders-repeat-gutter.png | Bin 0 -> 503 bytes tests/suite/layout/grid/subheaders.typ | 49 ++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan-gutter.png create mode 100644 tests/ref/grid-subheaders-repeat-gutter.png diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-gutter.png b/tests/ref/grid-subheaders-multi-page-rowspan-gutter.png new file mode 100644 index 0000000000000000000000000000000000000000..53beeb02e4183db6499534946ce75b5180d79e52 GIT binary patch literal 1560 zcmV+z2Iu*SP)wlL_|b3Ha6DQ)<{T5aBy(N#l_p(+s@9; zK|w($CnrEaK!SpT#KgqCy}gW#jP331@bK_7G&Fg6d2MZNTU%TD`uduhnj#`17#J9t znVGS%v1w^(?(Xh|g@x+kRH^z`&SJw4*$;x#ojr>CbQBO^02GtA7)O-)T185uD# zF-l5G!NI|-tgJXVIBIHYVq#*Ro}QMLmXni{;NakVe0*A3TBD<*=jZ49`}=iub==(C zj*gDW$;syC=AE6LqN1XEdwa03u-Dhu;o;%3va)k?b98icrlzLx@$rw3kByCuSy@@j z%gY7^231v6>FMd{=;(@yit_UE)6>(bsi}j5gIHKt-{0SagoL!TwD|b={QUgn_3U)YQ~yXlR$0m(bAAFE1|#2M4IAs8?54|3Nl-+>KQL00XE=L_t(|+U?qDQ&VRc z#__Ahh)D<`2|GwC$|8shwHBo;uDEOWec$(eb!(Mc!78GtAjlS3k{nBlCHV0=sWXX> zhj;QkH__`i!<+lfeV@XFwe8A<-KC+H}}82Hb1~;C~Hs z9_V4+QCWcHIo#upwS;DFH6A|nO2mQ|6SJy`|4&1+$)CGieB}@k5z)eUOb9=DA|g&; zIe}eN6WGuip$TmLI)MqS?4E0VZEb}}xD0hgQhTm3q435PFfU%RbxT9y{t0YCXacJk z=kGHjT6C`bUJMBZStx;ot5L|fehniK;3=-09OKOS(Pj0czf)o(Q040VL1diZk6>=f zP&l=U?HDgKK2AukVmk+|Gv`f*yK;bIM-zP)Oox-J*p3r`b;*U{bB4mHRqWoTK=90} zz7t(%4TbF!SVLhuh8bSK@S*M7M8f&S!8VaFin@Om37<-QepDo!IC=5AYCcX3GrSPt zz(%3)3tpjcc4+4gnedSpTla~Cy~x`y6#k-EAZ!)3p6ZFESjNH=L$gD(X)eCGGCb^9 zrgGR$JE27|OBJ-8^7%(7BH{#=6Idc5;sllxSWaL$f#n326Idc5PGC8KZNxJG0000< KMNUMnLSTZPN*i4O literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-gutter.png b/tests/ref/grid-subheaders-repeat-gutter.png new file mode 100644 index 0000000000000000000000000000000000000000..f8f7380ed16063c0e2cb157876e1f9b3e77dbc1e GIT binary patch literal 503 zcmeAS@N?(olHy`uVBq!ia0vp^6+rxn14u9|R*Bodz`(fA)5S5Q;?~<+=d}+P${a6L zzi)p?{-J+>NV})yL!~XZw?`iG+8n9)Q!g+|n>~9`m`ux76ZV+4AhR$PZF{~edSb4< z>-YU{`c%1e-uwCof$#m_+f7ez)}F54@M&xQNAWzus@vf>O=5lMh_{6Wh*Y z$Nl@wwTzQCbKbt+ocr5jLzcRN)$+*;xZe4w>{H!vU-n#g*xq*?4<>G6*wfWwT0GmQ@xW`{?(3EnCj859@45M`&-cLb zl}#*mbBokfKU9QmdG)j7_o{~Z-1`t`629ftjKz(wr%o&Q_4u@nO>SiG zw5d<}8X@4nTo`XY4-oixxFNw~^)QiRvci)Dfw1s+OA0bP00Av2dkhVLKv5DI{s)Rd k_~j6DkI6ElfY*uN*$+Mp`BbOox(XDdp00i_>zopr0QZO5LI3~& literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index 72bc55579..e4515c09e 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -170,6 +170,23 @@ ) ) +--- grid-subheaders-repeat-gutter --- +// Gutter below the header is also repeated +#set page(height: 8em) +#grid( + inset: (bottom: 0.5pt), + stroke: (bottom: 1pt), + gutter: (1pt, 6pt, 1pt), + grid.header( + [a] + ), + grid.header( + level: 2, + [b] + ), + ..([c],) * 10, +) + --- grid-subheaders-repeat-replace --- #set page(height: 8em) #grid( @@ -555,6 +572,38 @@ ) ) +--- grid-subheaders-multi-page-rowspan-gutter --- +#set page(height: 9em) +#grid( + columns: 2, + column-gutter: 4pt, + row-gutter: (0pt, 4pt, 8pt, 4pt), + inset: (bottom: 0.5pt), + stroke: (bottom: 1pt), + grid.header( + [a] + ), + [x], + grid.header( + level: 2, + [b] + ), + [y], + grid.header( + level: 3, + [c] + ), + [z], [z], + grid.cell( + rowspan: 5, + block(fill: red, width: 1.5em, height: 6.4em) + ), + [cell], + [cell], + [a\ b], + grid.cell(x: 0)[end], +) + --- grid-subheaders-short-lived-no-orphan-prevention --- // No orphan prevention for short-lived headers. #set page(height: 8em) From fe75a29488c25ba8599d9ed0fb90404c77484aa2 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 18 Apr 2025 19:33:45 -0300 Subject: [PATCH 56/82] use vector of maybe repeatable headers when resolving --- .../typst-library/src/layout/grid/resolve.rs | 142 ++++++++++-------- 1 file changed, 76 insertions(+), 66 deletions(-) diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 3f8006831..c6a6265e6 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -481,6 +481,7 @@ pub enum Repeatable { impl Repeatable { /// Gets the value inside this repeatable, regardless of whether /// it repeats. + #[inline] pub fn unwrap(&self) -> &T { match self { Self::Repeated(repeated) => repeated, @@ -488,7 +489,18 @@ impl Repeatable { } } + /// Gets the value inside this repeatable, regardless of whether + /// it repeats. + #[inline] + pub fn unwrap_mut(&mut self) -> &mut T { + match self { + Self::Repeated(repeated) => repeated, + Self::NotRepeated(not_repeated) => not_repeated, + } + } + /// Returns `Some` if the value is repeated, `None` otherwise. + #[inline] pub fn as_repeated(&self) -> Option<&T> { match self { Self::Repeated(repeated) => Some(repeated), @@ -974,6 +986,9 @@ struct RowGroupData { span: Span, kind: RowGroupKind, + /// Whether this header or footer may repeat. + repeat: bool, + /// Level of this header or footer. repeatable_level: NonZeroU32, @@ -1024,8 +1039,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { let mut pending_vlines: Vec<(Span, Line)> = vec![]; let has_gutter = self.gutter.any(|tracks| !tracks.is_empty()); - let mut headers: Vec
= vec![]; - let mut repeat_header = false; + let mut headers: Vec> = vec![]; // Stores where the footer is supposed to end, its span, and the // actual footer structure. @@ -1069,7 +1083,6 @@ impl<'x> CellGridResolver<'_, '_, 'x> { &mut pending_hlines, &mut pending_vlines, &mut headers, - &mut repeat_header, &mut footer, &mut repeat_footer, &mut auto_index, @@ -1089,10 +1102,9 @@ impl<'x> CellGridResolver<'_, '_, 'x> { row_amount, )?; - let (headers, footer) = self.finalize_headers_and_footers( + let footer = self.finalize_headers_and_footers( has_gutter, - headers, - repeat_header, + &mut headers, footer, repeat_footer, row_amount, @@ -1123,8 +1135,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { columns: usize, pending_hlines: &mut Vec<(Span, Line, bool)>, pending_vlines: &mut Vec<(Span, Line)>, - headers: &mut Vec
, - repeat_header: &mut bool, + headers: &mut Vec>, footer: &mut Option<(usize, Span, Footer)>, repeat_footer: &mut bool, auto_index: &mut usize, @@ -1168,13 +1179,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> { range: None, span, kind: RowGroupKind::Header, + repeat, repeatable_level: level, top_hlines_start: pending_hlines.len(), top_hlines_end: None, }); - *repeat_header = repeat; - first_available_row = find_next_empty_row(resolved_cells, local_auto_index, columns); @@ -1199,14 +1209,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> { row_group_data = Some(RowGroupData { range: None, span, + repeat, kind: RowGroupKind::Footer, repeatable_level: NonZeroU32::ONE, top_hlines_start: pending_hlines.len(), top_hlines_end: None, }); - *repeat_footer = repeat; - first_available_row = find_next_empty_row(resolved_cells, local_auto_index, columns); @@ -1521,7 +1530,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { match row_group.kind { RowGroupKind::Header => { - headers.push(Header { + let data = Header { start: group_range.start, // Later on, we have to correct this number in case there @@ -1531,6 +1540,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> { end: group_range.end, level: row_group.repeatable_level.get(), + }; + + headers.push(if row_group.repeat { + Repeatable::Repeated(data) + } else { + Repeatable::NotRepeated(data) }); } @@ -1552,6 +1567,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { level: 1, }, )); + + *repeat_footer = row_group.repeat; } } } else { @@ -1725,53 +1742,43 @@ impl<'x> CellGridResolver<'_, '_, 'x> { fn finalize_headers_and_footers( &self, has_gutter: bool, - headers: Vec
, - repeat_header: bool, + headers: &mut [Repeatable
], footer: Option<(usize, Span, Footer)>, repeat_footer: bool, row_amount: usize, - ) -> SourceResult<(Vec>, Option>)> { - let headers: Vec> = headers - .into_iter() - .map(|mut header| { - // Repeat the gutter below a header (hence why we don't - // subtract 1 from the gutter case). - // Don't do this if there are no rows under the header. - if has_gutter { - // Index of first y is doubled, as each row before it - // receives a gutter row below. - header.start *= 2; + ) -> SourceResult>> { + // Repeat the gutter below a header (hence why we don't + // subtract 1 from the gutter case). + // Don't do this if there are no rows under the header. + if has_gutter { + for header in &mut *headers { + let header = header.unwrap_mut(); - // - 'header.end' is always 'last y + 1'. The header stops - // before that row. - // - Therefore, '2 * header.end' will be 2 * (last y + 1), - // which is the adjusted index of the row before which the - // header stops, meaning it will still stop right before it - // even with gutter thanks to the multiplication below. - // - This means that it will span all rows up to - // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates - // to the index of the gutter row right below the header, - // which is what we want (that gutter spacing should be - // repeated across pages to maintain uniformity). - header.end *= 2; + // Index of first y is doubled, as each row before it + // receives a gutter row below. + header.start *= 2; - // If the header occupies the entire grid, ensure we don't - // include an extra gutter row when it doesn't exist, since - // the last row of the header is at the very bottom, - // therefore '2 * last y + 1' is not a valid index. - let row_amount = (2 * row_amount).saturating_sub(1); - header.end = header.end.min(row_amount); - } - header - }) - .map(|header| { - if repeat_header { - Repeatable::Repeated(header) - } else { - Repeatable::NotRepeated(header) - } - }) - .collect(); + // - 'header.end' is always 'last y + 1'. The header stops + // before that row. + // - Therefore, '2 * header.end' will be 2 * (last y + 1), + // which is the adjusted index of the row before which the + // header stops, meaning it will still stop right before it + // even with gutter thanks to the multiplication below. + // - This means that it will span all rows up to + // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates + // to the index of the gutter row right below the header, + // which is what we want (that gutter spacing should be + // repeated across pages to maintain uniformity). + header.end *= 2; + + // If the header occupies the entire grid, ensure we don't + // include an extra gutter row when it doesn't exist, since + // the last row of the header is at the very bottom, + // therefore '2 * last y + 1' is not a valid index. + let row_amount = (2 * row_amount).saturating_sub(1); + header.end = header.end.min(row_amount); + } + } let footer = footer .map(|(footer_end, footer_span, mut footer)| { @@ -1819,7 +1826,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { } }); - Ok((headers, footer)) + Ok(footer) } /// Resolves the cell's fields based on grid-wide properties. @@ -1990,7 +1997,7 @@ fn expand_row_group( /// Check if a cell's fixed row would conflict with a header or footer. fn check_for_conflicting_cell_row( - headers: &[Header], + headers: &[Repeatable
], footer: Option<&(usize, Span, Footer)>, cell_y: usize, rowspan: usize, @@ -2001,10 +2008,9 @@ fn check_for_conflicting_cell_row( // `y + 1 = header.start` holds, that means `y < header.start`, and it // only occupies one row (`y`), so the cell is actually not in // conflict. - if headers - .iter() - .any(|header| cell_y < header.end && cell_y + rowspan > header.start) - { + if headers.iter().any(|header| { + cell_y < header.unwrap().end && cell_y + rowspan > header.unwrap().start + }) { bail!( "cell would conflict with header spanning the same position"; hint: "try moving the cell or the header" @@ -2038,7 +2044,7 @@ fn resolve_cell_position( cell_y: Smart, colspan: usize, rowspan: usize, - headers: &[Header], + headers: &[Repeatable
], footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option], auto_index: &mut usize, @@ -2169,7 +2175,7 @@ fn resolve_cell_position( /// have cells specified by the user) as well as any headers and footers. #[inline] fn find_next_available_position( - headers: &[Header], + headers: &[Repeatable
], footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option>], columns: usize, @@ -2196,9 +2202,13 @@ fn find_next_available_position( // would become impractically large before this overflows. resolved_index += 1; } - } else if let Some(header) = headers.iter().find(|header| { - (header.start * columns..header.end * columns).contains(&resolved_index) - }) { + // TODO: consider keeping vector of upcoming headers to make this check + // non-quadratic (O(cells) instead of O(headers * cells)). + } else if let Some(header) = + headers.iter().map(Repeatable::unwrap).find(|header| { + (header.start * columns..header.end * columns).contains(&resolved_index) + }) + { // Skip header (can't place a cell inside it from outside it). resolved_index = header.end * columns; From 1e3719a9ba19a9736fe05049baa90b2a7d3b0330 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Fri, 18 Apr 2025 19:35:03 -0300 Subject: [PATCH 57/82] add non-repeating subheader tests --- .../grid-subheaders-non-repeat-replace.png | Bin 0 -> 878 bytes tests/ref/grid-subheaders-non-repeat.png | Bin 0 -> 614 bytes ...s-non-repeating-replace-didnt-fit-once.png | Bin 0 -> 895 bytes ...ubheaders-non-repeating-replace-orphan.png | Bin 0 -> 964 bytes tests/suite/layout/grid/subheaders.typ | 80 ++++++++++++++++++ 5 files changed, 80 insertions(+) create mode 100644 tests/ref/grid-subheaders-non-repeat-replace.png create mode 100644 tests/ref/grid-subheaders-non-repeat.png create mode 100644 tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.png create mode 100644 tests/ref/grid-subheaders-non-repeating-replace-orphan.png diff --git a/tests/ref/grid-subheaders-non-repeat-replace.png b/tests/ref/grid-subheaders-non-repeat-replace.png new file mode 100644 index 0000000000000000000000000000000000000000..e9c254b46f5b026379ee11586bef8b9720699a27 GIT binary patch literal 878 zcmV-!1CjiRP)NFDhq1R)ee zK7tJh1r3S_ZBY!Rfnr0Yv{l+>`ZdS3OI`4i%$Yy%WX`!a_ujd0CW$~#62lBL%rL|M z0f)NpEkoeNZ}vP0?A5ksLg4C2hXMlIY9{Wi$!b*paKtxu5paKUM2RVN`_*lNzzb=M z5ZGL>le~(6i*v-)Ed>0bgoq&UWnCWzzDc>Wpi*}|0Kw&-5%ALOCtY&ebHy=msJt@= z0_(bI8v>Vhf_glCZeHY!0{9N7?`vKfqm6Z4E*StXx2!GH%_I=?~ooBO@J1OwPXthrYyq1zg}VB zrL0JB4*@r)M8{PKn9le+A`rN>zMUX&_^1a1QyoFzsIY{AU4wj^$}qzW{}YUQxW^1L z%rL_oB;z32p$d|fDL_H88CdeA#2v%H=LgewKmine6A0G!A>jG6nOh0RQa6>62LeY_nzy{?IuN)vMI2H}09Z;58iSBhH|Ye>Wyp~&3`~`T zfyd@Ba7#7^$v8;HK{5`KagdCIWE>=uagglz^ezOZhw1M?L9()01njR9PC;N(D-}ZE z(h3VvFv$%DYIXoe30Q{|fk1YXDu z--W=|fj+amrjyNJG(q4_cVh?8wHVFJOS5Wf7{6OHIx&}1_{vus+LSWKA zBM9ucLV5^1)h4v@bYhrch8bp-+gE2>2D~)H{$2{2VR`dbKsoAc`6L53c-Q}3l{tj@UQ;U zuMv3iZRP+1FLuUvAaFF1tw-RgGvlo@>uOhj$*$z`1UwKZH8WAi_kK16eo;4xz{&8} zz%&6L-RDiu67aL*W`@AKyYDe@lkI;3McsD}f=|98;Pm0keY$h43VgQG8*X+0?1F{c z1tjW@?p{OS zOSC!w{!!&dKO#||cn~uL&TW0pz}938oF8Q1)E+;dCE)AZ%E<@;TW$VE8G+-GTZX`; zO-l@H=M902T^RvHhf&~i}ELgB$!GZ+~7Q9B_KRD@F&Bi%_rvLx|07*qoM6N<$f+}+q ARR910 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.png b/tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.png new file mode 100644 index 0000000000000000000000000000000000000000..6685125a49b440d71366c4553def917fbe15e606 GIT binary patch literal 895 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!qjx4+B z7``jhEjUP8kN(688Xk0Mvb?eRzo7U2&N7yLa~FLP z&0vVQ+`2~bM9_8f8LUj~<+_OpI~64=3V4{!pJ}c4m0~D+JH4EF!cx0ud14$n_P4ql zUB!Jk-+49wqCUH4lE zeCQ6zeIv&-?Mv!pw#MDwRxC`btLu{z3^`ctt%*5uV5?=<-(77#j#zE1ZF}>vHSxlP z8UAGn2`7bkWNNw^=SEJvz{a$1zL4y$jt14U_oW&S_?qf6HEu8aZLlGPo275oT#1UE z?f;hvsrmfqPq=c}R?TOIL1J49&&hwQC%joDCo-w`(5dN0@6FikZWL#TiY$TYa1vUv6>kF9JCdsiC0YSHBesF{dgKa(3A_glz3vBBqr@d!9 z%DK(pyN9kIHq-S<(G zS}e!PA2> zEc1SDn!(jrt|gtMSa5JdBy*#0tMm5ormR)_Xa3E$7bpl{p)R@k3h(r7EKL5cXB`r1 zf0exuVmh~Dkxs7CgOfUfjR(Y6Ss4~gT2jNHTl(2qV2*KjL$CRKv(GbM2RvA0u+%(~ z^+48+)mK>~Uv+8Cb$GB>dA3BvCl==Y(#H>6?L2k7N|a%?->EMQDJ#$GUgKoBHvjFx z148eP9%zheJRqHEEwMwzP0}ZvaYNCe>&eY;3LUHWIwZW_$jYN*&ceK&H{roOi}$M- z8TWSv9G6#4=-hN=wZIPTy}k+?{{OIJo95Mcps%TvCuY`y7sa6rZ#bquTtC5fpDBx# zN|+O`M8qQ&=J$J#95`mtf4Z+>eoH00vET-mcV<-z1-YKh4hi1_rP-z>H6DnYE65zZ z>!;~dgTzDiEQ|Ht8G=I4SS8uxn}2IcxygbFIXq$eQX*?5euR{NG1G9k7}oIMlpl+Y zplO+};{p5fXICFssm!|{?$#a3kkdQCP&sAgdMStA2E!r==59BG4VRi4&vNFqrSP1T zm}Cev_Sl3TMM-BPGyyFYWA&<^Kih$>0LEXM$1z(qpBV;;AXPuyH#s-|Jzd90daCn-^RIrEa_HQWYCQ0)dr|8F< Date: Sat, 19 Apr 2025 20:07:15 -0300 Subject: [PATCH 58/82] move per-region state to Current --- crates/typst-layout/src/grid/layouter.rs | 140 ++++++++++++----------- crates/typst-layout/src/grid/repeated.rs | 55 ++++----- crates/typst-layout/src/grid/rowspans.rs | 32 +++--- 3 files changed, 122 insertions(+), 105 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 52d42e732..3fd1d505f 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -43,6 +43,36 @@ pub struct GridLayouter<'a> { /// Rowspans not yet laid out because not all of their spanned rows were /// laid out yet. pub(super) rowspans: Vec, + /// Grid layout state for the current region. + pub(super) current: Current, + /// Frames for finished regions. + pub(super) finished: Vec, + /// The amount and height of header rows on each finished region. + pub(super) finished_header_rows: Vec, + /// Whether this is an RTL grid. + pub(super) is_rtl: bool, + /// Currently repeating headers, one per level. + /// Sorted by increasing levels. + /// + /// Note that some levels may be absent, in particular level 0, which does + /// not exist (so the first level is >= 1). + pub(super) repeating_headers: Vec<&'a Header>, + /// Headers, repeating or not, awaiting their first successful layout. + /// Sorted by increasing levels. + pub(super) pending_headers: &'a [Repeatable
], + pub(super) upcoming_headers: &'a [Repeatable
], + /// If this is `Some`, this will receive the currently laid out row's + /// height if it is auto or relative. This is used for header height + /// calculation. + /// TODO: consider refactoring this into something nicer. + pub(super) current_row_height: Option, + /// The span of the grid element. + pub(super) span: Span, +} + +/// Grid layout state for the current region. This should be reset or updated +/// on each region break. +pub struct Current { /// The initial size of the current region before we started subtracting. pub(super) initial: Size, /// The amount of repeated header rows at the start of the current region. @@ -68,32 +98,6 @@ pub struct GridLayouter<'a> { /// /// A value of zero indicates no headers were placed. pub(super) current_last_repeated_header_end: usize, - /// Frames for finished regions. - pub(super) finished: Vec, - /// The amount and height of header rows on each finished region. - pub(super) finished_header_rows: Vec, - /// Whether this is an RTL grid. - pub(super) is_rtl: bool, - /// Currently repeating headers, one per level. - /// Sorted by increasing levels. - /// - /// Note that some levels may be absent, in particular level 0, which does - /// not exist (so the first level is >= 1). - pub(super) repeating_headers: Vec<&'a Header>, - /// Headers, repeating or not, awaiting their first successful layout. - /// Sorted by increasing levels. - pub(super) pending_headers: &'a [Repeatable
], - pub(super) upcoming_headers: &'a [Repeatable
], - /// The height for each repeating header that was placed in this region. - /// Note that this includes headers not at the top of the region (pending - /// headers), and excludes headers removed by virtue of a new, conflicting - /// header being found. - pub(super) repeating_header_heights: Vec, - /// If this is `Some`, this will receive the currently laid out row's - /// height if it is auto or relative. This is used for header height - /// calculation. - /// TODO: consider refactoring this into something nicer. - pub(super) current_row_height: Option, /// Stores the length of `lrows` before a sequence of trailing rows /// equipped with orphan prevention were laid out. In this case, if no more /// rows are laid out after those rows before the region ends, the rows @@ -116,11 +120,14 @@ pub struct GridLayouter<'a> { /// In particular, non-repeating headers only occupy the initial region, /// but disappear on new regions, so they can be ignored. pub(super) repeating_header_height: Abs, + /// The height for each repeating header that was placed in this region. + /// Note that this includes headers not at the top of the region (pending + /// headers), and excludes headers removed by virtue of a new, conflicting + /// header being found. + pub(super) repeating_header_heights: Vec, /// The simulated footer height for this region. /// The simulation occurs before any rows are laid out for a region. pub(super) footer_height: Abs, - /// The span of the grid element. - pub(super) span: Span, } #[derive(Debug, Default)] @@ -188,21 +195,23 @@ impl<'a> GridLayouter<'a> { lrows: vec![], unbreakable_rows_left: 0, rowspans: vec![], - initial: regions.size, - current_repeating_header_rows: 0, - current_last_repeated_header_end: 0, finished: vec![], finished_header_rows: vec![], is_rtl: TextElem::dir_in(styles) == Dir::RTL, repeating_headers: vec![], upcoming_headers: &grid.headers, - repeating_header_heights: vec![], pending_headers: Default::default(), - lrows_orphan_snapshot: None, current_row_height: None, - header_height: Abs::zero(), - repeating_header_height: Abs::zero(), - footer_height: Abs::zero(), + current: Current { + initial: regions.size, + current_repeating_header_rows: 0, + current_last_repeated_header_end: 0, + lrows_orphan_snapshot: None, + header_height: Abs::zero(), + repeating_header_height: Abs::zero(), + repeating_header_heights: vec![], + footer_height: Abs::zero(), + }, span, } } @@ -215,7 +224,7 @@ impl<'a> GridLayouter<'a> { // Ensure rows in the first region will be aware of the possible // presence of the footer. self.prepare_footer(footer, engine, 0)?; - self.regions.size.y -= self.footer_height; + self.regions.size.y -= self.current.footer_height; } let mut y = 0; @@ -344,7 +353,7 @@ impl<'a> GridLayouter<'a> { self.layout_relative_row(engine, disambiguator, v, y)? } Sizing::Fr(v) => { - self.lrows_orphan_snapshot = None; + self.current.lrows_orphan_snapshot = None; self.lrows.push(Row::Fr(v, y, disambiguator)) } } @@ -1094,7 +1103,7 @@ impl<'a> GridLayouter<'a> { target.set_max( region.y - if i > 0 { - self.repeating_header_height + self.footer_height + self.current.repeating_header_height + self.current.footer_height } else { Abs::zero() }, @@ -1325,7 +1334,7 @@ impl<'a> GridLayouter<'a> { && !self.regions.size.y.fits(height) && may_progress_with_offset( self.regions, - self.header_height + self.footer_height, + self.current.header_height + self.current.footer_height, ) { self.finish_region(engine, false)?; @@ -1459,7 +1468,7 @@ impl<'a> GridLayouter<'a> { fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { // There is now a row after the rows equipped with orphan prevention, // so no need to remove them anymore. - self.lrows_orphan_snapshot = None; + self.current.lrows_orphan_snapshot = None; self.regions.size.y -= frame.height(); self.lrows.push(Row::Frame(frame, y, is_last)); } @@ -1470,11 +1479,11 @@ impl<'a> GridLayouter<'a> { engine: &mut Engine, last: bool, ) -> SourceResult<()> { - if let Some(orphan_snapshot) = self.lrows_orphan_snapshot.take() { + if let Some(orphan_snapshot) = self.current.lrows_orphan_snapshot.take() { if !last { self.lrows.truncate(orphan_snapshot); - self.current_repeating_header_rows = - self.current_repeating_header_rows.min(orphan_snapshot); + self.current.current_repeating_header_rows = + self.current.current_repeating_header_rows.min(orphan_snapshot); } } @@ -1485,11 +1494,12 @@ impl<'a> GridLayouter<'a> { { // Remove the last row in the region if it is a gutter row. self.lrows.pop().unwrap(); - self.current_repeating_header_rows = - self.current_repeating_header_rows.min(self.lrows.len()); + self.current.current_repeating_header_rows = + self.current.current_repeating_header_rows.min(self.lrows.len()); } let footer_would_be_widow = if let Some(last_header_row) = self + .current .current_repeating_header_rows .checked_sub(1) .and_then(|last_header_index| self.lrows.get(last_header_index)) @@ -1502,21 +1512,21 @@ impl<'a> GridLayouter<'a> { .as_ref() .and_then(Repeatable::as_repeated) .is_none_or(|footer| footer.start != last_header_end) - && self.lrows.len() == self.current_repeating_header_rows + && self.lrows.len() == self.current.current_repeating_header_rows && may_progress_with_offset( self.regions, // Since we're trying to find a region where to place all // repeating + pending headers, it makes sense to use // 'header_height' and include even non-repeating pending // headers for this check. - self.header_height + self.footer_height, + self.current.header_height + self.current.footer_height, ) { // Header and footer would be alone in this region, but there are more // rows beyond the header and the footer. Push an empty region. self.lrows.clear(); - self.current_last_repeated_header_end = 0; - self.current_repeating_header_rows = 0; + self.current.current_last_repeated_header_end = 0; + self.current.current_repeating_header_rows = 0; true } else { false @@ -1533,7 +1543,7 @@ impl<'a> GridLayouter<'a> { // This header height isn't doing much as we just confirmed // that there are no headers in this region, but let's keep // it here for correctness. It will add zero anyway. - self.header_height + self.footer_height, + self.current.header_height + self.current.footer_height, ) && footer.start != 0 } else { @@ -1564,9 +1574,9 @@ impl<'a> GridLayouter<'a> { // Determine the size of the grid in this region, expanding fully if // there are fr rows. - let mut size = Size::new(self.width, used).min(self.initial); - if fr.get() > 0.0 && self.initial.y.is_finite() { - size.y = self.initial.y; + let mut size = Size::new(self.width, used).min(self.current.initial); + if fr.get() > 0.0 && self.current.initial.y.is_finite() { + size.y = self.current.initial.y; } // The frame for the region. @@ -1588,7 +1598,7 @@ impl<'a> GridLayouter<'a> { }; let height = frame.height(); - if i < self.current_repeating_header_rows { + if i < self.current.current_repeating_header_rows { header_row_height += height; } @@ -1688,18 +1698,18 @@ impl<'a> GridLayouter<'a> { output, rrows, FinishedHeaderRowInfo { - repeated: self.current_repeating_header_rows, - last_repeated_header_end: self.current_last_repeated_header_end, + repeated: self.current.current_repeating_header_rows, + last_repeated_header_end: self.current.current_last_repeated_header_end, height: header_row_height, }, ); if !last { - self.current_repeating_header_rows = 0; - self.current_last_repeated_header_end = 0; - self.header_height = Abs::zero(); - self.repeating_header_height = Abs::zero(); - self.repeating_header_heights.clear(); + self.current.current_repeating_header_rows = 0; + self.current.current_last_repeated_header_end = 0; + self.current.header_height = Abs::zero(); + self.current.repeating_header_height = Abs::zero(); + self.current.repeating_header_heights.clear(); let disambiguator = self.finished.len(); if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { @@ -1710,7 +1720,7 @@ impl<'a> GridLayouter<'a> { // Note that header layout will only subtract this again if it has // to skip regions to fit headers, so there is no risk of // subtracting this twice. - self.regions.size.y -= self.footer_height; + self.regions.size.y -= self.current.footer_height; if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() { // Add headers to the new region. @@ -1732,14 +1742,14 @@ impl<'a> GridLayouter<'a> { self.finished.push(output); self.rrows.push(resolved_rows); self.regions.next(); - self.initial = self.regions.size; + self.current.initial = self.regions.size; if !self.grid.headers.is_empty() { self.finished_header_rows.push(header_row_info); } // Ensure orphan prevention is handled before resolving rows. - debug_assert!(self.lrows_orphan_snapshot.is_none()); + debug_assert!(self.current.lrows_orphan_snapshot.is_none()); } } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 0d39659c5..1ec3725ee 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -142,15 +142,16 @@ impl<'a> GridLayouter<'a> { // Ensure upcoming rows won't see that these headers will occupy any // space in future regions anymore. - for removed_height in self.repeating_header_heights.drain(first_conflicting_pos..) + for removed_height in + self.current.repeating_header_heights.drain(first_conflicting_pos..) { - self.repeating_header_height -= removed_height; + self.current.repeating_header_height -= removed_height; } // Non-repeating headers stop at the pending stage for orphan // prevention only. Flushing pending headers, so those will no longer // appear in a future region. - self.header_height = self.repeating_header_height; + self.current.header_height = self.current.repeating_header_height; // Let's try to place them at least once. // This might be a waste as we could generate an orphan and thus have @@ -223,7 +224,7 @@ impl<'a> GridLayouter<'a> { // available size for consistency with the first region, so we // need to consider the footer when evaluating if skipping yet // another region would make a difference. - self.footer_height, + self.current.footer_height, ) { // Advance regions without any output until we can place the @@ -238,7 +239,7 @@ impl<'a> GridLayouter<'a> { // if 'full' changes? (Assuming height doesn't change for now...) skipped_region = true; - self.regions.size.y -= self.footer_height; + self.regions.size.y -= self.current.footer_height; } if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { @@ -246,11 +247,11 @@ impl<'a> GridLayouter<'a> { // Simulate the footer again; the region's 'full' might have // changed. // TODO: maybe this should go in the loop, a bit hacky as is... - self.regions.size.y += self.footer_height; - self.footer_height = self + self.regions.size.y += self.current.footer_height; + self.current.footer_height = self .simulate_footer(footer, &self.regions, engine, disambiguator)? .height; - self.regions.size.y -= self.footer_height; + self.regions.size.y -= self.current.footer_height; } } @@ -265,22 +266,22 @@ impl<'a> GridLayouter<'a> { // within 'layout_row'. self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; - self.current_last_repeated_header_end = + self.current.current_last_repeated_header_end = self.repeating_headers.last().map(|h| h.end).unwrap_or_default(); // Reset the header height for this region. // It will be re-calculated when laying out each header row. - self.header_height = Abs::zero(); - self.repeating_header_height = Abs::zero(); - self.repeating_header_heights.clear(); + self.current.header_height = Abs::zero(); + self.current.repeating_header_height = Abs::zero(); + self.current.repeating_header_heights.clear(); // Use indices to avoid double borrow. We don't mutate headers in // 'layout_row' so this is fine. let mut i = 0; while let Some(&header) = self.repeating_headers.get(i) { let header_height = self.layout_header_rows(header, engine, disambiguator)?; - self.header_height += header_height; - self.repeating_header_height += header_height; + self.current.header_height += header_height; + self.current.repeating_header_height += header_height; // We assume that this vector will be sorted according // to increasing levels like 'repeating_headers' and @@ -300,26 +301,26 @@ impl<'a> GridLayouter<'a> { // headers which have now stopped repeating. They are always at // the end and new pending headers respect the existing sort, // so the vector will remain sorted. - self.repeating_header_heights.push(header_height); + self.current.repeating_header_heights.push(header_height); i += 1; } - self.current_repeating_header_rows = self.lrows.len(); + self.current.current_repeating_header_rows = self.lrows.len(); if !self.pending_headers.is_empty() { // Restore snapshot: if pending headers placed again turn out to be // orphans, remove their rows again. - self.lrows_orphan_snapshot = Some(self.lrows.len()); + self.current.lrows_orphan_snapshot = Some(self.lrows.len()); } for header in self.pending_headers { let header_height = self.layout_header_rows(header.unwrap(), engine, disambiguator)?; - self.header_height += header_height; + self.current.header_height += header_height; if matches!(header, Repeatable::Repeated(_)) { - self.repeating_header_height += header_height; - self.repeating_header_heights.push(header_height); + self.current.repeating_header_height += header_height; + self.current.repeating_header_heights.push(header_height); } } @@ -359,7 +360,7 @@ impl<'a> GridLayouter<'a> { // 'header_height == repeating_header_height' here // (there won't be any pending headers at this point, other // than the ones we are about to place). - self.header_height + self.footer_height, + self.current.header_height + self.current.footer_height, ) { // Note that, after the first region skip, the new headers will go @@ -382,10 +383,10 @@ impl<'a> GridLayouter<'a> { // region, so multi-page rows and cells can effectively ignore // this header. if !short_lived { - self.header_height += header_height; + self.current.header_height += header_height; if matches!(header, Repeatable::Repeated(_)) { - self.repeating_header_height += header_height; - self.repeating_header_heights.push(header_height); + self.current.repeating_header_height += header_height; + self.current.repeating_header_heights.push(header_height); } } } @@ -393,7 +394,7 @@ impl<'a> GridLayouter<'a> { // Remove new headers at the end of the region if upcoming child doesn't fit. // TODO: Short lived if footer comes afterwards if !short_lived { - self.lrows_orphan_snapshot = Some(initial_row_count); + self.current.lrows_orphan_snapshot = Some(initial_row_count); } Ok(()) @@ -466,7 +467,7 @@ impl<'a> GridLayouter<'a> { // That is unnecessary at the moment as 'prepare_footers' is only // called at the start of the region, but what about when we can have // footers in the middle of the region? Let's think about this then. - self.footer_height = if skipped_region { + self.current.footer_height = if skipped_region { // Simulate the footer again; the region's 'full' might have // changed. self.simulate_footer(footer, &self.regions, engine, disambiguator)? @@ -489,7 +490,7 @@ impl<'a> GridLayouter<'a> { // Ensure footer rows have their own height available. // Won't change much as we're creating an unbreakable row group // anyway, so this is mostly for correctness. - self.regions.size.y += self.footer_height; + self.regions.size.y += self.current.footer_height; let footer_len = self.grid.rows.len() - footer.start; self.unbreakable_rows_left += footer_len; diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 22060c6aa..eaa6b5096 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -263,7 +263,7 @@ impl GridLayouter<'_> { // due to orphan/widow prevention, which explains the usage of // 'header_height' (include non-repeating but pending headers) rather // than 'repeating_header_height'. - self.header_height + self.footer_height, + self.current.header_height + self.current.footer_height, ) { self.finish_region(engine, false)?; @@ -422,7 +422,9 @@ impl GridLayouter<'_> { let mapped_regions = self.regions.map(&mut custom_backlog, |size| { Size::new( size.x, - size.y - self.repeating_header_height - self.footer_height, + size.y + - self.current.repeating_header_height + - self.current.footer_height, ) }); @@ -535,7 +537,7 @@ impl GridLayouter<'_> { // and unbreakable rows in general, so there is no risk // of accessing an incomplete list of rows. let initial_header_height = self.lrows - [..self.current_repeating_header_rows] + [..self.current.current_repeating_header_rows] .iter() .map(|row| match row { Row::Frame(frame, _, _) => frame.height(), @@ -543,7 +545,9 @@ impl GridLayouter<'_> { }) .sum(); - self.initial.y - initial_header_height - self.footer_height + self.current.initial.y + - initial_header_height + - self.current.footer_height } else { // When measuring unbreakable auto rows, infinite // height is available for content to expand. @@ -559,7 +563,8 @@ impl GridLayouter<'_> { // Assume only repeating headers will survive starting at // the next region. let backlog = self.regions.backlog.iter().map(|&size| { - size - self.repeating_header_height - self.footer_height + size - self.current.repeating_header_height + - self.current.footer_height }); heights_up_to_current_region.chain(backlog).collect::>() @@ -574,10 +579,10 @@ impl GridLayouter<'_> { height = *rowspan_height; backlog = None; full = rowspan_full; - last = self - .regions - .last - .map(|size| size - self.repeating_header_height - self.footer_height); + last = self.regions.last.map(|size| { + size - self.current.repeating_header_height + - self.current.footer_height + }); } else { // The rowspan started in the current region, as its vector // of heights in regions is currently empty. @@ -782,7 +787,8 @@ impl GridLayouter<'_> { // Subtract the repeating header and footer height, since that's // the height we used when subtracting from the region backlog's // heights while measuring cells. - simulated_regions.size.y -= self.repeating_header_height + self.footer_height; + simulated_regions.size.y -= + self.current.repeating_header_height + self.current.footer_height; } if let Some(original_last_resolved_size) = last_resolved_size { @@ -921,8 +927,8 @@ impl GridLayouter<'_> { // rowspan, since headers and footers are unbreakable, so // assuming the repeating header height and footer height // won't change is safe. - self.repeating_header_height, - self.footer_height, + self.current.repeating_header_height, + self.current.footer_height, ); let total_spanned_height = rowspan_simulator.simulate_rowspan_layout( @@ -1006,7 +1012,7 @@ impl GridLayouter<'_> { extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero()); simulated_regions.next(); simulated_regions.size.y -= - self.repeating_header_height + self.footer_height; + self.current.repeating_header_height + self.current.footer_height; disambiguator += 1; } simulated_regions.size.y -= extra_amount_to_grow; From 5e572a56f3ed8e6537e4573dc6b385f54250258a Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sat, 19 Apr 2025 21:10:38 -0300 Subject: [PATCH 59/82] rename a few Current fields --- crates/typst-layout/src/grid/layouter.rs | 46 +++++++++++++----------- crates/typst-layout/src/grid/repeated.rs | 4 +-- crates/typst-layout/src/grid/rowspans.rs | 2 +- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 3fd1d505f..ae73a6a2a 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -76,28 +76,31 @@ pub struct Current { /// The initial size of the current region before we started subtracting. pub(super) initial: Size, /// The amount of repeated header rows at the start of the current region. + /// Thus, excludes rows from pending headers (which were placed for the + /// first time). + /// /// Note that `repeating_headers` and `pending_headers` can change if we /// find a new header inside the region (not at the top), so this field /// is required to access information from the top of the region. /// /// This is used for orphan prevention checks (if there are no rows other - /// than repeated header rows upon finishing a region, we'd have an orphan). + /// than repeated header rows upon finishing a region, we'd have orphans). /// Note that non-repeated and pending repeated header rows are not included - /// in this number and thus use a separate mechanism for orphan prevention + /// in this number as they use a separate mechanism for orphan prevention /// (`lrows_orphan_shapshot` field). /// /// In addition, this information is used on finish region to calculate the /// total height of resolved header rows at the top of the region, which is /// used by multi-page rowspans so they can properly skip the header rows /// at the top of the region during layout. - pub(super) current_repeating_header_rows: usize, + pub(super) repeated_header_rows: usize, /// The end bound of the last repeating header at the start of the region. /// The last row might have disappeared due to being empty, so this is how /// we can become aware of that. Line layout uses this to determine when to /// prioritize the last lines under a header. /// /// A value of zero indicates no headers were placed. - pub(super) current_last_repeated_header_end: usize, + pub(super) last_repeated_header_end: usize, /// Stores the length of `lrows` before a sequence of trailing rows /// equipped with orphan prevention were laid out. In this case, if no more /// rows are laid out after those rows before the region ends, the rows @@ -105,7 +108,9 @@ pub struct Current { /// headers will have been moved to the `pending_headers` vector and so /// will automatically be placed again until they fit. pub(super) lrows_orphan_snapshot: Option, - /// The simulated header height. + /// The total simulated height for all headers currently in + /// `repeating_headers` and `pending_headers`. + /// /// This field is reset in `layout_header` and properly updated by /// `layout_auto_row` and `layout_relative_row`, and should not be read /// before all header rows are fully laid out. It is usually fine because @@ -126,6 +131,7 @@ pub struct Current { /// header being found. pub(super) repeating_header_heights: Vec, /// The simulated footer height for this region. + /// /// The simulation occurs before any rows are laid out for a region. pub(super) footer_height: Abs, } @@ -204,8 +210,8 @@ impl<'a> GridLayouter<'a> { current_row_height: None, current: Current { initial: regions.size, - current_repeating_header_rows: 0, - current_last_repeated_header_end: 0, + repeated_header_rows: 0, + last_repeated_header_end: 0, lrows_orphan_snapshot: None, header_height: Abs::zero(), repeating_header_height: Abs::zero(), @@ -1482,8 +1488,8 @@ impl<'a> GridLayouter<'a> { if let Some(orphan_snapshot) = self.current.lrows_orphan_snapshot.take() { if !last { self.lrows.truncate(orphan_snapshot); - self.current.current_repeating_header_rows = - self.current.current_repeating_header_rows.min(orphan_snapshot); + self.current.repeated_header_rows = + self.current.repeated_header_rows.min(orphan_snapshot); } } @@ -1494,13 +1500,13 @@ impl<'a> GridLayouter<'a> { { // Remove the last row in the region if it is a gutter row. self.lrows.pop().unwrap(); - self.current.current_repeating_header_rows = - self.current.current_repeating_header_rows.min(self.lrows.len()); + self.current.repeated_header_rows = + self.current.repeated_header_rows.min(self.lrows.len()); } let footer_would_be_widow = if let Some(last_header_row) = self .current - .current_repeating_header_rows + .repeated_header_rows .checked_sub(1) .and_then(|last_header_index| self.lrows.get(last_header_index)) { @@ -1512,7 +1518,7 @@ impl<'a> GridLayouter<'a> { .as_ref() .and_then(Repeatable::as_repeated) .is_none_or(|footer| footer.start != last_header_end) - && self.lrows.len() == self.current.current_repeating_header_rows + && self.lrows.len() == self.current.repeated_header_rows && may_progress_with_offset( self.regions, // Since we're trying to find a region where to place all @@ -1525,8 +1531,8 @@ impl<'a> GridLayouter<'a> { // Header and footer would be alone in this region, but there are more // rows beyond the header and the footer. Push an empty region. self.lrows.clear(); - self.current.current_last_repeated_header_end = 0; - self.current.current_repeating_header_rows = 0; + self.current.last_repeated_header_end = 0; + self.current.repeated_header_rows = 0; true } else { false @@ -1598,7 +1604,7 @@ impl<'a> GridLayouter<'a> { }; let height = frame.height(); - if i < self.current.current_repeating_header_rows { + if i < self.current.repeated_header_rows { header_row_height += height; } @@ -1698,15 +1704,15 @@ impl<'a> GridLayouter<'a> { output, rrows, FinishedHeaderRowInfo { - repeated: self.current.current_repeating_header_rows, - last_repeated_header_end: self.current.current_last_repeated_header_end, + repeated: self.current.repeated_header_rows, + last_repeated_header_end: self.current.last_repeated_header_end, height: header_row_height, }, ); if !last { - self.current.current_repeating_header_rows = 0; - self.current.current_last_repeated_header_end = 0; + self.current.repeated_header_rows = 0; + self.current.last_repeated_header_end = 0; self.current.header_height = Abs::zero(); self.current.repeating_header_height = Abs::zero(); self.current.repeating_header_heights.clear(); diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 1ec3725ee..c877d038d 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -266,7 +266,7 @@ impl<'a> GridLayouter<'a> { // within 'layout_row'. self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; - self.current.current_last_repeated_header_end = + self.current.last_repeated_header_end = self.repeating_headers.last().map(|h| h.end).unwrap_or_default(); // Reset the header height for this region. @@ -306,7 +306,7 @@ impl<'a> GridLayouter<'a> { i += 1; } - self.current.current_repeating_header_rows = self.lrows.len(); + self.current.repeated_header_rows = self.lrows.len(); if !self.pending_headers.is_empty() { // Restore snapshot: if pending headers placed again turn out to be diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index eaa6b5096..b3f38dbe6 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -537,7 +537,7 @@ impl GridLayouter<'_> { // and unbreakable rows in general, so there is no risk // of accessing an incomplete list of rows. let initial_header_height = self.lrows - [..self.current.current_repeating_header_rows] + [..self.current.repeated_header_rows] .iter() .map(|row| match row { Row::Frame(frame, _, _) => frame.height(), From af0c27cb98716ae6614120996be1c5df85da7103 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sat, 19 Apr 2025 22:34:04 -0300 Subject: [PATCH 60/82] adjust Current visibility --- crates/typst-layout/src/grid/layouter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index ae73a6a2a..0a4462d5b 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -72,7 +72,7 @@ pub struct GridLayouter<'a> { /// Grid layout state for the current region. This should be reset or updated /// on each region break. -pub struct Current { +pub(super) struct Current { /// The initial size of the current region before we started subtracting. pub(super) initial: Size, /// The amount of repeated header rows at the start of the current region. From 6e21eae3ebfaaa10a877b92e9ba7bfd88a5fe80d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sat, 19 Apr 2025 23:08:10 -0300 Subject: [PATCH 61/82] mark headers as short-lived during resolve --- crates/typst-layout/src/grid/layouter.rs | 50 +---------- crates/typst-layout/src/grid/repeated.rs | 89 +++++++------------ .../typst-library/src/layout/grid/resolve.rs | 48 +++++++++- 3 files changed, 81 insertions(+), 106 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 0a4462d5b..2f3b7df84 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -236,54 +236,12 @@ impl<'a> GridLayouter<'a> { let mut y = 0; let mut consecutive_header_count = 0; while y < self.grid.rows.len() { - if let Some(first_header) = - self.upcoming_headers.get(consecutive_header_count) + if let Some(next_header) = self.upcoming_headers.get(consecutive_header_count) { - if first_header.unwrap().range().contains(&y) { - consecutive_header_count += 1; + if next_header.unwrap().range().contains(&y) { + self.place_new_headers(&mut consecutive_header_count, engine)?; + y = next_header.unwrap().end; - // TODO: surely there is a better way to do this - match self.upcoming_headers.get(consecutive_header_count) { - // No more headers, so place the latest headers. - None => { - self.place_new_headers( - consecutive_header_count, - None, - engine, - )?; - consecutive_header_count = 0; - } - // Next header is not consecutive, so place the latest headers. - Some(next_header) - if next_header.unwrap().start > first_header.unwrap().end => - { - self.place_new_headers( - consecutive_header_count, - None, - engine, - )?; - consecutive_header_count = 0; - } - // Next header is consecutive and conflicts with one or - // more of the latest consecutive headers, so we must - // place them before proceeding. - Some(next_header) - if next_header.unwrap().level - <= first_header.unwrap().level => - { - self.place_new_headers( - consecutive_header_count, - Some(next_header), - engine, - )?; - consecutive_header_count = 0; - } - // Next header is a non-conflicting consecutive header. - // Keep collecting more headers. - _ => {} - } - - y = first_header.unwrap().end; // Skip header rows during normal layout. continue; } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index c877d038d..01a087330 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -9,71 +9,44 @@ use super::rowspans::UnbreakableRowGroup; impl<'a> GridLayouter<'a> { pub fn place_new_headers( &mut self, - consecutive_header_count: usize, - conflicting_header: Option<&Repeatable
>, + consecutive_header_count: &mut usize, engine: &mut Engine, ) -> SourceResult<()> { + *consecutive_header_count += 1; let (consecutive_headers, new_upcoming_headers) = - self.upcoming_headers.split_at(consecutive_header_count); + self.upcoming_headers.split_at(*consecutive_header_count); + + if new_upcoming_headers.first().is_some_and(|next_header| { + consecutive_headers.last().is_none_or(|latest_header| { + !latest_header.unwrap().short_lived + && next_header.unwrap().start == latest_header.unwrap().end + }) && !next_header.unwrap().short_lived + }) { + // More headers coming, so wait until we reach them. + // TODO: refactor + return Ok(()); + } + self.upcoming_headers = new_upcoming_headers; + *consecutive_header_count = 0; - let (non_conflicting_headers, conflicting_headers) = match conflicting_header { - // Headers succeeded by end of grid or footer are short lived and - // can be placed in separate regions (no orphan prevention). - // TODO: do this during grid resolving? - // might be needed for multiple footers. Or maybe not if we check - // "upcoming_footers" (O(1) here), however that looks like. - _ if consecutive_headers - .last() - .is_some_and(|x| x.unwrap().end == self.grid.rows.len()) - || self - .grid - .footer - .as_ref() - .zip(consecutive_headers.last()) - .is_some_and(|(f, h)| f.unwrap().start == h.unwrap().end) => - { - (Default::default(), consecutive_headers) - } - - Some(conflicting_header) => { - // All immediately conflicting headers will - // be laid out without orphan prevention. - consecutive_headers.split_at(consecutive_headers.partition_point(|h| { - conflicting_header.unwrap().level > h.unwrap().level - })) - } - _ => (consecutive_headers, Default::default()), - }; - - self.layout_new_pending_headers(non_conflicting_headers, engine)?; - - // Layout each conflicting header independently, without orphan - // prevention (as they don't go into 'pending_headers'). - // These headers are short-lived as they are immediately followed by a - // header of the same or lower level, such that they never actually get - // to repeat. - for conflicting_header in conflicting_headers.chunks_exact(1) { - self.layout_new_headers( - // Using 'chunks_exact", we pass a slice of length one instead - // of a reference for type consistency. - // In addition, this is the only place where we layout - // short-lived headers. - conflicting_header, - true, - engine, - )? - } - - // No chance of orphans as we're immediately placing conflicting - // headers afterwards, which basically are not headers, for all intents - // and purposes. It is therefore guaranteed that all new headers have - // been placed at least once. - if !conflicting_headers.is_empty() { + // Layout short-lived headers immediately. + if consecutive_headers.last().is_some_and(|h| h.unwrap().short_lived) { + // No chance of orphans as we're immediately placing conflicting + // headers afterwards, which basically are not headers, for all intents + // and purposes. It is therefore guaranteed that all new headers have + // been placed at least once. self.flush_pending_headers(); - } - Ok(()) + // Layout each conflicting header independently, without orphan + // prevention (as they don't go into 'pending_headers'). + // These headers are short-lived as they are immediately followed by a + // header of the same or lower level, such that they never actually get + // to repeat. + self.layout_new_headers(consecutive_headers, true, engine) + } else { + self.layout_new_pending_headers(consecutive_headers, engine) + } } /// Lays out a row while indicating that it should store its persistent diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index c6a6265e6..9c872155a 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -438,6 +438,12 @@ pub struct Header { /// lower level header stops repeating, all higher level headers do as /// well. pub level: u32, + /// Whether this header cannot be repeated nor should have orphan + /// prevention because it would be about to cease repetition, either + /// because it is followed by headers of conflicting levels, or because + /// it is at the end of the table (possibly followed by some footers at the + /// end). + pub short_lived: bool, } impl Header { @@ -469,12 +475,15 @@ impl Footer { } } -/// A possibly repeatable grid object. +/// A possibly repeatable grid child. +/// /// It still exists even when not repeatable, but must not have additional /// considerations by grid layout, other than for consistency (such as making /// a certain group of rows unbreakable). pub enum Repeatable { + /// The user asked this grid child to repeat. Repeated(T), + /// The user asked this grid child to not repeat. NotRepeated(T), } @@ -490,7 +499,7 @@ impl Repeatable { } /// Gets the value inside this repeatable, regardless of whether - /// it repeats. + /// it repeats (mutably). #[inline] pub fn unwrap_mut(&mut self) -> &mut T { match self { @@ -1540,8 +1549,29 @@ impl<'x> CellGridResolver<'_, '_, 'x> { end: group_range.end, level: row_group.repeatable_level.get(), + + // This can only change at a later iteration, if we + // find a conflicting header or footer right away. + short_lived: false, }; + // Mark consecutive headers right before this one as short + // lived if they would have a higher or equal level, as + // then they would immediately stop repeating during + // layout. + let mut consecutive_header_start = data.start; + for conflicting_header in + headers.iter_mut().rev().take_while(move |h| { + let conflicts = h.unwrap().end == consecutive_header_start + && h.unwrap().level >= data.level; + + consecutive_header_start = h.unwrap().start; + conflicts + }) + { + conflicting_header.unwrap_mut().short_lived = true; + } + headers.push(if row_group.repeat { Repeatable::Repeated(data) } else { @@ -1826,6 +1856,20 @@ impl<'x> CellGridResolver<'_, '_, 'x> { } }); + // Mark consecutive headers right before the end of the table, or the + // final footer, as short lived, given that there are no normal rows + // after them, so repeating them is pointless. + let mut consecutive_header_start = + footer.as_ref().map(|f| f.unwrap().start).unwrap_or(row_amount); + for header_at_the_end in headers.iter_mut().rev().take_while(move |h| { + let at_the_end = h.unwrap().end == consecutive_header_start; + + consecutive_header_start = h.unwrap().start; + at_the_end + }) { + header_at_the_end.unwrap_mut().short_lived = true; + } + Ok(footer) } From 9a01b9bfe87f40536debf38eb2353a3daa09b19d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:26:42 -0300 Subject: [PATCH 62/82] remove unnecessary short lived header check --- crates/typst-layout/src/grid/layouter.rs | 98 ++++++++++++------------ 1 file changed, 47 insertions(+), 51 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 2f3b7df84..697bef225 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1462,63 +1462,59 @@ impl<'a> GridLayouter<'a> { self.current.repeated_header_rows.min(self.lrows.len()); } - let footer_would_be_widow = if let Some(last_header_row) = self - .current - .repeated_header_rows - .checked_sub(1) - .and_then(|last_header_index| self.lrows.get(last_header_index)) - { - let last_header_end = last_header_row.index(); - if self.grid.rows.len() > last_header_end - && self - .grid - .footer - .as_ref() - .and_then(Repeatable::as_repeated) - .is_none_or(|footer| footer.start != last_header_end) - && self.lrows.len() == self.current.repeated_header_rows - && may_progress_with_offset( - self.regions, - // Since we're trying to find a region where to place all - // repeating + pending headers, it makes sense to use - // 'header_height' and include even non-repeating pending - // headers for this check. - self.current.header_height + self.current.footer_height, - ) - { - // Header and footer would be alone in this region, but there are more - // rows beyond the header and the footer. Push an empty region. - self.lrows.clear(); - self.current.last_repeated_header_end = 0; - self.current.repeated_header_rows = 0; - true + let footer_would_be_widow = + if !self.lrows.is_empty() && self.current.repeated_header_rows > 0 { + // If headers are repeating, then we already know they are not + // short-lived as that is checked, so they have orphan prevention. + if self.lrows.len() == self.current.repeated_header_rows + && may_progress_with_offset( + self.regions, + // Since we're trying to find a region where to place all + // repeating + pending headers, it makes sense to use + // 'header_height' and include even non-repeating pending + // headers for this check. + self.current.header_height + self.current.footer_height, + ) + { + // Header and footer would be alone in this region, but + // there are more rows beyond the headers and the footer. + // Push an empty region. + self.lrows.clear(); + self.current.last_repeated_header_end = 0; + self.current.repeated_header_rows = 0; + true + } else { + false + } + } else if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + // If no rows other than the footer have been laid out so far, + // and there are rows beside the footer, then don't lay it out + // at all. (Similar check from above, but for the case without + // headers.) + // TODO: widow prevention for non-repeated footers with a + // similar mechanism / when implementing multiple footers. + self.lrows.is_empty() + && may_progress_with_offset( + self.regions, + // This header height isn't doing much as we just + // confirmed that there are no headers in this region, + // but let's keep it here for correctness. It will add + // zero anyway. + self.current.header_height + self.current.footer_height, + ) + && footer.start != 0 } else { false - } - } else if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - // If no rows other than the footer have been laid out so far, and - // there are rows beside the footer, then don't lay it out at all. - // (Similar check from above, but for the case without headers.) - // TODO: widow prevention for non-repeated footers with a similar - // mechanism / when implementing multiple footers. - self.lrows.is_empty() - && may_progress_with_offset( - self.regions, - // This header height isn't doing much as we just confirmed - // that there are no headers in this region, but let's keep - // it here for correctness. It will add zero anyway. - self.current.header_height + self.current.footer_height, - ) - && footer.start != 0 - } else { - false - }; + }; let mut laid_out_footer_start = None; if !footer_would_be_widow { if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - // Don't layout the footer if it would be alone with the header in - // the page (hence the widow check), and don't layout it twice. + // Don't layout the footer if it would be alone with the header + // in the page (hence the widow check), and don't layout it + // twice. + // TODO: this check can be replaced by a vector of repeating + // footers in the future. if self.lrows.iter().all(|row| row.index() < footer.start) { laid_out_footer_start = Some(footer.start); self.layout_footer(footer, engine, self.finished.len())?; From 71ae276071f7b47aa65419e2d7dac5e8e08a369a Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 20 Apr 2025 15:18:31 -0300 Subject: [PATCH 63/82] add Deref to Repeatable honestly, all the unwrapping was just generating noise. --- crates/typst-layout/src/grid/layouter.rs | 4 +- crates/typst-layout/src/grid/repeated.rs | 9 ++--- .../typst-library/src/layout/grid/resolve.rs | 40 +++++++++++-------- crates/typst-library/src/model/table.rs | 6 +-- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 697bef225..7b10f06d9 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -238,9 +238,9 @@ impl<'a> GridLayouter<'a> { while y < self.grid.rows.len() { if let Some(next_header) = self.upcoming_headers.get(consecutive_header_count) { - if next_header.unwrap().range().contains(&y) { + if next_header.range().contains(&y) { self.place_new_headers(&mut consecutive_header_count, engine)?; - y = next_header.unwrap().end; + y = next_header.end; // Skip header rows during normal layout. continue; diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 01a087330..9a4ebbefe 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -18,9 +18,8 @@ impl<'a> GridLayouter<'a> { if new_upcoming_headers.first().is_some_and(|next_header| { consecutive_headers.last().is_none_or(|latest_header| { - !latest_header.unwrap().short_lived - && next_header.unwrap().start == latest_header.unwrap().end - }) && !next_header.unwrap().short_lived + !latest_header.short_lived && next_header.start == latest_header.end + }) && !next_header.short_lived }) { // More headers coming, so wait until we reach them. // TODO: refactor @@ -31,7 +30,7 @@ impl<'a> GridLayouter<'a> { *consecutive_header_count = 0; // Layout short-lived headers immediately. - if consecutive_headers.last().is_some_and(|h| h.unwrap().short_lived) { + if consecutive_headers.last().is_some_and(|h| h.short_lived) { // No chance of orphans as we're immediately placing conflicting // headers afterwards, which basically are not headers, for all intents // and purposes. It is therefore guaranteed that all new headers have @@ -104,7 +103,7 @@ impl<'a> GridLayouter<'a> { // Assuming non-conflicting headers sorted by increasing y, this must // be the header with the lowest level (sorted by increasing levels). - let first_level = first_header.unwrap().level; + let first_level = first_header.level; // Stop repeating conflicting headers. // If we go to a new region before the pending headers fit alongside diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 9c872155a..4ecdb44b2 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1,5 +1,5 @@ use std::num::{NonZeroU32, NonZeroUsize}; -use std::ops::Range; +use std::ops::{Deref, Range}; use std::sync::Arc; use ecow::eco_format; @@ -487,6 +487,14 @@ pub enum Repeatable { NotRepeated(T), } +impl Deref for Repeatable { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.unwrap() + } +} + impl Repeatable { /// Gets the value inside this repeatable, regardless of whether /// it repeats. @@ -1562,10 +1570,10 @@ impl<'x> CellGridResolver<'_, '_, 'x> { let mut consecutive_header_start = data.start; for conflicting_header in headers.iter_mut().rev().take_while(move |h| { - let conflicts = h.unwrap().end == consecutive_header_start - && h.unwrap().level >= data.level; + let conflicts = h.end == consecutive_header_start + && h.level >= data.level; - consecutive_header_start = h.unwrap().start; + consecutive_header_start = h.start; conflicts }) { @@ -1818,8 +1826,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // TODO: will need a global slice of headers and footers for // when we have multiple footers - let last_header_end = - headers.last().map(Repeatable::unwrap).map(|header| header.end); + let last_header_end = headers.last().map(|header| header.end); if has_gutter { // Convert the footer's start index to post-gutter coordinates. @@ -1860,11 +1867,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // final footer, as short lived, given that there are no normal rows // after them, so repeating them is pointless. let mut consecutive_header_start = - footer.as_ref().map(|f| f.unwrap().start).unwrap_or(row_amount); + footer.as_ref().map(|f| f.start).unwrap_or(row_amount); for header_at_the_end in headers.iter_mut().rev().take_while(move |h| { - let at_the_end = h.unwrap().end == consecutive_header_start; + let at_the_end = h.end == consecutive_header_start; - consecutive_header_start = h.unwrap().start; + consecutive_header_start = h.start; at_the_end }) { header_at_the_end.unwrap_mut().short_lived = true; @@ -2052,9 +2059,10 @@ fn check_for_conflicting_cell_row( // `y + 1 = header.start` holds, that means `y < header.start`, and it // only occupies one row (`y`), so the cell is actually not in // conflict. - if headers.iter().any(|header| { - cell_y < header.unwrap().end && cell_y + rowspan > header.unwrap().start - }) { + if headers + .iter() + .any(|header| cell_y < header.end && cell_y + rowspan > header.start) + { bail!( "cell would conflict with header spanning the same position"; hint: "try moving the cell or the header" @@ -2248,11 +2256,9 @@ fn find_next_available_position( } // TODO: consider keeping vector of upcoming headers to make this check // non-quadratic (O(cells) instead of O(headers * cells)). - } else if let Some(header) = - headers.iter().map(Repeatable::unwrap).find(|header| { - (header.start * columns..header.end * columns).contains(&resolved_index) - }) - { + } else if let Some(header) = headers.iter().find(|header| { + (header.start * columns..header.end * columns).contains(&resolved_index) + }) { // Skip header (can't place a cell inside it from outside it). resolved_index = header.end * columns; diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 86ef59ed1..5a0b1f857 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -293,13 +293,13 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content { }; let footer = grid.footer.map(|ft| { - let rows = rows.drain(ft.unwrap().start..); + let rows = rows.drain(ft.start..); elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) }); // TODO: Headers and footers in arbitrary positions // Right now, only those at either end are accepted - let header = grid.headers.first().filter(|h| h.unwrap().start == 0).map(|hd| { - let rows = rows.drain(..hd.unwrap().end); + let header = grid.headers.first().filter(|h| h.start == 0).map(|hd| { + let rows = rows.drain(..hd.end); elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row)))) }); From 63b34cfe0a11a3db8f7118641c4426e35c22b285 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 20 Apr 2025 19:14:56 -0300 Subject: [PATCH 64/82] make footer short-lived when there are no regular cells --- .../typst-library/src/layout/grid/resolve.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 4ecdb44b2..085adfefb 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1063,6 +1063,10 @@ impl<'x> CellGridResolver<'_, '_, 'x> { let mut footer: Option<(usize, Span, Footer)> = None; let mut repeat_footer = false; + // If true, there has been at least one cell besides headers and + // footers. When false, footers at the end are forced to not repeat. + let mut at_least_one_cell = false; + // We can't just use the cell's index in the 'cells' vector to // determine its automatic position, since cells could have arbitrary // positions, so the position of a cell in 'cells' can differ from its @@ -1104,6 +1108,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { &mut repeat_footer, &mut auto_index, &mut resolved_cells, + &mut at_least_one_cell, child, )?; } @@ -1125,6 +1130,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { footer, repeat_footer, row_amount, + at_least_one_cell, )?; Ok(CellGrid::new_internal( @@ -1157,6 +1163,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { repeat_footer: &mut bool, auto_index: &mut usize, resolved_cells: &mut Vec>>, + at_least_one_cell: &mut bool, child: ResolvableGridChild, ) -> SourceResult<()> where @@ -1240,7 +1247,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> { (Some(items), None) } - ResolvableGridChild::Item(item) => (None, Some(item)), + ResolvableGridChild::Item(item) => { + if matches!(item, ResolvableGridItem::Cell(_)) { + *at_least_one_cell = true; + } + + (None, Some(item)) + } }; let items = header_footer_items.into_iter().flatten().chain(simple_item); @@ -1784,6 +1797,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { footer: Option<(usize, Span, Footer)>, repeat_footer: bool, row_amount: usize, + at_least_one_cell: bool, ) -> SourceResult>> { // Repeat the gutter below a header (hence why we don't // subtract 1 from the gutter case). @@ -1856,7 +1870,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> { }) .transpose()? .map(|footer| { - if repeat_footer { + // Don't repeat footers when the table only has headers and + // footers. + // TODO(subfooters): Switch this to marking the last N + // consecutive footers as short lived. + if repeat_footer && at_least_one_cell { Repeatable::Repeated(footer) } else { Repeatable::NotRepeated(footer) From 03118678b52d3fd19c19d6ae25581e2d6fcbe389 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:14:43 -0300 Subject: [PATCH 65/82] tests for short-lived footer --- ...s-alone-with-footer-no-orphan-prevention.png | Bin 377 -> 378 bytes ...h-gutter-and-footer-no-orphan-prevention.png | Bin 0 -> 382 bytes tests/suite/layout/grid/subheaders.typ | 16 ++++++++++++++++ 3 files changed, 16 insertions(+) create mode 100644 tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.png diff --git a/tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png index 74de3e16889d6c7730a17d1eff8e8672e6cf75d5..9b083f37807a9f9ea30eaaae31ef62d00498af30 100644 GIT binary patch delta 364 zcmey#^owbNc>O{S1|ZP1Q+dh2!071d;uunK>+MZLZ>B_<;~%55^D@>>i*qdYHrXN` z((LS1U?y_zuAr}PkiLRc%K|a?!oo|gj;};_vCR=N~AYm;drx`rWbf z1$RCc#Jf*<#=y*+FF42Z$FX<&bPseEpT7}#i|I{ulFnDwJ@vH(-+t)tGd;E~(XvZ_ z=j3grKSCa`L8a2Uh?P9#wW~7dklXh{XcNy-da|RH~qa| zm&trMQ8lkqz6*q$-9AMGHL7~N4w-Rq_ll4i zM{6TaXNJt^O#S`j+s_7u;6#fzj6j_b5S+Ne;J|?cAre|V1{-WErhR>_Rj~DPRY2q6 zGv}vr{fT-Jx9Y&z?SH%vyk_xfeYk<02L|>ef8bhEY#aE*%ythbSUp|+T-G@yGywof CGN=Fm delta 363 zcmV-x0hIpw0{H@v7k_IA00000nN1lx0003%Nkla13_8pg>uri+S zxV=B&F22IU9ml;BqO=4sFmR0E+f>fl&a(qJwc=Y_%sPN`zJFND+yloCew}@bZb9f8 zmYL{MoGUqp=OtSDf2`n+25hFqcs<$raSazTcfK#*NBJHv5a6M~tCLgKCbk{GrzfGc zy&KLLI0kS#(1CxiA0AcUcH?P71^ze>*Hz%l{L4Z`12%yvb$O0s3Lk71tgW3nfRC4x z)|RgL`2q|KJY<5f);MEeU|=c?AN0TjDop+TQ>DU~v?R+aF{B~;Un;PavpN-~b|ESi zrn}qxD{54jmUrk?d4T{^VK`%8Dhw5d3d3_ug`vU#0|So_+y^X}|3|Jfzv#slZI+++9Im-ynGfsg?y|$1^fdxjMRDlx8{V`tis~M~hmQ z>*WO(zc+sV&-1=_|HB>A+kaaxGT}2YDCl|MbIAPJtGi5dEWY(vdPO(X`uBM5WjxM* z?p^&^`KkMIBfdRzf43k{)-Gnw$zyH@lX)#}O+7L9ROLalKQpVo+MK+<-)E;oLpa;~ zoc9u&OEVeyZr3J9IZv}qKmvm9*%ikR95_&-UZ=^V|I+X7d;6*HL{y(D1)Q|E-T61X zQPtyh$?|iXSClM2SsHOFvt)T=_492&V_*8|R0H+FK#BS;VFLq$yPfY?c=p&;O!<0R zt6*c`?!d;wXO7S0`t#$qO=#n4e|vt$`NBX&sr(XfVDan&&*?s{bs0Za_JIP{)78&q Iol`;+0M_TLTmS$7 literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index a5b315900..9cd42bba3 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -788,3 +788,19 @@ [a], ) ) + +--- grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention --- +#set page(height: 5.5em) +#table( + gutter: 4pt, + table.header( + [L1] + ), + table.header( + level: 2, + [L2] + ), + table.footer( + [a], + ) +) From 9c49bd507a2aff63c0240b6e45433f862b4a5c8b Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 20 Apr 2025 19:21:11 -0300 Subject: [PATCH 66/82] remove redundant check for short-lived footers Now it is all handled at the resolving stage. --- crates/typst-layout/src/grid/layouter.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 7b10f06d9..de67654d3 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1486,12 +1486,18 @@ impl<'a> GridLayouter<'a> { } else { false } - } else if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + } else if let Some(Repeatable::Repeated(_)) = &self.grid.footer { // If no rows other than the footer have been laid out so far, // and there are rows beside the footer, then don't lay it out // at all. (Similar check from above, but for the case without // headers.) - // TODO: widow prevention for non-repeated footers with a + // + // It is worth noting that the footer is made non-repeatable at + // the grid resolving stage if it is short-lived, that is, if + // it is at the start of the table (or right after headers at + // the start of the table). + // TODO(subfooters): explicitly check for short-lived footers. + // TODO(subfooters): widow prevention for non-repeated footers with a // similar mechanism / when implementing multiple footers. self.lrows.is_empty() && may_progress_with_offset( @@ -1502,7 +1508,6 @@ impl<'a> GridLayouter<'a> { // zero anyway. self.current.header_height + self.current.footer_height, ) - && footer.start != 0 } else { false }; From ab852a5151de397a3d32b2a5e8dbcc909cfe5682 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 30 Apr 2025 01:45:50 -0300 Subject: [PATCH 67/82] some additional docs --- crates/typst-layout/src/grid/layouter.rs | 11 +++++++++++ crates/typst-layout/src/grid/repeated.rs | 3 +++ 2 files changed, 14 insertions(+) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index de67654d3..879e435d1 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -116,6 +116,9 @@ pub(super) struct Current { /// before all header rows are fully laid out. It is usually fine because /// header rows themselves are unbreakable, and unbreakable rows do not /// need to read this field at all. + /// + /// This height is not only computed at the beginning of the region. It is + /// updated whenever a new header is found. pub(super) header_height: Abs, /// The height of effectively repeating headers, that is, ignoring /// non-repeating pending headers. @@ -264,6 +267,14 @@ impl<'a> GridLayouter<'a> { // longer orphans and can repeat, so we move them to repeating // headers. self.flush_pending_headers(); + // + // Note that this is usually done in `push_row`, since the call to + // `layout_row` above might trigger region breaks (for multi-page + // auto rows), whereas this needs to be called as soon as any part + // of a row is laid out. However, it's possible a row has no + // visible output and thus does not push any rows even though it + // was successfully laid out, in which case we additionally flush + // here just in case. y += 1; } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 9a4ebbefe..f90aaf4f9 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -139,6 +139,9 @@ impl<'a> GridLayouter<'a> { Ok(()) } + /// Indicates all currently pending headers have been successfully placed + /// once, since another row has been placed after them, so they are + /// certainly not orphans. pub fn flush_pending_headers(&mut self) { for header in self.pending_headers { if let Repeatable::Repeated(header) = header { From 3ef21376198c2541ea2a27b77ecc8ebe959808aa Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 30 Apr 2025 01:45:50 -0300 Subject: [PATCH 68/82] flush orphans and pending headers at the same time (broken) Goal is to ensure pending headers are flushed before a region break, else non-repeating headers will appear more than once Must not be called while laying out headers, however, so it's broken --- crates/typst-layout/src/grid/layouter.rs | 8 ++++---- crates/typst-layout/src/grid/repeated.rs | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 879e435d1..dbe31c30b 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -254,7 +254,7 @@ impl<'a> GridLayouter<'a> { if y >= footer.start { if y == footer.start { self.layout_footer(footer, engine, self.finished.len())?; - self.flush_pending_headers(); + self.flush_orphans(); } y = footer.end; continue; @@ -266,7 +266,6 @@ impl<'a> GridLayouter<'a> { // After the first non-header row is placed, pending headers are no // longer orphans and can repeat, so we move them to repeating // headers. - self.flush_pending_headers(); // // Note that this is usually done in `push_row`, since the call to // `layout_row` above might trigger region breaks (for multi-page @@ -275,6 +274,7 @@ impl<'a> GridLayouter<'a> { // visible output and thus does not push any rows even though it // was successfully laid out, in which case we additionally flush // here just in case. + self.flush_orphans(); y += 1; } @@ -328,7 +328,7 @@ impl<'a> GridLayouter<'a> { self.layout_relative_row(engine, disambiguator, v, y)? } Sizing::Fr(v) => { - self.current.lrows_orphan_snapshot = None; + self.flush_orphans(); self.lrows.push(Row::Fr(v, y, disambiguator)) } } @@ -1443,7 +1443,7 @@ impl<'a> GridLayouter<'a> { fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { // There is now a row after the rows equipped with orphan prevention, // so no need to remove them anymore. - self.current.lrows_orphan_snapshot = None; + self.flush_orphans(); self.regions.size.y -= frame.height(); self.lrows.push(Row::Frame(frame, y, is_last)); } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index f90aaf4f9..bc1d82152 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -35,7 +35,7 @@ impl<'a> GridLayouter<'a> { // headers afterwards, which basically are not headers, for all intents // and purposes. It is therefore guaranteed that all new headers have // been placed at least once. - self.flush_pending_headers(); + self.flush_orphans(); // Layout each conflicting header independently, without orphan // prevention (as they don't go into 'pending_headers'). @@ -139,10 +139,26 @@ impl<'a> GridLayouter<'a> { Ok(()) } + /// This function should be called each time an additional row has been + /// laid out in a region to indicate that orphan prevention has succeeded. + /// + /// It removes the current orphan snapshot and flushes pending headers, + /// such that a non-repeating header won't try to be laid out again + /// anymore, and a repeating header will begin to be part of + /// `repeating_headers`. + pub fn flush_orphans(&mut self) { + self.current.lrows_orphan_snapshot = None; + self.flush_pending_headers(); + } + /// Indicates all currently pending headers have been successfully placed /// once, since another row has been placed after them, so they are /// certainly not orphans. pub fn flush_pending_headers(&mut self) { + if self.pending_headers.is_empty() { + return; + } + for header in self.pending_headers { if let Repeatable::Repeated(header) = header { // Vector remains sorted by increasing levels: From 6c42f67b3d3ce6fc4aca5dd34947b0b8011e118b Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 30 Apr 2025 01:49:43 -0300 Subject: [PATCH 69/82] only flush orphans outside of headers and footers --- crates/typst-layout/src/grid/layouter.rs | 23 +++++++++-- crates/typst-layout/src/grid/repeated.rs | 37 +++++++++++++++--- ...repeating-header-before-multi-page-row.png | Bin 0 -> 410 bytes tests/suite/layout/grid/subheaders.typ | 11 ++++++ 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index dbe31c30b..bc64b6e78 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -66,6 +66,16 @@ pub struct GridLayouter<'a> { /// calculation. /// TODO: consider refactoring this into something nicer. pub(super) current_row_height: Option, + /// This is `true` when laying out non-short lived headers and footers. + /// That is, headers and footers which are not immediately followed or + /// preceded (respectively) by conflicting headers and footers of same or + /// lower level, or the end or start of the table (respectively), which + /// would cause them to stop repeating. + /// + /// If this is `false`, the next row to be laid out will remove an active + /// orphan snapshot and will flush pending headers, as there is no risk + /// that they will be orphans anymore. + pub(super) in_active_repeatable: bool, /// The span of the grid element. pub(super) span: Span, } @@ -211,6 +221,7 @@ impl<'a> GridLayouter<'a> { upcoming_headers: &grid.headers, pending_headers: Default::default(), current_row_height: None, + in_active_repeatable: false, current: Current { initial: regions.size, repeated_header_rows: 0, @@ -328,7 +339,9 @@ impl<'a> GridLayouter<'a> { self.layout_relative_row(engine, disambiguator, v, y)? } Sizing::Fr(v) => { - self.flush_orphans(); + if !self.in_active_repeatable { + self.flush_orphans(); + } self.lrows.push(Row::Fr(v, y, disambiguator)) } } @@ -1441,9 +1454,11 @@ impl<'a> GridLayouter<'a> { /// will be pushed for this particular row. It can be `false` for rows /// spanning multiple regions. fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { - // There is now a row after the rows equipped with orphan prevention, - // so no need to remove them anymore. - self.flush_orphans(); + if !self.in_active_repeatable { + // There is now a row after the rows equipped with orphan + // prevention, so no need to keep moving them anymore. + self.flush_orphans(); + } self.regions.size.y -= frame.height(); self.lrows.push(Row::Frame(frame, y, is_last)); } diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index bc1d82152..f764839e4 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -57,11 +57,20 @@ impl<'a> GridLayouter<'a> { y: usize, engine: &mut Engine, disambiguator: usize, + as_short_lived: bool, ) -> SourceResult> { let previous_row_height = std::mem::replace(&mut self.current_row_height, Some(Abs::zero())); + let previous_in_active_repeatable = + std::mem::replace(&mut self.in_active_repeatable, !as_short_lived); + self.layout_row(y, engine, disambiguator)?; + _ = std::mem::replace( + &mut self.in_active_repeatable, + previous_in_active_repeatable, + ); + Ok(std::mem::replace(&mut self.current_row_height, previous_row_height)) } @@ -73,11 +82,13 @@ impl<'a> GridLayouter<'a> { header: &Header, engine: &mut Engine, disambiguator: usize, + as_short_lived: bool, ) -> SourceResult { let mut header_height = Abs::zero(); for y in header.range() { - header_height += - self.layout_header_row(y, engine, disambiguator)?.unwrap_or_default(); + header_height += self + .layout_header_row(y, engine, disambiguator, as_short_lived)? + .unwrap_or_default(); } Ok(header_height) } @@ -270,7 +281,8 @@ impl<'a> GridLayouter<'a> { // 'layout_row' so this is fine. let mut i = 0; while let Some(&header) = self.repeating_headers.get(i) { - let header_height = self.layout_header_rows(header, engine, disambiguator)?; + let header_height = + self.layout_header_rows(header, engine, disambiguator, false)?; self.current.header_height += header_height; self.current.repeating_header_height += header_height; @@ -307,7 +319,7 @@ impl<'a> GridLayouter<'a> { for header in self.pending_headers { let header_height = - self.layout_header_rows(header.unwrap(), engine, disambiguator)?; + self.layout_header_rows(header.unwrap(), engine, disambiguator, false)?; self.current.header_height += header_height; if matches!(header, Repeatable::Repeated(_)) { self.current.repeating_header_height += header_height; @@ -365,7 +377,8 @@ impl<'a> GridLayouter<'a> { let initial_row_count = self.lrows.len(); for header in headers { - let header_height = self.layout_header_rows(header.unwrap(), engine, 0)?; + let header_height = + self.layout_header_rows(header.unwrap(), engine, 0, false)?; // Only store this header height if it is actually going to // become a pending header. Otherwise, pretend it's not a @@ -483,10 +496,24 @@ impl<'a> GridLayouter<'a> { // anyway, so this is mostly for correctness. self.regions.size.y += self.current.footer_height; + let repeats = self + .grid + .footer + .as_ref() + .is_some_and(|f| matches!(f, Repeatable::Repeated(_))); let footer_len = self.grid.rows.len() - footer.start; self.unbreakable_rows_left += footer_len; + for y in footer.start..self.grid.rows.len() { + let previous_in_active_repeatable = + std::mem::replace(&mut self.in_active_repeatable, repeats); + self.layout_row(y, engine, disambiguator)?; + + _ = std::mem::replace( + &mut self.in_active_repeatable, + previous_in_active_repeatable, + ); } Ok(()) diff --git a/tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png b/tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png new file mode 100644 index 0000000000000000000000000000000000000000..3db97f782ed52b337bcbaf7a0ddc7354d76feee8 GIT binary patch literal 410 zcmeAS@N?(olHy`uVBq!ia0vp^6+m3c0VEi%2k%EaktaqI0hTW_aCiDQNP zoxOvd9a97ZMe_7`9bKF~{KQ(I7rnmHf z?b46{E}Nb+TX-z2J8akUROD{k@aFQXmklBBN|?{{SA=)7etrGXO=3L^J_i>0FS5tlc=`#}>EAxj`_Xnh<|}veYTmTU z0~>aAYj;n5mgOYOc9?NX#0QQ1bIqM8EQcQS^BtJ#w!YKze(>{7i;C=50_(XS1}n^V k?0M~iVg=N$hw&`+%$ENz`o#vX`vnSgPgg&ebxsLQ0F=zDPyhe` literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ index 9cd42bba3..d4d2fba14 100644 --- a/tests/suite/layout/grid/subheaders.typ +++ b/tests/suite/layout/grid/subheaders.typ @@ -684,6 +684,17 @@ grid.cell(x: 0)[end], ) +--- grid-subheaders-non-repeating-header-before-multi-page-row --- +#set page(height: 6em) +#grid( + grid.header( + repeat: false, + [h] + ), + [row #colbreak() row] +) + + --- grid-subheaders-short-lived-no-orphan-prevention --- // No orphan prevention for short-lived headers. #set page(height: 8em) From 09e7062b38fc8ccde74bff0a7d6d78ccf7812f15 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 30 Apr 2025 02:20:23 -0300 Subject: [PATCH 70/82] create RowState abstraction nicer to use than what that was before store temp values during row layout --- crates/typst-layout/src/grid/layouter.rs | 85 ++++++++++++++++++------ crates/typst-layout/src/grid/repeated.rs | 61 +++++++---------- 2 files changed, 87 insertions(+), 59 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index bc64b6e78..01b478fac 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -61,21 +61,12 @@ pub struct GridLayouter<'a> { /// Sorted by increasing levels. pub(super) pending_headers: &'a [Repeatable
], pub(super) upcoming_headers: &'a [Repeatable
], - /// If this is `Some`, this will receive the currently laid out row's - /// height if it is auto or relative. This is used for header height - /// calculation. - /// TODO: consider refactoring this into something nicer. - pub(super) current_row_height: Option, - /// This is `true` when laying out non-short lived headers and footers. - /// That is, headers and footers which are not immediately followed or - /// preceded (respectively) by conflicting headers and footers of same or - /// lower level, or the end or start of the table (respectively), which - /// would cause them to stop repeating. + /// State of the row being currently laid out. /// - /// If this is `false`, the next row to be laid out will remove an active - /// orphan snapshot and will flush pending headers, as there is no risk - /// that they will be orphans anymore. - pub(super) in_active_repeatable: bool, + /// This is kept as a field to avoid passing down too many parameters from + /// `layout_row` into called functions, which would then have to pass them + /// down to `push_row`, which reads these values. + pub(super) row_state: RowState, /// The span of the grid element. pub(super) span: Span, } @@ -149,6 +140,25 @@ pub(super) struct Current { pub(super) footer_height: Abs, } +/// Data about the row being laid out right now. +#[derive(Debug, Default)] +pub(super) struct RowState { + /// If this is `Some`, this will receive the currently laid out row's + /// height if it is auto or relative. This is used for header height + /// calculation. + pub(super) current_row_height: Option, + /// This is `true` when laying out non-short lived headers and footers. + /// That is, headers and footers which are not immediately followed or + /// preceded (respectively) by conflicting headers and footers of same or + /// lower level, or the end or start of the table (respectively), which + /// would cause them to stop repeating. + /// + /// If this is `false`, the next row to be laid out will remove an active + /// orphan snapshot and will flush pending headers, as there is no risk + /// that they will be orphans anymore. + pub(super) in_active_repeatable: bool, +} + #[derive(Debug, Default)] pub(super) struct FinishedHeaderRowInfo { /// The amount of repeated headers at the top of the region. @@ -220,8 +230,7 @@ impl<'a> GridLayouter<'a> { repeating_headers: vec![], upcoming_headers: &grid.headers, pending_headers: Default::default(), - current_row_height: None, - in_active_repeatable: false, + row_state: RowState::default(), current: Current { initial: regions.size, repeated_header_rows: 0, @@ -310,12 +319,46 @@ impl<'a> GridLayouter<'a> { self.render_fills_strokes() } - /// Layout the given row. + /// Layout a row with a certain initial state, returning the final state. + #[inline] + pub(super) fn layout_row_with_state( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, + initial_state: RowState, + ) -> SourceResult { + // Keep a copy of the previous value in the stack, as this function can + // call itself recursively (e.g. if a region break is triggered and a + // header is placed), so we shouldn't outright overwrite it, but rather + // save and later restore the state when back to this call. + let previous = std::mem::replace(&mut self.row_state, initial_state); + + // Keep it as a separate function to allow inlining the return below, + // as it's usually not needed. + self.layout_row_internal(y, engine, disambiguator)?; + + Ok(std::mem::replace(&mut self.row_state, previous)) + } + + /// Layout the given row with the default row state. + #[inline] pub(super) fn layout_row( &mut self, y: usize, engine: &mut Engine, disambiguator: usize, + ) -> SourceResult<()> { + self.layout_row_with_state(y, engine, disambiguator, RowState::default())?; + Ok(()) + } + + /// Layout the given row using the current state. + pub(super) fn layout_row_internal( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, ) -> SourceResult<()> { // Skip to next region if current one is full, but only for content // rows, not for gutter rows, and only if we aren't laying out an @@ -339,7 +382,7 @@ impl<'a> GridLayouter<'a> { self.layout_relative_row(engine, disambiguator, v, y)? } Sizing::Fr(v) => { - if !self.in_active_repeatable { + if !self.row_state.in_active_repeatable { self.flush_orphans(); } self.lrows.push(Row::Fr(v, y, disambiguator)) @@ -1067,7 +1110,7 @@ impl<'a> GridLayouter<'a> { let frame = self.layout_single_row(engine, disambiguator, first, y)?; self.push_row(frame, y, true); - if let Some(row_height) = &mut self.current_row_height { + if let Some(row_height) = &mut self.row_state.current_row_height { // Add to header height, as we are in a header row. *row_height += first; } @@ -1302,7 +1345,7 @@ impl<'a> GridLayouter<'a> { let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); let frame = self.layout_single_row(engine, disambiguator, resolved, y)?; - if let Some(row_height) = &mut self.current_row_height { + if let Some(row_height) = &mut self.row_state.current_row_height { // Add to header height, as we are in a header row. *row_height += resolved; } @@ -1454,7 +1497,7 @@ impl<'a> GridLayouter<'a> { /// will be pushed for this particular row. It can be `false` for rows /// spanning multiple regions. fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { - if !self.in_active_repeatable { + if !self.row_state.in_active_repeatable { // There is now a row after the rows equipped with orphan // prevention, so no need to keep moving them anymore. self.flush_orphans(); diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index f764839e4..62a8e578a 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -3,7 +3,7 @@ use typst_library::engine::Engine; use typst_library::layout::grid::resolve::{Footer, Header, Repeatable}; use typst_library::layout::{Abs, Axes, Frame, Regions}; -use super::layouter::{may_progress_with_offset, GridLayouter}; +use super::layouter::{may_progress_with_offset, GridLayouter, RowState}; use super::rowspans::UnbreakableRowGroup; impl<'a> GridLayouter<'a> { @@ -48,34 +48,10 @@ impl<'a> GridLayouter<'a> { } } - /// Lays out a row while indicating that it should store its persistent - /// height as a header row, which will be its height if relative or auto, - /// or zero otherwise (fractional). - #[inline] - fn layout_header_row( - &mut self, - y: usize, - engine: &mut Engine, - disambiguator: usize, - as_short_lived: bool, - ) -> SourceResult> { - let previous_row_height = - std::mem::replace(&mut self.current_row_height, Some(Abs::zero())); - let previous_in_active_repeatable = - std::mem::replace(&mut self.in_active_repeatable, !as_short_lived); - - self.layout_row(y, engine, disambiguator)?; - - _ = std::mem::replace( - &mut self.in_active_repeatable, - previous_in_active_repeatable, - ); - - Ok(std::mem::replace(&mut self.current_row_height, previous_row_height)) - } - /// Lays out rows belonging to a header, returning the calculated header - /// height only for that header. + /// height only for that header. Indicates to the laid out rows that they + /// should inform their laid out heights if appropriate (auto or fixed + /// size rows only). #[inline] fn layout_header_rows( &mut self, @@ -87,7 +63,16 @@ impl<'a> GridLayouter<'a> { let mut header_height = Abs::zero(); for y in header.range() { header_height += self - .layout_header_row(y, engine, disambiguator, as_short_lived)? + .layout_row_with_state( + y, + engine, + disambiguator, + RowState { + current_row_height: Some(Abs::zero()), + in_active_repeatable: !as_short_lived, + }, + )? + .current_row_height .unwrap_or_default(); } Ok(header_height) @@ -505,15 +490,15 @@ impl<'a> GridLayouter<'a> { self.unbreakable_rows_left += footer_len; for y in footer.start..self.grid.rows.len() { - let previous_in_active_repeatable = - std::mem::replace(&mut self.in_active_repeatable, repeats); - - self.layout_row(y, engine, disambiguator)?; - - _ = std::mem::replace( - &mut self.in_active_repeatable, - previous_in_active_repeatable, - ); + self.layout_row_with_state( + y, + engine, + disambiguator, + RowState { + in_active_repeatable: repeats, + ..Default::default() + }, + )?; } Ok(()) From 8045c72d28ba6fc0250e6b3834a78a488c7c1ab9 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 30 Apr 2025 20:09:36 -0300 Subject: [PATCH 71/82] switch to only snapshotting for orphan prevention --- crates/typst-layout/src/grid/layouter.rs | 86 ++++++++++-------------- crates/typst-layout/src/grid/repeated.rs | 39 +++++++---- 2 files changed, 59 insertions(+), 66 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 01b478fac..410e3c66c 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -104,10 +104,14 @@ pub(super) struct Current { pub(super) last_repeated_header_end: usize, /// Stores the length of `lrows` before a sequence of trailing rows /// equipped with orphan prevention were laid out. In this case, if no more - /// rows are laid out after those rows before the region ends, the rows - /// will be removed. For new headers in particular, which use this, those - /// headers will have been moved to the `pending_headers` vector and so - /// will automatically be placed again until they fit. + /// rows without orphan prevention are laid out after those rows before the + /// region ends, the rows will be removed. + /// + /// At the moment, this is only used by repeated headers (they aren't laid + /// out if alone in the region) and by new headers, which are moved to the + /// `pending_headers` vector and so will automatically be placed again + /// until they fit and are not orphans in at least one region (or exactly + /// one, for non-repeated headers). pub(super) lrows_orphan_snapshot: Option, /// The total simulated height for all headers currently in /// `repeating_headers` and `pending_headers`. @@ -1517,6 +1521,11 @@ impl<'a> GridLayouter<'a> { self.lrows.truncate(orphan_snapshot); self.current.repeated_header_rows = self.current.repeated_header_rows.min(orphan_snapshot); + + if orphan_snapshot == 0 { + // Removed all repeated headers. + self.current.last_repeated_header_end = 0; + } } } @@ -1531,55 +1540,28 @@ impl<'a> GridLayouter<'a> { self.current.repeated_header_rows.min(self.lrows.len()); } + // If no rows other than the footer have been laid out so far + // (e.g. due to header orphan prevention), and there are rows + // beside the footer, then don't lay it out at all. + // + // It is worth noting that the footer is made non-repeatable at + // the grid resolving stage if it is short-lived, that is, if + // it is at the start of the table (or right after headers at + // the start of the table). + // TODO(subfooters): explicitly check for short-lived footers. + // TODO(subfooters): widow prevention for non-repeated footers with a + // similar mechanism / when implementing multiple footers. let footer_would_be_widow = - if !self.lrows.is_empty() && self.current.repeated_header_rows > 0 { - // If headers are repeating, then we already know they are not - // short-lived as that is checked, so they have orphan prevention. - if self.lrows.len() == self.current.repeated_header_rows - && may_progress_with_offset( - self.regions, - // Since we're trying to find a region where to place all - // repeating + pending headers, it makes sense to use - // 'header_height' and include even non-repeating pending - // headers for this check. - self.current.header_height + self.current.footer_height, - ) - { - // Header and footer would be alone in this region, but - // there are more rows beyond the headers and the footer. - // Push an empty region. - self.lrows.clear(); - self.current.last_repeated_header_end = 0; - self.current.repeated_header_rows = 0; - true - } else { - false - } - } else if let Some(Repeatable::Repeated(_)) = &self.grid.footer { - // If no rows other than the footer have been laid out so far, - // and there are rows beside the footer, then don't lay it out - // at all. (Similar check from above, but for the case without - // headers.) - // - // It is worth noting that the footer is made non-repeatable at - // the grid resolving stage if it is short-lived, that is, if - // it is at the start of the table (or right after headers at - // the start of the table). - // TODO(subfooters): explicitly check for short-lived footers. - // TODO(subfooters): widow prevention for non-repeated footers with a - // similar mechanism / when implementing multiple footers. - self.lrows.is_empty() - && may_progress_with_offset( - self.regions, - // This header height isn't doing much as we just - // confirmed that there are no headers in this region, - // but let's keep it here for correctness. It will add - // zero anyway. - self.current.header_height + self.current.footer_height, - ) - } else { - false - }; + matches!(self.grid.footer, Some(Repeatable::Repeated(_))) + && self.lrows.is_empty() + && may_progress_with_offset( + self.regions, + // This header height isn't doing much as we just + // confirmed that there are no headers in this region, + // but let's keep it here for correctness. It will add + // zero anyway. + self.current.header_height + self.current.footer_height, + ); let mut laid_out_footer_start = None; if !footer_would_be_widow { diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 62a8e578a..4e3004967 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -262,6 +262,18 @@ impl<'a> GridLayouter<'a> { self.current.repeating_header_height = Abs::zero(); self.current.repeating_header_heights.clear(); + debug_assert!(self.lrows.is_empty()); + debug_assert!(self.current.lrows_orphan_snapshot.is_none()); + if may_progress_with_offset(self.regions, self.current.footer_height) { + // Enable orphan prevention for headers at the top of the region. + // + // It is very rare for this to make a difference as we're usually + // at the 'last' region after the first skip, at which the snapshot + // is handled by 'layout_new_headers'. Either way, we keep this + // here for correctness. + self.current.lrows_orphan_snapshot = Some(self.lrows.len()); + } + // Use indices to avoid double borrow. We don't mutate headers in // 'layout_row' so this is fine. let mut i = 0; @@ -295,13 +307,6 @@ impl<'a> GridLayouter<'a> { } self.current.repeated_header_rows = self.lrows.len(); - - if !self.pending_headers.is_empty() { - // Restore snapshot: if pending headers placed again turn out to be - // orphans, remove their rows again. - self.current.lrows_orphan_snapshot = Some(self.lrows.len()); - } - for header in self.pending_headers { let header_height = self.layout_header_rows(header.unwrap(), engine, disambiguator, false)?; @@ -357,10 +362,22 @@ impl<'a> GridLayouter<'a> { self.finish_region(engine, false)?; } + // Remove new headers at the end of the region if the upcoming row + // doesn't fit. + // TODO(subfooters): what if there is a footer right after it? + if !short_lived + && self.current.lrows_orphan_snapshot.is_none() + && may_progress_with_offset( + self.regions, + self.current.header_height + self.current.footer_height, + ) + { + self.current.lrows_orphan_snapshot = Some(self.lrows.len()); + } + self.unbreakable_rows_left += total_header_row_count(headers.iter().map(Repeatable::unwrap)); - let initial_row_count = self.lrows.len(); for header in headers { let header_height = self.layout_header_rows(header.unwrap(), engine, 0, false)?; @@ -380,12 +397,6 @@ impl<'a> GridLayouter<'a> { } } - // Remove new headers at the end of the region if upcoming child doesn't fit. - // TODO: Short lived if footer comes afterwards - if !short_lived { - self.current.lrows_orphan_snapshot = Some(initial_row_count); - } - Ok(()) } From 5289bdae504852ac9a721ddf413b669e2c6852af Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Thu, 1 May 2025 00:04:19 -0300 Subject: [PATCH 72/82] update some field docs --- crates/typst-layout/src/grid/layouter.rs | 72 ++++++++++++++---------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 410e3c66c..0b35278c3 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -32,7 +32,7 @@ pub struct GridLayouter<'a> { pub(super) rcols: Vec, /// The sum of `rcols`. pub(super) width: Abs, - /// Resolve row sizes, by region. + /// Resolved row sizes, by region. pub(super) rrows: Vec>, /// Rows in the current region. pub(super) lrows: Vec, @@ -51,15 +51,16 @@ pub struct GridLayouter<'a> { pub(super) finished_header_rows: Vec, /// Whether this is an RTL grid. pub(super) is_rtl: bool, - /// Currently repeating headers, one per level. - /// Sorted by increasing levels. + /// Currently repeating headers, one per level. Sorted by increasing + /// levels. /// /// Note that some levels may be absent, in particular level 0, which does - /// not exist (so the first level is >= 1). + /// not exist (so all levels are >= 1). pub(super) repeating_headers: Vec<&'a Header>, /// Headers, repeating or not, awaiting their first successful layout. /// Sorted by increasing levels. pub(super) pending_headers: &'a [Repeatable
], + /// Next headers to be processed. pub(super) upcoming_headers: &'a [Repeatable
], /// State of the row being currently laid out. /// @@ -84,28 +85,27 @@ pub(super) struct Current { /// find a new header inside the region (not at the top), so this field /// is required to access information from the top of the region. /// - /// This is used for orphan prevention checks (if there are no rows other - /// than repeated header rows upon finishing a region, we'd have orphans). - /// Note that non-repeated and pending repeated header rows are not included - /// in this number as they use a separate mechanism for orphan prevention - /// (`lrows_orphan_shapshot` field). - /// - /// In addition, this information is used on finish region to calculate the - /// total height of resolved header rows at the top of the region, which is - /// used by multi-page rowspans so they can properly skip the header rows - /// at the top of the region during layout. + /// This information is used on finish region to calculate the total height + /// of resolved header rows at the top of the region, which is used by + /// multi-page rowspans so they can properly skip the header rows at the + /// top of each region during layout. pub(super) repeated_header_rows: usize, - /// The end bound of the last repeating header at the start of the region. - /// The last row might have disappeared due to being empty, so this is how - /// we can become aware of that. Line layout uses this to determine when to - /// prioritize the last lines under a header. + /// The end bound of the row range of the last repeating header at the + /// start of the region. /// - /// A value of zero indicates no headers were placed. + /// The last row might have disappeared from layout due to being empty, so + /// this is how we can become aware of where the last header ends without + /// having to check the vector of rows. Line layout uses this to determine + /// when to prioritize the last lines under a header. + /// + /// A value of zero indicates no repeated headers were placed. pub(super) last_repeated_header_end: usize, - /// Stores the length of `lrows` before a sequence of trailing rows - /// equipped with orphan prevention were laid out. In this case, if no more - /// rows without orphan prevention are laid out after those rows before the - /// region ends, the rows will be removed. + /// Stores the length of `lrows` before a sequence of rows equipped with + /// orphan prevention was laid out. In this case, if no more rows without + /// orphan prevention are laid out after those rows before the region ends, + /// the rows will be removed, and there may be an attempt to place them + /// again in the new region. Effectively, this is the mechanism used for + /// orphan prevention of rows. /// /// At the moment, this is only used by repeated headers (they aren't laid /// out if alone in the region) and by new headers, which are moved to the @@ -116,14 +116,16 @@ pub(super) struct Current { /// The total simulated height for all headers currently in /// `repeating_headers` and `pending_headers`. /// - /// This field is reset in `layout_header` and properly updated by + /// This field is reset on each new region and properly updated by /// `layout_auto_row` and `layout_relative_row`, and should not be read /// before all header rows are fully laid out. It is usually fine because /// header rows themselves are unbreakable, and unbreakable rows do not /// need to read this field at all. /// /// This height is not only computed at the beginning of the region. It is - /// updated whenever a new header is found. + /// updated whenever a new header is found, subtracting the height of + /// headers which stopped repeating and adding the height of all new + /// headers. pub(super) header_height: Abs, /// The height of effectively repeating headers, that is, ignoring /// non-repeating pending headers. @@ -134,9 +136,14 @@ pub(super) struct Current { /// but disappear on new regions, so they can be ignored. pub(super) repeating_header_height: Abs, /// The height for each repeating header that was placed in this region. - /// Note that this includes headers not at the top of the region (pending - /// headers), and excludes headers removed by virtue of a new, conflicting - /// header being found. + /// Note that this includes headers not at the top of the region, before + /// their first repetition (pending headers), and excludes headers removed + /// by virtue of a new, conflicting header being found (short-lived + /// headers). + /// + /// This is used to know how much to update `repeating_header_height` by + /// when finding a new header and causing existing repeating headers to + /// stop. pub(super) repeating_header_heights: Vec, /// The simulated footer height for this region. /// @@ -147,7 +154,7 @@ pub(super) struct Current { /// Data about the row being laid out right now. #[derive(Debug, Default)] pub(super) struct RowState { - /// If this is `Some`, this will receive the currently laid out row's + /// If this is `Some`, this will be updated by the currently laid out row's /// height if it is auto or relative. This is used for header height /// calculation. pub(super) current_row_height: Option, @@ -155,7 +162,7 @@ pub(super) struct RowState { /// That is, headers and footers which are not immediately followed or /// preceded (respectively) by conflicting headers and footers of same or /// lower level, or the end or start of the table (respectively), which - /// would cause them to stop repeating. + /// would cause them to never repeat, even once. /// /// If this is `false`, the next row to be laid out will remove an active /// orphan snapshot and will flush pending headers, as there is no risk @@ -163,12 +170,15 @@ pub(super) struct RowState { pub(super) in_active_repeatable: bool, } +/// Data about laid out repeated header rows for a specific finished region. #[derive(Debug, Default)] pub(super) struct FinishedHeaderRowInfo { /// The amount of repeated headers at the top of the region. pub(super) repeated: usize, - /// The end bound of the last repeated header at the top of the region. + /// The end bound of the row range of the last repeated header at the top + /// of the region. pub(super) last_repeated_header_end: usize, + /// The total height of repeated headers at the top of the region. pub(super) height: Abs, } From b7c1dba314ebf7e1e7f3dc8f28357196a34db417 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Thu, 1 May 2025 00:23:42 -0300 Subject: [PATCH 73/82] improve variable names related to repeating headers --- crates/typst-layout/src/grid/layouter.rs | 39 +++++++++++++----------- crates/typst-layout/src/grid/lines.rs | 11 +++---- crates/typst-layout/src/grid/rowspans.rs | 2 +- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 0b35278c3..85df9d3ee 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -174,12 +174,12 @@ pub(super) struct RowState { #[derive(Debug, Default)] pub(super) struct FinishedHeaderRowInfo { /// The amount of repeated headers at the top of the region. - pub(super) repeated: usize, + pub(super) repeated_amount: usize, /// The end bound of the row range of the last repeated header at the top /// of the region. pub(super) last_repeated_header_end: usize, /// The total height of repeated headers at the top of the region. - pub(super) height: Abs, + pub(super) repeated_height: Abs, } /// Details about a resulting row piece. @@ -577,9 +577,11 @@ impl<'a> GridLayouter<'a> { }) .unwrap_or(LinePosition::Before); - // Header's lines have priority when repeated. - let end_under_repeated_header = finished_header_rows - .filter(|info| prev_y.is_some() && i == info.repeated) + // Header's lines at the bottom have priority when repeated. + // This will store the end bound of the last header if the + // current iteration is calculating lines under it. + let last_repeated_header_end_above = finished_header_rows + .filter(|info| prev_y.is_some() && i == info.repeated_amount) .map(|info| info.last_repeated_header_end); // If some grid rows were omitted between the previous resolved @@ -587,14 +589,15 @@ impl<'a> GridLayouter<'a> { // row don't "disappear" and are considered, albeit with less // priority. However, don't do this when we're below a header, // as it must have more priority instead of less, so it is - // chained later instead of before. The exception is when the + // chained later instead of before (stored in the + // 'header_hlines' variable below). The exception is when the // last row in the header is removed, in which case we append // both the lines under the row above us and also (later) the // lines under the header's (removed) last row. let prev_lines = prev_y .filter(|prev_y| { prev_y + 1 != y - && end_under_repeated_header.is_none_or( + && last_repeated_header_end_above.is_none_or( |last_repeated_header_end| { prev_y + 1 != last_repeated_header_end }, @@ -619,8 +622,8 @@ impl<'a> GridLayouter<'a> { }; let mut expected_header_line_position = LinePosition::Before; - let header_hlines = if let Some((under_header_end, prev_y)) = - end_under_repeated_header.zip(prev_y) + let header_hlines = if let Some((header_end_above, prev_y)) = + last_repeated_header_end_above.zip(prev_y) { if !self.grid.has_gutter || matches!( @@ -645,10 +648,10 @@ impl<'a> GridLayouter<'a> { // column-gutter is specified, for example. In that // case, we still repeat the line under the gutter. expected_header_line_position = expected_line_position( - under_header_end, - under_header_end == self.grid.rows.len(), + header_end_above, + header_end_above == self.grid.rows.len(), ); - get_hlines_at(under_header_end) + get_hlines_at(header_end_above) } else { &[] } @@ -706,7 +709,7 @@ impl<'a> GridLayouter<'a> { grid, rows, local_top_y, - end_under_repeated_header, + last_repeated_header_end_above, in_last_region, y, x, @@ -1610,7 +1613,7 @@ impl<'a> GridLayouter<'a> { let mut pos = Point::zero(); let mut rrows = vec![]; let current_region = self.finished.len(); - let mut header_row_height = Abs::zero(); + let mut repeated_header_row_height = Abs::zero(); // Place finished rows and layout fractional rows. for (i, row) in std::mem::take(&mut self.lrows).into_iter().enumerate() { @@ -1625,7 +1628,7 @@ impl<'a> GridLayouter<'a> { let height = frame.height(); if i < self.current.repeated_header_rows { - header_row_height += height; + repeated_header_row_height += height; } // Ensure rowspans which span this row will have enough space to @@ -1707,7 +1710,7 @@ impl<'a> GridLayouter<'a> { let rowspan = self.rowspans.remove(i); self.layout_rowspan( rowspan, - Some((&mut output, header_row_height)), + Some((&mut output, repeated_header_row_height)), engine, )?; } else { @@ -1724,9 +1727,9 @@ impl<'a> GridLayouter<'a> { output, rrows, FinishedHeaderRowInfo { - repeated: self.current.repeated_header_rows, + repeated_amount: self.current.repeated_header_rows, last_repeated_header_end: self.current.last_repeated_header_end, - height: header_row_height, + repeated_height: repeated_header_row_height, }, ); diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs index 535b901e1..b377a2112 100644 --- a/crates/typst-layout/src/grid/lines.rs +++ b/crates/typst-layout/src/grid/lines.rs @@ -396,7 +396,7 @@ pub fn hline_stroke_at_column( grid: &CellGrid, rows: &[RowPiece], local_top_y: Option, - end_under_repeated_header: Option, + header_end_above: Option, in_last_region: bool, y: usize, x: usize, @@ -501,12 +501,11 @@ pub fn hline_stroke_at_column( // Top border stroke and header stroke are generally prioritized, unless // they don't have explicit hline overrides and one or more user-provided // hlines would appear at the same position, which then are prioritized. - let top_stroke_comes_from_header = end_under_repeated_header - .zip(local_top_y) - .is_some_and(|(last_repeated_header_end, local_top_y)| { - // Ensure the row above us is a repeated header. + let top_stroke_comes_from_header = header_end_above.zip(local_top_y).is_some_and( + |(last_repeated_header_end, local_top_y)| { local_top_y < last_repeated_header_end && y > last_repeated_header_end - }); + }, + ); // Prioritize the footer's top stroke as well where applicable. let bottom_stroke_comes_from_footer = grid diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index b3f38dbe6..bc7473b7d 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -158,7 +158,7 @@ impl GridLayouter<'_> { let finished_header_rows = self .finished_header_rows .iter() - .map(|info| info.height) + .map(|info| info.repeated_height) .chain(current_header_row_height) .chain(std::iter::repeat(Abs::zero())); From 4bcf5c11a76a45d41d9b753ce98725836481a2ea Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 4 May 2025 02:28:10 -0300 Subject: [PATCH 74/82] move lrows to Current --- crates/typst-layout/src/grid/layouter.rs | 40 +++++++++++++----------- crates/typst-layout/src/grid/repeated.rs | 8 ++--- crates/typst-layout/src/grid/rowspans.rs | 3 +- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 85df9d3ee..c3035649d 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -34,8 +34,6 @@ pub struct GridLayouter<'a> { pub(super) width: Abs, /// Resolved row sizes, by region. pub(super) rrows: Vec>, - /// Rows in the current region. - pub(super) lrows: Vec, /// The amount of unbreakable rows remaining to be laid out in the /// current unbreakable row group. While this is positive, no region breaks /// should occur. @@ -77,6 +75,8 @@ pub struct GridLayouter<'a> { pub(super) struct Current { /// The initial size of the current region before we started subtracting. pub(super) initial: Size, + /// Rows in the current region. + pub(super) lrows: Vec, /// The amount of repeated header rows at the start of the current region. /// Thus, excludes rows from pending headers (which were placed for the /// first time). @@ -235,7 +235,6 @@ impl<'a> GridLayouter<'a> { rcols: vec![Abs::zero(); grid.cols.len()], width: Abs::zero(), rrows: vec![], - lrows: vec![], unbreakable_rows_left: 0, rowspans: vec![], finished: vec![], @@ -247,6 +246,7 @@ impl<'a> GridLayouter<'a> { row_state: RowState::default(), current: Current { initial: regions.size, + lrows: vec![], repeated_header_rows: 0, last_repeated_header_end: 0, lrows_orphan_snapshot: None, @@ -389,7 +389,7 @@ impl<'a> GridLayouter<'a> { } // Don't layout gutter rows at the top of a region. - if is_content_row || !self.lrows.is_empty() { + if is_content_row || !self.current.lrows.is_empty() { match self.grid.rows[y] { Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?, Sizing::Rel(v) => { @@ -399,7 +399,7 @@ impl<'a> GridLayouter<'a> { if !self.row_state.in_active_repeatable { self.flush_orphans(); } - self.lrows.push(Row::Fr(v, y, disambiguator)) + self.current.lrows.push(Row::Fr(v, y, disambiguator)) } } } @@ -1138,12 +1138,13 @@ impl<'a> GridLayouter<'a> { // Expand all but the last region. // Skip the first region if the space is eaten up by an fr row. let len = resolved.len(); - for ((i, region), target) in self - .regions - .iter() - .enumerate() - .zip(&mut resolved[..len - 1]) - .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) + for ((i, region), target) in + self.regions + .iter() + .enumerate() + .zip(&mut resolved[..len - 1]) + .skip(self.current.lrows.iter().any(|row| matches!(row, Row::Fr(..))) + as usize) { // Subtract header and footer heights from the region height when // it's not the first. Ignore non-repeating headers as they only @@ -1520,7 +1521,7 @@ impl<'a> GridLayouter<'a> { self.flush_orphans(); } self.regions.size.y -= frame.height(); - self.lrows.push(Row::Frame(frame, y, is_last)); + self.current.lrows.push(Row::Frame(frame, y, is_last)); } /// Finish rows for one region. @@ -1531,7 +1532,7 @@ impl<'a> GridLayouter<'a> { ) -> SourceResult<()> { if let Some(orphan_snapshot) = self.current.lrows_orphan_snapshot.take() { if !last { - self.lrows.truncate(orphan_snapshot); + self.current.lrows.truncate(orphan_snapshot); self.current.repeated_header_rows = self.current.repeated_header_rows.min(orphan_snapshot); @@ -1543,14 +1544,15 @@ impl<'a> GridLayouter<'a> { } if self + .current .lrows .last() .is_some_and(|row| self.grid.is_gutter_track(row.index())) { // Remove the last row in the region if it is a gutter row. - self.lrows.pop().unwrap(); + self.current.lrows.pop().unwrap(); self.current.repeated_header_rows = - self.current.repeated_header_rows.min(self.lrows.len()); + self.current.repeated_header_rows.min(self.current.lrows.len()); } // If no rows other than the footer have been laid out so far @@ -1566,7 +1568,7 @@ impl<'a> GridLayouter<'a> { // similar mechanism / when implementing multiple footers. let footer_would_be_widow = matches!(self.grid.footer, Some(Repeatable::Repeated(_))) - && self.lrows.is_empty() + && self.current.lrows.is_empty() && may_progress_with_offset( self.regions, // This header height isn't doing much as we just @@ -1584,7 +1586,7 @@ impl<'a> GridLayouter<'a> { // twice. // TODO: this check can be replaced by a vector of repeating // footers in the future. - if self.lrows.iter().all(|row| row.index() < footer.start) { + if self.current.lrows.iter().all(|row| row.index() < footer.start) { laid_out_footer_start = Some(footer.start); self.layout_footer(footer, engine, self.finished.len())?; } @@ -1594,7 +1596,7 @@ impl<'a> GridLayouter<'a> { // Determine the height of existing rows in the region. let mut used = Abs::zero(); let mut fr = Fr::zero(); - for row in &self.lrows { + for row in &self.current.lrows { match row { Row::Frame(frame, _, _) => used += frame.height(), Row::Fr(v, _, _) => fr += *v, @@ -1616,7 +1618,7 @@ impl<'a> GridLayouter<'a> { let mut repeated_header_row_height = Abs::zero(); // Place finished rows and layout fractional rows. - for (i, row) in std::mem::take(&mut self.lrows).into_iter().enumerate() { + for (i, row) in std::mem::take(&mut self.current.lrows).into_iter().enumerate() { let (frame, y, is_last) = match row { Row::Frame(frame, y, is_last) => (frame, y, is_last), Row::Fr(v, y, disambiguator) => { diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 4e3004967..3de3795b6 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -262,7 +262,7 @@ impl<'a> GridLayouter<'a> { self.current.repeating_header_height = Abs::zero(); self.current.repeating_header_heights.clear(); - debug_assert!(self.lrows.is_empty()); + debug_assert!(self.current.lrows.is_empty()); debug_assert!(self.current.lrows_orphan_snapshot.is_none()); if may_progress_with_offset(self.regions, self.current.footer_height) { // Enable orphan prevention for headers at the top of the region. @@ -271,7 +271,7 @@ impl<'a> GridLayouter<'a> { // at the 'last' region after the first skip, at which the snapshot // is handled by 'layout_new_headers'. Either way, we keep this // here for correctness. - self.current.lrows_orphan_snapshot = Some(self.lrows.len()); + self.current.lrows_orphan_snapshot = Some(self.current.lrows.len()); } // Use indices to avoid double borrow. We don't mutate headers in @@ -306,7 +306,7 @@ impl<'a> GridLayouter<'a> { i += 1; } - self.current.repeated_header_rows = self.lrows.len(); + self.current.repeated_header_rows = self.current.lrows.len(); for header in self.pending_headers { let header_height = self.layout_header_rows(header.unwrap(), engine, disambiguator, false)?; @@ -372,7 +372,7 @@ impl<'a> GridLayouter<'a> { self.current.header_height + self.current.footer_height, ) { - self.current.lrows_orphan_snapshot = Some(self.lrows.len()); + self.current.lrows_orphan_snapshot = Some(self.current.lrows.len()); } self.unbreakable_rows_left += diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index bc7473b7d..3ffd71518 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -479,6 +479,7 @@ impl GridLayouter<'_> { // Height of the rowspan covered by spanned rows in the current // region. let laid_out_height: Abs = self + .current .lrows .iter() .filter_map(|row| match row { @@ -536,7 +537,7 @@ impl GridLayouter<'_> { // 'breakable' can only be true outside of headers // and unbreakable rows in general, so there is no risk // of accessing an incomplete list of rows. - let initial_header_height = self.lrows + let initial_header_height = self.current.lrows [..self.current.repeated_header_rows] .iter() .map(|row| match row { From 1fc15467cd6768c6836ba02103da6ef84720a394 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 4 May 2025 03:21:15 -0300 Subject: [PATCH 75/82] use next_header counter to not check skipped headers on resolve --- .../typst-library/src/layout/grid/resolve.rs | 108 +++++++++++++----- tests/ref/grid-header-skip.png | Bin 0 -> 432 bytes tests/suite/layout/grid/headers.typ | 11 ++ 3 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 tests/ref/grid-header-skip.png diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 085adfefb..d3bad91ab 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1083,6 +1083,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // automatically-positioned cell. let mut auto_index: usize = 0; + // The next header after the latest auto-positioned cell. This is used + // to avoid checking for collision with headers that were already + // skipped. + let mut next_header = 0; + // We have to rebuild the grid to account for fixed cell positions. // // Create at least 'children.len()' positions, since there could be at @@ -1107,6 +1112,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { &mut footer, &mut repeat_footer, &mut auto_index, + &mut next_header, &mut resolved_cells, &mut at_least_one_cell, child, @@ -1162,6 +1168,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { footer: &mut Option<(usize, Span, Footer)>, repeat_footer: &mut bool, auto_index: &mut usize, + next_header: &mut usize, resolved_cells: &mut Vec>>, at_least_one_cell: &mut bool, child: ResolvableGridChild, @@ -1187,7 +1194,32 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // position than it would usually be if it would be in a non-empty // row, so we must step a local index inside headers and footers // instead, and use a separate counter outside them. - let mut local_auto_index = *auto_index; + let local_auto_index = if matches!(child, ResolvableGridChild::Item(_)) { + auto_index + } else { + // Although 'usize' is Copy, we need to be explicit here that we + // aren't reborrowing the original auto index but rather making a + // mutable copy of it using 'clone'. + &mut (*auto_index).clone() + }; + + // NOTE: usually, if 'next_header' were to be updated inside a row + // group (indicating a header was skipped by a cell), that would + // indicate a collision between the row group and that header, which + // is an error. However, the exception is for the first auto cell of + // the row group, which may skip headers while searching for a position + // where to begin the row group in the first place. + // + // Therefore, we cannot safely share the counter in the row group with + // the counter used by auto cells outside, as it might update it in a + // valid situation, whereas it must not, since its auto cells use a + // different auto index counter and will have seen different headers, + // so we copy the next header counter while inside a row group. + let local_next_header = if matches!(child, ResolvableGridChild::Item(_)) { + next_header + } else { + &mut (*next_header).clone() + }; // The first row in which this table group can fit. // @@ -1210,7 +1242,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { }); first_available_row = - find_next_empty_row(resolved_cells, local_auto_index, columns); + find_next_empty_row(resolved_cells, *local_auto_index, columns); // If any cell in the header is automatically positioned, // have it skip to the next empty row. This is to avoid @@ -1221,7 +1253,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // latest auto-position cell, since each auto-position cell // always occupies the first available position after the // previous one. Therefore, this will be >= auto_index. - local_auto_index = first_available_row * columns; + *local_auto_index = first_available_row * columns; (Some(items), None) } @@ -1241,9 +1273,9 @@ impl<'x> CellGridResolver<'_, '_, 'x> { }); first_available_row = - find_next_empty_row(resolved_cells, local_auto_index, columns); + find_next_empty_row(resolved_cells, *local_auto_index, columns); - local_auto_index = first_available_row * columns; + *local_auto_index = first_available_row * columns; (Some(items), None) } @@ -1268,7 +1300,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // gutter. skip_auto_index_through_fully_merged_rows( resolved_cells, - &mut local_auto_index, + local_auto_index, columns, ); @@ -1343,7 +1375,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // automatically positioned cell. Same for footers. local_auto_index .checked_sub(1) - .filter(|_| local_auto_index > first_available_row * columns) + .filter(|_| *local_auto_index > first_available_row * columns) .map_or(0, |last_auto_index| last_auto_index % columns + 1) }); if end.is_some_and(|end| end.get() < start) { @@ -1375,7 +1407,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { headers, footer.as_ref(), resolved_cells, - &mut local_auto_index, + local_auto_index, + local_next_header, first_available_row, columns, row_group_data.is_some(), @@ -1427,7 +1460,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { ); if top_hlines_end.is_none() - && local_auto_index > first_available_row * columns + && *local_auto_index > first_available_row * columns { // Auto index was moved, so upcoming auto-pos hlines should // no longer appear at the top. @@ -1514,7 +1547,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { None => { // Empty header/footer: consider the header/footer to be // at the next empty row after the latest auto index. - local_auto_index = first_available_row * columns; + *local_auto_index = first_available_row * columns; let group_start = first_available_row; let group_end = group_start + 1; @@ -1531,8 +1564,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // 'find_next_empty_row' will skip through any existing headers // and footers without having to loop through them each time. // Cells themselves, unfortunately, still have to. - assert!(resolved_cells[local_auto_index].is_none()); - resolved_cells[local_auto_index] = + assert!(resolved_cells[*local_auto_index].is_none()); + resolved_cells[*local_auto_index] = Some(Entry::Cell(self.resolve_cell( T::default(), 0, @@ -1622,11 +1655,6 @@ impl<'x> CellGridResolver<'_, '_, 'x> { *repeat_footer = row_group.repeat; } } - } else { - // The child was a single cell outside headers or footers. - // Therefore, 'local_auto_index' for this table child was - // simply an alias for 'auto_index', so we update it as needed. - *auto_index = local_auto_index; } Ok(()) @@ -2071,7 +2099,6 @@ fn check_for_conflicting_cell_row( cell_y: usize, rowspan: usize, ) -> HintedStrResult<()> { - // TODO: use upcoming headers slice to make this an O(1) check // NOTE: y + rowspan >, not >=, header.start, to check if the rowspan // enters the header. For example, consider a rowspan of 1: if // `y + 1 = header.start` holds, that means `y < header.start`, and it @@ -2118,6 +2145,7 @@ fn resolve_cell_position( footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option], auto_index: &mut usize, + next_header: &mut usize, first_available_row: usize, columns: usize, in_row_group: bool, @@ -2144,6 +2172,7 @@ fn resolve_cell_position( resolved_cells, columns, *auto_index, + next_header, )?; // Ensure the next cell with automatic position will be @@ -2202,6 +2231,21 @@ fn resolve_cell_position( resolved_cells, columns, initial_index, + // Make our own copy of the 'next_header' counter, since it + // should only be updated by auto cells. However, we cannot + // start with the same value as we are searching from the + // start, and not from 'auto_index', so auto cells might + // have skipped some headers already which this cell will + // also need to skip. + // + // We could, in theory, keep a separate 'next_header' + // counter for cells with fixed columns. But then we would + // need one for every column, and much like how we don't + // an index counter for each column either, the potential + // speed gain seems reduced for such a rarely-used feature. + // Still, it is something to consider for the future if + // this turns out to be a bottleneck in some cases. + &mut 0, ) } } @@ -2250,6 +2294,7 @@ fn find_next_available_position( resolved_cells: &[Option>], columns: usize, initial_index: usize, + next_header: &mut usize, ) -> HintedStrResult { let mut resolved_index = initial_index; @@ -2272,19 +2317,26 @@ fn find_next_available_position( // would become impractically large before this overflows. resolved_index += 1; } - // TODO: consider keeping vector of upcoming headers to make this check - // non-quadratic (O(cells) instead of O(headers * cells)). - } else if let Some(header) = headers.iter().find(|header| { - (header.start * columns..header.end * columns).contains(&resolved_index) - }) { + } else if let Some(header) = headers + .get(*next_header) + .filter(|header| resolved_index >= header.start * columns) + { // Skip header (can't place a cell inside it from outside it). - resolved_index = header.end * columns; + // No changes needed if we already passed this header (which + // also triggers this branch) - in that case, we only update the + // counter. + if resolved_index < header.end * columns { + resolved_index = header.end * columns; - if SKIP_ROWS { - // Ensure the cell's chosen column is kept after the - // header. - resolved_index += initial_index % columns; + if SKIP_ROWS { + // Ensure the cell's chosen column is kept after the + // header. + resolved_index += initial_index % columns; + } } + + // From now on, only check the headers afterwards. + *next_header += 1; } else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| { resolved_index >= footer.start * columns && resolved_index < *end * columns }) { diff --git a/tests/ref/grid-header-skip.png b/tests/ref/grid-header-skip.png new file mode 100644 index 0000000000000000000000000000000000000000..9c4f294d0bf96d1a24b9ae92a8677ec128b682e2 GIT binary patch literal 432 zcmV;h0Z;ykP)*0w!K~a1rE=Xd_Jeo?N!f@o}Q-yOs_(~0v51<1^f$mN%g5aSyjG8hV!&= zS-E$JTzx@GP>1@VQU<2BogjwG*035#SB0rz|N4tA1gMy*`rStlXRm;iGEWXit;;Jc zeVk3Nb+f?X>8DN>*kanOz2$%>`ij>j4){z5fCcVx1bE Date: Sun, 4 May 2025 04:05:28 -0300 Subject: [PATCH 76/82] fix pending header repetition with may progress = false --- crates/typst-layout/src/grid/repeated.rs | 45 ++++++++++++++---- ...-header-too-large-non-repeating-orphan.png | Bin 0 -> 372 bytes tests/suite/layout/grid/headers.typ | 10 ++++ 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 tests/ref/grid-header-too-large-non-repeating-orphan.png diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 3de3795b6..54f5cda7c 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -42,7 +42,9 @@ impl<'a> GridLayouter<'a> { // These headers are short-lived as they are immediately followed by a // header of the same or lower level, such that they never actually get // to repeat. - self.layout_new_headers(consecutive_headers, true, engine) + self.layout_new_headers(consecutive_headers, true, engine)?; + + Ok(()) } else { self.layout_new_pending_headers(consecutive_headers, engine) } @@ -125,13 +127,21 @@ impl<'a> GridLayouter<'a> { // This might be a waste as we could generate an orphan and thus have // to try to place old and new headers all over again, but that happens // for every new region anyway, so it's rather unavoidable. - self.layout_new_headers(headers, false, engine)?; + let snapshot_created = self.layout_new_headers(headers, false, engine)?; // After the first subsequent row is laid out, move to repeating, as // it's then confirmed the headers won't be moved due to orphan // prevention anymore. self.pending_headers = headers; + if !snapshot_created { + // Region probably couldn't progress. + // + // Mark new pending headers as final and ensure there isn't a + // snapshot. + self.flush_orphans(); + } + Ok(()) } @@ -264,8 +274,13 @@ impl<'a> GridLayouter<'a> { debug_assert!(self.current.lrows.is_empty()); debug_assert!(self.current.lrows_orphan_snapshot.is_none()); - if may_progress_with_offset(self.regions, self.current.footer_height) { + let may_progress = + may_progress_with_offset(self.regions, self.current.footer_height); + + if may_progress { // Enable orphan prevention for headers at the top of the region. + // Otherwise, we will flush pending headers below, after laying + // them out. // // It is very rare for this to make a difference as we're usually // at the 'last' region after the first skip, at which the snapshot @@ -317,6 +332,12 @@ impl<'a> GridLayouter<'a> { } } + if !may_progress { + // Flush pending headers immediately, as placing them again later + // won't help. + self.flush_orphans(); + } + Ok(()) } @@ -325,12 +346,15 @@ impl<'a> GridLayouter<'a> { /// If 'short_lived' is true, these headers are immediately followed by /// a conflicting header, so it is assumed they will not be pushed to /// pending headers. + /// + /// Returns whether orphan prevention was successfully setup, or couldn't + /// due to short-lived headers or the region couldn't progress. pub fn layout_new_headers( &mut self, headers: &'a [Repeatable
], short_lived: bool, engine: &mut Engine, - ) -> SourceResult<()> { + ) -> SourceResult { // At first, only consider the height of the given headers. However, // for upcoming regions, we will have to consider repeating headers as // well. @@ -365,13 +389,18 @@ impl<'a> GridLayouter<'a> { // Remove new headers at the end of the region if the upcoming row // doesn't fit. // TODO(subfooters): what if there is a footer right after it? - if !short_lived + let should_snapshot = !short_lived && self.current.lrows_orphan_snapshot.is_none() && may_progress_with_offset( self.regions, self.current.header_height + self.current.footer_height, - ) - { + ); + + if should_snapshot { + // If we don't enter this branch while laying out non-short lived + // headers, that means we will have to immediately flush pending + // headers and mark them as final, since trying to place them in + // the next page won't help get more space. self.current.lrows_orphan_snapshot = Some(self.current.lrows.len()); } @@ -397,7 +426,7 @@ impl<'a> GridLayouter<'a> { } } - Ok(()) + Ok(should_snapshot) } /// Calculates the total expected height of several headers. diff --git a/tests/ref/grid-header-too-large-non-repeating-orphan.png b/tests/ref/grid-header-too-large-non-repeating-orphan.png new file mode 100644 index 0000000000000000000000000000000000000000..a4e7843b0f391bffb50fe9a23aa6dda8593bc742 GIT binary patch literal 372 zcmeAS@N?(olHy`uVBq!ia0vp^6+pa@14uAv+Nr!`U|_WNba4!+xb^nxSudwRk+#JA zRn{E8U+W)cVtcz1$K&aDzdvWqp^BMfln`UJ1W7cPKXMKRzqU8T0L*-1P|cgoFg) ziZ6xH(+}KC%8Y0F{)f?5Wl#NEQ_h^&nzsU+KxyR)p}qN@54MC_OgeBlAt9LQ{!y!0 z9)ITV%NGVo8(aL#@qQ3;$ZFz&$NqsTa{J$wa{hVDsSlO>du#fElM%0CrX1a_%lSv> zWtNg)Vf(ZW76Su=4>pHZJ?w96Y)of-t}FP)UgV%;KUdt>*nZUy^NPhpZA)J`GRa%K oYhzk>@5iJVJ)m8 Date: Mon, 5 May 2025 02:42:19 -0300 Subject: [PATCH 77/82] update some todos/comments on resolve --- .../typst-library/src/layout/grid/resolve.rs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index d3bad91ab..a8b5a2c9c 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1866,8 +1866,16 @@ impl<'x> CellGridResolver<'_, '_, 'x> { bail!(footer_span, "footer must end at the last row"); } - // TODO: will need a global slice of headers and footers for - // when we have multiple footers + // TODO(subfooters): will need a global slice of headers and + // footers for when we have multiple footers + // Alternatively, never include the gutter in the footer's + // range and manually add it later on layout. This would allow + // laying out the gutter as part of both the header and footer, + // and, if the page only has headers, the gutter row below the + // header is automatically removed (as it becomes the last), so + // only the gutter above the footer is kept, ensuring the same + // gutter row isn't laid out two times in a row. When laying + // out the footer for real, the mechanism can be disabled. let last_header_end = headers.last().map(|header| header.end); if has_gutter { @@ -1912,6 +1920,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // Mark consecutive headers right before the end of the table, or the // final footer, as short lived, given that there are no normal rows // after them, so repeating them is pointless. + // + // TODO(subfooters): take the last footer if it is at the end and + // backtrack through consecutive footers until the first one in the + // sequence is found. If there is no footer at the end, there are no + // haeders to turn short-lived. let mut consecutive_header_start = footer.as_ref().map(|f| f.start).unwrap_or(row_amount); for header_at_the_end in headers.iter_mut().rev().take_while(move |h| { @@ -2240,11 +2253,11 @@ fn resolve_cell_position( // // We could, in theory, keep a separate 'next_header' // counter for cells with fixed columns. But then we would - // need one for every column, and much like how we don't + // need one for every column, and much like how there isn't // an index counter for each column either, the potential - // speed gain seems reduced for such a rarely-used feature. + // speed gain seems less relevant for a less used feature. // Still, it is something to consider for the future if - // this turns out to be a bottleneck in some cases. + // this turns out to be a bottleneck in important cases. &mut 0, ) } From 07a060a9dac594ec603852a667f491557c0c6910 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 5 May 2025 03:11:33 -0300 Subject: [PATCH 78/82] initial html target support --- crates/typst-library/src/model/table.rs | 57 ++++++++++++-- tests/ref/html/multi-header-inside-table.html | 69 +++++++++++++++++ tests/ref/html/multi-header-table.html | 49 ++++++++++++ tests/suite/layout/grid/html.typ | 75 +++++++++++++++++++ 4 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 tests/ref/html/multi-header-inside-table.html create mode 100644 tests/ref/html/multi-header-table.html diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 5a0b1f857..61021f8d0 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -292,18 +292,61 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content { elem(tag::tr, Content::sequence(row)) }; + // TODO(subfooters): similarly to headers, take consecutive footers from + // the end for 'tfoot'. let footer = grid.footer.map(|ft| { let rows = rows.drain(ft.start..); elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) }); - // TODO: Headers and footers in arbitrary positions - // Right now, only those at either end are accepted - let header = grid.headers.first().filter(|h| h.start == 0).map(|hd| { - let rows = rows.drain(..hd.end); - elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row)))) - }); - let mut body = Content::sequence(rows.into_iter().map(|row| tr(tag::td, row))); + // Store all consecutive headers at the start in 'thead'. All remaining + // headers are just 'th' rows across the table body. + let mut consecutive_header_end = 0; + let first_mid_table_header = grid + .headers + .iter() + .take_while(|hd| { + let is_consecutive = hd.start == consecutive_header_end; + consecutive_header_end = hd.end; + + is_consecutive + }) + .count(); + + let (y_offset, header) = if first_mid_table_header > 0 { + let removed_header_rows = + grid.headers.get(first_mid_table_header - 1).unwrap().end; + let rows = rows.drain(..removed_header_rows); + + ( + removed_header_rows, + Some(elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))), + ) + } else { + (0, None) + }; + + // TODO: Consider improving accessibility properties of multi-level headers + // inside tables in the future, e.g. indicating which columns they are + // relative to and so on. See also: + // https://www.w3.org/WAI/tutorials/tables/multi-level/ + let mut next_header = first_mid_table_header; + let mut body = + Content::sequence(rows.into_iter().enumerate().map(|(relative_y, row)| { + let y = relative_y + y_offset; + if let Some(current_header) = + grid.headers.get(next_header).filter(|h| h.range().contains(&y)) + { + if y + 1 == current_header.end { + next_header += 1; + } + + tr(tag::th, row) + } else { + tr(tag::td, row) + } + })); + if header.is_some() || footer.is_some() { body = elem(tag::tbody, body); } diff --git a/tests/ref/html/multi-header-inside-table.html b/tests/ref/html/multi-header-inside-table.html new file mode 100644 index 000000000..a4a61a697 --- /dev/null +++ b/tests/ref/html/multi-header-inside-table.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FirstHeader
SecondHeader
Level 2Header
Level 3Header
BodyCells
YetMore
Level 2Header Inside
Level 3
EvenMore
BodyCells
One Last HeaderFor Good Measure
FooterRow
EndingTable
+ + diff --git a/tests/ref/html/multi-header-table.html b/tests/ref/html/multi-header-table.html new file mode 100644 index 000000000..8a34ac170 --- /dev/null +++ b/tests/ref/html/multi-header-table.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FirstHeader
SecondHeader
Level 2Header
Level 3Header
BodyCells
YetMore
FooterRow
EndingTable
+ + diff --git a/tests/suite/layout/grid/html.typ b/tests/suite/layout/grid/html.typ index 10345cb06..cf98d4bc5 100644 --- a/tests/suite/layout/grid/html.typ +++ b/tests/suite/layout/grid/html.typ @@ -57,3 +57,78 @@ [d], [e], [f], [g], [h], [i] ) + +--- multi-header-table html --- +#table( + columns: 2, + + table.header( + [First], [Header] + ), + table.header( + [Second], [Header] + ), + table.header( + [Level 2], [Header], + level: 2, + ), + table.header( + [Level 3], [Header], + level: 3, + ), + + [Body], [Cells], + [Yet], [More], + + table.footer( + [Footer], [Row], + [Ending], [Table], + ), +) + +--- multi-header-inside-table html --- +#table( + columns: 2, + + table.header( + [First], [Header] + ), + table.header( + [Second], [Header] + ), + table.header( + [Level 2], [Header], + level: 2, + ), + table.header( + [Level 3], [Header], + level: 3, + ), + + [Body], [Cells], + [Yet], [More], + + table.header( + [Level 2], [Header Inside], + level: 2, + ), + table.header( + [Level 3], + level: 3, + ), + + [Even], [More], + [Body], [Cells], + + table.header( + [One Last Header], + [For Good Measure], + repeat: false, + level: 4, + ), + + table.footer( + [Footer], [Row], + [Ending], [Table], + ), +) From dafcf8b11ee427d88b56fe373d5b99345ea4ec4d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 6 May 2025 03:42:02 -0300 Subject: [PATCH 79/82] temp fix for imprecision in may_progress_with_offset better idea: check once at the start of the region if there are non-header rows, or we could progress at the start of the region, we may progress --- crates/typst-layout/src/grid/layouter.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index c3035649d..75cf16995 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1802,6 +1802,9 @@ pub(super) fn points( /// additional logic which adds content automatically on each region turn (in /// our case, headers). pub(super) fn may_progress_with_offset(regions: Regions<'_>, offset: Abs) -> bool { + // Use 'approx_eq' as float addition and subtraction are not associative. !regions.backlog.is_empty() - || regions.last.is_some_and(|height| regions.size.y + offset != height) + || regions + .last + .is_some_and(|height| !(regions.size.y + offset).approx_eq(height)) } From 6a12a451fce891ed4a403fd12f37b4b6aecbebf9 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 6 May 2025 02:30:29 -0300 Subject: [PATCH 80/82] test too large edge cases --- ...large-non-repeating-orphan-before-auto.png | Bin 0 -> 460 bytes ...e-non-repeating-orphan-before-relative.png | Bin 0 -> 437 bytes ...too-large-repeating-orphan-before-auto.png | Bin 0 -> 525 bytes ...large-repeating-orphan-before-relative.png | Bin 0 -> 437 bytes tests/suite/layout/grid/subheaders.typ | 54 ++++++++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png create mode 100644 tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png create mode 100644 tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.png create mode 100644 tests/ref/grid-subheaders-too-large-repeating-orphan-before-relative.png diff --git a/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..c7d632adeabf46ee397803c8d0de13f6f6204999 GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^6+pa@14uAv+Nr!`U|^i=>EaktaqI0Ze{GgR3HA@Q z+NUI+uDWyOT~XrG4Nnx7-z~a0#Ur>ob<_5XTu;yEXYeOi&ilk-t4 z9eZci?&18o?eXtDjsE-V?>{fEU}9Ruka(zt^FP;|;|K25FlQajZTZi!&a^2(dEeLb z_crDxJ_yp<%hC9D`P4j)IsSWE6hCa)cCDnhA@=m%4>$Tbh1ccZs6KGu`K=zt#&~Tv zeHJ~xdu@svc1_9M>waLq^|OQQOm#kUSXmkGPg#2OAd5$#lfs9ma~vL&yMD`M*(2Fi zw!vCZ&1Z%I$PL0U#&2df(eofN8I{Khl8JniIy-i}UaiSdvq|?X-?O4nXQoEkr;^O8 zr|#BgnUg)A>zcraj4d^-lE)fEs`+W}mYualr zu;z$k0pm55U(?GC3y!?BW=q{Vk>%iHR+bu*70YMFwA8ZIH$T`Y!C{u@n`Dul_~2aD ubkj^xg-EdLqYP8NN;aKudx6iuG4elF{r5}E)(Vai(o literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png new file mode 100644 index 0000000000000000000000000000000000000000..dfcac850083b082e6eccff867e8662f64f74244d GIT binary patch literal 437 zcmV;m0ZRUfP) zN_KX3@$vDmudh;4Qka;S>+9>{;^KaOewUY*oSdBL>FJG)jpgO#Gcz;w_4UHS!p6qN zr>Cd8yStj2nt*_S_V)JZ=;-0$;n>*NgM)+7(a}jsNiZ-le0+S_+1ZJSiR|p`?d|Q> z*4B!OiiL%R$jHdf&d%4@*WKOS=H}-5`uhC*{L<3Wy}iBk^z{EhHWgZ%)&Kwir%6OX zRCwC$)x{CQ002bM4Z+>r-Ccr3=)Wj#RKT#4VP6CP_#q)-s3I?lQOM+oC z9(Fq*n2rYh9tdX3)fx=*1vXDmSX)r+_Mj*hknuoJ%IC6Rczr0Wu)+!}tgylgE3B}> f3M;Izh(}@8_D-KZ{pr)EU%!4eHa0$V=+Loa$CfW&K5N#jhK7c3-@fhMz1!2% z^Ww#ej~+d$tE+qY^5vX4b6&i7@$lip8#ivee*L<+xj85(XvT~gZ{ECl`}Xa%Yu6MN z6?=MmT3cJYy1Lrh+BR+4l#`R=?d=^J8v5+nGZht;fB*h1UcA`G#^&$ezd#RZNt}oP zQhz*M978H@y}jigD?y+a_?0%5P=PL^%sl{U+{u_5s=gkf+&`oGfWgk>{sC*=b*GkCiC KxvX zN_KX3@$vDmudh;4Qka;S>+9>{;^KaOewUY*oSdBL>FJG)jpgO#Gcz;w_4UHS!p6qN zr>Cd8yStj2nt*_S_V)JZ=;-0$;n>*NgM)+7(a}jsNiZ-le0+S_+1ZJSiR|p`?d|Q> z*4B!OiiL%R$jHdf&d%4@*WKOS=H}-5`uhC*{L<3Wy}iBk^z{EhHWgZ%)&Kwir%6OX zRCwC$)x{CQ002bM4Z+>r-Ccr3=)Wj#RKT#4VP6CP_#q)-s3I?lQOM+oC z9(Fq*n2rYh9tdX3)fx=*1vXDmSX)r+_Mj*hknuoJ%IC6Rczr0Wu)+!}tgylgE3B}> f3M;Izh Date: Tue, 6 May 2025 03:55:34 -0300 Subject: [PATCH 81/82] allow skipping from non-repeated header on lack of space Can lead to orphans, but without enough space, anything goes. --- crates/typst-layout/src/grid/layouter.rs | 13 +++++-------- crates/typst-layout/src/grid/repeated.rs | 4 ++-- crates/typst-layout/src/grid/rowspans.rs | 11 +++++------ ...rge-non-repeating-orphan-before-relative.png | Bin 437 -> 542 bytes 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 75cf16995..8ebe59123 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1376,14 +1376,13 @@ impl<'a> GridLayouter<'a> { // endlessly repeated) when subtracting header and footer height. // // See 'check_for_unbreakable_rows' as for why we're using - // 'header_height' to predict header height and not - // 'repeating_header_height'. + // 'repeating_header_height' to predict header height. let height = frame.height(); while self.unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) && may_progress_with_offset( self.regions, - self.current.header_height + self.current.footer_height, + self.current.repeating_header_height + self.current.footer_height, ) { self.finish_region(engine, false)?; @@ -1571,11 +1570,9 @@ impl<'a> GridLayouter<'a> { && self.current.lrows.is_empty() && may_progress_with_offset( self.regions, - // This header height isn't doing much as we just - // confirmed that there are no headers in this region, - // but let's keep it here for correctness. It will add - // zero anyway. - self.current.header_height + self.current.footer_height, + // Don't sum header height as we just confirmed that there + // are no headers in this region. + self.current.footer_height, ); let mut laid_out_footer_start = None; diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 54f5cda7c..5b2be228b 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -377,7 +377,7 @@ impl<'a> GridLayouter<'a> { // 'header_height == repeating_header_height' here // (there won't be any pending headers at this point, other // than the ones we are about to place). - self.current.header_height + self.current.footer_height, + self.current.repeating_header_height + self.current.footer_height, ) { // Note that, after the first region skip, the new headers will go @@ -393,7 +393,7 @@ impl<'a> GridLayouter<'a> { && self.current.lrows_orphan_snapshot.is_none() && may_progress_with_offset( self.regions, - self.current.header_height + self.current.footer_height, + self.current.repeating_header_height + self.current.footer_height, ); if should_snapshot { diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 3ffd71518..9cbae6fe8 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -258,12 +258,11 @@ impl GridLayouter<'_> { while !self.regions.size.y.fits(row_group.height) && may_progress_with_offset( self.regions, - // Note that we consider that the exact same headers and footers will be - // added if we skip like this (blocking other rows from being laid out) - // due to orphan/widow prevention, which explains the usage of - // 'header_height' (include non-repeating but pending headers) rather - // than 'repeating_header_height'. - self.current.header_height + self.current.footer_height, + // Use 'repeating_header_height' (ignoring the height of + // non-repeated headers) to allow skipping if the + // non-repeated header is too large. It will become an + // orphan, but when there is no space left, anything goes. + self.current.repeating_header_height + self.current.footer_height, ) { self.finish_region(engine, false)?; diff --git a/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png index dfcac850083b082e6eccff867e8662f64f74244d..324787b2506342de1630d9dc2c013971d0d701d8 100644 GIT binary patch delta 530 zcmV+t0`2{^1D*ts7k{A$0{{R3JBy140002+P)t-s|Ns90002BZJo@_j{{H@>qoX7w zB>epR($dmwY;5=U_t)3gg@uKNhK7oYib+XH?Ck7-fPl!z$cc%G+1c6c?d{gq*5u^m zRaI3>OG_>;E-)}Ke0+R_goL1=po4>h*x1(SBC>3`|z=;-M7_V&BGyT-=G z!otG!_4VcDCdl;^LZ`ntpzMM@L7rw6x95&9$|)p`oFl zpPxoXM!~_s>gwvRudng(@pg80?(XheTU$y>N-r-jjg5`Y&d%N4-R9=zva+%vAt8*6 zjP&&My}iBt{eS)cK{ox_9CQEx0O?6YK~#9!?b=6j!T=CN(YA#woJfv1;hb~M#>QkJ z?tdG09Dy#ue;4>wvzbi`0Me}|i=^9;$u!{+$;`+joNa5e#93K1>en}Nh!3xB<1hoT@7k^L)0{{R3N_KX3@$vDmudh;4Qka;S z>+9>{;^KaOewUY*oSdBL>FJG)jpgO#Gcz;w_4UHS!p6qNr+=rXySuxZnwo%sfcEzG z=;-L-;o;cW*n@+E(b3UKNl7p;FnoM`+1c5NiHYp&?CtIC*4EaFii(AWg~-Ur&d$!) z*VoD_MKv2r(vS4_9D6Fu;3M;Iz!XXMPtgylgE3B}He8(Hs5Lc^x S0TxRD0000 Date: Fri, 9 May 2025 16:10:36 -0300 Subject: [PATCH 82/82] use const unwrap Stable since 1.83 (our MSRV) --- crates/typst-syntax/src/span.rs | 13 ++++--------- crates/typst-utils/src/lib.rs | 10 ++-------- crates/typst-utils/src/pico.rs | 5 +---- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/crates/typst-syntax/src/span.rs b/crates/typst-syntax/src/span.rs index 3618b8f2f..b383ec27f 100644 --- a/crates/typst-syntax/src/span.rs +++ b/crates/typst-syntax/src/span.rs @@ -71,10 +71,7 @@ impl Span { /// Create a span that does not point into any file. pub const fn detached() -> Self { - match NonZeroU64::new(Self::DETACHED) { - Some(v) => Self(v), - None => unreachable!(), - } + Self(NonZeroU64::new(Self::DETACHED).unwrap()) } /// Create a new span from a file id and a number. @@ -111,11 +108,9 @@ impl Span { /// Pack a file ID and the low bits into a span. const fn pack(id: FileId, low: u64) -> Self { let bits = ((id.into_raw().get() as u64) << Self::FILE_ID_SHIFT) | low; - match NonZeroU64::new(bits) { - Some(v) => Self(v), - // The file ID is non-zero. - None => unreachable!(), - } + + // The file ID is non-zero. + Self(NonZeroU64::new(bits).unwrap()) } /// Whether the span is detached. diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs index 8102e171f..abe6423df 100644 --- a/crates/typst-utils/src/lib.rs +++ b/crates/typst-utils/src/lib.rs @@ -66,17 +66,11 @@ pub trait NonZeroExt { } impl NonZeroExt for NonZeroUsize { - const ONE: Self = match Self::new(1) { - Some(v) => v, - None => unreachable!(), - }; + const ONE: Self = Self::new(1).unwrap(); } impl NonZeroExt for NonZeroU32 { - const ONE: Self = match Self::new(1) { - Some(v) => v, - None => unreachable!(), - }; + const ONE: Self = Self::new(1).unwrap(); } /// Extra methods for [`Arc`]. diff --git a/crates/typst-utils/src/pico.rs b/crates/typst-utils/src/pico.rs index 2c80d37de..ce43667e9 100644 --- a/crates/typst-utils/src/pico.rs +++ b/crates/typst-utils/src/pico.rs @@ -95,10 +95,7 @@ impl PicoStr { } }; - match NonZeroU64::new(value) { - Some(value) => Ok(Self(value)), - None => unreachable!(), - } + Ok(Self(NonZeroU64::new(value).unwrap())) } /// Resolve to a decoded string.