summaryrefslogtreecommitdiff
path: root/tests/suite/layout
diff options
context:
space:
mode:
Diffstat (limited to 'tests/suite/layout')
-rw-r--r--tests/suite/layout/align.typ142
-rw-r--r--tests/suite/layout/angle.typ8
-rw-r--r--tests/suite/layout/clip.typ0
-rw-r--r--tests/suite/layout/columns.typ124
-rw-r--r--tests/suite/layout/container.typ183
-rw-r--r--tests/suite/layout/dir.typ24
-rw-r--r--tests/suite/layout/flow/flow.typ67
-rw-r--r--tests/suite/layout/flow/invisibles.typ61
-rw-r--r--tests/suite/layout/flow/orphan.typ31
-rw-r--r--tests/suite/layout/grid/cell.typ132
-rw-r--r--tests/suite/layout/grid/colspan.typ142
-rw-r--r--tests/suite/layout/grid/footers.typ404
-rw-r--r--tests/suite/layout/grid/grid.typ276
-rw-r--r--tests/suite/layout/grid/headers.typ368
-rw-r--r--tests/suite/layout/grid/positioning.typ203
-rw-r--r--tests/suite/layout/grid/rowspan.typ490
-rw-r--r--tests/suite/layout/grid/rtl.typ195
-rw-r--r--tests/suite/layout/grid/stroke.typ435
-rw-r--r--tests/suite/layout/grid/styling.typ160
-rw-r--r--tests/suite/layout/hide.typ104
-rw-r--r--tests/suite/layout/inline/baseline.typ17
-rw-r--r--tests/suite/layout/inline/bidi.typ77
-rw-r--r--tests/suite/layout/inline/cjk.typ90
-rw-r--r--tests/suite/layout/inline/hyphenate.typ51
-rw-r--r--tests/suite/layout/inline/justify.typ170
-rw-r--r--tests/suite/layout/inline/linebreak.typ109
-rw-r--r--tests/suite/layout/inline/overhang.typ24
-rw-r--r--tests/suite/layout/inline/shaping.typ65
-rw-r--r--tests/suite/layout/inline/text.typ89
-rw-r--r--tests/suite/layout/layout.typ14
-rw-r--r--tests/suite/layout/length.typ69
-rw-r--r--tests/suite/layout/limits.typ32
-rw-r--r--tests/suite/layout/measure.typ9
-rw-r--r--tests/suite/layout/pad.typ30
-rw-r--r--tests/suite/layout/page.typ231
-rw-r--r--tests/suite/layout/pagebreak.typ143
-rw-r--r--tests/suite/layout/place.typ226
-rw-r--r--tests/suite/layout/relative.typ7
-rw-r--r--tests/suite/layout/repeat.typ44
-rw-r--r--tests/suite/layout/spacing.typ38
-rw-r--r--tests/suite/layout/stack.typ82
-rw-r--r--tests/suite/layout/table.typ284
-rw-r--r--tests/suite/layout/transform.typ106
43 files changed, 5556 insertions, 0 deletions
diff --git a/tests/suite/layout/align.typ b/tests/suite/layout/align.typ
new file mode 100644
index 00000000..61b79975
--- /dev/null
+++ b/tests/suite/layout/align.typ
@@ -0,0 +1,142 @@
+// Test alignment.
+
+--- align-right ---
+// Test ragged-left.
+#set align(right)
+To the right! Where the sunlight peeks behind the mountain.
+
+--- align-in-stack ---
+#set page(height: 100pt)
+#stack(dir: ltr,
+ align(left, square(size: 15pt, fill: eastern)),
+ align(center, square(size: 20pt, fill: eastern)),
+ align(right, square(size: 15pt, fill: eastern)),
+)
+#align(center + horizon, rect(fill: eastern, height: 10pt))
+#align(bottom, stack(
+ align(center, rect(fill: conifer, height: 10pt)),
+ rect(fill: forest, height: 10pt, width: 100%),
+))
+
+--- align-center-in-flow ---
+// Test that multiple paragraphs in subflow also respect alignment.
+#align(center)[
+ Lorem Ipsum
+
+ Dolor
+]
+
+--- align-start-and-end ---
+// Test start and end alignment.
+#rotate(-30deg, origin: end + horizon)[Hello]
+
+#set text(lang: "de")
+#align(start)[Start]
+#align(end)[Ende]
+
+#set text(lang: "ar")
+#align(start)[يبدأ]
+#align(end)[نهاية]
+
+--- alignment-fields-x ---
+// Test 2d alignment 'horizontal' field.
+#test((start + top).x, start)
+#test((end + top).x, end)
+#test((left + top).x, left)
+#test((right + top).x, right)
+#test((center + top).x, center)
+#test((start + bottom).x, start)
+#test((end + bottom).x, end)
+#test((left + bottom).x, left)
+#test((right + bottom).x, right)
+#test((center + bottom).x, center)
+#test((start + horizon).x, start)
+#test((end + horizon).x, end)
+#test((left + horizon).x, left)
+#test((right + horizon).x, right)
+#test((center + horizon).x, center)
+#test((top + start).x, start)
+#test((bottom + end).x, end)
+#test((horizon + center).x, center)
+
+--- alignment-fields-y ---
+// Test 2d alignment 'vertical' field.
+#test((start + top).y, top)
+#test((end + top).y, top)
+#test((left + top).y, top)
+#test((right + top).y, top)
+#test((center + top).y, top)
+#test((start + bottom).y, bottom)
+#test((end + bottom).y, bottom)
+#test((left + bottom).y, bottom)
+#test((right + bottom).y, bottom)
+#test((center + bottom).y, bottom)
+#test((start + horizon).y, horizon)
+#test((end + horizon).y, horizon)
+#test((left + horizon).y, horizon)
+#test((right + horizon).y, horizon)
+#test((center + horizon).y, horizon)
+#test((top + start).y, top)
+#test((bottom + end).y, bottom)
+#test((horizon + center).y, horizon)
+
+--- alignment-type ---
+#test(type(center), alignment)
+#test(type(horizon), alignment)
+#test(type(center + horizon), alignment)
+
+--- alignment-axis ---
+// Test alignment methods.
+#test(start.axis(), "horizontal")
+#test(end.axis(), "horizontal")
+#test(left.axis(), "horizontal")
+#test(right.axis(), "horizontal")
+#test(center.axis(), "horizontal")
+#test(top.axis(), "vertical")
+#test(bottom.axis(), "vertical")
+#test(horizon.axis(), "vertical")
+
+--- alignment-inv ---
+#test(start.inv(), end)
+#test(end.inv(), start)
+#test(left.inv(), right)
+#test(right.inv(), left)
+#test(center.inv(), center)
+#test(top.inv(), bottom)
+#test(bottom.inv(), top)
+#test(horizon.inv(), horizon)
+#test((start + top).inv(), (end + bottom))
+#test((end + top).inv(), (start + bottom))
+#test((left + top).inv(), (right + bottom))
+#test((right + top).inv(), (left + bottom))
+#test((center + top).inv(), (center + bottom))
+#test((start + bottom).inv(), (end + top))
+#test((end + bottom).inv(), (start + top))
+#test((left + bottom).inv(), (right + top))
+#test((right + bottom).inv(), (left + top))
+#test((center + bottom).inv(), (center + top))
+#test((start + horizon).inv(), (end + horizon))
+#test((end + horizon).inv(), (start + horizon))
+#test((left + horizon).inv(), (right + horizon))
+#test((right + horizon).inv(), (left + horizon))
+#test((center + horizon).inv(), (center + horizon))
+#test((top + start).inv(), (end + bottom))
+#test((bottom + end).inv(), (start + top))
+#test((horizon + center).inv(), (center + horizon))
+
+--- alignment-add-two-horizontal ---
+// Error: 8-22 cannot add two horizontal alignments
+#align(center + right, [A])
+
+--- alignment-add-two-vertical ---
+// Error: 8-20 cannot add two vertical alignments
+#align(top + bottom, [A])
+
+--- alignment-add-vertical-and-2d ---
+// Error: 8-30 cannot add a vertical and a 2D alignment
+#align(top + (bottom + right), [A])
+
+--- issue-1398-line-align ---
+// Test right-aligning a line and a rectangle.
+#align(right, line(length: 30%))
+#align(right, rect())
diff --git a/tests/suite/layout/angle.typ b/tests/suite/layout/angle.typ
new file mode 100644
index 00000000..65e80aeb
--- /dev/null
+++ b/tests/suite/layout/angle.typ
@@ -0,0 +1,8 @@
+--- angle-to-unit ---
+// Test angle methods.
+#test(1rad.rad(), 1.0)
+#test(1.23rad.rad(), 1.23)
+#test(0deg.rad(), 0.0)
+#test(2deg.deg(), 2.0)
+#test(2.94deg.deg(), 2.94)
+#test(0rad.deg(), 0.0)
diff --git a/tests/suite/layout/clip.typ b/tests/suite/layout/clip.typ
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tests/suite/layout/clip.typ
diff --git a/tests/suite/layout/columns.typ b/tests/suite/layout/columns.typ
new file mode 100644
index 00000000..87a9f773
--- /dev/null
+++ b/tests/suite/layout/columns.typ
@@ -0,0 +1,124 @@
+// Test the column layouter.
+
+--- columns-rtl ---
+// Test normal operation and RTL directions.
+#set page(height: 3.25cm, width: 7.05cm, columns: 2)
+#set text(lang: "ar", font: ("Noto Sans Arabic", "Linux Libertine"))
+#set columns(gutter: 30pt)
+
+#box(fill: conifer, height: 8pt, width: 6pt) وتحفيز
+العديد من التفاعلات الكيميائية. (DNA) من أهم الأحماض النووية التي تُشكِّل
+إلى جانب كل من البروتينات والليبيدات والسكريات المتعددة
+#box(fill: eastern, height: 8pt, width: 6pt)
+الجزيئات الضخمة الأربعة الضرورية للحياة.
+
+--- columns-in-fixed-size-rect ---
+// Test the `columns` function.
+#set page(width: auto)
+
+#rect(width: 180pt, height: 100pt, inset: 8pt, columns(2, [
+ A special plight has befallen our document.
+ Columns in text boxes reigned down unto the soil
+ to waste a year's crop of rich layouts.
+ The columns at least were graciously balanced.
+]))
+
+--- columns-set-page ---
+// Test columns for a sized page.
+#set page(height: 5cm, width: 7.05cm, columns: 2)
+
+Lorem ipsum dolor sit amet is a common blind text
+and I again am in need of filling up this page
+#align(bottom, rect(fill: eastern, width: 100%, height: 12pt))
+#colbreak()
+
+so I'm returning to this trusty tool of tangible terror.
+Sure, it is not the most creative way of filling up
+a page for a test but it does get the job done.
+
+--- columns-in-auto-sized-rect ---
+// Test the expansion behaviour.
+#set page(height: 2.5cm, width: 7.05cm)
+
+#rect(inset: 6pt, columns(2, [
+ ABC \
+ BCD
+ #colbreak()
+ DEF
+]))
+
+--- columns-more-with-gutter ---
+// Test setting a column gutter and more than two columns.
+#set page(height: 3.25cm, width: 7.05cm, columns: 3)
+#set columns(gutter: 30pt)
+
+#rect(width: 100%, height: 2.5cm, fill: conifer) #parbreak()
+#rect(width: 100%, height: 2cm, fill: eastern) #parbreak()
+#circle(fill: eastern)
+
+--- columns-set-page-colbreak-pagebreak ---
+// Test the `colbreak` and `pagebreak` functions.
+#set page(height: 1cm, width: 7.05cm, columns: 2)
+
+A
+#colbreak()
+#colbreak()
+B
+#pagebreak()
+C
+#colbreak()
+D
+
+--- columns-empty-second-column ---
+// Test an empty second column.
+#set page(width: 7.05cm, columns: 2)
+
+#rect(width: 100%, inset: 3pt)[So there isn't anything in the second column?]
+
+--- columns-page-width-auto ---
+// Test columns when one of them is empty.
+#set page(width: auto, columns: 3)
+
+Arbitrary horizontal growth.
+
+--- columns-page-height-auto ---
+// Test columns in an infinitely high frame.
+#set page(width: 7.05cm, columns: 2)
+
+There can be as much content as you want in the left column
+and the document will grow with it.
+
+#rect(fill: conifer, width: 100%, height: 30pt)
+
+Only an explicit #colbreak() `#colbreak()` can put content in the
+second column.
+
+--- columns-one ---
+// Test a page with a single column.
+#set page(height: auto, width: 7.05cm, columns: 1)
+
+This is a normal page. Very normal.
+
+--- columns-zero ---
+// Test a page with zero columns.
+// Error: 49-50 number must be positive
+#set page(height: auto, width: 7.05cm, columns: 0)
+
+--- columns-colbreak-after-place ---
+// Test colbreak after only out-of-flow elements.
+#set page(width: 7.05cm, columns: 2)
+#place[OOF]
+#colbreak()
+In flow.
+
+--- issue-columns-heading ---
+// The well-known columns bug.
+#set page(height: 70pt)
+
+Hallo
+#columns(2)[
+ = A
+ Text
+ = B
+ Text
+]
diff --git a/tests/suite/layout/container.typ b/tests/suite/layout/container.typ
new file mode 100644
index 00000000..ede051db
--- /dev/null
+++ b/tests/suite/layout/container.typ
@@ -0,0 +1,183 @@
+// Test the `box` and `block` containers.
+
+--- box ---
+// Test box in paragraph.
+A #box[B \ C] D.
+
+// Test box with height.
+Spaced \
+#box(height: 0.5cm) \
+Apart
+
+--- block-sizing ---
+// Test block sizing.
+#set page(height: 120pt)
+#set block(spacing: 0pt)
+#block(width: 90pt, height: 80pt, fill: red)[
+ #block(width: 60%, height: 60%, fill: green)
+ #block(width: 50%, height: 60%, fill: blue)
+]
+
+--- box-layoutable-child ---
+// Test box sizing with layoutable child.
+#box(
+ width: 50pt,
+ height: 50pt,
+ fill: yellow,
+ path(
+ fill: purple,
+ (0pt, 0pt),
+ (30pt, 30pt),
+ (0pt, 30pt),
+ (30pt, 0pt),
+ ),
+)
+
+--- box-width-fr ---
+// Test fr box.
+Hello #box(width: 1fr, rect(height: 0.7em, width: 100%)) World
+
+--- block-multiple-pages ---
+// Test block over multiple pages.
+#set page(height: 60pt)
+
+First!
+
+#block[
+ But, soft! what light through yonder window breaks? It is the east, and Juliet
+ is the sun.
+]
+
+--- block-box-fill ---
+#set page(height: 100pt)
+#let words = lorem(18).split()
+#block(inset: 8pt, width: 100%, fill: aqua, stroke: aqua.darken(30%))[
+ #words.slice(0, 13).join(" ")
+ #box(fill: teal, outset: 2pt)[tempor]
+ #words.slice(13).join(" ")
+]
+
+--- block-spacing-basic ---
+#set block(spacing: 10pt)
+Hello
+
+There
+
+#block(spacing: 20pt)[Further down]
+
+--- block-spacing-table ---
+// Test that paragraph spacing loses against block spacing.
+// TODO
+#set block(spacing: 100pt)
+#show table: set block(above: 5pt, below: 5pt)
+Hello
+#table(columns: 4, fill: (x, y) => if calc.odd(x + y) { silver })[A][B][C][D]
+
+--- block-spacing-maximum ---
+// While we're at it, test the larger block spacing wins.
+#set block(spacing: 0pt)
+#show raw: set block(spacing: 15pt)
+#show list: set block(spacing: 2.5pt)
+
+```rust
+fn main() {}
+```
+
+- List
+
+Paragraph
+
+--- block-spacing-collapse-text-style ---
+// Test spacing collapsing with different font sizes.
+#grid(columns: 2)[
+ #text(size: 12pt, block(below: 1em)[A])
+ #text(size: 8pt, block(above: 1em)[B])
+][
+ #text(size: 12pt, block(below: 1em)[A])
+ #text(size: 8pt, block(above: 1.25em)[B])
+]
+
+--- block-fixed-height ---
+#set page(height: 100pt)
+#set align(center)
+
+#lorem(10)
+#block(width: 80%, height: 60pt, fill: aqua)
+#lorem(6)
+#block(
+ breakable: false,
+ width: 100%,
+ inset: 4pt,
+ fill: aqua,
+ lorem(8) + colbreak(),
+)
+
+--- box-clip-rect ---
+// Test box clipping with a rectangle
+Hello #box(width: 1em, height: 1em, clip: false)[#rect(width: 3em, height: 3em, fill: red)]
+world 1
+
+Space
+
+Hello #box(width: 1em, height: 1em, clip: true)[#rect(width: 3em, height: 3em, fill: red)]
+world 2
+
+--- block-clip-text ---
+// Test cliping text
+#block(width: 5em, height: 2em, clip: false, stroke: 1pt + black)[
+ But, soft! what light through
+]
+
+#v(2em)
+
+#block(width: 5em, height: 2em, clip: true, stroke: 1pt + black)[
+ But, soft! what light through yonder window breaks? It is the east, and Juliet
+ is the sun.
+]
+
+--- block-clip-svg-glyphs ---
+// Test clipping svg glyphs
+Emoji: #box(height: 0.5em, stroke: 1pt + black)[🐪, 🌋, 🏞]
+
+Emoji: #box(height: 0.5em, clip: true, stroke: 1pt + black)[🐪, 🌋, 🏞]
+
+--- block-clipping-multiple-pages ---
+// Test block clipping over multiple pages.
+#set page(height: 60pt)
+
+First!
+
+#block(height: 4em, clip: true, stroke: 1pt + black)[
+ But, soft! what light through yonder window breaks? It is the east, and Juliet
+ is the sun.
+]
+
+--- box-clip-radius ---
+// Test clipping with `radius`.
+#set page(height: 60pt)
+
+#box(
+ radius: 5pt,
+ stroke: 2pt + black,
+ width: 20pt,
+ height: 20pt,
+ clip: true,
+ image("/assets/images/rhino.png", width: 30pt)
+)
+
+--- box-clip-radius-without-stroke ---
+// Test clipping with `radius`, but without `stroke`.
+#set page(height: 60pt)
+
+#box(
+ radius: 5pt,
+ width: 20pt,
+ height: 20pt,
+ clip: true,
+ image("/assets/images/rhino.png", width: 30pt)
+)
+
+--- issue-2128-block-width-box ---
+// Test box in 100% width block.
+#block(width: 100%, fill: red, box("a box"))
+#block(width: 100%, fill: red, [#box("a box") #box()])
diff --git a/tests/suite/layout/dir.typ b/tests/suite/layout/dir.typ
new file mode 100644
index 00000000..139a2285
--- /dev/null
+++ b/tests/suite/layout/dir.typ
@@ -0,0 +1,24 @@
+--- dir-axis ---
+// Test direction methods.
+#test(ltr.axis(), "horizontal")
+#test(rtl.axis(), "horizontal")
+#test(ttb.axis(), "vertical")
+#test(btt.axis(), "vertical")
+
+--- dir-start ---
+#test(ltr.start(), left)
+#test(rtl.start(), right)
+#test(ttb.start(), top)
+#test(btt.start(), bottom)
+
+--- dir-end ---
+#test(ltr.end(), right)
+#test(rtl.end(), left)
+#test(ttb.end(), bottom)
+#test(btt.end(), top)
+
+--- dir-inv ---
+#test(ltr.inv(), rtl)
+#test(rtl.inv(), ltr)
+#test(ttb.inv(), btt)
+#test(btt.inv(), ttb)
diff --git a/tests/suite/layout/flow/flow.typ b/tests/suite/layout/flow/flow.typ
new file mode 100644
index 00000000..9c48c9ac
--- /dev/null
+++ b/tests/suite/layout/flow/flow.typ
@@ -0,0 +1,67 @@
+--- flow-fr ---
+#set page(height: 2cm)
+#set text(white)
+#rect(fill: forest)[
+ #v(1fr)
+ #h(1fr) Hi you!
+]
+
+--- issue-flow-overlarge-frames ---
+// In this bug, the first line of the second paragraph was on its page alone an
+// the rest moved down. The reason was that the second block resulted in
+// overlarge frames because the region wasn't finished properly.
+#set page(height: 70pt)
+#block[This file tests a bug where an almost empty page occurs.]
+#block[
+ The text in this second block was torn apart and split up for
+ some reason beyond my knowledge.
+]
+
+--- issue-flow-trailing-leading ---
+// In this bug, the first part of the paragraph moved down to the second page
+// because trailing leading wasn't trimmed, resulting in an overlarge frame.
+#set page(height: 60pt)
+#v(19pt)
+#block[
+ But, soft! what light through yonder window breaks?
+ It is the east, and Juliet is the sun.
+]
+
+--- issue-flow-weak-spacing ---
+// In this bug, there was a bit of space below the heading because weak spacing
+// directly before a layout-induced column or page break wasn't trimmed.
+#set page(height: 60pt)
+#rect(inset: 0pt, columns(2)[
+ Text
+ #v(12pt)
+ Hi
+ #v(10pt, weak: true)
+ At column break.
+])
+
+--- issue-flow-frame-placement ---
+// In this bug, a frame intended for the second region ended up in the first.
+#set page(height: 105pt)
+#block(lorem(20))
+
+--- issue-flow-layout-index-out-of-bounds ---
+// This bug caused an index-out-of-bounds panic when layouting paragraphs needed
+// multiple reorderings.
+#set page(height: 200pt)
+#lorem(30)
+
+#figure(placement: auto, block(height: 100%))
+
+#lorem(10)
+
+#lorem(10)
+
+--- issue-3641-float-loop ---
+// Flow layout should terminate!
+//
+// This is not yet ideal: The heading should not move to the second page, but
+// that's a separate bug and not a regression.
+#set page(height: 40pt)
+
+= Heading
+#lorem(6)
diff --git a/tests/suite/layout/flow/invisibles.typ b/tests/suite/layout/flow/invisibles.typ
new file mode 100644
index 00000000..7e460373
--- /dev/null
+++ b/tests/suite/layout/flow/invisibles.typ
@@ -0,0 +1,61 @@
+// Test out-of-flow items (place, counter updates, etc.) at the
+// beginning of a block not creating a frame just for them.
+
+--- flow-first-region-no-item ---
+// No item in the first region.
+#set page(height: 5cm, margin: 1cm)
+No item in the first region.
+#block(breakable: true, stroke: 1pt, inset: 0.5cm)[
+ #rect(height: 2cm, fill: gray)
+]
+
+--- flow-first-region-counter-update ---
+// Counter update in the first region.
+#set page(height: 5cm, margin: 1cm)
+Counter update.
+#block(breakable: true, stroke: 1pt, inset: 0.5cm)[
+ #counter("dummy").step()
+ #rect(height: 2cm, fill: gray)
+]
+
+--- flow-first-region-placed ---
+// Placed item in the first region.
+#set page(height: 5cm, margin: 1cm)
+Placed item in the first region.
+#block(breakable: true, above: 1cm, stroke: 1pt, inset: 0.5cm)[
+ #place(dx: -0.5cm, dy: -0.75cm, box(width: 200%)[OOF])
+ #rect(height: 2cm, fill: gray)
+]
+
+--- flow-first-region-zero-sized-item ---
+// In-flow item with size zero in the first region.
+#set page(height: 5cm, margin: 1cm)
+In-flow, zero-sized item.
+#block(breakable: true, stroke: 1pt, inset: 0.5cm)[
+ #set block(spacing: 0pt)
+ #line(length: 0pt)
+ #rect(height: 2cm, fill: gray)
+ #line(length: 100%)
+]
+
+--- flow-first-region-counter-update-and-placed ---
+// Counter update and placed item in the first region.
+#set page(height: 5cm, margin: 1cm)
+Counter update + place.
+#block(breakable: true, above: 1cm, stroke: 1pt, inset: 0.5cm)[
+ #counter("dummy").step()
+ #place(dx: -0.5cm, dy: -0.75cm, box([OOF]))
+ #rect(height: 2cm, fill: gray)
+]
+
+--- flow-first-region-counter-update-placed-and-line ---
+// Mix-and-match all the previous ones.
+#set page(height: 5cm, margin: 1cm)
+Mix-and-match all the previous tests.
+#block(breakable: true, above: 1cm, stroke: 1pt, inset: 0.5cm)[
+ #counter("dummy").step()
+ #place(dx: -0.5cm, dy: -0.75cm, box(width: 200%)[OOF])
+ #line(length: 100%)
+ #place(dy: -0.8em)[OOF]
+ #rect(height: 2cm, fill: gray)
+]
diff --git a/tests/suite/layout/flow/orphan.typ b/tests/suite/layout/flow/orphan.typ
new file mode 100644
index 00000000..70eac731
--- /dev/null
+++ b/tests/suite/layout/flow/orphan.typ
@@ -0,0 +1,31 @@
+// Test that lines and headings doesn't become orphan.
+
+--- flow-heading-no-orphan ---
+#set page(height: 100pt)
+#lorem(12)
+
+= Introduction
+This is the start and it goes on.
+
+--- flow-par-no-orphan-and-widow-lines ---
+// LARGE
+#set page("a8", height: 140pt)
+#set text(weight: 700)
+
+// Fits fully onto the first page.
+#set text(blue)
+#lorem(27)
+
+// The first line would fit, but is moved to the second page.
+#lorem(20)
+
+// The second-to-last line is moved to the third page so that the last is isn't
+// as lonely.
+#set text(maroon)
+#lorem(11)
+
+#lorem(13)
+
+// All three lines go to the next page.
+#set text(olive)
+#lorem(10)
diff --git a/tests/suite/layout/grid/cell.typ b/tests/suite/layout/grid/cell.typ
new file mode 100644
index 00000000..3b08c752
--- /dev/null
+++ b/tests/suite/layout/grid/cell.typ
@@ -0,0 +1,132 @@
+// Test basic styling using the grid.cell element.
+
+--- grid-cell-override ---
+// Cell override
+#grid(
+ align: left,
+ fill: red,
+ stroke: blue,
+ inset: 5pt,
+ columns: 2,
+ [AAAAA], [BBBBB],
+ [A], [B],
+ grid.cell(align: right)[C], [D],
+ align(right)[E], [F],
+ align(horizon)[G], [A\ A\ A],
+ grid.cell(align: horizon)[G2], [A\ A\ A],
+ grid.cell(inset: 0pt)[I], [F],
+ [H], grid.cell(fill: blue)[J]
+)
+
+--- grid-cell-show ---
+// Cell show rule
+#show grid.cell: it => [Zz]
+
+#grid(
+ align: left,
+ fill: red,
+ stroke: blue,
+ inset: 5pt,
+ columns: 2,
+ [AAAAA], [BBBBB],
+ [A], [B],
+ grid.cell(align: right)[C], [D],
+ align(right)[E], [F],
+ align(horizon)[G], [A\ A\ A]
+)
+
+--- grid-cell-show-and-override ---
+#show grid.cell: it => (it.align, it.fill)
+#grid(
+ align: left,
+ row-gutter: 5pt,
+ [A],
+ grid.cell(align: right)[B],
+ grid.cell(fill: aqua)[B],
+)
+
+--- grid-cell-set ---
+// Cell set rules
+#set grid.cell(align: center)
+#show grid.cell: it => (it.align, it.fill, it.inset)
+#set grid.cell(inset: 20pt)
+#grid(
+ align: left,
+ row-gutter: 5pt,
+ [A],
+ grid.cell(align: right)[B],
+ grid.cell(fill: aqua)[B],
+)
+
+--- grid-cell-folding ---
+// Test folding per-cell properties (align and inset)
+#grid(
+ columns: (1fr, 1fr),
+ rows: (2.5em, auto),
+ align: right,
+ inset: 5pt,
+ fill: (x, y) => (green, aqua).at(calc.rem(x + y, 2)),
+ [Top], grid.cell(align: bottom)[Bot],
+ grid.cell(inset: (bottom: 0pt))[Bot], grid.cell(inset: (bottom: 0pt))[Bot]
+)
+
+--- grid-cell-align-override ---
+// Test overriding outside alignment
+#set align(bottom + right)
+#grid(
+ columns: (1fr, 1fr),
+ rows: 2em,
+ align: auto,
+ fill: green,
+ [BR], [BR],
+ grid.cell(align: left, fill: aqua)[BL], grid.cell(align: top, fill: red.lighten(50%))[TR]
+)
+
+--- grid-cell-various-overrides ---
+#grid(
+ columns: 2,
+ fill: red,
+ align: left,
+ inset: 5pt,
+ [ABC], [ABC],
+ grid.cell(fill: blue)[C], [D],
+ grid.cell(align: center)[E], [F],
+ [G], grid.cell(inset: 0pt)[H]
+)
+
+--- grid-cell-show-emph ---
+#{
+ show grid.cell: emph
+ grid(
+ columns: 2,
+ gutter: 3pt,
+ [Hello], [World],
+ [Sweet], [Italics]
+ )
+}
+
+--- grid-cell-show-based-on-position ---
+// Style based on position
+#{
+ show grid.cell: it => {
+ if it.y == 0 {
+ strong(it)
+ } else if it.x == 1 {
+ emph(it)
+ } else {
+ it
+ }
+ }
+ grid(
+ columns: 3,
+ gutter: 3pt,
+ [Name], [Age], [Info],
+ [John], [52], [Nice],
+ [Mary], [50], [Cool],
+ [Jake], [49], [Epic]
+ )
+}
+
+--- table-cell-in-grid ---
+// Error: 7-19 cannot use `table.cell` as a grid cell; use `grid.cell` instead
+#grid(table.cell[])
diff --git a/tests/suite/layout/grid/colspan.typ b/tests/suite/layout/grid/colspan.typ
new file mode 100644
index 00000000..707a9456
--- /dev/null
+++ b/tests/suite/layout/grid/colspan.typ
@@ -0,0 +1,142 @@
+--- grid-colspan ---
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ grid.cell(colspan: 4)[*Full Header*],
+ grid.cell(colspan: 2, fill: orange)[*Half*],
+ grid.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], grid.cell(colspan: 3, fill: orange.darken(10%))[6],
+ grid.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], grid.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ table.cell(colspan: 4)[*Full Header*],
+ table.cell(colspan: 2, fill: orange)[*Half*],
+ table.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], table.cell(colspan: 3, fill: orange.darken(10%))[6],
+ table.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], table.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+--- grid-colspan-gutter ---
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ grid.cell(colspan: 4)[*Full Header*],
+ grid.cell(colspan: 2, fill: orange)[*Half*],
+ grid.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], grid.cell(colspan: 3, fill: orange.darken(10%))[6],
+ grid.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], grid.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ table.cell(colspan: 4)[*Full Header*],
+ table.cell(colspan: 2, fill: orange)[*Half*],
+ table.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], table.cell(colspan: 3, fill: orange.darken(10%))[6],
+ table.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], table.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+--- grid-colspan-thick-stroke ---
+#set page(width: 300pt)
+#table(
+ columns: (2em, 2em, auto, auto),
+ stroke: 5pt,
+ [A], [B], [C], [D],
+ table.cell(colspan: 4, lorem(20)),
+ [A], table.cell(colspan: 2)[BCBCBCBC], [D]
+)
+
+--- grid-colspan-out-of-bounds ---
+// Error: 3:8-3:32 cell's colspan would cause it to exceed the available column(s)
+// Hint: 3:8-3:32 try placing the cell in another position or reducing its colspan
+#grid(
+ columns: 3,
+ [a], grid.cell(colspan: 3)[b]
+)
+
+--- grid-colspan-overlap ---
+// Error: 4:8-4:32 cell would span a previously placed cell at column 2, row 0
+// Hint: 4:8-4:32 try specifying your cells in a different order or reducing the cell's rowspan or colspan
+#grid(
+ columns: 3,
+ grid.cell(x: 2, y: 0)[x],
+ [a], grid.cell(colspan: 2)[b]
+)
+
+--- grid-colspan-over-all-fr-columns ---
+// Colspan over all fractional columns shouldn't expand auto columns on finite pages
+#table(
+ columns: (1fr, 1fr, auto),
+ [A], [B], [C],
+ [D], [E], [F]
+)
+#table(
+ columns: (1fr, 1fr, auto),
+ table.cell(colspan: 3, lorem(8)),
+ [A], [B], [C],
+ [D], [E], [F]
+)
+
+--- grid-colspan-over-some-fr-columns ---
+// Colspan over only some fractional columns will not trigger the heuristic, and
+// the auto column will expand more than it should. The table looks off, as a result.
+#table(
+ columns: (1fr, 1fr, auto),
+ [], table.cell(colspan: 2, lorem(8)),
+ [A], [B], [C],
+ [D], [E], [F]
+)
+
+--- grid-colspan-over-all-fr-columns-page-width-auto ---
+// On infinite pages, colspan over all fractional columns SHOULD expand auto columns
+#set page(width: auto)
+#table(
+ columns: (1fr, 1fr, auto),
+ [A], [B], [C],
+ [D], [E], [F]
+)
+#table(
+ columns: (1fr, 1fr, auto),
+ table.cell(colspan: 3, lorem(8)),
+ [A], [B], [C],
+ [D], [E], [F]
+)
+
+--- grid-colspan-multiple-regions ---
+// Test multiple regions
+#set page(height: 5em)
+#grid(
+ stroke: red,
+ fill: aqua,
+ columns: 4,
+ [a], [b], [c], [d],
+ [a], grid.cell(colspan: 2)[e, f, g, h, i], [f],
+ [e], [g], grid.cell(colspan: 2)[eee\ e\ e\ e],
+ grid.cell(colspan: 4)[eeee e e e]
+)
diff --git a/tests/suite/layout/grid/footers.typ b/tests/suite/layout/grid/footers.typ
new file mode 100644
index 00000000..c73bcb39
--- /dev/null
+++ b/tests/suite/layout/grid/footers.typ
@@ -0,0 +1,404 @@
+--- grid-footer ---
+#set page(width: auto, height: 15em)
+#set text(6pt)
+#set table(inset: 2pt, stroke: 0.5pt)
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..range(0, 5).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.footer(
+ table.hline(start: 2, end: 3, stroke: yellow),
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.cell(colspan: 5)[*Cool Zone*]
+ )
+)
+
+--- grid-footer-gutter-and-no-repeat ---
+// Gutter & no repetition
+#set page(width: auto, height: 16em)
+#set text(6pt)
+#set table(inset: 2pt, stroke: 0.5pt)
+#table(
+ columns: 5,
+ gutter: 2pt,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..range(0, 5).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.footer(
+ repeat: false,
+ table.hline(start: 2, end: 3, stroke: yellow),
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.cell(colspan: 5)[*Cool Zone*]
+ )
+)
+
+--- grid-cell-override-in-header-and-footer ---
+#table(
+ table.header(table.cell(stroke: red)[Hello]),
+ table.footer(table.cell(stroke: aqua)[Bye]),
+)
+
+--- grid-cell-override-in-header-and-footer-with-gutter ---
+#table(
+ gutter: 3pt,
+ table.header(table.cell(stroke: red)[Hello]),
+ table.footer(table.cell(stroke: aqua)[Bye]),
+)
+
+--- grid-footer-top-stroke ---
+// Footer's top stroke should win when repeated, but lose at the last page.
+#set page(height: 10em)
+#table(
+ stroke: green,
+ table.header(table.cell(stroke: red)[Hello]),
+ table.cell(stroke: yellow)[Hi],
+ table.cell(stroke: yellow)[Bye],
+ table.cell(stroke: yellow)[Ok],
+ table.footer[Bye],
+)
+
+--- grid-footer-relative-row-sizes ---
+// Relative lengths
+#set page(height: 10em)
+#table(
+ rows: (30%, 30%, auto),
+ [C],
+ [C],
+ table.footer[*A*][*B*],
+)
+
+--- grid-footer-cell-with-y ---
+#grid(
+ grid.footer(grid.cell(y: 2)[b]),
+ grid.cell(y: 0)[a],
+ grid.cell(y: 1)[c],
+)
+
+--- grid-footer-expand ---
+// Ensure footer properly expands
+#grid(
+ columns: 2,
+ [a], [],
+ [b], [],
+ grid.cell(x: 1, y: 3, rowspan: 4)[b],
+ grid.cell(y: 2, rowspan: 2)[a],
+ grid.footer(),
+ grid.cell(y: 4)[d],
+ grid.cell(y: 5)[e],
+ grid.cell(y: 6)[f],
+)
+
+--- grid-footer-not-at-last-row ---
+// Error: 2:3-2:19 footer must end at the last row
+#grid(
+ grid.footer([a]),
+ [b],
+)
+
+--- grid-footer-not-at-last-row-two-columns ---
+// Error: 3:3-3:19 footer must end at the last row
+#grid(
+ columns: 2,
+ grid.footer([a]),
+ [b],
+)
+
+--- grid-footer-overlap ---
+// Error: 4:3-4:19 footer would conflict with a cell placed before it at column 1 row 0
+// Hint: 4:3-4:19 try reducing that cell's rowspan or moving the footer
+#grid(
+ columns: 2,
+ grid.header(),
+ grid.footer([a]),
+ grid.cell(x: 1, y: 0, rowspan: 2)[a],
+)
+
+--- grid-footer-multiple ---
+// Error: 4:3-4:19 cannot have more than one footer
+#grid(
+ [a],
+ grid.footer([a]),
+ grid.footer([b]),
+)
+
+--- table-footer-in-grid ---
+// Error: 3:3-3:20 cannot use `table.footer` as a grid footer; use `grid.footer` instead
+#grid(
+ [a],
+ table.footer([a]),
+)
+
+--- grid-footer-in-table ---
+// Error: 3:3-3:19 cannot use `grid.footer` as a table footer; use `table.footer` instead
+#table(
+ [a],
+ grid.footer([a]),
+)
+
+--- grid-footer-in-grid-header ---
+// Error: 14-28 cannot place a grid footer within another footer or header
+#grid.header(grid.footer[a])
+
+--- table-footer-in-grid-header ---
+// Error: 14-29 cannot place a table footer within another footer or header
+#grid.header(table.footer[a])
+
+--- grid-footer-in-table-header ---
+// Error: 15-29 cannot place a grid footer within another footer or header
+#table.header(grid.footer[a])
+
+--- table-footer-in-table-header ---
+// Error: 15-30 cannot place a table footer within another footer or header
+#table.header(table.footer[a])
+
+--- grid-footer-in-grid-footer ---
+// Error: 14-28 cannot place a grid footer within another footer or header
+#grid.footer(grid.footer[a])
+
+--- table-footer-in-grid-footer ---
+// Error: 14-29 cannot place a table footer within another footer or header
+#grid.footer(table.footer[a])
+
+--- grid-footer-in-table-footer ---
+// Error: 15-29 cannot place a grid footer within another footer or header
+#table.footer(grid.footer[a])
+
+--- table-footer-in-table-footer ---
+// Error: 15-30 cannot place a table footer within another footer or header
+#table.footer(table.footer[a])
+
+--- grid-header-in-grid-footer ---
+// Error: 14-28 cannot place a grid header within another header or footer
+#grid.footer(grid.header[a])
+
+--- table-header-in-grid-footer ---
+// Error: 14-29 cannot place a table header within another header or footer
+#grid.footer(table.header[a])
+
+--- grid-header-in-table-footer ---
+// Error: 15-29 cannot place a grid header within another header or footer
+#table.footer(grid.header[a])
+
+--- table-header-in-table-footer ---
+// Error: 15-30 cannot place a table header within another header or footer
+#table.footer(table.header[a])
+
+--- grid-header-footer-block-with-fixed-height ---
+#set page(height: 17em)
+#table(
+ rows: (auto, 2.5em, auto),
+ table.header[*Hello*][*World*],
+ block(width: 2em, height: 10em, fill: red),
+ table.footer[*Bye*][*World*],
+)
+
+--- grid-header-footer-and-rowspan-non-contiguous-1 ---
+// Rowspan sizing algorithm doesn't do the best job at non-contiguous content
+// ATM.
+#set page(height: 20em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto, 5em, 2em, 2.5em),
+ table.header[*Hello*][*World*],
+ table.cell(rowspan: 3, lorem(20)),
+ table.footer[*Ok*][*Bye*],
+)
+
+--- grid-header-footer-and-rowspan-non-contiguous-2 ---
+// This should look right
+#set page(height: 20em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto),
+ gutter: 3pt,
+ table.header[*Hello*][*World*],
+ table.cell(rowspan: 3, lorem(20)),
+ table.footer[*Ok*][*Bye*],
+)
+--- grid-header-and-footer-lack-of-space ---
+// Test lack of space for header + text.
+#set page(height: 9em + 2.5em + 1.5em)
+
+#table(
+ rows: (auto, 2.5em, auto, auto, 10em, 2.5em, auto),
+ gutter: 3pt,
+ table.header[*Hello*][*World*],
+ table.cell(rowspan: 3, lorem(30)),
+ table.footer[*Ok*][*Bye*],
+)
+
+--- grid-header-and-footer-orphan-prevention ---
+// Orphan header prevention test
+#set page(height: 13em)
+#v(8em)
+#grid(
+ columns: 3,
+ gutter: 5pt,
+ grid.header(
+ [*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
+ [*Header*], [*Header* #v(0.1em)],
+ ),
+ ..([Test], [Test], [Test]) * 7,
+ grid.footer(
+ [*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
+ [*Footer*], [*Footer* #v(0.1em)],
+ ),
+)
+
+--- grid-header-and-footer-empty ---
+// Empty footer should just be a repeated blank row
+#set page(height: 8em)
+#table(
+ columns: 4,
+ align: center + horizon,
+ table.header(),
+ ..range(0, 2).map(i => (
+ [John \##i],
+ table.cell(stroke: green)[123],
+ table.cell(stroke: blue)[456],
+ [789]
+ )).flatten(),
+ table.footer(),
+)
+
+--- grid-header-and-footer-containing-rowspan ---
+// When a footer has a rowspan with an empty row, it should be displayed
+// properly
+#set page(height: 14em, width: auto)
+
+#let count = counter("g")
+#table(
+ rows: (auto, 2em, auto, auto),
+ table.header(
+ [eeec],
+ table.cell(rowspan: 2, count.step() + count.display()),
+ ),
+ [d],
+ block(width: 5em, fill: yellow, lorem(7)),
+ [d],
+ table.footer(
+ [eeec],
+ table.cell(rowspan: 2, count.step() + count.display()),
+ )
+)
+#count.display()
+
+--- grid-nested-with-footers ---
+// Nested table with footer should repeat both footers
+#set page(height: 10em, width: auto)
+#table(
+ table(
+ [a\ b\ c\ d],
+ table.footer[b],
+ ),
+ table.footer[a],
+)
+
+--- grid-nested-footers ---
+#set page(height: 12em, width: auto)
+#table(
+ [a\ b\ c\ d],
+ table.footer(table(
+ [c],
+ [d],
+ table.footer[b],
+ ))
+)
+
+--- grid-footer-rowspan ---
+// General footer-only tests
+#set page(height: 9em)
+#table(
+ columns: 2,
+ [a], [],
+ [b], [],
+ [c], [],
+ [d], [],
+ [e], [],
+ table.footer(
+ [*Ok*], table.cell(rowspan: 2)[test],
+ [*Thanks*]
+ )
+)
+
+--- grid-footer-bare-1 ---
+#set page(height: 5em)
+#table(
+ table.footer[a][b][c]
+)
+
+--- grid-footer-bare-2 ---
+#table(table.footer[a][b][c])
+
+#table(
+ gutter: 3pt,
+ table.footer[a][b][c]
+)
+
+--- grid-footer-stroke-edge-cases ---
+// Test footer stroke priority edge case
+#set page(height: 10em)
+#table(
+ columns: 2,
+ stroke: black,
+ ..(table.cell(stroke: aqua)[d],) * 8,
+ table.footer(
+ table.cell(rowspan: 2, colspan: 2)[a],
+ [c], [d]
+ )
+)
+
+--- grid-footer-hline-and-vline-1 ---
+// Footer should appear at the bottom. Red line should be above the footer.
+// Green line should be on the left border.
+#set page(margin: 2pt)
+#set text(6pt)
+#table(
+ columns: 2,
+ inset: 1.5pt,
+ table.cell(y: 0)[a],
+ table.cell(x: 1, y: 1)[a],
+ table.cell(y: 2)[a],
+ table.footer(
+ table.hline(stroke: red),
+ table.vline(stroke: green),
+ [b],
+ ),
+ table.cell(x: 1, y: 3)[c]
+)
+
+--- grid-footer-hline-and-vline-2 ---
+// Table should be just one row. [c] appears at the third column.
+#set page(margin: 2pt)
+#set text(6pt)
+#table(
+ columns: 3,
+ inset: 1.5pt,
+ table.cell(y: 0)[a],
+ table.footer(
+ table.hline(stroke: red),
+ table.hline(y: 1, stroke: aqua),
+ table.cell(y: 0)[b],
+ [c]
+ )
+)
+
+--- grid-footer-below-rowspans ---
+// Footer should go below the rowspans.
+#set page(margin: 2pt)
+#set text(6pt)
+#table(
+ columns: 2,
+ inset: 1.5pt,
+ table.cell(rowspan: 2)[a], table.cell(rowspan: 2)[b],
+ table.footer()
+)
diff --git a/tests/suite/layout/grid/grid.typ b/tests/suite/layout/grid/grid.typ
new file mode 100644
index 00000000..f4f0b90a
--- /dev/null
+++ b/tests/suite/layout/grid/grid.typ
@@ -0,0 +1,276 @@
+// Test grid layouts.
+
+--- grid-columns-sizings-rect ---
+#let cell(width, color) = rect(width: width, height: 2cm, fill: color)
+#set page(width: 100pt, height: 140pt)
+#grid(
+ columns: (auto, 1fr, 3fr, 0.25cm, 3%, 2mm + 10%),
+ cell(0.5cm, rgb("2a631a")),
+ cell(100%, forest),
+ cell(100%, conifer),
+ cell(100%, rgb("ff0000")),
+ cell(100%, rgb("00ff00")),
+ cell(80%, rgb("00faf0")),
+ cell(1cm, rgb("00ff00")),
+ cell(0.5cm, rgb("2a631a")),
+ cell(100%, forest),
+ cell(100%, conifer),
+ cell(100%, rgb("ff0000")),
+ cell(100%, rgb("00ff00")),
+)
+
+--- grid-gutter-fr ---
+#set rect(inset: 0pt)
+#grid(
+ columns: (auto, auto, 40%),
+ column-gutter: 1fr,
+ row-gutter: 1fr,
+ rect(fill: eastern)[dddaa aaa aaa],
+ rect(fill: conifer)[ccc],
+ rect(fill: rgb("dddddd"))[aaa],
+)
+
+--- grid-row-sizing-manual-align ---
+#set page(height: 3cm, margin: 0pt)
+#grid(
+ columns: (1fr,),
+ rows: (1fr, auto, 2fr),
+ [],
+ align(center)[A bit more to the top],
+ [],
+)
+
+--- grid-finance ---
+// Test using the `grid` function to create a finance table.
+#set page(width: 11cm, height: 2.5cm)
+#grid(
+ columns: 5,
+ column-gutter: (2fr, 1fr, 1fr),
+ row-gutter: 6pt,
+ [*Quarter*],
+ [Expenditure],
+ [External Revenue],
+ [Financial ROI],
+ [_total_],
+ [*Q1*],
+ [173,472.57 \$],
+ [472,860.91 \$],
+ [51,286.84 \$],
+ [_350,675.18 \$_],
+ [*Q2*],
+ [93,382.12 \$],
+ [439,382.85 \$],
+ [-1,134.30 \$],
+ [_344,866.43 \$_],
+ [*Q3*],
+ [96,421.49 \$],
+ [238,583.54 \$],
+ [3,497.12 \$],
+ [_145,659.17 \$_],
+)
+// Test grid cells that overflow to the next region.
+
+--- grid-cell-breaking ---
+#set page(width: 5cm, height: 3cm)
+#grid(
+ columns: 2,
+ row-gutter: 8pt,
+ [Lorem ipsum dolor sit amet.
+
+ Aenean commodo ligula eget dolor. Aenean massa. Penatibus et magnis.],
+ [Text that is rather short],
+ [Fireflies],
+ [Critical],
+ [Decorum],
+ [Rampage],
+)
+
+--- grid-consecutive-rows-breaking ---
+// Test a column that starts overflowing right after another row/column did
+// that.
+#set page(width: 5cm, height: 2cm)
+#grid(
+ columns: 4 * (1fr,),
+ row-gutter: 10pt,
+ column-gutter: (0pt, 10%),
+ align(top, image("/assets/images/rhino.png")),
+ align(top, rect(inset: 0pt, fill: eastern, align(right)[LoL])),
+ [rofl],
+ [\ A] * 3,
+ [Ha!\ ] * 3,
+)
+
+--- grid-same-row-multiple-columns-breaking ---
+// Test two columns in the same row overflowing by a different amount.
+#set page(width: 5cm, height: 2cm)
+#grid(
+ columns: 3 * (1fr,),
+ row-gutter: 8pt,
+ column-gutter: (0pt, 10%),
+ [A], [B], [C],
+ [Ha!\ ] * 6,
+ [rofl],
+ [\ A] * 3,
+ [hello],
+ [darkness],
+ [my old]
+)
+
+--- grid-nested-breaking ---
+// Test grid within a grid, overflowing.
+#set page(width: 5cm, height: 2.25cm)
+#grid(
+ columns: 4 * (1fr,),
+ row-gutter: 10pt,
+ column-gutter: (0pt, 10%),
+ [A], [B], [C], [D],
+ grid(columns: 2, [A], [B], [C\ ]*3, [D]),
+ align(top, rect(inset: 0pt, fill: eastern, align(right)[LoL])),
+ [rofl],
+ [E\ ]*4,
+)
+
+--- grid-column-sizing-auto-base ---
+// Test that auto and relative columns use the correct base.
+#grid(
+ columns: (auto, 60%),
+ rows: (auto, auto),
+ rect(width: 50%, height: 0.5cm, fill: conifer),
+ rect(width: 100%, height: 0.5cm, fill: eastern),
+ rect(width: 50%, height: 0.5cm, fill: forest),
+)
+
+--- grid-column-sizing-fr-base ---
+// Test that fr columns use the correct base.
+#grid(
+ columns: (1fr,) * 4,
+ rows: (1cm,),
+ rect(width: 50%, fill: conifer),
+ rect(width: 50%, fill: forest),
+ rect(width: 50%, fill: conifer),
+ rect(width: 50%, fill: forest),
+)
+
+--- grid-column-sizing-mixed-base ---
+// Test that all three kinds of rows use the correct bases.
+#set page(height: 4cm, margin: 0cm)
+#grid(
+ rows: (1cm, 1fr, 1fr, auto),
+ rect(height: 50%, width: 100%, fill: conifer),
+ rect(height: 50%, width: 100%, fill: forest),
+ rect(height: 50%, width: 100%, fill: conifer),
+ rect(height: 25%, width: 100%, fill: forest),
+)
+
+--- grid-trailing-linebreak-region-overflow ---
+// Test that trailing linebreak doesn't overflow the region.
+#set page(height: 2cm)
+#grid[
+ Hello \
+ Hello \
+ Hello \
+
+ World
+]
+
+--- grid-breaking-expand-vertically ---
+// Test that broken cell expands vertically.
+#set page(height: 2.25cm)
+#grid(
+ columns: 2,
+ gutter: 10pt,
+ align(bottom)[A],
+ [
+ Top
+ #align(bottom)[
+ Bottom \
+ Bottom \
+ #v(0pt)
+ Top
+ ]
+ ],
+ align(top)[B],
+)
+
+--- grid-complete-rows ---
+// Ensure grids expand enough for the given rows.
+#grid(
+ columns: (2em, 2em),
+ rows: (2em,) * 4,
+ fill: red,
+ stroke: aqua,
+ [a]
+)
+
+--- grid-auto-shrink ---
+// Test iterative auto column shrinking.
+#set page(width: 210mm - 2 * 2.5cm + 2 * 10pt)
+#set text(11pt)
+#table(
+ columns: 4,
+ [Hello!],
+ [Hello there, my friend!],
+ [Hello there, my friends! Hi!],
+ [Hello there, my friends! Hi! What is going on right now?],
+)
+
+--- issue-grid-base-auto-row ---
+// Test that grid base for auto rows makes sense.
+#set page(height: 150pt)
+#table(
+ columns: (1.5cm, auto),
+ rows: (auto, auto),
+ rect(width: 100%, fill: red),
+ rect(width: 100%, fill: blue),
+ rect(width: 100%, height: 50%, fill: green),
+)
+
+--- issue-grid-base-auto-row-list ---
+#rect(width: 100%, height: 1em)
+- #rect(width: 100%, height: 1em)
+ - #rect(width: 100%, height: 1em)
+
+--- issue-grid-skip ---
+// Grid now skips a remaining region when one of the cells
+// doesn't fit into it at all.
+#set page(height: 100pt)
+#grid(
+ columns: (2cm, auto),
+ rows: (auto, auto),
+ rect(width: 100%, fill: red),
+ rect(width: 100%, fill: blue),
+ rect(width: 100%, height: 80%, fill: green),
+ [hello \ darkness #parbreak() my \ old \ friend \ I],
+ rect(width: 100%, height: 20%, fill: blue),
+ polygon(fill: red, (0%, 0%), (100%, 0%), (100%, 20%))
+)
+
+--- issue-grid-skip-list ---
+#set page(height: 60pt)
+#lorem(5)
+- #lorem(5)
+
+--- issue-grid-double-skip ---
+// Ensure that the list does not jump to the third page.
+#set page(height: 70pt)
+#v(40pt)
+The following:
++ A
++ B
+
+--- issue-grid-gutter-skip ---
+// Ensure gutter rows at the top or bottom of a region are skipped.
+#set page(height: 10em)
+
+#table(
+ row-gutter: 1.5em,
+ inset: 0pt,
+ rows: (1fr, auto),
+ [a],
+ [],
+ [],
+ [f],
+ [e\ e],
+ [],
+ [a]
+)
diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ
new file mode 100644
index 00000000..b9a90461
--- /dev/null
+++ b/tests/suite/layout/grid/headers.typ
@@ -0,0 +1,368 @@
+--- grid-headers ---
+#set page(width: auto, height: 12em)
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..range(0, 6).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+)
+
+--- grid-headers-no-repeat ---
+// Disable repetition
+#set page(width: auto, height: 12em)
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow),
+ repeat: false
+ ),
+ ..range(0, 6).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+)
+
+--- grid-headers-gutter ---
+#set page(width: auto, height: 12em)
+#table(
+ columns: 5,
+ align: center + horizon,
+ gutter: 3pt,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow),
+ ),
+ ..range(0, 6).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten()
+)
+
+--- grid-header-relative-row-sizes ---
+// Relative lengths
+#set page(height: 10em)
+#table(
+ rows: (30%, 30%, auto),
+ table.header(
+ [*A*],
+ [*B*]
+ ),
+ [C],
+ [C]
+)
+
+--- grid-header-cell-with-y ---
+#grid(
+ grid.cell(y: 1)[a],
+ grid.header(grid.cell(y: 0)[b]),
+ grid.cell(y: 2)[c]
+)
+
+--- grid-header-last-child ---
+// When the header is the last grid child, it shouldn't include the gutter row
+// after it, because there is none.
+#grid(
+ columns: 2,
+ gutter: 3pt,
+ grid.header(
+ [a], [b],
+ [c], [d]
+ )
+)
+
+--- grid-header-nested ---
+#set page(height: 14em)
+#let t(n) = table(
+ columns: 3,
+ align: center + horizon,
+ gutter: 3pt,
+ table.header(
+ table.cell(colspan: 3)[*Cool Zone #n*],
+ [*Name*], [*Num*], [*Data*]
+ ),
+ ..range(0, 5).map(i => ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456])).flatten()
+)
+#grid(
+ gutter: 3pt,
+ t(0),
+ t(1)
+)
+
+--- grid-header-hline-and-vline ---
+// Test line positioning in header
+#table(
+ columns: 3,
+ stroke: none,
+ table.hline(stroke: red, end: 2),
+ table.vline(stroke: red, end: 3),
+ table.header(
+ table.hline(stroke: aqua, start: 2),
+ table.vline(stroke: aqua, start: 3), [*A*], table.hline(stroke: orange), table.vline(stroke: orange), [*B*],
+ [*C*], [*D*]
+ ),
+ [a], [b],
+ [c], [d],
+ [e], [f]
+)
+
+--- 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],
+ grid.header([b])
+)
+
+--- grow-header-multiple ---
+// Error: 3:3-3:19 cannot have more than one header
+#grid(
+ grid.header([a]),
+ grid.header([b]),
+ [a],
+)
+
+--- table-header-in-grid ---
+// Error: 2:3-2:20 cannot use `table.header` as a grid header; use `grid.header` instead
+#grid(
+ table.header([a]),
+ [a],
+)
+
+--- grid-header-in-table ---
+// Error: 2:3-2:19 cannot use `grid.header` as a table header; use `table.header` instead
+#table(
+ grid.header([a]),
+ [a],
+)
+
+--- grid-header-in-grid-header ---
+// Error: 14-28 cannot place a grid header within another header or footer
+#grid.header(grid.header[a])
+
+--- table-header-in-grid-header ---
+// Error: 14-29 cannot place a table header within another header or footer
+#grid.header(table.header[a])
+
+--- grid-header-in-table-header ---
+// Error: 15-29 cannot place a grid header within another header or footer
+#table.header(grid.header[a])
+
+--- table-header-in-table-header ---
+// Error: 15-30 cannot place a table header within another header or footer
+#table.header(table.header[a])
+
+--- grid-header-block-with-fixed-height ---
+#set page(height: 15em)
+#table(
+ rows: (auto, 2.5em, auto),
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ block(width: 2em, height: 20em, fill: red)
+)
+
+--- grid-header-and-rowspan-non-contiguous-1 ---
+// Rowspan sizing algorithm doesn't do the best job at non-contiguous content
+// ATM.
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto, 5em),
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(40))
+)
+
+--- grid-header-and-rowspan-non-contiguous-2 ---
+// Rowspan sizing algorithm doesn't do the best job at non-contiguous content
+// ATM.
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto, 5em),
+ gutter: 3pt,
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(40))
+)
+
+--- grid-header-and-rowspan-non-contiguous-3 ---
+// This should look right
+#set page(height: 15em)
+
+#table(
+ rows: (auto, 2.5em, 2em, auto),
+ gutter: 3pt,
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(40))
+)
+
+--- grid-header-lack-of-space ---
+// Test lack of space for header + text.
+#set page(height: 9em)
+
+#table(
+ rows: (auto, 2.5em, auto, auto, 10em),
+ gutter: 3pt,
+ table.header(
+ [*Hello*],
+ [*World*]
+ ),
+ table.cell(rowspan: 3, lorem(80))
+)
+
+--- grid-header-orphan-prevention ---
+// Orphan header prevention test
+#set page(height: 12em)
+#v(8em)
+#grid(
+ columns: 3,
+ grid.header(
+ [*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
+ [*Header*], [*Header* #v(0.1em)]
+ ),
+ ..([Test], [Test], [Test]) * 20
+)
+
+--- grid-header-empty ---
+// Empty header should just be a repeated blank row
+#set page(height: 12em)
+#table(
+ columns: 4,
+ align: center + horizon,
+ table.header(),
+ ..range(0, 4).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789])).flatten()
+)
+
+--- grid-header-containing-rowspan ---
+// When a header has a rowspan with an empty row, it should be displayed
+// properly
+#set page(height: 10em)
+
+#let count = counter("g")
+#table(
+ rows: (auto, 2em, auto, auto),
+ table.header(
+ [eeec],
+ table.cell(rowspan: 2, count.step() + count.display()),
+ ),
+ [d],
+ block(width: 5em, fill: yellow, lorem(15)),
+ [d]
+)
+#count.display()
+
+--- grid-header-expand ---
+// Ensure header expands to fit cell placed in it after its declaration
+#set page(height: 10em)
+#table(
+ columns: 2,
+ table.header(
+ [a], [b],
+ [c],
+ ),
+ table.cell(x: 1, y: 1, rowspan: 2, lorem(80))
+)
+
+--- grid-nested-with-headers ---
+// Nested table with header should repeat both headers
+#set page(height: 10em)
+#table(
+ table.header(
+ [a]
+ ),
+ table(
+ table.header(
+ [b]
+ ),
+ [a\ b\ c\ d]
+ )
+)
+
+--- grid-nested-headers ---
+#set page(height: 12em)
+#table(
+ table.header(
+ table(
+ table.header(
+ [b]
+ ),
+ [c],
+ [d]
+ )
+ ),
+ [a\ b]
+)
+
+--- grid-header-stroke-edge-cases ---
+// Test header stroke priority edge case (last header row removed)
+#set page(height: 8em)
+#table(
+ columns: 2,
+ stroke: black,
+ gutter: (auto, 3pt),
+ table.header(
+ [c], [d],
+ ),
+ ..(table.cell(stroke: aqua)[d],) * 8,
+)
+
+--- grid-header-hline-bottom ---
+// Yellow line should be kept here
+#set text(6pt)
+#table(
+ column-gutter: 3pt,
+ inset: 1pt,
+ table.header(
+ [a],
+ table.hline(stroke: yellow),
+ ),
+ table.cell(rowspan: 2)[b]
+)
+
+--- grid-header-hline-bottom-manually ---
+// Red line should be kept here
+#set page(height: 6em)
+#set text(6pt)
+#table(
+ column-gutter: 3pt,
+ inset: 1pt,
+ table.header(
+ table.hline(stroke: red, position: bottom),
+ [a],
+ ),
+ [a],
+ table.cell(stroke: aqua)[b]
+)
+
+--- grid-header-rowspan-base ---
+#set page(height: 7em)
+#set text(6pt)
+#let full-block = block(width: 2em, height: 100%, fill: red)
+#table(
+ columns: 3,
+ inset: 1.5pt,
+ table.header(
+ [a], full-block, table.cell(rowspan: 2, full-block),
+ [b]
+ )
+)
diff --git a/tests/suite/layout/grid/positioning.typ b/tests/suite/layout/grid/positioning.typ
new file mode 100644
index 00000000..31896d99
--- /dev/null
+++ b/tests/suite/layout/grid/positioning.typ
@@ -0,0 +1,203 @@
+// Test cell positioning in grids.
+
+--- grid-cell-show-x-y ---
+#{
+ show grid.cell: it => (it.x, it.y)
+ grid(
+ columns: 2,
+ inset: 5pt,
+ fill: aqua,
+ gutter: 3pt,
+ [Hello], [World],
+ [Sweet], [Home]
+ )
+}
+#{
+ show table.cell: it => pad(rest: it.inset)[#(it.x, it.y)]
+ table(
+ columns: 2,
+ gutter: 3pt,
+ [Hello], [World],
+ [Sweet], [Home]
+ )
+}
+
+--- grid-cell-position-out-of-order ---
+// Positioning cells in a different order than they appear
+#grid(
+ columns: 2,
+ [A], [B],
+ grid.cell(x: 1, y: 2)[C], grid.cell(x: 0, y: 2)[D],
+ grid.cell(x: 1, y: 1)[E], grid.cell(x: 0, y: 1)[F],
+)
+
+--- grid-cell-position-extra-rows ---
+// Creating more rows by positioning out of bounds
+#grid(
+ columns: 3,
+ rows: 1.5em,
+ inset: 5pt,
+ fill: (x, y) => if (x, y) == (0, 0) { blue } else if (x, y) == (2, 3) { red } else { green },
+ [A],
+ grid.cell(x: 2, y: 3)[B]
+)
+
+#table(
+ columns: (3em, 1em, 3em),
+ rows: 1.5em,
+ inset: (top: 0pt, bottom: 0pt, rest: 5pt),
+ fill: (x, y) => if (x, y) == (0, 0) { blue } else if (x, y) == (2, 3) { red } else { green },
+ align: (x, y) => (left, center, right).at(x),
+ [A],
+ table.cell(x: 2, y: 3)[B]
+)
+
+--- grid-cell-position-collide ---
+// Error: 3:3-3:42 attempted to place a second cell at column 0, row 0
+// Hint: 3:3-3:42 try specifying your cells in a different order
+#grid(
+ [A],
+ grid.cell(x: 0, y: 0)[This shall error]
+)
+
+--- table-cell-position-collide ---
+// Error: 3:3-3:43 attempted to place a second cell at column 0, row 0
+// Hint: 3:3-3:43 try specifying your cells in a different order
+#table(
+ [A],
+ table.cell(x: 0, y: 0)[This shall error]
+)
+
+--- grid-cell-position-automatic-skip-manual ---
+// Automatic position cell skips custom position cell
+#grid(
+ grid.cell(x: 0, y: 0)[This shall not error],
+ [A]
+)
+
+--- grid-cell-position-x-out-of-bounds ---
+// Error: 4:3-4:36 cell could not be placed at invalid column 2
+#grid(
+ columns: 2,
+ [A],
+ grid.cell(x: 2)[This shall error]
+)
+
+--- grid-cell-position-partial ---
+// Partial positioning
+#grid(
+ columns: 3,
+ rows: 1.5em,
+ inset: 5pt,
+ fill: aqua,
+ [A], grid.cell(y: 1, fill: green)[B], [C], grid.cell(x: auto, y: 1, fill: green)[D], [E],
+ grid.cell(y: 2, fill: green)[F], grid.cell(x: 0, fill: orange)[G], grid.cell(x: 0, y: auto, fill: orange)[H],
+ grid.cell(x: 1, fill: orange)[I]
+)
+
+#table(
+ columns: 3,
+ rows: 1.5em,
+ inset: 5pt,
+ fill: aqua,
+ [A], table.cell(y: 1, fill: green)[B], [C], table.cell(x: auto, y: 1, fill: green)[D], [E],
+ table.cell(y: 2, fill: green)[F], table.cell(x: 0, fill: orange)[G], table.cell(x: 0, y: auto, fill: orange)[H],
+ table.cell(x: 1, fill: orange)[I]
+)
+
+--- grid-cell-position-partial-collide ---
+// Error: 4:3-4:21 cell could not be placed in row 0 because it was full
+// Hint: 4:3-4:21 try specifying your cells in a different order
+#grid(
+ columns: 2,
+ [A], [B],
+ grid.cell(y: 0)[C]
+)
+
+--- table-cell-position-partial-collide ---
+// Error: 4:3-4:22 cell could not be placed in row 0 because it was full
+// Hint: 4:3-4:22 try specifying your cells in a different order
+#table(
+ columns: 2,
+ [A], [B],
+ table.cell(y: 0)[C]
+)
+
+--- grid-calendar ---
+#set page(width: auto)
+#show grid.cell: it => {
+ if it.y == 0 {
+ set text(white)
+ strong(it)
+ } else {
+ // For the second row and beyond, we will write the day number for each
+ // cell.
+
+ // In general, a cell's index is given by cell.x + columns * cell.y.
+ // Days start in the second grid row, so we subtract 1 row.
+ // But the first day is day 1, not day 0, so we add 1.
+ let day = it.x + 7 * (it.y - 1) + 1
+ if day <= 31 {
+ // Place the day's number at the top left of the cell.
+ // Only if the day is valid for this month (not 32 or higher).
+ place(top + left, dx: 2pt, dy: 2pt, text(8pt, red.darken(40%))[#day])
+ }
+ it
+ }
+}
+
+#grid(
+ fill: (x, y) => if y == 0 { gray.darken(50%) },
+ columns: (30pt,) * 7,
+ rows: (auto, 30pt),
+ // Events will be written at the bottom of each day square.
+ align: bottom,
+ inset: 5pt,
+ stroke: (thickness: 0.5pt, dash: "densely-dotted"),
+
+ [Sun], [Mon], [Tue], [Wed], [Thu], [Fri], [Sat],
+
+ // This event will occur on the first Friday (sixth column).
+ grid.cell(x: 5, fill: yellow.darken(10%))[Call],
+
+ // This event will occur every Monday (second column).
+ // We have to repeat it 5 times so it occurs every week.
+ ..(grid.cell(x: 1, fill: red.lighten(50%))[Meet],) * 5,
+
+ // This event will occur at day 19.
+ grid.cell(x: 4, y: 3, fill: orange.lighten(25%))[Talk],
+
+ // These events will occur at the second week, where available.
+ grid.cell(y: 2, fill: aqua)[Chat],
+ grid.cell(y: 2, fill: aqua)[Walk],
+)
+
+--- grid-exam ---
+#set page(width: auto)
+#show table.cell: it => {
+ if it.x == 0 or it.y == 0 {
+ set text(white)
+ strong(it)
+ } else if it.body == [] {
+ // Replace empty cells with 'N/A'
+ pad(rest: it.inset)[_N/A_]
+ } else {
+ it
+ }
+}
+
+#table(
+ fill: (x, y) => if x == 0 or y == 0 { gray.darken(50%) },
+ columns: 4,
+ [], [Exam 1], [Exam 2], [Exam 3],
+ ..([John], [Mary], [Jake], [Robert]).map(table.cell.with(x: 0)),
+
+ // Mary got grade A on Exam 3.
+ table.cell(x: 3, y: 2, fill: green)[A],
+
+ // Everyone got grade A on Exam 2.
+ ..(table.cell(x: 2, fill: green)[A],) * 4,
+
+ // Robert got grade B on other exams.
+ ..(table.cell(y: 4, fill: aqua)[B],) * 2,
+)
diff --git a/tests/suite/layout/grid/rowspan.typ b/tests/suite/layout/grid/rowspan.typ
new file mode 100644
index 00000000..f7a377b6
--- /dev/null
+++ b/tests/suite/layout/grid/rowspan.typ
@@ -0,0 +1,490 @@
+--- grid-rowspan ---
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ grid.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], grid.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], grid.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ table.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], table.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], table.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+--- grid-rowspan-gutter ---
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ grid.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], grid.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], grid.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ table.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], table.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], table.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+--- grid-rowspan-fixed-size ---
+// Fixed-size rows
+#set page(height: 10em)
+#grid(
+ columns: 2,
+ rows: 1.5em,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ grid.cell(rowspan: 3)[R1], [b],
+ [c],
+ [d],
+ [e], [f],
+ grid.cell(rowspan: 5)[R2], [h],
+ [i],
+ [j],
+ [k],
+ [l],
+ [m], [n]
+)
+
+--- grid-rowspan-cell-coordinates ---
+// Cell coordinate tests
+#set page(height: 10em)
+#show table.cell: it => [(#it.x, #it.y)]
+#table(
+ columns: 3,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c],
+ table.cell(colspan: 2)[d],
+ table.cell(colspan: 3, rowspan: 10)[a],
+ table.cell(colspan: 2)[b],
+)
+#table(
+ columns: 3,
+ gutter: 3pt,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c],
+ table.cell(colspan: 2)[d],
+ table.cell(colspan: 3, rowspan: 9)[a],
+ table.cell(colspan: 2)[b],
+)
+
+--- grid-rowspan-over-auto-row ---
+// Auto row expansion
+#set page(height: 10em)
+#grid(
+ columns: (1em, 1em),
+ rows: (0.5em, 0.5em, auto),
+ fill: orange,
+ gutter: 3pt,
+ grid.cell(rowspan: 4, [x x x x] + place(bottom)[*Bot*]),
+ [a],
+ [b],
+ [c],
+ [d]
+)
+
+--- grid-rowspan-excessive ---
+// Excessive rowspan (no gutter)
+#set page(height: 10em)
+#table(
+ columns: 4,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c], [d],
+ table.cell(colspan: 2, stroke: (bottom: aqua + 2pt))[e], table.cell(stroke: (bottom: aqua))[f],
+ table.cell(colspan: 2, rowspan: 10)[R1], table.cell(colspan: 2, rowspan: 10)[R2],
+ [b],
+)
+
+--- grid-rowspan-excessive-gutter ---
+// Excessive rowspan (with gutter)
+#set page(height: 10em)
+#table(
+ columns: 4,
+ gutter: 3pt,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c], [d],
+ table.cell(colspan: 2, stroke: (bottom: aqua + 2pt))[e], table.cell(stroke: (bottom: aqua))[f],
+ table.cell(colspan: 2, rowspan: 10)[R1], table.cell(colspan: 2, rowspan: 10)[R2],
+ [b],
+)
+
+--- grid-rowspan-over-fr-row-at-end ---
+// Fractional rows
+// They cause the auto row to expand more than needed.
+#set page(height: 10em)
+#grid(
+ fill: red,
+ gutter: 3pt,
+ columns: 3,
+ rows: (1em, auto, 1fr),
+ [a], [b], grid.cell(rowspan: 3, block(height: 4em, width: 1em, fill: orange)),
+ [c], [d],
+ [e], [f]
+)
+
+--- grid-rowspan-over-fr-row-at-start ---
+// Fractional rows
+#set page(height: 10em)
+#grid(
+ fill: red,
+ gutter: 3pt,
+ columns: 3,
+ rows: (1fr, auto, 1em),
+ [a], [b], grid.cell(rowspan: 3, block(height: 4em, width: 1em, fill: orange)),
+ [c], [d],
+ [e], [f]
+)
+
+--- grid-rowspan-cell-order ---
+// Cell order
+#let count = counter("count")
+#show grid.cell: it => {
+ count.step()
+ count.display()
+}
+
+#grid(
+ columns: (2em,) * 3,
+ stroke: aqua,
+ rows: 1.2em,
+ fill: (x, y) => if calc.odd(x + y) { red } else { orange },
+ [a], grid.cell(rowspan: 2)[b], grid.cell(rowspan: 2)[c],
+ [d],
+ grid.cell(rowspan: 2)[f], [g], [h],
+ [i], [j],
+ [k], [l], [m],
+ grid.cell(rowspan: 2)[n], [o], [p],
+ [q], [r],
+ [s], [t], [u]
+)
+
+--- grid-rowspan-unbreakable-1 ---
+#table(
+ columns: 3,
+ rows: (auto, auto, auto, 2em),
+ gutter: 3pt,
+ table.cell(rowspan: 4)[a \ b\ c\ d\ e], [c], [d],
+ [e], table.cell(breakable: false, rowspan: 2)[f],
+ [g]
+)
+
+--- grid-rowspan-unbreakable-2 ---
+// Test cell breakability
+#show grid.cell: it => {
+ assert.eq(it.breakable, (it.x, it.y) != (0, 6) and (it.y in (2, 5, 6) or (it.x, it.y) in ((0, 1), (2, 3), (1, 7))))
+ it.breakable
+}
+#grid(
+ columns: 3,
+ rows: (6pt, 1fr, auto, 1%, 1em, auto, auto, 0.2in),
+ row-gutter: (0pt, 0pt, 0pt, auto),
+ [a], [b], [c],
+ grid.cell(rowspan: 3)[d], [e], [f],
+ [g], [h],
+ [i], grid.cell(rowspan: 2)[j],
+ [k],
+ grid.cell(y: 5)[l],
+ grid.cell(y: 6, breakable: false)[m], grid.cell(y: 6, breakable: true)[n],
+ grid.cell(y: 7, breakable: false)[o], grid.cell(y: 7, breakable: true)[p], grid.cell(y: 7, breakable: auto)[q]
+)
+
+--- grid-rowspan-in-all-columns-stroke ---
+#table(
+ columns: 2,
+ table.cell(stroke: (bottom: red))[a], [b],
+ table.hline(stroke: green),
+ table.cell(stroke: (top: yellow, left: green, right: aqua, bottom: blue), colspan: 1, rowspan: 2)[d], table.cell(colspan: 1, rowspan: 2)[e],
+ [f],
+ [g]
+)
+
+--- grid-rowspan-in-all-columns-stroke-gutter ---
+#table(
+ columns: 2,
+ gutter: 3pt,
+ table.cell(stroke: (bottom: red))[a], [b],
+ table.hline(stroke: green),
+ table.cell(stroke: (top: yellow, left: green, right: aqua, bottom: blue), colspan: 1, rowspan: 2)[d], table.cell(colspan: 1, rowspan: 2)[e],
+ [f],
+ [g]
+)
+
+--- grid-rowspan-block-full-height ---
+// Block below shouldn't expand to the end of the page, but stay within its
+// rows' boundaries.
+#set page(height: 9em)
+#table(
+ rows: (1em, 1em, 1fr, 1fr, auto),
+ table.cell(rowspan: 2, block(width: 2em, height: 100%, fill: red)),
+ table.cell(rowspan: 2, block(width: 2em, height: 100%, fill: red)),
+ [a]
+)
+
+--- grid-rowspan-block-overflow ---
+#set page(height: 7em)
+#table(
+ columns: 3,
+ [], [], table.cell(breakable: true, rowspan: 2, block(width: 2em, height: 100%, fill: red)),
+ table.cell(breakable: false, block(width: 2em, height: 100%, fill: red)),
+ table.cell(breakable: false, rowspan: 2, block(width: 2em, height: 100%, fill: red)),
+)
+
+// Rowspan split tests
+
+--- grid-rowspan-split-1 ---
+#set page(height: 10em)
+#table(
+ columns: 2,
+ rows: (auto, auto, 3em),
+ fill: red,
+ [a], table.cell(rowspan: 3, block(width: 50%, height: 10em, fill: orange) + place(bottom)[*ZD*]),
+ [e],
+ [f]
+)
+
+--- grid-rowspan-split-2 ---
+#set page(height: 10em)
+#table(
+ columns: 2,
+ rows: (auto, auto, 3em),
+ row-gutter: 1em,
+ fill: red,
+ [a], table.cell(rowspan: 3, block(width: 50%, height: 10em, fill: orange) + place(bottom)[*ZD*]),
+ [e],
+ [f]
+)
+
+--- grid-rowspan-split-3 ---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ table.cell(fill: orange, rowspan: 10, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+--- grid-rowspan-split-4 ---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ gutter: 2pt,
+ table.cell(fill: orange, rowspan: 10, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+--- grid-rowspan-split-5 ---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ table.cell(fill: orange, rowspan: 10, breakable: false, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+--- grid-rowspan-split-6 ---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ gutter: 2pt,
+ table.cell(fill: orange, rowspan: 10, breakable: false, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+--- grid-rowspan-split-7 ---
+#set page(height: 5em)
+#grid(
+ columns: 2,
+ stroke: red,
+ inset: 5pt,
+ grid.cell(rowspan: 5)[a\ b\ c\ d\ e]
+)
+
+--- grid-rowspan-split-8 ---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ gutter: 3pt,
+ stroke: red,
+ inset: 5pt,
+ table.cell(rowspan: 5)[a\ b\ c\ d\ e]
+)
+
+// Rowspan split without ending at the auto row
+
+--- grid-rowspan-split-9 ---
+#set page(height: 6em)
+#table(
+ rows: (4em,) * 7 + (auto,) + (4em,) * 7,
+ columns: 2,
+ column-gutter: 1em,
+ row-gutter: (1em, 2em) * 4,
+ fill: (x, y) => if calc.odd(x + y) { orange.lighten(20%) } else { red },
+ table.cell(rowspan: 15, [a \ ] * 15),
+ [] * 15
+)
+
+--- grid-rowspan-split-10 ---
+#set page(height: 6em)
+#table(
+ rows: (4em,) * 7 + (auto,) + (4em,) * 7,
+ columns: 2,
+ column-gutter: 1em,
+ row-gutter: (1em, 2em) * 4,
+ fill: (x, y) => if calc.odd(x + y) { green } else { green.darken(40%) },
+ table.cell(rowspan: 15, block(fill: blue, width: 2em, height: 4em * 14 + 3em)),
+ [] * 15
+)
+
+--- grid-rowspan-split-11 ---
+#set page(height: 6em)
+#table(
+ rows: (3em,) * 15,
+ columns: 2,
+ column-gutter: 1em,
+ row-gutter: (1em, 2em) * 4,
+ fill: (x, y) => if calc.odd(x + y) { aqua } else { blue },
+ table.cell(breakable: true, rowspan: 15, [a \ ] * 15),
+ [] * 15
+)
+
+// Some splitting corner cases
+
+--- grid-rowspan-split-12 ---
+// Inside the larger rowspan's range, there's an unbreakable rowspan and a
+// breakable rowspan. This should work normally.
+// The auto row will also expand ignoring the last fractional row.
+#set page(height: 10em)
+#table(
+ gutter: 0.5em,
+ columns: 2,
+ rows: (2em,) * 10 + (auto, auto, 2em, 1fr),
+ fill: (_, y) => if calc.even(y) { aqua } else { blue },
+ table.cell(rowspan: 14, block(width: 2em, height: 2em * 10 + 2em + 5em, fill: red)[]),
+ ..([a],) * 5,
+ table.cell(rowspan: 3)[a\ b],
+ table.cell(rowspan: 5, [a\ b\ c\ d\ e\ f\ g\ h]),
+ [z]
+)
+
+--- grid-rowspan-split-13 ---
+// Inset moving to next region bug
+#set page(width: 10cm, height: 2.5cm, margin: 0.5cm)
+#set text(size: 11pt)
+#table(
+ columns: (1fr, 1fr, 1fr),
+ [A],
+ [B],
+ [C],
+ [D],
+ table.cell(rowspan: 2, lorem(4)),
+ [E],
+ [F],
+ [G],
+)
+
+--- grid-rowspan-split-14 ---
+// Second lorem must be sent to the next page, too big
+#set page(width: 10cm, height: 9cm, margin: 1cm)
+#set text(size: 11pt)
+#table(
+ columns: (1fr, 1fr, 1fr),
+ align: center,
+ rows: (4cm, auto),
+ [A], [B], [C],
+ table.cell(rowspan: 4, breakable: false, lorem(10)),
+ [D],
+ table.cell(rowspan: 2, breakable: false, lorem(20)),
+ [E],
+)
+
+--- grid-rowspan-split-15 ---
+// Auto row must expand properly in both cases
+#set text(10pt)
+#show table.cell: it => if it.x == 0 { it } else { layout(size => size.height) }
+#table(
+ columns: 2,
+ rows: (1em, auto, 2em, 3em, 4em),
+ gutter: 3pt,
+ table.cell(rowspan: 5, block(fill: orange, height: 15em)[a]),
+ [b],
+ [c],
+ [d],
+ [e],
+ [f]
+)
+
+#table(
+ columns: 2,
+ rows: (1em, auto, 2em, 3em, 4em),
+ gutter: 3pt,
+ table.cell(rowspan: 5, breakable: false, block(fill: orange, height: 15em)[a]),
+ [b],
+ [c],
+ [d],
+ [e],
+ [f]
+)
+
+--- grid-rowspan-split-16 ---
+// Expanding on unbreakable auto row
+#set page(height: 7em, margin: (bottom: 2em))
+#grid(
+ columns: 2,
+ rows: (1em, 1em, auto, 1em, 1em, 1em),
+ fill: (x, y) => if x == 0 { aqua } else { blue },
+ stroke: black,
+ gutter: 2pt,
+ grid.cell(rowspan: 5, block(height: 10em)[a]),
+ [a],
+ [b],
+ grid.cell(breakable: false, v(3em) + [c]),
+ [d],
+ [e],
+ [f], [g]
+)
+
+--- grid-rowspan-split-17 ---
+#show table.cell.where(x: 0): strong
+#show table.cell.where(y: 0): strong
+#set page(height: 13em)
+#let lets-repeat(thing, n) = ((thing + colbreak(),) * (calc.max(0, n - 1)) + (thing,)).join()
+#table(
+ columns: 4,
+ fill: (x, y) => if x == 0 or y == 0 { gray },
+ [], [Test 1], [Test 2], [Test 3],
+ table.cell(rowspan: 15, align: horizon, lets-repeat((rotate(-90deg, reflow: true)[*All Tests*]), 3)),
+ ..([123], [456], [789]) * 15
+)
diff --git a/tests/suite/layout/grid/rtl.typ b/tests/suite/layout/grid/rtl.typ
new file mode 100644
index 00000000..7c0e999a
--- /dev/null
+++ b/tests/suite/layout/grid/rtl.typ
@@ -0,0 +1,195 @@
+// Test RTL grid.
+
+--- list-rtl ---
+#set text(dir: rtl)
+- מימין לשמאל
+
+--- grid-rtl ---
+#set text(dir: rtl)
+#table(columns: 2)[A][B][C][D]
+
+--- grid-rtl-colspan ---
+// Test interaction between RTL and colspans
+#set text(dir: rtl)
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ grid.cell(colspan: 4)[*Full Header*],
+ grid.cell(colspan: 2, fill: orange)[*Half*],
+ grid.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], grid.cell(colspan: 3, fill: orange.darken(10%))[6],
+ grid.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], grid.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ grid.cell(colspan: 4)[*Full Header*],
+ grid.cell(colspan: 2, fill: orange)[*Half*],
+ grid.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], grid.cell(colspan: 3, fill: orange.darken(10%))[6],
+ grid.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], grid.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+--- grid-rtl-colspan-stroke ---
+#set text(dir: rtl)
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ table.cell(colspan: 4)[*Full Header*],
+ table.cell(colspan: 2, fill: orange)[*Half*],
+ table.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], table.cell(colspan: 3, fill: orange.darken(10%))[6],
+ table.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], table.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ table.cell(colspan: 4)[*Full Header*],
+ table.cell(colspan: 2, fill: orange)[*Half*],
+ table.cell(colspan: 2, fill: orange.darken(10%))[*Half*],
+ [*A*], [*B*], [*C*], [*D*],
+ [1], [2], [3], [4],
+ [5], table.cell(colspan: 3, fill: orange.darken(10%))[6],
+ table.cell(colspan: 2, fill: orange)[7], [8], [9],
+ [10], table.cell(colspan: 2, fill: orange.darken(10%))[11], [12]
+)
+
+--- grid-rtl-multiple-regions ---
+// Test multiple regions
+#set page(height: 5em)
+#set text(dir: rtl)
+#grid(
+ stroke: red,
+ fill: aqua,
+ columns: 4,
+ [a], [b], [c], [d],
+ [a], grid.cell(colspan: 2)[e, f, g, h, i], [f],
+ [e], [g], grid.cell(colspan: 2)[eee\ e\ e\ e],
+ grid.cell(colspan: 4)[eeee e e e]
+)
+
+--- grid-rtl-vline-position ---
+// Test left and right for vlines in RTL
+#set text(dir: rtl)
+#grid(
+ columns: 3,
+ inset: 5pt,
+ grid.vline(stroke: red, position: left), grid.vline(stroke: green, position: right), [a],
+ grid.vline(stroke: red, position: left), grid.vline(stroke: 2pt, position: right), [b],
+ grid.vline(stroke: red, position: left), grid.vline(stroke: 2pt, position: right), [c],
+ grid.vline(stroke: aqua, position: right)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [b],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [c],
+ grid.vline(stroke: 2pt, position: right)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ grid.vline(stroke: green, position: start), grid.vline(stroke: red, position: end), [a],
+ grid.vline(stroke: 2pt, position: start), grid.vline(stroke: red, position: end), [b],
+ grid.vline(stroke: 2pt, position: start), grid.vline(stroke: red, position: end), [c],
+ grid.vline(stroke: 2pt, position: start)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ grid.vline(stroke: green, position: start), grid.vline(stroke: red, position: end), [a],
+ grid.vline(stroke: blue, position: start), grid.vline(stroke: red, position: end), [b],
+ grid.vline(stroke: blue, position: start), grid.vline(stroke: red, position: end), [c],
+ grid.vline(stroke: 2pt, position: start)
+)
+
+--- grid-rtl-vline-out-of-bounds ---
+// Error: 3:8-3:34 cannot place vertical line at the 'end' position of the end border (x = 1)
+// Hint: 3:8-3:34 set the line's position to 'start' or place it at a smaller 'x' index
+#set text(dir: rtl)
+#grid(
+ [a], grid.vline(position: left)
+)
+
+--- grid-rtl-complex ---
+#set text(dir: rtl)
+
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ grid.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], grid.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], grid.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ table.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], table.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], table.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+--- grid-rtl-rowspan ---
+#set page(height: 10em)
+#set text(dir: rtl)
+#table(
+ columns: 2,
+ rows: (auto, auto, 3em),
+ row-gutter: 1em,
+ fill: red,
+ [a], table.cell(rowspan: 3, block(width: 50%, height: 10em, fill: orange) + place(bottom)[*ZD*]),
+ [e],
+ [f]
+)
+
+--- grid-rtl-header ---
+// Headers
+#set page(height: 15em)
+#set text(dir: rtl)
+#table(
+ columns: 5,
+ align: center + horizon,
+ table.header(
+ table.cell(colspan: 5)[*Cool Zone*],
+ table.cell(stroke: red)[*N1*], table.cell(stroke: aqua)[*N2*], [*D1*], [*D2*], [*Etc*],
+ table.hline(start: 2, end: 3, stroke: yellow)
+ ),
+ ..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()
+)
diff --git a/tests/suite/layout/grid/stroke.typ b/tests/suite/layout/grid/stroke.typ
new file mode 100644
index 00000000..9c1c3482
--- /dev/null
+++ b/tests/suite/layout/grid/stroke.typ
@@ -0,0 +1,435 @@
+--- grid-stroke-pattern ---
+#let double-line = pattern(size: (1.5pt, 1.5pt), {
+ place(line(stroke: .6pt, start: (0%, 50%), end: (100%, 50%)))
+})
+
+#table(
+ stroke: (_, y) => if y != 1 { (bottom: black) },
+ columns: 3,
+ table.cell(colspan: 3, align: center)[*Epic Table*],
+ align(center)[*Name*], align(center)[*Age*], align(center)[*Data*],
+ table.hline(stroke: (paint: double-line, thickness: 2pt)),
+ [John], [30], [None],
+ [Martha], [20], [A],
+ [Joseph], [35], [D]
+)
+
+--- grid-stroke-folding ---
+// Test folding
+#set grid(stroke: red)
+#set grid(stroke: 5pt)
+
+#grid(
+ inset: 10pt,
+ columns: 2,
+ stroke: stroke(dash: "loosely-dotted"),
+ grid.vline(start: 2, end: 3, stroke: (paint: green, dash: none)),
+ [a], [b],
+ grid.hline(end: 1, stroke: blue),
+ [c], [d],
+ [e], grid.cell(stroke: aqua)[f]
+)
+
+--- grid-stroke-set-on-cell-and-line ---
+// Test set rules on cells and folding
+#set table.cell(stroke: 4pt)
+#set table.cell(stroke: blue)
+#set table.hline(stroke: red)
+#set table.hline(stroke: 0.75pt)
+#set table.vline(stroke: 0.75pt)
+#set table.vline(stroke: aqua)
+
+#table(
+ columns: 3,
+ gutter: 3pt,
+ inset: 5pt,
+ [a], [b], table.vline(position: end), [c],
+ [d], [e], [f],
+ table.hline(position: bottom),
+ [g], [h], [i],
+)
+
+--- grid-stroke-field-in-show ---
+// Test stroke field on cell show rules
+#set grid.cell(stroke: (x: 4pt))
+#set grid.cell(stroke: (x: blue))
+#show grid.cell: it => {
+ test(it.stroke, (left: stroke(paint: blue, thickness: 4pt, dash: "loosely-dotted"), right: blue + 4pt, top: stroke(thickness: 1pt), bottom: none))
+ it
+}
+#grid(
+ stroke: (left: (dash: "loosely-dotted")),
+ inset: 5pt,
+ grid.hline(stroke: red),
+ grid.cell(stroke: (top: 1pt))[a], grid.vline(stroke: yellow),
+)
+
+--- grid-stroke-complex ---
+#table(
+ columns: 3,
+ [a], table.cell(colspan: 2)[b c],
+ table.cell(stroke: blue)[d], [e], [f],
+ [g], [h], table.cell(stroke: (left: yellow, top: green, right: aqua, bottom: red))[i],
+ [j], [k], [l],
+ table.cell(stroke: 3pt)[m], [n], table.cell(stroke: (dash: "loosely-dotted"))[o],
+)
+
+--- grid-stroke-array ---
+// Test per-column stroke array
+#let t = table(
+ columns: 3,
+ stroke: (red, blue, green),
+ [a], [b], [c],
+ [d], [e], [f],
+ [h], [i], [j],
+)
+#t
+#set text(dir: rtl)
+#t
+
+--- grid-stroke-func ---
+#grid(
+ columns: 3,
+ inset: 3pt,
+ stroke: (x, _) => (right: (5pt, (dash: "dotted")).at(calc.rem(x, 2)), bottom: (dash: "densely-dotted")),
+ grid.vline(x: 0, stroke: red),
+ grid.vline(x: 1, stroke: red),
+ grid.vline(x: 2, stroke: red),
+ grid.vline(x: 3, stroke: red),
+ grid.hline(y: 0, end: 1, stroke: blue),
+ grid.hline(y: 1, end: 1, stroke: blue),
+ grid.cell[a],
+ [b], [c]
+)
+
+--- grid-stroke-manually-positioned-lines ---
+#set page(height: 5em)
+#table(
+ columns: 3,
+ inset: 3pt,
+ table.hline(y: 0, end: none, stroke: 3pt + blue),
+ table.vline(x: 0, end: none, stroke: 3pt + green),
+ table.hline(y: 5, end: none, stroke: 3pt + red),
+ table.vline(x: 3, end: none, stroke: 3pt + yellow),
+ [a], [b], [c],
+ [a], [b], [c],
+ [a], [b], [c],
+ [a], [b], [c],
+ [a], [b], [c],
+)
+
+--- grid-stroke-automatically-positioned-lines ---
+// Automatically positioned lines
+// Plus stroke thickness ordering
+#table(
+ columns: 3,
+ table.hline(stroke: red + 5pt),
+ table.vline(stroke: blue + 5pt),
+ table.vline(stroke: 2pt),
+ [a],
+ table.vline(x: 1, stroke: aqua + 5pt),
+ [b],
+ table.vline(stroke: aqua + 5pt),
+ [c],
+ table.vline(stroke: yellow + 5.2pt),
+ table.hline(stroke: green + 5pt),
+ [a], [b], [c],
+ [a], table.hline(stroke: green + 2pt), table.vline(stroke: 2pt), [b], [c],
+)
+
+--- grid-stroke-priority-line ---
+// Line specification order priority
+// The last line should be blue, not red.
+// The middle aqua line should be gone due to the 'none' override.
+#grid(
+ columns: 2,
+ inset: 2pt,
+ grid.hline(y: 2, stroke: red + 5pt),
+ grid.vline(),
+ [a], [b],
+ grid.hline(stroke: red),
+ grid.hline(stroke: none),
+ [c], grid.cell(stroke: (top: aqua))[d],
+ grid.hline(stroke: blue),
+)
+
+--- grid-stroke-hline-position-bottom-gutter ---
+// Position: bottom and position: end with gutter should have a visible effect
+// of moving the lines after the next track.
+#table(
+ columns: 3,
+ gutter: 3pt,
+ stroke: blue,
+ table.hline(end: 2, stroke: red),
+ table.hline(end: 2, stroke: aqua, position: bottom),
+ table.vline(end: 2, stroke: green), [a], table.vline(end: 2, stroke: green), table.vline(end: 2, position: end, stroke: orange), [b], table.vline(end: 2, stroke: aqua, position: end), table.vline(end: 2, stroke: green), [c], table.vline(end: 2, stroke: green),
+ [d], [e], [f],
+ table.hline(end: 2, stroke: red),
+ [g], [h], [ie],
+ table.hline(end: 2, stroke: green),
+)
+
+--- grid-stroke-hline-position-bottom ---
+// Using position: bottom and position: end without gutter should be the same
+// as placing a line after the next track.
+#table(
+ columns: 3,
+ stroke: blue,
+ table.hline(end: 2, stroke: red),
+ table.hline(end: 2, stroke: aqua, position: bottom),
+ table.vline(end: 2, stroke: green), [a], table.vline(end: 2, stroke: green), [b], table.vline(end: 2, stroke: aqua, position: end), table.vline(end: 2, stroke: green), [c], table.vline(end: 2, stroke: green),
+ table.hline(end: 2, stroke: 5pt),
+ [d], [e], [f],
+ table.hline(end: 2, stroke: red),
+ [g], [h], [i],
+ table.hline(end: 2, stroke: red),
+)
+
+--- grid-stroke-vline-position-left-and-right ---
+// Test left and right for grid vlines.
+#grid(
+ columns: 3,
+ inset: 5pt,
+ grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a],
+ grid.vline(stroke: 2pt, position: left), grid.vline(stroke: red, position: right), [b],
+ grid.vline(stroke: 2pt, position: left), grid.vline(stroke: red, position: right), [c],
+ grid.vline(stroke: 2pt, position: left)
+)
+
+#grid(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [b],
+ grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [c],
+ grid.vline(stroke: 2pt, position: left)
+)
+
+--- table-stroke-vline-position-left-and-right ---
+// Test left and right for table vlines.
+#table(
+ columns: 3,
+ inset: 5pt,
+ table.vline(stroke: green, position: left), table.vline(stroke: red, position: right), [a],
+ table.vline(stroke: 2pt, position: left), table.vline(stroke: red, position: right), [b],
+ table.vline(stroke: 2pt, position: left), table.vline(stroke: red, position: right), [c],
+ table.vline(stroke: 2pt, position: left)
+)
+
+#table(
+ columns: 3,
+ inset: 5pt,
+ gutter: 3pt,
+ table.vline(stroke: green, position: left), table.vline(stroke: red, position: right), [a],
+ table.vline(stroke: blue, position: left), table.vline(stroke: red, position: right), [b],
+ table.vline(stroke: blue, position: left), table.vline(stroke: red, position: right), [c],
+ table.vline(stroke: 2pt, position: left)
+)
+
+--- grid-stroke-priority-line-cell ---
+// Hlines and vlines should always appear on top of cell strokes.
+#table(
+ columns: 3,
+ stroke: aqua,
+ table.vline(stroke: red, position: end), [a], table.vline(stroke: red), [b], [c],
+ table.cell(stroke: blue)[d], [e], [f],
+ table.hline(stroke: red),
+ [g], table.cell(stroke: blue)[h], [i],
+)
+
+#table(
+ columns: 3,
+ gutter: 3pt,
+ stroke: aqua,
+ table.vline(stroke: red, position: end), [a], table.vline(stroke: red), [b], [c],
+ table.cell(stroke: blue)[d], [e], [f],
+ table.hline(stroke: red),
+ [g], table.cell(stroke: blue)[h], [i],
+)
+
+--- grid-stroke-priority-cell ---
+// Ensure cell stroke overrides always appear on top.
+#table(
+ columns: 2,
+ stroke: black,
+ table.cell(stroke: red)[a], [b],
+ [c], [d],
+)
+
+#table(
+ columns: 2,
+ table.cell(stroke: red)[a], [b],
+ [c], [d],
+)
+
+--- grid-stroke-hline-position-bad ---
+// Error: 7:3-7:32 cannot place horizontal line at the 'bottom' position of the bottom border (y = 2)
+// Hint: 7:3-7:32 set the line's position to 'top' or place it at a smaller 'y' index
+#table(
+ columns: 2,
+ [a], [b],
+ [c], [d],
+ table.hline(stroke: aqua),
+ table.hline(position: top),
+ table.hline(position: bottom)
+)
+
+--- grid-stroke-border-partial ---
+// Test partial border line overrides
+#set page(width: auto, height: 7em, margin: (bottom: 1em))
+#table(
+ columns: 4,
+ stroke: (x, y) => if y == 0 or y == 4 { orange } else { aqua },
+ table.hline(stroke: blue, start: 1, end: 2), table.cell(stroke: red, v(3em)), table.cell(stroke: blue)[b], table.cell(stroke: green)[c], [M],
+ [a], [b], [c], [M],
+ [d], [e], [f], [M],
+ [g], [h], [i], [M],
+ table.cell(stroke: red)[a], table.cell(stroke: blue)[b], table.cell(stroke: green)[c], [M],
+ table.hline(stroke: blue, start: 1, end: 2),
+)
+
+--- grid-stroke-vline-colspan ---
+// - Vline should be placed after the colspan.
+// - Hline should be placed under the full-width rowspan.
+#table(
+ columns: 3,
+ rows: 1.25em,
+ inset: 1pt,
+ stroke: none,
+ table.cell(colspan: 2)[a], table.vline(stroke: red), table.hline(stroke: blue), [b],
+ [c], [d], [e],
+ table.cell(colspan: 3, rowspan: 2)[a], table.vline(stroke: blue), table.hline(stroke: red)
+)
+
+--- grid-stroke-hline-rowspan ---
+// Red line should be above [c] (hline skips the shortest rowspan).
+#set text(6pt)
+#table(
+ rows: 1em,
+ columns: 2,
+ inset: 1.5pt,
+ table.cell(rowspan: 3)[a], table.cell(rowspan: 2)[b],
+ table.hline(stroke: red),
+ [c]
+)
+
+--- grid-stroke-hline-position-bottom-out-of-bounds ---
+// Error: 8:3-8:32 cannot place horizontal line at the 'bottom' position of the bottom border (y = 2)
+// Hint: 8:3-8:32 set the line's position to 'top' or place it at a smaller 'y' index
+#table(
+ columns: 2,
+ gutter: 3pt,
+ [a], [b],
+ [c], [d], table.vline(stroke: red),
+ table.hline(stroke: aqua),
+ table.hline(position: top),
+ table.hline(position: bottom)
+)
+
+--- grid-stroke-vline-position-bottom-out-of-bounds ---
+// Error: 6:3-6:28 cannot place vertical line at the 'end' position of the end border (x = 2)
+// Hint: 6:3-6:28 set the line's position to 'start' or place it at a smaller 'x' index
+#grid(
+ columns: 2,
+ [a], [b],
+ grid.vline(stroke: aqua),
+ grid.vline(position: start),
+ grid.vline(position: end)
+)
+
+--- grid-stroke-vline-position-bottom-out-of-bounds-gutter ---
+// Error: 7:3-7:28 cannot place vertical line at the 'end' position of the end border (x = 2)
+// Hint: 7:3-7:28 set the line's position to 'start' or place it at a smaller 'x' index
+#grid(
+ columns: 2,
+ gutter: 3pt,
+ [a], [b],
+ grid.vline(stroke: aqua),
+ grid.vline(position: start),
+ grid.vline(position: end)
+)
+
+--- grid-stroke-hline-out-of-bounds ---
+// Error: 4:3-4:19 cannot place horizontal line at invalid row 3
+#grid(
+ [a],
+ [b],
+ grid.hline(y: 3)
+)
+
+--- grid-stroke-hline-out-of-bounds-gutter ---
+// Error: 5:3-5:19 cannot place horizontal line at invalid row 3
+#grid(
+ gutter: 3pt,
+ [a],
+ [b],
+ grid.hline(y: 3)
+)
+
+--- grid-stroke-vline-out-of-bounds ---
+// Error: 4:3-4:20 cannot place vertical line at invalid column 3
+#table(
+ columns: 2,
+ [a], [b],
+ table.vline(x: 3)
+)
+
+--- grid-stroke-vline-out-of-bounds-gutter ---
+// Error: 5:3-5:20 cannot place vertical line at invalid column 3
+#table(
+ columns: 2,
+ gutter: 3pt,
+ [a], [b],
+ table.vline(x: 3)
+)
+
+--- table-hline-in-grid ---
+// Error: 7-20 cannot use `table.hline` as a grid line; use `grid.hline` instead
+#grid(table.hline())
+
+--- table-vline-in-grid ---
+// Error: 7-20 cannot use `table.vline` as a grid line; use `grid.vline` instead
+#grid(table.vline())
+
+--- grid-hline-in-table ---
+// Error: 8-20 cannot use `grid.hline` as a table line; use `table.hline` instead
+#table(grid.hline())
+
+--- grid-vline-in-table ---
+// Error: 8-20 cannot use `grid.vline` as a table line; use `table.vline` instead
+#table(grid.vline())
+
+--- grid-hline-end-before-start-1 ---
+// Error: 3:3-3:31 line cannot end before it starts
+#grid(
+ columns: 3,
+ grid.hline(start: 2, end: 1),
+ [a], [b], [c],
+)
+
+--- grid-hline-end-before-start-2 ---
+// Error: 3:3-3:32 line cannot end before it starts
+#table(
+ columns: 3,
+ table.vline(start: 2, end: 1),
+ [a], [b], [c],
+ [d], [e], [f],
+ [g], [h], [i],
+)
+
+--- grid-hline-position-horizon ---
+// Error: 24-31 expected `top` or `bottom`, found horizon
+#table.hline(position: horizon)
+
+--- grid-vline-position-center ---
+// Error: 24-30 expected `start`, `left`, `right`, or `end`, found center
+#table.vline(position: center)
+
+--- grid-hline-position-right ---
+// Error: 24-29 expected `top` or `bottom`, found right
+#table.hline(position: right)
+
+--- grid-vline-position-top ---
+// Error: 24-27 expected `start`, `left`, `right`, or `end`, found top
+#table.vline(position: top)
diff --git a/tests/suite/layout/grid/styling.typ b/tests/suite/layout/grid/styling.typ
new file mode 100644
index 00000000..f7cfb97d
--- /dev/null
+++ b/tests/suite/layout/grid/styling.typ
@@ -0,0 +1,160 @@
+// Test grid styling options.
+
+--- grid-fill-func ---
+#set page(height: 70pt)
+#set grid(fill: (x, y) => if calc.even(x + y) { rgb("aaa") })
+
+#grid(
+ columns: (1fr,) * 3,
+ stroke: 2pt + rgb("333"),
+ [A], [B], [C], [], [], [D \ E \ F \ \ \ G], [H],
+)
+
+--- grid-stroke-none ---
+#grid(columns: 3, stroke: none, fill: green, [A], [B], [C])
+
+--- grid-align ---
+// Test general alignment.
+#grid(
+ columns: 3,
+ align: left,
+ [Hello], [Hello], [Hello],
+ [A], [B], [C],
+)
+
+// Test alignment with a function.
+#grid(
+ columns: 3,
+ align: (x, y) => (left, center, right).at(x),
+ [Hello], [Hello], [Hello],
+ [A], [B], [C],
+)
+
+// Test alignment with array.
+#grid(
+ columns: (1fr, 1fr, 1fr),
+ align: (left, center, right),
+ [A], [B], [C]
+)
+
+// Test empty array.
+#set align(center)
+#grid(
+ columns: (1fr, 1fr, 1fr),
+ align: (),
+ [A], [B], [C]
+)
+
+a
+
+--- grid-inset ---
+// Test inset.
+#grid(
+ columns: (1fr,) * 3,
+ stroke: 2pt + rgb("333"),
+ inset: 5pt,
+ [A], [B], [C], [], [], [D \ E \ F \ \ \ G], [H],
+)
+
+#grid(
+ columns: 3,
+ inset: 10pt,
+ fill: blue,
+ [A], [B], [C]
+)
+
+#grid(
+ columns: 3,
+ inset: (y: 10pt),
+ [A], [B], [C]
+)
+
+#grid(
+ columns: 3,
+ inset: (left: 20pt, rest: 10pt),
+ stroke: 3pt + red,
+ [A], [B], [C]
+)
+
+#grid(
+ columns: 2,
+ inset: (
+ left: 20pt,
+ right: 5pt,
+ top: 10pt,
+ bottom: 3pt,
+ ),
+ [A],
+ [B],
+)
+
+#grid(
+ columns: 3,
+ fill: (x, y) => (if y == 0 { aqua } else { orange }).darken(x * 15%),
+ inset: (x, y) => (left: if x == 0 { 0pt } else { 5pt }, right: if x == 0 { 5pt } else { 0pt }, y: if y == 0 { 0pt } else { 5pt }),
+ [A], [B], [C],
+ [A], [B], [C],
+)
+
+#grid(
+ columns: 3,
+ inset: (0pt, 5pt, 10pt),
+ fill: (x, _) => aqua.darken(x * 15%),
+ [A], [B], [C],
+)
+
+--- grid-inset-folding ---
+// Test inset folding
+#set grid(inset: 10pt)
+#set grid(inset: (left: 0pt))
+
+#grid(
+ fill: red,
+ inset: (right: 0pt),
+ grid.cell(inset: (top: 0pt))[a]
+)
+
+--- grid-funcs-gutter ---
+// Test interaction with gutters.
+#grid(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
+
+#grid(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ row-gutter: 5pt,
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
+
+#grid(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ column-gutter: 5pt,
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
+
+#grid(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ gutter: 5pt,
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
diff --git a/tests/suite/layout/hide.typ b/tests/suite/layout/hide.typ
new file mode 100644
index 00000000..a10090d7
--- /dev/null
+++ b/tests/suite/layout/hide.typ
@@ -0,0 +1,104 @@
+// Test the `hide` function.
+
+--- hide-text ---
+AB #h(1fr) CD \
+#hide[A]B #h(1fr) C#hide[D]
+
+--- hide-line ---
+Hidden:
+#hide[#line(length: 100%)]
+#line(length: 100%)
+
+--- hide-table ---
+Hidden:
+#hide(table(rows: 2, columns: 2)[a][b][c][d])
+#table(rows: 2, columns: 2)[a][b][c][d]
+
+--- hide-polygon ---
+Hidden:
+#hide[
+ #polygon((20%, 0pt),
+ (60%, 0pt),
+ (80%, 2cm),
+ (0%, 2cm),)
+]
+#polygon((20%, 0pt),
+ (60%, 0pt),
+ (80%, 2cm),
+ (0%, 2cm),)
+
+--- hide-rect ---
+#set rect(
+ inset: 8pt,
+ fill: rgb("e4e5ea"),
+ width: 100%,
+)
+
+Hidden:
+#hide[
+#grid(
+ columns: (1fr, 1fr, 2fr),
+ rows: (auto, 40pt),
+ gutter: 3pt,
+ rect[A],
+ rect[B],
+ rect[C],
+ rect(height: 100%)[D],
+)
+]
+#grid(
+ columns: (1fr, 1fr, 2fr),
+ rows: (auto, 40pt),
+ gutter: 3pt,
+ rect[A],
+ rect[B],
+ rect[C],
+ rect(height: 100%)[D],
+)
+
+--- hide-list ---
+Hidden:
+#hide[
+- 1
+- 2
+ 1. A
+ 2. B
+- 3
+]
+
+
+- 1
+- 2
+ 1. A
+ 2. B
+- 3
+
+--- hide-image ---
+Hidden:
+#hide(image("/assets/images/tiger.jpg", width: 5cm, height: 1cm,))
+
+#image("/assets/images/tiger.jpg", width: 5cm, height: 1cm,)
+
+--- issue-622-hide-meta-cite ---
+// Test that metadata of hidden stuff stays available.
+#set cite(style: "chicago-notes")
+
+A pirate. @arrgh \
+#set text(2pt)
+#hide[
+ A @arrgh pirate.
+ #bibliography("/assets/bib/works.bib")
+]
+
+--- issue-622-hide-meta-outline ---
+#set text(8pt)
+#outline()
+#set text(2pt)
+#hide(block(grid(
+ [= A],
+ [= B],
+ block(grid(
+ [= C],
+ [= D],
+ ))
+)))
diff --git a/tests/suite/layout/inline/baseline.typ b/tests/suite/layout/inline/baseline.typ
new file mode 100644
index 00000000..e9f9a645
--- /dev/null
+++ b/tests/suite/layout/inline/baseline.typ
@@ -0,0 +1,17 @@
+// Test baseline handling.
+
+--- baseline-text ---
+Hi #text(1.5em)[You], #text(0.75em)[how are you?]
+
+Our cockatoo was one of the
+#text(baseline: -0.2em)[#box(circle(radius: 2pt)) first]
+#text(baseline: 0.2em)[birds #box(circle(radius: 2pt))]
+that ever learned to mimic a human voice.
+
+--- baseline-box ---
+Hey #box(baseline: 40%, image("/assets/images/tiger.jpg", width: 1.5cm)) there!
+
+--- issue-2214-baseline-math ---
+// The math content should also be affected by the TextElem baseline.
+hello #text(baseline: -5pt)[123 #sym.WW\orld]\
+hello #text(baseline: -5pt)[$123 WW#text[or]$ld]\
diff --git a/tests/suite/layout/inline/bidi.typ b/tests/suite/layout/inline/bidi.typ
new file mode 100644
index 00000000..7da23b41
--- /dev/null
+++ b/tests/suite/layout/inline/bidi.typ
@@ -0,0 +1,77 @@
+// Test bidirectional text and language configuration.
+
+--- bidi-en-he-top-level ---
+// Test reordering with different top-level paragraph directions.
+#let content = par[Text טֶקסט]
+#text(lang: "he", content)
+#text(lang: "de", content)
+
+--- bidi-consecutive-embedded-ltr-runs ---
+// Test that consecutive, embedded LTR runs stay LTR.
+// Here, we have two runs: "A" and italic "B".
+#let content = par[أنت A#emph[B]مطرC]
+#set text(font: ("PT Sans", "Noto Sans Arabic"))
+#text(lang: "ar", content)
+#text(lang: "de", content)
+
+--- bidi-consecutive-embedded-rtl-runs ---
+// Test that consecutive, embedded RTL runs stay RTL.
+// Here, we have three runs: "גֶ", bold "שֶׁ", and "ם".
+#let content = par[Aגֶ#strong[שֶׁ]םB]
+#set text(font: ("Linux Libertine", "Noto Serif Hebrew"))
+#text(lang: "he", content)
+#text(lang: "de", content)
+
+--- bidi-nesting ---
+// Test embedding up to level 4 with isolates.
+#set text(dir: rtl)
+א\u{2066}A\u{2067}Bב\u{2069}?
+
+--- bidi-manual-linebreak ---
+// Test hard line break (leads to two paragraphs in unicode-bidi).
+#set text(lang: "ar", font: ("Noto Sans Arabic", "PT Sans"))
+Life المطر هو الحياة \
+الحياة تمطر is rain.
+
+--- bidi-spacing ---
+// Test spacing.
+L #h(1cm) ריווחR \
+Lריווח #h(1cm) R
+
+--- bidi-obj ---
+// Test inline object.
+#set text(lang: "he")
+קרנפיםRh#box(image("/assets/images/rhino.png", height: 11pt))inoחיים
+
+--- bidi-whitespace-reset ---
+// Test whether L1 whitespace resetting destroys stuff.
+الغالب #h(70pt) ن#" "ة
+
+--- bidi-explicit-dir ---
+// Test explicit dir
+#set text(dir: rtl)
+#text("8:00 - 9:00", dir: ltr) בבוקר
+#linebreak()
+ב #text("12:00 - 13:00", dir: ltr) בצהריים
+
+--- bidi-raw ---
+// Mixing raw
+#set text(lang: "he")
+לדוג. `if a == b:` זה תנאי
+#set raw(lang: "python")
+לדוג. `if a == b:` זה תנאי
+
+#show raw: set text(dir:rtl)
+לתכנת בעברית `אם א == ב:`
+
+--- bidi-vertical ---
+// Test setting a vertical direction.
+// Error: 16-19 text direction must be horizontal
+#set text(dir: ttb)
+
+--- issue-1373-bidi-tofus ---
+// Test that shaping missing characters in both left-to-right and
+// right-to-left directions does not cause a crash.
+#"\u{590}\u{591}\u{592}\u{593}"
+
+#"\u{30000}\u{30001}\u{30002}\u{30003}"
diff --git a/tests/suite/layout/inline/cjk.typ b/tests/suite/layout/inline/cjk.typ
new file mode 100644
index 00000000..0540cd19
--- /dev/null
+++ b/tests/suite/layout/inline/cjk.typ
@@ -0,0 +1,90 @@
+// Test CJK-specific features.
+
+--- text-chinese-basic ---
+// Test basic Chinese text from Wikipedia.
+#set text(font: "Noto Serif CJK SC")
+
+是美国广播公司电视剧《迷失》第3季的第22和23集,也是全剧的第71集和72集
+由执行制作人戴蒙·林道夫和卡尔顿·库斯编剧,导演则是另一名执行制作人杰克·本德
+节目于2007年5月23日在美国和加拿大首播,共计吸引了1400万美国观众收看
+本集加上插播广告一共也持续有两个小时
+
+--- text-cjk-latin-spacing ---
+#set page(width: 50pt + 10pt, margin: (x: 5pt))
+#set text(lang: "zh", font: "Noto Serif CJK SC", cjk-latin-spacing: auto)
+#set par(justify: true)
+
+中文,中12文1中,文12中文
+
+中文,中ab文a中,文ab中文
+
+#set text(cjk-latin-spacing: none)
+
+中文,中12文1中,文12中文
+
+中文,中ab文a中,文ab中文
+
+--- cjk-punctuation-adjustment-1 ---
+#set page(width: 15em)
+
+// In the following example, the space between 》! and ? should be squeezed.
+// because zh-CN follows GB style
+#set text(lang: "zh", region: "CN", font: "Noto Serif CJK SC")
+原来,你也玩《原神》!?
+
+// However, in the following example, the space between 》! and ? should not be squeezed.
+// because zh-TW does not follow GB style
+#set text(lang: "zh", region: "TW", font: "Noto Serif CJK TC")
+原來,你也玩《原神》! ?
+
+#set text(lang: "zh", region: "CN", font: "Noto Serif CJK SC")
+「真的吗?」
+
+#set text(lang: "ja", font: "Noto Serif CJK JP")
+「本当に?」
+
+--- cjk-punctuation-adjustment-2 ---
+#set text(lang: "zh", region: "CN", font: "Noto Serif CJK SC")
+《书名〈章节〉》 // the space between 〉 and 》 should be squeezed
+
+〔茸毛〕:很细的毛 // the space between 〕 and : should be squeezed
+
+--- cjk-punctuation-adjustment-3 ---
+#set page(width: 21em)
+#set text(lang: "zh", region: "CN", font: "Noto Serif CJK SC")
+
+// These examples contain extensive use of Chinese punctuation marks,
+// from 《Which parentheses should be used when applying parentheses?》.
+// link: https://archive.md/2bb1N
+
+
+(〔中〕医、〔中〕药、技)系列评审
+
+(长三角[长江三角洲])(GB/T 16159—2012《汉语拼音正词法基本规则》)
+
+【爱因斯坦(Albert Einstein)】物理学家
+
+〔(2009)民申字第1622号〕
+
+“江南海北长相忆,浅水深山独掩扉。”([唐]刘长卿《会赦后酬主簿所问》)
+
+参看1378页〖象形文字〗。(《现代汉语词典》修订本)
+
+--- issue-2538-cjk-latin-spacing-before-linebreak ---
+// Issue #2538
+#set text(cjk-latin-spacing: auto)
+
+abc字
+
+abc字#linebreak()
+
+abc字#linebreak()
+母
+
+abc字\
+母
+
+--- issue-2650-cjk-latin-spacing-meta ---
+测a试
+
+测#context [a]试
diff --git a/tests/suite/layout/inline/hyphenate.typ b/tests/suite/layout/inline/hyphenate.typ
new file mode 100644
index 00000000..aaabe816
--- /dev/null
+++ b/tests/suite/layout/inline/hyphenate.typ
@@ -0,0 +1,51 @@
+// Test hyphenation.
+
+--- hyphenate ---
+// Test hyphenating english and greek.
+#set text(hyphenate: true)
+#set page(width: auto)
+#grid(
+ columns: (50pt, 50pt),
+ [Warm welcomes to Typst.],
+ text(lang: "el")[διαμερίσματα. \ λατρευτός],
+)
+
+--- hyphenate-off-temporarily ---
+// Test disabling hyphenation for short passages.
+#set page(width: 110pt)
+#set text(hyphenate: true)
+
+Welcome to wonderful experiences. \
+Welcome to `wonderful` experiences. \
+Welcome to #text(hyphenate: false)[wonderful] experiences. \
+Welcome to wonde#text(hyphenate: false)[rf]ul experiences. \
+
+// Test enabling hyphenation for short passages.
+#set text(hyphenate: false)
+Welcome to wonderful experiences. \
+Welcome to wo#text(hyphenate: true)[nd]erful experiences. \
+
+--- hyphenate-between-shape-runs ---
+// Hyphenate between shape runs.
+#set page(width: 80pt)
+#set text(hyphenate: true)
+It's a #emph[Tree]beard.
+
+--- hyphenate-shy ---
+// Test shy hyphens.
+#set text(lang: "de", hyphenate: true)
+#grid(
+ columns: 2 * (20pt,),
+ gutter: 20pt,
+ [Barankauf],
+ [Bar-?ankauf],
+)
+
+--- hyphenate-punctuation ---
+// This sequence would confuse hypher if we passed trailing / leading
+// punctuation instead of just the words. So this tests that we don't
+// do that. The test passes if there's just one hyphenation between
+// "net" and "works".
+#set page(width: 60pt)
+#set text(hyphenate: true)
+#h(6pt) networks, the rest.
diff --git a/tests/suite/layout/inline/justify.typ b/tests/suite/layout/inline/justify.typ
new file mode 100644
index 00000000..e1e15578
--- /dev/null
+++ b/tests/suite/layout/inline/justify.typ
@@ -0,0 +1,170 @@
+--- justify ---
+#set page(width: 180pt)
+#set block(spacing: 5pt)
+#set par(justify: true, first-line-indent: 14pt, leading: 5pt)
+
+This text is justified, meaning that spaces are stretched so that the text
+forms a "block" with flush edges at both sides.
+
+First line indents and hyphenation play nicely with justified text.
+
+--- justify-knuth-story ---
+// LARGE
+#set page(width: auto, height: auto)
+#set par(leading: 4pt, justify: true)
+#set text(font: "New Computer Modern")
+
+#let story = [
+ In olden times when wishing still helped one, there lived a king whose
+ daughters were all beautiful; and the youngest was so beautiful that the sun
+ itself, which has seen so much, was astonished whenever it shone in her face.
+ Close by the king’s castle lay a great dark forest, and under an old lime-tree
+ in the forest was a well, and when the day was very warm, the king’s child
+ went out into the forest and sat down by the side of the cool fountain; and
+ when she was bored she took a golden ball, and threw it up on high and caught
+ it; and this ball was her favorite plaything.
+]
+
+#let column(title, linebreaks, hyphenate) = {
+ rect(inset: 0pt, width: 132pt, fill: rgb("eee"))[
+ #set par(linebreaks: linebreaks)
+ #set text(hyphenate: hyphenate)
+ #strong(title) \ #story
+ ]
+}
+
+#grid(
+ columns: 3,
+ gutter: 10pt,
+ column([Simple without hyphens], "simple", false),
+ column([Simple with hyphens], "simple", true),
+ column([Optimized with hyphens], "optimized", true),
+)
+
+--- justify-manual-linebreak ---
+// Test that lines with hard breaks aren't justified.
+#set par(justify: true)
+A B C \
+D
+
+--- justify-justified-linebreak ---
+// Test forced justification with justified break.
+A B C #linebreak(justify: true)
+D E F #linebreak(justify: true)
+
+--- justify-basically-empty ---
+// Test that there are no hick-ups with justification enabled and
+// basically empty paragraph.
+#set par(justify: true)
+#""
+
+--- justify-shrink-last-line ---
+// Test that the last line can be shrunk
+#set page(width: 155pt)
+#set par(justify: true)
+This text can be fitted in one line.
+
+--- justify-avoid-runts ---
+// Test that runts are avoided when it's not too costly to do so.
+#set page(width: 124pt)
+#set par(justify: true)
+#for i in range(0, 20) {
+ "a b c "
+}
+#"d"
+
+--- justify-no-leading-spaces ---
+// Test that justification cannot lead to a leading space
+#set par(justify: true)
+#set text(size: 12pt)
+#set page(width: 45mm, height: auto)
+
+lorem ipsum 1234, lorem ipsum dolor sit amet
+
+#" leading whitespace should still be displayed"
+
+--- justify-code-blocks ---
+// Test that justification doesn't break code blocks
+#set par(justify: true)
+
+```cpp
+int main() {
+ printf("Hello world\n");
+ return 0;
+}
+```
+
+--- justify-chinese ---
+// In Chinese typography, line length should be multiples of the character size
+// and the line ends should be aligned with each other. Most Chinese
+// publications do not use hanging punctuation at line end.
+#set page(width: auto)
+#set par(justify: true)
+#set text(lang: "zh", font: "Noto Serif CJK SC")
+
+#rect(inset: 0pt, width: 80pt, fill: rgb("eee"))[
+ 中文维基百科使用汉字书写,汉字是汉族或华人的共同文字,是中国大陆、新加坡、马来西亚、台湾、香港、澳门的唯一官方文字或官方文字之一。25.9%,而美国和荷兰则分別占13.7%及8.2%。近年來,中国大陆地区的维基百科编辑者正在迅速增加;
+]
+
+--- justify-japanese ---
+// Japanese typography is more complex, make sure it is at least a bit sensible.
+#set page(width: auto)
+#set par(justify: true)
+#set text(lang: "ja", font: ("Linux Libertine", "Noto Serif CJK JP"))
+#rect(inset: 0pt, width: 80pt, fill: rgb("eee"))[
+ ウィキペディア(英: Wikipedia)は、世界中のボランティアの共同作業によって執筆及び作成されるフリーの多言語インターネット百科事典である。主に寄付に依って活動している非営利団体「ウィキメディア財団」が所有・運営している。
+
+ 専門家によるオンライン百科事典プロジェクトNupedia(ヌーペディア)を前身として、2001年1月、ラリー・サンガーとジミー・ウェールズ(英: Jimmy Donal "Jimbo" Wales)により英語でプロジェクトが開始された。
+]
+
+--- justify-whitespace-adjustment ---
+// Test punctuation whitespace adjustment
+#set page(width: auto)
+#set text(lang: "zh", font: "Noto Serif CJK SC")
+#set par(justify: true)
+#rect(inset: 0pt, width: 80pt, fill: rgb("eee"))[
+ “引号测试”,还,
+
+ 《书名》《测试》下一行
+
+ 《书名》《测试》。
+]
+
+「『引号』」。“‘引号’”。
+
+--- justify-variants ---
+// Test Variants of Mainland China, Hong Kong, and Japan.
+
+// 17 characters a line.
+#set page(width: 170pt + 10pt, margin: (x: 5pt))
+#set text(lang: "zh", font: "Noto Serif CJK SC")
+#set par(justify: true)
+
+孔雀最早见于《山海经》中的《海内经》:“有孔雀。”东汉杨孚著《异物志》记载,岭南:“孔雀,其大如大雁而足高,毛皆有斑纹彩,捕而蓄之,拍手即舞。”
+
+#set text(lang: "zh", region: "hk", font: "Noto Serif CJK TC")
+孔雀最早见于《山海经》中的《海内经》:「有孔雀。」东汉杨孚著《异物志》记载,岭南:「孔雀,其大如大雁而足高,毛皆有斑纹彩,捕而蓄之,拍手即舞。」
+
+--- justify-punctuation-adjustment ---
+// Test punctuation marks adjustment in justified paragraph.
+
+// The test case includes the following scenarios:
+// - Compression of punctuation marks at line start or line end
+// - Adjustment of adjacent punctuation marks
+
+#set page(width: 110pt + 10pt, margin: (x: 5pt))
+#set text(lang: "zh", font: "Noto Serif CJK SC")
+#set par(justify: true)
+
+标注在字间的标点符号(乙式括号省略号以外)通常占一个汉字宽度,使其易于识别、适合配置及排版,有些排版风格完全不对标点宽度进行任何调整。但是为了让文字体裁更加紧凑易读,,,以及执行3.1.4 行首行尾禁则时,就需要对标点符号的宽度进行调整。是否调整取决于……
+
+--- justify-without-justifiables ---
+// Test breaking a line without justifiables.
+#set par(justify: true)
+#block(width: 1cm, fill: aqua, lorem(2))
+
+--- issue-2419-justify-hanging-indent ---
+// Test that combination of justification and hanging indent doesn't result in
+// an underfull first line.
+#set par(hanging-indent: 2.5cm, justify: true)
+#lorem(5)
diff --git a/tests/suite/layout/inline/linebreak.typ b/tests/suite/layout/inline/linebreak.typ
new file mode 100644
index 00000000..2fa29b6c
--- /dev/null
+++ b/tests/suite/layout/inline/linebreak.typ
@@ -0,0 +1,109 @@
+// Test line breaks.
+
+--- linebreak-overflow ---
+// Test overlong word that is not directly after a hard break.
+This is a spaceexceedinglylongy.
+
+--- linebreak-overflow-double ---
+// Test two overlong words in a row.
+Supercalifragilisticexpialidocious Expialigoricmetrioxidation.
+
+--- linebreak-hyphen-nbsp ---
+// Test for non-breaking space and hyphen.
+There are non\u{2011}breaking~characters.
+
+--- linebreak-narrow-nbsp ---
+// Test for narrow non-breaking space.
+#show "_": sym.space.nobreak.narrow
+0.1_g, 1_g, 10_g, 100_g, 1_000_g, 10_000_g, 100_000_g, 1_000_000_g
+
+--- linebreak-shape-run ---
+// Test that there are no unwanted line break opportunities on run change.
+This is partly emp#emph[has]ized.
+
+--- linebreak-manual ---
+Hard #linebreak() break.
+
+--- linebreak-manual-directly-after-automatic ---
+// Test hard break directly after normal break.
+Hard break directly after \ normal break.
+
+--- linebreak-manual-consecutive ---
+// Test consecutive breaks.
+Two consecutive \ \ breaks and three \ \ more.
+
+--- linebreak-manual-trailing-multiple ---
+// Test forcing an empty trailing line.
+Trailing break \ \
+
+--- linebreak-manual-justified ---
+// Test justified breaks.
+#set par(justify: true)
+With a soft #linebreak(justify: true)
+break you can force a break without #linebreak(justify: true)
+breaking justification. #linebreak(justify: false)
+Nice!
+
+--- linebreak-thai ---
+// Test linebreak for East Asian languages
+ทีวีตรวจทานนอร์ทแฟรีเลคเชอร์โกลด์อัลบัมเชอร์รี่เย้วสโตร์กฤษณ์เคลมเยอบีร่าพ่อค้าบลูเบอร์รี่สหัสวรรษโฮปแคนูโยโย่จูนสตรอว์เบอร์รีซื่อบื้อเยนแบ็กโฮเป็นไงโดนัททอมสเตริโอแคนูวิทย์แดรี่โดนัทวิทย์แอปพริคอทเซอร์ไพรส์ไฮบริดกิฟท์อินเตอร์โซนเซอร์วิสเทียมทานโคโยตี้ม็อบเที่ยงคืนบุญคุณ
+
+--- linebreak-cite-punctuation ---
+// Test punctuation after citations.
+#set page(width: 162pt)
+
+They can look for the details in @netwok,
+which is the authoritative source.
+
+#bibliography("/assets/bib/works.bib")
+
+--- linebreak-math-punctuation ---
+// Test punctuation after math equations.
+#set page(width: 85pt)
+
+We prove $1 < 2$. \
+We prove $1 < 2$! \
+We prove $1 < 2$? \
+We prove $1 < 2$, \
+We prove $1 < 2$; \
+We prove $1 < 2$: \
+We prove $1 < 2$- \
+We prove $1 < 2$– \
+We prove $1 < 2$— \
+
+--- linebreak-link ---
+#link("https://example.com/(ab") \
+#link("https://example.com/(ab)") \
+#link("https://example.com/(paren)") \
+#link("https://example.com/paren)") \
+#link("https://hi.com/%%%%%%%%abcdef") \
+
+--- linebreak-link-justify ---
+#set page(width: 240pt)
+#set par(justify: true)
+
+Here's a link https://url.com/data/extern12840%data_urlenc and then there are more
+links #link("www.url.com/data/extern12840%data_urlenc") in my text of links
+http://mydataurl/hash/12098541029831025981024980124124214/incremental/progress%linkdata_information_setup_my_link_just_never_stops_going/on?query=false
+
+--- linebreak-link-end ---
+// Ensure that there's no unconditional break at the end of a link.
+#set page(width: 180pt, height: auto, margin: auto)
+#set text(11pt)
+
+For info see #link("https://myhost.tld").
+
+--- issue-2105-linebreak-tofu ---
+#linebreak()中文
+
+--- issue-3082-chinese-punctuation ---
+#set text(font: "Noto Serif CJK TC", lang: "zh")
+#set page(width: 230pt)
+
+課有手冬,朱得過已誰卜服見以大您即乙太邊良,因且行肉因和拉幸,念姐遠米巴急(abc0),松黃貫誰。
+
+--- issue-80-emoji-linebreak ---
+// Test that there are no linebreaks in composite emoji (issue #80).
+#set page(width: 50pt, height: auto)
+#h(99%) 🏳️‍🌈
+🏳️‍🌈
diff --git a/tests/suite/layout/inline/overhang.typ b/tests/suite/layout/inline/overhang.typ
new file mode 100644
index 00000000..40b0e7f7
--- /dev/null
+++ b/tests/suite/layout/inline/overhang.typ
@@ -0,0 +1,24 @@
+// Test micro-typographical shenanigans.
+
+--- overhang ---
+// Test hanging punctuation.
+// TODO: This test was broken at some point.
+#set page(width: 130pt, margin: 15pt)
+#set par(justify: true, linebreaks: "simple")
+#set text(size: 9pt)
+#rect(inset: 0pt, fill: rgb(0, 0, 0, 0), width: 100%)[
+ This is a little bit of text that builds up to
+ hang-ing hyphens and dash---es and then, you know,
+ some punctuation in the margin.
+]
+
+// Test hanging punctuation with RTL.
+#set text(lang: "he", font: ("PT Sans", "Noto Serif Hebrew"))
+בנייה נכונה של משפטים ארוכים דורשת ידע בשפה. אז בואו נדבר על מזג האוויר.
+
+--- overhang-lone ---
+// Test that lone punctuation doesn't overhang into the margin.
+#set page(margin: 0pt)
+#set align(end)
+#set text(dir: rtl)
+:
diff --git a/tests/suite/layout/inline/shaping.typ b/tests/suite/layout/inline/shaping.typ
new file mode 100644
index 00000000..ec93eb47
--- /dev/null
+++ b/tests/suite/layout/inline/shaping.typ
@@ -0,0 +1,65 @@
+// Test shaping quirks.
+
+--- shaping-script-separation ---
+// Test separation by script.
+#set text(font: ("Linux Libertine", "IBM Plex Sans Devanagari"))
+ABCअपार्टमेंट
+
+// This is how it should look like.
+अपार्टमेंट
+
+// This (without the spaces) is how it would look
+// if we didn't separate by script.
+अ पा र् ट में ट
+
+--- shaping-forced-script-font-feature-inhibited ---
+// A forced `latn` script inhibits Devanagari font features.
+#set text(font: ("Linux Libertine", "IBM Plex Sans Devanagari"), script: "latn")
+ABCअपार्टमेंट
+
+--- shaping-forced-script-font-feature-enabled ---
+// A forced `deva` script enables Devanagari font features.
+#set text(font: ("Linux Libertine", "IBM Plex Sans Devanagari"), script: "deva")
+ABCअपार्टमेंट
+
+--- issue-rtl-safe-to-break-panic ---
+// Test that RTL safe-to-break doesn't panic even though newline
+// doesn't exist in shaping output.
+#set text(dir: rtl, font: "Noto Serif Hebrew")
+\ ט
+
+--- shaping-font-fallback ---
+// Font fallback for emoji.
+A😀B
+
+// Font fallback for entire text.
+دع النص يمطر عليك
+
+// Font fallback in right-to-left text.
+ب🐈😀سم
+
+// Multi-layer font fallback.
+Aب😀🏞سمB
+
+// Font fallback with composed emojis and multiple fonts.
+01️⃣2
+
+// Tofus are rendered with the first font.
+A🐈ዲሞB
+
+--- shaping-emoji-basic ---
+// This should form a three-member family.
+👩‍👩‍👦
+
+// This should form a pride flag.
+🏳️‍🌈
+
+// Skin tone modifier should be applied.
+👍🏿
+
+// This should be a 1 in a box.
+1️⃣
+
+--- shaping-emoji-bad-zwj ---
+// These two shouldn't be affected by a zero-width joiner.
+🏞‍🌋
diff --git a/tests/suite/layout/inline/text.typ b/tests/suite/layout/inline/text.typ
new file mode 100644
index 00000000..e2bc84ef
--- /dev/null
+++ b/tests/suite/layout/inline/text.typ
@@ -0,0 +1,89 @@
+// Test OpenType features.
+
+--- text-kerning ---
+// Test turning kerning off.
+#text(kerning: true)[Tq] \
+#text(kerning: false)[Tq]
+
+--- text-alternates-and-stylistic-sets ---
+// Test alternates and stylistic sets.
+#set text(font: "IBM Plex Serif")
+a vs #text(alternates: true)[a] \
+ß vs #text(stylistic-set: 5)[ß]
+
+--- text-ligatures ---
+// Test ligatures.
+fi vs. #text(ligatures: false)[No fi]
+
+--- text-number-type ---
+// Test number type.
+#set text(number-type: "old-style")
+0123456789 \
+#text(number-type: auto)[0123456789]
+
+--- text-number-width ---
+// Test number width.
+#text(number-width: "proportional")[0123456789] \
+#text(number-width: "tabular")[3456789123] \
+#text(number-width: "tabular")[0123456789]
+
+--- text-slashed-zero-and-fractions ---
+// Test extra number stuff.
+#set text(font: "IBM Plex Serif")
+0 vs. #text(slashed-zero: true)[0] \
+1/2 vs. #text(fractions: true)[1/2]
+
+--- text-features ---
+// Test raw features.
+#text(features: ("smcp",))[Smcp] \
+fi vs. #text(features: (liga: 0))[No fi]
+
+--- text-stylistic-set-bad-type ---
+// Error: 26-31 expected integer or none, found boolean
+#set text(stylistic-set: false)
+
+--- text-stylistic-set-out-of-bounds ---
+// Error: 26-28 stylistic set must be between 1 and 20
+#set text(stylistic-set: 25)
+
+--- text-number-type-bad ---
+// Error: 24-25 expected "lining", "old-style", or auto, found integer
+#set text(number-type: 2)
+
+--- text-features-bad ---
+// Error: 21-26 expected array or dictionary, found boolean
+#set text(features: false)
+
+--- text-features-bad-nested-type ---
+// Error: 21-35 expected string, found boolean
+#set text(features: ("tag", false))
+
+--- text-tracking-negative ---
+// Test tracking.
+#set text(tracking: -0.01em)
+I saw Zoe yӛsterday, on the tram.
+
+--- text-tracking-changed-temporarily ---
+// Test tracking for only part of paragraph.
+I'm in#text(tracking: 0.15em + 1.5pt)[ spaace]!
+
+--- text-tracking-mark-placement ---
+// Test that tracking doesn't disrupt mark placement.
+#set text(font: ("PT Sans", "Noto Serif Hebrew"))
+#set text(tracking: 0.3em)
+טֶקסט
+
+--- text-tracking-arabic ---
+// Test tracking in arabic text (makes no sense whatsoever)
+#set text(tracking: 0.3em)
+النص
+
+--- text-spacing ---
+// Test word spacing.
+#set text(spacing: 1em)
+My text has spaces.
+
+--- text-spacing-relative ---
+// Test word spacing relative to the font's space width.
+#set text(spacing: 50% + 1pt)
+This is tight.
diff --git a/tests/suite/layout/layout.typ b/tests/suite/layout/layout.typ
new file mode 100644
index 00000000..257e478b
--- /dev/null
+++ b/tests/suite/layout/layout.typ
@@ -0,0 +1,14 @@
+--- layout-in-fixed-size-block ---
+// Layout inside a block with certain dimensions should provide those dimensions.
+#set page(height: 120pt)
+#block(width: 60pt, height: 80pt, layout(size => [
+ This block has a width of #size.width and height of #size.height
+]))
+
+--- layout-in-page-call ---
+// Layout without any container should provide the page's dimensions, minus its margins.
+#page(width: 100pt, height: 100pt, {
+ layout(size => [This page has a width of #size.width and height of #size.height ])
+ h(1em)
+ place(left, rect(width: 80pt, stroke: blue))
+})
diff --git a/tests/suite/layout/length.typ b/tests/suite/layout/length.typ
new file mode 100644
index 00000000..68755619
--- /dev/null
+++ b/tests/suite/layout/length.typ
@@ -0,0 +1,69 @@
+--- length-fields ---
+// Test length fields.
+#test((1pt).em, 0.0)
+#test((1pt).abs, 1pt)
+#test((3em).em, 3.0)
+#test((3em).abs, 0pt)
+#test((2em + 2pt).em, 2.0)
+#test((2em + 2pt).abs, 2pt)
+
+--- length-to-unit ---
+// Test length unit conversions.
+#test((500.934pt).pt(), 500.934)
+#test((3.3453cm).cm(), 3.3453)
+#test((4.3452mm).mm(), 4.3452)
+#test((5.345in).inches(), 5.345)
+#test((500.333666999pt).pt(), 500.333666999)
+#test((3.5234354cm).cm(), 3.5234354)
+#test((4.12345678mm).mm(), 4.12345678)
+#test((5.333666999in).inches(), 5.333666999)
+#test((4.123456789123456mm).mm(), 4.123456789123456)
+#test((254cm).mm(), 2540.0)
+#test(calc.round((254cm).inches(), digits: 2), 100.0)
+#test((2540mm).cm(), 254.0)
+#test(calc.round((2540mm).inches(), digits: 2), 100.0)
+#test((100in).pt(), 7200.0)
+#test(calc.round((100in).cm(), digits: 2), 254.0)
+#test(calc.round((100in).mm(), digits: 2), 2540.0)
+#test(5em.abs.cm(), 0.0)
+#test((5em + 6in).abs.inches(), 6.0)
+
+--- length-to-absolute ---
+// Test length `to-absolute` method.
+#set text(size: 12pt)
+#context {
+ test((6pt).to-absolute(), 6pt)
+ test((6pt + 10em).to-absolute(), 126pt)
+ test((10em).to-absolute(), 120pt)
+}
+
+#set text(size: 64pt)
+#context {
+ test((6pt).to-absolute(), 6pt)
+ test((6pt + 10em).to-absolute(), 646pt)
+ test((10em).to-absolute(), 640pt)
+}
+
+--- length-unit-hint ---
+// Error: 1:17-1:19 expected length, found integer: a length needs a unit - did you mean 12pt?
+#set text(size: 12)
+
+--- length-ignore-em-pt-hint ---
+// Error: 2-21 cannot convert a length with non-zero em units (`-6pt + 10.5em`) to pt
+// Hint: 2-21 use `length.abs.pt()` instead to ignore its em component
+#(10.5em - 6pt).pt()
+
+--- length-ignore-em-cm-hint ---
+// Error: 2-12 cannot convert a length with non-zero em units (`3em`) to cm
+// Hint: 2-12 use `length.abs.cm()` instead to ignore its em component
+#(3em).cm()
+
+--- length-ignore-em-mm-hint ---
+// Error: 2-20 cannot convert a length with non-zero em units (`-226.77pt + 93em`) to mm
+// Hint: 2-20 use `length.abs.mm()` instead to ignore its em component
+#(93em - 80mm).mm()
+
+--- length-ignore-em-inches-hint ---
+// Error: 2-24 cannot convert a length with non-zero em units (`432pt + 4.5em`) to inches
+// Hint: 2-24 use `length.abs.inches()` instead to ignore its em component
+#(4.5em + 6in).inches()
diff --git a/tests/suite/layout/limits.typ b/tests/suite/layout/limits.typ
new file mode 100644
index 00000000..e1f0ec5f
--- /dev/null
+++ b/tests/suite/layout/limits.typ
@@ -0,0 +1,32 @@
+// Test how the layout engine reacts when reaching limits like
+// zero, infinity or when dealing with NaN.
+
+--- issue-1216-clamp-panic ---
+#set page(height: 20pt, margin: 0pt)
+#v(22pt)
+#block(fill: red, width: 100%, height: 10pt, radius: 4pt)
+
+--- issue-1918-layout-infinite-length-grid-columns ---
+// Test that passing infinite lengths to drawing primitives does not crash Typst.
+#set page(width: auto, height: auto)
+
+// Error: 58-59 cannot expand into infinite width
+#layout(size => grid(columns: (size.width, size.height))[a][b][c][d])
+
+--- issue-1918-layout-infinite-length-grid-rows ---
+#set page(width: auto, height: auto)
+
+// Error: 17-66 cannot create grid with infinite height
+#layout(size => grid(rows: (size.width, size.height))[a][b][c][d])
+
+--- issue-1918-layout-infinite-length-line ---
+#set page(width: auto, height: auto)
+
+// Error: 17-41 cannot create line with infinite length
+#layout(size => line(length: size.width))
+
+--- issue-1918-layout-infinite-length-polygon ---
+#set page(width: auto, height: auto)
+
+// Error: 17-54 cannot create polygon with infinite size
+#layout(size => polygon((0pt,0pt), (0pt, size.width)))
diff --git a/tests/suite/layout/measure.typ b/tests/suite/layout/measure.typ
new file mode 100644
index 00000000..5f82e915
--- /dev/null
+++ b/tests/suite/layout/measure.typ
@@ -0,0 +1,9 @@
+--- measure ---
+// Test `measure`.
+#let f(lo, hi) = context {
+ let h = measure[Hello].height
+ assert(h > lo)
+ assert(h < hi)
+}
+#text(10pt, f(6pt, 8pt))
+#text(20pt, f(13pt, 14pt))
diff --git a/tests/suite/layout/pad.typ b/tests/suite/layout/pad.typ
new file mode 100644
index 00000000..3a7439d0
--- /dev/null
+++ b/tests/suite/layout/pad.typ
@@ -0,0 +1,30 @@
+// Test the `pad` function.
+
+--- pad-basic ---
+// Use for indentation.
+#pad(left: 10pt, [Indented!])
+
+// All sides together.
+#set rect(inset: 0pt)
+#rect(fill: conifer,
+ pad(10pt, right: 20pt,
+ rect(width: 20pt, height: 20pt, fill: rgb("eb5278"))
+ )
+)
+
+Hi #box(pad(left: 10pt)[A]) there
+
+--- pad-expanding-contents ---
+// Pad can grow.
+#pad(left: 10pt, right: 10pt)[PL #h(1fr) PR]
+
+--- pad-followed-by-content ---
+// Test that the pad element doesn't consume the whole region.
+#set page(height: 6cm)
+#align(left)[Before]
+#pad(10pt, image("/assets/images/tiger.jpg"))
+#align(right)[After]
+
+--- pad-adding-to-100-percent ---
+// Test that padding adding up to 100% does not panic.
+#pad(50%)[]
diff --git a/tests/suite/layout/page.typ b/tests/suite/layout/page.typ
new file mode 100644
index 00000000..a529b429
--- /dev/null
+++ b/tests/suite/layout/page.typ
@@ -0,0 +1,231 @@
+// Test the page class.
+
+--- page-call-empty ---
+// Just empty page.
+// Should result in auto-sized page, just like nothing.
+#page[]
+
+--- page-call-styled-empty ---
+// Just empty page with styles.
+// Should result in one conifer-colored A11 page.
+#page("a11", flipped: true, fill: conifer)[]
+
+--- page-call-followed-by-pagebreak ---
+// Just page followed by pagebreak.
+// Should result in one forest-colored A11 page and one auto-sized page.
+#page("a11", flipped: true, fill: forest)[]
+#pagebreak()
+
+--- page-set-forces-break ---
+// Set width and height.
+// Should result in one high and one wide page.
+#set page(width: 80pt, height: 80pt)
+#[#set page(width: 40pt);High]
+#[#set page(height: 40pt);Wide]
+
+// Flipped predefined paper.
+#[#set page(paper: "a11", flipped: true);Flipped A11]
+
+--- page-set-in-container ---
+#box[
+ // Error: 4-18 page configuration is not allowed inside of containers
+ #set page("a4")
+]
+
+--- page-set-empty ---
+// Empty with styles
+// Should result in one conifer-colored A11 page.
+#set page("a11", flipped: true, fill: conifer)
+
+--- page-set-only-pagebreak ---
+// Empty with styles and then pagebreak
+// Should result in two forest-colored pages.
+#set page(fill: forest)
+#pagebreak()
+
+--- page-set-override-thrice ---
+// Empty with multiple page styles.
+// Should result in a small white page.
+#set page("a4")
+#set page("a5")
+#set page(width: 1cm, height: 1cm)
+
+--- page-set-override-and-mix ---
+// Empty with multiple page styles.
+// Should result in one eastern-colored A11 page.
+#set page("a4")
+#set page("a5")
+#set page("a11", flipped: true, fill: eastern)
+#set text(font: "Roboto", white)
+#smallcaps[Typst]
+
+--- page-large ---
+#set page("a4")
+
+--- page-fill ---
+// Test page fill.
+#set page(width: 80pt, height: 40pt, fill: eastern)
+#text(15pt, font: "Roboto", fill: white, smallcaps[Typst])
+#page(width: 40pt, fill: none, margin: (top: 10pt, rest: auto))[Hi]
+
+--- page-margin-uniform ---
+// Set all margins at once.
+#[
+ #set page(height: 20pt, margin: 5pt)
+ #place(top + left)[TL]
+ #place(bottom + right)[BR]
+]
+
+--- page-margin-individual ---
+// Set individual margins.
+#set page(height: 40pt)
+#[#set page(margin: (left: 0pt)); #align(left)[Left]]
+#[#set page(margin: (right: 0pt)); #align(right)[Right]]
+#[#set page(margin: (top: 0pt)); #align(top)[Top]]
+#[#set page(margin: (bottom: 0pt)); #align(bottom)[Bottom]]
+
+// Ensure that specific margins override general margins.
+#[#set page(margin: (rest: 0pt, left: 20pt)); Overridden]
+
+--- page-margin-inside-outside-override ---
+#set page(height: 100pt, margin: (inside: 30pt, outside: 20pt))
+#set par(justify: true)
+#set text(size: 8pt)
+
+#page(margin: (x: 20pt), {
+ set align(center + horizon)
+ text(20pt, strong[Title])
+ v(2em, weak: true)
+ text(15pt)[Author]
+})
+
+= Introduction
+#lorem(35)
+
+--- page-margin-inside ---
+#set page(margin: (inside: 30pt))
+#rect(width: 100%)[Bound]
+#pagebreak()
+#rect(width: 100%)[Left]
+
+--- page-margin-inside-with-binding ---
+// Test setting the binding explicitly.
+#set page(binding: right, margin: (inside: 30pt))
+#rect(width: 100%)[Bound]
+#pagebreak()
+#rect(width: 100%)[Right]
+
+--- page-margin-binding-from-text-lang ---
+// Test setting the binding implicitly.
+#set page(margin: (inside: 30pt))
+#set text(lang: "he")
+#rect(width: 100%)[Bound]
+#pagebreak()
+#rect(width: 100%)[Right]
+
+--- page-margin-left-and-outside ---
+// Error: 19-44 `inside` and `outside` are mutually exclusive with `left` and `right`
+#set page(margin: (left: 1cm, outside: 2cm))
+
+--- page-margin-binding-bad ---
+// Error: 20-23 must be `left` or `right`
+#set page(binding: top)
+
+--- page-marginals ---
+#set page(
+ paper: "a8",
+ margin: (x: 15pt, y: 30pt),
+ header: {
+ text(eastern)[*Typst*]
+ h(1fr)
+ text(0.8em)[_Chapter 1_]
+ },
+ footer: context align(center)[\~ #counter(page).display() \~],
+ background: context if counter(page).get().first() <= 2 {
+ place(center + horizon, circle(radius: 1cm, fill: luma(90%)))
+ }
+)
+
+But, soft! what light through yonder window breaks? It is the east, and Juliet
+is the sun. Arise, fair sun, and kill the envious moon, Who is already sick and
+pale with grief, That thou her maid art far more fair than she: Be not her maid,
+since she is envious; Her vestal livery is but sick and green And none but fools
+do wear it; cast it off. It is my lady, O, it is my love! O, that she knew she
+were! She speaks yet she says nothing: what of that? Her eye discourses; I will
+answer it.
+
+#set page(header: none, height: auto, margin: (top: 15pt, bottom: 25pt))
+The END.
+
+--- page-number-align-top-right ---
+#set page(
+ height: 100pt,
+ margin: 30pt,
+ numbering: "(1)",
+ number-align: top + right,
+)
+
+#block(width: 100%, height: 100%, fill: aqua.lighten(50%))
+
+--- page-number-align-bottom-left ---
+#set page(
+ height: 100pt,
+ margin: 30pt,
+ numbering: "[1]",
+ number-align: bottom + left,
+)
+
+#block(width: 100%, height: 100%, fill: aqua.lighten(50%))
+
+--- page-number-align-left-horizon ---
+// Error: 25-39 expected `top` or `bottom`, found horizon
+#set page(number-align: left + horizon)
+
+--- page-numbering-pdf-label ---
+#set page(margin: (bottom: 20pt, rest: 10pt))
+#let filler = lorem(20)
+
+// (i) - (ii). No style opt. because of suffix.
+#set page(numbering: "(i)")
+#filler
+#pagebreak()
+#filler
+
+// 3 - 4. Style opt. Page Label should use /D style.
+#set page(numbering: "1")
+#filler
+#pagebreak()
+#filler
+
+// I - IV. Style opt. Page Label should use /R style and start at 1 again.
+#set page(numbering: "I / I")
+#counter(page).update(1)
+#filler
+#pagebreak()
+#filler
+#pagebreak()
+#filler
+#pagebreak()
+#filler
+
+// Pre: ほ, Pre: ろ, Pre: は, Pre: に. No style opt. Uses prefix field entirely.
+// Counter update without numbering change.
+#set page(numbering: "Pre: い")
+#filler
+#pagebreak()
+#filler
+#counter(page).update(2)
+#filler
+#pagebreak()
+#filler
+#pagebreak()
+#filler
+
+// aa & ba. Style opt only for values <= 26. Page Label uses lower alphabet style.
+// Repeats letter each 26 pages or uses numbering directly as prefix.
+#set page(numbering: "a")
+#counter(page).update(27)
+#filler
+#pagebreak()
+#counter(page).update(53)
+#filler
diff --git a/tests/suite/layout/pagebreak.typ b/tests/suite/layout/pagebreak.typ
new file mode 100644
index 00000000..a1734596
--- /dev/null
+++ b/tests/suite/layout/pagebreak.typ
@@ -0,0 +1,143 @@
+// Test forced page breaks.
+
+--- pagebreak ---
+// Just a pagebreak.
+// Should result in two pages.
+#pagebreak()
+
+--- pagebreak-around-set-page ---
+// Pagebreak, empty with styles and then pagebreak
+// Should result in one auto-sized page and two conifer-colored 2cm wide pages.
+#pagebreak()
+#set page(width: 2cm, fill: conifer)
+#pagebreak()
+
+--- pagebreak-weak-after-set-page ---
+// Two text bodies separated with and surrounded by weak pagebreaks.
+// Should result in two aqua-colored pages.
+#set page(fill: aqua)
+#pagebreak(weak: true)
+First
+#pagebreak(weak: true)
+Second
+#pagebreak(weak: true)
+
+--- pagebreak-set-page-mixed ---
+// Test a combination of pagebreaks, styled pages and pages with bodies.
+// Should result in three five pages, with the fourth one being forest-colored.
+#set page(width: 80pt, height: 30pt)
+#[#set page(width: 60pt); First]
+#pagebreak()
+#pagebreak()
+Third
+#page(height: 20pt, fill: forest)[]
+Fif#[#set page();th]
+
+--- pagebreak-followed-by-page-call ---
+// Test hard and weak pagebreak followed by page with body.
+// Should result in three navy-colored pages.
+#set page(fill: navy)
+#set text(fill: white)
+First
+#pagebreak()
+#page[Second]
+#pagebreak(weak: true)
+#page[Third]
+
+--- pagebreak-in-container ---
+#box[
+ // Error: 4-15 pagebreaks are not allowed inside of containers
+ #pagebreak()
+]
+
+--- pagebreak-weak-place ---
+// After place
+// Should result in three pages.
+First
+#pagebreak(weak: true)
+#place(right)[placed A]
+#pagebreak(weak: true)
+Third
+
+--- pagebreak-weak-meta ---
+// After only ignorables & invisibles
+// Should result in two pages.
+First
+#pagebreak(weak: true)
+#counter(page).update(1)
+#metadata("Some")
+#pagebreak(weak: true)
+Second
+
+--- pagebreak-meta ---
+// After only ignorables, but regular break
+// Should result in three pages.
+First
+#pagebreak()
+#counter(page).update(1)
+#metadata("Some")
+#pagebreak()
+Third
+
+--- pagebreak-to ---
+#set page(width: 80pt, height: 30pt)
+First
+#pagebreak(to: "odd")
+Third
+#pagebreak(to: "even")
+Fourth
+#pagebreak(to: "even")
+Sixth
+#pagebreak()
+Seventh
+#pagebreak(to: "odd")
+#page[Ninth]
+
+--- pagebreak-to-auto-sized ---
+#set page(width: auto, height: auto)
+
+// Test with auto-sized page.
+First
+#pagebreak(to: "odd")
+Third
+
+--- pagebreak-to-multiple-pages ---
+#set page(height: 30pt, width: 80pt)
+
+// Test when content extends to more than one page
+First
+
+Second
+
+#pagebreak(to: "odd")
+
+Third
+
+--- issue-2134-pagebreak-bibliography ---
+// Test weak pagebreak before bibliography.
+#pagebreak(weak: true)
+#bibliography("/assets/bib/works.bib")
+
+--- issue-2095-pagebreak-numbering ---
+// The empty page 2 should not have a page number
+#set page(numbering: none)
+This and next page should not be numbered
+
+#pagebreak(weak: true, to: "odd")
+
+#set page(numbering: "1")
+#counter(page).update(1)
+
+This page should
+
+--- issue-2162-pagebreak-set-style ---
+// The styles should not be applied to the pagebreak empty page,
+// it should only be applied after that.
+#pagebreak(to: "even") // We should now skip to page 2
+
+Some text on page 2
+
+#pagebreak(to: "even") // We should now skip to page 4
+
+#set page(fill: orange) // This sets the color of the page starting from page 4
+Some text on page 4
diff --git a/tests/suite/layout/place.typ b/tests/suite/layout/place.typ
new file mode 100644
index 00000000..b8765e93
--- /dev/null
+++ b/tests/suite/layout/place.typ
@@ -0,0 +1,226 @@
+// Test the `place` function.
+
+--- place-basic ---
+#set page("a8")
+#place(bottom + center)[© Typst]
+
+= Placement
+#place(right, image("/assets/images/tiger.jpg", width: 1.8cm))
+Hi there. This is \
+a placed element. \
+Unfortunately, \
+the line breaks still had to be inserted manually.
+
+#stack(
+ rect(fill: eastern, height: 10pt, width: 100%),
+ place(right, dy: 1.5pt)[ABC],
+ rect(fill: conifer, height: 10pt, width: 80%),
+ rect(fill: forest, height: 10pt, width: 100%),
+ 10pt,
+ block[
+ #place(center, dx: -7pt, dy: -5pt)[Hello]
+ #place(center, dx: 7pt, dy: 5pt)[Hello]
+ Hello #h(1fr) Hello
+ ]
+)
+
+--- place-block-spacing ---
+// Test how the placed element interacts with paragraph spacing around it.
+#set page("a8", height: 60pt)
+
+First
+
+#place(bottom + right)[Placed]
+
+Second
+
+--- place-background ---
+#set page(paper: "a10", flipped: true)
+#set text(fill: white)
+#place(
+ dx: -10pt,
+ dy: -10pt,
+ image(
+ "/assets/images/tiger.jpg",
+ fit: "cover",
+ width: 100% + 20pt,
+ height: 100% + 20pt,
+ )
+)
+#align(bottom + right)[
+ _Welcome to_ #underline[*Tigerland*]
+]
+
+--- place-float ---
+#set page(height: 140pt)
+#set place(clearance: 5pt)
+#lorem(6)
+#place(auto, float: true, rect[A])
+#place(auto, float: true, rect[B])
+#place(auto, float: true, rect[C])
+#place(auto, float: true, rect[D])
+
+--- place-float-missing ---
+// Error: 2-20 automatic positioning is only available for floating placement
+// Hint: 2-20 you can enable floating placement with `place(float: true, ..)`
+#place(auto)[Hello]
+
+--- place-float-center-horizon ---
+// Error: 2-45 floating placement must be `auto`, `top`, or `bottom`
+#place(center + horizon, float: true)[Hello]
+
+--- place-float-horizon ---
+// Error: 2-36 floating placement must be `auto`, `top`, or `bottom`
+#place(horizon, float: true)[Hello]
+
+--- place-float-default ---
+// Error: 2-27 floating placement must be `auto`, `top`, or `bottom`
+#place(float: true)[Hello]
+
+--- place-float-right ---
+// Error: 2-34 floating placement must be `auto`, `top`, or `bottom`
+#place(right, float: true)[Hello]
+
+--- place-float-columns ---
+// LARGE
+#set page(height: 200pt, width: 300pt)
+#show: columns.with(2)
+
+= Introduction
+#figure(
+ placement: bottom,
+ caption: [A glacier],
+ image("/assets/images/glacier.jpg", width: 50%),
+)
+#lorem(45)
+#figure(
+ placement: top,
+ caption: [A rectangle],
+ rect[Hello!],
+)
+#lorem(20)
+
+--- place-float-figure ---
+// LARGE
+#set page(height: 250pt, width: 150pt)
+
+= Introduction
+#lorem(10) #footnote[Lots of Latin]
+
+#figure(
+ placement: bottom,
+ caption: [A glacier #footnote[Lots of Ice]],
+ image("/assets/images/glacier.jpg", width: 80%),
+)
+
+#lorem(40)
+
+#figure(
+ placement: top,
+ caption: [An important],
+ image("/assets/images/diagram.svg", width: 80%),
+)
+
+--- place-bottom-in-box ---
+#box(
+ fill: aqua,
+ width: 30pt,
+ height: 30pt,
+ place(bottom,
+ place(line(start: (0pt, 0pt), end: (20pt, 0pt), stroke: red + 3pt))
+ )
+)
+
+--- place-horizon-in-boxes ---
+#box(
+ fill: aqua,
+ width: 30pt,
+ height: 30pt,
+ {
+ box(fill: yellow, {
+ [Hello]
+ place(horizon, line(start: (0pt, 0pt), end: (20pt, 0pt), stroke: red + 2pt))
+ })
+ place(horizon, line(start: (0pt, 0pt), end: (20pt, 0pt), stroke: green + 3pt))
+ }
+)
+
+--- place-bottom-right-in-box ---
+#box(fill: aqua)[
+ #place(bottom + right)[Hi]
+ Hello World \
+ How are \
+ you?
+]
+
+--- place-top-left-in-box ---
+#box(fill: aqua)[
+ #place(top + left, dx: 50%, dy: 50%)[Hi]
+ #v(30pt)
+ #line(length: 50pt)
+]
+
+--- issue-place-base ---
+// Test that placement is relative to container and not itself.
+#set page(height: 80pt, margin: 0pt)
+#place(right, dx: -70%, dy: 20%, [First])
+#place(left, dx: 20%, dy: 60%, [Second])
+#place(center + horizon, dx: 25%, dy: 25%, [Third])
+
+--- issue-1368-place-pagebreak ---
+// Test placing on an already full page.
+// It shouldn't result in a page break.
+#set page(height: 40pt)
+#block(height: 100%)
+#place(bottom + right)[Hello world]
+
+--- issue-2199-place-spacing-bottom ---
+// Test that placed elements don't add extra block spacing.
+#show figure: set block(spacing: 4em)
+
+Paragraph before float.
+#figure(rect(), placement: bottom)
+Paragraph after float.
+
+--- issue-2199-place-spacing-default ---
+#show place: set block(spacing: 4em)
+
+Paragraph before place.
+#place(rect())
+Paragraph after place.
+
+--- issue-2595-float-overlap ---
+#set page(height: 80pt)
+
+Start.
+
+#place(auto, float: true, [
+ #block(height: 100%, width: 100%, fill: aqua)
+])
+
+#place(auto, float: true, [
+ #block(height: 100%, width: 100%, fill: red)
+])
+
+#lorem(20)
+
+--- issue-2715-float-order ---
+#set page(height: 180pt)
+#set figure(placement: auto)
+
+#figure(
+ rect(height: 60pt),
+ caption: [Rectangle I],
+)
+
+#figure(
+ rect(height: 50pt),
+ caption: [Rectangle II],
+)
+
+#figure(
+ circle(),
+ caption: [Circle],
+)
+
+#lorem(20)
diff --git a/tests/suite/layout/relative.typ b/tests/suite/layout/relative.typ
new file mode 100644
index 00000000..958aee3d
--- /dev/null
+++ b/tests/suite/layout/relative.typ
@@ -0,0 +1,7 @@
+--- relative-fields ---
+// Test relative length fields.
+#test((100% + 2em + 2pt).ratio, 100%)
+#test((100% + 2em + 2pt).length, 2em + 2pt)
+#test((100% + 2pt).length, 2pt)
+#test((100% + 2pt - 2pt).length, 0pt)
+#test((56% + 2pt - 56%).ratio, 0%)
diff --git a/tests/suite/layout/repeat.typ b/tests/suite/layout/repeat.typ
new file mode 100644
index 00000000..5c82fc19
--- /dev/null
+++ b/tests/suite/layout/repeat.typ
@@ -0,0 +1,44 @@
+// Test the `repeat` function.
+
+--- repeat-basic ---
+// Test multiple repeats.
+#let sections = (
+ ("Introduction", 1),
+ ("Approach", 1),
+ ("Evaluation", 3),
+ ("Discussion", 15),
+ ("Related Work", 16),
+ ("Conclusion", 253),
+)
+
+#for section in sections [
+ #section.at(0) #box(width: 1fr, repeat[.]) #section.at(1) \
+]
+
+--- repeat-dots-rtl ---
+// Test dots with RTL.
+#set text(lang: "ar")
+مقدمة #box(width: 1fr, repeat[.]) 15
+
+--- repeat-empty ---
+// Test empty repeat.
+A #box(width: 1fr, repeat[]) B
+
+--- repeat-unboxed ---
+// Test unboxed repeat.
+#repeat(rect(width: 2em, height: 1em))
+
+--- repeat-align-and-dir ---
+// Test single repeat in both directions.
+A#box(width: 1fr, repeat(rect(width: 6em, height: 0.7em)))B
+
+#set align(center)
+A#box(width: 1fr, repeat(rect(width: 6em, height: 0.7em)))B
+
+#set text(dir: rtl)
+ريجين#box(width: 1fr, repeat(rect(width: 4em, height: 0.7em)))سون
+
+--- repeat-unrestricted ---
+// Error: 2:2-2:13 repeat with no size restrictions
+#set page(width: auto)
+#repeat(".")
diff --git a/tests/suite/layout/spacing.typ b/tests/suite/layout/spacing.typ
new file mode 100644
index 00000000..430e9779
--- /dev/null
+++ b/tests/suite/layout/spacing.typ
@@ -0,0 +1,38 @@
+// Test the `h` and `v` functions.
+
+--- spacing-h-and-v ---
+// Linebreak and leading-sized weak spacing are equivalent.
+#box[A \ B] #box[A #v(0.65em, weak: true) B]
+
+// Eating up soft spacing.
+Inv#h(0pt)isible
+
+// Multiple spacings in a row.
+Add #h(10pt) #h(10pt) up
+
+// Relative to area.
+#let x = 25% - 4pt
+|#h(x)|#h(x)|#h(x)|#h(x)|
+
+// Fractional.
+| #h(1fr) | #h(2fr) | #h(1fr) |
+
+--- spacing-rtl ---
+// Test RTL spacing.
+#set text(dir: rtl)
+A #h(10pt) B \
+A #h(1fr) B
+
+--- spacing-missing-amount ---
+// Missing spacing.
+// Error: 10-13 missing argument: amount
+Totally #h() ignored
+
+--- issue-3624-spacing-behaviour ---
+// Test that metadata after spacing does not force a new paragraph.
+#{
+ h(1em)
+ counter(heading).update(4)
+ [Hello ]
+ counter(heading).display()
+}
diff --git a/tests/suite/layout/stack.typ b/tests/suite/layout/stack.typ
new file mode 100644
index 00000000..1eca52c9
--- /dev/null
+++ b/tests/suite/layout/stack.typ
@@ -0,0 +1,82 @@
+// Test stack layouts.
+
+--- stack-basic ---
+// Test stacks with different directions.
+#let widths = (
+ 30pt, 20pt, 40pt, 15pt,
+ 30pt, 50%, 20pt, 100%,
+)
+
+#let shaded(i, w) = {
+ let v = (i + 1) * 10%
+ rect(width: w, height: 10pt, fill: rgb(v, v, v))
+}
+
+#let items = for (i, w) in widths.enumerate() {
+ (align(right, shaded(i, w)),)
+}
+
+#set page(width: 50pt, margin: 0pt)
+#stack(dir: btt, ..items)
+
+--- stack-spacing ---
+// Test spacing.
+#set page(width: 50pt, margin: 0pt)
+
+#let x = square(size: 10pt, fill: eastern)
+#stack(
+ spacing: 5pt,
+ stack(dir: rtl, spacing: 5pt, x, x, x),
+ stack(dir: ltr, x, 20%, x, 20%, x),
+ stack(dir: ltr, spacing: 5pt, x, x, 7pt, 3pt, x),
+)
+
+--- stack-overflow ---
+// Test overflow.
+#set page(width: 50pt, height: 30pt, margin: 0pt)
+#box(stack(
+ rect(width: 40pt, height: 20pt, fill: conifer),
+ rect(width: 30pt, height: 13pt, fill: forest),
+))
+
+--- stack-fr ---
+#set page(height: 3.5cm)
+#stack(
+ dir: ltr,
+ spacing: 1fr,
+ ..for c in "ABCDEFGHI" {([#c],)}
+)
+
+Hello
+#v(2fr)
+from #h(1fr) the #h(1fr) wonderful
+#v(1fr)
+World! 🌍
+
+--- stack-rtl-align-and-fr ---
+// Test aligning things in RTL stack with align function & fr units.
+#set page(width: 50pt, margin: 5pt)
+#set block(spacing: 5pt)
+#set text(8pt)
+#stack(dir: rtl, 1fr, [A], 1fr, [B], [C])
+#stack(dir: rtl,
+ align(center, [A]),
+ align(left, [B]),
+ [C],
+)
+
+--- issue-1240-stack-h-fr ---
+// This issue is sort of horrible: When you write `h(1fr)` in a `stack` instead
+// of directly `1fr`, things go awry. To fix this, we now transparently detect
+// h/v children.
+#stack(dir: ltr, [a], 1fr, [b], 1fr, [c])
+#stack(dir: ltr, [a], h(1fr), [b], h(1fr), [c])
+
+--- issue-1240-stack-v-fr ---
+#set page(height: 60pt)
+#stack(
+ dir: ltr,
+ spacing: 1fr,
+ stack([a], 1fr, [b]),
+ stack([a], v(1fr), [b]),
+)
diff --git a/tests/suite/layout/table.typ b/tests/suite/layout/table.typ
new file mode 100644
index 00000000..7eec46a1
--- /dev/null
+++ b/tests/suite/layout/table.typ
@@ -0,0 +1,284 @@
+// Test tables.
+
+--- table-empty ---
+#table()
+
+--- table-newlines ---
+#set page(height: 70pt)
+#set table(fill: (x, y) => if calc.even(x + y) { rgb("aaa") })
+
+#table(
+ columns: (1fr,) * 3,
+ stroke: 2pt + rgb("333"),
+ [A], [B], [C], [], [], [D \ E \ F \ \ \ G], [H],
+)
+
+--- table-fill-basic ---
+#table(columns: 3, stroke: none, fill: green, [A], [B], [C])
+
+--- table-fill-bad ---
+// Error: 14-19 expected color, gradient, pattern, none, array, or function, found string
+#table(fill: "hey")
+
+--- table-align-array ---
+// Test alignment with array.
+#table(
+ columns: (1fr, 1fr, 1fr),
+ align: (left, center, right),
+ [A], [B], [C]
+)
+
+// Test empty array.
+#set align(center)
+#table(
+ columns: (1fr, 1fr, 1fr),
+ align: (),
+ [A], [B], [C]
+)
+
+--- table-inset ---
+// Test inset.
+#table(
+ columns: 3,
+ inset: 10pt,
+ [A], [B], [C]
+)
+
+#table(
+ columns: 3,
+ inset: (y: 10pt),
+ [A], [B], [C]
+)
+
+#table(
+ columns: 3,
+ inset: (left: 20pt, rest: 10pt),
+ [A], [B], [C]
+)
+
+#table(
+ columns: 2,
+ inset: (
+ left: 20pt,
+ right: 5pt,
+ top: 10pt,
+ bottom: 3pt,
+ ),
+ [A],
+ [B],
+)
+
+#table(
+ columns: 3,
+ fill: (x, y) => (if y == 0 { aqua } else { orange }).darken(x * 15%),
+ inset: (x, y) => (left: if x == 0 { 0pt } else { 5pt }, right: if x == 0 { 5pt } else { 0pt }, y: if y == 0 { 0pt } else { 5pt }),
+ [A], [B], [C],
+ [A], [B], [C],
+)
+
+#table(
+ columns: 3,
+ inset: (0pt, 5pt, 10pt),
+ fill: (x, _) => aqua.darken(x * 15%),
+ [A], [B], [C],
+)
+
+--- table-inset-fold ---
+// Test inset folding
+#set table(inset: 10pt)
+#set table(inset: (left: 0pt))
+
+#table(
+ fill: red,
+ inset: (right: 0pt),
+ table.cell(inset: (top: 0pt))[a]
+)
+
+--- table-gutters ---
+// Test interaction with gutters.
+#table(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
+
+#table(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ row-gutter: 5pt,
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
+
+#table(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ column-gutter: 5pt,
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
+
+#table(
+ columns: (3em, 3em),
+ fill: (x, y) => (red, blue).at(calc.rem(x, 2)),
+ align: (x, y) => (left, right).at(calc.rem(y, 2)),
+ gutter: 5pt,
+ [A], [B],
+ [C], [D],
+ [E], [F],
+ [G], [H]
+)
+
+--- table-cell-override ---
+// Cell override
+#table(
+ align: left,
+ fill: red,
+ stroke: blue,
+ columns: 2,
+ [AAAAA], [BBBBB],
+ [A], [B],
+ table.cell(align: right)[C], [D],
+ align(right)[E], [F],
+ align(horizon)[G], [A\ A\ A],
+ table.cell(align: horizon)[G2], [A\ A\ A],
+ table.cell(inset: 0pt)[I], [F],
+ [H], table.cell(fill: blue)[J]
+)
+
+--- table-cell-show ---
+// Cell show rule
+#show table.cell: it => [Zz]
+
+#table(
+ align: left,
+ fill: red,
+ stroke: blue,
+ columns: 2,
+ [AAAAA], [BBBBB],
+ [A], [B],
+ table.cell(align: right)[C], [D],
+ align(right)[E], [F],
+ align(horizon)[G], [A\ A\ A]
+)
+
+--- table-cell-show-and-override ---
+#show table.cell: it => (it.align, it.fill)
+#table(
+ align: left,
+ row-gutter: 5pt,
+ [A],
+ table.cell(align: right)[B],
+ table.cell(fill: aqua)[B],
+)
+
+--- table-cell-set ---
+// Cell set rules
+#set table.cell(align: center)
+#show table.cell: it => (it.align, it.fill, it.inset)
+#set table.cell(inset: 20pt)
+#table(
+ align: left,
+ row-gutter: 5pt,
+ [A],
+ table.cell(align: right)[B],
+ table.cell(fill: aqua)[B],
+)
+
+--- table-cell-folding ---
+// Test folding per-cell properties (align and inset)
+#table(
+ columns: (1fr, 1fr),
+ rows: (2.5em, auto),
+ align: right,
+ fill: (x, y) => (green, aqua).at(calc.rem(x + y, 2)),
+ [Top], table.cell(align: bottom)[Bot],
+ table.cell(inset: (bottom: 0pt))[Bot], table.cell(inset: (bottom: 0pt))[Bot]
+)
+
+--- table-cell-align-override ---
+// Test overriding outside alignment
+#set align(bottom + right)
+#table(
+ columns: (1fr, 1fr),
+ rows: 2em,
+ align: auto,
+ fill: green,
+ [BR], [BR],
+ table.cell(align: left, fill: aqua)[BL], table.cell(align: top, fill: red.lighten(50%))[TR]
+)
+
+--- table-cell-various-overrides ---
+#table(
+ columns: 2,
+ fill: green,
+ align: right,
+ [*Name*], [*Data*],
+ table.cell(fill: blue)[J.], [Organizer],
+ table.cell(align: center)[K.], [Leader],
+ [M.], table.cell(inset: 0pt)[Player]
+)
+
+--- table-cell-show-emph ---
+#{
+ show table.cell: emph
+ table(
+ columns: 2,
+ [Person], [Animal],
+ [John], [Dog]
+ )
+}
+
+--- table-cell-show-based-on-position ---
+// Style based on position
+#{
+ show table.cell: it => {
+ if it.y == 0 {
+ strong(it)
+ } else if it.x == 1 {
+ emph(it)
+ } else {
+ it
+ }
+ }
+ table(
+ columns: 3,
+ gutter: 3pt,
+ [Name], [Age], [Info],
+ [John], [52], [Nice],
+ [Mary], [50], [Cool],
+ [Jake], [49], [Epic]
+ )
+}
+
+--- grid-cell-in-table ---
+// Error: 8-19 cannot use `grid.cell` as a table cell; use `table.cell` instead
+#table(grid.cell[])
+
+--- issue-183-table-lines ---
+// Ensure no empty lines before a table that doesn't fit into the first page.
+#set page(height: 50pt)
+
+Hello
+#table(
+ columns: 4,
+ [1], [2], [3], [4]
+)
+
+--- issue-1388-table-row-missing ---
+// Test that a table row isn't wrongly treated like a gutter row.
+#set page(height: 70pt)
+#table(
+ rows: 16pt,
+ ..range(6).map(str).flatten(),
+)
diff --git a/tests/suite/layout/transform.typ b/tests/suite/layout/transform.typ
new file mode 100644
index 00000000..50a6d417
--- /dev/null
+++ b/tests/suite/layout/transform.typ
@@ -0,0 +1,106 @@
+// Test transformations.
+
+--- transform-tex-logo ---
+// Test creating the TeX and XeTeX logos.
+#let size = 11pt
+#let tex = {
+ [T]
+ h(-0.14 * size)
+ box(move(dy: 0.22 * size)[E])
+ h(-0.12 * size)
+ [X]
+}
+
+#let xetex = {
+ [X]
+ h(-0.14 * size)
+ box(scale(x: -100%, move(dy: 0.26 * size)[E]))
+ h(-0.14 * size)
+ [T]
+ h(-0.14 * size)
+ box(move(dy: 0.26 * size)[E])
+ h(-0.12 * size)
+ [X]
+}
+
+#set text(font: "New Computer Modern", size)
+Neither #tex, \
+nor #xetex!
+
+--- transform-rotate-and-scale ---
+// Test combination of scaling and rotation.
+#set page(height: 80pt)
+#align(center + horizon,
+ rotate(20deg, scale(70%, image("/assets/images/tiger.jpg")))
+)
+
+--- transform-rotate-origin ---
+// Test setting rotation origin.
+#rotate(10deg, origin: top + left,
+ image("/assets/images/tiger.jpg", width: 50%)
+)
+
+--- transform-scale-origin ---
+// Test setting scaling origin.
+#let r = rect(width: 100pt, height: 10pt, fill: forest)
+#set page(height: 65pt)
+#box(scale(r, x: 50%, y: 200%, origin: left + top))
+#box(scale(r, x: 50%, origin: center))
+#box(scale(r, x: 50%, y: 200%, origin: right + bottom))
+
+--- transform-rotate ---
+// Test that rotation impact layout.
+#set page(width: 200pt)
+#set rotate(reflow: true)
+
+#let one(angle) = box(fill: aqua, rotate(angle)[Test Text])
+#for angle in range(0, 360, step: 15) {
+ one(angle * 1deg)
+}
+
+--- transform-rotate-relative-sizing ---
+// Test relative sizing in rotated boxes.
+#set page(width: 200pt, height: 200pt)
+#set text(size: 32pt)
+#let rotated(body) = box(rotate(
+ 90deg,
+ box(stroke: 0.5pt, height: 20%, clip: true, body)
+))
+
+#set rotate(reflow: false)
+Hello #rotated[World]!\
+
+#set rotate(reflow: true)
+Hello #rotated[World]!
+
+--- transform-scale ---
+// Test that scaling impact layout.
+#set page(width: 200pt)
+#set text(size: 32pt)
+#let scaled(body) = box(scale(
+ x: 20%,
+ y: 40%,
+ body
+))
+
+#set scale(reflow: false)
+Hello #scaled[World]!
+
+#set scale(reflow: true)
+Hello #scaled[World]!
+
+--- transform-scale-relative-sizing ---
+// Test relative sizing in scaled boxes.
+#set page(width: 200pt, height: 200pt)
+#set text(size: 32pt)
+#let scaled(body) = box(scale(
+ x: 60%,
+ y: 40%,
+ box(stroke: 0.5pt, width: 30%, clip: true, body)
+))
+
+#set scale(reflow: false)
+Hello #scaled[World]!\
+
+#set scale(reflow: true)
+Hello #scaled[World]!