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 @@ +
+ + + + phonokit logo + +
+ +
+ +[![Typst Package](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fguilhermegarcia%2Fphonokit%2Fmain%2Ftypst.toml&query=%24.package.version&prefix=v&logo=typst&label=package&color=239DAD)](https://typst.app/universe/package/phonokit) +[![MIT License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![User Manual](https://img.shields.io/badge/manual-.pdf-purple)](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 example +
IPA transcription based on tipa +
+ Consonant inventory table +
Consonant inventories (with pre-defined languages) +
+ Vowel trapezoid chart +
Vowel trapezoids (with pre-defined languages and arrows) +
+ Multi-tier phonological representation +
Multi-tier representations +
+ Syllable structure tree +
Syllable structure (onset-rhyme and moraic) +
+ Prosodic word tree +
Prosodic word (with metrical parsing) +
+ Metrical grid +
Metrical grids with IPA support +
+ Autosegmental feature spreading +
Autosegmental phonology: features +
+ Autosegmental tone representation +
Autosegmental phonology: tones +
+ Feature geometry tree +
Feature geometry +
+ OT tableau with shading +
OT tableaux with automatic shading +
+ MaxEnt tableau with probability bars +
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)) + } + } + }) +}