From 23ec13718f0a56bad72db3ae48af552c9ae0a098 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Thu, 14 Mar 2024 06:26:27 -0300 Subject: [PATCH] Small table footer and hline placement improvements (#3659) --- crates/typst/src/layout/grid/layout.rs | 164 ++++++++++++++++++++----- tests/ref/layout/grid-footers-5.png | Bin 17228 -> 18713 bytes tests/ref/layout/grid-stroke.png | Bin 57339 -> 57859 bytes tests/typ/layout/grid-footers-5.typ | 46 +++++++ tests/typ/layout/grid-stroke.typ | 12 ++ 5 files changed, 194 insertions(+), 28 deletions(-) diff --git a/crates/typst/src/layout/grid/layout.rs b/crates/typst/src/layout/grid/layout.rs index 469939891..6dbe151e6 100644 --- a/crates/typst/src/layout/grid/layout.rs +++ b/crates/typst/src/layout/grid/layout.rs @@ -427,7 +427,10 @@ impl CellGrid { // validity, since the amount of rows isn't known until all items were // analyzed in the for loop below. // We keep their spans so we can report errors later. - let mut pending_hlines: Vec<(Span, Line)> = vec![]; + // The additional boolean indicates whether the hline had an automatic + // 'y' index, and is used to change the index of hlines at the top of a + // header or footer. + let mut pending_hlines: Vec<(Span, Line, bool)> = vec![]; // For consistency, only push vertical lines later as well. let mut pending_vlines: Vec<(Span, Line)> = vec![]; @@ -494,7 +497,9 @@ impl CellGrid { let mut child_start = usize::MAX; let mut child_end = 0; let mut child_span = Span::detached(); - let mut min_auto_index = 0; + let mut start_new_row = false; + let mut first_index_of_top_hlines = usize::MAX; + let mut first_index_of_non_top_hlines = usize::MAX; let (header_footer_items, simple_item) = match child { ResolvableGridChild::Header { repeat, span, items, .. } => { @@ -512,7 +517,11 @@ impl CellGrid { // that row instead of starting a new one. // FIXME: Revise this approach when headers can start from // arbitrary rows. - min_auto_index = auto_index.next_multiple_of(c); + start_new_row = true; + + // Any hlines at the top of the header will start at this + // index. + first_index_of_top_hlines = pending_hlines.len(); (Some(items), None) } @@ -529,7 +538,11 @@ impl CellGrid { // have it skip to the next row. This is to avoid having a // footer after a partially filled row just add cells to // that row instead of starting a new one. - min_auto_index = auto_index.next_multiple_of(c); + start_new_row = true; + + // Any hlines at the top of the footer will start at this + // index. + first_index_of_top_hlines = pending_hlines.len(); (Some(items), None) } @@ -550,7 +563,18 @@ impl CellGrid { span, position, } => { + let has_auto_y = y.is_auto(); let y = y.unwrap_or_else(|| { + // Avoid placing the hline inside consecutive + // rowspans occupying all columns, as it'd just + // disappear, at least when there's no column + // gutter. + skip_auto_index_through_fully_merged_rows( + &resolved_cells, + &mut auto_index, + c, + ); + // When no 'y' is specified for the hline, we place // it under the latest automatically positioned // cell. @@ -572,7 +596,6 @@ impl CellGrid { // the start of a header will always appear above // that header's first row. Similarly for footers. auto_index - .max(min_auto_index) .checked_sub(1) .map_or(0, |last_auto_index| last_auto_index / c + 1) }); @@ -594,7 +617,7 @@ impl CellGrid { // one "wins" in case of conflict. Pushing the current // hline before we push pending hlines later would // change their order! - pending_hlines.push((span, line)); + pending_hlines.push((span, line, has_auto_y)); continue; } ResolvableGridItem::VLine { @@ -620,17 +643,15 @@ impl CellGrid { // to the left of the table. // // Exceptionally, a vline is also placed to the - // left of the table if the current auto index from - // past iterations is smaller than the minimum auto - // index. For example, this means that a vline at + // left of the table if we should start a new row + // for the next automatically positioned cell. + // For example, this means that a vline at // the beginning of a header will be placed to its // left rather than after the previous // automatically positioned cell. Same for footers. auto_index .checked_sub(1) - .filter(|last_auto_index| { - last_auto_index >= &min_auto_index - }) + .filter(|_| !start_new_row) .map_or(0, |last_auto_index| last_auto_index % c + 1) }); if end.is_some_and(|end| end.get() < start) { @@ -661,7 +682,7 @@ impl CellGrid { rowspan, &resolved_cells, &mut auto_index, - min_auto_index, + &mut start_new_row, c, ) .at(cell_span)? @@ -778,12 +799,28 @@ impl CellGrid { // contained within it. child_start = child_start.min(y); child_end = child_end.max(y + rowspan); + + if start_new_row && child_start <= auto_index.div_ceil(c) { + // No need to start a new row as we already include + // the row of the next automatically positioned cell in + // the header or footer. + start_new_row = false; + } + + if !start_new_row { + // From now on, upcoming hlines won't be at the top of + // the child, as the first automatically positioned + // cell was placed. + first_index_of_non_top_hlines = + first_index_of_non_top_hlines.min(pending_hlines.len()); + } } } if (is_header || is_footer) && child_start == usize::MAX { // Empty header/footer: consider the header/footer to be - // one row after the latest auto index. + // at the next empty row after the latest auto index. + auto_index = find_next_empty_row(&resolved_cells, auto_index, c); child_start = auto_index.div_ceil(c); child_end = child_start + 1; @@ -830,6 +867,22 @@ impl CellGrid { } if is_header || is_footer { + let amount_hlines = pending_hlines.len(); + for (_, top_hline, has_auto_y) in pending_hlines + .get_mut( + first_index_of_top_hlines + ..first_index_of_non_top_hlines.min(amount_hlines), + ) + .unwrap_or(&mut []) + { + if *has_auto_y { + // Move this hline to the top of the child, as it was + // placed before the first automatically positioned cell + // and had an automatic index. + top_hline.index = child_start; + } + } + // Next automatically positioned cell goes under this header. // FIXME: Consider only doing this if the header has any fully // automatically positioned cells. Otherwise, @@ -944,7 +997,7 @@ impl CellGrid { let mut hlines: Vec> = vec![]; let row_amount = resolved_cells.len().div_ceil(c); - for (line_span, line) in pending_hlines { + for (line_span, line, _) in pending_hlines { let y = line.index; if y > row_amount { bail!(line_span, "cannot place horizontal line at invalid row {y}"); @@ -1293,11 +1346,10 @@ impl CellGrid { /// `(auto, auto)` cell) and the amount of columns in the grid, returns the /// final index of this cell in the vector of resolved cells. /// -/// The `min_auto_index` parameter is used to bump the auto index to that value -/// if it is currently smaller than it and a cell requests fully automatic -/// positioning. Useful with headers: if a cell in a header has automatic -/// positioning, it should start at the header's first row, and not at the end -/// of the previous row. +/// The `start_new_row` parameter is used to ensure that, if this cell is +/// fully automatically positioned, it should start a new, empty row. This is +/// useful for headers and footers, which must start at their own rows, without +/// interference from previous cells. #[allow(clippy::too_many_arguments)] fn resolve_cell_position( cell_x: Smart, @@ -1306,7 +1358,7 @@ fn resolve_cell_position( rowspan: usize, resolved_cells: &[Option], auto_index: &mut usize, - min_auto_index: usize, + start_new_row: &mut bool, columns: usize, ) -> HintedStrResult { // Translates a (x, y) position to the equivalent index in the final cell vector. @@ -1322,13 +1374,22 @@ fn resolve_cell_position( (Smart::Auto, Smart::Auto) => { // Let's find the first available position starting from the // automatic position counter, searching in row-major order. - let mut resolved_index = min_auto_index.max(*auto_index); - while let Some(Some(_)) = resolved_cells.get(resolved_index) { - // Skip any non-absent cell positions (`Some(None)`) to - // determine where this cell will be placed. An out of bounds - // position (thus `None`) is also a valid new position (only - // requires expanding the vector). - resolved_index += 1; + let mut resolved_index = *auto_index; + if *start_new_row { + resolved_index = + find_next_empty_row(resolved_cells, resolved_index, columns); + + // Next cell won't have to start a new row if we just did that, + // in principle. + *start_new_row = false; + } else { + while let Some(Some(_)) = resolved_cells.get(resolved_index) { + // Skip any non-absent cell positions (`Some(None)`) to + // determine where this cell will be placed. An out of + // bounds position (thus `None`) is also a valid new + // position (only requires expanding the vector). + resolved_index += 1; + } } // Ensure the next cell with automatic position will be @@ -1401,6 +1462,53 @@ fn resolve_cell_position( } } +/// Computes the index of the first cell in the next empty row in the grid, +/// starting with the given initial index. +fn find_next_empty_row( + resolved_cells: &[Option], + initial_index: usize, + columns: usize, +) -> usize { + let mut resolved_index = initial_index.next_multiple_of(columns); + while resolved_cells + .get(resolved_index..resolved_index + columns) + .is_some_and(|row| row.iter().any(Option::is_some)) + { + // Skip non-empty rows. + resolved_index += columns; + } + + resolved_index +} + +/// Fully merged rows under the cell of latest auto index indicate rowspans +/// occupying all columns, so we skip the auto index until the shortest rowspan +/// ends, such that, in the resulting row, we will be able to place an +/// automatically positioned cell - and, in particular, hlines under it. The +/// idea is that an auto hline will be placed after the shortest such rowspan. +/// Otherwise, the hline would just be placed under the first row of those +/// rowspans and disappear (except at the presence of column gutter). +fn skip_auto_index_through_fully_merged_rows( + resolved_cells: &[Option], + auto_index: &mut usize, + columns: usize, +) { + // If the auto index isn't currently at the start of a row, that means + // there's still at least one auto position left in the row, ignoring + // cells with manual positions, so we wouldn't have a problem in placing + // further cells or, in this case, hlines here. + if *auto_index % columns == 0 { + while resolved_cells + .get(*auto_index..*auto_index + columns) + .is_some_and(|row| { + row.iter().all(|entry| matches!(entry, Some(Entry::Merged { .. }))) + }) + { + *auto_index += columns; + } + } +} + /// Performs grid layout. pub struct GridLayouter<'a> { /// The grid of cells. diff --git a/tests/ref/layout/grid-footers-5.png b/tests/ref/layout/grid-footers-5.png index 0cfd2d66857ffc2de43e233d0880a67f06a76b62..6cae5592c946fc2d3f275aace0b439e846def38c 100644 GIT binary patch delta 3277 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 D*f8=A delta 1767 zcmZuyX;f3!7EVkJBC(*LT!yL-#3BOT5P=3FB0;b!0SY`ohD0JFq#B4wBn%-?21SM# zQyM6fQNc1;7J~&50?0fCh#*3+Oh!azvmj{TU2T8Zu6KT&z4ku)oc--jx7P z-=nb5N~7>vK=nK=xGS1r@~V9A^lJ6_@hz*?7{Ivy~%d-lZ1LvQX^+>8a{b@Z~6 zwi(<#A9P=T_nnCofGgad9Q$#OQS#T_S(cWTk}e*9C___IvyL{!q%VvG z!sk{$NNY;$>S)ua-Q1qXZdJ0$Pv6*%BI#pMK{T2P`&1~MKI_Rbj$km}pDyCErA~Zv z@fC3mxBKEX*`E!FD5ac(ELJaOF&w!C0c=B&_Jc7j0qhF{m-wICWzkndricm0(?-pE zeuX0vxxpTan!sq&;?PghtYqEI%yryyE)x?V5M;7U5ddc#qssi0s5bt@J_o{qU6S71 zv}V6B`;EnMyb14?txm=fA`$`MV#ddt60^)M)3gh!tB2>?UgiZ#da^ac7sj5FU~vjy zq52WuOC345@c#9-YVRIkKqUGK2?kpsEZ^<~1lD^S7ni3yQ&Uru)sKW-yEfbmnwf>k z*RL6ML_{znu*E-}{wB^Wy11m|GKDfQQ^DnOTL3Sh;_-$3DoM6Fb8~Z)!Q!15&}qg` zcr@Fbn&azglw;wqgvae2Dt&*Bt#16PE~>Nrm}@`qv!Ul*7qvQX%!|f6adKS4ehP9K znb6ngOz!IA+n12rBr7kwJiH>~ShcMD`ubM@pNY3BAq!WGTSF=~&44y7V62`=z10`J z)cmM6T7HgLw)|jZwEC)hv4?Z5Yjl0Cxwz04t^ZAao;yxKK|v|QLZK%3qi86t7|+md zt-dNDjGOF9o-t9`HbujNG5KofH@yCd=H$Z$2C4gwmlCxtqSoJc@i5Kg&Yz8lSlU4* z=V58-;3WP#ZL8~xc^e`PeXCqYCCb-_;rK&-PSmm58@4)(v#0L=P$Tl9>gwwL*KY7$ zJwqBqp%g_sJ7&~8az7WI`4!TtS0K^&f3$!&GV%K^cjd#f5R#2<>$=NXO)%J_T{1#t zqR@c?5>plLFsWQq9da*;Fxc0f!k%q1TIW59PQyS>Rs2$JL}5*hg}iHZUQF(fful$; z1OVj(|G9;_tpYU~akeN27j`v-HTUoW?}z+pjPJ)JZT0c1`cGaOx*oyD{~^FxD9vQmv4t0>0A zGsQWFh3|SGpg#x7RwNB1P;I*&=X)c5gSG$7^L~Cd0X3ILs!D>fI2;OT;DWkgYg4`{ zdgwy*4WcqYr{VKLnId$*Z`F^UbUK}RR8w3=PVAKop!FER5I;zHCXIOzm1$FLZ0P;7 z2X^}0WX;n(zYhu^a- z5AFcok=NSeN40qNNbq`Kx05!{6he#n39c`)d@{WEg-1q41_!G^^MQhDzd)0|eLnEO zVWd1j84x%P0-_f$enGz$un-X5jvOnQd;_2OH3U3q?P%0`D44DQUMQfEr@V~|B&SIG zl}W<4VUsN>3^SsOOF3=2<2vpNWh&G{E_Z!GUtpFN7awhjVVzcQ;@hWP?9Pcf0e zoc3$4B%te;eBKU7tVlN>pAye3LP0XIOd9k92#9pXpBK5uce)x(ugI#dh_@rbJNgMD zkEu#{H#ct2$9Hpc$0FB)&zwo-vY~l&7*$zWX>n|+B}L0Z@uUL(O@yhbseidAq{S() zur+Pp0(5llWu;Uq?YktJXbZ?3IER1a^_cLjzXO5r7PvDwILKnb_i6|rL9OBb=JUTA zB(NT96i(XArxX(p4Yg%wXMYxzVj?%DVJqqt!A%0Wyq%r%+5A3z4idGwYcq;N#Hw#$ y!8!9-4Nec%h;tsh8~jTvDi5hi|6Ch!8}O>F7kOlWdl&l6VBeo4*p}LmV*dv=q&_bI diff --git a/tests/ref/layout/grid-stroke.png b/tests/ref/layout/grid-stroke.png index e9f9b0615065d4ae08bf872f24e8be162dc4ea32..fbba379e8584da9d5463e2e54f470b17323945f2 100644 GIT binary patch delta 1548 zcmbVMdobGx5Dw8F9Y2I>8`^qSm=StGT`$TJTBW%Z8Ld~nFRG0SHH4~2qp4>rjT5R) zHN~|RkCG!IQX}=Kwu*DycvNVssd_|TB*ew^W^U$g=Ki>yZ~xhy-P!&2o0SWpG!e9L zxAumHDg**)KDD_Dfry_pTCm3{EWwAE?`*YaS268S_8kyzV^@;8ujp}>PtjPrh927^ z(xK1Q&bMYSAPLy;>LdMpLQ*`}VSIS@(xP03^}F_3cIze2_H>vqn4W|#09pM+F?qiw&i(qM%@)Qa6e#xUsK+nd77wm zF^+L8D?cAs6XjSIZ#`I1f~z^-!uPxP%YAP;q(5OG+DHEivic|+RWZ<4ox%&bS)Bu3 zR*9ZgERYTbth679188m=@n?#>N)NF5W$Lv#3Os<@Sut?RN`KMVp%lB89vThn;De*{ zBWJnYvk4otSt;3?BK17WHLMyaSHulGx^Kx)9Xz9N{35?{hi8GBss>O*azyyJj>5Q{a< zGOnPAdbZz1K_eh;9NtmV+e?wM|QMqpwQ zLIsufI8rL#Xkokh`|~HSfgkmfo?f?mkvK+llofV{lXMo$`F_zb(4;Fv7}MnN2$9KV^+dKPIW0C^I*%V5VgRVtxr%?gS|?5G*`I%^TpRq2!Mfa<^~%RjKk z6Dhp+$^5*>7|QeWE%RUGxp$U>h8=D$#q>L8X$=tUZCfYLzeaf#{_w5DK#k${^|mkr zMRUrMEhkz9a&>8Ev)(3zqox~NcFrQL6Z2jztU)98h^`H>$iHzA(`l2gZ{~tFF3A3g z)aX;Y-^I{aH^6wE@= ztE#GohK5>Nc%n!Y3N{2^TwKh}&7BJ6+W?q-?hPlkcWtN9Xz`o2NMyUf2McD|(ut12 zcsxENWMX7wXO0H~f$;YBe$w9`92Atuy9L*nBv{Dh^6~NUK1%dTT(y2a{$7z2)Tn3q z5D*E2{%>Tfh#>!6oT}gTC4N&p*Rw=9B+YgMOPIP`Q+-!(dGqr&{*ACILtb@T|OlM9n)$g4}>J`Ip^u3bFWD?0eIL!TF zWo1NctQS~<5G1Fh)YsMJQrEAGC6d^fn7Nsm8G)vtrp7W`JUwkLxk0XW6T$OwnS4M~!eEhZ)=)Klo;4SasEudgYv zVo%2+?9u49*4DA{ai@a^KP7-tatQb;4s2>_a(8#{3@4MxiHR$-a7RZ+002a;8Qm~s z-u(PLi^Y=5WQKe8C}&h;RI00Zm@!IAOHInK2yV^kUvgut*LpU^R_5Nlp;(4l*w1ng&}Vn1Bl8k6pBzV3r**8xs8pD9%jCQfu-J6 zB3E*hm9;frAkYFvM@M-fI55jWxdc8SpyU~WV?c@_YY{x1oSZtRrBYNmE|0q0-5?Z( zDft*4wud=8FFenTt0hKVfr~OZ@or2i(ZKc}g86zyNanyt%W1`Dt3Kg{6I&s#BRG#b IO!(zL0s4`~nE(I) delta 995 zcmV<9104K=!~^@k1CSRErw9T7000?6)M)?!-@>saquYPCy}Jp8Eer!V-g{zLOt#8K zC<&V>-IAoc!%m&d)`Bt%ixcvZkyx1Q?!r!tk7gwiw(bT^G#b4h|0X_xG=? ztb8;#>FIyzN%rd)h9T2mWY^9o7!rxZ;^N}S$cRjciO1t$Alq#2(RIDEvojWpt*x!) z=jU5nqSHG#IH<0!o}HbQ-9N!#(Cv1AgdrM@dOV(`rKOXT6PZn4Sy_2=a{~sl0S0;g zE-x=nCX?2t&8DWN)X&L6S*FZSO--fV^7(wf#UOuYmAbmRv9U4h8}st={C+gwte55i1tI?1VqfdFI!403!c zr6wjO{^Ulg4BpF#L6#Frsnylhy}i9~IJ~>NyS=@Ad3gy2%61HqNJJ@Ro*6z#p6NAB zySRV2$cSNRXhN=r)xgRCWF#cW|PmmI~##j+0B z+S;;)_Ju+rFpv!}+~42px~^%O9P6#Et#WQJNRIW?yXNNR(`$KBrE%Ij65 z(WpF@e;rsimn;nF!-kKJj^qbkUte>XMC^29%|KuP1Cs%|8h?S|Zvz8?0Sp8NFc28P zKwtm^8!srO4i68(Ku*LEiA1b{Na1i;Fv!Ic7|4kjq*yFAIy&0c*5>tkHBFnIo(2Os z5koK-EGa1ogYwwLovuuBbUo%j@zPr2Q%s{*x zgO_6vlaaa