diff --git a/packages/preview/synkit/0.0.41/LICENSE b/packages/preview/synkit/0.0.41/LICENSE
new file mode 100644
index 0000000000..a3b5d38d51
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Guilherme D. Garcia
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/preview/synkit/0.0.41/README.md b/packages/preview/synkit/0.0.41/README.md
new file mode 100644
index 0000000000..46bb2a7b17
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/README.md
@@ -0,0 +1,104 @@
+
+
+`synkit` is a Typst package for drawing syntax trees from bracket notation. It focuses on fast authoring, clean output, and the kinds of features syntacticians and semanticists actually need in day-to-day work.
+
+## Philosophy
+
+There are two key design choices for this package. First, the syntax should be minimal, intuitive, and readable. As a result, a tree is separated from its add-ons (arrows, highlights, etc.) inside the `#tree()` function. Second, functions should be smart enough to detect patterns, which helps minimize the amount of code you have to type. These two points are clearly connected to each other.
+
+Here are some examples illustrating this philosophy.
+
+1. Labels are automatically created. Every word or node you add to a tree automatically becomes a label that can later be targeted by an arrow, an annotation, or by an aesthetic adjustment (color, highlight, etc.).
+2. Triangles are automatically added to trees. `[XP content]` will trigger a triangle, but `[XP [X' [X content ] ] ]` will not. So, while you _can_ add triangles manually, you will likely never have to do that.
+3. Terminal branches aren't displayed by default (e.g., no line/link between a terminal node and its content `[X content]`). You can activate them if you so choose, but by default they won't be there.
+4. When you define a tree, spaces don't matter so much: `[NP[Det][N]]` is the same as `[NP [Det ] [N] ]` or any other equivalent string. Thus, you will no longer get syntax errors if you forget a space between two `]]` (cf. `tikz-qtree` in LaTeX).
+
+## Some examples
+
+
+
+
+
+ Tree with arrows indicating movement
+
+
+
+ Semantic annotation and multidominance
+
+
+
+ Equivalences between two different trees
+
+
+
+
+
+ Adjust color, font, and add emojis for less serious trees
+
+
+
+ In-line movement with minimal syntax
+
+
+
+ Examples and glosses
+
+
+
+
+## Installation
+
+```typst
+#import "@preview/synkit:0.0.41": *
+```
+
+If you are working from a local clone instead, import `lib.typ` directly:
+
+```typst
+#import "synkit/lib.typ": *
+```
+
+## Highlights
+
+- Draw syntax trees with flexible bracket notation using `#tree()`
+- Add movement arrows, curved paths, delinking, and trace targeting
+- Don't worry about creating triangles manually: they are automatically added based on phrase structure
+- Add multidominance and cross-tree equivalence lines between two trees using `#garden()`
+- Add semantic annotation between node labels and branches
+- Create numbered examples with `#eg()` and interlinear glosses with `#gloss()`
+- Adjust spacing, direction, scale, highlighting, numbering, and colors with lightweight arguments
+- Smart labels ensure that you never have to create labels yourself: every word, node and even emoji is its own label
+
+Literal square brackets inside labels can be written as `\[` and `\]`, which is useful for Adger-style feature bundles such as `DP_i\[wh, ~uOP~: INT\]`.
+
+## Manual
+
+Download the [**manual**](https://doi.org/10.5281/zenodo.19405774) for a comprehensive description of each function available.
+
+## Repository
+
+- GitHub:
+
+## Author
+
+**Guilherme D. Garcia**
+Email:
+Website:
+
+## License
+
+MIT
diff --git a/packages/preview/synkit/0.0.41/_symbols.typ b/packages/preview/synkit/0.0.41/_symbols.typ
new file mode 100644
index 0000000000..4295516218
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/_symbols.typ
@@ -0,0 +1,83 @@
+// Shared symbol map (LaTeX-style shortcuts → Unicode)
+// Used by syntax.typ and movement.typ
+
+#let symbol-map = (
+ // Greek lowercase
+ "\\alpha": "α",
+ "\\beta": "β",
+ "\\gamma": "γ",
+ "\\delta": "δ",
+ "\\epsilon": "ε",
+ "\\zeta": "ζ",
+ "\\eta": "η",
+ "\\theta": "θ",
+ "\\iota": "ι",
+ "\\kappa": "κ",
+ "\\lambda": "λ",
+ "\\mu": "μ",
+ "\\nu": "ν",
+ "\\xi": "ξ",
+ "\\pi": "π",
+ "\\rho": "ρ",
+ "\\sigma": "σ",
+ "\\tau": "τ",
+ "\\upsilon": "υ",
+ "\\phi": "φ",
+ "\\chi": "χ",
+ "\\psi": "ψ",
+ "\\omega": "ω",
+ // Greek uppercase
+ "\\Alpha": "Α",
+ "\\Beta": "Β",
+ "\\Gamma": "Γ",
+ "\\Delta": "Δ",
+ "\\Epsilon": "Ε",
+ "\\Zeta": "Ζ",
+ "\\Eta": "Η",
+ "\\Theta": "Θ",
+ "\\Iota": "Ι",
+ "\\Kappa": "Κ",
+ "\\Lambda": "Λ",
+ "\\Mu": "Μ",
+ "\\Nu": "Ν",
+ "\\Xi": "Ξ",
+ "\\Pi": "Π",
+ "\\Rho": "Ρ",
+ "\\Sigma": "Σ",
+ "\\Tau": "Τ",
+ "\\Upsilon": "Υ",
+ "\\Phi": "Φ",
+ "\\Chi": "Χ",
+ "\\Psi": "Ψ",
+ "\\Omega": "Ω",
+ // Common symbols
+ "\\emptyset": "∅",
+ "\\varnothing": "∅",
+ "\\forall": "∀",
+ "\\exists": "∃",
+ "\\rightarrow": "→",
+ "\\leftarrow": "←",
+ "\\infty": "∞",
+ "\\root": "√",
+ // Logical operators
+ "\\wedge": "∧",
+ "\\land": "∧",
+ "\\vee": "∨",
+ "\\lor": "∨",
+ "\\neg": "¬",
+ "\\to": "→",
+ "\\iff": "⇔",
+ "\\models": "⊨",
+ "\\proves": "⊢",
+)
+
+// Apply symbol substitutions to a string
+#let apply-symbols(s) = {
+ let result = s
+ for (key, val) in symbol-map {
+ if result.contains(key) {
+ result = result.replace(key, val)
+ }
+ }
+ result
+}
diff --git a/packages/preview/synkit/0.0.41/eg.typ b/packages/preview/synkit/0.0.41/eg.typ
new file mode 100644
index 0000000000..df4814b517
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/eg.typ
@@ -0,0 +1,313 @@
+// Linguistic example environment with automatic numbering
+// Similar to linguex's \ex. command in LaTeX
+//
+// Create a numbered linguistic example
+//
+// Generates numbered examples (1), (2), etc. similar to linguex in LaTeX.
+//
+// Arguments:
+// - body (content): The example content
+// - number-dy (length): Vertical offset for the number (optional; default: 0.4em)
+// - caption (string): Caption for outline (hidden in document; optional)
+// - title (content): Optional title shown on the same line as the number
+// - labels (array): Optional labels for sub-examples in list mode (e.g., (, ))
+//
+// Returns: Numbered example that can be labeled and referenced
+//
+// Smart modes — detected automatically from body content:
+//
+// Single item (auto-numbered):
+// #eg[#move("[CP Who ...]", arrows: (...))]
+//
+// Sub-examples with list syntax (auto-lettered):
+// #eg[
+// - Who do you think saw Mary?
+// - #move("[CP Who ...]", arrows: (...))
+// ]
+//
+// Sub-examples with labels:
+// #eg(labels: (, ))[
+// - Who do you think saw Mary?
+// - #move("[CP Who ...]", arrows: (...))
+// ]
+//
+// Legacy table mode (when body is a table — backward compatible):
+// #eg()[
+// #table(
+// columns: 3,
+// stroke: none,
+// align: (left + bottom, left + bottom, left + top),
+// [#eg-num-label()], [#subex-label()], [sentence a],
+// [], [#subex-label()], [sentence b],
+// )
+// ]
+
+// Counters
+#let example-counter = counter("linguistic-example")
+#let subex-counter = counter("linguistic-subexample")
+
+// Alphabet for sub-example lettering (a, b, c...)
+#let letters = "abcdefghijklmnopqrstuvwxyz"
+
+// Classify body content to determine rendering mode
+// Typst's `- item` syntax creates list.item elements in a sequence (not wrapped in a list)
+#let _classify-body(body) = {
+ if body.func() == list.item { return "list" }
+ if body.func() == table { return "legacy" }
+ if body.has("children") {
+ for child in body.children {
+ if child.func() == list.item { return "list" }
+ if child.func() == table { return "legacy" }
+ }
+ }
+ "single"
+}
+
+// Extract list items from body (may be nested in a sequence)
+#let _extract-items(body) = {
+ if body.func() == list.item { return (body,) }
+ if body.has("children") {
+ return body.children.filter(c => c.func() == list.item)
+ }
+ ()
+}
+
+// Build a grid from list items with auto numbering and lettering
+#let _build-subex-grid(items, labels, numbered: true) = {
+ let cells = ()
+ for (i, item) in items.enumerate() {
+ // Column 1: example number on first row only (skip when title handles numbering)
+ if numbered {
+ let num-cell = if i == 0 {
+ context {
+ subex-counter.update(0)
+ example-counter.step()
+ [(#(example-counter.get().first() + 1))]
+ }
+ } else { [] }
+ cells.push(num-cell)
+ }
+
+ // Column 2: sub-example letter (a., b., c.)
+ let letter-fig = figure(
+ box(baseline: 0pt, context {
+ set par(first-line-indent: 0em)
+ subex-counter.step()
+ let n = subex-counter.get().first()
+ [#letters.at(n).]
+ }),
+ kind: "linguistic-subexample",
+ supplement: none,
+ numbering: none,
+ )
+ // Attach label if provided (label must immediately follow figure, no whitespace)
+ let letter-cell = if labels != () and i < labels.len() {
+ [#letter-fig#labels.at(i)]
+ } else {
+ letter-fig
+ }
+
+ cells.push(letter-cell)
+ cells.push(item.body)
+ }
+
+ let col-spec = if numbered {
+ (2em, 2em, 1fr)
+ } else {
+ (2em, 1fr)
+ }
+
+ grid(
+ columns: col-spec,
+ row-gutter: 1em,
+ align: left + bottom,
+ ..cells,
+ )
+}
+
+// Main example function
+#let eg(
+ number-dy: 0.4em,
+ caption: none,
+ title: none,
+ labels: (),
+ body,
+) = {
+ // Build the smart body content (list or plain)
+ // numbered: false when title branch handles the example number
+ let _build-smart-body(body, labels, numbered: true) = {
+ let mode = _classify-body(body)
+ if mode == "list" {
+ let items = _extract-items(body)
+ _build-subex-grid(items, labels, numbered: numbered)
+ } else {
+ body
+ }
+ }
+
+ let content = if title != none {
+ // Title case: (num | title) / ([] | smart body)
+ let num = context {
+ subex-counter.update(0)
+ example-counter.step()
+ [(#(example-counter.get().first() + 1))]
+ }
+ let smart-body = _build-smart-body(body, labels, numbered: false)
+ grid(
+ columns: (auto, 1fr),
+ column-gutter: 0.75em,
+ row-gutter: 0.3em,
+ align: (left + top, left + top),
+ num, title,
+ [], smart-body,
+ )
+ } else {
+ let mode = _classify-body(body)
+ if mode == "list" {
+ // List mode: auto number + auto letter sub-examples
+ let items = _extract-items(body)
+ _build-subex-grid(items, labels)
+ } else if mode == "single" {
+ // Single-item mode: auto number to the left
+ let num = context {
+ subex-counter.update(0)
+ example-counter.step()
+ [(#(example-counter.get().first() + 1))]
+ }
+ grid(
+ columns: (auto, 1fr),
+ column-gutter: 0.75em,
+ align: (left + bottom, left + bottom),
+ num, body,
+ )
+ } else {
+ // Legacy table mode: user manages numbering via eg-num-label() / subex-label()
+ let step = context {
+ subex-counter.update(0)
+ example-counter.step()
+ []
+ }
+ grid(
+ columns: (auto, 1fr),
+ column-gutter: 0pt,
+ align: (left + top, left + top),
+ step, body,
+ )
+ }
+ }
+ figure(
+ align(left, content),
+ caption: if caption != none { caption } else { none },
+ outlined: caption != none,
+ kind: "linguistic-example",
+ supplement: none,
+ numbering: "(1)",
+ placement: none,
+ gap: 0pt,
+ )
+}
+
+// Display the current example number inside an eg() body.
+//
+// Use as the first-column cell of a 3-column table (num | sub-label | content)
+// when no title is provided. Because it lives in the same table, it can share
+// bottom alignment with the sub-example labels and the sentence text.
+//
+// Example:
+// #eg(caption: "Example")[
+// #table(
+// columns: 3,
+// stroke: none,
+// align: (left + bottom, left + bottom, left + top),
+// [#eg-num-label()], [#subex-label()], [sentence a],
+// [], [#subex-label()], [sentence b],
+// )
+// ]
+#let eg-num-label() = {
+ // No figure wrapper — a plain box behaves consistently with subex-label()
+ // in table cells without requiring explicit bottom alignment.
+ box(baseline: 0pt, context {
+ set par(first-line-indent: 0em)
+ let n = example-counter.get().first()
+ [(#n)]
+ })
+}
+
+// Create a sub-example label for use in tables
+//
+// Generates automatic lettering (a., b., c., ...) for table rows.
+// Place in the first column of each row and attach a label after it.
+//
+// Returns: Labelable letter marker (a., b., c., ...)
+//
+// Example:
+// #eg(caption: "A syntax example")[
+// #table(
+// columns: 4,
+// stroke: none,
+// align: left,
+// [#subex-label()], [sentence a],
+// [#subex-label()], [sentence b],
+// )
+// ]
+//
+// See @eg-syn, @eg-a, and @eg-b.
+#let subex-label() = {
+ // Figure must be outermost so labels attach to it (not to context)
+ // Box with baseline ensures proper vertical alignment in table cells
+ // Reset first-line-indent to avoid misalignment in documents with paragraph indentation
+ figure(
+ box(baseline: 0pt, context {
+ set par(first-line-indent: 0em)
+ subex-counter.step()
+ let n = subex-counter.get().first()
+ // get() returns value BEFORE step, so n=0,1,2... gives a,b,c...
+ [#letters.at(n).]
+ }),
+ kind: "linguistic-subexample",
+ supplement: none,
+ numbering: none,
+ )
+}
+
+// Show rules for linguistic examples
+//
+// Apply this to enable proper reference formatting for eg() and subex-label().
+// References render as (1), (1a), (1b), etc.
+//
+// Usage: #show: eg-rules
+#let eg-rules(doc) = {
+ show ref: it => {
+ let el = it.element
+ if el != none and el.func() == figure {
+ if el.kind == "linguistic-example" {
+ // Reference to main example: (1)
+ // at() returns value before step, so add 1
+ link(el.location(), context {
+ let loc = el.location()
+ let num = example-counter.at(loc).first() + 1
+ [(#num)]
+ })
+ } else if el.kind == "linguistic-subexample" {
+ // Reference to sub-example: (1a)
+ // Subex is inside parent eg, so example-counter already stepped (no +1)
+ // Subex counter: at() returns value before step (0,1,2...)
+ link(el.location(), context {
+ let loc = el.location()
+ let parent-num = example-counter.at(loc).first()
+ let letter-num = subex-counter.at(loc).first()
+ let letter = letters.at(letter-num)
+ [(#parent-num#letter)]
+ })
+ } else {
+ it
+ }
+ } else {
+ it
+ }
+ }
+ // Hide captions in document (they still appear in outline)
+ show figure.where(kind: "linguistic-example"): it => it.body
+ show figure.where(kind: "linguistic-subexample"): it => it.body
+ doc
+}
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_1.png b/packages/preview/synkit/0.0.41/gallery/tree_1.png
new file mode 100644
index 0000000000..833269d805
Binary files /dev/null and b/packages/preview/synkit/0.0.41/gallery/tree_1.png differ
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_1.typ b/packages/preview/synkit/0.0.41/gallery/tree_1.typ
new file mode 100644
index 0000000000..4745c652f4
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/gallery/tree_1.typ
@@ -0,0 +1,14 @@
+#import "@preview/synkit:0.0.41": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+Tree from Carnie (2013).
+
+#tree(
+ "[ CP [] [ C' [ C Ø_{\[+Q\]+T+Mangez} ] [ TP [ DP vous ] [ T' [ T *t*_i ] [ VP [ *t*_{DP} ] [ V' [V *t*_i ] [DP des pommes] ] ] ] ] ] ]",
+ arrows: (
+ (from: "trace3", to: "T1"),
+ (from: "trace2", to: "DP1"),
+ (from: "trace1", to: "C1"),
+ ),
+ curved: true,
+)
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_2.png b/packages/preview/synkit/0.0.41/gallery/tree_2.png
new file mode 100644
index 0000000000..c8eea9bc3c
Binary files /dev/null and b/packages/preview/synkit/0.0.41/gallery/tree_2.png differ
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_2.typ b/packages/preview/synkit/0.0.41/gallery/tree_2.typ
new file mode 100644
index 0000000000..e9d4741179
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/gallery/tree_2.typ
@@ -0,0 +1,50 @@
+#import "@preview/synkit:0.0.41": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+Tree from Fox & Johnson (2016, p. 7).
+
+#tree(
+ "[IP [IP [IP [IP [DP† [D the_2] [\\muP every woman] ] [IP [I] [VP is smiling] ] ] [IP [and] [IP [DP‡ [D the_2] [\\muP every man] ] [IP [I] [VP is frowning] ] ] ] ] [\\lambda2] ] [QP [Q \\forall ] [\\muP\\* [\\muP] [CP who came in together] ] ] ]",
+ annotation: (
+ (
+ "IP1",
+ [$forall$_y_ [_y_ is a woman+man $and$ _y_ came in together] $arrow$ \
+ [the woman part of _y_ is smiling and the man part of _y_ is frowning]],
+ ),
+ (
+ "IP2",
+ [$lambda$_x_ : _x_ has a has a unique maximal woman part \
+ and a unique maximal man part. \
+ the woman part of x is smiling and \
+ the man part of x is frowning],
+ ),
+ (
+ "QP1",
+ [$lambda$_Q_$forall$_y_[_y_ is woman+man \
+ $and$ _y_ came in together] $arrow$ _Q(y)_],
+ ),
+ (
+ "IP3",
+ [the woman part of g(2) is smiling \
+ and the man part of g(2) is frowning],
+ ),
+ (
+ "DP†1",
+ [the woman part \
+ of g(2)],
+ ),
+ (
+ "DP‡1",
+ [the man part \
+ of g(2)],
+ ),
+ ),
+ annotation-size: 0.8,
+ dominance: (
+ (from: "muP4", to: "muP1", ctrl: (-6.1, 8.5)),
+ (from: "muP4", to: "muP2", ctrl: (-6, 5)),
+ ),
+ scale: 0.8,
+ spread: 0.8,
+ terminal-branch: true,
+)
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_3.png b/packages/preview/synkit/0.0.41/gallery/tree_3.png
new file mode 100644
index 0000000000..b6ca59d3be
Binary files /dev/null and b/packages/preview/synkit/0.0.41/gallery/tree_3.png differ
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_3.typ b/packages/preview/synkit/0.0.41/gallery/tree_3.typ
new file mode 100644
index 0000000000..b147e7a02c
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/gallery/tree_3.typ
@@ -0,0 +1,28 @@
+#import "@preview/synkit:0.0.41": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+Tree adapted from David Chiang's tutorial on `tikz-qtree`.
+
+#garden(
+ (
+ input: "[S [NP [Det the] [N cat]] [VP [V sat] [PP [P on] [NP [Det the] [N mat]]]]]",
+ spread: 1.55,
+ content-size: 1,
+ ),
+ (
+ input: "[S [NP 猫が] [VP [PP [NP [NP マット] [Part の] [NP 上] ] [P に]] [V 座った]]]",
+ direction: "up",
+ content-size: 1,
+ ),
+ equivalence: (
+ ("Det1-1", "NP1-2"),
+ ("P1-1", "P1-2"),
+ ("P1-1", "NP4-2"),
+ ("N1-1", "NP1-2"),
+ ("N2-1", "NP3-2"),
+ ("Det2-1", "NP3-2"),
+ ("V1-1", "V1-2"),
+ ),
+ gap: 2.5,
+ scale: 0.7,
+)
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_4.png b/packages/preview/synkit/0.0.41/gallery/tree_4.png
new file mode 100644
index 0000000000..577cacd92b
Binary files /dev/null and b/packages/preview/synkit/0.0.41/gallery/tree_4.png differ
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_4.typ b/packages/preview/synkit/0.0.41/gallery/tree_4.typ
new file mode 100644
index 0000000000..34c13d1bea
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/gallery/tree_4.typ
@@ -0,0 +1,23 @@
+#import "@preview/synkit:0.0.41": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+A less serious tree.
+
+#tree(
+ "[S [NP the 🐈] [VP[V sat][PP[P on] [NP the mat]]]]",
+ content-size: 1,
+ drop: 0.8,
+ spread: 1.5,
+ dash-branches: (
+ ("VP1", "V1"),
+ ("S1", "VP1"),
+ ),
+ color: (
+ ("S1", green.darken(20%)),
+ ("NP1", red),
+ ("NP2", red),
+ ("P1down", blue),
+ ("VP1", "V1", orange),
+ ),
+ font: "Comic Sans MS",
+)
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_5.png b/packages/preview/synkit/0.0.41/gallery/tree_5.png
new file mode 100644
index 0000000000..e415845f42
Binary files /dev/null and b/packages/preview/synkit/0.0.41/gallery/tree_5.png differ
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_5.typ b/packages/preview/synkit/0.0.41/gallery/tree_5.typ
new file mode 100644
index 0000000000..e8ca810c8c
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/gallery/tree_5.typ
@@ -0,0 +1,13 @@
+#import "@preview/synkit:0.0.41": *
+#show: eg-rules
+#set page(height: 3cm, width: 10cm, margin: (bottom: 1em, top: 1em, x: 1em))
+
+In-line movement coupled with a numbered example.
+
+#eg(labels: (, ))[
+ - Who do you think saw Mary?
+ - #move(
+ "[CP Who do you think [(CP)[TPsaw Mary]]]",
+ arrows: ((from: "who2", to: "who1", dash: "solid", color: black),),
+ )
+]
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_6.png b/packages/preview/synkit/0.0.41/gallery/tree_6.png
new file mode 100644
index 0000000000..dc9700ca10
Binary files /dev/null and b/packages/preview/synkit/0.0.41/gallery/tree_6.png differ
diff --git a/packages/preview/synkit/0.0.41/gallery/tree_6.typ b/packages/preview/synkit/0.0.41/gallery/tree_6.typ
new file mode 100644
index 0000000000..9fa147a1d1
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/gallery/tree_6.typ
@@ -0,0 +1,10 @@
+#import "@preview/synkit:0.0.41": *
+#show: eg-rules
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#gloss(spacing: 1.2em, caption: [An example from Portuguese])[
+ - eu gosto de maçã
+ - I like-{1sg.prs} of apple
+ - 'I like apples.'
+]
+
diff --git a/packages/preview/synkit/0.0.41/gloss.typ b/packages/preview/synkit/0.0.41/gloss.typ
new file mode 100644
index 0000000000..61d8dc216c
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/gloss.typ
@@ -0,0 +1,300 @@
+// Interlinear glossed examples with automatic numbering
+// Creates aligned word-by-word glosses; wrap text in {} for small-caps
+//
+// Generates numbered glossed examples that share the (1), (2)... sequence with eg().
+//
+// Arguments:
+// - body (content): List items — all but last are alignment lines, last is free translation
+// - per (int or none): Lines per sub-gloss group (none = single gloss)
+// - labels (array): Optional labels for sub-glosses (e.g., (, ))
+// - caption (string): Caption for outline (hidden in document; optional)
+// - spacing (length): Vertical spacing between gloss lines (default: 0.75em)
+// - title (content or none): Title line shown above the gloss (not space-parsed)
+//
+// Returns: Numbered glossed example that can be labeled and referenced
+//
+// Single gloss:
+// #gloss()[
+// - eu gosto de maçã
+// - I like-{1sg.prs} of apple
+// - 'I like apples'
+// ]
+//
+// With title:
+// #gloss(title: [Inuktitut])[
+// - Qasuiirsarvigssarsingitluinarnarpuq
+// - tired not cause-to-be place-for suitable ...
+// - 'Someone did not find a completely suitable resting place.'
+// ]
+//
+// Sub-glosses:
+// #gloss(per: 3, labels: (, ))[
+// - eu gosto de maçã
+// - I like-{1sg.prs} of apple
+// - 'I like apples'
+// - elle aime les pommes
+// - she like.{3sg.prs} the.{pl} apple.{pl}
+// - 'She likes apples'
+// ]
+
+#import "eg.typ": example-counter, letters, subex-counter
+
+// Extract list items from body (may be nested in a sequence)
+#let _extract-items(body) = {
+ if body.func() == list.item { return (body,) }
+ if body.has("children") {
+ return body.children.filter(c => c.func() == list.item)
+ }
+ ()
+}
+
+// Recursively convert Typst content to a plain string
+// Handles text nodes, sequences, and spaces
+#let _content-to-string(c) = {
+ if type(c) == str { return c }
+ if c.has("text") { return c.text }
+ if c.has("children") {
+ let result = ""
+ for child in c.children {
+ if child.has("text") { result += child.text } else if child.has("children") {
+ for grandchild in child.children {
+ if grandchild.has("text") { result += grandchild.text } else { result += " " }
+ }
+ } else { result += " " }
+ }
+ return result
+ }
+ " "
+}
+
+// Format a single gloss token: text inside {} gets small-capped
+// "like-{1prs.sg}" → like- + sc(1prs.sg)
+#let _format-gloss-token(token) = {
+ if not token.contains("{") { return [#token] }
+ // Split by { and } to find small-cap regions
+ let result = []
+ let remaining = token
+ while remaining.contains("{") {
+ let open = remaining.position("{")
+ let before = remaining.slice(0, open)
+ remaining = remaining.slice(open + 1)
+ if remaining.contains("}") {
+ let close = remaining.position("}")
+ let sc-text = remaining.slice(0, close)
+ remaining = remaining.slice(close + 1)
+ result = result + [#before] + smallcaps[#sc-text]
+ } else {
+ // No closing brace, treat { as literal
+ result = result + [#before\{#remaining]
+ remaining = ""
+ }
+ }
+ if remaining != "" {
+ result = result + [#remaining]
+ }
+ result
+}
+
+// Tokenize alignment lines into arrays of tokens
+#let _tokenize-lines(line-strings) = {
+ line-strings.map(line => line.split(regex("\\s+")).filter(s => s != ""))
+}
+
+// Build a full gloss grid: optional prefix columns + aligned tokens + free translation row
+// prefix-cols: number of extra columns before the token columns (for number/letter)
+// prefix-cells: flat array of cells to prepend to each row (length = prefix-cols per row)
+#let _build-full-gloss(items, spacing, title: none, prefix-cols: 0, prefix-cells: (), escape: ()) = {
+ let n = items.len()
+ let align-items = items.slice(0, n - 1)
+ let free-trans = items.last().body
+
+ let line-strings = align-items.map(it => _content-to-string(it.body))
+ let tokenized = _tokenize-lines(line-strings)
+ // Escaped lines don't contribute to column count (they span all columns)
+ let non-escaped-tokens = tokenized.enumerate().filter(((i, _)) => i not in escape).map(((_, t)) => t)
+ let max-token-cols = if non-escaped-tokens.len() > 0 {
+ calc.max(..non-escaped-tokens.map(t => t.len()))
+ } else {
+ 1
+ }
+ let total-cols = prefix-cols + max-token-cols
+
+ let cells = ()
+
+ // Title row (spans all columns; steals first alignment row's prefix cells)
+ if title != none {
+ if prefix-cols > 0 {
+ for p in range(prefix-cols) {
+ if p < prefix-cells.len() {
+ cells.push(prefix-cells.at(p))
+ } else {
+ cells.push([])
+ }
+ }
+ }
+ cells.push(grid.cell(colspan: max-token-cols, title))
+ }
+
+ // Alignment rows
+ for (row-idx, row) in tokenized.enumerate() {
+ // Prefix cells for this row (skip first row's prefix if title already used them)
+ if prefix-cols > 0 {
+ let base = row-idx * prefix-cols
+ for p in range(prefix-cols) {
+ if title != none and row-idx == 0 {
+ cells.push([])
+ } else if base + p < prefix-cells.len() {
+ cells.push(prefix-cells.at(base + p))
+ } else {
+ cells.push([])
+ }
+ }
+ }
+ // Token cells (escaped lines span all token columns without tokenization)
+ if row-idx in escape {
+ cells.push(grid.cell(colspan: max-token-cols, align-items.at(row-idx).body))
+ } else {
+ for col in range(max-token-cols) {
+ if col < row.len() {
+ cells.push(_format-gloss-token(row.at(col)))
+ } else {
+ cells.push([])
+ }
+ }
+ }
+ }
+
+ // Free translation row (spans all token columns)
+ if prefix-cols > 0 {
+ for _ in range(prefix-cols) { cells.push([]) }
+ }
+ cells.push(grid.cell(colspan: max-token-cols, free-trans))
+
+ let col-spec = ()
+ for _ in range(prefix-cols) { col-spec.push(2em) }
+ for _ in range(max-token-cols) { col-spec.push(auto) }
+
+ grid(
+ columns: col-spec,
+ column-gutter: 0.75em,
+ row-gutter: spacing,
+ align: left + bottom,
+ ..cells,
+ )
+}
+
+// Main gloss function
+#let gloss(
+ per: none,
+ labels: (),
+ caption: none,
+ spacing: 0.75em,
+ title: none,
+ escape: (),
+ body,
+) = {
+ let items = _extract-items(body)
+
+ let content = if per != none and items.len() > per {
+ // Sub-glosses mode: chunk items into groups of `per`
+ let groups = ()
+ let i = 0
+ while i + per <= items.len() {
+ groups.push(items.slice(i, i + per))
+ i += per
+ }
+ if i < items.len() {
+ groups.push(items.slice(i))
+ }
+
+ // Build each sub-gloss as a flat grid, then stack them
+ let sub-blocks = ()
+ for (gi, group) in groups.enumerate() {
+ // Build prefix cells: number (first row only) + letter (first row only)
+ let n-align-lines = group.len() - 1 // all but free translation
+ let prefix = ()
+ for row in range(n-align-lines) {
+ if row == 0 {
+ // Number cell
+ if gi == 0 {
+ prefix.push(context {
+ subex-counter.update(0)
+ example-counter.step()
+ [(#(example-counter.get().first() + 1))]
+ })
+ } else {
+ prefix.push([])
+ }
+ // Letter cell
+ let letter-fig = figure(
+ box(baseline: 0pt, context {
+ set par(first-line-indent: 0em)
+ subex-counter.step()
+ let n = subex-counter.get().first()
+ [#letters.at(n).]
+ }),
+ kind: "linguistic-subexample",
+ supplement: none,
+ numbering: none,
+ )
+ let letter-cell = if labels != () and gi < labels.len() {
+ [#letter-fig#labels.at(gi)]
+ } else {
+ letter-fig
+ }
+ prefix.push(letter-cell)
+ } else {
+ prefix.push([]) // empty number
+ prefix.push([]) // empty letter
+ }
+ }
+
+ let group-title = if gi == 0 { title } else { none }
+ sub-blocks.push(_build-full-gloss(
+ group,
+ spacing,
+ title: group-title,
+ prefix-cols: 2,
+ prefix-cells: prefix,
+ escape: escape,
+ ))
+ }
+
+ stack(dir: ttb, spacing: spacing * 2, ..sub-blocks)
+ } else {
+ // Single gloss mode: number in prefix column
+ let n-align-lines = items.len() - 1
+ let prefix = ()
+ for row in range(n-align-lines) {
+ if row == 0 {
+ prefix.push(context {
+ subex-counter.update(0)
+ example-counter.step()
+ [(#(example-counter.get().first() + 1))]
+ })
+ } else {
+ prefix.push([])
+ }
+ }
+
+ _build-full-gloss(
+ items,
+ spacing,
+ title: title,
+ prefix-cols: 1,
+ prefix-cells: prefix,
+ escape: escape,
+ )
+ }
+
+ figure(
+ align(left, content),
+ caption: if caption != none { caption } else { none },
+ outlined: caption != none,
+ kind: "linguistic-example",
+ supplement: none,
+ numbering: "(1)",
+ placement: none,
+ gap: 0pt,
+ )
+}
diff --git a/packages/preview/synkit/0.0.41/lib.typ b/packages/preview/synkit/0.0.41/lib.typ
new file mode 100644
index 0000000000..f23b49d2fa
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/lib.typ
@@ -0,0 +1,95 @@
+// synkit — A toolkit to draw syntax trees
+// Version 0.1.0
+
+#import "syntax.typ": garden, tree
+#import "movement.typ": blank, move
+#import "eg.typ": eg, eg-rules
+#import "gloss.typ": gloss
+
+/// Draw a syntax tree from bracket notation.
+/// Optional arguments include spacing, arrows, annotations, color,
+/// numbering, font controls, and `show-refs`; see the package manual for the
+/// full list.
+///
+/// ```typ
+/// #tree("[CP [C' [C did] [TP [DP she] [T' [T e] [VP [V leave]]]]]]")
+/// ```
+#let tree = tree
+
+/// Draw multiple syntax trees in a single canvas with cross-tree equivalence lines.
+///
+/// Each positional argument is a spec dict with the tree-specific keys supported
+/// by `garden()` (`input`, `direction`, `spread`, `drop`, `content-size`,
+/// `node-size`, `triangle`, `terminal-branch`, `bottom`).
+/// Node anchors are suffixed with tree index: `"np1-1"` = first NP in tree 1.
+///
+/// ```typ
+/// #garden(
+/// (input: "[S [NP the cat] [VP [V sat]]]", direction: "down"),
+/// (input: "[S [NP 猫が] [VP [V 座った]]]", direction: "up"),
+/// equivalence: (("np1-1", "np1-2"),),
+/// gap: 2.0,
+/// )
+/// ```
+#let garden = garden
+
+/// Draw inline movement notation with subscripted bracket labels and
+/// rectangular arrows below the text. Optional arguments include `arrows`,
+/// `delinks`, `scale`, `content-size`, `line-width`, and `protect`.
+///
+/// ```typ
+/// #move(
+/// "[CP Who do you think [(CP)[TPsaw Mary]]]",
+/// arrows: ((from: "who2", to: "who1", dash: "solid", color: black),),
+/// )
+/// ```
+#let move = move
+
+/// Render a blank underline representing an empty position.
+/// Use `width` to adjust its length.
+///
+/// ```typ
+/// The word #blank(width: 3em) means "house".
+/// ```
+#let blank = blank
+
+/// Numbered linguistic example with automatic (1), (2)... numbering.
+/// Wrap content directly for a single example, or use list syntax for
+/// automatically lettered sub-examples. Use `labels` to make individual
+/// sub-examples referenceable.
+/// Apply `#show: eg-rules` to enable reference formatting.
+/// Optional arguments include `caption`, `title`, and `labels`.
+///
+/// ```typ
+/// #eg(labels: (, ))[
+/// - Who do you think saw Mary?
+/// - #move(
+/// "[CP Who do you think [(CP)[TPsaw Mary]]]",
+/// arrows: ((from: "who2", to: "who1", dash: "solid", color: black),),
+/// )
+/// ]
+/// ```
+#let eg = eg
+
+/// Show rules for linguistic examples.
+///
+/// Apply this to enable proper reference formatting for eg().
+/// References render as (1), (1a), (1b), etc.
+///
+/// Usage: `#show: eg-rules`
+#let eg-rules = eg-rules
+
+/// Create an interlinear glossed example with automatic numbering.
+/// Shares the same (1), (2)... sequence as eg().
+/// Apply `#show: eg-rules` to enable reference formatting.
+/// Optional arguments include `per`, `labels`, `caption`, `spacing`,
+/// `title`, and `escape`.
+///
+/// ```typ
+/// #gloss()[
+/// - eu gosto de maçã
+/// - I like.1prs.sg.pres of apple
+/// - 'I like apples'
+/// ]
+/// ```
+#let gloss = gloss
diff --git a/packages/preview/synkit/0.0.41/movement.typ b/packages/preview/synkit/0.0.41/movement.typ
new file mode 100644
index 0000000000..54479a7f3d
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/movement.typ
@@ -0,0 +1,573 @@
+// Movement module for synkit
+// Draws inline movement notation with subscripted bracket labels
+// and rectangular arrows below the text.
+// Usage: #move("[CP Who do you think [(CP)[TPsaw Mary]]]",
+// arrows: ((from: "who2", to: "who1"),))
+
+#import "@preview/cetz:0.5.2"
+#import "_symbols.typ": apply-symbols as _apply-symbols, symbol-map as _symbol-map
+
+// Draw a delink mark (two perpendicular bars) at a point along a line.
+#let _draw-delink(mx, my, tang-x, tang-y, sw) = {
+ let len = calc.sqrt(tang-x * tang-x + tang-y * tang-y)
+ if len == 0 { return }
+ let dir-x = tang-x / len
+ let dir-y = tang-y / len
+ let perp-x = -dir-y
+ let perp-y = dir-x
+ let bar = 0.15
+ let gap = 0.03
+ for sign in (-1, 1) {
+ let cx = mx + sign * gap * dir-x
+ let cy = my + sign * gap * dir-y
+ cetz.draw.line(
+ (cx - bar * perp-x, cy - bar * perp-y),
+ (cx + bar * perp-x, cy + bar * perp-y),
+ stroke: sw,
+ )
+ }
+}
+
+/// Render a blank underline, representing an empty position (e.g. where
+/// something used to be before moving). Use standalone in regular text.
+#let blank(width: 2em) = box(
+ width: width, height: 0.8em, baseline: 50%,
+ stroke: (bottom: 0.5pt + black),
+)
+
+// Internal helpers — shared between _move-layout and move() rendering
+// so measurements stay consistent.
+#let _blank-box() = box(width: 2em, height: 0.8em, stroke: (bottom: 0.5pt + black))
+
+#let _t-trace-box(subscript, fsz, sub-fsz) = {
+ text(size: fsz, style: "italic", "t") + text(size: sub-fsz, baseline: 0.2em, style: "italic", _apply-symbols(subscript))
+}
+
+// ── Classify a word buffer into word / blank / t-trace ────────────────────
+#let _classify-word(buf) = {
+ if buf == "__" {
+ (type: "blank")
+ } else if buf.len() >= 3 and buf.starts-with("t_") {
+ (type: "t-trace", subscript: buf.slice(2))
+ } else {
+ (type: "word", text: buf)
+ }
+}
+
+// ── Move tokenizer ──────────────────────────────────────────────────────────
+// Splits inline movement notation into tokens:
+// [LABEL → bracket-open with label
+// [(LABEL) → bracket-open with parenthesized label
+// ] → bracket-close
+// → trace marker (visible, creates anchor)
+// word → bare word
+#let _move-tokenize(input) = {
+ let tokens = ()
+ let chars = input.clusters()
+ let i = 0
+ let buf = ""
+ let n = chars.len()
+
+ while i < n {
+ let ch = chars.at(i)
+
+ if ch == "[" {
+ // Flush word buffer
+ if buf != "" {
+ tokens.push(_classify-word(buf))
+ buf = ""
+ }
+ // Scan label after [
+ i = i + 1
+ let label = ""
+ if i < n and chars.at(i) == "(" {
+ // Parenthesized label: [(LABEL)
+ let paren-buf = "("
+ i = i + 1
+ while i < n and chars.at(i) != ")" {
+ paren-buf = paren-buf + chars.at(i)
+ i = i + 1
+ }
+ if i < n {
+ paren-buf = paren-buf + ")"
+ i = i + 1
+ } // consume )
+ label = paren-buf
+ } else {
+ // Regular label: [LABEL (until whitespace, [, ], <, or end)
+ while i < n and chars.at(i) != " " and chars.at(i) != "[" and chars.at(i) != "]" and chars.at(i) != "<" {
+ label = label + chars.at(i)
+ i = i + 1
+ }
+ }
+ tokens.push((type: "bracket-open", label: label))
+ } else if ch == "]" {
+ if buf != "" {
+ tokens.push(_classify-word(buf))
+ buf = ""
+ }
+ tokens.push((type: "bracket-close"))
+ i = i + 1
+ } else if ch == "<" {
+ if buf != "" {
+ tokens.push(_classify-word(buf))
+ buf = ""
+ }
+ // Scan trace name until >
+ i = i + 1
+ let name = ""
+ while i < n and chars.at(i) != ">" {
+ name = name + chars.at(i)
+ i = i + 1
+ }
+ if i < n { i = i + 1 } // consume >
+ tokens.push((type: "trace", name: name))
+ } else if ch == " " {
+ if buf != "" {
+ tokens.push(_classify-word(buf))
+ buf = ""
+ }
+ i = i + 1
+ } else {
+ buf = buf + ch
+ i = i + 1
+ }
+ }
+ if buf != "" { tokens.push(_classify-word(buf)) }
+ tokens
+}
+
+// ── Assign anchors to move tokens ───────────────────────────────────────────
+// Words and traces share a common counter pool (case-insensitive).
+// Bracket labels get their own anchors too.
+// Strip punctuation and symbol shortcuts for anchor naming
+// \lambda → lambda, chocolate? → chocolate
+#let _strip-anchor(s) = {
+ let result = s
+ // Strip subscript suffix: Who_i → Who
+ let upos = result.position("_")
+ if upos != none and upos > 0 {
+ result = result.slice(0, upos)
+ }
+ // Strip formatting markers
+ for p in ("**", "*", "@", "&", "~") {
+ result = result.replace(p, "")
+ }
+ for p in ("?", "!", ".", ",", ";", ":", "'", "'") {
+ result = result.replace(p, "")
+ }
+ // Strip backslash from symbol shortcuts: \lambda → lambda
+ for (key, _val) in _symbol-map {
+ if result.contains(key) {
+ result = result.replace(key, key.slice(1))
+ }
+ }
+ result
+}
+
+// ── Format a word for move(): handles *italic*, **bold**, @smallcaps@,
+// &underline&, ~strike~, and Word_i subscripts.
+// Returns Typst content ready for rendering.
+#let _move-format-word(raw, fsz, sub-fsz) = {
+ // Split subscript: Who_i → main="Who", sub-text="i"
+ let main = raw
+ let sub-text = none
+ let upos = raw.position("_")
+ if upos != none and upos > 0 {
+ main = raw.slice(0, upos)
+ sub-text = raw.slice(upos + 1)
+ }
+
+ // Detect formatting markers
+ let is-bold = main.starts-with("**") and main.ends-with("**") and main.len() >= 5
+ let is-italic = not is-bold and main.starts-with("*") and main.ends-with("*") and main.len() >= 3
+ let is-smallcaps = main.starts-with("@") and main.ends-with("@") and main.len() >= 3
+ let is-underline = main.starts-with("&") and main.ends-with("&") and main.len() >= 3
+ let is-strike = main.starts-with("~") and main.ends-with("~") and main.len() >= 3
+
+ // Strip markers
+ let inner = if is-bold { main.slice(2, main.len() - 2) }
+ else if is-italic { main.slice(1, main.len() - 1) }
+ else if is-smallcaps { main.slice(1, main.len() - 1) }
+ else if is-underline { main.slice(1, main.len() - 1) }
+ else if is-strike { main.slice(1, main.len() - 1) }
+ else { main }
+
+ // Apply symbol substitution
+ let display = _apply-symbols(inner)
+
+ // Apply formatting
+ let body = if is-bold { text(size: fsz, weight: "bold", display) }
+ else if is-italic { text(size: fsz, style: "italic", display) }
+ else if is-smallcaps { text(size: fsz, smallcaps(display)) }
+ else if is-underline { text(size: fsz, underline(display)) }
+ else if is-strike { text(size: fsz, strike(display)) }
+ else { text(size: fsz, _apply-symbols(main)) }
+
+ // Append subscript
+ if sub-text != none {
+ body + text(size: sub-fsz, baseline: 0.2em, style: "italic", _apply-symbols(sub-text))
+ } else {
+ body
+ }
+}
+
+#let _move-assign-anchors(tokens) = {
+ let counts = (:)
+ let result = ()
+ for tok in tokens {
+ if tok.type == "word" {
+ let key = _strip-anchor(lower(tok.text)).replace("'", "bar").replace("'", "bar").replace(" ", "-")
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ result.push((..tok, anchor: key + str(c)))
+ } else if tok.type == "trace" {
+ let key = lower(tok.name).replace("'", "bar").replace("'", "bar").replace(" ", "-")
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ result.push((..tok, anchor: key + str(c)))
+ } else if tok.type == "blank" {
+ let key = "trace"
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ result.push((..tok, anchor: key + str(c)))
+ } else if tok.type == "t-trace" {
+ let key = "trace"
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ result.push((..tok, anchor: key + str(c)))
+ } else if tok.type == "bracket-open" and tok.label != "" {
+ // Strip parens for anchor naming: (CP) → cp
+ let raw = tok.label.replace("(", "").replace(")", "")
+ let key = lower(raw).replace("'", "bar").replace("'", "bar").replace(" ", "-")
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ result.push((..tok, anchor: key + str(c)))
+ } else {
+ result.push((..tok, anchor: none))
+ }
+ }
+ result
+}
+
+// ── Compute layout positions for move tokens (measure-based) ────────────────
+// Must be called inside a context block. Uses measure() for accurate widths.
+// Returns array of layout entries with x-positions, plus total width.
+#let _move-layout(tokens, fsz, sub-fsz, scale-factor) = {
+ let unit = scale-factor * 1cm // canvas unit length
+ let space-w = measure(text(size: fsz, " ")).width / unit
+ let x = 0.0
+ let entries = ()
+ let prev-type = none
+
+ for tok in tokens {
+ if tok.type == "bracket-open" {
+ // Space before bracket if preceded by word, trace, or bracket-close
+ if prev-type == "word" or prev-type == "trace" or prev-type == "bracket-close" or prev-type == "blank" or prev-type == "t-trace" {
+ x = x + space-w
+ }
+ let entry-x = x
+ let bw = measure(text(size: fsz, "[")).width / unit
+ x = x + bw
+ // Subscript label width
+ let sub-w = 0.0
+ if tok.label != "" {
+ sub-w = measure(text(size: sub-fsz, _apply-symbols(tok.label))).width / unit
+ x = x + sub-w
+ }
+ entries.push((..tok, x: entry-x, width: bw + sub-w, bw: bw, anchor-x: entry-x + bw * 0.5))
+ prev-type = "bracket-open"
+ } else if tok.type == "bracket-close" {
+ // No space before closing bracket
+ let entry-x = x
+ let bw = measure(text(size: fsz, "]")).width / unit
+ x = x + bw
+ entries.push((..tok, x: entry-x, width: bw, anchor-x: entry-x))
+ prev-type = "bracket-close"
+ } else if tok.type == "trace" {
+ if prev-type != none { x = x + space-w }
+ let entry-x = x
+ let display = "⟨" + _apply-symbols(tok.name) + "⟩"
+ let w = measure(text(size: fsz, display)).width / unit
+ x = x + w
+ entries.push((..tok, x: entry-x, width: w, anchor-x: entry-x + w * 0.5))
+ prev-type = "trace"
+ } else if tok.type == "word" {
+ if prev-type != none { x = x + space-w }
+ let entry-x = x
+ let w = measure(_move-format-word(tok.text, fsz, sub-fsz)).width / unit
+ x = x + w
+ entries.push((..tok, x: entry-x, width: w, anchor-x: entry-x + w * 0.5))
+ prev-type = "word"
+ } else if tok.type == "blank" {
+ if prev-type != none { x = x + space-w }
+ let entry-x = x
+ let w = measure(_blank-box()).width / unit
+ x = x + w
+ entries.push((..tok, x: entry-x, width: w, anchor-x: entry-x + w * 0.5))
+ prev-type = "blank"
+ } else if tok.type == "t-trace" {
+ if prev-type != none { x = x + space-w }
+ let entry-x = x
+ let w = measure(_t-trace-box(tok.subscript, fsz, sub-fsz)).width / unit
+ x = x + w
+ entries.push((..tok, x: entry-x, width: w, anchor-x: entry-x + w * 0.5))
+ prev-type = "t-trace"
+ }
+ }
+ (entries: entries, total-width: x)
+}
+
+/// Draw inline movement notation with subscripted bracket labels and
+/// rectangular arrows below the text.
+///
+/// - input (string): Bracket notation with movement markers.
+/// - `[LABEL ...]` — opening bracket with subscript label
+/// - `[(LABEL)...]` — bracket with parenthesized subscript label
+/// - `` — visible trace/copy marker; creates a named anchor
+/// - bare words — rendered as plain text; auto-create anchors
+///
+/// - arrows (array): Movement arrows drawn below the text.
+/// Each entry is a dict with `from`, `to`, and optional `dash`, `color`,
+/// `line-width`. Anchors are auto-numbered by occurrence (case-insensitive):
+/// "Who" → `who1`, `` → `who2`.
+/// - protect (bool): When `true`, reserves vertical space for arrows below the
+/// text. Use this for standalone output such as gallery PNG exports. The
+/// default `false` keeps baseline alignment cleaner inside table cells and
+/// numbered examples. (default: `false`)
+///
+/// Example:
+/// ```
+/// #move(
+/// "[CP Who do you think [(CP)[TPsaw Mary]]]",
+/// arrows: ((from: "who2", to: "who1", dash: "solid", color: black),),
+/// )
+/// ```
+#let move(
+ input,
+ arrows: (),
+ delinks: (),
+ scale: 1.0,
+ content-size: 1,
+ line-width: 1.0,
+ protect: false,
+) = {
+ let scale-factor = scale
+
+ // Tokenize and assign anchors
+ let tokens = _move-tokenize(input)
+ let tokens = _move-assign-anchors(tokens)
+
+ // Render (layout computed inside context for accurate measure())
+ context {
+ let fsz = content-size * 1em / scale-factor
+ let sub-fsz = fsz * 0.65
+
+ // Layout with real measurements
+ let layout = _move-layout(tokens, fsz, sub-fsz, scale-factor)
+ let entries = layout.entries
+
+ // Build name-to-pos mapping: anchor → x-center
+ let name-to-pos = (:)
+ for e in entries {
+ if e.anchor != none {
+ name-to-pos.insert(e.anchor, e.anchor-x)
+ }
+ }
+ let _norm = luma(15%)
+ let sw = 0.018 * line-width
+ let arrow-mark-scale = 0.5
+
+ // Render the canvas, then trim it to text-only height so table cells
+ // with bottom alignment put "b." at the text level.
+ let unit = scale-factor * 1cm
+ let box-w = layout.total-width * unit
+
+ let canvas-body = cetz.canvas(length: unit, {
+ import cetz.draw: *
+
+ // ── Render text elements ──────────────────────────────
+ // Fixed-height box ensures consistent baseline alignment across all elements.
+ let ref-h = measure(text(size: fsz, "Hgy[")).height
+ let _aligned(body) = box(height: ref-h, align(horizon, body))
+
+ for e in entries {
+ if e.type == "bracket-open" {
+ content(
+ (e.x, 0),
+ _aligned(text(size: fsz, "[")),
+ anchor: "west",
+ )
+ // Draw subscript label
+ if e.label != "" {
+ content(
+ (e.x + e.bw, -0.08),
+ text(size: sub-fsz, _apply-symbols(e.label)),
+ anchor: "north-west",
+ )
+ }
+ } else if e.type == "bracket-close" {
+ content(
+ (e.x, 0),
+ _aligned(text(size: fsz, "]")),
+ anchor: "west",
+ )
+ } else if e.type == "trace" {
+ content(
+ (e.x, 0),
+ _aligned(text(size: fsz, "⟨" + _apply-symbols(e.name) + "⟩")),
+ anchor: "west",
+ )
+ } else if e.type == "word" {
+ content(
+ (e.x, 0),
+ _aligned(_move-format-word(e.text, fsz, sub-fsz)),
+ anchor: "west",
+ )
+ } else if e.type == "blank" {
+ content(
+ (e.x, 0),
+ _aligned(_blank-box()),
+ anchor: "west",
+ )
+ } else if e.type == "t-trace" {
+ content(
+ (e.x, 0),
+ _aligned(_t-trace-box(e.subscript, fsz, sub-fsz)),
+ anchor: "west",
+ )
+ }
+ }
+
+ // ── Draw rectangular arrows below text ────────────────
+ let head-back = 0.12
+ let base-drop = 0.35 // gap below text baseline to start arrow
+ let rect-count = 0
+
+ for arrow in arrows {
+ let is-dict = type(arrow) == dictionary
+ let raw-from = if is-dict { arrow.at("from") } else { arrow.at(0) }
+ let raw-to = if is-dict { arrow.at("to") } else { arrow.at(1) }
+ let paint = if is-dict { arrow.at("color", default: _norm) } else { _norm }
+ let dash-style = if is-dict { arrow.at("dash", default: "solid") } else { "solid" }
+ let arrow-lw = if is-dict { arrow.at("line-width", default: 1.0) } else { 1.0 }
+
+ if raw-from in name-to-pos and raw-to in name-to-pos {
+ let fx = name-to-pos.at(raw-from)
+ let tx = name-to-pos.at(raw-to)
+
+ let a-sw = sw * arrow-lw
+ let is-wavy = dash-style == "wavy"
+ let shaft-stroke = if dash-style == "solid" or is-wavy {
+ (paint: paint, thickness: a-sw)
+ } else {
+ (paint: paint, thickness: a-sw, dash: dash-style)
+ }
+ let head-stroke = (paint: paint, thickness: a-sw)
+ let mark-style = (end: ">", fill: paint, scale: arrow-mark-scale)
+
+ // Rectangular arrow below text
+ let stagger = rect-count * 0.35
+ rect-count = rect-count + 1
+
+ let bar-y = -(base-drop + 0.5 + stagger)
+ let drop-y = -(base-drop)
+
+ if is-wavy {
+ // Wavy: smooth sine wave along each leg
+ let amp = 0.035 // wave amplitude
+ let wlen = 0.10 // wave length (one full cycle)
+ let samples-per-cycle = 12 // points per cycle for smoothness
+
+ // Sine-wave along a vertical segment
+ let _wavy-v(x0, y0, y1, stk) = {
+ let dist = calc.abs(y1 - y0)
+ let sgn = if y1 > y0 { 1 } else { -1 }
+ let cycles = calc.max(1, calc.round(dist / wlen))
+ let total = int(cycles * samples-per-cycle)
+ for k in range(total) {
+ let t0 = k / total
+ let t1 = (k + 1) / total
+ let py0 = y0 + sgn * t0 * dist
+ let py1 = y0 + sgn * t1 * dist
+ let px0 = x0 + amp * calc.sin(t0 * cycles * 2 * calc.pi)
+ let px1 = x0 + amp * calc.sin(t1 * cycles * 2 * calc.pi)
+ line((px0, py0), (px1, py1), stroke: stk)
+ }
+ }
+
+ // Sine-wave along a horizontal segment
+ let _wavy-h(y0, x0, x1, stk) = {
+ let dist = calc.abs(x1 - x0)
+ let sgn = if x1 > x0 { 1 } else { -1 }
+ let cycles = calc.max(1, calc.round(dist / wlen))
+ let total = int(cycles * samples-per-cycle)
+ for k in range(total) {
+ let t0 = k / total
+ let t1 = (k + 1) / total
+ let px0 = x0 + sgn * t0 * dist
+ let px1 = x0 + sgn * t1 * dist
+ let py0 = y0 + amp * calc.sin(t0 * cycles * 2 * calc.pi)
+ let py1 = y0 + amp * calc.sin(t1 * cycles * 2 * calc.pi)
+ line((px0, py0), (px1, py1), stroke: stk)
+ }
+ }
+
+ // From: drop down (wavy)
+ _wavy-v(fx, drop-y, bar-y, shaft-stroke)
+ // Horizontal bar (wavy)
+ _wavy-h(bar-y, fx, tx, shaft-stroke)
+ // To: rise up (wavy stops early, straight into arrowhead)
+ let clear = 0.18 // clearance for a clean arrowhead
+ _wavy-v(tx, bar-y, drop-y - clear, shaft-stroke)
+ line((tx, drop-y - clear), (tx, drop-y - head-back), stroke: shaft-stroke)
+ let tiny = 0.01
+ line((tx, drop-y - head-back - tiny), (tx, drop-y), stroke: head-stroke, mark: mark-style)
+ } else {
+ // From: drop down
+ line((fx, drop-y), (fx, bar-y), stroke: shaft-stroke)
+ // Horizontal bar
+ line((fx, bar-y), (tx, bar-y), stroke: shaft-stroke)
+ // To: rise up with arrowhead
+ line((tx, bar-y), (tx, drop-y - head-back), stroke: shaft-stroke)
+ let tiny = 0.01
+ line((tx, drop-y - head-back - tiny), (tx, drop-y), stroke: head-stroke, mark: mark-style)
+ }
+
+ // Delink mark on horizontal bar
+ if (rect-count - 1) in delinks {
+ let mid-x = (fx + tx) / 2
+ _draw-delink(mid-x, bar-y, 1.0, 0.0, (paint: paint, thickness: a-sw))
+ }
+ }
+ }
+ })
+
+ // When protect is true, extend the box downward to cover arrows
+ // so subsequent content doesn't overlap. When false (default),
+ // keep the box text-height only — correct for table cells with
+ // align: bottom.
+ if protect and arrows.len() > 0 {
+ let base-drop = 0.35
+ let depth = base-drop + 0.5 + (arrows.len() - 1) * 0.35
+ let arrow-depth = (depth + 0.15) * unit
+ let ref-content = text(size: fsz, "Hgy[")
+ let ref-height = measure(ref-content).height
+ box(
+ width: box-w,
+ height: ref-height + arrow-depth,
+ baseline: arrow-depth,
+ clip: false,
+ {
+ hide(ref-content)
+ place(top + left, canvas-body)
+ },
+ )
+ } else {
+ box(width: box-w, clip: false, {
+ hide(text(size: fsz, "Hgy["))
+ place(top + left, canvas-body)
+ })
+ }
+ }
+}
diff --git a/packages/preview/synkit/0.0.41/syntax.typ b/packages/preview/synkit/0.0.41/syntax.typ
new file mode 100644
index 0000000000..f9260a1e8b
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/syntax.typ
@@ -0,0 +1,2681 @@
+// Syntax tree module for synkit
+// Draws phrase structure trees from bracket notation input.
+// Usage: #tree("[CP [C' [C did] [TP [DP she] [T' [T e] [VP [V leave]]]]]]")
+
+#import "@preview/cetz:0.5.2"
+#import "_symbols.typ": apply-symbols as _apply-symbols, symbol-map as _symbol-map
+
+// ── Constants ────────────────────────────────────────────────────────────────
+#let _leaf-w = 1.0 // horizontal width per leaf
+#let _v-gap = 1.2 // vertical gap between levels
+#let _loff = 0.25 // label offset for anchor connections
+
+
+// ── Tokenizer ────────────────────────────────────────────────────────────────
+// Splits bracket notation into tokens: "[", "]", and word strings.
+#let _tokenize(input) = {
+ let tokens = ()
+ let buf = ""
+ let brace-depth = 0
+ let literal-bracket-depth = 0
+ let chars = input.clusters()
+ let skip-next = false
+ for (i, ch) in chars.enumerate() {
+ if skip-next {
+ skip-next = false
+ } else if ch == "\\" and i + 1 < chars.len() and (chars.at(i + 1) == "[" or chars.at(i + 1) == "]") {
+ // Escaped square brackets are literal label characters, not tree structure.
+ let next = chars.at(i + 1)
+ if next == "[" {
+ literal-bracket-depth = literal-bracket-depth + 1
+ } else {
+ literal-bracket-depth = calc.max(0, literal-bracket-depth - 1)
+ }
+ buf = buf + next
+ skip-next = true
+ } else if ch == "{" {
+ brace-depth = brace-depth + 1
+ buf = buf + ch
+ } else if ch == "}" {
+ brace-depth = calc.max(0, brace-depth - 1)
+ buf = buf + ch
+ } else if brace-depth > 0 or literal-bracket-depth > 0 {
+ // Inside braces or escaped literal brackets: everything is literal.
+ buf = buf + ch
+ } else if ch == "[" or ch == "]" {
+ if buf.trim() != "" { tokens.push(buf.trim()) }
+ buf = ""
+ tokens.push(ch)
+ } else if ch == " " or ch == "\t" or ch == "\n" {
+ if buf.trim() != "" { tokens.push(buf.trim()) }
+ buf = ""
+ } else {
+ buf = buf + ch
+ }
+ }
+ if buf.trim() != "" { tokens.push(buf.trim()) }
+ tokens
+}
+
+// ── Strip formatting markers for anchor naming ──────────────────────────────
+// Removes *, **, _subscript, and ^superscript from a label to get the bare name.
+#let _strip-fmt(label) = {
+ let s = label
+ // Remove escapes for literal formatting characters before stripping markers.
+ // This keeps anchors usable for labels like \muP\* -> muP, not muP\.
+ s = s
+ .replace("\\*", "")
+ .replace("\\@", "")
+ .replace("\\&", "")
+ .replace("\\~", "")
+ .replace("\\[", "")
+ .replace("\\]", "")
+ // Strip subscript: first _ and everything after (e.g., CP_i → CP)
+ if s.contains("_") {
+ let idx = s.position("_")
+ s = s.slice(0, idx)
+ }
+ // Strip superscript: first ^ and everything after (e.g., NP^max → NP)
+ if s.contains("^") {
+ let idx = s.position("^")
+ s = s.slice(0, idx)
+ }
+ // Strip literal feature suffixes after the base label (e.g., C'{INT,...} → C')
+ if s.contains("{") {
+ let idx = s.position("{")
+ s = s.slice(0, idx)
+ }
+ if s.contains("[") {
+ let idx = s.position("[")
+ s = s.slice(0, idx)
+ }
+ // Strip *, @, &, ~ for bold/italic/smallcaps/underline/strikethrough
+ s = s.replace("*", "")
+ s = s.replace("@", "")
+ s = s.replace("&", "")
+ s = s.replace("~", "")
+ // Strip angle brackets and commas (for semantic type labels like → ett)
+ s = s.replace("<", "")
+ s = s.replace(">", "")
+ s = s.replace(",", "")
+ // Strip symbol shortcuts: \lambda → lambda, \phiP → phiP, etc.
+ for (key, _val) in _symbol-map {
+ if s.contains(key) {
+ s = s.replace(key, key.slice(1)) // \lambda → lambda
+ }
+ }
+ s
+}
+
+// ── Anchor key ───────────────────────────────────────────────────────────────
+// Canonical anchor stem used everywhere labels become automatic anchor names.
+#let _anchor-key(label) = {
+ _strip-fmt(label).replace("'", "bar").replace("\u{2019}", "bar").replace(" ", "-")
+}
+
+// ── Anchor name ──────────────────────────────────────────────────────────────
+// Normalizes a label into an anchor name: lowercase, ' → p, spaces → -, append count.
+#let _anchor-name(label, count) = {
+ _anchor-key(label) + str(count)
+}
+
+// ── Parser ───────────────────────────────────────────────────────────────────
+// Recursive descent parser: tokens → tree node.
+// Returns (node, next-position, updated-counts).
+// Node: (label: str, anchor: str, children: array, is-leaf: bool)
+#let _parse(tokens, pos, counts) = {
+ if tokens.at(pos) == "[" {
+ // Empty node: [ ] → blank placeholder
+ if tokens.at(pos + 1) == "]" {
+ let key = "empty"
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ return (
+ (
+ label: "",
+ anchor: key + str(c),
+ children: (),
+ is-leaf: false,
+ ),
+ pos + 2,
+ counts,
+ )
+ }
+ // Non-terminal: [LABEL children... ]
+ let label = tokens.at(pos + 1)
+ let key = _anchor-key(label)
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ let anchor = key + str(c)
+ let children = ()
+ let p = pos + 2
+ while tokens.at(p) != "]" {
+ if tokens.at(p) == "[" {
+ let (child, np, nc) = _parse(tokens, p, counts)
+ children.push(child)
+ p = np
+ counts = nc
+ } else {
+ // Bare word = leaf child
+ let leaf-label = tokens.at(p)
+ let lk = _anchor-key(leaf-label)
+ let lc = counts.at(lk, default: 0) + 1
+ counts.insert(lk, lc)
+ children.push((
+ label: leaf-label,
+ anchor: lk + str(lc),
+ children: (),
+ is-leaf: true,
+ ))
+ p = p + 1
+ }
+ }
+ // Skip closing ]
+ (
+ (
+ label: label,
+ anchor: anchor,
+ children: children,
+ is-leaf: false,
+ ),
+ p + 1,
+ counts,
+ )
+ } else {
+ // Bare word at top level
+ let label = tokens.at(pos)
+ let key = _anchor-key(label)
+ let c = counts.at(key, default: 0) + 1
+ counts.insert(key, c)
+ (
+ (
+ label: label,
+ anchor: key + str(c),
+ children: (),
+ is-leaf: true,
+ ),
+ pos + 1,
+ counts,
+ )
+ }
+}
+
+
+// ── Collect all leaf labels under a node ─────────────────────────────────────
+#let _collect-leaves(node) = {
+ if node.is-leaf or node.children.len() == 0 { return (node.label,) }
+ let result = ()
+ for child in node.children {
+ result = result + _collect-leaves(child)
+ }
+ result
+}
+
+// ── Find a node by anchor in the parsed tree ─────────────────────────────────
+#let _find-node(node, anchor) = {
+ if node.anchor == anchor { return node }
+ if node.children.len() == 0 { return none }
+ for child in node.children {
+ let found = _find-node(child, anchor)
+ if found != none { return found }
+ }
+ none
+}
+
+// ── Collect all leaf anchors under a node ────────────────────────────────────
+#let _collect-leaf-anchors(node) = {
+ if node.is-leaf or node.children.len() == 0 { return (node.anchor,) }
+ let result = ()
+ for child in node.children {
+ result = result + _collect-leaf-anchors(child)
+ }
+ result
+}
+
+// ── Detect trace leaves ─────────────────────────────────────────────────────
+// A trace is any leaf whose label starts with *t* (explicit italic marker).
+// Plain t/T is only a trace when followed by a subscript: t_i, T_DP, etc.
+// Bare t or T without subscript is treated as a regular node (e.g., tense head).
+#let _is-trace(label) = {
+ let s = label
+ // Explicit italic markers → always a trace
+ if s.starts-with("*t*") or s.starts-with("*T*") { return true }
+ // T0, t0, t^x → NOT traces (null heads, etc.)
+ if s.contains("0") or s.contains("^") { return false }
+ // Plain t/T: only a trace when followed by _subscript
+ if not s.contains("_") { return false }
+ let main = s.slice(0, s.position("_"))
+ main == "t" or main == "T"
+}
+
+// Walk tree and collect trace info: (trace-number, leaf-anchor, parent-anchor, word-index-in-parent)
+#let _find-traces(node, parent-anchor: none) = {
+ // Match traces as leaves OR as bracketed nodes with no children (e.g., [*t*_DP])
+ if node.is-leaf or (node.children.len() == 0 and _is-trace(node.label)) {
+ if _is-trace(node.label) {
+ return ((leaf-anchor: node.anchor, parent-anchor: parent-anchor, label: node.label),)
+ }
+ return ()
+ }
+ let result = ()
+ for child in node.children {
+ result = result + _find-traces(child, parent-anchor: node.anchor)
+ }
+ result
+}
+
+// ── Estimate rendered character count (strips formatting markers) ────────────
+// Counts visible characters after stripping *, @, &, _subscript, and ^superscript.
+#let _esc-star = "\u{FFFD}" // placeholder for escaped \*
+#let _esc-at = "\u{FFFC}"
+#let _esc-amp = "\u{FFFB}"
+#let _esc-tilde = "\u{FFFA}"
+#let _esc-lbrack = "\u{FFF9}"
+#let _esc-rbrack = "\u{FFF8}"
+
+#let _normalize-escapes(s) = {
+ s
+ .replace("\\*", _esc-star)
+ .replace("\\@", _esc-at)
+ .replace("\\&", _esc-amp)
+ .replace("\\~", _esc-tilde)
+ .replace("\\[", _esc-lbrack)
+ .replace("\\]", _esc-rbrack)
+}
+
+#let _restore-escapes(s) = {
+ s
+ .replace(_esc-star, "*")
+ .replace(_esc-at, "@")
+ .replace(_esc-amp, "&")
+ .replace(_esc-tilde, "~")
+ .replace(_esc-lbrack, "[")
+ .replace(_esc-rbrack, "]")
+}
+
+#let _find-brace-end(s) = {
+ let depth = 0
+ for (i, ch) in s.clusters().enumerate() {
+ if ch == "{" { depth = depth + 1 }
+ if ch == "}" { depth = depth - 1 }
+ if depth == 0 {
+ return i
+ }
+ }
+ none
+}
+
+#let _annotation-boundary(ch) = {
+ (
+ ch == " "
+ or ch == "\t"
+ or ch == "\n"
+ or ch == "{"
+ or ch == "}"
+ or ch == "["
+ or ch == "]"
+ or ch == "_"
+ or ch == "^"
+ )
+}
+
+#let _is-bracketed-subscript(s) = {
+ if type(s) != str { return false }
+ let trimmed = s.trim()
+ trimmed.starts-with("[") and trimmed.ends-with("]")
+}
+
+#let _parse-label-parts(label) = {
+ let chars = label.clusters()
+ let main-chars = ()
+ let rest-chars = ()
+ let in-rest = false
+ for (i, ch) in chars.enumerate() {
+ if not in-rest and (ch == "_" or ch == "^" or (i > 0 and (ch == "{" or ch == "["))) {
+ in-rest = true
+ rest-chars.push(ch)
+ } else if in-rest {
+ rest-chars.push(ch)
+ } else {
+ main-chars.push(ch)
+ }
+ }
+ let main = if main-chars.len() == 0 { "" } else { main-chars.join() }
+ let rest = if rest-chars.len() == 0 { "" } else { rest-chars.join() }
+ let sub-text = none
+ let sup-text = none
+ let sub-braced = false
+ let sup-braced = false
+ let tail-text = ""
+ let rest-arr = rest.clusters()
+ let cursor = 0
+
+ while cursor < rest-arr.len() {
+ let ch = rest-arr.at(cursor)
+ if ch != "_" and ch != "^" {
+ tail-text = tail-text + rest-arr.slice(cursor).join()
+ break
+ }
+
+ let after = rest-arr.slice(cursor + 1)
+ let ann = ""
+ let braced = false
+ let consumed = 0
+ if after.len() > 0 and after.at(0) == "{" {
+ braced = true
+ let depth = 0
+ let ann-chars = ()
+ let end-found = false
+ for (j, ach) in after.enumerate() {
+ if ach == "{" {
+ depth = depth + 1
+ if depth > 1 { ann-chars.push(ach) }
+ } else if ach == "}" {
+ depth = depth - 1
+ if depth == 0 {
+ consumed = j + 1
+ end-found = true
+ break
+ } else {
+ ann-chars.push(ach)
+ }
+ } else if depth >= 1 {
+ ann-chars.push(ach)
+ }
+ }
+ ann = if ann-chars.len() == 0 { "" } else { ann-chars.join() }
+ if not end-found {
+ consumed = after.len()
+ }
+ } else {
+ // LaTeX-style lazy scope: an unbraced _/^ grabs the next character.
+ // If that character starts a symbol command, keep the whole command.
+ if after.len() > 0 {
+ if after.at(0) == "\\" {
+ let ann-chars = ("\\",)
+ consumed = 1
+ for ach in after.slice(1) {
+ if ach.match(regex("[A-Za-z]")) != none {
+ ann-chars.push(ach)
+ consumed = consumed + 1
+ } else {
+ break
+ }
+ }
+ ann = ann-chars.join()
+ } else {
+ ann = after.at(0)
+ consumed = 1
+ }
+ }
+ }
+
+ if ch == "_" and sub-text == none {
+ sub-text = ann
+ sub-braced = braced
+ } else if ch == "^" and sup-text == none {
+ sup-text = ann
+ sup-braced = braced
+ } else {
+ tail-text = tail-text + ch + if braced { "{" + ann + "}" } else { ann }
+ }
+ cursor = cursor + 1 + consumed
+ }
+
+ (
+ main: main,
+ sub-text: sub-text,
+ sup-text: sup-text,
+ sub-braced: sub-braced,
+ sup-braced: sup-braced,
+ tail-text: tail-text,
+ )
+}
+
+#let _rendered-len(s) = {
+ let parts = _parse-label-parts(_normalize-escapes(_apply-symbols(s)))
+ let result = parts.main + parts.tail-text
+ // Strip formatting markers and braces
+ result = result.replace("*", "").replace("@", "").replace("&", "").replace("~", "").replace("{", "").replace("}", "")
+ result = _restore-escapes(result)
+ // Strip sub/superscript markers but keep the content (it contributes to width)
+ // _text and ^text → text is rendered smaller, count at ~0.7x
+ let words = result.split(" ")
+ let total = 0
+ for w in words {
+ total = total + w.clusters().len()
+ total = total + 1 // space between words
+ }
+ let ann = ""
+ if parts.sup-text != none { ann = ann + parts.sup-text }
+ if parts.sub-text != none { ann = ann + parts.sub-text }
+ ann = _restore-escapes(_apply-symbols(ann).replace("*", "").replace("@", "").replace("&", "").replace("~", ""))
+ total = total + calc.ceil(ann.clusters().len() * 0.7)
+ calc.max(0, total - 1) // remove trailing space
+}
+
+// ── Leaf count (triangle-aware) ──────────────────────────────────────────────
+// If a node is in the triangle set, use at least 2 leaf widths for spacing.
+// When \n breaks are present, use the widest line's word count instead of total.
+// Wide leaf labels claim proportionally more slots.
+#let _leaf-count(node, tri-set, leaf-w: 1.0, content-size: 0.8, annotation-leaf-widths: (:), is-horiz: false) = {
+ if node.is-leaf or node.children.len() == 0 {
+ if is-horiz {
+ return 1
+ }
+ // Check if the label is wider than one slot
+ let char-w = 0.22 * content-size
+ let label-w = _rendered-len(node.label) * char-w
+ let slots = calc.max(1, calc.ceil(label-w / leaf-w))
+ return slots
+ }
+ if node.anchor in tri-set {
+ if is-horiz {
+ let lines = _collect-leaves(node).join(" ").split(" \\n ")
+ let lines = if lines.len() == 1 { _collect-leaves(node).join(" ").split("\\n") } else { lines }
+ return calc.max(1, lines.len())
+ }
+ // Use minimal slot count — triangle text overflows visually,
+ // and rightward collision with siblings is resolved in the layout loop.
+ return 2
+ }
+ let total = 0
+ for child in node.children {
+ total = total + _leaf-count(
+ child,
+ tri-set,
+ leaf-w: leaf-w,
+ content-size: content-size,
+ annotation-leaf-widths: annotation-leaf-widths,
+ is-horiz: is-horiz,
+ )
+ }
+ if is-horiz {
+ return total
+ }
+ // Ensure non-leaf node's own label fits within the allocated width.
+ // Node labels use full-size font (char-w ≈ 0.22), so wide labels like
+ // ⟨st,⟨st,t⟩⟩ need more slots than their leaf count would suggest.
+ // Uses exact (non-ceiled) width to avoid over-spacing.
+ let char-w = 0.22
+ let label-w = _rendered-len(node.label) * char-w
+ let needed = label-w / leaf-w
+ let annotation-needed = annotation-leaf-widths.at(node.anchor, default: 0)
+ calc.max(total, needed, annotation-needed)
+}
+
+// ── Layout ───────────────────────────────────────────────────────────────────
+// Recursive layout: positions each node based on leaf-proportional spacing.
+// Triangle nodes are collapsed to a single leaf with combined label.
+// Returns flat list of (label, anchor, x, y, parent-xy, is-leaf, is-triangle).
+#let _syntax-layout(
+ node,
+ x0,
+ y,
+ parent-xy,
+ parent-anchor,
+ leaf-w,
+ v-gap,
+ tri-set,
+ is-horiz: false,
+ append-map: (:),
+ content-size: 0.8,
+ level: 0,
+ drop-map: (:),
+ node-spacing-map: (:),
+ sister-spacing-map: (:),
+ sister-node-map: (:),
+ annotation-map: (:),
+ annotation-leaf-widths: (:),
+ annotation-gaps: (:),
+) = {
+ if node.is-leaf or node.children.len() == 0 {
+ let x = x0 + leaf-w / 2
+ return (
+ (
+ label: node.label,
+ anchor: node.anchor,
+ x: x,
+ y: y,
+ par: parent-xy,
+ par-anchor: parent-anchor,
+ is-leaf: true,
+ is-terminal: node.is-leaf, // true only for bare words, not empty bracket nodes
+ is-triangle: false,
+ ),
+ )
+ }
+
+ // Triangle node: collapse entire subtree into parent + single combined leaf
+ if node.anchor in tri-set {
+ let lc = _leaf-count(
+ node,
+ tri-set,
+ leaf-w: leaf-w,
+ content-size: content-size,
+ annotation-leaf-widths: annotation-leaf-widths,
+ is-horiz: is-horiz,
+ )
+ let my-x = x0 + lc * leaf-w / 2
+ let leaves = _collect-leaves(node)
+ let combined-label = leaves.join(" ")
+ let tri-leaves = leaves // individual labels for _display-label rendering
+ return (
+ (
+ label: node.label,
+ anchor: node.anchor,
+ x: my-x,
+ y: y,
+ par: parent-xy,
+ par-anchor: parent-anchor,
+ is-leaf: false,
+ is-triangle: true,
+ tri-label: combined-label,
+ tri-leaves: tri-leaves,
+ tri-y: y - v-gap - annotation-gaps.at(node.anchor, default: 0),
+ tri-span: lc * leaf-w, // allocated vertical span for horizontal triangles
+ ),
+ )
+ }
+
+ // Non-terminal: lay out children, then center self
+ let result = ()
+ let cursor = x0
+ let child-positions = ()
+
+ // Reserve extra annotation width outside the subtree, not between children.
+ // This keeps siblings as tight as their own labels allow while still giving
+ // the parent annotation enough room at the next level up.
+ let natural-w = node
+ .children
+ .map(c => _leaf-count(
+ c,
+ tri-set,
+ leaf-w: leaf-w,
+ content-size: content-size,
+ annotation-leaf-widths: annotation-leaf-widths,
+ is-horiz: is-horiz,
+ ) * leaf-w)
+ .sum(default: 0)
+ let reserved-w = if is-horiz {
+ natural-w
+ } else {
+ calc.max(natural-w, annotation-leaf-widths.at(node.anchor, default: 0) * leaf-w)
+ }
+ let cursor = x0 + calc.max(0, (reserved-w - natural-w) / 2)
+
+ for (i, child) in node.children.enumerate() {
+ let child-leaves = _leaf-count(
+ child,
+ tri-set,
+ leaf-w: leaf-w,
+ content-size: content-size,
+ annotation-leaf-widths: annotation-leaf-widths,
+ is-horiz: is-horiz,
+ )
+ // Per-gap horizontal multiplier: node-specific > level > default.
+ // A node-specific entry scales the gap after that child, i.e. between
+ // this child and its next sister.
+ let h-mult = if child.anchor in sister-node-map {
+ sister-node-map.at(child.anchor)
+ } else if str(level + 1) in sister-spacing-map {
+ sister-spacing-map.at(str(level + 1))
+ } else { 1.0 }
+ let child-width = child-leaves * leaf-w
+ // Pre-check: if this child is a single leaf, check if its label overflows
+ // leftward into the previous sibling's space. If so, push cursor right.
+ let _overflow-pad = 0.2 * calc.min(child-width, 1.0) // scale padding with effective slot width
+ if child.is-leaf and not is-horiz {
+ let char-w = 0.22 * content-size
+ let label-chars = _rendered-len(child.label)
+ let text-half-w = calc.max(label-chars * char-w / 2, 0.3 * calc.min(child-width, 1.0))
+ let slot-half-w = child-width / 2
+ let left-overflow = text-half-w - slot-half-w
+ if left-overflow > 0 {
+ cursor = cursor + left-overflow + _overflow-pad
+ }
+ }
+ // Pre-check: if this child is a triangle, check if its text would overflow
+ // leftward into the previous sibling's space. If so, push cursor right first.
+ if child.anchor in tri-set and not is-horiz {
+ let leaves = _collect-leaves(child)
+ let joined = leaves.join(" ")
+ let lines = joined.split(" \\n ")
+ let lines = if lines.len() == 1 { joined.split("\\n") } else { lines }
+ let char-w = 0.22 * content-size
+ let widest-chars = lines.fold(0, (acc, l) => {
+ calc.max(acc, _rendered-len(l.trim()))
+ })
+ let text-half-w = calc.max(widest-chars * char-w / 2, 0.3 * calc.min(child-width, 1.0))
+ let slot-half-w = child-width / 2
+ let left-overflow = text-half-w - slot-half-w
+ if left-overflow > 0 {
+ cursor = cursor + left-overflow + _overflow-pad
+ }
+ }
+ // Per-child vertical gap: node-specific override > level override > default
+ let child-gap = (
+ v-gap
+ * if child.anchor in node-spacing-map {
+ node-spacing-map.at(child.anchor)
+ } else if str(level + 1) in drop-map {
+ drop-map.at(str(level + 1))
+ } else { 1.0 }
+ )
+ // Extra gap when *this* node (the parent) has an annotation
+ if node.anchor in annotation-gaps {
+ child-gap = child-gap + annotation-gaps.at(node.anchor)
+ }
+ let child-nodes = _syntax-layout(
+ child,
+ cursor,
+ y - child-gap,
+ none,
+ none,
+ leaf-w,
+ v-gap,
+ tri-set,
+ is-horiz: is-horiz,
+ append-map: append-map,
+ content-size: content-size,
+ level: level + 1,
+ drop-map: drop-map,
+ node-spacing-map: node-spacing-map,
+ sister-spacing-map: sister-spacing-map,
+ sister-node-map: sister-node-map,
+ annotation-map: annotation-map,
+ annotation-leaf-widths: annotation-leaf-widths,
+ annotation-gaps: annotation-gaps,
+ )
+ result = result + child-nodes
+ let child-pos = if child.is-leaf or child.anchor in tri-set {
+ child-nodes.at(0)
+ } else {
+ child-nodes.at(-1)
+ }
+ child-positions.push(child-pos)
+ cursor = cursor + child-width
+ // If this child is a triangle in a vertical tree, check if rendered text
+ // overflows its slot width. Push cursor so the next sibling doesn't overlap.
+ // In horizontal trees, triangle text extends outward, not into sibling space.
+ if child.anchor in tri-set and not is-horiz {
+ let leaves = _collect-leaves(child)
+ let joined = leaves.join(" ")
+ let lines = joined.split(" \\n ")
+ let lines = if lines.len() == 1 { joined.split("\\n") } else { lines }
+ let char-w = 0.22 * content-size
+ let widest-chars = lines.fold(0, (acc, l) => {
+ calc.max(acc, _rendered-len(l.trim()))
+ })
+ let text-half-w = calc.max(widest-chars * char-w / 2, 0.3 * calc.min(child-width, 1.0))
+ let slot-half-w = child-width / 2
+ let overflow = text-half-w - slot-half-w
+ if overflow > 0 {
+ cursor = cursor + overflow + _overflow-pad
+ }
+ }
+ // Post-check: scan the child's entire subtree for rightward overflow.
+ // Any node whose label extends past cursor pushes cursor further right,
+ // preventing cross-depth overlaps with the next sibling's subtree.
+ if not is-horiz and child-nodes.len() > 0 {
+ let _post-pad = 0.15 * calc.min(child-width, 1.0)
+ for cn in child-nodes {
+ let cw = if cn.is-leaf or cn.at("is-terminal", default: false) {
+ 0.22 * content-size
+ } else { 0.22 }
+ let hw = _rendered-len(cn.label) * cw / 2
+ let right-edge = cn.x + hw
+ if right-edge > cursor {
+ cursor = right-edge + _post-pad
+ }
+ }
+ }
+ // Post-check: if this child is a single leaf, check if its label overflows
+ // rightward into the next sibling's space. Push cursor if so.
+ if child.is-leaf and not is-horiz {
+ let char-w = 0.22 * content-size
+ let label-chars = _rendered-len(child.label)
+ let text-half-w = calc.max(label-chars * char-w / 2, 0.3 * calc.min(child-width, 1.0))
+ let slot-half-w = child-width / 2
+ let overflow = text-half-w - slot-half-w
+ if overflow > 0 {
+ cursor = cursor + overflow + _overflow-pad
+ }
+ }
+ // If this child has an append annotation, push cursor to make room
+ if child.anchor in append-map {
+ let app-text = append-map.at(child.anchor)
+ // Strip formatting markers for width estimation
+ let stripped = app-text.replace("@", "").replace("*", "").replace("&", "").replace("~", "")
+ let app-char-w = 0.15 // subscript chars are smaller
+ let app-width = stripped.clusters().len() * app-char-w
+ cursor = cursor + app-width
+ }
+
+ // Apply local spread to the actual gap between adjacent sisters.
+ // Default center-to-center distance is (left-slot + right-slot) / 2.
+ // Multiplying that quantity makes spread-local behave like drop-local:
+ // 0.5 halves the local spread, 1.5 increases it by 50%.
+ if i < node.children.len() - 1 {
+ let next-child = node.children.at(i + 1)
+ let next-leaves = _leaf-count(
+ next-child,
+ tri-set,
+ leaf-w: leaf-w,
+ content-size: content-size,
+ annotation-leaf-widths: annotation-leaf-widths,
+ is-horiz: is-horiz,
+ )
+ let next-width = next-leaves * leaf-w
+ let pair-mult = calc.max(h-mult, 0.0)
+ let extra-gap = (pair-mult - 1.0) * (child-width + next-width) / 2
+ cursor = cursor + extra-gap
+ }
+ }
+
+ // Center this node over its children's span
+ let first-child-x = child-positions.at(0).x
+ let last-child-x = child-positions.at(-1).x
+ let my-x = (first-child-x + last-child-x) / 2
+
+ // Update children's parent pointers
+ let updated-result = ()
+ for entry in result {
+ let is-direct-child = node.children.any(c => c.anchor == entry.anchor)
+ if is-direct-child {
+ updated-result.push((..entry, par: (my-x, y), par-anchor: node.anchor))
+ } else {
+ updated-result.push(entry)
+ }
+ }
+
+ // Add this node
+ updated-result.push((
+ label: node.label,
+ anchor: node.anchor,
+ x: my-x,
+ y: y,
+ par: parent-xy,
+ par-anchor: parent-anchor,
+ is-leaf: false,
+ is-triangle: false,
+ ))
+
+ updated-result
+}
+
+// ── Display label ────────────────────────────────────────────────────────────
+// Renders a node label as content. Handles:
+// ' → prime symbol (′)
+// **text** → bold
+// *text* → italic
+// label_x → subscript x
+// label^x → superscript x
+// label^x_y → both superscript and subscript
+#let _display-label(label) = {
+ // ── Angle bracket protector: → ⟨et,t⟩ ──
+ let label = _normalize-escapes(label.replace("<", "⟨").replace(">", "⟩"))
+ // ── Auto-italic traces: t_X or T_X → *t*_X or *T*_X ──
+ // Only when there is a subscript (bare t/T without subscript is a regular node)
+ let label = {
+ let label-parts = _parse-label-parts(label)
+ let main = label-parts.main
+ let bare = main.replace("*", "").replace("@", "").replace("&", "").replace("~", "")
+ if not bare.contains("0") and (bare == "t" or bare == "T") and label-parts.sub-text != none {
+ "*" + main + "*" + if label.len() > main.len() { label.slice(main.len()) } else { "" }
+ } else { label }
+ }
+ let parts = _parse-label-parts(label)
+ let main = parts.main
+ let sub-text = parts.sub-text
+ let sup-text = parts.sup-text
+ let tail-text = parts.tail-text
+ // Helper: italicize Greek Unicode characters within a string
+ let _greek-set = "αβγδεζηθικλμνξπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΠΡΣΤΥΦΧΨΩ"
+ let _italicize-greek(s) = {
+ if type(s) != str { return s }
+ let chars = s.clusters()
+ if not chars.any(c => _greek-set.contains(c)) { return s }
+ let out = []
+ let buf = ""
+ for c in chars {
+ if _greek-set.contains(c) {
+ if buf != "" { out = out + [#buf]; buf = "" }
+ out = out + emph(c)
+ } else {
+ buf = buf + c
+ }
+ }
+ if buf != "" { out = out + [#buf] }
+ out
+ }
+ let _display-chars(s) = {
+ let display = _restore-escapes(_apply-symbols(s))
+ let display = if display.contains("'") or display.contains("'") {
+ let parts = display.split("'")
+ let parts = if parts.len() == 1 { display.split("'") } else { parts }
+ parts.at(0) + "′" + parts.slice(1).join("′")
+ } else { display }
+ let chars = display.clusters()
+ let parts = ()
+ let buf = ""
+ for (i, ch) in chars.enumerate() {
+ if ch == "0" and i > 0 and chars.at(i - 1).match(regex("\d")) == none {
+ if buf != "" { parts.push((text: buf)) }
+ buf = ""
+ parts.push((zero: true))
+ } else {
+ buf = buf + ch
+ }
+ }
+ if buf != "" { parts.push((text: buf)) }
+ if parts.any(p => p.keys().contains("zero")) {
+ // Has auto-superscript zeros: build mixed content
+ let out = []
+ for p in parts {
+ if p.keys().contains("zero") {
+ out = out + [#super[0]]
+ } else {
+ out = out + _italicize-greek(p.text)
+ }
+ }
+ out
+ } else {
+ display // plain string, no zeros to superscript
+ }
+ }
+ let _plain-content(s) = {
+ let content = _display-chars(s)
+ if type(content) == str {
+ _italicize-greek(content)
+ } else {
+ content
+ }
+ }
+ let _inline-content(s) = {
+ let pieces = ()
+ let active = none
+ let active-buf = ""
+ let buf = ""
+ let chars = s.clusters()
+ let i = 0
+ while i < chars.len() {
+ let ch = chars.at(i)
+ let token = if ch == "*" and i + 1 < chars.len() and chars.at(i + 1) == "*" {
+ "**"
+ } else if ch == "*" or ch == "@" or ch == "&" or ch == "~" {
+ ch
+ } else { none }
+ if token != none {
+ if active == none {
+ if buf != "" { pieces.push((text: buf, style: none)) }
+ buf = ""
+ active = token
+ } else if active == token {
+ pieces.push((text: active-buf, style: token))
+ active = none
+ active-buf = ""
+ } else {
+ active-buf = active-buf + token
+ }
+ i = i + token.len()
+ } else {
+ if active == none {
+ buf = buf + ch
+ } else {
+ active-buf = active-buf + ch
+ }
+ i = i + 1
+ }
+ }
+ if active != none {
+ buf = buf + active + active-buf
+ }
+ if buf != "" { pieces.push((text: buf, style: none)) }
+ let out = []
+ for piece in pieces {
+ let body = _plain-content(piece.text)
+ if piece.style == "**" {
+ out = out + [*#body*]
+ } else if piece.style == "*" {
+ out = out + emph(body)
+ } else if piece.style == "@" {
+ out = out + smallcaps(body)
+ } else if piece.style == "&" {
+ out = out + underline(body)
+ } else if piece.style == "~" {
+ out = out + strike(body)
+ } else {
+ out = out + body
+ }
+ }
+ out
+ }
+ let _sub-content(s) = if _is-bracketed-subscript(s) {
+ _inline-content(s)
+ } else {
+ emph(_inline-content(s))
+ }
+ let body = _inline-content(main)
+ let result = if sup-text != none and sup-text != "" and sub-text != none and sub-text != "" {
+ let sup-display = _inline-content(sup-text)
+ let sub-display = _sub-content(sub-text)
+ let sup-r = [#super[#sup-display]]
+ let sub-r = [#sub[#sub-display]]
+ [#body#sup-r#sub-r]
+ } else if sup-text != none and sup-text != "" {
+ let sup-display = _inline-content(sup-text)
+ [#body#super[#sup-display]]
+ } else if sub-text != none and sub-text != "" {
+ let sub-display = _sub-content(sub-text)
+ [#body#sub[#sub-display]]
+ } else { body }
+ // Append tail text (e.g., +T+mangez after _{[+Q]})
+ let final = if tail-text != "" {
+ [#result#_inline-content(tail-text)]
+ } else { result }
+ final
+}
+
+// ── Inline formatter for append text ────────────────────────────────────────
+// Processes @...@ segments as smallcaps, &...& as underline, *...* as italic,
+// **...** as bold. Everything else is plain text.
+#let _format-inline(s) = {
+ let parts = s.split("@")
+ let result = ()
+ for (i, part) in parts.enumerate() {
+ if calc.rem(i, 2) == 1 {
+ // Odd segments: smallcaps
+ result.push(smallcaps(part))
+ } else if part != "" {
+ result.push(part)
+ }
+ }
+ result.join()
+}
+
+// ── Estimate label half-width in canvas units ───────────────────────────────
+// Used for horizontal trees where branch offsets must clear the label width.
+#let _label-half-w(label) = {
+ let stripped = _strip-fmt(label)
+ let char-w = 0.28 // approximate character width in canvas units
+ _apply-symbols(stripped).clusters().len() * char-w / 2 + 0.05
+}
+
+// ── Delink mark ─────────────────────────────────────────────────────────────
+// Draws two perpendicular bars at a given point along a given tangent direction.
+// (mx, my): midpoint on the path; (tang-x, tang-y): tangent direction (will be normalized).
+#let _draw-delink(mx, my, tang-x, tang-y, sw) = {
+ let len = calc.sqrt(tang-x * tang-x + tang-y * tang-y)
+ if len == 0 { return }
+ let dir-x = tang-x / len
+ let dir-y = tang-y / len
+ let perp-x = -dir-y
+ let perp-y = dir-x
+ let bar = 0.15
+ let gap = 0.03
+ for sign in (-1, 1) {
+ let cx = mx + sign * gap * dir-x
+ let cy = my + sign * gap * dir-y
+ cetz.draw.line(
+ (cx - bar * perp-x, cy - bar * perp-y),
+ (cx + bar * perp-x, cy + bar * perp-y),
+ stroke: sw,
+ )
+ }
+}
+
+
+// ── Main function ────────────────────────────────────────────────────────────
+/// Draw a syntax tree from bracket notation
+///
+/// Parses a bracket-notation string and renders a phrase structure tree.
+/// Each node receives an automatic anchor name (lowercased label + counter,
+/// e.g. `"cp1"`, `"tp2"`) that can be used for cross-node arrows.
+///
+/// Arguments:
+/// - input (string): Bracket notation, e.g. `"[CP [C' [C did] [TP ...]]]"`
+/// - arrows (array): Cross-node arrows. Each entry is either `(from, to)` or a
+/// dict with keys: `from`, `to`, `color`, `bend` (arc depth, auto if omitted),
+/// `shift` (horizontal bias of arc apex, default `0`), `dash` (`"dashed"`,
+/// `"solid"`, `"dotted"`, etc.). Arrow names use the automatic anchors
+/// and always target leaf content (the "down" position). (default: `()`)
+/// - scale (number): Uniform scale factor (default: `1.0`)
+/// - drop (number): Multiplier for vertical distance between levels
+/// (default: `1.0`). A value of `1.2` increases branch length by 20%.
+/// - spread (number): Horizontal width per leaf in canvas units (default: `1.0`)
+/// - triangle (array): Anchor names whose branch should render as a triangle
+/// for elided structure, e.g. `("dp1",)` (default: `()`)
+/// - curved (bool): Draw arrows as Bézier curves (default: `false`)
+/// - direction (string): Growth direction of the tree: `"down"` (default),
+/// `"up"`, `"right"`, or `"left"`.
+/// - highlight (array): Anchor names to draw a box around (like `\fbox` in LaTeX).
+/// Bare names default to the node label; use `"dp1down"` to box the leaf content
+/// instead. (default: `()`)
+/// - bottom (bool): Align all terminal words at the bottom of the tree. When `true`,
+/// leaves are pushed to the lowest level regardless of depth. (default: `false`)
+/// - dash-branches (array): Array of `(parent, child)` anchor pairs whose branch
+/// line should be dashed instead of solid, e.g. `dash-branches: (("np1", "det1"),)`.
+/// (default: `()`)
+/// - delinks (array): Anchor names matching arrow `from` fields. The delink mark
+/// (two perpendicular bars) is drawn on the matching arrow's shaft, e.g.
+/// `delinks: ("dp3",)`. Only interpreted when a matching arrow exists. (default: `()`)
+/// - index (array): Coreference subscripts. Array of single-key dicts mapping anchor
+/// names to index strings, e.g. `(("cp1": "i"), ("np2": "j"))`. Rendered as
+/// subscripts after the node label. (default: `()`)
+/// - drop-local (array): Per-level or per-node branch length multipliers.
+/// Each entry is a tuple where the first element is a level number (int) or
+/// anchor name (string):
+/// - `(1, 1.5)` — all level-1 branches are 50% longer
+/// - `("ip2", 0.5)` — only the branch arriving at `ip2` is 50% shorter
+/// Node-specific entries override level entries. (default: `()`)
+/// - spread-local (array): Per-level or per-node horizontal spacing multipliers
+/// between sister nodes. Same format as `drop-local`:
+/// - `(1, 1.5)` — level-1 sisters are 50% wider apart
+/// - `("ip2", 0.5)` — the gap after `ip2` is 50% narrower
+/// (default: `()`)
+/// - dominance (array): Long-distance dominance lines (no arrowheads). Each entry
+/// is a tuple `("from-anchor", "to-anchor")` or a dict with optional `ctrl`:
+/// `(from: "np4", to: "np1", ctrl: (-1.0, -0.5))`. Lines depart south and
+/// arrive north, matching branch connection points. (default: `()`)
+/// - color (array): Colorize nodes, leaf content, or branches. Each entry is a tuple:
+/// - `("np1", red)` — color the node label text of `np1`
+/// - `("np1down", blue)` — color the leaf/content text under `np1`
+/// - `("vp1", "v1", yellow)` — color the branch from `vp1` to `v1`
+/// (default: `()`)
+/// - annotation (array): Semantic annotations displayed between a node label and
+/// its branches. Each entry is `("anchor", content)` where content is Typst
+/// content (use `$...$` for math/logic). Example:
+/// `annotation: (("dp1", $lambda Q forall x$),)` (default: `()`)
+/// - annotation-size (float): Size multiplier for annotation text relative to base
+/// font size. The vertical gap between node and branches adjusts automatically.
+/// (default: `0.70`)
+/// - annotation-leading (length or auto): Line spacing for multi-line annotations.
+/// Use smaller values like `0.3em` to tighten line spacing.
+/// (default: `auto`, which uses `0.45em`)
+/// - show-refs (bool): Show generated node references near labels for debugging.
+/// Triangle nodes also show their `-down` content reference. (default: `false`)
+///
+/// Returns: CeTZ drawing of the syntax tree
+///
+/// Example:
+/// ```
+/// #tree("[CP [C' [C did] [TP [DP she] [T' [T e] [VP [V leave]]]]]]")
+/// ```
+#let tree(
+ input,
+ arrows: (),
+ scale: 1.0,
+ spread: 1.0,
+ triangle: (),
+ content-size: 0.8,
+ node-size: 1.0,
+ curved: false,
+ direction: "down",
+ highlight: (),
+ bottom: false,
+ terminal-branch: false,
+ dash-branches: (),
+ delinks: (),
+ index: (),
+ append: (),
+ drop: 1.0,
+ drop-local: (),
+ spread-local: (),
+ dominance: (),
+ color: (),
+ annotation: (),
+ annotation-size: 0.70,
+ annotation-leading: auto,
+ line-width: 1.0,
+ font: none,
+ numbers: (),
+ numbers-size: 0.85,
+ show-refs: false,
+) = {
+ let scale-factor = scale
+ let is-horiz = direction == "right" or direction == "left"
+ let leaf-w = spread
+ let v-gap = if is-horiz { 1.2 * drop * 1.05 } else { 1.2 * drop }
+
+ // ── Build drop-local lookup maps ─────────────────────
+ let _drop-map = (:) // str(level) → multiplier
+ let _node-spacing-map = (:) // anchor → multiplier
+ // Normalize: single entry → array
+ let ls-entries = if drop-local == () {
+ ()
+ } else if type(drop-local.at(0, default: none)) != array {
+ (drop-local,)
+ } else {
+ drop-local
+ }
+ for entry in ls-entries {
+ let key = entry.at(0)
+ let mult = entry.at(1)
+ if type(key) == int {
+ _drop-map.insert(str(key), mult)
+ } else if type(key) == str {
+ _node-spacing-map.insert(key, mult)
+ }
+ }
+
+ // ── Build spread-local lookup maps ──────────────────────────────
+ let _sister-spacing-map = (:) // str(level) → multiplier
+ let _sister-node-map = (:) // anchor → multiplier
+ let ss-entries = if spread-local == () {
+ ()
+ } else if type(spread-local.at(0, default: none)) != array {
+ (spread-local,)
+ } else {
+ spread-local
+ }
+ for entry in ss-entries {
+ let key = entry.at(0)
+ let mult = entry.at(1)
+ if type(key) == int {
+ _sister-spacing-map.insert(str(key), mult)
+ } else if type(key) == str {
+ _sister-node-map.insert(key, mult)
+ }
+ }
+
+ // ── Build color lookup maps ───────────────────────────────────
+ // Normalize: single tuple → array of tuples
+ let color-entries = if color == () {
+ ()
+ } else if type(color.at(0, default: none)) != array {
+ // Single entry like (\"np1\", red) — wrap it
+ (color,)
+ } else {
+ color
+ }
+ // node-color-map: anchor → color (for node labels)
+ // down-color-map: anchor → color (for leaf content, keyed without "down" suffix)
+ // branch-color-map: "parent|child" → color (for branches)
+ let node-color-map = (:)
+ let down-color-map = (:)
+ let branch-color-map = (:)
+ for entry in color-entries {
+ if entry.len() == 3 {
+ // Branch color: (parent-anchor, child-anchor, color)
+ let key = entry.at(0) + "|" + entry.at(1)
+ branch-color-map.insert(key, entry.at(2))
+ } else if entry.len() == 2 {
+ let name = entry.at(0)
+ let c = entry.at(1)
+ if type(name) == str and name.ends-with("-down") {
+ let base = name.slice(0, name.len() - 5)
+ down-color-map.insert(base, c)
+ } else {
+ node-color-map.insert(name, c)
+ }
+ }
+ }
+ // Bottom-aligned leaves require terminal branches; otherwise looks weird
+ let terminal-branch = if bottom { true } else { terminal-branch }
+
+ // Parse
+ let tokens = _tokenize(input)
+ let (tree, _, _) = _parse(tokens, 0, (:))
+
+ // Build triangle set from anchor names
+ let tri-set = triangle
+
+ // Auto-triangle: phrase nodes (label len > 1, ends in P) with only leaf children
+ let _auto-tri(node) = {
+ let result = ()
+ if not node.is-leaf and node.children.len() > 0 {
+ let stripped = _strip-fmt(node.label)
+ let is-phrase = (stripped.len() > 1 and lower(stripped).ends-with("p")) or node.label.ends-with(">")
+ let all-leaves = node.children.all(c => c.is-leaf)
+
+ if is-phrase and all-leaves {
+ result.push(node.anchor)
+ }
+
+ for child in node.children {
+ result = result + _auto-tri(child)
+ }
+ }
+ result
+ }
+ let tri-set = tri-set + _auto-tri(tree)
+
+ // Build append map: anchor → formatted subscript text
+ let append-map = (:)
+ for entry in append {
+ let (anchor, text-str) = entry
+ append-map.insert(anchor, text-str)
+ }
+
+ // Build annotation map: anchor → Typst content
+ let annotation-map = (:)
+ for entry in annotation {
+ let (anchor, annotation-content) = entry
+ annotation-map.insert(anchor, annotation-content)
+ }
+ let annotation-gap = calc.max(annotation-size * (0.45 / 0.70), 0.25)
+
+ let _body = context {
+ let em-in-cu = text.size / (scale-factor * 1cm)
+ let fsz = 12 * scale-factor * 1pt
+
+ // Measure annotation widths and heights; compute per-anchor gaps
+ let annotation-leaf-widths = (:)
+ let annotation-gaps = (:)
+ // Resolve annotation leading
+ let annotation-leading-val = if annotation-leading == auto { 0.45em } else { annotation-leading }
+ for (anchor, annotation-content) in annotation-map {
+ let annotation-body = {
+ set text(size: fsz * annotation-size)
+ set par(leading: annotation-leading-val)
+ annotation-content
+ }
+ let measured = measure(annotation-body)
+ // Convert measured width to leaf-width units (each leaf = leaf-w * scale-factor * 1cm)
+ let width-in-units = measured.width / (leaf-w * scale-factor * 1cm)
+ annotation-leaf-widths.insert(anchor, width-in-units)
+ // Convert measured height to canvas units for vertical gap
+ let height-in-units = measured.height / (scale-factor * 1cm)
+ let gap = calc.max(height-in-units + 0.30, annotation-gap)
+ annotation-gaps.insert(anchor, gap)
+ }
+
+ // Layout (triangle-aware)
+ let nodes = _syntax-layout(
+ tree,
+ 0.0,
+ 0.0,
+ none,
+ none,
+ leaf-w,
+ v-gap,
+ tri-set,
+ is-horiz: is-horiz,
+ append-map: append-map,
+ content-size: content-size,
+ level: 0,
+ drop-map: _drop-map,
+ node-spacing-map: _node-spacing-map,
+ sister-spacing-map: _sister-spacing-map,
+ sister-node-map: _sister-node-map,
+ annotation-map: annotation-map,
+ annotation-leaf-widths: annotation-leaf-widths,
+ annotation-gaps: annotation-gaps,
+ )
+
+ // Bottom-align: push all leaves and triangle text to the lowest y in the tree
+ if bottom {
+ let min-y = nodes.fold(0.0, (acc, e) => {
+ if e.is-leaf { calc.min(acc, e.y) } else { acc }
+ })
+ // Also consider triangle leaf positions
+ let min-y = nodes.fold(min-y, (acc, e) => {
+ if e.at("is-triangle", default: false) { calc.min(acc, e.at("tri-y")) } else { acc }
+ })
+ nodes = nodes.map(e => {
+ if e.is-leaf { (..e, y: min-y) } else if e.at("is-triangle", default: false) {
+ (..e, tri-y: min-y, bottom-aligned: true)
+ } else { e }
+ })
+ }
+
+ // Reduce terminal-to-parent gap when no branch line is drawn
+ // (skip when bottom-aligned, since bottom alignment should take precedence)
+ if not terminal-branch and not bottom {
+ nodes = nodes.map(e => {
+ if e.at("is-terminal", default: false) and e.par != none {
+ // Horizontal trees need more gap (labels extend from position)
+ let pull = if is-horiz { 0.2 } else { 0.5 }
+ let mid-y = e.y + (e.par.at(1) - e.y) * pull
+ (..e, y: mid-y)
+ } else { e }
+ })
+ }
+
+ // Align terminal leaves with sibling triangles when terminal branches are drawn
+ if terminal-branch and not bottom {
+ // Build parent → min child y map (considering tri-y for triangles)
+ let parent-min-y = (:)
+ for e in nodes {
+ if e.par != none {
+ let pk = e.at("par-anchor", default: "")
+ if pk != "" {
+ let cy = if e.at("is-triangle", default: false) { e.at("tri-y") } else if e.is-leaf { e.y } else { none }
+ if cy != none {
+ let cur = parent-min-y.at(pk, default: cy)
+ parent-min-y.insert(pk, calc.min(cur, cy))
+ }
+ }
+ }
+ }
+ // Push terminal leaves to their parent's min child y
+ nodes = nodes.map(e => {
+ if e.at("is-terminal", default: false) and e.par != none {
+ let pk = e.at("par-anchor", default: "")
+ if pk != "" and pk in parent-min-y {
+ let target-y = parent-min-y.at(pk)
+ if target-y < e.y {
+ (..e, y: target-y, is-terminal-aligned: true)
+ } else { e }
+ } else { e }
+ } else { e }
+ })
+ }
+
+ // Direction transform: map layout coords (always computed as "down") to final coords
+ let _tx(x, y) = {
+ if direction == "up" { (x, -y) } else if direction == "right" { (-y, -x) } else if direction == "left" {
+ (y, -x)
+ } else { (x, y) }
+ }
+ // Growth direction vector (from parent toward child in final coords)
+ let (gdx, gdy) = if direction == "up" { (0, 1) } else if direction == "right" { (1, 0) } else if (
+ direction == "left"
+ ) {
+ (-1, 0)
+ } else { (0, -1) }
+
+ // Build per-node outgoing offset (from center to where branch departs)
+ // and incoming offset (from center to where branch arrives).
+ // Vertical: symmetric, fixed _loff.
+ // Horizontal: labels are aligned (left for "right", right for "left"),
+ // so outgoing = full label width, incoming = small gap.
+ let node-out = (:) // offset for branches leaving this node
+ let node-in = (:) // offset for branches arriving at this node
+ for e in nodes {
+ if is-horiz {
+ let full-w = _label-half-w(e.label) * 2
+ let is-tri = e.at("is-triangle", default: false)
+ node-out.insert(e.anchor, if is-tri { full-w + 0.25 } else { full-w })
+ node-in.insert(e.anchor, 0.05)
+ } else {
+ node-out.insert(e.anchor, _loff)
+ node-in.insert(e.anchor, _loff)
+ }
+ }
+
+ // Increase node-out for nodes with annotations to push branches below the annotation
+ for (anchor, _) in annotation-map {
+ let current = node-out.at(anchor, default: _loff)
+ node-out.insert(anchor, current + annotation-gaps.at(anchor, default: annotation-gap))
+ }
+
+ // Transform all node coordinates
+ nodes = nodes.map(e => {
+ let (nx, ny) = _tx(e.x, e.y)
+ let new-par = if e.par != none {
+ let (px, py) = _tx(e.par.at(0), e.par.at(1))
+ (px, py)
+ } else { none }
+ let result = (..e, x: nx, y: ny, par: new-par)
+ if e.at("is-triangle", default: false) {
+ let (_, ty-raw) = _tx(e.x, e.at("tri-y"))
+ // For horizontal directions, tri position needs both coords
+ let (tri-nx, tri-ny) = _tx(e.x, e.at("tri-y"))
+ (..result, tri-y: tri-ny, tri-x: tri-nx)
+ } else { result }
+ })
+
+ // Build name-to-pos dict (outside canvas, following geom.typ pattern)
+ // Also build arrow-off-map: per-anchor arrow clearance (distance from reference to below text)
+ let _text-half-h = 0.2
+ let _arrow-gap = 0.25
+ let name-to-pos = (:)
+ let arrow-off-map = (:)
+ let label-hw-map = (:) // per-anchor label half-width (horizontal semi-axis for degree arrows)
+ let regular-off = _text-half-h + _arrow-gap // 0.45: from text center to below text + gap
+ for e in nodes {
+ name-to-pos.insert(e.anchor, (e.x, e.y, _loff))
+ arrow-off-map.insert(e.anchor, regular-off)
+ label-hw-map.insert(e.anchor, _rendered-len(e.label) * 0.28 / 2 + 0.05)
+ }
+
+ // Add "down" positions: point to leaf content below each non-leaf node.
+ // Walk the parsed tree to find leaf descendants for each node.
+ let _build-down(node, ntp, aom, lhw) = {
+ if node.is-leaf or node.children.len() == 0 {
+ // Leaf: "-down" is same as the node
+ ntp.insert(node.anchor + "-down", ntp.at(node.anchor))
+ aom.insert(node.anchor + "-down", regular-off)
+ lhw.insert(node.anchor + "-down", _rendered-len(node.label) * 0.28 / 2 + 0.05)
+ return (ntp, aom, lhw)
+ }
+ // Triangle node: "-down" = the combined label position (tri-y)
+ let tri-entry = nodes.filter(e => e.anchor == node.anchor and e.at("is-triangle", default: false))
+ if tri-entry.len() > 0 {
+ let te = tri-entry.at(0)
+ ntp.insert(node.anchor + "-down", (te.x, te.at("tri-y"), _loff))
+ // Triangle text: top is at tri-y + gdy*tri-text-gap, so offset = tri-text-gap + text-height + gap
+ let tri-off = 0.05 + _text-half-h * 2 + _arrow-gap
+ aom.insert(node.anchor + "-down", tri-off)
+ // Triangle text width: use the widest line of the combined label
+ let tri-label = te.at("tri-label")
+ let tri-lines = tri-label.split(" \\n ")
+ let tri-lines = if tri-lines.len() == 1 { tri-label.split("\\n") } else { tri-lines }
+ let widest = tri-lines.fold(0, (acc, l) => calc.max(acc, _rendered-len(l.trim())))
+ let char-w = 0.22 * content-size
+ let tri-hw = calc.max(widest * char-w / 2, 0.3)
+ lhw.insert(node.anchor + "-down", tri-hw)
+ return (ntp, aom, lhw)
+ }
+ // Non-triangle non-leaf: find leaf descendants, average their positions
+ let leaf-anchors = _collect-leaf-anchors(node)
+ let found = leaf-anchors.filter(a => a in ntp)
+ if found.len() > 0 {
+ let avg-x = found.map(a => ntp.at(a).at(0)).fold(0.0, (a, b) => a + b) / found.len()
+ let ys = found.map(a => ntp.at(a).at(1))
+ let leaf-y = if gdy < 0 { calc.min(..ys) } else { calc.max(..ys) }
+ ntp.insert(node.anchor + "-down", (avg-x, leaf-y, _loff))
+ aom.insert(node.anchor + "-down", regular-off)
+ // Span of leaf descendants as half-width
+ let xs = found.map(a => ntp.at(a).at(0))
+ let span-hw = if xs.len() > 1 {
+ (calc.max(..xs) - calc.min(..xs)) / 2 + 0.3
+ } else {
+ (
+ _rendered-len(nodes.filter(e => e.anchor == found.at(0)).at(0, default: (label: "XX")).label) * 0.28 / 2
+ + 0.05
+ )
+ }
+ lhw.insert(node.anchor + "-down", span-hw)
+ }
+ // Recurse into children
+ for child in node.children {
+ (ntp, aom, lhw) = _build-down(child, ntp, aom, lhw)
+ }
+ (ntp, aom, lhw)
+ }
+ (name-to-pos, arrow-off-map, label-hw-map) = _build-down(tree, name-to-pos, arrow-off-map, label-hw-map)
+
+ // Build trace anchors: trace1, trace2, etc.
+ let trace-infos = _find-traces(tree)
+ let trace-count = 0
+ for ti in trace-infos {
+ trace-count = trace-count + 1
+ let trace-anchor = "trace" + str(trace-count)
+ // Check if this trace is inside a triangle
+ let parent-is-tri = ti.parent-anchor in tri-set
+ if parent-is-tri {
+ // Find the triangle entry to get its position
+ let tri-entries = nodes.filter(e => e.anchor == ti.parent-anchor and e.at("is-triangle", default: false))
+ if tri-entries.len() > 0 {
+ let te = tri-entries.at(0)
+ let tri-leaves = te.at("tri-leaves")
+ // Find the trace's word index in the leaf list (skip \n markers)
+ let word-idx = 0
+ let total-words = tri-leaves.filter(w => w != "\\n").len()
+ for (i, leaf) in tri-leaves.enumerate() {
+ if leaf == "\\n" { continue }
+ if _is-trace(leaf) { break }
+ word-idx = word-idx + 1
+ }
+ // Use the triangle's "down" position as base (same y that works for arrows)
+ let down-key = ti.parent-anchor + "-down"
+ if down-key in name-to-pos {
+ let (down-x, down-y, down-loff) = name-to-pos.at(down-key)
+ let down-off = arrow-off-map.at(down-key, default: regular-off)
+ // Estimate x offset for the trace word within the text
+ let char-w = 0.22 * content-size
+ let tri-label = te.at("tri-label")
+ let tri-text-lines = tri-label.split(" \\n ")
+ let tri-text-lines = if tri-text-lines.len() == 1 { tri-label.split("\\n") } else { tri-text-lines }
+ let widest = tri-text-lines.fold(0, (acc, l) => calc.max(acc, _rendered-len(l.trim())))
+ let half-w = calc.max(widest * char-w / 2, 0.3)
+ let vert-growth = direction == "down" or direction == "up"
+ let frac = (word-idx + 0.5) / total-words
+ // Shift from text anchor to text center:
+ // Triangle text is rendered with anchor "north"/"south" at tri-y + gdy*0.05.
+ // Regular nodes use anchor "center", so their position IS the center.
+ // To match, offset by tri-text-gap + text-half-height toward growth.
+ let tri-text-gap = 0.05
+ let center-shift = tri-text-gap + _text-half-h * content-size
+ // sub[] extends the trace word visually to the right (in physical
+ // units), shifting its apparent center; compensate in canvas units.
+ let x-sub-correction = -0.08 / scale
+ let (trace-x, trace-y) = if vert-growth {
+ (down-x - half-w + frac * 2 * half-w + x-sub-correction, down-y + gdy * center-shift)
+ } else {
+ (down-x + gdx * center-shift, down-y - half-w + frac * 2 * half-w + x-sub-correction)
+ }
+ name-to-pos.insert(trace-anchor, (trace-x, trace-y, _loff))
+ // Extra clearance: Typst's sub[] positions subscripts beyond the
+ // content bounding box that CeTZ measures for anchor placement.
+ // The overflow is constant in physical units, so we divide by
+ // scale to keep the physical gap consistent across scales.
+ let trace-off = regular-off + 0.20 / scale
+ arrow-off-map.insert(trace-anchor, trace-off)
+ label-hw-map.insert(trace-anchor, _rendered-len(ti.label) * 0.28 / 2 + 0.05)
+ }
+ }
+ } else {
+ // Non-triangle: trace is a regular leaf, use its position
+ if ti.leaf-anchor in name-to-pos {
+ name-to-pos.insert(trace-anchor, name-to-pos.at(ti.leaf-anchor))
+ let trace-off = _text-half-h + 0.05
+ arrow-off-map.insert(trace-anchor, trace-off)
+ label-hw-map.insert(trace-anchor, _label-half-w(ti.label))
+ }
+ }
+ }
+
+ // Resolve highlight names into a set of actual node anchors to box.
+ // Default: bare name → node itself (unlike arrows which default to "down").
+ // "dp3-down" → box the leaf descendants of dp3.
+ let box-set = ()
+ for h in highlight {
+ if h.ends-with("-down") {
+ let base = h.slice(0, h.len() - 5)
+ let target = _find-node(tree, base)
+ if target != none {
+ box-set = box-set + _collect-leaf-anchors(target)
+ }
+ } else if h.ends-with("up") {
+ let base = h.slice(0, h.len() - 2)
+ box-set.push(base)
+ } else {
+ box-set.push(h)
+ }
+ }
+
+ // Build index lookup: anchor → subscript string.
+ // Default: bare name → leaf content ("down"), like arrows.
+ // "dp1up" → attach index to the DP node label itself.
+ let index-map = (:)
+ for entry in index {
+ for (k, v) in entry.pairs() {
+ if k.ends-with("up") {
+ index-map.insert(k.slice(0, k.len() - 2), v)
+ } else if k.ends-with("-down") {
+ let base = k.slice(0, k.len() - 5)
+ let target = _find-node(tree, base)
+ if target != none {
+ for leaf in _collect-leaf-anchors(target) {
+ index-map.insert(leaf, v)
+ }
+ }
+ } else {
+ // Bare name: default to "down" only if node has exactly one leaf descendant.
+ // For complex nodes (CP with many children), stay on the node itself.
+ let target = _find-node(tree, k)
+ if target != none and not target.is-leaf and target.children.len() > 0 {
+ let leaves = _collect-leaf-anchors(target)
+ if leaves.len() == 1 {
+ index-map.insert(leaves.at(0), v)
+ } else {
+ index-map.insert(k, v)
+ }
+ } else {
+ index-map.insert(k, v)
+ }
+ }
+ }
+ }
+
+ // Build numbers lookup: anchor → circled digit
+ let numbers-map = (:)
+ let _circled = ("①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩",
+ "⑪", "⑫", "⑬", "⑭", "⑮", "⑯", "⑰", "⑱", "⑲", "⑳")
+ // Normalize: single entry (("a", 1),) vs array of entries
+ let num-entries = if numbers.len() > 0 and type(numbers.at(0)) == str {
+ (numbers,)
+ } else { numbers }
+ for entry in num-entries {
+ let anchor = entry.at(0)
+ let n = entry.at(1)
+ let display = if n >= 1 and n <= 20 { _circled.at(n - 1) } else { "(" + str(n) + ")" }
+ numbers-map.insert(anchor, display)
+ }
+
+ // Normalize arrows:
+ // - single dict: (from: "a", to: "b") → wrapped in array
+ // - single flat pair: ("a", "b") → wrapped in array
+ // - array of arrows: (("a", "b"), ...) → used as-is
+ let arrows = if type(arrows) == dictionary {
+ (arrows,)
+ } else if arrows.len() > 0 and type(arrows.at(0)) == str {
+ (arrows,)
+ } else { arrows }
+
+ // Colors
+ let _norm = luma(15%)
+
+ box(inset: 1.2em, baseline: 40%, {
+ cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ let node-fsz = fsz * node-size // non-terminal node labels
+ let content-fsz = fsz * content-size // terminal/leaf content
+ let sw = 0.05em * scale-factor * line-width
+ let arrow-sw = 0.018
+ let arrow-mark-scale = 0.5
+ let ref-color = luma(55%)
+ let ref-fsz = fsz * 0.55
+ let ref-items = ()
+
+ let draw-ref(pos, name, side) = {
+ if show-refs {
+ let (x, y) = pos
+ let off = 0.26
+ let (rx, ry, anch) = if side == "below" {
+ (x, y - off, "north")
+ } else if side == "above" {
+ (x, y + off, "south")
+ } else if side == "before" {
+ (x - off, y, "east")
+ } else {
+ (x + off, y, "west")
+ }
+ content(
+ (rx, ry),
+ box(
+ fill: white,
+ stroke: 0.25pt + red,
+ inset: (x: 0.22em, y: 0.10em),
+ text(size: ref-fsz, fill: ref-color, font: "Courier New", weight: "bold", name),
+ ),
+ anchor: anch,
+ )
+ }
+ }
+
+ let ref-side-node = if direction == "up" { "above" } else if direction == "right" {
+ "before"
+ } else if direction == "left" { "after" } else { "below" }
+ let ref-side-down = if direction == "up" { "below" } else if direction == "right" {
+ "after"
+ } else if direction == "left" { "before" } else { "above" }
+
+ // ── Draw edges ──────────────────────────────────────────────────
+ // Triangle nodes still get a line FROM their parent TO them;
+ // the triangle itself replaces the lines from the node to its children.
+ for e in nodes {
+ if e.par != none and (terminal-branch or not e.at("is-terminal", default: false)) {
+ let branch-key = e.at("par-anchor", default: "") + "|" + e.anchor
+ let col = branch-color-map.at(branch-key, default: _norm)
+ let is-dashed = (
+ e.at("par-anchor", default: none) != none
+ and dash-branches.any(pair => {
+ pair.at(0) == e.par-anchor and pair.at(1) == e.anchor
+ })
+ )
+ let edge-stroke = if is-dashed {
+ (paint: col, thickness: sw, dash: "dashed")
+ } else {
+ (paint: col, thickness: sw)
+ }
+ let par-off = node-out.at(e.par-anchor, default: _loff)
+ let child-off = node-in.at(e.anchor, default: _loff)
+ line(
+ (e.par.at(0) + gdx * par-off, e.par.at(1) + gdy * par-off),
+ (e.x - gdx * child-off, e.y - gdy * child-off),
+ stroke: edge-stroke,
+ )
+ }
+ }
+
+ // ── Draw triangles ──────────────────────────────────────────────
+ for e in nodes {
+ if e.at("is-triangle", default: false) {
+ let col = _norm
+ let tri-label = e.at("tri-label")
+ // Triangle label position
+ let tlx = e.at("tri-x", default: e.x)
+ let tly = e.at("tri-y")
+ let vert-growth = direction == "down" or direction == "up"
+ // Compute half-spread for triangle base
+ let half-w = if vert-growth {
+ // Vertical trees: base width from text character width
+ let char-w = 0.22 * content-size
+ let tri-text-lines = tri-label.split(" \\n ")
+ let tri-text-lines = if tri-text-lines.len() == 1 { tri-label.split("\\n") } else { tri-text-lines }
+ let widest = tri-text-lines.fold(0, (acc, l) => calc.max(acc, _rendered-len(l.trim())))
+ calc.max(widest * char-w / 2, 0.3)
+ } else {
+ // Horizontal trees: base height proportional to number of text lines
+ let tri-text-lines = tri-label.split(" \\n ")
+ let tri-text-lines = if tri-text-lines.len() == 1 { tri-label.split("\\n") } else { tri-text-lines }
+ let n-lines = tri-text-lines.len()
+ let line-h = 0.55 // approximate height per line in canvas units
+ calc.max(n-lines * line-h / 2, 0.4)
+ }
+ // Apex: node center offset toward children (same as branch offset)
+ let has-sub = e.anchor in annotation-map
+ let tri-off = node-out.at(e.anchor, default: _loff)
+ let apex = (e.x + gdx * tri-off, e.y + gdy * tri-off)
+ // Base endpoints (perpendicular to growth)
+ let (b1, b2) = if vert-growth {
+ (
+ (tlx - half-w, tly - gdy * _loff),
+ (tlx + half-w, tly - gdy * _loff),
+ )
+ } else {
+ // Extend base outward so the triangle depth matches the apex-to-text distance
+ let base-ext = _loff
+ (
+ (tlx + gdx * base-ext, tly - half-w),
+ (tlx + gdx * base-ext, tly + half-w),
+ )
+ }
+ line(apex, b1, stroke: (paint: col, thickness: sw))
+ line(apex, b2, stroke: (paint: col, thickness: sw))
+ line(b1, b2, stroke: (paint: col, thickness: sw))
+ }
+ }
+
+ // ── Draw node labels ────────────────────────────────────────────
+ for e in nodes {
+ let col = if e.at("is-terminal", default: false) {
+ down-color-map.at(e.at("par-anchor", default: ""), default: _norm)
+ } else {
+ node-color-map.at(e.anchor, default: _norm)
+ }
+ let display = _display-label(e.label)
+ // Use smaller font for terminal content (leaves), full size for node labels.
+ // Also treat bracketed nodes with no uppercase letters as content-sized
+ // (e.g., [and] is a conjunction, not a syntactic category).
+ let _has-upper = _strip-fmt(e.label).match(regex("[A-Z]")) != none
+ let sz = if e.at("is-terminal", default: false) or (e.is-leaf and not _has-upper) { content-fsz } else {
+ node-fsz
+ }
+ // Append coreference subscript if present
+ let idx = index-map.at(e.anchor, default: none)
+ // Build base label with optional coreference index
+ let base-label = if idx != none {
+ let idx-style = if _is-bracketed-subscript(idx) { "normal" } else { "italic" }
+ [#text(size: sz, fill: col, display)#sub[#text(
+ font: font,
+ size: sz * 0.75,
+ style: idx-style,
+ fill: col,
+ idx,
+ )]]
+ } else {
+ text(size: sz, fill: col, display)
+ }
+ // Append is drawn separately below, so label-content is just the base
+ let label-content = base-label
+ let boxed = e.anchor in box-set
+ let label-body = if boxed {
+ box(stroke: 0.5pt + col, inset: 2pt, label-content)
+ } else {
+ label-content
+ }
+
+ // CeTZ anchor for label alignment in horizontal trees
+ let label-anchor = if direction == "right" { "west" } else if direction == "left" { "east" } else { "center" }
+
+ if e.at("is-triangle", default: false) {
+ // Draw the node label (e.g. "DP")
+ content((e.x, e.y), label-body, anchor: label-anchor)
+ if show-refs { ref-items.push((pos: (e.x, e.y), name: e.anchor, side: ref-side-node)) }
+ // Draw combined triangle label using _display-label for formatting
+ let tri-leaves = e.at("tri-leaves")
+ let tri-leaf-boxed = highlight.any(h => h == e.anchor + "-down")
+ let tri-col = down-color-map.at(e.anchor, default: _norm)
+ // Build rendered content for each leaf, split by \n markers
+ let lines = ((),) // array of arrays (one per line)
+ for leaf in tri-leaves {
+ if leaf == "\\n" {
+ lines.push(()) // start a new line
+ } else {
+ lines.at(-1).push(leaf)
+ }
+ }
+ let line-contents = lines.map(words => {
+ // Render each word, then join into a single text element
+ // to avoid paragraph-level line spacing from content joins
+ let rendered = words.map(w => _display-label(w))
+ let joined = text(size: content-fsz, fill: tri-col, rendered.join([ ]))
+ if tri-leaf-boxed { box(stroke: 0.5pt + tri-col, inset: 2pt, joined) } else { joined }
+ })
+ let tri-align = if direction == "right" { left } else if direction == "left" { right } else { center }
+ let tri-body = if line-contents.len() > 1 {
+ align(tri-align, stack(spacing: 0.35em, ..line-contents))
+ } else {
+ line-contents.at(0)
+ }
+ let is-bottom-aligned = e.at("bottom-aligned", default: false)
+ let tri-text-gap = if is-bottom-aligned { 0 } else if is-horiz { 0.80 } else { 0.05 }
+ let tlx2 = e.at("tri-x", default: e.x)
+ let tly2 = e.at("tri-y")
+ // Bottom-aligned triangles should use the same directional anchoring
+ // as regular terminals so mixed triangle/non-triangle leaves keep the
+ // same shape-to-text gap.
+ let tri-anchor = if direction == "up" { "south" } else if (
+ direction == "down"
+ ) { "north" } else if (
+ direction == "right"
+ ) { "west" } else { "east" }
+ let tri-label-pos = (tlx2 + gdx * tri-text-gap, tly2 + gdy * tri-text-gap)
+ content(tri-label-pos, tri-body, anchor: tri-anchor)
+ if show-refs { ref-items.push((pos: tri-label-pos, name: e.anchor + "-down", side: ref-side-down)) }
+ } else {
+ // In horizontal trees, pull terminal leaves closer to their parent
+ let (lx, ly) = (e.x, e.y)
+ let cur-anchor = label-anchor
+ let is-terminal = e.at("is-terminal", default: false)
+ let is-term-aligned = e.at("is-terminal-aligned", default: false)
+ // Synchronize terminal nodes with triangles when terminal branches are drawn
+ if (is-terminal or is-term-aligned) and terminal-branch and not bottom {
+ let tri-text-gap = if is-horiz { 0.80 } else { 0.05 }
+ lx = lx + gdx * tri-text-gap
+ ly = ly + gdy * tri-text-gap
+ cur-anchor = if direction == "up" { "south" } else if direction == "down" { "north" } else if (
+ direction == "right"
+ ) { "west" } else { "east" }
+ } else if not is-horiz and is-terminal {
+ // Vertical terminal content should grow away from the tree, not be
+ // centered on the terminal point. Center anchoring makes words with
+ // different glyph shapes look vertically misaligned even when their
+ // layout coordinates are identical.
+ cur-anchor = if direction == "up" { "south" } else { "north" }
+ } else if is-horiz and is-terminal and e.par != none {
+ let pull = 0.4 // fraction of distance to reclaim
+ lx = lx + (e.par.at(0) - lx) * pull
+ ly = ly + (e.par.at(1) - ly) * pull
+ }
+ content((lx, ly), label-body, anchor: cur-anchor)
+ if show-refs { ref-items.push((pos: (lx, ly), name: e.anchor, side: ref-side-node)) }
+ }
+ // Draw circled number to the left of the node
+ let num-display = numbers-map.at(e.anchor, default: none)
+ if num-display != none {
+ let label-hw = _label-half-w(e.label)
+ let num-body = text(size: sz * numbers-size, num-display)
+ let num-gap = 0.08 // small gap between number and label
+ content((e.x - label-hw - num-gap, e.y), num-body, anchor: "east")
+ }
+ // Draw append subscript as separate content (doesn't affect branch targeting)
+ let app = append-map.at(e.anchor, default: none)
+ if app != none {
+ let app-content = _format-inline(app)
+ let app-body = sub[#text(size: sz * 0.75, fill: col, app-content)]
+ // Estimate label half-width to position append at the right edge
+ let label-hw = _label-half-w(e.label)
+ // Position to the right of the label, anchored at west (left edge of append)
+ content((e.x + label-hw, e.y), app-body, anchor: "north-west")
+ }
+ // Draw annotation between node label and branches
+ let annotation-content = annotation-map.at(e.anchor, default: none)
+ if annotation-content != none and not e.at("is-terminal", default: false) {
+ let annotation-y-off = _loff + 0.10
+ let annotation-body = {
+ set text(size: fsz * annotation-size, fill: col)
+ set par(leading: annotation-leading-val)
+ set align(center)
+ annotation-content
+ }
+ let annotation-anchor = if direction == "up" { "south" } else if direction == "right" { "west" } else if (
+ direction == "left"
+ ) { "east" } else { "north" }
+ content(
+ (e.x + gdx * annotation-y-off, e.y + gdy * annotation-y-off),
+ annotation-body,
+ anchor: annotation-anchor,
+ )
+ }
+ }
+
+ // ── Draw arrows (only for vertical trees) ────────────────────────
+ if not is-horiz {
+ let head-back = 0.12
+ // Compute extremal position along growth axis for rectangular arrow clearance
+ // For "down": lowest y; for "up": highest y; for "right": rightmost x; for "left": leftmost x
+ let y-floor = {
+ let vals = nodes.map(e => {
+ let v = if direction == "right" or direction == "left" { e.x } else { e.y }
+ if e.at("is-triangle", default: false) {
+ let tv = if direction == "right" or direction == "left" { e.at("tri-x", default: e.x) } else {
+ e.at("tri-y")
+ }
+ if direction == "up" or direction == "left" { calc.max(v, tv) } else { calc.min(v, tv) }
+ } else {
+ v
+ }
+ })
+ if direction == "up" or direction == "left" {
+ vals.fold(0.0, (a, b) => calc.max(a, b))
+ } else {
+ vals.fold(0.0, (a, b) => calc.min(a, b))
+ }
+ }
+
+ let rect-count = 0 // counter for staggering rectangular arrows
+ for arrow in arrows {
+ let is-dict = type(arrow) == dictionary
+ let raw-from = if is-dict { arrow.at("from") } else { arrow.at(0) }
+ let raw-to = if is-dict { arrow.at("to") } else { arrow.at(1) }
+ let paint = if is-dict { arrow.at("color", default: _norm) } else if arrow.len() >= 3 {
+ arrow.at(2)
+ } else { _norm }
+ let bend-val = if is-dict { arrow.at("bend", default: none) } else { none }
+ let shift-val = if is-dict { arrow.at("shift", default: 0.0) } else { 0.0 }
+ let dash-style = if is-dict { arrow.at("dash", default: "dashed") } else { "dashed" }
+ let arrow-lw = if is-dict { arrow.at("line-width", default: 1.0) } else { 1.0 }
+
+ // Check if this arrow should be delinked (match raw from or to name)
+ let is-delinked = delinks.any(d => d == raw-from or d == raw-to)
+
+ // Parse degree suffix: "np1-300" → (base: "np1", degree: 300)
+ // Checks full name in name-to-pos first to avoid conflicts with
+ // anchors that naturally end in dash-digits (e.g. "n-31" from label "N 3").
+ let _parse-deg(name) = {
+ if name in name-to-pos { return (name, none) }
+ let m = name.match(regex("^(.+)-(\d{1,3})$"))
+ if m != none {
+ let base = m.captures.at(0)
+ let deg = int(m.captures.at(1))
+ if base in name-to-pos { return (base, deg) }
+ }
+ (name, none)
+ }
+ let (from-base, from-deg) = _parse-deg(raw-from)
+ let (to-base, to-deg) = _parse-deg(raw-to)
+
+ // Resolve arrow names:
+ // - "vp3" or "vp3-down" → leaf content below node (default)
+ // - "vp3-top" → node label itself (arrow exits from side)
+ let _resolve(name) = {
+ if name.ends-with("-top") or name.ends-with("-down") {
+ name
+ } else {
+ let base = name
+ if base + "-down" in name-to-pos { base + "-down" } else { base }
+ }
+ }
+ // Check for -top suffix (node label as endpoint)
+ let from-is-top = from-base.ends-with("-top")
+ let to-is-top = to-base.ends-with("-top")
+ let from-name = _resolve(from-base)
+ let to-name = _resolve(to-base)
+ // For -top: strip suffix and use the node position directly
+ if from-is-top {
+ from-name = from-base.slice(0, from-base.len() - 4)
+ }
+ if to-is-top {
+ to-name = to-base.slice(0, to-base.len() - 4)
+ }
+
+ if from-name in name-to-pos and to-name in name-to-pos {
+ let (fx, fy, f-loff) = name-to-pos.at(from-name)
+ let (tx, ty, t-loff) = name-to-pos.at(to-name)
+
+ // Offset: -top endpoints exit from the side of the label,
+ // regular endpoints offset in growth direction (below text)
+ // Extra padding on "from" offset: the arrowhead mark at the "to"
+ // end visually shortens the line, creating implicit clearance there.
+ // The "from" end has no mark, so we add equivalent padding (~0.15).
+ let f-off = arrow-off-map.at(from-name, default: 0.45) + 0.15
+ let t-off = arrow-off-map.at(to-name, default: 0.45)
+ // For degree: exit at specified angle on invisible ellipse around node
+ // Horizontal semi-axis = label half-width + gap (clears text edge)
+ // Vertical semi-axis = arrow offset (already includes gap)
+ // For -top: exit horizontally from label side
+ // Default: offset in growth direction (below text)
+ let _deg-h-pad = 0.25 // horizontal padding beyond text edge
+ if from-deg != none {
+ let rad = from-deg * calc.pi / 180
+ let hw = label-hw-map.at(from-name, default: 0.35) + _deg-h-pad
+ fx = fx + hw * calc.cos(rad)
+ fy = fy + f-off * calc.sin(rad)
+ } else if from-is-top {
+ let side = if tx < fx { -1 } else { 1 } // left or right based on target
+ let hw = _label-half-w(nodes.filter(e => e.anchor == from-name).at(0, default: (label: "XX")).label)
+ fx = fx + side * hw
+ } else {
+ // Offset in growth direction (same as "to" endpoint)
+ fx = fx + gdx * f-off
+ fy = fy + gdy * f-off
+ }
+ if to-deg != none {
+ let rad = to-deg * calc.pi / 180
+ let hw = label-hw-map.at(to-name, default: 0.35) + _deg-h-pad
+ tx = tx + hw * calc.cos(rad)
+ ty = ty + t-off * calc.sin(rad)
+ } else if to-is-top {
+ let side = if fx < tx { -1 } else { 1 }
+ let hw = _label-half-w(nodes.filter(e => e.anchor == to-name).at(0, default: (label: "XX")).label)
+ tx = tx + side * hw
+ } else {
+ tx = tx + gdx * t-off
+ ty = ty + gdy * t-off
+ }
+ let dx = tx - fx
+ let dy = ty - fy
+ let len = calc.sqrt(dx * dx + dy * dy)
+ if len == 0 { continue }
+
+ let a-sw = arrow-sw * arrow-lw
+ let shaft-stroke = if dash-style == "solid" {
+ (paint: paint, thickness: a-sw)
+ } else {
+ (paint: paint, thickness: a-sw, dash: dash-style)
+ }
+ let head-stroke = (paint: paint, thickness: a-sw)
+ let mark-style = (end: ">", fill: paint, scale: arrow-mark-scale)
+
+ // bend or shift present → force curved for this arrow
+ let is-curved = curved or bend-val != none or shift-val != 0.0
+ if is-curved {
+ // Quadratic Bézier (parabolic arc)
+ // Auto-calculate bend if not specified: proportional to distance
+ let bend = if bend-val != none { bend-val } else {
+ calc.max(calc.abs(dx) * 0.3, calc.abs(dy) * 0.3, 0.6)
+ }
+ // Control point: centered perpendicular to growth, offset in growth direction
+ let cp = if direction == "right" or direction == "left" {
+ let mid-y = (fy + ty) / 2 + shift-val
+ let ext = if direction == "right" {
+ calc.max(fx, tx) + bend
+ } else {
+ calc.min(fx, tx) - bend
+ }
+ (ext, mid-y)
+ } else {
+ let mid-x = (fx + tx) / 2 + shift-val
+ let ext = if direction == "up" {
+ calc.max(fy, ty) + bend
+ } else {
+ calc.min(fy, ty) - bend
+ }
+ (mid-x, ext)
+ }
+ // Convert quadratic to cubic: C1 = P0 + 2/3*(cp - P0), C2 = P2 + 2/3*(cp - P2)
+ let c1 = (fx + 2.0 / 3.0 * (cp.at(0) - fx), fy + 2.0 / 3.0 * (cp.at(1) - fy))
+ let c2 = (tx + 2.0 / 3.0 * (cp.at(0) - tx), ty + 2.0 / 3.0 * (cp.at(1) - ty))
+ // Tangent at endpoint (cubic: B'(1) = 3(P3 - C2))
+ let tang-x = tx - c2.at(0)
+ let tang-y = ty - c2.at(1)
+ let ed = calc.sqrt(tang-x * tang-x + tang-y * tang-y)
+ let (tang-x, tang-y) = if ed > 0 { (tang-x / ed, tang-y / ed) } else { (dx / len, dy / len) }
+ let hb = calc.min(head-back, len * 0.4)
+ let hax = tx - tang-x * hb
+ let hay = ty - tang-y * hb
+ bezier((fx, fy), (hax, hay), c1, c2, stroke: shaft-stroke)
+ let tiny = 0.01
+ line((hax - tang-x * tiny, hay - tang-y * tiny), (tx, ty), stroke: head-stroke, mark: mark-style)
+ if is-delinked {
+ // Cubic midpoint at t=0.5, using actual drawn endpoint (hax, hay)
+ let mx = 0.125 * fx + 3 * 0.125 * c1.at(0) + 3 * 0.125 * c2.at(0) + 0.125 * hax
+ let my = 0.125 * fy + 3 * 0.125 * c1.at(1) + 3 * 0.125 * c2.at(1) + 0.125 * hay
+ // Cubic tangent at t=0.5
+ let dtx = 3 * (0.25 * (c1.at(0) - fx) + 0.5 * (c2.at(0) - c1.at(0)) + 0.25 * (hax - c2.at(0)))
+ let dty = 3 * (0.25 * (c1.at(1) - fy) + 0.5 * (c2.at(1) - c1.at(1)) + 0.25 * (hay - c2.at(1)))
+ _draw-delink(mx, my, dtx, dty, (paint: paint, thickness: sw))
+ }
+ } else {
+ // Rectangular (right-angle) path along growth axis
+ // Base floor: same for all arrows (endpoints drop to here)
+ let base-clearance = 1.0
+ let stagger = rect-count * 0.5
+ rect-count = rect-count + 1
+ let sign = if direction == "up" or direction == "left" { 1 } else { -1 }
+ let base-floor = y-floor + sign * base-clearance
+ let bar-val = base-floor + sign * stagger
+ let hb = head-back
+ if direction == "right" or direction == "left" {
+ // Horizontal growth: vertical bar
+ line((fx, fy), (bar-val, fy), stroke: shaft-stroke)
+ line((bar-val, fy), (bar-val, ty), stroke: shaft-stroke)
+ line((bar-val, ty), (tx + gdx * hb, ty), stroke: shaft-stroke)
+ let tiny = 0.01
+ line((tx + gdx * (hb + tiny), ty), (tx, ty), stroke: head-stroke, mark: mark-style)
+ if is-delinked {
+ let mid-y = (fy + ty) / 2
+ _draw-delink(bar-val, mid-y, 0.0, 1.0, (paint: paint, thickness: sw))
+ }
+ } else {
+ // Vertical growth: horizontal bar
+ line((fx, fy), (fx, bar-val), stroke: shaft-stroke)
+ line((fx, bar-val), (tx, bar-val), stroke: shaft-stroke)
+ line((tx, bar-val), (tx, ty + gdy * hb), stroke: shaft-stroke)
+ let tiny = 0.01
+ line((tx, ty + gdy * (hb + tiny)), (tx, ty), stroke: head-stroke, mark: mark-style)
+ if is-delinked {
+ let mid-x = (fx + tx) / 2
+ _draw-delink(mid-x, bar-val, 1.0, 0.0, (paint: paint, thickness: sw))
+ }
+ }
+ }
+ }
+ }
+ } // end if not is-horiz (arrows)
+
+ // ── Draw dominance lines ──────────────────────────────────────
+ // Normalize: single entry → array of entries
+ let dom-entries = if dominance == () {
+ ()
+ } else if type(dominance.at(0, default: none)) != array and type(dominance.at(0, default: none)) != dictionary {
+ // Single entry like ("np4", "np1") — wrap it
+ if type(dominance) == dictionary { (dominance,) } else { (dominance,) }
+ } else {
+ dominance
+ }
+ for entry in dom-entries {
+ let is-dict = type(entry) == dictionary
+ let raw-from = if is-dict { entry.at("from") } else { entry.at(0) }
+ let raw-to = if is-dict { entry.at("to") } else { entry.at(1) }
+ let ctrl-val = if is-dict { entry.at("ctrl", default: none) } else { none }
+ let dom-color = if is-dict { entry.at("color", default: _norm) } else { _norm }
+ let dom-dash = if is-dict { entry.at("dash", default: "solid") } else { "solid" }
+
+ if raw-from in name-to-pos and raw-to in name-to-pos {
+ let (fx, fy, _) = name-to-pos.at(raw-from)
+ let (tx, ty, _) = name-to-pos.at(raw-to)
+
+ // Depart south (node-out), arrive north (node-in) — matching branches
+ let f-out = node-out.at(raw-from, default: _loff)
+ let t-in = node-in.at(raw-to, default: _loff)
+ fx = fx + gdx * f-out
+ fy = fy + gdy * f-out
+ tx = tx - gdx * t-in
+ ty = ty - gdy * t-in
+
+ let dx = tx - fx
+ let dy = ty - fy
+ let len = calc.sqrt(dx * dx + dy * dy)
+ if len == 0 { continue }
+
+ let dom-stroke = (paint: dom-color, thickness: sw, dash: dom-dash)
+
+ // Default S-curve: depart in growth direction, arrive against it
+ // ctrl adjusts (adds to) these defaults
+ let v-reach = calc.abs(dy)
+ let h-reach = calc.abs(dx)
+ let lift1 = calc.max(v-reach * 0.50, h-reach * 0.20, 0.50)
+ let dip2 = -calc.max(v-reach * 0.25, 0.40)
+ // Apply growth direction sign
+ let (default-c1y, default-c2y) = if direction == "up" {
+ (-lift1, -dip2)
+ } else {
+ (lift1, dip2)
+ }
+ let adj0 = if ctrl-val != none { ctrl-val.at(0) } else { 0 }
+ let adj1 = if ctrl-val != none { ctrl-val.at(1) } else { 0 }
+ let ctrl1 = (fx + dx * 0.30, fy + default-c1y + adj0)
+ let ctrl2 = (tx - dx * 0.30, ty + default-c2y + adj1)
+ bezier((fx, fy), (tx, ty), ctrl1, ctrl2, stroke: dom-stroke)
+ }
+ }
+
+ // ── Draw debug references last so they stay in the foreground ───
+ for ref-item in ref-items {
+ draw-ref(ref-item.pos, ref-item.name, ref-item.side)
+ }
+ })
+ })
+ }
+
+ if font != none {
+ set text(font: font)
+ _body
+ } else {
+ _body
+ }
+}
+
+// ── Multi-tree group ──────────────────────────────────────────────────────────
+// Renders multiple syntax trees in a single canvas with cross-tree
+// equivalence lines. Follows the geom-group() pattern from phonokit.
+//
+// Each positional argument is a spec dict with the same keys as tree():
+// (input: "[S [NP ...] [VP ...]]", direction: "down", spread: 1.0, ...)
+//
+// Node anchors are suffixed with tree index: "np1-1" = first NP in tree 1.
+// The "-down" variant goes before the index: "np1-down-1".
+//
+// Equivalence entries: ("np1-1", "np1-2") or (from: "np1-1", to: "np1-2", ...)
+
+#let garden(
+ ..trees,
+ equivalence: (),
+ gap: 2.0,
+ scale: 1.0,
+ line-width: 1.0,
+ font: none,
+) = {
+ let specs = trees.pos()
+ let scale-factor = scale
+
+ let _body = context {
+ let fsz = 12 * scale-factor * 1pt
+ let _norm = luma(15%)
+
+ // ── Per-tree computation ──────────────────────────────────────────────
+ let all-td = () // array of tree-data dicts
+
+ for (idx, spec) in specs.enumerate() {
+ let tidx = idx + 1
+ let input = spec.at("input")
+ let direction = spec.at("direction", default: "down")
+ let spread-val = spec.at("spread", default: 1.0)
+ let drop-val = spec.at("drop", default: 1.0)
+ let content-size = spec.at("content-size", default: 0.8)
+ let node-size = spec.at("node-size", default: 1.0)
+ let triangle-arg = spec.at("triangle", default: ())
+ let terminal-branch = spec.at("terminal-branch", default: false)
+ let bottom = spec.at("bottom", default: true)
+
+ let is-horiz = direction == "right" or direction == "left"
+ let leaf-w = spread-val
+ let v-gap = if is-horiz { 1.2 * drop-val * 1.05 } else { 1.2 * drop-val }
+
+ // Parse
+ let tokens = _tokenize(input)
+ let (tree, _, _) = _parse(tokens, 0, (:))
+
+ // Auto-triangle
+ let tri-set = triangle-arg
+ let _auto-tri(node) = {
+ let result = ()
+ if not node.is-leaf and node.children.len() > 0 {
+ let stripped = _strip-fmt(node.label)
+ let is-phrase = stripped.len() > 1 and lower(stripped).ends-with("p")
+ let all-leaves = node.children.all(c => c.is-leaf)
+ if is-phrase and all-leaves { result.push(node.anchor) }
+ for child in node.children { result = result + _auto-tri(child) }
+ }
+ result
+ }
+ let tri-set = tri-set + _auto-tri(tree)
+
+ // Layout
+ let nodes = _syntax-layout(
+ tree,
+ 0.0,
+ 0.0,
+ none,
+ none,
+ leaf-w,
+ v-gap,
+ tri-set,
+ is-horiz: is-horiz,
+ append-map: (:),
+ content-size: content-size,
+ level: 0,
+ drop-map: (:),
+ node-spacing-map: (:),
+ sister-spacing-map: (:),
+ sister-node-map: (:),
+ annotation-map: (:),
+ annotation-leaf-widths: (:),
+ )
+
+ // Bottom-align
+ if bottom {
+ terminal-branch = true
+ let min-y = nodes.fold(0.0, (acc, e) => if e.is-leaf { calc.min(acc, e.y) } else { acc })
+ let min-y = nodes.fold(min-y, (acc, e) => {
+ if e.at("is-triangle", default: false) { calc.min(acc, e.at("tri-y")) } else { acc }
+ })
+ nodes = nodes.map(e => {
+ if e.is-leaf { (..e, y: min-y) } else if e.at("is-triangle", default: false) {
+ (..e, tri-y: min-y, bottom-aligned: true)
+ } else { e }
+ })
+ }
+
+ // Terminal pull (reduce gap when no branch line drawn)
+ if not terminal-branch {
+ nodes = nodes.map(e => {
+ if e.at("is-terminal", default: false) and e.par != none {
+ let pull = if is-horiz { 0.2 } else { 0.5 }
+ (..e, y: e.y + (e.par.at(1) - e.y) * pull)
+ } else { e }
+ })
+ }
+
+ // Direction transform
+ let _tx(x, y) = {
+ if direction == "up" { (x, -y) } else if direction == "right" { (-y, -x) } else if direction == "left" {
+ (y, -x)
+ } else { (x, y) }
+ }
+ let (gdx, gdy) = if direction == "up" { (0, 1) } else if direction == "right" { (1, 0) } else if (
+ direction == "left"
+ ) { (-1, 0) } else { (0, -1) }
+
+ // Node out/in offsets for branch connections
+ let node-out = (:)
+ let node-in = (:)
+ for e in nodes {
+ if is-horiz {
+ let full-w = _label-half-w(e.label) * 2
+ let is-tri = e.at("is-triangle", default: false)
+ node-out.insert(e.anchor, if is-tri { full-w + 0.25 } else { full-w })
+ node-in.insert(e.anchor, 0.05)
+ } else {
+ node-out.insert(e.anchor, _loff)
+ node-in.insert(e.anchor, _loff)
+ }
+ }
+
+ // Transform coordinates
+ nodes = nodes.map(e => {
+ let (nx, ny) = _tx(e.x, e.y)
+ let new-par = if e.par != none {
+ let (px, py) = _tx(e.par.at(0), e.par.at(1))
+ (px, py)
+ } else { none }
+ let result = (..e, x: nx, y: ny, par: new-par)
+ if e.at("is-triangle", default: false) {
+ let (tri-nx, tri-ny) = _tx(e.x, e.at("tri-y"))
+ (..result, tri-y: tri-ny, tri-x: tri-nx)
+ } else { result }
+ })
+
+ // Build name-to-pos, arrow-off-map, label-hw-map with tree-index suffix
+ let _text-half-h = 0.2
+ let _arrow-gap = 0.25
+ let regular-off = _text-half-h + _arrow-gap
+ let ntp = (:)
+ let aom = (:)
+ let lhw = (:)
+ let sfx = "-" + str(tidx)
+ for e in nodes {
+ ntp.insert(e.anchor + sfx, (e.x, e.y, _loff))
+ aom.insert(e.anchor + sfx, regular-off)
+ lhw.insert(e.anchor + sfx, _rendered-len(e.label) * 0.28 / 2 + 0.05)
+ }
+
+ // Build -down entries (leaf content positions)
+ let _build-down-g(node, ntp, aom, lhw) = {
+ if node.is-leaf or node.children.len() == 0 {
+ ntp.insert(node.anchor + "-down" + sfx, ntp.at(node.anchor + sfx))
+ aom.insert(node.anchor + "-down" + sfx, regular-off)
+ lhw.insert(node.anchor + "-down" + sfx, _rendered-len(node.label) * 0.28 / 2 + 0.05)
+ return (ntp, aom, lhw)
+ }
+ let tri-entry = nodes.filter(e => e.anchor == node.anchor and e.at("is-triangle", default: false))
+ if tri-entry.len() > 0 {
+ let te = tri-entry.at(0)
+ ntp.insert(node.anchor + "-down" + sfx, (te.x, te.at("tri-y"), _loff))
+ let tri-off = 0.05 + _text-half-h * 2 + _arrow-gap
+ aom.insert(node.anchor + "-down" + sfx, tri-off)
+ let tri-label = te.at("tri-label")
+ let tri-lines = tri-label.split(" \\n ")
+ let tri-lines = if tri-lines.len() == 1 { tri-label.split("\\n") } else { tri-lines }
+ let widest = tri-lines.fold(0, (acc, l) => calc.max(acc, _rendered-len(l.trim())))
+ let char-w = 0.22 * content-size
+ lhw.insert(node.anchor + "-down" + sfx, calc.max(widest * char-w / 2, 0.3))
+ return (ntp, aom, lhw)
+ }
+ let leaf-anchors = _collect-leaf-anchors(node)
+ let found = leaf-anchors.map(a => a + sfx).filter(a => a in ntp)
+ if found.len() > 0 {
+ let avg-x = found.map(a => ntp.at(a).at(0)).fold(0.0, (a, b) => a + b) / found.len()
+ let ys = found.map(a => ntp.at(a).at(1))
+ let leaf-y = if gdy < 0 { calc.min(..ys) } else { calc.max(..ys) }
+ ntp.insert(node.anchor + "-down" + sfx, (avg-x, leaf-y, _loff))
+ aom.insert(node.anchor + "-down" + sfx, regular-off)
+ let xs = found.map(a => ntp.at(a).at(0))
+ let span-hw = if xs.len() > 1 {
+ (calc.max(..xs) - calc.min(..xs)) / 2 + 0.3
+ } else {
+ (
+ _rendered-len(
+ nodes.filter(e => e.anchor + sfx == found.at(0)).at(0, default: (label: "XX")).label,
+ )
+ * 0.28
+ / 2
+ + 0.05
+ )
+ }
+ lhw.insert(node.anchor + "-down" + sfx, span-hw)
+ }
+ for child in node.children {
+ (ntp, aom, lhw) = _build-down-g(child, ntp, aom, lhw)
+ }
+ (ntp, aom, lhw)
+ }
+ (ntp, aom, lhw) = _build-down-g(tree, ntp, aom, lhw)
+
+ // Compute tree vertical extents
+ let y-min = nodes.fold(0.0, (acc, e) => {
+ let y = e.y
+ if e.at("is-triangle", default: false) { calc.min(acc, y, e.at("tri-y")) } else { calc.min(acc, y) }
+ })
+ let y-max = nodes.fold(0.0, (acc, e) => {
+ let y = e.y
+ if e.at("is-triangle", default: false) { calc.max(acc, y, e.at("tri-y")) } else { calc.max(acc, y) }
+ })
+
+ all-td.push((
+ nodes: nodes,
+ ntp: ntp,
+ aom: aom,
+ lhw: lhw,
+ tidx: tidx,
+ sfx: sfx,
+ direction: direction,
+ gdx: gdx,
+ gdy: gdy,
+ is-horiz: is-horiz,
+ node-out: node-out,
+ node-in: node-in,
+ tri-set: tri-set,
+ content-size: content-size,
+ node-size: node-size,
+ terminal-branch: terminal-branch,
+ y-min: y-min,
+ y-max: y-max,
+ ))
+ }
+
+ // ── Stack trees vertically ────────────────────────────────────────────
+ let y-offsets = ()
+ for (i, td) in all-td.enumerate() {
+ if i == 0 {
+ y-offsets.push(0.0)
+ } else {
+ let prev = all-td.at(i - 1)
+ let prev-bottom = prev.y-min + y-offsets.at(i - 1)
+ y-offsets.push(prev-bottom - gap - td.y-max)
+ }
+ }
+
+ // Apply y-offsets to nodes and merge name-to-pos
+ let shared-ntp = (:)
+ let shared-aom = (:)
+ let shared-lhw = (:)
+ for (i, td) in all-td.enumerate() {
+ let y-off = y-offsets.at(i)
+ all-td.at(i).nodes = td.nodes.map(e => {
+ let new-par = if e.par != none { (e.par.at(0), e.par.at(1) + y-off) } else { none }
+ let result = (..e, y: e.y + y-off, par: new-par)
+ if e.at("is-triangle", default: false) {
+ (..result, tri-y: e.at("tri-y") + y-off)
+ } else { result }
+ })
+ for (key, val) in td.ntp.pairs() {
+ shared-ntp.insert(key, (val.at(0), val.at(1) + y-off, val.at(2)))
+ }
+ for (key, val) in td.aom.pairs() { shared-aom.insert(key, val) }
+ for (key, val) in td.lhw.pairs() { shared-lhw.insert(key, val) }
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────
+ let sw = 0.05em * scale-factor * line-width
+
+ box(inset: 1.2em, baseline: 40%, {
+ cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ // Draw each tree
+ for td in all-td {
+ let nodes = td.nodes
+ let gdx = td.gdx
+ let gdy = td.gdy
+ let direction = td.direction
+ let is-horiz = td.is-horiz
+ let node-fsz = fsz * td.node-size
+ let content-fsz = fsz * td.content-size
+
+ // ── Edges ──
+ for e in nodes {
+ if e.par != none and (td.terminal-branch or not e.at("is-terminal", default: false)) {
+ let par-off = td.node-out.at(e.at("par-anchor", default: ""), default: _loff)
+ let child-off = td.node-in.at(e.anchor, default: _loff)
+ line(
+ (e.par.at(0) + gdx * par-off, e.par.at(1) + gdy * par-off),
+ (e.x - gdx * child-off, e.y - gdy * child-off),
+ stroke: (paint: _norm, thickness: sw),
+ )
+ }
+ }
+
+ // ── Triangles ──
+ for e in nodes {
+ if e.at("is-triangle", default: false) {
+ let tri-label = e.at("tri-label")
+ let tlx = e.at("tri-x", default: e.x)
+ let tly = e.at("tri-y")
+ let vert = direction == "down" or direction == "up"
+ let half-w = if vert {
+ let char-w = 0.22 * td.content-size
+ let tl = tri-label.split(" \\n ")
+ let tl = if tl.len() == 1 { tri-label.split("\\n") } else { tl }
+ calc.max(tl.fold(0, (acc, l) => calc.max(acc, _rendered-len(l.trim()))) * char-w / 2, 0.3)
+ } else {
+ let tl = tri-label.split(" \\n ")
+ let tl = if tl.len() == 1 { tri-label.split("\\n") } else { tl }
+ calc.max(tl.len() * 0.55 / 2, 0.4)
+ }
+ let tri-off = td.node-out.at(e.anchor, default: _loff)
+ let apex = (e.x + gdx * tri-off, e.y + gdy * tri-off)
+ let (b1, b2) = if vert {
+ ((tlx - half-w, tly - gdy * _loff), (tlx + half-w, tly - gdy * _loff))
+ } else {
+ let bx = _loff
+ ((tlx + gdx * bx, tly - half-w), (tlx + gdx * bx, tly + half-w))
+ }
+ line(apex, b1, stroke: (paint: _norm, thickness: sw))
+ line(apex, b2, stroke: (paint: _norm, thickness: sw))
+ line(b1, b2, stroke: (paint: _norm, thickness: sw))
+ }
+ }
+
+ // ── Labels ──
+ for e in nodes {
+ let display = _display-label(e.label)
+ let _has-upper = _strip-fmt(e.label).match(regex("[A-Z]")) != none
+ let sz = if e.at("is-terminal", default: false) or (e.is-leaf and not _has-upper) {
+ content-fsz
+ } else { node-fsz }
+ let label-body = text(size: sz, fill: _norm, display)
+ let label-anchor = if direction == "right" { "west" } else if direction == "left" { "east" } else {
+ "center"
+ }
+
+ if e.at("is-triangle", default: false) {
+ content((e.x, e.y), label-body, anchor: label-anchor)
+ let tri-leaves = e.at("tri-leaves")
+ let lines = ((),)
+ for leaf in tri-leaves {
+ if leaf == "\\n" { lines.push(()) } else { lines.at(-1).push(leaf) }
+ }
+ let line-contents = lines.map(words => {
+ text(size: content-fsz, fill: _norm, words.map(w => _display-label(w)).join([ ]))
+ })
+ let tri-align = if direction == "right" { left } else if direction == "left" { right } else { center }
+ let tri-body = if line-contents.len() > 1 {
+ align(tri-align, stack(spacing: 0.35em, ..line-contents))
+ } else { line-contents.at(0) }
+ let is-bottom-aligned = e.at("bottom-aligned", default: false)
+ let tri-text-gap = if is-bottom-aligned { 0 } else if is-horiz { 0.80 } else { 0.05 }
+ let tlx2 = e.at("tri-x", default: e.x)
+ let tly2 = e.at("tri-y")
+ let tri-anchor = if direction == "up" { "south" } else if (
+ direction == "down"
+ ) { "north" } else if direction == "right" { "west" } else { "east" }
+ content((tlx2 + gdx * tri-text-gap, tly2 + gdy * tri-text-gap), tri-body, anchor: tri-anchor)
+ } else {
+ let (lx, ly) = (e.x, e.y)
+ let cur-anchor = label-anchor
+ if is-horiz and e.at("is-terminal", default: false) and e.par != none {
+ lx = lx + (e.par.at(0) - lx) * 0.4
+ ly = ly + (e.par.at(1) - ly) * 0.4
+ } else if not is-horiz and e.at("is-terminal", default: false) {
+ cur-anchor = if direction == "up" { "south" } else { "north" }
+ }
+ content((lx, ly), label-body, anchor: cur-anchor)
+ }
+ }
+ }
+
+ // ── Equivalence lines ──────────────────────────────────────────────
+ let eq-entries = if equivalence.len() > 0 and type(equivalence.at(0)) == str {
+ (equivalence,)
+ } else { equivalence }
+
+ for eq in eq-entries {
+ let is-dict = type(eq) == dictionary
+ let raw-from = if is-dict { eq.at("from") } else { eq.at(0) }
+ let raw-to = if is-dict { eq.at("to") } else { eq.at(1) }
+ let eq-color = if is-dict { eq.at("color", default: _norm) } else { _norm }
+ let eq-dash = if is-dict { eq.at("dash", default: "dashed") } else { "dashed" }
+ let eq-lw = if is-dict { eq.at("line-width", default: 1.0) } else { 1.0 }
+
+ // Resolve: bare "np1-1" → "np1-down-1" if available (default to leaf text).
+ // "np1-1-top" → "np1-1" (node label position, stripping -top).
+ let _resolve-eq(name) = {
+ // -top: strip suffix, target node label
+ if name.ends-with("-top") {
+ return name.slice(0, name.len() - 4)
+ }
+ // Already has -down: use as-is
+ if name.contains("-down") { return name }
+ // Try resolving to -down (leaf content)
+ let m = name.match(regex("^(.+)-(\d+)$"))
+ if m != none {
+ let base = m.captures.at(0)
+ let tidx-str = m.captures.at(1)
+ let down-key = base + "-down-" + tidx-str
+ if down-key in shared-ntp { return down-key }
+ }
+ name
+ }
+ let from-name = _resolve-eq(raw-from)
+ let to-name = _resolve-eq(raw-to)
+
+ if from-name in shared-ntp and to-name in shared-ntp {
+ let (fx, fy, _) = shared-ntp.at(from-name)
+ let (tx, ty, _) = shared-ntp.at(to-name)
+
+ // Offset endpoints in their respective tree's growth direction
+ let _get-tidx(name) = {
+ let m = name.match(regex("-(\d+)$"))
+ if m != none { int(m.captures.at(0)) } else { 1 }
+ }
+ let from-td = all-td.at(_get-tidx(from-name) - 1)
+ let to-td = all-td.at(_get-tidx(to-name) - 1)
+ // Uniform offset for equivalence lines so endpoints align vertically
+ let eq-off = 0.45
+ let f-off = eq-off
+ let t-off = eq-off
+ fx = fx + from-td.gdx * f-off
+ fy = fy + from-td.gdy * f-off
+ tx = tx + to-td.gdx * t-off
+ ty = ty + to-td.gdy * t-off
+
+ let a-sw = 0.018 * eq-lw
+ let eq-stroke = if eq-dash == "solid" {
+ (paint: eq-color, thickness: a-sw)
+ } else {
+ (paint: eq-color, thickness: a-sw, dash: eq-dash)
+ }
+ line((fx, fy), (tx, ty), stroke: eq-stroke)
+ }
+ }
+ })
+ })
+ }
+
+ if font != none {
+ set text(font: font)
+ _body
+ } else {
+ _body
+ }
+}
diff --git a/packages/preview/synkit/0.0.41/typst.toml b/packages/preview/synkit/0.0.41/typst.toml
new file mode 100644
index 0000000000..10a350005a
--- /dev/null
+++ b/packages/preview/synkit/0.0.41/typst.toml
@@ -0,0 +1,13 @@
+[package]
+categories = ["utility", "text", "visualization"]
+disciplines = ["linguistics"]
+name = "synkit"
+version = "0.0.41"
+exclude = ["gallery/"]
+keywords = ["linguistics", "syntax", "semantics", "morphology"]
+entrypoint = "lib.typ"
+authors = ["Guilherme D. Garcia "]
+license = "MIT"
+description = "A toolkit to create syntactic representations"
+repository = "https://github.com/guilhermegarcia/synkit"
+homepage = "https://gdgarcia.ca/synkit"