diff --git a/packages/preview/phonokit/0.5.11/LICENSE b/packages/preview/phonokit/0.5.11/LICENSE
new file mode 100644
index 0000000000..e116f4b73a
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 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/phonokit/0.5.11/README.md b/packages/preview/phonokit/0.5.11/README.md
new file mode 100644
index 0000000000..392d070081
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/README.md
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+[](https://typst.app/universe/package/phonokit)
+[](LICENSE)
+[](https://doi.org/10.5281/zenodo.18260076)
+
+
+
+**Charis font is needed** for this package to work exactly as intended, but you can also use your own font with `#phonokit-init(font: "...")`. New Computer Modern is used for arrows.
+
+## 📝 Some examples
+
+
+
+
+
+ IPA transcription based on tipa
+
+
+
+ Consonant inventories (with pre-defined languages)
+
+
+
+ Vowel trapezoids (with pre-defined languages and arrows)
+
+
+
+
+
+ Multi-tier representations
+
+
+
+ Syllable structure (onset-rhyme and moraic)
+
+
+
+ Prosodic word (with metrical parsing)
+
+
+
+
+
+ Metrical grids with IPA support
+
+
+
+ Autosegmental phonology: features
+
+
+
+ Autosegmental phonology: tones
+
+
+
+
+
+ Feature geometry
+
+
+
+ OT tableaux with automatic shading
+
+
+
+ MaxEnt tableaux with automatic calculation
+
+
+
+
+Click on any image to view its source code.
+
+## 🔍 Manual
+
+See [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage.
+
+### IPA Module
+
+- **tipa-style input**: Use familiar LaTeX tipa notation instead of hunting for Unicode symbols
+- **Comprehensive symbol support**: most common IPA consonants, vowels, and diacritics
+- **Vowel charts**: Plot vowels on the IPA vowel trapezoid with accurate positioning
+- **Consonant tables**: Display consonants in the pulmonic IPA consonant table
+- **Scalable charts**: Adjust size to fit your document layout (scaling includes text as expected)
+
+### Phonetics Module
+
+- **Sound shifts**: Create schematic chain shifts, mergers, and splits by placing symbols freely in two-dimensional space and connecting them with arrows
+- **Voice Onset Time**: Generate pedagogical VOT timelines showing closure, release, aspiration, and voicing onset, with support for segment labels and localized interface labels
+- **Vowel dispersion**: Plot illustrative F1/F2 vowel distributions with optional jitter, ellipses, and support for reading real vowel data from `csv` files
+
+### Prosody Module
+
+- **Prosodic structure visualization**: Draw syllable structures (onset-rhyme and moraic representations) as well as feet and prosodic words with simple and intuitive syntax. You can also define which symbols you prefer to use for different prosodic domains
+- **Multi-tier representations**: Create complex non-linear representations
+- **Metrical grids**: Inputs as strings or tuples
+- **Sonority profile**: Visualize the sonority of a string
+- **ToBI labels**: Add intonational labels to running text with `#int()`, with optional vertical lines for association
+
+### Autosegmental Module
+
+- **Features and tones**: Create autosegmental representation for both features and tones
+- **Support for common processes**: Easily add linking, delinking, floating tones, one-to-many relationships and highlighting. Additional options for spacing and annotation also available
+
+### Feature Geometry Module
+
+- **Create complex feature geometry representations**: Pre-defined phonemes are available for quick representations
+- **Intuitive arguments based on typical nodes**: Create custom representations with intuitive argument structure
+- **Support for arrows and delinking**: Represent processes with arrows, which may or may not curve. Obstacle avoidance is attempted when arrows curve, but you can also customize arrow path.
+- **Highlight nodes**: Easily highlight nodes with the `highlight` argument, which dims the whole representation *except* the nodes you need highlighted.
+
+### SPE module
+
+- **Feature matrices**: Create SPE-style feature matrices with `#feat()` and segment-based distinctive-feature matrices with `#feat-matrix()`
+
+### Optimality Theory Module
+
+- **OT tableaux**: Create publication-ready Optimality Theory tableaux with automatic formatting
+- **Automatic shading**: Cells are automatically grayed out after fatal violations
+- **Winner indication**: Optimal candidates automatically marked with ☞ (pointing finger)
+- **IPA support**: Input and candidate forms can use tipa-style IPA notation
+- **Hasse diagrams**: Generate Hasse diagrams to visualize constraint rankings
+
+### Maximum Entropy Module
+
+- **MaxEnt tableaux**: Generate Maximum Entropy grammar tableaux with probability calculations
+- **Automatic calculations**: Computes harmony scores H(x), unnormalized probabilities P*(x), and normalized probabilities P(x)
+- **Visual probability bars**: Optional graphical representation of candidate probabilities
+- **IPA support**: Input and candidate forms can use tipa-style IPA notation
+
+### Harmonic Grammar Module
+
+- **HG tableaux**: Generate HG tableaux with automatic calculation of harmony given constraint weights and violations
+- **Noisy HG**: Generate NHG tableaux with automatic calculation of harmony and probabilities derived from simulated noise and multiple evaluations
+
+### General Module
+
+- **Numbered examples**: Create examples and sub-examples with labels and correct alignment
+- **Shortcuts**: Quick commands to add a range of arrows, angle brackets for extrametricality, and SPE-style underlines for context
+
+***
+
+## 📦 Package Repository
+
+- `https://github.com/guilhermegarcia/phonokit` [(most up-to-date version)](http://github.com/guilhermegarcia/phonokit)
+- `https://typst.app/universe/package/phonokit` [(published on Typst)](https://typst.app/universe/package/phonokit)
+
+## Package website
+
+For the most up-to-date information about the package, vignettes and demos, visit .
+
+## License
+
+MIT
+
+## Author
+
+**Guilherme D. Garcia** \
+Email: \
+Website:
+
+## 📚 Citation
+
+If you use this package in your research, please visit its GitHub repository and cite it using the metadata from the `CITATION.cff` file or click the "Cite this repository" button in the GitHub sidebar.
+
+## Contributing
+
+Contributions are welcome! Please feel free to submit issues or pull requests.
diff --git a/packages/preview/phonokit/0.5.11/_config.typ b/packages/preview/phonokit/0.5.11/_config.typ
new file mode 100644
index 0000000000..6ccb1944da
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/_config.typ
@@ -0,0 +1,8 @@
+// Shared configuration state for phonokit
+// This module provides a global font setting that all modules can access.
+
+#let phonokit-font = state("phonokit-font", "Charis")
+
+#let phonokit-init(font: "Charis") = {
+ phonokit-font.update(font)
+}
diff --git a/packages/preview/phonokit/0.5.11/autosegmental.typ b/packages/preview/phonokit/0.5.11/autosegmental.typ
new file mode 100644
index 0000000000..275f933d0e
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/autosegmental.typ
@@ -0,0 +1,275 @@
+#import "@preview/cetz:0.5.2"
+#import "ipa.typ": ipa
+#import "_config.typ": phonokit-font
+
+#let autoseg(
+ segments,
+ features: (),
+ links: (),
+ delinks: (),
+ spacing: 1.5,
+ arrow: false,
+ tone: false,
+ highlight: (),
+ float: (),
+ multilinks: (),
+ baseline: 40%,
+ gloss: "",
+ dash: "dashed",
+) = {
+ box(inset: 1.2em, baseline: baseline, cetz.canvas({
+ import cetz.draw: *
+
+ // Coordinate positions depend on whether we're drawing tones or features
+ let seg_y = if tone { 0 } else { 0.8 }
+ let feat_y = if tone { 0.8 } else { 0 }
+ let gloss_y = if tone { -0.8 } else { 1.6 }
+ let seg_anchor_dir = if tone { "north" } else { "south" }
+ let feat_anchor_dir = if tone { "south" } else { "north" }
+ let gloss_anchor_dir = if tone { "north" } else { "south" }
+
+ for (i, seg) in segments.enumerate() {
+ let feat = features.at(i, default: "")
+ let x = i * spacing
+
+ // Check if this position is part of a multilink (skip normal drawing if so)
+ let is_multilinked = multilinks.any(entry => {
+ let tone_spec = entry.at(0)
+ let tone_pos = if type(tone_spec) == array { tone_spec.at(0) } else { tone_spec }
+ tone_pos == i
+ })
+
+ // Handle multiple tones/features as an array (for branching)
+ let feat_array = if type(feat) == array { feat } else { (feat,) }
+ let num_feats = feat_array.len()
+
+ group(name: "n" + str(i), {
+ // 3. Labels (segment)
+ // Use horizon alignment to ensure consistent baseline across all segments
+ content((x, seg_y), padding: 0.1, anchor: seg_anchor_dir, box(height: 1em, align(horizon, context text(
+ font: phonokit-font.get(),
+ ipa(seg),
+ ))))
+
+ // Process each tone/feature (creates branching for multiple tones)
+ // Skip if this position is multilinked (will be drawn by multilink code)
+ for (j, f) in feat_array.enumerate() {
+ if f != "" and not is_multilinked {
+ // Calculate horizontal offset for multiple tones
+ let x_offset = if num_feats > 1 { (j - (num_feats - 1) / 2) * 0.6 } else { 0 }
+ let feat_x = x + x_offset
+
+ // 1. Draw the vertical stem if not floating
+ if i not in float {
+ line((feat_x, feat_y), (x, seg_y), stroke: 0.05em, name: "stem" + str(j))
+ }
+
+ // 2. Feature/tone label with optional circle highlight
+ // Check if this specific tone should be highlighted
+ let should_highlight = tone and (i in highlight or (i, j) in highlight)
+ let box_stroke = if should_highlight { 0.05em + black } else { none }
+ content((feat_x, feat_y), padding: 0.1, anchor: feat_anchor_dir, box(
+ stroke: box_stroke,
+ inset: 0.15em,
+ radius: 100%,
+ width: 1.2em,
+ height: 1.2em,
+ align(center + horizon, context text(font: phonokit-font.get(), f)),
+ ))
+
+ // Create anchor for this specific tone (for sub-indexing in links)
+ anchor("feat" + str(j), (feat_x, feat_y))
+ }
+ }
+
+ // 3. Anchors for association lines (center position and segment)
+ anchor("seg", (x, seg_y))
+ anchor("feat", (x, feat_y))
+ })
+ }
+
+ // 4. Draw the spreading links
+ for (src, dest) in links {
+ // Parse src and dest - can be int or (pos, sub) tuple for targeting specific tones
+ let parse_index = idx => {
+ if type(idx) == array {
+ (pos: idx.at(0), sub: idx.at(1))
+ } else {
+ (pos: idx, sub: none)
+ }
+ }
+
+ let src_parsed = parse_index(src)
+ let dest_parsed = parse_index(dest)
+
+ // Build anchor names with optional sub-index
+ let get_anchor = (parsed, tier) => {
+ let base = "n" + str(parsed.pos)
+ if tier == "feat" and parsed.sub != none {
+ base + ".feat" + str(parsed.sub)
+ } else if tier == "feat" {
+ base + ".feat"
+ } else {
+ base + ".seg"
+ }
+ }
+
+ // Both modes: link from feature/tone[src] to segment[dest]
+ let start_anchor = get_anchor(src_parsed, "feat")
+ let end_anchor = get_anchor(dest_parsed, "seg")
+
+ // Draw the association line
+ let link-stroke = if dash == "solid" { (thickness: 0.05em) } else { (dash: dash, thickness: 0.05em) }
+ line(start_anchor, end_anchor, stroke: link-stroke)
+
+ if arrow {
+ // Arrow points from feature/tone toward segment
+ line(start_anchor, end_anchor, stroke: (thickness: 0em), mark: (end: "stealth"), fill: black)
+ }
+ }
+
+ // 5. Draw multi-linked tones (shared tones positioned between segments)
+ for entry in multilinks {
+ let (tone_spec, seg_positions) = entry
+
+ // Parse tone specification (can be index or (pos, sub))
+ let tone_parsed = if type(tone_spec) == array {
+ (pos: tone_spec.at(0), sub: tone_spec.at(1))
+ } else {
+ (pos: tone_spec, sub: none)
+ }
+
+ // Calculate midpoint position for the tone
+ let min_seg = calc.min(..seg_positions)
+ let max_seg = calc.max(..seg_positions)
+ let mid_x = ((min_seg + max_seg) / 2) * spacing
+
+ // Get the tone text
+ let tone_feat = features.at(tone_parsed.pos, default: "")
+ let tone_array = if type(tone_feat) == array { tone_feat } else { (tone_feat,) }
+ let tone_text = if tone_parsed.sub != none {
+ tone_array.at(tone_parsed.sub)
+ } else {
+ if type(tone_feat) == array { tone_feat.join(" ") } else { tone_feat }
+ }
+
+ // Draw the tone at midpoint
+ let box_stroke = if tone and (tone_parsed.pos in highlight or (tone_parsed.pos, tone_parsed.sub) in highlight) {
+ 0.05em + black
+ } else {
+ none
+ }
+ content((mid_x, feat_y), padding: 0.1, anchor: feat_anchor_dir, box(
+ stroke: box_stroke,
+ inset: 0.15em,
+ radius: 100%,
+ width: 1.2em,
+ height: 1.2em,
+ align(center + horizon, context text(font: phonokit-font.get(), tone_text)),
+ ))
+
+ // Draw solid lines to each segment (no arrows, no dashes)
+ for seg_pos in seg_positions {
+ let seg_x = seg_pos * spacing
+ line((mid_x, feat_y), (seg_x, seg_y), stroke: 0.05em)
+ }
+ }
+
+ // 6. Draw delinks on association lines (drawn after multilinks to be on top)
+ for (src, dest) in delinks {
+ // Parse src and dest - can be int or (pos, sub) tuple
+ let parse_index = idx => {
+ if type(idx) == array {
+ (pos: idx.at(0), sub: idx.at(1))
+ } else {
+ (pos: idx, sub: none)
+ }
+ }
+
+ let src_parsed = parse_index(src)
+ let dest_parsed = parse_index(dest)
+
+ // Calculate dest position
+ let dest_x = dest_parsed.pos * spacing
+
+ // Check if source is multilinked
+ let is_multilinked = multilinks.any(entry => {
+ let tone_spec = entry.at(0)
+ let tone_pos = if type(tone_spec) == array { tone_spec.at(0) } else { tone_spec }
+ tone_pos == src_parsed.pos
+ })
+
+ let src_feat_x = if is_multilinked {
+ // Find the multilink entry for this source
+ let multilink_entry = multilinks.find(entry => {
+ let tone_spec = entry.at(0)
+ let tone_pos = if type(tone_spec) == array { tone_spec.at(0) } else { tone_spec }
+ tone_pos == src_parsed.pos
+ })
+ let seg_positions = multilink_entry.at(1)
+ let min_seg = calc.min(..seg_positions)
+ let max_seg = calc.max(..seg_positions)
+ ((min_seg + max_seg) / 2) * spacing
+ } else {
+ // Regular tone or branching tone
+ let src_x = src_parsed.pos * spacing
+ let src_feat = features.at(src_parsed.pos, default: "")
+ let src_feat_array = if type(src_feat) == array { src_feat } else { (src_feat,) }
+ let num_src_feats = src_feat_array.len()
+
+ let src_x_offset = if num_src_feats > 1 and src_parsed.sub != none {
+ (src_parsed.sub - (num_src_feats - 1) / 2) * 0.6
+ } else {
+ 0
+ }
+ src_x + src_x_offset
+ }
+
+ // Calculate midpoint for delink marks
+ let mid_x = (src_feat_x + dest_x) / 2
+ let mid_y = (seg_y + feat_y) / 2
+
+ // Draw the delink marks (two parallel lines perpendicular to the association line)
+ // Calculate the direction vector and its perpendicular
+ let dx = dest_x - src_feat_x
+ let dy = seg_y - feat_y
+ let length = calc.sqrt(dx * dx + dy * dy)
+
+ // Normalized direction
+ let dir_x = dx / length
+ let dir_y = dy / length
+
+ // Perpendicular direction (rotate 90 degrees)
+ let perp_x = -dir_y
+ let perp_y = dir_x
+
+ let offset = 0.15
+ let spacing_offset = 0.06
+
+ // First delink line (closer to tone)
+ let p1_start = (
+ mid_x - offset * perp_x - spacing_offset * dir_x,
+ mid_y - offset * perp_y - spacing_offset * dir_y,
+ )
+ let p1_end = (mid_x + offset * perp_x - spacing_offset * dir_x, mid_y + offset * perp_y - spacing_offset * dir_y)
+
+ // Second delink line (closer to segment)
+ let p2_start = (
+ mid_x - offset * perp_x + spacing_offset * dir_x,
+ mid_y - offset * perp_y + spacing_offset * dir_y,
+ )
+ let p2_end = (mid_x + offset * perp_x + spacing_offset * dir_x, mid_y + offset * perp_y + spacing_offset * dir_y)
+
+ line(p1_start, p1_end, stroke: 0.05em)
+ line(p2_start, p2_end, stroke: 0.05em)
+ }
+
+ // 7. Draw gloss (if provided)
+ if gloss != "" and gloss != () {
+ // Calculate center of the entire representation
+ let center_x = ((segments.len() - 1) / 2) * spacing
+ // Use fixed-height box to ensure consistent baseline alignment across autoseg instances
+ content((center_x, gloss_y), padding: 0.1, anchor: gloss_anchor_dir, box(height: 1em, align(horizon, context text(size: 0.9em, font: phonokit-font.get(), gloss))))
+ }
+ }))
+}
diff --git a/packages/preview/phonokit/0.5.11/consonants.typ b/packages/preview/phonokit/0.5.11/consonants.typ
new file mode 100644
index 0000000000..946d5f967f
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/consonants.typ
@@ -0,0 +1,938 @@
+#import "@preview/cetz:0.5.2": canvas, draw
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+#import "ui-lang.typ": resolve-ui-lang, ui-lang-error, ui-consonant-labels
+
+// Consonant data with place, manner, and voicing
+// place: 0=bilabial, 1=labiodental, 2=dental, 3=alveolar, 4=postalveolar
+// 5=retroflex, 6=palatal, 7=velar, 8=uvular, 9=pharyngeal, 10=glottal
+// manner: 0=plosive, 1=nasal, 2=trill, 3=tap/flap, 4=fricative,
+// 5=lateral fricative, 6=approximant, 7=lateral approximant
+#let consonant-data = (
+ // Plosives (row 0)
+ "p": (place: 0, manner: 0, voicing: false),
+ "b": (place: 0, manner: 0, voicing: true),
+ "t": (place: 3, manner: 0, voicing: false),
+ "d": (place: 3, manner: 0, voicing: true),
+ "ʈ": (place: 5, manner: 0, voicing: false),
+ "ɖ": (place: 5, manner: 0, voicing: true),
+ "c": (place: 6, manner: 0, voicing: false),
+ "ɟ": (place: 6, manner: 0, voicing: true),
+ "k": (place: 7, manner: 0, voicing: false),
+ "ɡ": (place: 7, manner: 0, voicing: true),
+ "g": (place: 7, manner: 0, voicing: true),
+ "q": (place: 8, manner: 0, voicing: false),
+ "ɢ": (place: 8, manner: 0, voicing: true),
+ "ʔ": (place: 10, manner: 0, voicing: false),
+ // Nasals (row 1)
+ "m": (place: 0, manner: 1, voicing: true),
+ "ɱ": (place: 1, manner: 1, voicing: true),
+ "n": (place: 3, manner: 1, voicing: true),
+ "ɳ": (place: 5, manner: 1, voicing: true),
+ "ɲ": (place: 6, manner: 1, voicing: true),
+ "ŋ": (place: 7, manner: 1, voicing: true),
+ "ɴ": (place: 8, manner: 1, voicing: true),
+ // Trills (row 2)
+ "ʙ": (place: 0, manner: 2, voicing: true),
+ "r": (place: 3, manner: 2, voicing: true),
+ "ʀ": (place: 8, manner: 2, voicing: true),
+ // Tap or Flap (row 3)
+ "ⱱ": (place: 1, manner: 3, voicing: true),
+ "ɾ": (place: 3, manner: 3, voicing: true),
+ "ɽ": (place: 5, manner: 3, voicing: true),
+ // Fricatives (row 4)
+ "ɸ": (place: 0, manner: 4, voicing: false),
+ "β": (place: 0, manner: 4, voicing: true),
+ "f": (place: 1, manner: 4, voicing: false),
+ "v": (place: 1, manner: 4, voicing: true),
+ "θ": (place: 2, manner: 4, voicing: false),
+ "ð": (place: 2, manner: 4, voicing: true),
+ "s": (place: 3, manner: 4, voicing: false),
+ "z": (place: 3, manner: 4, voicing: true),
+ "ʃ": (place: 4, manner: 4, voicing: false),
+ "ʒ": (place: 4, manner: 4, voicing: true),
+ "ʂ": (place: 5, manner: 4, voicing: false),
+ "ʐ": (place: 5, manner: 4, voicing: true),
+ "ç": (place: 6, manner: 4, voicing: false),
+ "ʝ": (place: 6, manner: 4, voicing: true),
+ "x": (place: 7, manner: 4, voicing: false),
+ "ɣ": (place: 7, manner: 4, voicing: true),
+ "χ": (place: 8, manner: 4, voicing: false),
+ "ʁ": (place: 8, manner: 4, voicing: true),
+ "ħ": (place: 9, manner: 4, voicing: false),
+ "ʕ": (place: 9, manner: 4, voicing: true),
+ "h": (place: 10, manner: 4, voicing: false),
+ "ɦ": (place: 10, manner: 4, voicing: true),
+ // Lateral fricatives (row 5)
+ "ɬ": (place: 3, manner: 5, voicing: false),
+ "ɮ": (place: 3, manner: 5, voicing: true),
+ // Approximants (row 6)
+ "w": (place: 0, manner: 6, voicing: true), // labiovelar, shown under bilabial
+ "ʋ": (place: 1, manner: 6, voicing: true),
+ "ɹ": (place: 3, manner: 6, voicing: true),
+ "ɻ": (place: 5, manner: 6, voicing: true),
+ "j": (place: 6, manner: 6, voicing: true),
+ "ɰ": (place: 7, manner: 6, voicing: true),
+ // Lateral approximants (row 7)
+ "l": (place: 3, manner: 7, voicing: true),
+ "ɭ": (place: 5, manner: 7, voicing: true),
+ "ʎ": (place: 6, manner: 7, voicing: true),
+ "ʟ": (place: 7, manner: 7, voicing: true),
+)
+
+// Aspirated plosive data (shown in separate row when aspirated: true)
+#let aspirated-plosive-data = (
+ // Aspirated plosives (voiceless only - aspiration is contrastive with plain voiceless)
+ "pʰ": (place: 0, voicing: false),
+ "tʰ": (place: 3, voicing: false),
+ "ʈʰ": (place: 5, voicing: false),
+ "cʰ": (place: 6, voicing: false),
+ "kʰ": (place: 7, voicing: false),
+ "qʰ": (place: 8, voicing: false),
+)
+
+// Affricate data (shown in separate row when affricates: true)
+// These appear after fricatives in the chart
+// Note: Displayed without tie bars since the row label makes it clear they're affricates
+#let affricate-data = (
+ // Labiodental affricates
+ "pf": (place: 1, voicing: false),
+ "bv": (place: 1, voicing: true),
+ // Alveolar affricates
+ "ts": (place: 3, voicing: false),
+ "dz": (place: 3, voicing: true),
+ // Postalveolar affricates
+ "tʃ": (place: 4, voicing: false),
+ "dʒ": (place: 4, voicing: true),
+ // Retroflex affricates
+ "ʈʂ": (place: 5, voicing: false),
+ "ɖʐ": (place: 5, voicing: true),
+ // Alveolo-palatal affricates
+ "tɕ": (place: 4, voicing: false), // Use postalveolar column
+ "dʑ": (place: 4, voicing: true),
+)
+
+// Aspirated affricate data (shown when both affricates: true AND aspirated: true)
+#let aspirated-affricate-data = (
+ // Aspirated affricates (voiceless only)
+ "tsʰ": (place: 3, voicing: false),
+ "tʃʰ": (place: 4, voicing: false),
+ "ʈʂʰ": (place: 5, voicing: false),
+ "tɕʰ": (place: 4, voicing: false), // Alveolo-palatal
+)
+
+// Column labels (places of articulation)
+#let places = (
+ "Bilabial",
+ "Labiodental",
+ "Dental",
+ "Alveolar",
+ "Postalveolar",
+ "Retroflex",
+ "Palatal",
+ "Velar",
+ "Uvular",
+ "Pharyngeal",
+ "Glottal",
+)
+
+#let places-short = (
+ "Bilab",
+ "Labdent",
+ "Dent",
+ "Alv",
+ "Postalv",
+ "Retro",
+ "Pal",
+ "Vel",
+ "Uvu",
+ "Phar",
+ "Glot",
+)
+
+// Row labels (manners of articulation)
+#let manners = (
+ "Plosive",
+ "Nasal",
+ "Trill",
+ "Tap or Flap",
+ "Fricative",
+ "Lateral fricative",
+ "Approximant",
+ "Lateral approximant",
+)
+
+#let manners-short = (
+ "Plos",
+ "Nas",
+ "Trill",
+ "Tap/Flap",
+ "Fric",
+ "Lat fric",
+ "Approx",
+ "Lat approx",
+)
+
+// Language consonant inventories
+#let language-consonants = (
+ "all": "pbtdʈɖcɟkɡqɢʔmɱnɳɲŋɴʙrʀⱱɾɽɸβfvθðszʃʒʂʐçʝxɣχʁħʕhɦɬɮʋɹɻjɰwlɭʎʟ",
+ "english": "pbmnŋtdkɡfvθðszʃʒhlɹwj",
+ "spanish": "pbmnɲtdkɡfθsxlrɾj",
+ "french": "pbmnɲtdkɡfrvszʃʒljw",
+ "german": "pbmntdkɡfvszʃʒçxhʁlj",
+ "italian": "pbmnɲtdkɡfvszʃʎlrj",
+ "japanese": "pbmnɲtdkɡçɸsʃzʒhɾj",
+ "portuguese": "pbmnɲtdkɡfvszʃʒʎxlɾjw",
+ "russian": "pbmntdkɡfvszʃʒxlrj",
+ "arabic": "btdkqʔmnfvðszʃxɣħʕhlrj",
+)
+
+// Language affricate inventories (used when affricates: true)
+// Note: No tie bars needed - the "Affricate" row label makes it clear
+// Note: Only one affricate pair per place - "all" uses more common variants
+#let language-affricates = (
+ "all": "pfbvtsdztʃdʒʈʂɖʐ", // tʃ/dʒ chosen over tɕ/dʑ (more common)
+ "english": "tʃdʒ",
+ "spanish": "tʃ",
+ "french": "", // No native affricates
+ "german": "pfts",
+ "italian": "tsdztʃdʒ",
+ "japanese": "", // Do not include allophones
+ "portuguese": "tʃdʒ",
+ "russian": "tstʃ",
+ "arabic": "", // No native affricates
+)
+
+// Language aspirated plosive inventories (used when aspirated: true)
+#let language-aspirated-plosives = (
+ "all": "pʰtʰʈʰcʰkʰqʰ",
+ "english": "", // English aspiration is allophonic, not phonemic
+ "spanish": "",
+ "french": "",
+ "german": "",
+ "italian": "",
+ "japanese": "",
+ "portuguese": "",
+ "russian": "",
+ "arabic": "",
+)
+
+// Language aspirated affricate inventories (used when both affricates: true AND aspirated: true)
+#let language-aspirated-affricates = (
+ "all": "tsʰtʃʰʈʂʰtɕʰ",
+ "english": "",
+ "spanish": "",
+ "french": "",
+ "german": "",
+ "italian": "",
+ "japanese": "",
+ "portuguese": "",
+ "russian": "",
+ "arabic": "",
+)
+
+// Helper function to extract braced content {phoneme} from input
+// Braced content can be affricates, aspirated consonants, etc.
+#let extract-braced-content(input) = {
+ let braced-items = ()
+ let cleaned = ""
+ let in-braces = false
+ let current-item = ""
+
+ for char in input.clusters() {
+ if char == "{" {
+ in-braces = true
+ current-item = ""
+ } else if char == "}" {
+ if in-braces and current-item != "" {
+ braced-items.push(current-item)
+ }
+ in-braces = false
+ current-item = ""
+ } else if in-braces {
+ current-item += char
+ } else {
+ cleaned += char
+ }
+ }
+
+ (braced-items: braced-items, cleaned: cleaned)
+}
+
+// Helper function to categorize braced items into affricates, aspirated plosives, and aspirated affricates
+#let categorize-braced-items(braced-items) = {
+ let affricates = ()
+ let aspirated-plosives = ()
+ let aspirated-affricates = ()
+
+ for item in braced-items {
+ // Convert IPA notation to Unicode (spaces are required for diacritics like \h)
+ let converted = ipa-to-unicode(item)
+
+ // Check which category this belongs to
+ if converted in aspirated-affricate-data {
+ aspirated-affricates.push(converted)
+ } else if converted in aspirated-plosive-data {
+ aspirated-plosives.push(converted)
+ } else if converted in affricate-data {
+ affricates.push(converted)
+ }
+ // Unknown braced items are silently ignored
+ }
+
+ (
+ affricates: affricates,
+ aspirated-plosives: aspirated-plosives,
+ aspirated-affricates: aspirated-affricates,
+ )
+}
+
+// Main consonants function
+#let consonants(
+ ..args, // Optional positional consonant-string (read below) — allows `lang`-only calls
+ lang: none,
+ ui-lang: "en",
+ affricates: false,
+ aspirated: false,
+ abbreviate: false,
+ simplify: false,
+ delete-cols: (),
+ delete-rows: (),
+ cell-width: 1.8,
+ cell-height: 0.9,
+ label-width: 3.5,
+ label-height: 1.2,
+ scale: 0.7,
+) = {
+ // Read the optional positional argument: consonant symbols (tipa-style IPA,
+ // optionally with braced affricates/aspirated items) or a built-in language
+ // name. Optional (via the `..args` sink) so that `lang`-only calls like
+ // `consonants(lang: "spanish")` work — a parameter with a default value would
+ // be named-only in Typst and could not be passed positionally.
+ assert(args.pos().len() <= 1,
+ message: "consonants: expected at most one positional argument (the consonant string)")
+ assert(args.named().len() == 0,
+ message: "consonants: unexpected named argument(s): " + args.named().keys().join(", "))
+ let consonant-string = args.pos().at(0, default: none)
+
+ // Determine which consonants to plot
+ let consonants-to-plot = ""
+ let custom-affricates-string = ""
+ let custom-aspirated-plosives-string = ""
+ let custom-aspirated-affricates-string = ""
+ let error-msg = none
+ let ui-locale = resolve-ui-lang(ui-lang)
+
+ if ui-locale == none {
+ return ui-lang-error(ui-lang)
+ }
+ let labels = ui-consonant-labels(ui-locale)
+
+ // Check if consonant-string is actually a language name
+ if consonant-string != none and consonant-string in language-consonants {
+ consonants-to-plot = language-consonants.at(consonant-string)
+ } else if lang != none {
+ if lang in language-consonants {
+ consonants-to-plot = language-consonants.at(lang)
+ } else {
+ let available = language-consonants.keys().join(", ")
+ error-msg = [*Error:* Language "#lang" not available. \ Available languages: #available]
+ }
+ } else if consonant-string != none and consonant-string != "" {
+ // Use as manual consonant specification
+ // Extract braced content first (affricates, aspirated consonants, etc.)
+ let extracted = extract-braced-content(consonant-string)
+
+ // Convert IPA notation to Unicode for consonants (excluding braced items)
+ consonants-to-plot = ipa-to-unicode(extracted.cleaned)
+
+ // Categorize braced items into their respective types
+ let categorized = categorize-braced-items(extracted.braced-items)
+ // Note: .join("") returns none for empty arrays in Typst, so we check length first
+ if categorized.affricates.len() > 0 {
+ custom-affricates-string = categorized.affricates.join("")
+ }
+ if categorized.aspirated-plosives.len() > 0 {
+ custom-aspirated-plosives-string = categorized.aspirated-plosives.join("")
+ }
+ if categorized.aspirated-affricates.len() > 0 {
+ custom-aspirated-affricates-string = categorized.aspirated-affricates.join("")
+ }
+ } else {
+ error-msg = [*Error:* Either provide consonant string or language name]
+ }
+
+ // If there's an error, display it and return
+ if error-msg != none {
+ return error-msg
+ }
+
+ // Determine which affricates to plot (if affricates: true)
+ let affricates-to-plot = ""
+ if affricates {
+ // Check if we used a language name
+ if consonant-string != none and consonant-string in language-affricates {
+ affricates-to-plot = language-affricates.at(consonant-string)
+ } else if lang != none and lang in language-affricates {
+ affricates-to-plot = language-affricates.at(lang)
+ } else {
+ // For custom input, use only the extracted affricates from braces
+ affricates-to-plot = custom-affricates-string
+ }
+ }
+
+ // Determine which aspirated consonants to plot (if aspirated: true)
+ let aspirated-plosives-to-plot = ""
+ let aspirated-affricates-to-plot = ""
+ if aspirated {
+ // Aspirated plosives
+ if consonant-string != none and consonant-string in language-aspirated-plosives {
+ aspirated-plosives-to-plot = language-aspirated-plosives.at(consonant-string)
+ } else if lang != none and lang in language-aspirated-plosives {
+ aspirated-plosives-to-plot = language-aspirated-plosives.at(lang)
+ } else {
+ // For custom input, use aspirated plosives extracted from braces
+ aspirated-plosives-to-plot = custom-aspirated-plosives-string
+ }
+
+ // Aspirated affricates (only if affricates is also true)
+ if affricates {
+ if consonant-string != none and consonant-string in language-aspirated-affricates {
+ aspirated-affricates-to-plot = language-aspirated-affricates.at(consonant-string)
+ } else if lang != none and lang in language-aspirated-affricates {
+ aspirated-affricates-to-plot = language-aspirated-affricates.at(lang)
+ } else {
+ // For custom input, use aspirated affricates extracted from braces
+ aspirated-affricates-to-plot = custom-aspirated-affricates-string
+ }
+ }
+ }
+
+ // If simplify is true, auto-delete empty columns and rows
+ if simplify {
+ // Collect all used places and manners from the data to plot
+ let used-places = ()
+ let used-manners = ()
+
+ // Scan main consonants
+ for consonant in consonants-to-plot.clusters() {
+ if consonant in consonant-data {
+ let info = consonant-data.at(consonant)
+ if info.place not in used-places { used-places.push(info.place) }
+ if info.manner not in used-manners { used-manners.push(info.manner) }
+ }
+ }
+
+ // Special /w/ handling: also occupies velar column if /ɰ/ is absent
+ if consonants-to-plot.contains("w") and not consonants-to-plot.contains("ɰ") {
+ if 7 not in used-places { used-places.push(7) }
+ }
+
+ // Scan aspirated plosives
+ if aspirated {
+ for asp-plosive in aspirated-plosive-data.keys() {
+ if aspirated-plosives-to-plot.contains(asp-plosive) {
+ let info = aspirated-plosive-data.at(asp-plosive)
+ if info.place not in used-places { used-places.push(info.place) }
+ // Aspirated plosives share manner 0 (Plosive row)
+ if 0 not in used-manners { used-manners.push(0) }
+ }
+ }
+ }
+
+ // Scan affricates
+ if affricates {
+ let affricates-cleaned = affricates-to-plot.replace("͡", "")
+ for affricate in affricate-data.keys() {
+ if affricates-cleaned.contains(affricate) {
+ let info = affricate-data.at(affricate)
+ if info.place not in used-places { used-places.push(info.place) }
+ // Affricates have their own inserted row, no base manner to add
+ }
+ }
+ }
+
+ // Scan aspirated affricates
+ if aspirated and affricates {
+ for asp-affricate in aspirated-affricate-data.keys() {
+ if aspirated-affricates-to-plot.contains(asp-affricate) {
+ let info = aspirated-affricate-data.at(asp-affricate)
+ if info.place not in used-places { used-places.push(info.place) }
+ }
+ }
+ }
+
+ // Merge: add unused places/manners to delete lists
+ for i in range(places.len()) {
+ if i not in used-places and i not in delete-cols {
+ delete-cols.push(i)
+ }
+ }
+ for i in range(manners.len()) {
+ if i not in used-manners and i not in delete-rows {
+ delete-rows.push(i)
+ }
+ }
+ }
+
+ // Select label sets based on abbreviation setting
+ let place-labels = if abbreviate { labels.places_short } else { labels.places }
+ let manner-labels = if abbreviate { labels.manners_short } else { labels.manners }
+
+ // Filter deleted columns and build column remapping
+ let kept-cols = range(places.len()).filter(i => i not in delete-cols)
+ let display-places = kept-cols.map(i => place-labels.at(i))
+ let col-remap = (:)
+ for (new-i, orig-i) in kept-cols.enumerate() {
+ col-remap.insert(str(orig-i), new-i)
+ }
+
+ // Filter deleted rows and build display-manners with optional row insertions
+ let kept-base-rows = range(manners.len()).filter(i => i not in delete-rows)
+ let display-manners = ()
+ let manner-to-row = (:)
+ let aspirated-plosive-row = -1
+ let affricate-row = -1
+ let aspirated-affricate-row = -1
+ let current-row = 0
+
+ for base-row in kept-base-rows {
+ display-manners.push(manner-labels.at(base-row))
+ manner-to-row.insert(str(base-row), current-row)
+ current-row += 1
+
+ // Insert aspirated plosive row after Plosive (base 0)
+ if base-row == 0 and aspirated {
+ let asp-label = if abbreviate { labels.aspirated_plosive_short } else { labels.aspirated_plosive }
+ display-manners.push(asp-label)
+ aspirated-plosive-row = current-row
+ current-row += 1
+ }
+
+ // Insert affricate rows after Fricative (base 4)
+ if base-row == 4 and affricates {
+ let affr-label = if abbreviate { labels.affricate_short } else { labels.affricate }
+ display-manners.push(affr-label)
+ affricate-row = current-row
+ current-row += 1
+
+ if aspirated {
+ let asp-affr-label = if abbreviate { labels.aspirated_affricate_short } else { labels.aspirated_affricate }
+ display-manners.push(asp-affr-label)
+ aspirated-affricate-row = current-row
+ current-row += 1
+ }
+ }
+ }
+
+ // Calculate scaled dimensions
+ let scaled-cell-width = cell-width * scale
+ let scaled-cell-height = cell-height * scale
+ let scaled-label-width = label-width * scale
+ let scaled-label-height = label-height * scale
+ let scaled-font-size = 18 * scale
+ let scaled-label-font-size = 9 * scale
+ let scaled-circle-radius = 0.3 * scale
+ let scaled-line-thickness = 0.8 * scale
+
+ let num-cols = display-places.len()
+ let num-rows = display-manners.len()
+
+ context {
+ let label-padding = 0.35 * scale
+ let column-widths = display-places.map(place => calc.max(
+ scaled-cell-width,
+ measure(text(size: scaled-label-font-size * 1pt, font: phonokit-font.get(), place)).width / 1cm + label-padding,
+ ))
+ let resolved-label-width = calc.max(
+ scaled-label-width,
+ calc.max(..display-manners.map(manner => measure(text(size: scaled-label-font-size * 1pt, font: phonokit-font.get(), manner)).width / 1cm)) + 0.35 * scale,
+ )
+
+ let col-starts = ()
+ let x-cursor = resolved-label-width
+ for width in column-widths {
+ col-starts.push(x-cursor)
+ x-cursor += width
+ }
+
+ canvas({
+ import draw: *
+
+ // Calculate total dimensions
+ let total-width = resolved-label-width + column-widths.sum()
+ let total-height = scaled-label-height + (num-rows * scaled-cell-height)
+
+ // Draw column headers (places of articulation)
+ for (i, place) in display-places.enumerate() {
+ let col-width = column-widths.at(i)
+ let x = col-starts.at(i) + (col-width / 2)
+ let y = total-height / 2 - (scaled-label-height * 0.65)
+
+ content((x, y), text(size: scaled-label-font-size * 1pt, font: phonokit-font.get(), top-edge: "cap-height", bottom-edge: "baseline", place), anchor: "center")
+ }
+
+ // Draw row headers (manners of articulation)
+ for (i, manner) in display-manners.enumerate() {
+ let x = resolved-label-width - 0.2
+ let y = total-height / 2 - scaled-label-height - (i * scaled-cell-height) - (scaled-cell-height / 2)
+
+ content((x, y), text(size: scaled-label-font-size * 1pt, font: phonokit-font.get(), manner), anchor: "east")
+ }
+
+ // Row indices for grid drawing (from the remapping built above)
+ let fricative-display-row = manner-to-row.at("4", default: -1)
+
+ // Draw grid lines
+ // Vertical lines
+ for i in range(num-cols + 1) {
+ let x = if i == num-cols { total-width } else { col-starts.at(i) }
+ let y1 = total-height / 2 - scaled-label-height
+ let y2 = -total-height / 2
+
+ // Check if this vertical line is at a dental-alveolar or alveolar-postalveolar boundary
+ let is-special = false
+ if i > 0 and i < num-cols {
+ let left-orig = kept-cols.at(i - 1)
+ let right-orig = kept-cols.at(i)
+ if (left-orig == 2 and right-orig == 3) or (left-orig == 3 and right-orig == 4) {
+ is-special = true
+ }
+ }
+
+ if is-special {
+ // Draw line segments only for fricative and affricate rows
+ for row in range(num-rows) {
+ if row == fricative-display-row or row == affricate-row or row == aspirated-affricate-row {
+ let row-y1 = total-height / 2 - scaled-label-height - (row * scaled-cell-height)
+ let row-y2 = row-y1 - scaled-cell-height
+ line((x, row-y1), (x, row-y2), stroke: (paint: gray.lighten(20%), thickness: scaled-line-thickness * 1pt))
+ }
+ }
+ } else {
+ // Draw normal full-height vertical line for other columns
+ line((x, y1), (x, y2), stroke: (paint: gray.lighten(20%), thickness: scaled-line-thickness * 1pt))
+ }
+ }
+
+ // Horizontal lines
+ for i in range(num-rows + 1) {
+ let y = total-height / 2 - scaled-label-height - (i * scaled-cell-height)
+ let x1 = resolved-label-width
+ let x2 = total-width
+ line((x1, y), (x2, y), stroke: (paint: gray.lighten(20%), thickness: scaled-line-thickness * 1pt))
+ }
+
+ // Gray out impossible consonant cells
+ // Format: (display-row, orig-col, half) where half can be "full", "voiced", "voiceless"
+ // Row positions come from manner-to-row mapping
+ let plosive-row = manner-to-row.at("0", default: -1)
+ let nasal-row = manner-to-row.at("1", default: -1)
+ let trill-row = manner-to-row.at("2", default: -1)
+ let tap-row = manner-to-row.at("3", default: -1)
+ let lat-fric-row = manner-to-row.at("5", default: -1)
+ let approx-row = manner-to-row.at("6", default: -1)
+ let lat-approx-row = manner-to-row.at("7", default: -1)
+
+ let impossible-cells = (
+ // Lateral fricative
+ (lat-fric-row, 0, "full"),
+ (lat-fric-row, 1, "full"),
+ (lat-fric-row, 9, "full"),
+ (lat-fric-row, 10, "full"),
+ // Lateral approximant
+ (lat-approx-row, 0, "full"),
+ (lat-approx-row, 1, "full"),
+ (lat-approx-row, 9, "full"),
+ (lat-approx-row, 10, "full"),
+ // Trill
+ (trill-row, 7, "full"),
+ (trill-row, 10, "full"),
+ // Tap or flap
+ (tap-row, 7, "full"),
+ (tap-row, 10, "full"),
+ // Approximant
+ (approx-row, 10, "full"),
+ // Plosive - voiced side only
+ (plosive-row, 9, "voiced"),
+ (plosive-row, 10, "voiced"),
+ // Nasal
+ (nasal-row, 9, "full"),
+ (nasal-row, 10, "full"),
+ )
+
+ // Add aspirated plosive impossible cells if that row exists
+ if aspirated-plosive-row >= 0 {
+ impossible-cells = (
+ impossible-cells
+ + (
+ (aspirated-plosive-row, 9, "full"),
+ (aspirated-plosive-row, 10, "full"),
+ )
+ )
+ }
+
+ for (row, orig-col, half) in impossible-cells {
+ if row < 0 or str(orig-col) not in col-remap {
+ // Row or column was deleted, skip
+ } else {
+ let col = col-remap.at(str(orig-col))
+ let col-width = column-widths.at(col)
+ let cell-x = col-starts.at(col)
+ let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height)
+
+ if half == "full" {
+ // Fill entire cell
+ rect(
+ (cell-x, cell-y),
+ (cell-x + col-width, cell-y - scaled-cell-height),
+ fill: gray.lighten(70%),
+ stroke: (paint: gray.lighten(20%), thickness: scaled-line-thickness * 1pt),
+ )
+ } else if half == "voiced" {
+ // Fill right half of cell (voiced side)
+ let mid-x = cell-x + (col-width / 2)
+ rect(
+ (mid-x, cell-y),
+ (cell-x + col-width, cell-y - scaled-cell-height),
+ fill: gray.lighten(70%),
+ stroke: (paint: gray.lighten(20%), thickness: scaled-line-thickness * 1pt),
+ )
+ } else if half == "voiceless" {
+ // Fill left half of cell (voiceless side)
+ let mid-x = cell-x + (col-width / 2)
+ rect(
+ (cell-x, cell-y),
+ (mid-x, cell-y - scaled-cell-height),
+ fill: gray.lighten(70%),
+ stroke: (paint: gray.lighten(20%), thickness: scaled-line-thickness * 1pt),
+ )
+ }
+ }
+ }
+
+ // Collect consonants by cell
+ let cell-consonants = (:)
+ for consonant in consonants-to-plot.clusters() {
+ if consonant in consonant-data {
+ let info = consonant-data.at(consonant)
+ let key = str(info.place) + "-" + str(info.manner)
+
+ if key not in cell-consonants {
+ cell-consonants.insert(key, (voiceless: none, voiced: none))
+ }
+
+ if info.voicing {
+ cell-consonants.at(key).voiced = consonant
+ } else {
+ cell-consonants.at(key).voiceless = consonant
+ }
+ }
+ }
+
+ // Special handling for /w/ (labiovelar approximant)
+ // If /w/ is present but /ɰ/ (velar approximant) is not, show /w/ in both bilabial and velar columns
+ if consonants-to-plot.contains("w") and not consonants-to-plot.contains("ɰ") {
+ let velar-approx-key = "7-6" // place 7 (velar), manner 6 (approximant)
+ if velar-approx-key not in cell-consonants {
+ cell-consonants.insert(velar-approx-key, (voiceless: none, voiced: none))
+ }
+ cell-consonants.at(velar-approx-key).voiced = "w"
+ }
+
+ // Collect aspirated plosives if enabled
+ let cell-aspirated-plosives = (:)
+ if aspirated {
+ for asp-plosive in aspirated-plosive-data.keys() {
+ if aspirated-plosives-to-plot.contains(asp-plosive) {
+ let info = aspirated-plosive-data.at(asp-plosive)
+ let key = str(info.place)
+
+ if key not in cell-aspirated-plosives {
+ cell-aspirated-plosives.insert(key, (voiceless: none, voiced: none))
+ }
+
+ // Aspirated plosives are always voiceless
+ cell-aspirated-plosives.at(key).voiceless = asp-plosive
+ }
+ }
+ }
+
+ // Collect affricates if enabled
+ let cell-affricates = (:)
+ if affricates {
+ // Strip tie bars from input (user might input t͡s, we display as ts)
+ let affricates-cleaned = affricates-to-plot.replace("͡", "")
+
+ for affricate in affricate-data.keys() {
+ if affricates-cleaned.contains(affricate) {
+ let info = affricate-data.at(affricate)
+ let key = str(info.place)
+
+ if key not in cell-affricates {
+ cell-affricates.insert(key, (voiceless: none, voiced: none))
+ }
+
+ if info.voicing {
+ cell-affricates.at(key).voiced = affricate
+ } else {
+ cell-affricates.at(key).voiceless = affricate
+ }
+ }
+ }
+ }
+
+ // Collect aspirated affricates if both enabled
+ let cell-aspirated-affricates = (:)
+ if aspirated and affricates {
+ for asp-affricate in aspirated-affricate-data.keys() {
+ if aspirated-affricates-to-plot.contains(asp-affricate) {
+ let info = aspirated-affricate-data.at(asp-affricate)
+ let key = str(info.place)
+
+ if key not in cell-aspirated-affricates {
+ cell-aspirated-affricates.insert(key, (voiceless: none, voiced: none))
+ }
+
+ // Aspirated affricates are always voiceless
+ cell-aspirated-affricates.at(key).voiceless = asp-affricate
+ }
+ }
+ }
+
+ // Draw consonants in cells
+ for (key, pair) in cell-consonants {
+ let parts = key.split("-")
+ let orig-col = int(parts.at(0))
+ let orig-manner = int(parts.at(1))
+
+ // Skip if column or row was deleted
+ if str(orig-col) in col-remap and str(orig-manner) in manner-to-row {
+ let col = col-remap.at(str(orig-col))
+ let row = manner-to-row.at(str(orig-manner))
+
+ let col-width = column-widths.at(col)
+ let cell-x = col-starts.at(col)
+ let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height)
+ let cell-center-x = cell-x + (col-width / 2)
+ let cell-center-y = cell-y - (scaled-cell-height / 2)
+
+ // Check if this is a sonorant manner (not contrastive for voicing, should be centered)
+ // Manners: 1=Nasal, 2=Trill, 3=Tap/Flap, 6=Approximant, 7=Lateral Approximant
+ let is-sonorant = orig-manner in (1, 2, 3, 6, 7)
+
+ if is-sonorant {
+ // Center the consonant (sonorants are always voiced in typical inventories)
+ if pair.voiced != none {
+ let pos = (cell-center-x, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiced), anchor: "center")
+ }
+ // In rare cases where voiceless sonorants exist, also center them
+ if pair.voiceless != none {
+ let pos = (cell-center-x, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiceless), anchor: "center")
+ }
+ } else {
+ // Obstruents: use left/right positioning for voicing contrast
+ let offset = col-width * 0.25
+
+ if pair.voiceless != none {
+ let pos = (cell-center-x - offset, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiceless), anchor: "center")
+ }
+
+ if pair.voiced != none {
+ let pos = (cell-center-x + offset, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiced), anchor: "center")
+ }
+ }
+ }
+ }
+
+ // Draw aspirated plosives
+ if aspirated and aspirated-plosive-row >= 0 {
+ for (key, pair) in cell-aspirated-plosives {
+ let orig-col = int(key)
+ if str(orig-col) in col-remap {
+ let col = col-remap.at(str(orig-col))
+ let row = aspirated-plosive-row
+
+ let col-width = column-widths.at(col)
+ let cell-x = col-starts.at(col)
+ let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height)
+ let cell-center-x = cell-x + (col-width / 2)
+ let cell-center-y = cell-y - (scaled-cell-height / 2)
+
+ let offset = col-width * 0.25
+
+ if pair.voiceless != none {
+ let pos = (cell-center-x - offset, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiceless), anchor: "center")
+ }
+ }
+ }
+ }
+
+ // Draw affricates
+ if affricates and affricate-row >= 0 {
+ for (key, pair) in cell-affricates {
+ let orig-col = int(key)
+ if str(orig-col) in col-remap {
+ let col = col-remap.at(str(orig-col))
+ let row = affricate-row
+
+ let col-width = column-widths.at(col)
+ let cell-x = col-starts.at(col)
+ let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height)
+ let cell-center-x = cell-x + (col-width / 2)
+ let cell-center-y = cell-y - (scaled-cell-height / 2)
+
+ let offset = col-width * 0.25
+
+ if pair.voiceless != none {
+ let pos = (cell-center-x - offset, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiceless), anchor: "center")
+ }
+
+ if pair.voiced != none {
+ let pos = (cell-center-x + offset, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiced), anchor: "center")
+ }
+ }
+ }
+ }
+
+ // Draw aspirated affricates
+ if aspirated and affricates and aspirated-affricate-row >= 0 {
+ for (key, pair) in cell-aspirated-affricates {
+ let orig-col = int(key)
+ if str(orig-col) in col-remap {
+ let col = col-remap.at(str(orig-col))
+ let row = aspirated-affricate-row
+
+ let col-width = column-widths.at(col)
+ let cell-x = col-starts.at(col)
+ let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height)
+ let cell-center-x = cell-x + (col-width / 2)
+ let cell-center-y = cell-y - (scaled-cell-height / 2)
+
+ let offset = col-width * 0.25
+
+ if pair.voiceless != none {
+ let pos = (cell-center-x - offset, cell-center-y)
+ circle(pos, radius: scaled-circle-radius, fill: white, stroke: none)
+ content(pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), pair.voiceless), anchor: "center")
+ }
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/packages/preview/phonokit/0.5.11/ex.typ b/packages/preview/phonokit/0.5.11/ex.typ
new file mode 100644
index 0000000000..eefddd5d06
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/ex.typ
@@ -0,0 +1,408 @@
+// 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., (, ))
+// - columns (array): Optional column widths for tabular cells (data columns only)
+//
+// Returns: Numbered example that can be labeled and referenced
+//
+// Smart modes — detected automatically from body content:
+//
+// Single item (auto-numbered):
+// #ex[Some example content]
+//
+// Single item with tabular cells (& separator):
+// #ex[#ipa("/anba/") & #a-r & #ipa("[amba]")]
+//
+// Sub-examples with list syntax (auto-lettered):
+// #ex[
+// - First example
+// - Second example
+// ]
+//
+// Sub-examples with tabular cells (& separator):
+// #ex(labels: (, ), columns: (5em, 2em, 5em))[
+// - #ipa("/anba/") & #a-r & #ipa("[amba]")
+// - #ipa("/anka/") & #a-r & #ipa("[aNka]")
+// ]
+//
+// Legacy table mode (when body is a table — backward compatible):
+// #ex()[
+// #table(
+// columns: (2em, 2em, 5em, 2em, 5em),
+// stroke: none, align: left,
+// [#ex-num-label()], [#subex-label()], [#ipa("/anba/")], [#a-r], [#ipa("[amba]")],
+// [], [#subex-label()], [#ipa("/anka/")], [#a-r], [#ipa("[aNka]")],
+// )
+// ]
+
+// Counters
+#let example-counter = counter("linguistic-example")
+#let subex-counter = counter("linguistic-subexample")
+
+// Alphabet for sub-example lettering (a, b, c...)
+#let letters = "abcdefghijklmnopqrstuvwxyz"
+
+// Split content at & characters into an array of cell contents.
+// Walks the content tree, accumulating children into cells.
+// Trims leading/trailing space nodes from each cell.
+#let _split-cells(body) = {
+ let children = if body.has("children") { body.children } else { (body,) }
+ let cells = ()
+ let current = ()
+ for child in children {
+ if child.has("text") and child.text.contains("&") {
+ // Found separator — finalize current cell
+ cells.push(current)
+ current = ()
+ } else {
+ current.push(child)
+ }
+ }
+ cells.push(current) // last cell
+ // Trim leading/trailing spaces from each cell and join into content
+ cells.map(cell => {
+ let trimmed = cell
+ // Trim leading spaces
+ while trimmed.len() > 0 and trimmed.first().func() == [ ].func() and trimmed.first().has("text") and trimmed.first().text.trim() == "" {
+ trimmed = trimmed.slice(1)
+ }
+ // Trim trailing spaces
+ while trimmed.len() > 0 and trimmed.last().func() == [ ].func() and trimmed.last().has("text") and trimmed.last().text.trim() == "" {
+ trimmed = trimmed.slice(0, trimmed.len() - 1)
+ }
+ trimmed.join()
+ })
+}
+
+// Check if content contains an & text node
+#let _has-ampersand(body) = {
+ let children = if body.has("children") { body.children } else { (body,) }
+ children.any(c => c.has("text") and c.text.contains("&"))
+}
+
+// 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
+// Supports & cell separator for tabular data
+#let _build-subex-grid(items, labels, columns, numbered: true) = {
+ // Check if any item has tabular cells
+ let has-tabular = items.any(item => _has-ampersand(item.body))
+
+ let cells = ()
+ let n-data-cols = 1 // default: single content column
+
+ 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)
+
+ if has-tabular {
+ let data-cells = _split-cells(item.body)
+ n-data-cols = calc.max(n-data-cols, data-cells.len())
+ for cell in data-cells {
+ cells.push(cell)
+ }
+ // Pad with empty cells if this row has fewer columns
+ let pad = n-data-cols - data-cells.len()
+ for _ in range(pad) { cells.push([]) }
+ } else {
+ cells.push(item.body)
+ }
+ }
+
+ // Build column spec
+ let data-col-spec = if columns != () {
+ columns
+ } else if has-tabular {
+ range(n-data-cols).map(_ => auto)
+ } else {
+ (1fr,)
+ }
+
+ let col-spec = if numbered {
+ (2em, 2em, ..data-col-spec)
+ } else {
+ (2em, ..data-col-spec)
+ }
+
+ grid(
+ columns: col-spec,
+ row-gutter: 1em,
+ column-gutter: 0.5em,
+ align: left + bottom,
+ ..cells,
+ )
+}
+
+// Main example function
+#let ex(
+ number-dy: 0.4em,
+ caption: none,
+ title: none,
+ labels: (),
+ columns: (),
+ body,
+) = {
+ // Build the smart body content (list, single, or legacy)
+ // numbered: false when title branch handles the example number
+ let _build-smart-body(body, labels, columns, numbered: true) = {
+ let mode = _classify-body(body)
+ if mode == "list" {
+ let items = _extract-items(body)
+ _build-subex-grid(items, labels, columns, numbered: numbered)
+ } else if mode == "single" and _has-ampersand(body) {
+ let data-cells = _split-cells(body)
+ let data-col-spec = if columns != () { columns } else {
+ range(data-cells.len()).map(_ => auto)
+ }
+ grid(
+ columns: data-col-spec,
+ column-gutter: 0.5em,
+ align: left + bottom,
+ ..data-cells,
+ )
+ } 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, columns, 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 (with optional & cells)
+ let items = _extract-items(body)
+ _build-subex-grid(items, labels, columns)
+ } 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))]
+ }
+ // Check for tabular cells in single-item mode
+ if _has-ampersand(body) {
+ let data-cells = _split-cells(body)
+ let data-col-spec = if columns != () { columns } else {
+ range(data-cells.len()).map(_ => auto)
+ }
+ grid(
+ columns: (2em, ..data-col-spec),
+ column-gutter: 0.5em,
+ align: left + bottom,
+ num, ..data-cells,
+ )
+ } else {
+ grid(
+ columns: (auto, 1fr),
+ column-gutter: 0.75em,
+ align: (left + bottom, left + bottom),
+ num, body,
+ )
+ }
+ } else {
+ // Legacy table mode: user manages numbering via ex-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 ex() 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:
+// #ex(caption: "Example")[
+// #table(
+// columns: 3,
+// stroke: none,
+// align: (left + bottom, left + bottom, left + top),
+// [#ex-num-label()], [#subex-label()], [sentence a],
+// [], [#subex-label()], [sentence b],
+// )
+// ]
+#let ex-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:
+// #ex(caption: "A phonology example")[
+// #table(
+// columns: 4,
+// stroke: none,
+// align: left,
+// [#subex-label()], [#ipa("/anba/")], [#a-r], [#ipa("[amba]")],
+// [#subex-label()], [#ipa("/anka/")], [#a-r], [#ipa("[aNka]")],
+// )
+// ]
+//
+// See @ex-phon2, @ex-anba, and @ex-anka.
+#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 ex() and subex-label().
+// References render as (1), (1a), (1b), etc.
+//
+// Usage: #show: ex-rules
+#let ex-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 ex, 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/phonokit/0.5.11/extras.typ b/packages/preview/phonokit/0.5.11/extras.typ
new file mode 100644
index 0000000000..bd2e0a88e8
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/extras.typ
@@ -0,0 +1,39 @@
+
+// NOTE: -- A collection of arrows
+#let a-r-large = text(font: "New Computer Modern", size: 1.5em)[#h(1em)#sym.arrow.r#h(1em)]
+#let a-r = text(font: "New Computer Modern", size: 1em)[#sym.arrow.r]
+#let a-l = text(font: "New Computer Modern")[#sym.arrow.l]
+#let a-u = text(font: "New Computer Modern")[#sym.arrow.t]
+#let a-d = text(font: "New Computer Modern")[#sym.arrow.b]
+#let a-ud = text(font: "New Computer Modern")[#sym.arrow.t.b]
+#let a-lr = text(font: "New Computer Modern")[#sym.arrow.l.r]
+#let a-sr = text(font: "New Computer Modern")[#sym.arrow.r.squiggly]
+#let a-sl = text(font: "New Computer Modern")[#sym.arrow.l.squiggly]
+
+// NOTE: -- Function for context underline
+#let blank(width: 2em) = box(
+ width: width,
+ height: 0.8em,
+ baseline: 50%,
+ stroke: (bottom: 0.5pt + black),
+)
+
+// NOTE: -- Greek symbols (upright, for phonological notation)
+#let alpha = sym.alpha
+#let beta = sym.beta
+#let gamma = sym.gamma
+#let delta = sym.delta
+#let lambda = sym.lambda
+#let mu = sym.mu
+#let phi = sym.phi
+#let pi = sym.pi
+#let sigma = sym.sigma
+#let tau = sym.tau
+#let omega = sym.omega
+#let cap-phi = sym.Phi
+#let cap-sigma = sym.Sigma
+#let cap-omega = sym.Omega
+
+// NOTE: Extrametricality
+#let extra(content) = [⟨#content⟩]
+
diff --git a/packages/preview/phonokit/0.5.11/features.typ b/packages/preview/phonokit/0.5.11/features.typ
new file mode 100644
index 0000000000..1e9e1d983d
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/features.typ
@@ -0,0 +1,2137 @@
+// Features module - Distinctive feature matrices from Hayes (2009)
+// Based on Hayes, B. (2009). Introductory Phonology. Wiley-Blackwell.
+
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+#import "ui-lang.typ": resolve-ui-lang, ui-lang-error, ui-feature-label
+
+// Function for feature matrices in SPE notation
+/// Display feature matrix in SPE-style notation
+///
+/// Creates a bracketed vertical list of features using math.vec.
+/// Handles both individual arguments and comma-separated strings.
+///
+/// Arguments:
+/// - args (variadic): Feature specifications (e.g., "+consonantal", "-sonorant")
+/// Can be passed as separate arguments or as a single comma-separated string
+///
+/// Returns: Formatted feature matrix with proper spacing to prevent overlaps
+///
+/// Example:
+/// ```
+/// #feat("+consonantal", "-sonorant", "+voice")
+/// #feat("+cons,-son,+voice") // comma-separated also works
+/// ```
+#let feat(..args) = context {
+ let items = args.pos()
+
+ // 1. Split string if comma-separated
+ if items.len() == 1 and type(items.at(0)) == str and items.at(0).contains(",") {
+ items = items.at(0).split(",")
+ }
+
+ // 2. Style the items
+ let features = items.map(i => {
+ let content = if type(i) == str { i.trim() } else { i }
+ text(font: phonokit-font.get(), size: 1em, content)
+ })
+
+ // 3. Use math.vec for perfect axis alignment
+ set math.vec(gap: 0.5em)
+ let matrix = math.vec(delim: "[", ..features)
+
+ // 4. Use box with vertical padding to add space around matrices
+ // This prevents overlaps while keeping matrices inline-friendly
+ box(
+ baseline: 50%,
+ inset: (top: 0.5em, bottom: 0.5em),
+ matrix,
+ )
+}
+
+// Complete feature specifications for consonants and vowels
+// Features: +, -, or 0 (not applicable/unspecified)
+#let feature-data = (
+ // CONSONANTS - Single place of articulation (Table 4.7)
+ // Bilabial
+ "p": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "b": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɸ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "β": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "m": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "–",
+ delayed_release: "0",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "+",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ʙ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "+",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ // Labiodental
+ "f": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "+",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "v": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "+",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɱ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "–",
+ delayed_release: "0",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "+",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "+",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ʋ": (
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "–",
+ labiodental: "+",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ // Dental
+ "θ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "+",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ð": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "+",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ // Alveolar
+ "t": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "d": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "t͡s": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "d͡z": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "s": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "z": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "n": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "–",
+ delayed_release: "0",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "+",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "l": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "–",
+ lateral: "+",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɾ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "+",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "r": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "+",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "–",
+ strident: "–",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ // Postalveolar
+ "ʃ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ʒ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "t͡ʃ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "d͡ʒ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ // Retroflex
+ "ʈ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɖ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ʂ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ʐ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɳ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "–",
+ delayed_release: "0",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "+",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɭ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "+",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɽ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "+",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɻ": (
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "–",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ // Palatal
+ "c": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "0",
+ ),
+ "ɟ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "0",
+ ),
+ "ç": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "0",
+ ),
+ "ʝ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "0",
+ ),
+ "ɲ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "–",
+ delayed_release: "0",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "+",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "0",
+ ),
+ "ʎ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "+",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "0",
+ ),
+ "j": (
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "+",
+ ),
+ // Velar
+ "k": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "–",
+ back: "0",
+ tense: "0",
+ ),
+ "g": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "–",
+ back: "0",
+ tense: "0",
+ ),
+ "x": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "–",
+ back: "0",
+ tense: "0",
+ ),
+ "ɣ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "–",
+ back: "0",
+ tense: "0",
+ ),
+ "ŋ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "–",
+ delayed_release: "0",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "+",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "–",
+ back: "0",
+ tense: "0",
+ ),
+ "ɰ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "–",
+ back: "0",
+ tense: "0",
+ ),
+ // Uvular
+ "q": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "–",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ "ɢ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "–",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ "χ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "–",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ "ʁ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "–",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ "ɴ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "–",
+ delayed_release: "0",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "+",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "–",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ "ʀ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "+",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "–",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ // Pharyngeal
+ "ħ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "+",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "+",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ "ʕ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "+",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "–",
+ low: "+",
+ front: "–",
+ back: "+",
+ tense: "0",
+ ),
+ // Glottal
+ "ʔ": (
+ consonantal: "+",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "–",
+ approximant: "–",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "+",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "h": (
+ consonantal: "–",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "+",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "–",
+ spread_gl: "+",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ "ɦ": (
+ consonantal: "–",
+ sonorant: "–",
+ continuant: "–",
+ delayed_release: "+",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "+",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "–",
+ high: "0",
+ low: "0",
+ front: "0",
+ back: "0",
+ tense: "0",
+ ),
+ // Complex segments (Table 4.8)
+ "w": (
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "+",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "–",
+ back: "+",
+ tense: "+",
+ ),
+ "ɥ": (
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "+",
+ round: "+",
+ labiodental: "–",
+ coronal: "–",
+ anterior: "0",
+ distributed: "0",
+ strident: "0",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "+",
+ ),
+ "ɹ": (
+ consonantal: "+",
+ sonorant: "+",
+ continuant: "+",
+ delayed_release: "0",
+ approximant: "+",
+ tap: "–",
+ trill: "–",
+ nasal: "–",
+ voice: "+",
+ spread_gl: "–",
+ constr_gl: "–",
+ labial: "–",
+ round: "–",
+ labiodental: "–",
+ coronal: "+",
+ anterior: "+",
+ distributed: "+",
+ strident: "+",
+ lateral: "–",
+ dorsal: "+",
+ high: "+",
+ low: "–",
+ front: "+",
+ back: "–",
+ tense: "0",
+ ),
+ // VOWELS (Table 4.9)
+ // High tense
+ "i": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "+",
+ front: "+",
+ back: "–",
+ round: "–",
+ ),
+ "y": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "+",
+ front: "+",
+ back: "–",
+ round: "+",
+ ),
+ "ɨ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "–",
+ round: "–",
+ ),
+ "ʉ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "–",
+ round: "+",
+ ),
+ "ɯ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "+",
+ round: "–",
+ ),
+ "u": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "+",
+ round: "+",
+ ),
+ // High lax
+ "ɪ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "–",
+ front: "+",
+ back: "–",
+ round: "–",
+ ),
+ "ʏ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "–",
+ front: "+",
+ back: "–",
+ round: "+",
+ ),
+ "ʊ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "+",
+ low: "–",
+ tense: "–",
+ front: "–",
+ back: "+",
+ round: "+",
+ ),
+ // Mid tense
+ "e": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "+",
+ front: "+",
+ back: "–",
+ round: "–",
+ ),
+ "ø": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "+",
+ front: "+",
+ back: "–",
+ round: "+",
+ ),
+ "ɘ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "–",
+ round: "–",
+ ),
+ "ɵ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "–",
+ round: "+",
+ ),
+ "ɤ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "+",
+ round: "–",
+ ),
+ "o": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "+",
+ front: "–",
+ back: "+",
+ round: "+",
+ ),
+ // Mid lax
+ "ɛ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "–",
+ front: "+",
+ back: "–",
+ round: "–",
+ ),
+ "œ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "–",
+ front: "+",
+ back: "–",
+ round: "+",
+ ),
+ "ə": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "–",
+ front: "–",
+ back: "–",
+ round: "–",
+ ),
+ "ɞ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "–",
+ front: "–",
+ back: "–",
+ round: "+",
+ ),
+ "ʌ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "–",
+ front: "–",
+ back: "+",
+ round: "–",
+ ),
+ "ɔ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "–",
+ tense: "–",
+ front: "–",
+ back: "+",
+ round: "+",
+ ),
+ // Low
+ "æ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "+",
+ tense: "0",
+ front: "+",
+ back: "–",
+ round: "–",
+ ),
+ "ɶ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "+",
+ tense: "0",
+ front: "+",
+ back: "–",
+ round: "+",
+ ),
+ "a": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "+",
+ tense: "0",
+ front: "–",
+ back: "–",
+ round: "–",
+ ),
+ "ɑ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "+",
+ tense: "0",
+ front: "–",
+ back: "+",
+ round: "–",
+ ),
+ "ɒ": (
+ syllabic: "+",
+ consonantal: "–",
+ sonorant: "+",
+ continuant: "+",
+ voice: "+",
+ high: "–",
+ low: "+",
+ tense: "0",
+ front: "–",
+ back: "+",
+ round: "+",
+ ),
+)
+
+// Feature matrix display function
+/// Display complete feature matrix for an IPA segment
+///
+/// Takes an IPA symbol (Unicode or tipa-style) and displays its complete
+/// distinctive feature specification from Hayes (2009).
+///
+/// Arguments:
+/// - segment (string): IPA symbol (e.g., "p", "i", "\\t s" for t͡s)
+/// - all (bool): Show all features including 0 values (default: false)
+///
+/// Returns: Formatted feature matrix in SPE-style notation
+///
+/// Example:
+/// ```
+/// #feat-matrix("p")
+/// #feat-matrix("t \\t s") // affricate using tipa notation
+/// #feat-matrix("i", all: true) // show all features including 0
+/// ```
+#let feat-matrix(segment, all: false, ui-lang: "en") = context {
+ let ui-locale = resolve-ui-lang(ui-lang)
+ if ui-locale == none {
+ return ui-lang-error(ui-lang)
+ }
+
+ // Convert tipa notation to Unicode if needed
+ let symbol = ipa-to-unicode(segment).trim()
+
+ // Look up features
+ if symbol not in feature-data {
+ return text(fill: red)[*Error:* No feature data for "#symbol"]
+ }
+
+ let features = feature-data.at(symbol)
+ let feature-list = ()
+
+ // Build feature list with readable names
+ for (feature-name, value) in features {
+ if all or value != "0" {
+ let display-name = ui-feature-label(feature-name, ui-locale)
+ feature-list.push(value + display-name)
+ }
+ }
+
+ // Display as inline block with top alignment to allow side-by-side placement
+ let phoneme = text(size: 1em, font: phonokit-font.get())[/#symbol/]
+
+ // Build matrix manually for precise control over alignment
+ let features = feature-list.map(f => text(font: phonokit-font.get(), size: 0.8em)[#f])
+ let content-stack = stack(
+ dir: ttb,
+ spacing: 0.55em,
+ ..features,
+ )
+
+ // Wrap in brackets with precise positioning
+ // Add horizontal padding inside brackets for better spacing
+ let matrix = box[
+ $lr(
+ [#h(0.15em)#content-stack#h(0.15em)],
+ size: #100%
+ )$
+ ]
+
+ // Use box with baseline at bottom (100%) for top alignment
+ // Counter-intuitively, this makes the tops align
+ box(baseline: 100%)[
+ #grid(
+ columns: 1,
+ row-gutter: 1.5em,
+ // Phoneme in large fixed box, anchored at bottom for consistent matrix starting point
+ // This ensures all matrices start at exactly the same vertical position
+ align(center, box(height: 2em, align(bottom, phoneme))),
+ // Feature matrix starts at fixed position below
+ align(center, matrix),
+ )
+ ]
+}
diff --git a/packages/preview/phonokit/0.5.11/gallery/autoseg_example_1.png b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_1.png
new file mode 100644
index 0000000000..9370523b68
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_1.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/autoseg_example_1.typ b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_1.typ
new file mode 100644
index 0000000000..a2ec21a6e1
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_1.typ
@@ -0,0 +1,11 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#autoseg(
+ ("k", "\\ae", "n", "t"),
+ features: ("", "", "[+nas]", ""),
+ links: ((2, 1),),
+ spacing: 1.0,
+ arrow: false,
+)
+
diff --git a/packages/preview/phonokit/0.5.11/gallery/autoseg_example_2.png b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_2.png
new file mode 100644
index 0000000000..58849e6555
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_2.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/autoseg_example_2.typ b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_2.typ
new file mode 100644
index 0000000000..ad9a225a33
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_2.typ
@@ -0,0 +1,22 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#autoseg(
+ ("e", "b", "e"),
+ features: ("L", "", "H"),
+ spacing: 0.5,
+ tone: true,
+ baseline: 50%,
+ gloss: [],
+)
+#a-r
+#autoseg(
+ ("e", "b", "e"),
+ features: ("L", "", "H"),
+ links: ((0, 2),),
+ spacing: 0.5,
+ baseline: 50%,
+ tone: true,
+ gloss: [èbě _pumpkin_],
+)
+
diff --git a/packages/preview/phonokit/0.5.11/gallery/autoseg_example_3.png b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_3.png
new file mode 100644
index 0000000000..6128c4a22b
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_3.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/autoseg_example_3.typ b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_3.typ
new file mode 100644
index 0000000000..56cc5655ca
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/autoseg_example_3.typ
@@ -0,0 +1,47 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#autoseg(
+ ("p", "a", "S", "E", "i"),
+ features: ("", "L", "", "H", "L"),
+ tone: true,
+ spacing: 0.5, // keep consistent
+ delinks: ((1, 1),),
+ float: (4,),
+ baseline: 37%,
+ gloss: [_delinking & floating tone_],
+)
+#autoseg(
+ ("Z", "W", "p", "K", "u"),
+ features: ("", "H", "", "", ""), // H at position 1, but will be repositioned
+ tone: true,
+ float: (1,), // Mark H as floating so it doesn't draw vertical stem
+ multilinks: ((1, (1, 4)),), // H links to segments at positions 1 and 4
+ spacing: 0.5,
+ baseline: 37%,
+ arrow: false,
+ gloss: [_one-to-many relationships_],
+)
+
+#autoseg(
+ ("m", "@", "a"),
+ features: ("", "", ("H", "L")),
+ links: (((2, 0), 1),), // From H (first tone at position 2) to segment 1
+ tone: true,
+ highlight: ((2, 0),), // Highlight the H tone
+ baseline: 37%,
+ spacing: 1.0,
+ arrow: true,
+ gloss: [_contour tone, linking & highlighting_],
+)
+#autoseg(
+ ("m", "@", "a"),
+ features: ("", "", ("H", "L")),
+ links: (((2, 0), 1),), // From H (first tone at position 2) to segment 1
+ tone: true,
+ highlight: ((2, 0),), // Highlight the H tone
+ baseline: 37%,
+ spacing: 0.35,
+ arrow: true,
+ gloss: [_quick spacing adjustments_],
+)
diff --git a/packages/preview/phonokit/0.5.11/gallery/consonants_example.png b/packages/preview/phonokit/0.5.11/gallery/consonants_example.png
new file mode 100644
index 0000000000..13f7d67cc3
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/consonants_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/consonants_example.typ b/packages/preview/phonokit/0.5.11/gallery/consonants_example.typ
new file mode 100644
index 0000000000..ac87b3df66
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/consonants_example.typ
@@ -0,0 +1,8 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#consonants("portuguese")
+
+#consonants("english")
+
+
diff --git a/packages/preview/phonokit/0.5.11/gallery/feat_geom.png b/packages/preview/phonokit/0.5.11/gallery/feat_geom.png
new file mode 100644
index 0000000000..f30315ef24
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/feat_geom.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/feat_geom.typ b/packages/preview/phonokit/0.5.11/gallery/feat_geom.typ
new file mode 100644
index 0000000000..64f47e709b
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/feat_geom.typ
@@ -0,0 +1,9 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#geom-group(
+ (ph: "a"),
+ (ph: "n"),
+ arrows: ((from: "nasal2", to: "root1", ctrl: (1.1, -1.5)),),
+ curved: true,
+)
diff --git a/packages/preview/phonokit/0.5.11/gallery/features_example.png b/packages/preview/phonokit/0.5.11/gallery/features_example.png
new file mode 100644
index 0000000000..d8867f4db2
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/features_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/features_example.typ b/packages/preview/phonokit/0.5.11/gallery/features_example.typ
new file mode 100644
index 0000000000..e4d5de5173
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/features_example.typ
@@ -0,0 +1,5 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#feat-matrix("p") #feat-matrix("\\ae") #feat-matrix("\\t tS") #feat-matrix("i")
+
diff --git a/packages/preview/phonokit/0.5.11/gallery/grid_example.png b/packages/preview/phonokit/0.5.11/gallery/grid_example.png
new file mode 100644
index 0000000000..052e5e706d
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/grid_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/grid_example.typ b/packages/preview/phonokit/0.5.11/gallery/grid_example.typ
new file mode 100644
index 0000000000..02c55c6a31
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/grid_example.typ
@@ -0,0 +1,4 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#met-grid(("b2", 3), ("R \\schwar", 1), ("flaI", 2))
diff --git a/packages/preview/phonokit/0.5.11/gallery/ipa_example.png b/packages/preview/phonokit/0.5.11/gallery/ipa_example.png
new file mode 100644
index 0000000000..80b94418c1
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/ipa_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/ipa_example.typ b/packages/preview/phonokit/0.5.11/gallery/ipa_example.typ
new file mode 100644
index 0000000000..f53ac50e52
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/ipa_example.typ
@@ -0,0 +1,17 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#ipa("/DIs \\s Iz \\s @ \\s sEn.t@ns/")
+
+#ipa("[N \\N R \\R \\I I Z \\Z ]")
+
+#ipa("[p \\h Ik \\* \\s \\t tS \\ae t \\s p \\r liz]")
+
+#ipa("['t \\h \\ae k,sI]")
+
+#ipa("[ \\!o || \\epsilonr k \\velar \\dental t ia]")
+
+#ipa("[ \\nh \\v lRKt \\palatal ]")
+
+#ipa("['lIR \\v \\darkl \\s 'b2R \\schwar ,flaI \\s 'oU.v \\schwar \\s DE \\*r ]")
+
diff --git a/packages/preview/phonokit/0.5.11/gallery/maxent_example.png b/packages/preview/phonokit/0.5.11/gallery/maxent_example.png
new file mode 100644
index 0000000000..27eaef5b63
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/maxent_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/maxent_example.typ b/packages/preview/phonokit/0.5.11/gallery/maxent_example.typ
new file mode 100644
index 0000000000..689a86ffc1
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/maxent_example.typ
@@ -0,0 +1,41 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, left: 1em, right: 2cm))
+
+#maxent(
+ input: "kraTa",
+ candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+ constraints: ("Max", "Dep", "*Complex"),
+ weights: (2.5, 1.8, 1),
+ violations: (
+ (0, 0, 1),
+ (1, 0, 0),
+ (0, 1, 0),
+ ),
+ visualize: true, // Show probability bars (default)
+)
+
+#maxent(
+ input: "kraTa",
+ candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+ constraints: ("Max", "Dep", "*Complex"),
+ weights: (2.5, 1.8, 1),
+ violations: (
+ (0, 0, 1),
+ (1, 0, 0),
+ (0, 1, 0),
+ ),
+ visualize: false,
+)
+
+#maxent(
+ input: "kraTa",
+ candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+ constraints: ("Max", "Dep", "*Complex"),
+ weights: (2.5, 1.8, 1),
+ violations: (
+ (0, 0, 1),
+ (1, 0, 0),
+ (0, 1, 0),
+ ),
+ sort: true, // Sort candidates by probability (most to least)
+)
diff --git a/packages/preview/phonokit/0.5.11/gallery/multi-tier_example.png b/packages/preview/phonokit/0.5.11/gallery/multi-tier_example.png
new file mode 100644
index 0000000000..9288da4ff1
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/multi-tier_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/multi-tier_example.typ b/packages/preview/phonokit/0.5.11/gallery/multi-tier_example.typ
new file mode 100644
index 0000000000..3c2a1f9ed3
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/multi-tier_example.typ
@@ -0,0 +1,18 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#multi-tier(
+ levels: (
+ ("O", "R", "", "O", "R", "O", "R"),
+ ("", "N1", "", "", "N2", "", "N3"),
+ ("", "x", "x", "x", "x", "x", "x"),
+ ("", "", "s", "t", "E", "m", ""),
+ ),
+ links: (("r2", "x2"),),
+ ipa: (3,),
+ arrows: (("t1", "s1"), ("r2", "r1")),
+ arrow-delinks: (1,),
+ spacing: 1,
+)
+
+// Figure from Goad (2012); see phonokit.pdf for full reference.
diff --git a/packages/preview/phonokit/0.5.11/gallery/ot_example.png b/packages/preview/phonokit/0.5.11/gallery/ot_example.png
new file mode 100644
index 0000000000..61b0341e0c
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/ot_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/ot_example.typ b/packages/preview/phonokit/0.5.11/gallery/ot_example.typ
new file mode 100644
index 0000000000..9f1043fb1c
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/ot_example.typ
@@ -0,0 +1,17 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+
+#tableau(
+ input: "kraTa",
+ candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+ constraints: ("Max", "Dep", "*Complex"),
+ violations: (
+ ("", "", "*"),
+ ("*!", "", ""),
+ ("", "*!", ""),
+ ),
+ winner: 0,
+ dashed-lines: (0,),
+)
+
diff --git a/packages/preview/phonokit/0.5.11/gallery/syllable_example.png b/packages/preview/phonokit/0.5.11/gallery/syllable_example.png
new file mode 100644
index 0000000000..3fc5d0cdd5
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/syllable_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/syllable_example.typ b/packages/preview/phonokit/0.5.11/gallery/syllable_example.typ
new file mode 100644
index 0000000000..a1243e0fde
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/syllable_example.typ
@@ -0,0 +1,6 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#syllable("\\t tS I t \\*", scale: 0.7) #h(1em) #syllable("\\t tS \\ae t", scale: 0.7)
+
+
diff --git a/packages/preview/phonokit/0.5.11/gallery/vowels_example.png b/packages/preview/phonokit/0.5.11/gallery/vowels_example.png
new file mode 100644
index 0000000000..b6215b5b3c
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/vowels_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/vowels_example.typ b/packages/preview/phonokit/0.5.11/gallery/vowels_example.typ
new file mode 100644
index 0000000000..04c2d37106
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/vowels_example.typ
@@ -0,0 +1,17 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#vowels(
+ "english",
+ arrows: (
+ ("a", "U"),
+ ("a", "I"),
+ ("e", "I"),
+ ("O", "I"),
+ ("o", "U"),
+ ),
+ arrow-color: blue.lighten(60%),
+ curved: true,
+ highlight: ("a", "e", "o", "O"),
+ highlight-color: blue.lighten(80%),
+)
diff --git a/packages/preview/phonokit/0.5.11/gallery/word_example.png b/packages/preview/phonokit/0.5.11/gallery/word_example.png
new file mode 100644
index 0000000000..d4a112dcbb
Binary files /dev/null and b/packages/preview/phonokit/0.5.11/gallery/word_example.png differ
diff --git a/packages/preview/phonokit/0.5.11/gallery/word_example.typ b/packages/preview/phonokit/0.5.11/gallery/word_example.typ
new file mode 100644
index 0000000000..6536b7d333
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/gallery/word_example.typ
@@ -0,0 +1,5 @@
+#import "@preview/phonokit:0.5.11": *
+#set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em))
+
+#word("('po.Ra).('man.pla)", foot: "R", scale: 0.9)
+
diff --git a/packages/preview/phonokit/0.5.11/geom.typ b/packages/preview/phonokit/0.5.11/geom.typ
new file mode 100644
index 0000000000..152fc60274
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/geom.typ
@@ -0,0 +1,2029 @@
+// Feature-Geometry Module
+// Draws hierarchical feature-geometry trees (consonants and vocoids).
+
+#import "@preview/cetz:0.5.2"
+#import "features.typ": feat
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+#import "ui-lang.typ": resolve-ui-lang, ui-lang-error, ui-geom-label
+
+// ── Layout constants ────────────────────────────────────────────────────────
+// Two gap sizes: tight for all-leaf sibling groups (e.g. spread/constricted),
+// normal for mixed groups that contain internal nodes (e.g. voice/C-place).
+#let _leaf-h-gap = 0.45 // gap between siblings when every sibling is a leaf
+#let _h-gap = 0.18 // gap between siblings in mixed groups
+#let _root-h-gap = 0.60 // gap between root's direct children (laryngeal / nasal / oral cavity)
+#let _leaf-w = 0.55 // minimum width of a leaf node (canvas units)
+#let _v-gap = 0.90 // vertical gap between levels (internal nodes)
+#let _mixed-leaf-vg = 0.65 // vertical gap for leaf children that have non-leaf siblings
+#let _mixed-nonleaf-vg = 1.25 // vertical gap for non-leaf in a mixed group (e.g. C-place under oral cavity)
+#let _stagger-dy = 0.38 // extra downward step per sibling in an all-leaf group
+#let _ml-stagger-dy = 0.70 // step for 2nd+ leading leaves before a non-leaf (e.g. [cont] before C-place)
+#let _nonleaf-stagger-dy = 0.38 // extra downward step per sibling in an all-non-leaf group
+// ── Label abbreviations ──────────────────────────────────────────────────────
+// Maps bare argument names → short display/anchor forms.
+// Split a label into (sign, bare-base). "+anterior" → ("+", "anterior").
+// "−" is U+2212 (3 UTF-8 bytes); check with starts-with, slice by byte length.
+#let _sign-base(lbl) = {
+ if lbl.starts-with("+") { ("+", lbl.slice(1)) } else if lbl.starts-with("−") { ("−", lbl.slice("−".len())) } else {
+ ("", lbl)
+ }
+}
+
+// Display form: preserve sign, abbreviate bare base.
+// "+anterior" → "+ant", "−voice" → "−voice", "coronal" → "cor"
+#let _display(lbl, ui-lang: "en") = {
+ let (sign, base) = _sign-base(lbl)
+ sign + ui-geom-label(base, ui-lang)
+}
+
+// Normalize a place-feature string from an array argument.
+// Converts ASCII "-" prefix → "−" (U+2212) to match the sign convention.
+// e.g. "-back" → "−back", "+high" → "+high", "round" → "round"
+#let _norm-feat(f) = if f.starts-with("-") { "−" + f.slice(1) } else { f }
+
+// ── Node constructors ───────────────────────────────────────────────────────
+#let _n(lbl, kind, ch) = (label: lbl, kind: kind, children: ch)
+#let _feat(lbl, ch: ()) = _n(lbl, "feature", ch)
+#let _class(lbl, ch) = _n(lbl, "class", ch)
+
+// ── All-leaf predicate ──────────────────────────────────────────────────────
+// True when every child of `node` is a terminal leaf (no grandchildren).
+// These groups use _leaf-h-gap for tighter packing.
+#let _all-leaves(node) = {
+ node.children.len() > 0 and node.children.all(c => c.children.len() == 0)
+}
+
+// ── All-non-leaf predicate ──────────────────────────────────────────────────
+// True when every child of `node` is an internal node (has its own children).
+// e.g. vocalic → [V-place, aperture]. These groups stagger vertically to
+// avoid horizontal label collisions between wide class-node names.
+#let _all-nonleaves(node) = {
+ node.children.len() > 0 and node.children.all(c => c.children.len() > 0)
+}
+
+// ── Recursive subtree width ─────────────────────────────────────────────────
+#let _tree-w(node) = {
+ if node.children.len() == 0 { return _leaf-w }
+ let gap = if _all-leaves(node) { _leaf-h-gap } else if node.kind == "root" { _root-h-gap } else { _h-gap }
+ let cw = node.children.map(c => _tree-w(c))
+ calc.max(cw.sum() + (node.children.len() - 1) * gap, _leaf-w)
+}
+
+// ── Recursive layout → flat list of positioned entries ──────────────────────
+// Returns array of (label, kind, feats, x, y, par) dictionaries.
+#let _layout(node, x0, y, par) = {
+ let w = _tree-w(node)
+ let my-x = x0 + w / 2
+ let me = ((label: node.label, kind: node.kind, feats: node.at("features", default: ()), x: my-x, y: y, par: par),)
+ let gap = if _all-leaves(node) { _leaf-h-gap } else if node.kind == "root" { _root-h-gap } else { _h-gap }
+ // Count consecutive leading leaves (leaves before the first non-leaf child).
+ let n-leading = {
+ let count = 0
+ for c in node.children {
+ if c.children.len() == 0 { count = count + 1 } else { break }
+ }
+ count
+ }
+ let cx = x0
+ let out = me
+ for (i, child) in node.children.enumerate() {
+ let cw = _tree-w(child)
+ let all-prev-leaves = node.children.slice(0, i).all(c => c.children.len() == 0)
+ // Leaf children are staggered vertically by their rank among leaf siblings.
+ // Non-leaf children (e.g. C-place) always stay at the standard level.
+ let child-y = if child.children.len() == 0 {
+ let leaf-rank = node.children.slice(0, i).filter(c => c.children.len() == 0).len()
+ // "Leading" leaves: all siblings before this one are also leaves.
+ // • n-leading == 1: single leaf before a non-leaf (e.g. [nasal] before oral
+ // cavity) — use _mixed-leaf-vg for vertical position.
+ // • n-leading >= 2: multiple leaves (e.g. voice/continuant before C-place) —
+ // use _mixed-leaf-vg so the stagger distributes them around the non-leaf level.
+ // "Sandwiched" leaves: a non-leaf precedes this leaf → push below non-leaf level.
+ let base = if _all-leaves(node) { _v-gap } else if all-prev-leaves { _mixed-leaf-vg } else {
+ _v-gap + _stagger-dy * 0.3
+ }
+ let step = if all-prev-leaves and not _all-leaves(node) { _ml-stagger-dy } else { _stagger-dy }
+ y - base - leaf-rank * step
+ } else {
+ // Non-leaf child: stagger vertically when all siblings are also non-leaves
+ // AND the parent is not the root node. Root's direct children (laryngeal,
+ // oral cavity) must stay at the same tier by convention. Deeper all-non-leaf
+ // groups (e.g. V-place + aperture under vocalic) do get staggered.
+ if _all-nonleaves(node) and node.kind != "root" {
+ y - _v-gap - i * _nonleaf-stagger-dy
+ } else if not _all-leaves(node) and not _all-nonleaves(node) {
+ y - _mixed-nonleaf-vg // mixed group: non-leaf drops further (e.g. C-place)
+ } else {
+ y - _v-gap
+ }
+ }
+ // Single leading leaf: shift further left so the line from the parent is
+ // more diagonal rather than nearly vertical next to the root label.
+ let cx-adj = if (
+ child.children.len() == 0 and all-prev-leaves and not _all-leaves(node) and n-leading == 1
+ ) { -0.40 } else { 0 }
+ out = out + _layout(child, cx + cx-adj, child-y, (my-x, y))
+ cx = cx + cw + gap
+ }
+ out
+}
+
+// ── Segment presets (Clements & Hume 1995) ──────────────────────────────────
+// Pre-built spec dicts for common segments. Used by the `ph` parameter in
+// geom() and as a `ph` key in geom-group spec dicts, e.g. (ph: "a").
+// Height is encoded by aperture (no features = high), front/back by V-place.
+#let _presets = (
+ // NOTE: The most common vowels:
+ "i": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ coronal: true,
+ aperture: ("-", "-", "-"),
+ segment: "i",
+ ),
+ "e": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ coronal: true,
+ aperture: ("-", "+", "-"),
+ segment: "e",
+ ),
+ "E": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ coronal: true,
+ aperture: ("-", "+", "+"),
+ segment: "E",
+ ),
+ "a": (root: ("+son", "+approx", "+vocoid"), vocalic: true, aperture: ("+", "+", "+"), segment: "a"),
+ "o": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: true,
+ dorsal: true,
+ aperture: ("-", "+", "-"),
+ segment: "o",
+ ),
+ "O": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: true,
+ dorsal: true,
+ aperture: ("-", "+", "+"),
+ segment: "O",
+ ),
+ "u": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: true,
+ dorsal: true,
+ aperture: ("-", "-", "-"),
+ segment: "u",
+ ),
+ "I": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ coronal: true,
+ tense: "-",
+ aperture: ("-", "-", "-"),
+ segment: "I",
+ ),
+ "U": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: true,
+ dorsal: true,
+ tense: "-",
+ aperture: ("-", "-", "-"),
+ segment: "U",
+ ),
+ // Additional vowels — high confidence:
+ "y": (
+ // ø̈ close front rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ coronal: true,
+ labial: true,
+ aperture: ("-", "-", "-"),
+ segment: "y",
+ ),
+ "W": (
+ // ɯ close back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: true,
+ aperture: ("-", "-", "-"),
+ segment: "W",
+ ),
+ "7": (
+ // ɤ close-mid back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "+",
+ dorsal: true,
+ aperture: ("-", "+", "-"),
+ segment: "7",
+ ),
+ "\\o": (
+ // ø close-mid front rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "+",
+ coronal: true,
+ labial: true,
+ aperture: ("-", "+", "-"),
+ segment: "\\o",
+ ),
+ "\\oe": (
+ // œ open-mid front rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ coronal: true,
+ labial: true,
+ aperture: ("-", "+", "+"),
+ segment: "\\oe",
+ ),
+ "2": (
+ // ʌ open-mid back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ dorsal: true,
+ aperture: ("-", "+", "+"),
+ segment: "2",
+ ),
+ "A": (
+ // ɑ open back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: true,
+ aperture: ("+", "+", "+"),
+ segment: "A",
+ ),
+ "6": (
+ // ɒ open back rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: true,
+ dorsal: true,
+ aperture: ("+", "+", "+"),
+ segment: "6",
+ ),
+ // Flagged vowels — central/near-open: place analysis is theory-dependent.
+ "@": (
+ // ə mid central — placeless in CH (no V-place node), aperture ("-","+","-")
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ aperture: ("-", "+", "-"),
+ segment: "@",
+ ),
+ "1": (
+ // ɨ close central unrounded — placeless in CH
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ aperture: ("-", "-", "-"),
+ segment: "1",
+ ),
+ "0": (
+ // ʉ close central rounded — labial only, no dorsal/coronal V-place
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ labial: true,
+ aperture: ("-", "-", "-"),
+ segment: "0",
+ ),
+ "\\ae": (
+ // æ near-open front — aperture approximated as open-mid ("-","+","+"); same as /ɛ/ in CH
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ coronal: true,
+ aperture: ("-", "+", "+"),
+ segment: "\\ae",
+ ),
+ // NOTE: Some consonants:
+ "p": (
+ root: ("-son", "-approx", "-vocoid"),
+ vocalic: false,
+ labial: true,
+ voice: "-",
+ segment: "p",
+ continuant: "-",
+ ),
+ "b": (
+ root: ("-son", "-approx", "-vocoid"),
+ vocalic: false,
+ labial: true,
+ voice: "+",
+ segment: "b",
+ continuant: "-",
+ ),
+ "t": (root: ("-son", "-approx", "-vocoid"), coronal: true, anterior: "+", voice: "-", continuant: "-", segment: "t"),
+ "d": (root: ("-son", "-approx", "-vocoid"), coronal: true, anterior: "+", voice: "+", continuant: "-", segment: "d"),
+ "k": (
+ root: ("-son", "-approx", "-vocoid"),
+ vocalic: false,
+ dorsal: true,
+ voice: "-",
+ segment: "k",
+ continuant: "-",
+ ),
+ "g": (
+ root: ("-son", "-approx", "-vocoid"),
+ vocalic: false,
+ dorsal: true,
+ voice: "+",
+ segment: "g",
+ continuant: "-",
+ ),
+ "f": (
+ root: ("-son", "-approx", "-vocoid"),
+ vocalic: false,
+ labial: true,
+ voice: "-",
+ segment: "f",
+ continuant: "+",
+ ),
+ "v": (
+ root: ("-son", "-approx", "-vocoid"),
+ vocalic: false,
+ labial: true,
+ voice: "+",
+ segment: "v",
+ continuant: "+",
+ ),
+ "s": (root: ("-son", "-approx", "-vocoid"), coronal: true, anterior: "+", voice: "-", continuant: "+", segment: "s"),
+ "z": (root: ("-son", "-approx", "-vocoid"), coronal: true, anterior: "+", voice: "+", continuant: "+", segment: "z"),
+ // ʃ/ʒ: coronal [-anterior], NOT dorsal
+ "S": (root: ("-son", "-approx", "-vocoid"), coronal: true, anterior: "-", voice: "-", continuant: "+", segment: "S"),
+ "Z": (root: ("-son", "-approx", "-vocoid"), coronal: true, anterior: "-", voice: "+", continuant: "+", segment: "Z"),
+ // Affricates — [-continuant] following standard SPE/Kenstowicz analysis
+ "ts": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "+",
+ voice: "-",
+ continuant: ("-", "+"),
+ segment: "ts",
+ ),
+ "dz": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "+",
+ voice: "+",
+ continuant: ("-", "+"),
+ segment: "dz",
+ ),
+ "tS": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "-",
+ voice: "-",
+ continuant: ("-", "+"),
+ segment: "tS",
+ ),
+ "dZ": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "-",
+ voice: "+",
+ continuant: ("-", "+"),
+ segment: "dZ",
+ ),
+ "n": (
+ root: ("+son", "-approx", "-vocoid"),
+ vocalic: false,
+ nasal: true,
+ coronal: true,
+ voice: "+",
+ segment: "n",
+ continuant: "-",
+ ),
+ "m": (
+ root: ("+son", "-approx", "-vocoid"),
+ vocalic: false,
+ nasal: true,
+ labial: true,
+ voice: "+",
+ segment: "m",
+ continuant: "-",
+ ),
+ "N": (
+ root: ("+son", "-approx", "-vocoid"),
+ vocalic: false,
+ nasal: true,
+ dorsal: true,
+ voice: "+",
+ segment: "N",
+ continuant: "-",
+ ),
+ // ɲ: palatal nasal = coronal [-anterior]
+ "\\N": (root: ("+son", "-approx", "-vocoid"), coronal: true, anterior: "-", nasal: "+", segment: "\\N"),
+ // Additional consonants — high confidence:
+ "j": (root: ("+son", "+approx", "-vocoid"), dorsal: true, continuant: "+", segment: "j"), // j palatal approximant
+ "h": (root: ("-son", "-approx", "-vocoid"), spread: true, continuant: "+", segment: "h"), // h glottal fricative
+ "?": (root: ("-son", "-approx", "-vocoid"), constricted: true, continuant: "-", segment: "?"), // ʔ glottal stop
+ "T": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "+",
+ distributed: true,
+ voice: "-",
+ continuant: "+",
+ segment: "T",
+ ), // θ voiceless dental fricative
+ "D": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "+",
+ distributed: true,
+ voice: "+",
+ continuant: "+",
+ segment: "D",
+ ), // ð voiced dental fricative
+ "x": (root: ("-son", "-approx", "-vocoid"), dorsal: true, voice: "-", continuant: "+", segment: "x"), // x voiceless velar fricative
+ "G": (root: ("-son", "-approx", "-vocoid"), dorsal: true, voice: "+", continuant: "+", segment: "G"), // ɣ voiced velar fricative
+ "F": (root: ("-son", "-approx", "-vocoid"), labial: true, voice: "-", continuant: "+", segment: "F"), // ɸ voiceless bilabial fricative
+ "B": (root: ("-son", "-approx", "-vocoid"), labial: true, voice: "+", continuant: "+", segment: "B"), // β voiced bilabial fricative
+ "V": (root: ("+son", "+approx", "-vocoid"), labial: true, continuant: "+", segment: "V"), // ʋ labiodental approximant
+ "M": (root: ("+son", "-approx", "-vocoid"), labial: true, nasal: true, voice: "+", continuant: "-", segment: "M"), // ɱ labiodental nasal
+ "\\:t": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "-",
+ distributed: true,
+ voice: "-",
+ continuant: "-",
+ segment: "\\:t",
+ ), // ʈ retroflex stop (vl)
+ "\\:d": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "-",
+ distributed: true,
+ voice: "+",
+ continuant: "-",
+ segment: "\\:d",
+ ), // ɖ retroflex stop (vd)
+ "\\:s": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "-",
+ distributed: true,
+ voice: "-",
+ continuant: "+",
+ segment: "\\:s",
+ ), // ʂ retroflex fricative (vl)
+ "\\:z": (
+ root: ("-son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "-",
+ distributed: true,
+ voice: "+",
+ continuant: "+",
+ segment: "\\:z",
+ ), // ʐ retroflex fricative (vd)
+ "\\:n": (
+ root: ("+son", "-approx", "-vocoid"),
+ coronal: true,
+ anterior: "-",
+ distributed: true,
+ nasal: "+",
+ voice: "+",
+ continuant: "-",
+ segment: "\\:n",
+ ), // ɳ retroflex nasal
+ // Flagged consonants — place analysis is theory-dependent:
+ "r": (root: ("+son", "-approx", "-vocoid"), coronal: true, anterior: "+", voice: "+", continuant: "-", segment: "r"), // r alveolar trill — [-cont] following Kenstowicz (1994)
+ "l": (root: ("+son", "+approx", "-vocoid"), coronal: true, anterior: "+", voice: "+", continuant: "+", lateral: true, segment: "l"), // l alveolar lateral
+ "L": (root: ("+son", "+approx", "-vocoid"), coronal: true, anterior: "-", voice: "+", continuant: "+", lateral: true, segment: "L"), // ʎ palatal lateral approximant — palatal = coronal [−anterior], cf. ɲ/ç
+ "J": (root: ("-son", "-approx", "-vocoid"), dorsal: true, voice: "+", continuant: "+", segment: "J"), // ʝ voiced palatal fricative — NOTE: coronal vs. dorsal analysis contested; dorsal used here
+ "C": (root: ("-son", "-approx", "-vocoid"), coronal: true, anterior: "-", voice: "-", continuant: "+", segment: "C"), // ç voiceless palatal fricative — NOTE: strident not modelled (cf. /ʃ/)
+ // archiphoneme /T/: any stop — no place, no voice specified
+ "\\T": (root: ("-son", "-approx", "-vocoid"), continuant: "-", segment: "\\T"),
+ "\\C": (root: ("±son", "±approx", "-vocoid"), segment: "\\C"),
+ "\\V": (root: ("+son", "+approx", "+vocoid"), segment: "\\V"),
+)
+
+// ── Segment presets (Sagey 1986) ─────────────────────────────────────────────
+// Vowels only — consonants fall back to _presets in both models. The only
+// consonant difference between models is where [lateral] attaches, which
+// _build-tree handles from the `model` key (oral cavity vs. [coronal]).
+// Height/backness encoded as dorsal sub-features; roundness as labial: ("round",).
+// No aperture node. Note: [e]/[ɛ] and [o]/[ɔ] share the same basic features
+// in Sagey (ATR/tense distinguishes them, which is not modelled here).
+#let _presets-sagey = (
+ "i": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: ("+high", "-back"),
+ segment: "i",
+ ),
+ "e": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "+",
+ dorsal: ("-high", "-back"),
+ segment: "e",
+ ),
+ "E": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ dorsal: ("-high", "-back"),
+ segment: "E",
+ ),
+ "a": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: ("-high", "+lo"),
+ segment: "a",
+ ),
+ "o": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "+",
+ labial: ("round",),
+ dorsal: ("-high", "+back"),
+ segment: "o",
+ ),
+ "O": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ labial: ("round",),
+ dorsal: ("-high", "+back"),
+ segment: "O",
+ ),
+ "u": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: ("round",),
+ dorsal: ("+high", "+back"),
+ segment: "u",
+ ),
+ "I": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ dorsal: ("+high", "-back"),
+ segment: "I",
+ ),
+ "U": (
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ labial: ("round",),
+ dorsal: ("+high", "+back"),
+ segment: "U",
+ ),
+ // Additional vowels — high confidence:
+ "y": (
+ // y close front rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: ("round",),
+ dorsal: ("+high", "-back"),
+ segment: "y",
+ ),
+ "W": (
+ // ɯ close back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: ("+high", "+back"),
+ segment: "W",
+ ),
+ "7": (
+ // ɤ close-mid back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "+",
+ dorsal: ("-high", "+back"),
+ segment: "7",
+ ),
+ "\\o": (
+ // ø close-mid front rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "+",
+ labial: ("round",),
+ dorsal: ("-high", "-back"),
+ segment: "\\o",
+ ),
+ "\\oe": (
+ // œ open-mid front rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ labial: ("round",),
+ dorsal: ("-high", "-back"),
+ segment: "\\oe",
+ ),
+ "2": (
+ // ʌ open-mid back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ tense: "-",
+ dorsal: ("-high", "+back"),
+ segment: "2",
+ ),
+ "A": (
+ // ɑ open back unrounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: ("-high", "+lo", "+back"),
+ segment: "A",
+ ),
+ "6": (
+ // ɒ open back rounded
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: ("round",),
+ dorsal: ("-high", "+lo", "+back"),
+ segment: "6",
+ ),
+ // Flagged vowels — central/near-open: place analysis is theory-dependent.
+ "@": (
+ // ə mid central — dorsal [-hi] only; back unspecified
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: ("-high",),
+ segment: "@",
+ ),
+ "1": (
+ // ɨ close central unrounded — dorsal [+hi], no back value
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: ("+high",),
+ segment: "1",
+ ),
+ "0": (
+ // ʉ close central rounded — [+hi] + labial, no back value
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ labial: ("round",),
+ dorsal: ("+high",),
+ segment: "0",
+ ),
+ "\\ae": (
+ // æ near-open front — [-hi, +lo, -back]; +lo distinguishes from /ɛ/ (-hi, -back)
+ root: ("+son", "+approx", "+vocoid"),
+ vocalic: true,
+ vplace: true,
+ dorsal: ("-high", "+lo", "-back"),
+ segment: "\\ae",
+ ),
+)
+
+// ── Build tree from spec dict ────────────────────────────────────────────────
+// Accepts a dict with the same keys as geom() (all optional, same defaults).
+// Returns (tree: root-node-dict, is-vocoid: bool).
+#let _build-tree(spec) = {
+ let root = spec.at("root", default: ())
+ let laryngeal = spec.at("laryngeal", default: false)
+ let nasal = spec.at("nasal", default: false)
+ let spread = spec.at("spread", default: false)
+ let constricted = spec.at("constricted", default: false)
+ let voice = spec.at("voice", default: false)
+ let continuant = spec.at("continuant", default: false)
+ let labial = spec.at("labial", default: false)
+ let coronal = spec.at("coronal", default: false)
+ let anterior = spec.at("anterior", default: false)
+ let distributed = spec.at("distributed", default: false)
+ let dorsal = spec.at("dorsal", default: false)
+ let vocalic = spec.at("vocalic", default: false)
+ let vplace = spec.at("vplace", default: false)
+ let aperture = spec.at("aperture", default: false)
+ let lateral = spec.at("lateral", default: false)
+ // Feature-geometry model: "ch" (Clements & Hume) attaches [lateral] under the
+ // oral cavity (sibling of [continuant]); "sagey" attaches it under [coronal].
+ let model = spec.at("model", default: "ch")
+
+ // Normalize root to array
+ let root-feats = if type(root) == str { (root,) } else { root }
+
+ // Auto-inference: show parent when any child is active
+ let radical = spec.at("radical", default: false)
+
+ let laryngeal = laryngeal or spread or constricted or voice != false
+
+ // [lateral] label and placement. In the Sagey model [lateral] hangs off
+ // [coronal]; in C&H it is a manner feature under the oral cavity.
+ let lateral-lbl = if lateral == true { "lateral" } else if lateral == "+" { "+lateral" } else if lateral == "-" {
+ "−lateral"
+ } else { none }
+ let lateral-cor = lateral-lbl != none and model == "sagey" // attach under [coronal]
+ let lateral-oc = lateral-lbl != none and model != "sagey" // attach under oral cavity
+
+ // coronal may be bool or array; treat array as "active".
+ // A Sagey-style [lateral] forces [coronal] to appear as its host.
+ let coronal = if type(coronal) == array { coronal } else if (
+ coronal or (anterior != false) or distributed or lateral-cor
+ ) {
+ true
+ } else { false }
+ let tense = spec.at("tense", default: false)
+
+ // aperture is active when: true, or a non-empty array with at least one active element
+ let aperture-active = if type(aperture) == array {
+ aperture.any(v => v != false)
+ } else { aperture != false }
+ let vocalic = vocalic or vplace or aperture-active or (tense != false)
+ // If already in vocoid mode and place features are supplied, vplace is implied.
+ // labial/coronal/dorsal/radical may now be arrays (truthy) — != false covers all.
+ let place-active = (labial != false) or (coronal != false) or (dorsal != false) or (radical != false)
+ let vplace = vplace or (vocalic and place-active)
+ let vocalic = vocalic or vplace or aperture-active
+ let show-cplace = place-active or vocalic
+
+ // [nasal] label
+ let nasal-lbl = if nasal == true { "nasal" } else if nasal == "+" { "+nasal" } else if nasal == "-" {
+ "−nasal"
+ } else { none }
+
+ // [continuant] labels — accepts a single value or an array of up to 2 (affricates).
+ let _cont-lbl(v) = if v == true { "continuant" } else if v == "+" { "+continuant" } else if v == "-" {
+ "−continuant"
+ } else { none }
+ let continuant-lbls = if type(continuant) == array {
+ continuant.slice(0, calc.min(continuant.len(), 2)).map(_cont-lbl).filter(l => l != none)
+ } else {
+ let l = _cont-lbl(continuant)
+ if l != none { (l,) } else { () }
+ }
+
+ // [voice] label
+ let voice-lbl = if voice == true { "voice" } else if voice == "+" { "+voice" } else if voice == "-" {
+ "−voice"
+ } else { none }
+
+ // [anterior] label
+ let ant-lbl = if anterior == true { "anterior" } else if anterior == "+" { "+anterior" } else if anterior == "-" {
+ "−anterior"
+ } else { none }
+
+ // [coronal] subtree — used when coronal is bool (existing anterior/distributed params)
+ let cor-ch-default = ()
+ if ant-lbl != none { cor-ch-default = cor-ch-default + (_feat(ant-lbl),) }
+ if distributed { cor-ch-default = cor-ch-default + (_feat("distributed"),) }
+
+ // Place features (shared by consonant and vocoid branches).
+ // Each of labial/coronal/dorsal/radical may be:
+ // false → node absent
+ // true → node shown, no sub-features (bool mode)
+ // array → node shown with sub-feature children from the array
+ // (e.g. dorsal: ("+high", "-back"), labial: ("round",))
+ // Array mode for coronal replaces the anterior/distributed params.
+ let place-ch = ()
+ if labial != false {
+ let ch = if type(labial) == array { labial.map(f => _feat(_norm-feat(f))) } else { () }
+ place-ch = place-ch + (_feat("labial", ch: ch),)
+ }
+ if coronal != false {
+ let ch = if type(coronal) == array { coronal.map(f => _feat(_norm-feat(f))) } else { cor-ch-default }
+ // Sagey model: [lateral] is a dependent of [coronal].
+ if lateral-cor { ch = ch + (_feat(lateral-lbl),) }
+ place-ch = place-ch + (_feat("coronal", ch: ch),)
+ }
+ if radical != false { place-ch = place-ch + (_feat("radical"),) }
+ if dorsal != false {
+ let ch = if type(dorsal) == array { dorsal.map(f => _feat(_norm-feat(f))) } else { () }
+ place-ch = place-ch + (_feat("dorsal", ch: ch),)
+ }
+
+ // [tense] label
+ let tense-lbl = if tense == true { "tense" } else if tense == "+" { "+tense" } else if tense == "-" {
+ "−tense"
+ } else { none }
+
+ // Vocoid branch
+ let voc-ch = ()
+ if tense-lbl != none { voc-ch = voc-ch + (_feat(tense-lbl),) }
+ if vplace {
+ voc-ch = voc-ch + (_class("V-place", place-ch),)
+ }
+ if aperture-active {
+ let ap-ch = if type(aperture) == array {
+ let names = ("open1", "open2", "open3")
+ let result = ()
+ for (i, v) in aperture.slice(0, calc.min(aperture.len(), 3)).enumerate() {
+ let lbl = if v == true { names.at(i) } else if v == "+" { "+" + names.at(i) } else if v == "-" {
+ "−" + names.at(i)
+ } else { none }
+ if lbl != none { result = result + (_feat(lbl),) }
+ }
+ result
+ } else { () }
+ voc-ch = voc-ch + (_class("aperture", ap-ch),)
+ }
+
+ // C-place children
+ let cplace-ch = if vocalic { (_class("vocalic", voc-ch),) } else { place-ch }
+
+ // Auto-inference: != false covers true, "+", "-"
+ let show-oc = continuant-lbls.len() > 0 or show-cplace or lateral-oc
+
+ // Oral cavity children. C&H model: [lateral] is a manner feature here,
+ // sitting alongside [continuant] and before C-place.
+ let oc-ch = continuant-lbls.map(_feat)
+ if lateral-oc { oc-ch = oc-ch + (_feat(lateral-lbl),) }
+ if show-cplace { oc-ch = oc-ch + (_class("C-place", cplace-ch),) }
+
+ // Laryngeal children — [voice] is under laryngeal (Clements & Hume 1995)
+ let laryng-ch = ()
+ if voice-lbl != none { laryng-ch = laryng-ch + (_feat(voice-lbl),) }
+ if spread { laryng-ch = laryng-ch + (_feat("spread"),) }
+ if constricted { laryng-ch = laryng-ch + (_feat("constricted"),) }
+
+ // Root children
+ let root-ch = ()
+ if laryngeal { root-ch = root-ch + (_class("laryngeal", laryng-ch),) }
+ if nasal-lbl != none { root-ch = root-ch + (_feat(nasal-lbl),) }
+ if show-oc { root-ch = root-ch + (_class("oral cavity", oc-ch),) }
+
+ (
+ tree: (label: "root", kind: "root", features: root-feats, children: root-ch),
+ is-vocoid: vocalic,
+ )
+}
+
+// ── Vocoid positional nudge ──────────────────────────────────────────────────
+// Vocoid trees are naturally skewed: the deep vocalic subtree inflates the
+// oral-cavity subtree's width, pushing it far right and laryngeal far left.
+// Corrections:
+// 1. Shift oral-cavity subtree left (oc-shift)
+// 2. Shift laryngeal subtree right (lar-shift)
+// 3. Individual nudges for [cont], [lab], [dor], [nasal]
+//
+// Subtrees identified via a single forward pass (pre-order). par coords are
+// exact copies of the parent's (x,y), so array.contains() is exact.
+#let _apply-vocoid-nudge(nodes, is-vocoid) = {
+ if not is-vocoid { return nodes }
+
+ let oc-shift = -2.00 // move oral-cavity subtree left
+ let lar-shift = +1.10 // move laryngeal subtree right
+
+ // Build a subtree membership set from a named root label.
+ let _build-sub(root-label) = {
+ let sub = ()
+ let rn = nodes.find(e => e.label == root-label)
+ if rn != none {
+ sub = sub + ((rn.x, rn.y),)
+ for n in nodes {
+ if n.par != none and sub.contains(n.par) {
+ let pos = (n.x, n.y)
+ if not sub.contains(pos) { sub = sub + (pos,) }
+ }
+ }
+ }
+ sub
+ }
+
+ let vp-extra = -0.35 // push V-place subtree left (more gap from aperture)
+ let ap-extra = +0.35 // push aperture subtree right (more gap from V-place)
+ // Only spread V-place/aperture when BOTH are present under vocalic;
+ // if only one is present it is the sole child and should stay centred.
+
+ let oc-sub = _build-sub("oral cavity")
+ let lar-sub = _build-sub("laryngeal")
+ let vplace-sub = _build-sub("V-place")
+ let apt-sub = _build-sub("aperture")
+
+ // Only apply root-level shifts when the root has multiple children.
+ let has-laryngeal = nodes.any(e => e.label == "laryngeal")
+ let has-nasal = nodes.any(e => e.label == "nasal" or e.label.ends-with("nasal"))
+ let has-open3 = nodes.any(e => e.label.ends-with("open3"))
+ let has-lab = nodes.any(e => e.label == "labial")
+ let has-cor = nodes.any(e => e.label == "coronal")
+ let has-dor = nodes.any(e => e.label == "dorsal")
+ // Full vocoid tree (laryngeal + nasal + OC): OC moves right to spread out.
+ // Partial tree (only laryngeal or only nasal): OC moves left as before.
+ let effective-oc-shift = if has-laryngeal and has-nasal { +0.10 } else if has-laryngeal or has-nasal {
+ oc-shift
+ } else { 0 }
+ let effective-lar-shift = if oc-sub.len() > 0 { lar-shift + (if has-open3 { 0.60 } else { 0 }) } else { 0 }
+ let effective-vp-extra = if vplace-sub.len() > 0 and apt-sub.len() > 0 { vp-extra } else { 0 }
+ let effective-ap-extra = if vplace-sub.len() > 0 and apt-sub.len() > 0 { ap-extra } else { 0 }
+
+ // Total x displacement for a given (x,y) position — sum of all applicable shifts.
+ let _dx-for(pos) = {
+ let d = 0.0
+ if oc-sub.contains(pos) { d = d + effective-oc-shift }
+ if vplace-sub.contains(pos) { d = d + effective-vp-extra }
+ if apt-sub.contains(pos) { d = d + effective-ap-extra }
+ if lar-sub.contains(pos) { d = d + effective-lar-shift }
+ d
+ }
+
+ nodes.map(e => {
+ let base-x = e.x + _dx-for((e.x, e.y))
+ let new-par = if e.par == none { none } else {
+ let pd = _dx-for(e.par)
+ if pd != 0 { (e.par.at(0) + pd, e.par.at(1)) } else { e.par }
+ }
+
+ if e.label.ends-with("continuant") {
+ let cont-nudge = if not has-lab and has-cor and has-dor { -0.40 } else { 0 }
+ (..e, x: base-x + 2.80 + cont-nudge, par: new-par)
+ } else if e.label == "labial" {
+ // Only nudge toward [cor] when [cor] is actually present.
+ // Amount increases when [dor] is also there (three-way spread needs more room).
+ let lab-nudge = if has-cor { (if has-dor { 0.40 } else { 0.10 }) } else { 0 }
+ (..e, x: base-x + lab-nudge, par: new-par)
+ } else if e.label == "dorsal" {
+ if has-cor {
+ // [cor] is in the middle: pull [dor] left toward it and lift above open1.
+ (..e, x: base-x - 0.15, y: e.y + _stagger-dy, par: new-par)
+ } else {
+ // No [cor]: stay at natural position (sole feature, or alongside [lab] only).
+ (..e, x: base-x, par: new-par)
+ }
+ } else if e.label == "nasal" or e.label.ends-with("nasal") {
+ // Full vocoid tree only (has laryngeal): nudge nasal right.
+ let nx = if has-laryngeal { e.x + 0.90 + (if has-open3 { 0.60 } else { 0 }) } else { e.x }
+ (..e, x: nx, y: e.y - 0.30)
+ } else {
+ (..e, x: base-x, par: new-par)
+ }
+ })
+}
+
+// ── Node name for CeTZ anchor ────────────────────────────────────────────────
+// Based on the argument name: sign stripped, spaces→hyphens, NO abbreviation.
+// "+anterior" → "anterior1", "oral cavity" → "oral-cavity2", "−voice" → "voice1"
+#let _node-name(label, tidx) = {
+ let (_, base) = _sign-base(lower(label))
+ base.replace(" ", "-") + str(tidx)
+}
+
+// ── Manual position adjustments ──────────────────────────────────────────────
+// `position` is an array of (key, dx, dy) triples. `key` is:
+// - geom(): bare argument name, e.g. "continuant", "oral-cavity"
+// - geom-group(): argument name + tree index, e.g. "continuant1", "oral-cavity2"
+// `use-tidx`: when true, match key against _node-name(label, tidx);
+// when false, match against bare base label (spaces→hyphens, no index).
+// Moving a node also patches every node whose stored parent coords match the
+// original position, so tree lines stay connected.
+#let _apply-positions(nodes, position, use-tidx) = {
+ // Auto-wrap a single flat (key, dx, dy) triple
+ let position = if position.len() > 0 and type(position.at(0)) == str {
+ (position,)
+ } else { position }
+ if position.len() == 0 { return nodes }
+
+ // Build adjustment dict: key → (dx, dy)
+ let adj = (:)
+ for entry in position {
+ adj.insert(entry.at(0), (entry.at(1), entry.at(2)))
+ }
+
+ // Resolve key for a node entry
+ let node-key(e) = if use-tidx {
+ _node-name(e.label, e.tidx)
+ } else {
+ let (_, base) = _sign-base(lower(e.label))
+ base.replace(" ", "-")
+ }
+
+ // First pass: build "orig-x,orig-y" → new-(x,y) for all nodes that move,
+ // so we can patch children's stored parent coordinates in the second pass.
+ let moved = (:)
+ for e in nodes {
+ let key = node-key(e)
+ if key in adj {
+ let (dx, dy) = adj.at(key)
+ moved.insert(str(e.x) + "," + str(e.y), (e.x + dx, e.y + dy))
+ }
+ }
+
+ // Second pass: update node positions and parent references
+ nodes.map(e => {
+ let key = node-key(e)
+ let (nx, ny) = if key in adj {
+ let (dx, dy) = adj.at(key)
+ (e.x + dx, e.y + dy)
+ } else { (e.x, e.y) }
+
+ let new-par = if e.par != none {
+ let pk = str(e.par.at(0)) + "," + str(e.par.at(1))
+ if pk in moved { moved.at(pk) } else { e.par }
+ } else { none }
+
+ (..e, x: nx, y: ny, par: new-par)
+ })
+}
+
+// ── Segment label normalisation ──────────────────────────────────────────────
+// Strings are passed through ipa-to-unicode (TIPA conventions); content is used as-is.
+// Strip phonemic/phonetic delimiters from a ph key so it can be looked up in _presets.
+// "/a/" → "a", "[a]" → "a", "a" → "a"
+#let _ph-bare(s) = if type(s) != str { s } else if s.starts-with("/") and s.ends-with("/") and s.len() > 2 {
+ s.slice(1, -1)
+} else if s.starts-with("[") and s.ends-with("]") and s.len() > 2 { s.slice(1, -1) } else { s }
+
+// Render a segment label string. The user controls brackets by the string itself:
+// "/a/" → /ipa("a")/ (phonemic)
+// "[a]" → [ipa("a")] (phonetic)
+// "a" → ipa("a") (bare)
+// content → passed through unchanged
+#let _seg(s) = if type(s) != str { s } else if s.starts-with("/") and s.ends-with("/") and s.len() > 2 {
+ [/#(ipa-to-unicode(s.slice(1, -1)))/]
+} else if s.starts-with("[") and s.ends-with("]") and s.len() > 2 {
+ [\[#(ipa-to-unicode(s.slice(1, -1)))\]]
+} else { ipa-to-unicode(s) }
+
+// ── Delink mark drawing ───────────────────────────────────────────────────────
+// Draws two parallel bars perpendicular to the parent→child line at its midpoint,
+// matching the same symbol used in autoseg() / multi-tier().
+// (fx,fy) = parent bottom endpoint; (tx,ty) = child top endpoint.
+#let _draw-delink(fx, fy, tx, ty, sw) = {
+ let dx = tx - fx
+ let dy = ty - fy
+ let len = calc.sqrt(dx * dx + dy * dy)
+ if len == 0 { return }
+ let dir-x = dx / len
+ let dir-y = dy / len
+ let perp-x = -dir-y
+ let perp-y = dir-x
+ let mid-x = (fx + tx) / 2
+ let mid-y = (fy + ty) / 2
+ let bar = 0.15 // half-length of each bar (canvas units)
+ let gap = 0.03 // half-gap between the two bars
+ for sign in (-1, 1) {
+ let cx = mid-x + sign * gap * dir-x
+ let cy = mid-y + 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,
+ )
+ }
+}
+
+// ── General post-layout nudge (all tree types) ───────────────────────────────
+// When [voice] and [continuant] coexist they end up at nearly the same vertical
+// level and close in x, causing overlap. Push [voice] down unconditionally.
+#let _apply-general-nudge(nodes) = {
+ let has-cont = nodes.any(e => e.label.ends-with("continuant"))
+ let has-dor = nodes.any(e => e.label == "dorsal")
+
+ // Full consonant tree: root has laryngeal + nasal + oral cavity all present.
+ // Shift the oral-cavity subtree slightly left so it doesn't crowd the tree.
+ // Gated on all three being present — leaves other consonant trees unchanged.
+ let has-lar = nodes.any(e => e.label == "laryngeal")
+ let has-nas = nodes.any(e => e.label == "nasal" or e.label.ends-with("nasal"))
+ let has-oc = nodes.any(e => e.label == "oral cavity")
+ let has-distr = nodes.any(e => e.label == "distributed")
+ let oc-shift = if has-lar and has-nas and has-oc { -0.50 } else { 0.0 }
+
+ // Build oral-cavity subtree membership (pre-order, using original positions).
+ let oc-sub = if oc-shift != 0.0 {
+ let sub = ()
+ let rn = nodes.find(e => e.label == "oral cavity")
+ if rn != none {
+ sub = sub + ((rn.x, rn.y),)
+ for n in nodes {
+ if n.par != none and sub.contains(n.par) {
+ let pos = (n.x, n.y)
+ if not sub.contains(pos) { sub = sub + (pos,) }
+ }
+ }
+ }
+ sub
+ } else { () }
+
+ // Fix 2 — OC not centered under root when nasal+OC present but no laryngeal.
+ // Compute how far the root is displaced from the OC's current center.
+ let oc-center-shift = if not has-lar and has-nas and has-oc {
+ let rn = nodes.find(e => e.kind == "root")
+ let on = nodes.find(e => e.label == "oral cavity")
+ if rn != none and on != none { rn.x - on.x } else { 0.0 }
+ } else { 0.0 }
+
+ // Build OC subtree membership for the center-shift (different gate from oc-sub).
+ let oc-center-sub = if oc-center-shift != 0.0 {
+ let sub = ()
+ let on = nodes.find(e => e.label == "oral cavity")
+ if on != none {
+ sub = sub + ((on.x, on.y),)
+ for n in nodes {
+ if n.par != none and sub.contains(n.par) {
+ let pos = (n.x, n.y)
+ if not sub.contains(pos) { sub = sub + (pos,) }
+ }
+ }
+ }
+ sub
+ } else { () }
+
+ // Fix 3 — [cor] and [rad] overlap when lab+cor+rad+dor all present.
+ // Pre-account for the rad nudge (-0.30 when dor present) and shift [cor]
+ // subtree to the midpoint between [lab].x and post-nudge [rad].x.
+ let rad-entry = nodes.find(e => e.label == "radical")
+ let lab-entry = nodes.find(e => e.label == "labial")
+ let cor-entry = nodes.find(e => e.label == "coronal")
+ let has-rad = rad-entry != none
+
+ let cor-shift = if has-rad and cor-entry != none and lab-entry != none {
+ let rad-x = rad-entry.x + (if has-dor { -0.30 } else { 0.0 })
+ (lab-entry.x + rad-x) / 2.0 - cor-entry.x
+ } else { 0.0 }
+
+ let cor-sub = if cor-shift != 0.0 {
+ let sub = ()
+ if cor-entry != none {
+ sub = sub + ((cor-entry.x, cor-entry.y),)
+ for n in nodes {
+ if n.par != none and sub.contains(n.par) {
+ let pos = (n.x, n.y)
+ if not sub.contains(pos) { sub = sub + (pos,) }
+ }
+ }
+ }
+ sub
+ } else { () }
+
+ // Fix 4 — second [−cont] overlaps C-place in affricates (two continuant nodes).
+ let cont-nodes = nodes.filter(e => e.label.ends-with("continuant"))
+ let cont-count = cont-nodes.len()
+ let cont-right-x = if cont-count == 2 {
+ calc.max(..cont-nodes.map(e => e.x))
+ } else { none }
+
+ // Fix 5 — C&H [lateral] leaf is a sibling of [continuant] under oral cavity and
+ // its label crowds the C-place node. Locate the oral-cavity node so a lateral
+ // child of it can be nudged clear. (Sagey laterals sit under [coronal], untouched.)
+ let oc-lat-host = nodes.find(e => e.label == "oral cavity")
+
+ // Fix 6 — a Sagey [lateral] is a child of [coronal], which widens the C-place
+ // subtree and pushes C-place right, stranding [+cont] far to the left under
+ // oral cavity. Detect that case so [+cont] can be nudged right to recentre it.
+ let cor-lat-host = nodes.find(e => e.label == "coronal")
+ let has-sagey-lat = cor-lat-host != none and nodes.any(e => (
+ e.label.ends-with("lateral") and e.par == (cor-lat-host.x, cor-lat-host.y)
+ ))
+
+ nodes.map(e => {
+ // [voice] drops down when [continuant] is also present (avoids overlap).
+ let e2 = if has-cont and (e.label == "voice" or e.label.ends-with("voice")) {
+ (..e, y: e.y - 0.80)
+ } else { e }
+ // Full tree: nudge [nasal] left and up so it sits naturally between laryngeal and oral cavity.
+ let e2 = if oc-shift != 0.0 and (e2.label == "nasal" or e2.label.ends-with("nasal")) {
+ (..e2, x: e2.x + 0.10, y: e2.y + 0.20)
+ } else { e2 }
+ // Fix 7: retroflex nasal ɳ has a wide oral-cavity subtree ([distr] under
+ // [cor]) that crowds [+nasal] against the laryngeal line — shift it right.
+ // Gated like the nudge above (oc-shift != 0 ⇒ full laryngeal+nasal+oral-cavity
+ // tree) plus [distr], so it only fires when that laryngeal line is present.
+ // Among the presets this is unique to ɳ; a hand-built nasal retroflex with a
+ // laryngeal hits the same crowding and benefits from the same shift.
+ let e2 = if oc-shift != 0.0 and has-distr and (e2.label == "nasal" or e2.label.ends-with("nasal")) {
+ (..e2, x: e2.x + 0.35)
+ } else { e2 }
+ // [rad] nudge left when [dor] is also present, to close the gap between them.
+ let e2 = if has-dor and e2.label == "radical" {
+ (..e2, x: e2.x - 0.30)
+ } else { e2 }
+ // Fix 5: C&H [lateral] child of oral cavity overlaps C-place — push left+down.
+ let e2 = if e2.label.ends-with("lateral") and oc-lat-host != none and e.par == (
+ oc-lat-host.x,
+ oc-lat-host.y,
+ ) {
+ (..e2, x: e2.x - 0.35, y: e2.y - 0.30)
+ } else { e2 }
+ // Fix 6: Sagey [lateral] widens C-place — nudge [+cont] right under oral cavity.
+ let e2 = if has-sagey-lat and e2.label.ends-with("continuant") and oc-lat-host != none and e.par == (
+ oc-lat-host.x,
+ oc-lat-host.y,
+ ) {
+ (..e2, x: e2.x + 0.45)
+ } else { e2 }
+ // Fix 3: shift [cor] subtree to midpoint between [lab] and post-nudge [rad].
+ let e2 = if cor-sub.contains((e.x, e.y)) {
+ let new-par = if e2.par == none { none } else if cor-sub.contains(e2.par) {
+ (e2.par.at(0) + cor-shift, e2.par.at(1))
+ } else { e2.par }
+ (..e2, x: e2.x + cor-shift, par: new-par)
+ } else { e2 }
+ // Fix 4: nudge the rightmost continuant node left+down when two are present.
+ let e2 = if cont-count == 2 and e2.label.ends-with("continuant") and e2.x == cont-right-x {
+ (..e2, x: e2.x - 0.25, y: e2.y - 0.20)
+ } else { e2 }
+ // Shift oral-cavity subtree left in full consonant trees (has-lar+has-nas+has-oc).
+ let e2 = if oc-sub.contains((e.x, e.y)) {
+ let new-par = if e2.par == none { none } else if oc-sub.contains(e2.par) {
+ (e2.par.at(0) + oc-shift, e2.par.at(1))
+ } else { e2.par }
+ (..e2, x: e2.x + oc-shift, par: new-par)
+ } else { e2 }
+ // Fix 2: center OC subtree under root when nasal+OC present but no laryngeal.
+ if oc-center-sub.contains((e.x, e.y)) {
+ let new-par = if e2.par == none { none } else if oc-center-sub.contains(e2.par) {
+ (e2.par.at(0) + oc-center-shift, e2.par.at(1))
+ } else { e2.par }
+ (..e2, x: e2.x + oc-center-shift, par: new-par)
+ } else { e2 }
+ })
+}
+
+/// Draw a feature-geometry tree for a consonant or vocoid.
+///
+/// Arguments control which nodes are present. By default all nodes are absent.
+/// Parent nodes are inferred automatically from their children (e.g. specifying
+/// `spread: true` automatically shows "laryngeal").
+///
+/// - root (array): Feature strings for the root matrix, e.g. `("+son", "-vocoid")`.
+/// Accepts the same formats as `feat()`.
+/// - laryngeal (bool): Show "laryngeal" class node.
+/// - nasal (bool, str): Show `[nasal]`. Pass `true` → `[nasal]`,
+/// `"+"` → `[+nasal]`, `"-"` → `[−nasal]`.
+/// - spread (bool): Show `[spread]` under laryngeal.
+/// - constricted (bool): Show `[constricted]` under laryngeal.
+/// - voice (bool, str): Show `[voice]` under laryngeal (Clements & Hume 1995). Pass
+/// `true` → `[voice]`, `"+"` → `[+voice]`, `"-"` → `[−voice]`.
+/// - continuant (bool, str): Show `[continuant]` under oral cavity. Pass `true` → `[cont]`,
+/// `"+"` → `[+cont]`, `"-"` → `[−cont]`.
+/// - labial (bool): Show `[labial]` (under C-place or V-place).
+/// - coronal (bool): Show `[coronal]` (under C-place or V-place).
+/// - anterior (bool, str): Show `[anterior]`. Pass `true` → `[anterior]`,
+/// `"+"` → `[+anterior]`, `"-"` → `[−anterior]`.
+/// - distributed (bool): Show `[distributed]` under `[coronal]`.
+/// - radical (bool): Show `[rad]` (radical/pharyngeal) under C-place or V-place.
+/// - dorsal (bool, array): Show `[dorsal]` (under C-place or V-place).
+/// Pass an array of feature strings to add sub-features (Sagey-style):
+/// `dorsal: ("+high", "-back")` → `[dor]` with `[+hi]` and `[−back]` children.
+/// - labial (bool, array): Show `[labial]`. Array adds sub-features:
+/// `labial: ("round",)` → `[lab]` with `[round]` child.
+/// - coronal (bool, array): Show `[coronal]`. Array provides children directly,
+/// replacing the separate `anterior`/`distributed` params:
+/// `coronal: ("+ant", "-distr")` → `[cor]` with `[+ant]` and `[−distr]`.
+/// - tense (bool, str): Show `[tense]` under the vocalic node. Pass `true` → `[tense]`,
+/// `"+"` → `[+tense]`, `"-"` → `[−tense]`. Automatically infers `vocalic: true`.
+/// Used in Sagey-style representations to distinguish [e]/[ɛ] and [o]/[ɔ].
+/// - vocalic (bool): Show "vocalic" class node under C-place (vocoid branch).
+/// - vplace (bool): Show "V-place" under vocalic. When true, `labial`/`coronal`/`dorsal`
+/// attach here instead of directly under C-place. Inferred automatically when
+/// `vocalic` is active and any place feature is supplied.
+/// - aperture (bool, array): Show "aperture" class node under vocalic.
+/// Pass `true` → node only; pass an array of up to 3 values to show
+/// `[open1]`/`[open2]`/`[open3]` as children. Each element may be
+/// `true` → `[openN]`, `"+"` → `[+openN]`, `"-"` → `[−openN]`,
+/// or `false` → omit that degree. E.g. `aperture: ("+", false, "-")`.
+/// (Replaces the former `open` parameter.)
+/// - lateral (bool, str): Show `[lateral]`. Pass `true` → `[lateral]`,
+/// `"+"` → `[+lateral]`, `"-"` → `[−lateral]`. Attachment depends on `model`:
+/// under the oral cavity (sibling of `[continuant]`) for `"ch"`, under
+/// `[coronal]` for `"sagey"`. In the Sagey case `[coronal]` is shown automatically.
+/// - scale (number): Uniform scale factor (default: 1).
+/// - position (array): Manual position tweaks. Each entry: `(key, dx, dy)` where
+/// `key` is the bare argument name (`"continuant"`, `"oral-cavity"`) and `dx`/`dy`
+/// are canvas-unit offsets (positive x = right, positive y = up).
+/// Example: `position: (("continuant", -0.2, 0.3),)`
+/// - delinks (array): Node keys whose line *to their parent* is replaced with a
+/// delink mark (two perpendicular bars). Keys follow the same convention as
+/// `position`: bare argument name for `geom()`, e.g. `delinks: ("c-place",)`.
+/// - prefix (str): String prepended to the segment label. `"-"` is automatically converted to `"–"`. E.g. `prefix: "-"` → `–/a/`.
+/// - suffix (str): String appended to the segment label. `"-"` is automatically converted to `"–"`.
+/// - segment (content): Optional label centred above the root node, e.g. `"s"` or `$s$`.
+/// - ph (str): Pre-built segment preset. Supports `"a"`, `"e"`, `"i"`, `"o"`, `"u"`,
+/// `"E"` (ɛ), `"O"` (ɔ). The segment label defaults to the `ph` value unless overridden
+/// by `segment`. Any other explicitly-provided argument overrides the corresponding preset
+/// value: `#geom(ph: "O", root: ("+son", "+approx"))` replaces its root features.
+/// Example: `#geom(ph: "i", scale: 1.5)`.
+/// - model (str): Feature-geometry model for preset vowels. `"ch"` (default) uses
+/// Clements & Hume 1995 (aperture nodes for height). `"sagey"` uses Sagey 1986
+/// (dorsal sub-features for height/backness, labial `[round]` for rounding, no aperture).
+/// Consonant presets are otherwise identical in both models, except for the
+/// placement of `[lateral]` (oral cavity under `"ch"`, `[coronal]` under `"sagey"`).
+/// -> content
+#let geom(
+ ph: none,
+ ui-lang: "en",
+ model: "ch",
+ root: (),
+ laryngeal: false,
+ nasal: false,
+ spread: false,
+ constricted: false,
+ voice: false,
+ continuant: false,
+ labial: false,
+ coronal: false,
+ anterior: false,
+ distributed: false,
+ dorsal: false,
+ radical: false,
+ vocalic: false,
+ vplace: false,
+ aperture: false,
+ tense: false,
+ lateral: false,
+ scale: 1.0,
+ position: (),
+ delinks: (),
+ segment: none,
+ prefix: "",
+ suffix: "",
+ highlight: (),
+ timing: auto,
+) = {
+ let ui-locale = resolve-ui-lang(ui-lang)
+ if ui-locale == none {
+ return ui-lang-error(ui-lang)
+ }
+
+ // Auto-detect length from ph: "iː" or "i:" → long (two timing slots).
+ // Only a TRAILING colon counts as a length mark — a colon elsewhere belongs to
+ // a TIPA escape such as "\:t" (ʈ) and must not be stripped, or the preset lookup
+ // would fail. Strip just the trailing mark; keep the original as label fallback.
+ let _is-long = ph != none and type(ph) == str and (ph.ends-with("ː") or ph.ends-with(":"))
+ let _ph-orig = ph
+ let ph = if _is-long { ph.trim("ː", at: end, repeat: false).trim(":", at: end, repeat: false) } else { ph }
+
+ // Resolve timing:
+ // auto → one × normally, two × when ph contains a length mark
+ // false → no timing tier
+ // string/symbol/array → explicit (normalized below)
+ let timing = if timing == false {
+ ()
+ } else if timing == auto {
+ if _is-long { ($times$, $times$) } else { ($times$,) }
+ } else {
+ // Coerce bare string/symbol to array, then normalize "mora"/"mu" → μ, "x"/"X" → ×
+ let t = if type(timing) == str or type(timing) == symbol { (timing,) } else { timing }
+ t.map(t => if type(t) == str {
+ let tl = lower(t)
+ if tl == "mora" or tl == "mu" { sym.mu } else if tl == "x" { $times$ } else { t }
+ } else { t })
+ }
+ let prefix = prefix.replace("-", "–")
+ let suffix = suffix.replace("-", "–")
+ let scale-factor = scale
+
+ // When ph is set, load the preset, then apply any explicitly-provided
+ // non-default arguments on top. This lets callers override individual keys:
+ // #geom(ph: "O", root: ("+son", "+approx")) ← root replaces preset's root
+ // Sagey model uses _presets-sagey for vowels; consonants fall back to _presets.
+ let ph-key = if ph != none { _ph-bare(ph) } else { none }
+ let spec = if ph-key != none and ph-key in _presets {
+ let preset-dict = if model == "sagey" and ph-key in _presets-sagey {
+ _presets-sagey
+ } else { _presets }
+ // Use segment label exactly as provided by the user; fall back to the preset's own segment field.
+ let seg = if segment != none { prefix + segment + suffix } else { prefix + _ph-orig + suffix }
+ let overrides = (:)
+ if root != () { overrides.insert("root", root) }
+ if laryngeal != false { overrides.insert("laryngeal", laryngeal) }
+ if nasal != false { overrides.insert("nasal", nasal) }
+ if spread != false { overrides.insert("spread", spread) }
+ if constricted != false { overrides.insert("constricted", constricted) }
+ if voice != false { overrides.insert("voice", voice) }
+ if continuant != false { overrides.insert("continuant", continuant) }
+ if labial != false { overrides.insert("labial", labial) }
+ if coronal != false { overrides.insert("coronal", coronal) }
+ if anterior != false { overrides.insert("anterior", anterior) }
+ if distributed != false { overrides.insert("distributed", distributed) }
+ if dorsal != false { overrides.insert("dorsal", dorsal) }
+ if radical != false { overrides.insert("radical", radical) }
+ if vocalic != false { overrides.insert("vocalic", vocalic) }
+ if vplace != false { overrides.insert("vplace", vplace) }
+ if aperture != false { overrides.insert("aperture", aperture) }
+ if tense != false { overrides.insert("tense", tense) }
+ if lateral != false { overrides.insert("lateral", lateral) }
+ (..(preset-dict.at(ph-key)), segment: seg, ..overrides)
+ } else {
+ (
+ root: root,
+ laryngeal: laryngeal,
+ nasal: nasal,
+ spread: spread,
+ constricted: constricted,
+ voice: voice,
+ continuant: continuant,
+ labial: labial,
+ coronal: coronal,
+ anterior: anterior,
+ distributed: distributed,
+ dorsal: dorsal,
+ radical: radical,
+ vocalic: vocalic,
+ vplace: vplace,
+ aperture: aperture,
+ tense: tense,
+ lateral: lateral,
+ segment: if segment != none { prefix + segment + suffix } else if prefix != "" or suffix != "" {
+ prefix + suffix
+ } else { none },
+ )
+ }
+ let spec = (..spec, model: model)
+ let result = _build-tree(spec)
+ let tree = result.tree
+ let is-vocoid = result.is-vocoid
+
+ let nodes = _layout(tree, 0.0, 0.0, none)
+ let nodes = _apply-vocoid-nudge(nodes, is-vocoid)
+ let nodes = _apply-general-nudge(nodes)
+ // Tag with tree index 1 (single tree)
+ let nodes = nodes.map(e => (..e, tidx: 1))
+ let nodes = _apply-positions(nodes, position, false)
+
+ // ── Render ────────────────────────────────────────────────────────────
+ let _loff = 0.20
+ let _dim = luma(65%)
+ let _norm = luma(15%)
+ // Per-node color: dim everything not in the highlight set (when set is non-empty).
+ let _nc = nname => if highlight.len() == 0 or nname in highlight { _norm } else { _dim }
+
+ // Dynamic baseline: anchor root node (canvas y=0) at the text baseline.
+ let y-min = nodes.fold(0.0, (acc, e) => calc.min(acc, e.y))
+
+ context {
+ let em-in-cu = text.size / (scale-factor * 1cm)
+ let _timing-gap = 0.65 // vertical gap from root to timing tier
+ let _timing-y = 0.55 + _timing-gap // y-coordinate of timing nodes (root at 0)
+ let _t-spacing = 0.55 // horizontal gap between timing nodes
+ let seg-present = spec.at("segment", default: none) != none
+ let y-top = if timing.len() > 0 {
+ // timing nodes sit at _timing-y; segment label (if any) floats above them
+ let label-top = if seg-present {
+ _timing-y + 0.45 + text.size * 0.84 / (scale-factor * 1cm)
+ } else {
+ _timing-y + text.size * 0.35 / (scale-factor * 1cm)
+ }
+ label-top
+ } else if seg-present {
+ 0.55 + text.size * 0.84 / (scale-factor * 1cm)
+ } else {
+ text.size * 0.35 / (scale-factor * 1cm)
+ }
+ let bl = (em-in-cu + (-y-min)) / (2 * em-in-cu + y-top - y-min)
+ let fsz = text.size * 0.70 * scale-factor
+ let font = phonokit-font.get()
+
+ box(inset: 1em * scale-factor, baseline: bl * 100%, {
+ cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ for entry in nodes {
+ let nname = _node-name(entry.label, entry.tidx)
+ let is-delinked = nname.slice(0, -1) in delinks
+ let nc = _nc(nname)
+
+ if entry.par != none {
+ let (px, py) = entry.par
+ let (fx, fy) = (px, py - _loff)
+ let (tx, ty) = (entry.x, entry.y + _loff)
+ let par-entry = nodes.find(e => e.x == px and e.y == py)
+ let par-nname = if par-entry != none { _node-name(par-entry.label, par-entry.tidx) } else { none }
+ let both-highlighted = (
+ highlight.len() > 0 and nname in highlight and par-nname != none and par-nname in highlight
+ )
+ let line-paint = if highlight.len() == 0 or both-highlighted { _norm } else { _dim }
+ let sw = (paint: line-paint, thickness: 0.016)
+ line((fx, fy), (tx, ty), stroke: sw)
+ if is-delinked { _draw-delink(fx, fy, tx, ty, sw) }
+ }
+
+ if entry.kind == "root" {
+ content(
+ (entry.x, entry.y),
+ text(font: font, size: fsz, fill: nc, ui-geom-label("root", ui-locale)),
+ name: nname,
+ )
+ if entry.feats.len() > 0 {
+ let mat-x = entry.x + 0.25
+ let items = entry.feats.map(f => {
+ let norm = f.replace("-", "−")
+ let (sign, base) = if norm.starts-with("±") { ("±", norm.slice("±".len())) } else if norm.starts-with(
+ "+",
+ ) { ("+", norm.slice(1)) } else if norm.starts-with("−") { ("−", norm.slice("−".len())) } else {
+ ("", norm)
+ }
+ text(font: font, fill: nc, if sign != "" { box(width: 0.65em, align(center, sign)) + base } else {
+ norm
+ })
+ })
+ content(
+ (mat-x, entry.y),
+ {
+ set text(font: font, size: fsz, fill: nc)
+ box(baseline: 50%, math.vec(
+ align: left,
+ delim: "[",
+ gap: 0pt,
+ ..items,
+ ))
+ },
+ anchor: "west",
+ )
+ }
+ } else {
+ let inner = if entry.kind == "feature" {
+ [\[#(_display(entry.label, ui-lang: ui-locale))\]]
+ } else {
+ [#(_display(entry.label, ui-lang: ui-locale))]
+ }
+ content(
+ (entry.x, entry.y),
+ text(font: font, size: fsz, fill: nc, inner),
+ name: nname,
+ )
+ }
+ }
+
+ // Segment label — always full colour (never dimmed by highlight)
+ let seg-label = spec.at("segment", default: none)
+ if seg-label != none {
+ let root-entry = nodes.find(e => e.kind == "root")
+ if root-entry != none {
+ let label-y = if timing.len() > 0 {
+ root-entry.y + _timing-y + 0.45
+ } else {
+ root-entry.y + 0.55
+ }
+ content(
+ (root-entry.x, label-y),
+ text(font: font, size: fsz * 1.2, fill: _norm, _seg(seg-label)),
+ anchor: "south",
+ )
+ }
+ }
+
+ // Timing tier (X-slots, morae, etc.)
+ if timing.len() > 0 {
+ let root-entry = nodes.find(e => e.kind == "root")
+ if root-entry != none {
+ let rx = root-entry.x
+ let ry = root-entry.y
+ let t-y = ry + _timing-y
+ let n = timing.len()
+ let t-paint = if highlight.len() == 0 { _norm } else { _dim }
+ for (i, t) in timing.enumerate() {
+ let tx = if n == 1 { rx } else { rx + (i - (n - 1) / 2.0) * _t-spacing }
+ line(
+ (rx, ry + _loff),
+ (tx, t-y - _loff),
+ stroke: (paint: t-paint, thickness: 0.016),
+ )
+ content((tx, t-y), text(font: font, size: fsz, fill: t-paint, t))
+ }
+ }
+ }
+ })
+ })
+ } // context
+}
+
+/// Draw two or more feature-geometry trees side by side in a single canvas,
+/// with optional dashed arrows connecting nodes across trees.
+///
+/// Each tree is specified as a dict with the same keys as `geom()` (all
+/// optional, same defaults). Trees cannot be passed as rendered `#geom()`
+/// content — pass spec dicts or `#let` variables instead:
+///
+/// ```typst
+/// #let consonant = (root: ("-son",), labial: true)
+/// #let vowel = (root: ("+son",), vocalic: true, dorsal: true)
+/// #geom-group(consonant, vowel,
+/// arrows: (("labial1", "dorsal2"),))
+/// ```
+///
+/// Node names are formed by stripping `+`/`−` prefixes, replacing spaces with
+/// hyphens, and appending the 1-based tree index:
+/// `"anterior1"`, `"oral-cavity2"`, `"c-place1"`, `"root2"`, etc.
+///
+/// Each arrow entry is either a simple array `(from, to)` or a dict with
+/// named keys for full control:
+/// ```typst
+/// arrows: (
+/// ("labial1", "labial2"), // simple
+/// (from: "cor1", to: "cor2", color: blue), // coloured
+/// (from: "dor1", to: "dor2", ctrl: (1.0, -0.5)), // custom S-curve
+/// )
+/// ```
+///
+/// - ..trees (arguments): Positional spec dicts, one per tree.
+/// - arrows (array): Cross-tree arrows. Each entry: `(from, to)` array or
+/// `(from: str, to: str, color: color, ctrl: array)` dict (all keys except
+/// `from`/`to` optional).
+/// - gap (number): Canvas-unit gap between trees (default: 1.5).
+/// - scale (number): Uniform scale factor (default: 1).
+/// - model (str): Feature-geometry model for preset vowels. `"ch"` (default) or `"sagey"`.
+/// Applies to all trees in the group. See `geom()` for details.
+/// - position (array): Manual position tweaks after layout. Each entry: `(key, dx, dy)`
+/// where `key` is the node anchor name with tree index (`"continuant1"`, `"oral-cavity2"`)
+/// and `dx`/`dy` are canvas-unit offsets. Arrows automatically use the adjusted positions.
+/// Example: `position: (("continuant1", -0.2, 0.3),)`
+/// - delinks (array): Node anchor names (with tree index) whose line to their parent is
+/// replaced with a delink mark. E.g. `delinks: ("c-place1",)`.
+/// - curved (bool): When `true`, arrows are drawn as quadratic bézier curves with
+/// automatic obstacle avoidance — they route around intervening nodes rather than
+/// crossing them. Uses the same algorithm as `#vowels()`. (default: `false`)
+/// -> content
+#let geom-group(
+ ..args,
+ arrows: (),
+ gap: 1.5,
+ scale: 1.0,
+ ui-lang: "en",
+ model: "ch",
+ position: (),
+ delinks: (),
+ curved: false,
+ highlight: (),
+) = {
+ let ui-locale = resolve-ui-lang(ui-lang)
+ if ui-locale == none {
+ return ui-lang-error(ui-lang)
+ }
+
+ let specs = args.pos()
+ let scale-factor = scale
+
+ // Auto-wrap a flat ("from", "to") pair so both forms are valid:
+ // arrows: ("labial1", "c-place3") ← single arrow, flat
+ // arrows: (("labial1", "c-place3"), ...) ← multiple arrows, nested
+ let arrows = if arrows.len() > 0 and type(arrows.at(0)) == str {
+ (arrows,)
+ } else { arrows }
+
+ // ── Build and layout each tree, offset x by cumulative width + gap ────
+ // Each spec may carry a `scale` key (default 1.0) that scales that tree's
+ // coordinates and font size relative to the group scale.
+ let all-nodes = ()
+ let x-cursor = 0.0
+ let seg-labels = () // (x, y, ts, text, root-nname, has-timing)
+ let timing-data = () // (root-x, ts, resolved-timing-array)
+ for (idx, spec) in specs.enumerate() {
+ // Auto-detect a TRAILING length mark in ph; strip just that so preset lookup
+ // works. A mid-string colon belongs to a TIPA escape like "\:t" (ʈ) — leave it.
+ let ph-raw = spec.at("ph", default: none)
+ let _is-long = ph-raw != none and type(ph-raw) == str and (ph-raw.ends-with("ː") or ph-raw.ends-with(":"))
+ let spec = if _is-long {
+ (..spec, ph: ph-raw.trim("ː", at: end, repeat: false).trim(":", at: end, repeat: false))
+ } else { spec }
+
+ // Resolve preset if ph key is present; explicit spec keys override the preset.
+ // Sagey model uses _presets-sagey for vowels; consonants fall back to _presets.
+ let spec = if "ph" in spec and _ph-bare(spec.at("ph")) in _presets {
+ let ph-val = spec.at("ph")
+ let ph-key = _ph-bare(ph-val)
+ let preset-dict = if model == "sagey" and ph-key in _presets-sagey {
+ _presets-sagey
+ } else { _presets }
+ let base = preset-dict.at(ph-key)
+ let px = spec.at("prefix", default: "").replace("-", "–")
+ let sx = spec.at("suffix", default: "").replace("-", "–")
+ // Use original ph (with length mark) as segment label fallback
+ let seg-ph = if ph-raw != none { ph-raw } else { ph-val }
+ let seg = if "segment" in spec { px + spec.at("segment") + sx } else { px + seg-ph + sx }
+ let overrides = (:)
+ for pair in spec.pairs() {
+ if pair.at(0) not in ("ph", "prefix", "suffix") { overrides.insert(pair.at(0), pair.at(1)) }
+ }
+ (..base, segment: seg, ..overrides)
+ } else { spec }
+
+ // Resolve timing for this tree (same logic as geom())
+ let timing-raw = spec.at("timing", default: auto)
+ let tree-timing = if timing-raw == false {
+ ()
+ } else if timing-raw == auto {
+ if _is-long { ($times$, $times$) } else { ($times$,) }
+ } else {
+ let t = if type(timing-raw) == str or type(timing-raw) == symbol { (timing-raw,) } else { timing-raw }
+ t.map(t => if type(t) == str {
+ let tl = lower(t)
+ if tl == "mora" or tl == "mu" { sym.mu } else if tl == "x" { $times$ } else { t }
+ } else { t })
+ }
+
+ let spec = (..spec, model: model)
+ let result = _build-tree(spec)
+ let tree = result.tree
+ let is-vocoid = result.is-vocoid
+ let ts = spec.at("scale", default: 1.0) // per-tree relative scale
+ let px = spec.at("prefix", default: "").replace("-", "–")
+ let sx = spec.at("suffix", default: "").replace("-", "–")
+ let seg = if spec.at("segment", default: none) != none { px + spec.at("segment") + sx } else { none }
+ let nodes = _layout(tree, 0.0, 0.0, none)
+ let nodes = _apply-vocoid-nudge(nodes, is-vocoid)
+ let nodes = _apply-general-nudge(nodes)
+ let tidx = idx + 1
+ // Scale coordinates and offset into the shared canvas.
+ all-nodes = (
+ all-nodes
+ + nodes.map(e => (
+ ..e,
+ x: e.x * ts + x-cursor,
+ y: e.y * ts,
+ par: if e.par == none { none } else {
+ (e.par.at(0) * ts + x-cursor, e.par.at(1) * ts)
+ },
+ tidx: tidx,
+ tscale: ts,
+ ))
+ )
+ let root-w = _tree-w(tree)
+ let root-x = x-cursor + root-w / 2 * ts
+ let root-nname = _node-name("root", tidx)
+ // Record segment label position
+ if seg != none {
+ seg-labels = seg-labels + ((root-x, 0.0, ts, seg, root-nname, tree-timing.len() > 0),)
+ }
+ // Record timing tier data
+ if tree-timing.len() > 0 {
+ timing-data = timing-data + ((root-x, ts, tree-timing),)
+ }
+ x-cursor = x-cursor + _tree-w(tree) * ts + gap
+ }
+ let all-nodes = _apply-positions(all-nodes, position, true)
+
+ // ── Render ────────────────────────────────────────────────────────────
+ let _loff = 0.20
+ let _dim = luma(65%)
+ let _norm = luma(15%)
+ let _nc = nname => if highlight.len() == 0 or nname in highlight { _norm } else { _dim }
+
+ let y-min-g = all-nodes.fold(0.0, (acc, e) => calc.min(acc, e.y))
+
+ // Build name → (x, y, loff) lookup OUTSIDE the canvas block.
+ // dict.insert() inside CeTZ's canvas block may not behave correctly
+ // because CeTZ processes drawing commands in a special context.
+ let name-to-pos = (:)
+ for e in all-nodes {
+ name-to-pos.insert(_node-name(e.label, e.tidx), (e.x, e.y, _loff * e.tscale))
+ }
+
+ context {
+ let em-in-cu-g = text.size / (scale-factor * 1cm)
+ let _timing-gap = 0.65
+ let _timing-y = 0.55 + _timing-gap
+ let _t-spacing = 0.55
+ // y-top-g: highest point across all trees (timing + segment labels)
+ let y-top-g = {
+ let timing-tops = timing-data.map(td => {
+ let (rx, ts, tt) = td
+ let has-seg = seg-labels.any(sl => calc.abs(sl.at(0) - rx) < 0.001)
+ if has-seg {
+ (_timing-y + 0.45 + text.size * 0.84 / (scale-factor * 1cm)) * ts
+ } else {
+ (_timing-y + text.size * 0.35 / (scale-factor * 1cm)) * ts
+ }
+ })
+ let seg-tops = seg-labels
+ .filter(sl => not sl.at(5))
+ .map(sl => 0.55 * sl.at(2) + text.size * 0.84 / (scale-factor * 1cm))
+ let all-tops = timing-tops + seg-tops
+ if all-tops.len() > 0 {
+ all-tops.fold(text.size * 0.35 / (scale-factor * 1cm), calc.max)
+ } else {
+ text.size * 0.35 / (scale-factor * 1cm)
+ }
+ }
+ let bl-g = (em-in-cu-g + (-y-min-g)) / (2 * em-in-cu-g + y-top-g - y-min-g)
+ let fsz = text.size * 0.70 * scale-factor
+ let font = phonokit-font.get()
+
+ box(inset: 1em * scale-factor, baseline: bl-g * 100%, {
+ cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ // Draw all tree nodes
+ for entry in all-nodes {
+ let nname = _node-name(entry.label, entry.tidx)
+ let ts = entry.tscale
+ let efsz = fsz * ts
+ let eloff = _loff * ts
+ let nc = _nc(nname)
+
+ if entry.par != none {
+ let (px, py) = entry.par
+ let (fx, fy) = (px, py - eloff)
+ let (tx, ty) = (entry.x, entry.y + eloff)
+ let par-entry = all-nodes.find(e => e.x == px and e.y == py)
+ let par-nname = if par-entry != none { _node-name(par-entry.label, par-entry.tidx) } else { none }
+ let both-highlighted = (
+ highlight.len() > 0 and nname in highlight and par-nname != none and par-nname in highlight
+ )
+ let line-paint = if highlight.len() == 0 or both-highlighted { _norm } else { _dim }
+ let sw = (paint: line-paint, thickness: 0.016)
+ line((fx, fy), (tx, ty), stroke: sw)
+ if nname in delinks { _draw-delink(fx, fy, tx, ty, sw) }
+ }
+
+ if entry.kind == "root" {
+ content(
+ (entry.x, entry.y),
+ text(font: font, size: efsz, fill: nc, ui-geom-label("root", ui-locale)),
+ name: nname,
+ )
+ if entry.feats.len() > 0 {
+ let mat-x = entry.x + 0.25 * ts
+ let items = entry.feats.map(f => {
+ let norm = f.replace("-", "−")
+ let (sign, base) = if norm.starts-with("±") { ("±", norm.slice("±".len())) } else if norm.starts-with(
+ "+",
+ ) { ("+", norm.slice(1)) } else if norm.starts-with("−") { ("−", norm.slice("−".len())) } else {
+ ("", norm)
+ }
+ text(font: font, fill: nc, if sign != "" { box(width: 0.65em, align(center, sign)) + base } else {
+ norm
+ })
+ })
+ content(
+ (mat-x, entry.y),
+ {
+ set text(font: font, size: efsz, fill: nc)
+ box(baseline: 50%, math.vec(
+ align: left,
+ delim: "[",
+ gap: 0pt,
+ ..items,
+ ))
+ },
+ anchor: "west",
+ )
+ }
+ } else {
+ let inner = if entry.kind == "feature" {
+ [\[#(_display(entry.label, ui-lang: ui-locale))\]]
+ } else {
+ [#(_display(entry.label, ui-lang: ui-locale))]
+ }
+ content(
+ (entry.x, entry.y),
+ text(font: font, size: efsz, fill: nc, inner),
+ name: nname,
+ )
+ }
+ }
+
+ // Segment labels — always full colour (never dimmed by highlight)
+ for (sx, sy, ts, seg, root-nname, has-timing) in seg-labels {
+ let label-y = sy + (if has-timing { (_timing-y + 0.45) * ts } else { 0.55 * ts })
+ let seg-body = _seg(seg)
+ content(
+ (sx, label-y),
+ text(font: font, size: fsz * ts * 1.2, fill: _norm, seg-body),
+ anchor: "south",
+ )
+ }
+
+ // Timing tiers
+ let t-paint = if highlight.len() == 0 { _norm } else { _dim }
+ for (rx, ts, tt) in timing-data {
+ let ry = 0.0
+ let t-y = ry + _timing-y * ts
+ let n = tt.len()
+ let loff = 0.20 * ts
+ for (i, t) in tt.enumerate() {
+ let tx = if n == 1 { rx } else { rx + (i - (n - 1) / 2.0) * _t-spacing * ts }
+ line(
+ (rx, ry + loff),
+ (tx, t-y - loff),
+ stroke: (paint: t-paint, thickness: 0.016),
+ )
+ content((tx, t-y), text(font: font, size: fsz * ts, fill: t-paint, t))
+ }
+ }
+
+ // Draw cross-tree arrows.
+ // Shaft is dashed; head is a separate solid segment so the arrowhead
+ // contour is never dashed (same technique as #vowels).
+ // When curved: quadratic bézier with obstacle avoidance (same algorithm as vowels.typ).
+ let head-back = 0.12 // canvas units of solid segment before the tip
+ let clearance = 0.45 // obstacle avoidance radius (canvas units)
+ let sample-ts = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9)
+ let obs-pts = all-nodes.map(e => (e.x, e.y)) // all node centres
+
+ for arrow in arrows {
+ // Accept both positional arrays ("from", "to") / ("from", "to", color)
+ // and dicts (from: "...", to: "...", color: ..., ctrl: ...).
+ let is-dict = type(arrow) == dictionary
+ let from-name = if is-dict { arrow.at("from") } else { arrow.at(0) }
+ let to-name = if is-dict { arrow.at("to") } else { arrow.at(1) }
+ let paint = if is-dict { arrow.at("color", default: luma(15%)) } else if arrow.len() >= 3 {
+ arrow.at(2)
+ } else { luma(15%) }
+ // ctrl: two-element array (lift1, lift2) — Y-offsets from each endpoint.
+ // When set, bypasses curved entirely.
+ let ctrl-val = if is-dict { arrow.at("ctrl", default: none) } else { none }
+
+ 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)
+ // Dim arrow if neither endpoint is highlighted (when highlight is active).
+ let arrow-lit = highlight.len() == 0 or from-name in highlight or to-name in highlight
+ let paint = if arrow-lit { paint } else { _dim }
+ // Arrows connect to the TOP of each node (where parent lines terminate),
+ // EXCEPT for root nodes which have no parent — they connect at the BOTTOM
+ // (facing their children) to avoid landing near the segment label above.
+ let fy = if from-name.starts-with("root") { fy - f-loff } else { fy + f-loff }
+ let ty = ty - t-loff
+ let dx = tx - fx
+ let dy = ty - fy
+ let len = calc.sqrt(dx * dx + dy * dy)
+
+ // ── Control points ─────────────────────────────────────────────
+ // Priority: ctrl > curved.
+ // ctrl: explicit Y-offsets from each endpoint → cubic Bézier, any shape.
+ // curved: auto S-curve scaled by distance.
+ let (ctrl1, ctrl2) = if ctrl-val != none {
+ // ctrl: (lift1, lift2) — Y-offsets from each endpoint.
+ (
+ (fx + dx * 0.30, fy + ctrl-val.at(0)),
+ (tx - dx * 0.30, ty + ctrl-val.at(1)),
+ )
+ } else {
+ if curved and len > 0 {
+ let v-reach = calc.abs(dy)
+ let h-reach = calc.abs(dx)
+ // ctrl1: departs with upward bias, scaling with whichever reach dominates.
+ let lift1 = calc.max(v-reach * 0.50, h-reach * 0.20, 0.50)
+ // ctrl2: arrives from below target — dip scales with vertical distance.
+ let dip2 = calc.max(v-reach * 0.25, 0.40)
+ (
+ (fx + dx * 0.30, fy + lift1),
+ (tx - dx * 0.10, ty - dip2),
+ )
+ } else { (none, none) }
+ }
+
+ // ── Tangent at tip & pullback ───────────────────────────────────
+ let (tang-x, tang-y) = if ctrl2 != none {
+ let ex = tx - ctrl2.at(0)
+ let ey = ty - ctrl2.at(1)
+ let ed = calc.sqrt(ex * ex + ey * ey)
+ if ed > 0 { (ex / ed, ey / ed) } else { (dx / len, dy / len) }
+ } 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
+
+ let shaft-stroke = (paint: paint, thickness: 0.018, dash: "dashed")
+ let head-stroke = (paint: paint, thickness: 0.018)
+ let mark-style = (end: ">", fill: paint, scale: 0.5)
+
+ // Dashed shaft
+ if ctrl1 != none {
+ bezier((fx, fy), (hax, hay), ctrl1, ctrl2, stroke: shaft-stroke)
+ } else {
+ line((fx, fy), (hax, hay), stroke: shaft-stroke)
+ }
+ // Solid arrowhead
+ let tiny = 0.01
+ line(
+ (hax - tang-x * tiny, hay - tang-y * tiny),
+ (tx, ty),
+ stroke: head-stroke,
+ mark: mark-style,
+ )
+ }
+ }
+ })
+ })
+ } // context
+}
diff --git a/packages/preview/phonokit/0.5.11/grids.typ b/packages/preview/phonokit/0.5.11/grids.typ
new file mode 100644
index 0000000000..0009963c28
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/grids.typ
@@ -0,0 +1,114 @@
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+
+// Helper function to parse string-based input like "te2.ne1.see3"
+#let parse-grid-string(input) = {
+ let units = input.split(".")
+ let parsed = ()
+
+ for unit in units {
+ if unit.len() > 0 {
+ // Extract the last character as the level
+ let level-str = unit.at(unit.len() - 1)
+ let level = int(level-str)
+
+ // Extract everything except the last character as the syllable
+ let syllable = unit.slice(0, unit.len() - 1)
+
+ parsed.push((text: syllable, level: level))
+ }
+ }
+
+ parsed
+}
+
+// Main metrical grid function - creates metrical grid representations
+// Supports two input formats:
+//
+// 1. String format (simple, not IPA-compatible):
+// met-grid("te2.ne1.see3.Ti3.tans1")
+// Format: syllable + level number, separated by dots
+//
+// 2. Array format (IPA-compatible):
+// met-grid(("te", 2), ("ne", 1), ("see", 3))
+// met-grid(("te", 2), ("ne", 1), ("see", 3), ipa: true) // Auto-converts strings to IPA
+// Format: array of (content, level) tuples
+//
+#let met-grid(..args, ipa: true) = {
+ let data = ()
+
+ // Determine input format
+ if args.pos().len() == 1 and type(args.pos().at(0)) == str {
+ // String format: "te2.ne1.see3"
+ data = parse-grid-string(args.pos().at(0))
+ } else {
+ // Array format: ("te", 2), ("ne", 1), ...
+ for arg in args.pos() {
+ if type(arg) == array and arg.len() == 2 {
+ let text-content = arg.at(0)
+ let level = arg.at(1)
+
+ // If ipa mode is enabled and text-content is a string, convert it
+ if ipa and type(text-content) == str {
+ text-content = context text(font: phonokit-font.get(), ipa-to-unicode(text-content))
+ }
+
+ data.push((text: text-content, level: level))
+ } else {
+ return text(fill: red, weight: "bold")[⚠ Error: Each argument must be a (text, level) tuple]
+ }
+ }
+ }
+
+ if data.len() == 0 {
+ return text(fill: red, weight: "bold")[⚠ Error: No data to display]
+ }
+
+ // Find maximum level to determine number of rows
+ let max-level = 0
+ for item in data {
+ if item.level > max-level {
+ max-level = item.level
+ }
+ }
+
+ // Build the table rows from top to bottom
+ let rows = ()
+
+ // Create rows for each stress level (from highest to lowest)
+ for level in range(max-level, 0, step: -1) {
+ let row = ()
+ for item in data {
+ if item.level >= level {
+ row.push($times$)
+ } else {
+ row.push([])
+ }
+ }
+ rows.push(row)
+ }
+
+ // Add the syllable row at the bottom
+ let syllable-row = ()
+ for item in data {
+ if type(item.text) == str {
+ syllable-row.push(context text(font: phonokit-font.get(), item.text))
+ } else {
+ syllable-row.push(item.text)
+ }
+ }
+ rows.push(syllable-row)
+
+ // Create table with no borders, wrapped in box for inline placement
+ // baseline: 50% centers the grid vertically with text baseline
+ box(
+ baseline: 50%,
+ table(
+ columns: data.len(),
+ stroke: none,
+ align: center,
+ inset: 8pt,
+ ..rows.flatten()
+ )
+ )
+}
diff --git a/packages/preview/phonokit/0.5.11/hasse.typ b/packages/preview/phonokit/0.5.11/hasse.typ
new file mode 100644
index 0000000000..544bc82331
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/hasse.typ
@@ -0,0 +1,460 @@
+// Hasse diagram visualization for OT constraint rankings
+// Part of phonokit package
+
+#import "@preview/cetz:0.5.2"
+#import "_config.typ": phonokit-font
+
+/// Create a Hasse diagram for Optimality Theory constraint rankings
+///
+/// A Hasse diagram represents the partial order of constraint rankings,
+/// showing only minimal domination relationships (transitive reduction).
+/// Constraints higher in the diagram dominate those lower.
+///
+/// Features:
+/// - Supports partial orders (not all constraints need to be ranked)
+/// - Handles floating constraints with no ranking relationships
+/// - Automatically computes transitive reduction
+/// - Auto-scales for complex hierarchies
+///
+/// Arguments:
+/// - rankings (array): Array of tuples representing rankings:
+/// - Three-element tuple `(A, B, level)` means A dominates B, and A is at stratum `level` (REQUIRED)
+/// - Four-element tuple `(A, B, level, style)` means A dominates B, A at stratum `level`, with line `style`
+/// - Single-element tuple `(A,)` means A is floating (no ranking)
+/// - Line styles: "solid" (default), "dashed", "dotted"
+/// - Note: Level specification is REQUIRED for all edges to ensure proper stratification
+/// - scale (number or auto): Scale factor for diagram (default: auto-scales based on complexity)
+/// - node-spacing (number): Horizontal spacing between nodes (default: 2.5)
+/// - level-spacing (number): Vertical spacing between levels (default: 1.5)
+///
+/// Returns: A Hasse diagram showing the constraint hierarchy
+///
+/// Example:
+/// ```
+/// #hasse(
+/// (
+/// ("Onset", "NoCoda", 0),
+/// ("Onset", "Dep", 0),
+/// ("Max", "Dep", 0),
+/// ("Max", "NoCoda", 0),
+/// ("Faith",) // floating constraint
+/// )
+/// )
+///
+/// // With line styles
+/// #hasse(
+/// (
+/// ("Ident[F]", "Agree[place]", 0, "dashed"), // Level 0, dashed line
+/// ("Dep", "Agree[vce]", 0), // Level 0, solid line (default)
+/// ("Max", "Dep", 1, "dotted"), // Level 1, dotted line
+/// )
+/// )
+/// ```
+#let hasse(
+ rankings,
+ scale: auto,
+ node-spacing: 2.5,
+ level-spacing: 1.5,
+) = {
+ // Validate input
+ assert(type(rankings) == array, message: "rankings must be an array of tuples")
+ assert(rankings.len() > 0, message: "rankings array cannot be empty")
+
+ // Extract all constraints and build graph structure
+ let all-constraints = ()
+ let edges = () // (from, to) pairs
+ let floating = ()
+ let user-specified-levels = (:) // Track user-specified levels
+ let edge-styles = (:) // Track line styles for edges
+
+ for ranking in rankings {
+ assert(type(ranking) == array, message: "Each ranking must be a tuple (array)")
+
+ if ranking.len() == 1 {
+ // Floating constraint
+ let constraint = ranking.at(0)
+ if constraint not in floating {
+ floating.push(constraint)
+ }
+ if constraint not in all-constraints {
+ all-constraints.push(constraint)
+ }
+ } else if ranking.len() >= 3 and ranking.len() <= 4 {
+ // Domination relationship (level specification is REQUIRED)
+ let from = ranking.at(0)
+ let to = ranking.at(1)
+ let level = ranking.at(2)
+ let style = "solid"
+
+ // Validate that level is a number
+ assert(type(level) == int or type(level) == float, message: "Third element must be a level (number). Use (A, B, level) or (A, B, level, style)")
+
+ if ranking.len() == 4 {
+ // Fourth element is style
+ style = ranking.at(3)
+ assert(type(style) == str, message: "Fourth element must be a line style string")
+ // Validate style
+ assert(style in ("solid", "dashed", "dotted"), message: "Line style must be 'solid', 'dashed', or 'dotted'")
+ }
+
+ if (from, to) not in edges {
+ edges.push((from, to))
+ }
+ if from not in all-constraints {
+ all-constraints.push(from)
+ }
+ if to not in all-constraints {
+ all-constraints.push(to)
+ }
+
+ // Store user-specified level
+ user-specified-levels.insert(from, level)
+
+ // Store edge style
+ edge-styles.insert(from + "->" + to, style)
+ } else {
+ assert(false, message: "Each ranking tuple must have 1 (floating), 3 (with level), or 4 (with level and style) elements")
+ }
+ }
+
+ // Compute transitive reduction (remove redundant edges)
+ // For each edge (A, C), check if there's a path A -> B -> C
+ let reduced-edges = ()
+ for edge in edges {
+ let (from, to) = edge
+ let is-redundant = false
+
+ // Check if there's an intermediate node B such that (from, B) and (B, to) exist
+ for potential-middle in all-constraints {
+ if potential-middle != from and potential-middle != to {
+ let has-first = (from, potential-middle) in edges
+ let has-second = (potential-middle, to) in edges
+ if has-first and has-second {
+ is-redundant = true
+ break
+ }
+ }
+ }
+
+ if not is-redundant {
+ reduced-edges.push(edge)
+ }
+ }
+
+ // Compute levels using topological sort
+ // Start with user-specified levels
+ let constraint-levels = user-specified-levels
+
+ // Level 0 = constraints with no incoming edges (top-ranked)
+ // Level k = constraints whose dominators are all at level < k
+ let unassigned = all-constraints.filter(c => c not in constraint-levels.keys())
+ let current-level = 0
+
+ while unassigned.len() > 0 {
+ let assigned-this-round = ()
+
+ for constraint in unassigned {
+ // Check if all dominators (if any) are already assigned
+ let dominators = reduced-edges.filter(e => e.at(1) == constraint).map(e => e.at(0))
+
+ if dominators.len() == 0 {
+ // No dominators, can be assigned to current level
+ constraint-levels.insert(constraint, current-level)
+ assigned-this-round.push(constraint)
+ } else {
+ // Check if all dominators are assigned
+ let all-assigned = dominators.all(d => d in constraint-levels.keys())
+ if all-assigned {
+ // Assign to one level below the maximum dominator level
+ let max-dom-level = calc.max(..dominators.map(d => constraint-levels.at(d)))
+ constraint-levels.insert(constraint, max-dom-level + 1)
+ assigned-this-round.push(constraint)
+ }
+ }
+ }
+
+ // Remove assigned constraints
+ unassigned = unassigned.filter(c => c not in assigned-this-round)
+ current-level += 1
+
+ // Safety check to prevent infinite loop
+ if current-level > all-constraints.len() {
+ break
+ }
+ }
+
+ // Assign floating constraints to the bottom
+ if floating.len() > 0 {
+ let max-level = if constraint-levels.len() > 0 {
+ calc.max(..constraint-levels.values())
+ } else {
+ -1
+ }
+ for fc in floating {
+ constraint-levels.insert(fc, max-level + 1)
+ }
+ }
+
+ // Group constraints by level
+ let max-level-num = if constraint-levels.len() > 0 {
+ calc.max(..constraint-levels.values())
+ } else {
+ 0
+ }
+
+ let levels = range(max-level-num + 1).map(i => ())
+ for (constraint, level) in constraint-levels {
+ levels.at(level).push(constraint)
+ }
+
+ // Minimize edge crossings using barycenter heuristic
+ // This reorders constraints at each level to reduce visual clutter
+ for iteration in range(3) {
+ // Multiple passes improve layout
+ // Top-down pass: order by average position of parents
+ for level-idx in range(1, levels.len()) {
+ let level-constraints = levels.at(level-idx)
+
+ // Calculate barycenter (average parent position) for each constraint
+ let constraint-barycenters = ()
+ for constraint in level-constraints {
+ let parents = reduced-edges.filter(e => e.at(1) == constraint).map(e => e.at(0))
+
+ if parents.len() > 0 {
+ // Find parent indices in previous level
+ let parent-indices = parents.map(p => {
+ let idx = levels.at(level-idx - 1).position(c => c == p)
+ if idx == none { 0 } else { idx }
+ })
+ let barycenter = parent-indices.sum() / parents.len()
+ constraint-barycenters.push((constraint, barycenter))
+ } else {
+ // No parents, keep original position
+ let original-idx = level-constraints.position(c => c == constraint)
+ constraint-barycenters.push((constraint, original-idx))
+ }
+ }
+
+ // Sort by barycenter
+ constraint-barycenters = constraint-barycenters.sorted(key: item => item.at(1))
+ levels.at(level-idx) = constraint-barycenters.map(item => item.at(0))
+ }
+
+ // Bottom-up pass: order by average position of children
+ for level-idx in range(levels.len() - 1).rev() {
+ let level-constraints = levels.at(level-idx)
+
+ // Calculate barycenter (average child position) for each constraint
+ let constraint-barycenters = ()
+ for constraint in level-constraints {
+ let children = reduced-edges.filter(e => e.at(0) == constraint).map(e => e.at(1))
+
+ if children.len() > 0 {
+ // Find child indices in next level
+ let child-indices = children.map(c => {
+ let idx = levels.at(level-idx + 1).position(ch => ch == c)
+ if idx == none { 0 } else { idx }
+ })
+ let barycenter = child-indices.sum() / children.len()
+ constraint-barycenters.push((constraint, barycenter))
+ } else {
+ // No children, keep original position
+ let original-idx = level-constraints.position(c => c == constraint)
+ constraint-barycenters.push((constraint, original-idx))
+ }
+ }
+
+ // Sort by barycenter
+ constraint-barycenters = constraint-barycenters.sorted(key: item => item.at(1))
+ levels.at(level-idx) = constraint-barycenters.map(item => item.at(0))
+ }
+ }
+
+ // Determine scale
+ let scale-factor = if scale == auto {
+ if all-constraints.len() > 8 {
+ 0.7
+ } else if all-constraints.len() > 5 {
+ 0.85
+ } else {
+ 1.0
+ }
+ } else {
+ scale
+ }
+
+ // Adjust node spacing based on constraint name lengths to prevent overlap
+ // Estimate text width: roughly 0.12 units per character in smallcaps at 10pt
+ let max-constraint-length = calc.max(..all-constraints.map(c => c.len()))
+ let estimated-width = max-constraint-length * 0.12 * scale-factor
+ let min-spacing = estimated-width + 0.4 // Add padding
+ let adjusted-node-spacing = calc.max(node-spacing, min-spacing)
+
+ // Calculate layout positions for ranked constraints
+ let positions = (:)
+ for (level-idx, level-constraints) in levels.enumerate() {
+ let n = level-constraints.len()
+ let total-width = (n - 1) * adjusted-node-spacing
+ let start-x = -total-width / 2
+
+ for (i, constraint) in level-constraints.enumerate() {
+ let x = start-x + i * adjusted-node-spacing
+ let y = -level-idx * level-spacing
+ positions.insert(constraint, (x, y))
+ }
+ }
+
+ // Align one-to-one chains vertically for straight lines
+ // If a constraint has exactly one parent and that parent has exactly one child,
+ // align them vertically
+ for (constraint, pos) in positions {
+ let parents = reduced-edges.filter(e => e.at(1) == constraint).map(e => e.at(0))
+
+ if parents.len() == 1 {
+ let parent = parents.at(0)
+ let parent-children = reduced-edges.filter(e => e.at(0) == parent).map(e => e.at(1))
+
+ if parent-children.len() == 1 and parent in positions {
+ // One-to-one relationship: align x-coordinates
+ let parent-pos = positions.at(parent)
+ let (parent-x, parent-y) = parent-pos
+ let (child-x, child-y) = pos
+
+ // Update child's x to match parent's x
+ positions.insert(constraint, (parent-x, child-y))
+ }
+ }
+ // Note: For constraints with multiple parents, the barycenter heuristic
+ // already positioned them optimally to minimize crossings. Don't override.
+ }
+
+ // Re-center the diagram for symmetry
+ // Calculate the average x position and shift to center at 0
+ if positions.len() > 0 {
+ let all-x = positions.values().map(pos => pos.at(0))
+ let min-x = calc.min(..all-x)
+ let max-x = calc.max(..all-x)
+ let center-offset = -(min-x + max-x) / 2
+
+ // Apply centering offset
+ let centered-positions = (:)
+ for (constraint, pos) in positions {
+ let (x, y) = pos
+ centered-positions.insert(constraint, (x + center-offset, y))
+ }
+ positions = centered-positions
+ }
+
+ // Draw the diagram
+ box(inset: 1.2em, baseline: 40%, cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ // Draw edges first (so they appear behind nodes)
+ for edge in reduced-edges {
+ let (from, to) = edge
+ if from in positions and to in positions {
+ let (x1, y1) = positions.at(from)
+ let (x2, y2) = positions.at(to)
+
+ // Get line style for this edge
+ let edge-key = from + "->" + to
+ let line-style = if edge-key in edge-styles {
+ edge-styles.at(edge-key)
+ } else {
+ "solid"
+ }
+
+ // Scale stroke width with scale factor
+ let stroke-width = 0.8pt * scale-factor
+
+ // Create stroke based on style
+ let stroke-style = if line-style == "dashed" {
+ (paint: black, thickness: stroke-width, dash: "dashed")
+ } else if line-style == "dotted" {
+ (paint: black, thickness: stroke-width, dash: "dotted")
+ } else {
+ stroke-width + black
+ }
+
+ // Draw line
+ line((x1, y1), (x2, y2), stroke: stroke-style)
+
+ // Draw arrow head (also scaled)
+ let arrow-size = 0.15 * scale-factor
+ let dx = x2 - x1
+ let dy = y2 - y1
+ let length = calc.sqrt(dx * dx + dy * dy)
+ let ux = dx / length
+ let uy = dy / length
+
+ // Arrow at destination (always solid)
+ let arrow-x = x2 - uy * arrow-size
+ let arrow-y = y2 + ux * arrow-size
+ line((x2, y2), (arrow-x, arrow-y), stroke: stroke-width + black)
+
+ arrow-x = x2 + uy * arrow-size
+ arrow-y = y2 - ux * arrow-size
+ line((x2, y2), (arrow-x, arrow-y), stroke: stroke-width + black)
+ }
+ }
+
+ // Draw white rectangles behind constraint names (to create whitespace)
+ for (constraint, pos) in positions {
+ let (x, y) = pos
+ // Estimate text dimensions based on constraint name length
+ let name-length = constraint.len()
+ // Width: roughly 0.22 units per character (generous to ensure full coverage)
+ let text-width = name-length * 0.22 * scale-factor
+ // Height: based on font size (increased to cover actual rendered height)
+ let text-height = 0.5 * scale-factor
+ // Add padding
+ let padding = 0.50
+ let rect-width = text-width + padding
+ let rect-height = text-height + padding
+
+ // Draw rounded rectangle centered at constraint position
+ rect(
+ (x - rect-width / 2, y - rect-height / 2),
+ (x + rect-width / 2, y + rect-height / 2),
+ fill: white,
+ stroke: none,
+ radius: 0.6,
+ )
+ }
+
+ // Draw constraint names in smallcaps (but not text inside brackets)
+ for (constraint, pos) in positions {
+ let (x, y) = pos
+
+ // Parse constraint name to apply smallcaps only outside brackets
+ let formatted-name = {
+ // Match pattern: text before bracket, bracket content, text after
+ let bracket-match = constraint.match(regex("^([^\[]*)\[([^\]]*)\](.*)$"))
+
+ if bracket-match != none {
+ // Has brackets: smallcaps before, regular inside brackets, smallcaps after
+ let before = bracket-match.captures.at(0)
+ let inside = bracket-match.captures.at(1)
+ let after = bracket-match.captures.at(2)
+
+ // Compose the parts using content block
+ [#smallcaps(before)\[#inside\]#if after != "" { smallcaps(after) }]
+ } else {
+ // No brackets: all smallcaps
+ smallcaps(constraint)
+ }
+ }
+
+ content(
+ (x, y),
+ padding: 0.1,
+ anchor: "center",
+ context text(
+ font: phonokit-font.get(),
+ size: 10pt * scale-factor,
+ formatted-name,
+ ),
+ )
+ }
+ }))
+}
diff --git a/packages/preview/phonokit/0.5.11/intonational.typ b/packages/preview/phonokit/0.5.11/intonational.typ
new file mode 100644
index 0000000000..4d4dd2552f
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/intonational.typ
@@ -0,0 +1,80 @@
+#import "_config.typ": phonokit-font
+
+/// Place a ToBI label above the current inline text position
+///
+/// Designed to be placed inline immediately after the syllable or word being annotated.
+/// The label floats above the text at the insertion point, optionally connected by
+/// a vertical stem.
+///
+/// Always pass labels as *strings* (e.g. `"H*"`), not bare content (`[H*]`), since
+/// characters like `*` and `_` have special meaning in Typst markup. Use ASCII
+/// hyphens for phrase accents (e.g. `"L-"`, `"L-H%"`); en/em dashes are
+/// automatically normalized to hyphens. Double hyphens are converted to superscript "-".
+///
+/// Arguments:
+/// - label (string): ToBI label
+/// - line (boolean): draw a vertical stem connecting label to text (default: true)
+/// - height (length): distance from text baseline to the bottom of the label (default: 1.8em)
+/// - lift (length): gap between text baseline and stem bottom (default: 0.6em)
+/// - gap (length): gap between stem top and label bottom (default: 0.15em)
+/// - en-dash (boolean): render phrase-accent hyphens as en dashes (default: false)
+///
+/// Example:
+/// ```
+/// You're a were#int("*L")wolf?#h(1em)#int("H%", line: false)
+
+/// ```
+#let int(
+ label,
+ line: true,
+ height: 2em,
+ lift: 0.8em,
+ gap: 0.22em,
+ en-dash: true,
+) = context {
+ // Normalize all dash variants to ASCII hyphen, then output in the desired
+ // form. U+2011 (non-breaking hyphen, class GL) never breaks. For en-dash
+ // rendering, U+2060 (word joiner, class WJ) prepended to U+2013 suppresses
+ // any break opportunity around the en dash.
+ let single-dash = s => if en-dash { s.replace("-", "\u{2060}\u{2013}\u{2060}") } else { s.replace("-", "\u{2011}") }
+ let normalize-str = s => single-dash(
+ s.replace("\u{2011}", "-").replace("\u{2013}", "-").replace("\u{2014}", "-"),
+ )
+ // "--" becomes a superscript en-dash (Zsiga-style phrase accent).
+ // Normalize other dashes first, then split on "--" before the
+ // single-dash replacement so the two hyphens aren't individually
+ // converted. Use a for loop to build content instead of .join()
+ // to avoid content-separator ambiguity.
+ let lbl-text = if type(label) == str {
+ let base = label.replace("\u{2011}", "-").replace("\u{2013}", "-").replace("\u{2014}", "-")
+ if base.contains("--") {
+ let parts = base.split("--").map(single-dash)
+ text(font: phonokit-font.get(), size: 0.8em, {
+ parts.at(0)
+ for i in range(1, parts.len()) {
+ super(size: 0.90em, [–])
+ parts.at(i)
+ }
+ })
+ } else {
+ text(font: phonokit-font.get(), size: 0.8em, normalize-str(label))
+ }
+ } else {
+ text(font: phonokit-font.get(), size: 0.8em, label)
+ }
+ let lbl-w = measure(lbl-text).width
+ let lbl-h = measure(lbl-text).height
+ let lbl = box(width: lbl-w, lbl-text)
+ // baseline: 0pt places the box bottom at the text baseline,
+ // so the box extends upward to reserve space for the annotation.
+ // place() positions elements absolutely so surrounding alignment cannot shift them.
+ let stem-h = height - lift - gap
+ box(width: 0pt, height: height + lbl-h, baseline: 0pt, {
+ // Stem (anchored at bottom-left, offset upward by lift)
+ if line {
+ place(bottom + left, dy: -lift, rect(width: 0.05em, height: stem-h, fill: black, stroke: none))
+ }
+ // Label (anchored at top, centered horizontally via dx)
+ place(top + left, dx: -lbl-w / 2, lbl)
+ })
+}
diff --git a/packages/preview/phonokit/0.5.11/ipa.typ b/packages/preview/phonokit/0.5.11/ipa.typ
new file mode 100644
index 0000000000..e3fbc65f3c
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/ipa.typ
@@ -0,0 +1,412 @@
+// Convert tipa-style notation to IPA Unicode (without font styling)
+// This is exported separately so other modules can use the conversion logic
+#let ipa-to-unicode(input) = {
+ // Define TIPA to IPA mappings
+ let mappings = (
+ // CONSONANTS - Plosives
+ "p": "p",
+ "b": "b",
+ "t": "t",
+ "d": "d",
+ "\\:t": "ʈ",
+ "\\:d": "ɖ",
+ "\\textbardotlessj": "ɟ",
+ "\\barredj": "ɟ",
+ "c": "c",
+ "k": "k",
+ "g": "ɡ",
+ "q": "q",
+ "\\;G": "ɢ",
+ "?": "ʔ",
+ "P": "ʔ",
+ // CONSONANTS - Nasals
+ "m": "m",
+ "M": "ɱ",
+ "n": "n",
+ "\\:n": "ɳ",
+ "\\textltailn": "ɲ",
+ "N": "ŋ",
+ "\\;N": "ɴ",
+ "\\nh": "ɲ",
+ // CONSONANTS - Trills
+ "\\;B": "ʙ",
+ "r": "r",
+ "\\;R": "ʀ",
+ // CONSONANTS - Tap or Flap
+ "R": "ɾ",
+ "\\:r": "ɽ",
+ // CONSONANTS - Fricatives
+ "f": "f",
+ "v": "v",
+ "F": "ɸ",
+ "B": "β",
+ "T": "θ",
+ "D": "ð",
+ "s": "s",
+ "z": "z",
+ "S": "ʃ",
+ "Z": "ʒ",
+ "\\:s": "ʂ",
+ "\\:z": "ʐ",
+ "\\c{c}": "ç",
+ "C": "ç",
+ "J": "ʝ",
+ "x": "x",
+ "G": "ɣ",
+ "X": "χ",
+ "K": "ʁ",
+ "\\textcrh": "ħ",
+ "\\barredh": "ħ",
+ "Q": "ʕ",
+ "h": "h",
+ "H": "ɦ",
+ // CONSONANTS - Lateral Fricatives
+ "\\textbeltl": "ɬ",
+ "\\textlyoghlig": "ɮ",
+ "\\l3": "ɮ",
+ // CONSONANTS - Approximants
+ "V": "ʋ",
+ "\\*r": "ɹ",
+ "j": "j",
+ "\\textturnmrleg": "ɰ",
+ "\\mw": "ɰ",
+ "\\:R": "ɻ",
+ // CONSONANTS - Lateral Approximants
+ "l": "l",
+ "\\:l": "ɭ",
+ "L": "ʎ",
+ "\\;L": "ʟ",
+ // CONSONANTS - Velarized l
+ "\\darkl": "ɫ",
+ // OTHER CONSONANTS - Clicks
+ "\\!o": "ʘ",
+ "\\textdoublebarpipe": "ǂ",
+ "\\doublebarpipe": "ǂ",
+ "||": "ǁ",
+ // OTHER CONSONANTS - Other
+ "\\textbarglotstop": "ʡ",
+ "\\barredP": "ʡ",
+ // OTHER CONSONANTS - Implosives
+ "\\!b": "ɓ",
+ "\\!d": "ɗ",
+ "\\!j": "ʄ",
+ "\\!g": "ɠ",
+ "\\!G": "ʛ",
+ // OTHER CONSONANTS - Additional Fricatives
+ "\\*w": "ʍ",
+ "\\texththeng": "ɧ",
+ "\\;H": "ʜ",
+ "\\textctz": "ʑ",
+ "\\textbarrevglotstop": "ʢ",
+ "\\barrevglotstop": "ʢ",
+ // OTHER CONSONANTS - Approximant/Flap
+ "\\textturnlonglegr": "ɺ",
+ "\\turnlonglegr": "ɺ",
+ // VOWELS - Close
+ "i": "i",
+ "I": "ɪ",
+ "y": "y",
+ "Y": "ʏ",
+ "1": "ɨ",
+ "0": "ʉ",
+ "W": "ɯ",
+ "u": "u",
+ "U": "ʊ",
+ // VOWELS - Close-mid/Mid
+ "e": "e",
+ "\\o": "ø",
+ "9": "ɘ",
+ "8": "ɵ",
+ "7": "ɤ",
+ "o": "o",
+ // VOWELS - Mid
+ "@": "ə",
+ // VOWELS - Open-mid
+ "E": "ɛ",
+ "\\oe": "œ",
+ "3": "ɜ",
+ "\\textcloseepsilon": "ɞ",
+ "\\closeepsilon": "ɞ",
+ "2": "ʌ",
+ "O": "ɔ",
+ // VOWELS - Near-open/Open
+ "\\ae": "æ",
+ "\\OE": "ɶ",
+ "a": "a",
+ "5": "ɐ",
+ "A": "ɑ",
+ "6": "ɒ",
+ "\\schwar": "ɚ",
+ "\\epsilonr": "ɝ",
+ // SUPRASEGMENTALS
+ "'": "ˈ", // primary stress
+ ",": "ˌ", // secondary stress
+ ":": "ː", // length mark
+ // SPACING
+ "\\s": " ", // space
+ // ARCHIPHONEMES escaped
+ "\\A": "A",
+ "\\B": "B",
+ "\\C": "C",
+ "\\D": "D",
+ "\\E": "E",
+ "\\F": "F",
+ "\\G": "G",
+ "\\H": "H",
+ "\\I": "I",
+ "\\J": "J",
+ "\\K": "K",
+ "\\L": "L",
+ "\\M": "M",
+ "\\N": "N",
+ "\\O": "O",
+ "\\P": "P",
+ "\\Q": "Q",
+ "\\R": "R",
+ "\\S": "S",
+ "\\T": "T",
+ "\\U": "U",
+ "\\V": "V",
+ "\\W": "W",
+ "\\X": "X",
+ "\\Y": "Y",
+ "\\Z": "Z",
+ // TIPA LONG-FORM ALTERNATIVES AND ADDITIONAL SYMBOLS
+ // A
+ "\\textturna": "ɐ",
+ "\\textscripta": "ɑ",
+ "\\textturnscripta": "ɒ",
+ "\\textsca": "ᴀ",
+ "\\;A": "ᴀ",
+ "\\textturnv": "ʌ",
+ // B
+ "\\texthtb": "ɓ",
+ "\\textscb": "ʙ",
+ "\\textcrb": "ƀ",
+ "\\textbarb": "ƀ",
+ "\\textbeta": "β",
+ "\\textsoftsign": "ь",
+ "\\texthardsign": "ъ",
+ // C
+ "\\textbarc": "ȼ",
+ "\\texthtc": "ƈ",
+ "\\v{c}": "č",
+ "\\textctc": "ɕ",
+ "\\textstretchc": "ʗ",
+ // D
+ "\\textcrd": "đ",
+ "\\textbard": "đ",
+ "\\texthtd": "ɗ",
+ "\\textrtaild": "ɖ",
+ "\\textctd": "ȡ",
+ "\\textdzlig": "ʣ",
+ "\\textdctzlig": "ʥ",
+ "\\textdyoghlig": "ʤ",
+ "\\dh": "ð",
+ // E
+ "\\textschwa": "ə",
+ "\\textrhookschwa": "ɚ",
+ "\\textreve": "ɘ",
+ "\\textsce": "ᴇ",
+ "\\;E": "ᴇ",
+ "\\textepsilon": "ɛ",
+ "\\textrevepsilon": "ɜ",
+ "\\textrhookrevepsilon": "ɝ",
+ "\\textcloserevepsilon": "ɞ",
+ // G
+ "\\textg": "ɡ",
+ "\\textbarg": "ǥ",
+ "\\textcrg": "ǥ",
+ "\\texthtg": "ɠ",
+ "\\textscg": "ɢ",
+ "\\texthtscg": "ʛ",
+ "\\textgamma": "ɣ",
+ "\\textbabygamma": "ɤ",
+ "\\textramshorns": "ɤ",
+ // H
+ "\\texthvlig": "ƕ",
+ "\\texthth": "ɦ",
+ "\\textturnh": "ɥ",
+ "4": "ɥ",
+ "\\textsch": "ʜ",
+ // I
+ "\\i": "ı",
+ "\\textbari": "ɨ",
+ "\\textiota": "ɩ",
+ "\\textlhti": "ɩ",
+ "\\textsci": "ɪ",
+ // J
+ "\\j": "ȷ",
+ "\\textctj": "ʝ",
+ "\\textscj": "ᴊ",
+ "\\;J": "ᴊ",
+ "\\v{\\j}": "ǰ",
+ "\\textObardotlessj": "ɟ",
+ "\\texthtbardotlessj": "ʄ",
+ // K
+ "\\texthtk": "ƙ",
+ "\\textturnk": "ʞ",
+ // L
+ "\\textltilde": "ɫ",
+ "\\textbarl": "ł",
+ "\\textrtaill": "ɭ",
+ "\\textOlyoghlig": "ɮ",
+ "\\textscl": "ʟ",
+ "\\textlambda": "λ",
+ "\\textcrlambda": "ƛ",
+ // M
+ "\\textltailm": "ɱ",
+ "\\textturnm": "ɯ",
+ // N
+ "\\textnrleg": "ƞ",
+ "\\ng": "ŋ",
+ "\\textrtailn": "ɳ",
+ "\\textctn": "ȵ",
+ "\\textscn": "ɴ",
+ // O
+ "\\textbullseye": "ʘ",
+ "\\textbaro": "ɵ",
+ "\\textscoelig": "ɶ",
+ "\\textopeno": "ɔ",
+ "\\textomega": "ω",
+ "\\textcloseomega": "ɷ",
+ // P
+ "\\textwynn": "ƿ",
+ "\\textthorn": "þ",
+ "\\th": "þ",
+ "\\texthtp": "ƥ",
+ "\\textphi": "ɸ",
+ // Q
+ "\\texthtq": "ʠ",
+ // R
+ "\\textfishhookr": "ɾ",
+ "\\textlonglegr": "ɼ",
+ "\\textrtailr": "ɽ",
+ "\\textturnr": "ɹ",
+ "\\textturnrrtail": "ɻ",
+ "\\textscr": "ʀ",
+ "\\textinvscr": "ʁ",
+ // S
+ "\\v{s}": "š",
+ "\\textrtails": "ʂ",
+ "\\textesh": "ʃ",
+ "\\textctesh": "ʆ",
+ // T
+ "\\texthtt": "ƭ",
+ "\\textlhookt": "ƫ",
+ "\\textrtailt": "ʈ",
+ "\\texttctclig": "ʨ",
+ "\\texttslig": "ʦ",
+ "\\texteshlig": "ʧ",
+ "\\textturnt": "ʇ",
+ "\\textctt": "ȶ",
+ "\\texttheta": "θ",
+ // U
+ "\\textbaru": "ʉ",
+ "\\textupsilon": "ʊ",
+ "\\textscu": "ᴜ",
+ "\\;U": "ᴜ",
+ // V
+ "\\textscriptv": "ʋ",
+ // W
+ "\\textturnw": "ʍ",
+ // X
+ "\\textchi": "χ",
+ // Y
+ "\\textturny": "ʎ",
+ "\\textscy": "ʏ",
+ // Z
+ "\\textcommatailz": "ʐ",
+ "\\v{z}": "ž",
+ "\\textrevyogh": "ʕ",
+ "\\textrtailz": "ʐ",
+ "\\textyogh": "ʒ",
+ "\\textctyogh": "ʓ",
+ "\\textcrtwo": "ƻ",
+ "\\textglotstop": "ʔ",
+ "\\textraiseglotstop": "ˀ",
+ "\\textinvglotstop": "ʖ",
+ "\\textrevglotstop": "ʕ",
+ )
+
+ // Define combining diacritics
+ // Forward-looking: precede the phoneme in input (e.g., \~ a → ã)
+ let forward_diacritics = (
+ "\\~": "̃", // combining tilde (nasalization)
+ "\\r": "̥", // combining ring below (devoicing)
+ "\\v": "̩", // combining vertical line below (voicing)
+ "\\t": "͡", // combining double inverted breve (tie bar for affricates)
+ "\\dental": "̪", // no trailing space
+ )
+
+ // Backward-looking: follow the phoneme in input (e.g., p \h → pʰ)
+ let backward_diacritics = (
+ "\\*": "̚", // combining left angle above (unreleased)
+ "\\h": "ʰ", // modifier letter small h (aspirated)
+ "\\velar": "ˠ",
+ "\\palatal": "ʲ",
+ "\\labial": "ʷ",
+ "\\ej": "ʼ", // modifier letter apostrophe (ejective)
+ )
+
+ // Split by spaces and process each token
+ let tokens = input.split(" ")
+ let result = ""
+ let i = 0
+ let pending_diacritic = none
+
+ while i < tokens.len() {
+ let token = tokens.at(i)
+
+ // Check if this token is a forward-looking diacritic
+ if token in forward_diacritics {
+ // Store it to apply to next character
+ pending_diacritic = forward_diacritics.at(token)
+ } else if token in backward_diacritics {
+ // Apply immediately to previous character
+ result += backward_diacritics.at(token)
+ } else if token.contains("\\") {
+ // Backslash command
+ if token in mappings {
+ result += mappings.at(token)
+ // Apply pending diacritic if any
+ if pending_diacritic != none {
+ result += pending_diacritic
+ pending_diacritic = none
+ }
+ } else {
+ result += token
+ }
+ } else {
+ // No backslash: split into individual characters
+ let chars = token.clusters()
+ for (idx, char) in chars.enumerate() {
+ if char in mappings {
+ result += mappings.at(char)
+ } else {
+ result += char
+ }
+ // Apply pending diacritic to first character only
+ if idx == 0 and pending_diacritic != none {
+ result += pending_diacritic
+ pending_diacritic = none
+ }
+ }
+ }
+
+ i += 1
+ }
+
+ result
+}
+
+// Main IPA function: converts tipa-style notation to IPA
+#import "_config.typ": phonokit-font
+
+#let ipa(input) = {
+ let rendered = ipa-to-unicode(input)
+ context {
+ metadata(rendered)
+ text(font: phonokit-font.get(), rendered)
+ }
+}
diff --git a/packages/preview/phonokit/0.5.11/lib.typ b/packages/preview/phonokit/0.5.11/lib.typ
new file mode 100644
index 0000000000..98e61e0398
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/lib.typ
@@ -0,0 +1,1148 @@
+// phonokit - a toolkit to create phonological representations
+// Author: Guilherme D. Garcia
+//
+// This package provides:
+// - IPA transcription with tipa-style input syntax
+// - Prosodic structure visualization (syllables, moras, feet, prosodic words)
+// - Autosegmental representations and processes (features and tones)
+// - IPA vowel charts (trapezoid) with language inventories
+// - IPA consonant tables (pulmonic) with language inventories
+// - Optimality Theory (OT) tableaux with violation marking and shading
+// - Harmonic Grammar (HG) tableaux with weighted harmony calculations
+// - Noisy Harmonic Grammar (NHG) tableaux with stochastic simulations
+// - Maximum Entropy (MaxEnt) grammar tableaux with probability calculations
+// - SPE-style feature matrices for phonological representations
+// - Feature-geometry trees (Clements & Hume 1995; Sagey 1986)
+
+// Import modules
+#import "_config.typ": phonokit-init
+#import "ipa.typ": *
+#import "prosody.typ": *
+#import "ot.typ": *
+#import "hasse.typ": *
+#import "extras.typ": *
+#import "vowels.typ": *
+#import "grids.typ": *
+#import "consonants.typ": *
+#import "features.typ": *
+#import "sonority.typ": *
+#import "autosegmental.typ": *
+#import "multi-tier.typ": *
+#import "sound-shift.typ": *
+#import "ex.typ": ex, ex-rules
+#import "intonational.typ": *
+#import "geom.typ": *
+#import "phonetics.typ": *
+
+/// Initialize phonokit settings
+///
+/// Call this at the top of your document to configure package-wide settings.
+/// Currently supports setting a custom font for all phonokit functions.
+///
+/// Arguments:
+/// - font (string): Font name to use for IPA rendering (default: "Charis")
+///
+/// Example:
+/// ```
+/// #import "@preview/phonokit:0.5.11": *
+/// #phonokit-init(font: "Libertinus Serif")
+/// ```
+#let phonokit-init = phonokit-init
+
+/// Convert tipa-style notation to IPA symbols
+///
+/// Supports:
+/// - IPA consonants and vowels
+/// - Combining diacritics (\\~, \\r, \\v, \\t for nasal, devoiced, voiced, tie)
+/// - Suprasegmentals (' and , for primary and secondary stress; : for length)
+/// - Automatic character splitting for efficiency
+///
+/// Example: `#ipa("'hEloU")` produces ˈhɛloʊ
+///
+/// Arguments:
+/// - input (string): tipa-style notation
+///
+/// Returns: IPA symbols in the configured font (default: Charis)
+#let ipa = ipa
+
+/// Visualize sonority profiles based on Parker (2011)
+///
+/// Generates a visual sonority profile for a given phonemic transcription.
+/// Phonemes are mapped to a vertical sonority axis (1-13) based on Parker's
+/// acoustic scale and connected to show the sonority contour of the word.
+///
+/// Features:
+/// - Uses Parker (2011) hierarchy (e.g., Flaps > Laterals > Trills)
+/// - Visualizes syllable boundaries using alternating background shading (white/gray)
+/// - Automatic parsing of tipa-style input strings
+///
+/// Arguments:
+/// - word (string): Phonemic string in tipa-style (use "." for syllable boundaries)
+/// - syl (none): Legacy parameter, kept for API compatibility
+/// - stressed (int, optional): Index of stressed syllable (default: none)
+/// - box-size (float): Size of individual phoneme boxes (default: 0.8)
+/// - scale (float): Overall scale factor for the diagram (default: 1.0)
+/// - y-range (array): Vertical axis range for plotting (default: (0, 8))
+/// - show-lines (bool): Connect phonemes with dashed lines (default: true)
+///
+/// Returns: CeTZ drawing of the sonority profile
+///
+/// Example:
+/// ```
+/// // Visualizes "par.to.me" with 3 distinct background zones
+/// #sonority("par.to.me")
+///
+/// // Demonstrates Flap (R) > Lateral (l) ranking
+/// #sonority("ka.Ra.lo")
+/// ```
+///
+/// Note: Input is automatically truncated to the first 10 phonemes to prevent
+/// visual overflow.
+#let sonority = sonority
+
+/// Create an illustrative F1/F2 vowel cloud for teaching.
+///
+/// Generates synthetic vowel tokens around built-in F1/F2 means and displays
+/// them on an inverted F1/F2 diagram.
+///
+/// Arguments:
+/// - vowels (string): Tipa-style/IPA vowel string or a built-in language name
+/// such as `"english"`
+/// - source (array, optional): Tabular data from `csv(...)` with required
+/// columns `vowel`, `f1`, and `f2`; a plain `csv("...")` table with a
+/// header row is accepted
+/// - sd (float): Spread of synthetic tokens in Hz
+/// - sd2 (float, optional): Optional separate F2 spread in Hz
+/// - n (int): Number of tokens per vowel (default: 10)
+/// - seed (int): Deterministic seed for token placement (default: 1)
+/// - labels (bool): Show vowel labels at the means (default: true)
+/// - points (bool): Show vowel tokens (default: true)
+/// - centers (bool): Show explicit `+` mean markers (default: false)
+/// - ellipse (bool): Show 1-SD ellipses centered on the means (default: false)
+/// - ellipse-stroke (stroke or auto): Stroke for SD ellipses
+/// (default: `0.8pt + luma(190)`)
+/// - ellipse-fill (fill): Fill for SD ellipses (default: none)
+/// - grid (bool): Show the background grid (default: true)
+/// - color-by-vowel (bool): Use a color cycle by vowel category (default: true)
+/// - point-size (int): Marker size for synthetic tokens (default: 50)
+/// - point-color (color or auto): Override token color (default: auto)
+/// - point-alpha (ratio): Token transparency (default: 20%)
+/// - vowel-color (color): Color of vowel labels (default: black)
+/// - vowel-size (length): Font size of vowel labels (default: 20pt)
+/// - vowel-weight (str): Font weight of vowel labels (default: `"regular"`)
+/// - axis-size (length): Font size of axis labels and tick labels (default: 10pt)
+/// - scale (float): Overall scale factor for the figure (default: 1.0)
+/// - x-label (content): X-axis label (default: `[F2 (Hz)]`)
+/// - y-label (content): Y-axis label (default: `[F1 (Hz)]`)
+/// - width (length): Diagram width (default: 10cm)
+/// - height (length): Diagram height (default: 7cm)
+///
+/// Notes:
+/// - In synthetic mode, ellipses visualize the user-provided spread parameters
+/// (`sd`, `sd2`).
+/// - In CSV mode, vowel means and ellipse sizes are computed from the observed
+/// tokens in the input data.
+///
+/// Example:
+/// ```
+/// #formants("italian", scale: 0.6, ellipse: true, axis-size: 1.3em)
+/// #formants(source: csv("extras/formants_sample.csv"), scale: 0.8)
+/// ```
+#let formants = formants
+
+/// Draw a schematic voice onset time (VOT) timeline.
+///
+/// Positive values show an aspiration interval between release and voicing
+/// onset, zero values align release and voicing onset, and negative values
+/// show prevoicing.
+///
+/// Arguments:
+/// - vot (number): Voice onset time in milliseconds
+/// - closure (number): Closure duration in milliseconds (default: 40)
+/// - vowel (number): Vowel region duration in milliseconds (default: 60)
+/// - scale (float): Overall scale factor for the diagram (default: 1.0)
+/// - label (auto or bool): Show the VOT value label (default: auto, equivalent
+/// to true)
+/// - keys (bool): Show event keys and legend (`R`, `V`, and compact interval
+/// keys such as `A`; default: false)
+/// - ui-lang (string): UI label language. Supported aliases: en/english,
+/// fr/french, pt/portuguese (default: "en")
+/// - closure-label, release-label, voicing-label, vowel-label, vot-label,
+/// interval-label (auto, content, string, or none): Override individual
+/// labels. `auto` uses localized defaults; `interval-label: auto` is the
+/// localized aspiration label. Set `interval-label: none` to hide it
+/// - interval-key (auto or content): Key used when `interval-label` does not
+/// fit and `keys: true` (default: auto, usually `A` for aspiration)
+/// - closure-segment, interval-segment, vowel-segment (string, content, or none):
+/// Optional IPA/segment labels placed below the closure, interval, and
+/// vowel regions. Strings use phonokit's IPA parser (default: none)
+/// - segment-size (length): Font size for segment labels (default: 10pt)
+/// - fill-closure (color): Closure region fill (default: luma(230))
+/// - fill-vowel (color): Vowel region fill (default: white)
+/// - fill-aspiration (color): Positive-VOT interval fill (default: luma(245))
+/// - voicing (bool): Draw a schematic voicing/noise waveform (default: true)
+/// - voicing-stroke (stroke or auto): Stroke for the voicing waveform (default:
+/// auto)
+///
+/// Region labels are placed above the boxes and are shown only when their
+/// localized or overridden label fits the available region width. Positive-VOT
+/// interval labels use the same fit logic; when they do not fit and `keys:
+/// true`, the interval key is shown in the diagram and explained in the legend.
+///
+/// Example:
+/// ```
+/// #vot(65)
+/// #vot(
+/// 65,
+/// closure-segment: "t",
+/// interval-segment: "\\h",
+/// vowel-segment: "\\ae",
+/// )
+/// #vot(-60, voicing: false)
+/// #vot(-60, ui-lang: "fr")
+/// ```
+#let vot = vot
+
+/// Draw a single syllable's internal structure
+///
+/// Visualizes only the syllable (σ) level with onset, rhyme, nucleus, and coda.
+///
+/// Arguments:
+/// - input (string): A single syllable (e.g., "ka" or "'va")
+/// - scale (float): Scale factor for the diagram (default: 1.0)
+/// - symbol (array): Domain labels top-down: (σ) (default: ("σ",))
+/// - distance (float, optional): Horizontal distance between segments (default: none)
+///
+/// Returns: CeTZ drawing of syllable structure
+///
+/// Example: `#syllable("\\t tS \\ae t", scale: 0.9)`
+#let syllable = syllable
+
+/// Draw a mora-based structure
+///
+/// Visualizes mora (μ) and syllable (σ) levels, showing how syllables
+/// are decomposed into moras based on weight.
+///
+/// Arguments:
+/// - input (string): A single syllable (e.g., "kan" or "ka")
+/// - coda (bool): Whether codas contribute to weight (default: false)
+/// - scale (float): Scale factor for the diagram (default: 1.0)
+/// - symbol (array): Domain labels top-down: (σ, μ) (default: ("σ", "μ"))
+/// - distance (float, optional): Horizontal distance between segments (default: none)
+///
+/// Returns: CeTZ drawing of moraic structure
+///
+/// Examples:
+/// - `#mora("\\t tS \\ae t", coda: true)` - Moraic representation with coda weight
+/// - `#mora("tR \\~ a:m", coda: true)` - Long vowel represented with two moras
+#let mora = mora
+
+/// Draw a foot with syllables
+///
+/// Visualizes foot (Σ) and syllable (σ) levels. All syllables are part of the foot.
+/// Stressed syllables are marked with an apostrophe before the syllable.
+///
+/// Arguments:
+/// - input (string): Syllables separated by dots (e.g., "ka.'va.lo")
+/// - scale (float): Scale factor for the diagram (default: 1.0)
+/// - symbol (array): Domain labels top-down: (Σ, σ) (default: ("Σ", "σ"))
+/// - distance (float, optional): Horizontal distance between segments (default: none)
+///
+/// Returns: CeTZ drawing of foot structure
+///
+/// Example: `#foot("'p \\h \\ae.\\*r Is", scale: 0.9)`
+#let foot = foot
+
+/// Draw a foot with moraic structure
+///
+/// Visualizes foot (Σ), syllable (σ), and mora (μ) levels. Combines foot
+/// structure with moraic weight representation.
+/// Stressed syllables are marked with an apostrophe before the syllable.
+///
+/// Arguments:
+/// - input (string): Syllables separated by dots (e.g., "po.'Ral")
+/// - coda (bool): Whether codas contribute to weight (default: false)
+/// - scale (float): Scale factor for the diagram (default: 1.0)
+/// - symbol (array): Domain labels top-down: (Σ, σ, μ) (default: ("Σ", "σ", "μ"))
+/// - distance (float, optional): Horizontal distance between segments (default: none)
+///
+/// Returns: CeTZ drawing of moraic foot structure
+///
+/// Examples:
+/// - `#foot-mora("po.'Ral", coda: true, scale: 0.9)` - Disyllabic foot with moraic structure
+/// - `#foot-mora("'po.Ra.ma", coda: true, scale: 0.9)` - Dactyl with moraic structure
+#let foot-mora = foot-mora
+
+/// Draw a prosodic word structure with explicit foot boundaries
+///
+/// Visualizes prosodic word (PWd), foot (Σ), and syllable (σ) levels.
+/// Use parentheses to mark foot boundaries.
+/// Stressed syllables are marked with an apostrophe before the syllable.
+///
+/// Arguments:
+/// - input (string): Syllables with optional foot markers in parentheses
+/// - foot (string): "R" (right-aligned) or "L" (left-aligned) for PWd alignment (default: "R")
+/// - scale (float): Scale factor for the diagram (default: 1.0)
+/// - symbol (array): Domain labels top-down: (ω, Σ, σ) (default: ("ω", "Σ", "σ"))
+/// - distance (float, optional): Horizontal distance between segments (default: none)
+///
+/// Returns: CeTZ drawing of prosodic structure
+///
+/// Examples:
+/// - `#word("('po.Ra).ma", scale: 0.9)` - One foot plus one unfooted syllable
+/// - `#word("('po.Ra).('ma.pa)", foot: "R", scale: 0.9)` - Two feet, right-headed PWd
+#let word = word
+
+/// Draw a prosodic word structure with moraic representation
+///
+/// Visualizes prosodic word (PWd), foot (Σ), syllable (σ), and mora (μ) levels.
+/// Combines prosodic word structure with moraic weight representation.
+/// Use parentheses to mark foot boundaries.
+/// Stressed syllables are marked with an apostrophe before the syllable.
+///
+/// Arguments:
+/// - input (string): Syllables with optional foot markers in parentheses
+/// - foot (string): "R" (right-aligned) or "L" (left-aligned) for PWd alignment (default: "R")
+/// - coda (bool): Whether codas contribute to weight (default: false)
+/// - scale (float): Scale factor for the diagram (default: 1.0)
+/// - symbol (array): Domain labels top-down: (ω, Σ, σ, μ) (default: ("ω", "Σ", "σ", "μ"))
+/// - distance (float, optional): Horizontal distance between segments (default: none)
+///
+/// Returns: CeTZ drawing of moraic prosodic structure
+///
+/// Examples:
+/// - `#word-mora("('po.Ra).ma", coda: true, scale: 0.9)` - Trochee with unfooted syllable
+/// - `#word-mora("('po.Ra).('ma.pa)", foot: "L", coda: true, scale: 0.9)` - Two feet, left-headed PWd
+#let word-mora = word-mora
+
+/// Create a metrical grid representation for stress and rhythm analysis
+///
+/// Visualizes hierarchical stress levels using stacked × marks above syllables.
+/// This follows metrical grid theory where each level represents a different
+/// metrical prominence tier.
+///
+/// Supports two input formats:
+/// 1. String format (simple, but not IPA-compatible):
+/// - Syllables separated by dots, each ending with a stress level number
+/// 2. Array format (IPA-compatible):
+/// - Array of (syllable, level) tuples
+///
+/// Arguments:
+/// - ..args: Either a single string or multiple (syllable, level) tuples
+/// - String format: syllables separated by dots, each ending with stress level (e.g., "te2.ne1.see3")
+/// - Tuple format: pairs of (syllable, level) passed as separate arguments
+/// - ipa (bool): Automatically convert strings to IPA notation (default: true)
+///
+/// Returns: Table showing syllables with stacked × marks indicating stress levels
+///
+/// Examples:
+/// - `#met-grid("bu3.tter1.fly2")` - String format
+/// - `#met-grid(
+/// ("b2", 3),
+/// ("R \\schwar", 1),
+/// ("flaI", 2),
+/// )` - Array format
+///
+/// Note: The string format uses numbers to indicate stress levels, which conflicts
+/// with IPA numeric symbols. For IPA compatibility, use the array format.
+#let met-grid = met-grid
+
+
+/// Plot vowels on an IPA vowel trapezoid
+///
+/// Visualizes vowels on the IPA vowel chart (trapezoid) with proper positioning
+/// based on frontness, height, and roundedness. Supports language-specific
+/// inventories, custom vowel sets, or tipa-style IPA notation.
+///
+/// Arguments:
+/// - vowel-string (string, optional): Vowel symbols to plot, a built-in language
+/// name, or tipa-style IPA, passed positionally (e.g. `vowels("aeiou")` or
+/// `vowels("spanish")`). Optional: you may omit it and pass `lang` instead
+/// (e.g. `vowels(lang: "spanish")`). If both are given and the positional is a
+/// language name, the positional wins.
+/// - lang (string, optional): Built-in language whose inventory to plot, used when
+/// no language-name positional is given (e.g. `vowels(lang: "spanish")`). Also
+/// selects the nasal-vowel preset when the positional is a custom symbol string.
+/// - width (float): Base width of trapezoid (default: 8)
+/// - height (float): Base height of trapezoid (default: 6)
+/// - rows (int): Number of horizontal grid lines (default: 3)
+/// - cols (int): Number of vertical grid lines (default: 2)
+/// - scale (float): Scale factor for entire chart (default: 0.7)
+/// - nasals (bool): Draw schematic nasalized copies near their oral
+/// counterparts. For preset languages, this currently adds only the French
+/// nasal vowels; for custom strings, only vowels explicitly nasalized in the
+/// input are shown in the nasal layer (default: false)
+/// - arrows (array): List of (from-vowel, to-vowel) tuples for drawing directed
+/// arrows between vowel positions (e.g. diphthongs). Each vowel string accepts
+/// tipa-style notation. Unknown vowels are silently skipped. (default: ())
+/// - arrow-color (color): Color for arrow lines and heads (default: black)
+/// - arrow-style (string): "solid" or "dashed" line style for arrows (default: "solid")
+/// - curved (bool): Curve arrows with a quadratic bezier arc (default: false)
+/// - shift (array): List of (vowel, x-offset, y-offset) tuples. Draws a copy of
+/// the vowel symbol offset from its canonical trapezoid position by (x, y) in
+/// CeTZ canvas units. If the vowel is already plotted, an additional copy is
+/// drawn; otherwise it is created. Unknown vowels are silently skipped. (default: ())
+/// - shift-color (color): Color for shifted vowel symbols (default: gray)
+/// - shift-size (length, optional): Font size for shifted vowels; none uses the
+/// same size as regular vowels (default: none)
+/// - highlight (array): List of tipa strings whose background circle is highlighted (default: ())
+/// - highlight-color (color): Circle color for highlighted vowels (default: luma(220))
+///
+/// Returns: CeTZ drawing of IPA vowel chart with positioned vowels
+///
+/// Examples:
+/// - `#vowels("english", scale: 0.6)` - Plot English vowel inventory
+/// - `#vowels("french", scale: 0.6)` - Plot French vowel inventory
+/// - `#vowels("aãioõu", nasals: true)` - Add only the nasal vowels marked in the custom inventory
+/// - `#vowels("french", nasals: true)` - Add the French nasal vowels
+/// - `#vowels("english", arrows: (("a", "U"), ("a", "I"), ("e", "I"), ("O", "I"), ("o", "U")), curved: true)` - Diphthong trajectories
+///
+/// Note: Diacritics and non-vowel symbols are ignored during plotting. Nasal
+/// overlays are illustrative only and are not language-specific placements.
+///
+/// Available languages: english, spanish, portuguese, italian, french, german,
+/// japanese, russian, arabic
+#let vowels = vowels
+
+/// Plot consonants on an IPA consonant table
+///
+/// Visualizes consonants on the pulmonic IPA consonant chart with proper
+/// positioning by place and manner of articulation. Voiceless/voiced pairs
+/// are shown left/right in each cell. Impossible articulations are grayed out.
+///
+/// Arguments:
+/// - consonant-string (string, optional): Consonant symbols to plot, a built-in
+/// language name, or tipa-style IPA, passed positionally (e.g.
+/// `consonants("ptk")` or `consonants("russian")`). Optional: you may omit it
+/// and pass `lang` instead (e.g. `consonants(lang: "russian")`). If both are
+/// given and the positional is a language name, the positional wins.
+/// - lang (string, optional): Built-in language whose inventory to plot, used when
+/// no language-name positional is given (e.g. `consonants(lang: "russian")`).
+/// - ui-lang (string): UI label language. Supported aliases: en/english, fr/french,
+/// pt/portuguese (default: "en")
+/// - affricates (bool): Show affricate row after fricatives (default: false)
+/// - aspirated (bool): Show aspirated plosive/affricate rows (default: false)
+/// - abbreviate (bool): Use abbreviated place/manner labels (default: false)
+/// - simplify (bool): Automatically drop empty rows and columns (default: false)
+/// - delete-cols (array): 0-indexed column indices to remove (0=Bilabial ... 10=Glottal)
+/// - delete-rows (array): 0-indexed row indices to remove (0=Plosive ... 7=Lateral approximant)
+/// - cell-width (float): Width of each cell (default: 1.8)
+/// - cell-height (float): Height of each cell (default: 0.9)
+/// - label-width (float): Width of row labels (default: 3.5)
+/// - label-height (float): Height of column labels (default: 1.2)
+/// - scale (float): Scale factor for entire table (default: 0.7)
+///
+/// Returns: CeTZ drawing of IPA consonant table with positioned consonants
+///
+/// Examples:
+/// - `#consonants("italian", affricates: true, abbreviate: true)` - Italian inventory
+/// - `#consonants("ts{ts}psS \\*r g{tS} {k \\h}", affricates: true, aspirated: true)` - Custom inventory
+/// - `#consonants("english", affricates: true, simplify: true)` - Simplified English inventory
+/// - `#consonants("italian", affricates: true, simplify: true, ui-lang: "fr")` - Localized labels
+///
+/// Notes:
+/// - /w/ (labiovelar) appears in both bilabial and velar columns when /ɰ/ is not present; otherwise only bilabial
+/// - Affricates appear in a separate row when affricates: true (displayed without tie bars)
+/// - Aspirated consonants appear in separate rows when aspirated: true (e.g., "Plosive (aspirated)")
+/// - Both aspirated consonants and affricates must be wrapped with curly brackets: {p \\h} will produce an aspirated p, and {ts} will produce a voiceless alveolar affricate
+/// - Diacritics and non-consonant symbols are ignored during plotting
+///
+/// Available languages: all, english, spanish, french, german, italian,
+/// japanese, portuguese, russian, arabic
+#let consonants = consonants
+
+/// Create an Optimality Theory tableau
+///
+/// Generates a formatted OT tableau with candidates, constraints, violations,
+/// and shading for irrelevant cells after fatal violations.
+///
+/// Arguments:
+/// - input (string or content): The input form (can use IPA notation)
+/// - candidates (array): Array of candidate forms (strings or content)
+/// - constraints (array): Array of constraint names (strings)
+/// - violations (array): 2D array of violation strings (use "*" for violations, "!" for fatal)
+/// - winner (int): Index of the winning candidate (0-indexed)
+/// - dashed-lines (array): Indices of constraints to show with dashed borders (optional)
+/// - scale (number, optional): Scale factor for the tableau (default: none)
+/// - shade (bool): Whether cells should be shaded after fatal violations (default: true)
+/// - prosody-scale (float): Scale factor for prosodic structures in candidates (default: 0.5)
+/// - letters (bool): Use letter labels (a, b, c, ...) for candidates (default: false)
+/// - gloss (string, optional): Gloss text displayed below the input (default: none)
+///
+/// Returns: Table showing OT tableau with winner marked by ☞
+///
+/// Example:
+/// ```
+/// #tableau(
+/// input: "kraTa",
+/// candidates: ("kra.Ta", "ka.Ta", "ka.ra.Ta"),
+/// constraints: ("Max", "Dep", "*Complex"),
+/// violations: (
+/// ("", "", "*"),
+/// ("*!", "", ""),
+/// ("", "*!", ""),
+/// ),
+/// winner: 0, // <- Position of winning cand
+/// dashed-lines: (0,) // <- Note the comma
+/// )
+/// ```
+#let tableau = tableau
+
+/// Create a Maximum Entropy (MaxEnt) grammar tableau
+///
+/// Generates a MaxEnt tableau showing harmony scores, probabilities,
+/// and optional probability visualizations.
+///
+/// Arguments:
+/// - input (string or content): The input form
+/// - candidates (array): Array of candidate forms
+/// - constraints (array): Array of constraint names
+/// - weights (array): Array of constraint weights (numbers)
+/// - violations (array): 2D array of violation counts (numbers)
+/// - visualize (bool): Whether to show probability bars (default: true)
+/// - sort (bool): Whether to sort candidates by probability, most to least (default: false)
+/// - scale (number, optional): Scale factor for the tableau (default: none)
+/// - letters (bool): Use letter labels (a, b, c, ...) for candidates (default: false)
+///
+/// Returns: Table showing MaxEnt tableau with H(x), P*(x), and P(x) columns
+///
+/// Example:
+/// ```
+/// #maxent(
+/// input: "kraTa",
+/// candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+/// constraints: ("Max", "Dep", "Complex"),
+/// weights: (2.5, 1.8, 1),
+/// violations: (
+/// (0, 0, 1),
+/// (1, 0, 0),
+/// (0, 1, 0),
+/// ),
+/// visualize: true,
+/// )
+/// ```
+#let maxent = maxent
+
+/// Create a Harmonic Grammar (HG) tableau
+///
+/// Generates an HG tableau showing harmony scores calculated from
+/// weighted constraint violations. HG is deterministic: the candidate
+/// with the highest harmony wins.
+///
+/// Arguments:
+/// - input (string or content): The input form
+/// - candidates (array): Array of candidate forms
+/// - constraints (array): Array of constraint names
+/// - weights (array): Array of constraint weights (numbers)
+/// - violations (array): 2D array of violation counts (negative numbers)
+/// - scale (number): Optional scale factor (default: auto-scales for >6 constraints)
+/// - letters (bool): Use letter labels (a, b, c, ...) for candidates (default: false)
+///
+/// Returns: Table showing HG tableau with constraint weights and h(y) harmony column
+///
+/// Example:
+/// ```
+/// #hg(
+/// input: "kraTa",
+/// candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+/// constraints: ("Max", "Dep", "*Complex"),
+/// weights: (2.5, 1.8, 1),
+/// violations: (
+/// (0, 0, -1),
+/// (-1, 0, 0),
+/// (0, -1, 0),
+/// ),
+/// )
+/// ```
+///
+/// Note: h(y) = Σ(weight × violation). Candidate with highest (least negative) harmony wins.
+#let hg = hg
+
+/// Create a Noisy Harmonic Grammar (NHG) tableau with symbolic noise
+///
+/// Pedagogical version showing noise as symbolic formulas (e.g., "-n₁").
+/// Useful for teaching how noise affects harmony calculations.
+///
+/// Arguments:
+/// - input (string or content): The input form
+/// - candidates (array): Array of candidate forms
+/// - constraints (array): Array of constraint names
+/// - weights (array): Array of constraint weights
+/// - violations (array): 2D array of violation counts (negative numbers)
+/// - probabilities (array): Optional array of probability values to display
+/// - scale (number): Scale factor (default: auto-scales for >6 constraints)
+/// - letters (bool): Use letter labels (a, b, c, ...) for candidates (default: false)
+///
+/// Returns: Table with h(y), ε(y) (symbolic), and optional P(y) columns
+///
+/// Example:
+/// ```
+/// #nhg-demo(
+/// input: "kraTa",
+/// candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+/// constraints: ("Max", "Dep", "*Complex"),
+/// weights: (2.5, 1.8, 1),
+/// violations: (
+/// (0, 0, -1),
+/// (-1, 0, 0),
+/// (0, -1, 0),
+/// ),
+/// probabilities: (0.673, 0.08, 0.247),
+///)
+/// ```
+#let nhg-demo = nhg-demo
+
+/// Create a Noisy Harmonic Grammar (NHG) tableau with Monte Carlo simulation
+///
+/// Samples noise from N(0,1), calculates probabilities via simulation.
+/// Noise is added to constraint weights, and the candidate with highest
+/// noisy harmony wins each trial.
+///
+/// Arguments:
+/// - input (string or content): The input form
+/// - candidates (array): Array of candidate forms
+/// - constraints (array): Array of constraint names
+/// - weights (array): Array of constraint weights
+/// - violations (array): 2D array of violation counts (negative numbers)
+/// - num-simulations (int): Number of Monte Carlo trials (default: 1000)
+/// - seed (int, optional): Random seed for reproducibility (default: none)
+/// - show-epsilon (bool): Whether to show epsilon column (default: true)
+/// - scale (number): Scale factor (default: auto-scales for >6 constraints)
+/// - letters (bool): Use letter labels (a, b, c, ...) for candidates (default: false)
+///
+/// Returns: Table with h(y), optional ε(y) (one sample), and P(y) (from simulation)
+///
+/// Example:
+/// ```
+/// #nhg(
+/// input: "kraTa",
+/// candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Ta]"),
+/// constraints: ("Max", "Dep", "*Complex"),
+/// weights: (2.5, 1.8, 1),
+/// violations: (
+/// (0, 0, -1),
+/// (-1, 0, 0),
+/// (0, -1, 0),
+/// ),
+/// )
+/// ```
+///
+/// Note: Probabilities are estimated empirically. More simulations = more accurate
+/// but slower compilation. Default 1000 is usually sufficient.
+#let nhg = nhg
+
+/// Create a Hasse diagram for Optimality Theory constraint rankings
+///
+/// Generates a visual representation of the partial order of constraint rankings.
+///
+/// Features:
+/// - Supports partial orders (not all constraints need to be ranked)
+/// - Handles floating constraints with no ranking relationships
+/// - Supports dashed and dotted line styles for different edge types
+/// - Auto-scales for complex hierarchies
+///
+/// Arguments:
+/// - rankings (array): Array of tuples representing rankings:
+/// - Three-element tuple `(A, B, level)` means A dominates B, and A is at stratum `level` (REQUIRED)
+/// - Four-element tuple `(A, B, level, style)` means A dominates B, A at stratum `level`, with line `style`
+/// - Single-element tuple `(A,)` means A is floating (no ranking)
+/// - Line styles: "solid" (default), "dashed", "dotted"
+/// - Note: Level specification is REQUIRED for all edges to ensure proper stratification
+/// - scale (number or auto): Scale factor for diagram (default: auto-scales based on complexity)
+/// - node-spacing (number): Horizontal spacing between nodes (default: 2.5)
+/// - level-spacing (number): Vertical spacing between levels (default: 1.5)
+///
+/// Returns: A Hasse diagram showing the constraint hierarchy
+///
+/// Examples:
+/// ```
+/// // Basic scenario
+/// #hasse(
+/// (
+/// ("*Complex", "Max", 0),
+/// ("*Complex", "Dep", 0),
+/// ("Onset", "Max", 0),
+/// ("Onset", "Dep", 0),
+/// ("Max", "NoCoda", 1),
+/// ("Dep", "NoCoda", 1),
+/// ),
+/// scale: 0.9
+/// )
+/// ```
+#let hasse = hasse
+
+// SPE/Feature function
+/// Create a feature matrix in SPE notation
+///
+/// Displays phonological features in a vertical matrix with square brackets,
+/// commonly used in Sound Pattern of English (SPE) style representations.
+///
+/// Arguments:
+/// - ..args: Features as separate arguments or comma-separated string
+///
+/// Returns: Mathematical vector notation with features
+///
+/// Examples:
+/// - `#feat("+cons", "-son", "+voice")` - Three features as separate args
+/// - `#feat("+cons,-son,+voice")` - Three features as comma-separated string
+#let feat = feat
+
+/// Display complete distinctive feature matrix for an IPA segment
+///
+/// Takes an IPA symbol and displays its complete distinctive feature specification
+/// from Hayes (2009) Introductory Phonology. Features are shown in SPE-style
+/// vertical matrix notation.
+///
+/// Arguments:
+/// - segment (string): IPA symbol using Unicode or tipa-style notation
+/// - all (bool): Show all features including unspecified (0) values (default: false)
+/// - ui-lang (string): UI label language. Supported aliases: en/english, fr/french,
+/// pt/portuguese (default: "en")
+///
+/// Returns: Complete feature matrix in SPE notation
+///
+/// Examples:
+/// - `#feat-matrix("p")` - Shows feature matrix for /p/
+/// - `#feat-matrix("\\ae")` - Shows feature matrix for /æ/
+/// - `#feat-matrix("\\t tS")` - Affricate using tipa-inspired notation
+/// - `#feat-matrix("i", all: true)` - Shows all features including 0 values
+///
+/// Note: Based on Hayes (2009) feature system. Includes manner, laryngeal,
+/// and place features for consonants; syllabic, height, backness, and rounding
+/// features for vowels.
+#let feat-matrix = feat-matrix
+
+/// Create an autosegmental representation
+///
+/// Generates an autosegmental representation visualizing features or tones
+/// on a separate tier from segments. Supports spreading (one-to-many associations),
+/// delinking, multiple linking, and floating features/tones. Ideal for illustrating
+/// phonological processes like tone spreading, vowel harmony, or feature geometry.
+///
+/// Arguments:
+/// - segments (array): Segment strings (use "" for empty timing slots)
+/// - features (array): Feature/tone labels corresponding to segments (use "" for no association)
+/// - links (array): Tuples of (feature-index, segment-index) for association lines (default: ())
+/// - delinks (array): Tuples of (feature-index, segment-index) for delinking marks (default: ())
+/// - spacing (float): Horizontal spacing between segments (default: 1.5)
+/// - arrow (bool): Show arrow between representations (for process diagrams) (default: false)
+/// - tone (bool): Whether the representation shows tones vs features (default: false)
+/// - highlight (array): Indices of segments to highlight with background color (default: ())
+/// - float (array): Indices of floating (unassociated) features/tones (default: ())
+/// - multilinks (array): Tuples of (feature-index, (seg1, seg2, ...)) for one-to-many links (default: ())
+/// - baseline (ratio): Vertical alignment of the box (default: 40%)
+/// - gloss (string): Optional gloss text below baseline (default: "")
+/// - dash (string): Line style for dashed association lines (default: "dashed")
+///
+/// Returns: Autosegmental representation
+///
+/// Examples:
+/// ```
+/// // Basic tone spreading: L tone spreads to multiple syllables
+/// #autoseg(
+/// ("a", "", "g", "a", "f", "i"),
+/// features: ("L", "", "", "H", "", ""),
+/// links: ((0, 3),),
+/// delinks: ((3, 3),),
+/// tone: true,
+/// spacing: 0.5,
+/// multilinks: ((3, (3, 5)),),
+/// )
+///
+/// // Feature spreading with arrow (showing phonological process)
+/// #autoseg(
+/// ("t", "a", "n"),
+/// features: ("+nasal", "", ""),
+/// links: ((0, 0),),
+/// arrow: true,
+/// )
+///
+/// // Floating tone with highlighting
+/// #autoseg(
+/// ("m", "a", "m", "a"),
+/// features: ("H", "L", "", ""),
+/// float: (0,),
+/// highlight: (1, 3),
+/// tone: true,
+/// )
+/// ```
+///
+/// Note: Index numbering is 0-based. Use empty strings "" in segments or features
+/// arrays to create timing slots without content or features without associations.
+#let autoseg = autoseg
+
+/// Create a multi-tier phonological representation
+///
+/// Draws N-tier diagrams for CV phonology, skeletal tier structures, and other
+/// multi-level representations. Each tier is a row of labels connected by
+/// association lines. Supports auto-linking, floating elements, highlighting,
+/// dashed lines, and delinking marks.
+///
+/// Arguments:
+/// - levels (array): Array of arrays; each inner array is a tier of label strings (use "" for empty positions).
+/// Entries can be "label", ("label", col) for fractional columns, or ("label", col, level) for fractional levels.
+/// - links (array): Extra solid lines as either `((level1, col1), (level2, col2))`
+/// tuples or `("name1", "name2")` pairs (default: ())
+/// - dashed (array): Dashed lines as either coordinate tuples or string-name pairs (default: ())
+/// - delinks (array): Cross marks on connections as either coordinate tuples or string-name pairs (default: ())
+/// - arrows (array): Rectangular-path arrows as either coordinate tuples or string-name pairs (default: ()).
+/// Top-level arrows arc above; bottom-level arrows arc below. Arrowhead at destination.
+/// - arrow-delinks (array): Indices of arrows that should have a delink mark (||) at the midpoint (default: ())
+/// - float (array): Positions excluded from auto-linking as `(level, col)` tuples or `"name"` strings (default: ())
+/// - highlight (array): Positions with circle highlight as `(level, col)` tuples or `"name"` strings (default: ())
+/// - ipa (array): Level indices whose labels should be rendered as IPA (default: ())
+/// - tier-labels (array): Labels for tiers as (level, "label") tuples, placed to the right (default: ())
+/// - spacing (float): Horizontal spacing between columns (default: 1.5)
+/// - level-spacing (float): Vertical spacing between tiers (default: 1.2)
+/// - stroke-width (length): Line thickness (default: 0.05em)
+/// - baseline (string): Vertical alignment (default: 40%)
+/// - scale (float): Uniform scale factor (default: 1.0)
+/// - show-grid (bool): Show background grid for debugging layout (default: false)
+/// - show-refs (bool): Show node references below nodes for debugging; string nodes use generated names and content nodes fall back to coordinates (default: false)
+///
+/// Returns: Multi-tier phonological representation
+///
+/// Example:
+/// ```
+/// // From Goad (2012)
+/// #multi-tier(
+/// levels: (
+/// ("O", "R", "", "O", "R", "O", "R"),
+/// ("", "N1", "", "", "N2", "", "N3"),
+/// ("", "x", "x", "x", "x", "x", "x"),
+/// ("", "", "s", "t", "E", "m", ""),
+/// ),
+/// links: (
+/// ("r2", "x2"),
+/// ),
+/// ipa: (3,),
+/// arrows: (
+/// ("t1", "s1"),
+/// ("r2", "r1"),
+/// ),
+/// arrow-delinks: (
+/// (1,)
+/// ),
+/// spacing: 1,
+/// )
+/// ```
+///
+/// Note: Trailing digits in labels are automatically rendered as subscripts
+/// (e.g., "O1" becomes O₁). Standalone "x" is rendered as "×" (multiplication sign).
+/// Non-empty labels also receive automatic reference names in reading order
+/// (e.g., `sigma1`, `sigma2`, `x3`), which can be used in `links`, `dashed`,
+/// `delinks`, `float`, `highlight`, and `arrows`.
+#let multi-tier = multi-tier
+
+/// Create free-positioned sound-shift diagrams
+///
+/// Draws IPA labels at arbitrary 2D positions and connects them with arrows.
+/// This is useful for historical or schematic shift diagrams that are awkward
+/// to represent in an IPA trapezoid.
+///
+/// Arguments:
+/// - nodes (array): Array of dictionaries describing nodes. Each node needs
+/// `at: (x, y)` and `label:`. When `label` is a string, it also serves as
+/// the node identifier by default. Use `id:` only when labels are duplicated
+/// or when the visible label should differ from the reference key.
+/// - arrows (array): Array of arrow specs. Each arrow can be a `(from, to)`
+/// tuple or a dictionary with `from:` and `to:`. Endpoints can be node ids
+/// or raw coordinate pairs.
+/// - highlights (array): Node ids or coordinate pairs to highlight with a
+/// background circle (default: ())
+/// - node-size (length): Default IPA font size for nodes (default: 2.2em)
+/// - text-fill (color): Default node color (default: black)
+/// - highlight-fill (fill): Default node/highlight fill (default: `luma(230)`)
+/// - highlight-radius (float): Default circle radius in canvas units (default: 0.42)
+/// - arrow-color (color): Default arrow color (default: black)
+/// - arrow-style (str): Default arrow style: `"solid"`, `"dashed"`, or `"dotted"`
+/// - arrow-width (length): Default arrow stroke width (default: 0.8pt)
+/// - arrow-size (float): Default arrowhead scale (default: 1.0)
+/// - curved (bool): Curve arrows by default (default: false)
+/// - curve (float): Default curvature multiplier for curved arrows (default: 0.45)
+/// - scale (float): Uniform diagram scale factor (default: 1.0)
+///
+/// Returns: A CeTZ-based sound-shift diagram
+///
+/// Example:
+/// ```
+/// #sound-shift(
+/// nodes: (
+/// (label: "I", at: (-4.2, 2.8)),
+/// (label: "E", at: (-1.9, 1.0)),
+/// (label: "2", at: (0.9, 1.0)),
+/// (label: "O", at: (3.8, 1.0)),
+/// (label: "A", at: (2.4, -1.5)),
+/// (label: "\\ae", at: (-1.0, -1.5)),
+/// ),
+/// arrows: (
+/// ("E", "2"),
+/// ("2", "O"),
+/// ("O", "A"),
+/// ("A", "\\ae"),
+/// ("I", "E"),
+/// (from: "\\ae", to: "I", curved: true, curve: 0.28),
+/// ),
+/// scale: 0.7,
+/// )
+/// ```
+#let sound-shift = sound-shift
+
+/// Create a numbered linguistic example
+///
+/// Generates numbered examples (1), (2), etc. similar to linguex in LaTeX.
+/// Wrap content directly for a single example, or use list syntax for
+/// automatically lettered sub-examples. Use `labels` to make individual
+/// sub-examples referenceable.
+///
+/// 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 (string, optional): Title for the example (default: none)
+/// - labels (array): Array of labels for sub-examples (default: ())
+/// - columns (array): Column widths for data columns (default: ())
+///
+/// Returns: Numbered example that can be labeled and referenced
+///
+/// Example:
+/// ```
+/// #ex(caption: "A phonology example", labels: (, ), columns: (5em, 2em, 5em))[
+/// - #ipa("/anba/") & #a-r & #ipa("[amba]")
+/// - #ipa("/anka/") & #a-r & #ipa("[aNka]")
+/// ]
+/// ```
+#let ex = ex
+
+/// Show rules for linguistic examples
+///
+/// Apply this to enable proper reference formatting for ex().
+/// References render as (1), (1a), (1b), etc.
+///
+/// Usage: `#show: ex-rules`
+#let ex-rules = ex-rules
+
+/// Arrow symbols for phonological rules and processes
+///
+/// Convenience symbols for showing derivations, mappings, and processes.
+/// All arrows use New Computer Modern font for consistent styling.
+///
+/// Available arrows:
+/// - `#a-r` → right arrow
+/// - `#a-l` ← left arrow
+/// - `#a-u` ↑ up arrow
+/// - `#a-d` ↓ down arrow
+/// - `#a-lr` ↔ bidirectional arrow
+/// - `#a-ud` ↕ vertical bidirectional arrow
+/// - `#a-sr` ↝ squiggly right arrow
+/// - `#a-sl` ↜ squiggly left arrow
+/// - `#a-r-large` → large right arrow with horizontal spacing
+///
+/// Example: `#ipa("/anba/") #a-r #ipa("[amba]")` produces /anba/ → [amba]
+#let a-r = a-r
+#let a-l = a-l
+#let a-u = a-u
+#let a-d = a-d
+#let a-lr = a-lr
+#let a-ud = a-ud
+#let a-sr = a-sr
+#let a-sl = a-sl
+#let a-r-large = a-r-large
+
+/// Upright Greek symbols for phonological notation
+///
+/// Convenience bindings for commonly used Greek letters in phonology.
+/// These render upright in text mode (unlike math-mode `$sigma$` which italicizes).
+///
+/// Lowercase:
+/// - `#alpha` α, `#beta` β, `#gamma` γ, `#delta` δ
+/// - `#lambda` λ, `#mu` μ, `#phi` φ, `#pi` π
+/// - `#sigma` σ, `#tau` τ, `#omega` ω
+///
+/// Uppercase:
+/// - `#cap-sigma` Σ (foot), `#cap-phi` Φ (phonological phrase), `#cap-omega` Ω (utterance)
+///
+/// Example: `The syllable #sigma contains an onset and a rhyme.`
+#let alpha = alpha
+#let beta = beta
+#let gamma = gamma
+#let delta = delta
+#let lambda = lambda
+#let mu = mu
+#let phi = phi
+#let pi = pi
+#let sigma = sigma
+#let tau = tau
+#let omega = omega
+#let cap-phi = cap-phi
+#let cap-sigma = cap-sigma
+#let cap-omega = cap-omega
+
+/// Create an underline blank for fill-in exercises or SPE rules
+///
+/// Generates a horizontal line (underline) useful for worksheets,
+/// exercises, or indicating missing/redacted content.
+///
+/// Arguments:
+/// - width (length): Width of the blank line (default: 2em)
+///
+/// Returns: A box with bottom stroke
+///
+/// Example: `The word #blank() means "house".`
+#let blank = blank
+
+/// Mark extrametrical content with angle brackets
+///
+/// Wraps content in ⟨angle brackets⟩ to indicate extrametricality
+/// in metrical phonology representations.
+///
+/// Arguments:
+/// - content: The content to mark as extrametrical
+///
+/// Returns: Content wrapped in ⟨⟩
+///
+/// Example: `#extra[tion]` produces ⟨tion⟩
+#let extra = extra
+
+/// Place a ToBI intonation label above the current inline text position
+///
+/// Designed to be placed inline immediately after the syllable or word being annotated.
+/// The label floats above the text at the insertion point, optionally connected by
+/// a vertical stem.
+///
+/// Arguments:
+/// - label (string): ToBI label, e.g., "*L", "H%", "L+H*", "!H*"
+/// - line (boolean): draw a vertical stem connecting label to text (default: true)
+/// - height (length): stem length — controls how far above the text the label sits (default: 2em)
+/// - lift (length): gap between stem bottom and text baseline (default: 0.8em)
+/// - gap (length): horizontal gap around the annotation (default: 0.22em)
+/// - en-dash (bool): render dashes as en-dashes instead of non-breaking hyphens (default: true)
+///
+/// Example:
+/// ```
+/// You're a we#int("*L")rewolf?#h(2em)#int("H%", line: false)
+/// ```
+#let int = int
+
+/// Draw a feature-geometry tree for a consonant or vocoid
+///
+/// Produces a hierarchical diagram following Clements & Hume (1995)
+/// or Sagey (1986). All feature nodes are optional; parent nodes are inferred
+/// automatically from their children.
+///
+/// Use `ph` to load a built-in segment preset and optionally override individual
+/// features. The preset label (e.g. "/i/") is shown above the root unless
+/// `segment` is given.
+///
+/// Arguments:
+/// - ph (str): Segment preset key in tipa-style notation. Supported segments
+/// include common vowels (a e i o u E O I U y W 7 \o \oe 2 A 6 @ 1 0 \ae)
+/// and consonants (p b t d k g f v s z S Z n m N \N j h ? T D x G F B V M
+/// \:t \:d \:s \:z \:n r l J C \T). Wrap in slashes/brackets to override the
+/// auto segment label: `ph: "/a/"`.
+/// - model (str): `"ch"` (default) for Clements & Hume 1995 (aperture nodes for
+/// height); `"sagey"` for Sagey 1986 (dorsal sub-features for height/backness,
+/// `[round]` under labial). Affects preset vowels only; consonant presets are
+/// identical in both models.
+/// - ui-lang (string): UI label language. Supported aliases: en/english, fr/french,
+/// pt/portuguese (default: "en")
+/// - root (array): Root matrix features, e.g. `("+son", "-vocoid")`.
+/// - laryngeal (bool): Show "laryngeal" class node explicitly.
+/// - nasal (bool, str): `[nasal]`. Values: `true` → `[nasal]`,
+/// `"+"` → `[+nasal]`, `"-"` → `[−nasal]`.
+/// - spread (bool): `[spread glottis]` under laryngeal.
+/// - constricted (bool): `[constricted glottis]` under laryngeal.
+/// - voice (bool, str): `[voice]` under laryngeal. Same sign convention as `nasal`.
+/// - continuant (bool, str): `[continuant]` under oral cavity. Same sign convention.
+/// Pass an array of two values for affricates: `continuant: ("-", "+")`.
+/// - labial (bool, array): `[labial]`. Array adds sub-features:
+/// `labial: ("round",)`.
+/// - coronal (bool, array): `[coronal]`. Array replaces `anterior`/`distributed`:
+/// `coronal: ("+ant", "-distr")`.
+/// - anterior (bool, str): `[anterior]` under coronal. Same sign convention.
+/// - distributed (bool): `[distributed]` under coronal.
+/// - dorsal (bool, array): `[dorsal]`. Array adds sub-features (Sagey-style):
+/// `dorsal: ("+hi", "-back")`.
+/// - radical (bool): `[rad]` (pharyngeal/radical place).
+/// - vocalic (bool): Show "vocalic" class node (vocoid branch).
+/// - vplace (bool): Show "V-place" under vocalic. Inferred automatically when
+/// vocalic is active and any place feature is supplied.
+/// - aperture (bool, array): "aperture" node under vocalic (CH model). Array of
+/// up to 3 values controls `[open1]`/`[open2]`/`[open3]`:
+/// `aperture: ("-", "+", "-")` → close-mid height.
+/// - tense (bool, str): `[tense]` under vocalic. Same sign convention as `nasal`.
+/// - scale (number): Uniform scale factor (default: 1.0).
+/// - position (array): Manual layout tweaks. Each entry: `("node-key", dx, dy)`.
+/// Node keys are bare argument names, e.g. `"continuant"`, `"oral-cavity"`.
+/// - delinks (array): Node keys whose line to their parent is replaced with a
+/// delink mark, e.g. `delinks: ("c-place",)`.
+/// - segment (content): Label shown above root. Defaults to the `ph` value
+/// wrapped in slashes when `ph` is set.
+/// - prefix (string): Prefix text before the segment label (default: "")
+/// - suffix (string): Suffix text after the segment label (default: "")
+/// - highlight (array): Node names to highlight; all others are dimmed.
+/// - timing (auto, false, array, or string): Timing tier specification. `auto` infers
+/// from `ph` (e.g., long vowels get two timing slots), `false` hides the tier (default: auto)
+///
+/// Returns: CeTZ drawing of the feature-geometry tree
+///
+/// Examples:
+/// ```
+/// // Preset segment
+/// #geom(ph: "i")
+///
+/// // Manual consonant: voiceless alveolar stop
+/// #geom(root: ("-son", "-approx", "-vocoid"),
+/// coronal: true, anterior: "+", voice: "-", continuant: "-",
+/// segment: "/t/")
+///
+/// // Sagey-model vowel /y/
+/// #geom(ph: "y", model: "sagey", scale: 1.2)
+/// ```
+#let geom = geom
+
+/// Draw two or more feature-geometry trees side by side with optional inter-tree arrows
+///
+/// Each tree is specified as a spec dict (the same keys as `geom()`, all optional).
+/// The `model` and `scale` parameters apply uniformly to all trees.
+///
+/// Cross-tree arrows connect nodes by their anchor names. Node names are formed
+/// by lowercasing the argument name, replacing spaces with hyphens, and appending
+/// the 1-based tree index: `"labial1"`, `"oral-cavity2"`, `"c-place1"`.
+///
+/// Arguments:
+/// - ..trees (arguments): Positional spec dicts, one per tree. Each may include
+/// a `ph` key to load a preset, plus any `geom()` keys to override features.
+/// A per-tree `scale` key (number) scales that tree's coordinates and font
+/// size relative to the group `scale`.
+/// - arrows (array): Cross-tree arrows. Each entry is either `(from, to)` or a
+/// dict `(from: str, to: str, color: color, ctrl: (number, number))`.
+/// - `ctrl`: two Y-lifts `(lift1, lift2)`, one per endpoint. Positive lifts
+/// the departure upward; negative dips the arrival below the target.
+/// Overrides `curved` when set.
+/// All keys except `from`/`to` are optional.
+/// - gap (number): Canvas-unit gap between trees (default: 1.5).
+/// - scale (number): Uniform scale factor for the whole group (default: 1.0).
+/// - ui-lang (string): UI label language. Supported aliases: en/english, fr/french,
+/// pt/portuguese (default: "en")
+/// - model (str): `"ch"` (default) or `"sagey"`. Applies to all trees.
+/// - position (array): Layout tweaks. Each entry: `("node-key-with-index", dx, dy)`,
+/// e.g. `("continuant1", -0.2, 0.3)`. Arrows follow the adjusted positions.
+/// - delinks (array): Node anchor names (with tree index) whose parent line is
+/// replaced with a delink mark, e.g. `delinks: ("c-place1",)`.
+/// - curved (bool): Draw arrows as obstacle-avoiding Bézier curves (default: false).
+/// - highlight (array): Node anchor names to highlight; all others are dimmed.
+///
+/// Returns: CeTZ drawing of all trees in one canvas
+///
+/// Example:
+/// ```
+/// // Spreading: nasal spreading from n to a
+/// #geom-group(
+/// (ph: "a"),
+/// (ph: "n"),
+/// arrows: ((from: "nasal2", to: "root1", ctrl: (1.1, -1.5)),),
+/// curved: true,
+/// )
+/// ```
+#let geom-group = geom-group
diff --git a/packages/preview/phonokit/0.5.11/multi-tier.typ b/packages/preview/phonokit/0.5.11/multi-tier.typ
new file mode 100644
index 0000000000..fe97fac79b
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/multi-tier.typ
@@ -0,0 +1,510 @@
+// Multi-tier phonological representations (CV phonology, skeletal tiers, etc.)
+// Part of phonokit package
+
+#import "@preview/cetz:0.5.2"
+#import "_config.typ": phonokit-font
+#import "ipa.typ": ipa as ipa-convert
+
+#let multi-tier(
+ levels: (),
+ links: (),
+ dashed: (),
+ delinks: (),
+ arrows: (),
+ arrow-delinks: (),
+ float: (),
+ highlight: (),
+ ipa: (),
+ tier-labels: (),
+ spacing: 1.5,
+ level-spacing: 1.2,
+ stroke-width: 0.05em,
+ baseline: 40%,
+ scale: 1.0,
+ show-grid: false,
+ show-refs: false,
+) = {
+ // Validate input
+ assert(type(levels) == array, message: "levels must be an array of arrays")
+ assert(levels.len() > 0, message: "levels array cannot be empty")
+
+ // Convert labels: "x" → "×" (skeletal slot), Greek names → Unicode, trailing digits → subscripts
+ let greek-map = (
+ "sigma": "σ",
+ "Sigma": "Σ",
+ "mu": "μ",
+ "omega": "ω",
+ "Omega": "Ω",
+ "beta": "β",
+ "alpha": "α",
+ "gamma": "γ",
+ "delta": "δ",
+ "phi": "φ",
+ "Phi": "Φ",
+ "pi": "π",
+ "tau": "τ",
+ "lambda": "λ",
+ )
+
+ let render-label(label) = {
+ // Greek letter substitution (applied before digit subscripting so "sigma1" → "σ₁")
+ let m-greek = label.match(regex("^([A-Za-z]+?)(\d*)$"))
+ if m-greek != none {
+ let base = m-greek.captures.at(0)
+ let trail = m-greek.captures.at(1)
+ if base in greek-map {
+ label = greek-map.at(base) + trail
+ }
+ }
+
+ let label = label.replace(regex("^x$"), "×")
+
+ let m = label.match(regex("^(.*?)(\d+)$"))
+ if m != none {
+ let base = m.captures.at(0)
+ let digits = m.captures.at(1)
+ let subscript-map = (
+ "0": "₀",
+ "1": "₁",
+ "2": "₂",
+ "3": "₃",
+ "4": "₄",
+ "5": "₅",
+ "6": "₆",
+ "7": "₇",
+ "8": "₈",
+ "9": "₉",
+ )
+ let sub = digits.clusters().map(d => subscript-map.at(d, default: d)).join()
+ base + sub
+ } else {
+ label
+ }
+ }
+
+ // Normalize a visible label into an automatic reference stem.
+ // Examples: "N1" -> "n1", "C'" -> "cbar", "sigma" -> "sigma".
+ let ref-stem(label) = {
+ if type(label) != str { return none }
+
+ let stem = lower(label.trim())
+ if stem == "" { return none }
+
+ stem = stem.replace("'", "bar").replace("\u{2019}", "bar")
+ stem = stem.replace(regex("[^a-z0-9]+"), "-")
+ stem = stem.replace(regex("^-+"), "")
+ stem = stem.replace(regex("-+$"), "")
+
+ if stem == "" { none } else { stem }
+ }
+
+ // Parse levels into a grid of (label, x-position, y-level) tuples
+ // Entries can be:
+ // "label" → label at (array col index, array level index)
+ // ("label", col) → fractional column, normal level
+ // ("label", col, level) → fractional column, fractional level
+ // "" → empty slot
+ let parsed-grid = levels
+ .enumerate()
+ .map(((level-idx, row)) => {
+ row
+ .enumerate()
+ .map(((col-idx, entry)) => {
+ if type(entry) == array {
+ let col-pos = entry.at(1)
+ let level-pos = entry.at(2, default: level-idx)
+ (label: entry.at(0), col-x: col-pos, level-pos: level-pos)
+ } else {
+ (label: entry, col-x: col-idx, level-pos: level-idx)
+ }
+ })
+ })
+
+ let ref-counts = (:)
+ let name-to-node = (:)
+ let tier-grid = ()
+
+ for (level-idx, row) in parsed-grid.enumerate() {
+ let tier-row = ()
+ for (col-idx, cell) in row.enumerate() {
+ let stem = ref-stem(cell.label)
+ let ref-name = if cell.label == "" or stem == none {
+ none
+ } else {
+ let count = ref-counts.at(stem, default: 0) + 1
+ ref-counts.insert(stem, count)
+ let key = stem + str(count)
+ name-to-node.insert(key, (level-idx, col-idx))
+ key
+ }
+
+ tier-row.push((..cell, ref-name: ref-name))
+ }
+ tier-grid.push(tier-row)
+ }
+
+ let resolve-node-ref(ref, arg-name: "reference") = {
+ if type(ref) == str {
+ assert(ref in name-to-node, message: arg-name + " references unknown multi-tier node `" + ref + "`")
+ name-to-node.at(ref)
+ } else {
+ assert(type(ref) == array and ref.len() == 2, message: arg-name + " node references must be `(level, col)` or a string key")
+ (ref.at(0), ref.at(1))
+ }
+ }
+
+ let resolve-link-ref(link, arg-name: "reference") = {
+ assert(type(link) == array and link.len() == 2, message: arg-name + " entries must contain exactly two node references")
+ (
+ resolve-node-ref(link.at(0), arg-name: arg-name),
+ resolve-node-ref(link.at(1), arg-name: arg-name),
+ )
+ }
+
+ let resolved-float = float.map(item => resolve-node-ref(item, arg-name: "float"))
+ let resolved-highlight = highlight.map(item => resolve-node-ref(item, arg-name: "highlight"))
+ let resolved-links = links.map(item => resolve-link-ref(item, arg-name: "links"))
+ let resolved-dashed = dashed.map(item => resolve-link-ref(item, arg-name: "dashed"))
+ let resolved-delinks = delinks.map(item => resolve-link-ref(item, arg-name: "delinks"))
+ let resolved-arrows = arrows.map(item => resolve-link-ref(item, arg-name: "arrows"))
+
+ let num-levels = tier-grid.len()
+
+ // Find the rightmost column x-position across all levels (for tier label placement)
+ let max-col-x = calc.max(..tier-grid.map(row => calc.max(..row.map(cell => cell.col-x))))
+
+ // Bind parameters to avoid shadowing built-in/imported names inside CeTZ closure
+ let scale-factor = scale
+ let sw = stroke-width * scale-factor
+ let ipa-levels = ipa
+ let refs-visible = show-refs
+
+ // Vertical offsets from level center (following prosody.typ pattern)
+ let text-above = 0.30
+ let text-below = -0.58
+ let line-top = 0.42
+ let line-bot = -0.18
+
+ box(inset: 1.2em, baseline: baseline, cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ let debug-ref(level, col, cell) = {
+ if cell.ref-name != none {
+ cell.ref-name
+ } else if cell.label != "" {
+ "(" + str(level) + ", " + str(col) + ")"
+ } else {
+ none
+ }
+ }
+
+ // Helper: y-coordinate for a level index (used for default positioning)
+ let level-y(level) = -level * level-spacing
+
+ // Helper: get a cell's actual position (respects fractional col and level overrides)
+ let cell-x(level, col) = tier-grid.at(level).at(col).col-x * spacing
+ let cell-y(level, col) = -tier-grid.at(level).at(col).level-pos * level-spacing
+
+ // Helper: line departure point (bottom of label)
+ let bot-point(level, col) = {
+ (cell-x(level, col), cell-y(level, col) + line-bot)
+ }
+
+ // Helper: line arrival point (top of label)
+ let top-point(level, col) = {
+ (cell-x(level, col), cell-y(level, col) + line-top)
+ }
+
+ // Determine line attachment points for a pair of positions
+ let line-endpoints(l1, c1, l2, c2) = {
+ if l1 == l2 {
+ (bot-point(l1, c1), bot-point(l2, c2))
+ } else if l1 < l2 {
+ (bot-point(l1, c1), top-point(l2, c2))
+ } else {
+ (bot-point(l2, c2), top-point(l1, c1))
+ }
+ }
+
+ // === Layer 0: Debug grid (behind everything) ===
+ if show-grid {
+ let grid-color = luma(180)
+ let grid-stroke = (paint: grid-color, thickness: 0.3pt, dash: "dashed")
+
+ let max-col = int(max-col-x) + 1
+ let pad = 0.6
+
+ // Vertical lines for each integer column
+ for col in range(max-col) {
+ let x = col * spacing
+ line(
+ (x, pad),
+ (x, -(num-levels - 1) * level-spacing - pad),
+ stroke: grid-stroke,
+ )
+ }
+
+ // Horizontal lines for each level
+ for level-i in range(num-levels) {
+ let y = level-y(level-i)
+ line(
+ (-pad, y),
+ ((max-col - 1) * spacing + pad, y),
+ stroke: grid-stroke,
+ )
+ }
+
+ // Column index labels (above the diagram)
+ for col in range(max-col) {
+ content(
+ (col * spacing, pad + 0.3),
+ text(size: 0.9em * scale-factor, fill: grid-color, font: "Courier New", str(col)),
+ )
+ }
+
+ // Level index labels (left of the diagram)
+ for level-i in range(num-levels) {
+ content(
+ (-pad - 0.4, level-y(level-i)),
+ text(size: 0.9em * scale-factor, fill: grid-color, font: "Courier New", str(level-i)),
+ )
+ }
+
+ // Column index labels (below the diagram)
+ for col in range(max-col) {
+ content(
+ (col * spacing, -(num-levels - 1) * level-spacing - pad - 0.3),
+ text(size: 0.9em * scale-factor, fill: grid-color, font: "Courier New", str(col)),
+ )
+ }
+
+ // Level index labels (right of the diagram)
+ for level-i in range(num-levels) {
+ content(
+ ((max-col - 1) * spacing + pad + 0.4, level-y(level-i)),
+ text(size: 0.9em * scale-factor, fill: grid-color, font: "Courier New", str(level-i)),
+ )
+ }
+
+ // Dots at every grid intersection
+ for level-i in range(num-levels) {
+ for col in range(max-col) {
+ circle(
+ (col * spacing, level-y(level-i)),
+ radius: 0.05,
+ fill: grid-color,
+ stroke: none,
+ )
+ }
+ }
+ }
+
+ // === Layer 1: Auto-link lines (between adjacent-level same-column non-empty cells) ===
+ for level in range(num-levels - 1) {
+ let row-top = tier-grid.at(level)
+ let row-bot = tier-grid.at(level + 1)
+ let cols = calc.min(row-top.len(), row-bot.len())
+
+ for col in range(cols) {
+ if row-top.at(col).label == "" or row-bot.at(col).label == "" { continue }
+ if (level, col) in resolved-float or (level + 1, col) in resolved-float { continue }
+
+ let (p1, p2) = line-endpoints(level, col, level + 1, col)
+ line(p1, p2, stroke: sw)
+ }
+ }
+
+ // === Layer 2: Extra solid links (skip any that also appear in dashed) ===
+ for link in resolved-links {
+ if link in resolved-dashed { continue }
+ let ((l1, c1), (l2, c2)) = link
+ let (p1, p2) = line-endpoints(l1, c1, l2, c2)
+ line(p1, p2, stroke: sw)
+ }
+
+ // === Layer 3: Dashed lines ===
+ for d in resolved-dashed {
+ let ((l1, c1), (l2, c2)) = d
+ let (p1, p2) = line-endpoints(l1, c1, l2, c2)
+ line(p1, p2, stroke: (dash: "dashed", thickness: sw))
+ }
+
+ // === Layer 4: Highlight circles (drawn behind labels, centered on text) ===
+ for (level-idx, col-idx) in resolved-highlight {
+ let cell = tier-grid.at(level-idx).at(col-idx)
+ if cell.label == "" { continue }
+
+ let x = cell-x(level-idx, col-idx)
+ let y = cell-y(level-idx, col-idx) + text-above / 3
+
+ // White mask circle (slightly larger, masks lines behind it)
+ circle((x, y), radius: 0.45, stroke: none, fill: white)
+ // Visible highlight circle
+ circle((x, y), radius: 0.35, stroke: sw, fill: none)
+ }
+
+ // === Layer 5: Labels (identical rendering regardless of highlight) ===
+ for (level-idx, row) in tier-grid.enumerate() {
+ for (col-idx, cell) in row.enumerate() {
+ if cell.label == "" { continue }
+
+ let x = cell-x(level-idx, col-idx)
+ let y = cell-y(level-idx, col-idx) + text-above
+
+ let is-ipa = level-idx in ipa-levels
+
+ let label-content = if is-ipa {
+ context text(font: phonokit-font.get(), ipa-convert(cell.label))
+ } else if type(cell.label) == str {
+ let rendered = render-label(cell.label)
+ context text(font: phonokit-font.get(), rendered)
+ } else {
+ context text(font: phonokit-font.get(), cell.label)
+ }
+
+ content(
+ (x, y),
+ anchor: "north",
+ text(size: 1em * scale-factor, box(
+ inset: 0.15em,
+ align(center + horizon, label-content),
+ )),
+ )
+ }
+ }
+
+ // === Layer 5: Tier labels (right-aligned, to the right of the diagram) ===
+ for tl in tier-labels {
+ let (level-idx, label-text) = tl
+ let x = (max-col-x + 1.5) * spacing
+ let y = level-y(level-idx) + text-above
+
+ content(
+ (x, y),
+ anchor: "north-west",
+ text(size: 1em * scale-factor, context text(font: phonokit-font.get(), label-text)),
+ )
+ }
+
+ // === Layer 5: Debug references (below labels) ===
+ if refs-visible {
+ let ref-color = luma(155)
+
+ for (level-idx, row) in tier-grid.enumerate() {
+ for (col-idx, cell) in row.enumerate() {
+ let ref-label = debug-ref(level-idx, col-idx, cell)
+ if ref-label == none { continue }
+
+ let x = cell-x(level-idx, col-idx)
+ let y = cell-y(level-idx, col-idx) + text-below
+
+ content(
+ (x, y),
+ anchor: "south",
+ text(
+ size: 0.75em * scale-factor,
+ fill: ref-color,
+ font: "Courier New",
+ ref-label,
+ ),
+ )
+ }
+ }
+ }
+
+ // === Layer 6: Delink cross marks (on top of everything) ===
+ for d in resolved-delinks {
+ let ((l1, c1), (l2, c2)) = d
+
+ let (p1, p2) = line-endpoints(l1, c1, l2, c2)
+ let (x1, y1) = p1
+ let (x2, y2) = p2
+
+ let mid-x = (x1 + x2) / 2
+ let mid-y = (y1 + y2) / 2
+
+ let dx = x2 - x1
+ let dy = y2 - y1
+ let length = calc.sqrt(dx * dx + dy * dy)
+
+ if length == 0 { continue }
+
+ let dir-x = dx / length
+ let dir-y = dy / length
+
+ let perp-x = -dir-y
+ let perp-y = dir-x
+
+ let offset = 0.15
+ let spacing-offset = 0.06
+
+ let p1-start = (
+ mid-x - offset * perp-x - spacing-offset * dir-x,
+ mid-y - offset * perp-y - spacing-offset * dir-y,
+ )
+ let p1-end = (
+ mid-x + offset * perp-x - spacing-offset * dir-x,
+ mid-y + offset * perp-y - spacing-offset * dir-y,
+ )
+
+ let p2-start = (
+ mid-x - offset * perp-x + spacing-offset * dir-x,
+ mid-y - offset * perp-y + spacing-offset * dir-y,
+ )
+ let p2-end = (
+ mid-x + offset * perp-x + spacing-offset * dir-x,
+ mid-y + offset * perp-y + spacing-offset * dir-y,
+ )
+
+ line(p1-start, p1-end, stroke: sw)
+ line(p2-start, p2-end, stroke: sw)
+ }
+
+ // === Layer 7: Arrows (rectangular paths above top / below bottom level) ===
+ let arrow-clearance = 0.5
+
+ for (arrow-idx, a) in resolved-arrows.enumerate() {
+ let ((l1, c1), (l2, c2)) = a
+
+ let is-top = l1 == 0 and l2 == 0
+ let is-bot = l1 == num-levels - 1 and l2 == num-levels - 1
+
+ let x1 = cell-x(l1, c1)
+ let x2 = cell-x(l2, c2)
+
+ if is-top {
+ let y1 = cell-y(l1, c1) + line-top
+ let y2 = cell-y(l2, c2) + line-top
+ let y-bar = level-y(0) + line-top + arrow-clearance
+
+ line((x1, y1), (x1, y-bar), stroke: sw)
+ line((x1, y-bar), (x2, y-bar), stroke: sw)
+ line((x2, y-bar), (x2, y2), stroke: sw, mark: (end: "stealth"), fill: black)
+
+ if arrow-idx in arrow-delinks {
+ let mid-x = (x1 + x2) / 2
+ let cross-h = 0.15
+ let cross-gap = 0.06
+ line((mid-x - cross-gap, y-bar - cross-h), (mid-x - cross-gap, y-bar + cross-h), stroke: sw)
+ line((mid-x + cross-gap, y-bar - cross-h), (mid-x + cross-gap, y-bar + cross-h), stroke: sw)
+ }
+ } else if is-bot {
+ let y1 = cell-y(l1, c1) + line-bot
+ let y2 = cell-y(l2, c2) + line-bot
+ let y-bar = level-y(num-levels - 1) + line-bot - arrow-clearance
+
+ line((x1, y1), (x1, y-bar), stroke: sw)
+ line((x1, y-bar), (x2, y-bar), stroke: sw)
+ line((x2, y-bar), (x2, y2), stroke: sw, mark: (end: "stealth"), fill: black)
+
+ if arrow-idx in arrow-delinks {
+ let mid-x = (x1 + x2) / 2
+ let cross-h = 0.15
+ let cross-gap = 0.06
+ line((mid-x - cross-gap, y-bar - cross-h), (mid-x - cross-gap, y-bar + cross-h), stroke: sw)
+ line((mid-x + cross-gap, y-bar - cross-h), (mid-x + cross-gap, y-bar + cross-h), stroke: sw)
+ }
+ }
+ }
+ }))
+}
diff --git a/packages/preview/phonokit/0.5.11/ot.typ b/packages/preview/phonokit/0.5.11/ot.typ
new file mode 100644
index 0000000000..f27c769ced
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/ot.typ
@@ -0,0 +1,1263 @@
+#import "ipa.typ": *
+#import "_config.typ": phonokit-font
+#import "prosody.typ": foot, foot-mora, mora, syllable, word, word-mora
+
+#let finger = text(size: 14pt)[☞]
+#let viol-sym = text(size: 1.2em)[#sym.ast]
+
+// Helper: Format constraint names with smallcaps, but not text inside brackets
+#let format-constraint(name) = {
+ // Match pattern: text before bracket, bracket content, text after
+ let bracket-match = name.match(regex("^([^\[]*)\[([^\]]*)\](.*)$"))
+
+ if bracket-match != none {
+ // Has brackets: smallcaps before, regular inside brackets, smallcaps after
+ let before = bracket-match.captures.at(0)
+ let inside = bracket-match.captures.at(1)
+ let after = bracket-match.captures.at(2)
+
+ // Compose the parts
+ [#smallcaps(before)\[#inside\]#if after != "" { smallcaps(after) }]
+ } else {
+ // No brackets: all smallcaps
+ smallcaps(name)
+ }
+}
+
+// --- Helper: Parse Violation String ---
+#let format-viol(v) = {
+ if v == "" { return [] }
+ let parts = ()
+ let stars = v.matches("*").len()
+ let fatal = v.contains("!")
+ for _ in range(stars) { parts.push(viol-sym) }
+ if fatal { parts.push(strong("!")) }
+ parts.join(h(1pt))
+}
+
+// --- Helper: Parse string with rich formatting ---
+// - _{...} or _x: subscript (not IPA-parsed)
+// - ^{...} or ^x: superscript (not IPA-parsed)
+// - {...}: raw text (not IPA-parsed)
+// - \\{ and \\}: literal brace characters
+// - \\,: literal comma (since , maps to secondary stress)
+// - everything else: IPA-parsed as usual
+#let _ends-with-space(s) = {
+ let cls = s.clusters()
+ cls.len() > 0 and cls.at(cls.len() - 1) == " "
+}
+
+#let parse-ot-string(s) = {
+ if type(s) != str { return s }
+
+ let clusters = s.clusters()
+ let parts = ()
+ let buf = ""
+ let i = 0
+ let len = clusters.len()
+
+ while i < len {
+ let ch = clusters.at(i)
+
+ if (
+ ch == "\\"
+ and i + 1 < len
+ and (clusters.at(i + 1) == "{" or clusters.at(i + 1) == "}" or clusters.at(i + 1) == ",")
+ ) {
+ let literal = clusters.at(i + 1)
+ let tighten = buf != "" and not _ends-with-space(buf)
+ if buf != "" {
+ parts.push(ipa(buf))
+ buf = ""
+ }
+ let tighten-after = literal == "{" and i + 2 < len and clusters.at(i + 2) != " "
+ let lit = context text(font: phonokit-font.get(), literal)
+ let before-kern = if literal == "}" { -0.3em } else { -0.25em }
+ let after-kern = if literal == "{" { -0.3em } else { -0.15em }
+ let lit = if tighten { [#h(before-kern)#lit] } else { lit }
+ parts.push(if tighten-after { [#lit#h(after-kern)] } else { lit })
+ i += 2
+ } else if (ch == "_" or ch == "^") and i + 1 < len {
+ let tighten = buf != "" and not _ends-with-space(buf)
+ if buf != "" {
+ parts.push(ipa(buf))
+ buf = ""
+ }
+ let is-sub = ch == "_"
+ i += 1
+ if clusters.at(i) == "{" {
+ i += 1
+ let group = ""
+ while i < len and clusters.at(i) != "}" {
+ group += clusters.at(i)
+ i += 1
+ }
+ if i < len { i += 1 }
+ let script = if is-sub {
+ sub(context text(font: phonokit-font.get(), group))
+ } else {
+ super(context text(font: phonokit-font.get(), group))
+ }
+ if is-sub {
+ parts.push(if tighten { [#h(-0.25em)#script] } else { script })
+ } else {
+ parts.push(if tighten { [#h(-0.25em)#script] } else { script })
+ }
+ } else {
+ let c = clusters.at(i)
+ i += 1
+ let script = if is-sub {
+ sub(context text(font: phonokit-font.get(), c))
+ } else {
+ super(context text(font: phonokit-font.get(), c))
+ }
+ if is-sub {
+ parts.push(if tighten { [#h(-0.25em)#script] } else { script })
+ } else {
+ parts.push(if tighten { [#h(-0.25em)#script] } else { script })
+ }
+ }
+ } else if ch == "{" {
+ let tighten = buf != "" and not _ends-with-space(buf)
+ if buf != "" {
+ parts.push(ipa(buf))
+ buf = ""
+ }
+ i += 1
+ let raw = ""
+ while i < len and clusters.at(i) != "}" {
+ raw += clusters.at(i)
+ i += 1
+ }
+ if i < len { i += 1 }
+ let raw-text = context text(font: phonokit-font.get(), raw)
+ parts.push(if tighten { [#h(-0.25em)#raw-text] } else { raw-text })
+ } else {
+ buf += ch
+ i += 1
+ }
+ }
+
+ if buf != "" { parts.push(ipa(buf)) }
+ if parts.len() == 0 { return [] }
+ parts.fold([], (acc, part) => [#acc#part])
+}
+
+// --- Helper: Dispatch prosody function by name ---
+#let dispatch-prosody(func-name, arg, ps) = {
+ if func-name == "syllable" { syllable(arg, scale: ps) } else if func-name == "mora" { mora(arg, scale: ps) } else if (
+ func-name == "foot"
+ ) { foot(arg, scale: ps) } else if func-name == "foot-mora" { foot-mora(arg, scale: ps) } else if (
+ func-name == "word"
+ ) { word(arg, scale: ps) } else if func-name == "word-mora" { word-mora(arg, scale: ps) } else { ipa(arg) }
+}
+
+// --- Helper: Parse candidate string for prosodic function calls ---
+#let prosody-pattern = regex("#(syllable|mora|foot-mora|foot|word-mora|word)\\('([^']*)'\\)")
+
+#let parse-candidate(cand, ps) = {
+ if type(cand) != str { return cand }
+
+ let all-matches = cand.matches(prosody-pattern)
+
+ if all-matches.len() == 0 {
+ return parse-ot-string(cand)
+ }
+
+ let parts = ()
+ let pos = 0
+
+ for m in all-matches {
+ if m.start > pos {
+ let before = cand.slice(pos, m.start).trim()
+ if before != "" and before != "+" {
+ parts.push(parse-ot-string(before))
+ }
+ }
+
+ let func-name = m.captures.at(0)
+ let arg = m.captures.at(1)
+ parts.push(dispatch-prosody(func-name, arg, ps))
+
+ pos = m.end
+ }
+
+ if pos < cand.len() {
+ let after = cand.slice(pos).trim()
+ if after != "" and after != "+" {
+ parts.push(parse-ot-string(after))
+ }
+ }
+
+ parts.join()
+}
+
+#let has-prosody(c) = {
+ if type(c) == str { c.matches(prosody-pattern).len() > 0 } else { type(c) == content }
+}
+
+// NOTE: --- The Main Function ---
+#let tableau(
+ input: "Input",
+ candidates: (),
+ constraints: (),
+ violations: (),
+ winner: 0,
+ dashed-lines: (),
+ scale: none,
+ shade: true,
+ prosody-scale: 0.5,
+ letters: false,
+ gloss: none,
+) = {
+ // 1. Validation and Truncation
+ assert(constraints.len() <= 20, message: "Maximum 20 constraints allowed in tableau")
+
+ // Truncate constraint names to 20 characters
+ let constraints = constraints.map(c => {
+ if c.len() > 20 { c.slice(0, 20) } else { c }
+ })
+
+ // Scale: use user-provided scale if given, otherwise auto-scale
+ let scale-factor = if scale != none {
+ scale
+ } else if constraints.len() > 6 {
+ 0.85
+ } else {
+ 1.0
+ }
+ let font-size = scale-factor * 1em
+ let scaled-finger = text(size: 14pt * scale-factor)[☞]
+
+ // 2. Shading Logic (only if enabled)
+ let fatal-map = ()
+ if shade {
+ for (r, row-viols) in violations.enumerate() {
+ let fatal-col = 999
+ for (c, cell) in row-viols.enumerate() {
+ if cell.contains("!") {
+ fatal-col = c
+ break
+ }
+ }
+ fatal-map.push(fatal-col)
+ }
+ // Winner shading: shade after the rightmost fatal column among all losers
+ if winner != none and winner < fatal-map.len() {
+ let loser-fatals = fatal-map.enumerate().filter(((r, fc)) => r != winner and fc != 999)
+ if loser-fatals.len() > 0 {
+ let max-fatal = calc.max(..loser-fatals.map(((_, fc)) => fc))
+ fatal-map.at(winner) = max-fatal
+ }
+ }
+ }
+
+ // 3. Prepare Input Content
+ let gloss-content = if gloss != none {
+ [ _#gloss.at(0)_ '#gloss.at(1)']
+ } else { [] }
+ let input-content = if type(input) == str {
+ [#parse-ot-string(input)#gloss-content]
+ } else {
+ [#input#gloss-content]
+ }
+
+ // 4. Grid Definitions
+ let letter-labels = "abcdefghijklmnopqrstuvwxyz"
+ let cons-start = 3 // first constraint column index
+ let row-defs = (1.75em, 2pt) + candidates.map(c => if has-prosody(c) { auto } else { 1.75em })
+
+ context {
+ let text-style(it) = text(size: font-size, font: phonokit-font.get(), it)
+
+ // Measure input-content (it spans col 0 and 1)
+ // Inset for spanned cell: (left: 5pt, right: 10pt) -> 15pt total
+ let w-input = measure(text-style(input-content)).width + 15pt
+
+ // Measure Col 0 (Prefix/Finger)
+ // Inset for Col 0: (left: 5pt, right: 0pt) -> 5pt total
+ let w-col0-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let finger-content = if i == winner { scaled-finger + " " } else { "" }
+ let it = if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ [#finger-content #letter.]
+ } else {
+ [#finger-content]
+ }
+ w-col0-max = calc.max(w-col0-max, measure(text-style(it)).width)
+ }
+ w-col0-max += 5pt
+
+ // Measure Col 1 (Candidate)
+ // Inset for Col 1: (left: 8pt, right: 10pt) -> 18pt total
+ let w-col1-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let cand-content = parse-candidate(cand, prosody-scale)
+ w-col1-max = calc.max(w-col1-max, measure(text-style(cand-content)).width)
+ }
+ w-col1-max += 18pt
+
+ // Distribution: if input is wider than col0+col1, stretch col0
+ let w0 = w-col0-max
+ let w1 = w-col1-max
+ if w-input > w0 + w1 {
+ w0 = w-input - w1
+ }
+
+ let col-defs = (w0, w1, 2pt) + constraints.map(_ => auto)
+
+ text-style[#table(
+ columns: col-defs,
+ rows: row-defs,
+ align: (col, row) => {
+ let v-align = bottom
+ if row >= 2 {
+ if col <= 1 {
+ if has-prosody(candidates.at(row - 2)) { v-align = horizon }
+ } else {
+ v-align = horizon
+ }
+ }
+ if col <= 1 { right + v-align } else { center + v-align }
+ },
+ inset: (col, row) => if col == 0 {
+ (left: 5pt, top: 5pt, bottom: 5pt, right: 0pt)
+ } else if col == 1 {
+ (left: 8pt, top: 5pt, bottom: 5pt, right: 10pt)
+ } else { 5pt },
+
+ stroke: (col, row) => {
+ let s = 0.4pt + black
+ if col == 0 { return (left: s, top: s, bottom: s, right: none) }
+ if col == 1 { return (left: none, top: s, bottom: s, right: s) }
+ let is-dashed = if col >= cons-start { dashed-lines.contains(col - cons-start) } else { false }
+ (
+ left: s,
+ top: s,
+ bottom: s,
+ right: if is-dashed { (thickness: 0.4pt, dash: "dashed") } else { s },
+ )
+ },
+
+ fill: (col, row) => {
+ if not shade or row < 2 or col < cons-start { return none }
+ let cand-idx = row - 2
+ let cons-idx = col - cons-start
+ if cand-idx < fatal-map.len() {
+ let fatal-col = fatal-map.at(cand-idx)
+ if cons-idx > fatal-col {
+ let has-solid-line = false
+ for c in range(fatal-col, cons-idx) {
+ if not dashed-lines.contains(c) {
+ has-solid-line = true
+ break
+ }
+ }
+ if has-solid-line { return luma(230) }
+ }
+ }
+ return none
+ },
+
+ // --- Content ---
+ table.cell(colspan: 2, inset: (left: 5pt, right: 10pt), align: right + bottom, input-content),
+ [],
+ ..constraints.map(c => format-constraint(c)),
+
+ // Gap Row
+ ..range(col-defs.len()).map(_ => []),
+
+ // Candidates
+ ..candidates
+ .enumerate()
+ .map(((i, cand)) => {
+ let cells = ()
+ let cand-content = parse-candidate(cand, prosody-scale)
+
+ let finger-content = if i == winner { scaled-finger + " " } else { "" }
+ if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ cells.push([#finger-content #letter.])
+ } else {
+ cells.push([#finger-content])
+ }
+ cells.push([#cand-content])
+ cells.push([])
+
+ let row-viols = if i < violations.len() { violations.at(i) } else { () }
+ for j in range(constraints.len()) {
+ if j < row-viols.len() {
+ cells.push(format-viol(row-viols.at(j)))
+ } else {
+ cells.push([])
+ }
+ }
+ return cells
+ })
+ .flatten()
+ )]
+ }
+}
+
+// NOTE: --- HG TABLEAU FUNCTION ---
+#let hg(
+ input: "Input",
+ candidates: (),
+ constraints: (),
+ weights: (),
+ violations: (),
+ scale: none,
+ letters: false,
+) = {
+ // 1. Validation and Truncation
+ assert(constraints.len() <= 20, message: "Maximum 20 constraints allowed")
+ let letter-labels = "abcdefghijklmnopqrstuvwxyz"
+
+ // Truncate constraint names to 15 characters
+ let constraints = constraints.map(c => {
+ if c.len() > 15 { c.slice(0, 15) } else { c }
+ })
+
+ // Scale: use user-provided scale if given, otherwise auto-scale
+ let font-size = if scale != none {
+ scale * 1em
+ } else if constraints.len() > 6 {
+ 0.85em
+ } else {
+ 0.95em
+ }
+
+ // 2. CALCULATIONS
+ let h-scores = ()
+
+ for row-viols in violations {
+ let h = 0.0
+ for (i, v) in row-viols.enumerate() {
+ if i < weights.len() {
+ h += float(v) * float(weights.at(i))
+ }
+ }
+ h-scores.push(h)
+ }
+
+ // 3. GRID DEFINITIONS (simpler than maxent - only h(y) column)
+ let row-defs = (auto, 1.75em, 2pt) + candidates.map(c => if has-prosody(c) { auto } else { 1.75em })
+
+ context {
+ let text-style(it) = text(size: font-size, font: phonokit-font.get(), it)
+ let input-content = if type(input) == str { parse-ot-string(input) } else { input }
+
+ // Measure input-content (it spans col 0 and 1)
+ let w-input = measure(text-style(input-content)).width + 15pt
+
+ // Measure Col 0 (Prefix)
+ let w-col0-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let it = if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ [#letter.]
+ } else {
+ []
+ }
+ w-col0-max = calc.max(w-col0-max, measure(text-style(it)).width)
+ }
+ w-col0-max += 5pt
+
+ // Measure Col 1 (Candidate)
+ let w-col1-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let cand-content = parse-candidate(cand, 0.5)
+ w-col1-max = calc.max(w-col1-max, measure(text-style(cand-content)).width)
+ }
+ w-col1-max += 18pt
+
+ // Distribution
+ let w0 = w-col0-max
+ let w1 = w-col1-max
+ if w-input > w0 + w1 {
+ w0 = w-input - w1
+ }
+
+ let col-defs = (w0, w1, 2pt) + constraints.map(_ => auto) + (2pt, auto)
+
+ text-style[#table(
+ columns: col-defs,
+ rows: row-defs,
+ align: (col, row) => {
+ let v-align = bottom
+ if row >= 3 {
+ if col <= 1 {
+ if has-prosody(candidates.at(row - 3)) { v-align = horizon }
+ } else {
+ v-align = horizon
+ }
+ }
+ if col <= 1 { right + v-align } else { center + v-align }
+ },
+ inset: (col, row) => if col == 0 {
+ (left: 5pt, top: 5pt, bottom: 5pt, right: 0pt)
+ } else if col == 1 {
+ (left: 8pt, top: 5pt, bottom: 5pt, right: 10pt)
+ } else { 5pt },
+
+ // --- STROKE LOGIC ---
+ stroke: (col, row) => {
+ if row == 0 { return none }
+ let s = 0.4pt + black
+ if col == 0 { return (left: s, top: s, bottom: s, right: none) }
+ if col == 1 { return (left: none, top: s, bottom: s, right: s) }
+ (left: s, top: s, bottom: s, right: s)
+ },
+
+ // --- ROW 0: WEIGHTS
+ [], [], [],
+ ..weights.map(w => text(size: 0.9em)[$w=#w$]),
+ // Fill remaining columns: gap (2pt) + h(y) column
+ [], [],
+
+ // --- ROW 1: HEADERS ---
+ table.cell(colspan: 2, inset: (left: 5pt, right: 10pt), align: right + bottom, input-content),
+ [],
+ ..constraints.map(c => format-constraint(c)),
+ [],
+ [$h_i$],
+
+ // --- ROW 2: GAP ---
+ ..range(col-defs.len()).map(_ => []),
+
+ // --- ROWS 3+: CANDIDATES ---
+ ..candidates
+ .enumerate()
+ .map(((i, cand)) => {
+ let cells = ()
+ let cand-content = parse-candidate(cand, 0.5)
+
+ if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ cells.push([#letter.])
+ } else {
+ cells.push([])
+ }
+ cells.push([#cand-content])
+ cells.push([])
+
+ // Violations
+ let row-viols = if i < violations.len() { violations.at(i) } else { () }
+ for j in range(constraints.len()) {
+ if j < row-viols.len() { cells.push(text(size: 0.85em)[#str(row-viols.at(j))]) } else { cells.push([]) }
+ }
+
+ cells.push([])
+
+ // Only h(y) column - no MaxEnt probabilities
+ if i < h-scores.len() {
+ cells.push(text(size: 0.85em)[#str(calc.round(h-scores.at(i), digits: 2))])
+ } else {
+ cells.push("-")
+ }
+
+ return cells
+ })
+ .flatten()
+ )]
+ }
+}
+
+// NOTE: --- NHG DEMO TABLEAU (Pedagogical - shows symbolic noise) ---
+#let nhg-demo(
+ input: "Input",
+ candidates: (),
+ constraints: (),
+ weights: (),
+ violations: (),
+ probabilities: none, // optional: if provided, will display P(y) values
+ scale: none,
+ letters: false,
+) = {
+ // 1. Validation and Truncation
+ assert(constraints.len() <= 20, message: "Maximum 20 constraints allowed")
+ let letter-labels = "abcdefghijklmnopqrstuvwxyz"
+
+ // Truncate constraint names to 15 characters
+ let constraints = constraints.map(c => {
+ if c.len() > 15 { c.slice(0, 15) } else { c }
+ })
+
+ // Scale: use user-provided scale if given, otherwise auto-scale
+ let font-size = if scale != none {
+ scale * 1em
+ } else if constraints.len() > 6 {
+ 0.85em
+ } else {
+ 0.95em
+ }
+
+ // 2. CALCULATIONS
+ let h-scores = ()
+ let epsilon-formulas = ()
+
+ for row-viols in violations {
+ // Calculate deterministic harmony h(y)
+ let h = 0.0
+ for (i, v) in row-viols.enumerate() {
+ if i < weights.len() {
+ h += float(v) * float(weights.at(i))
+ }
+ }
+ h-scores.push(h)
+
+ // Build epsilon formula: ε(y) = Σ(n_i × v_i)
+ let epsilon-parts = ()
+ for (i, v) in row-viols.enumerate() {
+ if i < constraints.len() and v != 0 {
+ let v-val = float(v)
+ let abs-v = calc.abs(v-val)
+ let sign = if v-val < 0 { "-" } else { "" }
+ let coef = if abs-v == 1 { "" } else { str(int(abs-v)) }
+ epsilon-parts.push(sign + coef + "n_" + str(i + 1))
+ }
+ }
+
+ // Join epsilon parts and create single math expression
+ let epsilon-formula = if epsilon-parts.len() > 0 {
+ let formula-str = epsilon-parts.join(" ")
+ eval("$" + formula-str + "$")
+ } else {
+ $0$
+ }
+ epsilon-formulas.push(epsilon-formula)
+ }
+
+ // 3. GRID DEFINITIONS (h, ε, P columns)
+ let row-defs = (auto, 1.75em, 2pt) + candidates.map(c => if has-prosody(c) { auto } else { 1.75em })
+
+ context {
+ let text-style(it) = text(size: font-size, font: phonokit-font.get(), it)
+ let input-content = if type(input) == str { parse-ot-string(input) } else { input }
+
+ // Measure input-content (it spans col 0 and 1)
+ let w-input = measure(text-style(input-content)).width + 15pt
+
+ // Measure Col 0 (Prefix)
+ let w-col0-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let it = if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ [#letter.]
+ } else {
+ []
+ }
+ w-col0-max = calc.max(w-col0-max, measure(text-style(it)).width)
+ }
+ w-col0-max += 5pt
+
+ // Measure Col 1 (Candidate)
+ let w-col1-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let cand-content = parse-candidate(cand, 0.5)
+ w-col1-max = calc.max(w-col1-max, measure(text-style(cand-content)).width)
+ }
+ w-col1-max += 18pt
+
+ // Distribution
+ let w0 = w-col0-max
+ let w1 = w-col1-max
+ if w-input > w0 + w1 {
+ w0 = w-input - w1
+ }
+
+ let col-defs = (w0, w1, 2pt) + constraints.map(_ => auto) + (2pt, auto, auto)
+ if probabilities != none {
+ col-defs.push(auto) // Add P(y) column
+ }
+
+ text-style[#table(
+ columns: col-defs,
+ rows: row-defs,
+ align: (col, row) => {
+ let v-align = bottom
+ if row >= 3 {
+ if col <= 1 {
+ if has-prosody(candidates.at(row - 3)) { v-align = horizon }
+ } else {
+ v-align = horizon
+ }
+ }
+ if col <= 1 { right + v-align } else { center + v-align }
+ },
+ inset: (col, row) => if col == 0 {
+ (left: 5pt, top: 5pt, bottom: 5pt, right: 0pt)
+ } else if col == 1 {
+ (left: 8pt, top: 5pt, bottom: 5pt, right: 10pt)
+ } else { 5pt },
+
+ // --- STROKE LOGIC ---
+ stroke: (col, row) => {
+ if row == 0 { return none }
+ let s = 0.4pt + black
+ if col == 0 { return (left: s, top: s, bottom: s, right: none) }
+ if col == 1 { return (left: none, top: s, bottom: s, right: s) }
+ (left: s, top: s, bottom: s, right: s)
+ },
+
+ // --- ROW 0: WEIGHTS
+ [], [], [],
+ ..weights.map(w => text(size: 0.9em)[$w=#w$]),
+ // Fill remaining columns: gap + h + ε + (optional P)
+ ..range(if probabilities != none { 4 } else { 3 }).map(_ => []),
+
+ // --- ROW 1: HEADERS ---
+ table.cell(colspan: 2, inset: (left: 5pt, right: 10pt), align: right + bottom, input-content),
+ [],
+ ..constraints.map(c => format-constraint(c)),
+ [],
+ [$h_i$],
+ [$epsilon_i$],
+ ..(if probabilities != none { ([$P_i$],) } else { () }),
+
+ // --- ROW 2: GAP ---
+ ..range(col-defs.len()).map(_ => []),
+
+ // --- ROWS 3+: CANDIDATES ---
+ ..candidates
+ .enumerate()
+ .map(((i, cand)) => {
+ let cells = ()
+ let cand-content = parse-candidate(cand, 0.5)
+
+ if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ cells.push([#letter.])
+ } else {
+ cells.push([])
+ }
+ cells.push([#cand-content])
+ cells.push([])
+
+ // Violations
+ let row-viols = if i < violations.len() { violations.at(i) } else { () }
+ for j in range(constraints.len()) {
+ if j < row-viols.len() {
+ cells.push(text(size: 0.85em)[#str(row-viols.at(j))])
+ } else {
+ cells.push([])
+ }
+ }
+
+ cells.push([])
+
+ // h(y) column
+ if i < h-scores.len() {
+ cells.push(text(size: 0.85em)[#str(calc.round(h-scores.at(i), digits: 2))])
+ } else {
+ cells.push("-")
+ }
+
+ // ε(y) column (formula)
+ if i < epsilon-formulas.len() {
+ cells.push(text(size: 0.85em)[#epsilon-formulas.at(i)])
+ } else {
+ cells.push("-")
+ }
+
+ // P(y) column (if provided)
+ if probabilities != none and i < probabilities.len() {
+ cells.push(text(size: 0.85em)[#str(calc.round(probabilities.at(i), digits: 3))])
+ } else if probabilities != none {
+ cells.push("-")
+ }
+
+ return cells
+ })
+ .flatten()
+ )]
+ }
+}
+
+// NOTE: --- NHG TABLEAU (Smart - samples noise and calculates probabilities) ---
+#let nhg(
+ input: "Input",
+ candidates: (),
+ constraints: (),
+ weights: (),
+ violations: (),
+ num-simulations: 1000,
+ seed: none,
+ show-epsilon: true,
+ scale: none,
+ letters: false,
+) = {
+ // 1. Validation and Truncation
+ assert(constraints.len() <= 20, message: "Maximum 20 constraints allowed")
+ let letter-labels = "abcdefghijklmnopqrstuvwxyz"
+
+ // Truncate constraint names to 15 characters
+ let constraints = constraints.map(c => {
+ if c.len() > 15 { c.slice(0, 15) } else { c }
+ })
+
+ // Scale: use user-provided scale if given, otherwise auto-scale
+ let font-size = if scale != none {
+ scale * 1em
+ } else if constraints.len() > 6 {
+ 0.85em
+ } else {
+ 0.95em
+ }
+
+ // 2. Generate all random samples upfront using LCG
+ let initial-seed = if seed != none { seed } else { 12345 }
+ let total-samples = (num-simulations + 1) * constraints.len()
+
+ let normal-samples = ()
+ let state = initial-seed
+
+ for i in range(total-samples) {
+ // Generate two uniform random numbers for Box-Muller
+ state = calc.rem(state * 1103515245 + 12345, 2147483648)
+ let u1 = state / 2147483648.0
+ state = calc.rem(state * 1103515245 + 12345, 2147483648)
+ let u2 = state / 2147483648.0
+
+ // Box-Muller transform
+ if u1 < 0.00001 { u1 = 0.00001 }
+ let sample = calc.sqrt(-2.0 * calc.ln(u1)) * calc.cos(2.0 * calc.pi * u2)
+ normal-samples.push(sample)
+ }
+
+ // 3. Calculate deterministic harmonies
+ let h-scores = ()
+ for row-viols in violations {
+ let h = 0.0
+ for (i, v) in row-viols.enumerate() {
+ if i < weights.len() {
+ h += float(v) * float(weights.at(i))
+ }
+ }
+ h-scores.push(h)
+ }
+
+ // 4. Monte Carlo simulation
+ let win-counts = candidates.map(_ => 0)
+ let sample-idx = 0
+
+ for sim in range(num-simulations) {
+ // Get noise for this simulation
+ let noise = ()
+ for c in range(constraints.len()) {
+ noise.push(normal-samples.at(sample-idx))
+ sample-idx = sample-idx + 1
+ }
+
+ // Calculate noisy harmonies
+ let noisy-harmonies = ()
+ for (cand-idx, row-viols) in violations.enumerate() {
+ let h = h-scores.at(cand-idx)
+ let epsilon = 0.0
+ for (i, v) in row-viols.enumerate() {
+ if i < noise.len() {
+ epsilon += noise.at(i) * float(v)
+ }
+ }
+ noisy-harmonies.push(h + epsilon)
+ }
+
+ // Find winner
+ let max-harmony = calc.max(..noisy-harmonies)
+ let winner-idx = noisy-harmonies.position(h => h == max-harmony)
+ if winner-idx != none {
+ win-counts.at(winner-idx) = win-counts.at(winner-idx) + 1
+ }
+ }
+
+ // Calculate probabilities
+ let probabilities = win-counts.map(count => float(count) / float(num-simulations))
+
+ // 5. Get epsilon values for display (one more set of samples)
+ let display-noise = ()
+ for c in range(constraints.len()) {
+ display-noise.push(normal-samples.at(sample-idx))
+ sample-idx = sample-idx + 1
+ }
+
+ let epsilon-values = ()
+ for row-viols in violations {
+ let epsilon = 0.0
+ for (i, v) in row-viols.enumerate() {
+ if i < display-noise.len() {
+ epsilon += display-noise.at(i) * float(v)
+ }
+ }
+ epsilon-values.push(epsilon)
+ }
+
+ // 6. GRID DEFINITIONS
+ let row-defs = (auto, 1.75em, 2pt) + candidates.map(c => if has-prosody(c) { auto } else { 1.75em })
+
+ context {
+ let text-style(it) = text(size: font-size, font: phonokit-font.get(), it)
+ let input-content = if type(input) == str { parse-ot-string(input) } else { input }
+
+ // Measure input-content (it spans col 0 and 1)
+ let w-input = measure(text-style(input-content)).width + 15pt
+
+ // Measure Col 0 (Prefix)
+ let w-col0-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let it = if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ [#letter.]
+ } else {
+ []
+ }
+ w-col0-max = calc.max(w-col0-max, measure(text-style(it)).width)
+ }
+ w-col0-max += 5pt
+
+ // Measure Col 1 (Candidate)
+ let w-col1-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let cand-content = parse-candidate(cand, 0.5)
+ w-col1-max = calc.max(w-col1-max, measure(text-style(cand-content)).width)
+ }
+ w-col1-max += 18pt
+
+ // Distribution
+ let w0 = w-col0-max
+ let w1 = w-col1-max
+ if w-input > w0 + w1 {
+ w0 = w-input - w1
+ }
+
+ let col-defs = (w0, w1, 2pt) + constraints.map(_ => auto) + (2pt, auto)
+ if show-epsilon {
+ col-defs.push(auto) // epsilon column
+ }
+ col-defs.push(auto) // P(y) column
+
+ text-style[#table(
+ columns: col-defs,
+ rows: row-defs,
+ align: (col, row) => {
+ let v-align = bottom
+ if row >= 3 {
+ if col <= 1 {
+ if has-prosody(candidates.at(row - 3)) { v-align = horizon }
+ } else {
+ v-align = horizon
+ }
+ }
+ if col <= 1 { right + v-align } else { center + v-align }
+ },
+ inset: (col, row) => if col == 0 {
+ (left: 5pt, top: 5pt, bottom: 5pt, right: 0pt)
+ } else if col == 1 {
+ (left: 8pt, top: 5pt, bottom: 5pt, right: 10pt)
+ } else { 5pt },
+
+ // --- STROKE LOGIC ---
+ stroke: (col, row) => {
+ if row == 0 { return none }
+ let s = 0.4pt + black
+ if col == 0 { return (left: s, top: s, bottom: s, right: none) }
+ if col == 1 { return (left: none, top: s, bottom: s, right: s) }
+ (left: s, top: s, bottom: s, right: s)
+ },
+
+ // --- ROW 0: WEIGHTS
+ [], [], [],
+ ..weights.map(w => text(size: 0.9em)[$w=#w$]),
+ // Fill remaining columns: gap + h + optional ε + P
+ ..range(if show-epsilon { 4 } else { 3 }).map(_ => []),
+
+ // --- ROW 1: HEADERS ---
+ table.cell(colspan: 2, inset: (left: 5pt, right: 10pt), align: right + bottom, input-content),
+ [],
+ ..constraints.map(c => format-constraint(c)),
+ [],
+ [$h_i$],
+ ..(if show-epsilon { ([$epsilon_i$],) } else { () }),
+ [$P_i$],
+
+ // --- ROW 2: GAP ---
+ ..range(col-defs.len()).map(_ => []),
+
+ // --- ROWS 3+: CANDIDATES ---
+ ..candidates
+ .enumerate()
+ .map(((i, cand)) => {
+ let cells = ()
+ let cand-content = parse-candidate(cand, 0.5)
+
+ if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ cells.push([#letter.])
+ } else {
+ cells.push([])
+ }
+ cells.push([#cand-content])
+ cells.push([])
+
+ // Violations
+ let row-viols = if i < violations.len() { violations.at(i) } else { () }
+ for j in range(constraints.len()) {
+ if j < row-viols.len() {
+ cells.push(text(size: 0.85em)[#str(row-viols.at(j))])
+ } else {
+ cells.push([])
+ }
+ }
+
+ cells.push([])
+
+ // h(y) column
+ if i < h-scores.len() {
+ cells.push(text(size: 0.85em)[#str(calc.round(h-scores.at(i), digits: 2))])
+ } else {
+ cells.push("-")
+ }
+
+ // ε(y) column (sampled value) - only if show-epsilon
+ if show-epsilon {
+ if i < epsilon-values.len() {
+ cells.push(text(size: 0.85em)[#str(calc.round(epsilon-values.at(i), digits: 2))])
+ } else {
+ cells.push("-")
+ }
+ }
+
+ // P(y) column (estimated from simulations)
+ if i < probabilities.len() {
+ cells.push(text(size: 0.85em)[#str(calc.round(probabilities.at(i), digits: 3))])
+ } else {
+ cells.push("-")
+ }
+
+ return cells
+ })
+ .flatten()
+ )]
+ }
+}
+
+// NOTE: --- MAXENT TABLEAU FUNCTION ---
+#let maxent(
+ input: "Input",
+ candidates: (),
+ constraints: (),
+ weights: (),
+ violations: (),
+ visualize: true,
+ sort: false,
+ scale: none,
+ letters: false,
+) = {
+ // 1. Validation and Truncation
+ assert(constraints.len() <= 20, message: "Maximum 20 constraints allowed in maxent")
+ let letter-labels = "abcdefghijklmnopqrstuvwxyz"
+
+ // Truncate constraint names to 15 characters
+ let constraints = constraints.map(c => {
+ if c.len() > 15 { c.slice(0, 15) } else { c }
+ })
+
+ // Scale: use user-provided scale if given, otherwise auto-scale
+ let font-size = if scale != none {
+ scale * 1em
+ } else if constraints.len() > 6 {
+ 0.85em
+ } else {
+ 0.95em
+ }
+
+ // 2. CALCULATIONS
+ let h-scores = ()
+ let p-star-scores = ()
+ let total-p-star = 0.0
+
+ for row-viols in violations {
+ let h = 0.0
+ for (i, v) in row-viols.enumerate() {
+ if i < weights.len() {
+ h += float(v) * float(weights.at(i))
+ }
+ }
+ h-scores.push(h)
+ let p-star = calc.exp(-h)
+ p-star-scores.push(p-star)
+ total-p-star += p-star
+ }
+
+ // Safety check for empty violations/division by zero
+ let p-scores = if total-p-star > 0 {
+ p-star-scores.map(x => x / total-p-star)
+ } else {
+ candidates.map(_ => 0.0)
+ }
+
+ // 3. SORT BY PROBABILITY (if enabled)
+ let order = if sort {
+ range(candidates.len()).sorted(key: i => -p-scores.at(i))
+ } else {
+ range(candidates.len())
+ }
+ let candidates = order.map(i => candidates.at(i))
+ let violations = order.map(i => violations.at(i))
+ let h-scores = order.map(i => h-scores.at(i))
+ let p-star-scores = order.map(i => p-star-scores.at(i))
+ let p-scores = order.map(i => p-scores.at(i))
+
+ // 4. GRID DEFINITIONS
+ let bar-col-width = 3cm
+ let row-defs = (auto, 1.75em, 2pt) + candidates.map(c => if has-prosody(c) { auto } else { 1.75em })
+
+ context {
+ let text-style(it) = text(size: font-size, font: phonokit-font.get(), it)
+ let input-content = if type(input) == str { parse-ot-string(input) } else { input }
+
+ // Measure input-content (it spans col 0 and 1)
+ let w-input = measure(text-style(input-content)).width + 15pt
+
+ // Measure Col 0 (Prefix)
+ let w-col0-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let it = if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ [#letter.]
+ } else {
+ []
+ }
+ w-col0-max = calc.max(w-col0-max, measure(text-style(it)).width)
+ }
+ w-col0-max += 5pt
+
+ // Measure Col 1 (Candidate)
+ let w-col1-max = 0pt
+ for (i, cand) in candidates.enumerate() {
+ let cand-content = parse-candidate(cand, 0.5)
+ w-col1-max = calc.max(w-col1-max, measure(text-style(cand-content)).width)
+ }
+ w-col1-max += 18pt
+
+ // Distribution
+ let w0 = w-col0-max
+ let w1 = w-col1-max
+ if w-input > w0 + w1 {
+ w0 = w-input - w1
+ }
+
+ let col-defs = (w0, w1, 2pt) + constraints.map(_ => auto) + (2pt, auto, auto, auto)
+ if visualize {
+ col-defs.push(bar-col-width) // The Floating Column
+ }
+ let last-col-idx = col-defs.len() - 1
+
+ let tbl = text-style[#table(
+ columns: col-defs,
+ rows: row-defs,
+ align: (col, row) => {
+ let v-align = bottom
+ if row >= 3 {
+ if col <= 1 {
+ if has-prosody(candidates.at(row - 3)) { v-align = horizon }
+ } else {
+ v-align = horizon
+ }
+ }
+ if col <= 1 { right + v-align } else { center + v-align }
+ },
+ inset: (col, row) => if col == 0 {
+ (left: 5pt, top: 5pt, bottom: 5pt, right: 0pt)
+ } else if col == 1 {
+ (left: 8pt, top: 5pt, bottom: 5pt, right: 10pt)
+ } else { 5pt },
+
+ // --- STROKE LOGIC ---
+ stroke: (col, row) => {
+ if row == 0 { return none }
+ if visualize and col == last-col-idx { return none }
+ let s = 0.4pt + black
+ if col == 0 { return (left: s, top: s, bottom: s, right: none) }
+ if col == 1 { return (left: none, top: s, bottom: s, right: s) }
+ (left: s, top: s, bottom: s, right: s)
+ },
+
+ // --- ROW 0: WEIGHTS
+ [], [], [],
+ ..weights.map(w => text(size: 0.9em)[$w=#w$]),
+ // Fill remaining columns with empty cells
+ ..range(if visualize { 5 } else { 4 }).map(_ => []),
+
+ // --- ROW 1: HEADERS ---
+ table.cell(colspan: 2, inset: (left: 5pt, right: 10pt), align: right + bottom, input-content),
+ [],
+ ..constraints.map(c => format-constraint(c)),
+ [],
+ [$h_i$], [$e^(-h_i)$], [$P_i$],
+ // Add empty floating header if visualizing
+ ..(if visualize { ([],) } else { () }),
+
+ // --- ROW 2: GAP ---
+ ..range(col-defs.len()).map(_ => []),
+
+ // --- ROWS 3+: CANDIDATES (letters assigned AFTER sort)
+ ..candidates
+ .enumerate()
+ .map(((i, cand)) => {
+ let cells = ()
+ let cand-content = parse-candidate(cand, 0.5)
+
+ // Letters are assigned after sorting, so i reflects the sorted order
+ if letters {
+ let letter = letter-labels.at(calc.min(i, 25))
+ cells.push([#letter.])
+ } else {
+ cells.push([])
+ }
+ cells.push([#cand-content])
+ cells.push([])
+
+ // Violations
+ let row-viols = if i < violations.len() { violations.at(i) } else { () }
+ for j in range(constraints.len()) {
+ if j < row-viols.len() { cells.push(text(size: 0.85em)[#str(row-viols.at(j))]) } else { cells.push([]) }
+ }
+
+ cells.push([])
+ if i < h-scores.len() {
+ cells.push(text(size: 0.85em)[#str(calc.round(h-scores.at(i), digits: 2))])
+ cells.push(text(size: 0.85em)[#str(calc.round(p-star-scores.at(i), digits: 3))])
+ let p-val = p-scores.at(i)
+ cells.push(text(size: 0.85em)[#str(calc.round(p-val, digits: 3))])
+
+ // --- FLOATING VISUAL BAR ---
+ if visualize {
+ cells.push(align(left + horizon)[
+ #box(width: 50%, height: 0.5em, stroke: 0.5pt + luma(100))[
+ #rect(
+ width: p-val * 100%,
+ height: 100%,
+ fill: luma(100),
+ stroke: 0.5pt + luma(100),
+ )
+ ]
+ ])
+ }
+ } else {
+ // Fallback
+ cells.push("-")
+ cells.push("-")
+ cells.push("-")
+ if visualize { cells.push([]) }
+ }
+
+ return cells
+ })
+ .flatten()
+ )]
+
+ if visualize { pad(right: -bar-col-width, tbl) } else { tbl }
+ }
+}
+}
diff --git a/packages/preview/phonokit/0.5.11/phonetics.typ b/packages/preview/phonokit/0.5.11/phonetics.typ
new file mode 100644
index 0000000000..292829577e
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/phonetics.typ
@@ -0,0 +1,1017 @@
+#import "@preview/cetz:0.5.2"
+#import "@preview/lilaq:0.6.0" as lq
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+#import "vowels.typ": language-vowels
+#import "ui-lang.typ": resolve-ui-lang, ui-lang-error
+
+#let formants-default-vowel-size = 18pt
+
+#let phonetic-vowel-presets = (
+ "i": (f1: 280, f2: 2290),
+ "y": (f1: 310, f2: 1990),
+ "ɨ": (f1: 300, f2: 1750),
+ "ʉ": (f1: 320, f2: 1400),
+ "ɯ": (f1: 300, f2: 1100),
+ "ɪ": (f1: 390, f2: 1990),
+ "ʏ": (f1: 400, f2: 1750),
+ "e": (f1: 390, f2: 2200),
+ "ø": (f1: 390, f2: 1800),
+ "ɘ": (f1: 420, f2: 1650),
+ "ɵ": (f1: 420, f2: 1400),
+ "ɤ": (f1: 420, f2: 1200),
+ "ɛ": (f1: 530, f2: 1840),
+ "œ": (f1: 540, f2: 1600),
+ "ɜ": (f1: 560, f2: 1500),
+ "ɞ": (f1: 560, f2: 1300),
+ "æ": (f1: 660, f2: 1720),
+ "ɐ": (f1: 650, f2: 1450),
+ "a": (f1: 730, f2: 1500),
+ "ɶ": (f1: 780, f2: 1450),
+ "ɑ": (f1: 730, f2: 1090),
+ "ɒ": (f1: 700, f2: 900),
+ "ə": (f1: 500, f2: 1500),
+ "ʌ": (f1: 610, f2: 1300),
+ "ɔ": (f1: 570, f2: 840),
+ "o": (f1: 430, f2: 930),
+ "ʊ": (f1: 440, f2: 1020),
+ "u": (f1: 300, f2: 870),
+)
+
+// Sourced English preset adapted from the male values in Hillenbrand et al. (1995),
+// as provided by the user from a secondary table reproducing those averages.
+// For legibility in a teaching plot, a few values are slightly regularized so
+// categories do not collapse visually. This is used only when the input preset
+// is "english". Other presets remain schematic/illustrative for teaching.
+#let english-hillenbrand-male = (
+ "i": (f1: 342, f2: 2322),
+ "ɪ": (f1: 427, f2: 2034),
+ "e": (f1: 476, f2: 2089),
+ "ɛ": (f1: 580, f2: 1799),
+ "æ": (f1: 588, f2: 1952),
+ "a": (f1: 768, f2: 1425),
+ "ɑ": (f1: 768, f2: 1225),
+ "ɔ": (f1: 652, f2: 997),
+ "o": (f1: 497, f2: 910),
+ "u": (f1: 378, f2: 997),
+ "ʊ": (f1: 469, f2: 1122),
+ "ʌ": (f1: 623, f2: 1200),
+ "ə": (f1: 474, f2: 1379),
+ "ɚ": (f1: 474, f2: 1379),
+)
+
+#let _unique(items) = {
+ let seen = ()
+ for item in items {
+ if item not in seen {
+ seen.push(item)
+ }
+ }
+ seen
+}
+
+#let _index-of(items, target) = {
+ for (idx, item) in items.enumerate() {
+ if item == target {
+ return idx
+ }
+ }
+ none
+}
+
+#let _normalize-vowel-string(vowels) = {
+ let cleaned = vowels
+ if cleaned in language-vowels {
+ return language-vowels.at(cleaned).clusters()
+ }
+ let normalized = ipa-to-unicode(cleaned)
+ .replace("/", " ")
+ .replace("[", " ")
+ .replace("]", " ")
+ .replace(",", " ")
+ .replace(";", " ")
+ .replace("|", " ")
+
+ let split-tokens = normalized.split(" ").filter(token => token != "")
+ if split-tokens.len() > 0 {
+ let expanded = ()
+ for token in split-tokens {
+ if token in phonetic-vowel-presets {
+ expanded.push(token)
+ } else {
+ let pieces = token.clusters()
+ if pieces.all(piece => piece in phonetic-vowel-presets) {
+ for piece in pieces {
+ expanded.push(piece)
+ }
+ } else {
+ expanded.push(token)
+ }
+ }
+ }
+ expanded
+ } else {
+ normalized.clusters().filter(token => token in phonetic-vowel-presets)
+ }
+}
+
+#let _next-rand(state) = calc.rem(state * 48271 + 1, 2147483647)
+
+#let _rand-unit(state) = {
+ let next = _next-rand(state)
+ (next, calc.abs(float(next)) / 2147483646.0)
+}
+
+#let _rand-symmetric(state, spread) = {
+ let state-0 = state
+ let acc = 0.0
+ for _ in range(6) {
+ let sample = _rand-unit(state-0)
+ state-0 = sample.at(0)
+ acc += sample.at(1)
+ }
+ (state-0, (acc - 3.0) * spread)
+}
+
+#let _token-cloud(vowels, preset-map, n, sd, sd2, seed) = {
+ let tokens = ()
+ let state = calc.max(seed, 1)
+ for (index, vowel) in vowels.enumerate() {
+ let mean = preset-map.at(vowel)
+ for token-index in range(n) {
+ let jitter-f1 = _rand-symmetric(state + index * 101 + token-index * 17, sd)
+ let jitter-f2 = _rand-symmetric(jitter-f1.at(0) + 29, sd2)
+ state = jitter-f2.at(0)
+ tokens.push((
+ vowel: vowel,
+ f1: mean.f1 + jitter-f1.at(1),
+ f2: mean.f2 + jitter-f2.at(1),
+ ))
+ }
+ }
+ tokens
+}
+
+#let _centroid-pairs(vowels, preset-map) = {
+ vowels.map(vowel => {
+ let mean = preset-map.at(vowel)
+ (
+ vowel: vowel,
+ f1: mean.f1,
+ f2: mean.f2,
+ )
+ })
+}
+
+#let _mean(values) = values.sum() / values.len()
+
+#let _sd(values) = {
+ if values.len() <= 1 { return 0.0 }
+ let mean = _mean(values)
+ let variance = values.map(value => calc.pow(value - mean, 2)).sum() / (values.len() - 1)
+ calc.sqrt(variance)
+}
+
+#let _csv-tokens(source) = {
+ if type(source) != array {
+ return (error: [*Error:* `source` must be tabular data from `csv(..., row-type: dictionary)`], tokens: ())
+ }
+ if source.len() == 0 {
+ return (error: [*Error:* `source` is empty], tokens: ())
+ }
+
+ let first = source.at(0)
+ let tokens = ()
+ if type(first) == dictionary {
+ if not ("vowel" in first and "f1" in first and "f2" in first) {
+ return (error: [*Error:* CSV source must contain columns `"vowel"`, `"f1"`, and `"f2"`], tokens: ())
+ }
+ for row in source {
+ if type(row) != dictionary or not ("vowel" in row and "f1" in row and "f2" in row) {
+ return (error: [*Error:* Every CSV row must contain `"vowel"`, `"f1"`, and `"f2"`], tokens: ())
+ }
+ let vowel = ipa-to-unicode(str(row.at("vowel")))
+ let f1 = float(row.at("f1"))
+ let f2 = float(row.at("f2"))
+ tokens.push((vowel: vowel, f1: f1, f2: f2))
+ }
+ } else if type(first) == array {
+ if first.len() < 3 {
+ return (error: [*Error:* CSV source must contain columns `"vowel"`, `"f1"`, and `"f2"`], tokens: ())
+ }
+ let headers = first.map(value => str(value))
+ let vowel-idx = _index-of(headers, "vowel")
+ let f1-idx = _index-of(headers, "f1")
+ let f2-idx = _index-of(headers, "f2")
+ if vowel-idx == none or f1-idx == none or f2-idx == none {
+ return (error: [*Error:* CSV source must contain columns `"vowel"`, `"f1"`, and `"f2"`], tokens: ())
+ }
+ for row in source.slice(1) {
+ if type(row) != array or calc.max(vowel-idx, f1-idx, f2-idx) >= row.len() {
+ return (error: [*Error:* Every CSV row must contain values for `"vowel"`, `"f1"`, and `"f2"`], tokens: ())
+ }
+ let vowel = ipa-to-unicode(str(row.at(vowel-idx)))
+ let f1 = float(row.at(f1-idx))
+ let f2 = float(row.at(f2-idx))
+ tokens.push((vowel: vowel, f1: f1, f2: f2))
+ }
+ } else {
+ return (error: [*Error:* `source` must be tabular data from `csv(...)`], tokens: ())
+ }
+ (error: none, tokens: tokens)
+}
+
+#let _centroids-from-tokens(tokens) = {
+ let vowels = _unique(tokens.map(token => token.vowel))
+ vowels.map(vowel => {
+ let subset = tokens.filter(token => token.vowel == vowel)
+ (
+ vowel: vowel,
+ f1: _mean(subset.map(token => token.f1)),
+ f2: _mean(subset.map(token => token.f2)),
+ sd-f1: _sd(subset.map(token => token.f1)),
+ sd-f2: _sd(subset.map(token => token.f2)),
+ )
+ })
+}
+
+#let _warn(body) = text(fill: red.darken(20%), weight: "bold")[⚠ #body]
+
+#let _nice-tick-step(span, target: 6) = {
+ let rough = span / target
+ if rough <= 50 { 50 } else if rough <= 100 { 100 } else if rough <= 200 { 200 } else if rough <= 250 { 250 } else if (
+ rough <= 500
+ ) { 500 } else { 1000 }
+}
+
+#let _make-ticks(minimum, maximum, step) = {
+ let start = int(calc.ceil(minimum / step) * step)
+ let stop = int(calc.floor(maximum / step) * step)
+ let ticks = ()
+ let current = start
+ while current <= stop {
+ ticks.push(current)
+ current += step
+ }
+ ticks
+}
+
+/// Create an illustrative F1/F2 vowel cloud for teaching.
+///
+/// This function generates synthetic vowel tokens around built-in F1/F2 means
+/// and displays them on an inverted F1/F2 diagram in the style common in
+/// phonetics teaching.
+///
+/// Data policy:
+/// - If `vowels` is `"english"`, the means are based on the male averages in
+/// Hillenbrand et al. (1995), lightly regularized for pedagogical clarity.
+/// - Other vowel means in this module are schematic/illustrative defaults for
+/// teaching and are not tied to a single empirical source.
+///
+/// Arguments:
+/// - vowels (string): Tipa-style/IPA vowel string or a built-in language name
+/// such as `"english"`.
+/// - source (array, optional): Tabular data from `csv(...)` with required
+/// columns `vowel`, `f1`, and `f2`. Extra columns are ignored.
+/// - sd (float): Standard deviation used for F1 jitter in Hz.
+/// - sd2 (float, optional): Standard deviation for F2 jitter in Hz; defaults to `sd`.
+/// - n (int): Number of synthetic tokens per vowel (default: 10).
+/// - seed (int): Seed for deterministic token placement (default: 1).
+/// - labels (bool): Show vowel labels at preset means (default: true).
+/// - points (bool): Show synthetic tokens (default: true).
+/// - centers (bool): Show explicit `+` mean markers (default: false).
+/// - ellipse (bool): Show 1-SD ellipses centered on the vowel means (default: false).
+/// - ellipse-stroke (stroke or auto): Stroke used for SD ellipses
+/// (default: `0.8pt + luma(190)`).
+/// - ellipse-fill (fill): Fill used for SD ellipses (default: none).
+/// - grid (bool): Show the background grid (default: true).
+/// - color-by-vowel (bool): Use a color cycle by vowel category (default: true).
+/// - point-size (int): Marker size for synthetic tokens (default: 50).
+/// - point-color (color or auto): Override token color; `auto` uses the plot cycle
+/// (default: auto).
+/// - point-alpha (ratio): Token transparency (default: 20%).
+/// - vowel-color (color): Color used for vowel labels (default: black).
+/// - vowel-size (length): Font size used for vowel labels (default: 20pt).
+/// - vowel-weight (str): Font weight used for vowel labels (default: `"regular"`).
+/// - axis-size (length): Font size used for axis labels and tick labels
+/// (default: 10pt).
+/// - scale (float): Overall scale factor for the figure (default: 1.0).
+/// - x-label (content): X-axis label (default: `[F2 (Hz)]`).
+/// - y-label (content): Y-axis label (default: `[F1 (Hz)]`).
+/// - width (length): Diagram width (default: 10cm).
+/// - height (length): Diagram height (default: 7cm).
+///
+/// Notes:
+/// - Language-name input is checked first. If `vowels` matches a built-in
+/// language preset such as `"english"` or `"french"`, that inventory is used.
+/// - Otherwise, the input is parsed through `ipa-to-unicode` by default, so
+/// tipa-style strings like `"a e E o O i u"` work directly.
+/// - In CSV mode, `source: csv("...")` assumes the first row is a header row.
+/// - In synthetic mode, ellipses visualize the user-provided spread parameters
+/// (`sd`, `sd2`); in CSV mode, they reflect sample standard deviations
+/// computed from the observed tokens.
+///
+/// Example:
+/// ```
+/// #formants("i ɪ e ɛ æ a ə ɔ o ʊ u", sd: 80)
+/// ```
+#let _formants_impl(
+ vowels: none,
+ source: none,
+ sd: 60,
+ sd2: none,
+ n: 10,
+ seed: 1,
+ labels: true,
+ points: true,
+ centers: false,
+ ellipse: false,
+ ellipse-stroke: 0.8pt + luma(190),
+ ellipse-fill: none,
+ grid: true,
+ color-by-vowel: true,
+ point-size: 50,
+ point-color: auto,
+ point-alpha: 20%,
+ vowel-color: black,
+ vowel-size: formants-default-vowel-size,
+ vowel-weight: "regular",
+ axis-size: 10pt,
+ scale: 1.0,
+ x-label: [F2 (Hz)],
+ y-label: [F1 (Hz)],
+ width: 10cm,
+ height: 7cm,
+) = {
+ let tokens = ()
+ let centroids = ()
+ let spread2 = if sd2 == none { sd } else { sd2 }
+ let unknown = ()
+
+ if source != none {
+ let parsed = _csv-tokens(source)
+ if parsed.error != none {
+ return parsed.error
+ }
+ tokens = parsed.tokens
+ centroids = _centroids-from-tokens(tokens)
+ if tokens.len() == 0 {
+ return [*Error:* `source` is empty]
+ }
+ } else {
+ let preset-map = if vowels == "english" {
+ english-hillenbrand-male
+ } else {
+ phonetic-vowel-presets
+ }
+ let requested = _normalize-vowel-string(vowels)
+ let known = requested.filter(vowel => vowel in preset-map)
+ unknown = _unique(requested.filter(vowel => vowel not in preset-map))
+
+ if known.len() == 0 {
+ return _warn([No supported vowels found in input: "#vowels".])
+ }
+
+ tokens = _token-cloud(known, preset-map, n, sd, spread2, seed)
+ centroids = _centroid-pairs(known, preset-map).map(ct => (..ct, sd-f1: sd, sd-f2: spread2))
+ }
+
+ let f1-values = tokens.map(t => t.f1) + centroids.map(t => t.f1)
+ let f2-values = tokens.map(t => t.f2) + centroids.map(t => t.f2)
+ let pad-f1 = if source != none {
+ calc.max(calc.max(..centroids.map(ct => ct.sd-f1)) * 1.5, 60)
+ } else {
+ calc.max(sd * 1.5, 60)
+ }
+ let pad-f2 = if source != none {
+ calc.max(calc.max(..centroids.map(ct => ct.sd-f2)) * 1.5, 80)
+ } else {
+ calc.max(spread2 * 1.5, 80)
+ }
+ let f1-min = calc.min(..f1-values) - pad-f1
+ let f1-max = calc.max(..f1-values) + pad-f1
+ let f2-min = calc.min(..f2-values) - pad-f2
+ let f2-max = calc.max(..f2-values) + pad-f2
+ let x-step = _nice-tick-step(f2-max - f2-min)
+ let y-step = _nice-tick-step(f1-max - f1-min)
+ let x-ticks = _make-ticks(f2-min, f2-max, x-step)
+ let y-ticks = _make-ticks(f1-min, f1-max, y-step)
+ let groups = _unique(tokens.map(token => token.vowel)).map(vowel => (
+ vowel,
+ tokens.filter(token => token.vowel == vowel),
+ ))
+
+ let cycle = if color-by-vowel {
+ (
+ rgb("#3b82f6"),
+ rgb("#ef4444"),
+ rgb("#10b981"),
+ rgb("#f59e0b"),
+ rgb("#8b5cf6"),
+ rgb("#ec4899"),
+ rgb("#14b8a6"),
+ rgb("#f97316"),
+ rgb("#06b6d4"),
+ rgb("#84cc16"),
+ )
+ } else {
+ (rgb("#64748b"),)
+ }
+
+ [
+ #if unknown.len() > 0 {
+ _warn([Skipped unsupported vowel(s): #unknown.join(", ").])
+ v(0.5em)
+ }
+ #context {
+ let doc-font = text.font
+ let scaled-width = width * scale
+ let scaled-height = height * scale
+ let scaled-point-size = point-size * scale
+ let scaled-vowel-size = vowel-size * scale
+ let scaled-axis-size = axis-size * scale
+ let axis-tick-color = gray.darken(35%)
+ let scaled-axis-stroke = 0.8pt * scale + axis-tick-color
+ let scaled-grid-stroke = 0.5pt * scale + luma(220)
+ let scaled-center-size = 48 * scale
+ let scaled-center-stroke = 1pt * scale
+ let tick-pad = scaled-axis-size * 0.35
+ show lq.selector(lq.tick-label): it => []
+ let x-tick-labels = x-ticks.map(value => text(font: doc-font, size: scaled-axis-size)[#str(value)])
+ let y-tick-labels = y-ticks.map(value => text(font: doc-font, size: scaled-axis-size)[#str(value)])
+ let max-x-tick-height = calc.max(..x-tick-labels.map(label => measure(label).height))
+ let max-y-tick-width = calc.max(..y-tick-labels.map(label => measure(label).width))
+ let x-label-pad = max-x-tick-height + scaled-axis-size * 0.9
+ let y-label-pad = max-y-tick-width + scaled-axis-size * 1.05
+ let axis-label-color = black
+ let x-axis-label = text(font: doc-font, size: scaled-axis-size, fill: axis-label-color)[#x-label]
+ let y-axis-label = rotate(
+ -90deg,
+ text(font: doc-font, size: scaled-axis-size, fill: axis-label-color)[#y-label],
+ reflow: true,
+ )
+ lq.diagram(
+ width: scaled-width,
+ height: scaled-height,
+ xlim: (f2-max, f2-min),
+ ylim: (f1-max, f1-min),
+ xlabel: none,
+ ylabel: none,
+ xscale: "linear",
+ yscale: "linear",
+ xaxis: (
+ position: top,
+ stroke: scaled-axis-stroke,
+ mirror: false,
+ exponent: none,
+ tick-distance: x-step,
+ ),
+ yaxis: (
+ position: right,
+ stroke: scaled-axis-stroke,
+ mirror: false,
+ exponent: none,
+ tick-distance: y-step,
+ ),
+ grid: if grid { scaled-grid-stroke } else { none },
+ fill: white,
+ cycle: cycle,
+ legend: none,
+ ..centroids.map(ct => if ellipse {
+ lq.ellipse(
+ ct.f2 - ct.sd-f2,
+ ct.f1 - ct.sd-f1,
+ width: ct.sd-f2 * 2,
+ height: ct.sd-f1 * 2,
+ stroke: ellipse-stroke,
+ fill: ellipse-fill,
+ )
+ } else {
+ none
+ }),
+ ..groups.map(group => {
+ let vowel = group.at(0)
+ let cloud = group.at(1)
+ if points {
+ lq.scatter(
+ cloud.map(token => token.f2),
+ cloud.map(token => token.f1),
+ mark: "o",
+ color: point-color,
+ size: cloud.map(_ => scaled-point-size),
+ stroke: none,
+ alpha: point-alpha,
+ label: if color-by-vowel { context text(font: phonokit-font.get())[#vowel] } else { none },
+ )
+ } else {
+ none
+ }
+ }),
+ ..centroids.map(ct => if centers {
+ lq.scatter(
+ (ct.f2,),
+ (ct.f1,),
+ mark: "+",
+ size: (scaled-center-size,),
+ color: black,
+ stroke: (paint: black, thickness: scaled-center-stroke),
+ )
+ } else {
+ none
+ }),
+ ..centroids.map(ct => if labels {
+ lq.place(
+ ct.f2,
+ ct.f1,
+ align: center + horizon,
+ context text(
+ font: phonokit-font.get(),
+ weight: vowel-weight,
+ size: scaled-vowel-size,
+ fill: vowel-color,
+ )[#ct.vowel],
+ )
+ } else {
+ none
+ }),
+ ..x-ticks.map(value => lq.place(
+ value,
+ 0%,
+ align: bottom + center,
+ pad(bottom: tick-pad, text(font: doc-font, size: scaled-axis-size, fill: axis-tick-color)[#str(value)]),
+ )),
+ ..y-ticks.map(value => lq.place(
+ 100%,
+ value,
+ align: left + horizon,
+ pad(left: tick-pad, text(font: doc-font, size: scaled-axis-size, fill: axis-tick-color)[#str(value)]),
+ )),
+ lq.place(
+ 50%,
+ 0%,
+ align: bottom + center,
+ pad(bottom: x-label-pad, x-axis-label),
+ ),
+ lq.place(
+ 100%,
+ 50%,
+ align: left + horizon,
+ pad(left: y-label-pad, y-axis-label),
+ ),
+ )
+ }
+ ]
+}
+
+#let formants(..args) = {
+ let pos = args.pos()
+ let named = args.named()
+ let vowels = if "vowels" in named { named.at("vowels") } else { pos.at(0, default: none) }
+
+ _formants_impl(
+ vowels: vowels,
+ source: named.at("source", default: none),
+ sd: named.at("sd", default: 60),
+ sd2: named.at("sd2", default: none),
+ n: named.at("n", default: 10),
+ seed: named.at("seed", default: 1),
+ labels: named.at("labels", default: true),
+ points: named.at("points", default: true),
+ centers: named.at("centers", default: false),
+ ellipse: named.at("ellipse", default: false),
+ ellipse-stroke: named.at("ellipse-stroke", default: 0.8pt + luma(190)),
+ ellipse-fill: named.at("ellipse-fill", default: none),
+ grid: named.at("grid", default: true),
+ color-by-vowel: named.at("color-by-vowel", default: true),
+ point-size: named.at("point-size", default: 50),
+ point-color: named.at("point-color", default: auto),
+ point-alpha: named.at("point-alpha", default: 20%),
+ vowel-color: named.at("vowel-color", default: black),
+ vowel-size: named.at("vowel-size", default: formants-default-vowel-size),
+ vowel-weight: named.at("vowel-weight", default: "regular"),
+ axis-size: named.at("axis-size", default: 10pt),
+ scale: named.at("scale", default: 1.0),
+ x-label: named.at("x-label", default: [F2 (Hz)]),
+ y-label: named.at("y-label", default: [F1 (Hz)]),
+ width: named.at("width", default: 10cm),
+ height: named.at("height", default: 7cm),
+ )
+}
+
+#let _vot-label(value, abbreviation) = {
+ let shown = if calc.abs(value) == calc.round(calc.abs(value)) {
+ str(int(value))
+ } else {
+ str(value)
+ }
+ [#abbreviation: #shown ms]
+}
+
+#let _vot-ui-labels = (
+ "en": (
+ closure: [closure],
+ release: [release],
+ voicing-onset: [voicing onset],
+ vowel: [vowel],
+ aspiration: [aspiration],
+ prevoicing: [prevoicing],
+ vot-abbr: [VOT],
+ ),
+ "fr": (
+ closure: [occlusion],
+ release: [relâchement],
+ voicing-onset: [début du voisement],
+ vowel: [voyelle],
+ aspiration: [aspiration],
+ prevoicing: [prévoisement],
+ vot-abbr: [DES],
+ ),
+ "pt": (
+ closure: [oclusão],
+ release: [soltura],
+ voicing-onset: [início do vozeamento],
+ vowel: [vogal],
+ aspiration: [aspiração],
+ prevoicing: [pré-vozeamento],
+ vot-abbr: [VOT],
+ ),
+)
+
+#let _vot-label-or-default(label, default) = {
+ if label == auto {
+ default
+ } else {
+ label
+ }
+}
+
+#let vot(
+ vot,
+ closure: 40,
+ vowel: 60,
+ scale: 1.0,
+ label: auto,
+ keys: false,
+ ui-lang: "en",
+ closure-label: auto,
+ release-label: auto,
+ voicing-label: auto,
+ vowel-label: auto,
+ vot-label: auto,
+ interval-label: auto,
+ interval-key: auto,
+ closure-segment: none,
+ interval-segment: none,
+ vowel-segment: none,
+ segment-size: 10pt,
+ fill-closure: luma(230),
+ fill-vowel: white,
+ fill-aspiration: luma(245),
+ voicing: true,
+ voicing-stroke: auto,
+) = context {
+ assert(type(vot) == int or type(vot) == float, message: "vot must be a number of milliseconds")
+ assert(type(closure) == int or type(closure) == float, message: "closure must be a number of milliseconds")
+ assert(type(vowel) == int or type(vowel) == float, message: "vowel must be a number of milliseconds")
+ let ui-locale = resolve-ui-lang(ui-lang)
+ if ui-locale == none {
+ return ui-lang-error(ui-lang)
+ }
+ let ui-labels = _vot-ui-labels.at(ui-locale)
+
+ let vot-ms = float(vot)
+ let closure-ms = calc.max(float(closure), 0.0)
+ let vowel-ms = calc.max(float(vowel), 1.0)
+ let release = 0.0
+ let voicing-onset-time = vot-ms
+ let closure-start-time = -closure-ms + calc.min(vot-ms, 0.0)
+ let left-time = calc.min(closure-start-time, calc.min(vot-ms, 0.0))
+ let right-time = calc.max(vot-ms, 0.0) + vowel-ms
+ let time-span = calc.max(right-time - left-time, 1.0)
+ let width = calc.max(4.5, time-span * 0.045)
+ let tx = t => (t - left-time) / time-span * width
+ let show-label = if label == auto { true } else { label }
+ let closure-text = _vot-label-or-default(closure-label, ui-labels.closure)
+ let release-text = _vot-label-or-default(release-label, ui-labels.release)
+ let voicing-text = _vot-label-or-default(voicing-label, ui-labels.voicing-onset)
+ let vowel-text = _vot-label-or-default(vowel-label, ui-labels.vowel)
+ let vot-text = _vot-label-or-default(vot-label, _vot-label(vot-ms, ui-labels.vot-abbr))
+ let interval-text = _vot-label-or-default(interval-label, ui-labels.aspiration)
+ let region-y1 = 0.1
+ let region-y2 = 0.85
+ let bracket-y = -0.86
+ let event-label-y = 1.56
+ let event-line-top = if keys { event-label-y } else { region-y2 }
+ let legend-y = 2.2
+ let release-x = tx(release)
+ let voicing-x = tx(voicing-onset-time)
+ let vowel-start = calc.max(vot-ms, 0.0)
+ let scale-factor = scale
+ let doc-font-size = 8pt * scale-factor
+ let label-size = 7pt * scale-factor
+ let segment-font-size = segment-size * scale-factor
+ let event-size = 6pt * scale-factor
+ let region-stroke = 0.7pt * scale-factor + black
+ let release-stroke = (
+ paint: black,
+ thickness: 0.7pt * scale-factor,
+ dash: "dashed",
+ cap: "butt",
+ join: "miter",
+ )
+ let aspiration-stroke = (
+ paint: gray.darken(35%),
+ thickness: 0.7pt * scale-factor,
+ dash: "dotted",
+ cap: "butt",
+ join: "miter",
+ )
+ let wave-stroke = if voicing-stroke == auto {
+ (paint: gray.darken(45%), thickness: 0.55pt * scale-factor)
+ } else {
+ voicing-stroke
+ }
+ let waveform-stroke = if type(wave-stroke) == dictionary {
+ (cap: "round", join: "round", ..wave-stroke)
+ } else {
+ wave-stroke
+ }
+ let text-label = body => text(size: label-size, font: phonokit-font.get())[#body]
+ let text-doc = body => text(size: doc-font-size, font: phonokit-font.get())[#body]
+ let text-segment = body => {
+ let rendered = if type(body) == str { ipa-to-unicode(body) } else { body }
+ text(size: segment-font-size, font: phonokit-font.get())[#rendered]
+ }
+ let canvas-label-width = body => measure(text-label(body)).width / 1cm / scale-factor
+ let label-padding = 0.35
+ let interval-width = calc.abs(voicing-x - release-x)
+ let closure-width = calc.abs(release-x - tx(closure-start-time))
+ let vowel-width = calc.abs(tx(right-time) - tx(vowel-start))
+ let minimum-labelled-interval = 5.0
+ let show-closure-label = closure-ms > 0 and canvas-label-width(closure-text) + label-padding <= closure-width
+ let show-vowel-label = canvas-label-width(vowel-text) + label-padding <= vowel-width
+ let show-interval-segment = vot-ms >= minimum-labelled-interval and interval-segment != none
+ let show-positive-interval-label = (
+ vot-ms >= minimum-labelled-interval
+ and interval-label != none
+ and canvas-label-width(interval-text) + label-padding <= interval-width
+ )
+ let legend-interval = vot-ms > 0 and interval-label != none and not show-positive-interval-label
+ let interval-key-text = if interval-key != auto {
+ interval-key
+ } else if type(interval-text) == str and lower(interval-text).contains("aspir") {
+ [A]
+ } else {
+ [I]
+ }
+ let legend-text = {
+ [R = #release-text; V = #voicing-text]
+ if legend-interval {
+ [; #interval-key-text = #interval-text]
+ }
+ }
+ let event-tag = body => box(
+ width: 0.28cm * scale-factor,
+ height: 0.28cm * scale-factor,
+ stroke: region-stroke,
+ fill: white,
+ align(center + horizon, text(size: event-size, font: phonokit-font.get())[#body]),
+ )
+ let event-key-width = measure(event-tag(interval-key-text)).width / 1cm / scale-factor
+ let show-interval-key = keys and legend-interval and event-key-width + label-padding <= interval-width
+ let region-label-y = region-y2 + 0.24
+ let interval-label-y = region-label-y
+ let region-label-anchor = "base"
+ let segment-y = -0.22
+ let event-line-bottom = bracket-y - 0.12
+
+ box(inset: 1em, baseline: 50%, cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ let draw-wave = (x1, x2, y, amp: 0.08, step: 0.025, cycle-width: 0.65) => {
+ let span = x2 - x1
+ if span > step {
+ let count = int(calc.max(18, calc.floor(span / step)))
+ let cycles = calc.max(1.0, span / cycle-width)
+ let points = ()
+ for i in range(count) {
+ let p = i / (count - 1)
+ points.push((x1 + p * span, y + amp * calc.sin(p * cycles * 360deg)))
+ }
+ line(..points, stroke: waveform-stroke)
+ }
+ }
+ let noise-sample = i => {
+ (calc.sin(i * 137deg) * 0.55) + (calc.sin(i * 271deg) * 0.32) + (calc.sin(i * 53deg) * 0.18)
+ }
+ let draw-noise = (x1, x2, y, amp: 0.055, step: 0.04) => {
+ let span = x2 - x1
+ if span > step {
+ let count = int(calc.max(8, calc.floor(span / step)))
+ let points = ()
+ for i in range(count) {
+ let p = i / (count - 1)
+ points.push((x1 + p * span, y + amp * noise-sample(i)))
+ }
+ line(..points, stroke: waveform-stroke)
+ }
+ }
+ let draw-prevoicing = (x1, x2, y, amp: 0.035, step: 0.04, cycle-width: 0.32) => {
+ let span = x2 - x1
+ if span > step {
+ let count = int(calc.max(10, calc.floor(span / step)))
+ let cycles = calc.max(1.0, span / cycle-width)
+ let points = ()
+ for i in range(count) {
+ let p = i / (count - 1)
+ let modulation = 0.75 + 0.25 * calc.sin(p * 137deg)
+ let sample = (
+ 0.45 * modulation * calc.sin(p * cycles * 360deg)
+ + 0.55 * calc.sin(p * cycles * 900deg + 37deg)
+ + 0.42 * calc.sin(p * cycles * 1450deg + 83deg)
+ + 0.34 * noise-sample(i)
+ )
+ points.push((x1 + p * span, y + amp * sample))
+ }
+ line(..points, stroke: waveform-stroke)
+ }
+ }
+ let draw-prevoicing-transition = (x1, x2, y, step: 0.04, cycle-width: 0.36) => {
+ let span = x2 - x1
+ if span > step {
+ let count = int(calc.max(8, calc.floor(span / step)))
+ let cycles = calc.max(1.0, span / cycle-width)
+ let points = ()
+ for i in range(count) {
+ let p = i / (count - 1)
+ let closure = (1.0 - p) * 0.008 * calc.sin(p * 180deg)
+ let prevoice = (
+ p
+ * 0.035
+ * (
+ 0.45 * calc.sin(p * cycles * 360deg)
+ + 0.55 * calc.sin(p * cycles * 900deg + 37deg)
+ + 0.42 * calc.sin(p * cycles * 1450deg + 83deg)
+ + 0.34 * noise-sample(i)
+ )
+ )
+ points.push((x1 + p * span, y + closure + prevoice))
+ }
+ line(..points, stroke: waveform-stroke)
+ }
+ }
+ let draw-waveforms = () => {
+ if voicing {
+ if closure-ms > 0 {
+ if vot-ms < 0 {
+ let transition-width = calc.min(voicing-x - tx(closure-start-time), 0.32)
+ let transition-start-x = voicing-x - transition-width
+ draw-wave(
+ tx(closure-start-time),
+ transition-start-x,
+ region-y1 + 0.16,
+ amp: 0.008,
+ step: 0.07,
+ cycle-width: 1.2,
+ )
+ draw-prevoicing-transition(transition-start-x, voicing-x, region-y1 + 0.16)
+ draw-prevoicing(voicing-x, release-x, region-y1 + 0.16)
+ } else {
+ draw-wave(tx(closure-start-time), release-x, region-y1 + 0.16, amp: 0.008, step: 0.07, cycle-width: 1.2)
+ }
+ }
+ if vot-ms > 0 {
+ draw-noise(release-x, voicing-x, region-y1 + 0.16)
+ }
+ draw-wave(tx(vowel-start), tx(right-time), region-y1 + 0.16)
+ }
+ }
+
+ if vot-ms > 0 {
+ rect(
+ (release-x, region-y1),
+ (voicing-x, region-y2),
+ fill: fill-aspiration,
+ stroke: none,
+ )
+ line((release-x, region-y2), (voicing-x, region-y2), stroke: aspiration-stroke)
+ line((release-x, region-y1), (voicing-x, region-y1), stroke: aspiration-stroke)
+ }
+
+ line((release-x, event-line-bottom), (release-x, event-line-top), stroke: release-stroke)
+ if vot-ms != 0 {
+ line((voicing-x, event-line-bottom), (voicing-x, event-line-top), stroke: release-stroke)
+ }
+
+ if closure-ms > 0 {
+ rect(
+ (tx(closure-start-time), region-y1),
+ (release-x, region-y2),
+ fill: fill-closure,
+ stroke: region-stroke,
+ )
+ if show-closure-label {
+ content(
+ ((tx(closure-start-time) + release-x) / 2, region-label-y),
+ text-label(closure-text),
+ anchor: region-label-anchor,
+ )
+ }
+ if closure-segment != none {
+ let closure-segment-right-x = if vot-ms < 0 { voicing-x } else { release-x }
+ content(
+ ((tx(closure-start-time) + closure-segment-right-x) / 2, segment-y),
+ text-segment(closure-segment),
+ anchor: "center",
+ )
+ }
+ }
+
+ if vot-ms > 0 {
+ if show-positive-interval-label {
+ content(
+ ((release-x + voicing-x) / 2, interval-label-y),
+ text-label(interval-text),
+ anchor: region-label-anchor,
+ )
+ } else if interval-label != none and show-interval-key {
+ content(
+ ((release-x + voicing-x) / 2, interval-label-y),
+ event-tag(interval-key-text),
+ anchor: "center",
+ )
+ }
+ if show-interval-segment {
+ content(
+ ((release-x + voicing-x) / 2, segment-y),
+ text-segment(interval-segment),
+ anchor: "center",
+ )
+ }
+ }
+
+ rect(
+ (tx(vowel-start), region-y1),
+ (tx(right-time), region-y2),
+ fill: fill-vowel,
+ stroke: region-stroke,
+ )
+ if show-vowel-label {
+ content(
+ ((tx(vowel-start) + tx(right-time)) / 2, region-label-y),
+ text-label(vowel-text),
+ anchor: region-label-anchor,
+ )
+ }
+ if vowel-segment != none {
+ content(
+ ((tx(vowel-start) + tx(right-time)) / 2, segment-y),
+ text-segment(vowel-segment),
+ anchor: "center",
+ )
+ }
+
+ if keys {
+ content(
+ (release-x, event-label-y),
+ event-tag([R]),
+ anchor: "center",
+ )
+ if vot-ms != 0 {
+ content(
+ (voicing-x, event-label-y),
+ event-tag([V]),
+ anchor: "center",
+ )
+ }
+ content(
+ (tx(right-time), legend-y),
+ text-doc(legend-text),
+ anchor: "east",
+ )
+ }
+
+ draw-waveforms()
+
+ if show-label {
+ if vot-ms == 0 {
+ content(
+ (release-x, bracket-y - 0.35),
+ text-doc(vot-text),
+ anchor: "center",
+ )
+ } else {
+ let start-x = calc.min(release-x, voicing-x)
+ let end-x = calc.max(release-x, voicing-x)
+ line((start-x, bracket-y), (end-x, bracket-y), stroke: region-stroke)
+ line((start-x, bracket-y - 0.12), (start-x, bracket-y + 0.12), stroke: region-stroke)
+ line((end-x, bracket-y - 0.12), (end-x, bracket-y + 0.12), stroke: region-stroke)
+ content(
+ ((start-x + end-x) / 2, bracket-y - 0.35),
+ text-doc(vot-text),
+ anchor: "center",
+ )
+ }
+ }
+ }))
+}
diff --git a/packages/preview/phonokit/0.5.11/prosody.typ b/packages/preview/phonokit/0.5.11/prosody.typ
new file mode 100644
index 0000000000..ea89ad518e
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/prosody.typ
@@ -0,0 +1,2200 @@
+#import "@preview/cetz:0.5.2"
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+
+// Follows the same spacing convention as #ipa():
+// - Backslash commands need spaces: "t \\ae p" → "tæp"
+// - Single characters don't: "SIp" → "ʃɪp"
+#let convert-prosody-input(input) = {
+ let result = ""
+ let buffer = ""
+ let chars = input.codepoints()
+ let i = 0
+
+ while i < chars.len() {
+ let char = chars.at(i)
+
+ // Check if this is a structural marker
+ // Note: ' and , are stress markers (not printed, just for structure)
+ if char in ("'", ",", ".", "(", ")") {
+ // First, process any buffered content
+ if buffer != "" {
+ result += ipa-to-unicode(buffer)
+ buffer = ""
+ }
+ // Add the structural marker (will be stripped during parsing)
+ result += char
+ } else {
+ // Add to buffer (including spaces, which will be handled by ipa-to-unicode)
+ buffer += char
+ }
+
+ i += 1
+ }
+
+ // Process any remaining buffer
+ if buffer != "" {
+ result += ipa-to-unicode(buffer)
+ }
+
+ result
+}
+
+#let is-vowel(cluster) = {
+ // Check the base character (first codepoint) to handle both
+ // precomposed vowels (like "ã") and combining forms (like "a" + "̃")
+ let base = cluster.codepoints().at(0)
+
+ // Check if base is a vowel
+ let base-is-vowel = (
+ base
+ in ("a", "e", "i", "o", "u", "ɚ", "ɝ", "ɯ", "ɐ", "ɒ", "æ", "ɛ", "ɪ", "ɔ", "ø", "œ", "ɨ", "ʉ", "ʊ", "ə", "ʌ", "ɑ")
+ )
+
+ // Also check for diphthongs and precomposed forms as complete clusters
+ let cluster-is-vowel = cluster in ("aɪ", "eɪ", "oɪ", "aʊ", "oʊ", "ã", "ẽ", "õ", "ɛ̃", "ɔ̃", "œ̃", "ɑ̃")
+
+ // Check if cluster contains syllabicity marker (̩ U+0329)
+ // Syllabic consonants (like m̩, n̩, l̩) function as vowels/nuclei
+ let is-syllabic = cluster.contains("̩")
+
+ base-is-vowel or cluster-is-vowel or is-syllabic
+}
+
+// Custom clustering that handles:
+// 1. Affricates (tie bar U+0361) - merge "t͡" + "ʃ" → "t͡ʃ"
+// 2. Spacing modifiers (like "ʰ") - merge "b" + "ʰ" → "bʰ"
+// 3. Length marks (ː) - merge "a" + "ː" → "aː" as atomic unit
+#let smart-clusters(text) = {
+ let basic-clusters = text.clusters()
+ let result = ()
+ let i = 0
+
+ // Define spacing modifiers that should merge with preceding segment
+ let spacing-modifiers = ("ʰ", "ʷ", "ʲ", "ˠ", "ˤ", "̚")
+ // Length mark (U+02D0)
+ let length-mark = "ː"
+
+ while i < basic-clusters.len() {
+ let current = basic-clusters.at(i)
+
+ // Check if this cluster contains a tie bar
+ if current.contains("͡") {
+ // This cluster has a tie bar - merge with next cluster (affricate)
+ if i + 1 < basic-clusters.len() {
+ result.push(current + basic-clusters.at(i + 1))
+ i += 2 // Skip both clusters
+ } else {
+ result.push(current)
+ i += 1
+ }
+ } else if i + 1 < basic-clusters.len() and basic-clusters.at(i + 1) in spacing-modifiers {
+ // Next cluster is a spacing modifier - merge with current
+ result.push(current + basic-clusters.at(i + 1))
+ i += 2 // Skip both clusters
+ } else if i + 1 < basic-clusters.len() and basic-clusters.at(i + 1) == length-mark {
+ // Next cluster is a length mark - merge with current (atomic long vowel)
+ result.push(current + basic-clusters.at(i + 1))
+ i += 2 // Skip both clusters
+ } else {
+ // Regular cluster
+ result.push(current)
+ i += 1
+ }
+ }
+
+ result
+}
+
+#let parse-syllable(syll) = {
+ // Use smart-clusters() to properly handle affricates and combining diacritics
+ let clusters = smart-clusters(syll)
+ let onset = ""
+ let nucleus = ""
+ let coda = ""
+ let found-nucleus = false
+
+ for cluster in clusters {
+ if is-vowel(cluster) {
+ nucleus += cluster
+ found-nucleus = true
+ } else if not found-nucleus {
+ onset += cluster
+ } else {
+ coda += cluster
+ }
+ }
+
+ (onset: onset, nucleus: nucleus, coda: coda)
+}
+
+// Helper function to draw syllable internal structure
+#let draw-syllable-structure(
+ x-offset,
+ sigma-y,
+ syll,
+ terminal-y,
+ diagram-scale: 1.0,
+ geminate-coda-x: none,
+ geminate-onset-x: none,
+ geminate-coda-text: none,
+ geminate-onset-text: none,
+ compact: false,
+ or-y: none,
+ n-y: none,
+) = {
+ import cetz.draw: *
+
+ // O/R and N/C level positions (defaults preserve original hardcoded offsets)
+ let or-level = if or-y == none { sigma-y - 0.75 } else { or-y }
+ let n-level = if n-y == none { sigma-y - 1.65 } else { n-y }
+
+ // Choose offset values based on compact mode
+ let (line-offset, text-offset) = if compact {
+ (0.70, 0.40) // Compact: shorter lines, raised segments
+ } else {
+ (0.30, 0) // Standard: longer lines, lower segments
+ }
+
+ let has-onset = syll.onset != ""
+ let has-coda = syll.coda != ""
+
+ // Calculate segment counts for adaptive spacing
+ // Use smart-clusters() to properly count segments including affricates
+ let onset-segments = if has-onset { smart-clusters(syll.onset) } else { () }
+ let num-onset = if has-onset { onset-segments.len() } else { 0 }
+
+ let nucleus-segments = smart-clusters(syll.nucleus)
+ let num-nucleus = nucleus-segments.len()
+
+ let coda-segments = if has-coda { smart-clusters(syll.coda) } else { () }
+ let num-coda = if has-coda { coda-segments.len() } else { 0 }
+
+ let segment-spacing = 0.35
+ let min-gap = 0.75
+
+ // Headedness: Rhyme is head of syllable, Nucleus is head of Rhyme
+ // Heads align vertically, non-heads are angled
+ let rhyme-x = x-offset // vertical (head of syllable)
+ let nucleus-x = rhyme-x // MUST stay at rhyme-x (vertical, head of rhyme)
+
+ // Adaptive positioning for onset (move left if many segments)
+ let onset-x = if has-onset {
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + (num-nucleus - 1) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { x-offset - min-offset } else { x-offset - default-offset }
+ } else {
+ x-offset
+ }
+
+ // Adaptive positioning for coda (move right to avoid nucleus segments)
+ let coda-x = if has-coda {
+ let min-offset = (num-nucleus + num-coda - 2) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { rhyme-x + min-offset } else { rhyme-x + default-offset }
+ } else {
+ rhyme-x
+ }
+
+ // Branches from syllable
+ if has-onset {
+ line((x-offset, sigma-y + 0.25), (onset-x, or-level + 0.30))
+ content((onset-x, or-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[O])
+
+ // Branch to each onset segment
+ let onset-segments = smart-clusters(syll.onset)
+ let num-onset = onset-segments.len()
+
+ // Always draw all segments, but handle geminates specially
+ let onset-total-width = (num-onset - 1) * segment-spacing
+ let onset-start-x = onset-x - onset-total-width / 2
+
+ for (i, segment) in onset-segments.enumerate() {
+ let seg-x = onset-start-x + i * segment-spacing
+
+ // Check if this segment is the geminate
+ let is-geminate = (geminate-onset-x != none and segment == geminate-onset-text)
+
+ if is-geminate {
+ // Geminate: draw line to geminate position (text drawn separately in geminate section)
+ line((onset-x, or-level - 0.35), (geminate-onset-x, terminal-y + line-offset))
+ } else {
+ // Normal segment: draw line and text
+ line((onset-x, or-level - 0.35), (seg-x, terminal-y + line-offset))
+ content(
+ (seg-x, terminal-y + text-offset),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+ }
+
+ // Rhyme branch
+ line((x-offset, sigma-y + 0.25), (rhyme-x, or-level + 0.30))
+ content((rhyme-x, or-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[R])
+
+ // Nucleus
+ line((rhyme-x, or-level - 0.35), (nucleus-x, n-level + 0.30))
+ content((nucleus-x, n-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[N])
+
+ // Branch to each nucleus segment
+ let nucleus-total-width = (num-nucleus - 1) * segment-spacing
+ let nucleus-start-x = nucleus-x - nucleus-total-width / 2
+
+ for (i, segment) in nucleus-segments.enumerate() {
+ let seg-x = nucleus-start-x + i * segment-spacing
+ line((nucleus-x, n-level - 0.25), (seg-x, terminal-y + line-offset))
+ content(
+ (seg-x, terminal-y + text-offset),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+
+ // Coda (if exists)
+ if has-coda {
+ line((rhyme-x, or-level - 0.35), (coda-x, n-level + 0.30))
+ content((coda-x, n-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[C])
+
+ // Branch to each coda segment
+ // Always draw all segments, but handle geminates specially
+ let coda-total-width = (num-coda - 1) * segment-spacing
+ let coda-start-x = coda-x - coda-total-width / 2
+
+ for (i, segment) in coda-segments.enumerate() {
+ let seg-x = coda-start-x + i * segment-spacing
+
+ // Check if this segment is the geminate
+ let is-geminate = (geminate-coda-x != none and segment == geminate-coda-text)
+
+ if is-geminate {
+ // Geminate: draw line to geminate position (text drawn separately in geminate section)
+ line((coda-x, n-level - 0.25), (geminate-coda-x, terminal-y + line-offset))
+ } else {
+ // Normal segment: draw line and text
+ line((coda-x, n-level - 0.25), (seg-x, terminal-y + line-offset))
+ content(
+ (seg-x, terminal-y + text-offset),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+ }
+}
+
+// Visualizes a single syllable's internal structure (On/Rh/Nu/Co)
+// Now accepts IPA-style input like "k a" or "'t a"
+#let syllable(input, scale: 1.0, symbol: ("σ",), distance: none) = {
+ // Check for syllable boundary markers
+ if input.contains(".") {
+ return text(fill: red, weight: "bold")[⚠ Warning: For more than one syllable, use \#foot() or \#word().]
+ }
+
+ // Check for problematic diacritic sequences
+ let problematic-sequences = ("''", ",,", "\\* \\*", "\\t \\t", "::", "((", "))")
+ for seq in problematic-sequences {
+ if input.contains(seq) {
+ return text(fill: red, weight: "bold")[⚠ Warning: Problematic sequence involving diacritics: "#seq"]
+ }
+ }
+
+ // Convert IPA-style input to Unicode
+ let converted = convert-prosody-input(input)
+
+ // Parse a single syllable
+ // Strip ALL stress markers (' for primary, , for secondary) regardless of position
+ let stressed = converted.starts-with("'") or converted.starts-with(",")
+ let clean-input = converted.replace("'", "").replace(",", "")
+
+ let parsed = parse-syllable(clean-input)
+
+ // Check for too many codas (limit: 5 to avoid crossing lines)
+ let coda-segments-temp = if parsed.coda != "" { smart-clusters(parsed.coda) } else { () }
+ if coda-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many coda consonants (max 5 to avoid line crossings). Found: #coda-segments-temp.len()]
+ }
+
+ // Check for too many onsets (limit: 5 to avoid crossing lines)
+ let onset-segments-temp = if parsed.onset != "" { smart-clusters(parsed.onset) } else { () }
+ if onset-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many onset consonants (max 5 to avoid line crossings). Found: #onset-segments-temp.len()]
+ }
+
+ // Check for too many nucleus segments (limit: 5)
+ let nucleus-segments-temp = smart-clusters(parsed.nucleus)
+ if nucleus-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many nucleus segments (max 5 to avoid line crossings). Found: #nucleus-segments-temp.len()]
+ }
+ let syll = (
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: stressed,
+ )
+
+ let sym_syll = symbol.at(0, default: "σ")
+
+ let diagram-scale = scale
+ box(baseline: 50%, cetz.canvas(length: 1cm * diagram-scale, {
+ import cetz.draw: *
+ set-style(stroke: 0.7 * diagram-scale * 1pt)
+
+ // Distance multiplier lookup (floor: 1.0 for all sub-syllable levels)
+ let dist-mult(level) = {
+ let result = 1.0
+ if distance != none {
+ for entry in distance {
+ if entry.at(0) == level {
+ result = calc.max(1.0, entry.at(1))
+ }
+ }
+ }
+ result
+ }
+
+ let sigma-y = 0
+ let x-offset = 0
+
+ // Sub-syllable level positions (0=σ→O/R, 1=O/R→N/C, 2=N/C→segments)
+ let or-level = sigma-y - 0.75 * dist-mult(0)
+ let n-level = or-level - 1.25 * dist-mult(1)
+ let terminal-y = n-level - 1.50 * dist-mult(2)
+
+ // Standalone syllable spacing: Nu/Co positioned lower (halfway between Rh and segments)
+ let line-offset = 0.70
+ let text-offset = 0.40
+
+ let has-onset = syll.onset != ""
+ let has-coda = syll.coda != ""
+
+ // Calculate segment counts for adaptive spacing
+ let onset-segments = if has-onset { smart-clusters(syll.onset) } else { () }
+ let num-onset = if has-onset { onset-segments.len() } else { 0 }
+
+ let nucleus-segments = smart-clusters(syll.nucleus)
+ let num-nucleus = nucleus-segments.len()
+
+ let coda-segments = if has-coda { smart-clusters(syll.coda) } else { () }
+ let num-coda = if has-coda { coda-segments.len() } else { 0 }
+
+ let segment-spacing = 0.35
+ let min-gap = 0.75
+
+ // Headedness: Rhyme is head of syllable, Nucleus is head of Rhyme
+ let rhyme-x = x-offset
+ let nucleus-x = rhyme-x
+
+ // Adaptive positioning for onset
+ let onset-x = if has-onset {
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + (num-nucleus - 1) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { x-offset - min-offset } else { x-offset - default-offset }
+ } else {
+ x-offset
+ }
+
+ // Adaptive positioning for coda
+ let coda-x = if has-coda {
+ let min-offset = (num-nucleus + num-coda - 2) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { rhyme-x + min-offset } else { rhyme-x + default-offset }
+ } else {
+ rhyme-x
+ }
+
+ // Syllable node
+ content((x-offset, sigma-y + 0.54), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_syll])
+
+ // Onset branches (if exists)
+ if has-onset {
+ line((x-offset, sigma-y + 0.25), (onset-x, or-level + 0.30))
+ content((onset-x, or-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[O])
+
+ let onset-total-width = (num-onset - 1) * segment-spacing
+ let onset-start-x = onset-x - onset-total-width / 2
+
+ for (i, segment) in onset-segments.enumerate() {
+ let seg-x = onset-start-x + i * segment-spacing
+ line((onset-x, or-level - 0.35), (seg-x, terminal-y + line-offset))
+ content(
+ (seg-x, terminal-y + text-offset),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+
+ // Rhyme branch
+ line((x-offset, sigma-y + 0.25), (rhyme-x, or-level + 0.30))
+ content((rhyme-x, or-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[R])
+
+ // Nucleus
+ line((rhyme-x, or-level - 0.35), (nucleus-x, n-level + 0.35))
+ content((nucleus-x, n-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[N])
+
+ // Branch to each nucleus segment
+ let nucleus-total-width = (num-nucleus - 1) * segment-spacing
+ let nucleus-start-x = nucleus-x - nucleus-total-width / 2
+
+ for (i, segment) in nucleus-segments.enumerate() {
+ let seg-x = nucleus-start-x + i * segment-spacing
+ line((nucleus-x, n-level - 0.25), (seg-x, terminal-y + line-offset))
+ content(
+ (seg-x, terminal-y + text-offset),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+
+ // Coda (if exists)
+ if has-coda {
+ line((rhyme-x, or-level - 0.35), (coda-x, n-level + 0.35))
+ content((coda-x, n-level), context text(size: 10 * diagram-scale * 1pt, font: phonokit-font.get())[C])
+
+ let coda-total-width = (num-coda - 1) * segment-spacing
+ let coda-start-x = coda-x - coda-total-width / 2
+
+ for (i, segment) in coda-segments.enumerate() {
+ let seg-x = coda-start-x + i * segment-spacing
+ line((coda-x, n-level - 0.25), (seg-x, terminal-y + line-offset))
+ content(
+ (seg-x, terminal-y + text-offset),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+ }))
+}
+
+// Visualizes moraic structure (Hyman 1976)
+// Onsets: non-moraic (connect directly to σ)
+// Nucleus: always moraic (connects to μ, which connects to σ)
+// Coda: optionally moraic (coda: false → connects to σ; coda: true → connects to μ)
+#let mora(input, coda: false, scale: 1.0, symbol: ("σ", "μ"), distance: none) = {
+ // Check for syllable boundary markers
+ if input.contains(".") {
+ return text(fill: red, weight: "bold")[⚠ Warning: For more than one syllable, use \#foot() or \#word().]
+ }
+
+ // Check for problematic diacritic sequences
+ let problematic-sequences = ("''", ",,", "\\* \\*", "\\t \\t", "::", "((", "))")
+ for seq in problematic-sequences {
+ if input.contains(seq) {
+ return text(fill: red, weight: "bold")[⚠ Warning: Problematic sequence involving diacritics: "#seq"]
+ }
+ }
+
+ // Convert IPA-style input to Unicode
+ let converted = convert-prosody-input(input)
+
+ // Parse syllable
+ // Strip ALL stress markers (' for primary, , for secondary) regardless of position
+ let clean-input = converted.replace("'", "").replace(",", "")
+
+ let parsed = parse-syllable(clean-input)
+
+ // Check for too many codas (limit: 5 to avoid crossing lines)
+ let coda-segments-temp = if parsed.coda != "" { smart-clusters(parsed.coda) } else { () }
+ if coda-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many coda consonants (max 5 to avoid line crossings). Found: #coda-segments-temp.len()]
+ }
+
+ // Check for too many onsets (limit: 5 to avoid crossing lines)
+ let onset-segments-temp = if parsed.onset != "" { smart-clusters(parsed.onset) } else { () }
+ if onset-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many onset consonants (max 5 to avoid line crossings). Found: #onset-segments-temp.len()]
+ }
+
+ // Check for too many nucleus segments (limit: 5)
+ let nucleus-segments-temp = smart-clusters(parsed.nucleus)
+ if nucleus-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many nucleus segments (max 5 to avoid line crossings). Found: #nucleus-segments-temp.len()]
+ }
+
+ let sym_syll = symbol.at(0, default: "σ")
+ let sym_mora = symbol.at(1, default: "μ")
+
+ let diagram-scale = scale
+ box(baseline: 50%, cetz.canvas(length: 1cm * diagram-scale, {
+ import cetz.draw: *
+ set-style(stroke: 0.7 * diagram-scale * 1pt)
+
+ // Distance multiplier lookup (floor: 0.5 for all mora levels)
+ let dist-mult(level) = {
+ let result = 1.0
+ if distance != none {
+ for entry in distance {
+ if entry.at(0) == level {
+ result = calc.max(0.5, entry.at(1))
+ }
+ }
+ }
+ result
+ }
+
+ let sigma-y = 0
+ let segment-spacing = 0.35
+ let x-offset = 0
+
+ // Syllable node
+ content((x-offset, sigma-y + 0.54), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_syll])
+
+ // Calculate segment counts
+ let onset-segments = if parsed.onset != "" { smart-clusters(parsed.onset) } else { () }
+ let nucleus-segments = smart-clusters(parsed.nucleus)
+ let coda-segments = if parsed.coda != "" { smart-clusters(parsed.coda) } else { () }
+
+ let num-onset = onset-segments.len()
+ let num-nucleus = nucleus-segments.len()
+ let num-coda = coda-segments.len()
+
+ // Position calculations (σ→μ = level 0, μ→segments = level 1)
+ let mora-base-gap = 1.62
+ let terminal-y = sigma-y + 0.35 - mora-base-gap * (dist-mult(0) + dist-mult(1))
+ let mora-y = sigma-y + 0.54 - mora-base-gap * 1.4 * dist-mult(0)
+ let nucleus-mora-x = x-offset
+
+ // Onset position (left of nucleus mora)
+ // Adaptive positioning: move left based on number of segments to avoid crossings
+ let onset-x = if num-onset > 0 {
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + 0.8
+ let default-offset = 1.2
+ nucleus-mora-x - calc.max(min-offset, default-offset)
+ } else {
+ nucleus-mora-x
+ }
+
+ // Coda position (right of nucleus mora)
+ // Adaptive positioning: move right based on number of segments to avoid crossings
+ let coda-x = if num-coda > 0 {
+ let min-offset = (num-coda - 1) * segment-spacing / 2 + 0.8
+ let default-offset = 1.2
+ nucleus-mora-x + calc.max(min-offset, default-offset)
+ } else {
+ nucleus-mora-x
+ }
+
+ // Draw ONSET (non-moraic - connects directly to σ)
+ if num-onset > 0 {
+ let onset-total-width = (num-onset - 1) * segment-spacing
+ let onset-start-x = onset-x - onset-total-width / 2
+
+ for (i, segment) in onset-segments.enumerate() {
+ let seg-x = onset-start-x + i * segment-spacing
+ line((x-offset, sigma-y + 0.25), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+
+ // Check if nucleus contains long vowel (Vː)
+ let has-long-vowel = parsed.nucleus.contains("ː")
+
+ // Draw NUCLEUS MORA(E) - one mora for short vowel, two for long vowel
+ if has-long-vowel {
+ // Long vowel: draw TWO morae that branch from σ and converge on Vː
+ let mora-spacing = 0.6
+ let mora1-x = nucleus-mora-x - mora-spacing / 2
+ let mora2-x = nucleus-mora-x + mora-spacing / 2
+
+ // Draw two μ nodes
+ content((mora1-x, mora-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_mora])
+ content((mora2-x, mora-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_mora])
+
+ // Lines from σ to both morae
+ line((x-offset, sigma-y + 0.25), (mora1-x, mora-y + 0.35))
+ line((x-offset, sigma-y + 0.25), (mora2-x, mora-y + 0.35))
+
+ // Both morae converge to the long vowel segment
+ let nucleus-total-width = (num-nucleus - 1) * segment-spacing
+ let nucleus-start-x = nucleus-mora-x - nucleus-total-width / 2
+
+ for (i, segment) in nucleus-segments.enumerate() {
+ let seg-x = nucleus-start-x + i * segment-spacing
+ // Lines from both morae converge to the segment
+ line((mora1-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+ line((mora2-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ } else {
+ // Short vowel: one mora
+ content((nucleus-mora-x, mora-y), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_mora])
+ line((x-offset, sigma-y + 0.25), (nucleus-mora-x, mora-y + 0.35))
+
+ // Draw nucleus segments below mora
+ let nucleus-total-width = (num-nucleus - 1) * segment-spacing
+ let nucleus-start-x = nucleus-mora-x - nucleus-total-width / 2
+
+ for (i, segment) in nucleus-segments.enumerate() {
+ let seg-x = nucleus-start-x + i * segment-spacing
+ line((nucleus-mora-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+
+ // Draw CODA
+ if num-coda > 0 {
+ if coda {
+ // Moraic coda: ONE μ for all coda segments (they share the mora)
+ // Draw the coda μ
+ content((coda-x, mora-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_mora])
+
+ // Line from σ to coda μ
+ line((x-offset, sigma-y + 0.25), (coda-x, mora-y + 0.35))
+
+ // All coda segments branch from this single μ
+ let coda-total-width = (num-coda - 1) * segment-spacing
+ let coda-start-x = coda-x - coda-total-width / 2
+
+ for (i, segment) in coda-segments.enumerate() {
+ let seg-x = coda-start-x + i * segment-spacing
+
+ // Line from shared μ to each segment
+ line((coda-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+
+ // Draw segment
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ } else {
+ // Non-moraic coda: connects directly to σ
+ let coda-total-width = (num-coda - 1) * segment-spacing
+ let coda-start-x = coda-x - coda-total-width / 2
+
+ for (i, segment) in coda-segments.enumerate() {
+ let seg-x = coda-start-x + i * segment-spacing
+ line((x-offset, sigma-y + 0.25), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+ }
+ }))
+}
+
+// Helper function to draw moraic structure (used by foot.mora and word.mora)
+#let draw-moraic-structure(
+ x-offset,
+ sigma-y,
+ syll,
+ terminal-y,
+ coda: false,
+ diagram-scale: 1.0,
+ geminate-coda-x: none,
+ geminate-onset-x: none,
+ mora-symbol: "μ",
+ mora-y-override: none,
+) = {
+ import cetz.draw: *
+
+ let segment-spacing = 0.35
+
+ // Calculate segment counts
+ let onset-segments = if syll.onset != "" { smart-clusters(syll.onset) } else { () }
+ let nucleus-segments = smart-clusters(syll.nucleus)
+ let coda-segments = if syll.coda != "" { smart-clusters(syll.coda) } else { () }
+
+ let num-onset = onset-segments.len()
+ let num-nucleus = nucleus-segments.len()
+ let num-coda = coda-segments.len()
+
+ // Check if nucleus contains long vowel (needed for spacing calculations)
+ let has-long-vowel = syll.nucleus.contains("ː")
+
+ // Position calculations
+ let mora-y = if mora-y-override != none { mora-y-override } else { 0.4 * (sigma-y + 0.54) + 0.6 * terminal-y }
+ let nucleus-mora-x = x-offset
+
+ // Adaptive onset position (same formula as draw-syllable-structure for uniform spacing)
+ let onset-x = if num-onset > 0 {
+ let min-gap = 0.75
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + (num-nucleus - 1) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { nucleus-mora-x - min-offset } else { nucleus-mora-x - default-offset }
+ } else {
+ nucleus-mora-x
+ }
+
+ // Adaptive coda position (same formula as draw-syllable-structure for uniform spacing)
+ let coda-x = if num-coda > 0 {
+ let min-gap = 0.75
+ let min-offset = (num-nucleus + num-coda - 2) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { nucleus-mora-x + min-offset } else { nucleus-mora-x + default-offset }
+ } else {
+ nucleus-mora-x
+ }
+
+ // Draw ONSET (non-moraic - connects directly to σ)
+ if num-onset > 0 {
+ // Check if this is a geminate onset
+ if geminate-onset-x != none {
+ // Geminate: draw line to geminate position
+ line((x-offset, sigma-y + 0.25), (geminate-onset-x, terminal-y + 0.30))
+ } else {
+ // Normal onset: draw branches to individual segments
+ let onset-total-width = (num-onset - 1) * segment-spacing
+ let onset-start-x = onset-x - onset-total-width / 2
+
+ for (i, segment) in onset-segments.enumerate() {
+ let seg-x = onset-start-x + i * segment-spacing
+ line((x-offset, sigma-y + 0.25), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+ }
+
+ // Draw NUCLEUS MORA(E)
+ if has-long-vowel {
+ // Long vowel: TWO morae
+ let mora-spacing = 0.6
+ let mora1-x = nucleus-mora-x - mora-spacing / 2
+ let mora2-x = nucleus-mora-x + mora-spacing / 2
+
+ content((mora1-x, mora-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#mora-symbol])
+ content((mora2-x, mora-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#mora-symbol])
+
+ line((x-offset, sigma-y + 0.25), (mora1-x, mora-y + 0.35))
+ line((x-offset, sigma-y + 0.25), (mora2-x, mora-y + 0.35))
+
+ let nucleus-total-width = (num-nucleus - 1) * segment-spacing
+ let nucleus-start-x = nucleus-mora-x - nucleus-total-width / 2
+
+ for (i, segment) in nucleus-segments.enumerate() {
+ let seg-x = nucleus-start-x + i * segment-spacing
+ line((mora1-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+ line((mora2-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ } else {
+ // Short vowel: ONE mora
+ content((nucleus-mora-x, mora-y), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#mora-symbol])
+ line((x-offset, sigma-y + 0.25), (nucleus-mora-x, mora-y + 0.35))
+
+ let nucleus-total-width = (num-nucleus - 1) * segment-spacing
+ let nucleus-start-x = nucleus-mora-x - nucleus-total-width / 2
+
+ for (i, segment) in nucleus-segments.enumerate() {
+ let seg-x = nucleus-start-x + i * segment-spacing
+ line((nucleus-mora-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+
+ // Draw CODA
+ if num-coda > 0 {
+ if coda {
+ // Moraic coda: ONE μ shared by all segments
+ content((coda-x, mora-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#mora-symbol])
+ line((x-offset, sigma-y + 0.25), (coda-x, mora-y + 0.35))
+
+ // Check if this is a geminate coda
+ if geminate-coda-x != none {
+ // Geminate: draw line to geminate position
+ line((coda-x, mora-y - 0.35), (geminate-coda-x, terminal-y + 0.30))
+ } else {
+ // Normal coda: all segments branch from shared μ
+ let coda-total-width = (num-coda - 1) * segment-spacing
+ let coda-start-x = coda-x - coda-total-width / 2
+
+ for (i, segment) in coda-segments.enumerate() {
+ let seg-x = coda-start-x + i * segment-spacing
+ line((coda-x, mora-y - 0.35), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+ } else {
+ // Non-moraic coda: connects directly to σ
+ // Check if this is a geminate coda
+ if geminate-coda-x != none {
+ // Geminate: draw line to geminate position
+ line((x-offset, sigma-y + 0.25), (geminate-coda-x, terminal-y + 0.30))
+ } else {
+ // Normal coda: branches to individual segments
+ let coda-total-width = (num-coda - 1) * segment-spacing
+ let coda-start-x = coda-x - coda-total-width / 2
+
+ for (i, segment) in coda-segments.enumerate() {
+ let seg-x = coda-start-x + i * segment-spacing
+ line((x-offset, sigma-y + 0.25), (seg-x, terminal-y + 0.30))
+ content(
+ (seg-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#segment],
+ anchor: "north",
+ )
+ }
+ }
+ }
+ }
+}
+
+// Visualizes foot and syllable levels
+// Now accepts IPA-style input like "k a.'v a.l o"
+#let foot(input, scale: 1.0, symbol: ("Σ", "σ"), distance: none) = {
+ // Check for parentheses (foot should not have multiple feet)
+ if input.contains("(") or input.contains(")") {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: foot() is for a single foot. If you need multiple feet, use word() instead.]
+ }
+
+ // Convert IPA-style input to Unicode
+ let converted = convert-prosody-input(input)
+
+ // Parse syllables from dotted input
+ let syllables = ()
+ let buffer = ""
+ let chars = converted.codepoints()
+ let i = 0
+
+ while i < chars.len() {
+ let char = chars.at(i)
+
+ if char == "." {
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+ buffer = ""
+ }
+ } else {
+ buffer += char
+ }
+ i += 1
+ }
+
+ // Handle remaining buffer
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+ }
+
+ // Check for too many onsets/codas/nucleus in any syllable (limit: 5 to avoid crossing lines)
+ for (i, syll) in syllables.enumerate() {
+ let coda-segments-temp = if syll.coda != "" { smart-clusters(syll.coda) } else { () }
+ if coda-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many coda consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #coda-segments-temp.len()]
+ }
+ let onset-segments-temp = if syll.onset != "" { smart-clusters(syll.onset) } else { () }
+ if onset-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many onset consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #onset-segments-temp.len()]
+ }
+ let nucleus-segments-temp = smart-clusters(syll.nucleus)
+ if nucleus-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many nucleus segments in syllable #(i + 1) (max 5 to avoid line crossings). Found: #nucleus-segments-temp.len()]
+ }
+ }
+
+ // Find stressed syllable (head of foot)
+ let head-idx = 0
+ for (i, syll) in syllables.enumerate() {
+ if syll.stressed {
+ head-idx = i
+ }
+ }
+
+ let sym_foot = symbol.at(0, default: "Σ")
+ let sym_syll = symbol.at(1, default: "σ")
+
+ let diagram-scale = scale
+
+ box(baseline: 50%, cetz.canvas(length: 1cm * diagram-scale, {
+ import cetz.draw: *
+ set-style(stroke: 0.7 * diagram-scale * 1pt)
+
+ // Distance multiplier lookup (floor: 0.5 for levels 0–1, 1.0 for levels 2+)
+ let dist-mult(level) = {
+ let result = 1.0
+ if distance != none {
+ let floor = if level <= 1 { 0.5 } else { 1.0 }
+ for entry in distance {
+ if entry.at(0) == level {
+ result = calc.max(floor, entry.at(1))
+ }
+ }
+ }
+ result
+ }
+
+ let segment-spacing = 0.35
+ let min-gap-between-sylls = 0.8
+ let default-spacing = 1.6
+
+ // Calculate extents for each syllable
+ let syllable-extents = ()
+ for syll in syllables {
+ let has-onset = syll.onset != ""
+ let has-coda = syll.coda != ""
+ let num-onset = if has-onset { smart-clusters(syll.onset).len() } else { 0 }
+ let num-nucleus = smart-clusters(syll.nucleus).len()
+ let num-coda = if has-coda { smart-clusters(syll.coda).len() } else { 0 }
+ let min-gap = 0.75
+
+ // Calculate constituent positions
+ let onset-x-rel = if has-onset {
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + (num-nucleus - 1) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { -min-offset } else { -default-offset }
+ } else { 0 }
+
+ let coda-x-rel = if has-coda {
+ let min-offset = (num-nucleus + num-coda - 2) * segment-spacing / 2 + 0.4
+ let default-offset = 0.7
+ if min-offset > default-offset { min-offset } else { default-offset }
+ } else { 0 }
+
+ // Calculate segment widths
+ let onset-width = if has-onset { (num-onset - 1) * segment-spacing } else { 0 }
+ let nucleus-width = (num-nucleus - 1) * segment-spacing
+ let coda-width = if has-coda { (num-coda - 1) * segment-spacing } else { 0 }
+
+ // Calculate left and right extents
+ let left-parts = (
+ if has-onset { onset-x-rel - onset-width / 2 } else { 0 },
+ -nucleus-width / 2,
+ if has-coda { coda-x-rel - coda-width / 2 } else { 0 },
+ )
+ let right-parts = (
+ if has-onset { onset-x-rel + onset-width / 2 } else { 0 },
+ nucleus-width / 2,
+ if has-coda { coda-x-rel + coda-width / 2 } else { 0 },
+ )
+
+ let left-extent = calc.min(..left-parts)
+ let right-extent = calc.max(..right-parts)
+
+ syllable-extents.push((left: left-extent, right: right-extent))
+ }
+
+ // Calculate adaptive spacing and positions
+ let syllable-positions = ()
+ for (i, extent) in syllable-extents.enumerate() {
+ if i == 0 {
+ syllable-positions.push(0)
+ } else {
+ let prev-right = syllable-extents.at(i - 1).right
+ let required-spacing = prev-right - extent.left + min-gap-between-sylls
+ let actual-spacing = calc.max(required-spacing, default-spacing)
+ let prev-position = syllable-positions.at(i - 1)
+ syllable-positions.push(prev-position + actual-spacing)
+ }
+ }
+
+ // Center the structure
+ let first-left = syllable-positions.at(0) + syllable-extents.at(0).left
+ let last-right = syllable-positions.at(-1) + syllable-extents.at(-1).right
+ let total-width = last-right - first-left
+ let start-x = -total-width / 2 - first-left
+
+ let foot-x = start-x + syllable-positions.at(head-idx)
+
+ // Vertical level positions
+ let sigma-y = -2.4
+ let sigma-y-label = sigma-y + 0.54
+ let base-ft-height = -0.9 + (syllables.len() * 0.3)
+ let ft-sigma-gap = base-ft-height - sigma-y-label
+ let ft-height = sigma-y-label + ft-sigma-gap * dist-mult(0)
+
+ // Sub-syllable level positions
+ let or-y = sigma-y - 0.75 * dist-mult(1)
+ let n-y = or-y - 0.90 * dist-mult(2)
+ let terminal-y = n-y - 0.95 * dist-mult(3)
+
+ // Draw Ft node above the head
+ content((foot-x, ft-height), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_foot])
+
+ // Detect geminates
+ // A geminate occurs when the last consonant of a coda matches the first consonant of the next onset
+ let geminates = ()
+ for i in range(syllables.len() - 1) {
+ if syllables.at(i).coda != "" and syllables.at(i + 1).onset != "" {
+ let coda-segments = smart-clusters(syllables.at(i).coda)
+ let onset-segments = smart-clusters(syllables.at(i + 1).onset)
+ let last-coda = coda-segments.at(-1)
+ let first-onset = onset-segments.at(0)
+
+ // Check if they match and are consonants (not vowels)
+ if last-coda == first-onset and not is-vowel(last-coda) {
+ let gem-x = start-x + (syllable-positions.at(i) + syllable-positions.at(i + 1)) / 2
+ geminates.push((syll-idx: i, gem-x: gem-x, gem-text: last-coda))
+ }
+ }
+ }
+
+ // Draw syllables
+ for (i, syll) in syllables.enumerate() {
+ let x-offset = start-x + syllable-positions.at(i)
+
+ // Syllable node
+ content((x-offset, sigma-y + 0.54), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_syll])
+
+ // Line from Ft to σ
+ line((foot-x, ft-height - 0.25), (x-offset, sigma-y + 0.8))
+
+ // Check for geminate
+ let gem-coda-x = none
+ let gem-coda-text = none
+ let gem-onset-x = none
+ let gem-onset-text = none
+ for gem in geminates {
+ if gem.syll-idx == i {
+ gem-coda-x = gem.gem-x
+ gem-coda-text = gem.gem-text
+ }
+ if gem.syll-idx == i - 1 {
+ gem-onset-x = gem.gem-x
+ gem-onset-text = gem.gem-text
+ }
+ }
+
+ draw-syllable-structure(
+ x-offset,
+ sigma-y,
+ syll,
+ terminal-y,
+ diagram-scale: diagram-scale,
+ geminate-coda-x: gem-coda-x,
+ geminate-onset-x: gem-onset-x,
+ geminate-coda-text: gem-coda-text,
+ geminate-onset-text: gem-onset-text,
+ or-y: or-y,
+ n-y: n-y,
+ )
+ }
+
+ // Draw geminate segments
+ for gem in geminates {
+ content(
+ (gem.gem-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#gem.gem-text],
+ anchor: "north",
+ )
+ }
+ }))
+}
+
+// Visualizes word, foot, and syllable levels
+// Now accepts IPA-style input like "(k a.'v a).l o"
+#let word(input, foot: "R", scale: 1.0, symbol: ("ω", "Σ", "σ"), distance: none) = {
+ // Convert IPA-style input to Unicode
+ let converted = convert-prosody-input(input)
+
+ let syllables = ()
+ let feet = ()
+ let current-foot = ()
+ let in-foot = false
+ let buffer = ""
+
+ let chars = converted.codepoints()
+ let i = 0
+
+ while i < chars.len() {
+ let char = chars.at(i)
+
+ if char == "(" {
+ in-foot = true
+ current-foot = ()
+ } else if char == ")" {
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+ current-foot.push(syllables.len() - 1)
+ buffer = ""
+ }
+ if current-foot.len() > 0 {
+ feet.push(current-foot)
+ }
+ in-foot = false
+ } else if char == "." {
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+
+ if in-foot {
+ current-foot.push(syllables.len() - 1)
+ }
+
+ buffer = ""
+ }
+ } else {
+ buffer += char
+ }
+
+ i += 1
+ }
+
+ // Handle remaining buffer
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+
+ if in-foot {
+ current-foot.push(syllables.len() - 1)
+ }
+ }
+
+ // Check for too many onsets/codas/nucleus in any syllable (limit: 5 to avoid crossing lines)
+ for (i, syll) in syllables.enumerate() {
+ let coda-segments-temp = if syll.coda != "" { smart-clusters(syll.coda) } else { () }
+ if coda-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many coda consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #coda-segments-temp.len()]
+ }
+ let onset-segments-temp = if syll.onset != "" { smart-clusters(syll.onset) } else { () }
+ if onset-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many onset consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #onset-segments-temp.len()]
+ }
+ let nucleus-segments-temp = smart-clusters(syll.nucleus)
+ if nucleus-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many nucleus segments in syllable #(i + 1) (max 5 to avoid line crossings). Found: #nucleus-segments-temp.len()]
+ }
+ }
+
+ // Determine which syllables are in feet
+ let in-foot-set = ()
+ for foot in feet {
+ for syll-idx in foot {
+ in-foot-set.push(syll-idx)
+ }
+ }
+
+ let sym_word = symbol.at(0, default: "ω")
+ let sym_foot = symbol.at(1, default: "Σ")
+ let sym_syll = symbol.at(2, default: "σ")
+
+ let diagram-scale = scale
+
+ // Draw the structure
+ box(baseline: 50%, cetz.canvas(length: 1cm * diagram-scale, {
+ import cetz.draw: *
+
+ set-style(stroke: 0.7 * diagram-scale * 1pt)
+
+ let segment-spacing = 0.35
+ let min-gap-between-sylls = 0.8
+ let default-spacing = 1.6
+
+ // Distance multiplier lookup (floor: 0.5 for levels 0–1, 1.0 for levels 2–4)
+ let dist-mult(level) = {
+ let result = 1.0
+ if distance != none {
+ let floor = if level <= 1 { 0.5 } else { 1.0 }
+ for entry in distance {
+ if entry.at(0) == level {
+ result = calc.max(floor, entry.at(1))
+ }
+ }
+ }
+ result
+ }
+
+ // Vertical level positions (built top-down from σ)
+ let sigma-y = -2.4
+ let sigma-y-label = sigma-y + 0.54
+ let base-gap = 0.96
+ let foot-y = sigma-y-label + base-gap * dist-mult(1)
+
+ // Sub-syllable level positions
+ let or-y = sigma-y - 0.75 * dist-mult(2)
+ let n-y = or-y - 0.90 * dist-mult(3)
+ let terminal-y = n-y - 0.95 * dist-mult(4)
+
+
+ // Calculate extents for each syllable
+ let syllable-extents = ()
+ for syll in syllables {
+ let has-onset = syll.onset != ""
+ let has-coda = syll.coda != ""
+ let num-onset = if has-onset { smart-clusters(syll.onset).len() } else { 0 }
+ let num-nucleus = smart-clusters(syll.nucleus).len()
+ let num-coda = if has-coda { smart-clusters(syll.coda).len() } else { 0 }
+ let min-gap = 0.75
+
+ let onset-x-rel = if has-onset {
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + (num-nucleus - 1) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { -min-offset } else { -default-offset }
+ } else { 0 }
+
+ let coda-x-rel = if has-coda {
+ let min-offset = (num-nucleus + num-coda - 2) * segment-spacing / 2 + 0.4
+ let default-offset = 0.7
+ if min-offset > default-offset { min-offset } else { default-offset }
+ } else { 0 }
+
+ let onset-width = if has-onset { (num-onset - 1) * segment-spacing } else { 0 }
+ let nucleus-width = (num-nucleus - 1) * segment-spacing
+ let coda-width = if has-coda { (num-coda - 1) * segment-spacing } else { 0 }
+
+ let left-parts = (
+ if has-onset { onset-x-rel - onset-width / 2 } else { 0 },
+ -nucleus-width / 2,
+ if has-coda { coda-x-rel - coda-width / 2 } else { 0 },
+ )
+ let right-parts = (
+ if has-onset { onset-x-rel + onset-width / 2 } else { 0 },
+ nucleus-width / 2,
+ if has-coda { coda-x-rel + coda-width / 2 } else { 0 },
+ )
+
+ let left-extent = calc.min(..left-parts)
+ let right-extent = calc.max(..right-parts)
+
+ syllable-extents.push((left: left-extent, right: right-extent))
+ }
+
+ // Calculate adaptive spacing and positions
+ let syllable-positions = ()
+ for (i, extent) in syllable-extents.enumerate() {
+ if i == 0 {
+ syllable-positions.push(0)
+ } else {
+ let prev-right = syllable-extents.at(i - 1).right
+ let required-spacing = prev-right - extent.left + min-gap-between-sylls
+ let actual-spacing = calc.max(required-spacing, default-spacing)
+ let prev-position = syllable-positions.at(i - 1)
+ syllable-positions.push(prev-position + actual-spacing)
+ }
+ }
+
+ // Center the structure
+ let first-left = syllable-positions.at(0) + syllable-extents.at(0).left
+ let last-right = syllable-positions.at(-1) + syllable-extents.at(-1).right
+ let total-width = last-right - first-left
+ let start-x = -total-width / 2 - first-left
+
+ // Determine PWd x-position
+ let pwd-x = 0
+
+ if feet.len() > 0 {
+ let target-foot = if foot == "L" { feet.at(0) } else { feet.at(-1) }
+
+ let head-idx = target-foot.at(0)
+ for syll-idx in target-foot {
+ if syllables.at(syll-idx).stressed {
+ head-idx = syll-idx
+ }
+ }
+ pwd-x = start-x + syllable-positions.at(head-idx)
+ } else if syllables.len() > 0 {
+ let target-idx = if foot == "L" { 0 } else { syllables.len() - 1 }
+ pwd-x = start-x + syllable-positions.at(target-idx)
+ }
+
+ // Calculate minimum PWd height
+ let clearance-margin = 0.5
+ let min-pwd-height = foot-y + base-gap * 1.5
+
+ for (i, syll) in syllables.enumerate() {
+ if i not in in-foot-set {
+ let syll-x = start-x + syllable-positions.at(i)
+
+ for ft in feet {
+ let head-idx = ft.at(0)
+ for syll-idx in ft {
+ if syllables.at(syll-idx).stressed {
+ head-idx = syll-idx
+ }
+ }
+ let foot-x = start-x + syllable-positions.at(head-idx)
+
+ let is-between = (pwd-x < foot-x and foot-x < syll-x) or (syll-x < foot-x and foot-x < pwd-x)
+
+ if is-between and calc.abs(syll-x - pwd-x) > 0.01 {
+ let t = (foot-x - pwd-x) / (syll-x - pwd-x)
+ let required-height = (1.35 * t - 0.35 + clearance-margin) / (1 - t)
+ min-pwd-height = calc.max(min-pwd-height, required-height)
+ }
+ }
+ }
+ }
+
+ let pwd-height = calc.max(min-pwd-height, min-pwd-height * dist-mult(0))
+
+ content((pwd-x, pwd-height), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_word])
+
+ // Detect geminates
+ // A geminate occurs when the last consonant of a coda matches the first consonant of the next onset
+ let geminates = ()
+ for i in range(syllables.len() - 1) {
+ if syllables.at(i).coda != "" and syllables.at(i + 1).onset != "" {
+ let coda-segments = smart-clusters(syllables.at(i).coda)
+ let onset-segments = smart-clusters(syllables.at(i + 1).onset)
+ let last-coda = coda-segments.at(-1)
+ let first-onset = onset-segments.at(0)
+
+ // Check if they match and are consonants (not vowels)
+ if last-coda == first-onset and not is-vowel(last-coda) {
+ let gem-x = start-x + (syllable-positions.at(i) + syllable-positions.at(i + 1)) / 2
+ geminates.push((syll-idx: i, gem-x: gem-x, gem-text: last-coda))
+ }
+ }
+ }
+
+ // Draw footless syllables
+ for (i, syll) in syllables.enumerate() {
+ if i not in in-foot-set {
+ let x-offset = start-x + syllable-positions.at(i)
+
+ content((x-offset, sigma-y + 0.54), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_syll])
+ line((pwd-x, pwd-height - 0.3), (x-offset, sigma-y + 0.75))
+
+ let gem-coda-x = none
+ let gem-coda-text = none
+ let gem-onset-x = none
+ let gem-onset-text = none
+ for gem in geminates {
+ if gem.syll-idx == i {
+ gem-coda-x = gem.gem-x
+ gem-coda-text = gem.gem-text
+ }
+ if gem.syll-idx == i - 1 {
+ gem-onset-x = gem.gem-x
+ gem-onset-text = gem.gem-text
+ }
+ }
+
+ draw-syllable-structure(
+ x-offset,
+ sigma-y,
+ syll,
+ terminal-y,
+ diagram-scale: diagram-scale,
+ geminate-coda-x: gem-coda-x,
+ geminate-onset-x: gem-onset-x,
+ geminate-coda-text: gem-coda-text,
+ geminate-onset-text: gem-onset-text,
+ or-y: or-y,
+ n-y: n-y,
+ )
+ }
+ }
+
+ // Draw each foot
+ for foot in feet {
+ let head-idx = foot.at(0)
+ for syll-idx in foot {
+ if syllables.at(syll-idx).stressed {
+ head-idx = syll-idx
+ }
+ }
+
+ let foot-x = start-x + syllable-positions.at(head-idx)
+
+ content((foot-x, foot-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_foot])
+ line((pwd-x, pwd-height - 0.3), (foot-x, foot-y + 0.25))
+
+ for syll-idx in foot {
+ let x-offset = start-x + syllable-positions.at(syll-idx)
+ let syll = syllables.at(syll-idx)
+
+ content((x-offset, sigma-y + 0.54), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_syll])
+ line((foot-x, foot-y - 0.25), (x-offset, sigma-y + 0.8))
+
+ let gem-coda-x = none
+ let gem-coda-text = none
+ let gem-onset-x = none
+ let gem-onset-text = none
+ for gem in geminates {
+ if gem.syll-idx == syll-idx {
+ gem-coda-x = gem.gem-x
+ gem-coda-text = gem.gem-text
+ }
+ if gem.syll-idx == syll-idx - 1 {
+ gem-onset-x = gem.gem-x
+ gem-onset-text = gem.gem-text
+ }
+ }
+
+ draw-syllable-structure(
+ x-offset,
+ sigma-y,
+ syll,
+ terminal-y,
+ diagram-scale: diagram-scale,
+ geminate-coda-x: gem-coda-x,
+ geminate-onset-x: gem-onset-x,
+ geminate-coda-text: gem-coda-text,
+ geminate-onset-text: gem-onset-text,
+ or-y: or-y,
+ n-y: n-y,
+ )
+ }
+ }
+
+ // Draw geminate segments
+ for gem in geminates {
+ content(
+ (gem.gem-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#gem.gem-text],
+ anchor: "north",
+ )
+ }
+ }))
+}
+
+// Visualizes foot with moraic structure
+// Now accepts IPA-style input like "k a.'v a.l o"
+#let foot-mora(input, coda: false, scale: 1.0, symbol: ("Σ", "σ", "μ"), distance: none) = {
+ // Check for problematic diacritic sequences
+ let problematic-sequences = ("''", ",,", "\\* \\*", "\\t \\t", "::", "((", "))")
+ for seq in problematic-sequences {
+ if input.contains(seq) {
+ return text(fill: red, weight: "bold")[⚠ Warning: Problematic sequence involving diacritics: "#seq"]
+ }
+ }
+
+ // Convert IPA-style input to Unicode
+ let converted = convert-prosody-input(input)
+
+ // Parse syllables from dotted input
+ let syllables = ()
+ let buffer = ""
+ let chars = converted.codepoints()
+ let i = 0
+
+ while i < chars.len() {
+ let char = chars.at(i)
+
+ if char == "." {
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+ buffer = ""
+ }
+ } else if char == "(" or char == ")" {
+ // Skip parentheses - they're just delimiters, not segments
+ } else {
+ buffer += char
+ }
+ i += 1
+ }
+
+ // Handle remaining buffer
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+ }
+
+ // Check for too many onsets/codas/nucleus in any syllable (limit: 5 to avoid crossing lines)
+ for (i, syll) in syllables.enumerate() {
+ let coda-segments-temp = if syll.coda != "" { smart-clusters(syll.coda) } else { () }
+ if coda-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many coda consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #coda-segments-temp.len()]
+ }
+ let onset-segments-temp = if syll.onset != "" { smart-clusters(syll.onset) } else { () }
+ if onset-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many onset consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #onset-segments-temp.len()]
+ }
+ let nucleus-segments-temp = smart-clusters(syll.nucleus)
+ if nucleus-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many nucleus segments in syllable #(i + 1) (max 5 to avoid line crossings). Found: #nucleus-segments-temp.len()]
+ }
+ }
+
+ // Find stressed syllable (head of foot)
+ let head-idx = 0
+ for (i, syll) in syllables.enumerate() {
+ if syll.stressed {
+ head-idx = i
+ }
+ }
+
+ let sym_foot = symbol.at(0, default: "Σ")
+ let sym_syll = symbol.at(1, default: "σ")
+ let sym_mora = symbol.at(2, default: "μ")
+
+ let diagram-scale = scale
+
+ box(baseline: 50%, cetz.canvas(length: 1cm * diagram-scale, {
+ import cetz.draw: *
+ set-style(stroke: 0.7 * diagram-scale * 1pt)
+
+ // Distance multiplier lookup (floor: 0.5 for all mora levels)
+ let dist-mult(level) = {
+ let result = 1.0
+ if distance != none {
+ for entry in distance {
+ if entry.at(0) == level {
+ result = calc.max(0.5, entry.at(1))
+ }
+ }
+ }
+ result
+ }
+
+ let segment-spacing = 0.35
+ let min-gap-between-sylls = 0.8 // Same as foot() to prevent overlap
+ let default-spacing = 1.6 // Same as foot() to prevent overlap
+
+ // Calculate extents for each syllable
+ let syllable-extents = ()
+ for syll in syllables {
+ let has-onset = syll.onset != ""
+ let has-coda = syll.coda != ""
+ let num-onset = if has-onset { smart-clusters(syll.onset).len() } else { 0 }
+ let num-nucleus = smart-clusters(syll.nucleus).len()
+ let num-coda = if has-coda { smart-clusters(syll.coda).len() } else { 0 }
+ let min-gap = 0.75
+
+ // Calculate constituent positions (simplified like word())
+ // Use segment-based extents without mora adjustments for uniform spacing
+ let onset-x-rel = if has-onset {
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + (num-nucleus - 1) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { -min-offset } else { -default-offset }
+ } else { 0 }
+
+ let coda-x-rel = if has-coda {
+ let min-offset = (num-nucleus + num-coda - 2) * segment-spacing / 2 + 0.4
+ let default-offset = 0.7
+ if min-offset > default-offset { min-offset } else { default-offset }
+ } else { 0 }
+
+ // Calculate segment widths
+ let onset-width = if has-onset { (num-onset - 1) * segment-spacing } else { 0 }
+ let nucleus-width = (num-nucleus - 1) * segment-spacing
+ let coda-width = if has-coda { (num-coda - 1) * segment-spacing } else { 0 }
+
+ // Calculate left and right extents (same as word())
+ let left-parts = (
+ if has-onset { onset-x-rel - onset-width / 2 } else { 0 },
+ -nucleus-width / 2,
+ if has-coda { coda-x-rel - coda-width / 2 } else { 0 },
+ )
+ let right-parts = (
+ if has-onset { onset-x-rel + onset-width / 2 } else { 0 },
+ nucleus-width / 2,
+ if has-coda { coda-x-rel + coda-width / 2 } else { 0 },
+ )
+
+ let left-extent = calc.min(..left-parts)
+ let right-extent = calc.max(..right-parts)
+
+ syllable-extents.push((left: left-extent, right: right-extent))
+ }
+
+ // Calculate spacing (same as regular foot() for uniform segment spacing)
+ let syllable-positions = ()
+ for (i, extent) in syllable-extents.enumerate() {
+ if i == 0 {
+ syllable-positions.push(0)
+ } else {
+ let prev-right = syllable-extents.at(i - 1).right
+ let required-spacing = prev-right - extent.left + min-gap-between-sylls
+ let actual-spacing = calc.max(required-spacing, default-spacing)
+ let prev-position = syllable-positions.at(i - 1)
+ syllable-positions.push(prev-position + actual-spacing)
+ }
+ }
+
+ // Center the structure
+ let first-left = syllable-positions.at(0) + syllable-extents.at(0).left
+ let last-right = syllable-positions.at(-1) + syllable-extents.at(-1).right
+ let total-width = last-right - first-left
+ let start-x = -total-width / 2 - first-left
+
+ let foot-x = start-x + syllable-positions.at(head-idx)
+
+ // Vertical level positions
+ let sigma-y = -2.4
+ let sigma-y-label = sigma-y + 0.54
+ let base-ft-height = -0.9 + (syllables.len() * 0.3)
+ let ft-sigma-gap = base-ft-height - sigma-y-label
+ let ft-height = sigma-y-label + ft-sigma-gap * dist-mult(0)
+
+ // Mora level positions (σ→μ = level 1, μ→segments = level 2)
+ let mora-base-gap = 1.57
+ let mora-y = sigma-y-label - mora-base-gap * 1.2 * dist-mult(1)
+ let terminal-y = mora-y - mora-base-gap * dist-mult(2)
+
+ // Draw Ft node above the head
+ content((foot-x, ft-height), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_foot])
+
+ // Detect geminates
+ // A geminate occurs when the last consonant of a coda matches the first consonant of the next onset
+ let geminates = ()
+ for i in range(syllables.len() - 1) {
+ if syllables.at(i).coda != "" and syllables.at(i + 1).onset != "" {
+ let coda-segments = smart-clusters(syllables.at(i).coda)
+ let onset-segments = smart-clusters(syllables.at(i + 1).onset)
+ let last-coda = coda-segments.at(-1)
+ let first-onset = onset-segments.at(0)
+
+ // Check if they match and are consonants (not vowels)
+ if last-coda == first-onset and not is-vowel(last-coda) {
+ let gem-x = start-x + (syllable-positions.at(i) + syllable-positions.at(i + 1)) / 2
+ geminates.push((syll-idx: i, gem-x: gem-x, gem-text: last-coda))
+ }
+ }
+ }
+
+ // Draw syllables with moraic structure
+ for (i, syll) in syllables.enumerate() {
+ let x-offset = start-x + syllable-positions.at(i)
+
+ // Syllable node
+ content((x-offset, sigma-y + 0.54), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_syll])
+
+ // Line from Ft to σ
+ line((foot-x, ft-height - 0.25), (x-offset, sigma-y + 0.8))
+
+ // Check for geminate
+ let gem-coda-x = none
+ let gem-onset-x = none
+ for gem in geminates {
+ if gem.syll-idx == i {
+ gem-coda-x = gem.gem-x
+ }
+ if gem.syll-idx == i - 1 {
+ gem-onset-x = gem.gem-x
+ }
+ }
+
+ draw-moraic-structure(
+ x-offset,
+ sigma-y,
+ syll,
+ terminal-y,
+ coda: coda,
+ diagram-scale: diagram-scale,
+ geminate-coda-x: gem-coda-x,
+ geminate-onset-x: gem-onset-x,
+ mora-symbol: sym_mora,
+ mora-y-override: mora-y,
+ )
+ }
+
+ // Draw geminate segments
+ for gem in geminates {
+ content(
+ (gem.gem-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#gem.gem-text],
+ anchor: "north",
+ )
+ }
+ }))
+}
+
+// Visualizes word with moraic structure
+// Now accepts IPA-style input like "(k a.'v a).l o"
+#let word-mora(input, foot: "R", coda: false, scale: 1.0, symbol: ("ω", "Σ", "σ", "μ"), distance: none) = {
+ // Check for problematic diacritic sequences
+ let problematic-sequences = ("''", ",,", "\\* \\*", "\\t \\t", "::", "((", "))")
+ for seq in problematic-sequences {
+ if input.contains(seq) {
+ return text(fill: red, weight: "bold")[⚠ Warning: Problematic sequence involving diacritics: "#seq"]
+ }
+ }
+
+ // Convert IPA-style input to Unicode
+ let converted = convert-prosody-input(input)
+
+ let syllables = ()
+ let feet = ()
+ let current-foot = ()
+ let in-foot = false
+ let buffer = ""
+
+ let chars = converted.codepoints()
+ let i = 0
+
+ while i < chars.len() {
+ let char = chars.at(i)
+
+ if char == "(" {
+ in-foot = true
+ current-foot = ()
+ } else if char == ")" {
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+ current-foot.push(syllables.len() - 1)
+ buffer = ""
+ }
+ if current-foot.len() > 0 {
+ feet.push(current-foot)
+ }
+ in-foot = false
+ } else if char == "." {
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+
+ if in-foot {
+ current-foot.push(syllables.len() - 1)
+ }
+
+ buffer = ""
+ }
+ } else {
+ buffer += char
+ }
+
+ i += 1
+ }
+
+ // Handle remaining buffer
+ if buffer != "" {
+ let clean-buffer = if buffer.starts-with("'") { buffer.slice(1) } else { buffer }
+ let parsed = parse-syllable(clean-buffer)
+ syllables.push((
+ onset: parsed.onset,
+ nucleus: parsed.nucleus,
+ coda: parsed.coda,
+ stressed: buffer.starts-with("'"),
+ ))
+
+ if in-foot {
+ current-foot.push(syllables.len() - 1)
+ }
+ }
+
+ // Check for too many onsets/codas/nucleus in any syllable (limit: 5 to avoid crossing lines)
+ for (i, syll) in syllables.enumerate() {
+ let coda-segments-temp = if syll.coda != "" { smart-clusters(syll.coda) } else { () }
+ if coda-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many coda consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #coda-segments-temp.len()]
+ }
+ let onset-segments-temp = if syll.onset != "" { smart-clusters(syll.onset) } else { () }
+ if onset-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many onset consonants in syllable #(i + 1) (max 5 to avoid line crossings). Found: #onset-segments-temp.len()]
+ }
+ let nucleus-segments-temp = smart-clusters(syll.nucleus)
+ if nucleus-segments-temp.len() > 5 {
+ return text(
+ fill: red,
+ weight: "bold",
+ )[⚠ Warning: Too many nucleus segments in syllable #(i + 1) (max 5 to avoid line crossings). Found: #nucleus-segments-temp.len()]
+ }
+ }
+
+ // Determine which syllables are in feet
+ let in-foot-set = ()
+ for foot in feet {
+ for syll-idx in foot {
+ in-foot-set.push(syll-idx)
+ }
+ }
+
+ let sym_word = symbol.at(0, default: "ω")
+ let sym_foot = symbol.at(1, default: "Σ")
+ let sym_syll = symbol.at(2, default: "σ")
+ let sym_mora = symbol.at(3, default: "μ")
+
+ let diagram-scale = scale
+
+ // Draw the structure
+ box(baseline: 50%, cetz.canvas(length: 1cm * diagram-scale, {
+ import cetz.draw: *
+
+ set-style(stroke: 0.7 * diagram-scale * 1pt)
+
+ // Distance multiplier lookup (floor: 0.5 for all mora levels)
+ let dist-mult(level) = {
+ let result = 1.0
+ if distance != none {
+ for entry in distance {
+ if entry.at(0) == level {
+ result = calc.max(0.5, entry.at(1))
+ }
+ }
+ }
+ result
+ }
+
+ let segment-spacing = 0.35
+ let min-gap-between-sylls = 0.6
+ let default-spacing = 1.4
+
+ // Calculate extents for each syllable
+ let syllable-extents = ()
+ for syll in syllables {
+ let has-onset = syll.onset != ""
+ let has-coda = syll.coda != ""
+ let num-onset = if has-onset { smart-clusters(syll.onset).len() } else { 0 }
+ let num-nucleus = smart-clusters(syll.nucleus).len()
+ let num-coda = if has-coda { smart-clusters(syll.coda).len() } else { 0 }
+
+ // Calculate constituent positions (simplified like word())
+ // Use segment-based extents without mora adjustments for uniform spacing
+ let min-gap = 0.75
+
+ let onset-x-rel = if has-onset {
+ let min-offset = (num-onset - 1) * segment-spacing / 2 + (num-nucleus - 1) * segment-spacing / 2 + min-gap
+ let default-offset = 0.7
+ if min-offset > default-offset { -min-offset } else { -default-offset }
+ } else { 0 }
+
+ let coda-x-rel = if has-coda {
+ let min-offset = (num-nucleus + num-coda - 2) * segment-spacing / 2 + 0.4
+ let default-offset = 0.7
+ if min-offset > default-offset { min-offset } else { default-offset }
+ } else { 0 }
+
+ // Calculate segment widths
+ let onset-width = if has-onset { (num-onset - 1) * segment-spacing } else { 0 }
+ let nucleus-width = (num-nucleus - 1) * segment-spacing
+ let coda-width = if has-coda { (num-coda - 1) * segment-spacing } else { 0 }
+
+ // Calculate left and right extents (same as word())
+ let left-parts = (
+ if has-onset { onset-x-rel - onset-width / 2 } else { 0 },
+ -nucleus-width / 2,
+ if has-coda { coda-x-rel - coda-width / 2 } else { 0 },
+ )
+ let right-parts = (
+ if has-onset { onset-x-rel + onset-width / 2 } else { 0 },
+ nucleus-width / 2,
+ if has-coda { coda-x-rel + coda-width / 2 } else { 0 },
+ )
+
+ let left-extent = calc.min(..left-parts)
+ let right-extent = calc.max(..right-parts)
+
+ syllable-extents.push((left: left-extent, right: right-extent))
+ }
+
+ // Calculate spacing based on uniform segment-to-segment distance
+ let syllable-positions = ()
+ for (i, extent) in syllable-extents.enumerate() {
+ if i == 0 {
+ syllable-positions.push(0)
+ } else {
+ // Use same spacing calculation as word() for uniform segment spacing
+ let prev-right = syllable-extents.at(i - 1).right
+ let required-spacing = prev-right - extent.left + min-gap-between-sylls
+ let actual-spacing = calc.max(required-spacing, default-spacing)
+ let prev-position = syllable-positions.at(i - 1)
+ syllable-positions.push(prev-position + actual-spacing)
+ }
+ }
+
+ // Center the structure
+ let first-left = syllable-positions.at(0) + syllable-extents.at(0).left
+ let last-right = syllable-positions.at(-1) + syllable-extents.at(-1).right
+ let total-width = last-right - first-left
+ let start-x = -total-width / 2 - first-left
+
+ // Determine PWd x-position
+ let pwd-x = 0
+
+ if feet.len() > 0 {
+ let target-foot = if foot == "L" { feet.at(0) } else { feet.at(-1) }
+ let target-syll-idx = target-foot.at(0)
+ for (i, syll) in target-foot.enumerate() {
+ let this-syll = syllables.at(syll)
+ if this-syll.stressed {
+ target-syll-idx = syll
+ break
+ }
+ }
+ pwd-x = start-x + syllable-positions.at(target-syll-idx)
+ }
+
+ // Vertical level positions
+ let sigma-y = -2.4
+ let sigma-y-label = sigma-y + 0.54
+ let base-gap = 0.96
+ let ft-y = sigma-y-label + base-gap * dist-mult(1)
+
+ // Mora level positions (σ→μ = level 2, μ→segments = level 3)
+ let mora-base-gap = 1.57
+ let mora-y = sigma-y-label - mora-base-gap * 1.2 * dist-mult(2)
+ let terminal-y = mora-y - mora-base-gap * dist-mult(3)
+
+ // Calculate minimum PWd height
+ let clearance-margin = 0.5
+ let min-pwd-height = ft-y + base-gap * 1.5
+
+ // Check geometric constraints for unfooted syllables
+ for (i, syll) in syllables.enumerate() {
+ if i not in in-foot-set {
+ let syll-x = start-x + syllable-positions.at(i)
+
+ // Check all feet to find those between PWd and this footless syllable
+ for ft in feet {
+ // Find foot's head position
+ let head-idx = ft.at(0)
+ for syll-idx in ft {
+ if syllables.at(syll-idx).stressed {
+ head-idx = syll-idx
+ }
+ }
+ let foot-x = start-x + syllable-positions.at(head-idx)
+
+ // Check if foot is between PWd and footless syllable
+ let is-between = (pwd-x < foot-x and foot-x < syll-x) or (syll-x < foot-x and foot-x < pwd-x)
+
+ if is-between and calc.abs(syll-x - pwd-x) > 0.01 {
+ let t = (foot-x - pwd-x) / (syll-x - pwd-x)
+ let required-height = (1.35 * t - 0.35 + clearance-margin) / (1 - t)
+ min-pwd-height = calc.max(min-pwd-height, required-height)
+ }
+ }
+ }
+ }
+
+ let pwd-y = calc.max(min-pwd-height, min-pwd-height * dist-mult(0))
+
+ // Draw PWd node
+ content((pwd-x, pwd-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_word])
+
+ // Detect geminates
+ // A geminate occurs when the last consonant of a coda matches the first consonant of the next onset
+ let geminates = ()
+ for i in range(syllables.len() - 1) {
+ if syllables.at(i).coda != "" and syllables.at(i + 1).onset != "" {
+ let coda-segments = smart-clusters(syllables.at(i).coda)
+ let onset-segments = smart-clusters(syllables.at(i + 1).onset)
+ let last-coda = coda-segments.at(-1)
+ let first-onset = onset-segments.at(0)
+
+ // Check if they match and are consonants (not vowels)
+ if last-coda == first-onset and not is-vowel(last-coda) {
+ let gem-x = start-x + (syllable-positions.at(i) + syllable-positions.at(i + 1)) / 2
+ geminates.push((syll-idx: i, gem-x: gem-x, gem-text: last-coda))
+ }
+ }
+ }
+
+ // Draw feet
+ for (foot-idx, foot-sylls) in feet.enumerate() {
+ // Find head of foot (stressed syllable)
+ let head-idx = foot-sylls.at(0)
+ for syll-idx in foot-sylls {
+ if syllables.at(syll-idx).stressed {
+ head-idx = syll-idx
+ break
+ }
+ }
+
+ let foot-x = start-x + syllable-positions.at(head-idx)
+ content((foot-x, ft-y), context text(size: 12 * diagram-scale * 1pt, font: phonokit-font.get())[#sym_foot])
+ line((pwd-x, pwd-y - 0.3), (foot-x, ft-y + 0.25))
+
+ // Draw lines from Ft to syllables in this foot
+ for syll-idx in foot-sylls {
+ let syll-x = start-x + syllable-positions.at(syll-idx)
+ line((foot-x, ft-y - 0.25), (syll-x, sigma-y + 0.8))
+ }
+ }
+
+ // Draw lines from PWd to unfooted syllables
+ for (i, syll) in syllables.enumerate() {
+ if i not in in-foot-set {
+ let syll-x = start-x + syllable-positions.at(i)
+ line((pwd-x, pwd-y - 0.3), (syll-x, sigma-y + 0.75))
+ }
+ }
+
+ // Draw syllables with moraic structure
+ for (i, syll) in syllables.enumerate() {
+ let x-offset = start-x + syllable-positions.at(i)
+
+ // Syllable node
+ content((x-offset, sigma-y + 0.54), context text(
+ size: 12 * diagram-scale * 1pt,
+ font: phonokit-font.get(),
+ )[#sym_syll])
+
+ // Check for geminate
+ let gem-coda-x = none
+ let gem-onset-x = none
+ for gem in geminates {
+ if gem.syll-idx == i {
+ gem-coda-x = gem.gem-x
+ }
+ if gem.syll-idx == i - 1 {
+ gem-onset-x = gem.gem-x
+ }
+ }
+
+ draw-moraic-structure(
+ x-offset,
+ sigma-y,
+ syll,
+ terminal-y,
+ coda: coda,
+ diagram-scale: diagram-scale,
+ geminate-coda-x: gem-coda-x,
+ geminate-onset-x: gem-onset-x,
+ mora-symbol: sym_mora,
+ mora-y-override: mora-y,
+ )
+ }
+
+ // Draw geminate segments
+ for gem in geminates {
+ content(
+ (gem.gem-x, terminal-y),
+ context text(size: 11 * diagram-scale * 1pt, font: phonokit-font.get())[#gem.gem-text],
+ anchor: "north",
+ )
+ }
+ }))
+}
diff --git a/packages/preview/phonokit/0.5.11/sonority.typ b/packages/preview/phonokit/0.5.11/sonority.typ
new file mode 100644
index 0000000000..34d0962269
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/sonority.typ
@@ -0,0 +1,242 @@
+// Sonority Module
+// Visualize sonority profiles of phonemic transcriptions
+// Based on Parker (2011) sonority scale
+
+#import "ipa.typ": ipa, ipa-to-unicode
+#import "_config.typ": phonokit-font
+#import "@preview/cetz:0.5.2"
+
+// Sonority scale based on Parker (2011)
+#let sonority-scale = (
+ "a": 13,
+ "ɑ": 13,
+ "æ": 13,
+ "ɐ": 13,
+ "ɛ": 12,
+ "ɔ": 12,
+ "ʌ": 12,
+ "œ": 12,
+ "e": 12,
+ "o": 12,
+ "ə": 12,
+ "ɘ": 12,
+ "ɵ": 12,
+ "ø": 12,
+ "ɚ": 12,
+ "ɝ": 12,
+ "i": 12,
+ "u": 12,
+ "ɪ": 12,
+ "ʊ": 12,
+ "y": 12,
+ "ɯ": 12,
+ "ɨ": 12,
+ "ʉ": 12,
+ "j": 11,
+ "w": 11,
+ "ɥ": 11,
+ "ɰ": 11,
+ "ɾ": 10,
+ "ɽ": 10,
+ "l": 9,
+ "ɫ": 9,
+ "ʎ": 9,
+ "ʟ": 9,
+ "ɬ": 8,
+ "ɮ": 8,
+ "r": 8,
+ "ʀ": 8,
+ "ɹ": 8,
+ "ʁ": 8,
+ "ɻ": 8,
+ "m": 7,
+ "n": 7,
+ "ŋ": 7,
+ "ɲ": 7,
+ "ɳ": 7,
+ "ɴ": 7,
+ "ɱ": 7,
+ "v": 6,
+ "z": 6,
+ "ʒ": 6,
+ "ð": 6,
+ "ʐ": 6,
+ "ʝ": 6,
+ "ɣ": 6,
+ "β": 6,
+ "f": 3,
+ "s": 3,
+ "ʃ": 3,
+ "θ": 3,
+ "x": 3,
+ "χ": 3,
+ "ħ": 3,
+ "h": 3,
+ "ɸ": 3,
+ "ç": 3,
+ "ʂ": 3,
+ "b": 4,
+ "d": 4,
+ "g": 4,
+ "ɡ": 4,
+ "ɢ": 4,
+ "ɖ": 4,
+ "ʄ": 4,
+ "ɗ": 4,
+ "ɓ": 4,
+ "p": 1,
+ "t": 1,
+ "k": 1,
+ "q": 1,
+ "ʔ": 1,
+ "ʈ": 1,
+ "c": 1,
+ "d͡ʒ": 5,
+ "d͡z": 5,
+ "d͡ʑ": 5,
+ "ɖ͡ʐ": 5,
+ "t͡ʃ": 2,
+ "t͡s": 2,
+ "t͡ɕ": 2,
+ "ʈ͡ʂ": 2,
+ "t͡θ": 2,
+ "p͡f": 2,
+)
+
+#let get-sonority(phoneme) = {
+ if phoneme in sonority-scale {
+ sonority-scale.at(phoneme)
+ } else {
+ let base = phoneme.codepoints().at(0)
+ if base in sonority-scale {
+ sonority-scale.at(base)
+ } else {
+ 5
+ }
+ }
+}
+
+// Parse IPA string into individual phonemes and syllable boundaries
+#let parse-phonemes(ipa-string) = {
+ let cleaned = ipa-string.replace("ˈ", "").replace("ˌ", "").replace("ː", "")
+ let syllable-boundaries = ()
+ let phonemes = ()
+ let position = 0
+ let basic-clusters = cleaned.clusters()
+ let i = 0
+
+ while i < basic-clusters.len() {
+ let cluster = basic-clusters.at(i)
+ if cluster == "." {
+ syllable-boundaries.push(position)
+ i += 1
+ } else if cluster == " " or cluster == "-" {
+ i += 1
+ } else if cluster.contains("͡") {
+ if i + 1 < basic-clusters.len() {
+ let next = basic-clusters.at(i + 1)
+ if next != " " and next != "-" and next != "." {
+ phonemes.push(cluster + next)
+ i += 2
+ position += 1
+ } else {
+ phonemes.push(cluster)
+ i += 1
+ position += 1
+ }
+ } else {
+ phonemes.push(cluster)
+ i += 1
+ position += 1
+ }
+ } else {
+ phonemes.push(cluster)
+ i += 1
+ position += 1
+ }
+ }
+ (phonemes, syllable-boundaries)
+}
+
+// Main sonority plotting function
+#let sonority(
+ word, // Tipa-style string
+ syl: none, // (Legacy/unused directly in calculation now, kept for API compatibility)
+ stressed: none, // Index of stressed syllable
+ box-size: 0.8, // Size of phoneme boxes
+ scale: 1.0, // Overall scale
+ y-range: (0, 8), // Sonority range for y-axis
+ show-lines: true, // Connect phonemes with lines
+) = {
+ // Convert tipa-style input to IPA
+ let ipa-string = ipa-to-unicode(word)
+ let (phonemes, syllable-boundaries) = parse-phonemes(ipa-string)
+
+ // Truncation check
+ let original-count = phonemes.len()
+ let truncated = original-count > 10
+ if truncated {
+ phonemes = phonemes.slice(0, 10)
+ syllable-boundaries = syllable-boundaries.filter(pos => pos <= 10)
+ }
+
+ let sonority-values = phonemes.map(p => get-sonority(p))
+ let n-phonemes = phonemes.len()
+ let width = n-phonemes * 1.5
+ let height = 3
+
+ if truncated {
+ text(size: 9pt, fill: red, weight: "bold")[⚠ Warning: Truncated to first 10 phonemes.]
+ v(0.5em)
+ }
+
+ cetz.canvas(length: scale * 1cm, {
+ import cetz.draw: *
+ set-origin((0, 0))
+
+ // Draw connecting lines first
+ if show-lines and n-phonemes > 1 {
+ for i in range(n-phonemes - 1) {
+ let x1 = float(i) * 1.5
+ let y1 = float((sonority-values.at(i) - y-range.at(0))) / float((y-range.at(1) - y-range.at(0))) * float(height)
+ let x2 = float(i + 1) * 1.5
+ let y2 = (
+ float((sonority-values.at(i + 1) - y-range.at(0))) / float((y-range.at(1) - y-range.at(0))) * float(height)
+ )
+ line((x1, y1), (x2, y2), stroke: (thickness: 0.5pt, paint: gray, dash: "dashed"))
+ }
+ }
+
+ // Draw phoneme boxes with syllable-based alternating colors
+ for (i, phoneme) in phonemes.enumerate() {
+ let sonority = sonority-values.at(i)
+ let x = float(i) * 1.5
+ let y = float((sonority - y-range.at(0))) / float((y-range.at(1) - y-range.at(0))) * float(height)
+
+ // Determine syllable index by checking how many boundaries we have passed
+ let syllable-index = syllable-boundaries.filter(b => b <= i).len()
+
+ // Alternate colors: Even syllables = White, Odd syllables = Gray
+ let box-fill = if calc.even(syllable-index) {
+ white
+ } else {
+ rgb("dddddd") // Light gray
+ }
+
+ // Draw box
+ rect(
+ (x - box-size / 2, y - box-size / 2),
+ (x + box-size / 2, y + box-size / 2),
+ fill: box-fill,
+ stroke: 0.5pt + black,
+ )
+
+ // Add phoneme label (always black now)
+ content(
+ (x, y),
+ context text(size: 10pt, font: phonokit-font.get(), fill: black)[#phoneme],
+ anchor: "center",
+ )
+ }
+ })
+}
diff --git a/packages/preview/phonokit/0.5.11/sound-shift.typ b/packages/preview/phonokit/0.5.11/sound-shift.typ
new file mode 100644
index 0000000000..a1e6229631
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/sound-shift.typ
@@ -0,0 +1,261 @@
+#import "@preview/cetz:0.5.2"
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+
+#let _ss-render-label(label) = {
+ if type(label) == str {
+ ipa-to-unicode(label)
+ } else {
+ label
+ }
+}
+
+#let _ss-node-id(node) = {
+ if "id" in node {
+ str(node.id)
+ } else if "label" in node and type(node.label) == str {
+ node.label
+ } else {
+ panic("Each sound-shift node must have either an id or a string label.")
+ }
+}
+
+#let _ss-node-label(node) = {
+ if "label" in node {
+ node.label
+ } else if "id" in node {
+ node.id
+ } else {
+ panic("Each sound-shift node must have either a label or an id.")
+ }
+}
+
+#let _ss-node-pos(node) = {
+ if "at" in node {
+ node.at
+ } else if "pos" in node {
+ node.pos
+ } else {
+ panic("Each sound-shift node must define a position with at: (x, y).")
+ }
+}
+
+#let _ss-arrow-end(endpoint, nodes) = {
+ if type(endpoint) == str {
+ let key = str(endpoint)
+ let node = nodes.find(n => n.id == key)
+ assert(node != none, message: "Unknown sound-shift endpoint: " + key)
+ (pos: node.at, radius: node.radius)
+ } else if type(endpoint) == array and endpoint.len() >= 2 {
+ let radius = endpoint.at(2, default: 0)
+ (pos: (endpoint.at(0), endpoint.at(1)), radius: radius)
+ } else {
+ panic("Arrow endpoints must be node ids or coordinate pairs.")
+ }
+}
+
+#let _ss-arrow-dict(arrow) = {
+ if type(arrow) == dictionary {
+ arrow
+ } else if type(arrow) == array and arrow.len() >= 2 {
+ (from: arrow.at(0), to: arrow.at(1))
+ } else {
+ panic("Each sound-shift arrow must be a dictionary or a (from, to) tuple.")
+ }
+}
+
+#let sound-shift(
+ nodes: (),
+ arrows: (),
+ highlights: (),
+ node-size: 2.2em,
+ text-fill: black,
+ highlight-fill: luma(230),
+ highlight-radius: 0.42,
+ arrow-color: black,
+ arrow-style: "solid",
+ arrow-width: 0.8pt,
+ arrow-size: 1.0,
+ curved: false,
+ curve: 0.45,
+ scale: 1.0,
+) = {
+ assert(type(nodes) == array, message: "nodes must be an array")
+ assert(nodes.len() > 0, message: "nodes array cannot be empty")
+
+ let scale-factor = scale
+ let normalized-nodes = nodes.map(node => {
+ assert(type(node) == dictionary, message: "Each sound-shift node must be a dictionary.")
+ let id = _ss-node-id(node)
+ let label = _ss-node-label(node)
+ let pos = _ss-node-pos(node)
+ let normalized = (
+ id: id,
+ label: label,
+ at: pos,
+ fill: node.at("fill", default: none),
+ size: node.at("size", default: node-size),
+ text-fill: node.at("text-fill", default: text-fill),
+ radius: node.at("radius", default: highlight-radius),
+ )
+ normalized
+ })
+
+ let all-points = normalized-nodes.map(n => n.at)
+ let arrow-points = arrows.map(arrow => {
+ let spec = _ss-arrow-dict(arrow)
+ let from = _ss-arrow-end(spec.from, normalized-nodes)
+ let to = _ss-arrow-end(spec.to, normalized-nodes)
+ (from.pos, to.pos)
+ })
+
+ let pad = 0.6
+ let xs = all-points.map(p => p.at(0)) + arrow-points.map(pair => pair.at(0).at(0)) + arrow-points.map(pair => pair.at(1).at(0))
+ let ys = all-points.map(p => p.at(1)) + arrow-points.map(pair => pair.at(0).at(1)) + arrow-points.map(pair => pair.at(1).at(1))
+ let min-x = calc.min(..xs) - pad
+ let max-x = calc.max(..xs) + pad
+ let min-y = calc.min(..ys) - pad
+ let max-y = calc.max(..ys) + pad
+
+ box(inset: 1.2em, baseline: 40%, cetz.canvas(length: scale-factor * 1cm, {
+ import cetz.draw: *
+
+ for item in highlights {
+ let pos = _ss-arrow-end(item, normalized-nodes).pos
+ circle(
+ pos,
+ radius: highlight-radius,
+ fill: highlight-fill,
+ stroke: none,
+ )
+ }
+
+ for node in normalized-nodes {
+ if node.fill != none {
+ circle(
+ node.at,
+ radius: node.radius,
+ fill: node.fill,
+ stroke: none,
+ )
+ }
+ }
+
+ for arrow in arrows {
+ let spec = _ss-arrow-dict(arrow)
+ let from-end = _ss-arrow-end(spec.from, normalized-nodes)
+ let to-end = _ss-arrow-end(spec.to, normalized-nodes)
+ let from-pos = from-end.pos
+ let to-pos = to-end.pos
+ let dx = to-pos.at(0) - from-pos.at(0)
+ let dy = to-pos.at(1) - from-pos.at(1)
+ let dist = calc.sqrt(dx * dx + dy * dy)
+ let safe-dist = if dist == 0 { 1 } else { dist }
+ let ux = dx / safe-dist
+ let uy = dy / safe-dist
+ let start-gap = spec.at("start-gap", default: from-end.radius)
+ let end-gap = spec.at("end-gap", default: to-end.radius)
+ let drawn-from = (
+ from-pos.at(0) + ux * start-gap,
+ from-pos.at(1) + uy * start-gap,
+ )
+ let drawn-to = (
+ to-pos.at(0) - ux * end-gap,
+ to-pos.at(1) - uy * end-gap,
+ )
+ let px = -dy / safe-dist
+ let py = dx / safe-dist
+ let amount = spec.at("curve", default: curve)
+ let ctrl = spec.at("ctrl", default: (
+ (from-pos.at(0) + to-pos.at(0)) / 2 + px * safe-dist * amount,
+ (from-pos.at(1) + to-pos.at(1)) / 2 + py * safe-dist * amount,
+ ))
+ let local-curved = spec.at("curved", default: curved)
+ let local-color = spec.at("color", default: arrow-color)
+ let local-width = spec.at("width", default: arrow-width) * scale-factor
+ let local-style = spec.at("style", default: arrow-style)
+ let start-tangent = if local-curved {
+ let sx = ctrl.at(0) - from-pos.at(0)
+ let sy = ctrl.at(1) - from-pos.at(1)
+ let sd = calc.sqrt(sx * sx + sy * sy)
+ if sd == 0 { (ux, uy) } else { (sx / sd, sy / sd) }
+ } else {
+ (ux, uy)
+ }
+ let end-tangent = if local-curved {
+ let ex = to-pos.at(0) - ctrl.at(0)
+ let ey = to-pos.at(1) - ctrl.at(1)
+ let ed = calc.sqrt(ex * ex + ey * ey)
+ if ed == 0 { (ux, uy) } else { (ex / ed, ey / ed) }
+ } else {
+ (ux, uy)
+ }
+ let drawn-from = (
+ from-pos.at(0) + start-tangent.at(0) * start-gap,
+ from-pos.at(1) + start-tangent.at(1) * start-gap,
+ )
+ let drawn-to = (
+ to-pos.at(0) - end-tangent.at(0) * end-gap,
+ to-pos.at(1) - end-tangent.at(1) * end-gap,
+ )
+ let drawn-ctrl = if local-curved {
+ let from-shift = (
+ drawn-from.at(0) - from-pos.at(0),
+ drawn-from.at(1) - from-pos.at(1),
+ )
+ let to-shift = (
+ drawn-to.at(0) - to-pos.at(0),
+ drawn-to.at(1) - to-pos.at(1),
+ )
+ (
+ ctrl.at(0) + (from-shift.at(0) + to-shift.at(0)) / 2,
+ ctrl.at(1) + (from-shift.at(1) + to-shift.at(1)) / 2,
+ )
+ } else {
+ ctrl
+ }
+ let shaft-stroke = if local-style == "dashed" {
+ (paint: local-color, thickness: local-width, dash: "dashed")
+ } else if local-style == "dotted" {
+ (paint: local-color, thickness: local-width, dash: "dotted")
+ } else {
+ (paint: local-color, thickness: local-width)
+ }
+ let head-stroke = (paint: local-color, thickness: local-width)
+ let mark-style = (end: ">", fill: local-color, scale: spec.at("arrow-size", default: arrow-size) * scale-factor)
+
+ if local-style == "dashed" or local-style == "dotted" {
+ if local-curved {
+ bezier(drawn-from, drawn-to, drawn-ctrl, stroke: shaft-stroke)
+ } else {
+ line(drawn-from, drawn-to, stroke: shaft-stroke)
+ }
+ let tiny = 0.01
+ let head-anchor = (
+ drawn-to.at(0) - end-tangent.at(0) * tiny,
+ drawn-to.at(1) - end-tangent.at(1) * tiny,
+ )
+ line(head-anchor, drawn-to, stroke: head-stroke, mark: mark-style)
+ } else if local-curved {
+ bezier(drawn-from, drawn-to, drawn-ctrl, stroke: shaft-stroke, mark: mark-style)
+ } else {
+ line(drawn-from, drawn-to, stroke: shaft-stroke, mark: mark-style)
+ }
+ }
+
+ for node in normalized-nodes {
+ content(
+ node.at,
+ anchor: "center",
+ context text(
+ font: phonokit-font.get(),
+ size: node.size * scale-factor,
+ fill: node.text-fill,
+ top-edge: "x-height",
+ bottom-edge: "baseline",
+ _ss-render-label(node.label),
+ ),
+ )
+ }
+ }))
+}
diff --git a/packages/preview/phonokit/0.5.11/typst.toml b/packages/preview/phonokit/0.5.11/typst.toml
new file mode 100644
index 0000000000..6890db6997
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/typst.toml
@@ -0,0 +1,23 @@
+[package]
+categories = ["utility", "text", "visualization"]
+disciplines = ["linguistics"]
+name = "phonokit"
+version = "0.5.11"
+exclude = ["gallery/"]
+keywords = [
+ "linguistics",
+ "phonology",
+ "phonetics",
+ "IPA",
+ "transcription",
+ "prosody",
+ "optimality-theory",
+ "features",
+ "autosegmental",
+]
+entrypoint = "lib.typ"
+authors = ["Guilherme D. Garcia "]
+license = "MIT"
+description = "A toolkit to create phonological representations"
+repository = "https://github.com/guilhermegarcia/phonokit"
+homepage = "https://gdgarcia.ca/phonokit"
diff --git a/packages/preview/phonokit/0.5.11/ui-lang.typ b/packages/preview/phonokit/0.5.11/ui-lang.typ
new file mode 100644
index 0000000000..9a93953708
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/ui-lang.typ
@@ -0,0 +1,369 @@
+// Shared UI-language helpers for translatable labels.
+
+#let _ui-lang-aliases = (
+ "en": "en",
+ "english": "en",
+ "fr": "fr",
+ "french": "fr",
+ "pt": "pt",
+ "portuguese": "pt",
+)
+
+#let _ui-lang-available = "en, english, fr, french, pt, portuguese"
+
+#let resolve-ui-lang(ui-lang) = {
+ if type(ui-lang) != str {
+ none
+ } else {
+ let key = lower(ui-lang.trim())
+ _ui-lang-aliases.at(key, default: none)
+ }
+}
+
+#let ui-lang-error(ui-lang) = [*Error:* UI language "#ui-lang" not available. \
+ Available UI languages: #_ui-lang-available]
+
+#let _consonant-ui-labels = (
+ "en": (
+ places: (
+ "Bilabial",
+ "Labiodental",
+ "Dental",
+ "Alveolar",
+ "Postalveolar",
+ "Retroflex",
+ "Palatal",
+ "Velar",
+ "Uvular",
+ "Pharyngeal",
+ "Glottal",
+ ),
+ places_short: (
+ "Bilab",
+ "Labdent",
+ "Dent",
+ "Alv",
+ "Postalv",
+ "Retro",
+ "Pal",
+ "Vel",
+ "Uvu",
+ "Phar",
+ "Glot",
+ ),
+ manners: (
+ "Plosive",
+ "Nasal",
+ "Trill",
+ "Tap or Flap",
+ "Fricative",
+ "Lateral fricative",
+ "Approximant",
+ "Lateral approximant",
+ ),
+ manners_short: (
+ "Plos",
+ "Nas",
+ "Trill",
+ "Tap/Flap",
+ "Fric",
+ "Lat fric",
+ "Approx",
+ "Lat approx",
+ ),
+ aspirated_plosive: "Plosive (aspirated)",
+ aspirated_plosive_short: "Plos (asp)",
+ affricate: "Affricate",
+ affricate_short: "Affr",
+ aspirated_affricate: "Affricate (aspirated)",
+ aspirated_affricate_short: "Affr (asp)",
+ ),
+ "fr": (
+ places: (
+ "Bilabiale",
+ "Labio-dentale",
+ "Dentale",
+ "Alvéolaire",
+ "Post-alvéolaire",
+ "Retroflexe",
+ "Palatale",
+ "Vélaire",
+ "Uvulaire",
+ "Pharyngale",
+ "Glottale",
+ ),
+ places_short: (
+ "Bilab",
+ "Labiod",
+ "Dent",
+ "Alv",
+ "Postalv",
+ "Retrof",
+ "Pal",
+ "Vel",
+ "Uvul",
+ "Phar",
+ "Glot",
+ ),
+ manners: (
+ "Plosive",
+ "Nasale",
+ "Vibrante",
+ "Battue",
+ "Fricative",
+ "Fricative latérale",
+ "Approximante",
+ "Approximante latérale",
+ ),
+ manners_short: (
+ "Plos",
+ "Nas",
+ "Vibr",
+ "Batt",
+ "Fric",
+ "Fric lat",
+ "Approx",
+ "Approx lat",
+ ),
+ aspirated_plosive: "Plosive (aspirée)",
+ aspirated_plosive_short: "Plos (asp)",
+ affricate: "Affriquée",
+ affricate_short: "Affr",
+ aspirated_affricate: "Affriquée (aspirée)",
+ aspirated_affricate_short: "Affr (asp)",
+ ),
+ "pt": (
+ places: (
+ "Bilabial",
+ "Labiodental",
+ "Dental",
+ "Alveolar",
+ "Pós-alveolar",
+ "Retroflexa",
+ "Palatal",
+ "Velar",
+ "Uvular",
+ "Faríngea",
+ "Glotal",
+ ),
+ places_short: (
+ "Bilab",
+ "Labdent",
+ "Dent",
+ "Alv",
+ "Posalv",
+ "Retrof",
+ "Pal",
+ "Vel",
+ "Uvul",
+ "Far",
+ "Glot",
+ ),
+ manners: (
+ "Plosiva",
+ "Nasal",
+ "Vibrante",
+ "Tepe",
+ "Fricativa",
+ "Fricativa lateral",
+ "Aproximante",
+ "Aproximante lateral",
+ ),
+ manners_short: (
+ "Plos",
+ "Nas",
+ "Vibr",
+ "Tepe",
+ "Fric",
+ "Fric lat",
+ "Aprox",
+ "Aprox lat",
+ ),
+ aspirated_plosive: "Plosiva (aspirada)",
+ aspirated_plosive_short: "Plos (asp)",
+ affricate: "Africada",
+ affricate_short: "Afr",
+ aspirated_affricate: "Africada (aspirada)",
+ aspirated_affricate_short: "Afr (asp)",
+ ),
+)
+
+#let ui-consonant-labels(ui-lang) = _consonant-ui-labels.at(ui-lang)
+
+#let _feature-ui-labels = (
+ "en": (
+ "consonantal": "consonantal",
+ "sonorant": "sonorant",
+ "continuant": "continuant",
+ "delayed_release": "del rel",
+ "approximant": "approximant",
+ "tap": "tap",
+ "trill": "trill",
+ "nasal": "nasal",
+ "voice": "voice",
+ "spread_gl": "spread gl",
+ "constr_gl": "constr gl",
+ "labial": "labial",
+ "round": "round",
+ "labiodental": "labiodental",
+ "coronal": "coronal",
+ "anterior": "anterior",
+ "distributed": "distributed",
+ "strident": "strident",
+ "lateral": "lateral",
+ "dorsal": "dorsal",
+ "high": "high",
+ "low": "low",
+ "front": "front",
+ "back": "back",
+ "tense": "tense",
+ ),
+ "fr": (
+ "consonantal": "consonantique",
+ "sonorant": "sonant",
+ "continuant": "continu",
+ "delayed_release": "rel ret",
+ "approximant": "approximant",
+ "tap": "battu",
+ "trill": "vibrant",
+ "nasal": "nasal",
+ "voice": "voisé",
+ "spread_gl": "gl écartée",
+ "constr_gl": "gl contr",
+ "labial": "labial",
+ "round": "arrondi",
+ "labiodental": "labio-dental",
+ "coronal": "coronal",
+ "anterior": "antérieur",
+ "distributed": "distribué",
+ "strident": "strident",
+ "lateral": "latéral",
+ "dorsal": "dorsal",
+ "high": "haut",
+ "low": "bas",
+ "front": "antérieur",
+ "back": "postérieur",
+ "tense": "tendu",
+ ),
+ "pt": (
+ "consonantal": "consonantal",
+ "sonorant": "sonorante",
+ "continuant": "contínuo",
+ "delayed_release": "lib ret",
+ "approximant": "aproximante",
+ "tap": "tepe",
+ "trill": "vibrante",
+ "nasal": "nasal",
+ "voice": "vozeado",
+ "spread_gl": "gl aberta",
+ "constr_gl": "gl constr",
+ "labial": "labial",
+ "round": "arredondado",
+ "labiodental": "labiodental",
+ "coronal": "coronal",
+ "anterior": "anterior",
+ "distributed": "distribuído",
+ "strident": "estridente",
+ "lateral": "lateral",
+ "dorsal": "dorsal",
+ "high": "alto",
+ "low": "baixo",
+ "front": "anterior",
+ "back": "posterior",
+ "tense": "tenso",
+ ),
+)
+
+#let ui-feature-label(key, ui-lang) = _feature-ui-labels.at(ui-lang).at(key, default: key)
+
+#let _geom-ui-labels = (
+ "en": (
+ "root": "root",
+ "laryngeal": "laryngeal",
+ "oral cavity": "oral cavity",
+ "C-place": "C-place",
+ "vocalic": "vocalic",
+ "V-place": "V-place",
+ "aperture": "aperture",
+ "constricted": "constr",
+ "continuant": "cont",
+ "distributed": "distr",
+ "dorsal": "dor",
+ "coronal": "cor",
+ "labial": "lab",
+ "anterior": "ant",
+ "radical": "rad",
+ "high": "hi",
+ "low": "low",
+ "back": "back",
+ "round": "round",
+ "tense": "tense",
+ "voice": "voice",
+ "nasal": "nasal",
+ "lateral": "lat",
+ "spread": "spread",
+ "open1": "open1",
+ "open2": "open2",
+ "open3": "open3",
+ ),
+ "fr": (
+ "root": "racine",
+ "laryngeal": "laryng",
+ "oral cavity": "cav orale",
+ "C-place": "lieu C",
+ "vocalic": "vocaliq",
+ "V-place": "lieu V",
+ "aperture": "apert",
+ "constricted": "constr",
+ "continuant": "cont",
+ "distributed": "distr",
+ "dorsal": "dor",
+ "coronal": "cor",
+ "labial": "lab",
+ "anterior": "ant",
+ "radical": "rad",
+ "high": "haut",
+ "low": "bas",
+ "back": "post",
+ "round": "arr",
+ "tense": "tens",
+ "voice": "vois",
+ "nasal": "nas",
+ "lateral": "lat",
+ "spread": "écart",
+ "open1": "ouv1",
+ "open2": "ouv2",
+ "open3": "ouv3",
+ ),
+ "pt": (
+ "root": "raiz",
+ "laryngeal": "laring",
+ "oral cavity": "cav oral",
+ "C-place": "ponto C",
+ "vocalic": "vocal",
+ "V-place": "ponto V",
+ "aperture": "abert",
+ "constricted": "constr",
+ "continuant": "cont",
+ "distributed": "distr",
+ "dorsal": "dor",
+ "coronal": "cor",
+ "labial": "lab",
+ "anterior": "ant",
+ "radical": "rad",
+ "high": "alto",
+ "low": "baixo",
+ "back": "post",
+ "round": "arr",
+ "tense": "tens",
+ "voice": "voz",
+ "nasal": "nas",
+ "lateral": "lat",
+ "spread": "abert",
+ "open1": "abr1",
+ "open2": "abr2",
+ "open3": "abr3",
+ ),
+)
+
+#let ui-geom-label(key, ui-lang) = _geom-ui-labels.at(ui-lang).at(key, default: key)
diff --git a/packages/preview/phonokit/0.5.11/vowels.typ b/packages/preview/phonokit/0.5.11/vowels.typ
new file mode 100644
index 0000000000..64212c9bf4
--- /dev/null
+++ b/packages/preview/phonokit/0.5.11/vowels.typ
@@ -0,0 +1,546 @@
+#import "@preview/cetz:0.5.2": canvas, draw
+#import "ipa.typ": ipa-to-unicode
+#import "_config.typ": phonokit-font
+
+// Vowel data with relative positions (0-1 scale)
+// frontness: 0 = front, 0.5 = central, 1 = back
+// height: 1 = close, 0.67 = close-mid, 0.33 = open-mid, 0 = open
+// rounded: affects horizontal positioning within minimal pairs
+#let vowel-data = (
+ "i": (frontness: 0.05, height: 1.00, rounded: false),
+ "y": (frontness: 0.05, height: 1.00, rounded: true),
+ "ɨ": (frontness: 0.50, height: 1.00, rounded: false),
+ "ʉ": (frontness: 0.50, height: 1.00, rounded: true),
+ "ɯ": (frontness: 0.95, height: 1.00, rounded: false),
+ "u": (frontness: 0.95, height: 1.00, rounded: true),
+ "ɪ": (frontness: 0.15, height: 0.85, rounded: false),
+ "ʏ": (frontness: 0.25, height: 0.85, rounded: true),
+ "ʊ": (frontness: 0.85, height: 0.85, rounded: true),
+ "e": (frontness: 0.05, height: 0.67, rounded: false),
+ "ø": (frontness: 0.05, height: 0.67, rounded: true),
+ "ɘ": (frontness: 0.50, height: 0.67, rounded: false),
+ "ɵ": (frontness: 0.50, height: 0.67, rounded: true),
+ "ɤ": (frontness: 0.95, height: 0.67, rounded: false),
+ "o": (frontness: 0.95, height: 0.67, rounded: true),
+ "ə": (frontness: 0.585, height: 0.51, rounded: false),
+ "ɛ": (frontness: 0.05, height: 0.34, rounded: false),
+ "œ": (frontness: 0.05, height: 0.34, rounded: true),
+ "ɜ": (frontness: 0.50, height: 0.34, rounded: false),
+ "ɞ": (frontness: 0.50, height: 0.34, rounded: true),
+ "ʌ": (frontness: 0.95, height: 0.34, rounded: false),
+ "ɔ": (frontness: 0.95, height: 0.34, rounded: true),
+ "æ": (frontness: 0.05, height: 0.15, rounded: false),
+ "ɐ": (frontness: 0.585, height: 0.18, rounded: false),
+ "a": (frontness: 0.05, height: 0.00, rounded: false),
+ "ɶ": (frontness: 0.05, height: 0.00, rounded: true),
+ "ɑ": (frontness: 0.95, height: 0.00, rounded: false),
+ "ɒ": (frontness: 0.95, height: 0.00, rounded: true),
+)
+
+// Calculate actual position from relative coordinates
+#let get-vowel-position(vowel-info, trapezoid, width, height, offset) = {
+ let front = vowel-info.frontness
+ let h = vowel-info.height
+
+ // Calculate y coordinate
+ let y = -height / 2 + (h * height)
+
+ // Interpolate x based on trapezoid shape at this height
+ let t = 1 - h // interpolation factor (0 at top, 1 at bottom)
+ let left-x = trapezoid.at(0).at(0) * (1 - t) + trapezoid.at(3).at(0) * t
+ let right-x = trapezoid.at(1).at(0) * (1 - t) + trapezoid.at(2).at(0) * t
+
+ let x = 0
+
+ // Front vowels (frontness < 0.4)
+ if front < 0.4 {
+ // Extreme front (< 0.15): tense vowels like i, e
+ if front < 0.15 {
+ if vowel-info.rounded {
+ // Front rounded: inside (right of left edge)
+ x = left-x + offset
+ } else {
+ // Front unrounded: outside (left of left edge)
+ x = left-x - offset
+ }
+ } // Near-front (0.15-0.4): lax vowels like ɪ, ɛ
+ // Always positioned inside the trapezoid
+ else {
+ x = left-x + (front * (right-x - left-x))
+ }
+ } // Back vowels (frontness > 0.6)
+ else if front > 0.6 {
+ // Extreme back (> 0.85): tense vowels like u, o
+ if front > 0.85 {
+ if vowel-info.rounded {
+ // Back rounded: outside (right of right edge)
+ x = right-x + offset
+ } else {
+ // Back unrounded: inside (left of right edge)
+ x = right-x - offset
+ }
+ } // Near-back (0.6-0.85): lax vowels like ʊ
+ // Always positioned inside the trapezoid
+ else {
+ x = left-x + (front * (right-x - left-x))
+ }
+ } // Central vowels
+ else {
+ // Calculate base central position
+ let center-x = left-x + (front * (right-x - left-x))
+
+ // Apply full offset for rounded/unrounded pairs (same as front/back)
+ if vowel-info.rounded {
+ x = center-x + offset // Rounded to the right
+ } else {
+ x = center-x - offset // Unrounded to the left
+ }
+ }
+
+ (x, y)
+}
+
+// Language vowel inventories
+#let language-vowels = (
+ "spanish": "aeoiu",
+ "portuguese": "iɔeaouɛ",
+ "italian": "iɔeaouɛ",
+ "english": "iɪaeɛæɑɔoʊuʌə",
+ "french": "iœɑɔøeaouɛyə",
+ "german": "iyʊuɪʏeøoɔɐaɛœ",
+ "japanese": "ieaou",
+ "russian": "iɨueoa",
+ "arabic": "aiu",
+ "all": "iyɨʉɯuɪʏʊeøɘɵɤoəɛœɜɞʌɔæɐaɶɑɒ",
+ // Add more languages here or adjust existing inventories
+)
+
+#let language-nasal-vowels = (
+ "french": ("ɛ", "œ", "ɔ", "ɑ"),
+)
+
+#let _strip-nasal(vowel) = vowel.normalize(form: "nfd").replace("̃", "")
+
+#let _collect-custom-vowels(input) = {
+ let converted = ipa-to-unicode(input)
+ let oral = ""
+ let nasals = ()
+
+ for cluster in converted.clusters() {
+ let decomposed = cluster.normalize(form: "nfd")
+ let base = _strip-nasal(decomposed)
+ if base in vowel-data {
+ if base not in oral {
+ oral += base
+ }
+ if "̃" in decomposed and base not in nasals {
+ nasals.push(base)
+ }
+ }
+ }
+
+ (oral: oral, nasals: nasals)
+}
+
+// Main vowels function
+#let vowels(
+ ..args, // Optional positional vowel-string (read below) — allows `lang`-only calls
+ lang: none,
+ width: 8,
+ height: 6,
+ rows: 3, // Only 2 internal horizontal lines
+ cols: 2, // Only 1 vertical line inside trapezoid
+ scale: 0.7, // Scale factor for entire chart
+ nasals: false, // Draw nasalized copies of plotted vowels
+ arrows: (), // List of (from-tipa-str, to-tipa-str) tuples
+ arrow-color: black, // Color for arrow lines and heads
+ arrow-style: "solid", // "solid" or "dashed"
+ curved: false, // Curve arrows with a quadratic bezier arc
+ shift: (), // List of (tipa-str, x-offset, y-offset) tuples
+ shift-color: gray, // Color for shifted vowel symbols
+ shift-size: none, // Font size for shifted vowels; none = same as regular
+ highlight: (), // List of tipa strings whose background circle is highlighted
+ highlight-color: luma(220), // Circle color for highlighted vowels (default: light gray)
+) = {
+ // Read the optional positional argument: vowel symbols, a language name, or
+ // tipa-style IPA. It is optional (via the `..args` sink) so that `lang`-only
+ // calls like `vowels(lang: "spanish")` work — a parameter with a default value
+ // would be named-only in Typst and could not be passed positionally.
+ assert(args.pos().len() <= 1,
+ message: "vowels: expected at most one positional argument (the vowel string)")
+ assert(args.named().len() == 0,
+ message: "vowels: unexpected named argument(s): " + args.named().keys().join(", "))
+ let vowel-string = args.pos().at(0, default: none)
+
+ // Determine which vowels to plot
+ let vowels-to-plot = ""
+ let nasal-target-vowels = ()
+ let error-msg = none
+
+ // Check if vowel-string is actually a language name
+ if vowel-string != none and vowel-string in language-vowels {
+ // It's a language name - use language vowels
+ vowels-to-plot = language-vowels.at(vowel-string)
+ } else if lang != none {
+ // Explicit lang parameter provided
+ if lang in language-vowels {
+ vowels-to-plot = language-vowels.at(lang)
+ } else {
+ // Language not available - prepare error message
+ let available = language-vowels.keys().join(", ")
+ error-msg = [*Error:* Language "#lang" not available. \ Available languages: #available]
+ }
+ } else if vowel-string != none and vowel-string != "" {
+ // Use as manual vowel specification - keep oral bases for plotting and
+ // remember which vowels were explicitly nasalized in the input.
+ let parsed = _collect-custom-vowels(vowel-string)
+ vowels-to-plot = parsed.oral
+ nasal-target-vowels = parsed.nasals
+ } else {
+ // Nothing specified
+ error-msg = [*Error:* Either provide vowel string or language name]
+ }
+
+ // If there's an error, display it and return
+ if error-msg != none {
+ return error-msg
+ }
+
+ // Calculate scaled dimensions
+ let scaled-width = width * scale
+ let scaled-height = height * scale
+ let scaled-offset = 0.55 * scale
+ let scaled-circle-radius = 0.35 * scale
+ let scaled-bullet-radius = 0.09 * scale
+ let scaled-font-size = 22 * scale
+ let scaled-line-thickness = 0.85 * scale
+ let scaled-arrow-mark = 1.5 * scale
+ let scaled-nasal-dy = 0.44 * scale
+ let scaled-nasal-font-size = scaled-font-size * 0.9 * 1pt
+ let nasal-color = gray.darken(10%)
+ let resolved-shift-size = if shift-size != none { shift-size * scale } else { scaled-font-size * 1pt }
+ // Split highlight into regular-vowel highlights (strings) and shifted-vowel
+ // highlights (arrays in the same (tipa-str, x, y) format as shift:)
+ let highlight-set = highlight.filter(h => type(h) == str).map(ipa-to-unicode)
+ let highlight-shifts = highlight.filter(h => type(h) != str)
+ .map(h => (ipa-to-unicode(h.at(0)), h.at(1), h.at(2)))
+
+ canvas({
+ import draw: *
+
+ // Define the trapezoidal quadrilateral using scaled dimensions
+ let trapezoid = (
+ (-scaled-width / 2, scaled-height / 2.),
+ (scaled-width / 2., scaled-height / 2),
+ (scaled-width / 2., -scaled-height / 2),
+ (-scaled-width / 10, -scaled-height / 2),
+ )
+
+ // Draw horizontal grid lines
+ for i in range(1, rows) {
+ let t = i / rows
+ let left-x = trapezoid.at(0).at(0) * (1 - t) + trapezoid.at(3).at(0) * t
+ let right-x = trapezoid.at(1).at(0) * (1 - t) + trapezoid.at(2).at(0) * t
+ let y = scaled-height / 2 - (scaled-height * t)
+
+ line((left-x, y), (right-x, y), stroke: (paint: gray.lighten(30%), thickness: scaled-line-thickness * 1pt))
+ }
+
+ // Draw vertical grid lines
+ for i in range(1, cols) {
+ let t = i / cols
+ let top-x = trapezoid.at(0).at(0) * (1 - t) + trapezoid.at(1).at(0) * t
+ let bottom-x = trapezoid.at(3).at(0) * (1 - t) + trapezoid.at(2).at(0) * t
+
+ line((top-x, scaled-height / 2), (bottom-x, -scaled-height / 2), stroke: (
+ paint: gray.lighten(30%),
+ thickness: scaled-line-thickness * 1pt,
+ ))
+ }
+
+ // Draw the outline
+ line(..trapezoid, close: true, stroke: (paint: gray.lighten(30%), thickness: scaled-line-thickness * 1pt))
+
+ // Resolve an arrow endpoint to a canvas position.
+ // endpoint is either a tipa string (canonical vowel position) or a
+ // (tipa-str, x-offset, y-offset) array (shifted position, same format as shift:).
+ // Returns (found, position) where found is false if the vowel is unknown.
+ let pos-of(endpoint) = {
+ let is-str = type(endpoint) == str
+ let v = ipa-to-unicode(if is-str { endpoint } else { endpoint.at(0) })
+ let x-off = if is-str { 0 } else { endpoint.at(1) }
+ let y-off = if is-str { 0 } else { endpoint.at(2) }
+ if v in vowel-data {
+ let base = get-vowel-position(vowel-data.at(v), trapezoid, scaled-width, scaled-height, scaled-offset)
+ (true, (base.at(0) + x-off, base.at(1) + y-off))
+ } else {
+ (false, (0, 0))
+ }
+ }
+
+ // Collect vowel positions
+ let vowel-positions = ()
+ for vowel in vowels-to-plot.clusters() {
+ if vowel in vowel-data {
+ let vowel-info = vowel-data.at(vowel)
+ let pos = get-vowel-position(vowel-info, trapezoid, scaled-width, scaled-height, scaled-offset)
+ vowel-positions.push((vowel: vowel, info: vowel-info, pos: pos))
+ }
+ }
+
+ // Build the global obstacle list for curved-arrow avoidance:
+ // all plotted vowels plus all shifted copies, pre-computed once.
+ let shifted-obs = shift
+ .filter(s => ipa-to-unicode(s.at(0)) in vowel-data)
+ .map(s => {
+ let sv = ipa-to-unicode(s.at(0))
+ let base = get-vowel-position(vowel-data.at(sv), trapezoid, scaled-width, scaled-height, scaled-offset)
+ (base.at(0) + s.at(1), base.at(1) + s.at(2))
+ })
+ let preset-name = if vowel-string != none and vowel-string in language-vowels {
+ vowel-string
+ } else {
+ lang
+ }
+ let nasal-bases = if preset-name != none and preset-name in language-nasal-vowels {
+ language-nasal-vowels.at(preset-name)
+ } else {
+ nasal-target-vowels
+ }
+ let nasal-positions = if nasals {
+ vowel-positions
+ .filter(vp => vp.vowel in nasal-bases)
+ .map(vp => (
+ vowel: vp.vowel,
+ pos: (vp.pos.at(0), vp.pos.at(1) + scaled-nasal-dy),
+ label: vp.vowel + "̃",
+ ))
+ } else {
+ ()
+ }
+ let all-obstacle-positions = vowel-positions.map(vp => vp.pos) + shifted-obs + nasal-positions.map(np => np.pos)
+
+ // True if p is either at/near endpoint ep, or is its minimal-pair partner.
+ // Minimal-pair partners share height (dy ≈ 0) and lie exactly 2×scaled-offset
+ // apart in x (one rounded, one unrounded). They are geometrically inseparable
+ // from their partner, so arrows approaching ep will inevitably pass near the
+ // partner and should not try to avoid it. The ±40% relative tolerance on the
+ // distance keeps the check scale-independent while excluding near-front lax
+ // pairs like ɪ/ʏ (whose inter-vowel distance is only ~68% of 2×scaled-offset).
+ let near-or-pair(p, ep) = {
+ let dx = p.at(0) - ep.at(0)
+ let dy = p.at(1) - ep.at(1)
+ let d = calc.sqrt(dx*dx + dy*dy)
+ let at-endpoint = d < scaled-circle-radius * 0.5
+ let is-pair = calc.abs(dy) < scaled-offset * 0.1 and calc.abs(d - 2*scaled-offset) < scaled-offset * 0.4
+ at-endpoint or is-pair
+ }
+
+ // Draw arrows in three phases so that arrowhead clustering can be applied
+ // after all control points and tangents are known.
+
+ // ── Phase 1: compute drawing parameters for every valid arrow ────────────
+ let arrows-data = ()
+ for arrow in arrows {
+ let fr = pos-of(arrow.at(0))
+ let tr = pos-of(arrow.at(1))
+ if fr.at(0) and tr.at(0) {
+ let from-pos = fr.at(1)
+ let to-pos = tr.at(1)
+ let dx = to-pos.at(0) - from-pos.at(0)
+ let dy = to-pos.at(1) - from-pos.at(1)
+ let dist = calc.sqrt(dx * dx + dy * dy)
+ let mid-x = (from-pos.at(0) + to-pos.at(0)) / 2
+ let mid-y = (from-pos.at(1) + to-pos.at(1)) / 2
+
+ // Control point selection for curved arrows.
+ // Two obstacle lists are used in priority order:
+ // strict – excludes only the exact endpoints; pair partners are live
+ // obstacles so the algorithm avoids departing through them
+ // (e.g. ɔ→ɪ must not start by crossing through ʌ).
+ // loose – also excludes pair partners; used as a fallback only when
+ // no strict-safe path exists.
+ // Sampling at t = 0.1 and 0.9 (in addition to interior midpoints) catches
+ // obstacles very close to the source or destination vowel.
+ let ctrl = if curved {
+ let px = -dy / dist // CCW perpendicular unit vector
+ let py = dx / dist
+ let ccw-sm = (mid-x + px * dist * 0.30, mid-y + py * dist * 0.30)
+ let cw-sm = (mid-x - px * dist * 0.30, mid-y - py * dist * 0.30)
+ let ccw-lg = (mid-x + px * dist * 0.55, mid-y + py * dist * 0.55)
+ let cw-lg = (mid-x - px * dist * 0.55, mid-y - py * dist * 0.55)
+ let local-obs-strict = all-obstacle-positions.filter(p => {
+ let dfx = p.at(0) - from-pos.at(0)
+ let dfy = p.at(1) - from-pos.at(1)
+ let dtx = p.at(0) - to-pos.at(0)
+ let dty = p.at(1) - to-pos.at(1)
+ let far-from = calc.sqrt(dfx*dfx + dfy*dfy) > scaled-circle-radius * 0.5
+ let far-to = calc.sqrt(dtx*dtx + dty*dty) > scaled-circle-radius * 0.5
+ far-from and far-to
+ })
+ let local-obs-loose = all-obstacle-positions.filter(p =>
+ not near-or-pair(p, from-pos) and not near-or-pair(p, to-pos)
+ )
+ let clearance = scaled-circle-radius * 1.3
+ let sample-ts = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9)
+ let hits(obs, c) = obs.any(ob =>
+ sample-ts.any(t => {
+ let bx = (1-t)*(1-t)*from-pos.at(0) + 2*t*(1-t)*c.at(0) + t*t*to-pos.at(0)
+ let by = (1-t)*(1-t)*from-pos.at(1) + 2*t*(1-t)*c.at(1) + t*t*to-pos.at(1)
+ let ex = ob.at(0) - bx
+ let ey = ob.at(1) - by
+ calc.sqrt(ex*ex + ey*ey) < clearance
+ })
+ )
+ let ctrl-candidates = (ccw-sm, cw-sm, ccw-lg, cw-lg)
+ let chosen = ctrl-candidates.find(c => not hits(local-obs-strict, c))
+ let chosen = if chosen != none { chosen } else {
+ ctrl-candidates.find(c => not hits(local-obs-loose, c))
+ }
+ if chosen != none { chosen } else { ccw-sm }
+ } else {
+ (mid-x + (-dy / dist) * dist * 0.3, mid-y + (dx / dist) * dist * 0.3)
+ }
+
+ // Tangent at destination: ctrl→to-pos for curves, chord for straight lines.
+ let tangent = if curved {
+ let ex = to-pos.at(0) - ctrl.at(0)
+ let ey = to-pos.at(1) - ctrl.at(1)
+ let ed = calc.sqrt(ex * ex + ey * ey)
+ (ex / ed, ey / ed)
+ } else {
+ (dx / dist, dy / dist)
+ }
+
+ // Pull endpoint back to circle edge along the arrival tangent
+ let adjusted-to = (
+ to-pos.at(0) - tangent.at(0) * scaled-circle-radius,
+ to-pos.at(1) - tangent.at(1) * scaled-circle-radius,
+ )
+
+ arrows-data.push((
+ from-pos: from-pos,
+ to-pos: to-pos,
+ ctrl: ctrl,
+ tangent: tangent,
+ adjusted-to: adjusted-to,
+ ))
+ }
+ }
+
+ // ── Phase 2: merge arrowheads converging at the same vowel ───────────────
+ // When multiple arrows target the same vowel and their adjusted-to points
+ // are within cluster-radius of each other, snap them all to their centroid
+ // and use the averaged (renormalized) tangent for a consistent arrowhead.
+ let cluster-radius = scaled-circle-radius
+ let arrows-data = arrows-data.map(a => {
+ let cluster = arrows-data.filter(b => {
+ let dtx = b.to-pos.at(0) - a.to-pos.at(0)
+ let dty = b.to-pos.at(1) - a.to-pos.at(1)
+ let same-target = calc.sqrt(dtx*dtx + dty*dty) < 0.01
+ let dax = b.adjusted-to.at(0) - a.adjusted-to.at(0)
+ let day = b.adjusted-to.at(1) - a.adjusted-to.at(1)
+ same-target and calc.sqrt(dax*dax + day*day) < cluster-radius
+ })
+ if cluster.len() > 1 {
+ let n = cluster.len()
+ let tx = cluster.map(b => b.tangent.at(0)).sum() / n
+ let ty = cluster.map(b => b.tangent.at(1)).sum() / n
+ let tn = calc.sqrt(tx*tx + ty*ty)
+ let avg-tan = if tn > 0.001 { (tx/tn, ty/tn) } else { a.tangent }
+ // Re-derive adjusted-to from the normalised tangent so the tip lands
+ // exactly on the circle edge (the centroid of circle-edge points sits
+ // strictly inside the circle and would leave the head floating there).
+ let snapped = (
+ a.to-pos.at(0) - avg-tan.at(0) * scaled-circle-radius,
+ a.to-pos.at(1) - avg-tan.at(1) * scaled-circle-radius,
+ )
+ (from-pos: a.from-pos, to-pos: a.to-pos, ctrl: a.ctrl,
+ tangent: avg-tan, adjusted-to: snapped)
+ } else {
+ a
+ }
+ })
+
+ // ── Phase 3: render ──────────────────────────────────────────────────────
+ let shaft-stroke = (paint: arrow-color, thickness: scaled-line-thickness * 1.5pt,
+ dash: if arrow-style == "dashed" { "dashed" } else { none })
+ let head-stroke = (paint: arrow-color, thickness: scaled-line-thickness * 1.5pt)
+ let mark-style = (end: ">", fill: arrow-color, scale: scaled-arrow-mark)
+ for a in arrows-data {
+ let from-pos = a.from-pos
+ let adjusted-to = a.adjusted-to
+ let ctrl = a.ctrl
+ let tangent = a.tangent
+ if arrow-style == "dashed" {
+ // Draw dashed shaft without a mark
+ if curved {
+ bezier(from-pos, adjusted-to, ctrl, stroke: shaft-stroke)
+ } else {
+ line(from-pos, adjusted-to, stroke: shaft-stroke)
+ }
+ // Solid near-zero segment at the tip renders the mark independently of
+ // the dash pattern, correctly oriented along the arrival tangent
+ let tiny = 0.01
+ let head-anchor = (
+ adjusted-to.at(0) - tangent.at(0) * tiny,
+ adjusted-to.at(1) - tangent.at(1) * tiny,
+ )
+ line(head-anchor, adjusted-to, stroke: head-stroke, mark: mark-style)
+ } else {
+ // Solid: shaft and arrowhead in one draw call
+ if curved {
+ bezier(from-pos, adjusted-to, ctrl, stroke: shaft-stroke, mark: mark-style)
+ } else {
+ line(from-pos, adjusted-to, stroke: shaft-stroke, mark: mark-style)
+ }
+ }
+ }
+
+ // Draw bullets between minimal pairs (same frontness/height, different rounding)
+ for i in range(vowel-positions.len()) {
+ for j in range(i + 1, vowel-positions.len()) {
+ let v1 = vowel-positions.at(i)
+ let v2 = vowel-positions.at(j)
+
+ // Check if they form a minimal pair
+ let same-front = v1.info.frontness == v2.info.frontness
+ let same-height = v1.info.height == v2.info.height
+ let diff-round = v1.info.rounded != v2.info.rounded
+
+ if same-front and same-height and diff-round {
+ // Draw bullet at midpoint between vowels
+ let mid-x = (v1.pos.at(0) + v2.pos.at(0)) / 2
+ let mid-y = (v1.pos.at(1) + v2.pos.at(1)) / 2
+ circle((mid-x, mid-y), radius: scaled-bullet-radius, fill: black)
+ }
+ }
+ }
+
+ // Plot vowels with background circles (white, or highlight color if highlighted)
+ for vp in vowel-positions {
+ let circle-fill = if vp.vowel in highlight-set { highlight-color } else { white }
+ circle(vp.pos, radius: scaled-circle-radius, fill: circle-fill, stroke: none)
+ content(vp.pos, context text(size: scaled-font-size * 1pt, font: phonokit-font.get(), top-edge: "x-height", bottom-edge: "baseline", vp.vowel))
+ }
+
+ // Draw schematic nasalized copies slightly offset from the oral vowels.
+ for np in nasal-positions {
+ content(np.pos, context text(size: scaled-nasal-font-size, font: phonokit-font.get(), fill: nasal-color, top-edge: "x-height", bottom-edge: "baseline", np.label))
+ }
+
+ // Draw shifted vowels (on top of regular vowels)
+ for s in shift {
+ let vowel = ipa-to-unicode(s.at(0))
+ let x-off = s.at(1)
+ let y-off = s.at(2)
+ if vowel in vowel-data {
+ let base-pos = get-vowel-position(vowel-data.at(vowel), trapezoid, scaled-width, scaled-height, scaled-offset)
+ let shifted-pos = (base-pos.at(0) + x-off, base-pos.at(1) + y-off)
+ let shift-fill = if highlight-shifts.any(h => h.at(0) == vowel and h.at(1) == x-off and h.at(2) == y-off) { highlight-color } else { white }
+ circle(shifted-pos, radius: scaled-circle-radius, fill: shift-fill, stroke: none)
+ content(shifted-pos, context text(size: resolved-shift-size, font: phonokit-font.get(), fill: shift-color, top-edge: "x-height", bottom-edge: "baseline", vowel))
+ }
+ }
+ })
+}