Repository: typst/typst
Pull request #6232: Respect RTL cell layouting order in grid layout
Author: login: saecki
Created: 2025-05-02T12:17:01Z | Merged: 2025-05-06T08:31:43Z

Fixes #5178 by layouting cells from right to left in grid layout, when RTL is enabled.

Linked issue #5178: Counter/State problem in Grid/Table when text is set to RTL
### Description

Hello again.
I'm sorry for opening a new issue but I think this issue's title and example is way better than #5171 
You can remove or close the previous one.

In order to see the problem, you can just try the example in the following URL.
Please do as follows:
1. Uncomment the first line and see the result.
2. Comment the first line and see the result.

It will perfectly show the problem.

### Reproduction URL

https://typst.app/project/rPkKhKHdLohNy8xdpV8W42

### Operating system

Linux

### Typst version

- [X] I am using the latest version of Typst

Diff:
diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs
index dc9e2238d4dd..99b85eddb1d4 100644
--- a/crates/typst-layout/src/grid/layouter.rs
+++ b/crates/typst-layout/src/grid/layouter.rs
@@ -11,7 +11,7 @@ use typst_library::layout::{
 use typst_library::text::TextElem;
 use typst_library::visualize::Geometry;
 use typst_syntax::Span;
-use typst_utils::{MaybeReverseIter, Numeric};
+use typst_utils::Numeric;
 
 use super::{
     generate_line_segments, hline_stroke_at_column, layout_cell, vline_stroke_at_row,
@@ -574,7 +574,7 @@ impl<'a> GridLayouter<'a> {
 
             // Reverse with RTL so that later columns start first.
             let mut dx = Abs::zero();
-            for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
+            for (x, &col) in self.rcols.iter().enumerate() {
                 let mut dy = Abs::zero();
                 for row in rows {
                     // We want to only draw the fill starting at the parent
@@ -643,18 +643,13 @@ impl<'a> GridLayouter<'a> {
                                     .sum()
                             };
                             let width = self.cell_spanned_width(cell, x);
-                            // In the grid, cell colspans expand to the right,
-                            // so we're at the leftmost (lowest 'x') column
-                            // spanned by the cell. However, in RTL, cells
-                            // expand to the left. Therefore, without the
-                            // offset below, cell fills would start at the
-                            // rightmost visual position of a cell and extend
-                            // over to unrelated columns to the right in RTL.
-                            // We avoid this by ensuring the fill starts at the
-                            // very left of the cell, even with colspan > 1.
-                            let offset =
-                                if self.is_rtl { -width + col } else { Abs::zero() };
-                            let pos = Point::new(dx + offset, dy);
+                            let mut pos = Point::new(dx, dy);
+                            if self.is_rtl {
+                                // In RTL cells expand to the left, thus the
+                                // position must additionally be offset by the
+                                // cell's width.
+                                pos.x = self.width - (dx + width);
+                            }
                             let size = Size::new(width, height);
                             let rect = Geometry::Rect(size).filled(fill);
                             fills.push((pos, FrameItem::Shape(rect, self.span)));
@@ -1236,10 +1231,9 @@ impl<'a> GridLayouter<'a> {
         }
 
         let mut output = Frame::soft(Size::new(self.width, height));
-        let mut pos = Point::zero();
+        let mut offset = Point::zero();
 
-        // Reverse the column order when using RTL.
-        for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
+        for (x, &rcol) in self.rcols.iter().enumerate() {
             if let Some(cell) = self.grid.cell(x, y) {
                 // Rowspans have a separate layout step
                 if cell.rowspan.get() == 1 {
@@ -1257,25 +1251,17 @@ impl<'a> GridLayouter<'a> {
                     let frame =
                         layout_cell(cell, engine, disambiguator, self.styles, pod)?
                             .into_frame();
-                    let mut pos = pos;
+                    let mut pos = offset;
                     if self.is_rtl {
-                        // In the grid, cell colspans expand to the right,
-                        // so we're at the leftmost (lowest 'x') column
-                        // spanned by the cell. However, in RTL, cells
-                        // expand to the left. Therefore, without the
-                        // offset below, the cell's contents would be laid out
-                        // starting at its rightmost visual position and extend
-                        // over to unrelated cells to its right in RTL.
-                        // We avoid this by ensuring the rendered cell starts at
-                        // the very left of the cell, even with colspan > 1.
-                        let offset = -width + rcol;
-                        pos.x += offset;
+                        // In RTL cells expand to the left, thus the position
+                        // must additionally be offset by the cell's width.
+                        pos.x = self.width - (pos.x + width);
                     }
                     output.push_frame(pos, frame);
                 }
             }
 
-            pos.x += rcol;
+            offset.x += rcol;
         }
 
         Ok(output)
@@ -1302,8 +1288,8 @@ impl<'a> GridLayouter<'a> {
         pod.backlog = &heights[1..];
 
         // Layout the row.
-        let mut pos = Point::zero();
-        for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
+        let mut offset = Point::zero();
+        for (x, &rcol) in self.rcols.iter().enumerate() {
             if let Some(cell) = self.grid.cell(x, y) {
                 // Rowspans have a separate layout step
                 if cell.rowspan.get() == 1 {
@@ -1314,17 +1300,19 @@ impl<'a> GridLayouter<'a> {
                     let fragment =
                         layout_cell(cell, engine, disambiguator, self.styles, pod)?;
                     for (output, frame) in outputs.iter_mut().zip(fragment) {
-                        let mut pos = pos;
+                        let mut pos = offset;
                         if self.is_rtl {
-                            let offset = -width + rcol;
-                            pos.x += offset;
+                            // In RTL cells expand to the left, thus the
+                            // position must additionally be offset by the
+                            // cell's width.
+                            pos.x = self.width - (offset.x + width);
                         }
                         output.push_frame(pos, frame);
                     }
                 }
             }
 
-            pos.x += rcol;
+            offset.x += rcol;
         }
 
         Ok(Fragment::frames(outputs))
diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs
index 21992ed02de4..5ab0417d8b30 100644
--- a/crates/typst-layout/src/grid/rowspans.rs
+++ b/crates/typst-layout/src/grid/rowspans.rs
@@ -3,7 +3,6 @@ use typst_library::engine::Engine;
 use typst_library::foundations::Resolve;
 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::{layout_cell, Cell, GridLayouter};
@@ -23,6 +22,10 @@ pub struct Rowspan {
     /// specified for the parent cell's `breakable` field.
     pub is_effectively_unbreakable: bool,
     /// The horizontal offset of this rowspan in all regions.
+    ///
+    /// This is the offset from the text direction start, meaning that, on RTL
+    /// grids, this is the offset from the right of the grid, whereas, on LTR
+    /// grids, it is the offset from the left.
     pub dx: Abs,
     /// The vertical offset of this rowspan in the first region.
     pub dy: Abs,
@@ -118,10 +121,11 @@ impl GridLayouter<'_> {
             // Nothing to layout.
             return Ok(());
         };
-        let first_column = self.rcols[x];
         let cell = self.grid.cell(x, y).unwrap();
         let width = self.cell_spanned_width(cell, x);
-        let dx = if self.is_rtl { dx - width + first_column } else { dx };
+        // In RTL cells expand to the left, thus the position
+        // must additionally be offset by the cell's width.
+        let dx = if self.is_rtl { self.width - (dx + width) } else { dx };
 
         // Prepare regions.
         let size = Size::new(width, *first_height);
@@ -185,10 +189,8 @@ impl GridLayouter<'_> {
     /// Checks if a row contains the beginning of one or more rowspan cells.
     /// If so, adds them to the rowspans vector.
     pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) {
-        // We will compute the horizontal offset of each rowspan in advance.
-        // For that reason, we must reverse the column order when using RTL.
-        let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl));
-        for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) {
+        let offsets = points(self.rcols.iter().copied());
+        for (x, dx) in (0..self.rcols.len()).zip(offsets) {
             let Some(cell) = self.grid.cell(x, y) else {
                 continue;
             };
diff --git a/tests/ref/grid-rtl-counter.png b/tests/ref/grid-rtl-counter.png
new file mode 100644
index 000000000000..fb0df44ad40d
Binary files /dev/null and b/tests/ref/grid-rtl-counter.png differ
diff --git a/tests/ref/grid-rtl-rowspan-counter-equal.png b/tests/ref/grid-rtl-rowspan-counter-equal.png
new file mode 100644
index 000000000000..fb0df44ad40d
Binary files /dev/null and b/tests/ref/grid-rtl-rowspan-counter-equal.png differ
diff --git a/tests/ref/grid-rtl-rowspan-counter-mixed-1.png b/tests/ref/grid-rtl-rowspan-counter-mixed-1.png
new file mode 100644
index 000000000000..fffccc5664ed
Binary files /dev/null and b/tests/ref/grid-rtl-rowspan-counter-mixed-1.png differ
diff --git a/tests/ref/grid-rtl-rowspan-counter-mixed-2.png b/tests/ref/grid-rtl-rowspan-counter-mixed-2.png
new file mode 100644
index 000000000000..c091f3a806bb
Binary files /dev/null and b/tests/ref/grid-rtl-rowspan-counter-mixed-2.png differ
diff --git a/tests/ref/grid-rtl-rowspan-counter-unequal-1.png b/tests/ref/grid-rtl-rowspan-counter-unequal-1.png
new file mode 100644
index 000000000000..c091f3a806bb
Binary files /dev/null and b/tests/ref/grid-rtl-rowspan-counter-unequal-1.png differ
diff --git a/tests/ref/grid-rtl-rowspan-counter-unequal-2.png b/tests/ref/grid-rtl-rowspan-counter-unequal-2.png
new file mode 100644
index 000000000000..fffccc5664ed
Binary files /dev/null and b/tests/ref/grid-rtl-rowspan-counter-unequal-2.png differ
diff --git a/tests/suite/layout/grid/rtl.typ b/tests/suite/layout/grid/rtl.typ
index 7c0e999a2841..e79b465ab4b1 100644
--- a/tests/suite/layout/grid/rtl.typ
+++ b/tests/suite/layout/grid/rtl.typ
@@ -193,3 +193,143 @@
   ),
   ..range(0, 10).map(i => ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
 )
+
+--- grid-rtl-counter ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ],
+  [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ],
+)
+
+--- grid-rtl-rowspan-counter-equal ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 2, [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 2, [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+)
+
+--- grid-rtl-rowspan-counter-unequal-1 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 5, [
+    b: // will produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 2, [
+    a: // will produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 3, [
+    c: // will produce 3
+    #test.step()
+    #context test.get().first()
+  ]),
+)
+
+--- grid-rtl-rowspan-counter-unequal-2 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 2, [
+    a: // will produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 5, [
+    b: // will produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 3, [
+    c: // will produce 3
+    #test.step()
+    #context test.get().first()
+  ]),
+)
+
+--- grid-rtl-rowspan-counter-mixed-1 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  [
+    a: // will produce 1
+    #test.step()
+    #context test.get().first()
+  ],
+  grid.cell(rowspan: 2, [
+    b: // will produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  [
+    c: // will produce 3
+    #test.step()
+    #context test.get().first()
+  ],
+)
+
+--- grid-rtl-rowspan-counter-mixed-2 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 2, [
+    b: // will produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  [
+    a: // will produce 1
+    #test.step()
+    #context test.get().first()
+  ],
+  [
+    c: // will produce 3
+    #test.step()
+    #context test.get().first()
+  ]
+)


