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 logo + +
+ +
+ +![Typst Package](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fguilhermegarcia%2Fsynkit%2Fmain%2Ftypst.toml&query=%24.package.version&prefix=v&logo=typst&label=package&color=239DAD) +![MIT license badge](https://img.shields.io/badge/license-MIT-blue) +[![PDF manual badge](https://img.shields.io/badge/manual-.pdf-purple)](https://doi.org/10.5281/zenodo.19405774) + +
+ +`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 + + + + + + + + + + + + +
+ Syntax tree with movement arrows +
Tree with arrows indicating movement +
+ Syntax tree with semantic annotation and multidominance +
Semantic annotation and multidominance +
+ Two syntax trees linked by equivalence lines +
Equivalences between two different trees +
+ Styled syntax tree with color and emoji +
Adjust color, font, and add emojis for less serious trees +
+ Numbered example with inline movement notation +
In-line movement with minimal syntax +
+ Numbered examples and interlinear glosses +
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"