summaryrefslogtreecommitdiff
path: root/tests/suite
diff options
context:
space:
mode:
Diffstat (limited to 'tests/suite')
-rw-r--r--tests/suite/foundations/array.typ494
-rw-r--r--tests/suite/foundations/assert.typ40
-rw-r--r--tests/suite/foundations/bytes.typ31
-rw-r--r--tests/suite/foundations/calc.typ261
-rw-r--r--tests/suite/foundations/content.typ120
-rw-r--r--tests/suite/foundations/context.typ65
-rw-r--r--tests/suite/foundations/datetime.typ93
-rw-r--r--tests/suite/foundations/dict.typ266
-rw-r--r--tests/suite/foundations/duration.typ103
-rw-r--r--tests/suite/foundations/eval.typ54
-rw-r--r--tests/suite/foundations/float.typ66
-rw-r--r--tests/suite/foundations/int.typ73
-rw-r--r--tests/suite/foundations/label.typ70
-rw-r--r--tests/suite/foundations/panic.typ14
-rw-r--r--tests/suite/foundations/plugin.typ47
-rw-r--r--tests/suite/foundations/repr.typ57
-rw-r--r--tests/suite/foundations/str.typ315
-rw-r--r--tests/suite/foundations/type.typ25
-rw-r--r--tests/suite/foundations/version.typ47
-rw-r--r--tests/suite/introspection/counter.typ78
-rw-r--r--tests/suite/introspection/here.typ3
-rw-r--r--tests/suite/introspection/locate.typ32
-rw-r--r--tests/suite/introspection/query.typ267
-rw-r--r--tests/suite/introspection/state.typ63
-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
-rw-r--r--tests/suite/loading/csv.typ27
-rw-r--r--tests/suite/loading/json.typ16
-rw-r--r--tests/suite/loading/read.typ12
-rw-r--r--tests/suite/loading/toml.typ41
-rw-r--r--tests/suite/loading/xml.typ28
-rw-r--r--tests/suite/loading/yaml.typ17
-rw-r--r--tests/suite/math/accent.typ33
-rw-r--r--tests/suite/math/alignment.typ34
-rw-r--r--tests/suite/math/attach.typ130
-rw-r--r--tests/suite/math/cancel.typ38
-rw-r--r--tests/suite/math/cases.typ13
-rw-r--r--tests/suite/math/class.typ47
-rw-r--r--tests/suite/math/delimited.typ64
-rw-r--r--tests/suite/math/equation.typ212
-rw-r--r--tests/suite/math/frac.typ43
-rw-r--r--tests/suite/math/interactions.typ95
-rw-r--r--tests/suite/math/mat.typ163
-rw-r--r--tests/suite/math/multiline.typ109
-rw-r--r--tests/suite/math/op.typ30
-rw-r--r--tests/suite/math/primes.typ50
-rw-r--r--tests/suite/math/root.typ45
-rw-r--r--tests/suite/math/size.typ9
-rw-r--r--tests/suite/math/spacing.typ59
-rw-r--r--tests/suite/math/style.typ34
-rw-r--r--tests/suite/math/syntax.typ34
-rw-r--r--tests/suite/math/text.typ45
-rw-r--r--tests/suite/math/underover.typ21
-rw-r--r--tests/suite/math/vec.typ27
-rw-r--r--tests/suite/model/bibliography.typ55
-rw-r--r--tests/suite/model/cite.typ92
-rw-r--r--tests/suite/model/document.typ36
-rw-r--r--tests/suite/model/emph-strong.typ74
-rw-r--r--tests/suite/model/enum.typ156
-rw-r--r--tests/suite/model/figure.typ220
-rw-r--r--tests/suite/model/footnote.typ182
-rw-r--r--tests/suite/model/heading.typ80
-rw-r--r--tests/suite/model/link.typ77
-rw-r--r--tests/suite/model/list.typ147
-rw-r--r--tests/suite/model/numbering.typ103
-rw-r--r--tests/suite/model/outline.typ176
-rw-r--r--tests/suite/model/par.typ78
-rw-r--r--tests/suite/model/quote.typ86
-rw-r--r--tests/suite/model/ref.typ56
-rw-r--r--tests/suite/model/terms.typ77
-rw-r--r--tests/suite/playground.typ1
-rw-r--r--tests/suite/scripting/blocks.typ143
-rw-r--r--tests/suite/scripting/call.typ200
-rw-r--r--tests/suite/scripting/closure.typ223
-rw-r--r--tests/suite/scripting/destructuring.typ357
-rw-r--r--tests/suite/scripting/field.typ76
-rw-r--r--tests/suite/scripting/for.typ135
-rw-r--r--tests/suite/scripting/get-rule.typ67
-rw-r--r--tests/suite/scripting/if.typ134
-rw-r--r--tests/suite/scripting/import.typ334
-rw-r--r--tests/suite/scripting/include.typ32
-rw-r--r--tests/suite/scripting/let.typ143
-rw-r--r--tests/suite/scripting/loop.typ142
-rw-r--r--tests/suite/scripting/methods.typ51
-rw-r--r--tests/suite/scripting/module.typ13
-rw-r--r--tests/suite/scripting/modules/chap1.typ8
-rw-r--r--tests/suite/scripting/modules/chap2.typ10
-rw-r--r--tests/suite/scripting/modules/cycle1.typ5
-rw-r--r--tests/suite/scripting/modules/cycle2.typ5
-rw-r--r--tests/suite/scripting/ops.typ465
-rw-r--r--tests/suite/scripting/params.typ69
-rw-r--r--tests/suite/scripting/recursion.typ55
-rw-r--r--tests/suite/scripting/return.typ87
-rw-r--r--tests/suite/scripting/while.typ59
-rw-r--r--tests/suite/styling/fold.typ19
-rw-r--r--tests/suite/styling/set.typ96
-rw-r--r--tests/suite/styling/show-set.typ70
-rw-r--r--tests/suite/styling/show-text.typ133
-rw-r--r--tests/suite/styling/show-where.typ89
-rw-r--r--tests/suite/styling/show.typ262
-rw-r--r--tests/suite/symbols/symbol.typ38
-rw-r--r--tests/suite/syntax/backtracking.typ32
-rw-r--r--tests/suite/syntax/comment.typ43
-rw-r--r--tests/suite/syntax/embedded.typ9
-rw-r--r--tests/suite/syntax/escape.typ36
-rw-r--r--tests/suite/syntax/newlines.typ77
-rw-r--r--tests/suite/syntax/numbers.typ32
-rw-r--r--tests/suite/syntax/shorthand.typ61
-rw-r--r--tests/suite/text/case.typ11
-rw-r--r--tests/suite/text/coma.typ26
-rw-r--r--tests/suite/text/copy-paste.typ8
-rw-r--r--tests/suite/text/deco.typ85
-rw-r--r--tests/suite/text/edge.typ39
-rw-r--r--tests/suite/text/em.typ33
-rw-r--r--tests/suite/text/font.typ66
-rw-r--r--tests/suite/text/lang.typ74
-rw-r--r--tests/suite/text/lorem.typ32
-rw-r--r--tests/suite/text/raw.typ630
-rw-r--r--tests/suite/text/shift.typ19
-rw-r--r--tests/suite/text/smallcaps.typ3
-rw-r--r--tests/suite/text/smartquote.typ122
-rw-r--r--tests/suite/text/space.typ60
-rw-r--r--tests/suite/visualize/circle.typ69
-rw-r--r--tests/suite/visualize/color.typ331
-rw-r--r--tests/suite/visualize/ellipse.typ31
-rw-r--r--tests/suite/visualize/gradient.typ631
-rw-r--r--tests/suite/visualize/image.typ122
-rw-r--r--tests/suite/visualize/line.typ92
-rw-r--r--tests/suite/visualize/path.typ52
-rw-r--r--tests/suite/visualize/pattern.typ131
-rw-r--r--tests/suite/visualize/polygon.typ51
-rw-r--r--tests/suite/visualize/rect.typ107
-rw-r--r--tests/suite/visualize/square.typ146
-rw-r--r--tests/suite/visualize/stroke.typ171
175 files changed, 18364 insertions, 0 deletions
diff --git a/tests/suite/foundations/array.typ b/tests/suite/foundations/array.typ
new file mode 100644
index 00000000..3992d75e
--- /dev/null
+++ b/tests/suite/foundations/array.typ
@@ -0,0 +1,494 @@
+// Test arrays.
+
+--- array-basic-syntax ---
+#set page(width: 150pt)
+
+// Empty.
+#()
+
+// Not an array, just a parenthesized expression.
+#(1)
+
+// One item and trailing comma.
+#(-1,)
+
+// No trailing comma.
+#(true, false)
+
+// Multiple lines and items and trailing comma.
+#("1"
+ , rgb("002")
+ ,)
+
+--- array-bad-token ---
+// Error: 4-6 unexpected end of block comment
+#(1*/2)
+
+--- array-bad-number-suffix ---
+// Error: 6-8 invalid number suffix: u
+#(1, 1u 2)
+
+--- array-leading-comma ---
+// Error: 3-4 unexpected comma
+#(,1)
+
+--- array-incomplete-pair ---
+// Missing expression makes named pair incomplete, making this an empty array.
+// Error: 5 expected expression
+#(a:)
+
+--- array-named-pair ---
+// Named pair after this is already identified as an array.
+// Error: 6-10 expected expression, found named pair
+#(1, b: 2)
+
+--- array-keyed-pair ---
+// Keyed pair after this is already identified as an array.
+// Error: 6-14 expected expression, found keyed pair
+#(1, "key": 2)
+
+--- array-bad-conversion-from-string ---
+// Error: 8-15 expected array, bytes, or version, found string
+#array("hello")
+
+--- spread-into-array ---
+// Test spreading into array and dictionary.
+#{
+ let l = (1, 2, 3)
+ let r = (5, 6, 7)
+ test((..l, 4, ..r), range(1, 8))
+ test((..none), ())
+}
+
+--- spread-dict-into-array ---
+// Error: 9-17 cannot spread dictionary into array
+#(1, 2, ..(a: 1))
+
+--- array-len ---
+// Test the `len` method.
+#test(().len(), 0)
+#test(("A", "B", "C").len(), 3)
+
+--- array-at-lvalue ---
+// Test lvalue and rvalue access.
+#{
+ let array = (1, 2)
+ array.at(1) += 5 + array.at(0)
+ test(array, (1, 8))
+}
+
+--- array-first-and-at-lvalue ---
+// Test different lvalue method.
+#{
+ let array = (1, 2, 3)
+ array.first() = 7
+ array.at(1) *= 8
+ test(array, (7, 16, 3))
+}
+
+--- array-at-out-of-bounds ---
+// Test rvalue out of bounds.
+// Error: 2-17 array index out of bounds (index: 5, len: 3) and no default value was specified
+#(1, 2, 3).at(5)
+
+--- array-at-out-of-bounds-negative ---
+// Error: 2-18 array index out of bounds (index: -4, len: 3) and no default value was specified
+#(1, 2, 3).at(-4)
+
+--- array-at-out-of-bounds-lvalue ---
+// Test lvalue out of bounds.
+#{
+ let array = (1, 2, 3)
+ // Error: 3-14 array index out of bounds (index: 3, len: 3)
+ array.at(3) = 5
+}
+
+--- array-at-with-default ---
+// Test default value.
+#test((1, 2, 3).at(2, default: 5), 3)
+#test((1, 2, 3).at(3, default: 5), 5)
+
+--- array-remove-with-default ---
+// Test remove with default value.
+
+#{
+ let array = (1, 2, 3)
+ test(array.remove(2, default: 5), 3)
+}
+
+#{
+ let array = (1, 2, 3)
+ test(array.remove(3, default: 5), 5)
+}
+
+--- array-range ---
+// Test the `range` function.
+#test(range(4), (0, 1, 2, 3))
+#test(range(1, 4), (1, 2, 3))
+#test(range(-4, 2), (-4, -3, -2, -1, 0, 1))
+#test(range(10, 5), ())
+#test(range(10, step: 3), (0, 3, 6, 9))
+#test(range(1, 4, step: 1), (1, 2, 3))
+#test(range(1, 8, step: 2), (1, 3, 5, 7))
+#test(range(5, 2, step: -1), (5, 4, 3))
+#test(range(10, 0, step: -3), (10, 7, 4, 1))
+
+--- array-range-end-missing ---
+// Error: 2-9 missing argument: end
+#range()
+
+--- array-range-float-invalid ---
+// Error: 11-14 expected integer, found float
+#range(1, 2.0)
+
+--- array-range-bad-step-type ---
+// Error: 17-22 expected integer, found string
+#range(4, step: "one")
+
+--- array-range-step-zero ---
+// Error: 18-19 number must not be zero
+#range(10, step: 0)
+
+--- array-bad-method-lvalue ---
+// Test bad lvalue.
+// Error: 2:3-2:14 cannot mutate a temporary value
+#let array = (1, 2, 3)
+#(array.len() = 4)
+
+--- array-unknown-method-lvalue ---
+// Test bad lvalue.
+// Error: 2:9-2:13 type array has no method `yolo`
+#let array = (1, 2, 3)
+#(array.yolo() = 4)
+
+--- array-negative-indices ---
+// Test negative indices.
+#{
+ let array = (1, 2, 3, 4)
+ test(array.at(0), 1)
+ test(array.at(-1), 4)
+ test(array.at(-2), 3)
+ test(array.at(-3), 2)
+ test(array.at(-4), 1)
+}
+
+--- array-first-and-last ---
+// The the `first` and `last` methods.
+#test((1,).first(), 1)
+#test((2,).last(), 2)
+#test((1, 2, 3).first(), 1)
+#test((1, 2, 3).last(), 3)
+
+--- array-first-empty ---
+// Error: 2-12 array is empty
+#().first()
+
+--- array-last-empty ---
+// Error: 2-11 array is empty
+#().last()
+
+--- array-push-and-pop ---
+// Test the `push` and `pop` methods.
+#{
+ let tasks = (a: (1, 2, 3), b: (4, 5, 6))
+ test(tasks.at("a").pop(), 3)
+ tasks.b.push(7)
+ test(tasks.a, (1, 2))
+ test(tasks.at("b"), (4, 5, 6, 7))
+}
+
+--- array-insert-and-remove ---
+// Test the `insert` and `remove` methods.
+#{
+ let array = (0, 1, 2, 4, 5)
+ array.insert(3, 3)
+ test(array, range(6))
+ array.remove(1)
+ test(array, (0, 2, 3, 4, 5))
+}
+
+--- array-insert-missing-index ---
+// Error: 2:2-2:18 missing argument: index
+#let numbers = ()
+#numbers.insert()
+
+--- array-slice ---
+// Test the `slice` method.
+#test((1, 2, 3, 4).slice(2), (3, 4))
+#test(range(10).slice(2, 6), (2, 3, 4, 5))
+#test(range(10).slice(4, count: 3), (4, 5, 6))
+#test(range(10).slice(-5, count: 2), (5, 6))
+#test((1, 2, 3).slice(2, -2), ())
+#test((1, 2, 3).slice(-2, 2), (2,))
+#test((1, 2, 3).slice(-3, 2), (1, 2))
+#test("ABCD".split("").slice(1, -1).join("-"), "A-B-C-D")
+
+--- array-slice-out-of-bounds ---
+// Error: 2-30 array index out of bounds (index: 12, len: 10)
+#range(10).slice(9, count: 3)
+
+--- array-slice-out-of-bounds-negative ---
+// Error: 2-24 array index out of bounds (index: -4, len: 3)
+#(1, 2, 3).slice(0, -4)
+
+--- array-position ---
+// Test the `position` method.
+#test(("Hi", "❤️", "Love").position(s => s == "❤️"), 1)
+#test(("Bye", "💘", "Apart").position(s => s == "❤️"), none)
+#test(("A", "B", "CDEF", "G").position(v => v.len() > 2), 2)
+
+--- array-filter ---
+// Test the `filter` method.
+#test(().filter(calc.even), ())
+#test((1, 2, 3, 4).filter(calc.even), (2, 4))
+#test((7, 3, 2, 5, 1).filter(x => x < 5), (3, 2, 1))
+
+--- array-map ---
+// Test the `map` method.
+#test(().map(x => x * 2), ())
+#test((2, 3).map(x => x * 2), (4, 6))
+
+--- array-fold ---
+// Test the `fold` method.
+#test(().fold("hi", grid), "hi")
+#test((1, 2, 3, 4).fold(0, (s, x) => s + x), 10)
+
+--- array-fold-closure-without-params ---
+// Error: 20-22 unexpected argument
+#(1, 2, 3).fold(0, () => none)
+
+--- array-sum ---
+// Test the `sum` method.
+#test(().sum(default: 0), 0)
+#test(().sum(default: []), [])
+#test((1, 2, 3).sum(), 6)
+
+--- array-sum-empty ---
+// Error: 2-10 cannot calculate sum of empty array with no default
+#().sum()
+
+--- array-product ---
+// Test the `product` method.
+#test(().product(default: 0), 0)
+#test(().product(default: []), [])
+#test(([ab], 3).product(), [ab]*3)
+#test((1, 2, 3).product(), 6)
+
+--- array-product-empty ---
+// Error: 2-14 cannot calculate product of empty array with no default
+#().product()
+
+--- array-rev ---
+// Test the `rev` method.
+#test(range(3).rev(), (2, 1, 0))
+
+--- array-join ---
+// Test the `join` method.
+#test(().join(), none)
+#test((1,).join(), 1)
+#test(("a", "b", "c").join(), "abc")
+#test("(" + ("a", "b", "c").join(", ") + ")", "(a, b, c)")
+
+--- array-join-bad-values ---
+// Error: 2-22 cannot join boolean with boolean
+#(true, false).join()
+
+--- array-join-bad-separator ---
+// Error: 2-20 cannot join string with integer
+#("a", "b").join(1)
+
+--- array-join-content ---
+// Test joining content.
+#([One], [Two], [Three]).join([, ], last: [ and ]).
+
+--- array-intersperse ---
+// Test the `intersperse` method
+#test(().intersperse("a"), ())
+#test((1,).intersperse("a"), (1,))
+#test((1, 2).intersperse("a"), (1, "a", 2))
+#test((1, 2, "b").intersperse("a"), (1, "a", 2, "a", "b"))
+
+--- array-chunks ---
+// Test the `chunks` method.
+#test(().chunks(10), ())
+#test((1, 2, 3).chunks(10), ((1, 2, 3),))
+#test((1, 2, 3, 4, 5, 6).chunks(3), ((1, 2, 3), (4, 5, 6)))
+#test((1, 2, 3, 4, 5, 6, 7, 8).chunks(3), ((1, 2, 3), (4, 5, 6), (7, 8)))
+
+#test(().chunks(10, exact: true), ())
+#test((1, 2, 3).chunks(10, exact: true), ())
+#test((1, 2, 3, 4, 5, 6).chunks(3, exact: true), ((1, 2, 3), (4, 5, 6)))
+#test((1, 2, 3, 4, 5, 6, 7, 8).chunks(3, exact: true), ((1, 2, 3), (4, 5, 6)))
+
+--- array-chunks-size-zero ---
+// Error: 19-20 number must be positive
+#(1, 2, 3).chunks(0)
+
+--- array-chunks-size-negative ---
+// Error: 19-21 number must be positive
+#(1, 2, 3).chunks(-5)
+
+--- array-sorted ---
+// Test the `sorted` method.
+#test(().sorted(), ())
+#test(().sorted(key: x => x), ())
+#test(((true, false) * 10).sorted(), (false,) * 10 + (true,) * 10)
+#test(("it", "the", "hi", "text").sorted(), ("hi", "it", "text", "the"))
+#test(("I", "the", "hi", "text").sorted(key: x => x), ("I", "hi", "text", "the"))
+#test(("I", "the", "hi", "text").sorted(key: x => x.len()), ("I", "hi", "the", "text"))
+#test((2, 1, 3, 10, 5, 8, 6, -7, 2).sorted(), (-7, 1, 2, 2, 3, 5, 6, 8, 10))
+#test((2, 1, 3, -10, -5, 8, 6, -7, 2).sorted(key: x => x), (-10, -7, -5, 1, 2, 2, 3, 6, 8))
+#test((2, 1, 3, -10, -5, 8, 6, -7, 2).sorted(key: x => x * x), (1, 2, 2, 3, -5, 6, -7, 8, -10))
+
+--- array-sorted-key-function-positional-1 ---
+// Error: 12-18 unexpected argument
+#().sorted(x => x)
+
+--- array-zip ---
+// Test the `zip` method.
+#test(().zip(()), ())
+#test((1,).zip(()), ())
+#test((1,).zip((2,)), ((1, 2),))
+#test((1, 2).zip((3, 4)), ((1, 3), (2, 4)))
+#test((1, 2, 3, 4).zip((5, 6)), ((1, 5), (2, 6)))
+#test(((1, 2), 3).zip((4, 5)), (((1, 2), 4), (3, 5)))
+#test((1, "hi").zip((true, false)), ((1, true), ("hi", false)))
+#test((1, 2, 3).zip((3, 4, 5), (6, 7, 8)), ((1, 3, 6), (2, 4, 7), (3, 5, 8)))
+#test(().zip((), ()), ())
+#test((1,).zip((2,), (3,)), ((1, 2, 3),))
+#test((1, 2, 3).zip(), ((1,), (2,), (3,)))
+#test(array.zip(()), ())
+
+--- array-enumerate ---
+// Test the `enumerate` method.
+#test(().enumerate(), ())
+#test(().enumerate(start: 5), ())
+#test(("a", "b", "c").enumerate(), ((0, "a"), (1, "b"), (2, "c")))
+#test(("a", "b", "c").enumerate(start: 1), ((1, "a"), (2, "b"), (3, "c")))
+#test(("a", "b", "c").enumerate(start: 42), ((42, "a"), (43, "b"), (44, "c")))
+#test(("a", "b", "c").enumerate(start: -7), ((-7, "a"), (-6, "b"), (-5, "c")))
+
+--- array-dedup ---
+// Test the `dedup` method.
+#test(().dedup(), ())
+#test((1,).dedup(), (1,))
+#test((1, 1).dedup(), (1,))
+#test((1, 2, 1).dedup(), (1, 2))
+#test(("Jane", "John", "Eric").dedup(), ("Jane", "John", "Eric"))
+#test(("Jane", "John", "Eric", "John").dedup(), ("Jane", "John", "Eric"))
+
+--- array-dedup-key ---
+// Test the `dedup` method with the `key` argument.
+#test((1, 2, 3, 4, 5, 6).dedup(key: x => calc.rem(x, 2)), (1, 2))
+#test((1, 2, 3, 4, 5, 6).dedup(key: x => calc.rem(x, 3)), (1, 2, 3))
+#test(("Hello", "World", "Hi", "There").dedup(key: x => x.len()), ("Hello", "Hi"))
+#test(("Hello", "World", "Hi", "There").dedup(key: x => x.at(0)), ("Hello", "World", "There"))
+
+--- array-to-dict ---
+// Test the `to-dict` method.
+#test(().to-dict(), (:))
+#test((("a", 1), ("b", 2), ("c", 3)).to-dict(), (a: 1, b: 2, c: 3))
+#test((("a", 1), ("b", 2), ("c", 3), ("b", 4)).to-dict(), (a: 1, b: 4, c: 3))
+
+--- array-to-dict-bad-item-type ---
+// Error: 2-16 expected (str, any) pairs, found integer
+#(1,).to-dict()
+
+--- array-to-dict-bad-pair-length-1 ---
+// Error: 2-19 expected pairs of length 2, found length 1
+#((1,),).to-dict()
+
+--- array-to-dict-bad-pair-length-3 ---
+// Error: 2-26 expected pairs of length 2, found length 3
+#(("key",1,2),).to-dict()
+
+--- array-to-dict-bad-key-type ---
+// Error: 2-21 expected key of type str, found integer
+#((1, 2),).to-dict()
+
+--- array-zip-positional-and-named-argument ---
+// Error: 13-30 unexpected argument: val
+#().zip((), val: "applicable")
+
+--- array-sorted-bad-key ---
+// Error: 32-37 cannot divide by zero
+#(1, 2, 0, 3).sorted(key: x => 5 / x)
+
+--- array-sorted-uncomparable ---
+// Error: 2-26 cannot compare content and content
+#([Hi], [There]).sorted()
+
+--- array-sorted-uncomparable-lengths ---
+// Error: 2-26 cannot compare 3em with 2pt
+#(1pt, 2pt, 3em).sorted()
+
+--- array-sorted-key-function-positional-2 ---
+// Error: 42-52 unexpected argument
+#((k: "a", v: 2), (k: "b", v: 1)).sorted(it => it.v)
+
+--- issue-3014-mix-array-dictionary ---
+// Error: 8-17 expected expression, found named pair
+#(box, fill: red)
+
+--- issue-3154-array-first-empty ---
+#{
+ let array = ()
+ // Error: 3-16 array is empty
+ array.first()
+}
+
+--- issue-3154-array-first-mutable-empty ---
+#{
+ let array = ()
+ // Error: 3-16 array is empty
+ array.first() = 9
+}
+
+--- issue-3154-array-last-empty ---
+#{
+ let array = ()
+ // Error: 3-15 array is empty
+ array.last()
+}
+
+--- issue-3154-array-last-mutable-empty ---
+#{
+ let array = ()
+ // Error: 3-15 array is empty
+ array.last() = 9
+}
+
+--- issue-3154-array-at-out-of-bounds ---
+#{
+ let array = (1,)
+ // Error: 3-14 array index out of bounds (index: 1, len: 1) and no default value was specified
+ array.at(1)
+}
+
+--- issue-3154-array-at-out-of-bounds-default ---
+#{
+ let array = (1,)
+ test(array.at(1, default: 0), 0)
+}
+
+--- issue-3154-array-at-out-of-bounds-mutable ---
+#{
+ let array = (1,)
+ // Error: 3-14 array index out of bounds (index: 1, len: 1)
+ array.at(1) = 9
+}
+
+--- issue-3154-array-at-out-of-bounds-mutable-default ---
+#{
+ let array = (1,)
+ // Error: 3-26 array index out of bounds (index: 1, len: 1)
+ array.at(1, default: 0) = 9
+}
+
+--- array-unopened ---
+// Error: 2-3 unclosed delimiter
+#{)}
+
+--- array-unclosed ---
+// Error: 3-4 unclosed delimiter
+#{(}
diff --git a/tests/suite/foundations/assert.typ b/tests/suite/foundations/assert.typ
new file mode 100644
index 00000000..5de0f387
--- /dev/null
+++ b/tests/suite/foundations/assert.typ
@@ -0,0 +1,40 @@
+--- assert-fail ---
+// Test failing assertions.
+// Error: 2-16 assertion failed
+#assert(1 == 2)
+
+--- assert-fail-message ---
+// Test failing assertions.
+// Error: 2-51 assertion failed: two is smaller than one
+#assert(2 < 1, message: "two is smaller than one")
+
+--- assert-bad-type ---
+// Test failing assertions.
+// Error: 9-15 expected boolean, found string
+#assert("true")
+
+--- assert-eq-fail ---
+// Test failing assertions.
+// Error: 2-19 equality assertion failed: value 10 was not equal to 11
+#assert.eq(10, 11)
+
+--- assert-eq-fail-message ---
+// Test failing assertions.
+// Error: 2-55 equality assertion failed: 10 and 12 are not equal
+#assert.eq(10, 12, message: "10 and 12 are not equal")
+
+--- assert-ne-fail ---
+// Test failing assertions.
+// Error: 2-19 inequality assertion failed: value 11 was equal to 11
+#assert.ne(11, 11)
+
+--- assert-ne-fail-message ---
+// Test failing assertions.
+// Error: 2-57 inequality assertion failed: must be different from 11
+#assert.ne(11, 11, message: "must be different from 11")
+
+--- assert-success ---
+// Test successful assertions.
+#assert(5 > 3)
+#assert.eq(15, 15)
+#assert.ne(10, 12)
diff --git a/tests/suite/foundations/bytes.typ b/tests/suite/foundations/bytes.typ
new file mode 100644
index 00000000..c7089278
--- /dev/null
+++ b/tests/suite/foundations/bytes.typ
@@ -0,0 +1,31 @@
+// Test the bytes type.
+
+--- bytes-basic ---
+#let data = read("/assets/images/rhino.png", encoding: none)
+#test(data.len(), 232243)
+#test(data.slice(0, count: 5), bytes((137, 80, 78, 71, 13)))
+#test(str(data.slice(1, 4)), "PNG")
+#test(repr(data), "bytes(232243)")
+
+--- bytes-string-conversion ---
+#test(str(bytes(range(0x41, 0x50))), "ABCDEFGHIJKLMNO")
+
+--- bytes-array-conversion ---
+#test(array(bytes("Hello")), (0x48, 0x65, 0x6C, 0x6C, 0x6F))
+
+--- bytes-addition ---
+// Test addition and joining.
+#test(bytes((1, 2)) + bytes(()), bytes((1, 2)))
+#test(bytes((1, 2)) + bytes((3, 4)), bytes((1, 2, 3, 4)))
+#test(bytes(()) + bytes((3, 4)), bytes((3, 4)))
+
+--- bytes-joining ---
+#test(str({
+ bytes("Hello")
+ bytes((0x20,))
+ bytes("World")
+}), "Hello World")
+
+--- bytes-bad-conversion-from-dict ---
+// Error: 8-14 expected string, array, or bytes, found dictionary
+#bytes((a: 1))
diff --git a/tests/suite/foundations/calc.typ b/tests/suite/foundations/calc.typ
new file mode 100644
index 00000000..e702be9f
--- /dev/null
+++ b/tests/suite/foundations/calc.typ
@@ -0,0 +1,261 @@
+--- calc-round ---
+#test(calc.round(calc.e, digits: 2), 2.72)
+#test(calc.round(calc.pi, digits: 2), 3.14)
+
+--- calc-abs ---
+// Test the `abs` function.
+#test(calc.abs(-3), 3)
+#test(calc.abs(3), 3)
+#test(calc.abs(-0.0), 0.0)
+#test(calc.abs(0.0), -0.0)
+#test(calc.abs(-3.14), 3.14)
+#test(calc.abs(50%), 50%)
+#test(calc.abs(-25%), 25%)
+
+--- cals-abs-bad-type ---
+// Error: 11-22 expected integer, float, length, angle, ratio, or fraction, found string
+#calc.abs("no number")
+
+--- calc-even-and-odd ---
+// Test the `even` and `odd` functions.
+#test(calc.even(2), true)
+#test(calc.odd(2), false)
+#test(calc.odd(-1), true)
+#test(calc.even(-11), false)
+
+--- calc-rem ---
+// Test the `rem` function.
+#test(calc.rem(1, 1), 0)
+#test(calc.rem(5, 3), 2)
+#test(calc.rem(5, -3), 2)
+#test(calc.rem(22.5, 10), 2.5)
+#test(calc.rem(9, 4.5), 0)
+
+--- calc-rem-divisor-zero-1 ---
+// Error: 14-15 divisor must not be zero
+#calc.rem(5, 0)
+
+--- calc-rem-divisor-zero-2 ---
+// Error: 16-19 divisor must not be zero
+#calc.rem(3.0, 0.0)
+
+--- calc-div-euclid ---
+// Test the `div-euclid` function.
+#test(calc.div-euclid(7, 3), 2)
+#test(calc.div-euclid(7, -3), -2)
+#test(calc.div-euclid(-7, 3), -3)
+#test(calc.div-euclid(-7, -3), 3)
+#test(calc.div-euclid(2.5, 2), 1)
+
+--- calc-div-euclid-divisor-zero-1 ---
+// Error: 21-22 divisor must not be zero
+#calc.div-euclid(5, 0)
+
+--- calc-div-euclid-divisor-zero-2 ---
+// Error: 23-26 divisor must not be zero
+#calc.div-euclid(3.0, 0.0)
+
+--- calc-rem-euclid ---
+// Test the `rem-euclid` function.
+#test(calc.rem-euclid(7, 3), 1)
+#test(calc.rem-euclid(7, -3), 1)
+#test(calc.rem-euclid(-7, 3), 2)
+#test(calc.rem-euclid(-7, -3), 2)
+#test(calc.rem-euclid(2.5, 2), 0.5)
+
+--- calc-rem-euclid-divisor-zero-1 ---
+// Error: 21-22 divisor must not be zero
+#calc.rem-euclid(5, 0)
+
+--- calc-rem-euclid-divisor-zero-2 ---
+// Error: 23-26 divisor must not be zero
+#calc.rem-euclid(3.0, 0.0)
+
+--- calc-quo ---
+// Test the `quo` function.
+#test(calc.quo(1, 1), 1)
+#test(calc.quo(5, 3), 1)
+#test(calc.quo(5, -3), -1)
+#test(calc.quo(22.5, 10), 2)
+#test(calc.quo(9, 4.5), 2)
+
+--- calc-quo-divisor-zero-1 ---
+// Error: 14-15 divisor must not be zero
+#calc.quo(5, 0)
+
+--- calc-quo-divisor-zero-2 ---
+// Error: 16-19 divisor must not be zero
+#calc.quo(3.0, 0.0)
+
+--- calc-min-and-max ---
+// Test the `min` and `max` functions.
+#test(calc.min(2, -4), -4)
+#test(calc.min(3.5, 1e2, -0.1, 3), -0.1)
+#test(calc.max(-3, 11), 11)
+#test(calc.min("hi"), "hi")
+
+--- calc-pow-log-exp-ln ---
+// Test the `pow`, `log`, `exp`, and `ln` functions.
+#test(calc.pow(10, 0), 1)
+#test(calc.pow(2, 4), 16)
+#test(calc.exp(2), calc.pow(calc.e, 2))
+#test(calc.ln(10), calc.log(10, base: calc.e))
+
+--- calc-bit-logical ---
+// Test the `bit-not`, `bit-and`, `bit-or` and `bit-xor` functions.
+#test(64.bit-not(), -65)
+#test(0.bit-not(), -1)
+#test((-56).bit-not(), 55)
+#test(128.bit-and(192), 128)
+#test(192.bit-and(224), 192)
+#test((-1).bit-and(325532), 325532)
+#test(0.bit-and(-53), 0)
+#test(0.bit-or(-1), -1)
+#test(5.bit-or(3), 7)
+#test((-50).bit-or(3), -49)
+#test(64.bit-or(32), 96)
+#test((-1).bit-xor(1), -2)
+#test(64.bit-xor(96), 32)
+#test((-1).bit-xor(-7), 6)
+#test(0.bit-xor(492), 492)
+
+--- calc-bit-shift ---
+// Test the `bit-lshift` and `bit-rshift` functions.
+#test(32.bit-lshift(2), 128)
+#test(694.bit-lshift(0), 694)
+#test(128.bit-rshift(2), 32)
+#test(128.bit-rshift(12345), 0)
+#test((-7).bit-rshift(2), -2)
+#test((-7).bit-rshift(12345), -1)
+#test(128.bit-rshift(2, logical: true), 32)
+#test((-7).bit-rshift(61, logical: true), 7)
+#test(128.bit-rshift(12345, logical: true), 0)
+#test((-7).bit-rshift(12345, logical: true), 0)
+
+--- calc-bit-shift-too-large ---
+// Error: 2-18 the result is too large
+#1.bit-lshift(64)
+
+--- calc-bit-lshift-negative ---
+// Error: 15-17 number must be at least zero
+#1.bit-lshift(-1)
+
+--- calc-bit-rshift-negative ---
+// Error: 15-17 number must be at least zero
+#1.bit-rshift(-1)
+
+--- calc-pow-zero-to-power-of-zero ---
+// Error: 2-16 zero to the power of zero is undefined
+#calc.pow(0, 0)
+
+--- calc-pow-exponent-too-large ---
+// Error: 14-31 exponent is too large
+#calc.pow(2, 10000000000000000)
+
+--- calc-pow-too-large ---
+// Error: 2-25 the result is too large
+#calc.pow(2, 2147483647)
+
+--- calc-pow-bad-exponent ---
+// Error: 14-36 exponent may not be infinite, subnormal, or NaN
+#calc.pow(2, calc.pow(2.0, 10000.0))
+
+--- calc-pow-not-real ---
+// Error: 2-19 the result is not a real number
+#calc.pow(-1, 0.5)
+
+--- calc-sqrt-not-real ---
+// Error: 12-14 cannot take square root of negative number
+#calc.sqrt(-1)
+
+--- calc-root ---
+#test(calc.root(12.0, 1), 12.0)
+#test(calc.root(9.0, 2), 3.0)
+#test(calc.root(27.0, 3), 3.0)
+#test(calc.root(-27.0, 3), -3.0)
+// 100^(-1/2) = (100^(1/2))^-1 = 1/sqrt(100)
+#test(calc.root(100.0, -2), 0.1)
+
+--- calc-root-zeroth ---
+// Error: 17-18 cannot take the 0th root of a number
+#calc.root(1.0, 0)
+
+--- calc-root-negative-even ---
+// Error: 24-25 negative numbers do not have a real nth root when n is even
+#test(calc.root(-27.0, 4), -3.0)
+
+--- calc-log-negative ---
+// Error: 11-13 value must be strictly positive
+#calc.log(-1)
+
+--- calc-log-bad-base ---
+// Error: 20-21 base may not be zero, NaN, infinite, or subnormal
+#calc.log(1, base: 0)
+
+--- calc-log-not-real ---
+// Error: 2-24 the result is not a real number
+#calc.log(10, base: -1)
+
+--- calc-fact ---
+// Test the `fact` function.
+#test(calc.fact(0), 1)
+#test(calc.fact(5), 120)
+
+--- calc-fact-too-large ---
+// Error: 2-15 the result is too large
+#calc.fact(21)
+
+--- calc-perm ---
+// Test the `perm` function.
+#test(calc.perm(0, 0), 1)
+#test(calc.perm(5, 3), 60)
+#test(calc.perm(5, 5), 120)
+#test(calc.perm(5, 6), 0)
+
+--- calc-perm-too-large ---
+// Error: 2-19 the result is too large
+#calc.perm(21, 21)
+
+--- calc-binom ---
+// Test the `binom` function.
+#test(calc.binom(0, 0), 1)
+#test(calc.binom(5, 3), 10)
+#test(calc.binom(5, 5), 1)
+#test(calc.binom(5, 6), 0)
+#test(calc.binom(6, 2), 15)
+
+--- calc-gcd ---
+// Test the `gcd` function.
+#test(calc.gcd(112, 77), 7)
+#test(calc.gcd(12, 96), 12)
+#test(calc.gcd(13, 9), 1)
+#test(calc.gcd(13, -9), 1)
+#test(calc.gcd(272557, 272557), 272557)
+#test(calc.gcd(0, 0), 0)
+#test(calc.gcd(7, 0), 7)
+
+--- calc-lcm ---
+// Test the `lcm` function.
+#test(calc.lcm(112, 77), 1232)
+#test(calc.lcm(12, 96), 96)
+#test(calc.lcm(13, 9), 117)
+#test(calc.lcm(13, -9), 117)
+#test(calc.lcm(272557, 272557), 272557)
+#test(calc.lcm(0, 0), 0)
+#test(calc.lcm(8, 0), 0)
+
+--- calc-lcm-too-large ---
+// Error: 2-41 the result is too large
+#calc.lcm(15486487489457, 4874879896543)
+
+--- calc-min-nothing ---
+// Error: 2-12 expected at least one value
+#calc.min()
+
+--- calc-min-uncomparable ---
+// Error: 14-18 cannot compare string and integer
+#calc.min(1, "hi")
+
+--- calc-max-uncomparable ---
+// Error: 16-19 cannot compare 1pt with 1em
+#calc.max(1em, 1pt)
diff --git a/tests/suite/foundations/content.typ b/tests/suite/foundations/content.typ
new file mode 100644
index 00000000..afecc124
--- /dev/null
+++ b/tests/suite/foundations/content.typ
@@ -0,0 +1,120 @@
+--- content-at-default ---
+// Test .at() default values for content.
+#test(auto, [a].at("doesn't exist", default: auto))
+
+--- content-field-syntax ---
+// Test fields on elements.
+#show list: it => {
+ test(it.children.len(), 3)
+}
+
+- A
+- B
+- C
+
+--- content-field-missing ---
+// Error: 25-28 content does not contain field "fun"
+#show heading: it => it.fun
+= A
+
+--- content-fields ---
+// Test content fields method.
+#test([a].fields(), (text: "a"))
+#test([a *b*].fields(), (children: ([a], [ ], strong[b])))
+
+--- content-fields-mutable-invalid ---
+#{
+ let object = [hi]
+ // Error: 3-9 cannot mutate fields on content
+ object.property = "value"
+}
+
+--- content-field-materialized-table ---
+// Ensure that fields from set rules are materialized into the element before
+// a show rule runs.
+#set table(columns: (10pt, auto))
+#show table: it => it.columns
+#table[A][B][C][D]
+
+--- content-field-materialized-heading ---
+// Test it again with a different element.
+#set heading(numbering: "(I)")
+#show heading: set text(size: 11pt, weight: "regular")
+#show heading: it => it.numbering
+= Heading
+
+--- content-field-materialized-query ---
+// Test it with query.
+#set raw(lang: "rust")
+#context query(<myraw>).first().lang
+`raw` <myraw>
+
+--- content-fields-complex ---
+// Integrated test for content fields.
+#let compute(equation, ..vars) = {
+ let vars = vars.named()
+ let f(elem) = {
+ let func = elem.func()
+ if func == text {
+ let text = elem.text
+ if regex("^\d+$") in text {
+ int(text)
+ } else if text in vars {
+ int(vars.at(text))
+ } else {
+ panic("unknown math variable: " + text)
+ }
+ } else if func == math.attach {
+ let value = f(elem.base)
+ if elem.has("t") {
+ value = calc.pow(value, f(elem.t))
+ }
+ value
+ } else if elem.has("children") {
+ elem
+ .children
+ .filter(v => v != [ ])
+ .split[+]
+ .map(xs => xs.fold(1, (prod, v) => prod * f(v)))
+ .fold(0, (sum, v) => sum + v)
+ }
+ }
+ let result = f(equation.body)
+ [With ]
+ vars
+ .pairs()
+ .map(p => $#p.first() = #p.last()$)
+ .join(", ", last: " and ")
+ [ we have:]
+ $ equation = result $
+}
+
+#compute($x y + y^2$, x: 2, y: 3)
+
+--- content-label-has-method ---
+// Test whether the label is accessible through the `has` method.
+#show heading: it => {
+ assert(it.has("label"))
+ it
+}
+
+= Hello, world! <my-label>
+
+--- content-label-field-access ---
+// Test whether the label is accessible through field syntax.
+#show heading: it => {
+ assert(str(it.label) == "my-label")
+ it
+}
+
+= Hello, world! <my-label>
+
+--- content-label-fields-method ---
+// Test whether the label is accessible through the fields method.
+#show heading: it => {
+ assert("label" in it.fields())
+ assert(str(it.fields().label) == "my-label")
+ it
+}
+
+= Hello, world! <my-label>
diff --git a/tests/suite/foundations/context.typ b/tests/suite/foundations/context.typ
new file mode 100644
index 00000000..fea9f544
--- /dev/null
+++ b/tests/suite/foundations/context.typ
@@ -0,0 +1,65 @@
+// Test context expressions.
+
+--- context-body-atomic-in-markup ---
+// Test that context body is parsed as atomic expression.
+#let c = [#context "hello".]
+#test(c.children.first().func(), (context none).func())
+#test(c.children.last(), [.])
+
+--- context-element-constructor-forbidden ---
+// Test that manual construction is forbidden.
+// Error: 2-25 cannot be constructed manually
+#(context none).func()()
+
+--- context-in-show-rule ---
+// Test that show rule establishes context.
+#set heading(numbering: "1.")
+#show heading: it => test(
+ counter(heading).get(),
+ (intro: (1,), back: (2,)).at(str(it.label)),
+)
+
+= Introduction <intro>
+= Background <back>
+
+--- context-in-show-rule-query ---
+// Test that show rule on non-locatable element allows `query`.
+// Error: 18-47 Assertion failed: 2 != 3
+#show emph: _ => test(query(heading).len(), 3)
+#show strong: _ => test(query(heading).len(), 2)
+= Introduction
+= Background
+*Hi* _there_
+
+--- context-assign-to-captured-variable ---
+// Test error when captured variable is assigned to.
+#let i = 0
+// Error: 11-12 variables from outside the context expression are read-only and cannot be modified
+#context (i = 1)
+
+--- context-compatibility-locate ---
+#let s = state("x", 0)
+#let compute(expr) = [
+ #s.update(x =>
+ eval(expr.replace("x", str(x)))
+ )
+ New value is #s.display().
+]
+
+#locate(loc => {
+ let elem = query(<here>, loc).first()
+ test(s.at(elem.location()), 13)
+})
+
+#compute("10") \
+#compute("x + 3") \
+*Here.* <here> \
+#compute("x * 2") \
+#compute("x - 5")
+
+--- context-compatibility-styling ---
+#style(styles => measure([it], styles).width < 20pt)
+
+--- context-compatibility-counter-display ---
+#counter(heading).update(10)
+#counter(heading).display(n => test(n, 10))
diff --git a/tests/suite/foundations/datetime.typ b/tests/suite/foundations/datetime.typ
new file mode 100644
index 00000000..b54c11f3
--- /dev/null
+++ b/tests/suite/foundations/datetime.typ
@@ -0,0 +1,93 @@
+--- datetime-constructor-empty ---
+// Error: 2-12 at least one of date or time must be fully specified
+#datetime()
+
+--- datetime-constructor-time-invalid ---
+// Error: 2-42 time is invalid
+#datetime(hour: 25, minute: 0, second: 0)
+
+--- datetime-constructor-date-invalid ---
+// Error: 2-41 date is invalid
+#datetime(year: 2000, month: 2, day: 30)
+
+--- datetime-display ---
+// Test displaying of dates.
+#test(datetime(year: 2023, month: 4, day: 29).display(), "2023-04-29")
+#test(datetime(year: 2023, month: 4, day: 29).display("[year]"), "2023")
+#test(
+ datetime(year: 2023, month: 4, day: 29)
+ .display("[year repr:last_two]"),
+ "23",
+)
+#test(
+ datetime(year: 2023, month: 4, day: 29)
+ .display("[year] [month repr:long] [day] [week_number] [weekday]"),
+ "2023 April 29 17 Saturday",
+)
+
+// Test displaying of times
+#test(datetime(hour: 14, minute: 26, second: 50).display(), "14:26:50")
+#test(datetime(hour: 14, minute: 26, second: 50).display("[hour]"), "14")
+#test(
+ datetime(hour: 14, minute: 26, second: 50)
+ .display("[hour repr:12 padding:none]"),
+ "2",
+)
+#test(
+ datetime(hour: 14, minute: 26, second: 50)
+ .display("[hour], [minute], [second]"), "14, 26, 50",
+)
+
+// Test displaying of datetimes
+#test(
+ datetime(year: 2023, month: 4, day: 29, hour: 14, minute: 26, second: 50).display(),
+ "2023-04-29 14:26:50",
+)
+
+// Test getting the year/month/day etc. of a datetime
+#let d = datetime(year: 2023, month: 4, day: 29, hour: 14, minute: 26, second: 50)
+#test(d.year(), 2023)
+#test(d.month(), 4)
+#test(d.weekday(), 6)
+#test(d.day(), 29)
+#test(d.hour(), 14)
+#test(d.minute(), 26)
+#test(d.second(), 50)
+
+#let e = datetime(year: 2023, month: 4, day: 29)
+#test(e.hour(), none)
+#test(e.minute(), none)
+#test(e.second(), none)
+
+// Test today
+#test(datetime.today().display(), "1970-01-01")
+#test(datetime.today(offset: auto).display(), "1970-01-01")
+#test(datetime.today(offset: 2).display(), "1970-01-01")
+
+--- datetime-ordinal ---
+// Test date methods.
+#test(datetime(day: 1, month: 1, year: 2000).ordinal(), 1);
+#test(datetime(day: 1, month: 3, year: 2000).ordinal(), 31 + 29 + 1);
+#test(datetime(day: 31, month: 12, year: 2000).ordinal(), 366);
+#test(datetime(day: 1, month: 3, year: 2001).ordinal(), 31 + 28 + 1);
+#test(datetime(day: 31, month: 12, year: 2001).ordinal(), 365);
+
+--- datetime-display-missing-closing-bracket ---
+// Error: 27-34 missing closing bracket for bracket at index 0
+#datetime.today().display("[year")
+
+--- datetime-display-invalid-component ---
+// Error: 27-38 invalid component name 'nothing' at index 1
+#datetime.today().display("[nothing]")
+
+--- datetime-display-invalid-modifier ---
+// Error: 27-50 invalid modifier 'wrong' at index 6
+#datetime.today().display("[year wrong:last_two]")
+
+--- datetime-display-expected-component ---
+// Error: 27-33 expected component name at index 2
+#datetime.today().display(" []")
+
+--- datetime-display-insufficient-information ---
+// Error: 2-36 failed to format datetime (insufficient information)
+#datetime.today().display("[hour]")
diff --git a/tests/suite/foundations/dict.typ b/tests/suite/foundations/dict.typ
new file mode 100644
index 00000000..2c2d2a41
--- /dev/null
+++ b/tests/suite/foundations/dict.typ
@@ -0,0 +1,266 @@
+// Test dictionaries.
+
+--- dict-basic-syntax ---
+
+// Empty
+#(:)
+
+// Two pairs and string key.
+#let dict = (normal: 1, "spacy key": 2)
+#dict
+
+#test(dict.normal, 1)
+#test(dict.at("spacy key"), 2)
+
+--- dict-fields ---
+// Test field on dictionary.
+#let dict = (nothing: "ness", hello: "world")
+#test(dict.nothing, "ness")
+#{
+ let world = dict
+ .hello
+
+ test(world, "world")
+}
+
+--- dict-missing-field ---
+// Error: 6-13 dictionary does not contain key "invalid"
+#(:).invalid
+
+--- dict-bad-key ---
+// Error: 3-7 expected string, found boolean
+// Error: 16-18 expected string, found integer
+#(true: false, 42: 3)
+
+--- dict-duplicate-key ---
+// Error: 24-29 duplicate key: first
+#(first: 1, second: 2, first: 3)
+
+--- dict-duplicate-key-stringy ---
+// Error: 17-20 duplicate key: a
+#(a: 1, "b": 2, "a": 3)
+
+--- dict-bad-expression ---
+// Simple expression after already being identified as a dictionary.
+// Error: 9-10 expected named or keyed pair, found identifier
+#(a: 1, b)
+
+--- dict-leading-colon ---
+// Identified as dictionary due to initial colon.
+// The boolean key is allowed for now since it will only cause an error at the evaluation stage.
+// Error: 4-5 expected named or keyed pair, found integer
+// Error: 17 expected expression
+#(:1 b:"", true:)
+
+--- spread-into-dict ---
+#{
+ let x = (a: 1)
+ let y = (b: 2)
+ let z = (a: 3)
+ test((:..x, ..y, ..z), (a: 3, b: 2))
+ test((..(a: 1), b: 2), (a: 1, b: 2))
+}
+
+--- spread-array-into-dict ---
+// Error: 3-11 cannot spread array into dictionary
+#(..(1, 2), a: 1)
+
+--- dict-at-lvalue ---
+// Test lvalue and rvalue access.
+#{
+ let dict = (a: 1, "b b": 1)
+ dict.at("b b") += 1
+ dict.state = (ok: true, err: false)
+ test(dict, (a: 1, "b b": 2, state: (ok: true, err: false)))
+ test(dict.state.ok, true)
+ dict.at("state").ok = false
+ test(dict.state.ok, false)
+ test(dict.state.err, false)
+}
+
+--- dict-at-missing-key ---
+// Test rvalue missing key.
+#{
+ let dict = (a: 1, b: 2)
+ // Error: 11-23 dictionary does not contain key "c" and no default value was specified
+ let x = dict.at("c")
+}
+
+--- dict-at-default ---
+// Test default value.
+#test((a: 1, b: 2).at("b", default: 3), 2)
+#test((a: 1, b: 2).at("c", default: 3), 3)
+
+--- dict-insert ---
+// Test insert.
+#{
+ let dict = (a: 1, b: 2)
+ dict.insert("b", 3)
+ test(dict, (a: 1, b: 3))
+ dict.insert("c", 5)
+ test(dict, (a: 1, b: 3, c: 5))
+}
+
+--- dict-remove-with-default ---
+// Test remove with default value.
+#{
+ let dict = (a: 1, b: 2)
+ test(dict.remove("b", default: 3), 2)
+}
+
+#{
+ let dict = (a: 1, b: 2)
+ test(dict.remove("c", default: 3), 3)
+}
+
+--- dict-missing-lvalue ---
+// Missing lvalue is not automatically none-initialized.
+#{
+ let dict = (:)
+ // Error: 3-9 dictionary does not contain key "b"
+ // Hint: 3-9 use `insert` to add or update values
+ dict.b += 1
+}
+
+--- dict-basic-methods ---
+// Test dictionary methods.
+#let dict = (a: 3, c: 2, b: 1)
+#test("c" in dict, true)
+#test(dict.len(), 3)
+#test(dict.values(), (3, 2, 1))
+#test(dict.pairs().map(p => p.first() + str(p.last())).join(), "a3c2b1")
+
+#dict.remove("c")
+#test("c" in dict, false)
+#test(dict, (a: 3, b: 1))
+
+--- dict-from-module ---
+// Test dictionary constructor
+#dictionary(sys).at("version")
+#dictionary(sys).at("no-crash", default: none)
+
+--- dict-remove-order ---
+// Test that removal keeps order.
+#let dict = (a: 1, b: 2, c: 3, d: 4)
+#dict.remove("b")
+#test(dict.keys(), ("a", "c", "d"))
+
+--- dict-temporary-lvalue ---
+// Error: 3-15 cannot mutate a temporary value
+#((key: "val").other = "some")
+
+--- dict-function-item-not-a-method ---
+#{
+ let dict = (
+ call-me: () => 1,
+ )
+ // Error: 8-15 type dictionary has no method `call-me`
+ // Hint: 8-15 to call the function stored in the dictionary, surround the field access with parentheses, e.g. `(dict.call-me)(..)`
+ dict.call-me()
+}
+
+--- dict-item-missing-method ---
+#{
+ let dict = (
+ nonfunc: 1
+ )
+
+ // Error: 8-15 type dictionary has no method `nonfunc`
+ // Hint: 8-15 did you mean to access the field `nonfunc`?
+ dict.nonfunc()
+}
+
+--- dict-dynamic-uplicate-key ---
+#let a = "hello"
+#let b = "world"
+#let c = "value"
+#let d = "conflict"
+
+#assert.eq(((a): b), ("hello": "world"))
+#assert.eq(((a): 1, (a): 2), ("hello": 2))
+#assert.eq((hello: 1, (a): 2), ("hello": 2))
+#assert.eq((a + b: c, (a + b): d, (a): "value2", a: "value3"), ("helloworld": "conflict", "hello": "value2", "a": "value3"))
+
+--- issue-1338-dictionary-underscore ---
+#let foo = "foo"
+#let bar = "bar"
+// Error: 8-9 expected expression, found underscore
+// Error: 16-17 expected expression, found underscore
+#(foo: _, bar: _)
+
+--- issue-1342-dictionary-bare-expressions ---
+// Error: 5-8 expected named or keyed pair, found identifier
+// Error: 10-13 expected named or keyed pair, found identifier
+#(: foo, bar)
+
+--- issue-3154-dict-at-not-contained ---
+#{
+ let dict = (a: 1)
+ // Error: 3-15 dictionary does not contain key "b" and no default value was specified
+ dict.at("b")
+}
+
+--- issue-3154-dict-at-missing-default ---
+#{
+ let dict = (a: 1)
+ test(dict.at("b", default: 0), 0)
+}
+
+--- issue-3154-dict-at-missing-mutable ---
+#{
+ let dict = (a: 1)
+ // Error: 3-15 dictionary does not contain key "b"
+ // Hint: 3-15 use `insert` to add or update values
+ dict.at("b") = 9
+}
+
+--- issue-3154-dict-at-missing-mutable-default ---
+#{
+ let dict = (a: 1)
+ // Error: 3-27 dictionary does not contain key "b"
+ // Hint: 3-27 use `insert` to add or update values
+ dict.at("b", default: 0) = 9
+}
+
+--- issue-3154-dict-syntax-missing ---
+#{
+ let dict = (a: 1)
+ // Error: 8-9 dictionary does not contain key "b"
+ dict.b
+}
+
+--- issue-3154-dict-syntax-missing-mutable ---
+#{
+ let dict = (a: 1)
+ dict.b = 9
+ test(dict, (a: 1, b: 9))
+}
+
+--- issue-3154-dict-syntax-missing-add-assign ---
+#{
+ let dict = (a: 1)
+ // Error: 3-9 dictionary does not contain key "b"
+ // Hint: 3-9 use `insert` to add or update values
+ dict.b += 9
+}
+
+--- issue-3232-dict-unexpected-keys-sides ---
+// Confusing "expected relative length or dictionary, found dictionary"
+// Error: 16-58 unexpected keys "unexpected" and "unexpected-too"
+#block(outset: (unexpected: 0.5em, unexpected-too: 0.2em), [Hi])
+
+--- issue-3232-dict-unexpected-keys-corners ---
+// Error: 14-56 unexpected keys "unexpected" and "unexpected-too"
+#box(radius: (unexpected: 0.5em, unexpected-too: 0.5em), [Hi])
+
+--- issue-3232-dict-unexpected-key-sides ---
+// Error: 16-49 unexpected key "unexpected", valid keys are "left", "top", "right", "bottom", "x", "y", and "rest"
+#block(outset: (unexpected: 0.2em, right: 0.5em), [Hi]) // The 1st key is unexpected
+
+--- issue-3232-dict-unexpected-key-corners ---
+// Error: 14-50 unexpected key "unexpected", valid keys are "top-left", "top-right", "bottom-right", "bottom-left", "left", "top", "right", "bottom", and "rest"
+#box(radius: (top-left: 0.5em, unexpected: 0.5em), [Hi]) // The 2nd key is unexpected
+
+--- issue-3232-dict-empty ---
+#block(outset: (:), [Hi]) // Ok
+#box(radius: (:), [Hi]) // Ok
diff --git a/tests/suite/foundations/duration.typ b/tests/suite/foundations/duration.typ
new file mode 100644
index 00000000..7e53f703
--- /dev/null
+++ b/tests/suite/foundations/duration.typ
@@ -0,0 +1,103 @@
+// Test durations.
+
+--- duration-negate ---
+// Test negating durations.
+#test(-duration(hours: 2), duration(hours: -2))
+
+--- duration-add-and-subtract ---
+// Test adding and subtracting durations.
+#test(duration(weeks: 1, hours: 1), duration(weeks: 1) + duration(hours: 1))
+#test(duration(weeks: 1, hours: -1), duration(weeks: 1) - duration(hours: 1))
+#test(duration(days: 6, hours: 23), duration(weeks: 1) - duration(hours: 1))
+
+--- duration-add-and-subtract-dates ---
+// Test adding and subtracting durations and dates.
+#let d = datetime(day: 1, month: 1, year: 2000)
+#let d2 = datetime(day: 1, month: 2, year: 2000)
+#test(d + duration(weeks: 2), datetime(day: 15, month: 1, year: 2000))
+#test(d + duration(days: 3), datetime(day: 4, month: 1, year: 2000))
+#test(d + duration(weeks: 1, days: 3), datetime(day: 11, month: 1, year: 2000))
+#test(d2 + duration(days: -1), datetime(day: 31, month: 1, year: 2000))
+#test(d2 + duration(days: -3), datetime(day: 29, month: 1, year: 2000))
+#test(d2 + duration(weeks: -1), datetime(day: 25, month: 1, year: 2000))
+#test(d + duration(days: -1), datetime(day: 31, month: 12, year: 1999))
+#test(d + duration(weeks: 1, days: -7), datetime(day: 1, month: 1, year: 2000))
+#test(d2 - duration(days: 1), datetime(day: 31, month: 1, year: 2000))
+#test(d2 - duration(days: 3), datetime(day: 29, month: 1, year: 2000))
+#test(d2 - duration(weeks: 1), datetime(day: 25, month: 1, year: 2000))
+#test(d - duration(days: 1), datetime(day: 31, month: 12, year: 1999))
+#test(datetime(day: 31, month: 1, year: 2000) + duration(days: 1), d2)
+#test(
+ datetime(day: 31, month: 12, year: 2000) + duration(days: 1),
+ datetime(day: 1, month: 1, year: 2001),
+)
+
+--- duration-add-and-subtract-times ---
+// Test adding and subtracting durations and times.
+#let a = datetime(hour: 12, minute: 0, second: 0)
+#test(a + duration(hours: 1, minutes: -60), datetime(hour: 12, minute: 0, second: 0))
+#test(a + duration(hours: 2), datetime(hour: 14, minute: 0, second: 0))
+#test(a + duration(minutes: 10), datetime(hour: 12, minute: 10, second: 0))
+#test(a + duration(seconds: 30), datetime(hour: 12, minute: 0, second: 30))
+#test(a + duration(hours: -2), datetime(hour: 10, minute: 0, second: 0))
+#test(a - duration(hours: 2), datetime(hour: 10, minute: 0, second: 0))
+#test(a + duration(minutes: -10), datetime(hour: 11, minute: 50, second: 0))
+#test(a - duration(minutes: 10), datetime(hour: 11, minute: 50, second: 0))
+#test(a + duration(seconds: -30), datetime(hour: 11, minute: 59, second: 30))
+#test(a - duration(seconds: 30), datetime(hour: 11, minute: 59, second: 30))
+#test(
+ a + duration(hours: 1, minutes: 13, seconds: 13),
+ datetime(hour: 13, minute: 13, second: 13),
+)
+
+--- duration-add-and-subtract-datetimes ---
+// Test adding and subtracting durations and datetimes.
+#test(
+ datetime(day: 1, month: 1, year: 2000, hour: 12, minute: 0, second: 0)
+ + duration(weeks: 1, days: 3, hours: -13, minutes: 10, seconds: -10 ),
+ datetime(day: 10, month: 1, year: 2000, hour: 23, minute: 9, second: 50),
+)
+#test(
+ datetime(day: 1, month: 1, year: 2000, hour: 12, minute: 0, second: 0)
+ + duration(weeks: 1, days: 3, minutes: 10)
+ - duration(hours: 13, seconds: 10),
+ datetime(day: 10, month: 1, year: 2000, hour: 23, minute: 9, second: 50),
+)
+
+--- duration-from-date-subtraction ---
+// Test subtracting dates.
+#let a = datetime(hour: 12, minute: 0, second: 0)
+#let b = datetime(day: 1, month: 1, year: 2000)
+#test(datetime(hour: 14, minute: 0, second: 0) - a, duration(hours: 2))
+#test(datetime(hour: 14, minute: 0, second: 0) - a, duration(minutes: 120))
+#test(datetime(hour: 13, minute: 0, second: 0) - a, duration(seconds: 3600))
+#test(datetime(day: 1, month: 2, year: 2000) - b, duration(days: 31))
+#test(datetime(day: 15, month: 1, year: 2000) - b, duration(weeks: 2))
+
+--- duration-multiply-with-number ---
+// Test multiplying and dividing durations with numbers.
+#test(duration(minutes: 10) * 6, duration(hours: 1))
+#test(duration(minutes: 10) * 2, duration(minutes: 20))
+#test(duration(minutes: 10) * 2.5, duration(minutes: 25))
+#test(duration(minutes: 10) / 2, duration(minutes: 5))
+#test(duration(minutes: 10) / 2.5, duration(minutes: 4))
+
+--- duration-divide ---
+// Test dividing durations with durations
+#test(duration(minutes: 20) / duration(hours: 1), 1 / 3)
+#test(duration(minutes: 20) / duration(minutes: 10), 2)
+#test(duration(minutes: 20) / duration(minutes: 8), 2.5)
+
+--- duration-compare ---
+// Test comparing durations
+#test(duration(minutes: 20) > duration(minutes: 10), true)
+#test(duration(minutes: 20) >= duration(minutes: 10), true)
+#test(duration(minutes: 10) < duration(minutes: 20), true)
+#test(duration(minutes: 10) <= duration(minutes: 20), true)
+#test(duration(minutes: 10) == duration(minutes: 10), true)
+#test(duration(minutes: 10) != duration(minutes: 20), true)
+#test(duration(minutes: 10) <= duration(minutes: 10), true)
+#test(duration(minutes: 10) >= duration(minutes: 10), true)
+#test(duration(minutes: 20) < duration(minutes: 10), false)
+#test(duration(minutes: 20) <= duration(minutes: 10), false)
+#test(duration(minutes: 20) == duration(minutes: 10), false)
diff --git a/tests/suite/foundations/eval.typ b/tests/suite/foundations/eval.typ
new file mode 100644
index 00000000..f85146b2
--- /dev/null
+++ b/tests/suite/foundations/eval.typ
@@ -0,0 +1,54 @@
+--- eval ---
+// Test the eval function.
+#test(eval("1 + 2"), 3)
+#test(eval("1 + x", scope: (x: 3)), 4)
+#test(eval("let x = x + 1; x + 1", scope: (x: 1)), 3)
+
+--- eval-mode ---
+// Test evaluation in other modes.
+#eval("[_Hello" + " World!_]") \
+#eval("_Hello" + " World!_", mode: "markup") \
+#eval("RR_1^NN", mode: "math", scope: (RR: math.NN, NN: math.RR))
+
+--- eval-syntax-error-1 ---
+// Error: 7-12 expected pattern
+#eval("let")
+
+--- eval-in-show-rule ---
+#show raw: it => text(font: "PT Sans", eval("[" + it.text + "]"))
+
+Interacting
+```
+#set text(blue)
+Blue #move(dy: -0.15em)[🌊]
+```
+
+--- eval-runtime-error ---
+// Error: 7-17 cannot continue outside of loop
+#eval("continue")
+
+--- eval-syntax-error-2 ---
+// Error: 7-12 expected semicolon or line break
+#eval("1 2")
+
+--- eval-path-resolve ---
+// Test absolute path.
+#eval("image(\"/assets/images/tiger.jpg\", width: 50%)")
+
+--- eval-path-resolve-in-show-rule ---
+#show raw: it => eval(it.text, mode: "markup")
+
+```
+#show emph: image("/assets/images/tiger.jpg", width: 50%)
+_Tiger!_
+```
+
+--- eval-path-resolve-relative ---
+// Test relative path.
+#test(eval(`"HELLO" in read("./eval.typ")`.text), true)
+
+--- issue-2055-math-eval ---
+// Evaluating a math expr should renders the same as an equation
+#eval(mode: "math", "f(a) = cases(a + b\, space space x >= 3,a + b\, space space x = 5)")
+
+$f(a) = cases(a + b\, space space x >= 3,a + b\, space space x = 5)$
diff --git a/tests/suite/foundations/float.typ b/tests/suite/foundations/float.typ
new file mode 100644
index 00000000..770533b9
--- /dev/null
+++ b/tests/suite/foundations/float.typ
@@ -0,0 +1,66 @@
+--- float-constructor ---
+#test(float(10), 10.0)
+#test(float(50% * 30%), 0.15)
+#test(float("31.4e-1"), 3.14)
+#test(float("31.4e\u{2212}1"), 3.14)
+#test(float("3.1415"), 3.1415)
+#test(float("-7654.321"), -7654.321)
+#test(float("\u{2212}7654.321"), -7654.321)
+#test(type(float(10)), float)
+
+--- float-constructor-bad-type ---
+// Error: 8-13 expected float, boolean, integer, ratio, or string, found type
+#float(float)
+
+--- float-constructor-bad-value ---
+// Error: 8-15 invalid float: 1.2.3
+#float("1.2.3")
+
+--- float-is-nan ---
+// Test float `is-nan()`.
+#test(float(calc.nan).is-nan(), true)
+#test(float(10).is-nan(), false)
+
+--- float-is-infinite ---
+// Test float `is-infinite()`.
+#test(float(calc.inf).is-infinite(), true)
+#test(float(-calc.inf).is-infinite(), true)
+#test(float(10).is-infinite(), false)
+#test(float(-10).is-infinite(), false)
+
+--- float-signum ---
+// Test float `signum()`
+#test(float(0.0).signum(), 1.0)
+#test(float(1.0).signum(), 1.0)
+#test(float(-1.0).signum(), -1.0)
+#test(float(10.0).signum(), 1.0)
+#test(float(-10.0).signum(), -1.0)
+#test(float(calc.nan).signum().is-nan(), true)
+
+--- float-repr ---
+// Test the `repr` function with floats.
+#repr(12.0) \
+#repr(3.14) \
+#repr(1234567890.0) \
+#repr(0123456789.0) \
+#repr(0.0) \
+#repr(-0.0) \
+#repr(-1.0) \
+#repr(-9876543210.0) \
+#repr(-0987654321.0) \
+#repr(-3.14) \
+#repr(4.0 - 8.0)
+
+--- float-display ---
+// Test floats.
+#12.0 \
+#3.14 \
+#1234567890.0 \
+#0123456789.0 \
+#0.0 \
+#(-0.0) \
+#(-1.0) \
+#(-9876543210.0) \
+#(-0987654321.0) \
+#(-3.14) \
+#(4.0 - 8.0)
diff --git a/tests/suite/foundations/int.typ b/tests/suite/foundations/int.typ
new file mode 100644
index 00000000..0c85dcab
--- /dev/null
+++ b/tests/suite/foundations/int.typ
@@ -0,0 +1,73 @@
+--- int-base-alternative ---
+// Test numbers with alternative bases.
+#test(0x10, 16)
+#test(0b1101, 13)
+#test(0xA + 0xa, 0x14)
+
+--- int-base-binary-invalid ---
+// Error: 2-7 invalid binary number: 0b123
+#0b123
+
+--- int-base-hex-invalid ---
+// Error: 2-8 invalid hexadecimal number: 0x123z
+#0x123z
+
+--- int-constructor ---
+// Test conversion to numbers.
+#test(int(false), 0)
+#test(int(true), 1)
+#test(int(10), 10)
+#test(int("150"), 150)
+#test(int("-834"), -834)
+#test(int("\u{2212}79"), -79)
+#test(int(10 / 3), 3)
+
+--- int-constructor-bad-type ---
+// Error: 6-10 expected integer, boolean, float, or string, found length
+#int(10pt)
+
+--- int-constructor-bad-value ---
+// Error: 6-12 invalid integer: nope
+#int("nope")
+
+--- int-signum ---
+// Test int `signum()`
+#test(int(0).signum(), 0)
+#test(int(1.0).signum(), 1)
+#test(int(-1.0).signum(), -1)
+#test(int(10.0).signum(), 1)
+#test(int(-10.0).signum(), -1)
+
+--- int-repr ---
+// Test the `repr` function with integers.
+#repr(12) \
+#repr(1234567890) \
+#repr(0123456789) \
+#repr(0) \
+#repr(-0) \
+#repr(-1) \
+#repr(-9876543210) \
+#repr(-0987654321) \
+#repr(4 - 8)
+
+--- int-display ---
+// Test integers.
+#12 \
+#1234567890 \
+#0123456789 \
+#0 \
+#(-0) \
+#(-1) \
+#(-9876543210) \
+#(-0987654321) \
+#(4 - 8)
+
+--- issue-int-constructor ---
+// Test that integer -> integer conversion doesn't do a roundtrip through float.
+#let x = 9223372036854775800
+#test(type(x), int)
+#test(int(x), x)
+
+--- number-invalid-suffix ---
+// Error: 2-4 invalid number suffix: u
+#1u
diff --git a/tests/suite/foundations/label.typ b/tests/suite/foundations/label.typ
new file mode 100644
index 00000000..2cde102c
--- /dev/null
+++ b/tests/suite/foundations/label.typ
@@ -0,0 +1,70 @@
+// Test labels.
+
+--- label-show-where-selector ---
+// Test labelled headings.
+#show heading: set text(10pt)
+#show heading.where(label: <intro>): underline
+
+= Introduction <intro>
+The beginning.
+
+= Conclusion
+The end.
+
+--- label-after-expression ---
+// Test label after expression.
+#show strong.where(label: <v>): set text(red)
+
+#let a = [*A*]
+#let b = [*B*]
+#a <v> #b
+
+--- label-on-text ---
+// Test labelled text.
+#show "t": it => {
+ set text(blue) if it.has("label") and it.label == <last>
+ it
+}
+
+This is a thing #[that <last>] happened.
+
+--- label-dynamic-show-set ---
+// Test abusing dynamic labels for styling.
+#show <red>: set text(red)
+#show <blue>: set text(blue)
+
+*A* *B* <red> *C* #label("bl" + "ue") *D*
+
+--- label-after-parbreak ---
+// Test that label ignores parbreak.
+#show <hide>: none
+
+_Hidden_
+<hide>
+
+_Hidden_
+
+<hide>
+_Visible_
+
+--- label-in-block ---
+// Test that label only works within one content block.
+#show <strike>: strike
+*This is* #[<strike>] *protected.*
+*This is not.* <strike>
+
+--- label-unclosed-is-text ---
+// Test that incomplete label is text.
+1 < 2 is #if 1 < 2 [not] a label.
+
+--- label-text-styled-and-sequence ---
+// Test label on text, styled, and sequence.
+#test([Hello<hi>].label, <hi>)
+#test([#[A *B* C]<hi>].label, <hi>)
+#test([#text(red)[Hello]<hi>].label, <hi>)
+
+--- label-string-conversion ---
+// Test getting the name of a label.
+#test(str(<hey>), "hey")
+#test(str(label("hey")), "hey")
+#test(str([Hmm<hey>].label), "hey")
diff --git a/tests/suite/foundations/panic.typ b/tests/suite/foundations/panic.typ
new file mode 100644
index 00000000..5d9d4046
--- /dev/null
+++ b/tests/suite/foundations/panic.typ
@@ -0,0 +1,14 @@
+--- panic ---
+// Test panic.
+// Error: 2-9 panicked
+#panic()
+
+--- panic-with-int ---
+// Test panic.
+// Error: 2-12 panicked with: 123
+#panic(123)
+
+--- panic-with-str ---
+// Test panic.
+// Error: 2-24 panicked with: "this is wrong"
+#panic("this is wrong")
diff --git a/tests/suite/foundations/plugin.typ b/tests/suite/foundations/plugin.typ
new file mode 100644
index 00000000..0842980e
--- /dev/null
+++ b/tests/suite/foundations/plugin.typ
@@ -0,0 +1,47 @@
+// Test WebAssembly plugins.
+
+--- plugin-basic ---
+#let p = plugin("/assets/plugins/hello.wasm")
+#test(p.hello(), bytes("Hello from wasm!!!"))
+#test(p.double_it(bytes("hey!")), bytes("hey!.hey!"))
+#test(
+ p.shuffle(bytes("value1"), bytes("value2"), bytes("value3")),
+ bytes("value3-value1-value2"),
+)
+
+--- plugin-wrong-number-of-arguments ---
+#let p = plugin("/assets/plugins/hello.wasm")
+
+// Error: 2-20 plugin function takes 0 arguments, but 1 was given
+#p.hello(bytes(""))
+
+--- plugin-wrong-argument-type ---
+#let p = plugin("/assets/plugins/hello.wasm")
+
+// Error: 10-14 expected bytes, found boolean
+// Error: 27-29 expected bytes, found integer
+#p.hello(true, bytes(()), 10)
+
+--- plugin-error ---
+#let p = plugin("/assets/plugins/hello.wasm")
+
+// Error: 2-17 plugin errored with: This is an `Err`
+#p.returns_err()
+
+--- plugin-panic ---
+#let p = plugin("/assets/plugins/hello.wasm")
+
+// Error: 2-16 plugin panicked: wasm `unreachable` instruction executed
+#p.will_panic()
+
+--- plugin-out-of-bounds-read ---
+#let p = plugin("/assets/plugins/plugin-oob.wasm")
+
+// Error: 2-14 plugin tried to read out of bounds: pointer 0x40000000 is out of bounds for read of length 1
+#p.read_oob()
+
+--- plugin-out-of-bounds-write ---
+#let p = plugin("/assets/plugins/plugin-oob.wasm")
+
+// Error: 2-27 plugin tried to write out of bounds: pointer 0x40000000 is out of bounds for write of length 3
+#p.write_oob(bytes("xyz"))
diff --git a/tests/suite/foundations/repr.typ b/tests/suite/foundations/repr.typ
new file mode 100644
index 00000000..7f03209b
--- /dev/null
+++ b/tests/suite/foundations/repr.typ
@@ -0,0 +1,57 @@
+--- repr ---
+#test(repr(ltr), "ltr")
+#test(repr((1, 2, false, )), "(1, 2, false)")
+
+--- repr-literals ---
+// Literal values.
+#auto \
+#none (empty) \
+#true \
+#false
+
+--- repr-numerical ---
+// Numerical values.
+#1 \
+#1.0e-4 \
+#3.15 \
+#1e-10 \
+#50.368% \
+#0.0000012345pt \
+#4.5cm \
+#12e1pt \
+#2.5rad \
+#45deg \
+#1.7em \
+#(1cm + 0em) \
+#(2em + 10pt) \
+#(100% + (2em + 2pt)) \
+#(100% + 0pt) \
+#(100% - 2em + 2pt) \
+#(100% - 2pt) \
+#2.3fr
+
+--- repr-misc ---
+// Colors and strokes.
+#set text(0.8em)
+#rgb("f7a205") \
+#(2pt + rgb("f7a205"))
+
+// Strings and escaping.
+#raw(repr("hi"), lang: "typc")
+#repr("a\n[]\"\u{1F680}string")
+
+// Content.
+#raw(lang: "typc", repr[*Hey*]) \
+#raw(lang: "typc", repr[A _sequence_]) \
+#raw(lang: "typc", repr[A _longer_ *sequence*!])
+
+// Functions.
+#let f(x) = x
+#f \
+#rect \
+#(() => none)
+
+// Types.
+#int \
+#type("hi") \
+#type((a: 1))
diff --git a/tests/suite/foundations/str.typ b/tests/suite/foundations/str.typ
new file mode 100644
index 00000000..025ec53d
--- /dev/null
+++ b/tests/suite/foundations/str.typ
@@ -0,0 +1,315 @@
+// Test the string methods.
+
+--- str-constructor ---
+// Test conversion to string.
+#test(str(123), "123")
+#test(str(123, base: 3), "11120")
+#test(str(-123, base: 16), "−7b")
+#test(str(9223372036854775807, base: 36), "1y2p0ij32e8e7")
+#test(str(50.14), "50.14")
+#test(str(10 / 3).len() > 10, true)
+
+--- str-from-float ---
+// Test the `str` function with floats.
+#test(str(12.0), "12")
+#test(str(3.14), "3.14")
+#test(str(1234567890.0), "1234567890")
+#test(str(0123456789.0), "123456789")
+#test(str(0.0), "0")
+#test(str(-0.0), "0")
+#test(str(-1.0), "−1")
+#test(str(-9876543210.0), "−9876543210")
+#test(str(-0987654321.0), "−987654321")
+#test(str(-3.14), "−3.14")
+#test(str(4.0 - 8.0), "−4")
+
+--- str-from-int ---
+// Test the `str` function with integers.
+#test(str(12), "12")
+#test(str(1234567890), "1234567890")
+#test(str(0123456789), "123456789")
+#test(str(0), "0")
+#test(str(-0), "0")
+#test(str(-1), "−1")
+#test(str(-9876543210), "−9876543210")
+#test(str(-0987654321), "−987654321")
+#test(str(4 - 8), "−4")
+
+--- str-constructor-bad-type ---
+// Error: 6-8 expected integer, float, version, bytes, label, type, or string, found content
+#str([])
+
+--- str-constructor-bad-base ---
+// Error: 17-19 base must be between 2 and 36
+#str(123, base: 99)
+
+--- str-constructor-unsupported-base ---
+// Error: 18-19 base is only supported for integers
+#str(1.23, base: 2)
+
+--- str-from-and-to-unicode ---
+// Test the unicode function.
+#test(str.from-unicode(97), "a")
+#test(str.to-unicode("a"), 97)
+
+--- str-from-unicode-bad-type ---
+// Error: 19-22 expected integer, found content
+#str.from-unicode([a])
+
+--- str-to-unicode-bad-type ---
+// Error: 17-21 expected exactly one character
+#str.to-unicode("ab")
+
+--- str-from-unicode-negative ---
+// Error: 19-21 number must be at least zero
+#str.from-unicode(-1)
+
+--- str-from-unicode-bad-value ---
+// Error: 2-28 0x110000 is not a valid codepoint
+#str.from-unicode(0x110000) // 0x10ffff is the highest valid code point
+
+--- string-len ---
+// Test the `len` method.
+#test("Hello World!".len(), 12)
+
+--- string-first-and-last ---
+// Test the `first` and `last` methods.
+#test("Hello".first(), "H")
+#test("Hello".last(), "o")
+#test("🏳️‍🌈A🏳️‍⚧️".first(), "🏳️‍🌈")
+#test("🏳️‍🌈A🏳️‍⚧️".last(), "🏳️‍⚧️")
+
+--- string-first-empty ---
+// Error: 2-12 string is empty
+#"".first()
+
+--- string-last-empty ---
+// Error: 2-11 string is empty
+#"".last()
+
+--- string-at ---
+// Test the `at` method.
+#test("Hello".at(1), "e")
+#test("Hello".at(4), "o")
+#test("Hello".at(-1), "o")
+#test("Hello".at(-2), "l")
+#test("Hey: 🏳️‍🌈 there!".at(5), "🏳️‍🌈")
+
+--- string-at-default ---
+// Test `at`'s 'default' parameter.
+#test("z", "Hello".at(5, default: "z"))
+
+--- string-at-not-a-char-boundary ---
+// Error: 2-14 string index 2 is not a character boundary
+#"🏳️‍🌈".at(2)
+
+--- string-at-out-of-bounds ---
+// Error: 2-15 no default value was specified and string index out of bounds (index: 5, len: 5)
+#"Hello".at(5)
+
+--- string-at-at-default-other-type ---
+#test("Hello".at(5, default: (a: 10)), (a: 10))
+
+--- string-slice ---
+// Test the `slice` method.
+#test("abc".slice(1, 2), "b")
+#test("abc🏡def".slice(2, 7), "c🏡")
+#test("abc🏡def".slice(2, -2), "c🏡d")
+#test("abc🏡def".slice(-3, -1), "de")
+
+--- string-slice-not-a-char-boundary ---
+// Error: 2-21 string index -1 is not a character boundary
+#"🏳️‍🌈".slice(0, -1)
+
+--- string-clusters ---
+// Test the `clusters` and `codepoints` methods.
+#test("abc".clusters(), ("a", "b", "c"))
+#test("abc".clusters(), ("a", "b", "c"))
+#test("🏳️‍🌈!".clusters(), ("🏳️‍🌈", "!"))
+
+--- string-codepoints ---
+#test("🏳️‍🌈!".codepoints(), ("🏳", "\u{fe0f}", "\u{200d}", "🌈", "!"))
+
+--- string-contains ---
+// Test the `contains` method.
+#test("abc".contains("b"), true)
+#test("b" in "abc", true)
+#test("1234f".contains(regex("\d")), true)
+#test(regex("\d") in "1234f", true)
+#test("abc".contains("d"), false)
+#test("1234g" in "1234f", false)
+#test("abc".contains(regex("^[abc]$")), false)
+#test("abc".contains(regex("^[abc]+$")), true)
+
+--- string-starts-with ---
+// Test the `starts-with` and `ends-with` methods.
+#test("Typst".starts-with("Ty"), true)
+#test("Typst".starts-with(regex("[Tt]ys")), false)
+#test("Typst".starts-with("st"), false)
+
+--- string-ends-with ---
+#test("Typst".ends-with("st"), true)
+#test("Typst".ends-with(regex("\d*")), true)
+#test("Typst".ends-with(regex("\d+")), false)
+#test("Typ12".ends-with(regex("\d+")), true)
+#test("typst13".ends-with(regex("1[0-9]")), true)
+#test("typst113".ends-with(regex("1[0-9]")), true)
+#test("typst23".ends-with(regex("1[0-9]")), false)
+
+--- string-find-and-position ---
+// Test the `find` and `position` methods.
+#let date = regex("\d{2}:\d{2}")
+#test("Hello World".find("World"), "World")
+#test("Hello World".position("World"), 6)
+#test("It's 12:13 now".find(date), "12:13")
+#test("It's 12:13 now".position(date), 5)
+
+--- string-match ---
+// Test the `match` method.
+#test("Is there a".match("for this?"), none)
+#test(
+ "The time of my life.".match(regex("[mit]+e")),
+ (start: 4, end: 8, text: "time", captures: ()),
+)
+
+--- string-matches ---
+// Test the `matches` method.
+#test("Hello there".matches("\d"), ())
+#test("Day by Day.".matches("Day"), (
+ (start: 0, end: 3, text: "Day", captures: ()),
+ (start: 7, end: 10, text: "Day", captures: ()),
+))
+
+// Compute the sum of all timestamps in the text.
+#let timesum(text) = {
+ let time = 0
+ for match in text.matches(regex("(\d+):(\d+)")) {
+ let caps = match.captures
+ time += 60 * int(caps.at(0)) + int(caps.at(1))
+ }
+ str(int(time / 60)) + ":" + str(calc.rem(time, 60))
+}
+
+#test(timesum(""), "0:0")
+#test(timesum("2:70"), "3:10")
+#test(timesum("1:20, 2:10, 0:40"), "4:10")
+
+--- stgring-replace ---
+// Test the `replace` method with `Str` replacements.
+#test("ABC".replace("", "-"), "-A-B-C-")
+#test("Ok".replace("Ok", "Nope", count: 0), "Ok")
+#test("to add?".replace("", "How ", count: 1), "How to add?")
+#test("AB C DEF GH J".replace(" ", ",", count: 2), "AB,C,DEF GH J")
+#test("Walcemo"
+ .replace("o", "k")
+ .replace("e", "o")
+ .replace("k", "e")
+ .replace("a", "e"),
+ "Welcome"
+)
+#test("123".replace(regex("\d$"), "_"), "12_")
+#test("123".replace(regex("\d{1,2}$"), "__"), "1__")
+
+--- string-replace-function ---
+// Test the `replace` method with `Func` replacements.
+
+#test("abc".replace(regex("[a-z]"), m => {
+ str(m.start) + m.text + str(m.end)
+}), "0a11b22c3")
+#test("abcd, efgh".replace(regex("\w+"), m => {
+ upper(m.text)
+}), "ABCD, EFGH")
+#test("hello : world".replace(regex("^(.+)\s*(:)\s*(.+)$"), m => {
+ upper(m.captures.at(0)) + m.captures.at(1) + " " + upper(m.captures.at(2))
+}), "HELLO : WORLD")
+#test("hello world, lorem ipsum".replace(regex("(\w+) (\w+)"), m => {
+ m.captures.at(1) + " " + m.captures.at(0)
+}), "world hello, ipsum lorem")
+#test("hello world, lorem ipsum".replace(regex("(\w+) (\w+)"), count: 1, m => {
+ m.captures.at(1) + " " + m.captures.at(0)
+}), "world hello, lorem ipsum")
+#test("123 456".replace(regex("[a-z]+"), "a"), "123 456")
+
+#test("abc".replace("", m => "-"), "-a-b-c-")
+#test("abc".replace("", m => "-", count: 1), "-abc")
+#test("123".replace("abc", m => ""), "123")
+#test("123".replace("abc", m => "", count: 2), "123")
+#test("a123b123c".replace("123", m => {
+ str(m.start) + "-" + str(m.end)
+}), "a1-4b5-8c")
+#test("halla warld".replace("a", m => {
+ if m.start == 1 { "e" }
+ else if m.start == 4 or m.start == 7 { "o" }
+}), "hello world")
+#test("aaa".replace("a", m => str(m.captures.len())), "000")
+
+--- string-replace-function-bad-type ---
+// Error: 23-24 expected string, found integer
+#"123".replace("123", m => 1)
+
+--- string-replace-bad-type ---
+// Error: 23-32 expected string or function, found array
+#"123".replace("123", (1, 2, 3))
+
+--- string-trim-basic ---
+// Test the `trim` method; the pattern is not provided.
+#let str = "Typst, LaTeX, Word, InDesign"
+#let array = ("Typst", "LaTeX", "Word", "InDesign")
+#test(str.split(",").map(s => s.trim()), array)
+#test("".trim(), "")
+#test(" ".trim(), "")
+#test("\t".trim(), "")
+#test("\n".trim(), "")
+#test("\t \n".trim(), "")
+#test(" abc ".trim(at: start), "abc ")
+#test("\tabc ".trim(at: start), "abc ")
+#test("abc\n".trim(at: end), "abc")
+#test(" abc ".trim(at: end, repeat: true), " abc")
+#test(" abc".trim(at: start, repeat: false), "abc")
+
+--- string-trim-pattern-str ---
+// Test the `trim` method; the pattern is a string.
+#test("aabcaa".trim("a", repeat: false), "abca")
+#test("aabca".trim("a", at: start), "bca")
+#test("aabcaa".trim("a", at: end, repeat: false), "aabca")
+#test(" abc\n".trim("\n"), " abc")
+#test("whole".trim("whole", at: start), "")
+
+--- string-trim-pattern-regex ---
+// Test the `trim` method; the pattern is a regex.
+#test("".trim(regex(".")), "")
+#test("123abc456".trim(regex("\d")), "abc")
+#test("123abc456".trim(regex("\d"), repeat: false), "23abc45")
+#test("123a4b5c678".trim(regex("\d"), repeat: true), "a4b5c")
+#test("123a4b5c678".trim(regex("\d"), repeat: false), "23a4b5c67")
+#test("123abc456".trim(regex("\d"), at: start), "abc456")
+#test("123abc456".trim(regex("\d"), at: end), "123abc")
+#test("123abc456".trim(regex("\d+"), at: end, repeat: false), "123abc")
+#test("123abc456".trim(regex("\d{1,2}$"), repeat: false), "123abc4")
+#test("hello world".trim(regex(".")), "")
+#test("12306".trim(regex("\d"), at: start), "")
+#test("12306abc".trim(regex("\d"), at: start), "abc")
+#test("whole".trim(regex("whole"), at: start), "")
+#test("12306".trim(regex("\d"), at: end), "")
+#test("abc12306".trim(regex("\d"), at: end), "abc")
+#test("whole".trim(regex("whole"), at: end), "")
+
+--- string-trim-at-bad-alignment ---
+// Error: 17-21 expected either `start` or `end`
+#"abc".trim(at: left)
+
+--- string-split ---
+// Test the `split` method.
+#test("abc".split(""), ("", "a", "b", "c", ""))
+#test("abc".split("b"), ("a", "c"))
+#test("a123c".split(regex("\d")), ("a", "", "", "c"))
+#test("a123c".split(regex("\d+")), ("a", "c"))
+
+--- string-rev ---
+// Test the `rev` method.
+#test("abc".rev(), "cba")
+#test("ax̂e".rev(), "ex̂a")
+
+--- string-unclosed ---
+// Error: 2-2:1 unclosed string
+#"hello\"
diff --git a/tests/suite/foundations/type.typ b/tests/suite/foundations/type.typ
new file mode 100644
index 00000000..f2a98845
--- /dev/null
+++ b/tests/suite/foundations/type.typ
@@ -0,0 +1,25 @@
+--- type ---
+#test(type(1), int)
+#test(type(ltr), direction)
+#test(type(10 / 3), float)
+
+--- type-string-compatibility ---
+#test(type(10), int)
+#test(type(10), "integer")
+#test("is " + type(10), "is integer")
+#test(int in ("integer", "string"), true)
+#test(int in "integers or strings", true)
+#test(str in "integers or strings", true)
+
+--- issue-3110-type-constructor ---
+// Let the error message report the type name.
+// Error: 2-9 type content does not have a constructor
+#content()
+
+--- issue-3110-associated-field ---
+// Error: 6-12 type integer does not contain field `MAXVAL`
+#int.MAXVAL
+
+--- issue-3110-associated-function ---
+// Error: 6-18 type string does not contain field `from-unïcode`
+#str.from-unïcode(97)
diff --git a/tests/suite/foundations/version.typ b/tests/suite/foundations/version.typ
new file mode 100644
index 00000000..bf2cadb1
--- /dev/null
+++ b/tests/suite/foundations/version.typ
@@ -0,0 +1,47 @@
+// Test versions.
+
+--- version-constructor ---
+// Test version constructor.
+
+// Empty.
+#version()
+
+// Plain.
+#test(version(1, 2).major, 1)
+
+// Single Array argument.
+#test(version((1, 2)).minor, 2)
+
+// Mixed arguments.
+#test(version(1, (2, 3), 4, (5, 6), 7).at(5), 6)
+
+--- version-equality ---
+// Test equality of different-length versions
+#test(version(), version(0))
+#test(version(0), version(0, 0))
+#test(version(1, 2), version(1, 2, 0, 0, 0, 0))
+
+--- version-at ---
+// Test `version.at`.
+
+// Non-negative index in bounds
+#test(version(1, 2).at(1), 2)
+
+// Non-negative index out of bounds
+#test(version(1, 2).at(4), 0)
+
+// Negative index in bounds
+#test(version(1, 2).at(-2), 1)
+
+// Error: 2-22 component index out of bounds (index: -3, len: 2)
+#version(1, 2).at(-3)
+
+--- version-fields ---
+// Test version fields.
+#test(version(1, 2, 3).major, 1)
+#test(version(1, 2, 3).minor, 2)
+#test(version(1, 2, 3).patch, 3)
+
+--- version-type ---
+// Test the type of `sys.version`
+#test(type(sys.version), version)
diff --git a/tests/suite/introspection/counter.typ b/tests/suite/introspection/counter.typ
new file mode 100644
index 00000000..8a5315f9
--- /dev/null
+++ b/tests/suite/introspection/counter.typ
@@ -0,0 +1,78 @@
+// Test counters.
+
+--- counter-basic-1 ---
+// Count with string key.
+#let mine = counter("mine!")
+
+Final: #context mine.final().at(0) \
+#mine.step()
+First: #context mine.display() \
+#mine.update(7)
+#context mine.display("1 of 1", both: true) \
+#mine.step()
+#mine.step()
+Second: #context mine.display("I")
+#mine.update(n => n * 2)
+#mine.step()
+
+--- counter-basic-2 ---
+// Test `counter`.
+#let c = counter("heading")
+#c.update(2)
+#c.update(n => n + 2)
+#context test(c.get(), (4,))
+#c.update(n => n - 3)
+#context test(c.at(here()), (1,))
+
+--- counter-label ---
+// Count labels.
+#let label = <heya>
+#let count = context counter(label).display()
+#let elem(it) = [#box(it) #label]
+
+#elem[hey, there!] #count \
+#elem[more here!] #count
+
+--- counter-heading ---
+// Count headings.
+#set heading(numbering: "1.a.")
+#show heading: set text(10pt)
+#counter(heading).step()
+
+= Alpha
+In #context counter(heading).display()
+== Beta
+
+#set heading(numbering: none)
+= Gamma
+#heading(numbering: "I.")[Delta]
+
+At Beta, it was #context {
+ let it = query(heading).find(it => it.body == [Beta])
+ numbering(it.numbering, ..counter(heading).at(it.location()))
+}
+
+--- counter-page ---
+#set page(height: 50pt, margin: (bottom: 20pt, rest: 10pt))
+#lorem(12)
+#set page(numbering: "(i)")
+#lorem(6)
+#pagebreak()
+#set page(numbering: "1 / 1")
+#counter(page).update(1)
+#lorem(20)
+
+--- counter-figure ---
+// Count figures.
+#figure(numbering: "A", caption: [Four 'A's], kind: image, supplement: "Figure")[_AAAA!_]
+#figure(numbering: none, caption: [Four 'B's], kind: image, supplement: "Figure")[_BBBB!_]
+#figure(caption: [Four 'C's], kind: image, supplement: "Figure")[_CCCC!_]
+#counter(figure.where(kind: image)).update(n => n + 3)
+#figure(caption: [Four 'D's], kind: image, supplement: "Figure")[_DDDD!_]
+
+--- counter-at-no-context ---
+// Test `counter.at` outside of context.
+// Error: 2-28 can only be used when context is known
+// Hint: 2-28 try wrapping this in a `context` expression
+// Hint: 2-28 the `context` expression should wrap everything that depends on this function
+#counter("key").at(<label>)
diff --git a/tests/suite/introspection/here.typ b/tests/suite/introspection/here.typ
new file mode 100644
index 00000000..18fff439
--- /dev/null
+++ b/tests/suite/introspection/here.typ
@@ -0,0 +1,3 @@
+--- here-position ---
+// Test `context` + `here`.
+#context test(here().position().y, 10pt)
diff --git a/tests/suite/introspection/locate.typ b/tests/suite/introspection/locate.typ
new file mode 100644
index 00000000..981f8c46
--- /dev/null
+++ b/tests/suite/introspection/locate.typ
@@ -0,0 +1,32 @@
+--- locate-position ---
+// Test `locate`.
+#v(10pt)
+= Introduction <intro>
+#context test(locate(<intro>).position().y, 20pt)
+
+--- locate-missing-label ---
+// Error: 10-25 label `<intro>` does not exist in the document
+#context locate(<intro>)
+
+--- locate-duplicate-label ---
+= Introduction <intro>
+= Introduction <intro>
+
+// Error: 10-25 label `<intro>` occurs multiple times in the document
+#context locate(<intro>)
+
+--- locate-element-selector ---
+#v(10pt)
+= Introduction <intro>
+#context test(locate(heading).position().y, 20pt)
+
+--- locate-element-selector-no-match ---
+// Error: 10-25 selector does not match any element
+#context locate(heading)
+
+--- locate-element-selector-multiple-matches ---
+= Introduction <intro>
+= Introduction <intro>
+
+// Error: 10-25 selector matches multiple elements
+#context locate(heading)
diff --git a/tests/suite/introspection/query.typ b/tests/suite/introspection/query.typ
new file mode 100644
index 00000000..3a4b4fbf
--- /dev/null
+++ b/tests/suite/introspection/query.typ
@@ -0,0 +1,267 @@
+// Test creating a header with the query function.
+
+--- query-here ---
+// Test that `here()` yields the context element's location.
+#context test(query(here()).first().func(), (context none).func())
+
+--- query-running-header ---
+#set page(
+ paper: "a8",
+ margin: (y: 1cm, x: 0.5cm),
+ header: context {
+ smallcaps[Typst Academy]
+ h(1fr)
+ let after = query(selector(heading).after(here()))
+ let before = query(selector(heading).before(here()))
+ let elem = if before.len() != 0 {
+ before.last()
+ } else if after.len() != 0 {
+ after.first()
+ }
+ emph(elem.body)
+ }
+)
+
+#outline()
+
+= Introduction
+#v(1cm)
+
+= Background
+#v(2cm)
+
+= Approach
+
+--- query-list-of-figures ---
+#set page(
+ paper: "a8",
+ numbering: "1 / 1",
+ margin: (bottom: 1cm, rest: 0.5cm),
+)
+
+#set figure(numbering: "I")
+#show figure: set image(width: 80%)
+
+= List of Figures
+#context {
+ let elements = query(selector(figure).after(here()))
+ for it in elements [
+ Figure
+ #numbering(it.numbering,
+ ..counter(figure).at(it.location())):
+ #it.caption.body
+ #box(width: 1fr, repeat[.])
+ #counter(page).at(it.location()).first() \
+ ]
+}
+
+#figure(
+ image("/assets/images/cylinder.svg", width: 50%),
+ caption: [Cylinder],
+)
+
+#figure(
+ rect[Just some stand-in text],
+ kind: image,
+ supplement: "Figure",
+ caption: [Stand-in text],
+)
+
+#figure(
+ image("/assets/images/tetrahedron.svg", width: 50%),
+ caption: [Tetrahedron],
+)
+
+--- query-before-after ---
+// LARGE
+#set page(
+ paper: "a7",
+ numbering: "1 / 1",
+ margin: (bottom: 1cm, rest: 0.5cm),
+)
+
+#show heading.where(level: 1, outlined: true): it => [
+ #it
+
+ #set text(size: 12pt, weight: "regular")
+ #outline(
+ title: "Chapter outline",
+ indent: true,
+ target: heading
+ .where(level: 1)
+ .or(heading.where(level: 2))
+ .after(it.location(), inclusive: true)
+ .before(
+ heading
+ .where(level: 1, outlined: true)
+ .after(it.location(), inclusive: false),
+ inclusive: false,
+ )
+ )
+]
+
+#set heading(outlined: true, numbering: "1.")
+
+= Section 1
+== Subsection 1
+== Subsection 2
+=== Subsubsection 1
+=== Subsubsection 2
+== Subsection 3
+
+= Section 2
+== Subsection 1
+== Subsection 2
+
+= Section 3
+== Subsection 1
+== Subsection 2
+=== Subsubsection 1
+=== Subsubsection 2
+=== Subsubsection 3
+== Subsection 3
+
+--- query-and-or ---
+#set page(
+ paper: "a7",
+ numbering: "1 / 1",
+ margin: (bottom: 1cm, rest: 0.5cm),
+)
+
+#set heading(outlined: true, numbering: "1.")
+
+#context [
+ Non-outlined elements:
+ #(query(selector(heading).and(heading.where(outlined: false)))
+ .map(it => it.body).join(", "))
+]
+
+#heading("A", outlined: false)
+#heading("B", outlined: true)
+#heading("C", outlined: true)
+#heading("D", outlined: false)
+
+--- query-complex ---
+= A
+== B
+#figure([Cat], kind: "cat", supplement: [Other])
+=== D
+= E <first>
+#figure([Frog], kind: "frog", supplement: none)
+#figure([Giraffe], kind: "giraffe", supplement: none) <second>
+#figure([GiraffeCat], kind: "cat", supplement: [Other]) <second>
+= H
+#figure([Iguana], kind: "iguana", supplement: none)
+== I
+
+#let test-selector(selector, ref) = context {
+ test(query(selector).map(e => e.body), ref)
+}
+
+// Test `or`.
+#test-selector(
+ heading.where(level: 1).or(heading.where(level: 3)),
+ ([A], [D], [E], [H]),
+)
+
+#test-selector(
+ heading.where(level: 1).or(
+ heading.where(level: 3),
+ figure.where(kind: "frog"),
+ ),
+ ([A], [D], [E], [Frog], [H]),
+)
+
+#test-selector(
+ heading.where(level: 1).or(
+ heading.where(level: 2),
+ figure.where(kind: "frog"),
+ figure.where(kind: "cat"),
+ ),
+ ([A], [B], [Cat], [E], [Frog], [GiraffeCat], [H], [I]),
+)
+
+#test-selector(
+ figure.where(kind: "dog").or(heading.where(level: 3)),
+ ([D],),
+)
+
+#test-selector(
+ figure.where(kind: "dog").or(figure.where(kind: "fish")),
+ (),
+)
+
+// Test `or` duplicates removal.
+#test-selector(
+ heading.where(level: 1).or(heading.where(level: 1)),
+ ([A], [E], [H]),
+)
+
+// Test `and`.
+#test-selector(
+ figure.where(kind: "cat").and(figure.where(kind: "frog")),
+ (),
+)
+
+// Test `or` with `before`/`after`
+#test-selector(
+ selector(heading)
+ .before(<first>)
+ .or(selector(figure).before(<first>)),
+ ([A], [B], [Cat], [D], [E]),
+)
+
+#test-selector(
+ heading.where(level: 2)
+ .after(<first>)
+ .or(selector(figure).after(<first>)),
+ ([Frog], [Giraffe], [GiraffeCat], [Iguana], [I]),
+)
+
+// Test `and` with `after`
+#test-selector(
+ figure.where(kind: "cat")
+ .and(figure.where(supplement: [Other]))
+ .after(<first>),
+ ([GiraffeCat],),
+)
+
+// Test `and` (with nested `or`)
+#test-selector(
+ heading.where(level: 2)
+ .or(heading.where(level: 3))
+ .and(heading.where(level: 2).or(heading.where(level: 1))),
+ ([B], [I]),
+)
+
+#test-selector(
+ heading.where(level: 2)
+ .or(heading.where(level: 3), heading.where(level:1))
+ .and(
+ heading.where(level: 2).or(heading.where(level: 1)),
+ heading.where(level: 3).or(heading.where(level: 1)),
+ ),
+ ([A], [E], [H]),
+)
+
+// Test `and` with `or` and `before`/`after`
+#test-selector(
+ heading.where(level: 1).before(<first>)
+ .or(heading.where(level: 3).before(<first>))
+ .and(
+ heading.where(level: 1).before(<first>)
+ .or(heading.where(level: 2).before(<first>))
+ ),
+ ([A], [E]),
+)
+
+#test-selector(
+ heading.where(level: 1).before(<first>, inclusive: false)
+ .or(selector(figure).after(<first>))
+ .and(figure.where(kind: "iguana").or(
+ figure.where(kind: "frog"),
+ figure.where(kind: "cat"),
+ heading.where(level: 1).after(<first>),
+ )),
+ ([Frog], [GiraffeCat], [Iguana])
+)
diff --git a/tests/suite/introspection/state.typ b/tests/suite/introspection/state.typ
new file mode 100644
index 00000000..208a4ea2
--- /dev/null
+++ b/tests/suite/introspection/state.typ
@@ -0,0 +1,63 @@
+// Test state.
+
+--- state-basic ---
+#let s = state("hey", "a")
+#let double(it) = 2 * it
+
+#s.update(double)
+#s.update(double)
+$ 2 + 3 $
+#s.update(double)
+
+Is: #context s.get(),
+Was: #context {
+ let it = query(math.equation).first()
+ s.at(it.location())
+}.
+
+--- state-multiple-calls-same-key ---
+// Try same key with different initial value.
+#context state("key", 2).get()
+#state("key").update(x => x + 1)
+#context state("key", 2).get()
+#context state("key", 3).get()
+#state("key").update(x => x + 1)
+#context state("key", 2).get()
+
+--- state-nested ---
+#set page(width: 200pt)
+#set text(8pt)
+
+#let ls = state("lorem", lorem(1000).split("."))
+#let loremum(count) = {
+ context ls.get().slice(0, count).join(".").trim() + "."
+ ls.update(list => list.slice(count))
+}
+
+#let fs = state("fader", red)
+#let trait(title) = block[
+ #context text(fill: fs.get())[
+ *#title:* #loremum(1)
+ ]
+ #fs.update(color => color.lighten(30%))
+]
+
+#trait[Boldness]
+#trait[Adventure]
+#trait[Fear]
+#trait[Anger]
+
+--- state-no-convergence ---
+// Make sure that a warning is produced if the layout fails to converge.
+// Warning: layout did not converge within 5 attempts
+// Hint: check if any states or queries are updating themselves
+#let s = state("s", 1)
+#context s.update(s.final() + 1)
+#context s.get()
+
+--- state-at-no-context ---
+// Test `state.at` outside of context.
+// Error: 2-26 can only be used when context is known
+// Hint: 2-26 try wrapping this in a `context` expression
+// Hint: 2-26 the `context` expression should wrap everything that depends on this function
+#state("key").at(<label>)
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]!
diff --git a/tests/suite/loading/csv.typ b/tests/suite/loading/csv.typ
new file mode 100644
index 00000000..415488fc
--- /dev/null
+++ b/tests/suite/loading/csv.typ
@@ -0,0 +1,27 @@
+--- csv ---
+// Test reading CSV data.
+#set page(width: auto)
+#let data = csv("/assets/data/zoo.csv")
+#let cells = data.at(0).map(strong) + data.slice(1).flatten()
+#table(columns: data.at(0).len(), ..cells)
+
+--- csv-row-type-dict ---
+// Test reading CSV data with dictionary rows enabled.
+#let data = csv("/assets/data/zoo.csv", row-type: dictionary)
+#test(data.len(), 3)
+#test(data.at(0).Name, "Debby")
+#test(data.at(2).Weight, "150kg")
+#test(data.at(1).Species, "Tiger")
+
+--- csv-file-not-found ---
+// Error: 6-16 file not found (searched at tests/suite/loading/nope.csv)
+#csv("nope.csv")
+
+--- csv-invalid ---
+// Error: 6-28 failed to parse CSV (found 3 instead of 2 fields in line 3)
+#csv("/assets/data/bad.csv")
+
+--- csv-invalid-row-type-dict ---
+// Test error numbering with dictionary rows.
+// Error: 6-28 failed to parse CSV (found 3 instead of 2 fields in line 3)
+#csv("/assets/data/bad.csv", row-type: dictionary)
diff --git a/tests/suite/loading/json.typ b/tests/suite/loading/json.typ
new file mode 100644
index 00000000..3ebeaf2f
--- /dev/null
+++ b/tests/suite/loading/json.typ
@@ -0,0 +1,16 @@
+--- json ---
+// Test reading JSON data.
+#let data = json("/assets/data/zoo.json")
+#test(data.len(), 3)
+#test(data.at(0).name, "Debby")
+#test(data.at(2).weight, 150)
+
+--- json-invalid ---
+// Error: 7-30 failed to parse JSON (expected value at line 3 column 14)
+#json("/assets/data/bad.json")
+
+--- issue-3363-json-large-number ---
+// Big numbers (larger than what i64 can store) should just lose some precision
+// but not overflow
+#let bignum = json("/assets/data/big-number.json")
+#bignum
diff --git a/tests/suite/loading/read.typ b/tests/suite/loading/read.typ
new file mode 100644
index 00000000..b5c9c089
--- /dev/null
+++ b/tests/suite/loading/read.typ
@@ -0,0 +1,12 @@
+--- read-text ---
+// Test reading plain text files
+#let data = read("/assets/text/hello.txt")
+#test(data, "Hello, world!\n")
+
+--- read-file-not-found ---
+// Error: 18-44 file not found (searched at assets/text/missing.txt)
+#let data = read("/assets/text/missing.txt")
+
+--- read-invalid-utf-8 ---
+// Error: 18-40 file is not valid utf-8
+#let data = read("/assets/text/bad.txt")
diff --git a/tests/suite/loading/toml.typ b/tests/suite/loading/toml.typ
new file mode 100644
index 00000000..855ca995
--- /dev/null
+++ b/tests/suite/loading/toml.typ
@@ -0,0 +1,41 @@
+--- toml ---
+// Test reading TOML data.
+#let data = toml("/assets/data/toml-types.toml")
+#test(data.string, "wonderful")
+#test(data.integer, 42)
+#test(data.float, 3.14)
+#test(data.boolean, true)
+#test(data.array, (1, "string", 3.0, false))
+#test(data.inline_table, ("first": "amazing", "second": "greater") )
+#test(data.table.element, 5)
+#test(data.table.others, (false, "indeed", 7))
+#test(data.date_time, datetime(
+ year: 2023,
+ month: 2,
+ day: 1,
+ hour: 15,
+ minute: 38,
+ second: 57,
+))
+#test(data.date_time2, datetime(
+ year: 2023,
+ month: 2,
+ day: 1,
+ hour: 15,
+ minute: 38,
+ second: 57,
+))
+#test(data.date, datetime(
+ year: 2023,
+ month: 2,
+ day: 1,
+))
+#test(data.time, datetime(
+ hour: 15,
+ minute: 38,
+ second: 57,
+))
+
+--- toml-invalid ---
+// Error: 7-30 failed to parse TOML (expected `.`, `=` at line 1 column 16)
+#toml("/assets/data/bad.toml")
diff --git a/tests/suite/loading/xml.typ b/tests/suite/loading/xml.typ
new file mode 100644
index 00000000..41cd20e7
--- /dev/null
+++ b/tests/suite/loading/xml.typ
@@ -0,0 +1,28 @@
+--- xml ---
+// Test reading XML data.
+#let data = xml("/assets/data/hello.xml")
+#test(data, ((
+ tag: "data",
+ attrs: (:),
+ children: (
+ "\n ",
+ (tag: "hello", attrs: (name: "hi"), children: ("1",)),
+ "\n ",
+ (
+ tag: "data",
+ attrs: (:),
+ children: (
+ "\n ",
+ (tag: "hello", attrs: (:), children: ("World",)),
+ "\n ",
+ (tag: "hello", attrs: (:), children: ("World",)),
+ "\n ",
+ ),
+ ),
+ "\n",
+ ),
+),))
+
+--- xml-invalid ---
+// Error: 6-28 failed to parse XML (found closing tag 'data' instead of 'hello' in line 3)
+#xml("/assets/data/bad.xml")
diff --git a/tests/suite/loading/yaml.typ b/tests/suite/loading/yaml.typ
new file mode 100644
index 00000000..bbfea41c
--- /dev/null
+++ b/tests/suite/loading/yaml.typ
@@ -0,0 +1,17 @@
+--- yaml ---
+// Test reading YAML data
+#let data = yaml("/assets/data/yaml-types.yaml")
+#test(data.len(), 9)
+#test(data.null_key, (none, none))
+#test(data.string, "text")
+#test(data.integer, 5)
+#test(data.float, 1.12)
+#test(data.mapping, ("1": "one", "2": "two"))
+#test(data.seq, (1,2,3,4))
+#test(data.bool, false)
+#test(data.keys().contains("true"), true)
+#test(data.at("1"), "ok")
+
+--- yaml-invalid ---
+// Error: 7-30 failed to parse YAML (did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 18)
+#yaml("/assets/data/bad.yaml")
diff --git a/tests/suite/math/accent.typ b/tests/suite/math/accent.typ
new file mode 100644
index 00000000..9f57d69b
--- /dev/null
+++ b/tests/suite/math/accent.typ
@@ -0,0 +1,33 @@
+// Test math accents.
+
+--- math-accent-sym-call ---
+// Test function call.
+$grave(a), acute(b), hat(f), tilde(§), macron(ä), diaer(a), ä \
+ breve(\&), dot(!), circle(a), caron(@), arrow(Z), arrow.l(Z)$
+
+--- math-accent-align ---
+$ x &= p \ dot(x) &= v \ dot.double(x) &= a \ dot.triple(x) &= j \ dot.quad(x) &= s $
+
+--- math-accent-func ---
+// Test `accent` function.
+$accent(ö, .), accent(v, <-), accent(ZZ, \u{0303})$
+
+--- math-accent-bounds ---
+// Test accent bounds.
+$sqrt(tilde(T)) + hat(f)/hat(g)$
+
+--- math-accent-wide-base ---
+// Test wide base.
+$arrow("ABC" + d), tilde(sum)$
+
+--- math-accent-superscript ---
+// Test effect of accent on superscript.
+$A^x != hat(A)^x != hat(hat(A))^x$
+
+--- math-accent-high-base ---
+// Test high base.
+$ tilde(integral), tilde(integral)_a^b, tilde(integral_a^b) $
+
+--- math-accent-sized ---
+// Test accent size.
+$tilde(sum), tilde(sum, size: #50%), accent(H, hat, size: #200%)$
diff --git a/tests/suite/math/alignment.typ b/tests/suite/math/alignment.typ
new file mode 100644
index 00000000..f110b139
--- /dev/null
+++ b/tests/suite/math/alignment.typ
@@ -0,0 +1,34 @@
+// Test implicit alignment math.
+
+--- math-align-weird ---
+// Test alignment step functions.
+#set page(width: 225pt)
+$
+"a" &= c \
+&= c + 1 & "By definition" \
+&= d + 100 + 1000 \
+&= x && "Even longer" \
+$
+
+--- math-align-post-fix ---
+// Test post-fix alignment.
+$
+& "right" \
+"a very long line" \
+"left" \
+$
+
+--- math-align-implicit ---
+// Test no alignment.
+$
+"right" \
+"a very long line" \
+"left" \
+$
+
+--- math-align-toggle ---
+// Test #460 equations.
+$
+a &=b & quad c&=d \
+e &=f & g&=h
+$
diff --git a/tests/suite/math/attach.typ b/tests/suite/math/attach.typ
new file mode 100644
index 00000000..c9510c6a
--- /dev/null
+++ b/tests/suite/math/attach.typ
@@ -0,0 +1,130 @@
+// Test t and b attachments, part 1.
+
+--- math-attach-postscripts ---
+// Test basics, postscripts.
+$f_x + t^b + V_1^2 + attach(A, t: alpha, b: beta)$
+
+--- math-attach-prescripts ---
+// Test basics, prescripts. Notably, the upper and lower prescripts' content need to be
+// aligned on the right edge of their bounding boxes, not on the left as in postscripts.
+$
+attach(upright(O), bl: 8, tl: 16, br: 2, tr: 2-),
+attach("Pb", bl: 82, tl: 207) + attach(upright(e), bl: -1, tl: 0) + macron(v)_e \
+$
+
+--- math-attach-mixed ---
+// A mixture of attachment positioning schemes.
+$
+attach(a, tl: u), attach(a, tr: v), attach(a, bl: x),
+attach(a, br: y), limits(a)^t, limits(a)_b \
+
+attach(a, tr: v, t: t),
+attach(a, tr: v, br: y),
+attach(a, br: y, b: b),
+attach(limits(a), b: b, bl: x),
+attach(a, tl: u, bl: x),
+attach(limits(a), t: t, tl: u) \
+
+attach(a, tl: u, tr: v),
+attach(limits(a), t: t, br: y),
+attach(limits(a), b: b, tr: v),
+attach(a, bl: x, br: y),
+attach(limits(a), b: b, tl: u),
+attach(limits(a), t: t, bl: u),
+limits(a)^t_b \
+
+attach(a, tl: u, tr: v, bl: x, br: y),
+attach(limits(a), t: t, bl: x, br: y, b: b),
+attach(limits(a), t: t, tl: u, tr: v, b: b),
+attach(limits(a), tl: u, bl: x, t: t, b: b),
+attach(limits(a), t: t, b: b, tr: v, br: y),
+attach(a, tl: u, t: t, tr: v, bl: x, b: b, br: y)
+$
+
+--- math-attach-followed-by-func-call ---
+// Test function call after subscript.
+$pi_1(Y), a_f(x), a^zeta (x), a^abs(b)_sqrt(c) \
+ a^subset.eq (x), a_(zeta(x)), pi_(1(Y)), a^(abs(b))_(sqrt(c))$
+
+--- math-attach-nested ---
+// Test associativity and scaling.
+$ 1/(V^2^3^4^5),
+ frac(
+ attach(
+ limits(V), br: attach(2, br: 3), b: attach(limits(2), b: 3)),
+ attach(
+ limits(V), tl: attach(2, tl: 3), t: attach(limits(2), t: 3))),
+ attach(Omega,
+ tl: attach(2, tl: attach(3, tl: attach(4, tl: 5))),
+ tr: attach(2, tr: attach(3, tr: attach(4, tr: 5))),
+ bl: attach(2, bl: attach(3, bl: attach(4, bl: 5))),
+ br: attach(2, br: attach(3, br: attach(4, br: 5))),
+ )
+$
+
+--- math-attach-high ---
+// Test high subscript and superscript.
+$ sqrt(a_(1/2)^zeta), sqrt(a_alpha^(1/2)), sqrt(a_(1/2)^(3/4)) \
+ sqrt(attach(a, tl: 1/2, bl: 3/4)),
+ sqrt(attach(a, tl: 1/2, bl: 3/4, tr: 1/2, br: 3/4)) $
+
+--- math-attach-descender-collision ---
+// Test for no collisions between descenders/ascenders and attachments
+
+$ sup_(x in P_i) quad inf_(x in P_i) $
+$ op("fff",limits: #true)^(y) quad op("yyy", limits:#true)_(f) $
+
+--- math-attach-to-group ---
+// Test frame base.
+$ (-1)^n + (1/2 + 3)^(-1/2) $
+
+--- math-attach-horizontal-align ---
+#set text(size: 8pt)
+
+// Test that the attachments are aligned horizontally.
+$ x_1 p_1 frak(p)_1 2_1 dot_1 lg_1 !_1 \\_1 ]_1 "ip"_1 op("iq")_1 \
+ x^1 b^1 frak(b)^1 2^1 dot^1 lg^1 !^1 \\^1 ]^1 "ib"^1 op("id")^1 \
+ x_1 y_1 "_"_1 x^1 l^1 "`"^1 attach(I,tl:1,bl:1,tr:1,br:1)
+ scripts(sum)_1^1 integral_1^1 abs(1/2)_1^1 \
+ x^1_1, "("b y")"^1_1 != (b y)^1_1, "[∫]"_1 [integral]_1 $
+
+--- math-attach-limit ---
+// Test limit.
+$ lim_(n->oo \ n "grows") sum_(k=0 \ k in NN)^n k $
+
+--- math-attach-force-scripts-and-limits ---
+// Test forcing scripts and limits.
+$ limits(A)_1^2 != A_1^2 $
+$ scripts(sum)_1^2 != sum_1^2 $
+$ limits(integral)_a^b != integral_a^b $
+
+--- issue-math-attach-realize-panic ---
+// Error: 25-29 unknown variable: oops
+$ attach(A, t: #context oops) $
+
+--- math-attach-show-limit ---
+// Show and let rules for limits and scripts
+#let eq = $ ∫_a^b iota_a^b $
+#eq
+#show "∫": math.limits
+#show math.iota: math.limits.with(inline: false)
+#eq
+$iota_a^b$
+
+--- math-attach-default-placement ---
+// Test default of limit attachments on relations at all sizes
+#set page(width: auto)
+$ a =^"def" b quad a lt.eq_"really" b quad a arrow.r.long.squiggly^"slowly" b $
+$a =^"def" b quad a lt.eq_"really" b quad a arrow.r.long.squiggly^"slowly" b$
+
+$a scripts(=)^"def" b quad a scripts(lt.eq)_"really" b quad a scripts(arrow.r.long.squiggly)^"slowly" b$
+
+--- math-attach-integral ---
+// Test default of scripts attachments on integrals at display size
+$ integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b $
+$integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b$
+
+--- math-attach-large-operator ---
+// Test default of limit attachments on large operators at display size only
+$ tack.t.big_0^1 quad \u{02A0A}_0^1 quad join_0^1 $
+$tack.t.big_0^1 quad \u{02A0A}_0^1 quad join_0^1$
diff --git a/tests/suite/math/cancel.typ b/tests/suite/math/cancel.typ
new file mode 100644
index 00000000..e2fd5efd
--- /dev/null
+++ b/tests/suite/math/cancel.typ
@@ -0,0 +1,38 @@
+// Tests the cancel() function.
+
+--- math-cancel-inline ---
+// Inline
+$a + 5 + cancel(x) + b - cancel(x)$
+
+$c + (a dot.c cancel(b dot.c c))/(cancel(b dot.c c))$
+
+--- math-cancel-display ---
+// Display
+#set page(width: auto)
+$ a + b + cancel(b + c) - cancel(b) - cancel(c) - 5 + cancel(6) - cancel(6) $
+$ e + (a dot.c cancel((b + c + d)))/(cancel(b + c + d)) $
+
+--- math-cancel-inverted ---
+// Inverted
+$a + cancel(x, inverted: #true) - cancel(x, inverted: #true) + 10 + cancel(y) - cancel(y)$
+$ x + cancel("abcdefg", inverted: #true) $
+
+--- math-cancel-cross ---
+// Cross
+$a + cancel(b + c + d, cross: #true, stroke: #red) + e$
+$ a + cancel(b + c + d, cross: #true) + e $
+
+--- math-cancel-customized ---
+// Resized and styled
+#set page(width: 200pt, height: auto)
+$a + cancel(x, length: #200%) - cancel(x, length: #50%, stroke: #(red + 1.1pt))$
+$ b + cancel(x, length: #150%) - cancel(a + b + c, length: #50%, stroke: #(blue + 1.2pt)) $
+
+--- math-cancel-angle-absolute ---
+// Specifying cancel line angle with an absolute angle
+$cancel(x, angle: #0deg) + cancel(x, angle: #45deg) + cancel(x, angle: #90deg) + cancel(x, angle: #135deg)$
+
+--- math-cancel-angle-func ---
+// Specifying cancel line angle with a function
+$x + cancel(y, angle: #{angle => angle + 90deg}) - cancel(z, angle: #(angle => angle + 135deg))$
+$ e + cancel((j + e)/(f + e)) - cancel((j + e)/(f + e), angle: #(angle => angle + 30deg)) $
diff --git a/tests/suite/math/cases.typ b/tests/suite/math/cases.typ
new file mode 100644
index 00000000..e6c4956d
--- /dev/null
+++ b/tests/suite/math/cases.typ
@@ -0,0 +1,13 @@
+// Test case distinction.
+
+--- math-cases ---
+$ f(x, y) := cases(
+ 1 quad &"if" (x dot y)/2 <= 0,
+ 2 &"if" x divides 2,
+ 3 &"if" x in NN,
+ 4 &"else",
+) $
+
+--- math-cases-gap ---
+#set math.cases(gap: 1em)
+$ x = cases(1, 2) $
diff --git a/tests/suite/math/class.typ b/tests/suite/math/class.typ
new file mode 100644
index 00000000..7aad0446
--- /dev/null
+++ b/tests/suite/math/class.typ
@@ -0,0 +1,47 @@
+// Test math classes.
+
+--- math-class-chars ---
+// Test characters.
+$ a class("normal", +) b \
+ a class("binary", .) b \
+ lr(class("opening", \/) a/b class("closing", \\)) \
+ { x class("fence", \;) x > 0} \
+ a class("large", \/) b \
+ a class("punctuation", :) b \
+ a class("relation", ~) b \
+ a + class("unary", times) b \
+ class("vary", :) a class("vary", :) b $
+
+--- math-class-content ---
+// Test custom content.
+#let dotsq = square(
+ size: 0.7em,
+ stroke: 0.5pt,
+ align(center+horizon, circle(radius: 0.15em, fill: black))
+)
+
+$ a dotsq b \
+ a class("normal", dotsq) b \
+ a class("vary", dotsq) b \
+ a + class("vary", dotsq) b \
+ a class("punctuation", dotsq) b $
+
+--- math-class-nested ---
+// Test nested.
+#let normal = math.class.with("normal")
+#let pluseq = $class("binary", normal(+) normal(=))$
+$ a pluseq 5 $
+
+--- math-class-exceptions ---
+// Test exceptions.
+$ sqrt(3)\/2 quad d_0.d_1d_2 dots $
+
+--- math-class-limits ---
+// Test if the math class changes the limit configuration.
+$ class("normal", ->)_a $
+$class("relation", x)_a$
+$ class("large", x)_a $
+$class("large", ->)_a$
+
+$limits(class("normal", ->))_a$
+$ scripts(class("relation", x))_a $
diff --git a/tests/suite/math/delimited.typ b/tests/suite/math/delimited.typ
new file mode 100644
index 00000000..98579947
--- /dev/null
+++ b/tests/suite/math/delimited.typ
@@ -0,0 +1,64 @@
+// Test delimiter matching and scaling.
+
+--- math-lr-matching ---
+// Test automatic matching.
+#set page(width:122pt)
+$ (a) + {b/2} + abs(a)/2 + (b) $
+$f(x/2) < zeta(c^2 + abs(a + b/2))$
+
+--- math-lr-unmatched ---
+// Test unmatched.
+$[1,2[ = [1,2) != zeta\(x/2\) $
+
+--- math-lr-call ---
+// Test manual matching.
+$ [|a/b|] != lr(|]a/b|]) != [a/b) $
+$ lr(| ]1,2\[ + 1/2|) $
+
+--- math-lr-fences ---
+// Test fence confusion.
+$ |x + |y| + z/a| \
+ lr(|x + |y| + z/a|) $
+
+--- math-lr-symbol-unmatched ---
+// Test that symbols aren't matched automatically.
+$ bracket.l a/b bracket.r
+ = lr(bracket.l a/b bracket.r) $
+
+--- math-lr-half ---
+// Test half LRs.
+$ lr(a/b\]) = a = lr(\{a/b) $
+
+--- math-lr-size ---
+// Test manual scaling.
+$ lr(]sum_(x=1)^n x], size: #70%)
+ < lr((1, 2), size: #200%) $
+
+--- math-lr-shorthands ---
+// Test predefined delimiter pairings.
+$floor(x/2), ceil(x/2), abs(x), norm(x)$
+
+--- math-lr-color ---
+// Test colored delimiters
+$ lr(
+ text("(", fill: #green) a/b
+ text(")", fill: #blue)
+ ) $
+
+--- math-lr-mid ---
+// Test middle functions
+$ { x mid(|) sum_(i=1)^oo phi_i (x) < 1 } \
+ { integral |x| dif x
+ mid(bar.v.double)
+ floor(hat(A) mid(|) { x mid(|) y } mid(|) A) } $
+
+--- math-lr-unbalanced ---
+// Test unbalanced delimiters.
+$ 1/(2 (x) $
+$ 1_(2 y (x) () $
+$ 1/(2 y (x) (2(3)) $
+
+--- math-lr-weak-spacing ---
+// Test ignoring weak spacing immediately after the opening
+// and immediately before the closing.
+$ [#h(1em, weak: true)A(dif x, f(x) dif x)sum#h(1em, weak: true)] $
diff --git a/tests/suite/math/equation.typ b/tests/suite/math/equation.typ
new file mode 100644
index 00000000..dd2745d1
--- /dev/null
+++ b/tests/suite/math/equation.typ
@@ -0,0 +1,212 @@
+// Test alignment of block equations.
+// Test show rules on equations.
+
+--- math-equation-numbering ---
+#set page(width: 150pt)
+#set math.equation(numbering: "(I)")
+
+We define $x$ in preparation of @fib:
+$ phi.alt := (1 + sqrt(5)) / 2 $ <ratio>
+
+With @ratio, we get
+$ F_n = round(1 / sqrt(5) phi.alt^n) $ <fib>
+
+--- math-equation-font ---
+// Test different font.
+#show math.equation: set text(font: "Fira Math")
+$ v := vec(1 + 2, 2 - 4, sqrt(3), arrow(x)) + 1 $
+
+--- math-equation-show-rule ---
+This is small: $sum_(i=0)^n$
+
+#show math.equation: math.display
+This is big: $sum_(i=0)^n$
+
+--- math-equation-align-unnumbered ---
+// Test unnumbered
+#let eq(alignment) = {
+ show math.equation: set align(alignment)
+ $ a + b = c $
+}
+
+#eq(center)
+#eq(left)
+#eq(right)
+
+#set text(dir: rtl)
+#eq(start)
+#eq(end)
+
+--- math-equation-align-numbered ---
+// Test numbered
+#let eq(alignment) = {
+ show math.equation: set align(alignment)
+ $ a + b = c $
+}
+
+#set math.equation(numbering: "(1)")
+
+#eq(center)
+#eq(left)
+#eq(right)
+
+#set text(dir: rtl)
+#eq(start)
+#eq(end)
+
+--- math-equation-number-align ---
+#set math.equation(numbering: "(1)")
+
+$ a + b = c $
+
+#show math.equation: set align(center)
+$ a + b = c $
+#show math.equation: set align(left)
+$ a + b = c $
+#show math.equation: set align(right)
+$ a + b = c $
+
+#set text(dir: rtl)
+#show math.equation: set align(start)
+$ a + b = c $
+#show math.equation: set align(end)
+$ a + b = c $
+
+--- math-equation-number-align-start ---
+#set math.equation(numbering: "(1)", number-align: start)
+
+$ a + b = c $
+
+#show math.equation: set align(center)
+$ a + b = c $
+#show math.equation: set align(left)
+$ a + b = c $
+#show math.equation: set align(right)
+$ a + b = c $
+
+#set text(dir: rtl)
+#show math.equation: set align(start)
+$ a + b = c $
+#show math.equation: set align(end)
+$ a + b = c $
+
+--- math-equation-number-align-end ---
+#set math.equation(numbering: "(1)", number-align: end)
+
+$ a + b = c $
+
+#show math.equation: set align(center)
+$ a + b = c $
+#show math.equation: set align(left)
+$ a + b = c $
+#show math.equation: set align(right)
+$ a + b = c $
+
+#set text(dir: rtl)
+#show math.equation: set align(start)
+$ a + b = c $
+#show math.equation: set align(end)
+$ a + b = c $
+
+--- math-equation-number-align-left ---
+#set math.equation(numbering: "(1)", number-align: left)
+
+$ a + b = c $
+
+#show math.equation: set align(center)
+$ a + b = c $
+#show math.equation: set align(left)
+$ a + b = c $
+#show math.equation: set align(right)
+$ a + b = c $
+
+#set text(dir: rtl)
+#show math.equation: set align(start)
+$ a + b = c $
+#show math.equation: set align(end)
+$ a + b = c $
+
+--- math-equation-number-align-right ---
+#set math.equation(numbering: "(1)", number-align: right)
+
+$ a + b = c $
+
+#show math.equation: set align(center)
+$ a + b = c $
+#show math.equation: set align(left)
+$ a + b = c $
+#show math.equation: set align(right)
+$ a + b = c $
+
+#set text(dir: rtl)
+#show math.equation: set align(start)
+$ a + b = c $
+#show math.equation: set align(end)
+$ a + b = c $
+
+--- math-equation-number-align-center ---
+// Error: 52-58 expected `start`, `left`, `right`, or `end`, found center
+#set math.equation(numbering: "(1)", number-align: center)
+
+--- math-equation-number-align-center-bottom ---
+// Error: 52-67 expected `start`, `left`, `right`, or `end`, found center
+#set math.equation(numbering: "(1)", number-align: center + bottom)
+
+--- math-equation-number-align-multiline ---
+#set math.equation(numbering: "(1)")
+
+$ p &= ln a b \
+ &= ln a + ln b $
+
+--- math-equation-number-align-multiline-top-start ---
+#set math.equation(numbering: "(1)", number-align: top+start)
+
+$ p &= ln a b \
+ &= ln a + ln b $
+
+--- math-equation-number-align-multiline-bottom ---
+#show math.equation: set align(left)
+#set math.equation(numbering: "(1)", number-align: bottom)
+
+$ q &= ln sqrt(a b) \
+ &= 1/2 (ln a + ln b) $
+
+--- math-equation-number-align-multiline-expand ---
+// Tests that if the numbering's layout box vertically exceeds the box of
+// the equation frame's boundary, the latter's frame is resized correctly
+// to encompass the numbering. #box() below delineates the resized frame.
+//
+// A row with "-" only has a height that's smaller than the height of the
+// numbering's layout box. Note we use pattern "1" here, not "(1)", since
+// the parenthesis exceeds the numbering's layout box, due to the default
+// settings of top-edge and bottom-edge of the TextElem that laid it out.
+#set math.equation(numbering: "1", number-align: top)
+#box(
+$ - &- - \
+ a &= b $,
+fill: silver)
+
+#set math.equation(numbering: "1", number-align: horizon)
+#box(
+$ - - - $,
+fill: silver)
+
+#set math.equation(numbering: "1", number-align: bottom)
+#box(
+$ a &= b \
+ - &- - $,
+fill: silver)
+
+--- issue-numbering-hint ---
+// In this bug, the hint and error messages for an equation
+// being reference mentioned that it was a "heading" and was
+// lacking the proper path.
+#set page(height: 70pt)
+
+$
+ Delta = b^2 - 4 a c
+$ <quadratic>
+
+// Error: 14-24 cannot reference equation without numbering
+// Hint: 14-24 you can enable equation numbering with `#set math.equation(numbering: "1.")`
+Looks at the @quadratic formula.
diff --git a/tests/suite/math/frac.typ b/tests/suite/math/frac.typ
new file mode 100644
index 00000000..b3ca8aa0
--- /dev/null
+++ b/tests/suite/math/frac.typ
@@ -0,0 +1,43 @@
+// Test fractions.
+
+--- math-frac-baseline ---
+// Test that denominator baseline matches in the common case.
+$ x = 1/2 = a/(a h) = a/a = a/(1/2) $
+
+--- math-frac-paren-removal ---
+// Test parenthesis removal.
+$ (|x| + |y|)/2 < [1+2]/3 $
+
+--- math-frac-large ---
+// Test large fraction.
+$ x = (-b plus.minus sqrt(b^2 - 4a c))/(2a) $
+
+--- math-binom ---
+// Test binomial.
+$ binom(circle, square) $
+
+--- math-binom-multiple ---
+// Test multinomial coefficients.
+$ binom(n, k_1, k_2, k_3) $
+
+--- math-binom-missing-lower ---
+// Error: 3-13 missing argument: lower
+$ binom(x^2) $
+
+--- math-dif ---
+// Test dif.
+$ (dif y)/(dif x), dif/x, x/dif, dif/dif \
+ frac(dif y, dif x), frac(dif, x), frac(x, dif), frac(dif, dif) $
+
+--- math-frac-associativity ---
+// Test associativity.
+$ 1/2/3 = (1/2)/3 = 1/(2/3) $
+
+--- math-frac-precedence ---
+// Test precedence.
+$ a_1/b_2, 1/f(x), zeta(x)/2, "foo"[|x|]/2 \
+ 1.2/3.7, 2.3^3.4 \
+ 🏳️‍🌈[x]/2, f [x]/2, phi [x]/2, 🏳️‍🌈 [x]/2 \
+ +[x]/2, 1(x)/2, 2[x]/2 \
+ (a)b/2, b(a)[b]/2 \
+ n!/2, 5!/2, n !/2, 1/n!, 1/5! $
diff --git a/tests/suite/math/interactions.typ b/tests/suite/math/interactions.typ
new file mode 100644
index 00000000..37185962
--- /dev/null
+++ b/tests/suite/math/interactions.typ
@@ -0,0 +1,95 @@
+// Test interactions with styling and normal layout.
+// Hint: They are bad ...
+
+--- math-nested-normal-layout ---
+// Test images and font fallback.
+#let monkey = move(dy: 0.2em, image("/assets/images/monkey.svg", height: 1em))
+$ sum_(i=#emoji.apple)^#emoji.apple.red i + monkey/2 $
+
+--- math-table ---
+// Test tables.
+$ x := #table(columns: 2)[x][y]/mat(1, 2, 3)
+ = #table[A][B][C] $
+
+--- math-equation-auto-wrapping ---
+// Test non-equation math directly in content.
+#math.attach($a$, t: [b])
+
+--- math-font-switch ---
+// Test font switch.
+#let here = text.with(font: "Noto Sans")
+$#here[f] := #here[Hi there]$.
+
+--- math-box-without-baseline ---
+// Test boxes without a baseline act as if the baseline is at the base
+#{
+ box(stroke: 0.2pt, $a #box(stroke: 0.2pt, $a$)$)
+ h(12pt)
+ box(stroke: 0.2pt, $a #box(stroke: 0.2pt, $g$)$)
+ h(12pt)
+ box(stroke: 0.2pt, $g #box(stroke: 0.2pt, $g$)$)
+}
+
+--- math-box-with-baseline ---
+// Test boxes with a baseline are respected
+#box(stroke: 0.2pt, $a #box(baseline:0.5em, stroke: 0.2pt, $a$)$)
+
+--- issue-2821-missing-fields ---
+// Issue #2821: Setting a figure's supplement to none removes the field
+#show figure.caption: it => {
+ assert(it.has("supplement"))
+ assert(it.supplement == none)
+}
+#figure([], caption: [], supplement: none)
+
+--- math-symbol-show-rule ---
+// Test using rules for symbols
+#show sym.tack: it => $#h(1em) it #h(1em)$
+$ a tack b $
+
+--- issue-math-realize-show ---
+// Test that content in math can be realized without breaking
+// nested equations.
+#let my = $pi$
+#let f1 = box(baseline: 10pt, [f])
+#let f2 = context f1
+#show math.vec: [nope]
+
+$ pi a $
+$ my a $
+$ 1 + sqrt(x/2) + sqrt(#hide($x/2$)) $
+$ a x #link("url", $+ b$) $
+$ f f1 f2 $
+$ vec(1,2) * 2 $
+
+--- issue-math-realize-hide ---
+$ x^2 #hide[$(>= phi.alt) union y^2 0$] z^2 $
+Hello #hide[there $x$]
+and #hide[$ f(x) := x^2 $]
+
+--- issue-math-realize-scripting ---
+// Test equations can embed equation pieces built by functions
+#let foo(v1, v2) = {
+ // Return an equation piece that would've been rendered in
+ // inline style if the piece is not embedded
+ $v1 v2^2$
+}
+#let bar(v1, v2) = {
+ // Return an equation piece that would've been rendered in
+ // block style if the piece is not embedded
+ $ v1 v2^2 $
+}
+#let baz(..sink) = {
+ // Return an equation piece built by joining arrays
+ sink.pos().map(x => $hat(#x)$).join(sym.and)
+}
+
+Inline $2 foo(alpha, (M+foo(a, b)))$.
+
+Inline $2 bar(alpha, (M+foo(a, b)))$.
+
+Inline $2 baz(x,y,baz(u, v))$.
+
+$ 2 foo(alpha, (M+foo(a, b))) $
+$ 2 bar(alpha, (M+foo(a, b))) $
+$ 2 baz(x,y,baz(u, v)) $
diff --git a/tests/suite/math/mat.typ b/tests/suite/math/mat.typ
new file mode 100644
index 00000000..e6148a34
--- /dev/null
+++ b/tests/suite/math/mat.typ
@@ -0,0 +1,163 @@
+// Test matrices.
+
+--- math-mat-semicolon ---
+// Test semicolon syntax.
+#set align(center)
+$mat() dot
+ mat(;) dot
+ mat(1, 2) dot
+ mat(1, 2;) \
+ mat(1; 2) dot
+ mat(1, 2; 3, 4) dot
+ mat(1 + &2, 1/2; &3, 4)$
+
+--- math-mat-sparse ---
+// Test sparse matrix.
+$ mat(
+ 1, 2, ..., 10;
+ 2, 2, ..., 10;
+ dots.v, dots.v, dots.down, dots.v;
+ 10, 10, ..., 10;
+) $
+
+--- math-mat-baseline ---
+// Test baseline alignment.
+$ mat(
+ a, b^2;
+ sum_(x \ y) x, a^(1/2);
+ zeta, alpha;
+) $
+
+--- math-mat-delim-set ---
+// Test alternative delimiter with set rule.
+#set math.mat(delim: "[")
+$ mat(1, 2; 3, 4) $
+$ a + mat(delim: #none, 1, 2; 3, 4) + b $
+
+--- math-mat-delim-direct ---
+// Test alternative math delimiter directly in call.
+#set align(center)
+#grid(
+ columns: 3,
+ gutter: 10pt,
+
+ $ mat(1, 2, delim: "[") $,
+ $ mat(1, 2; delim: "[") $,
+ $ mat(delim: "[", 1, 2) $,
+
+ $ mat(1; 2; delim: "[") $,
+ $ mat(1; delim: "[", 2) $,
+ $ mat(delim: "[", 1; 2) $,
+
+ $ mat(1, 2; delim: "[", 3, 4) $,
+ $ mat(delim: "[", 1, 2; 3, 4) $,
+ $ mat(1, 2; 3, 4; delim: "[") $,
+)
+
+--- math-mat-gap ---
+#set math.mat(gap: 1em)
+$ mat(1, 2; 3, 4) $
+
+--- math-mat-gaps ---
+#set math.mat(row-gap: 1em, column-gap: 2em)
+$ mat(1, 2; 3, 4) $
+
+--- math-mat-augment ---
+// Test matrix line drawing (augmentation).
+#grid(
+ columns: 2,
+ gutter: 10pt,
+
+ $ mat(10, 2, 3, 4; 5, 6, 7, 8; augment: #3) $,
+ $ mat(10, 2, 3, 4; 5, 6, 7, 8; augment: #(-1)) $,
+ $ mat(100, 2, 3; 4, 5, 6; 7, 8, 9; augment: #(hline: 2)) $,
+ $ mat(100, 2, 3; 4, 5, 6; 7, 8, 9; augment: #(hline: -1)) $,
+ $ mat(100, 2, 3; 4, 5, 6; 7, 8, 9; augment: #(hline: 1, vline: 1)) $,
+ $ mat(100, 2, 3; 4, 5, 6; 7, 8, 9; augment: #(hline: -2, vline: -2)) $,
+ $ mat(100, 2, 3; 4, 5, 6; 7, 8, 9; augment: #(vline: 2, stroke: 1pt + blue)) $,
+ $ mat(100, 2, 3; 4, 5, 6; 7, 8, 9; augment: #(vline: -1, stroke: 1pt + blue)) $,
+)
+
+--- math-mat-augment-set ---
+// Test using matrix line drawing with a set rule.
+#set math.mat(augment: (hline: 2, vline: 1, stroke: 2pt + green))
+$ mat(1, 0, 0, 0; 0, 1, 0, 0; 0, 0, 1, 1) $
+
+#set math.mat(augment: 2)
+$ mat(1, 0, 0, 0; 0, 1, 0, 0; 0, 0, 1, 1) $
+
+#set math.mat(augment: none)
+
+--- math-mat-augment-line-out-of-bounds ---
+// Error: 3-37 cannot draw a vertical line after column 3 of a matrix with 3 columns
+$ mat(1, 0, 0; 0, 1, 1; augment: #3) $,
+
+--- math-mat-align-explicit--alternating ---
+// Test alternating explicit alignment in a matrix.
+$ mat(
+ "a" & "a a a" & "a a";
+ "a a" & "a a" & "a";
+ "a a a" & "a" & "a a a";
+) $
+
+--- math-mat-align-implicit ---
+// Test alignment in a matrix.
+$ mat(
+ "a", "a a a", "a a";
+ "a a", "a a", "a";
+ "a a a", "a", "a a a";
+) $
+
+--- math-mat-align-explicit-left ---
+// Test explicit left alignment in a matrix.
+$ mat(
+ &"a", &"a a a", &"a a";
+ &"a a", &"a a", &"a";
+ &"a a a", &"a", &"a a a";
+) $
+
+--- math-mat-align-explicit-right ---
+// Test explicit right alignment in a matrix.
+$ mat(
+ "a"&, "a a a"&, "a a"&;
+ "a a"&, "a a"&, "a"&;
+ "a a a"&, "a"&, "a a a"&;
+) $
+
+--- math-mat-align-complex ---
+// Test #460 equations.
+#let stop = {
+ math.class("punctuation",$.$)
+}
+$ mat(&a+b,c;&d, e) $
+$ mat(&a+b&,c;&d&, e) $
+$ mat(&&&a+b,c;&&&d, e) $
+$ mat(stop &a+b&stop,c;...stop stop&d&...stop stop, e) $
+
+--- math-mat-align-signed-numbers ---
+// Test #454 equations.
+$ mat(-1, 1, 1; 1, -1, 1; 1, 1, -1) $
+$ mat(-1&, 1&, 1&; 1&, -1&, 1&; 1&, 1&, -1&) $
+$ mat(-1&, 1&, 1&; 1, -1, 1; 1, 1, -1) $
+$ mat(&-1, &1, &1; 1, -1, 1; 1, 1, -1) $
+
+--- math-mat-bad-comma ---
+// This error message is bad.
+// Error: 13-14 expected array, found content
+$ mat(1, 2; 3, 4, delim: "[") $,
+
+--- issue-852-mat-type ---
+$ mat(B, A B) $
+$ mat(B, A B, dots) $
+$ mat(B, A B, dots;) $
+$ mat(#1, #(foo: "bar")) $
+
+--- issue-2268-mat-augment-color ---
+// The augment line should be of the same color as the text
+#set text(
+ font: "New Computer Modern",
+ lang: "en",
+ fill: yellow,
+)
+
+$mat(augment: #1, M, v) arrow.r.squiggly mat(augment: #1, R, b)$
diff --git a/tests/suite/math/multiline.typ b/tests/suite/math/multiline.typ
new file mode 100644
index 00000000..85433627
--- /dev/null
+++ b/tests/suite/math/multiline.typ
@@ -0,0 +1,109 @@
+// Test multiline math.
+
+--- math-align-basic ---
+// Test basic alignment.
+$ x &= x + y \
+ &= x + 2z \
+ &= sum x dot 2z $
+
+--- math-align-wider-first-column ---
+// Test text before first alignment point.
+$ x + 1 &= a^2 + b^2 \
+ y &= a + b^2 \
+ z &= alpha dot beta $
+
+--- math-align-aligned-in-source ---
+// Test space between inner alignment points.
+$ a + b &= 2 + 3 &= 5 \
+ b &= c &= 3 $
+
+--- math-align-cases ---
+// Test in case distinction.
+$ f := cases(
+ 1 + 2 &"iff" &x,
+ 3 &"if" &y,
+) $
+
+--- math-align-lines-mixed ---
+// Test mixing lines with and some without alignment points.
+$ "abc" &= c \
+ &= d + 1 \
+ = x $
+
+--- math-attach-subscript-multiline ---
+// Test multiline subscript.
+$ sum_(n in NN \ n <= 5) n = (5(5+1))/2 = 15 $
+
+--- math-multiline-no-trailing-linebreak ---
+// Test no trailing line break.
+$
+"abc" &= c
+$
+No trailing line break.
+
+--- math-multiline-trailing-linebreak ---
+// Test single trailing line break.
+$
+"abc" &= c \
+$
+One trailing line break.
+
+--- math-multiline-multiple-trailing-linebreaks ---
+// Test multiple trailing line breaks.
+$
+"abc" &= c \ \ \
+$
+Multiple trailing line breaks.
+
+--- math-linebreaking-after-binop-and-rel ---
+// Basic breaking after binop, rel
+#let hrule(x) = box(line(length: x))
+#hrule(45pt)$e^(pi i)+1 = 0$\
+#hrule(55pt)$e^(pi i)+1 = 0$\
+#hrule(70pt)$e^(pi i)+1 = 0$
+
+--- math-linebreaking-lr ---
+// LR groups prevent linbreaking.
+#let hrule(x) = box(line(length: x))
+#hrule(76pt)$a+b$\
+#hrule(74pt)$(a+b)$\
+#hrule(74pt)$paren.l a+b paren.r$
+
+--- math-linebreaking-multiline ---
+// Multiline yet inline does not linebreak
+#let hrule(x) = box(line(length: x))
+#hrule(80pt)$a + b \ c + d$\
+
+--- math-linebreaking-trailing-linebreak ---
+// A single linebreak at the end still counts as one line.
+#let hrule(x) = box(line(length: x))
+#hrule(60pt)$e^(pi i)+1 = 0\ $
+
+--- math-linebreaking-in-box ---
+// Inline, in a box, doesn't linebreak.
+#let hrule(x) = box(line(length: x))
+#hrule(80pt)#box($a+b$)
+
+--- math-linebreaking-between-consecutive-relations ---
+// A relation followed by a relation doesn't linebreak
+#let hrule(x) = box(line(length: x))
+#hrule(70pt)$a < = b$\
+#hrule(74pt)$a < = b$
+
+--- math-linebreaking-after-relation-without-space ---
+// Line breaks can happen after a relation even if there is no
+// explicit space.
+#let hrule(x) = box(line(length: x))
+#hrule(90pt)$<;$\
+#hrule(95pt)$<;$\
+#hrule(90pt)$<)$\
+#hrule(95pt)$<)$
+
+--- math-linebreaking-empty ---
+// Verify empty rows are handled ok.
+$ $\
+Nothing: $ $, just empty.
+
+--- issue-1948-math-text-break ---
+// Test text with linebreaks in math.
+$ x := "a\nb\nc\nd\ne" $
diff --git a/tests/suite/math/op.typ b/tests/suite/math/op.typ
new file mode 100644
index 00000000..4139a08b
--- /dev/null
+++ b/tests/suite/math/op.typ
@@ -0,0 +1,30 @@
+// Test text operators.
+
+--- math-op-predefined ---
+// Test predefined.
+$ max_(1<=n<=m) n $
+
+--- math-op-call ---
+// With or without parens.
+$ &sin x + log_2 x \
+ = &sin(x) + log_2(x) $
+
+--- math-op-scripts-vs-limits ---
+// Test scripts vs limits.
+#set page(width: auto)
+#set text(font: "New Computer Modern")
+Discuss $lim_(n->oo) 1/n$ now.
+$ lim_(n->infinity) 1/n = 0 $
+
+--- math-op-custom ---
+// Test custom operator.
+$ op("myop", limits: #false)_(x:=1) x \
+ op("myop", limits: #true)_(x:=1) x $
+
+--- math-op-styled ---
+// Test styled operator.
+$ bold(op("bold", limits: #true))_x y $
+
+--- math-non-math-content ---
+// With non-text content
+$ op(#underline[ul]) a $
diff --git a/tests/suite/math/primes.typ b/tests/suite/math/primes.typ
new file mode 100644
index 00000000..e10f8876
--- /dev/null
+++ b/tests/suite/math/primes.typ
@@ -0,0 +1,50 @@
+--- math-primes ---
+// Test dedicated syntax for primes
+$a'$, $a'''_b$, $'$, $'''''''$
+
+--- math-primes-spaces ---
+// Test spaces between
+$a' ' '$, $' ' '$, $a' '/b$
+
+--- math-primes-complex ---
+// Test complex prime combinations
+$a'_b^c$, $a_b'^c$, $a_b^c'$, $a_b'^c'^d'$
+
+$(a'_b')^(c'_d')$, $a'/b'$, $a_b'/c_d'$
+
+$∫'$, $∑'$, $ ∑'_S' $
+
+--- math-primes-attach ---
+// Test attaching primes only
+$a' = a^', a_', a_'''^''^'$
+
+--- math-primes-scripts ---
+// Test primes always attaching as scripts
+$ x' $
+$ x^' $
+$ attach(x, t: ') $
+$ <' $
+$ attach(<, br: ') $
+$ op(<, limits: #true)' $
+$ limits(<)' $
+
+--- math-primes-limits ---
+// Test forcefully attaching primes as limits
+$ attach(<, t: ') $
+$ <^' $
+$ attach(<, b: ') $
+$ <_' $
+
+$ limits(x)^' $
+$ attach(limits(x), t: ') $
+
+--- math-primes-after-code-expr ---
+// Test prime symbols after code mode.
+#let g = $f$
+#let gg = $f$
+
+$
+ #(g)' #g' #g ' \
+ #g''''''''''''''''' \
+ gg'
+$
diff --git a/tests/suite/math/root.typ b/tests/suite/math/root.typ
new file mode 100644
index 00000000..a690802e
--- /dev/null
+++ b/tests/suite/math/root.typ
@@ -0,0 +1,45 @@
+// Test roots.
+
+--- math-root-basic ---
+// Test root with more than one character.
+$A = sqrt(x + y) = c$
+
+--- math-root-radical-attachment ---
+// Test root size with radicals containing attachments.
+$ sqrt(a) quad
+ sqrt(f) quad
+ sqrt(q) quad
+ sqrt(a^2) \
+ sqrt(n_0) quad
+ sqrt(b^()) quad
+ sqrt(b^2) quad
+ sqrt(q_1^2) $
+
+--- math-root-precomposed ---
+// Test precomposed vs constructed roots.
+// 3 and 4 are precomposed.
+$sqrt(x)$
+$root(2, x)$
+$root(3, x)$
+$root(4, x)$
+$root(5, x)$
+
+--- math-root-large-body ---
+// Test large bodies
+$ sqrt([|x|]^2 + [|y|]^2) < [|z|] $
+$ v = sqrt((1/2) / (4/5))
+ = root(3, (1/2/3) / (4/5/6))
+ = root(4, ((1/2) / (3/4)) / ((1/2) / (3/4))) $
+
+--- math-root-large-index ---
+// Test large index.
+$ root(2, x) quad
+ root(3/(2/1), x) quad
+ root(1/11, x) quad
+ root(1/2/3, 1) $
+
+--- math-root-syntax ---
+// Test shorthand.
+$ √2^3 = sqrt(2^3) $
+$ √(x+y) quad ∛x quad ∜x $
+$ (√2+3) = (sqrt(2)+3) $
diff --git a/tests/suite/math/size.typ b/tests/suite/math/size.typ
new file mode 100644
index 00000000..d0d41dc9
--- /dev/null
+++ b/tests/suite/math/size.typ
@@ -0,0 +1,9 @@
+--- math-size ---
+// Test forcing math size
+$a/b, display(a/b), display(a)/display(b), inline(a/b), script(a/b), sscript(a/b) \
+ mono(script(a/b)), script(mono(a/b))\
+ script(a^b, cramped: #true), script(a^b, cramped: #false)$
+
+--- issue-3658-math-size ---
+$ #rect[$1/2$] $
+$#rect[$1/2$]$
diff --git a/tests/suite/math/spacing.typ b/tests/suite/math/spacing.typ
new file mode 100644
index 00000000..2a387f92
--- /dev/null
+++ b/tests/suite/math/spacing.typ
@@ -0,0 +1,59 @@
+// Test spacing in math formulas.
+
+--- math-spacing-basic ---
+// Test spacing cases.
+$ä, +, c, (, )$ \
+$=), (+), {times}$ \
+$⟧<⟦, abs(-), [=$ \
+$a=b, a==b$ \
+$-a, +a$ \
+$a not b$ \
+$a+b, a*b$ \
+$sum x, sum(x)$ \
+$sum product x$ \
+$f(x), zeta(x), "frac"(x)$ \
+$a+dots.c+b$
+$f(x) sin(y)$
+
+--- math-spacing-kept-spaces ---
+// Test ignored vs non-ignored spaces.
+$f (x), f(x)$ \
+$[a|b], [a | b]$ \
+$a"is"b, a "is" b$
+
+--- math-spacing-predefined ---
+// Test predefined spacings.
+$a thin b, a med b, a thick b, a quad b$ \
+$a = thin b$ \
+$a - b equiv c quad (mod 2)$
+
+--- math-spacing-set-comprehension ---
+// Test spacing for set comprehension.
+#set page(width: auto)
+$ { x in RR | x "is natural" and x < 10 } $
+
+--- math-spacing-decorated ---
+// Test spacing for operators with decorations and modifiers on them
+#set page(width: auto)
+$a equiv b + c - d => e log 5 op("ln") 6$ \
+$a cancel(equiv) b overline(+) c arrow(-) d hat(=>) e cancel(log) 5 dot(op("ln")) 6$ \
+$a overbrace(equiv) b underline(+) c grave(-) d underbracket(=>) e circle(log) 5 caron(op("ln")) 6$ \
+\
+$a attach(equiv, tl: a, tr: b) b attach(limits(+), t: a, b: b) c tilde(-) d breve(=>) e attach(limits(log), t: a, b: b) 5 attach(op("ln"), tr: a, bl: b) 6$
+
+--- math-spacing-weak ---
+// Test weak spacing
+$integral f(x) dif x$,
+// Not weak
+$integral f(x) thin dif x$,
+// Both are weak, collide
+$integral f(x) #h(0.166em, weak: true)dif x$
+
+--- issue-1052-math-number-spacing ---
+// Test spacing after numbers in math.
+$
+10degree \
+10 degree \
+10.1degree \
+10.1 degree
+$
diff --git a/tests/suite/math/style.typ b/tests/suite/math/style.typ
new file mode 100644
index 00000000..09ddd3c1
--- /dev/null
+++ b/tests/suite/math/style.typ
@@ -0,0 +1,34 @@
+// Test text styling in math.
+
+--- math-style-italic-default ---
+// Test italic defaults.
+$a, A, delta, ϵ, diff, Delta, ϴ$
+
+--- math-style ---
+// Test forcing a specific style.
+$A, italic(A), upright(A), bold(A), bold(upright(A)), \
+ serif(A), sans(A), cal(A), frak(A), mono(A), bb(A), \
+ italic(diff), upright(diff), \
+ bb("hello") + bold(cal("world")), \
+ mono("SQRT")(x) wreath mono(123 + 456)$
+
+--- math-style-exceptions ---
+// Test a few style exceptions.
+$h, bb(N), cal(R), Theta, italic(Theta), sans(Theta), sans(italic(Theta)) \
+ bb(d), bb(italic(d)), italic(bb(d)), bb(e), bb(italic(e)), italic(bb(e)) \
+ bb(i), bb(italic(i)), italic(bb(i)), bb(j), bb(italic(j)), italic(bb(j)) \
+ bb(D), bb(italic(D)), italic(bb(D))$
+
+--- math-style-greek-exceptions ---
+// Test a few greek exceptions.
+$bb(Gamma) , bb(gamma), bb(Pi), bb(pi), bb(sum)$
+
+--- math-style-hebrew-exceptions ---
+// Test hebrew exceptions.
+$aleph, beth, gimel, daleth$
+
+--- issue-3650-italic-equation ---
+_abc $sin(x) "abc"$_ \
+$italic(sin(x) "abc" #box[abc])$ \
+*abc $sin(x) "abc"$* \
+$bold(sin(x) "abc" #box[abc])$ \
diff --git a/tests/suite/math/syntax.typ b/tests/suite/math/syntax.typ
new file mode 100644
index 00000000..fcb8b89e
--- /dev/null
+++ b/tests/suite/math/syntax.typ
@@ -0,0 +1,34 @@
+// Test math syntax.
+
+--- math-call-non-func ---
+$ pi(a) $
+$ pi(a,) $
+$ pi(a,b) $
+$ pi(a,b,) $
+
+--- math-unicode ---
+// Test Unicode math.
+$ ∑_(i=0)^ℕ a ∘ b = \u{2211}_(i=0)^NN a compose b $
+
+--- math-shorthandes ---
+// Test a few shorthands.
+$ underline(f' : NN -> RR) \
+ n |-> cases(
+ [|1|] &"if" n >>> 10,
+ 2 * 3 &"if" n != 5,
+ 1 - 0 thick &...,
+ ) $
+
+--- math-common-symbols ---
+// Test common symbols.
+$ dot \ dots \ ast \ tilde \ star $
+
+--- issue-2044-invalid-parsed-ident ---
+// In this bug, the dot at the end was causing the right parenthesis to be
+// parsed as an identifier instead of the closing right parenthesis.
+$floor(phi.alt.)$
+$floor(phi.alt. )$
+
+--- math-unclosed ---
+// Error: 1-2 unclosed delimiter
+$a
diff --git a/tests/suite/math/text.typ b/tests/suite/math/text.typ
new file mode 100644
index 00000000..760910f4
--- /dev/null
+++ b/tests/suite/math/text.typ
@@ -0,0 +1,45 @@
+// Test that setting font features in math.equation has an effect.
+
+--- math-font-fallback ---
+// Test font fallback.
+$ よ and 🏳️‍🌈 $
+
+--- math-text-color ---
+// Test text properties.
+$text(#red, "time"^2) + sqrt("place")$
+
+--- math-font-features ---
+$ nothing $
+$ "hi ∅ hey" $
+$ sum_(i in NN) 1 + i $
+#show math.equation: set text(features: ("cv01",), fallback: false)
+$ nothing $
+$ "hi ∅ hey" $
+$ sum_(i in NN) 1 + i $
+
+--- math-optical-size-nested-scripts ---
+// Test transition from script to scriptscript.
+#[
+#set text(size:20pt)
+$ e^(e^(e^(e))) $
+]
+A large number: $e^(e^(e^(e)))$.
+
+--- math-optical-size-primes ---
+// Test prime/double prime via scriptsize
+#let prime = [ \u{2032} ]
+#let dprime = [ \u{2033} ]
+#let tprime = [ \u{2034} ]
+$ y^dprime-2y^prime + y = 0 $
+$y^dprime-2y^prime + y = 0$
+$ y^tprime_3 + g^(prime 2) $
+
+--- math-optical-size-prime-large-operator ---
+// Test prime superscript on large symbol
+$ scripts(sum_(k in NN))^prime 1/k^2 $
+$sum_(k in NN)^prime 1/k^2$
+
+--- math-optical-size-frac-script-script ---
+// Test script-script in a fraction.
+$ 1/(x^A) $
+#[#set text(size:18pt); $1/(x^A)$] vs. #[#set text(size:14pt); $x^A$]
diff --git a/tests/suite/math/underover.typ b/tests/suite/math/underover.typ
new file mode 100644
index 00000000..0768bf73
--- /dev/null
+++ b/tests/suite/math/underover.typ
@@ -0,0 +1,21 @@
+// Test under/over things.
+
+--- math-underover-brace ---
+// Test braces.
+$ x = underbrace(
+ 1 + 2 + ... + 5,
+ underbrace("numbers", x + y)
+) $
+
+--- math-underover-line-bracket ---
+// Test lines and brackets.
+$ x = overbracket(
+ overline(underline(x + y)),
+ 1 + 2 + ... + 5,
+) $
+
+--- math-underover-brackets ---
+// Test brackets.
+$ underbracket([1, 2/3], "relevant stuff")
+ arrow.l.r.double.long
+ overbracket([4/5,6], "irrelevant stuff") $
diff --git a/tests/suite/math/vec.typ b/tests/suite/math/vec.typ
new file mode 100644
index 00000000..312c0ee4
--- /dev/null
+++ b/tests/suite/math/vec.typ
@@ -0,0 +1,27 @@
+// Test vectors.
+
+--- math-vec-gap ---
+#set math.vec(gap: 1em)
+$ vec(1, 2) $
+
+
+--- math-vec-align-explicit-alternating ---
+// Test alternating alignment in a vector.
+$ vec(
+ "a" & "a a a" & "a a",
+ "a a" & "a a" & "a",
+ "a a a" & "a" & "a a a",
+) $
+
+--- math-vec-wide ---
+// Test wide cell.
+$ v = vec(1, 2+3, 4) $
+
+--- math-vec-delim-set ---
+// Test alternative delimiter.
+#set math.vec(delim: "[")
+$ vec(1, 2) $
+
+--- math-vec-delim-invalid ---
+// Error: 22-25 expected "(", "[", "{", "|", "||", or none
+#set math.vec(delim: "%")
diff --git a/tests/suite/model/bibliography.typ b/tests/suite/model/bibliography.typ
new file mode 100644
index 00000000..7197082f
--- /dev/null
+++ b/tests/suite/model/bibliography.typ
@@ -0,0 +1,55 @@
+// Test citations and bibliographies.
+
+--- bibliography-basic ---
+#set page(width: 200pt)
+
+= Details
+See also @arrgh #cite(<distress>, supplement: [p.~22]), @arrgh[p.~4], and @distress[p.~5].
+#bibliography("/assets/bib/works.bib")
+
+--- bibliography-before-content ---
+// Test unconventional order.
+#set page(width: 200pt)
+#bibliography(
+ "/assets/bib/works.bib",
+ title: [Works to be cited],
+ style: "chicago-author-date",
+)
+#line(length: 100%)
+
+As described by #cite(<netwok>, form: "prose"),
+the net-work is a creature of its own.
+This is close to piratery! @arrgh
+And quark! @quark
+
+--- bibliography-multiple-files ---
+#set page(width: 200pt)
+#set heading(numbering: "1.")
+#show bibliography: set heading(numbering: "1.")
+
+= Multiple Bibs
+Now we have multiple bibliographies containing @glacier-melt @keshav2007read
+#bibliography(("/assets/bib/works.bib", "/assets/bib/works_too.bib"))
+
+--- bibliography-duplicate-key ---
+// Error: 15-65 duplicate bibliography keys: netwok, issue201, arrgh, quark, distress, glacier-melt, tolkien54, DBLP:books/lib/Knuth86a, sharing, restful, mcintosh_anxiety, psychology25
+#bibliography(("/assets/bib/works.bib", "/assets/bib/works.bib"))
+
+--- bibliography-ordering ---
+#set page(width: 300pt)
+
+@mcintosh_anxiety
+@psychology25
+
+#bibliography("/assets/bib/works.bib")
+
+--- bibliography-full ---
+// LARGE
+#set page(paper: "a6", height: 170mm)
+#bibliography("/assets/bib/works.bib", full: true)
+
+--- bibliography-math ---
+#set page(width: 200pt)
+
+@Zee04
+#bibliography("/assets/bib/works_too.bib", style: "mla")
diff --git a/tests/suite/model/cite.typ b/tests/suite/model/cite.typ
new file mode 100644
index 00000000..c6bdccb1
--- /dev/null
+++ b/tests/suite/model/cite.typ
@@ -0,0 +1,92 @@
+--- cite-footnote ---
+Hello @netwok
+And again: @netwok
+
+#pagebreak()
+#bibliography("/assets/bib/works.bib", style: "chicago-notes")
+
+--- cite-form ---
+#set page(width: 200pt)
+
+Nothing: #cite(<arrgh>, form: none)
+
+#cite(<netwok>, form: "prose") say stuff.
+
+#bibliography("/assets/bib/works.bib", style: "apa")
+
+--- cite-group ---
+A#[@netwok@arrgh]B \
+A@netwok@arrgh B \
+A@netwok @arrgh B \
+A@netwok @arrgh. B \
+
+A @netwok#[@arrgh]B \
+A @netwok@arrgh, B \
+A @netwok @arrgh, B \
+A @netwok @arrgh. B \
+
+A#[@netwok @arrgh @quark]B. \
+A @netwok @arrgh @quark B. \
+A @netwok @arrgh @quark, B.
+
+#set text(0pt)
+#bibliography("/assets/bib/works.bib")
+
+--- cite-grouping-and-ordering ---
+@mcintosh_anxiety
+@psychology25
+@netwok
+@issue201
+@arrgh
+@quark
+@distress,
+@glacier-melt
+@issue201
+@tolkien54
+@sharing
+@restful
+
+#show bibliography: none
+#bibliography("/assets/bib/works.bib")
+
+--- issue-785-cite-locate ---
+// Test citation in other introspection.
+#set page(width: 180pt)
+#set heading(numbering: "1")
+
+#outline(
+ title: [List of Figures],
+ target: figure.where(kind: image),
+)
+
+#pagebreak()
+
+= Introduction <intro>
+#figure(
+ rect[-- PIRATE --],
+ caption: [A pirate @arrgh in @intro],
+)
+
+#context [Citation @distress on page #here().page()]
+
+#pagebreak()
+#bibliography("/assets/bib/works.bib", style: "chicago-notes")
+
+--- issue-1597-cite-footnote ---
+// Tests that when a citation footnote is pushed to next page, things still
+// work as expected.
+#set page(height: 60pt)
+#lorem(4)
+
+#footnote[@netwok]
+#show bibliography: none
+#bibliography("/assets/bib/works.bib")
+
+--- issue-2531-cite-show-set ---
+// Test show set rules on citations.
+#show cite: set text(red)
+A @netwok @arrgh.
+B #cite(<netwok>) #cite(<arrgh>).
+
+#show bibliography: none
+#bibliography("/assets/bib/works.bib")
diff --git a/tests/suite/model/document.typ b/tests/suite/model/document.typ
new file mode 100644
index 00000000..6f8e7131
--- /dev/null
+++ b/tests/suite/model/document.typ
@@ -0,0 +1,36 @@
+// Test document and page-level styles.
+
+--- document-set-title ---
+// This is okay.
+#set document(title: [Hello])
+What's up?
+
+--- document-set-author-date ---
+// This, too.
+#set document(author: ("A", "B"), date: datetime.today())
+
+--- document-date-bad ---
+// Error: 21-28 expected datetime, none, or auto, found string
+#set document(date: "today")
+
+--- document-author-bad ---
+// This, too.
+// Error: 23-29 expected string, found integer
+#set document(author: (123,))
+What's up?
+
+--- document-set-after-content ---
+Hello
+
+// Error: 2-30 document set rules must appear before any content
+#set document(title: [Hello])
+
+--- document-constructor ---
+// Error: 2-12 can only be used in set rules
+#document()
+
+--- document-set-in-container ---
+#box[
+ // Error: 4-32 document set rules are not allowed inside of containers
+ #set document(title: [Hello])
+]
diff --git a/tests/suite/model/emph-strong.typ b/tests/suite/model/emph-strong.typ
new file mode 100644
index 00000000..2af8bb16
--- /dev/null
+++ b/tests/suite/model/emph-strong.typ
@@ -0,0 +1,74 @@
+// Test emph and strong.
+
+--- emph-syntax ---
+// Basic.
+_Emphasized and *strong* words!_
+
+// Inside of a word it's a normal underscore or star.
+hello_world Nutzer*innen
+
+// CJK characters will not need spaces.
+中文一般使用*粗体*或者_楷体_来表示强调。
+
+日本語では、*太字*や_斜体_を使って強調します。
+
+中文中混有*Strong*和_Empasis_。
+
+// Can contain paragraph in nested content block.
+_Still #[
+
+] emphasized._
+
+--- emph-and-strong-call-in-word ---
+// Inside of words can still use the functions.
+P#strong[art]ly em#emph[phas]ized.
+
+--- emph-empty-hint ---
+// Warning: 1-3 no text within underscores
+// Hint: 1-3 using multiple consecutive underscores (e.g. __) has no additional effect
+__
+
+--- emph-double-underscore-empty-hint ---
+// Warning: 1-3 no text within underscores
+// Hint: 1-3 using multiple consecutive underscores (e.g. __) has no additional effect
+// Warning: 13-15 no text within underscores
+// Hint: 13-15 using multiple consecutive underscores (e.g. __) has no additional effect
+__not italic__
+
+--- emph-unclosed ---
+// Error: 6-7 unclosed delimiter
+#box[_Scoped] to body.
+
+--- emph-ends-at-parbreak ---
+// Ends at paragraph break.
+// Error: 1-2 unclosed delimiter
+_Hello
+
+World
+
+--- emph-strong-unclosed-nested ---
+// Error: 11-12 unclosed delimiter
+// Error: 3-4 unclosed delimiter
+#[_Cannot *be interleaved]
+
+--- strong-delta ---
+// Adjusting the delta that strong applies on the weight.
+Normal
+
+#set strong(delta: 300)
+*Bold*
+
+#set strong(delta: 150)
+*Medium* and *#[*Bold*]*
+
+--- strong-empty-hint ---
+// Warning: 1-3 no text within stars
+// Hint: 1-3 using multiple consecutive stars (e.g. **) has no additional effect
+**
+
+--- strong-double-star-empty-hint ---
+// Warning: 1-3 no text within stars
+// Hint: 1-3 using multiple consecutive stars (e.g. **) has no additional effect
+// Warning: 11-13 no text within stars
+// Hint: 11-13 using multiple consecutive stars (e.g. **) has no additional effect
+**not bold**
diff --git a/tests/suite/model/enum.typ b/tests/suite/model/enum.typ
new file mode 100644
index 00000000..57a4d7a6
--- /dev/null
+++ b/tests/suite/model/enum.typ
@@ -0,0 +1,156 @@
+// Test enumerations.
+
+--- enum-function-call ---
+#enum[Embrace][Extend][Extinguish]
+
+--- enum-number-override-nested ---
+0. Before first!
+1. First.
+ 2. Indented
+
++ Second
+
+--- enum-built-in-loop ---
+// Test automatic numbering in summed content.
+#for i in range(5) {
+ [+ #numbering("I", 1 + i)]
+}
+
+--- list-mix ---
+// Mix of different lists
+- Bullet List
++ Numbered List
+/ Term: List
+
+--- enum-syntax-at-start ---
+// In the line.
+1.2 \
+This is 0. \
+See 0.3. \
+
+--- enum-syntax-edge-cases ---
+// Edge cases.
++
+Empty \
++Nope \
+a + 0.
+
+--- enum-number-override ---
+// Test item number overriding.
+1. first
++ second
+5. fifth
+
+#enum(
+ enum.item(1)[First],
+ [Second],
+ enum.item(5)[Fifth]
+)
+
+--- enum-numbering-pattern ---
+// Test numbering pattern.
+#set enum(numbering: "(1.a.*)")
++ First
++ Second
+ 2. Nested
+ + Deep
++ Normal
+
+--- enum-numbering-full ---
+// Test full numbering.
+#set enum(numbering: "1.a.", full: true)
++ First
+ + Nested
+
+--- enum-numbering-closure ---
+// Test numbering with closure.
+#enum(
+ start: 3,
+ spacing: 0.65em - 3pt,
+ tight: false,
+ numbering: n => text(
+ fill: (red, green, blue).at(calc.rem(n, 3)),
+ numbering("A", n),
+ ),
+ [Red], [Green], [Blue], [Red],
+)
+
+--- enum-numbering-closure-nested ---
+// Test numbering with closure and nested lists.
+#set enum(numbering: n => super[#n])
++ A
+ + B
++ C
+
+--- enum-numbering-closure-nested-complex ---
+// Test numbering with closure and nested lists.
+#set text(font: "New Computer Modern")
+#set enum(numbering: (..args) => math.mat(args.pos()), full: true)
++ A
+ + B
+ + C
+ + D
++ E
++ F
+
+--- enum-numbering-pattern-empty ---
+// Error: 22-24 invalid numbering pattern
+#set enum(numbering: "")
+
+--- enum-numbering-pattern-invalid ---
+// Error: 22-28 invalid numbering pattern
+#set enum(numbering: "(())")
+
+--- enum-number-align-unaffected ---
+// Alignment shouldn't affect number
+#set align(horizon)
+
++ ABCDEF\ GHIJKL\ MNOPQR
+ + INNER\ INNER\ INNER
++ BACK\ HERE
+
+--- enum-number-align-default ---
+// Enum number alignment should be 'end' by default
+1. a
+10. b
+100. c
+
+--- enum-number-align-specified ---
+#set enum(number-align: start)
+1. a
+8. b
+16. c
+
+--- enum-number-align-2d ---
+#set enum(number-align: center + horizon)
+1. #box(fill: teal, inset: 10pt )[a]
+8. #box(fill: teal, inset: 10pt )[b]
+16. #box(fill: teal,inset: 10pt )[c]
+
+--- enum-number-align-unfolded ---
+// Number align option should not be affected by the context.
+#set align(center)
+#set enum(number-align: start)
+
+4. c
+8. d
+16. e\ f
+ 2. f\ g
+ 32. g
+ 64. h
+
+--- enum-number-align-values ---
+// Test valid number align values (horizontal and vertical)
+#set enum(number-align: start)
+#set enum(number-align: end)
+#set enum(number-align: left)
+#set enum(number-align: center)
+#set enum(number-align: right)
+#set enum(number-align: top)
+#set enum(number-align: horizon)
+#set enum(number-align: bottom)
+
+--- issue-2530-enum-item-panic ---
+// Enum item (pre-emptive)
+#enum.item(none)[Hello]
+#enum.item(17)[Hello]
diff --git a/tests/suite/model/figure.typ b/tests/suite/model/figure.typ
new file mode 100644
index 00000000..6846760f
--- /dev/null
+++ b/tests/suite/model/figure.typ
@@ -0,0 +1,220 @@
+// Test figures.
+
+--- figure-basic ---
+#set page(width: 150pt)
+#set figure(numbering: "I")
+
+We can clearly see that @fig-cylinder and
+@tab-complex are relevant in this context.
+
+#figure(
+ table(columns: 2)[a][b],
+ caption: [The basic table.],
+) <tab-basic>
+
+#figure(
+ pad(y: -6pt, image("/assets/images/cylinder.svg", height: 2cm)),
+ caption: [The basic shapes.],
+ numbering: "I",
+) <fig-cylinder>
+
+#figure(
+ table(columns: 3)[a][b][c][d][e][f],
+ caption: [The complex table.],
+) <tab-complex>
+
+--- figure-table ---
+// Testing figures with tables.
+#figure(
+ table(
+ columns: 2,
+ [Second cylinder],
+ image("/assets/images/cylinder.svg"),
+ ),
+ caption: "A table containing images."
+) <fig-image-in-table>
+
+--- figure-theorem ---
+// Testing show rules with figures with a simple theorem display
+#show figure.where(kind: "theorem"): it => {
+ let name = none
+ if not it.caption == none {
+ name = [ #emph(it.caption.body)]
+ } else {
+ name = []
+ }
+
+ let title = none
+ if not it.numbering == none {
+ title = it.supplement
+ if not it.numbering == none {
+ title += " " + it.counter.display(it.numbering)
+ }
+ }
+ title = strong(title)
+ pad(
+ top: 0em, bottom: 0em,
+ block(
+ fill: green.lighten(90%),
+ stroke: 1pt + green,
+ inset: 10pt,
+ width: 100%,
+ radius: 5pt,
+ breakable: false,
+ [#title#name#h(0.1em):#h(0.2em)#it.body#v(0.5em)]
+ )
+ )
+}
+
+#set page(width: 150pt)
+#figure(
+ $a^2 + b^2 = c^2$,
+ supplement: "Theorem",
+ kind: "theorem",
+ caption: "Pythagoras' theorem.",
+ numbering: "1",
+) <fig-formula>
+
+#figure(
+ $a^2 + b^2 = c^2$,
+ supplement: "Theorem",
+ kind: "theorem",
+ caption: "Another Pythagoras' theorem.",
+ numbering: none,
+) <fig-formula>
+
+#figure(
+ ```rust
+ fn main() {
+ println!("Hello!");
+ }
+ ```,
+ caption: [Hello world in _rust_],
+)
+
+--- figure-breakable ---
+// Test breakable figures
+#set page(height: 6em)
+#show figure: set block(breakable: true)
+
+#figure(table[a][b][c][d][e], caption: [A table])
+
+--- figure-caption-separator ---
+// Test custom separator for figure caption
+#set figure.caption(separator: [ --- ])
+
+#figure(
+ table(columns: 2)[a][b],
+ caption: [The table with custom separator.],
+)
+
+--- figure-caption-show ---
+// Test figure.caption element
+#show figure.caption: emph
+
+#figure(
+ [Not italicized],
+ caption: [Italicized],
+)
+
+--- figure-caption-where-selector ---
+// Test figure.caption element for specific figure kinds
+#show figure.caption.where(kind: table): underline
+
+#figure(
+ [Not a table],
+ caption: [Not underlined],
+)
+
+#figure(
+ table[A table],
+ caption: [Underlined],
+)
+
+--- figure-and-caption-show ---
+// Test creating custom figure and custom caption
+
+#let gap = 0.7em
+#show figure.where(kind: "custom"): it => rect(inset: gap, {
+ align(center, it.body)
+ v(gap, weak: true)
+ line(length: 100%)
+ v(gap, weak: true)
+ align(center, it.caption)
+})
+
+#figure(
+ [A figure],
+ kind: "custom",
+ caption: [Hi],
+ supplement: [A],
+)
+
+#show figure.caption: it => emph[
+ #it.body
+ (#it.supplement
+ #context it.counter.display(it.numbering))
+]
+
+#figure(
+ [Another figure],
+ kind: "custom",
+ caption: [Hi],
+ supplement: [B],
+)
+
+--- figure-caption-position ---
+#set figure.caption(position: top)
+
+--- figure-caption-position-bad ---
+// Error: 31-38 expected `top` or `bottom`, found horizon
+#set figure.caption(position: horizon)
+
+--- figure-localization-fr ---
+// Test French
+#set text(lang: "fr")
+#figure(
+ circle(),
+ caption: [Un cercle.],
+)
+
+--- figure-localization-zh ---
+// Test Chinese
+#set text(lang: "zh")
+#figure(
+ rect(),
+ caption: [一个矩形],
+)
+
+--- figure-localization-ru ---
+// Test Russian
+#set text(lang: "ru")
+
+#figure(
+ polygon.regular(size: 1cm, vertices: 8),
+ caption: [Пятиугольник],
+)
+
+--- figure-localization-gr ---
+// Test Greek
+#set text(lang: "gr")
+#figure(
+ circle(),
+ caption: [Ένας κύκλος.],
+)
+
+--- issue-2165-figure-caption-panic ---
+#figure.caption[]
+
+--- issue-2328-figure-entry-panic ---
+// Error: 4-43 footnote entry must have a location
+// Hint: 4-43 try using a query or a show rule to customize the footnote instead
+HI#footnote.entry(clearance: 2.5em)[There]
+
+--- issue-2530-figure-caption-panic ---
+#figure(caption: [test])[].caption
+
+--- issue-3586-figure-caption-separator ---
+// Test that figure caption separator is synthesized correctly.
+#show figure.caption: c => test(c.separator, [#": "])
+#figure(table[], caption: [This is a test caption])
diff --git a/tests/suite/model/footnote.typ b/tests/suite/model/footnote.typ
new file mode 100644
index 00000000..c41db577
--- /dev/null
+++ b/tests/suite/model/footnote.typ
@@ -0,0 +1,182 @@
+// Test footnotes.
+
+--- footnote-basic ---
+#footnote[Hi]
+
+--- footnote-space-collapsing ---
+// Test space collapsing before footnote.
+A#footnote[A] \
+A #footnote[A]
+
+--- footnote-nested ---
+// Test nested footnotes.
+First \
+Second #footnote[A, #footnote[B, #footnote[C]]] \
+Third #footnote[D, #footnote[E]] \
+Fourth
+
+--- footnote-nested-same-frame ---
+// Currently, numbers a bit out of order if a nested footnote ends up in the
+// same frame as another one. :(
+#footnote[A, #footnote[B]], #footnote[C]
+
+--- footnote-entry ---
+// Test customization.
+#show footnote: set text(red)
+#show footnote.entry: set text(8pt, style: "italic")
+#set footnote.entry(
+ indent: 0pt,
+ gap: 0.6em,
+ clearance: 0.3em,
+ separator: repeat[.],
+)
+
+Beautiful footnotes. #footnote[Wonderful, aren't they?]
+
+--- footnote-break-across-pages ---
+// LARGE
+#set page(height: 200pt)
+
+#lorem(5)
+#footnote[ // 1
+ A simple footnote.
+ #footnote[Well, not that simple ...] // 2
+]
+#lorem(15)
+#footnote[Another footnote: #lorem(30)] // 3
+#lorem(15)
+#footnote[My fourth footnote: #lorem(50)] // 4
+#lorem(15)
+#footnote[And a final footnote.] // 5
+
+--- footnote-in-columns ---
+// Test footnotes in columns, even those that are not enabled via `set page`.
+#set page(height: 120pt)
+#align(center, strong[Title])
+#show: columns.with(2)
+#lorem(3) #footnote(lorem(6))
+Hello there #footnote(lorem(2))
+
+--- footnote-in-caption ---
+// Test footnote in caption.
+Read the docs #footnote[https://typst.app/docs]!
+#figure(
+ image("/assets/images/graph.png", width: 70%),
+ caption: [
+ A graph #footnote[A _graph_ is a structure with nodes and edges.]
+ ]
+)
+More #footnote[just for ...] footnotes #footnote[... testing. :)]
+
+--- footnote-duplicate ---
+// Test duplicate footnotes.
+#let lang = footnote[Languages.]
+#let nums = footnote[Numbers.]
+
+/ "Hello": A word #lang
+/ "123": A number #nums
+
+- "Hello" #lang
+- "123" #nums
+
++ "Hello" #lang
++ "123" #nums
+
+#table(
+ columns: 2,
+ [Hello], [A word #lang],
+ [123], [A number #nums],
+)
+
+--- footnote-invariant ---
+// Ensure that a footnote and the first line of its entry
+// always end up on the same page.
+#set page(height: 120pt)
+
+#lorem(13)
+
+There #footnote(lorem(20))
+
+--- footnote-ref ---
+// Test references to footnotes.
+A footnote #footnote[Hi]<fn> \
+A reference to it @fn
+
+--- footnote-ref-multiple ---
+// Multiple footnotes are refs
+First #footnote[A]<fn1> \
+Second #footnote[B]<fn2> \
+First ref @fn1 \
+Third #footnote[C] \
+Fourth #footnote[D]<fn4> \
+Fourth ref @fn4 \
+Second ref @fn2 \
+Second ref again @fn2
+
+--- footnote-ref-forward ---
+// Forward reference
+Usage @fn \
+Definition #footnote[Hi]<fn>
+
+--- footnote-ref-in-footnote ---
+// Footnote ref in footnote
+#footnote[Reference to next @fn]
+#footnote[Reference to myself @fn]<fn>
+#footnote[Reference to previous @fn]
+
+--- footnote-styling ---
+// Styling
+#show footnote: text.with(fill: red)
+Real #footnote[...]<fn> \
+Ref @fn
+
+--- footnote-ref-call ---
+// Footnote call with label
+#footnote(<fn>)
+#footnote[Hi]<fn>
+#ref(<fn>)
+#footnote(<fn>)
+
+--- footnote-in-table ---
+// Test footnotes in tables. When the table spans multiple pages, the footnotes
+// will all be after the table, but it shouldn't create any empty pages.
+#set page(height: 100pt)
+
+= Tables
+#table(
+ columns: 2,
+ [Hello footnote #footnote[This is a footnote.]],
+ [This is more text],
+ [This cell
+ #footnote[This footnote is not on the same page]
+ breaks over multiple pages.],
+ image("/assets/images/tiger.jpg"),
+)
+
+#table(
+ columns: 3,
+ ..range(1, 10)
+ .map(numbering.with("a"))
+ .map(v => upper(v) + footnote(v))
+)
+
+--- issue-multiple-footnote-in-one-line ---
+// Test that the logic that keeps footnote entry together with
+// their markers also works for multiple footnotes in a single
+// line or frame (here, there are two lines, but they are one
+// unit due to orphan prevention).
+#set page(height: 100pt)
+#v(40pt)
+A #footnote[a] \
+B #footnote[b]
+
+--- issue-1433-footnote-in-list ---
+// Test that footnotes in lists do not produce extraneous page breaks. The list
+// layout itself does not currently react to the footnotes layout, weakening the
+// "footnote and its entry are on the same page" invariant somewhat, but at
+// least there shouldn't be extra page breaks.
+#set page(height: 100pt)
+#block(height: 50pt, width: 100%, fill: aqua)
+
+- #footnote[1]
+- #footnote[2]
diff --git a/tests/suite/model/heading.typ b/tests/suite/model/heading.typ
new file mode 100644
index 00000000..5d50eeee
--- /dev/null
+++ b/tests/suite/model/heading.typ
@@ -0,0 +1,80 @@
+// Test headings.
+
+--- heading-basic ---
+// Different number of equals signs.
+
+= Level 1
+== Level 2
+=== Level 3
+
+// After three, it stops shrinking.
+=========== Level 11
+
+--- heading-syntax-at-start ---
+// Heading vs. no heading.
+
+// Parsed as headings if at start of the context.
+/**/ = Level 1
+#[== Level 2]
+#box[=== Level 3]
+
+// Not at the start of the context.
+No = heading
+
+// Escaped.
+\= No heading
+
+--- heading-block ---
+// Blocks can continue the heading.
+
+= #[This
+is
+multiline.
+]
+
+= This
+ is not.
+
+--- heading-show-where ---
+// Test styling.
+#show heading.where(level: 5): it => block(
+ text(font: "Roboto", fill: eastern, it.body + [!])
+)
+
+= Heading
+===== Heading 🌍
+#heading(level: 5)[Heading]
+
+--- heading-offset ---
+// Test setting the starting offset.
+#set heading(numbering: "1.1")
+#show heading.where(level: 2): set text(blue)
+= Level 1
+
+#heading(depth: 1)[We're twins]
+#heading(level: 1)[We're twins]
+
+== Real level 2
+
+#set heading(offset: 1)
+= Fake level 2
+== Fake level 3
+
+--- heading-offset-and-level ---
+// Passing level directly still overrides all other set values
+#set heading(numbering: "1.1", offset: 1)
+#heading(level: 1)[Still level 1]
+
+--- heading-syntax-edge-cases ---
+// Edge cases.
+#set heading(numbering: "1.")
+=
+Not in heading
+=Nope
+
+--- heading-numbering-hint ---
+= Heading <intro>
+
+// Error: 1:20-1:26 cannot reference heading without numbering
+// Hint: 1:20-1:26 you can enable heading numbering with `#set heading(numbering: "1.")`
+Can not be used as @intro
diff --git a/tests/suite/model/link.typ b/tests/suite/model/link.typ
new file mode 100644
index 00000000..27afd53c
--- /dev/null
+++ b/tests/suite/model/link.typ
@@ -0,0 +1,77 @@
+// Test hyperlinking.
+
+--- link-basic ---
+// Link syntax.
+https://example.com/
+
+// Link with body.
+#link("https://typst.org/")[Some text text text]
+
+// With line break.
+This link appears #link("https://google.com/")[in the middle of] a paragraph.
+
+// Certain prefixes are trimmed when using the `link` function.
+Contact #link("mailto:hi@typst.app") or
+call #link("tel:123") for more information.
+
+--- link-trailing-period ---
+// Test that the period is trimmed.
+#show link: underline
+https://a.b.?q=%10#. \
+Wahttp://link \
+Nohttps:\//link \
+Nohttp\://comment
+
+--- link-bracket-balanced ---
+// Verify that brackets are included in links.
+https://[::1]:8080/ \
+https://example.com/(paren) \
+https://example.com/#(((nested))) \
+
+--- link-bracket-unbalanced-closing ---
+// Check that unbalanced brackets are not included in links.
+#[https://example.com/] \
+https://example.com/)
+
+--- link-bracket-unbalanced-opening ---
+// Verify that opening brackets without closing brackets throw an error.
+// Error: 1-22 automatic links cannot contain unbalanced brackets, use the `link` function instead
+https://exam(ple.com/
+
+--- link-show ---
+// Styled with underline and color.
+#show link: it => underline(text(fill: rgb("283663"), it))
+You could also make the
+#link("https://html5zombo.com/")[link look way more typical.]
+
+--- link-transformed ---
+// Transformed link.
+#set page(height: 60pt)
+#let mylink = link("https://typst.org/")[LINK]
+My cool #box(move(dx: 0.7cm, dy: 0.7cm, rotate(10deg, scale(200%, mylink))))
+
+--- link-on-block ---
+// Link containing a block.
+#link("https://example.com/", block[
+ My cool rhino
+ #box(move(dx: 10pt, image("/assets/images/rhino.png", width: 1cm)))
+])
+
+--- link-to-page ---
+// Link to page one.
+#link((page: 1, x: 10pt, y: 20pt))[Back to the start]
+
+--- link-to-label ---
+// Test link to label.
+Text <hey>
+#link(<hey>)[Go to text.]
+
+--- link-to-label-missing ---
+// Error: 2-20 label `<hey>` does not exist in the document
+#link(<hey>)[Nope.]
+
+--- link-to-label-duplicate ---
+Text <hey>
+Text <hey>
+// Error: 2-20 label `<hey>` occurs multiple times in the document
+#link(<hey>)[Nope.]
diff --git a/tests/suite/model/list.typ b/tests/suite/model/list.typ
new file mode 100644
index 00000000..e37fa65d
--- /dev/null
+++ b/tests/suite/model/list.typ
@@ -0,0 +1,147 @@
+// Test bullet lists.
+
+--- list-basic ---
+_Shopping list_
+#list[Apples][Potatoes][Juice]
+
+--- list-nested ---
+- First level.
+
+ - Second level.
+ There are multiple paragraphs.
+
+ - Third level.
+
+ Still the same bullet point.
+
+ - Still level 2.
+
+- At the top.
+
+--- list-content-block ---
+- Level 1
+ - Level #[
+2 through content block
+]
+
+--- list-top-level-indent ---
+ - Top-level indent
+- is fine.
+
+--- list-indent-specifics ---
+ - A
+ - B
+ - C
+- D
+
+--- list-tabs ---
+// This works because tabs are used consistently.
+ - A with 1 tab
+ - B with 2 tabs
+
+--- list-mixed-tabs-and-spaces ---
+// This doesn't work because of mixed tabs and spaces.
+ - A with 2 spaces
+ - B with 2 tabs
+
+--- list-syntax-edge-cases ---
+// Edge cases.
+-
+Not in list
+-Nope
+
+--- list-marker-align-unaffected ---
+// Alignment shouldn't affect marker
+#set align(horizon)
+
+- ABCDEF\ GHIJKL\ MNOPQR
+
+--- list-marker-dash ---
+// Test en-dash.
+#set list(marker: [--])
+- A
+- B
+
+--- list-marker-cycle ---
+// Test that items are cycled.
+#set list(marker: ([--], [•]))
+- A
+ - B
+ - C
+
+--- list-marker-closure ---
+// Test function.
+#set list(marker: n => if n == 1 [--] else [•])
+- A
+- B
+ - C
+ - D
+ - E
+- F
+
+--- list-marker-bare-hyphen ---
+// Test that bare hyphen doesn't lead to cycles and crashes.
+#set list(marker: [-])
+- Bare hyphen is
+- a bad marker
+
+--- list-marker-array-empty ---
+// Error: 19-21 array must contain at least one marker
+#set list(marker: ())
+
+--- list-attached ---
+// Test basic attached list.
+Attached to:
+- the bottom
+- of the paragraph
+
+Next paragraph.
+
+--- list-attached-above-spacing ---
+// Test that attached list isn't affected by block spacing.
+#show list: set block(above: 100pt)
+Hello
+- A
+World
+- B
+
+--- list-non-attached-followed-by-attached ---
+// Test non-attached list followed by attached list,
+// separated by only word.
+Hello
+
+- A
+
+World
+- B
+
+--- list-tight-non-attached-tight ---
+// Test non-attached tight list.
+#set block(spacing: 15pt)
+Hello
+- A
+World
+
+- B
+- C
+
+More.
+
+--- list-wide-cannot-attach ---
+// Test that wide lists cannot be ...
+#set block(spacing: 15pt)
+Hello
+- A
+
+- B
+World
+
+--- list-wide-really-cannot-attach ---
+// ... even if forced to.
+Hello
+#list(tight: false)[A][B]
+World
+
+--- issue-2530-list-item-panic ---
+// List item (pre-emptive)
+#list.item[Hello]
diff --git a/tests/suite/model/numbering.typ b/tests/suite/model/numbering.typ
new file mode 100644
index 00000000..c2de1e05
--- /dev/null
+++ b/tests/suite/model/numbering.typ
@@ -0,0 +1,103 @@
+// Test integrated numbering patterns.
+
+--- numbering-symbol-and-roman ---
+#for i in range(0, 9) {
+ numbering("*", i)
+ [ and ]
+ numbering("I.a", i, i)
+ [ for #i \ ]
+}
+
+--- numbering-latin ---
+#for i in range(0, 4) {
+ numbering("A", i)
+ [ for #i \ ]
+}
+... \
+#for i in range(26, 30) {
+ numbering("A", i)
+ [ for #i \ ]
+}
+... \
+#for i in range(702, 706) {
+ numbering("A", i)
+ [ for #i \ ]
+}
+
+--- numbering-hebrew ---
+#set text(lang: "he")
+#for i in range(9, 21, step: 2) {
+ numbering("א.", i)
+ [ עבור #i \ ]
+}
+
+--- numbering-chinese ---
+#set text(lang: "zh", font: ("Linux Libertine", "Noto Serif CJK SC"))
+#for i in range(9, 21, step: 2){
+ numbering("一", i)
+ [ and ]
+ numbering("壹", i)
+ [ for #i \ ]
+}
+
+--- numbering-japanese-iroha ---
+#set text(lang: "ja", font: ("Linux Libertine", "Noto Serif CJK JP"))
+#for i in range(0, 4) {
+ numbering("イ", i)
+ [ (or ]
+ numbering("い", i)
+ [) for #i \ ]
+}
+... \
+#for i in range(47, 51) {
+ numbering("イ", i)
+ [ (or ]
+ numbering("い", i)
+ [) for #i \ ]
+}
+... \
+#for i in range(2256, 2260) {
+ numbering("イ", i)
+ [ for #i \ ]
+}
+
+--- numbering-korean ---
+#set text(lang: "ko", font: ("Linux Libertine", "Noto Serif CJK KR"))
+#for i in range(0, 4) {
+ numbering("가", i)
+ [ (or ]
+ numbering("ㄱ", i)
+ [) for #i \ ]
+}
+... \
+#for i in range(47, 51) {
+ numbering("가", i)
+ [ (or ]
+ numbering("ㄱ", i)
+ [) for #i \ ]
+}
+... \
+#for i in range(2256, 2260) {
+ numbering("ㄱ", i)
+ [ for #i \ ]
+}
+
+--- numbering-japanese-aiueo ---
+#set text(lang: "jp", font: ("Linux Libertine", "Noto Serif CJK JP"))
+#for i in range(0, 9) {
+ numbering("あ", i)
+ [ and ]
+ numbering("I.あ", i, i)
+ [ for #i \ ]
+}
+
+#for i in range(0, 9) {
+ numbering("ア", i)
+ [ and ]
+ numbering("I.ア", i, i)
+ [ for #i \ ]
+}
+
+--- numbering-negative ---
+// Error: 17-19 number must be at least zero
+#numbering("1", -1)
diff --git a/tests/suite/model/outline.typ b/tests/suite/model/outline.typ
new file mode 100644
index 00000000..d8fc1a43
--- /dev/null
+++ b/tests/suite/model/outline.typ
@@ -0,0 +1,176 @@
+--- outline ---
+// LARGE
+#set page("a7", margin: 20pt, numbering: "1")
+#set heading(numbering: "(1/a)")
+#show heading.where(level: 1): set text(12pt)
+#show heading.where(level: 2): set text(10pt)
+#set math.equation(numbering: "1")
+
+#outline()
+#outline(title: [Figures], target: figure)
+#outline(title: [Equations], target: math.equation)
+
+= Introduction
+#lorem(12)
+
+= Analysis
+#lorem(10)
+
+#[
+ #set heading(outlined: false)
+ == Methodology
+ #lorem(6)
+]
+
+== Math
+$x$ is a very useful constant. See it in action:
+$ x = x $
+
+== Interesting figures
+#figure(rect[CENSORED], kind: image, caption: [A picture showing a programmer at work.])
+#figure(table[1x1], caption: [A very small table.])
+
+== Programming
+```rust
+fn main() {
+ panic!("in the disco");
+}
+```
+
+==== Deep Stuff
+Ok ...
+
+// Ensure 'bookmarked' option doesn't affect the outline
+#set heading(numbering: "(I)", bookmarked: false)
+
+= #text(blue)[Sum]mary
+#lorem(10)
+
+--- outline-indent-numbering ---
+// LARGE
+// With heading numbering
+#set page(width: 200pt)
+#set heading(numbering: "1.a.")
+#show heading: none
+#set outline(fill: none)
+
+#context test(outline.indent, none)
+#outline(indent: false)
+#outline(indent: true)
+#outline(indent: none)
+#outline(indent: auto)
+#outline(indent: 2em)
+#outline(indent: n => ([-], [], [==], [====]).at(n))
+
+= About ACME Corp.
+== History
+== Products
+=== Categories
+==== General
+
+--- outline-indent-no-numbering ---
+// Without heading numbering
+#set page(width: 200pt)
+#show heading: none
+#set outline(fill: none)
+
+#outline(indent: false)
+#outline(indent: true)
+#outline(indent: none)
+#outline(indent: auto)
+#outline(indent: n => 2em * n)
+
+= About
+== History
+
+--- outline-indent-bad-type ---
+// Error: 2-35 expected relative length or content, found dictionary
+#outline(indent: n => (a: "dict"))
+
+= Heading
+
+--- outline-first-line-indent ---
+#set par(first-line-indent: 1.5em)
+#set heading(numbering: "1.1.a.")
+#show outline.entry.where(level: 1): it => {
+ v(0.5em, weak: true)
+ strong(it)
+}
+
+#outline()
+
+= Introduction
+= Background
+== History
+== State of the Art
+= Analysis
+== Setup
+
+--- outline-entry ---
+#set page(width: 150pt)
+#set heading(numbering: "1.")
+
+#show outline.entry.where(
+ level: 1
+): it => {
+ v(12pt, weak: true)
+ strong(it)
+}
+
+#outline(indent: auto)
+
+#set text(8pt)
+#show heading: set block(spacing: 0.65em)
+
+= Introduction
+= Background
+== History
+== State of the Art
+= Analysis
+== Setup
+
+--- outline-entry-complex ---
+#set page(width: 150pt, numbering: "I", margin: (bottom: 20pt))
+#set heading(numbering: "1.")
+#show outline.entry.where(level: 1): it => [
+ #let loc = it.element.location()
+ #let num = numbering(loc.page-numbering(), ..counter(page).at(loc))
+ #emph(link(loc, it.body))
+ #text(luma(100), box(width: 1fr, repeat[#it.fill.body;·]))
+ #link(loc, num)
+]
+
+#counter(page).update(3)
+#outline(indent: auto, fill: repeat[--])
+
+#set text(8pt)
+#show heading: set block(spacing: 0.65em)
+
+= Top heading
+== Not top heading
+=== Lower heading
+=== Lower too
+== Also not top
+
+#pagebreak()
+#set page(numbering: "1")
+
+= Another top heading
+== Middle heading
+=== Lower heading
+
+--- outline-bad-element ---
+// Error: 2-27 cannot outline metadata
+#outline(target: metadata)
+#metadata("hello")
+
+--- issue-2530-outline-entry-panic-text ---
+// Outline entry (pre-emptive)
+// Error: 2-48 cannot outline text
+#outline.entry(1, [Hello], [World!], none, [1])
+
+--- issue-2530-outline-entry-panic-heading ---
+// Outline entry (pre-emptive, improved error)
+// Error: 2-55 heading must have a location
+// Hint: 2-55 try using a query or a show rule to customize the outline.entry instead
+#outline.entry(1, heading[Hello], [World!], none, [1])
diff --git a/tests/suite/model/par.typ b/tests/suite/model/par.typ
new file mode 100644
index 00000000..65779f6a
--- /dev/null
+++ b/tests/suite/model/par.typ
@@ -0,0 +1,78 @@
+// Test configuring paragraph properties.
+
+--- par-basic ---
+#set page(width: 250pt, height: 120pt)
+
+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.
+
+I am too bold, 'tis not to me she speaks: Two of the fairest stars in all the
+heaven, Having some business, do entreat her eyes To twinkle in their spheres
+till they return. What if her eyes were there, they in her head? The brightness
+of her cheek would shame those stars, As daylight doth a lamp; her eyes in
+heaven Would through the airy region stream so bright That birds would sing and
+think it were not night. See, how she leans her cheek upon her hand! O, that I
+were a glove upon that hand, That I might touch that cheek!
+
+--- par-leading-and-block-spacing ---
+// Test changing leading and spacing.
+#set block(spacing: 1em)
+#set par(leading: 2pt)
+But, soft! what light through yonder window breaks?
+
+It is the east, and Juliet is the sun.
+
+--- par-first-line-indent ---
+#set par(first-line-indent: 12pt, leading: 5pt)
+#set block(spacing: 5pt)
+#show heading: set text(size: 10pt)
+
+The first paragraph has no indent.
+
+But the second one does.
+
+#box(image("/assets/images/tiger.jpg", height: 6pt))
+starts a paragraph, also with indent.
+
+#align(center, image("/assets/images/rhino.png", width: 1cm))
+
+= Headings
+- And lists.
+- Have no indent.
+
+ Except if you have another paragraph in them.
+
+#set text(8pt, lang: "ar", font: ("Noto Sans Arabic", "Linux Libertine"))
+#set par(leading: 8pt)
+
+= Arabic
+دع النص يمطر عليك
+
+ثم يصبح النص رطبًا وقابل للطرق ويبدو المستند رائعًا.
+
+--- par-spacing-and-first-line-indent ---
+// This is madness.
+#set par(first-line-indent: 12pt)
+Why would anybody ever ...
+
+... want spacing and indent?
+
+--- par-hanging-indent ---
+// Test hanging indent.
+#set par(hanging-indent: 15pt, justify: true)
+#lorem(10)
+
+--- par-hanging-indent-manual-linebreak ---
+#set par(hanging-indent: 1em)
+Welcome \ here. Does this work well?
+
+--- par-hanging-indent-rtl ---
+#set par(hanging-indent: 2em)
+#set text(dir: rtl)
+لآن وقد أظلم الليل وبدأت النجوم
+تنضخ وجه الطبيعة التي أعْيَتْ من طول ما انبعثت في النهار
diff --git a/tests/suite/model/quote.typ b/tests/suite/model/quote.typ
new file mode 100644
index 00000000..446784ee
--- /dev/null
+++ b/tests/suite/model/quote.typ
@@ -0,0 +1,86 @@
+// Test the quote element.
+
+--- quote-dir-author-pos ---
+// Text direction affects author positioning
+And I quote: #quote(attribution: [René Descartes])[cogito, ergo sum].
+
+#set text(lang: "ar")
+#quote(attribution: [عالم])[مرحبًا]
+
+--- quote-dir-align ---
+// Text direction affects block alignment
+#set quote(block: true)
+#quote(attribution: [René Descartes])[cogito, ergo sum]
+
+#set text(lang: "ar")
+#quote(attribution: [عالم])[مرحبًا]
+
+--- quote-block-spacing ---
+// Spacing with other blocks
+#set quote(block: true)
+#set text(8pt)
+
+#lorem(10)
+#quote(lorem(10))
+#lorem(10)
+
+--- quote-inline ---
+// Inline citation
+#set text(8pt)
+#quote(attribution: <tolkien54>)[In a hole in the ground there lived a hobbit.]
+
+#set text(0pt)
+#bibliography("/assets/bib/works.bib")
+
+--- quote-cite-format-label-or-numeric ---
+// Citation-format: label or numeric
+#set text(8pt)
+#set quote(block: true)
+#quote(attribution: <tolkien54>)[In a hole in the ground there lived a hobbit.]
+
+#set text(0pt)
+#bibliography("/assets/bib/works.bib", style: "ieee")
+
+--- quote-cite-format-note ---
+// Citation-format: note
+#set text(8pt)
+#set quote(block: true)
+#quote(attribution: <tolkien54>)[In a hole in the ground there lived a hobbit.]
+
+#set text(0pt)
+#bibliography("/assets/bib/works.bib", style: "chicago-notes")
+
+--- quote-cite-format-author-date ---
+// Citation-format: author-date or author
+#set text(8pt)
+#set quote(block: true)
+#quote(attribution: <tolkien54>)[In a hole in the ground there lived a hobbit.]
+
+#set text(0pt)
+#bibliography("/assets/bib/works.bib", style: "apa")
+
+--- quote-nesting ---
+// Test quote selection.
+#set page(width: auto)
+#set text(lang: "en")
+=== EN
+#quote[An apostroph'] \
+#quote[A #quote[nested] quote] \
+#quote[A #quote[very #quote[nested]] quote]
+
+#set text(lang: "de")
+=== DE
+#quote[Satz mit Apostroph'] \
+#quote[Satz mit #quote[Zitat]] \
+#quote[A #quote[very #quote[nested]] quote]
+
+#set smartquote(alternative: true)
+=== DE Alternative
+#quote[Satz mit Apostroph'] \
+#quote[Satz mit #quote[Zitat]] \
+#quote[A #quote[very #quote[nested]] quote]
+
+--- quote-nesting-custom ---
+// With custom quotes.
+#set smartquote(quotes: (single: ("<", ">"), double: ("(", ")")))
+#quote[A #quote[nested] quote]
diff --git a/tests/suite/model/ref.typ b/tests/suite/model/ref.typ
new file mode 100644
index 00000000..200f40aa
--- /dev/null
+++ b/tests/suite/model/ref.typ
@@ -0,0 +1,56 @@
+// Test references.
+
+--- ref-basic ---
+#set heading(numbering: "1.")
+
+= Introduction <intro>
+See @setup.
+
+== Setup <setup>
+As seen in @intro, we proceed.
+
+--- ref-label-missing ---
+// Error: 1-5 label `<foo>` does not exist in the document
+@foo
+
+--- ref-label-duplicate ---
+= First <foo>
+= Second <foo>
+
+// Error: 1-5 label `<foo>` occurs multiple times in the document
+@foo
+
+--- ref-supplements ---
+#set heading(numbering: "1.", supplement: [Chapter])
+#set math.equation(numbering: "(1)", supplement: [Eq.])
+
+= Intro
+#figure(
+ image("/assets/images/cylinder.svg", height: 1cm),
+ caption: [A cylinder.],
+ supplement: "Fig",
+) <fig1>
+
+#figure(
+ image("/assets/images/tiger.jpg", height: 1cm),
+ caption: [A tiger.],
+ supplement: "Tig",
+) <fig2>
+
+$ A = 1 $ <eq1>
+
+#set math.equation(supplement: none)
+$ A = 1 $ <eq2>
+
+@fig1, @fig2, @eq1, (@eq2)
+
+#set ref(supplement: none)
+@fig1, @fig2, @eq1, @eq2
+
+--- ref-ambigious ---
+// Test ambiguous reference.
+= Introduction <arrgh>
+
+// Error: 1-7 label occurs in the document and its bibliography
+@arrgh
+#bibliography("/assets/bib/works.bib")
diff --git a/tests/suite/model/terms.typ b/tests/suite/model/terms.typ
new file mode 100644
index 00000000..6a08b923
--- /dev/null
+++ b/tests/suite/model/terms.typ
@@ -0,0 +1,77 @@
+// Test term list.
+
+--- terms-constructor ---
+// Test with constructor.
+#terms(
+ ([One], [First]),
+ ([Two], [Second]),
+)
+
+--- terms-built-in-loop ---
+// Test joining.
+#for word in lorem(4).split().map(s => s.trim(".")) [
+ / #word: Latin stuff.
+]
+
+--- terms-multiline ---
+// Test multiline.
+#set text(8pt)
+
+/ Fruit: A tasty, edible thing.
+/ Veggie:
+ An important energy source
+ for vegetarians.
+
+ And healthy!
+
+--- terms-style-change-interrupted ---
+// Test style change.
+#set text(8pt)
+/ First list: #lorem(6)
+
+#set terms(hanging-indent: 30pt)
+/ Second list: #lorem(5)
+
+--- terms-rtl ---
+// Test RTL.
+#set text(8pt, dir: rtl)
+
+/ פרי: דבר טעים, אכיל. ומקור אנרגיה חשוב לצמחונים.
+
+--- terms-grid ---
+// Test grid like show rule.
+#show terms: it => table(
+ columns: 2,
+ inset: 3pt,
+ ..it.children.map(v => (emph(v.term), v.description)).flatten(),
+)
+
+/ A: One letter
+/ BB: Two letters
+/ CCC: Three letters
+
+--- terms-syntax-edge-cases ---
+/ Term:
+Not in list
+/Nope
+
+--- terms-missing-colon ---
+// Error: 8 expected colon
+/ Hello
+
+--- issue-1050-terms-indent ---
+#set page(width: 200pt)
+#set par(first-line-indent: 0.5cm)
+
+- #lorem(10)
+- #lorem(10)
+
++ #lorem(10)
++ #lorem(10)
+
+/ Term 1: #lorem(10)
+/ Term 2: #lorem(10)
+
+--- issue-2530-term-item-panic ---
+// Term item (pre-emptive)
+#terms.item[Hello][World!]
diff --git a/tests/suite/playground.typ b/tests/suite/playground.typ
new file mode 100644
index 00000000..1af70bea
--- /dev/null
+++ b/tests/suite/playground.typ
@@ -0,0 +1 @@
+--- playground ---
diff --git a/tests/suite/scripting/blocks.typ b/tests/suite/scripting/blocks.typ
new file mode 100644
index 00000000..f139b8c6
--- /dev/null
+++ b/tests/suite/scripting/blocks.typ
@@ -0,0 +1,143 @@
+// Test code blocks.
+
+--- code-block-basic-syntax ---
+
+// Evaluates to join of none, [My ] and the two loop bodies.
+#{
+ let parts = ("my fri", "end.")
+ [Hello, ]
+ for s in parts [#s]
+}
+
+// Evaluates to join of the content and strings.
+#{
+ [How]
+ if true {
+ " are"
+ }
+ [ ]
+ if false [Nope]
+ [you] + "?"
+}
+
+--- code-block-empty ---
+// Nothing evaluates to none.
+#test({}, none)
+
+--- code-block-let ---
+// Let evaluates to none.
+#test({ let v = 0 }, none)
+
+--- code-block-single-expression ---
+// Evaluates to single expression.
+#test({ "hello" }, "hello")
+
+--- code-block-multiple-expressions-single-line ---
+// Evaluates to string.
+#test({ let x = "m"; x + "y" }, "my")
+
+--- code-block-join-let-with-expression ---
+// Evaluated to int.
+#test({
+ let x = 1
+ let y = 2
+ x + y
+}, 3)
+
+--- code-block-join-expression-with-none ---
+// String is joined with trailing none, evaluates to string.
+#test({
+ type("")
+ none
+}, str)
+
+--- code-block-join-int-with-content ---
+// Some things can't be joined.
+#{
+ [A]
+ // Error: 3-4 cannot join content with integer
+ 1
+ [B]
+}
+
+--- code-block-scope-in-markup ---
+// Block directly in markup also creates a scope.
+#{ let x = 1 }
+
+// Error: 7-8 unknown variable: x
+#test(x, 1)
+
+--- code-block-scope-in-let ---
+// Block in expression does create a scope.
+#let a = {
+ let b = 1
+ b
+}
+
+#test(a, 1)
+
+// Error: 3-4 unknown variable: b
+#{b}
+
+--- code-block-double-scope ---
+// Double block creates a scope.
+#{{
+ import "module.typ": b
+ test(b, 1)
+}}
+
+// Error: 2-3 unknown variable: b
+#b
+
+--- code-block-nested-scopes ---
+// Multiple nested scopes.
+#{
+ let a = "a1"
+ {
+ let a = "a2"
+ {
+ test(a, "a2")
+ let a = "a3"
+ test(a, "a3")
+ }
+ test(a, "a2")
+ }
+ test(a, "a1")
+}
+
+--- code-block-multiple-literals-without-semicolon ---
+// Multiple unseparated expressions in one line.
+// Error: 4 expected semicolon or line break
+#{1 2}
+
+--- code-block-multiple-expressions-without-semicolon ---
+// Error: 13 expected semicolon or line break
+// Error: 23 expected semicolon or line break
+#{let x = -1 let y = 3 x + y}
+
+--- code-block-incomplete-expressions ---
+#{
+ // Error: 7-10 expected pattern, found string
+ for "v"
+
+ // Error: 8 expected keyword `in`
+ // Error: 22 expected block
+ for v let z = 1 + 2
+
+ z
+}
+
+--- code-block-unclosed ---
+// Error: 2-3 unclosed delimiter
+#{
+
+--- code-block-unopened ---
+// Error: 2-3 unexpected closing brace
+#}
+
+--- content-block-in-markup-scope ---
+// Content blocks also create a scope.
+#[#let x = 1]
+
+// Error: 2-3 unknown variable: x
+#x
diff --git a/tests/suite/scripting/call.typ b/tests/suite/scripting/call.typ
new file mode 100644
index 00000000..e79fc949
--- /dev/null
+++ b/tests/suite/scripting/call.typ
@@ -0,0 +1,200 @@
+// Test function calls.
+
+--- call-basic ---
+
+// Omitted space.
+#let f() = {}
+#[#f()*Bold*]
+
+// Call return value of function with body.
+#let f(x, body) = (y) => [#x] + body + [#y]
+#f(1)[2](3)
+
+// Don't parse this as a function.
+#test (it)
+
+#let f(body) = body
+#f[A]
+#f()[A]
+#f([A])
+
+#let g(a, b) = a + b
+#g[A][B]
+#g([A], [B])
+#g()[A][B]
+
+--- call-aliased-function ---
+// Call function assigned to variable.
+#let alias = type
+#test(alias(alias), type)
+
+--- call-complex-callee-expression ---
+// Callee expressions.
+#{
+ // Wrapped in parens.
+ test((type)("hi"), str)
+
+ // Call the return value of a function.
+ let adder(dx) = x => x + dx
+ test(adder(2)(5), 7)
+}
+
+--- call-bad-type-bool-literal ---
+// Error: 2-6 expected function, found boolean
+#true()
+
+--- call-bad-type-string-var ---
+#let x = "x"
+
+// Error: 2-3 expected function, found string
+#x()
+
+--- call-bad-type-int-expr ---
+#let f(x) = x
+
+// Error: 2-6 expected function, found integer
+#f(1)(2)
+
+--- call-bad-type-content-expr ---
+#let f(x) = x
+
+// Error: 2-6 expected function, found content
+#f[1](2)
+
+--- call-args-trailing-comma ---
+// Trailing comma.
+#test(1 + 1, 2,)
+
+--- call-args-duplicate ---
+// Error: 26-30 duplicate argument: font
+#set text(font: "Arial", font: "Helvetica")
+
+--- call-args-bad-positional-as-named ---
+// Error: 4-15 the argument `amount` is positional
+// Hint: 4-15 try removing `amount:`
+#h(amount: 0.5)
+
+--- call-args-bad-colon ---
+// Error: 7-8 unexpected colon
+#func(:)
+
+--- call-args-bad-token ---
+// Error: 10-12 unexpected end of block comment
+#func(a:1*/)
+
+--- call-args-missing-comma ---
+// Error: 8 expected comma
+#func(1 2)
+
+--- call-args-bad-name-and-incomplete-pair ---
+// Error: 7-8 expected identifier, found integer
+// Error: 9 expected expression
+#func(1:)
+
+--- call-args-bad-name-int ---
+// Error: 7-8 expected identifier, found integer
+#func(1:2)
+
+--- call-args-bad-name-string ---
+// Error: 7-12 expected identifier, found string
+#func("abc": 2)
+
+--- call-args-bad-name-group ---
+// Error: 7-10 expected identifier, found group
+#func((x):1)
+
+--- call-args-lone-underscore ---
+// Test that lone underscore works.
+#test((1, 2, 3).map(_ => {}).len(), 3)
+
+--- call-args-spread-override ---
+// Test standard argument overriding.
+#{
+ let f(style: "normal", weight: "regular") = {
+ "(style: " + style + ", weight: " + weight + ")"
+ }
+
+ let myf(..args) = f(weight: "bold", ..args)
+ test(myf(), "(style: normal, weight: bold)")
+ test(myf(weight: "black"), "(style: normal, weight: black)")
+ test(myf(style: "italic"), "(style: italic, weight: bold)")
+}
+
+--- call-args-spread-forward ---
+// Test multiple calls.
+#{
+ let f(b, c: "!") = b + c
+ let g(a, ..sink) = a + f(..sink)
+ test(g("a", "b", c: "c"), "abc")
+}
+
+--- call-args-spread-type-repr ---
+// Test doing things with arguments.
+#{
+ let save(..args) = {
+ test(type(args), arguments)
+ test(repr(args), "(three: true, 1, 2)")
+ }
+
+ save(1, 2, three: true)
+}
+
+--- call-args-spread-array-and-dict ---
+// Test spreading array and dictionary.
+#{
+ let more = (3, -3, 6, 10)
+ test(calc.min(1, 2, ..more), -3)
+ test(calc.max(..more, 9), 10)
+ test(calc.max(..more, 11), 11)
+}
+
+#{
+ let more = (c: 3, d: 4)
+ let tostr(..args) = repr(args)
+ test(tostr(a: 1, ..more, b: 2), "(a: 1, c: 3, d: 4, b: 2)")
+}
+
+--- call-args-spread-none ---
+// None is spreadable.
+#let f() = none
+#f(..none)
+#f(..if false {})
+#f(..for x in () [])
+
+--- call-args-spread-string-invalid ---
+// Error: 11-19 cannot spread string
+#calc.min(.."nope")
+
+--- call-args-content-block-unclosed ---
+// Error: 6-7 unclosed delimiter
+#func[`a]`
+
+--- issue-886-args-sink ---
+// Test bugs with argument sinks.
+#let foo(..body) = repr(body.pos())
+#foo(a: "1", b: "2", 1, 2, 3, 4, 5, 6)
+
+--- issue-3144-unexpected-arrow ---
+#let f(a: 10) = a(1) + 1
+#test(f(a: _ => 5), 6)
+
+--- issue-3502-space-and-comments-around-destructuring-colon ---
+#let ( key : /* hi */ binding ) = ( key: "ok" )
+#test(binding, "ok")
+
+--- issue-3502-space-around-dict-colon ---
+#test(( key : "value" ).key, "value")
+
+--- issue-3502-space-around-param-colon ---
+// Test that a space after a named parameter is permissible.
+#let f( param : v ) = param
+#test(f( param /* ok */ : 2 ), 2)
+
+--- call-args-unclosed ---
+// Error: 7-8 unclosed delimiter
+#{func(}
+
+--- call-args-unclosed-string ---
+// Error: 6-7 unclosed delimiter
+// Error: 1:7-2:1 unclosed string
+#func("]
diff --git a/tests/suite/scripting/closure.typ b/tests/suite/scripting/closure.typ
new file mode 100644
index 00000000..e3677d33
--- /dev/null
+++ b/tests/suite/scripting/closure.typ
@@ -0,0 +1,223 @@
+// Test closures.
+
+--- closure-without-params-non-atomic ---
+// Don't parse closure directly in content.
+
+#let x = "x"
+
+// Should output `x => y`.
+#x => y
+
+--- closure-without-captures ---
+// Basic closure without captures.
+#{
+ let adder = (x, y) => x + y
+ test(adder(2, 3), 5)
+}
+
+--- closure-as-arg ---
+// Pass closure as argument and return closure.
+// Also uses shorthand syntax for a single argument.
+#{
+ let chain = (f, g) => (x) => f(g(x))
+ let f = x => x + 1
+ let g = x => 2 * x
+ let h = chain(f, g)
+ test(h(2), 5)
+}
+
+--- closure-capture-from-popped-stack-frame ---
+// Capture environment.
+#{
+ let mark = "!"
+ let greet = {
+ let hi = "Hi"
+ name => {
+ hi + ", " + name + mark
+ }
+ }
+
+ test(greet("Typst"), "Hi, Typst!")
+
+ // Changing the captured variable after the closure definition has no effect.
+ mark = "?"
+ test(greet("Typst"), "Hi, Typst!")
+}
+
+--- closure-shadows-outer-var ---
+// Redefined variable.
+#{
+ let x = 1
+ let f() = {
+ let x = x + 2
+ x
+ }
+ test(f(), 3)
+}
+
+--- closure-shadows-outer-var-import ---
+// Import bindings.
+#{
+ let b = "module.typ"
+ let f() = {
+ import b: b
+ b
+ }
+ test(f(), 1)
+}
+
+--- closure-shadows-outer-var-for-loop ---
+// For loop bindings.
+#{
+ let v = (1, 2, 3)
+ let f() = {
+ let s = 0
+ for v in v { s += v }
+ s
+ }
+ test(f(), 6)
+}
+
+--- closure-let-basic ---
+// Let + closure bindings.
+#{
+ let g = "hi"
+ let f() = {
+ let g() = "bye"
+ g()
+ }
+ test(f(), "bye")
+}
+
+--- closure-let-args ---
+// Parameter bindings.
+#{
+ let x = 5
+ let g() = {
+ let f(x, y: x) = x + y
+ f
+ }
+
+ test(g()(8), 13)
+}
+
+--- closure-bad-capture ---
+// Don't leak environment.
+#{
+ // Error: 16-17 unknown variable: x
+ let func() = x
+ let x = "hi"
+ func()
+}
+
+--- closure-missing-arg-positional ---
+// Too few arguments.
+#{
+ let types(x, y) = "[" + str(type(x)) + ", " + str(type(y)) + "]"
+ test(types(14%, 12pt), "[ratio, length]")
+
+ // Error: 8-21 missing argument: y
+ test(types("nope"), "[string, none]")
+}
+
+--- closure-too-many-args-positional ---
+// Too many arguments.
+#{
+ let f(x) = x + 1
+
+ // Error: 8-13 unexpected argument
+ f(1, "two", () => x)
+}
+
+--- closure-capture-in-lvalue ---
+// Mutable method with capture in argument.
+#let x = "b"
+#let f() = {
+ let a = (b: 5)
+ a.at(x) = 10
+ a
+}
+#f()
+
+--- closure-capture-mutate ---
+#let x = ()
+#let f() = {
+ // Error: 3-4 variables from outside the function are read-only and cannot be modified
+ x.at(1) = 2
+}
+#f()
+
+--- closure-named-args-basic ---
+// Named arguments.
+#{
+ let greet(name, birthday: false) = {
+ if birthday { "Happy Birthday, " } else { "Hey, " } + name + "!"
+ }
+
+ test(greet("Typst"), "Hey, Typst!")
+ test(greet("Typst", birthday: true), "Happy Birthday, Typst!")
+
+ // Error: 23-35 unexpected argument: whatever
+ test(greet("Typst", whatever: 10))
+}
+
+--- closure-args-sink ---
+// Parameter unpacking.
+#let f((a, b), ..c) = (a, b, c)
+#test(f((1, 2), 3, 4), (1, 2, (3, 4)))
+
+#let f((k: a, b), c: 3, (d,)) = (a, b, c, d)
+#test(f((k: 1, b: 2), (4,)), (1, 2, 3, 4))
+
+// Error: 8-14 expected identifier, found destructuring pattern
+#let f((a, b): 0) = none
+
+// Error: 10-19 expected pattern, found array
+#let f(..(a, b: c)) = none
+
+// Error: 10-16 expected pattern, found array
+#let f(..(a, b)) = none
+
+--- closure-param-duplicate-positional ---
+// Error: 11-12 duplicate parameter: x
+#let f(x, x) = none
+
+--- closure-body-multiple-expressions ---
+// Error: 21 expected comma
+// Error: 22-23 expected pattern, found integer
+// Error: 24-25 unexpected plus
+// Error: 26-27 expected pattern, found integer
+#let f = (x: () => 1 2 + 3) => 4
+
+--- closure-param-duplicate-mixed ---
+// Error: 14-15 duplicate parameter: a
+// Error: 23-24 duplicate parameter: b
+// Error: 35-36 duplicate parameter: b
+#let f(a, b, a: none, b: none, c, b) = none
+
+--- closure-param-duplicate-spread ---
+// Error: 13-14 duplicate parameter: a
+#let f(a, ..a) = none
+
+--- closure-pattern-bad-string ---
+// Error: 7-14 expected pattern, found string
+#((a, "named": b) => none)
+
+--- closure-let-pattern-bad-string ---
+// Error: 10-15 expected pattern, found string
+#let foo("key": b) = key
+
+--- closure-param-keyword ---
+// Error: 10-14 expected pattern, found `none`
+// Hint: 10-14 keyword `none` is not allowed as an identifier; try `none_` instead
+#let foo(none: b) = key
+
+--- closure-param-named-underscore ---
+// Error: 10-11 expected identifier, found underscore
+#let foo(_: 3) = none
+
+--- issue-non-atomic-closure ---
+// Ensure that we can't have non-atomic closures.
+#let x = 1
+#let c = [#(x) => (1, 2)]
+#test(c.children.last(), [(1, 2)]))
diff --git a/tests/suite/scripting/destructuring.typ b/tests/suite/scripting/destructuring.typ
new file mode 100644
index 00000000..0a3c1c54
--- /dev/null
+++ b/tests/suite/scripting/destructuring.typ
@@ -0,0 +1,357 @@
+--- destructuring-group-1 ---
+// This wasn't allowed.
+#let ((x)) = 1
+#test(x, 1)
+
+--- destructuring-group-2 ---
+// This also wasn't allowed.
+#let ((a, b)) = (1, 2)
+#test(a, 1)
+#test(b, 2)
+
+--- destructuring-dict-underscore ---
+// Here, `best` was accessed as a variable, where it shouldn't have.
+#{
+ (best: _) = (best: "brr")
+}
+
+--- destructuring-dict-array-at ---
+// Same here.
+#{
+ let array = (1, 2, 3, 4)
+ (test: array.at(1), best: _) = (test: "baz", best: "brr")
+ test(array, (1, "baz", 3, 4))
+}
+
+--- destructuring-dict-bad ---
+// Error: 7-10 expected identifier, found group
+// Error: 12-14 expected pattern, found integer
+#let ((a): 10) = "world"
+
+--- destructuring-bad-duplicate ---
+// Here, `a` is not duplicate, where it was previously identified as one.
+#let f((a: b), (c,), a) = (a, b, c)
+#test(f((a: 1), (2,), 3), (3, 1, 2))
+
+--- destructuring-non-atomic ---
+// Ensure that we can't have non-atomic destructuring.
+#let x = 1
+#let c = [#() = ()]
+#test(c.children.last(), [()])
+
+--- destructuring-let-array ---
+// Simple destructuring.
+#let (a, b) = (1, 2)
+#test(a, 1)
+#test(b, 2)
+
+--- destructuring-let-array-single-item ---
+#let (a,) = (1,)
+#test(a, 1)
+
+--- destructuring-let-array-placeholders ---
+// Destructuring with multiple placeholders.
+#let (a, _, c, _) = (1, 2, 3, 4)
+#test(a, 1)
+#test(c, 3)
+
+--- destructuring-let-array-with-sink-at-end ---
+// Destructuring with a sink.
+#let (a, b, ..c) = (1, 2, 3, 4, 5, 6)
+#test(a, 1)
+#test(b, 2)
+#test(c, (3, 4, 5, 6))
+
+--- destructuring-let-array-with-sink-in-middle ---
+// Destructuring with a sink in the middle.
+#let (a, ..b, c) = (1, 2, 3, 4, 5, 6)
+#test(a, 1)
+#test(b, (2, 3, 4, 5))
+#test(c, 6)
+
+--- destructuring-let-array-with-sink-at-start-empty ---
+// Destructuring with an empty sink.
+#let (..a, b, c) = (1, 2)
+#test(a, ())
+#test(b, 1)
+#test(c, 2)
+
+--- destructuring-let-array-with-sink-in-middle-empty ---
+// Destructuring with an empty sink.
+#let (a, ..b, c) = (1, 2)
+#test(a, 1)
+#test(b, ())
+#test(c, 2)
+
+--- destructuring-let-array-with-sink-at-end-empty ---
+// Destructuring with an empty sink.
+#let (a, b, ..c) = (1, 2)
+#test(a, 1)
+#test(b, 2)
+#test(c, ())
+
+--- destructuring-let-array-with-sink-empty ---
+// Destructuring with an empty sink and empty array.
+#let (..a) = ()
+#test(a, ())
+
+--- destructuring-let-array-with-unnamed-sink ---
+// Destructuring with unnamed sink.
+#let (a, .., b) = (1, 2, 3, 4)
+#test(a, 1)
+#test(b, 4)
+
+// Error: 10-11 duplicate binding: a
+#let (a, a) = (1, 2)
+
+// Error: 12-15 only one destructuring sink is allowed
+#let (..a, ..a) = (1, 2)
+
+// Error: 12-13 duplicate binding: a
+#let (a, ..a) = (1, 2)
+
+// Error: 13-14 duplicate binding: a
+#let (a: a, a) = (a: 1, b: 2)
+
+// Error: 13-20 expected pattern, found function call
+#let (a, b: b.at(0)) = (a: 1, b: 2)
+
+// Error: 7-14 expected pattern, found function call
+#let (a.at(0),) = (1,)
+
+--- destructuring-let-array-too-few-elements ---
+// Error: 13-14 not enough elements to destructure
+#let (a, b, c) = (1, 2)
+
+--- destructuring-let-array-too-few-elements-with-sink ---
+// Error: 7-10 not enough elements to destructure
+#let (..a, b, c, d) = (1, 2)
+
+--- destructuring-let-array-bool-invalid ---
+// Error: 6-12 cannot destructure boolean
+#let (a, b) = true
+
+--- destructuring-let-dict ---
+// Simple destructuring.
+#let (a: a, b, x: c) = (a: 1, b: 2, x: 3)
+#test(a, 1)
+#test(b, 2)
+#test(c, 3)
+
+--- destructuring-let-dict-with-sink-at-end ---
+// Destructuring with a sink.
+#let (a: _, ..b) = (a: 1, b: 2, c: 3)
+#test(b, (b: 2, c: 3))
+
+--- destructuring-let-dict-with-sink-in-middle ---
+// Destructuring with a sink in the middle.
+#let (a: _, ..b, c: _) = (a: 1, b: 2, c: 3)
+#test(b, (b: 2))
+
+--- destructuring-let-dict-with-sink-at-end-empty ---
+// Destructuring with an empty sink.
+#let (a: _, ..b) = (a: 1)
+#test(b, (:))
+
+--- destructuring-let-dict-with-sink-empty ---
+// Destructuring with an empty sink and empty dict.
+#let (..a) = (:)
+#test(a, (:))
+
+--- destructuring-let-dict-with-unnamed-sink ---
+// Destructuring with unnamed sink.
+#let (a, ..) = (a: 1, b: 2)
+#test(a, 1)
+
+--- destructuring-let-nested ---
+// Nested destructuring.
+#let ((a, b), (key: c)) = ((1, 2), (key: 3))
+#test((a, b, c), (1, 2, 3))
+
+--- destructuring-let-dict-key-string-invalid ---
+// Keyed destructuring is not currently supported.
+// Error: 7-18 expected pattern, found string
+#let ("spacy key": val) = ("spacy key": 123)
+#val
+
+--- destructuring-let-dict-key-expr-invalid ---
+// Keyed destructuring is not currently supported.
+#let x = "spacy key"
+// Error: 7-10 expected identifier, found group
+#let ((x): v) = ("spacy key": 123)
+
+--- destructuring-let-array-trailing-placeholders ---
+// Trailing placeholders.
+// Error: 10-11 not enough elements to destructure
+#let (a, _, _, _, _) = (1,)
+#test(a, 1)
+
+--- destructuring-let-dict-patterns-invalid ---
+// Error: 10-13 expected pattern, found string
+// Error: 18-19 expected pattern, found integer
+#let (a: "a", b: 2) = (a: 1, b: 2)
+
+--- destructuring-let-dict-shorthand-missing-key ---
+// Error: 10-11 dictionary does not contain key "b"
+#let (a, b) = (a: 1)
+
+--- destructuring-let-dict-missing-key ---
+// Error: 10-11 dictionary does not contain key "b"
+#let (a, b: b) = (a: 1)
+
+--- destructuring-let-dict-from-array ---
+// Error: 7-11 cannot destructure named pattern from an array
+#let (a: a, b) = (1, 2, 3)
+
+--- destructuring-during-loop-continue ---
+// Test continue while destructuring.
+// Should output "one = I \ two = II \ one = I".
+#for num in (1, 2, 3, 1) {
+ let (word, roman) = if num == 1 {
+ ("one", "I")
+ } else if num == 2 {
+ ("two", "II")
+ } else {
+ continue
+ }
+ [#word = #roman \ ]
+}
+
+--- destructuring-assign ---
+// Test destructuring assignments.
+
+#let a = none
+#let b = none
+#let c = none
+#((a,) = (1,))
+#test(a, 1)
+
+#((_, a, b, _) = (1, 2, 3, 4))
+#test(a, 2)
+#test(b, 3)
+
+#((a, b, ..c) = (1, 2, 3, 4, 5, 6))
+#test(a, 1)
+#test(b, 2)
+#test(c, (3, 4, 5, 6))
+
+#((a: a, b, x: c) = (a: 1, b: 2, x: 3))
+#test(a, 1)
+#test(b, 2)
+#test(c, 3)
+
+#let a = (1, 2)
+#((a: a.at(0), b) = (a: 3, b: 4))
+#test(a, (3, 2))
+#test(b, 4)
+
+#let a = (1, 2)
+#((a.at(0), b) = (3, 4))
+#test(a, (3, 2))
+#test(b, 4)
+
+#((a, ..b) = (1, 2, 3, 4))
+#test(a, 1)
+#test(b, (2, 3, 4))
+
+#let a = (1, 2)
+#((b, ..a.at(0)) = (1, 2, 3, 4))
+#test(a, ((2, 3, 4), 2))
+#test(b, 1)
+
+--- destructuring-assign-commas ---
+// Test comma placement in destructuring assignment.
+#let array = (1, 2, 3)
+#((key: array.at(1)) = (key: "hi"))
+#test(array, (1, "hi", 3))
+
+#let array = (1, 2, 3)
+#((array.at(1)) = ("hi"))
+#test(array, (1, "hi", 3))
+
+#let array = (1, 2, 3)
+#((array.at(1),) = ("hi",))
+#test(array, (1, "hi", 3))
+
+#let array = (1, 2, 3)
+#((array.at(1)) = ("hi",))
+#test(array, (1, ("hi",), 3))
+
+--- destructuring-assign-nested ---
+// Test nested destructuring assignment.
+#let a
+#let b
+#let c
+#(((a, b), (key: c)) = ((1, 2), (key: 3)))
+#test((a, b, c), (1, 2, 3))
+
+--- destructuring-assign-nested-invalid ---
+#let array = (1, 2, 3)
+// Error: 3-17 cannot destructure string
+#((array.at(1),) = ("hi"))
+#test(array, (1, ("hi",), 3))
+
+--- issue-3275-normal-variable ---
+// Normal variable.
+#for x in (1, 2) {}
+#for x in (a: 1, b: 2) {}
+#for x in "foo" {}
+#for x in bytes("😊") {}
+
+--- issue-3275-placeholder ---
+// Placeholder.
+#for _ in (1, 2) {}
+#for _ in (a: 1, b: 2) {}
+#for _ in "foo" {}
+#for _ in bytes("😊") {}
+
+--- issue-3275-destructuring ---
+// Destructuring.
+#for (a,b,c) in (("a", 1, bytes(())), ("b", 2, bytes(""))) {}
+#for (a, ..) in (("a", 1, bytes(())), ("b", 2, bytes(""))) {}
+#for (k, v) in (a: 1, b: 2, c: 3) {}
+#for (.., v) in (a: 1, b: 2, c: 3) {}
+
+--- issue-3275-loop-over-content ---
+// Error: 11-17 cannot loop over content
+#for x in [1, 2] {}
+
+--- issue-3275-loop-over-arguments ---
+// Error: 11-25 cannot loop over arguments
+#for _ in arguments("a") {}
+
+--- issue-3275-loop-over-integer ---
+// Error: 16-21 cannot loop over integer
+#for (x, y) in 12306 {}
+
+--- issue-3275-destructuring-loop-over-content ---
+// Error: 16-22 cannot loop over content
+#for (x, y) in [1, 2] {}
+
+--- issue-3275-destructuring-loop-over-string ---
+// Error: 6-12 cannot destructure values of string
+#for (x, y) in "foo" {}
+
+--- issue-3275-destructuring-loop-over-string-array ---
+// Error: 6-12 cannot destructure string
+#for (x, y) in ("foo", "bar") {}
+
+--- issue-3275-destructuring-loop-over-bytes ---
+// Error: 6-12 cannot destructure values of bytes
+#for (x, y) in bytes("😊") {}
+
+--- issue-3275-destructuring-loop-over-bytes-array ---
+// Error: 6-12 cannot destructure bytes
+#for (x, y) in (bytes((1,2)), bytes((1,2))) {}
+
+--- issue-3275-destructuring-loop-over-int-array ---
+// Error: 6-12 cannot destructure integer
+#for (x, y) in (1, 2) {}
+
+--- issue-3275-destructuring-loop-over-2d-array-1 ---
+// Error: 10-11 not enough elements to destructure
+#for (x, y) in ((1,), (2,)) {}
+
+--- issue-3275-destructuring-loop-over-2d-array-2 ---
+// Error: 6-12 too many elements to destructure
+#for (x, y) in ((1,2,3), (4,5,6)) {}
diff --git a/tests/suite/scripting/field.typ b/tests/suite/scripting/field.typ
new file mode 100644
index 00000000..7b2427e3
--- /dev/null
+++ b/tests/suite/scripting/field.typ
@@ -0,0 +1,76 @@
+// Test field access.
+
+--- field-function ---
+// Test fields on function scopes.
+#enum.item
+#assert.eq
+#assert.ne
+
+--- field-normal-function-invalid ---
+// Error: 9-16 function `assert` does not contain field `invalid`
+#assert.invalid
+
+--- field-elem-function-invalid ---
+// Error: 7-14 function `enum` does not contain field `invalid`
+#enum.invalid
+
+--- field-elem-function-invalid-call ---
+// Error: 7-14 function `enum` does not contain field `invalid`
+#enum.invalid()
+
+--- field-closure-invalid ---
+// Closures cannot have fields.
+#let f(x) = x
+// Error: 4-11 cannot access fields on user-defined functions
+#f.invalid
+
+--- field-bool-invalid ---
+// Error: 8-10 cannot access fields on type boolean
+#false.ok
+
+--- field-bool-keyword-invalid ---
+// Error: 9-13 cannot access fields on type boolean
+#{false.true}
+
+--- field-invalid-none ---
+#{
+ let object = none
+ // Error: 3-9 none does not have accessible fields
+ object.property = "value"
+}
+
+--- field-invalid-int ---
+#{
+ let object = 10
+ // Error: 3-9 integer does not have accessible fields
+ object.property = "value"
+}
+
+--- field-mutable-invalid-symbol ---
+#{
+ let object = sym.eq.not
+ // Error: 3-9 cannot mutate fields on symbol
+ object.property = "value"
+}
+
+--- field-mutable-invalid-module ---
+#{
+ let object = calc
+ // Error: 3-9 cannot mutate fields on module
+ object.property = "value"
+}
+
+--- field-mutable-invalid-function ---
+#{
+ let object = calc.sin
+ // Error: 3-9 cannot mutate fields on function
+ object.property = "value"
+}
+
+--- field-mutable-invalid-stroke ---
+#{
+ let s = 1pt + red
+ // Error: 3-4 fields on stroke are not yet mutable
+ // Hint: 3-4 try creating a new stroke with the updated field value instead
+ s.thickness = 5pt
+}
diff --git a/tests/suite/scripting/for.typ b/tests/suite/scripting/for.typ
new file mode 100644
index 00000000..e98b3c72
--- /dev/null
+++ b/tests/suite/scripting/for.typ
@@ -0,0 +1,135 @@
+// Test for loops.
+
+--- for-loop-basic ---
+
+// Empty array.
+#for x in () [Nope]
+
+// Dictionary is traversed in insertion order.
+// Should output `Name: Typst. Age: 2.`.
+#for (k, v) in (Name: "Typst", Age: 2) [
+ #k: #v.
+]
+
+// Block body.
+// Should output `[1st, 2nd, 3rd, 4th]`.
+#{
+ "["
+ for v in (1, 2, 3, 4) {
+ if v > 1 [, ]
+ [#v]
+ if v == 1 [st]
+ if v == 2 [nd]
+ if v == 3 [rd]
+ if v >= 4 [th]
+ }
+ "]"
+}
+
+// Content block body.
+// Should output `2345`.
+#for v in (1, 2, 3, 4, 5, 6, 7) [#if v >= 2 and v <= 5 { repr(v) }]
+
+// Map captured arguments.
+#let f1(..args) = args.pos().map(repr)
+#let f2(..args) = args.named().pairs().map(p => repr(p.first()) + ": " + repr(p.last()))
+#let f(..args) = (f1(..args) + f2(..args)).join(", ")
+#f(1, a: 2)
+
+--- for-loop-integrated ---
+#let out = ()
+
+// Values of array.
+#for v in (1, 2, 3) {
+ out += (v,)
+}
+
+// Indices and values of array.
+#for (i, v) in ("1", "2", "3").enumerate() {
+ test(repr(i + 1), v)
+}
+
+// Pairs of dictionary.
+#for v in (a: 4, b: 5) {
+ out += (v,)
+}
+
+// Keys and values of dictionary.
+#for (k, v) in (a: 6, b: 7) {
+ out += (k,)
+ out += (v,)
+}
+
+#test(out, (1, 2, 3, ("a", 4), ("b", 5), "a", 6, "b", 7))
+
+// Grapheme clusters of string.
+#let first = true
+#let joined = for c in "abc👩‍👩‍👦‍👦" {
+ if not first { ", " }
+ first = false
+ c
+}
+
+#test(joined, "a, b, c, 👩‍👩‍👦‍👦")
+
+// Return value.
+#test(for v in "" [], none)
+#test(type(for v in "1" []), content)
+
+--- for-loop-over-bool ---
+// Uniterable expression.
+// Error: 11-15 cannot loop over boolean
+#for v in true {}
+
+--- for-loop-over-string ---
+// Keys and values of strings.
+// Error: 6-12 cannot destructure values of string
+#for (k, v) in "hi" {
+ dont-care
+}
+
+--- for-loop-destructuring-without-parentheses ---
+// Destructuring without parentheses.
+// Error: 7-8 unexpected comma
+// Hint: 7-8 destructuring patterns must be wrapped in parentheses
+#for k, v in (a: 4, b: 5) {
+ dont-care
+}
+
+--- for-loop-destructuring-half ---
+// Error: 7-8 unexpected comma
+// Hint: 7-8 destructuring patterns must be wrapped in parentheses
+#for k, in () {}
+
+--- for-loop-incomplete ---
+// Error: 5 expected pattern
+#for
+
+// Error: 5 expected pattern
+#for//
+
+// Error: 6 expected pattern
+#{for}
+
+// Error: 7 expected keyword `in`
+#for v
+
+// Error: 10 expected expression
+#for v in
+
+// Error: 15 expected block
+#for v in iter
+
+// Error: 5 expected pattern
+#for
+v in iter {}
+
+// Error: 7-10 expected pattern, found string
+// Error: 16 expected block
+A#for "v" thing
+
+// Error: 6-9 expected pattern, found string
+#for "v" in iter {}
+
+// Error: 7 expected keyword `in`
+#for a + b in iter {}
diff --git a/tests/suite/scripting/get-rule.typ b/tests/suite/scripting/get-rule.typ
new file mode 100644
index 00000000..24d4e5db
--- /dev/null
+++ b/tests/suite/scripting/get-rule.typ
@@ -0,0 +1,67 @@
+--- get-rule-basic ---
+// Test basic get rule.
+#context test(text.lang, "en")
+#set text(lang: "de")
+#context test(text.lang, "de")
+#text(lang: "es", context test(text.lang, "es"))
+
+--- get-rule-in-function ---
+// Test whether context is retained in nested function.
+#let translate(..args) = args.named().at(text.lang)
+#set text(lang: "de")
+#context test(translate(de: "Inhalt", en: "Contents"), "Inhalt")
+
+--- get-rule-in-array-callback ---
+// Test whether context is retained in built-in callback.
+#set text(lang: "de")
+#context test(
+ ("en", "de", "fr").sorted(key: v => v != text.lang),
+ ("de", "en", "fr"),
+)
+
+--- get-rule-folding ---
+// Test folding.
+#set rect(stroke: red)
+#context {
+ test(type(rect.stroke), stroke)
+ test(rect.stroke.paint, red)
+}
+#[
+ #set rect(stroke: 4pt)
+ #context test(rect.stroke, 4pt + red)
+]
+#context test(rect.stroke, stroke(red))
+
+--- get-rule-figure-caption-collision ---
+// We have one collision: `figure.caption` could be both the element and a get
+// rule for the `caption` field, which is settable. We always prefer the
+// element. It's unfortunate, but probably nobody writes
+// `set figure(caption: ..)` anyway.
+#test(type(figure.caption), function)
+#context test(type(figure.caption), function)
+
+--- get-rule-assertion-failure ---
+// Error: 10-31 Assertion failed: "en" != "de"
+#context test(text.lang, "de")
+
+--- get-rule-unknown-field ---
+// Error: 15-20 function `text` does not contain field `langs`
+#context text.langs
+
+--- get-rule-inherent-field ---
+// Error: 18-22 function `heading` does not contain field `body`
+#context heading.body
+
+--- get-rule-missing-context-no-context ---
+// Error: 7-11 can only be used when context is known
+// Hint: 7-11 try wrapping this in a `context` expression
+// Hint: 7-11 the `context` expression should wrap everything that depends on this function
+#text.lang
+
+--- get-rule-unknown-field-no-context ---
+// Error: 7-12 function `text` does not contain field `langs`
+#text.langs
+
+--- get-rule-inherent-field-no-context ---
+// Error: 10-14 function `heading` does not contain field `body`
+#heading.body
diff --git a/tests/suite/scripting/if.typ b/tests/suite/scripting/if.typ
new file mode 100644
index 00000000..cc88925f
--- /dev/null
+++ b/tests/suite/scripting/if.typ
@@ -0,0 +1,134 @@
+// Test if-else expressions.
+
+--- if-markup ---
+// Test condition evaluation.
+#if 1 < 2 [
+ One.
+]
+
+#if true == false [
+ {Bad}, but we {dont-care}!
+]
+
+--- if-condition-complex ---
+// Braced condition.
+#if {true} [
+ One.
+]
+
+// Content block in condition.
+#if [] != none [
+ Two.
+]
+
+// Multi-line condition with parens.
+#if (
+ 1 + 1
+ == 1
+) [
+ Nope.
+] else {
+ "Three."
+}
+
+// Multiline.
+#if false [
+ Bad.
+] else {
+ let point = "."
+ "Four" + point
+}
+
+// Content block can be argument or body depending on whitespace.
+#{
+ if content == type[b] [Fi] else [Nope]
+ if content == type [Nope] else [ve.]
+}
+
+#let i = 3
+#if i < 2 [
+ Five.
+] else if i < 4 [
+ Six.
+] else [
+ Seven.
+]
+
+--- if-else-if-else ---
+// Test else if.
+
+#let nth(n) = {
+ str(n)
+ if n == 1 { "st" }
+ else if n == 2 { "nd" }
+ else if n == 3 { "rd" }
+ else { "th" }
+}
+
+#test(nth(1), "1st")
+#test(nth(2), "2nd")
+#test(nth(3), "3rd")
+#test(nth(4), "4th")
+#test(nth(5), "5th")
+
+--- if-expression ---
+// Value of if expressions.
+
+#{
+ let x = 1
+ let y = 2
+ let z
+
+ // Returns if branch.
+ z = if x < y { "ok" }
+ test(z, "ok")
+
+ // Returns else branch.
+ z = if x > y { "bad" } else { "ok" }
+ test(z, "ok")
+
+ // Missing else evaluates to none.
+ z = if x > y { "bad" }
+ test(z, none)
+}
+
+--- if-condition-string-invalid ---
+// Condition must be boolean.
+// If it isn't, neither branch is evaluated.
+// Error: 5-14 expected boolean, found string
+#if "a" + "b" { nope } else { nope }
+
+--- if-condition-invalid-and-wrong-type ---
+// Make sure that we don't complain twice.
+// Error: 5-12 cannot add integer and string
+#if 1 + "2" {}
+
+--- if-incomplete ---
+// Error: 4 expected expression
+#if
+
+// Error: 5 expected expression
+#{if}
+
+// Error: 6 expected block
+#if x
+
+// Error: 2-6 unexpected keyword `else`
+#else {}
+
+// Should output `x`.
+// Error: 4 expected expression
+#if
+x {}
+
+// Should output `something`.
+// Error: 6 expected block
+#if x something
+
+// Should output `A thing.`
+// Error: 19 expected block
+A#if false {} else thing
+
+#if a []else [b]
+#if a [] else [b]
+#if a {} else [b]
diff --git a/tests/suite/scripting/import.typ b/tests/suite/scripting/import.typ
new file mode 100644
index 00000000..820f81d6
--- /dev/null
+++ b/tests/suite/scripting/import.typ
@@ -0,0 +1,334 @@
+// Test function and module imports.
+
+--- import-basic ---
+// Test basic syntax and semantics.
+
+// Test that this will be overwritten.
+#let value = [foo]
+
+// Import multiple things.
+#import "module.typ": fn, value
+#fn[Like and Subscribe!]
+#value
+
+// Should output `bye`.
+// Stop at semicolon.
+#import "module.typ": a, c;bye
+
+--- import-item-markup ---
+// An item import.
+#import "module.typ": item
+#test(item(1, 2), 3)
+
+--- import-item-in-code ---
+// Code mode
+#{
+ import "module.typ": b
+ test(b, 1)
+}
+
+--- import-wildcard-in-markup ---
+// A wildcard import.
+#import "module.typ": *
+
+// It exists now!
+#test(d, 3)
+
+--- import-item-renamed ---
+// A renamed item import.
+#import "module.typ": item as something
+#test(something(1, 2), 3)
+
+--- import-items-renamed-mixed ---
+// Mixing renamed and not renamed items.
+#import "module.typ": fn, b as val, item as other
+#test(val, 1)
+#test(other(1, 2), 3)
+
+--- import-from-function-scope ---
+// Test importing from function scopes.
+
+#import enum: item
+#import assert.with(true): *
+
+#enum(
+ item(1)[First],
+ item(5)[Fifth]
+)
+#eq(10, 10)
+#ne(5, 6)
+
+--- import-from-function-scope-item-renamed ---
+// Test renaming items imported from function scopes.
+#import assert: eq as aseq
+#aseq(10, 10)
+
+--- import-from-file-bare ---
+// A module import without items.
+#import "module.typ"
+#test(module.b, 1)
+#test(module.item(1, 2), 3)
+#test(module.push(2), 3)
+
+--- import-from-file-renamed ---
+// A renamed module import without items.
+#import "module.typ" as other
+#test(other.b, 1)
+#test(other.item(1, 2), 3)
+#test(other.push(2), 3)
+
+--- import-from-file-items-renamed-mixed ---
+// Mixing renamed module and items.
+#import "module.typ" as newname: b as newval, item
+#test(newname.b, 1)
+#test(newval, 1)
+#test(item(1, 2), 3)
+#test(newname.item(1, 2), 3)
+
+--- import-from-function-scope-renamed ---
+// Renamed module import with function scopes.
+#import enum as othernum
+#test(enum, othernum)
+
+--- import-from-function-scope-renamed-twice ---
+// Mixing renamed module import from function with renamed item import.
+#import assert as asrt
+#import asrt: ne as asne
+#asne(1, 2)
+
+--- import-module-item-name-mutating ---
+// Edge case for module access that isn't fixed.
+#import "module.typ"
+
+// Works because the method name isn't categorized as mutating.
+#test((module,).at(0).item(1, 2), 3)
+
+// Doesn't work because of mutating name.
+// Error: 2-11 cannot mutate a temporary value
+#(module,).at(0).push()
+
+--- import-no-whitespace ---
+// Who needs whitespace anyways?
+#import"module.typ":*
+
+--- import-trailing-comma ---
+// Allow the trailing comma.
+#import "module.typ": a, c,
+
+--- import-source-field-access ---
+// Usual importing syntax also works for function scopes
+#let d = (e: enum)
+#import d.e
+#import d.e as renamed
+#import d.e: item
+#item(2)[a]
+
+--- import-item-rename-unnecessary ---
+// Warning: 23-27 unnecessary import rename to same name
+#import enum: item as item
+
+--- import-rename-unnecessary ---
+// Warning: 17-21 unnecessary import rename to same name
+#import enum as enum
+
+--- import-rename-unnecessary-mixed ---
+// Warning: 17-21 unnecessary import rename to same name
+#import enum as enum: item
+
+// Warning: 17-21 unnecessary import rename to same name
+// Warning: 31-35 unnecessary import rename to same name
+#import enum as enum: item as item
+
+--- import-item-rename-unnecessary-but-ok ---
+// No warning on a case that isn't obviously pathological
+#import "module.typ" as module
+
+--- import-from-closure-invalid ---
+// Can't import from closures.
+#let f(x) = x
+// Error: 9-10 cannot import from user-defined functions
+#import f: x
+
+--- import-from-closure-renamed-invalid ---
+// Can't import from closures, despite renaming.
+#let f(x) = x
+// Error: 9-10 cannot import from user-defined functions
+#import f as g
+
+--- import-from-with-closure-invalid ---
+// Can't import from closures, despite modifiers.
+#let f(x) = x
+// Error: 9-18 cannot import from user-defined functions
+#import f.with(5): x
+
+--- import-from-with-closure-literal-invalid ---
+// Error: 9-18 cannot import from user-defined functions
+#import () => {5}: x
+
+--- import-from-int-invalid ---
+// Error: 9-10 expected path, module, function, or type, found integer
+#import 5: something
+
+--- import-from-int-renamed-invalid ---
+// Error: 9-10 expected path, module, function, or type, found integer
+#import 5 as x
+
+--- import-from-string-invalid ---
+// Error: 9-11 failed to load file (is a directory)
+#import "": name
+
+--- import-from-string-renamed-invalid ---
+// Error: 9-11 failed to load file (is a directory)
+#import "" as x
+
+--- import-file-not-found-invalid ---
+// Error: 9-20 file not found (searched at tests/suite/scripting/lib/0.2.1)
+#import "lib/0.2.1"
+
+--- import-file-not-found-renamed-invalid ---
+// Error: 9-20 file not found (searched at tests/suite/scripting/lib/0.2.1)
+#import "lib/0.2.1" as x
+
+--- import-file-not-valid-utf-8 ---
+// Some non-text stuff.
+// Error: 9-35 file is not valid utf-8
+#import "/assets/images/rhino.png"
+
+--- import-item-not-found ---
+// Unresolved import.
+// Error: 23-35 unresolved import
+#import "module.typ": non_existing
+
+--- import-cyclic ---
+// Cyclic import of this very file.
+// Error: 9-23 cyclic import
+#import "./import.typ"
+
+--- import-cyclic-in-other-file ---
+// Cyclic import in other file.
+#import "./modules/cycle1.typ": *
+
+This is never reached.
+
+--- import-renamed-old-name ---
+// Renaming does not import the old name (without items).
+#import "./modules/chap1.typ" as something
+#test(something.name, "Klaus")
+// Error: 7-12 unknown variable: chap1
+#test(chap1.name, "Klaus")
+
+--- import-items-renamed-old-name ---
+// Renaming does not import the old name (with items).
+#import "./modules/chap1.typ" as something: name as other
+#test(other, "Klaus")
+#test(something.name, "Klaus")
+// Error: 7-12 unknown variable: chap1
+#test(chap1.b, "Klaus")
+
+--- import-incomplete ---
+// Error: 8 expected expression
+#import
+
+--- import-item-string-invalid ---
+// Error: 26-29 unexpected string
+#import "module.typ": a, "b", c
+
+--- import-bad-token ---
+// Error: 23-24 unexpected equals sign
+#import "module.typ": =
+
+--- import-duplicate-comma ---
+// An additional trailing comma.
+// Error: 31-32 unexpected comma
+#import "module.typ": a, b, c,,
+
+--- import-no-colon ---
+// Error: 2:2 expected semicolon or line break
+#import "module.typ
+"stuff
+
+--- import-bad-token-star ---
+// A star in the list.
+// Error: 26-27 unexpected star
+#import "module.typ": a, *, b
+
+--- import-item-after-star ---
+// An item after a star.
+// Error: 24 expected semicolon or line break
+#import "module.typ": *, a
+
+--- import-bad-colon-in-items ---
+// Error: 14-15 unexpected colon
+// Error: 16-17 unexpected integer
+#import "": a: 1
+
+--- import-missing-comma ---
+// Error: 14 expected comma
+#import "": a b
+
+--- import-from-package-bare ---
+// Test import without items.
+#import "@test/adder:0.1.0"
+#test(adder.add(2, 8), 10)
+
+--- import-from-package-items ---
+// Test import with items.
+#import "@test/adder:0.1.0": add
+#test(add(2, 8), 10)
+
+--- import-from-package-required-compiler-version ---
+// Test too high required compiler version.
+// Error: 9-29 package requires typst 1.0.0 or newer (current version is VERSION)
+#import "@test/future:0.1.0": future
+
+--- import-from-package-namespace-invalid-1 ---
+// Error: 9-13 `@` is not a valid package namespace
+#import "@@": *
+
+--- import-from-package-name-missing-1 ---
+// Error: 9-16 package specification is missing name
+#import "@heya": *
+
+--- import-from-package-namespace-invalid-2 ---
+// Error: 9-15 `123` is not a valid package namespace
+#import "@123": *
+
+--- import-from-package-name-missing-2 ---
+// Error: 9-17 package specification is missing name
+#import "@test/": *
+
+--- import-from-package-version-missing-1 ---
+// Error: 9-22 package specification is missing version
+#import "@test/mypkg": *
+
+--- import-from-package-name-invalid ---
+// Error: 9-20 `$$$` is not a valid package name
+#import "@test/$$$": *
+
+--- import-from-package-version-missing-2 ---
+// Error: 9-23 package specification is missing version
+#import "@test/mypkg:": *
+
+--- import-from-package-version-missing-minor ---
+// Error: 9-24 version number is missing minor version
+#import "@test/mypkg:0": *
+
+--- import-from-package-version-major-invalid-1 ---
+// Error: 9-29 `latest` is not a valid major version
+#import "@test/mypkg:latest": *
+
+--- import-from-package-version-major-invalid-2 ---
+// Error: 9-29 `-3` is not a valid major version
+#import "@test/mypkg:-3.0.0": *
+
+--- import-from-package-version-missing-patch-1 ---
+// Error: 9-26 version number is missing patch version
+#import "@test/mypkg:0.3": *
+
+--- import-from-package-version-missing-patch-2 ---
+// Error: 9-27 version number is missing patch version
+#import "@test/mypkg:0.3.": *
+
+--- import-from-file-package-lookalike ---
+// Error: 9-28 file not found (searched at tests/suite/scripting/#test/mypkg:1.0.0)
+#import "#test/mypkg:1.0.0": *
diff --git a/tests/suite/scripting/include.typ b/tests/suite/scripting/include.typ
new file mode 100644
index 00000000..e4da6d19
--- /dev/null
+++ b/tests/suite/scripting/include.typ
@@ -0,0 +1,32 @@
+// Test module includes.
+
+--- include-file ---
+#set page(width: 200pt)
+
+= Document
+
+// Include a file
+#include "modules/chap1.typ"
+
+// Expression as a file name.
+#let chap2 = include "modu" + "les/chap" + "2.typ"
+
+-- _Intermission_ --
+#chap2
+
+--- include-file-not-found ---
+#{
+ // Error: 19-38 file not found (searched at tests/suite/scripting/modules/chap3.typ)
+ let x = include "modules/chap3.typ"
+}
+
+--- include-no-bindings ---
+#include "modules/chap1.typ"
+
+// The variables of the file should not appear in this scope.
+// Error: 2-6 unknown variable: name
+#name
+
+--- include-semicolon-or-linebreak ---
+// Error: 18 expected semicolon or line break
+#include "hi.typ" Hi
diff --git a/tests/suite/scripting/let.typ b/tests/suite/scripting/let.typ
new file mode 100644
index 00000000..2604c4ea
--- /dev/null
+++ b/tests/suite/scripting/let.typ
@@ -0,0 +1,143 @@
+// Test let bindings.
+
+--- let-basic ---
+// Automatically initialized with none.
+#let x
+#test(x, none)
+
+// Manually initialized with one.
+#let z = 1
+#test(z, 1)
+
+// Syntax sugar for function definitions.
+#let fill = conifer
+#let f(body) = rect(width: 2cm, fill: fill, inset: 5pt, body)
+#f[Hi!]
+
+--- let-termination ---
+// Termination.
+
+// Terminated by line break.
+#let v1 = 1
+One
+
+// Terminated by semicolon.
+#let v2 = 2; Two
+
+// Terminated by semicolon and line break.
+#let v3 = 3;
+Three
+
+#test(v1, 1)
+#test(v2, 2)
+#test(v3, 3)
+
+--- let-valid-idents ---
+// Test what constitutes a valid Typst identifier.
+#let name = 1
+#test(name, 1)
+#let name_ = 1
+#test(name_, 1)
+#let name-2 = 1
+#test(name-2, 1)
+#let name_2 = 1
+#test(name_2, 1)
+#let __name = 1
+#test(__name, 1)
+#let ůñıćóðė = 1
+#test(ůñıćóðė, 1)
+
+--- let-binding-keyword-in-markup ---
+// Error: 6-8 expected pattern, found keyword `as`
+// Hint: 6-8 keyword `as` is not allowed as an identifier; try `as_` instead
+#let as = 1 + 2
+
+--- let-binding-keyword-in-code ---
+#{
+ // Error: 7-9 expected pattern, found keyword `as`
+ // Hint: 7-9 keyword `as` is not allowed as an identifier; try `as_` instead
+ let as = 10
+}
+
+--- let-ident-parenthesized ---
+// Test parenthesised assignments.
+#let (a) = (1, 2)
+
+--- let-incomplete ---
+// Error: 5 expected pattern
+#let
+
+// Error: 6 expected pattern
+#{let}
+
+// Error: 6-9 expected pattern, found string
+#let "v"
+
+// Error: 7 expected semicolon or line break
+#let v 1
+
+// Error: 9 expected expression
+#let v =
+
+// Error: 6-9 expected pattern, found string
+#let "v" = 1
+
+// Terminated because expression ends.
+// Error: 12 expected semicolon or line break
+#let v4 = 4 Four
+
+// Terminated by semicolon even though we are in a paren group.
+// Error: 18 expected expression
+// Error: 11-12 unclosed delimiter
+#let v5 = (1, 2 + ; Five
+
+// Error: 9-13 expected pattern, found boolean
+#let (..true) = false
+
+--- underscore-invalid ---
+#let _ = 4
+
+#for _ in range(2) []
+
+// Error: 2-3 unexpected underscore
+#_
+
+// Error: 8-9 expected expression, found underscore
+#lorem(_)
+
+// Error: 3-4 expected expression, found underscore
+#(_,)
+
+// Error: 3-4 expected expression, found underscore
+#{_}
+
+// Error: 8-9 expected expression, found underscore
+#{ 1 + _ }
+
+--- let-function-incomplete ---
+// Error: 13 expected equals sign
+#let func(x)
+
+// Error: 15 expected expression
+#let func(x) =
+
+--- let-function-parenthesized ---
+// This is not yet parsed in the ideal way.
+// Error: 12 expected equals sign
+#let (func)(x)
+
+--- let-function-parenthesized-with-init ---
+// These errors aren't great.
+// Error: 12 expected equals sign
+// Error: 15-15 expected semicolon or line break
+#let (func)(x) = 3
+
+--- let-with-no-init-group ---
+// This was unintentionally allowed ...
+// Error: 9 expected equals sign
+#let (a)
+
+--- let-with-no-init-destructuring ---
+// ... where this wasn't.
+// Error: 12 expected equals sign
+#let (a, b)
diff --git a/tests/suite/scripting/loop.typ b/tests/suite/scripting/loop.typ
new file mode 100644
index 00000000..689c1c93
--- /dev/null
+++ b/tests/suite/scripting/loop.typ
@@ -0,0 +1,142 @@
+// Test break and continue in loops.
+
+--- loop-break-basic ---
+// Test break.
+
+#let var = 0
+#let error = false
+
+#for i in range(10) {
+ var += i
+ if i > 5 {
+ break
+ error = true
+ }
+}
+
+#test(var, 21)
+#test(error, false)
+
+--- loop-break-join-basic ---
+// Test joining with break.
+
+#let i = 0
+#let x = while true {
+ i += 1
+ str(i)
+ if i >= 5 {
+ "."
+ break
+ }
+}
+
+#test(x, "12345.")
+
+--- loop-continue-basic ---
+// Test continue.
+
+#let i = 0
+#let x = 0
+
+#while x < 8 {
+ i += 1
+ if calc.rem(i, 3) == 0 {
+ continue
+ }
+ x += i
+}
+
+// If continue did not work, this would equal 10.
+#test(x, 12)
+
+--- loop-continue-join ---
+// Test joining with continue.
+
+#let x = for i in range(5) {
+ "a"
+ if calc.rem(i, 3) == 0 {
+ "_"
+ continue
+ }
+ str(i)
+}
+
+#test(x, "a_a1a2a_a4")
+
+--- loop-break-outside-of-loop ---
+// Test break outside of loop.
+#let f() = {
+ // Error: 3-8 cannot break outside of loop
+ break
+}
+
+#for i in range(1) {
+ f()
+}
+
+--- loop-break-join-in-last-arg ---
+// Test break in function call.
+#let identity(x) = x
+#let out = for i in range(5) {
+ "A"
+ identity({
+ "B"
+ break
+ })
+ "C"
+}
+
+#test(out, "AB")
+
+--- loop-continue-outside-of-loop-in-block ---
+// Test continue outside of loop.
+
+// Error: 12-20 cannot continue outside of loop
+#let x = { continue }
+
+--- loop-continue-outside-of-loop-in-markup ---
+// Error: 2-10 cannot continue outside of loop
+#continue
+
+--- loop-break-join-in-nested-blocks ---
+// Should output `Hello World 🌎`.
+#for _ in range(10) {
+ [Hello ]
+ [World #{
+ [🌎]
+ break
+ }]
+}
+
+--- loop-break-join-set-and-show ---
+// Should output `Some` in red, `Some` in blue and `Last` in green.
+// Everything should be in smallcaps.
+#for color in (red, blue, green, yellow) [
+ #set text(font: "Roboto")
+ #show: it => text(fill: color, it)
+ #smallcaps(if color != green [
+ Some
+ ] else [
+ Last
+ #break
+ ])
+]
+
+--- loop-break-join-in-set-rule-args ---
+// Test break in set rule.
+// Should output `Hi` in blue.
+#for i in range(10) {
+ [Hello]
+ set text(blue, ..break)
+ [Not happening]
+}
+
+--- loop-break-join-in-first-arg ---
+// Test second block during break flow.
+
+#for i in range(10) {
+ table(
+ { [A]; break },
+ for _ in range(3) [B]
+ )
+}
diff --git a/tests/suite/scripting/methods.typ b/tests/suite/scripting/methods.typ
new file mode 100644
index 00000000..5deea2cf
--- /dev/null
+++ b/tests/suite/scripting/methods.typ
@@ -0,0 +1,51 @@
+// Test method calls.
+
+--- method-whitespace ---
+// Test whitespace around dot.
+#test( "Hi there" . split() , ("Hi", "there"))
+
+--- method-mutating ---
+// Test mutating indexed value.
+#{
+ let matrix = (((1,), (2,)), ((3,), (4,)))
+ matrix.at(1).at(0).push(5)
+ test(matrix, (((1,), (2,)), ((3, 5), (4,))))
+}
+
+--- method-multiline ---
+// Test multiline chain in code block.
+#{
+ let rewritten = "Hello. This is a sentence. And one more."
+ .split(".")
+ .map(s => s.trim())
+ .filter(s => s != "")
+ .map(s => s + "!")
+ .join("\n ")
+
+ test(rewritten, "Hello!\n This is a sentence!\n And one more!")
+}
+
+--- method-unknown ---
+// Error: 2:10-2:13 type array has no method `fun`
+#let numbers = ()
+#numbers.fun()
+
+--- method-unknown-but-field-exists ---
+// Error: 2:4-2:10 type content has no method `stroke`
+// Hint: 2:4-2:10 did you mean to access the field `stroke`?
+#let l = line(stroke: red)
+#l.stroke()
+
+--- method-mutate-on-temporary ---
+// Error: 2:2-2:43 cannot mutate a temporary value
+#let numbers = (1, 2, 3)
+#numbers.map(v => v / 2).sorted().map(str).remove(4)
+
+--- assign-to-method-invalid ---
+// Error: 2:3-2:19 cannot mutate a temporary value
+#let numbers = (1, 2, 3)
+#(numbers.sorted() = 1)
+
+--- method-mutate-on-std-constant ---
+// Error: 2-5 cannot mutate a constant: box
+#box.push(1)
diff --git a/tests/suite/scripting/module.typ b/tests/suite/scripting/module.typ
new file mode 100644
index 00000000..8a67d225
--- /dev/null
+++ b/tests/suite/scripting/module.typ
@@ -0,0 +1,13 @@
+// SKIP
+// A file to import in import / include tests.
+
+#let a
+#let b = 1
+#let c = 2
+#let d = 3
+#let value = [hi]
+#let item(a, b) = a + b
+#let push(a) = a + 1
+#let fn = rect.with(fill: conifer, inset: 5pt)
+
+Some _includable_ text.
diff --git a/tests/suite/scripting/modules/chap1.typ b/tests/suite/scripting/modules/chap1.typ
new file mode 100644
index 00000000..13d0acf8
--- /dev/null
+++ b/tests/suite/scripting/modules/chap1.typ
@@ -0,0 +1,8 @@
+// SKIP
+#let name = "Klaus"
+
+== Chapter 1
+#name stood in a field of wheat. There was nothing of particular interest about
+the field #name just casually surveyed for any paths on which the corn would not
+totally ruin his semi-new outdorsy jacket but then again, most of us spend
+considerable time in non-descript environments.
diff --git a/tests/suite/scripting/modules/chap2.typ b/tests/suite/scripting/modules/chap2.typ
new file mode 100644
index 00000000..9c9d12d7
--- /dev/null
+++ b/tests/suite/scripting/modules/chap2.typ
@@ -0,0 +1,10 @@
+// SKIP
+#let name = "Klaus"
+
+== Chapter 2
+Their motivations, however, were pretty descript, so to speak. #name had not yet
+conceptualized their consequences, but that should change pretty quickly. #name
+approached the center of the field and picked up a 4-foot long disk made from
+what could only be cow manure. The hair on the back of #name' neck bristled as
+he stared at the unusual sight. After studying the object for a while, he
+promptly popped the question, "How much?"
diff --git a/tests/suite/scripting/modules/cycle1.typ b/tests/suite/scripting/modules/cycle1.typ
new file mode 100644
index 00000000..0f924ac7
--- /dev/null
+++ b/tests/suite/scripting/modules/cycle1.typ
@@ -0,0 +1,5 @@
+// SKIP
+#import "cycle2.typ": *
+#let inaccessible = "wow"
+
+This is the first element of an import cycle.
diff --git a/tests/suite/scripting/modules/cycle2.typ b/tests/suite/scripting/modules/cycle2.typ
new file mode 100644
index 00000000..69eb2033
--- /dev/null
+++ b/tests/suite/scripting/modules/cycle2.typ
@@ -0,0 +1,5 @@
+// SKIP
+#import "cycle1.typ": *
+#let val = "much cycle"
+
+This is the second element of an import cycle.
diff --git a/tests/suite/scripting/ops.typ b/tests/suite/scripting/ops.typ
new file mode 100644
index 00000000..0f13d212
--- /dev/null
+++ b/tests/suite/scripting/ops.typ
@@ -0,0 +1,465 @@
+// Test binary expressions.
+
+--- ops-add-content ---
+// Test adding content.
+#([*Hello* ] + [world!])
+
+--- ops-unary-basic ---
+// Test math operators.
+
+// Test plus and minus.
+#for v in (1, 3.14, 12pt, 45deg, 90%, 13% + 10pt, 6.3fr) {
+ // Test plus.
+ test(+v, v)
+
+ // Test minus.
+ test(-v, -1 * v)
+ test(--v, v)
+
+ // Test combination.
+ test(-++ --v, -v)
+}
+
+#test(-(4 + 2), 6-12)
+
+// Addition.
+#test(2 + 4, 6)
+#test("a" + "b", "ab")
+#test("a" + if false { "b" }, "a")
+#test("a" + if true { "b" }, "ab")
+#test(13 * "a" + "bbbbbb", "aaaaaaaaaaaaabbbbbb")
+#test((1, 2) + (3, 4), (1, 2, 3, 4))
+#test((a: 1) + (b: 2, c: 3), (a: 1, b: 2, c: 3))
+
+--- ops-add-too-large ---
+// Error: 3-26 value is too large
+#(9223372036854775807 + 1)
+
+--- ops-binary-basic ---
+// Subtraction.
+#test(1-4, 3*-1)
+#test(4cm - 2cm, 2cm)
+#test(1e+2-1e-2, 99.99)
+
+// Multiplication.
+#test(2 * 4, 8)
+
+// Division.
+#test(12pt/.4, 30pt)
+#test(7 / 2, 3.5)
+
+// Combination.
+#test(3-4 * 5 < -10, true)
+#test({ let x; x = 1 + 4*5 >= 21 and { x = "a"; x + "b" == "ab" }; x }, true)
+
+// With block.
+#test(if true {
+ 1
+} + 2, 3)
+
+// Mathematical identities.
+#let nums = (
+ 1, 3.14,
+ 12pt, 3em, 12pt + 3em,
+ 45deg,
+ 90%,
+ 13% + 10pt, 5% + 1em + 3pt,
+ 2.3fr,
+)
+
+#for v in nums {
+ // Test plus and minus.
+ test(v + v - v, v)
+ test(v - v - v, -v)
+
+ // Test plus/minus and multiplication.
+ test(v - v, 0 * v)
+ test(v + v, 2 * v)
+
+ // Integer addition does not give a float.
+ if type(v) != int {
+ test(v + v, 2.0 * v)
+ }
+
+ if type(v) != relative and ("pt" not in repr(v) or "em" not in repr(v)) {
+ test(v / v, 1.0)
+ }
+}
+
+// Make sure length, ratio and relative length
+// - can all be added to / subtracted from each other,
+// - multiplied with integers and floats,
+// - divided by integers and floats.
+#let dims = (10pt, 1em, 10pt + 1em, 30%, 50% + 3cm, 40% + 2em + 1cm)
+#for a in dims {
+ for b in dims {
+ test(type(a + b), type(a - b))
+ }
+
+ for b in (7, 3.14) {
+ test(type(a * b), type(a))
+ test(type(b * a), type(a))
+ test(type(a / b), type(a))
+ }
+}
+
+// Test division of different numeric types with zero components.
+#for a in (0pt, 0em, 0%) {
+ for b in (10pt, 10em, 10%) {
+ test((2 * b) / b, 2)
+ test((a + b * 2) / b, 2)
+ test(b / (b * 2 + a), 0.5)
+ }
+}
+
+--- ops-multiply-inf-with-length ---
+// Test that multiplying infinite numbers by certain units does not crash.
+#(float("inf") * 1pt)
+#(float("inf") * 1em)
+#(float("inf") * (1pt + 1em))
+
+--- ops-attempt-nan-length ---
+// Test that trying to produce a NaN scalar (such as in lengths) does not crash.
+#let infpt = float("inf") * 1pt
+#test(infpt - infpt, 0pt)
+#test(infpt + (-infpt), 0pt)
+// TODO: this result is surprising
+#test(infpt / float("inf"), 0pt)
+
+--- ops-unary-bool ---
+// Test boolean operators.
+
+// Test not.
+#test(not true, false)
+#test(not false, true)
+
+// And.
+#test(false and false, false)
+#test(false and true, false)
+#test(true and false, false)
+#test(true and true, true)
+
+// Or.
+#test(false or false, false)
+#test(false or true, true)
+#test(true or false, true)
+#test(true or true, true)
+
+// Short-circuiting.
+#test(false and dont-care, false)
+#test(true or dont-care, true)
+
+--- ops-equality ---
+// Test equality operators.
+
+// Most things compare by value.
+#test(1 == "hi", false)
+#test(1 == 1.0, true)
+#test(30% == 30% + 0cm, true)
+#test(1in == 0% + 72pt, true)
+#test(30% == 30% + 1cm, false)
+#test("ab" == "a" + "b", true)
+#test(() == (1,), false)
+#test((1, 2, 3) == (1, 2.0) + (3,), true)
+#test((:) == (a: 1), false)
+#test((a: 2 - 1.0, b: 2) == (b: 2, a: 1), true)
+#test("a" != "a", false)
+
+// Functions compare by identity.
+#test(test == test, true)
+#test((() => {}) == (() => {}), false)
+
+// Content compares field by field.
+#let t = [a]
+#test(t == t, true)
+#test([] == [], true)
+#test([a] == [a], true)
+#test(grid[a] == grid[a], true)
+#test(grid[a] == grid[b], false)
+
+--- ops-compare ---
+// Test comparison operators.
+
+#test(13 * 3 < 14 * 4, true)
+#test(5 < 10, true)
+#test(5 > 5, false)
+#test(5 <= 5, true)
+#test(5 <= 4, false)
+#test(45deg < 1rad, true)
+#test(10% < 20%, true)
+#test(50% < 40% + 0pt, false)
+#test(40% + 0pt < 50% + 0pt, true)
+#test(1em < 2em, true)
+#test((0, 1, 2, 4) < (0, 1, 2, 5), true)
+#test((0, 1, 2, 4) < (0, 1, 2, 3), false)
+#test((0, 1, 2, 3.3) > (0, 1, 2, 4), false)
+#test((0, 1, 2) < (0, 1, 2, 3), true)
+#test((0, 1, "b") > (0, 1, "a", 3), true)
+#test((0, 1.1, 3) >= (0, 1.1, 3), true)
+#test((0, 1, datetime(day: 1, month: 12, year: 2023)) <= (0, 1, datetime(day: 1, month: 12, year: 2023), 3), true)
+#test(("a", 23, 40, "b") > ("a", 23, 40), true)
+#test(() <= (), true)
+#test(() >= (), true)
+#test(() <= (1,), true)
+#test((1,) <= (), false)
+
+--- ops-in ---
+// Test `in` operator.
+#test("hi" in "worship", true)
+#test("hi" in ("we", "hi", "bye"), true)
+#test("Hey" in "abHeyCd", true)
+#test("Hey" in "abheyCd", false)
+#test(5 in range(10), true)
+#test(12 in range(10), false)
+#test("" in (), false)
+#test("key" in (key: "value"), true)
+#test("value" in (key: "value"), false)
+#test("Hey" not in "abheyCd", true)
+#test("a" not
+/* fun comment? */ in "abc", false)
+
+--- ops-not-trailing ---
+// Error: 10 expected keyword `in`
+#("a" not)
+
+--- func-with ---
+// Test `with` method.
+
+// Apply positional arguments.
+#let add(x, y) = x + y
+#test(add.with(2)(3), 5)
+#test(add.with(2, 3)(), 5)
+#test(add.with(2).with(3)(), 5)
+#test((add.with(2))(4), 6)
+#test((add.with(2).with(3))(), 5)
+
+// Make sure that named arguments are overridable.
+#let inc(x, y: 1) = x + y
+#test(inc(1), 2)
+
+#let inc2 = inc.with(y: 2)
+#test(inc2(2), 4)
+#test(inc2(2, y: 4), 6)
+
+// Apply arguments to an argument sink.
+#let times(..sink) = {
+ let res = sink.pos().product()
+ if sink.named().at("negate", default: false) { res *= -1 }
+ res
+}
+#test((times.with(2, negate: true).with(5))(), -10)
+#test((times.with(2).with(5).with(negate: true))(), -10)
+#test((times.with(2).with(5, negate: true))(), -10)
+#test((times.with(2).with(negate: true))(5), -10)
+
+--- ops-precedence-basic ---
+// Multiplication binds stronger than addition.
+#test(1+2*-3, -5)
+
+// Subtraction binds stronger than comparison.
+#test(3 == 5 - 2, true)
+
+// Boolean operations bind stronger than '=='.
+#test("a" == "a" and 2 < 3, true)
+#test(not "b" == "b", false)
+
+--- ops-precedence-boolean-ops ---
+// Assignment binds stronger than boolean operations.
+// Error: 2:3-2:8 cannot mutate a temporary value
+#let x = false
+#(not x = "a")
+
+--- ops-precedence-unary ---
+// Precedence doesn't matter for chained unary operators.
+// Error: 3-12 cannot apply '-' to boolean
+#(-not true)
+
+--- ops-precedence-not-in ---
+// Not in handles precedence.
+#test(-1 not in (1, 2, 3), true)
+
+--- ops-precedence-parentheses ---
+// Parentheses override precedence.
+#test((1), 1)
+#test((1+2)*-3, -9)
+
+// Error: 8-9 unclosed delimiter
+#test({(1 + 1}, 2)
+
+--- ops-associativity-left ---
+// Math operators are left-associative.
+#test(10 / 2 / 2 == (10 / 2) / 2, true)
+#test(10 / 2 / 2 == 10 / (2 / 2), false)
+#test(1 / 2 * 3, 1.5)
+
+--- ops-associativity-right ---
+// Assignment is right-associative.
+#{
+ let x = 1
+ let y = 2
+ x = y = "ok"
+ test(x, none)
+ test(y, "ok")
+}
+
+--- ops-unary-minus-missing-expr ---
+// Error: 4 expected expression
+#(-)
+
+--- ops-add-missing-rhs ---
+// Error: 10 expected expression
+#test({1+}, 1)
+
+--- ops-mul-missing-rhs ---
+// Error: 10 expected expression
+#test({2*}, 2)
+
+--- ops-unary-plus-on-content ---
+// Error: 3-13 cannot apply unary '+' to content
+#(+([] + []))
+
+--- ops-unary-plus-on-string ---
+// Error: 3-6 cannot apply '-' to string
+#(-"")
+
+--- ops-not-on-array ---
+// Error: 3-9 cannot apply 'not' to array
+#(not ())
+
+--- ops-compare-relative-length-and-ratio ---
+// Error: 3-19 cannot compare relative length and ratio
+#(30% + 1pt <= 40%)
+
+--- ops-compare-em-with-abs ---
+// Error: 3-14 cannot compare 1em with 10pt
+#(1em <= 10pt)
+
+--- ops-compare-normal-float-with-nan ---
+// Error: 3-22 cannot compare 2.2 with NaN
+#(2.2 <= float("nan"))
+
+--- ops-compare-int-and-str ---
+// Error: 3-26 cannot compare integer and string
+#((0, 1, 3) > (0, 1, "a"))
+
+--- ops-compare-array-nested-failure ---
+// Error: 3-42 cannot compare 3.5 with NaN
+#((0, "a", 3.5) <= (0, "a", float("nan")))
+
+--- ops-divide-by-zero-float ---
+// Error: 3-12 cannot divide by zero
+#(1.2 / 0.0)
+
+--- ops-divide-by-zero-int ---
+// Error: 3-8 cannot divide by zero
+#(1 / 0)
+
+--- ops-divide-by-zero-angle ---
+// Error: 3-15 cannot divide by zero
+#(15deg / 0deg)
+
+--- ops-binary-arithmetic-error-message ---
+// Special messages for +, -, * and /.
+// Error: 3-10 cannot add integer and string
+#(1 + "2", 40% - 1)
+
+--- add-assign-int-and-str ---
+// Error: 15-23 cannot add integer and string
+#{ let x = 1; x += "2" }
+
+--- ops-divide-ratio-by-length ---
+// Error: 4-13 cannot divide ratio by length
+#( 10% / 5pt )
+
+--- ops-divide-em-by-abs ---
+// Error: 3-12 cannot divide these two lengths
+#(1em / 5pt)
+
+--- ops-divide-relative-length-by-ratio ---
+// Error: 3-19 cannot divide relative length by ratio
+#((10% + 1pt) / 5%)
+
+--- ops-divide-relative-lengths ---
+// Error: 3-28 cannot divide these two relative lengths
+#((10% + 1pt) / (20% + 1pt))
+
+--- ops-subtract-int-from-ratio ---
+// Error: 13-20 cannot subtract integer from ratio
+#((1234567, 40% - 1))
+
+--- ops-multiply-int-with-bool ---
+// Error: 3-11 cannot multiply integer with boolean
+#(2 * true)
+
+--- ops-divide-int-by-length ---
+// Error: 3-11 cannot divide integer by length
+#(3 / 12pt)
+
+--- multiply-negative-int-with-str ---
+// Error: 3-10 number must be at least zero
+#(-1 * "")
+
+--- ops-assign ---
+// Test assignment operators.
+
+#let x = 0
+#(x = 10) #test(x, 10)
+#(x -= 5) #test(x, 5)
+#(x += 1) #test(x, 6)
+#(x *= x) #test(x, 36)
+#(x /= 2.0) #test(x, 18.0)
+#(x = "some") #test(x, "some")
+#(x += "thing") #test(x, "something")
+
+--- ops-assign-unknown-var-lhs ---
+#{
+ // Error: 3-6 unknown variable: a-1
+ // Hint: 3-6 if you meant to use subtraction, try adding spaces around the minus sign
+ a-1 = 2
+}
+
+--- ops-assign-unknown-var-rhs ---
+#{
+ let a = 2
+ a = 1-a
+ a = a -1
+
+ // Error: 7-10 unknown variable: a-1
+ // Hint: 7-10 if you meant to use subtraction, try adding spaces around the minus sign
+ a = a-1
+}
+
+--- ops-assign-unknown-parenthesized-variable ---
+// Error: 4-5 unknown variable: x
+#((x) = "")
+
+--- ops-assign-destructuring-unknown-variable ---
+// Error: 4-5 unknown variable: x
+#((x,) = (1,))
+
+--- ops-assign-to-temporary ---
+// Error: 3-8 cannot mutate a temporary value
+#(1 + 2 += 3)
+
+--- ops-assign-to-invalid-unary-op ---
+// Error: 2:3-2:8 cannot apply 'not' to string
+#let x = "Hey"
+#(not x = "a")
+
+--- ops-assign-to-invalid-binary-op ---
+// Error: 7-8 unknown variable: x
+#(1 + x += 3)
+
+--- ops-assign-unknown-variable ---
+// Error: 3-4 unknown variable: z
+#(z = 1)
+
+--- ops-assign-to-std-constant ---
+// Error: 3-7 cannot mutate a constant: rect
+#(rect = "hi")
+
+--- ops-assign-to-shadowed-std-constant ---
+// Works if we define rect beforehand
+// (since then it doesn't resolve to the standard library version anymore).
+#let rect = ""
+#(rect = "hi")
diff --git a/tests/suite/scripting/params.typ b/tests/suite/scripting/params.typ
new file mode 100644
index 00000000..688124f2
--- /dev/null
+++ b/tests/suite/scripting/params.typ
@@ -0,0 +1,69 @@
+--- param-underscore-missing-argument ---
+// Error: 17-20 missing argument: pattern parameter
+#let f(a: 10) = a() + 1
+#f(a: _ => 5)
+
+--- params-sink-named ---
+// ... but this was.
+#let f(..x) = {}
+#f(arg: 1)
+
+--- params-sink-unnamed ---
+// unnamed spread
+#let f(.., a) = a
+#test(f(1, 2, 3), 3)
+
+// This wasn't allowed before the bug fix ...
+#let f(..) = 2
+#test(f(arg: 1), 2)
+
+--- params-sink-bool-invalid ---
+// Error: 10-14 expected pattern, found boolean
+#let f(..true) = none
+
+--- params-sink-multiple-invalid ---
+// Error: 13-16 only one argument sink is allowed
+#let f(..a, ..b) = none
+
+--- params-sink-at-start ---
+// Spread at beginning.
+#{
+ let f(..a, b) = (a, b)
+ test(repr(f(1)), "((), 1)")
+ test(repr(f(1, 2, 3)), "((1, 2), 3)")
+ test(repr(f(1, 2, 3, 4, 5)), "((1, 2, 3, 4), 5)")
+}
+
+--- params-sink-in-middle ---
+// Spread in the middle.
+#{
+ let f(a, ..b, c) = (a, b, c)
+ test(repr(f(1, 2)), "(1, (), 2)")
+ test(repr(f(1, 2, 3, 4, 5)), "(1, (2, 3, 4), 5)")
+}
+
+--- params-sink-unnamed-empty ---
+// Unnamed sink should just ignore any extra arguments.
+#{
+ let f(a, b: 5, ..) = (a, b)
+ test(f(4), (4, 5))
+ test(f(10, b: 11), (10, 11))
+ test(f(13, 20, b: 12), (13, 12))
+ test(f(15, b: 16, c: 13), (15, 16))
+}
+
+--- params-sink-missing-arguments ---
+#{
+ let f(..a, b, c, d) = none
+
+ // Error: 3-10 missing argument: d
+ f(1, 2)
+}
+
+--- issue-1029-parameter-destructuring ---
+// Test that underscore works in parameter patterns.
+#test((1, 2, 3).zip((1, 2, 3)).map(((_, x)) => x), (1, 2, 3))
+
+--- issue-1351-parameter-dictionary ---
+// Error: 17-22 expected pattern, found string
+#let foo((test: "bar")) = {}
diff --git a/tests/suite/scripting/recursion.typ b/tests/suite/scripting/recursion.typ
new file mode 100644
index 00000000..43fe848e
--- /dev/null
+++ b/tests/suite/scripting/recursion.typ
@@ -0,0 +1,55 @@
+// Test recursive function calls.
+
+--- recursion-named ---
+// Test with named function.
+#let fib(n) = {
+ if n <= 2 {
+ 1
+ } else {
+ fib(n - 1) + fib(n - 2)
+ }
+}
+
+#test(fib(10), 55)
+
+--- recursion-unnamed-invalid ---
+// Test with unnamed function.
+// Error: 17-18 unknown variable: f
+#let f = (n) => f(n - 1)
+#f(10)
+
+--- recursion-named-returns-itself ---
+// Test capturing with named function.
+#let f = 10
+#let f() = f
+#test(type(f()), function)
+
+--- recursion-unnamed-does-not-return-itself ---
+// Test capturing with unnamed function.
+#let f = 10
+#let f = () => f
+#test(type(f()), int)
+
+--- recursion-shadowing ---
+// Test redefinition.
+#let f(x) = "hello"
+#let f(x) = if x != none { f(none) } else { "world" }
+#test(f(1), "world")
+
+--- recursion-maximum-depth ---
+// Error: 15-21 maximum function call depth exceeded
+#let rec(n) = rec(n) + 1
+#rec(1)
+
+--- recursion-via-include-in-layout ---
+// Test cyclic imports during layout.
+// Error: 2-38 maximum show rule depth exceeded
+// Hint: 2-38 check whether the show rule matches its own output
+#layout(_ => include "recursion.typ")
+
+--- recursion-show-math ---
+// Test recursive show rules.
+// Error: 22-25 maximum show rule depth exceeded
+// Hint: 22-25 check whether the show rule matches its own output
+#show math.equation: $x$
+$ x $
diff --git a/tests/suite/scripting/return.typ b/tests/suite/scripting/return.typ
new file mode 100644
index 00000000..63e1c0b9
--- /dev/null
+++ b/tests/suite/scripting/return.typ
@@ -0,0 +1,87 @@
+// Test return out of functions.
+
+--- return-with-value ---
+// Test return with value.
+#let f(x) = {
+ return x + 1
+}
+
+#test(f(1), 2)
+
+--- return-join ---
+// Test return with joining.
+
+#let f(x) = {
+ "a"
+ if x == 0 {
+ return "b"
+ } else if x == 1 {
+ "c"
+ } else {
+ "d"
+ return
+ "e"
+ }
+}
+
+#test(f(0), "b")
+#test(f(1), "ac")
+#test(f(2), "ad")
+
+--- return-in-nested-content-block ---
+// Test return with joining and content.
+
+#let f(text, caption: none) = {
+ text
+ if caption == none [\.#return]
+ [, ]
+ emph(caption)
+ [\.]
+}
+
+#f(caption: [with caption])[My figure]
+
+#f[My other figure]
+
+--- return-outside-of-function ---
+// Test return outside of function.
+
+#for x in range(5) {
+ // Error: 3-9 cannot return outside of function
+ return
+}
+
+--- return-in-first-arg ---
+// Test that the expression is evaluated to the end.
+#let sum(..args) = {
+ let s = 0
+ for v in args.pos() {
+ s += v
+ }
+ s
+}
+
+#let f() = {
+ sum(..return, 1, 2, 3)
+ "nope"
+}
+
+#test(f(), 6)
+
+--- return-in-content-block ---
+// Test value return from content.
+#let x = 3
+#let f() = [
+ Hello 😀
+ #return "nope"
+ World
+]
+
+#test(f(), "nope")
+
+--- return-semicolon-or-linebreak ---
+// Test rejection of extra value
+#let f() = [
+ // Error: 16-16 expected semicolon or line break
+ #return a + b Hello World
+]
diff --git a/tests/suite/scripting/while.typ b/tests/suite/scripting/while.typ
new file mode 100644
index 00000000..5e452a89
--- /dev/null
+++ b/tests/suite/scripting/while.typ
@@ -0,0 +1,59 @@
+// Test while expressions.
+
+--- while-loop-basic ---
+// Should output `2 4 6 8 10`.
+#let i = 0
+#while i < 10 [
+ #(i += 2)
+ #i
+]
+
+// Should output `Hi`.
+#let iter = true
+#while iter {
+ iter = false
+ "Hi."
+}
+
+#while false {
+ dont-care
+}
+
+--- while-loop-expr ---
+// Value of while loops.
+
+#test(while false {}, none)
+
+#let i = 0
+#test(type(while i < 1 [#(i += 1)]), content)
+
+--- while-loop-condition-content-invalid ---
+// Condition must be boolean.
+// Error: 8-14 expected boolean, found content
+#while [nope] [nope]
+
+--- while-loop-condition-always-true ---
+// Error: 8-25 condition is always true
+#while 2 < "hello".len() {}
+
+--- while-loop-limit ---
+// Error: 2:2-2:24 loop seems to be infinite
+#let i = 1
+#while i > 0 { i += 1 }
+
+--- while-loop-incomplete ---
+// Error: 7 expected expression
+#while
+
+// Error: 8 expected expression
+#{while}
+
+// Error: 9 expected block
+#while x
+
+// Error: 7 expected expression
+#while
+x {}
+
+// Error: 9 expected block
+#while x something
diff --git a/tests/suite/styling/fold.typ b/tests/suite/styling/fold.typ
new file mode 100644
index 00000000..26fe991b
--- /dev/null
+++ b/tests/suite/styling/fold.typ
@@ -0,0 +1,19 @@
+--- fold-vec-order-text-features ---
+// Test fold order of vectors.
+#set text(features: (liga: 1))
+#set text(features: (liga: 0))
+fi
+
+--- fold-vec-order-text-decos ---
+#underline(stroke: aqua + 4pt)[
+ #underline[Hello]
+]
+
+--- fold-vec-order-meta ---
+#let c = counter("mycounter")
+#c.update(1)
+#locate(loc => [
+ #c.update(2)
+ #c.at(loc) \
+ Second: #locate(loc => c.at(loc))
+])
diff --git a/tests/suite/styling/set.typ b/tests/suite/styling/set.typ
new file mode 100644
index 00000000..a31cd165
--- /dev/null
+++ b/tests/suite/styling/set.typ
@@ -0,0 +1,96 @@
+// General tests for set.
+
+--- set-instantiation-site ---
+// Test that text is affected by instantiation-site bold.
+#let x = [World]
+Hello *#x*
+
+--- set-instantiation-site-markup ---
+// Test that lists are affected by correct indents.
+#let fruit = [
+ - Apple
+ - Orange
+ #list(body-indent: 20pt)[Pear]
+]
+
+- Fruit
+#[#set list(indent: 10pt)
+ #fruit]
+- No more fruit
+
+--- set-text-override ---
+// Test that that block spacing and text style are respected from
+// the outside, but the more specific fill is respected.
+#set block(spacing: 4pt)
+#set text(style: "italic", fill: eastern)
+#let x = [And the forest #parbreak() lay silent!]
+#text(fill: forest, x)
+
+--- set-scoped-in-code-block ---
+// Test that scoping works as expected.
+#{
+ if true {
+ set text(blue)
+ [Blue ]
+ }
+ [Not blue]
+}
+
+--- closure-path-resolve-in-layout-phase ---
+// Test relative path resolving in layout phase.
+#let choice = ("monkey.svg", "rhino.png", "tiger.jpg")
+#set enum(numbering: n => {
+ let path = "/assets/images/" + choice.at(n - 1)
+ move(dy: -0.15em, image(path, width: 1em, height: 1em))
+})
+
++ Monkey
++ Rhino
++ Tiger
+
+--- set-if ---
+// Test conditional set.
+#show ref: it => {
+ set text(red) if it.target == <unknown>
+ "@" + str(it.target)
+}
+
+@hello from the @unknown
+
+--- set-if-bad-type ---
+// Error: 19-24 expected boolean, found integer
+#set text(red) if 1 + 2
+
+--- set-in-expr ---
+// Error: 12-26 set is only allowed directly in code and content blocks
+#{ let x = set text(blue) }
+
+--- set-vs-construct-1 ---
+// Ensure that constructor styles aren't passed down the tree.
+// The inner list should have no extra indent.
+#set par(leading: 2pt)
+#list(body-indent: 20pt, [First], list[A][B])
+
+--- set-vs-construct-2 ---
+// Ensure that constructor styles win, but not over outer styles.
+// The outer paragraph should be right-aligned,
+// but the B should be center-aligned.
+#set list(marker: [>])
+#list(marker: [--])[
+ #rect(width: 2cm, fill: conifer, inset: 4pt, list[A])
+]
+
+--- set-vs-construct-3 ---
+// The inner rectangle should also be yellow here.
+// (and therefore invisible)
+#[#set rect(fill: yellow);#text(1em, rect(inset: 5pt, rect()))]
+
+--- set-vs-construct-4 ---
+// The inner rectangle should not be yellow here.
+A #box(rect(fill: yellow, inset: 5pt, rect())) B
+
+--- show-set-vs-construct ---
+// The constructor property should still work
+// when there are recursive show rules.
+#show enum: set text(blue)
+#enum(numbering: "(a)", [A], enum[B])
diff --git a/tests/suite/styling/show-set.typ b/tests/suite/styling/show-set.typ
new file mode 100644
index 00000000..ea788c93
--- /dev/null
+++ b/tests/suite/styling/show-set.typ
@@ -0,0 +1,70 @@
+// Test show-set rules.
+
+--- show-set-override ---
+// Test overriding show-set rules.
+#show strong: set text(red)
+Hello *World*
+
+#show strong: set text(blue)
+Hello *World*
+
+--- show-set-on-same-element ---
+// Test show-set rule on the same element.
+#set figure(supplement: [Default])
+#show figure.where(kind: table): set figure(supplement: [Tableau])
+#figure(
+ table(columns: 2)[A][B][C][D],
+ caption: [Four letters],
+)
+
+--- show-set-same-element-and-order ---
+// Test both things at once.
+#show heading: set text(red)
+= Level 1
+== Level 2
+
+#show heading.where(level: 1): set text(blue)
+#show heading.where(level: 1): set text(green)
+#show heading.where(level: 1): set heading(numbering: "(I)")
+= Level 1
+== Level 2
+
+--- show-set-same-element-matched-field ---
+// Test setting the thing we just matched on.
+// This is quite cursed, but it works.
+#set heading(numbering: "(I)")
+#show heading.where(numbering: "(I)"): set heading(numbering: "1.")
+= Heading
+
+--- show-set-same-element-synthesized-matched-field ---
+// Same thing, but even more cursed, because `kind` is synthesized.
+#show figure.where(kind: table): set figure(kind: raw)
+#figure(table[A], caption: [Code])
+
+--- show-set-same-element-matching-interaction ---
+// Test that show-set rules on the same element don't affect each other. This
+// could be implemented, but isn't as of yet.
+#show heading.where(level: 1): set heading(numbering: "(I)")
+#show heading.where(numbering: "(I)"): set text(red)
+= Heading
+
+--- show-set-on-layoutable-element ---
+// Test show-set rules on layoutable element to ensure it is realized
+// even though it implements `LayoutMultiple`.
+#show table: set text(red)
+#pad(table(columns: 4)[A][B][C][D])
+
+--- show-function-order-with-set ---
+// These are both red because in the expanded form, `set text(red)` ends up
+// closer to the content than `set text(blue)`.
+#show strong: it => { set text(red); it }
+Hello *World*
+
+#show strong: it => { set text(blue); it }
+Hello *World*
+
+--- show-function-set-on-it ---
+// This doesn't have an effect. An element is materialized before any show
+// rules run.
+#show heading: it => { set heading(numbering: "(I)"); it }
+= Heading
diff --git a/tests/suite/styling/show-text.typ b/tests/suite/styling/show-text.typ
new file mode 100644
index 00000000..56b659b2
--- /dev/null
+++ b/tests/suite/styling/show-text.typ
@@ -0,0 +1,133 @@
+// Test text replacement show rules.
+
+--- show-text-basic ---
+// Test classic example.
+#set text(font: "Roboto")
+#show "Der Spiegel": smallcaps
+Die Zeitung Der Spiegel existiert.
+
+--- show-text-regex ---
+// Another classic example.
+#show "TeX": [T#h(-0.145em)#box(move(dy: 0.233em)[E])#h(-0.135em)X]
+#show regex("(Lua)?(La)?TeX"): name => box(text(font: "New Computer Modern")[#name])
+
+TeX, LaTeX, LuaTeX and LuaLaTeX!
+
+--- show-text-cyclic ---
+// Test direct cycle.
+#show "Hello": text(red)[Hello]
+Hello World!
+
+--- show-text-cyclic-raw ---
+// Test replacing text with raw text.
+#show "rax": `rax`
+The register rax.
+
+--- show-text-indirectly-cyclic ---
+// Test indirect cycle.
+#show "Good": [Typst!]
+#show "Typst": [Fun!]
+#show "Fun": [Good!]
+
+#set text(ligatures: false)
+Good \
+Fun \
+Typst \
+
+--- show-text-exactly-once ---
+// Test that replacements happen exactly once.
+#show "A": [BB]
+#show "B": [CC]
+AA (8)
+
+--- show-text-regex-word-boundary ---
+// Test caseless match and word boundaries.
+#show regex("(?i)\bworld\b"): [🌍]
+
+Treeworld, the World of worlds, is a world.
+
+--- show-text-empty ---
+// Test there is no crashing on empty strings
+// Error: 1:7-1:9 text selector is empty
+#show "": []
+
+--- show-text-regex-empty ---
+// Error: 1:7-1:16 regex selector is empty
+#show regex(""): [AA]
+
+--- show-text-regex-matches-empty ---
+// Error: 1:7-1:42 regex matches empty text
+#show regex("(VAR_GLOBAL|END_VAR||BOOL)") : []
+
+--- show-text-regex-character-class ---
+// This is a fun one.
+#set par(justify: true)
+#show regex("\S"): letter => box(stroke: 1pt, inset: 2pt, upper(letter))
+#lorem(5)
+
+--- show-text-regex-case-insensitive ---
+// See also: https://github.com/mTvare6/hello-world.rs
+#show regex("(?i)rust"): it => [#it (🚀)]
+Rust is memory-safe and blazingly fast. Let's rewrite everything in rust.
+
+--- show-text-get-text-on-it ---
+// Test accessing the string itself.
+#show "hello": it => it.text.split("").map(upper).join("|")
+Oh, hello there!
+
+--- show-text-in-other-show ---
+// Replace worlds but only in lists.
+#show list: it => [
+ #show "World": [🌎]
+ #it
+]
+
+World
+- World
+
+--- show-text-path-resolving ---
+// Test absolute path in layout phase.
+
+#show "GRAPH": image("/assets/images/graph.png")
+
+The GRAPH has nodes.
+
+--- show-set-text-order-adjacent-1 ---
+#show "He": set text(red)
+#show "ya": set text(blue)
+Heya
+
+--- show-set-text-order-contained-1 ---
+#show "Heya": set text(red)
+#show "ya": set text(blue)
+Heya
+
+--- show-set-text-order-contained-3 ---
+#show "He": set text(red)
+#show "Heya": set text(blue)
+Heya
+
+--- show-set-text-order-overlapping-1 ---
+#show "Heya": set text(red)
+#show "yaho": set text(blue)
+Heyaho
+
+--- show-set-text-order-adjacent-2 ---
+#show "He": set text(red)
+#show "ya": set text(weight: "bold")
+Heya
+
+--- show-set-text-order-contained-2 ---
+#show "Heya": set text(red)
+#show "ya": set text(weight: "bold")
+Heya
+
+--- show-set-text-order-contained-4 ---
+#show "He": set text(red)
+#show "Heya": set text(weight: "bold")
+Heya
+
+--- show-set-text-order-overlapping-2 ---
+#show "Heya": set text(red)
+#show "yaho": set text(weight: "bold")
+Heyaho
diff --git a/tests/suite/styling/show-where.typ b/tests/suite/styling/show-where.typ
new file mode 100644
index 00000000..72dbae69
--- /dev/null
+++ b/tests/suite/styling/show-where.typ
@@ -0,0 +1,89 @@
+--- show-where-optional-field-raw ---
+// Test that where selectors also trigger on set rule fields.
+#show raw.where(block: false): box.with(
+ fill: luma(220),
+ inset: (x: 3pt, y: 0pt),
+ outset: (y: 3pt),
+ radius: 2pt,
+)
+
+This is #raw("fn main() {}") some text.
+
+--- show-where-optional-field-text ---
+// Note: This show rule is horribly inefficient because it triggers for
+// every individual text element. But it should still work.
+#show text.where(lang: "de"): set text(red)
+
+#set text(lang: "es")
+Hola, mundo!
+
+#set text(lang: "de")
+Hallo Welt!
+
+#set text(lang: "en")
+Hello World!
+
+--- show-where-folding-text-size ---
+// Test that folding is taken into account.
+#set text(5pt)
+#set text(2em)
+
+#[
+ #show text.where(size: 2em): set text(blue)
+ 2em not blue
+]
+
+#[
+ #show text.where(size: 10pt): set text(blue)
+ 10pt blue
+]
+
+--- show-where-folding-stroke ---
+// Test again that folding is taken into account.
+#set rect(width: 40pt, height: 10pt)
+#set rect(stroke: blue)
+#set rect(stroke: 2pt)
+
+#{
+ show rect.where(stroke: blue): "Not Triggered"
+ rect()
+}
+#{
+ show rect.where(stroke: 2pt): "Not Triggered"
+ rect()
+}
+#{
+ show rect.where(stroke: 2pt + blue): "Triggered"
+ rect()
+}
+
+--- show-where-resolving-length ---
+// Test that resolving is *not* taken into account.
+#set line(start: (1em, 1em + 2pt))
+
+#{
+ show line.where(start: (1em, 1em + 2pt)): "Triggered"
+ line()
+}
+#{
+ show line.where(start: (10pt, 12pt)): "Not Triggered"
+ line()
+}
+
+
+--- show-where-resolving-hyphenate ---
+// Test again that resolving is *not* taken into account.
+#set text(hyphenate: auto)
+
+#[
+ #show text.where(hyphenate: auto): underline
+ Auto
+]
+#[
+ #show text.where(hyphenate: true): underline
+ True
+]
+#[
+ #show text.where(hyphenate: false): underline
+ False
+]
diff --git a/tests/suite/styling/show.typ b/tests/suite/styling/show.typ
new file mode 100644
index 00000000..aa121bff
--- /dev/null
+++ b/tests/suite/styling/show.typ
@@ -0,0 +1,262 @@
+// Test show rules.
+
+--- show-selector-basic ---
+// Override lists.
+#show list: it => "(" + it.children.map(v => v.body).join(", ") + ")"
+
+- A
+ - B
+ - C
+- D
+- E
+
+--- show-selector-replace-and-show-set ---
+// Test full reset.
+#show heading: [B]
+#show heading: set text(size: 10pt, weight: 400)
+A #[= Heading] C
+
+--- show-selector-discard ---
+// Test full removal.
+#show heading: none
+
+Where is
+= There are no headings around here!
+my heading?
+
+--- show-selector-realistic ---
+// Test integrated example.
+#show heading: it => block({
+ set text(10pt)
+ box(move(dy: -1pt)[📖])
+ h(5pt)
+ if it.level == 1 {
+ underline(text(1.25em, blue, it.body))
+ } else {
+ text(red, it.body)
+ }
+})
+
+= Task 1
+Some text.
+
+== Subtask
+Some more text.
+
+= Task 2
+Another text.
+
+--- show-in-show ---
+// Test set and show in code blocks.
+#show heading: it => {
+ set text(red)
+ show "ding": [🛎]
+ it.body
+}
+
+= Heading
+
+--- show-nested-scopes ---
+// Test that scoping works as expected.
+#{
+ let world = [ World ]
+ show "W": strong
+ world
+ {
+ set text(blue)
+ show: it => {
+ show "o": "Ø"
+ it
+ }
+ world
+ }
+ world
+}
+
+--- show-selector-replace ---
+#show heading: [1234]
+= Heading
+
+--- show-unknown-field ---
+// Error: 25-29 content does not contain field "page"
+#show heading: it => it.page
+= Heading
+
+--- show-text-element-discard ---
+#show text: none
+Hey
+
+--- show-selector-not-an-element-function ---
+// Error: 7-12 only element functions can be used as selectors
+#show upper: it => {}
+
+--- show-bad-replacement-type ---
+// Error: 16-20 expected content or function, found integer
+#show heading: 1234
+= Heading
+
+--- show-bad-selector-type ---
+// Error: 7-10 expected symbol, string, label, function, regex, or selector, found color
+#show red: []
+
+--- show-selector-in-expression ---
+// Error: 7-25 show is only allowed directly in code and content blocks
+#(1 + show heading: none)
+
+--- show-bare-basic ---
+#set page(height: 130pt)
+#set text(0.7em)
+
+#align(center)[
+ #text(1.3em)[*Essay on typography*] \
+ T. Ypst
+]
+
+#show: columns.with(2)
+Great typography is at the essence of great storytelling. It is the medium that
+transports meaning from parchment to reader, the wave that sparks a flame
+in booklovers and the great fulfiller of human need.
+
+--- show-bare-content-block ---
+// Test bare show in content block.
+A #[_B #show: c => [*#c*]; C_] D
+
+--- show-bare-vs-set-text ---
+// Test style precedence.
+#set text(fill: eastern, size: 1.5em)
+#show: text.with(fill: forest)
+Forest
+
+--- show-bare-replace-with-content ---
+#show: [Shown]
+Ignored
+
+--- show-bare-in-expression ---
+// Error: 4-19 show is only allowed directly in code and content blocks
+#((show: body => 2) * body)
+
+--- show-bare-missing-colon-closure ---
+// Error: 6 expected colon
+#show it => {}
+
+--- show-bare-missing-colon ---
+// Error: 6 expected colon
+#show it
+
+--- show-recursive-identity ---
+// Test basic identity.
+#show heading: it => it
+= Heading
+
+--- show-multiple-rules ---
+// Test more recipes down the chain.
+#show list: scale.with(origin: left, x: 80%)
+#show heading: []
+#show enum: []
+- Actual
+- Tight
+- List
+= Nope
+
+--- show-rule-in-function ---
+// Test show rule in function.
+#let starwars(body) = {
+ show list: it => block({
+ stack(dir: ltr,
+ text(red, it),
+ 1fr,
+ scale(x: -100%, text(blue, it)),
+ )
+ })
+ body
+}
+
+- Normal list
+
+#starwars[
+ - Star
+ - Wars
+ - List
+]
+
+- Normal list
+
+--- show-recursive-multiple ---
+// Test multi-recursion with nested lists.
+#set rect(inset: 3pt)
+#show list: rect.with(stroke: blue)
+#show list: rect.with(stroke: red)
+#show list: block
+
+- List
+ - Nested
+ - List
+- Recursive!
+
+--- show-selector-where ---
+// Inline code.
+#show raw.where(block: false): box.with(
+ radius: 2pt,
+ outset: (y: 2.5pt),
+ inset: (x: 3pt, y: 0pt),
+ fill: luma(230),
+)
+
+// Code blocks.
+#show raw.where(block: true): block.with(
+ outset: -3pt,
+ inset: 11pt,
+ fill: luma(230),
+ stroke: (left: 1.5pt + luma(180)),
+)
+
+#set page(margin: (top: 12pt))
+#set par(justify: true)
+
+This code tests `code`
+with selectors and justification.
+
+```rs
+code!("it");
+```
+
+You can use the ```rs *const T``` pointer or
+the ```rs &mut T``` reference.
+
+--- show-set-where-override ---
+#show heading: set text(green)
+#show heading.where(level: 1): set text(red)
+#show heading.where(level: 2): set text(blue)
+= Red
+== Blue
+=== Green
+
+--- show-selector-or-elements-with-set ---
+// Looking forward to `heading.where(level: 1 | 2)` :)
+#show heading.where(level: 1).or(heading.where(level: 2)): set text(red)
+= L1
+== L2
+=== L3
+==== L4
+
+--- show-selector-element-or-label ---
+// Test element selector combined with label selector.
+#show selector(strong).or(<special>): highlight
+I am *strong*, I am _emphasized_, and I am #[special<special>].
+
+--- show-selector-element-or-text ---
+// Ensure that text selector cannot be nested in and/or. That's too complicated,
+// at least for now.
+
+// Error: 7-41 this selector cannot be used with show
+#show heading.where(level: 1).or("more"): set text(red)
+
+--- show-delayed-error ---
+// Error: 21-34 panicked with: "hey1"
+#show heading: _ => panic("hey1")
+
+// Error: 20-33 panicked with: "hey2"
+#show strong: _ => panic("hey2")
+
+= Hello
+*strong*
diff --git a/tests/suite/symbols/symbol.typ b/tests/suite/symbols/symbol.typ
new file mode 100644
index 00000000..8f9a49ca
--- /dev/null
+++ b/tests/suite/symbols/symbol.typ
@@ -0,0 +1,38 @@
+// Test symbols.
+
+--- symbol ---
+#emoji.face
+#emoji.woman.old
+#emoji.turtle
+
+#set text(font: "New Computer Modern Math")
+#sym.arrow
+#sym.arrow.l
+#sym.arrow.r.squiggly
+#sym.arrow.tr.hook
+
+#sym.arrow.r;this and this#sym.arrow.l;
+
+--- symbol-constructor ---
+#let envelope = symbol(
+ "🖂",
+ ("stamped", "🖃"),
+ ("stamped.pen", "🖆"),
+ ("lightning", "🖄"),
+ ("fly", "🖅"),
+)
+
+#envelope
+#envelope.stamped
+#envelope.pen
+#envelope.stamped.pen
+#envelope.lightning
+#envelope.fly
+
+--- symbol-constructor-empty ---
+// Error: 2-10 expected at least one variant
+#symbol()
+
+--- symbol-unknown-modifier ---
+// Error: 13-20 unknown symbol modifier
+#emoji.face.garbage
diff --git a/tests/suite/syntax/backtracking.typ b/tests/suite/syntax/backtracking.typ
new file mode 100644
index 00000000..33f05770
--- /dev/null
+++ b/tests/suite/syntax/backtracking.typ
@@ -0,0 +1,32 @@
+// Ensure that parser backtracking doesn't lead to exponential time consumption.
+// If this regresses, the test suite will not terminate, which is a bit
+// unfortunate compared to a good error, but at least we know something is up.
+//
+
+--- parser-backtracking-param-default-value ---
+#{
+ let s = "(x: 1) => x"
+ let pat = "(x: {}) => 1 + x()"
+ for _ in range(50) {
+ s = pat.replace("{}", s)
+ }
+ test(eval(s)(), 51)
+}
+
+--- parser-backtracking-destructuring-assignment ---
+#{
+ let s = "(x) = 1"
+ let pat = "(x: {_}) = 1"
+ for _ in range(100) {
+ s = pat.replace("_", s)
+ }
+ // Error: 8-9 cannot destructure integer
+ eval(s)
+}
+
+--- parser-backtracking-destructuring-whitespace ---
+// Test whitespace after memoized part.
+#( (x: () => 1 ) => 1 )
+// -------
+// This is memoized and we want to ensure that whitespace after this
+// is handled correctly.
diff --git a/tests/suite/syntax/comment.typ b/tests/suite/syntax/comment.typ
new file mode 100644
index 00000000..ac3e1943
--- /dev/null
+++ b/tests/suite/syntax/comment.typ
@@ -0,0 +1,43 @@
+// Test line and block comments.
+
+--- comments ---
+// Line comment acts as spacing.
+A// you
+B
+
+// Block comment does not act as spacing, nested block comments.
+C/*
+ /* */
+*/D
+
+// Works in code.
+#test(type(/*1*/ 1) //
+, int)
+
+// End of block comment in line comment.
+// Hello */
+
+// Nested "//" doesn't count as line comment.
+/* // */
+E
+
+/*//*/
+This is a comment.
+*/*/
+
+--- comment-end-of-line ---
+// Test comments at the end of a line
+First part//
+Second part
+
+// Test comments at the end of a line with pre-spacing
+First part //
+Second part
+
+--- comment-block-unclosed ---
+// End should not appear without start.
+// Error: 7-9 unexpected end of block comment
+/* */ */
+
+// Unterminated is okay.
+/*
diff --git a/tests/suite/syntax/embedded.typ b/tests/suite/syntax/embedded.typ
new file mode 100644
index 00000000..74ce4a03
--- /dev/null
+++ b/tests/suite/syntax/embedded.typ
@@ -0,0 +1,9 @@
+// Test embedded expressions.
+
+--- markup-expr-incomplete ---
+// Error: 2-2 expected expression
+#
+
+--- markup-expr-incomplete-followed-by-text ---
+// Error: 2-2 expected expression
+# hello
diff --git a/tests/suite/syntax/escape.typ b/tests/suite/syntax/escape.typ
new file mode 100644
index 00000000..ff05aa99
--- /dev/null
+++ b/tests/suite/syntax/escape.typ
@@ -0,0 +1,36 @@
+// Test escape sequences.
+
+--- escape ---
+// Escapable symbols.
+\\ \/ \[ \] \{ \} \# \* \_ \+ \= \~ \
+\` \$ \" \' \< \> \@ \( \) \A
+
+// No need to escape.
+( ) ;
+
+// Escaped comments.
+\//
+\/\* \*\/
+\/* \*/ *
+
+// Unicode escape sequence.
+\u{1F3D5} == 🏕
+
+// Escaped escape sequence.
+\u{41} vs. \\u\{41\}
+
+// Some code stuff in text.
+let f() , ; : | + - /= == 12 "string"
+
+// Escaped dot.
+10\. May
+
+--- escape-invalid-codepoint ---
+// Unicode codepoint does not exist.
+// Error: 1-11 invalid Unicode codepoint: FFFFFF
+\u{FFFFFF}
+
+--- escape-unclosed ---
+// Unterminated.
+// Error: 1-6 unclosed Unicode escape sequence
+\u{41[*Bold*]
diff --git a/tests/suite/syntax/newlines.typ b/tests/suite/syntax/newlines.typ
new file mode 100644
index 00000000..eef45619
--- /dev/null
+++ b/tests/suite/syntax/newlines.typ
@@ -0,0 +1,77 @@
+// Test newline continuations.
+
+--- newline-continuation-code ---
+#{
+ "hello"
+ .clusters()
+ if false {
+
+ }
+ else {
+ ("1", "2")
+ }
+}
+
+--- newline-continuation-markup ---
+#"hello"
+ .codepoints()
+
+#if false {
+
+}
+else {
+ ("1", "2")
+}
+
+--- newline-continuation-method-blank ---
+#test({
+ "hi 1"
+
+ .clusters()
+}, ("h", "i", " ", "1"))
+
+--- newline-continuation-method-line-comment-after ---
+#test({
+ "hi 2"// comment
+ .clusters()
+}, ("h", "i", " ", "2"))
+
+--- newline-continuation-method-block-comment-after ---
+#test({
+ "hi 3"/* comment */
+ .clusters()
+}, ("h", "i", " ", "3"))
+
+--- newline-continuation-method-line-comment-between ---
+#test({
+ "hi 4"
+ // comment
+ .clusters()
+}, ("h", "i", " ", "4"))
+
+--- newline-continuation-method-block-comment-between ---
+#test({
+ "hi 5"
+ /*comment*/.clusters()
+}, ("h", "i", " ", "5"))
+
+--- newline-continuation-method-comments-and-blanks ---
+#test({
+ "hi 6"
+ // comment
+
+
+ /* comment */
+ .clusters()
+}, ("h", "i", " ", "6"))
+
+--- newline-continuation-if-else-comment ---
+#test({
+ let foo(x) = {
+ if x < 0 { "negative" }
+ // comment
+ else { "non-negative" }
+ }
+
+ foo(1)
+}, "non-negative")
diff --git a/tests/suite/syntax/numbers.typ b/tests/suite/syntax/numbers.typ
new file mode 100644
index 00000000..1f15ac72
--- /dev/null
+++ b/tests/suite/syntax/numbers.typ
@@ -0,0 +1,32 @@
+// Test how numbers are displayed.
+
+--- numbers ---
+// Test numbers in text mode.
+12 \
+12.0 \
+3.14 \
+1234567890 \
+0123456789 \
+0 \
+0.0 \
++0 \
++0.0 \
+-0 \
+-0.0 \
+-1 \
+-3.14 \
+-9876543210 \
+-0987654321 \
+٣٫١٤ \
+-٣٫١٤ \
+-¾ \
+#text(fractions: true)[-3/2] \
+2022 - 2023 \
+2022 -- 2023 \
+2022--2023 \
+2022-2023 \
+٢٠٢٢ - ٢٠٢٣ \
+٢٠٢٢ -- ٢٠٢٣ \
+٢٠٢٢--٢٠٢٣ \
+٢٠٢٢-٢٠٢٣ \
+-500 -- -400
diff --git a/tests/suite/syntax/shorthand.typ b/tests/suite/syntax/shorthand.typ
new file mode 100644
index 00000000..81aa6b7b
--- /dev/null
+++ b/tests/suite/syntax/shorthand.typ
@@ -0,0 +1,61 @@
+// Test shorthands for unicode codepoints.
+
+--- shorthand-nbsp-and-shy-hyphen ---
+The non-breaking space~does work, soft-?hyphen also does.
+
+--- shorthand-nbsp-width ---
+// Make sure non-breaking and normal space always
+// have the same width. Even if the font decided
+// differently.
+#set text(font: "New Computer Modern")
+a b \
+a~b
+
+--- shorthand-dashes ---
+- En dash: --
+- Em dash: ---
+
+--- shorthand-ellipsis ---
+#set text(font: "Roboto")
+A... vs #"A..."
+
+--- shorthands-math ---
+// Check all math shorthands
+$...$\
+$-$\
+$'$\
+$*$\
+$!=$\
+$:=$\
+$::=$\
+$=:$\
+$<<$\
+$<<<$\
+$>>$\
+$>>>$\
+$<=$\
+$>=$\
+$->$\
+$-->$\
+$|->$\
+$>->$\
+$->>$\
+$<-$\
+$<--$\
+$<-<$\
+$<<-$\
+$<->$\
+$<-->$\
+$~>$\
+$~~>$\
+$<~$\
+$<~~$\
+$=>$\
+$|=>$\
+$==>$\
+$<==$\
+$<=>$\
+$<==>$\
+$[|$\
+$|]$\
+$||$
diff --git a/tests/suite/text/case.typ b/tests/suite/text/case.typ
new file mode 100644
index 00000000..2bf68bc3
--- /dev/null
+++ b/tests/suite/text/case.typ
@@ -0,0 +1,11 @@
+// Test the `upper` and `lower` functions.
+
+--- lower-and-upper ---
+#let memes = "ArE mEmEs gReAt?";
+#test(lower(memes), "are memes great?")
+#test(upper(memes), "ARE MEMES GREAT?")
+#test(upper("Ελλάδα"), "ΕΛΛΆΔΑ")
+
+--- upper-bad-type ---
+// Error: 8-9 expected string or content, found integer
+#upper(1)
diff --git a/tests/suite/text/coma.typ b/tests/suite/text/coma.typ
new file mode 100644
index 00000000..df757633
--- /dev/null
+++ b/tests/suite/text/coma.typ
@@ -0,0 +1,26 @@
+--- coma ---
+// LARGE
+#set page(width: 450pt, margin: 1cm)
+
+*Technische Universität Berlin* #h(1fr) *WiSe 2019/2020* \
+*Fakultät II, Institut for Mathematik* #h(1fr) Woche 3 \
+Sekretariat MA \
+Dr. Max Mustermann \
+Ola Nordmann, John Doe
+
+#v(3mm)
+#align(center)[
+ #set par(leading: 3mm)
+ #text(1.2em)[*3. Übungsblatt Computerorientierte Mathematik II*] \
+ *Abgabe: 03.05.2019* (bis 10:10 Uhr in MA 001) \
+ *Alle Antworten sind zu beweisen.*
+]
+
+*1. Aufgabe* #h(1fr) (1 + 1 + 2 Punkte)
+
+Ein _Binärbaum_ ist ein Wurzelbaum, in dem jeder Knoten ≤ 2 Kinder hat.
+Die Tiefe eines Knotens _v_ ist die Länge des eindeutigen Weges von der Wurzel
+zu _v_, und die Höhe von _v_ ist die Länge eines längsten (absteigenden) Weges
+von _v_ zu einem Blatt. Die Höhe des Baumes ist die Höhe der Wurzel.
+
+#align(center, image("/assets/images/graph.png", width: 75%))
diff --git a/tests/suite/text/copy-paste.typ b/tests/suite/text/copy-paste.typ
new file mode 100644
index 00000000..ff6da893
--- /dev/null
+++ b/tests/suite/text/copy-paste.typ
@@ -0,0 +1,8 @@
+// Test copy-paste and search in PDF with ligatures
+// and Arabic test. Must be tested manually!
+
+--- text-copy-paste-ligatures ---
+The after fira 🏳️‍🌈!
+
+#set text(lang: "ar", font: "Noto Sans Arabic")
+مرحبًا
diff --git a/tests/suite/text/deco.typ b/tests/suite/text/deco.typ
new file mode 100644
index 00000000..07fdb6c1
--- /dev/null
+++ b/tests/suite/text/deco.typ
@@ -0,0 +1,85 @@
+// Test text decorations.
+
+--- underline-overline-strike ---
+#let red = rgb("fc0030")
+
+// Basic strikethrough.
+#strike[Statements dreamt up by the utterly deranged.]
+
+// Move underline down.
+#underline(offset: 5pt)[Further below.]
+
+// Different color.
+#underline(stroke: red, evade: false)[Critical information is conveyed here.]
+
+// Inherits font color.
+#text(fill: red, underline[Change with the wind.])
+
+// Both over- and underline.
+#overline(underline[Running amongst the wolves.])
+
+--- strike-with ---
+#let redact = strike.with(stroke: 10pt, extent: 0.05em)
+#let highlight-custom = strike.with(stroke: 10pt + rgb("abcdef88"), extent: 0.05em)
+
+// Abuse thickness and transparency for redacting and highlighting stuff.
+Sometimes, we work #redact[in secret].
+There might be #highlight-custom[redacted] things.
+
+--- underline-stroke-folding ---
+// Test stroke folding.
+#set underline(stroke: 2pt, offset: 2pt)
+#underline(text(red, [DANGER!]))
+
+--- underline-background ---
+// Test underline background
+#set underline(background: true, stroke: (thickness: 0.5em, paint: red, cap: "round"))
+#underline[This is in the background]
+
+--- overline-background ---
+// Test overline background
+#set overline(background: true, stroke: (thickness: 0.5em, paint: red, cap: "round"))
+#overline[This is in the background]
+
+--- strike-background ---
+// Test strike background
+#set strike(background: true, stroke: 5pt + red)
+#strike[This is in the background]
+
+--- highlight ---
+// Test highlight.
+This is the built-in #highlight[highlight with default color].
+We can also specify a customized value
+#highlight(fill: green.lighten(80%))[to highlight].
+
+--- highlight-bounds ---
+// Test default highlight bounds.
+#highlight[ace],
+#highlight[base],
+#highlight[super],
+#highlight[phone #sym.integral]
+
+--- highlight-edges ---
+// Test a tighter highlight.
+#set highlight(top-edge: "x-height", bottom-edge: "baseline")
+#highlight[ace],
+#highlight[base],
+#highlight[super],
+#highlight[phone #sym.integral]
+
+--- highlight-edges-bounds ---
+// Test a bounds highlight.
+#set highlight(top-edge: "bounds", bottom-edge: "bounds")
+#highlight[abc]
+#highlight[abc #sym.integral]
+
+--- highlight-radius ---
+// Test highlight radius
+#highlight(radius: 3pt)[abc],
+#highlight(radius: 1em)[#lorem(5)]
+
+--- highlight-stroke ---
+// Test highlight stroke
+#highlight(stroke: 2pt + blue)[abc]
+#highlight(stroke: (top: blue, left: red, bottom: green, right: orange))[abc]
+#highlight(stroke: 1pt, radius: 3pt)[#lorem(5)]
diff --git a/tests/suite/text/edge.typ b/tests/suite/text/edge.typ
new file mode 100644
index 00000000..57732156
--- /dev/null
+++ b/tests/suite/text/edge.typ
@@ -0,0 +1,39 @@
+// Test top and bottom text edge.
+
+--- text-edge ---
+#set page(width: 160pt)
+#set text(size: 8pt)
+
+#let try(top, bottom) = rect(inset: 0pt, fill: conifer)[
+ #set text(font: "IBM Plex Mono", top-edge: top, bottom-edge: bottom)
+ From #top to #bottom
+]
+
+#let try-bounds(top, bottom) = rect(inset: 0pt, fill: conifer)[
+ #set text(font: "IBM Plex Mono", top-edge: top, bottom-edge: bottom)
+ #top to #bottom: "yay, Typst"
+]
+
+#try("ascender", "descender")
+#try("ascender", "baseline")
+#try("cap-height", "baseline")
+#try("x-height", "baseline")
+#try-bounds("cap-height", "baseline")
+#try-bounds("bounds", "baseline")
+#try-bounds("bounds", "bounds")
+#try-bounds("x-height", "bounds")
+
+#try(4pt, -2pt)
+#try(1pt + 0.3em, -0.15em)
+
+--- text-edge-bad-type ---
+// Error: 21-23 expected "ascender", "cap-height", "x-height", "baseline", "bounds", or length, found array
+#set text(top-edge: ())
+
+--- text-edge-bad-value ---
+// Error: 24-26 expected "baseline", "descender", "bounds", or length
+#set text(bottom-edge: "")
+
+--- text-edge-wrong-edge ---
+// Error: 24-36 expected "baseline", "descender", "bounds", or length
+#set text(bottom-edge: "cap-height")
diff --git a/tests/suite/text/em.typ b/tests/suite/text/em.typ
new file mode 100644
index 00000000..be7e3428
--- /dev/null
+++ b/tests/suite/text/em.typ
@@ -0,0 +1,33 @@
+// Test font-relative sizing.
+
+--- text-size-em-nesting ---
+#set text(size: 5pt)
+A // 5pt
+#[
+ #set text(size: 2em)
+ B // 10pt
+ #[
+ #set text(size: 1.5em + 1pt)
+ C // 16pt
+ #text(size: 2em)[D] // 32pt
+ E // 16pt
+ ]
+ F // 10pt
+]
+G // 5pt
+
+--- text-size-em ---
+// Test using ems in arbitrary places.
+#set text(size: 5pt)
+#set text(size: 2em)
+#set square(fill: red)
+
+#let size = {
+ let size = 0.25em + 1pt
+ for _ in range(3) {
+ size *= 2
+ }
+ size - 3pt
+}
+
+#stack(dir: ltr, spacing: 1fr, square(size: size), square(size: 25pt))
diff --git a/tests/suite/text/font.typ b/tests/suite/text/font.typ
new file mode 100644
index 00000000..47ec6419
--- /dev/null
+++ b/tests/suite/text/font.typ
@@ -0,0 +1,66 @@
+// Test configuring font properties.
+
+--- text-font-properties ---
+// Set same font size in three different ways.
+#text(20pt)[A]
+#text(2em)[A]
+#text(size: 15pt + 0.5em)[A]
+
+// Do nothing.
+#text()[Normal]
+
+// Set style (is available).
+#text(style: "italic")[Italic]
+
+// Set weight (is available).
+#text(weight: "bold")[Bold]
+
+// Set stretch (not available, matching closest).
+#text(stretch: 50%)[Condensed]
+
+// Set font family.
+#text(font: "IBM Plex Serif")[Serif]
+
+// Emoji.
+Emoji: 🐪, 🌋, 🏞
+
+// Colors.
+#[
+ #set text(fill: eastern)
+ This is #text(rgb("FA644B"))[way more] colorful.
+]
+
+// Transparency.
+#block(fill: green)[
+ #set text(fill: rgb("FF000080"))
+ This text is transparent.
+]
+
+// Disable font fallback beyond the user-specified list.
+// Without disabling, New Computer Modern Math would come to the rescue.
+#set text(font: ("PT Sans", "Twitter Color Emoji"), fallback: false)
+2π = 𝛼 + 𝛽. ✅
+
+--- text-call-body ---
+// Test string body.
+#text("Text") \
+#text(red, "Text") \
+#text(font: "Ubuntu", blue, "Text") \
+#text([Text], teal, font: "IBM Plex Serif") \
+#text(forest, font: "New Computer Modern", [Text]) \
+
+--- text-bad-argument ---
+// Error: 11-16 unexpected argument
+#set text(false)
+
+--- text-style-bad ---
+// Error: 18-24 expected "normal", "italic", or "oblique"
+#set text(style: "bold", weight: "thin")
+
+--- text-bad-extra-argument ---
+// Error: 23-27 unexpected argument
+#set text(size: 10pt, 12pt)
+
+--- text-bad-named-argument ---
+// Error: 11-31 unexpected argument: something
+#set text(something: "invalid")
diff --git a/tests/suite/text/lang.typ b/tests/suite/text/lang.typ
new file mode 100644
index 00000000..74f70140
--- /dev/null
+++ b/tests/suite/text/lang.typ
@@ -0,0 +1,74 @@
+// Test setting the document language.
+
+--- text-lang ---
+// without any region
+#set text(font: "Noto Serif CJK TC", lang: "zh")
+#outline()
+
+--- text-lang-unknown-region ---
+// with unknown region configured
+#set text(font: "Noto Serif CJK TC", lang: "zh", region: "XX")
+#outline()
+
+--- text-lang-region ---
+// with region configured
+#set text(font: "Noto Serif CJK TC", lang: "zh", region: "TW")
+#outline()
+
+--- text-lang-hyphenate ---
+// Ensure that setting the language does have effects.
+#set text(hyphenate: true)
+#grid(
+ columns: 2 * (20pt,),
+ gutter: 1fr,
+ text(lang: "en")["Eingabeaufforderung"],
+ text(lang: "de")["Eingabeaufforderung"],
+)
+
+--- text-lang-shaping ---
+// Test that the language passed to the shaper has an effect.
+#set text(font: "Ubuntu")
+
+// Some lowercase letters are different in Serbian Cyrillic compared to other
+// Cyrillic languages. Since there is only one set of Unicode codepoints for
+// Cyrillic, these can only be seen when setting the language to Serbian and
+// selecting one of the few fonts that support these letterforms.
+Бб
+#text(lang: "uk")[Бб]
+#text(lang: "sr")[Бб]
+
+--- text-lang-script-shaping ---
+// Verify that writing script/language combination has an effect
+#{
+ set text(size:20pt)
+ set text(script: "latn", lang: "en")
+ [Ş ]
+ set text(script: "latn", lang: "ro")
+ [Ş ]
+ set text(script: "grek", lang: "ro")
+ [Ş ]
+}
+
+--- text-script-bad-type ---
+// Error: 19-23 expected string or auto, found none
+#set text(script: none)
+
+--- text-script-bad-value ---
+// Error: 19-23 expected three or four letter script code (ISO 15924 or 'math')
+#set text(script: "ab")
+
+--- text-lang-bad-type ---
+// Error: 17-21 expected string, found none
+#set text(lang: none)
+
+--- text-lang-bad-value ---
+// Error: 17-20 expected two or three letter language code (ISO 639-1/2/3)
+#set text(lang: "ӛ")
+
+--- text-lang-bad-value-emoji ---
+// Error: 17-20 expected two or three letter language code (ISO 639-1/2/3)
+#set text(lang: "😃")
+
+--- text-region-bad-value ---
+// Error: 19-24 expected two letter region code (ISO 3166-1 alpha-2)
+#set text(region: "hey")
diff --git a/tests/suite/text/lorem.typ b/tests/suite/text/lorem.typ
new file mode 100644
index 00000000..1524e2a3
--- /dev/null
+++ b/tests/suite/text/lorem.typ
@@ -0,0 +1,32 @@
+// Test blind text.
+
+--- lorem ---
+// Test basic call.
+#lorem(19)
+
+--- lorem-pars ---
+// Test custom paragraphs with user code.
+#set text(8pt)
+
+#{
+ let sentences = lorem(59)
+ .split(".")
+ .filter(s => s != "")
+ .map(s => s + ".")
+
+ let used = 0
+ for s in sentences {
+ if used < 2 {
+ used += 1
+ } else {
+ parbreak()
+ used = 0
+ }
+ s.trim()
+ [ ]
+ }
+}
+
+--- lorem-missing-words ---
+// Error: 2-9 missing argument: words
+#lorem()
diff --git a/tests/suite/text/raw.typ b/tests/suite/text/raw.typ
new file mode 100644
index 00000000..dce77fdb
--- /dev/null
+++ b/tests/suite/text/raw.typ
@@ -0,0 +1,630 @@
+// Test raw blocks.
+
+--- raw-empty ---
+// Empty raw block.
+Empty raw block:``.
+
+--- raw-consecutive-single-backticks ---
+// No extra space.
+`A``B`
+
+--- raw-typst-lang ---
+// Typst syntax inside.
+```typ #let x = 1``` \
+```typ #f(1)```
+
+--- raw-block-no-parbreaks ---
+// Multiline block splits paragraphs.
+
+Text
+```rust
+fn code() {}
+```
+Text
+
+--- raw-more-backticks ---
+// Lots of backticks inside.
+````
+```backticks```
+````
+
+--- raw-trimming ---
+// Trimming.
+
+// Space between "rust" and "let" is trimmed.
+The keyword ```rust let```.
+
+// Trimming depends on number backticks.
+(``) \
+(` untrimmed `) \
+(``` trimmed` ```) \
+(``` trimmed ```) \
+(``` trimmed```) \
+
+--- raw-single-backtick-lang ---
+// Single ticks should not have a language.
+`rust let`
+
+--- raw-dedent-first-line ---
+// First line is not dedented and leading space is still possible.
+ ``` A
+ B
+ C
+ ```
+
+--- raw-dedent-empty-line ---
+// Do not take empty lines into account when computing dedent.
+```
+ A
+
+ B
+```
+
+--- raw-dedent-last-line ---
+// Take last line into account when computing dedent.
+```
+ A
+
+ B
+ ```
+
+--- raw-tab-size ---
+#set raw(tab-size: 8)
+
+```tsv
+Year Month Day
+2000 2 3
+2001 2 1
+2002 3 10
+```
+
+--- raw-syntaxes ---
+#set page(width: 180pt)
+#set text(6pt)
+#set raw(syntaxes: "/assets/syntaxes/SExpressions.sublime-syntax")
+
+```sexp
+(defun factorial (x)
+ (if (zerop x)
+ ; with a comment
+ 1
+ (* x (factorial (- x 1)))))
+```
+
+
+--- raw-theme ---
+// Test code highlighting with custom theme.
+#set page(width: 180pt)
+#set text(6pt)
+#set raw(theme: "/assets/themes/halcyon.tmTheme")
+#show raw: it => {
+ set text(fill: rgb("a2aabc"))
+ rect(
+ width: 100%,
+ inset: (x: 4pt, y: 5pt),
+ radius: 4pt,
+ fill: rgb("1d2433"),
+ place(right, text(luma(240), it.lang)) + it,
+ )
+}
+
+```typ
+= Chapter 1
+#lorem(100)
+
+#let hi = "Hello World"
+#show heading: emph
+```
+
+--- raw-show-set ---
+// Text show rule
+#show raw: set text(font: "Roboto")
+`Roboto`
+
+--- raw-align-default ---
+// Text inside raw block should be unaffected by outer alignment by default.
+#set align(center)
+#set page(width: 180pt)
+#set text(6pt)
+
+#lorem(20)
+
+```py
+def something(x):
+ return x
+
+a = 342395823859823958329
+b = 324923
+```
+
+#lorem(20)
+
+--- raw-align-specified ---
+// Text inside raw block should follow the specified alignment.
+#set page(width: 180pt)
+#set text(6pt)
+
+#lorem(20)
+#align(center, raw(
+ lang: "typ",
+ block: true,
+ align: right,
+ "#let f(x) = x\n#align(center, line(length: 1em))",
+))
+#lorem(20)
+
+--- raw-align-invalid ---
+// Error: 17-20 expected `start`, `left`, `center`, `right`, or `end`, found top
+#set raw(align: top)
+
+--- raw-highlight-typ ---
+// LARGE
+#set page(width: auto)
+
+```typ
+#set hello()
+#set hello()
+#set hello.world()
+#set hello.my.world()
+#let foo(x) = x * 2
+#show heading: func
+#show module.func: func
+#show module.func: it => {}
+#foo(ident: ident)
+#hello
+#hello()
+#box[]
+#hello.world
+#hello.world()
+#hello().world()
+#hello.my.world
+#hello.my.world()
+#hello.my().world
+#hello.my().world()
+#{ hello }
+#{ hello() }
+#{ hello.world() }
+$ hello $
+$ hello() $
+$ box[] $
+$ hello.world $
+$ hello.world() $
+$ hello.my.world() $
+$ f_zeta(x), f_zeta(x)/1 $
+$ emph(hello.my.world()) $
+$ emph(hello.my().world) $
+$ emph(hello.my().world()) $
+$ #hello $
+$ #hello() $
+$ #hello.world $
+$ #hello.world() $
+$ #box[] $
+#if foo []
+```
+
+--- raw-highlight ---
+#set page(width: 180pt)
+#set text(6pt)
+#show raw: it => rect(
+ width: 100%,
+ inset: (x: 4pt, y: 5pt),
+ radius: 4pt,
+ fill: rgb(239, 241, 243),
+ place(right, text(luma(110), it.lang)) + it,
+)
+
+```typ
+= Chapter 1
+#lorem(100)
+
+#let hi = "Hello World"
+#show heading: emph
+```
+
+```rust
+/// A carefully designed state machine.
+#[derive(Debug)]
+enum State<'a> { A(u8), B(&'a str) }
+
+fn advance(state: State<'_>) -> State<'_> {
+ unimplemented!("state machine")
+}
+```
+
+```py
+import this
+
+def hi():
+ print("Hi!")
+```
+
+```cpp
+#include <iostream>
+
+int main() {
+ std::cout << "Hello, world!";
+}
+```
+
+```julia
+# Add two numbers
+function add(x, y)
+ return x * y
+end
+```
+
+ // Try with some indent.
+ ```html
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>Topic</h1>
+ <p>The Hypertext Markup Language.</p>
+ <script>
+ function foo(a, b) {
+ return a + b + "string";
+ }
+ </script>
+ </body>
+ </html>
+ ```
+
+--- raw-inline-multiline ---
+#set page(width: 180pt)
+#set text(6pt)
+#set raw(lang:"python")
+
+Inline raws, multiline e.g. `for i in range(10):
+ # Only this line is a comment.
+ print(i)` or otherwise e.g. `print(j)`, are colored properly.
+
+Inline raws, multiline e.g. `
+# Appears blocky due to linebreaks at the boundary.
+for i in range(10):
+ print(i)
+` or otherwise e.g. `print(j)`, are colored properly.
+
+--- raw-blocky ---
+// Test various raw parsing edge cases.
+#let empty = (
+ name: "empty",
+ input: ``,
+ text: "",
+)
+
+#let backtick = (
+ name: "backtick",
+ input: ``` ` ```,
+ text: "`",
+ block: false,
+)
+
+#let lang-backtick = (
+ name: "lang-backtick",
+ input: ```js ` ```,
+ lang: "js",
+ text: "`",
+ block: false,
+)
+
+// The language tag stops on space
+#let lang-space = (
+ name: "lang-space",
+ input: ```js test ```,
+ lang: "js",
+ text: "test ",
+ block: false,
+)
+
+// The language tag stops on newline
+#let lang-newline = (
+ name: "lang-newline",
+ input: ```js
+test
+```,
+ lang: "js",
+ text: "test",
+ block: true,
+)
+
+// The first line and the last line are ignored
+#let blocky = (
+ name: "blocky",
+ input: {
+```
+test
+```
+},
+ text: "test",
+ block: true,
+)
+
+// A blocky raw should handle dedents
+#let blocky-dedent = (
+ name: "blocky-dedent",
+ input: {
+```
+ test
+ ```
+ },
+ text: "test",
+ block: true,
+)
+
+// When there is content in the first line, it should exactly eat a whitespace char.
+#let blocky-dedent-firstline = (
+ name: "blocky-dedent-firstline",
+ input: ``` test
+ ```,
+ text: "test",
+ block: true,
+)
+
+// When there is content in the first line, it should exactly eat a whitespace char.
+#let blocky-dedent-firstline2 = (
+ name: "blocky-dedent-firstline2",
+ input: ``` test
+```,
+ text: "test",
+ block: true,
+)
+
+// The first line is not affected by dedent, and the middle lines don't consider the whitespace prefix of the first line.
+#let blocky-dedent-firstline3 = (
+ name: "blocky-dedent-firstline3",
+ input: ``` test
+ test2
+ ```,
+ text: "test\n test2",
+ block: true,
+)
+
+// The first line is not affected by dedent, and the middle lines don't consider the whitespace prefix of the first line.
+#let blocky-dedent-firstline4 = (
+ name: "blocky-dedent-firstline4",
+ input: ``` test
+ test2
+ ```,
+ text: " test\ntest2",
+ block: true,
+)
+
+#let blocky-dedent-lastline = (
+ name: "blocky-dedent-lastline",
+ input: ```
+ test
+ ```,
+ text: " test",
+ block: true,
+)
+
+#let blocky-dedent-lastline2 = (
+ name: "blocky-dedent-lastline2",
+ input: ```
+ test
+ ```,
+ text: "test",
+ block: true,
+)
+
+#let blocky-tab = (
+ name: "blocky-tab",
+ input: {
+```
+ test
+```
+},
+ text: "\ttest",
+ block: true,
+)
+
+// This one is a bit problematic because there is a trailing tab below "test"
+// which the editor constantly wants to remove.
+#let blocky-tab-dedent = (
+ name: "blocky-tab-dedent",
+ input: eval("```\n\ttest\n \n ```"),
+ text: "test\n ",
+ block: true,
+)
+
+#let cases = (
+ empty,
+ backtick,
+ lang-backtick,
+ lang-space,
+ lang-newline,
+ blocky,
+ blocky-dedent,
+ blocky-dedent-firstline,
+ blocky-dedent-firstline2,
+ blocky-dedent-firstline3,
+ blocky-dedent-lastline,
+ blocky-dedent-lastline2,
+ blocky-tab,
+ blocky-tab-dedent,
+)
+
+#for c in cases {
+ assert.eq(c.text, c.input.text, message: "in point " + c.name + ", expect " + repr(c.text) + ", got " + repr(c.input.text) + "")
+ let block = c.at("block", default: false)
+ assert.eq(block, c.input.block, message: "in point " + c.name + ", expect " + repr(block) + ", got " + repr(c.input.block) + "")
+}
+
+--- raw-line ---
+#set page(width: 200pt)
+
+```rs
+fn main() {
+ println!("Hello, world!");
+}
+```
+
+#show raw.line: it => {
+ box(stack(
+ dir: ltr,
+ box(width: 15pt)[#it.number],
+ it.body,
+ ))
+ linebreak()
+}
+
+```rs
+fn main() {
+ println!("Hello, world!");
+}
+```
+
+--- raw-line-alternating-fill ---
+#set page(width: 200pt)
+#show raw: it => stack(dir: ttb, ..it.lines)
+#show raw.line: it => {
+ box(
+ width: 100%,
+ height: 1.75em,
+ inset: 0.25em,
+ fill: if calc.rem(it.number, 2) == 0 {
+ luma(90%)
+ } else {
+ white
+ },
+ align(horizon, stack(
+ dir: ltr,
+ box(width: 15pt)[#it.number],
+ it.body,
+ ))
+ )
+}
+
+```typ
+#show raw.line: block.with(
+ fill: luma(60%)
+);
+
+Hello, world!
+
+= A heading for good measure
+```
+
+--- raw-line-text-fill ---
+#set page(width: 200pt)
+#show raw.line: set text(fill: red)
+
+```py
+import numpy as np
+
+def f(x):
+ return x**2
+
+x = np.linspace(0, 10, 100)
+y = f(x)
+
+print(x)
+print(y)
+```
+
+--- raw-line-scripting ---
+
+// Test line extraction works.
+
+#show raw: code => {
+ for i in code.lines {
+ test(i.count, 10)
+ }
+
+ test(code.lines.at(0).text, "import numpy as np")
+ test(code.lines.at(1).text, "")
+ test(code.lines.at(2).text, "def f(x):")
+ test(code.lines.at(3).text, " return x**2")
+ test(code.lines.at(4).text, "")
+ test(code.lines.at(5).text, "x = np.linspace(0, 10, 100)")
+ test(code.lines.at(6).text, "y = f(x)")
+ test(code.lines.at(7).text, "")
+ test(code.lines.at(8).text, "print(x)")
+ test(code.lines.at(9).text, "print(y)")
+ test(code.lines.at(10, default: none), none)
+}
+
+```py
+import numpy as np
+
+def f(x):
+ return x**2
+
+x = np.linspace(0, 10, 100)
+y = f(x)
+
+print(x)
+print(y)
+```
+
+--- issue-3601-empty-raw ---
+// Test that empty raw block with `typ` language doesn't cause a crash.
+```typ
+```
+
+--- issue-3841-tabs-in-raw-type-code ---
+// Tab chars were not rendered in raw blocks with lang: "typ(c)"
+#raw("#if true {\n\tf()\t// typ\n}", lang: "typ")
+
+#raw("if true {\n\tf()\t// typc\n}", lang: "typc")
+
+```typ
+#if true {
+ // tabs around f()
+ f() // typ
+}
+```
+
+```typc
+if true {
+ // tabs around f()
+ f() // typc
+}
+```
+
+--- issue-2259-raw-color-overwrite ---
+// Test that the color of a raw block is not overwritten
+#show raw: set text(fill: blue)
+
+`Hello, World!`
+
+```rs
+fn main() {
+ println!("Hello, World!");
+}
+```
+
+--- issue-3191-raw-indent-shrink ---
+// Spaces in raw blocks should not be shrunk as it would mess up the indentation
+// of code.
+#set par(justify: true)
+
+#show raw.where(block: true): block.with(
+ fill: luma(240),
+ inset: 10pt,
+)
+
+#block(
+ width: 60%,
+ ```py
+ for x in xs:
+ print("x=",x)
+ ```
+)
+
+--- issue-3191-raw-normal-paragraphs-still-shrink ---
+// In normal paragraphs, spaces should still be shrunk.
+// The first line here serves as a reference, while the second
+// uses non-breaking spaces to create an overflowing line
+// (which should shrink).
+~~~~No shrinking here
+
+~~~~The~spaces~on~this~line~shrink
+
+--- raw-unclosed ---
+// Unterminated.
+// Error: 1-2:1 unclosed raw text
+`endless
diff --git a/tests/suite/text/shift.typ b/tests/suite/text/shift.typ
new file mode 100644
index 00000000..090f6ee8
--- /dev/null
+++ b/tests/suite/text/shift.typ
@@ -0,0 +1,19 @@
+// Test sub- and superscipt shifts.
+
+--- sub-super ---
+#table(
+ columns: 3,
+ [Typo.], [Fallb.], [Synth],
+ [x#super[1]], [x#super[5n]], [x#super[2 #box(square(size: 6pt))]],
+ [x#sub[1]], [x#sub[5n]], [x#sub[2 #box(square(size: 6pt))]],
+)
+
+--- sub-super-non-typographic ---
+#set super(typographic: false, baseline: -0.25em, size: 0.7em)
+n#super[1], n#sub[2], ... n#super[N]
+
+--- super-underline ---
+#set underline(stroke: 0.5pt, offset: 0.15em)
+#underline[The claim#super[\[4\]]] has been disputed. \
+The claim#super[#underline[\[4\]]] has been disputed. \
+It really has been#super(box(text(baseline: 0pt, underline[\[4\]]))) \
diff --git a/tests/suite/text/smallcaps.typ b/tests/suite/text/smallcaps.typ
new file mode 100644
index 00000000..6f977244
--- /dev/null
+++ b/tests/suite/text/smallcaps.typ
@@ -0,0 +1,3 @@
+--- smallcaps ---
+// Test smallcaps.
+#smallcaps[Smallcaps]
diff --git a/tests/suite/text/smartquote.typ b/tests/suite/text/smartquote.typ
new file mode 100644
index 00000000..28fcba5b
--- /dev/null
+++ b/tests/suite/text/smartquote.typ
@@ -0,0 +1,122 @@
+--- smartquote ---
+// LARGE
+#set page(width: 250pt)
+
+// Test simple quotations in various languages.
+#set text(lang: "en")
+"The horse eats no cucumber salad" was the first sentence ever uttered on the 'telephone.'
+
+#set text(lang: "de")
+"Das Pferd frisst keinen Gurkensalat" war der erste jemals am 'Fernsprecher' gesagte Satz.
+
+#set text(lang: "de", region: "CH")
+"Das Pferd frisst keinen Gurkensalat" war der erste jemals am 'Fernsprecher' gesagte Satz.
+
+#set text(lang: "es", region: none)
+"El caballo no come ensalada de pepino" fue la primera frase pronunciada por 'teléfono'.
+
+#set text(lang: "es", region: "MX")
+"El caballo no come ensalada de pepino" fue la primera frase pronunciada por 'teléfono'.
+
+#set text(lang: "fr", region: none)
+"Le cheval ne mange pas de salade de concombres" est la première phrase jamais prononcée au 'téléphone'.
+
+#set text(lang: "fi")
+"Hevonen ei syö kurkkusalaattia" oli ensimmäinen koskaan 'puhelimessa' lausuttu lause.
+
+#set text(lang: "gr")
+"Το άλογο δεν τρώει αγγουροσαλάτα" ήταν η πρώτη πρόταση που ειπώθηκε στο 'τηλέφωνο'.
+
+#set text(lang: "he")
+"הסוס לא אוכל סלט מלפפונים" היה המשפט ההראשון שנאמר ב 'טלפון'.
+
+#set text(lang: "ro")
+"Calul nu mănâncă salată de castraveți" a fost prima propoziție rostită vreodată la 'telefon'.
+
+#set text(lang: "ru")
+"Лошадь не ест салат из огурцов" - это была первая фраза, сказанная по 'телефону'.
+
+--- smartquote-empty ---
+// Test single pair of quotes.
+""
+
+--- smartquote-apostrophe ---
+// Test sentences with numbers and apostrophes.
+The 5'11" 'quick' brown fox jumps over the "lazy" dog's ear.
+
+He said "I'm a big fella."
+
+--- smartquote-escape ---
+// Test escape sequences.
+The 5\'11\" 'quick\' brown fox jumps over the \"lazy" dog\'s ear.
+
+--- smartquote-disable ---
+// Test turning smart quotes off.
+He's told some books contain questionable "example text".
+
+#set smartquote(enabled: false)
+He's told some books contain questionable "example text".
+
+--- smartquote-disabled-temporarily ---
+// Test changing properties within text.
+"She suddenly started speaking french: #text(lang: "fr")['Je suis une banane.']" Roman told me.
+
+Some people's thought on this would be #[#set smartquote(enabled: false); "strange."]
+
+--- smartquote-nesting ---
+// Test nested double and single quotes.
+"'test statement'" \
+"'test' statement" \
+"statement 'test'"
+
+--- smartquote-custom ---
+// Use language quotes for missing keys, allow partial reset
+#set smartquote(quotes: "«»")
+"Double and 'Single' Quotes"
+
+#set smartquote(quotes: (double: auto, single: "«»"))
+"Double and 'Single' Quotes"
+
+--- smartquote-custom-complex ---
+// Allow 2 graphemes
+#set smartquote(quotes: "a\u{0301}a\u{0301}")
+"Double and 'Single' Quotes"
+
+#set smartquote(quotes: (single: "a\u{0301}a\u{0301}"))
+"Double and 'Single' Quotes"
+
+--- smartquote-custom-bad-string ---
+// Error: 25-28 expected 2 characters, found 1 character
+#set smartquote(quotes: "'")
+
+--- smartquote-custom-bad-array ---
+// Error: 25-35 expected 2 quotes, found 4 quotes
+#set smartquote(quotes: ("'",) * 4)
+
+--- smartquote-custom-bad-dict ---
+// Error: 25-45 expected 2 quotes, found 4 quotes
+#set smartquote(quotes: (single: ("'",) * 4))
+
+--- issue-3662-pdf-smartquotes ---
+// Smart quotes were not appearing in the PDF outline, because they didn't
+// implement `PlainText`.
+= It's "Unnormal Heading"
+= It’s “Normal Heading”
+
+#set smartquote(enabled: false)
+= It's "Unnormal Heading"
+= It's 'single quotes'
+= It’s “Normal Heading”
+
+--- issue-1041-smartquotes-in-outline ---
+#set page(width: 15em)
+#outline()
+
+= "This" "is" "a" "test"
+
+--- issue-1540-smartquotes-across-newlines ---
+// Test that smart quotes are inferred correctly across newlines.
+"test"#linebreak()"test"
+
+"test"\
+"test"
diff --git a/tests/suite/text/space.typ b/tests/suite/text/space.typ
new file mode 100644
index 00000000..97541e38
--- /dev/null
+++ b/tests/suite/text/space.typ
@@ -0,0 +1,60 @@
+// Test whitespace handling.
+
+--- space-collapsing ---
+// Spacing around code constructs.
+A#let x = 1;B #test(x, 1) \
+C #let x = 2;D #test(x, 2) \
+E#if true [F]G \
+H #if true{"I"} J \
+K #if true [L] else []M \
+#let c = true; N#while c [#(c = false)O] P \
+#let c = true; Q #while c { c = false; "R" } S \
+T#for _ in (none,) {"U"}V
+#let foo = "A" ; \
+#foo;B \
+#foo; B \
+#foo ;B
+
+--- space-collapsing-comments ---
+// Test spacing with comments.
+A/**/B/**/C \
+A /**/ B/**/C \
+A /**/B/**/ C
+
+--- space-collapsing-with-h ---
+// Test spacing collapsing before spacing.
+#set align(right)
+A #h(0pt) B #h(0pt) \
+A B \
+A #h(-1fr) B
+
+--- text-font-just-a-space ---
+// Test that a run consisting only of whitespace isn't trimmed.
+A#text(font: "IBM Plex Serif")[ ]B
+
+--- text-font-change-after-space ---
+// Test font change after space.
+Left #text(font: "IBM Plex Serif")[Right].
+
+--- space-collapsing-linebreaks ---
+// Test that linebreak consumed surrounding spaces.
+#align(center)[A \ B \ C]
+
+--- space-collapsing-stringy-linebreak ---
+// Test that space at start of non-backslash-linebreak line isn't trimmed.
+A#"\n" B
+
+--- space-trailing-linebreak ---
+// Test that trailing space does not force a line break.
+LLLLLLLLLLLLLLLLLL R _L_
+
+--- space-ideographic-kept ---
+// Test that ideographic spaces are preserved.
+#set text(lang: "ja", font: "Noto Serif CJK JP")
+
+だろうか? 何のために! 私は、
+
+--- space-thin-kept ---
+// Test that thin spaces are preserved.
+| | U+0020 regular space \
+| | U+2009 thin space
diff --git a/tests/suite/visualize/circle.typ b/tests/suite/visualize/circle.typ
new file mode 100644
index 00000000..43459eb5
--- /dev/null
+++ b/tests/suite/visualize/circle.typ
@@ -0,0 +1,69 @@
+// Test the `circle` function.
+
+--- circle ---
+// Default circle.
+#box(circle())
+#box(circle[Hey])
+
+--- circle-auto-sizing ---
+// Test auto sizing.
+#set circle(inset: 0pt)
+
+Auto-sized circle.
+#circle(fill: rgb("eb5278"), stroke: 2pt + black,
+ align(center + horizon)[But, soft!]
+)
+
+Center-aligned rect in auto-sized circle.
+#circle(fill: forest, stroke: conifer,
+ align(center + horizon,
+ rect(fill: conifer, inset: 5pt)[But, soft!]
+ )
+)
+
+Rect in auto-sized circle.
+#circle(fill: forest,
+ rect(fill: conifer, stroke: white, inset: 4pt)[
+ #set text(8pt)
+ But, soft! what light through yonder window breaks?
+ ]
+)
+
+Expanded by height.
+#circle(stroke: black, align(center)[A \ B \ C])
+
+--- circle-directly-in-rect ---
+// Ensure circle directly in rect works.
+#rect(width: 40pt, height: 30pt, fill: forest,
+ circle(fill: conifer))
+
+--- circle-relative-sizing ---
+// Test relative sizing.
+#set text(fill: white)
+#show: rect.with(width: 100pt, height: 50pt, inset: 0pt, fill: rgb("aaa"))
+#set align(center + horizon)
+#stack(
+ dir: ltr,
+ spacing: 1fr,
+ 1fr,
+ circle(radius: 10pt, fill: eastern, [A]), // D=20pt
+ circle(height: 60%, fill: eastern, [B]), // D=30pt
+ circle(width: 20% + 20pt, fill: eastern, [C]), // D=40pt
+ 1fr,
+)
+
+--- circle-radius-width-and-height ---
+// Radius wins over width and height.
+// Error: 23-34 unexpected argument: width
+#circle(radius: 10pt, width: 50pt, height: 100pt, fill: eastern)
+
+--- circle-sizing-options ---
+// Test different ways of sizing.
+#set page(width: 120pt, height: 40pt)
+#stack(
+ dir: ltr,
+ spacing: 2pt,
+ circle(radius: 5pt),
+ circle(width: 10%),
+ circle(height: 50%),
+)
diff --git a/tests/suite/visualize/color.typ b/tests/suite/visualize/color.typ
new file mode 100644
index 00000000..6cf887a4
--- /dev/null
+++ b/tests/suite/visualize/color.typ
@@ -0,0 +1,331 @@
+// Test color modification methods.
+
+--- color-mix ---
+// Compare both ways.
+#test-repr(rgb(0%, 30.2%, 70.2%), rgb("004db3"))
+
+// Alpha channel.
+#test(rgb(255, 0, 0, 50%), rgb("ff000080"))
+
+// Test color modification methods.
+#test(rgb(25, 35, 45).lighten(10%), rgb(48, 57, 66))
+#test(rgb(40, 30, 20).darken(10%), rgb(36, 27, 18))
+#test(rgb("#133337").negate(space: rgb), rgb(236, 204, 200))
+#test(white.lighten(100%), white)
+
+// Color mixing, in Oklab space by default.
+#test(rgb(color.mix(rgb("#ff0000"), rgb("#00ff00"))), rgb("#d0a800"))
+#test(rgb(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: oklab)), rgb("#d0a800"))
+#test(rgb(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: rgb)), rgb("#808000"))
+
+#test(rgb(color.mix(red, green, blue)), rgb("#909282"))
+#test(rgb(color.mix(red, blue, green)), rgb("#909282"))
+#test(rgb(color.mix(blue, red, green)), rgb("#909282"))
+
+// Mix with weights.
+#test(rgb(color.mix((red, 50%), (green, 50%))), rgb("#c0983b"))
+#test(rgb(color.mix((red, 0.5), (green, 0.5))), rgb("#c0983b"))
+#test(rgb(color.mix((red, 5), (green, 5))), rgb("#c0983b"))
+#test(rgb(color.mix((green, 5), (white, 0), (red, 5))), rgb("#c0983b"))
+#test(color.mix((rgb("#aaff00"), 25%), (rgb("#aa00ff"), 75%), space: rgb), rgb("#aa40bf"))
+#test(color.mix((rgb("#aaff00"), 50%), (rgb("#aa00ff"), 50%), space: rgb), rgb("#aa8080"))
+#test(color.mix((rgb("#aaff00"), 75%), (rgb("#aa00ff"), 25%), space: rgb), rgb("#aabf40"))
+
+// Mix in hue-based space.
+#test(rgb(color.mix(red, blue, space: color.hsl)), rgb("#c408ff"))
+#test(rgb(color.mix((red, 50%), (blue, 100%), space: color.hsl)), rgb("#5100f8"))
+// Error: 6-51 cannot mix more than two colors in a hue-based space
+#rgb(color.mix(red, blue, white, space: color.hsl))
+
+--- color-conversion ---
+// Test color conversion method kinds
+#test(rgb(rgb(10, 20, 30)).space(), rgb)
+#test(color.linear-rgb(rgb(10, 20, 30)).space(), color.linear-rgb)
+#test(oklab(rgb(10, 20, 30)).space(), oklab)
+#test(oklch(rgb(10, 20, 30)).space(), oklch)
+#test(color.hsl(rgb(10, 20, 30)).space(), color.hsl)
+#test(color.hsv(rgb(10, 20, 30)).space(), color.hsv)
+#test(cmyk(rgb(10, 20, 30)).space(), cmyk)
+#test(luma(rgb(10, 20, 30)).space(), luma)
+
+#test(rgb(color.linear-rgb(10, 20, 30)).space(), rgb)
+#test(color.linear-rgb(color.linear-rgb(10, 20, 30)).space(), color.linear-rgb)
+#test(oklab(color.linear-rgb(10, 20, 30)).space(), oklab)
+#test(oklch(color.linear-rgb(10, 20, 30)).space(), oklch)
+#test(color.hsl(color.linear-rgb(10, 20, 30)).space(), color.hsl)
+#test(color.hsv(color.linear-rgb(10, 20, 30)).space(), color.hsv)
+#test(cmyk(color.linear-rgb(10, 20, 30)).space(), cmyk)
+#test(luma(color.linear-rgb(10, 20, 30)).space(), luma)
+
+#test(rgb(oklab(10%, 20%, 30%)).space(), rgb)
+#test(color.linear-rgb(oklab(10%, 20%, 30%)).space(), color.linear-rgb)
+#test(oklab(oklab(10%, 20%, 30%)).space(), oklab)
+#test(oklch(oklab(10%, 20%, 30%)).space(), oklch)
+#test(color.hsl(oklab(10%, 20%, 30%)).space(), color.hsl)
+#test(color.hsv(oklab(10%, 20%, 30%)).space(), color.hsv)
+#test(cmyk(oklab(10%, 20%, 30%)).space(), cmyk)
+#test(luma(oklab(10%, 20%, 30%)).space(), luma)
+
+#test(rgb(oklch(60%, 40%, 0deg)).space(), rgb)
+#test(color.linear-rgb(oklch(60%, 40%, 0deg)).space(), color.linear-rgb)
+#test(oklab(oklch(60%, 40%, 0deg)).space(), oklab)
+#test(oklch(oklch(60%, 40%, 0deg)).space(), oklch)
+#test(color.hsl(oklch(60%, 40%, 0deg)).space(), color.hsl)
+#test(color.hsv(oklch(60%, 40%, 0deg)).space(), color.hsv)
+#test(cmyk(oklch(60%, 40%, 0deg)).space(), cmyk)
+#test(luma(oklch(60%, 40%, 0deg)).space(), luma)
+
+#test(rgb(color.hsl(10deg, 20%, 30%)).space(), rgb)
+#test(color.linear-rgb(color.hsl(10deg, 20%, 30%)).space(), color.linear-rgb)
+#test(oklab(color.hsl(10deg, 20%, 30%)).space(), oklab)
+#test(oklch(color.hsl(10deg, 20%, 30%)).space(), oklch)
+#test(color.hsl(color.hsl(10deg, 20%, 30%)).space(), color.hsl)
+#test(color.hsv(color.hsl(10deg, 20%, 30%)).space(), color.hsv)
+#test(cmyk(color.hsl(10deg, 20%, 30%)).space(), cmyk)
+#test(luma(color.hsl(10deg, 20%, 30%)).space(), luma)
+
+#test(rgb(color.hsv(10deg, 20%, 30%)).space(), rgb)
+#test(color.linear-rgb(color.hsv(10deg, 20%, 30%)).space(), color.linear-rgb)
+#test(oklab(color.hsv(10deg, 20%, 30%)).space(), oklab)
+#test(oklch(color.hsv(10deg, 20%, 30%)).space(), oklch)
+#test(color.hsl(color.hsv(10deg, 20%, 30%)).space(), color.hsl)
+#test(color.hsv(color.hsv(10deg, 20%, 30%)).space(), color.hsv)
+#test(cmyk(color.hsv(10deg, 20%, 30%)).space(), cmyk)
+#test(luma(color.hsv(10deg, 20%, 30%)).space(), luma)
+
+#test(rgb(cmyk(10%, 20%, 30%, 40%)).space(), rgb)
+#test(color.linear-rgb(cmyk(10%, 20%, 30%, 40%)).space(), color.linear-rgb)
+#test(oklab(cmyk(10%, 20%, 30%, 40%)).space(), oklab)
+#test(oklch(cmyk(10%, 20%, 30%, 40%)).space(), oklch)
+#test(color.hsl(cmyk(10%, 20%, 30%, 40%)).space(), color.hsl)
+#test(color.hsv(cmyk(10%, 20%, 30%, 40%)).space(), color.hsv)
+#test(cmyk(cmyk(10%, 20%, 30%, 40%)).space(), cmyk)
+#test(luma(cmyk(10%, 20%, 30%, 40%)).space(), luma)
+
+#test(rgb(luma(10%)).space(), rgb)
+#test(color.linear-rgb(luma(10%)).space(), color.linear-rgb)
+#test(oklab(luma(10%)).space(), oklab)
+#test(oklch(luma(10%)).space(), oklch)
+#test(color.hsl(luma(10%)).space(), color.hsl)
+#test(color.hsv(luma(10%)).space(), color.hsv)
+#test(cmyk(luma(10%)).space(), cmyk)
+#test(luma(luma(10%)).space(), luma)
+
+#test(rgb(1, 2, 3).to-hex(), "#010203")
+#test(rgb(1, 2, 3, 4).to-hex(), "#01020304")
+#test(luma(40).to-hex(), "#282828")
+#test-repr(cmyk(4%, 5%, 6%, 7%).to-hex(), "#e0dcda")
+#test-repr(rgb(cmyk(4%, 5%, 6%, 7%)), rgb(87.84%, 86.27%, 85.49%, 100%))
+#test-repr(rgb(luma(40%)), rgb(40%, 40%, 40%))
+#test-repr(cmyk(luma(40)), cmyk(11.76%, 10.67%, 10.51%, 14.12%))
+#test-repr(cmyk(rgb(1, 2, 3)), cmyk(66.67%, 33.33%, 0%, 98.82%))
+#test-repr(luma(rgb(1, 2, 3)), luma(0.73%))
+#test-repr(color.hsl(luma(40)), color.hsl(0deg, 0%, 15.69%))
+#test-repr(color.hsv(luma(40)), color.hsv(0deg, 0%, 15.69%))
+#test-repr(color.linear-rgb(luma(40)), color.linear-rgb(2.12%, 2.12%, 2.12%))
+#test-repr(color.linear-rgb(rgb(1, 2, 3)), color.linear-rgb(0.03%, 0.06%, 0.09%))
+#test-repr(color.hsl(rgb(1, 2, 3)), color.hsl(-150deg, 50%, 0.78%))
+#test-repr(color.hsv(rgb(1, 2, 3)), color.hsv(-150deg, 66.67%, 1.18%))
+#test-repr(oklab(luma(40)), oklab(27.68%, 0.0, 0.0, 100%))
+#test-repr(oklab(rgb(1, 2, 3)), oklab(8.23%, -0.004, -0.007, 100%))
+#test-repr(oklch(oklab(40%, 0.2, 0.2)), oklch(40%, 0.283, 45deg, 100%))
+#test-repr(oklch(luma(40)), oklch(27.68%, 0.0, 72.49deg, 100%))
+#test-repr(oklch(rgb(1, 2, 3)), oklch(8.23%, 0.008, 240.75deg, 100%))
+
+--- color-spaces ---
+// The the different color spaces
+#let col = rgb(50%, 64%, 16%)
+#box(square(size: 9pt, fill: col))
+#box(square(size: 9pt, fill: rgb(col)))
+#box(square(size: 9pt, fill: oklab(col)))
+#box(square(size: 9pt, fill: oklch(col)))
+#box(square(size: 9pt, fill: luma(col)))
+#box(square(size: 9pt, fill: cmyk(col)))
+#box(square(size: 9pt, fill: color.linear-rgb(col)))
+#box(square(size: 9pt, fill: color.hsl(col)))
+#box(square(size: 9pt, fill: color.hsv(col)))
+
+--- color-space ---
+// Test color kind method.
+#test(rgb(1, 2, 3, 4).space(), rgb)
+#test(cmyk(4%, 5%, 6%, 7%).space(), cmyk)
+#test(luma(40).space(), luma)
+#test(rgb(1, 2, 3, 4).space() != luma, true)
+
+--- color-components ---
+// Test color '.components()' without conversions
+
+#let test-components(col, ref, has-alpha: true) = {
+ // Perform an approximate scalar comparison.
+ let are-equal((a, b)) = {
+ let to-float(x) = if type(x) == angle { x.rad() } else { float(x) }
+ let epsilon = 1e-4 // The maximum error between both numbers
+ assert.eq(type(a), type(b))
+ calc.abs(to-float(a) - to-float(b)) < epsilon
+ }
+
+ let ref-without-alpha = if has-alpha { ref.slice(0, -1) } else { ref }
+ assert.eq(col.components().len(), ref.len())
+ assert(col.components().zip(ref).all(are-equal))
+ assert(col.components(alpha: false).zip(ref-without-alpha).all(are-equal))
+}
+#test-components(rgb(1, 2, 3, 4), (0.39%, 0.78%, 1.18%, 1.57%))
+#test-components(luma(40), (15.69%, 100%))
+#test-components(luma(40, 50%), (15.69%, 50%))
+#test-components(cmyk(4%, 5%, 6%, 7%), (4%, 5%, 6%, 7%), has-alpha: false)
+#test-components(oklab(10%, 0.2, 0.4), (10%, 0.2, 0.4, 100%))
+#test-components(oklch(10%, 0.2, 90deg), (10%, 0.2, 90deg, 100%))
+#test-components(oklab(10%, 50%, 200%), (10%, 0.2, 0.8, 100%))
+#test-components(oklch(10%, 50%, 90deg), (10%, 0.2, 90deg, 100%))
+#test-components(color.linear-rgb(10%, 20%, 30%), (10%, 20%, 30%, 100%))
+#test-components(color.hsv(10deg, 20%, 30%), (10deg, 20%, 30%, 100%))
+#test-components(color.hsl(10deg, 20%, 30%), (10deg, 20%, 30%, 100%))
+
+--- color-luma ---
+// Test gray color conversion.
+#stack(dir: ltr, rect(fill: luma(0)), rect(fill: luma(80%)))
+
+--- color-rgb-out-of-range ---
+// Error for values that are out of range.
+// Error: 11-14 number must be between 0 and 255
+#test(rgb(-30, 15, 50))
+
+--- color-rgb-bad-string ---
+// Error: 6-11 color string contains non-hexadecimal letters
+#rgb("lol")
+
+--- color-rgb-missing-argument-red ---
+// Error: 2-7 missing argument: red component
+#rgb()
+
+--- color-rgb-missing-argument-blue ---
+// Error: 2-11 missing argument: blue component
+#rgb(0, 1)
+
+--- color-rgb-bad-type ---
+// Error: 21-26 expected integer or ratio, found boolean
+#rgb(10%, 20%, 30%, false)
+
+--- color-luma-unexpected-argument ---
+// Error: 10-20 unexpected argument: key
+#luma(1, key: "val")
+
+--- color-mix-bad-amount-type ---
+// Error: 12-24 expected float or ratio, found string
+// Error: 26-39 expected float or ratio, found string
+#color.mix((red, "yes"), (green, "no"), (green, 10%))
+
+--- color-mix-bad-value ---
+// Error: 12-23 expected a color or color-weight pair
+#color.mix((red, 1, 2))
+
+--- color-mix-bad-space-type ---
+// Error: 31-38 expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`, found string
+#color.mix(red, green, space: "cyber")
+
+--- color-mix-bad-space-value-1 ---
+// Error: 31-36 expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`
+#color.mix(red, green, space: image)
+
+--- color-mix-bad-space-value-2 ---
+// Error: 31-41 expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`
+#color.mix(red, green, space: calc.round)
+
+--- color-cmyk-ops ---
+// Test CMYK color conversion.
+#let c = cmyk(50%, 64%, 16%, 17%)
+#stack(
+ dir: ltr,
+ spacing: 1fr,
+ rect(width: 1cm, fill: cmyk(69%, 11%, 69%, 41%)),
+ rect(width: 1cm, fill: c),
+ rect(width: 1cm, fill: c.negate(space: cmyk)),
+)
+
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: c.lighten(x * 10%)))
+}
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: c.darken(x * 10%)))
+}
+
+--- color-outside-srgb-gamut ---
+// Colors outside the sRGB gamut.
+#box(square(size: 9pt, fill: oklab(90%, -0.2, -0.1)))
+#box(square(size: 9pt, fill: oklch(50%, 0.5, 0deg)))
+
+--- color-rotate-hue ---
+// Test hue rotation
+#let col = rgb(50%, 64%, 16%)
+
+// Oklch
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: rgb(col).rotate(x * 36deg)))
+}
+
+// HSL
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: rgb(col).rotate(x * 36deg, space: color.hsl)))
+}
+
+// HSV
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: rgb(col).rotate(x * 36deg, space: color.hsv)))
+}
+
+--- color-saturation ---
+// Test saturation
+#let col = color.hsl(180deg, 0%, 50%)
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: col.saturate(x * 10%)))
+}
+
+#let col = color.hsl(180deg, 100%, 50%)
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: col.desaturate(x * 10%)))
+}
+
+#let col = color.hsv(180deg, 0%, 50%)
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: col.saturate(x * 10%)))
+}
+
+#let col = color.hsv(180deg, 100%, 50%)
+#for x in range(0, 11) {
+ box(square(size: 9pt, fill: col.desaturate(x * 10%)))
+}
+
+--- color-luma-ops ---
+// Test gray color modification.
+#test-repr(luma(20%).lighten(50%), luma(60%))
+#test-repr(luma(80%).darken(20%), luma(64%))
+#test-repr(luma(80%).negate(space: luma), luma(20%))
+
+--- color-transparentize ---
+// Test alpha modification.
+#test-repr(luma(100%, 100%).transparentize(50%), luma(100%, 50%))
+#test-repr(luma(100%, 100%).transparentize(75%), luma(100%, 25%))
+#test-repr(luma(100%, 50%).transparentize(50%), luma(100%, 25%))
+#test-repr(luma(100%, 10%).transparentize(250%), luma(100%, 0%))
+#test-repr(luma(100%, 40%).transparentize(-50%), luma(100%, 70%))
+#test-repr(luma(100%, 0%).transparentize(-100%), luma(100%, 100%))
+
+--- color-opacify ---
+#test-repr(luma(100%, 50%).opacify(50%), luma(100%, 75%))
+#test-repr(luma(100%, 20%).opacify(100%), luma(100%, 100%))
+#test-repr(luma(100%, 100%).opacify(250%), luma(100%, 100%))
+#test-repr(luma(100%, 50%).opacify(-50%), luma(100%, 25%))
+#test-repr(luma(100%, 0%).opacify(0%), luma(100%, 0%))
+
+--- repr-color ---
+// Colors
+#set page(width: 400pt)
+#set text(0.8em)
+#blue \
+#color.linear-rgb(blue) \
+#oklab(blue) \
+#oklch(blue) \
+#cmyk(blue) \
+#color.hsl(blue) \
+#color.hsv(blue) \
+#luma(blue)
diff --git a/tests/suite/visualize/ellipse.typ b/tests/suite/visualize/ellipse.typ
new file mode 100644
index 00000000..970a795e
--- /dev/null
+++ b/tests/suite/visualize/ellipse.typ
@@ -0,0 +1,31 @@
+// Test the `ellipse` function.
+
+--- ellipse ---
+// Default ellipse.
+#ellipse()
+
+--- ellipse-auto-sizing ---
+#set rect(inset: 0pt)
+#set ellipse(inset: 0pt)
+
+Rect in ellipse in fixed rect.
+#rect(width: 3cm, height: 2cm, fill: rgb("2a631a"),
+ ellipse(fill: forest, width: 100%, height: 100%,
+ rect(fill: conifer, width: 100%, height: 100%,
+ align(center + horizon)[
+ Stuff inside an ellipse!
+ ]
+ )
+ )
+)
+
+Auto-sized ellipse.
+#ellipse(fill: conifer, stroke: 3pt + forest, inset: 3pt)[
+ #set text(8pt)
+ But, soft! what light through yonder window breaks?
+]
+
+
+An inline
+#box(ellipse(width: 8pt, height: 6pt, outset: (top: 3pt, rest: 5.5pt)))
+ellipse.
diff --git a/tests/suite/visualize/gradient.typ b/tests/suite/visualize/gradient.typ
new file mode 100644
index 00000000..1ee5489a
--- /dev/null
+++ b/tests/suite/visualize/gradient.typ
@@ -0,0 +1,631 @@
+--- gradient-linear-angled ---
+// Test gradients with direction.
+#set page(width: 90pt)
+#grid(
+ gutter: 3pt,
+ columns: 4,
+ ..range(0, 360, step: 15).map(i => box(
+ height: 15pt,
+ width: 15pt,
+ fill: gradient.linear(angle: i * 1deg, (red, 0%), (blue, 100%)),
+ ))
+)
+
+
+--- gradient-linear-oklab ---
+// The tests below test whether hue rotation works correctly.
+// Here we test in Oklab space for reference.
+#set page(
+ width: 100pt,
+ height: 30pt,
+ fill: gradient.linear(red, purple, space: oklab)
+)
+
+--- gradient-linear-oklch ---
+// Test in OkLCH space.
+#set page(
+ width: 100pt,
+ height: 30pt,
+ fill: gradient.linear(red, purple, space: oklch)
+)
+
+--- gradient-linear-hsv ---
+// Test in HSV space.
+#set page(
+ width: 100pt,
+ height: 30pt,
+ fill: gradient.linear(red, purple, space: color.hsv)
+)
+
+--- gradient-linear-hsl ---
+// Test in HSL space.
+#set page(
+ width: 100pt,
+ height: 30pt,
+ fill: gradient.linear(red, purple, space: color.hsl)
+)
+
+
+--- gradient-linear-relative-parent ---
+// The image should look as if there is a single gradient that is being used for
+// both the page and the rectangles.
+#let grad = gradient.linear(red, blue, green, purple, relative: "parent");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 50pt,
+ width: 50pt,
+ margin: 2.5pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
+
+--- gradient-linear-relative-self ---
+// The image should look as if there are multiple gradients, one for each
+// rectangle.
+#let grad = gradient.linear(red, blue, green, purple, relative: "self");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 50pt,
+ width: 50pt,
+ margin: 2.5pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
+
+--- gradient-linear-repeat-and-mirror-1 ---
+// Test repeated gradients.
+#rect(
+ height: 40pt,
+ width: 100%,
+ fill: gradient.linear(..color.map.inferno).repeat(2, mirror: true)
+)
+
+--- gradient-linear-repeat-and-mirror-2 ---
+#rect(
+ height: 40pt,
+ width: 100%,
+ fill: gradient.linear(..color.map.rainbow).repeat(2, mirror: true),
+)
+
+--- gradient-linear-repeat-and-mirror-3 ---
+#rect(
+ height: 40pt,
+ width: 100%,
+ fill: gradient.linear(..color.map.rainbow).repeat(5, mirror: true)
+)
+
+--- gradient-linear-sharp-and-repeat ---
+#rect(
+ height: 40pt,
+ width: 100%,
+ fill: gradient.linear(..color.map.rainbow).sharp(10).repeat(5, mirror: false)
+)
+
+--- gradient-linear-sharp-repeat-and-mirror ---
+#rect(
+ height: 40pt,
+ width: 100%,
+ fill: gradient.linear(..color.map.rainbow).sharp(10).repeat(5, mirror: true)
+)
+
+--- gradient-linear-sharp ---
+#square(
+ size: 100pt,
+ fill: gradient.linear(..color.map.rainbow, space: color.hsl).sharp(10),
+)
+#square(
+ size: 100pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10),
+)
+#square(
+ size: 100pt,
+ fill: gradient.conic(..color.map.rainbow, space: color.hsl).sharp(10),
+)
+
+--- gradient-linear-sharp-and-smooth ---
+#square(
+ size: 100pt,
+ fill: gradient.linear(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%),
+)
+#square(
+ size: 100pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%),
+)
+#square(
+ size: 100pt,
+ fill: gradient.conic(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%),
+)
+
+--- gradient-linear-stroke ---
+#align(center + top, square(size: 50pt, fill: black, stroke: 5pt + gradient.linear(red, blue)))
+
+--- gradient-fill-and-stroke ---
+#align(
+ center + bottom,
+ square(
+ size: 50pt,
+ fill: gradient.radial(red, blue, radius: 70.7%, focal-center: (10%, 10%)),
+ stroke: 10pt + gradient.radial(red, blue, radius: 70.7%, focal-center: (10%, 10%))
+ )
+)
+
+--- gradient-linear-line ---
+// Test gradient on lines
+#set page(width: 100pt, height: 100pt)
+#line(length: 100%, stroke: 1pt + gradient.linear(red, blue))
+#line(length: 100%, angle: 10deg, stroke: 1pt + gradient.linear(red, blue))
+#line(length: 100%, angle: 10deg, stroke: 1pt + gradient.linear(red, blue, relative: "parent"))
+
+--- gradient-radial-hsl ---
+#square(
+ size: 100pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl),
+)
+
+--- gradient-radial-center ---
+#grid(
+ columns: 2,
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (0%, 0%)),
+ ),
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (0%, 100%)),
+ ),
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (100%, 0%)),
+ ),
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (100%, 100%)),
+ ),
+)
+
+--- gradient-radial-radius ---
+#square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, radius: 10%),
+)
+#square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, radius: 72%),
+)
+
+--- gradient-radial-focal-center-and-radius ---
+#circle(
+ radius: 25pt,
+ fill: gradient.radial(white, rgb("#8fbc8f"), focal-center: (35%, 35%), focal-radius: 5%),
+)
+#circle(
+ radius: 25pt,
+ fill: gradient.radial(white, rgb("#8fbc8f"), focal-center: (75%, 35%), focal-radius: 5%),
+)
+
+--- gradient-radial-relative-parent ---
+// The image should look as if there is a single gradient that is being used for
+// both the page and the rectangles.
+#let grad = gradient.radial(red, blue, green, purple, relative: "parent");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 50pt,
+ width: 50pt,
+ margin: 2.5pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
+
+--- gradient-radial-relative-self ---
+// The image should look as if there are multiple gradients, one for each
+// rectangle.
+#let grad = gradient.radial(red, blue, green, purple, relative: "self");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 50pt,
+ width: 50pt,
+ margin: 2.5pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
+
+--- gradient-radial-text ---
+// Test that gradient fills on text.
+// The solid bar gradients are used to make sure that all transforms are
+// correct: if you can see the text through the bar, then the gradient is
+// misaligned to its reference container.
+#set page(width: 200pt, height: auto, margin: 10pt)
+#set par(justify: true)
+#set text(fill: gradient.radial(red, blue))
+#lorem(30)
+
+--- gradient-conic ---
+#square(
+ size: 50pt,
+ fill: gradient.conic(..color.map.rainbow, space: color.hsv),
+)
+
+--- gradient-conic-center-shifted-1 ---
+#square(
+ size: 50pt,
+ fill: gradient.conic(..color.map.rainbow, space: color.hsv, center: (10%, 10%)),
+)
+
+--- gradient-conic-center-shifted-2 ---
+#square(
+ size: 50pt,
+ fill: gradient.conic(..color.map.rainbow, space: color.hsv, center: (90%, 90%)),
+)
+
+--- gradient-conic-angled ---
+#square(
+ size: 50pt,
+ fill: gradient.conic(..color.map.rainbow, space: color.hsv, angle: 90deg),
+)
+
+--- gradient-conic-oklab ---
+// Test in Oklab space for reference.
+#set page(
+ width: 100pt,
+ height: 100pt,
+ fill: gradient.conic(red, purple, space: oklab)
+)
+
+--- gradient-conic-oklch ---
+// Test in OkLCH space.
+#set page(
+ width: 100pt,
+ height: 100pt,
+ fill: gradient.conic(red, purple, space: oklch)
+)
+
+--- gradient-conic-hsv ---
+// Test in HSV space.
+#set page(
+ width: 100pt,
+ height: 100pt,
+ fill: gradient.conic(red, purple, space: color.hsv)
+)
+
+--- gradient-conic-hsl ---
+// Test in HSL space.
+#set page(
+ width: 100pt,
+ height: 100pt,
+ fill: gradient.conic(red, purple, space: color.hsl)
+)
+
+--- gradient-conic-relative-parent ---
+// The image should look as if there is a single gradient that is being used for
+// both the page and the rectangles.
+#let grad = gradient.conic(red, blue, green, purple, relative: "parent");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 50pt,
+ width: 50pt,
+ margin: 2.5pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
+
+--- gradient-conic-relative-self ---
+// The image should look as if there are multiple gradients, one for each
+// rectangle.
+#let grad = gradient.conic(red, blue, green, purple, relative: "self");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 50pt,
+ width: 50pt,
+ margin: 2.5pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
+
+--- gradient-conic-stroke ---
+#align(
+ center + bottom,
+ square(
+ size: 50pt,
+ fill: black,
+ stroke: 10pt + gradient.conic(red, blue)
+ )
+)
+
+--- gradient-conic-text ---
+#set page(width: 200pt, height: auto, margin: 10pt)
+#set par(justify: true)
+#set text(fill: gradient.conic(red, blue, angle: 45deg))
+#lorem(30)
+
+--- gradient-text-bad-relative ---
+// Make sure they don't work when `relative: "self"`.
+// Hint: 17-61 make sure to set `relative: auto` on your text fill
+// Error: 17-61 gradients and patterns on text must be relative to the parent
+#set text(fill: gradient.linear(red, blue, relative: "self"))
+
+--- gradient-text-global ---
+// Test that gradient fills on text work for globally defined gradients.
+#set page(width: 200pt, height: auto, margin: 10pt, background: {
+ rect(width: 100%, height: 30pt, fill: gradient.linear(red, blue))
+})
+#set par(justify: true)
+#set text(fill: gradient.linear(red, blue))
+#lorem(30)
+
+--- gradient-text-dir ---
+// Sanity check that the direction works on text.
+#set page(width: 200pt, height: auto, margin: 10pt, background: {
+ rect(height: 100%, width: 30pt, fill: gradient.linear(dir: btt, red, blue))
+})
+#set par(justify: true)
+#set text(fill: gradient.linear(dir: btt, red, blue))
+#lorem(30)
+
+--- gradient-text-in-container ---
+// Test that gradient fills on text work for locally defined gradients.
+#set page(width: auto, height: auto, margin: 10pt)
+#show box: set text(fill: gradient.linear(..color.map.rainbow))
+Hello, #box[World]!
+
+--- gradient-text-rotate ---
+// Test that gradients fills on text work with transforms.
+#set page(width: auto, height: auto, margin: 10pt)
+#show box: set text(fill: gradient.linear(..color.map.rainbow))
+#rotate(45deg, box[World])
+
+--- gradient-text-decoration ---
+#set text(fill: gradient.linear(red, blue))
+
+Hello #underline[World]! \
+Hello #overline[World]! \
+Hello #strike[World]! \
+
+--- gradient-transformed ---
+// Test whether gradients work well when they are contained within a transform.
+#let grad = gradient.linear(red, blue, green, purple, relative: "parent");
+#let my-rect = rect(width: 50pt, height: 50pt, fill: grad)
+#set page(
+ height: 50pt,
+ width: 50pt,
+ margin: 2.5pt,
+)
+#place(top + right, scale(x: 200%, y: 130%, my-rect))
+#place(bottom + center, rotate(45deg, my-rect))
+#place(horizon + center, scale(x: 200%, y: 130%, rotate(45deg, my-rect)))
+
+--- gradient-presets ---
+// Test all gradient presets.
+#set page(width: 100pt, height: auto, margin: 0pt)
+#set text(fill: white, size: 18pt)
+#set text(top-edge: "bounds", bottom-edge: "bounds")
+
+#let presets = (
+ ("turbo", color.map.turbo),
+ ("cividis", color.map.cividis),
+ ("rainbow", color.map.rainbow),
+ ("spectral", color.map.spectral),
+ ("viridis", color.map.viridis),
+ ("inferno", color.map.inferno),
+ ("magma", color.map.magma),
+ ("plasma", color.map.plasma),
+ ("rocket", color.map.rocket),
+ ("mako", color.map.mako),
+ ("vlag", color.map.vlag),
+ ("icefire", color.map.icefire),
+ ("flare", color.map.flare),
+ ("crest", color.map.crest),
+)
+
+#stack(
+ spacing: 3pt,
+ ..presets.map(((name, preset)) => block(
+ width: 100%,
+ height: 20pt,
+ fill: gradient.linear(..preset),
+ align(center + horizon, smallcaps(name)),
+ ))
+)
+
+// Test that gradients are applied correctly on equations.
+
+--- gradient-math-cancel ---
+// Test on cancel
+#show math.equation: set text(fill: gradient.linear(..color.map.rainbow))
+#show math.equation: box
+
+$ a dot cancel(5) = cancel(25) 5 x + cancel(5) 1 $
+
+--- gradient-math-frac ---
+// Test on frac
+#show math.equation: set text(fill: gradient.linear(..color.map.rainbow))
+#show math.equation: box
+
+$ nabla dot bold(E) = frac(rho, epsilon_0) $
+
+--- gradient-math-root ---
+// Test on root
+#show math.equation: set text(fill: gradient.linear(..color.map.rainbow))
+#show math.equation: box
+
+$ x_"1,2" = frac(-b +- sqrt(b^2 - 4 a c), 2 a) $
+
+--- gradient-math-mat ---
+// Test on matrix
+#show math.equation: set text(fill: gradient.linear(..color.map.rainbow))
+#show math.equation: box
+
+$ A = mat(
+ 1, 2, 3;
+ 4, 5, 6;
+ 7, 8, 9
+) $
+
+--- gradient-math-underover ---
+// Test on underover
+#show math.equation: set text(fill: gradient.linear(..color.map.rainbow))
+#show math.equation: box
+
+$ underline(X^2) $
+$ overline("hello, world!") $
+
+--- gradient-math-dir ---
+// Test a different direction
+#show math.equation: set text(fill: gradient.linear(..color.map.rainbow, dir: ttb))
+#show math.equation: box
+
+$ A = mat(
+ 1, 2, 3;
+ 4, 5, 6;
+ 7, 8, 9
+) $
+
+$ x_"1,2" = frac(-b +- sqrt(b^2 - 4 a c), 2 a) $
+
+--- gradient-math-misc ---
+// Test miscellaneous
+#show math.equation: set text(fill: gradient.linear(..color.map.rainbow))
+#show math.equation: box
+
+$ hat(x) = bar x bar = vec(x, y, z) = tilde(x) = dot(x) $
+$ x prime = vec(1, 2, delim: "[") $
+$ sum_(i in NN) 1 + i $
+$ attach(
+ Pi, t: alpha, b: beta,
+ tl: 1, tr: 2+3, bl: 4+5, br: 6,
+) $
+
+--- gradient-math-radial ---
+// Test radial gradient
+#show math.equation: set text(fill: gradient.radial(..color.map.rainbow, center: (30%, 30%)))
+#show math.equation: box
+
+$ A = mat(
+ 1, 2, 3;
+ 4, 5, 6;
+ 7, 8, 9
+) $
+
+--- gradient-math-conic ---
+// Test conic gradient
+#show math.equation: set text(fill: gradient.conic(red, blue, angle: 45deg))
+#show math.equation: box
+
+$ A = mat(
+ 1, 2, 3;
+ 4, 5, 6;
+ 7, 8, 9
+) $
+
+
+--- gradient-kind ---
+// Test gradient functions.
+#test(gradient.linear(red, green, blue).kind(), gradient.linear)
+
+--- gradient-stops ---
+#test(gradient.linear(red, green, blue).stops(), ((red, 0%), (green, 50%), (blue, 100%)))
+
+--- gradient-sample ---
+#test(gradient.linear(red, green, blue, space: rgb).sample(0%), red)
+#test(gradient.linear(red, green, blue, space: rgb).sample(25%), rgb("#97873b"))
+#test(gradient.linear(red, green, blue, space: rgb).sample(50%), green)
+#test(gradient.linear(red, green, blue, space: rgb).sample(75%), rgb("#17a08c"))
+#test(gradient.linear(red, green, blue, space: rgb).sample(100%), blue)
+
+--- gradient-space ---
+#test(gradient.linear(red, green, space: rgb).space(), rgb)
+#test(gradient.linear(red, green, space: oklab).space(), oklab)
+#test(gradient.linear(red, green, space: oklch).space(), oklch)
+#test(gradient.linear(red, green, space: cmyk).space(), cmyk)
+#test(gradient.linear(red, green, space: luma).space(), luma)
+#test(gradient.linear(red, green, space: color.linear-rgb).space(), color.linear-rgb)
+#test(gradient.linear(red, green, space: color.hsl).space(), color.hsl)
+#test(gradient.linear(red, green, space: color.hsv).space(), color.hsv)
+
+--- gradient-relative ---
+#test(gradient.linear(red, green, relative: "self").relative(), "self")
+#test(gradient.linear(red, green, relative: "parent").relative(), "parent")
+#test(gradient.linear(red, green).relative(), auto)
+
+--- gradient-angle ---
+#test(gradient.linear(red, green).angle(), 0deg)
+#test(gradient.linear(red, green, dir: ltr).angle(), 0deg)
+#test(gradient.linear(red, green, dir: rtl).angle(), 180deg)
+#test(gradient.linear(red, green, dir: ttb).angle(), 90deg)
+#test(gradient.linear(red, green, dir: btt).angle(), 270deg)
+
+--- gradient-repeat ---
+#test(
+ gradient.linear(red, green, blue).repeat(2).stops(),
+ ((red, 0%), (green, 25%), (blue, 50%), (red, 50%), (green, 75%), (blue, 100%))
+)
+#test(
+ gradient.linear(red, green, blue).repeat(2, mirror: true).stops(),
+ ((red, 0%), (green, 25%), (blue, 50%), (green, 75%), (red, 100%))
+)
+
+--- gradient-repr ---
+// Gradients
+#set page(width: 400pt)
+#set text(0.8em)
+#gradient.linear(blue, red) \
+#gradient.linear(blue, red, dir: ttb) \
+#gradient.linear(blue, red, angle: 45deg, relative: "self") \
+#gradient.linear(blue, red, angle: 45deg, space: rgb)
+
+--- issue-2902-gradient-oklch-panic ---
+// Minimal reproduction of #2902
+#set page(width: 15cm, height: auto, margin: 1em)
+#set block(width: 100%, height: 1cm, above: 2pt)
+
+// Oklch
+#block(fill: gradient.linear(red, purple, space: oklch))
+#block(fill: gradient.linear(..color.map.rainbow, space: oklch))
+#block(fill: gradient.linear(..color.map.plasma, space: oklch))
+
+--- issue-2902-gradient-oklab-panic ---
+#set page(width: 15cm, height: auto, margin: 1em)
+#set block(width: 100%, height: 1cm, above: 2pt)
+
+// Oklab
+#block(fill: gradient.linear(red, purple, space: oklab))
+#block(fill: gradient.linear(..color.map.rainbow, space: oklab))
+#block(fill: gradient.linear(..color.map.plasma, space: oklab))
+
+--- issue-gradient-cmyk-encode ---
+// Test that CMYK works on gradients
+#set page(margin: 0pt, width: 100pt, height: auto)
+
+#let violet = cmyk(75%, 80%, 0%, 0%)
+#let blue = cmyk(75%, 30%, 0%, 0%)
+
+#rect(
+ width: 100%,
+ height: 10pt,
+ fill: gradient.linear(violet, blue)
+)
+
+#rect(
+ width: 100%,
+ height: 10pt,
+ fill: gradient.linear(rgb(violet), rgb(blue))
+)
+
+// In PDF format, this gradient can look different from the others.
+// This is because PDF readers do weird things with CMYK.
+#rect(
+ width: 100%,
+ height: 10pt,
+ fill: gradient.linear(violet, blue, space: cmyk)
+)
diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ
new file mode 100644
index 00000000..ac2d5af9
--- /dev/null
+++ b/tests/suite/visualize/image.typ
@@ -0,0 +1,122 @@
+// Test the `image` function.
+
+--- image-rgba-png-and-jpeg ---
+// Test loading different image formats.
+
+// Load an RGBA PNG image.
+#image("/assets/images/rhino.png")
+
+// Load an RGB JPEG image.
+#set page(height: 60pt)
+#image("/assets/images/tiger.jpg")
+
+--- image-sizing ---
+// Test configuring the size and fitting behaviour of images.
+
+// Set width and height explicitly.
+#box(image("/assets/images/rhino.png", width: 30pt))
+#box(image("/assets/images/rhino.png", height: 30pt))
+
+// Set width and height explicitly and force stretching.
+#image("/assets/images/monkey.svg", width: 100%, height: 20pt, fit: "stretch")
+
+// Make sure the bounding-box of the image is correct.
+#align(bottom + right, image("/assets/images/tiger.jpg", width: 40pt, alt: "A tiger"))
+
+--- image-fit ---
+// Test all three fit modes.
+#set page(height: 50pt, margin: 0pt)
+#grid(
+ columns: (1fr, 1fr, 1fr),
+ rows: 100%,
+ gutter: 3pt,
+ image("/assets/images/tiger.jpg", width: 100%, height: 100%, fit: "contain"),
+ image("/assets/images/tiger.jpg", width: 100%, height: 100%, fit: "cover"),
+ image("/assets/images/monkey.svg", width: 100%, height: 100%, fit: "stretch"),
+)
+
+--- image-jump-to-next-page ---
+// Does not fit to remaining height of page.
+#set page(height: 60pt)
+Stuff
+#image("/assets/images/rhino.png")
+
+--- image-baseline-with-box ---
+// Test baseline.
+A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
+
+--- image-svg-complex ---
+// Test advanced SVG features.
+#image("/assets/images/pattern.svg")
+
+--- image-svg-text ---
+#set page(width: 250pt)
+
+#figure(
+ image("/assets/images/diagram.svg"),
+ caption: [A textful diagram],
+)
+
+--- image-svg-text-font ---
+#set page(width: 250pt)
+#show image: set text(font: ("Roboto", "Noto Serif CJK SC"))
+
+#figure(
+ image("/assets/images/chinese.svg"),
+ caption: [Bilingual text]
+)
+
+--- image-natural-dpi-sizing ---
+// Test that images aren't upscaled.
+// Image is just 48x80 at 220dpi. It should not be scaled to fit the page
+// width, but rather max out at its natural size.
+#image("/assets/images/f2t.jpg")
+
+--- image-file-not-found ---
+// Error: 8-29 file not found (searched at tests/suite/visualize/path/does/not/exist)
+#image("path/does/not/exist")
+
+--- image-bad-format ---
+// Error: 2-22 unknown image format
+#image("./image.typ")
+
+--- image-bad-svg ---
+// Error: 2-33 failed to parse SVG (found closing tag 'g' instead of 'style' in line 4)
+#image("/assets/images/bad.svg")
+
+--- image-decode-svg ---
+// Test parsing from svg data
+#image.decode(`<svg xmlns="http://www.w3.org/2000/svg" height="140" width="500"><ellipse cx="200" cy="80" rx="100" ry="50" style="fill:yellow;stroke:purple;stroke-width:2" /></svg>`.text, format: "svg")
+
+--- image-decode-bad-svg ---
+// Error: 2-168 failed to parse SVG (missing root node)
+#image.decode(`<svg height="140" width="500"><ellipse cx="200" cy="80" rx="100" ry="50" style="fill:yellow;stroke:purple;stroke-width:2" /></svg>`.text, format: "svg")
+
+--- image-decode-detect-format ---
+// Test format auto detect
+#image.decode(read("/assets/images/tiger.jpg", encoding: none), width: 80%)
+
+--- image-decode-specify-format ---
+// Test format manual
+#image.decode(read("/assets/images/tiger.jpg", encoding: none), format: "jpg", width: 80%)
+
+--- image-decode-specify-wrong-format ---
+// Error: 2-91 failed to decode image (Format error decoding Png: Invalid PNG signature.)
+#image.decode(read("/assets/images/tiger.jpg", encoding: none), format: "png", width: 80%)
+
+--- issue-870-image-rotation ---
+// Ensure that EXIF rotation is applied.
+// https://github.com/image-rs/image/issues/1045
+// File is from https://magnushoff.com/articles/jpeg-orientation/
+#image("/assets/images/f2t.jpg", width: 10pt)
+
+--- issue-measure-image ---
+// Test that image measurement doesn't turn `inf / some-value` into 0pt.
+#context {
+ let size = measure(image("/assets/images/tiger.jpg"))
+ test(size, (width: 1024pt, height: 670pt))
+}
+
+--- issue-2051-new-cm-svg ---
+#set text(font: "New Computer Modern")
+#image("/assets/images/diagram.svg")
diff --git a/tests/suite/visualize/line.typ b/tests/suite/visualize/line.typ
new file mode 100644
index 00000000..7259f72b
--- /dev/null
+++ b/tests/suite/visualize/line.typ
@@ -0,0 +1,92 @@
+// Test lines.
+
+--- line-basic ---
+#set page(height: 60pt)
+#box({
+ set line(stroke: 0.75pt)
+ place(line(end: (0.4em, 0pt)))
+ place(line(start: (0pt, 0.4em), end: (0pt, 0pt)))
+ line(end: (0.6em, 0.6em))
+}) Hello #box(line(length: 1cm))!
+
+#line(end: (70%, 50%))
+
+--- line-positioning ---
+// Test the angle argument and positioning.
+
+#set page(fill: rgb("0B1026"))
+#set line(stroke: white)
+
+#let star(size, ..args) = box(width: size, height: size)[
+ #set text(spacing: 0%)
+ #set line(..args)
+ #set align(left)
+ #v(30%)
+ #place(line(length: +30%, start: (09.0%, 02%)))
+ #place(line(length: +30%, start: (38.7%, 02%), angle: -72deg))
+ #place(line(length: +30%, start: (57.5%, 02%), angle: 252deg))
+ #place(line(length: +30%, start: (57.3%, 02%)))
+ #place(line(length: -30%, start: (88.0%, 02%), angle: -36deg))
+ #place(line(length: +30%, start: (73.3%, 48%), angle: 252deg))
+ #place(line(length: -30%, start: (73.5%, 48%), angle: 36deg))
+ #place(line(length: +30%, start: (25.4%, 48%), angle: -36deg))
+ #place(line(length: +30%, start: (25.6%, 48%), angle: -72deg))
+ #place(line(length: +32%, start: (8.50%, 02%), angle: 34deg))
+]
+
+#align(center, grid(
+ columns: 3,
+ column-gutter: 10pt,
+ ..((star(20pt, stroke: 0.5pt),) * 9)
+))
+
+--- line-stroke ---
+// Some simple test lines
+#line(length: 60pt, stroke: red)
+#v(3pt)
+#line(length: 60pt, stroke: 2pt)
+#v(3pt)
+#line(length: 60pt, stroke: blue + 1.5pt)
+#v(3pt)
+#line(length: 60pt, stroke: (paint: red, thickness: 1pt, dash: "dashed"))
+#v(3pt)
+#line(length: 60pt, stroke: (paint: red, thickness: 4pt, cap: "round"))
+
+--- line-stroke-set ---
+// Set rules with stroke
+#set line(stroke: (paint: red, thickness: 1pt, cap: "butt", dash: "dash-dotted"))
+#line(length: 60pt)
+#v(3pt)
+#line(length: 60pt, stroke: blue)
+#v(3pt)
+#line(length: 60pt, stroke: (dash: none))
+
+--- line-stroke-dash ---
+// Dashing
+#line(length: 60pt, stroke: (paint: red, thickness: 1pt, dash: ("dot", 1pt)))
+#v(3pt)
+#line(length: 60pt, stroke: (paint: red, thickness: 1pt, dash: ("dot", 1pt, 4pt, 2pt)))
+#v(3pt)
+#line(length: 60pt, stroke: (paint: red, thickness: 1pt, dash: (array: ("dot", 1pt, 4pt, 2pt), phase: 5pt)))
+#v(3pt)
+#line(length: 60pt, stroke: (paint: red, thickness: 1pt, dash: ()))
+#v(3pt)
+#line(length: 60pt, stroke: (paint: red, thickness: 1pt, dash: (1pt, 3pt, 9pt)))
+
+--- line-stroke-field-typo ---
+// Error: 29-56 unexpected key "thicknes", valid keys are "paint", "thickness", "cap", "join", "dash", and "miter-limit"
+#line(length: 60pt, stroke: (paint: red, thicknes: 1pt))
+
+--- line-stroke-bad-dash-kind ---
+// Error: 29-55 expected "solid", "dotted", "densely-dotted", "loosely-dotted", "dashed", "densely-dashed", "loosely-dashed", "dash-dotted", "densely-dash-dotted", "loosely-dash-dotted", array, dictionary, none, or auto
+#line(length: 60pt, stroke: (paint: red, dash: "dash"))
+
+--- line-bad-point-array ---
+// Test errors.
+
+// Error: 12-19 point array must contain exactly two entries
+#line(end: (50pt,))
+
+--- line-bad-point-component-type ---
+// Error: 14-26 expected relative length, found angle
+#line(start: (3deg, 10pt), length: 5cm)
diff --git a/tests/suite/visualize/path.typ b/tests/suite/visualize/path.typ
new file mode 100644
index 00000000..10955f14
--- /dev/null
+++ b/tests/suite/visualize/path.typ
@@ -0,0 +1,52 @@
+// Test paths.
+
+--- path ---
+#set page(height: 200pt, width: 200pt)
+#table(
+ columns: (1fr, 1fr),
+ rows: (1fr, 1fr),
+ align: center + horizon,
+ path(
+ fill: red,
+ closed: true,
+ ((0%, 0%), (4%, -4%)),
+ ((50%, 50%), (4%, -4%)),
+ ((0%, 50%), (4%, 4%)),
+ ((50%, 0%), (4%, 4%)),
+ ),
+ path(
+ fill: purple,
+ stroke: 1pt,
+ (0pt, 0pt),
+ (30pt, 30pt),
+ (0pt, 30pt),
+ (30pt, 0pt),
+ ),
+ path(
+ fill: blue,
+ stroke: 1pt,
+ closed: true,
+ ((30%, 0%), (35%, 30%), (-20%, 0%)),
+ ((30%, 60%), (-20%, 0%), (0%, 0%)),
+ ((50%, 30%), (60%, -30%), (60%, 0%)),
+ ),
+ path(
+ stroke: 5pt,
+ closed: true,
+ (0pt, 30pt),
+ (30pt, 30pt),
+ (15pt, 0pt),
+ ),
+)
+
+--- path-bad-vertex ---
+// Error: 7-9 path vertex must have 1, 2, or 3 points
+#path(())
+
+--- path-bad-point-count ---
+// Error: 7-47 path vertex must have 1, 2, or 3 points
+#path(((0%, 0%), (0%, 0%), (0%, 0%), (0%, 0%)))
+
+--- path-bad-point-array ---
+// Error: 7-31 point array must contain exactly two entries
+#path(((0%, 0%), (0%, 0%, 0%)))
diff --git a/tests/suite/visualize/pattern.typ b/tests/suite/visualize/pattern.typ
new file mode 100644
index 00000000..08051ed2
--- /dev/null
+++ b/tests/suite/visualize/pattern.typ
@@ -0,0 +1,131 @@
+// Test patterns.
+
+--- pattern-line ---
+// Tests that simple patterns work.
+#set page(width: auto, height: auto, margin: 0pt)
+#let pat = pattern(size: (10pt, 10pt), line(stroke: 4pt, start: (0%, 0%), end: (100%, 100%)))
+#rect(width: 50pt, height: 50pt, fill: pat)
+
+--- pattern-lines ---
+#set page(width: auto, height: auto, margin: 0pt)
+
+#let pat = pattern(size: (10pt, 10pt), {
+ place(line(stroke: 4pt, start: (0%, 0%), end: (100%, 100%)))
+ place(line(stroke: 4pt, start: (100%,0%), end: (200%, 100%)))
+ place(line(stroke: 4pt, start: (0%,100%), end: (100%, 200%)))
+ place(line(stroke: 4pt, start: (-100%,0%), end: (0%, 100%)))
+ place(line(stroke: 4pt, start: (0%,-100%), end: (100%, 0%)))
+})
+#rect(width: 50pt, height: 50pt, fill: pat)
+
+--- pattern-relative-self ---
+// Test with relative set to `"self"`
+#let pat(..args) = pattern(size: (30pt, 30pt), ..args)[
+ #place(top + left, line(start: (0%, 0%), end: (100%, 100%), stroke: 1pt))
+ #place(top + left, line(start: (0%, 100%), end: (100%, 0%), stroke: 1pt))
+]
+
+#set page(fill: pat(), width: 100pt, height: 100pt)
+
+#rect(fill: pat(relative: "self"), width: 100%, height: 100%, stroke: 1pt)
+
+--- pattern-relative-parent ---
+// Test with relative set to `"parent"`
+#let pat(..args) = pattern(size: (30pt, 30pt), ..args)[
+ #place(top + left, line(start: (0%, 0%), end: (100%, 100%), stroke: 1pt))
+ #place(top + left, line(start: (0%, 100%), end: (100%, 0%), stroke: 1pt))
+]
+
+#set page(fill: pat(), width: 100pt, height: 100pt)
+
+#rect(fill: pat(relative: "parent"), width: 100%, height: 100%, stroke: 1pt)
+
+--- pattern-small ---
+// Tests small patterns for pixel accuracy.
+#box(
+ width: 8pt,
+ height: 1pt,
+ fill: pattern(size: (1pt, 1pt), square(size: 1pt, fill: black))
+)
+#v(-1em)
+#box(
+ width: 8pt,
+ height: 1pt,
+ fill: pattern(size: (2pt, 1pt), square(size: 1pt, fill: black))
+)
+
+--- pattern-zero-sized ---
+// Error: 15-52 pattern tile size must be non-zero
+// Hint: 15-52 try setting the size manually
+#line(stroke: pattern(path((0pt, 0pt), (1em, 0pt))))
+
+--- pattern-spacing-negative ---
+// Test with spacing set to `(-10pt, -10pt)`
+#let pat(..args) = pattern(size: (30pt, 30pt), ..args)[
+ #square(width: 100%, height: 100%, stroke: 1pt, fill: blue)
+]
+
+#set page(width: 100pt, height: 100pt)
+
+#rect(fill: pat(spacing: (-10pt, -10pt)), width: 100%, height: 100%, stroke: 1pt)
+
+--- pattern-spacing-zero ---
+// Test with spacing set to `(0pt, 0pt)`
+#let pat(..args) = pattern(size: (30pt, 30pt), ..args)[
+ #square(width: 100%, height: 100%, stroke: 1pt, fill: blue)
+]
+
+#set page(width: 100pt, height: 100pt)
+
+#rect(fill: pat(spacing: (0pt, 0pt)), width: 100%, height: 100%, stroke: 1pt)
+
+--- pattern-spacing-positive ---
+// Test with spacing set to `(10pt, 10pt)`
+#let pat(..args) = pattern(size: (30pt, 30pt), ..args)[
+ #square(width: 100%, height: 100%, stroke: 1pt, fill: blue)
+]
+
+#set page(width: 100pt, height: 100pt)
+
+#rect(fill: pat(spacing: (10pt, 10pt,)), width: 100%, height: 100%, stroke: 1pt)
+
+--- pattern-stroke ---
+// Test pattern on strokes
+#align(
+ center + top,
+ square(
+ size: 50pt,
+ stroke: 5pt + pattern(
+ size: (5pt, 5pt),
+ align(horizon + center, circle(fill: blue, radius: 2.5pt))
+ )
+ )
+)
+
+--- pattern-text ---
+// Test a pattern on some text
+// You shouldn't be able to see the text, if you can then
+// that means that the transform matrices are not being
+// applied to the text correctly.
+#let pat = pattern(
+ size: (30pt, 30pt),
+ relative: "parent",
+ square(size: 30pt, fill: gradient.conic(..color.map.rainbow))
+);
+
+#set page(
+ width: 140pt,
+ height: 140pt,
+ fill: pat
+)
+
+#rotate(45deg, scale(x: 50%, y: 70%, rect(
+ width: 100%,
+ height: 100%,
+ stroke: 1pt,
+)[
+ #lorem(10)
+
+ #set text(fill: pat)
+ #lorem(10)
+]))
diff --git a/tests/suite/visualize/polygon.typ b/tests/suite/visualize/polygon.typ
new file mode 100644
index 00000000..a3f4c8ef
--- /dev/null
+++ b/tests/suite/visualize/polygon.typ
@@ -0,0 +1,51 @@
+// Test polygons.
+
+--- polygon ---
+#set page(width: 50pt)
+#set polygon(stroke: 0.75pt, fill: blue)
+
+// These are not visible, but should also not give an error
+#polygon()
+#polygon((0em, 0pt))
+#polygon((0pt, 0pt), (10pt, 0pt))
+#polygon.regular(size: 0pt, vertices: 9)
+
+#polygon((5pt, 0pt), (0pt, 10pt), (10pt, 10pt))
+#polygon(
+ (0pt, 0pt), (5pt, 5pt), (10pt, 0pt),
+ (15pt, 5pt),
+ (5pt, 10pt)
+)
+#polygon(stroke: none, (5pt, 0pt), (0pt, 10pt), (10pt, 10pt))
+#polygon(stroke: 3pt, fill: none, (5pt, 0pt), (0pt, 10pt), (10pt, 10pt))
+
+// Relative size
+#polygon((0pt, 0pt), (100%, 5pt), (50%, 10pt))
+
+// Antiparallelogram
+#polygon((0pt, 5pt), (5pt, 0pt), (0pt, 10pt), (5pt, 15pt))
+
+// Self-intersections
+#polygon((0pt, 10pt), (30pt, 20pt), (0pt, 30pt), (20pt, 0pt), (20pt, 35pt))
+
+// Regular polygon; should have equal side lengths
+#for k in range(3, 9) {polygon.regular(size: 30pt, vertices: k,)}
+
+--- polygon-line-join ---
+// Line joins
+#stack(
+ dir: ltr,
+ spacing: 1em,
+ polygon(stroke: (thickness: 4pt, paint: blue, join: "round"),
+ (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)),
+ polygon(stroke: (thickness: 4pt, paint: blue, join: "bevel"),
+ (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)),
+ polygon(stroke: (thickness: 4pt, paint: blue, join: "miter"),
+ (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)),
+ polygon(stroke: (thickness: 4pt, paint: blue, join: "miter", miter-limit: 20.0),
+ (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)),
+)
+
+--- polygon-bad-point-array ---
+// Error: 10-17 point array must contain exactly two entries
+#polygon((50pt,))
diff --git a/tests/suite/visualize/rect.typ b/tests/suite/visualize/rect.typ
new file mode 100644
index 00000000..f84fafcb
--- /dev/null
+++ b/tests/suite/visualize/rect.typ
@@ -0,0 +1,107 @@
+// Test the `rect` function.
+
+--- rect ---
+// Default rectangle.
+#rect()
+
+--- rect-customization ---
+#set page(width: 150pt)
+
+// Fit to text.
+#rect(fill: conifer)[Textbox]
+
+// Empty with fixed width and height.
+#block(rect(
+ height: 15pt,
+ fill: rgb("46b3c2"),
+ stroke: 2pt + rgb("234994"),
+))
+
+// Fixed width, text height.
+#rect(width: 2cm, fill: rgb("9650d6"))[Fixed and padded]
+
+// Page width, fixed height.
+#rect(height: 1cm, width: 100%, fill: rgb("734ced"))[Topleft]
+
+// These are inline with text.
+{#box(rect(width: 0.5in, height: 7pt, fill: rgb("d6cd67")))
+ #box(rect(width: 0.5in, height: 7pt, fill: rgb("edd466")))
+ #box(rect(width: 0.5in, height: 7pt, fill: rgb("e3be62")))}
+
+// Rounded corners.
+#stack(
+ dir: ltr,
+ spacing: 1fr,
+ rect(width: 2cm, radius: 30%),
+ rect(width: 1cm, radius: (left: 10pt, right: 5pt)),
+ rect(width: 1.25cm, radius: (
+ top-left: 2pt,
+ top-right: 5pt,
+ bottom-right: 8pt,
+ bottom-left: 11pt
+ )),
+)
+
+// Different strokes.
+#set rect(stroke: (right: red))
+#rect(width: 100%, fill: lime, stroke: (x: 5pt, y: 1pt))
+
+--- rect-stroke ---
+// Rectangle strokes
+#rect(width: 20pt, height: 20pt, stroke: red)
+#v(3pt)
+#rect(width: 20pt, height: 20pt, stroke: (rest: red, top: (paint: blue, dash: "dashed")))
+#v(3pt)
+#rect(width: 20pt, height: 20pt, stroke: (thickness: 5pt, join: "round"))
+
+--- red-stroke-bad-type ---
+// Error: 15-21 expected length, color, gradient, pattern, dictionary, stroke, none, or auto, found array
+#rect(stroke: (1, 2))
+
+--- rect-fill-stroke ---
+#let variant = rect.with(width: 20pt, height: 10pt)
+#let items = for (i, item) in (
+ variant(stroke: none),
+ variant(),
+ variant(fill: none),
+ variant(stroke: 2pt),
+ variant(stroke: eastern),
+ variant(stroke: eastern + 2pt),
+ variant(fill: eastern),
+ variant(fill: eastern, stroke: none),
+ variant(fill: forest, stroke: none),
+ variant(fill: forest, stroke: conifer),
+ variant(fill: forest, stroke: black + 2pt),
+ variant(fill: forest, stroke: conifer + 2pt),
+).enumerate() {
+ (align(horizon)[#(i + 1).], item, [])
+}
+
+#grid(
+ columns: (auto, auto, 1fr, auto, auto, 0fr),
+ gutter: 5pt,
+ ..items,
+)
+
+--- rect-radius-bad-key ---
+// Error: 15-38 unexpected key "cake", valid keys are "top-left", "top-right", "bottom-right", "bottom-left", "left", "top", "right", "bottom", and "rest"
+#rect(radius: (left: 10pt, cake: 5pt))
+
+--- issue-1825-rect-overflow ---
+#set page(width: 17.8cm)
+#set par(justify: true)
+#rect(lorem(70))
+
+--- issue-3264-rect-negative-dimensions ---
+// Negative dimensions
+#rect(width: -1cm, fill: gradient.linear(red, blue))[Reverse left]
+
+#rect(width: 1cm, fill: gradient.linear(red, blue))[Left]
+
+#align(center, rect(width: -1cm, fill: gradient.linear(red, blue))[Reverse center])
+
+#align(center, rect(width: 1cm, fill: gradient.linear(red, blue))[Center])
+
+#align(right, rect(width: -1cm, fill: gradient.linear(red, blue))[Reverse right])
+
+#align(right, rect(width: 1cm, fill: gradient.linear(red, blue))[Right])
diff --git a/tests/suite/visualize/square.typ b/tests/suite/visualize/square.typ
new file mode 100644
index 00000000..caa1fc21
--- /dev/null
+++ b/tests/suite/visualize/square.typ
@@ -0,0 +1,146 @@
+// Test the `square` function.
+
+--- square ---
+// Default square.
+#box(square())
+#box(square[hey!])
+
+--- square-auto-sized ---
+// Test auto-sized square.
+#square(fill: eastern)[
+ #set text(fill: white, weight: "bold")
+ Typst
+]
+
+--- square-relatively-sized-child ---
+// Test relative-sized child.
+#square(fill: eastern)[
+ #rect(width: 10pt, height: 5pt, fill: conifer)
+ #rect(width: 40%, height: 5pt, stroke: conifer)
+]
+
+--- square-contents-overflow ---
+// Test text overflowing height.
+#set page(width: 75pt, height: 100pt)
+#square(fill: conifer)[
+ But, soft! what light through yonder window breaks?
+]
+
+--- square-height-limited ---
+// Test that square does not overflow page.
+#set page(width: 100pt, height: 75pt)
+#square(fill: conifer)[
+ But, soft! what light through yonder window breaks?
+]
+
+--- square-size-width-and-height ---
+// Size wins over width and height.
+// Error: 09-20 unexpected argument: width
+#square(width: 10cm, height: 20cm, size: 1cm, fill: rgb("eb5278"))
+
+--- square-relative-size ---
+// Test relative width and height and size that is smaller
+// than default size.
+#set page(width: 120pt, height: 70pt)
+#set align(bottom)
+#let centered = align.with(center + horizon)
+#stack(
+ dir: ltr,
+ spacing: 1fr,
+ square(width: 50%, centered[A]),
+ square(height: 50%),
+ stack(
+ square(size: 10pt),
+ square(size: 20pt, centered[B])
+ ),
+)
+
+--- square-circle-alignment ---
+// Test alignment in automatically sized square and circle.
+#set text(8pt)
+#box(square(inset: 4pt)[
+ Hey there, #align(center + bottom, rotate(180deg, [you!]))
+])
+#box(circle(align(center + horizon, [Hey.])))
+
+--- square-circle-overspecified ---
+// Test that minimum wins if both width and height are given.
+#stack(
+ dir: ltr,
+ spacing: 2pt,
+ square(width: 20pt, height: 40pt),
+ circle(width: 20%, height: 100pt),
+)
+
+--- square-height-limited-stack ---
+// Test square that is limited by region size.
+#set page(width: 20pt, height: 10pt, margin: 0pt)
+#stack(dir: ltr, square(fill: forest), square(fill: conifer))
+
+--- square-overflow ---
+// Test that square doesn't overflow due to its aspect ratio.
+#set page(width: 40pt, height: 25pt, margin: 5pt)
+#square(width: 100%)
+#square(width: 100%)[Hello there]
+
+--- square-size-relative-invalid ---
+// Size cannot be relative because we wouldn't know
+// relative to which axis.
+// Error: 15-18 expected length or auto, found ratio
+#square(size: 50%)
+
+--- square-rect-rounded ---
+#set square(size: 20pt, stroke: 4pt)
+
+// no radius for non-rounded corners
+#stack(
+ dir: ltr,
+ square(),
+ h(10pt),
+ square(radius: 0pt),
+ h(10pt),
+ square(radius: -10pt),
+)
+
+#stack(
+ dir: ltr,
+ square(),
+ h(10pt),
+ square(radius: 0%),
+ h(10pt),
+ square(radius: -10%),
+)
+
+// small values for small radius
+#stack(
+ dir: ltr,
+ square(radius: 1pt),
+ h(10pt),
+ square(radius: 5%),
+ h(10pt),
+ square(radius: 2pt),
+)
+
+// large values for large radius or circle
+#stack(
+ dir: ltr,
+ square(radius: 8pt),
+ h(10pt),
+ square(radius: 10pt),
+ h(10pt),
+ square(radius: 12pt),
+)
+
+#stack(
+ dir: ltr,
+ square(radius: 45%),
+ h(10pt),
+ square(radius: 50%),
+ h(10pt),
+ square(radius: 55%),
+)
+
+--- square-base ---
+// Test that square sets correct base for its content.
+#set page(height: 80pt)
+#square(width: 40%, rect(width: 60%, height: 80%))
diff --git a/tests/suite/visualize/stroke.typ b/tests/suite/visualize/stroke.typ
new file mode 100644
index 00000000..b03c96c5
--- /dev/null
+++ b/tests/suite/visualize/stroke.typ
@@ -0,0 +1,171 @@
+// Test lines.
+
+--- stroke-constructor ---
+// Converting to stroke
+#assert.eq(stroke(red).paint, red)
+#assert.eq(stroke(red).thickness, auto)
+#assert.eq(stroke(2pt).paint, auto)
+#assert.eq(stroke((cap: "round", paint: blue)).cap, "round")
+#assert.eq(stroke((cap: auto, paint: blue)).cap, auto)
+#assert.eq(stroke((cap: auto, paint: blue)).thickness, auto)
+
+// Constructing with named arguments
+#assert.eq(stroke(paint: blue, thickness: 8pt), 8pt + blue)
+#assert.eq(stroke(thickness: 2pt), stroke(2pt))
+#assert.eq(stroke(cap: "round").thickness, auto)
+#assert.eq(stroke(cap: "round", thickness: auto).thickness, auto)
+
+--- stroke-constructor-unknown-key ---
+// Error: 9-21 unexpected key "foo", valid keys are "paint", "thickness", "cap", "join", "dash", and "miter-limit"
+#stroke((foo: "bar"))
+
+--- stroke-fields-simple ---
+// Test stroke fields for simple strokes.
+#test((1em + blue).paint, blue)
+#test((1em + blue).thickness, 1em)
+#test((1em + blue).cap, auto)
+#test((1em + blue).join, auto)
+#test((1em + blue).dash, auto)
+#test((1em + blue).miter-limit, auto)
+
+--- stroke-fields-complex ---
+// Test complex stroke fields.
+#let r1 = rect(stroke: (paint: cmyk(1%, 2%, 3%, 4%), thickness: 4em + 2pt, cap: "round", join: "bevel", miter-limit: 5.0, dash: none))
+#let r2 = rect(stroke: (paint: cmyk(1%, 2%, 3%, 4%), thickness: 4em + 2pt, cap: "round", join: "bevel", miter-limit: 5.0, dash: (3pt, "dot", 4em)))
+#let r3 = rect(stroke: (paint: cmyk(1%, 2%, 3%, 4%), thickness: 4em + 2pt, cap: "round", join: "bevel", dash: (array: (3pt, "dot", 4em), phase: 5em)))
+#let s1 = r1.stroke
+#let s2 = r2.stroke
+#let s3 = r3.stroke
+#test(s1.paint, cmyk(1%, 2%, 3%, 4%))
+#test(s1.thickness, 4em + 2pt)
+#test(s1.cap, "round")
+#test(s1.join, "bevel")
+#test(s1.miter-limit, 5.0)
+#test(s3.miter-limit, auto)
+#test(s1.dash, none)
+#test(s2.dash, (array: (3pt, "dot", 4em), phase: 0pt))
+#test(s3.dash, (array: (3pt, "dot", 4em), phase: 5em))
+
+--- stroke-zero-thickness ---
+// 0pt strokes must function exactly like 'none' strokes and not draw anything
+#rect(width: 10pt, height: 10pt, stroke: none)
+#rect(width: 10pt, height: 10pt, stroke: 0pt)
+#rect(width: 10pt, height: 10pt, stroke: none, fill: blue)
+#rect(width: 10pt, height: 10pt, stroke: 0pt + red, fill: blue)
+
+#line(length: 30pt, stroke: 0pt)
+#line(length: 30pt, stroke: (paint: red, thickness: 0pt, dash: ("dot", 1pt)))
+
+#table(columns: 2, stroke: none)[A][B]
+#table(columns: 2, stroke: 0pt)[A][B]
+
+#path(
+ fill: red,
+ stroke: none,
+ closed: true,
+ ((0%, 0%), (4%, -4%)),
+ ((50%, 50%), (4%, -4%)),
+ ((0%, 50%), (4%, 4%)),
+ ((50%, 0%), (4%, 4%)),
+)
+
+#path(
+ fill: red,
+ stroke: 0pt,
+ closed: true,
+ ((0%, 0%), (4%, -4%)),
+ ((50%, 50%), (4%, -4%)),
+ ((0%, 50%), (4%, 4%)),
+ ((50%, 0%), (4%, 4%)),
+)
+
+--- stroke-text ---
+#set text(size: 20pt)
+#set page(width: auto)
+#let v = [测试字体Test]
+
+#text(stroke: 0.3pt + red, v)
+
+#text(stroke: 0.7pt + red, v)
+
+#text(stroke: 7pt + red, v)
+
+#text(stroke: (paint: blue, thickness: 1pt, dash: "dashed"), v)
+
+#text(stroke: 1pt + gradient.linear(..color.map.rainbow), v)
+
+--- stroke-folding ---
+// Test stroke folding.
+#let sq(..args) = box(square(size: 10pt, ..args))
+
+#set square(stroke: none)
+#sq()
+#set square(stroke: auto)
+#sq()
+#sq(fill: teal)
+#sq(stroke: 2pt)
+#sq(stroke: blue)
+#sq(fill: teal, stroke: blue)
+#sq(fill: teal, stroke: 2pt + blue)
+
+--- stroke-composition ---
+// Test stroke composition.
+#set square(stroke: 4pt)
+#set text(font: "Roboto")
+#stack(
+ dir: ltr,
+ square(
+ stroke: (left: red, top: yellow, right: green, bottom: blue),
+ radius: 50%, align(center+horizon)[*G*],
+ inset: 8pt
+ ),
+ h(0.5cm),
+ square(
+ stroke: (left: red, top: yellow + 8pt, right: green, bottom: blue + 2pt),
+ radius: 50%, align(center+horizon)[*G*],
+ inset: 8pt
+ ),
+ h(0.5cm),
+ square(
+ stroke: (left: red, top: yellow, right: green, bottom: blue),
+ radius: 100%, align(center+horizon)[*G*],
+ inset: 8pt
+ ),
+)
+
+// Join between different solid strokes
+#set square(size: 20pt, stroke: 2pt)
+#set square(stroke: (left: green + 4pt, top: black + 2pt, right: blue, bottom: black + 2pt))
+#stack(
+ dir: ltr,
+ square(),
+ h(0.2cm),
+ square(radius: (top-left: 0pt, rest: 1pt)),
+ h(0.2cm),
+ square(radius: (top-left: 0pt, rest: 8pt)),
+ h(0.2cm),
+ square(radius: (top-left: 0pt, rest: 100pt)),
+)
+
+// Join between solid and dotted strokes
+#set square(stroke: (left: green + 4pt, top: black + 2pt, right: (paint: blue, dash: "dotted"), bottom: (paint: black, dash: "dotted")))
+#stack(
+ dir: ltr,
+ square(),
+ h(0.2cm),
+ square(radius: (top-left: 0pt, rest: 1pt)),
+ h(0.2cm),
+ square(radius: (top-left: 0pt, rest: 8pt)),
+ h(0.2cm),
+ square(radius: (top-left: 0pt, rest: 100pt)),
+)
+
+--- issue-3700-deformed-stroke ---
+// Test shape fill & stroke for specific values that used to make the stroke
+// deformed.
+#rect(
+ radius: 1mm,
+ width: 100%,
+ height: 10pt,
+ stroke: (left: rgb("46b3c2") + 16.0mm),
+)