Reviews:
user: login: PgBiel
body: The logic seems to check out per my quick sketch below (very high quality :sunglasses:). However, this fix probably needs to be applied to `check_for_rowspans` as well in `rowspans.rs`. (It should also be tested by having rowspan cells in tests, particularly starting at the same row, but also in different rows.)

![image](https://github.com/user-attachments/assets/03769d8a-456e-433e-a5d3-1f869e4f16d2)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T16:55:10Z
user: login: PgBiel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T21:58:12Z
user: login: saecki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T06:47:58Z
user: login: saecki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T06:53:42Z
user: login: PgBiel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T00:18:19Z
user: login: PgBiel
body: Looks good to me. Only two style nits and it's mergeable.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T00:30:49Z

Review comments:
diff_hunk: @@ -193,3 +193,93 @@
   ),
   ..range(0, 10).map(i => ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
 )
+
+--- grid-rtl-counter ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ],
+  [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ],
+)
+
+--- grid-rtl-rowspan-counter-equal ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 2, [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 2, [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+)
+
+--- grid-rtl-rowspan-counter-unequal-1 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 1, [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 2, [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 1, [
+    c: // should produce 3
+    #test.step()
+    #context test.get().first()
+  ]),
+)
+
+--- grid-rtl-rowspan-counter-unequal-2 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 2, [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 1, [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 1, [
+    d: // should produce 3
+    #test.step()
+    #context test.get().first()
+  ]),
path: tests/suite/layout/grid/rtl.typ
user: login: PgBiel
body: This isn't testing rowspans at the same row as `rowspan: 1` is the usual case, let's test that. Same for other tests. Although it can't hurt to test mixing them as well as you are doing - maybe they could be separate tests.

```suggestion
  grid.cell(rowspan: 2, [
    b: // should produce 2
    #test.step()
    #context test.get().first()
  ]),
  grid.cell(rowspan: 2, [
    d: // should produce 3
    #test.step()
    #context test.get().first()
  ]),
```
created_at: 2025-05-02T21:53:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,10 +183,8 @@ impl GridLayouter<'_> {
     /// Checks if a row contains the beginning of one or more rowspan cells.
     /// If so, adds them to the rowspans vector.
     pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) {
-        // We will compute the horizontal offset of each rowspan in advance.
-        // For that reason, we must reverse the column order when using RTL.
-        let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl));
-        for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) {
+        let offsets = points(self.rcols.iter().copied());
+        for (x, dx) in (0..self.rcols.len()).zip(offsets) {
path: crates/typst-layout/src/grid/rowspans.rs
user: login: PgBiel
body: I suspect this double reversal was indeed an oversight... The order of `self.rowspans` changes, but this doesn't seem to affect rowspan layout code as it doesn't consider that order (all information is contained in x, y, dx, which seems to be the same anyway), so that was a good catch.
created_at: 2025-05-02T21:56:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -193,3 +193,93 @@
   ),
   ..range(0, 10).map(i => ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
 )
+
+--- grid-rtl-counter ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ],
+  [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ],
+)
+
+--- grid-rtl-rowspan-counter-equal ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 2, [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 2, [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+)
+
+--- grid-rtl-rowspan-counter-unequal-1 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 1, [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 2, [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 1, [
+    c: // should produce 3
+    #test.step()
+    #context test.get().first()
+  ]),
+)
+
+--- grid-rtl-rowspan-counter-unequal-2 ---
+// Test interaction between RTL and counters
+#set text(dir: rtl)
+#let test = counter("test")
+#grid(
+  columns: (1fr, 1fr),
+  inset: 5pt,
+  align: center,
+  grid.cell(rowspan: 2, [
+    a: // should produce 1
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 1, [
+    b: // should produce 2
+    #test.step()
+    #context test.get().first()
+  ]),
+  grid.cell(rowspan: 1, [
+    d: // should produce 3
+    #test.step()
+    #context test.get().first()
+  ]),
path: tests/suite/layout/grid/rtl.typ
user: login: saecki
body: That was kind of the intention, but I've renamed these tests to `mixed` and added some other tests with `rowspan > 1` to test how rowspans interact :+1: 
created_at: 2025-05-05T06:47:58Z
author_association: MEMBER
diff_hunk: @@ -185,10 +183,8 @@ impl GridLayouter<'_> {
     /// Checks if a row contains the beginning of one or more rowspan cells.
     /// If so, adds them to the rowspans vector.
     pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) {
-        // We will compute the horizontal offset of each rowspan in advance.
-        // For that reason, we must reverse the column order when using RTL.
-        let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl));
-        for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) {
+        let offsets = points(self.rcols.iter().copied());
+        for (x, dx) in (0..self.rcols.len()).zip(offsets) {
path: crates/typst-layout/src/grid/rowspans.rs
user: login: saecki
body: This change here only works because, the code in `layout_rowspan` was also changed, otherwise the double reversal would have been necessary. Essentially the same transformation as for the other layout code was done here as well, where now the offset is computed from whichever direction the columns are drawn (LTR or RTL) and then the position is then fixed up afterwards for RTL in `layout_rowspan`: 
```rs
        let dx = if self.is_rtl { self.width - (dx + width) } else { dx };
```
created_at: 2025-05-05T06:53:42Z
author_association: MEMBER
diff_hunk: @@ -185,10 +183,8 @@ impl GridLayouter<'_> {
     /// Checks if a row contains the beginning of one or more rowspan cells.
     /// If so, adds them to the rowspans vector.
     pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) {
-        // We will compute the horizontal offset of each rowspan in advance.
-        // For that reason, we must reverse the column order when using RTL.
-        let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl));
-        for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) {
+        let offsets = points(self.rcols.iter().copied());
+        for (x, dx) in (0..self.rcols.len()).zip(offsets) {
path: crates/typst-layout/src/grid/rowspans.rs
user: login: PgBiel
body: You're right, I forgot that `points()` accumulates and thus the order of its inputs matters. :+1:
created_at: 2025-05-06T00:18:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -643,18 +643,12 @@ impl<'a> GridLayouter<'a> {
                                     .sum()
                             };
                             let width = self.cell_spanned_width(cell, x);
-                            // In the grid, cell colspans expand to the right,
-                            // so we're at the leftmost (lowest 'x') column
-                            // spanned by the cell. However, in RTL, cells
-                            // expand to the left. Therefore, without the
-                            // offset below, cell fills would start at the
-                            // rightmost visual position of a cell and extend
-                            // over to unrelated columns to the right in RTL.
-                            // We avoid this by ensuring the fill starts at the
-                            // very left of the cell, even with colspan > 1.
-                            let offset =
-                                if self.is_rtl { -width + col } else { Abs::zero() };
-                            let pos = Point::new(dx + offset, dy);
+                            let mut pos = Point::new(dx, dy);
+                            if self.is_rtl {
+                                // In RTL cells expand to the left, thus the position
+                                // must additionally be offset by the width of the cell.
path: crates/typst-layout/src/grid/layouter.rs
user: login: PgBiel
body: We will need to reflow this comment to stop at 80 chars (a little style guideline we have).
created_at: 2025-05-06T00:29:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1314,17 +1299,18 @@ impl<'a> GridLayouter<'a> {
                     let fragment =
                         layout_cell(cell, engine, disambiguator, self.styles, pod)?;
                     for (output, frame) in outputs.iter_mut().zip(fragment) {
-                        let mut pos = pos;
+                        let mut pos = offset;
                         if self.is_rtl {
-                            let offset = -width + rcol;
-                            pos.x += offset;
+                            // In RTL cells expand to the left, thus the position
+                            // must additionally be offset by the width of the cell.
path: crates/typst-layout/src/grid/layouter.rs
user: login: PgBiel
body: also reflow here
created_at: 2025-05-06T00:30:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: saecki
created_at: 2025-05-02T12:28:25Z
body: I think for consistency this loop should maybe also be changed? https://github.com/typst/typst/blob/62e6c97df592b85c9d7dff08c77c99baa495b45f/crates/typst-layout/src/grid/layouter.rs#L577

And it's probably a good idea to add a test case for the linked issue.
author_association: MEMBER
user: login: PgBiel
created_at: 2025-05-02T16:59:58Z
body: > I think for consistency this loop should maybe also be changed?
> 
> https://github.com/typst/typst/blob/62e6c97df592b85c9d7dff08c77c99baa495b45f/crates/typst-layout/src/grid/layouter.rs#L577

Yeah that was a good call. It determines the order in which fills are rendered. In principle it doesn't make a difference (other than, perhaps, in a very minimal scale at the border of each cell) as the fills don't usually intersect, but I suppose there could be a more visible effect if the fill is made using a `tiling`. Though I'm pretty sure that can't overflow / is clipped, but it's worth it for the peace of mind anyway.

Edit: If the `tiling` can contain `context` then it absolutely makes a difference, so let's keep that.
author_association: CONTRIBUTOR
user: login: saecki
created_at: 2025-05-02T18:37:25Z
body: > > I think for consistency this loop should maybe also be changed?
> > https://github.com/typst/typst/blob/62e6c97df592b85c9d7dff08c77c99baa495b45f/crates/typst-layout/src/grid/layouter.rs#L577
> 
> Yeah that was a good call. It determines the order in which fills are rendered. In principle it doesn't make a difference (other than, perhaps, in a very minimal scale at the border of each cell) as the fills don't usually intersect, but I suppose there could be a more visible effect if the fill is made using a `tiling`. Though I'm pretty sure that can't overflow / is clipped, but it's worth it for the peace of mind anyway.

I've fixed the immediate issue with RTL layouting for rowspans and added some tests for that.

But the way rowspans are handled, currently doesn't play nice with counters.
For example the following grid layout (in LTR) returns a somewhat unexpected result:
```typ
#let test = counter("test-counter")
#block(below: 1em)[
  #grid(
    columns: (1fr, 1fr),
    gutter: 1em,
    stroke: black,
    inset: 1em,
    grid.cell(rowspan: 3, [
      cell 1: counter
      #test.step()
      #context test.get().first()
    ]),
    grid.cell(rowspan: 1, [
      cell 2: counter
      #test.step()
      #context test.get().first()
    ]),
    grid.cell(rowspan: 1, [
      cell 3: counter
      #test.step()
      #context test.get().first()
    ]),
    grid.cell(rowspan: 1, [
      cell 4: counter
      #test.step()
      #context test.get().first()
    ]),
  )
]
```
![image](https://github.com/user-attachments/assets/b450f8fe-692d-4f01-b3d6-beae1c02abbe)

But that is a whole other issue.
author_association: MEMBER
user: login: PgBiel
created_at: 2025-05-02T21:48:54Z
body: > But the way rowspans are handled, currently doesn't play nice with counters.

`rowspan: 1` is the default, worth mentioning, so it isn't handled differently in the code. With that said, it's hard to produce the semantics you're seeking as rowspans can only be laid out after all of their rows have as well, so the best we can do at the moment is lay them out at the end rather than the opposite. This is especially relevant for multi-page rowspans, which are always laid out at the end of the page, after everything else.

There could be some way to layout the rowspan at the end but keep the correct order, but we'd need to either predict how much space to "leave" for the rowspan ahead of time in the frame's vector of items (probably not possible), or just insert the rowspan in the frame and accept the (in principle) undesirable performance cost. It could be studied whether that's possible to do in a good enough way.
author_association: CONTRIBUTOR
user: login: saecki
created_at: 2025-05-05T06:37:53Z
body: > > But the way rowspans are handled, currently doesn't play nice with counters.
> 
> `rowspan: 1` is the default, worth mentioning, so it isn't handled differently in the code. With that said, it's hard to produce the semantics you're seeking as rowspans can only be laid out after all of their rows have as well, so the best we can do at the moment is lay them out at the end rather than the opposite. This is especially relevant for multi-page rowspans, which are always laid out at the end of the page, after everything else.
> 
> There could be some way to layout the rowspan at the end but keep the correct order, but we'd need to either predict how much space to "leave" for the rowspan ahead of time in the frame's vector of items (probably not possible), or just insert the rowspan in the frame and accept the (in principle) undesirable performance cost. It could be studied whether that's possible to do in a good enough way.

Yeah, I think this is fine for now.
author_association: MEMBER
user: login: Amirhosein-GPR
created_at: 2025-05-07T16:47:58Z
body: Thank you very much! :grinning: 
author_association: NONE