From 3cefbb7b166346e4e8e2eedba4ef76e612db3a3f Mon Sep 17 00:00:00 2001 From: "Guilherme D. Garcia" Date: Mon, 30 Mar 2026 16:48:52 -0400 Subject: [PATCH 1/6] phonokit:0.5.3 --- packages/preview/phonokit/0.5.3/LICENSE | 21 + packages/preview/phonokit/0.5.3/README.md | 179 ++ packages/preview/phonokit/0.5.3/_config.typ | 8 + .../preview/phonokit/0.5.3/autosegmental.typ | 276 +++ .../preview/phonokit/0.5.3/consonants.typ | 806 ++++++ packages/preview/phonokit/0.5.3/ex.typ | 408 +++ packages/preview/phonokit/0.5.3/extras.typ | 39 + packages/preview/phonokit/0.5.3/features.typ | 2136 ++++++++++++++++ .../0.5.3/gallery/autoseg_example_1.png | Bin 0 -> 1322 bytes .../0.5.3/gallery/autoseg_example_1.typ | 11 + .../0.5.3/gallery/autoseg_example_2.png | Bin 0 -> 2887 bytes .../0.5.3/gallery/autoseg_example_2.typ | 22 + .../0.5.3/gallery/autoseg_example_3.png | Bin 0 -> 13912 bytes .../0.5.3/gallery/autoseg_example_3.typ | 47 + .../0.5.3/gallery/consonants_example.png | Bin 0 -> 23689 bytes .../0.5.3/gallery/consonants_example.typ | 8 + .../phonokit/0.5.3/gallery/feat_geom.png | Bin 0 -> 10462 bytes .../phonokit/0.5.3/gallery/feat_geom.typ | 9 + .../0.5.3/gallery/features_example.png | Bin 0 -> 29559 bytes .../0.5.3/gallery/features_example.typ | 5 + .../phonokit/0.5.3/gallery/grid_example.png | Bin 0 -> 1364 bytes .../phonokit/0.5.3/gallery/grid_example.typ | 4 + .../phonokit/0.5.3/gallery/ipa_example.png | Bin 0 -> 6916 bytes .../phonokit/0.5.3/gallery/ipa_example.typ | 17 + .../phonokit/0.5.3/gallery/maxent_example.png | Bin 0 -> 30109 bytes .../phonokit/0.5.3/gallery/maxent_example.typ | 41 + .../0.5.3/gallery/multi-tier_example.png | Bin 0 -> 4215 bytes .../0.5.3/gallery/multi-tier_example.typ | 18 + .../phonokit/0.5.3/gallery/ot_example.png | Bin 0 -> 5203 bytes .../phonokit/0.5.3/gallery/ot_example.typ | 17 + .../0.5.3/gallery/syllable_example.png | Bin 0 -> 2192 bytes .../0.5.3/gallery/syllable_example.typ | 6 + .../phonokit/0.5.3/gallery/vowels_example.png | Bin 0 -> 11514 bytes .../phonokit/0.5.3/gallery/vowels_example.typ | 17 + .../phonokit/0.5.3/gallery/word_example.png | Bin 0 -> 5842 bytes .../phonokit/0.5.3/gallery/word_example.typ | 5 + packages/preview/phonokit/0.5.3/geom.typ | 1957 +++++++++++++++ packages/preview/phonokit/0.5.3/grids.typ | 114 + packages/preview/phonokit/0.5.3/hasse.typ | 460 ++++ .../preview/phonokit/0.5.3/intonational.typ | 81 + packages/preview/phonokit/0.5.3/ipa.typ | 410 +++ packages/preview/phonokit/0.5.3/lib.typ | 977 ++++++++ .../preview/phonokit/0.5.3/multi-tier.typ | 407 +++ packages/preview/phonokit/0.5.3/ot.typ | 1233 +++++++++ packages/preview/phonokit/0.5.3/prosody.typ | 2200 +++++++++++++++++ packages/preview/phonokit/0.5.3/sonority.typ | 242 ++ packages/preview/phonokit/0.5.3/typst.toml | 23 + packages/preview/phonokit/0.5.3/vowels.typ | 476 ++++ 48 files changed, 12680 insertions(+) create mode 100644 packages/preview/phonokit/0.5.3/LICENSE create mode 100644 packages/preview/phonokit/0.5.3/README.md create mode 100644 packages/preview/phonokit/0.5.3/_config.typ create mode 100644 packages/preview/phonokit/0.5.3/autosegmental.typ create mode 100644 packages/preview/phonokit/0.5.3/consonants.typ create mode 100644 packages/preview/phonokit/0.5.3/ex.typ create mode 100644 packages/preview/phonokit/0.5.3/extras.typ create mode 100644 packages/preview/phonokit/0.5.3/features.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/autoseg_example_3.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/autoseg_example_3.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/consonants_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/consonants_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/feat_geom.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/feat_geom.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/features_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/features_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/grid_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/grid_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/ipa_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/ipa_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/maxent_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/maxent_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/multi-tier_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/multi-tier_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/ot_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/ot_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/syllable_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/syllable_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/vowels_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/vowels_example.typ create mode 100644 packages/preview/phonokit/0.5.3/gallery/word_example.png create mode 100644 packages/preview/phonokit/0.5.3/gallery/word_example.typ create mode 100644 packages/preview/phonokit/0.5.3/geom.typ create mode 100644 packages/preview/phonokit/0.5.3/grids.typ create mode 100644 packages/preview/phonokit/0.5.3/hasse.typ create mode 100644 packages/preview/phonokit/0.5.3/intonational.typ create mode 100644 packages/preview/phonokit/0.5.3/ipa.typ create mode 100644 packages/preview/phonokit/0.5.3/lib.typ create mode 100644 packages/preview/phonokit/0.5.3/multi-tier.typ create mode 100644 packages/preview/phonokit/0.5.3/ot.typ create mode 100644 packages/preview/phonokit/0.5.3/prosody.typ create mode 100644 packages/preview/phonokit/0.5.3/sonority.typ create mode 100644 packages/preview/phonokit/0.5.3/typst.toml create mode 100644 packages/preview/phonokit/0.5.3/vowels.typ diff --git a/packages/preview/phonokit/0.5.3/LICENSE b/packages/preview/phonokit/0.5.3/LICENSE new file mode 100644 index 0000000000..e116f4b73a --- /dev/null +++ b/packages/preview/phonokit/0.5.3/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.3/README.md b/packages/preview/phonokit/0.5.3/README.md new file mode 100644 index 0000000000..5185e6cc12 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/README.md @@ -0,0 +1,179 @@ +
+ + + + phonokit logo + +
+ +
+ +[![DOI badge](https://zenodo.org/badge/1113733598.svg)](https://doi.org/10.5281/zenodo.17971031) +[![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 🔍 + +Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/main/intro-to-phonokit/intro-to-phonokit.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. + +## Fonts + +As of version `0.3.7`, the package allows the user to choose a global font for all functions. By default, Charis is used (Typst has a fallback font should you not have it installed). However, you can use whichever font you prefer with the following command: + +```typst +#import "@preview/phonokit:0.5.3": * +#phonokit-init(font: "New Computer Modern") // <- add to the top of your document +``` + +### 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) + +### 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 + +### 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**: Easily display feature matrices for SPE-style rules + +### 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 + +## Installation + +### 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.3/_config.typ b/packages/preview/phonokit/0.5.3/_config.typ new file mode 100644 index 0000000000..6ccb1944da --- /dev/null +++ b/packages/preview/phonokit/0.5.3/_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.3/autosegmental.typ b/packages/preview/phonokit/0.5.3/autosegmental.typ new file mode 100644 index 0000000000..3f360fab02 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/autosegmental.typ @@ -0,0 +1,276 @@ +#import "@preview/cetz:0.4.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.3/consonants.typ b/packages/preview/phonokit/0.5.3/consonants.typ new file mode 100644 index 0000000000..267e3e7245 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/consonants.typ @@ -0,0 +1,806 @@ +#import "@preview/cetz:0.4.2": canvas, draw +#import "ipa.typ": ipa-to-unicode +#import "_config.typ": phonokit-font + +// 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( + consonant-string, + lang: none, + affricates: false, + aspirated: false, + abbreviate: false, + cell-width: 1.8, + cell-height: 0.9, + label-width: 3.5, + label-height: 1.2, + scale: 0.7, +) = { + // 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 + + // Check if consonant-string is actually a language name + if 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 != "" { + // 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 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 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 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 + } + } + } + + // Select label sets based on abbreviation setting + let display-places = if abbreviate { places-short } else { places } + let display-manners = if abbreviate { manners-short } else { manners } + + // Build manners array with optional rows + if aspirated { + let asp-plos-label = if abbreviate { "Plos (asp)" } else { "Plosive (aspirated)" } + display-manners = display-manners.slice(0, 1) + (asp-plos-label,) + display-manners.slice(1) + } + if affricates { + let affr-label = if abbreviate { "Affr" } else { "Affricate" } + let affricate-insert-index = if aspirated { 6 } else { 5 } + display-manners = ( + display-manners.slice(0, affricate-insert-index) + (affr-label,) + display-manners.slice(affricate-insert-index) + ) + + if aspirated { + let asp-affr-label = if abbreviate { "Affr (asp)" } else { "Affricate (aspirated)" } + display-manners = ( + display-manners.slice(0, affricate-insert-index + 1) + + (asp-affr-label,) + + display-manners.slice(affricate-insert-index + 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 = places.len() + let num-rows = display-manners.len() + + canvas({ + import draw: * + + // Calculate total dimensions + let total-width = scaled-label-width + (num-cols * scaled-cell-width) + 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 x = scaled-label-width + (i * scaled-cell-width) + (scaled-cell-width / 2) + let y = total-height / 2 - (scaled-label-height * 0.65) + + content((x, y), context 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 = scaled-label-width - 0.2 + let y = total-height / 2 - scaled-label-height - (i * scaled-cell-height) - (scaled-cell-height / 2) + + content((x, y), context text(size: scaled-label-font-size * 1pt, font: phonokit-font.get(), manner), anchor: "east") + } + + // Calculate row positions accounting for optional row insertions (needed for grid drawing) + let fricative-row = if aspirated { 5 } else { 4 } + let affricate-row = if affricates { (if aspirated { 6 } else { 5 }) } else { -1 } + let aspirated-affricate-row = if (affricates and aspirated) { 7 } else { -1 } + + // Draw grid lines + // Vertical lines + for i in range(num-cols + 1) { + let x = scaled-label-width + (i * scaled-cell-width) + let y1 = total-height / 2 - scaled-label-height + let y2 = -total-height / 2 + + // Special handling: Remove lines between dental-alveolar (i=3) and alveolar-postalveolar (i=4) + // EXCEPT in the fricative and affricate rows + if i == 3 or i == 4 { + // Draw line segments for each row, but only draw for fricative and affricate rows + for row in range(num-rows) { + if row == fricative-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 = scaled-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: (row, col, half) where half can be "full", "voiced", "voiceless" + // Calculate row positions accounting for optional row insertions + let plosive-row = 0 + let nasal-row = if aspirated { 2 } else { 1 } + let trill-row = if aspirated { 3 } else { 2 } + let tap-row = if aspirated { 4 } else { 3 } + let fricative-row = if aspirated { 5 } else { 4 } + + // Rows after fricative need additional offset for affricate row(s) + let affricate-offset = 0 + if affricates { + affricate-offset += 1 + if aspirated { + affricate-offset += 1 + } + } + + let lat-fric-row = (if aspirated { 6 } else { 5 }) + affricate-offset + let approx-row = (if aspirated { 7 } else { 6 }) + affricate-offset + let lat-approx-row = (if aspirated { 8 } else { 7 }) + affricate-offset + + 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 { + impossible-cells = ( + impossible-cells + + ( + (1, 9, "full"), // Pharyngeal aspirated plosive - physiologically impossible + (1, 10, "full"), // Glottal aspirated plosive - physiologically impossible + ) + ) + } + + for (row, col, half) in impossible-cells { + let cell-x = scaled-label-width + (col * scaled-cell-width) + 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 + scaled-cell-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 + (scaled-cell-width / 2) + rect( + (mid-x, cell-y), + (cell-x + scaled-cell-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 + (scaled-cell-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 col = int(parts.at(0)) + let manner = int(parts.at(1)) // Original manner of articulation + let row = manner // Display row (will be adjusted) + + // Adjust row based on inserted optional rows + if aspirated and row >= 1 { + row = row + 1 // Plosive (aspirated) row inserted at 1 + } + if affricates and row >= 5 { + let affricate-offset = if aspirated { 6 } else { 5 } + if row >= affricate-offset { + row = row + 1 // Affricate row inserted + if aspirated { + row = row + 1 // Affricate (aspirated) row also inserted + } + } + } + + let cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-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 = 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 = scaled-cell-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 in row 1 (after regular plosives) + if aspirated { + for (key, pair) in cell-aspirated-plosives { + let col = int(key) + let row = 1 // Plosive (aspirated) row + + let cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-width / 2) + let cell-center-y = cell-y - (scaled-cell-height / 2) + + // Only left position (voiceless only for aspirated) + let offset = scaled-cell-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 (after fricatives) + if affricates { + let affricate-row = if aspirated { 6 } else { 5 } + for (key, pair) in cell-affricates { + let col = int(key) + let row = affricate-row + + let cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-width / 2) + let cell-center-y = cell-y - (scaled-cell-height / 2) + + // Offset for left/right positioning + let offset = scaled-cell-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 (after regular affricates) + if aspirated and affricates { + let asp-affricate-row = if aspirated { 7 } else { 6 } // After affricate row + for (key, pair) in cell-aspirated-affricates { + let col = int(key) + let row = asp-affricate-row + + let cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-width / 2) + let cell-center-y = cell-y - (scaled-cell-height / 2) + + // Only left position (voiceless only for aspirated) + let offset = scaled-cell-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.3/ex.typ b/packages/preview/phonokit/0.5.3/ex.typ new file mode 100644 index 0000000000..828d392512 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/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( + 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.3/extras.typ b/packages/preview/phonokit/0.5.3/extras.typ new file mode 100644 index 0000000000..bd2e0a88e8 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/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.3/features.typ b/packages/preview/phonokit/0.5.3/features.typ new file mode 100644 index 0000000000..8af38a05d2 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/features.typ @@ -0,0 +1,2136 @@ +// 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 + +// 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) = context { + // 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" { + // Show all features or only specified ones + // Format feature names for display + let display-name = feature-name + .replace("spread_gl", "spread gl") + .replace("constr_gl", "constr gl") + .replace("delayed_release", "del rel") + 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.3/gallery/autoseg_example_1.png b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.png new file mode 100644 index 0000000000000000000000000000000000000000..102e7f4e0bb66e40c9204ba3b1744b859e36fe60 GIT binary patch literal 1322 zcmeAS@N?(olHy`uVBq!ia0y~yU{nCIuW|qhhD!&EoER8bK6<)1hE&{od%ND}N~z58 z!u#=8d^9zEFYP?@XX@%Ho1QIOWO6&eZ29Ch2Yj`+E%SCy&HEc1C$#LcL+(r~bB*v5 zXJnT?ohC7H=KfW|zg1@`Z&0=PJmP=2B--V-HU0uSTuKBCBK0Ryo`JuJ_iRkJ6CM&B>pY#57=cP$>aG+oh z(}jK2x`v@~R=+-M==o??=DYvP?nh#$o$sfr-gXBUj$KKauyN+1`x!hq=@A&o7Ofaxdxi`xTS6Uy{3_n!81D(zM&3 zKLy3Fo-`%9c%A%$w}1OJn`TYF*|KWYsv7TJh7~84#hW{{e*ByDbZgt9$nyTJEf*IW zDKTj6>uZ?iQ@HG8@@(Pu`l8CsXG5~*N}pJ1dHw!P75}&8Z1EG4*gRCvot^qAsPAkb zhsM^$ja-YFm-wVVO0CRT%bR)a=+3lA$CXOE7dKpbd$W(t>PdI8nB0Z;wsUXpzWOgq zD97hl!}R<98{)q6zxmd6>;87{-M9WQYt77J4!e3X`LwQH@xxDitheT0zhsvg8yC0I zrljWJW6R}B9?V*!9%rk`FMBHcrFJg2toW0S$L@UjIs07k;uBM~p>cZQznq3ZfD5aA zzo5c?rPXV=^9>sqvhp469oGG<{>@ONYmnh4ps>AZ@+2mPozv#>3Iy{gG%Sh@l5|)n z`X!DxU`FqYlL`)rW{VVn8Y)>0k236f#;V0wlG04@so_wb3&YNQm%4@-Neql4 zPqik4&3VACamMWlGmtP5SGeB92$GHc{Kv5&X7&^j28}yzECD-uITWrlE!JjC>SAOG zm~wi)AdAKwekn$-&4N!EoJ0SCE;SEr;)9`yF6i>}&P zvN!+kdake02QOWYIFA%25dHAQ^;(Po&IIn*-29A{?ao&0eH^b#FTJ()eB0Gfg*spYZS5+JMRITjg%wW||tqxGK^z@yQ&4 z3ERGuKC12Y*|?wi={Je*OIJlThWsuuPru>WaEnRwX0z`uE|uE){#2$bVlO146=E50 z{g|>uUx#zlCUU{SJM#``_%%xaf&9g93vobIoF>q4ROwzB@& z+v}Gamrh-OUb+5=%7urqm#+4SZ!Za{c5+DS7t?>{|YwOu&?u`@fBsB-!6q#Sg;6?SB3=6{>Q}XRn_V_ S@6&ovA?WGq=d#Wzp$PzEj6jh9 literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.typ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.typ new file mode 100644 index 0000000000..24cf7a68ea --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.typ @@ -0,0 +1,11 @@ +#import "@preview/phonokit:0.5.3": * +#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.3/gallery/autoseg_example_2.png b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.png new file mode 100644 index 0000000000000000000000000000000000000000..4917f11176f5e1093bf62c416410a77efa823352 GIT binary patch literal 2887 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 IPIx8$7yDT<1poj5 literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ new file mode 100644 index 0000000000..c619114382 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ @@ -0,0 +1,22 @@ +#import "@preview/phonokit:0.5.3": * +#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.3/gallery/autoseg_example_3.png b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_3.png new file mode 100644 index 0000000000000000000000000000000000000000..89c174368bee6818f9da2bcf45a0219e15a9175f GIT binary patch literal 13912 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 H&+mT$Jy+JW literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/feat_geom.typ b/packages/preview/phonokit/0.5.3/gallery/feat_geom.typ new file mode 100644 index 0000000000..d381e9b18b --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/feat_geom.typ @@ -0,0 +1,9 @@ +#import "@preview/phonokit:0.5.3": * +#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.3/gallery/features_example.png b/packages/preview/phonokit/0.5.3/gallery/features_example.png new file mode 100644 index 0000000000000000000000000000000000000000..7bd54a76e8d6bc569354237e64926a9abb783602 GIT binary patch literal 29559 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 keR z(3{(!hqWIcg>hTffB07YxP&q3Ulg%EPo>lu&hb@XZ}myi{xO;j4yj)h2`Ig$b`T0j zwX5G((G-4Gv_G8a62w{6x@N}s`wW>r*Z({gZ=j!7p(arTf;gkY zmR@BYFtatEEQhTR?w?O29>*M;ZI566&3=YUOkJ3c7x2|cs$gbW z*qJ8&!BH{iE99yqVdKRo{AE@xl%(o1dU5`W<^p`KRZS+vGizF8oCZ%}1qY33sL_OX?TeIfQHmAG&mWJ4w}35l>1JF&F!CKJwK$2a zxFu=Xw}J~q&P==`9 zp}-@Olq?eUHN7r?_WPVCZNX&S0;olwh#!EU!M+ktvLlHv zM6KGpOBnfHO8^fzIYf>InX-OadF`^)yzfbFXqSMCRvzC1&!P{B0oeBI+M{hwmme*~ z&3w6g0q{c&dx8s|#+J28pK^F68$gG?Wsaxb`F&(j*eT{7;Cy|`y9rtYUn*s8MfkRO zO1R58QKjW%-n~U=wx81P(Yfs%X(!;WN-M0+f8!N<=}xYmS@|vd@qzBVWP$7V(&ybN zcV_Wrkul+TBEhl@9@=A`ogq|NGV^x|)Pw=!K2WB!&T_bx8lFbjy`0nBzf zT0eZzx6GPbaUJq9w=S%3qbbYx zl2tRNB%o+besf$49N#TuT$3nG*eWBA3{MxVFm&2+?lkP36|}{~uMg+_EW11#z~fAL zw|>w+8{iK8si~%Cem5rk z>iFr8lb&3GZ+sLU@jQU;@%%x%(WR~Eq59Rl~j5bzq z*w)Pn>G(m1`0$quALk&AA$r;;h5?M4tbXiyy^ldW!t7o3&wL+y0g6ExAC zxw$dCoRIysrB7P1-w`t4@F4^;PkYCQ?1E9rsuPybeE(pNL@~!dG}V*! zwhkXfGt`XRD96TWAZqbP@D@Ypk19dR^^Zjgv>M*7p) z{3b%>c5W@Jxik7sRa?Y9UYct@6v!;^*ni&acuxSVq}5Ihx~FpU`Vsvau~eq@_I7NY z^N&u8^bFrU8yBZ!Z+Oc-{9A zJT#9sZ}ct>c-64jb7k+5drlh3ghlrmvAVQ-i^xRfX8u+s{3$yPDf+wiC5e=rhSp-x z^apytfr@?K9IBe_KK98Dy_YogPeNJ0%UBAVc%2py5a=BU$1pdLhW2#+f1Cs zT($8&Gz+@vB@hH^I)#y0-hX9)|K_vu1T02p9xLc>$TryrGp78j!f2~xXU}aKD&J`k zj@;&fd`ahjS*lla7Zz`hIWpG{`H6!=-S0ciyX8>xhF6!Hk=x zcqTl??ls3UprmV1*vj&nwy-#Eol?D$ySG|@!P>47hsVvdlUyinzK0CB0o8f3t&#E2e!pHlH?lzR(~h;Ole>N0QS)zBH@a_rr#4hf7NkeYRoO zq5Y$|-ZauTxo(W0ztP+O7LG#0dcKH#!q$7+*O9#;qjgQt#cZhYd2ku`u%-;8D`u1!@Lu7A%*1YqEQ3o@43@@e%*v+^`e7>WN}g%4 zcP#zlhKzzGzNd-aRgO(UDb@^NE9-m$iDSdvQJ7mJByO}7!4Tjif6pAHEJEb92{)jzpMAK1%Jt7)BIF+j47?gYab0*f=KnY<;mCpgO5S9njpLm= zBL?Ugo{aHXT##ddI)N(N&*iMxV)w||_08ym(ZW*8`rz@XveX>Bm)$g<$N;-clG z5_-ovHvkpZA#28{J^7s0L);+&Dyese>n%6C$}WQD6kYQgaWZ7ka6T7?RC-5|O%!&> zAVGJ2znbMm$U2?n6lOI~iK`;$7GYcs(}vfZ^%p9NY2uEO2jXYn87K;(v=TR@`f?DE z>M~dbbJ_wTSi9@VdadoPBP7u^MTG>`O=G|)>ddOgrdY1(=h*;Z==r5%lWEyf!_~3Z zy{#q}Q+u@ar!wQj{D~(e-Y&ESfHD}0;)p2&z?r#Dnh4my+Z9LS{kMX(1Z0I|(s`Z->j@cy5dA zNfO*Z3TfGsi}GjQ?FT4RY2O&m`x5RIDs2)AJc>XfUw(Zuw4Czh_RN=`{5r8|D}y%G`kC0+GYAV8=^Iji+-DV( z6<}}?qjll0anfV!RpuM2eO)jxb z0u8~jk{BH1F{3G)A8inZmkFrvJkYUmyvW8h^nA5?LU~O_T3&fpu4%tlr>j2`W5vj_>O?pF_A&j+fjt6TKG&LVP=gS_U& zZAMQ%bGs^@Cr`2uP9OaB=1(Kb?HbMsQd3~43AM_Hj%ne!(Dg;==u5YGOMJD;LR4}m zZR5ElK6tHb(egRE&HmjB+f;uK+A z@|u(YExtdHMSa*lSx=YDO)ir%KMEsA>hqls=bu2$dk84`ddYXh!gS{CmS3$0|Mx zxOfI&^LmQguIt?90`~m0E2oMGJ-jINix6!*;D9`S-u&wiTg~?1oSLH;<(nQ2 zJ24<3zhV`PxgVD|LdONI`oOi$$*k45Y+=JDZm4AN|V zv9dNLFCzn~V@v4O*D~yx>{e(CM@ES18WVMxs%bUrn3!O^SH-zKY6Ali9WiDc% z<}5)a*JVgX?Yyv*)ylg4X9G;mEB@HXCGr}I#`gm^nZ(}r-N~MRt}^K+O~jUD%ekWL zMt8#{h{A&=-L+vm+k!u$a*|lGamHR^RKW|bd6Ke&`|`-I*B7P52&mm@TSHzJ!CnvNFe2#k47)UvN1}MGgW2T>}1FDa-rXR4ws^!0?vcVd2NxR)#{9~XVS$}LYrWH zy%a;FPfr=juST^Z9ETKrJg9}g+Q0WW?VO15LzQp4CL^_%<)*m%^yKqD051=pgfuy9 zD7AR3UB5HRPz9gnq10@HuKqSuW*VRVEeWJtCu8G{oEe%13HSS8!+1e*PfLCtip##- zinsa#-_fe@H2hDj(tp67*hBCsQENa`VgKcUr*1M7!aSoH#|~D;nO|jNIJQxzpsi!w zSzullPYN{Dt|BQVfdSNNy4!Xo?0ItKC}$XR_GU2Zv?FpTg)0HhzDMGbb_(~PO?MLK z0X_n>{tivl3aFawQ-~YsFl=_0Y;P=hp8Ai8lbbJTTVwLv&u z=sAPS?lGa;oLvY@W8Ys2E$l*H`m8->%1@P+9W;v|Wbq|3MF%sp9{th8cD02jawVK- zLH=Y<`LI>OGi^s4s`v4yQY&|+oQ_*wnd_Yl+VFauq1KNunGEu#NrrbTWPEkq5hbPe z5Xex>^k7fUx991(Ke8hQK04Gq+j3W4!kYJWE!~`Iv38{^5RB}Ph!pMD8b!d~>YhYC z4eN!Sq)C{KX(Z!8D7RwjZrbweEDso>k01Y42I@RwNBuf)fOl)tEs}Qcb=O7Lz-c5( ze>BkB9`j{JKeP)QcPOPy;9CP?J8xz^^A=3^2_vzM-}}tTc^-9=)aUlWlXaKiUhEPU zp|EV;_*xn=Rztp;{CR)1$Stjs^P_!HR)W^O)t6KzZmMR?RW_DbN6tQ;KcC}5W4NX{ z#KR^zKU&Rc?F<8It3Q>brqZh5TecVRpy^rc&Dqte!(X2yK4WFlgM1%$;is2k0prJk z?OG$m6g}zgc7wJ}5g%wyHw0MlE54G%u86>SyFJXt$U1nYunC%$?DpSE$y;kR12gJu zs3L#NIL6Tyg$6GQBA1ojQf1xl(gga_S-5q(i_q2|tdR7Vb)&MF*j?~UI^ALy4V7F zVRnYk*U@qVlr(siymt|Sghp;v)s}3X;4H``4(k#FFhh4dIIDkfZ+5J`7COBSp-YH2 ztR+nV`!)&%Du+lx4)NMojF%`Tp##?hyUDtVt}49y4=eEtmRA7K4ElwEeYIBI=?{uY z&QIL*?fvvR3Y1RGy;ocYhp)&lhMA&0aIF00Ga)QZ=E|$R=lY)j@E#DJw0OI(W0Mkd ziyOY8{Jh0OLSLM~%-!@PG_+hwaSky%4q<*TwtYtw$DRos7M4A^p2jr1JHs!)f<}vK z3MxsMg1Q39qQ5Pd;e0#KpaJz>no0Dr^-JD@u^OJ02`u1Sh8}(xy>YwVXvulzE7JvI zlj+5r-Q3!aJXp!qj9CR-o-$tN7M*`lqJ1?{y?;h#430EyN#oRR`yt;8&P~KUKXTO< z*?wFt^>UsdXaFEMeinUJp<^?*EUA$!-&bng=F}mQ$*)@!&F%JiNJ775=Nzfju_O>D%nfLIhu z(n%Dq)Wy!%nX=5Z2%t2^1pkkC(AE2E4 zzC-bG{ry#V5yY&YS$da70rgg%qRtA_$aMw`)-3F3Ap{vhG>Yb5%gvV^Z~c&_<(hnw zefJzPa+7t~$?M_7lg>-ad5psd*{jdvx`=bjL~BtC$L6A5*wNT*kB?$( z7xNp;njyWNj!huaSm*_qUqWI+8BlWEWj^G7E(g2-$D~j1dlh~A2TEa1fwW?R8rYKF zKUx77?``T5oUsA=A4&lm<~Ul@cXFImVC1H^->yTcCJT=0e?oGm!QO(p{Jt}MaIgeR z>#8>d*)X9p(Km#w6%v;18y+JxeGg0DlkHDn2JpHBNO#|ajNmB+mmJ>7nGfY};|9z( zBKYjZw4X+7Mj|tfu{66G*N5=FsrSXHtZ$)S3!qoZELGm-N1w^bhdIrBt=DB>XznO% zlXql=;*TpRO~(Te14E4hXzGKB?9u~RqTZ>G;Q{$g)d8vUvo_Q1$4PSwB=;Yg}w<2dB- zx}MpKO?RoQL<3lOq0vax)SI&E^&vJHnvc`Ft0N!$9uT%uGL9A-mgH&qL}O9wTa0dh zTyy^*d2)3-mj72a3NCOVV5hKw--|Jv88Ld=sCeg!f%QzRi!}Ve?9fo(0$o;0{NAPZVnW<7j^l{3$#g|ozHh(%(aI>m43N>9 zyb+FMBQ;yHt3W>+!IYOPTjiK)d}#5GXRaL;+vHduWz%&gd1dih=4u7=aJ8cJlty64 zxs-nw$QQY4Uig*;z$Jwa7WUe*z!D3N; z1&oF5DQwNNt0cfZUFX2tx$G)Ao|ipsX|fQl%{DOp=fzIgl}dsPB0uDqyR{jYx3j0R zfW1(ck@I-?0e2RLg0SV-T%NRhHG5&>BfFf+kslxMhAG*Yo;EtW#lFzB2`h!$B_#Q_nh)rK#0J$Lg# zcaFbvmIqO2Y)AqVOB==yB=z|0PlmptLv~QR{W&D`Dm|;zcxJVH#_(cZ`VRS*Ifq^eKi(wew*k z*G5yB)xl3jZb4hDUS~b?^(xg$g&L>G3}y3N^t+0`N{CpE9qTmOfJ&Iy#*MJ)I${rn z^0%ztp%k$t)k@@V7dR&uFSD2)W=LKI%!s%<57TJx{1Ou(cy2`L5`*VvP#->hP@Pft z(c6wtSUHQRD|U1jVL}mBD{F0EJh)3tN^21|g?Ay)h=XUmA8I*YrRc5!=KbGGCcFk< zw-$)YX?<9le-<+(cv#DL)Be_Td>9_VH>`ywPI#Zkf~YGFtNK;di=jQF&u`H2wBySP zOR;P(!r5gAmP(Mr4~!4R09c1UjyOEz0kof*tO&mG^!X%-Pfj~2$z~h8K8S%wWYiG@ z>K)P+jglw9fB6 zzNK8`Ls+y9rOSNl4n8)-;*tpB^$zfNJ3pZ|yo9DGhBbAbYn zqQPC9lJUPh-V;$%F7Wh!od|sDPLrm*z?;ERsWRL$BATj8F(nc;^hK=^}}$qfsO-wV;=$(bfF7$aVOjfXvh(?IN0jF2E5(B|8a3*dj8Djm?z~>mS+gZYclbT z+}?ilN+bJh&mpy|dzEXw0v#XvBs$?qZ{bW)xW^VYEH%M*e%7?Mzy#^bjf9Z6>zbun?hu%ai)UcSaz?$rh8kcZBIuDpw_b8#Q)V=$_#3eBJ#*6W zzzDh_XboKHImo_wWepY@JIX$W2Z+N1C$knnpV%5=;4c+k|GhHpKEDj4O)X}*`Y}Kd z$(I35jGn0I>(B*e?HvAo_m&uS3QIsQfZHA7IC-T6FF1`hEt(?(nLZ=7qXIT^GTsZ_ z(Ov~n`~s6rpuuNN@-AsK#<*q}C#AUG7FGpo(vrl0vK19e!HB5^3AJP74a)aCLm|afoTOe^y znF1d>C5h2XPXH!C0Tk``IZb33(g zPazYhA}b@Nm3`4d`N)2{i@Xoho-S)wSxEX-`EX?as*CBba)edcwC|1E7_9PJPPfT= z8oeZ4{EM!b&KG_5c?q<#sM6cQFPas9Jp^Cqn|0?!5Yl%nZN(_*KP`NqA*u#*YWDtg zB9m;?Yg<`MJE;G`sJs2_Nb~ILE;Tu2ZFB@tFf9?qetxQIM!8ffLOhw2SVXGAeqD|B zWAp01CMgvy)7V7u-z5W5O=HPRr%4)In5@Sbz4=tXz8+O5F#qShd9R&76CS8XO-GCh z?vuXcpFMAnRs6xl5Vx?588CE#4hf2&E_ z%WmlW?I@|x#jz0PIPyvltY2Ed)7JirT>~ki3JBVxmQ;FH3I-u=Ys3#g@GA6rqdWA9 zAm`6!(*w7Sen&T{1+6bv-;k{q4HRk>;W}Gi{*yZgnp($RgJJ1~U3jK_C9Hv2<)KTY z%2vz&hYr%2jQl>JgWFEBrFHlGNJ&AiZyelqe~ySXH9Hrb;s!>>Hhq@|{UqlN#Bq^5 zwi#)%k%HV^0B7oLWrgq(qr^69srRPqWvp$Xly42|UByRm>{h3z>kx$!OrRC7(P z>eC)k05zq5?dXM}=^GB-$^PH#IU$`{cHpcr`n86^%SxNshpU1XNh_O?pYECMPh@+# z;JmZ>EKkPkassC$G2U1Ge4N(Lr=tW&yChxIhI0e-pB2)#-J@1|ojBcrtXt{-voeTt zByGpI%iHJv;9!kUq2PQE`yj9pEZ9QFcM_)V!Ej%Ix&2`c%{6|5MZ6?E53nd1z0{zYMTvT=zT(XI^S$*<+Q`qMT1L5g9fG&|3?(>=*JHL)fpc9YC(_^Vde5pA zQ-hKnVc3yO5g{qIJBS+jftL`&0cl}mZ?p#&+dXx8mj^N>kg8@u;dqowyb7#(;$&iI zoBl`c zd|LGA>>HkI5=DW1tslK6rx&^0fYqrQt3F+(QGgcC8$8~nsuh~I_+`}vw{F4v=rfRN zMxp8zeTf4O-v)QtR+|jAo|?Ophc;MJVA<#kn2O?3Axs@hIMTqj&d0WDipqRw7j2^t z>G??f1quI#FnO4UM`_c21LRCEkD|gk-i?LM2(YZu_#_?t^-E)8Jv7z5C@A1#DZf9> zg!uFw@}(k>I9V%t6>Pp>n&Q8X#tJ(4B_oH{G6(Ei?Rr{G9zRBgS8Dhh_82o`Biw;0 zZrdNv_irS`cJkk8+cNXHo%uRN@;(b@;LeKBp(V$fCKM$oawj#09WqV@r84D?r47p` zcp)Ns@#)B6y|O+stbC#u(=9diocfS+Xn*U?NV8jHfUll}Xjw4nRCa+5b*zyvTf9Tw zY&Y3wTayLzCtsawyC0waj-J*yyrOYM+dOR9;CZ^Zt&UX?+_I%BM4`6}5ybd%JC7O% z%~wbz)H67lREr+kjw?=ZJ>uH~>LSEIeGyI5Q4_>D-0(CUZ}0nM%=-;S7Q+4D#FoXs z{>i8S7s%dG@a;Q?tvg-kezLzyj=A43jdAr|^3`AnR|KyR>ee{CPzG+j+>#srA3Li5 z;7E1B*3=NzkGl>3z>Rm%b9z#P+tX2NNxhh~0O@hu#{20IZEOz`X{L>X%`VliiqX#s zL*{{|bz$@3e+b+0IH3lAE|yd4w+y@ACJ-uJu@5qUs08Pnb-ZoR|IbPJp?<@IRAaZa5~u23$cc}G!qxI`xDkzd#9Nv#kExGoDK-@ z;AtB1kQm~Od2+7hCN|EZEkQRe!kWgIAwRJFZq-><$hw$ue%0wIFxsJ&D8J%CG4{bv zI-Z|5?chLr#A^NySj2*b&pMG89K`1N{Z5>Hb-aV`d}!8s>XC9(q)8l`@T`9)1vvy~ zc+o}UDsr3Ry}rV)7=``%VWo>mWM8C5-GX5q)_2=QG5vghafs_XCdn|i5}lg zJyoiHVUhGnq5CYWf_AHrDGtsj4TL8xdpe-p5`}N0vDXM%Hr1v(p26^G^RQA2UTA26 zeuQD5hoPKJZ}Kl2wV-(6BM+-jXBp5>Vq>^!vNWSCrN0mGes+*yE1)VPq&LJ%h{qq5 zZcV?Z(X{0*Xon4d`Xn%^KER~1Ty^sLZvg^1S9sK6yWfXN3OEI`#hZ(Er4-hiJ>m@t z$$>Oe&u)(CUOE2U_h4g84a7)?4`A_Gt=>>Iovg9U51Nk4r3>T$Xv6`+1XG=P!Erpb6de+r1jhrbSu>jq7&9p!u^ z2oxWE-M{XEz4uea7Ot0}gIdEac?3EAa-rJEObhk)>s#IA)7XUTegDJ_PMI)@gI!cm z7!&z|$yoj?7ckuwVCB{Be(^kvnB>g-c6Y6&E)|K${5?ocFYcZ>}o*=KfMaz0wNH zkM8djI&Hc7Tf1Y@-2V;h{HK}u?@KYV_@pnVVV)XaP!9``y zKgM&KpH|}lFm*X?cHtOB7TD}=f`AQ_6MeK-G;r5N1PIMvE^dKdLh4=efSG!S&lIQh z!LOWOZwXr;$0tKiU-n|&!QvvI+_aSF9ftk@XHrjE_z};7fJrY>+#FwS#by7doKq<- zJ_HS7dZk&A0c|o#(Njg34G#@Nuoa!e{OsUvif5>&W-KksIzF@l_WeXQ_5Ez!8oor{_iy88P(QW)s>e|fYdsz`WKHv+&C7J4m$ga!On~gaLJjaX z(Oy0(a@66=zFdw-3xDTjo0!!;{Pt=+eT-M%%)DXX+30~0ki#)wIa^Bl; z*cNkU(C}Mo;LasvRHtZ&Qus|z#Yam)P@U>=r(?l7up-^06L;8_QO{8RC>u-n=oKRN zgJvo#oO>S0u;)h z*v$}%=d3h-CYOx9*$Q8DaGS)p=c4p@(ogj1apabz^owsdhI=}?MI9K#?C@?Jjrv>t zW(h~WTQp=HD`%V8;U;+ctqg*N%h=82&Sxjx7@~j+UB#^@e5%b9jX*NsJ@sl*(zqJV zE_w+v%lhA?G2H2NS)mA(R&-3u!dZy(F(>Ia2Vm>v50x?BEv!nqw#qsPfR>4-FUnKm zw_mY~x2_GAmA(9J)I8J-f2mHP@_i+8Ev&d!=&L`Nm+_+8; zy6}JCs^L@EyEB3_7 zX%JoRKC8(J`&IrPE@tNDC%H@hjVbnCZjNF#yyWwbT#AktKi7Aw-t}9iUuN|ukfD*q z8!dDN$u(N&E_A<6AM9G!`D3|b3h2=mZQ=OJ47`Oj&r}vpP_D@O1jDM zqxRArv$H{F9M)!J{WnNG+5b07O7~Xv|h>666q_`@>ls|t}hCo zmNc_7<2&_zD&HTt$l?tQw=2P)QS>417TgP*VcN%{2E*!ZVkeJM!W`R|8i(he*dy!tmR z+(r7(4qqt=$%j%(wpGo99FI=(J$N?TR?UJxB1~;ow2pHz@#^cS@(D?kWWXm_mLq6T zEnA=x9Mp%b=X#NN-H}@_)!X~x_@ii&~GsWv@O@6IEkUug${r9zXO zm#cIN@yV?+Q^a(91j|Ev4sz!+Z7!rh%m=CBDG`mLKFr6?|QVOnU=NZhEW zc}RYt^1It8T3;+^_3O}~EU?+Ec&rKTF}>D1rUw0^)iaRye&9h?v}l~c-gAX`{0}wD zB;Z7BB@Ve;a)%BDfKhxvsjFL>I|HR|A~E0Sae`L6ktZ=EOsbCPj_C`ZX~_qfzR#;0 z7`-fk$w9`XAXZ`LUoT9p!BGi$WHH+@%?BTM5cY_(G*?Z`=rAbp| z1&K?x$K=$WA*MNjP^0YQcW@D_bOu~2UZ*Dr%cze8U-f4H1MvPgo&4WYGX9-)#x+6W z(kQTb(5KC5ng9IoWB6>cz6N`3gEp@j4^V3F*1f|Gw|ehUbS4N!4ZQ&MuD=~MTqhwz zaW>xi3}onwdVIXSgcu~)g_Cvo9Jh**hs^rTdW)N`@GP^hgJy&jcZVEDP$^5Yhf-dm z**#ZpCelZ@6Izh33wyJ1G_BidCcJ|~gt+qkhBOn2h7UHqlKJG;v*Voju6Z$+_)0Yo zT&vP`?`exY{^OB@kS)-tTLzbpIf-`HJ)$HfdkloEZTfiB zdKATgQkh&;ED=c0lm>``XVY_+C_EpB6D*8Xt34V8;``ixmSv(hZq?ZYo!{a37WBo@(OpIXp&Num zVqnc)={*By;?&;@fekWIwej?OaX+WtG^ohh6%%;6G=y}Q0ngRCX<2o1*Zj<%h39K02{FTIu8&0q8(dY1RW!&?DbBG7K z0WOpvJvvgl6;fdrIut%RLxPWs7Dvs{7s3JAftG~TsM_F8)01krty-Fn$4aL1Mj6*G zxoFJ9Q08_q?sjPb*BJ>BMX$ltzLeqF<%jS17$$Q@JZ6g{a<+^63`C-^ibS}F$_tlb zz@_Jz#v_Dv_T%0&T3I0v9zdKJ0*ku9F_>!$jdoyILXz3Nm{ue2ZM} z4Hj33jDb%NuNQ9cGBLM$iW}6ACehic7(BJl#15eE3r%aeg!x^gZ8I-@P;W%a4)#=F zAUoILR_Ivg2>Ne3Xf3tja&a5ih5F_K_&$+aGW(Z&geYUm&k^)W2)TfVj`T+sFIG=K&*aWcb6E%}4xWacC1*M6jXnlL}Gk04{BsMR9{}aWtj+bqG zoda5D2c22ET*G$x=+3q)#yb;|+HAAaausRKiT7*nn8iC!S-xb|n7k&TXg?G5hVF!+ zNN;tw$cS2YjNUn8KInYQnRsK15;wZI2#Xt%Z|IU_Xo^RX4iCWl-@e=|xT5@e^V2Je U7LwwB{!vr)nby-vMT@up0(`VP@c;k- literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/features_example.typ b/packages/preview/phonokit/0.5.3/gallery/features_example.typ new file mode 100644 index 0000000000..4544a6b200 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/features_example.typ @@ -0,0 +1,5 @@ +#import "@preview/phonokit:0.5.3": * +#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.3/gallery/grid_example.png b/packages/preview/phonokit/0.5.3/gallery/grid_example.png new file mode 100644 index 0000000000000000000000000000000000000000..4ffed557ea3a03478c0c76985d24d8095244a4eb GIT binary patch literal 1364 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 N8R!^;?{7Lr{Wo1o8No;-+kxp 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
rVeDXshr=Y>TcnNEOmjuT(u4FHP{=DzC6 zm`ym#C!>Trfm4Ao>ofiQ3EO1_tWI6cbC2p(SHd0Y#31*nng#&Y=t?oDJ1{yizxqn9 zCCaObiP1#_x6P12T&sQw4GJy`?#6^Bz)R{pZG?d>x$eM%_JK(Do9iG{0cCc`2V_{y zF}r&Z;f*E0{%?~)nsr?Z?#X#KnhJ*`zB4H2FlW!(u%WD1lQ@QO4xt!i9bt|+UuKR$ zU(^0g#Lhxcu8@0Z9BfNG%EGZS>vPnZ6XwbZv6WTCgL8C)=+v4lbA_O;0#z{oJ&w+w zrzvF90>RkW>oF+{Wq@wh|nB5?|wj2gt0 ze$+E6T;!AMEwHF`#*sI4*lB%B(BhT0Nt34u8VZ{d7S{h)th)SQqLIFPb=K!~X`(;$ zczpYG_;&a#Ztg`bS!;}_>K$vLUQ{4=k`GIIq)Eh{IuhWlQL872{CTF=0_Rbp7iXwL z2$A=6_gAwEMk7cEM(NaQkfPRvlNk1bRLdX4tLIHe_z2Ja??=cvoIN<0ayZL2G>7=Y zDcgZ3!oA2CFy%^%V1w4hhg%wR-Y+o@Tqj8r;O<}p#nspA4|5b3C|lTLuKKXei=@Ad z9?VZHdD(FfwsH!7Jvz0}^%ze&z9?cem4Bo?cJ9|2OK3qjO}yaRD8DMdT(#znn7Y~& zlt94JY*>Y#gStzyeFSIJC*t>lClmdst!)ILA95yAfBG)G!zoPth*C-9>Zzs%z6@`X z#b<++MO*)NfBK&I(&g^EKtelwTwwY)jMViS^GxQhq@O2zgx&n|M=xak^Y}50lKAt* zsVXrfqAG&AeUvfA9{RKVTgu%9CJPTw!KA$-z6z`NrTf9B#4`U3)3>{Q(R&&?fTH zYE>hw<@^#Rw$C@068GSUKl&Rr?Gnr%Zx5#-I$Zz1o-@=#Y`0L68W1Z40rqjG@;@yg zphhAD_m+Jt&;4Lo->#S%Ac>hR~j1|Jx0B7Ru+4~Wt$)BN8xW)`5ZPo0hu zbx{qWjwVGYYMm|?t3YqJr{P*7{MW#@`k`}&z1lx*?-nv|Qg0V+kYMfG*rzJbSOo^> zoxkE!?}>VB3oXa)k25L0Kc^u%XEQ#hJ>@B^{|^7%aR1F50)`ZdUyP&vO^;9=VjO7K zDbiu=*%~>JDCU>SD4HFzbC;u79AK5R3Vt1Q&!v3Sy%Hs%8E&^ehD#bpJWZT)?m&JH zQ>N?wTY^bf#`(?u0q*Y6eGAtTr}rj74*bIpHRh%JISO)#f(gyD`t@_H8%NzQ7!lg> z&}1?rfO0e!O(}7H3&rH2r1+%Qu}Tn-Zk)xcf~` zSR8<;f!o#6KA^VUW%E4M+*k2^Vla|X$uBMX9RG{q^n+*gP;G0-;6+8emxH?*gnd`( zAT<7sg!*0STVzhIi=0`AnlDVX;+eBJrk(M#)k)8hOs>&4OoI4V54m6@%9DSA_Z}Y< zR}a-9{|5qE)B8<%5llddKJ}q#FWl3*{nfXWFf1q>km-w&`Vxh(jYHB)vl|VUs+j;I zl-G)yYmw&@DTG}f_Sv%n2Y;J z^b^$u54BVJ?Suy8baX8S&T!2K@Uh?RG-m z*JgWlgjB*6KnR8Ve;g!^#=StDed)KxE+UTJY@Nj^ty6eVMwEByLjpSUTV@b3Z#kh~ zp*SAKe|Q*yem4LiW(9}v?R94E^|*mQ`mbWuNO=^l;lY@t-DrUQdZG{W^6*~YC)_L# zqVlzfo^(_A<6rX6G3Rv=UlD{oW~bPN{q+Bj#=bhLjjvlf1TF3^L5ddF;0}f2?v!H1 zOL3RtmY~JmAry*xTUtV~;)S9m6t}cUfWnvGz4woM?|SdMzO`n}PGJVdIcgf!DN&+sZYPamk3@WSv@v{ul>iMb>is`^TLA`q_ zRD)q#q!gg>qE9)&`eaiPr4S{R*ARV{FQm(Az0xuUYhC?1(I5&kmEN&KtW_!a*ma_B zT@6y$$6&CQ3CYtr^m~`s(pU_?CPXkna%$|d)1Hc?ts!byx~baCaJY;E6IS;W;PbaCI>RBdDDYO(uj9sa~P7;Cg{ zFo`f>>kUhl9UQqGCN1QC%|`N`p1_a4xQPg<;U)JoTAC(NKJ%${&WvJAxc^*bF~{i# zCKQh<6SjOSUhJVSr?6=$p#6O^s11etRM9EY2u_z5{#gf%$rf=dihOIC6?N^9lhiD3 zFw|&FJB{E+xD2b$?2iTFTx8ZpIl3oIh~bVGmOVxy6fG1uZB!6XI=+t@H1@ou02?J= zx9N1@>p({5-|f$X`Nj#_$rPZ*gN{O18?Ey8A?;fW(xY-G#&V1WN&KWYA=7WnyDSZa z`uB7*srP@8+V;E+T^xFyR|8ELm|>Uiw3*~~vY{Dn$@9t`}XpgXc9i>zaJ0UU_umo>W97Ky@JSr5}!3T`vDX;uTmv;h2#UoF>s-SotD@)ZVa~Ptl%6W7L)ekSo?HxaJ_b>XvQv?h)b( z-wEbJeHi9PdZ#5KygkEKEt8DC;bT}ly3?)edBuL71?OXb1j~Dtro1H{jqAr6gI$(? zdv|0*?omY&I0`pJ-Re-hwD&?loTvIoDN4XAsS~mhI@2S5>WCh*w*t!}=+|k6|NESr zU8sfn;wn_%?t3oXfJOHH6kUBEv8t2_<6H&erF~M|$%6WjS)LAY{Zf6Ml_vo$a-yuM z0wl)Go!%ZBTKSX&A3u6r5}^4Iyj(*I@bc^fk|`-YLSv?F6r?3BHFY;mJ1Mol`X9gr zqB57>7fccCW`(ca{)4I|=f?1VaF*5VUl~Cm_-@FFkjjq-^(lr6?QQ2i@5q23wsmV3 zjid9P^j&|0>E%_2N))D_|FDC7VDC~`OA)4R=ZFNAt~!~@*CWOLe(hV1tYy`4QRo8; z&d)Y2_--<$75MX*uF?muYGiR`DrX}{)=DzYwkIn zaPROLhsI?n%aw=3Wt%fRD}cyN+v_>SkKSHt5x?Bv!r$A4wA=F*-P;{t9+ z_xs-BA`va?`*;5IU+!raDn9+e`(snMckdk)`og*}oq#V0PSkz%z=X&>HifgOeJigrg-L%sB%op5nqZQf1?l#4eSQX#L@dwn{5@TBsRKU!@ zUgLf?(2PIyZ!9E|eGqFZ`AS@)#IjSE)O#Ps~C!ORG8KFsV80+p;43=${LYfBHs4Qv#d;@^rSY+BS}hx?Lz%pXtD z_-9hMUzj}y@_U@-sS>Xj-S>74Xx;f>0J_e{hEW6WQctAt1(PrUEQu+4tR&># z5=|B=i%C&DmpN~^McEfPkner*6upDfR)68M096NWIvTX|xIa3&M14T|VWNHoxpSz1maU8C7nck zbb1T1gMT3l1duskI!=vH^B^E0Swe~OeV-2zfW^j0s{2ssE{PLKkp;gRp8;SZUI_&> z@7YVS6|6<`w^6m{XlF4$m@DB>>LV;lLzyrE>HL>*xJbJmb{gAwx+m$;YB-U@a?gzv z`a~S$cBxt72yL*_Ag2_iK(VY>*aSU=slX}t2WKVuccvVpHdP5@GqzOW7_uWQdaN5< zoEYs(YL%zY^V#TXAh%;m0J&;~FDL!$GDHE%`+#PChon=>k&hv&Ooa$w%ALn~=@?JQ z9H|9gAOgwY+uPZm^fsy7)nF^*NE(kN>N^vQ`{h)b!kdz(3ufHraNM!RVK49;BRqD; z0n|}*GFU(JMN2YDLgbPM;WCl{`1F~Fup{s48EZynF`(d(d~E;5on17F;^P@o^%b*b zPYI(<6)rYa@WN7>L|}XUT~fv)#H-g0;x``8yGP*TxL~grLi+^hDu9EGi!Bv1ahi;+JKeVD!NN{Z2Wq_+TAd?Aeqa z=-d{vG5UN)*z_2%`lND3^5aj(XtjGUb@o=gVWV_y&Vv>>d*hGe(4WOhx@@$=1~gN~ zsOOBX33soH1;beNsDu#vRD~+ORA%k!Cu?%Fw(j^G#(|o`U`y{FDv2?jGoxuCU+DJp zS9Cz_KOog!jD#mR=;5$*4?#q>jJ-~a&vniG7qqtQhg!_u%U~v8@=(HHt1mG&4R|S; za)5Yghru2n&3Qq&AbO;Q(L&3qt`;Ip{7{#EGye;ZAJwjjci;O1Dkl30oYkhPMZzVA zj2@e_BkJvR1l>fB-OG~9AGz?h+jdkbEi?wF23YqzP8kouxcDrc_~4sdy_R|!<>Rt@ zLEtWt7&F+rf6JNUO5;Kgu4KyPe^*L4PquDN3dSb;WgPg)Oq^Z;XsXw27l4UP2Y#&< zD;DNdZ`@t2F<5sTXN_QaPzb#uCpV#udj9rxax#8}j@nF(gqoVFSxniRWDY6vmCr!- zI3V^XZF?T}MK*$F(6g#U#&1oj)^9i*IAToq>Xg!*Xw}r%c!KFX=m)0~4H>H6R;K@v zKjWW1h|GJ~k|gISCQ)Axl=9L9*7(B zw?Va5#A+vjxNAA{e)W2WW>4L22G)f$I)7RJGFrB^WrpCaDebfot;*(@BUL)NT((8s zM+eJHilGxD;oqWtc zSo(UBVz!S~v?pA%L1foXuswiwRNB~!)+7@-0UUBsYOfM^er9nn82p;yHdr^#D)Bje zaIRC9mxXDsC0p(>k6Db9IPYtlW=f~!{>)_4mQ-)|t|s=I&ex}99%g1!c14J+d9ohA zS{aD5g*aMRwnf`hnOFq>B#7(~FoPA%9r#vLfT0OS2suf2qy>o5<{6%hCRq z!*C>3aGBV4K&`sJb^TR1PqR_tSIa+zR}KNyoKwH?S2&Qbh%AHsYh9?^N7z3Jr6tnLOUdb{YyAQ_v?a@vvOMxf}T`Wv3Z?``6V zj^om*0nVx6Nv*^~WM3pG*;t);s`V|~PrE`lhEA^ps9tM#4H|%3Mz>c(@Ao9Gwtk1L zj?xWY*jTEijRa{deyhRu{NQt`JhwN%-4y;gdOWxJ>BJYE}7{KT}x~ z%6lVgrrR)q;(vuTD2wb6K5RHc5s}z#m(KH@rhtay52b&&Ri+?~9v@Ul=-<%JywsF| z#XiaFT~5;oFsRAZ_nR?|Nkrn7H%wr>T}QKL*^eTNB_RtPUI8w~?4#DoD_F{t5%>xf z#pxPHI^v;6Zre*Oh(Z6WG0XG4d8ZlJ9y@oxZe~u zMyWhbfBOJ4O9th7zJGY`&>Xx3Q3GiMjyc-g#?Ss6tx8QVvQwYA)}$J{c{b1=qC z;P{l27(!V$%1$X0QKY`OLG74GVy|%l^T)_i)3FF$EBuRI#ZS?00*Wio;C=o4GJ5ev zth>@RP<+}u;?;GW;1bv}Q$j@~mnL9n0YYW64FqV13mZ$o$)&WjqVl1Tt|jrlQC?_< zABLH$f8Fv5uOi0)kZ}+Yd;(jxafH+P!f-2mtjn!H%=s~^zeSn;&gm`kPXpe&b715? zR}5OVEAQ=*i$1T=lr71S{mp(joMRYD@AFj_V|aE)|9zIz$aV33+_ixrBB$!3z4Pxv zwe+5W+{ylV2h`pzyfWI!X#lX;u8qr%KM2U>i6H%3sJ{TZzwg+y#1m?jDTn_u;W`MeBb(%>l}qEf!~FR_ z*&K&nx%XC%FYV>^wBMZ)>EdFS#m4`Y=@vlYFgi5>0 zv27>52c0KJuAa4w{MHJdyv*>(A88JTd5+8E-B#~e{Za(B-2aRcFMk!^(&+PN!OuHn zMc*tJ^fms!jkbjE%S1tRFVFKrU>uKYp?&+&oi`@>C`XoMV12dmN-|YeykC{Q%X}KL zTglI!1V$e0Mdij$)6fR*iNItaT`@mSDS_zEgB_uA9C2!c_JC}g+n&Ib!8yIraAAZ} zbEB~!BT{uTGJyZefCaVeWrONXv!FrgfHYj!;gYi^)283pvDNM)j4+8r=-Oh`LL9RT zN#PsbO-eXT=0Xi&agXccROCCd46PYf+>Xn0`1j`p;~B@C*9a2s*gAp(rnyx-5s9ZN z=_6ck?7Xdxg;mlE={o_v2iMf3V4?c1_*eATrhYIrIo4^xCLwq<%a}93fy*F^1Y2V; z4Dyhlv$cq>qc-fzX6BPL&I{3y*W>m@yrS1$&?up;)dWfzXN-#8D1{^HnhS{qSVUr2 z6Nt_MKDiTuQpi^W`>)Ox{>l-cv<(ANr) z1-PE1=h=^~8XarEOFj{Tww2#-3ToqumG*sRAMx#oEgrbrl&9p7*iOTu!|)WAChzMX zgPRO44X(H~XMyM6Kd<#7((Yt?Y9x^D`&pF1z~iInjf1FZQFSH+YrD*KSkie{Y*@y? zzyL|6?xc2@yt5DE$gO8@KyfL4;`qK6K=qq6K$(WfQ*y8922S!VTBJ4P+yd@G;j0G= z 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 literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/maxent_example.typ b/packages/preview/phonokit/0.5.3/gallery/maxent_example.typ new file mode 100644 index 0000000000..cb3c68fb90 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/maxent_example.typ @@ -0,0 +1,41 @@ +#import "@preview/phonokit:0.5.3": * +#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.3/gallery/multi-tier_example.png b/packages/preview/phonokit/0.5.3/gallery/multi-tier_example.png new file mode 100644 index 0000000000000000000000000000000000000000..bd598ae53736a62f3f03631312658716c5b9e3fd GIT binary patch literal 4215 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 literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/ot_example.typ b/packages/preview/phonokit/0.5.3/gallery/ot_example.typ new file mode 100644 index 0000000000..fa2591764b --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/ot_example.typ @@ -0,0 +1,17 @@ +#import "@preview/phonokit:0.5.3": * +#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: (1,), +) + diff --git a/packages/preview/phonokit/0.5.3/gallery/syllable_example.png b/packages/preview/phonokit/0.5.3/gallery/syllable_example.png new file mode 100644 index 0000000000000000000000000000000000000000..71d1bd31c40ad05b31db5b26f4bfb7153d639b79 GIT binary patch literal 2192 zcmZ{mX*ipS7RN&^9j)rbt$p{>iArh-L#WaQdBvI=yD_MarPdCuU2G+ml8`D@Ez(-k z(zI%C5UTAcg4V8$Z4mp;sL^}x$2-sSKg)A|=gT=?&LLV`nhFX?2tXhZK@fw+9Y@x& z^`1O-5POsx1p+x~52B6mVIxbUKGu}aT*+%=X~C@$1L>y*bsW&t;xb@4v7r}DYt8*8fHuP7eR24Ddt%35+Jl^_qZQUO&bA7D(fSdQK=-`gzI zXlj~6YR&CMczygMaCR*3s6cNAR#;ca0xbSf)T3$cfN>Ki1i>R$1NZfV>*bZ+i)FWY z$s@u;b9sma6TXsEJX^i7#XTm?!Wg463a9X2ZCFOwn?&@}q`u7F^W{7{@k~^MZ{|Fe5WMkTe zNzR#vsbGnSu_8TrnSQHGNwDifmYK@pu8tY}-J&WEceMET)TtTgmx#U~ujajwI9 zIF`FJylQHj0a2T5v1=@<`-S_?xsmnVi77!W=x*?eae3*{DX@xe@7{n~2_gv6u!(^3 znV00vy1_+Ah~T;G*#q|${ZRw~>P)^raQVF%OV?Qg(rUnsV{Rv9W7Q|^Mqv=IN?mSN zDqK@U{{=auuU3Mr?YK}aTTN!J*D5id+RY^bBA10`C70~^(SH@&VLXDWo>2FR32&MojE76! zp)|ZTn9gw?@i)Ic82D*sof+^}ePK;zv~8DM4yq^z$9j|T(D0>gm~w05MXZvMcY!a|)Jf)^nZk93MF$|Z z^9&F5isaxd6nwfa!3^_AuM@%Kvs@P7ctO3bmdJ%5isT|0XCaU8QZ+z^Q|bh3YK3jU`lVJ$MuBgh3(S z*YZBQ_~(}RotelaYSen=+AJtO0T=kfOzf+V?-}ixAk9AXmM02D*1q8rE%k8lIdC*w z*4Y4_Zl3ldEJ>hEGtGiVxEQ19-mAq243 z!~^M;r~FkDN=m^8{F}NjEx3GI&9#pR`b=AJ)# zh|l7wTFR)GIck}w`hR1{JFEYeFFLH_V~MOTR*ZKUG=vx5+DGQ)3>W!ZpFK0m#pA!T zvZftB;XAJ7ji7|eA!;dJy^B$P&)dql=Fwqz?JB=an>|g@g6RkM?LUuo z)}@APX5TRPZ`!Dx!uXH&Bk%!D30~j6+7Owy#+)>vPpBQV6Wa>(B56~d0dkOnZfQ(p f?>}7e{YN{2QV5^tt6Y+W-(L;T*b@Ehid+2OiE%6c literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/syllable_example.typ b/packages/preview/phonokit/0.5.3/gallery/syllable_example.typ new file mode 100644 index 0000000000..e3c74f7f01 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/syllable_example.typ @@ -0,0 +1,6 @@ +#import "@preview/phonokit:0.5.3": * +#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.3/gallery/vowels_example.png b/packages/preview/phonokit/0.5.3/gallery/vowels_example.png new file mode 100644 index 0000000000000000000000000000000000000000..872951ac82371d67b6e60e5fea137033e8092fed GIT binary patch literal 11514 zcmb8VWmr_-_XmoAfOJTA4T99bNQW>35(7#jDML$_GzdrxAl)q~-Q6&Nv`Dvfcc*vs z`}@DR@9sU%GcRWE6??6<_d0v6^MSupeuIZig^hxOf+sHrQAI&{Qi=QtV4)&MK46Ll zp`dVf%0r~o-RAZemS5g^(sZ00ZIvF~Ydd_KAY;v4mj;pN=CYV>_%r1#C@@JXIIRS| zwA50Jc3gq6o(#vo?AeV4j89mkj1OGrauxSay{{fmF0a0ER33S^>@7y`+hsLBes`A9 z+Sc;~77B<6#exatC*=Rr#Jwb}a%OIB?$<9>7Gk4q zlr|5NH?p!2X*)X}Fy`@OsdXHAexlOC%uIz&X?%P<o=F9lT)mcSJ}63egM=gIMNkLxqscW z%tG~5`c&cXk^FuD9|e-`m5O0A9E<=l zz3-9ZJLev)LsH%?20Tjlh&xDR-qe7W8C=;)}u zsp)oWR`})1;);rQDse+r*_?xt2125uVeOHNwp&&Tr1Fo3QbIIl&kY;k@~Nq*Yinz0 zXlQ2DH~05q85Q;QSIjcVEmB~83S}JR(>moauAgrt{RZEk-TWSsx$q3B@E5`vJwPfV zlwykM|j^s}-KCnVC9I1%qQ;1)y5hxk} zs08Eg?n{d^818np4}EqO82Cqh>j&*B;$MBCznBEkdz<)Z}Q6m6vOh2vA4IElME-*=rYK=BrGhfw6wIWjGGbB)g^u32!}soMEw5! z`||Sg;^LJOv;e+Oc&E+E${NFB38Io{-N(CQadmYiB_$OW7UuCKb9Z!Pl2=tzQ{yCC zn-v}gRDr_*Rf3$=;SmuEF%t_5G`>pj-zT%kn^h+R@@FBRLLMHK?wOC-JiCuy5Mo)> z>M(xkHwkUeFE4)^(mvCe8IPX*0{6GQ!^&K3!_^bL27C5~lV^H2=w;qd$R+$r`6`@M zQZ;L_{X?^}yRx$6LUbrpQoasYjQ5FyaQU^G@i_w0BCAz;C3CJ*osv&NEI=Pq0IA!3 zkPJpL6B0v_&BKG~xqh}$ytQ`&8&y$->Kq9i6{OLAs>l>bxn4$zf;!OyVGy-?0Vpc$iS z!2##@xIVSot6Dpqkd5668U|KsLw~3jPwrT4sdMxIMy#0#g0LdsR|kyR$t)wAGH+tV ztY7O&%bS+FPSbJd%9TtND(0o8h;J?#1{A^paCx3Sr*1EnWGFUeNi>(n=}k*i|AwPA zQ))v8CF~lH=LZC3<&{jPSWT!&0_o?OTxkQgl=*4>jlWxoB!NThIWm<@NLGb?G zg5%4GYSAyzVn$EMm36Sa)~`0B6{pXq9>@<<&Jm>)#(+T=6FXNs3Q%EfZA$+j~>3eu3Q{tJvZ#B@D#1*=Y$k)91( zbywaDd{hU`nBj2XD+CQ8BSS13Heq+C=(vy*eyZ(^-0ZWoH~mziEPe%ZDCp^n1^h!4 z`lqm6sd2hH2{L0C6c6}`|A&Y$>M1`=fNfk0Z@)FkR=W{Rph{!IBlXE=eu-}tDUphF zQ|MuZ_nWHI#f^m%kD?Or54Z4M1dIxXD<$90&${q36EV2StMaB)jeoHM=q>mwqMt6v zP{V~UN(sw-{Wfl5{qq;*0Gj9@tB_^DhMe+ z^?wSYhZTidzINgO+CV{o{8xy!$FHyDBPjOxHdXoj_w>Af$4HgdYBw1Xt5qxHW&cLO z8!h6j!#we^O);Y;`3fIBxZgCy#p%6$mnkp1aFSxlzNC=aQ&lEjZ6p?(G`GyPveTgd zT+1M5oKMlvvI1#^n^;7mz?DRs*U?H&n+|Cq%aapP6C?V%$r;yPjR{^y?e8BlalXH` z!`55Hq{)js8}z9MR!#zpMs6}~(e(%dg>m8*lxard9V!RxV2gFJ{ z7{e65n)4V%6fovbbNXL8cV{Q78{%N|JS^T_>hLsz^;y6L6+?qPW4#E06bNB%8X6TW zr4MvK?_t(tbYZRb8cc5I-`>(?ZJ?4`fUn+Obx1{`YmRHxsqh*M^+i|O9dPdc`03Z! z#53l|s~<-0!zM%Owp%irfg2>l^0*cumXTR``M;1f#rrwkVT1P9CKGzM0~fPAS2mFw zcuvILc)+l%)NE=^^Ib~(ggMPmCUG&cT%x=+ERE&<0_bS{v>SYYI_GA}d~^_k>bVsE z9#qiU@ll8x&=qk3H1TE~{T>`%=9t=>yZKbR#tYUQZp2j>`#DIv`Y_NEX#&Y9VrM>- z*rx2eZkSkw;it63nNTYS-WDYfW@RXuuN1Kh`&FoD+eS?@(VX#6MP{ zfzOak+BzmpMr+N%sx^9B`JYaN#5He-xJ;at;^60K^^(72l!ua%tRgdM9(;5cx}$mM zWRdB^!MVC9?AQS+Sd!MhsbI6+uRYSH$R}$F7OK^`#c>IQlX~#`)R;qu1hyO#dtu}H zGVrbj@^QSEn9R~W4H8tD5n;PyZw9jmt5*8A)Vm>%H$t&jo3_hINdh9QKTXNYAAR%B z9xw+DeSC}xJaZDsA+dwU1W_v-0*59z?Z_;065uh1-u6X8Vb`UL&B)2P zKu+TesIW1?A0k4YknC+Y8bOo`aObSz{7lb(?<=4^S^q5;K92|IAOQxqRE&gVcpc~E zR=&}cN+x=S6(*lcI!V)ZgJG45VKHHmESEJcW_coO;Pd3eySXMemk~Vyr;D=lRUJ(M zfvk|WLRQ|Nz}l<}&7dht7$J&bBEN?%Mf5m(Kw}lArX9uNq!cI7Y z`Jk1NIxmcN#!fEyR?4RDwp&YpwpnFKLN0Zxt%XkcCr zR`T~PMaI~Oy$|Oh_3j_4A28!-So>>t&LX|v^(ygqg zdi&$R^~WmPq4(O&zap$JUPOMdOoL5e|B2PuvB-x4S^gU1JCU4vw9}Rbu>5ApHI1JD zIeZhgx*}EsNMn@#HRBv<2BQ$myc9+M_V0SjD75nwe;1zU()`yBe7(Wfc9abIR3c|>F zV8bm*#rFB!^Vt23G--MbjxX#1DB1buBLn=APxjLdEfO?n`uZieH7K#N>-Et`d_SmT zy*!1%Ud&c8s%zy;P=n8HpC2e-0C9F;LvRac751t}cVJ@OX2PbKlZ^?sKPy*i&rD#z z*ZV3S8UyboanGeIv7=dG^JN}z?_D@BbIVm?^d|l6&U+M`c#PA7ScR5&=VZ9r{P9%U zK_3!&yS`V}-0aCf<8r=;_$=&w6Y1y9AeQn{t4!>aG2(j8d~ z<+wef4x$lW`ASs~?ukdhc_!?tYz}i2KXJPYTt5~YzbKY3Yj5^agAO$vg$GJ62|1Uh zHD8*GpjX>%jf0`Zp+0v($=Hy!Z}jckn6|im(nhe`wzE6>I+k()`nwpdX&W!)yOF#} zHW*Ie)nRS@68A0IvO$}lc**%KB3TRp(m0W1{n6E0DDC8v>-y?Zzzi#W*CgSdL zHduc8aqW#wfAMu7Y&G1QQ`?Gn4`17$#ci#xY#ONrgG!h8BM z-H5MXvpo31<*+MPgZJ?^lCGo?SYckfkc;zgkNGbgKTB;#i-XJF=3 zgE@bxP-Thd&*?>-93!f{ z4hqM)kkxQoNqfR(9rj?t+!pl!fuN4{#BOF=1|J8P{jNyzv1D$5YE8k0CC3t9^hq+G zn@Tj=nVQ#1>O`r^4V#W#cO)XF?^M2!<#j-ez$%N)=_%vb?Y>SaKi>CUlzy3r? z03_WQ;vyVY_J6-iim9f|`jqLxgz4n#iT19w?AE2Jjzl-ALN9xLPm}crI#>f+O?2kw_{kuS7<#5jgf?~^ z1piE#K<;glUAQ6JrR6M1MZX%;e}F*n1ruKX;29hOIuih)!~xs`Mjoc0lUAt#_czro za)5+Z`FBEYIMpJ+F|{!YRJM;ne6E`xuw2DPMTR@Ec>qUl+}I;utnv8vlP!3FW3DIB zm?pTL!l=I~UJzDu!cV#KehkHc2*Y66NfzHa#1TxnTW+T&yQD6@JDN02 zk-s8~bAywxNzw&P# zRJw;$j*gyD5v9(CdUX$I8p+fry%rd4uKVFO$F>3MVFQ45o0+6z7aXD<_p{Gg5)ZFM=s0})&HUGO<{Mf7$vc_J>c-W!w-*Ifb)HoNoURRa0IPrQ&ZFPx}_v6({iV!F>JEVsNd~ma@TIn|Cve z&ydqcK;MN2pnxxiU=lv3MC%>qTMZ?BiPe@H2-DT2=Bcf#;{d!|4?`;K^bC2TI%H0( z&Mac@K2vK&-Z?!zef5KNgd~wmH)s;~smaF3C=m%OH9_Td>vLgJm%7;Tu`$0c>x%NW zwzlK5%gawbKBw#QGR89GVaUzsMgY`tS-~@`T%|5JoNabJ!nk|gA?|tUC0QDPNx*H; zLZSE-`GkTow;aOYF@eF_6Z4$tWp}Ho;9z_%!?rYe(2cgfzW3$+`={T~I31%Q&^YM} zLSx%Op|U6^t_mTxFw)l6)&k}9-Ti%pjn;l+J(Qe;6^Edb;=xpDD%|dTd&_()GwDU( zE@KcwuYh7;m6`ZCa_f5OH#_J;}l^_jh9H$t9X0yV(p1E0ky22cgN8ywEL zmJssLlsj55jco55FB?{FK}&j!G|0%!5s-dZ5rz6Rf4!!gcJ=5%JQxhd7VCq8;8-MB zK$WUvLYxAc;C4T=^9-Ifj^hRXXsfAS(KpaI)MLW#vXH=bMrMi6{*ab0>!g=|&@M;V zSc+bRuX=N+Q%ymRk_R-uieq;hs4HT&|HG~+sGU`l>e(!23 z0*PyrDf1yu3zUey#o={A;0jQ%vaz#U7lTtl@QC7at37^XU#jS6y_?Tj^YVjh88wvC zxwJzl0-8(X7Gfzz`)09&j*I!>?q$RMEEhofX)G3B^sg~6G#wn^G%MCqQA=d?@}!Q^ zg_~Ce2xlN+FGXfw#sn=tWqy7EF`#HL&0Y$i`iTw}ba)Yd$e1@b zt&{qSFlU4mP$a4w4UbC?PI6FtE~Q#cW+#;t5%7pDn~nojPYWlqGhxar57wpxHVN0D z@}+Ea`t{G_3Tq{OS9W0mm%DoVr-MysB^4ahgMo=NscT0W6#fjeQGb(^ye zV%7C<6Q-|>m;P{zk^3u_=r$vh?1$&mJ8#JdT^(EQZgDjDlH~}1m8!UGGw=)!CqD5t1=t{p|Pg%jFbyWG|DnFCH;1o#JpA0#L!C?>WrIk|Cl z=jq|W6fMUQ{|lLxiSm=Yg_V%f%*tZ=*Joz5nWAUJ0ZcRMLrOelGP0TIwqJM*4u$s4 zT=kyeF4AY}56ql%#lV|hrViKD*K4V(t7~f3)zw{IUEO&}5Owywdy!iZoRoqh^7gOH z8Y*gP8N}Yj;nF>VscG2j-r*I6h;y#&b9d!5k893L9?tAHG0+K}^z?KlRDd!R`Wzei z1*YfM#6(;W9c))wVwE=?Xvsv`9^Es zX!$Ztk@ya&;vE4^i>So=7&w+bpNe7LxaGZ}Om1PJrIov)VwkpcfQ}#maFVLeJw|Ss zH)nEwUROhdH36BQ2dR6ogS-7+GmwSF@5`%?C0tkBTbydBtn39&j&E^?P#ll@n7_b+uv?xY-AKT3VPEyPJ&pd}IH_I>q;!6crbbkw(~)66u*&i}LsF z@9oXk%i!VRy*3%cPBx5Q2yY3@Fy> zOs?X)4Le>Eu34?7q(UYIhfog{7zjHe4a)1gLwaETPK;i4q`xODPL6NOK_?CmctGy` z!xWF7t!jC2M>J+u3S45M1T8?U)Hb6p0Z^Z0dV!^=&1n9x>^1t?O-os1LyxErhzbr3 zW;E87;!9q)Vv_XAmuwN*O$BgpD8(h3Ra1VIrNzh8jG5lU3+}d$VztBVoQ9kzW$#j( zJ#p>XagOLSJ|IgKg+0d(^!*NhW9VZs#cI()`$A~}ppX}{Xv>xjVe-@3lfC3iS1xdQ zY_V<)07zJ6QuJCK7>7&l;Oby2e%{w=Yxdut>k-4Y5f7GTeC)RlQz@Tj^o7*S&fFZY zMNxbTM!WqE+p_68x*|cdo5M>$e4u?hnjE|Gd~W*In#T9>@#^~6`J;RU<@ldx6o+1} zd1Drw_^Z6p6yL2^CZ6sv{K3Euu}Qaik~vx*(dT%49!24o>KfNqW!kW4TWVq|kXjl# zox97}8<`%x%CO|Cdx2N|xfwOUO(K(8njXf`E&J0vY1^Gz||8n@Y4ciQ@0aBpsfdY4;uJXEb zqaI}a#0?SK&*1;_-9BYl50knoi#auUs76So@4HD%L+@*kF>m?eW6uXmYmt+!(SM^|?{&Qy_Avn4Fzi0hQ^zC#4dEQHBrw?Xea^ck~l>znUi&_y2c zeyp8Ksr9~R?kS%3$54W-3>-9H`Bb|4FluBt(7eg%de3zN2zlf={sNi%KJyjJWlV-Q zDugtkmtyJ5yXM)ilMHs6stoGX!^0aCm*y{>c!N2qv}ft9Vm}(q@0wBbS29x>T$>wS z=}L|${}T4_n)ST&X`@*^Z*e}myghMW+Pb_PZeuxYdO;1 zJq-k^lm{s&D5%#b36rqex@Fk9d>w{mwhDSR@3w{tF1t5oz4P_X``%D^0KgB~KlDHX z0#sbp-Q64M*jo*RKkaM#`eJM`U>*`G0PW>9wzp<@_5>`&;mA7eL-cs8=i<_pyo11*b25;!fPBIDBK7NFF%b$H$Z82JJ zcN}=UrdFP{=%a;odel9*k=%vv}6UAR7=MUzT1JwPw1q~DbH+#!8_q^KD=iJ0Gf&JM6<7Sv?{qOKR>?K}4 zF6eo>MJG2a-d(WJSqAzMp%dVi5j}+?;@|97=y{R$t2~Rj?_2M1OuWx!=%IlG^t;=Hq>`)hq@pjs9>JYT_2~5H|7nj(#V#><6Z2Qm7^7MxV@PEYAR!yYjB<;J|nv`}FP?oVzA?}!i%WRk5zI6WOuIVB4 z`BErx+qKyJ_wNKPO!bRO=~txIE=Yc8+nU(YrnULJ)827VCxlo(IH_KEIx zH%X3(C+OW;C|6qQQN>O;k|pMcD8gj&v0)8PE8L;5DjT|7>wT0|{A8aZ)eWSgMK9W%`4MitQ)by8g%AO)+M( zF%ZwRh^#{BB)lw`U%h%2)czEQYHDh#5_d#h=k*i^i7w%H(m=241`yA(TaYWanseE&!vGG1fD&N23U)%A)U?(%oTRp zg!=eAoAtx}!% zw!enftd-0%dDtMsOaurLPvkLNYW2K|VO2C6Nh_q?sf_4o=cq zkz*~MT@Bu@{cjy}dQHxj$SSXEIa?-7-96_v6;bP?sHnIJ!z%A`7vmkm!1))1?$rEz zfD*2TQMJq7l%Jm;;tEcs9u7|yMU-H_(H;G6@}Ib0xC8{ns4Bln$nI`$L43%QQ<$xz zX zo5w5NjicFBsT+fddx|P5DxRLrbD4tHGf}aG)8L_W(W~o|HRGM0cW!NGu0{X3FzG4L zBFpC_pmFd0&6!5B8?qujXuY0v+8VK0YI$!ef|}igqNt#7d9*B9$c+PDxw-UQUJ6tD z?|32hL#BGz!!~;X^ma~d?pKL#%O&!4sQ*PiI-*{<4>JQh#PG4>tb@^#H4S^LjgalMl8BI8?DIYR%P;E!+Tr0OBKR}n z9mr$VZ+NiiE0^X%)8UZ8qg2gbpD-e4QmfOnp=bGZQJ z^IeYQM>3*AdO`4ZV%%@p=UjR0lnd{FQ_W>j8?e2h#iP_7nApEP7XOqc?uyA~eAc-2 zuqst&ZtS~oJbWg$T%OryfWx`QA69wR*`RJ>`xmoR$57wjItZ z?cjWxHx8TFv#o%B879}jAtcO6u6`>>^>rf$)bR&u7=(W;o5L7C2?<<^#@E#R^Jhr~~H%}K>|lpYCPHIJ;$wF02N zM#x2c2~1RjYK5>Has^Gpf+(Dj2&xi}F@8knSmvT#%bByHuEucZ-Icgk*5NzP=`nsa zU&O;;l_!uUfD$He<-XM9W-ORs!9v4!5^oC?R@Tn?DZuQA>#G z>d$Az4a|BeHjZoDSS;4fW5;P$JdBk4wh#)2RlXb>BCI}G;B8+vR8upyKvp}*tHtTA zy?dC6jEv0sVk5Hn2N6*xis6B#ZH@wz^Sn>LvU!{)lN+e$tgUPj+x(37jp`))ltB#; zt;EL0j#F)HY($m}V`slQTUx}PF)4kyNYGs8d#8W0V`+EZa#i1MH`FAryrfYhYD=>5 z%|zQcC`o)PsNdHa39M2dU9rFRYCG0BI5<$qfzLFU1bh>=A@K_Y4|9#Yp^IHxzvij* zt!b{&V|VG!SIS7fX?WjKb4`#KT9-~eH9cJbs-Nd-&x6YaPsXpmxdCe^D7O9T!UcMc z;1!pYfI!dQF8~~maQDQaAo{iTk=Cn~EHP+{>k*n`poPUR1SIk~C9ctYqY)Ae7Gz{% zZa!FU$;8PnkG-+8(#$5|D*l)&298_n=qxbsz0+3d_wFwz+34aQ!>vRiKK5uu!p5E` z0{`rr@lt?lVq#)0UICwDKZUY{fQY8s;eD8IJ}p!$vb$IrwC&5&i2gA@UlN`K$X;!r zM}1yCBGCQ{8f*Ep@G~?ltQHPZjt`%oV-ps`A?ng7T{BQ291}P!uvtdqsi!ut-6p!qa zfB^#<35|JViO65m+bjEbwp#ztnO%uY12*dWYGuC7*gOWHjNwiaR*FYJK%JtBBz;L^ zjJz)v7Rp>0{RaE?3v42roU!Mr*Qs8G0<3IBpxGbzS0gdz@6=Zs!Xyh%nxDSr# zdt!{HJuKs5NxVfC4rbrb3tY_Ox_wUbjdq7vCJY;M+-rc9>$#M!&Q z+v8I9X8Mz_eeG3cA2_G70OqRv{ z+YRY*59?0O0>hw|FiT*SYjCRR|G{woe}aJj->~6|oIfZ$+sbpB3~sJSOf!nSj54H5 I`h)-f10|XvV*mgE literal 0 HcmV?d00001 diff --git a/packages/preview/phonokit/0.5.3/gallery/vowels_example.typ b/packages/preview/phonokit/0.5.3/gallery/vowels_example.typ new file mode 100644 index 0000000000..bfe770ad3d --- /dev/null +++ b/packages/preview/phonokit/0.5.3/gallery/vowels_example.typ @@ -0,0 +1,17 @@ +#import "@preview/phonokit:0.5.3": * +#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.3/gallery/word_example.png b/packages/preview/phonokit/0.5.3/gallery/word_example.png new file mode 100644 index 0000000000000000000000000000000000000000..680e587a503512aeb4147bee9f7554c55026a552 GIT binary patch literal 5842 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 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: "+", segment: "l"), // l alveolar lateral — NOTE: lateral feature not modelled + "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 — consonant presets are identical in both models. +// 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) + + // 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 + // coronal may be bool or array; treat array as "active" + let coronal = if type(coronal) == array { coronal } else if coronal or (anterior != false) or distributed { + 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 } + 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 + + // Oral cavity children + let oc-ch = continuant-lbls.map(_feat) + 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 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 } + + 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 } + // [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 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.) +/// - 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 identical in both models. +/// -> content +#let geom( + ph: none, + 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, + scale: 1.0, + position: (), + delinks: (), + segment: none, + prefix: "", + suffix: "", + highlight: (), + timing: auto, +) = { + // Auto-detect length from ph: "iː" or "i:" → long (two timing slots) + // Strip the length mark so the preset lookup finds "i", not "iː" + // Keep the original for use as the segment label fallback. + let _is-long = ph != none and type(ph) == str and (ph.contains("ː") or ph.contains(":")) + let _ph-orig = ph + let ph = if ph != none and type(ph) == str { ph.replace("ː", "").replace(":", "") } 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) } + (..(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, + segment: if segment != none { prefix + segment + suffix } else if prefix != "" or suffix != "" { + prefix + suffix + } else { none }, + ) + } + 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, [root]), + 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))\]] + } else { + [#(_display(entry.label))] + } + 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, + model: "ch", + position: (), + delinks: (), + curved: false, + highlight: (), +) = { + 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 length mark in ph; strip it so preset lookup works. + let ph-raw = spec.at("ph", default: none) + let _is-long = ph-raw != none and type(ph-raw) == str and (ph-raw.contains("ː") or ph-raw.contains(":")) + let spec = if _is-long { + (..spec, ph: ph-raw.replace("ː", "").replace(":", "")) + } 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 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, [root]), + 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))\]] + } else { + [#(_display(entry.label))] + } + 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.3/grids.typ b/packages/preview/phonokit/0.5.3/grids.typ new file mode 100644 index 0000000000..0009963c28 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/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.3/hasse.typ b/packages/preview/phonokit/0.5.3/hasse.typ new file mode 100644 index 0000000000..05555a2158 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/hasse.typ @@ -0,0 +1,460 @@ +// Hasse diagram visualization for OT constraint rankings +// Part of phonokit package + +#import "@preview/cetz:0.4.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.3/intonational.typ b/packages/preview/phonokit/0.5.3/intonational.typ new file mode 100644 index 0000000000..7c0fbd9bb0 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/intonational.typ @@ -0,0 +1,81 @@ +#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: +/// ``` +/// #import "@preview/phonokit:0.5.3": * +/// 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.3/ipa.typ b/packages/preview/phonokit/0.5.3/ipa.typ new file mode 100644 index 0000000000..369fb93d0e --- /dev/null +++ b/packages/preview/phonokit/0.5.3/ipa.typ @@ -0,0 +1,410 @@ +// 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) = { + context { + text(font: phonokit-font.get(), ipa-to-unicode(input)) + } +} diff --git a/packages/preview/phonokit/0.5.3/lib.typ b/packages/preview/phonokit/0.5.3/lib.typ new file mode 100644 index 0000000000..c394131ab8 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/lib.typ @@ -0,0 +1,977 @@ +// 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 "ex.typ": * +#import "intonational.typ": * +#import "geom.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.3": * +/// #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) +/// - 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 + +/// 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: ("σ",)) +/// +/// Returns: CeTZ drawing of syllable structure +/// +/// Example: `#syllable("man", scale: 0.8)` +#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: ("σ", "μ")) +/// +/// Returns: CeTZ drawing of moraic structure +/// +/// Examples: +/// - `#mora("kan")` - CVN syllable with one mora (coda doesn't count) +/// - `#mora("kan", coda: true)` - CVN syllable with two moras (coda counts) +/// - `#mora("ka")` - CV syllable with one mora +#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: ("Σ", "σ")) +/// +/// Returns: CeTZ drawing of foot structure +/// +/// Example: `#foot("man.'tal", scale: 1.2)` +#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: ("Σ", "σ", "μ")) +/// +/// Returns: CeTZ drawing of moraic foot structure +/// +/// Examples: +/// - `#foot-mora("po.'Ral", coda: true)` - Disyllabic foot with moraic structure +/// - `#foot-mora("'po.Ra.ma")` - 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: ("ω", "Σ", "σ")) +/// +/// Returns: CeTZ drawing of prosodic structure +/// +/// Examples: +/// - `#word("(ka.'va).lo")` - One iamb with two syllables, one footless syllable +/// - `#word("('ka.va)", foot: "L")` - Trochee +/// - `#word("ka.va", scale: 0.7)` - Two footless syllables, smaller +#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: ("ω", "Σ", "σ", "μ")) +/// +/// Returns: CeTZ drawing of moraic prosodic structure +/// +/// Examples: +/// - `#word-mora("('po.Ra).ma", coda: true)` - Trochee with unfooted syllable +/// - `#word-mora("('po.Ra).('ma.pa)", foot: "L")` - 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): Vowel symbols to plot, language name, or tipa-style IPA +/// - lang (string, optional): Explicit language parameter (e.g., lang: "spanish") +/// - 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) +/// - 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") +/// - 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) +/// +/// Returns: CeTZ drawing of IPA vowel chart with positioned vowels +/// +/// Examples: +/// - `#vowels("english")` - Plot English vowel inventory +/// - `#vowels("aeiou")` - Plot specific vowels +/// - `#vowels("french", scale: 0.5)` - Smaller French vowel chart +/// - `#vowels("aU", arrows: (("a", "U"),))` - Diphthong /aʊ/ with arrow +/// - `#vowels("english", arrows: (("e", "I"),), arrow-style: "dashed")` - Dashed arrow +/// +/// Note: Diacritics and non-vowel symbols are ignored during plotting +/// +/// 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): Consonant symbols to plot, language name, or tipa-style IPA +/// - lang (string, optional): Explicit language parameter (e.g., lang: "russian") +/// - 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) +/// - cell-width (float): Width of each cell (default: 1.8) +/// - cell-height (float): Height of each cell (default: 1.2) +/// - 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("all")` - Show complete pulmonic consonant chart +/// - `#consonants("english")` - Plot English consonant inventory +/// - `#consonants("ptk")` - Plot specific consonants +/// - `#consonants("T D s z S Z")` - Plot consonants using tipa-style notation +/// - `#consonants("t \\t s d \\t z", affricates: true)` - Show affricates row +/// - `#consonants("spanish", scale: 0.5)` - Smaller Spanish consonant chart +/// +/// 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) +/// - shade (bool): Whether cells should be shaded after fatal violations (default: true) +/// +/// 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: (1,) // <- 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) +/// +/// 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 // Show probability bars (default) +/// ) +/// ``` +#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) +/// +/// Returns: Table showing HG tableau with constraint weights and h(y) harmony column +/// +/// Example: +/// ``` +/// #hg( +/// input: "kraTa", +/// candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Tu]"), +/// 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) +/// +/// Returns: Table with h(y), ε(y) (symbolic), and optional P(y) columns +/// +/// Example: +/// ``` +/// #nhg-demo( +/// input: "kraTa", +/// candidates: ("[kra.Ta]", "[ka.Ta]", "[ka.ra.Tu]"), +/// 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): Random seed for reproducibility (default: 12345) +/// - show-epsilon (bool): Whether to show epsilon column (default: true) +/// - scale (number): Scale factor (default: auto-scales for >6 constraints) +/// +/// 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.Tu]"), +/// 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) +/// +/// 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: 0.8) +/// - 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 (string): Optional baseline text below segments (default: "") +/// - gloss (string): Optional gloss text below baseline (default: "") +/// +/// 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 ((level1, col1), (level2, col2)) tuples (default: ()) +/// - dashed (array): Dashed lines as ((level1, col1), (level2, col2)) tuples (default: ()) +/// - delinks (array): Cross marks on connections as ((level1, col1), (level2, col2)) tuples (default: ()) +/// - arrows (array): Rectangular-path arrows as ((level, col-from), (level, col-to)) tuples (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 (default: ()) +/// - highlight (array): Positions with circle highlight as (level, col) tuples (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) +/// +/// 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: ( +/// ((0, 1), (2, 2)), +/// ), +/// ipa: (3,), +/// arrows: ( +/// ((3, 3), (3, 2)), +/// ((0, 4), (0, 1)), +/// ), +/// 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). +#let multi-tier = multi-tier + +/// Create a numbered linguistic example +/// +/// Generates numbered examples (1), (2), etc. similar to linguex in LaTeX. +/// Use with tables and subex-label() for aligned, labelable sub-examples. +/// +/// Arguments: +/// - body (content): The example content (typically a table) +/// - number-dy (length): Vertical offset for the number (optional; default: 0.4em) +/// - caption (string): Caption for outline (hidden in document; optional) +/// +/// Returns: Numbered example that can be labeled and referenced +/// +/// Example: +/// ``` +/// #ex(caption: "A phonology example")[ +/// #table( +/// columns: 3, // <- where we may specify widths +/// stroke: none, +/// align: left, +/// [#ipa("/anba/")], [#a-r], [#ipa("[amba]")], +/// [#ipa("/anka/")], [#a-r], [#ipa("[aNka]")], +/// ) +/// ] +/// +/// See @ex-phon1. +/// ``` +#let ex = ex + +/// 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, // <- where we may specify widths +/// 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 = subex-label + +/// Display the current example number inside an ex() body. +/// +/// Use as the first-column cell of a 3-column table when no title is provided, +/// so the number shares bottom alignment with sub-example labels and sentence text. +/// +/// ``` +/// #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 = ex-num-label + +/// 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 = 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: 1.5em) +/// - lift (length): gap between stem bottom and text baseline (default: 0.4em) +/// +/// Example: +/// ``` +/// #import "@preview/phonokit:0.5.3": * +/// 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. +/// - 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. +/// - highlight (array): Node names to highlight; all others are dimmed. +/// +/// 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). +/// - 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.3/multi-tier.typ b/packages/preview/phonokit/0.5.3/multi-tier.typ new file mode 100644 index 0000000000..5618d4f08c --- /dev/null +++ b/packages/preview/phonokit/0.5.3/multi-tier.typ @@ -0,0 +1,407 @@ +// Multi-tier phonological representations (CV phonology, skeletal tiers, etc.) +// Part of phonokit package + +#import "@preview/cetz:0.4.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, +) = { + // 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 + } + } + + // 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 tier-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 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 + + // Vertical offsets from level center (following prosody.typ pattern) + let text-above = 0.30 + 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: * + + // 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 float or (level + 1, col) in 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 links { + if link in 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 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 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 6: Delink cross marks (on top of everything) === + for d in 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 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.3/ot.typ b/packages/preview/phonokit/0.5.3/ot.typ new file mode 100644 index 0000000000..f15c8ef7a4 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/ot.typ @@ -0,0 +1,1233 @@ +#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 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) == ",") + ) { + if buf != "" { + parts.push(ipa(buf)) + buf = "" + } + parts.push(clusters.at(i + 1)) + i += 2 + } else if (ch == "_" or ch == "^") and i + 1 < len { + 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 } + if is-sub { parts.push(sub(group)) } else { parts.push(super(group)) } + } else { + let c = clusters.at(i) + i += 1 + if is-sub { parts.push(sub(c)) } else { parts.push(super(c)) } + } + } else if ch == "{" { + 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 } + parts.push(raw) + } else { + buf += ch + i += 1 + } + } + + if buf != "" { parts.push(ipa(buf)) } + if parts.len() == 0 { return [] } + parts.join() +} + +// --- 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.3/prosody.typ b/packages/preview/phonokit/0.5.3/prosody.typ new file mode 100644 index 0000000000..70d6a78879 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/prosody.typ @@ -0,0 +1,2200 @@ +#import "@preview/cetz:0.4.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.3/sonority.typ b/packages/preview/phonokit/0.5.3/sonority.typ new file mode 100644 index 0000000000..9d6dcbd918 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/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.4.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.3/typst.toml b/packages/preview/phonokit/0.5.3/typst.toml new file mode 100644 index 0000000000..d7f4c0e58f --- /dev/null +++ b/packages/preview/phonokit/0.5.3/typst.toml @@ -0,0 +1,23 @@ +[package] +categories = ["utility", "text", "visualization"] +disciplines = ["linguistics"] +name = "phonokit" +version = "0.5.3" +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.3/vowels.typ b/packages/preview/phonokit/0.5.3/vowels.typ new file mode 100644 index 0000000000..ba2cddd807 --- /dev/null +++ b/packages/preview/phonokit/0.5.3/vowels.typ @@ -0,0 +1,476 @@ +#import "@preview/cetz:0.4.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 +) + +// Main vowels function +#let vowels( + vowel-string, // Positional parameter (no default to allow positional args) + 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 + 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) +) = { + // Determine which vowels to plot + let vowels-to-plot = "" + let error-msg = none + + // Check if vowel-string is actually a language name + if 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 != "" { + // Use as manual vowel specification - convert IPA notation to Unicode + // Note: Diacritics and non-vowel symbols will be ignored during plotting + vowels-to-plot = ipa-to-unicode(vowel-string) + } 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 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 all-obstacle-positions = vowel-positions.map(vp => vp.pos) + shifted-obs + + // 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 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)) + } + } + }) +} From 7dc4013b3221700bae00f0e86256a4e80652aab2 Mon Sep 17 00:00:00 2001 From: "Guilherme D. Garcia" Date: Mon, 30 Mar 2026 16:51:34 -0400 Subject: [PATCH 2/6] fix: use permalink for README link --- packages/preview/phonokit/0.5.3/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/preview/phonokit/0.5.3/README.md b/packages/preview/phonokit/0.5.3/README.md index 5185e6cc12..1b0bdd02fe 100644 --- a/packages/preview/phonokit/0.5.3/README.md +++ b/packages/preview/phonokit/0.5.3/README.md @@ -82,7 +82,7 @@ ## Manual 🔍 -Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/main/intro-to-phonokit/intro-to-phonokit.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. +Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/995bf61c514ec280e58f2a29756ea47901b69f26/intro-to-phonokit/intro-to-phonokit.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. ## Fonts From 2bfcc070ca1b0424e53466097c654e822374a21a Mon Sep 17 00:00:00 2001 From: "Guilherme D. Garcia" Date: Mon, 30 Mar 2026 16:53:11 -0400 Subject: [PATCH 3/6] fix: correct slides link path --- packages/preview/phonokit/0.5.3/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/preview/phonokit/0.5.3/README.md b/packages/preview/phonokit/0.5.3/README.md index 1b0bdd02fe..690e3ed884 100644 --- a/packages/preview/phonokit/0.5.3/README.md +++ b/packages/preview/phonokit/0.5.3/README.md @@ -82,7 +82,7 @@ ## Manual 🔍 -Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/995bf61c514ec280e58f2a29756ea47901b69f26/intro-to-phonokit/intro-to-phonokit.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. +Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/995bf61c514ec280e58f2a29756ea47901b69f26/extras/slides.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. ## Fonts From c892c81cc64a5f2189ccb8dc6b175dca84a353fa Mon Sep 17 00:00:00 2001 From: "Guilherme D. Garcia" Date: Mon, 30 Mar 2026 21:17:45 -0400 Subject: [PATCH 4/6] phonokit:0.5.3 --- packages/preview/phonokit/0.5.3/README.md | 2 +- .../preview/phonokit/0.5.3/consonants.typ | 413 +++++++++++------- .../0.5.3/gallery/autoseg_example_1.typ | 2 +- .../0.5.3/gallery/autoseg_example_2.typ | 2 +- .../0.5.3/gallery/autoseg_example_3.typ | 2 +- .../0.5.3/gallery/consonants_example.typ | 2 +- .../phonokit/0.5.3/gallery/feat_geom.typ | 2 +- .../0.5.3/gallery/features_example.typ | 2 +- .../phonokit/0.5.3/gallery/grid_example.typ | 2 +- .../phonokit/0.5.3/gallery/ipa_example.typ | 2 +- .../phonokit/0.5.3/gallery/maxent_example.typ | 2 +- .../0.5.3/gallery/multi-tier_example.typ | 2 +- .../phonokit/0.5.3/gallery/ot_example.typ | 2 +- .../0.5.3/gallery/syllable_example.typ | 2 +- .../phonokit/0.5.3/gallery/vowels_example.typ | 2 +- .../phonokit/0.5.3/gallery/word_example.typ | 2 +- packages/preview/phonokit/0.5.3/lib.typ | 46 +- 17 files changed, 306 insertions(+), 183 deletions(-) diff --git a/packages/preview/phonokit/0.5.3/README.md b/packages/preview/phonokit/0.5.3/README.md index 690e3ed884..29fedc7d2d 100644 --- a/packages/preview/phonokit/0.5.3/README.md +++ b/packages/preview/phonokit/0.5.3/README.md @@ -82,7 +82,7 @@ ## Manual 🔍 -Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/995bf61c514ec280e58f2a29756ea47901b69f26/extras/slides.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. +Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/main/extras/slides.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. ## Fonts diff --git a/packages/preview/phonokit/0.5.3/consonants.typ b/packages/preview/phonokit/0.5.3/consonants.typ index 267e3e7245..02d67791df 100644 --- a/packages/preview/phonokit/0.5.3/consonants.typ +++ b/packages/preview/phonokit/0.5.3/consonants.typ @@ -293,6 +293,9 @@ affricates: false, aspirated: false, abbreviate: false, + simplify: false, + delete-cols: (), + delete-rows: (), cell-width: 1.8, cell-height: 0.9, label-width: 3.5, @@ -386,29 +389,120 @@ } } - // Select label sets based on abbreviation setting - let display-places = if abbreviate { places-short } else { places } - let display-manners = if abbreviate { manners-short } else { manners } + // 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 = () - // Build manners array with optional rows - if aspirated { - let asp-plos-label = if abbreviate { "Plos (asp)" } else { "Plosive (aspirated)" } - display-manners = display-manners.slice(0, 1) + (asp-plos-label,) + display-manners.slice(1) - } - if affricates { - let affr-label = if abbreviate { "Affr" } else { "Affricate" } - let affricate-insert-index = if aspirated { 6 } else { 5 } - display-manners = ( - display-manners.slice(0, affricate-insert-index) + (affr-label,) + display-manners.slice(affricate-insert-index) - ) + // 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 { - let asp-affr-label = if abbreviate { "Affr (asp)" } else { "Affricate (aspirated)" } - display-manners = ( - display-manners.slice(0, affricate-insert-index + 1) - + (asp-affr-label,) - + display-manners.slice(affricate-insert-index + 1) - ) + 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 { places-short } else { places } + let manner-labels = if abbreviate { manners-short } else { 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 { "Plos (asp)" } else { "Plosive (aspirated)" } + 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 { "Affr" } else { "Affricate" } + display-manners.push(affr-label) + affricate-row = current-row + current-row += 1 + + if aspirated { + let asp-affr-label = if abbreviate { "Affr (asp)" } else { "Affricate (aspirated)" } + display-manners.push(asp-affr-label) + aspirated-affricate-row = current-row + current-row += 1 + } } } @@ -422,7 +516,7 @@ let scaled-circle-radius = 0.3 * scale let scaled-line-thickness = 0.8 * scale - let num-cols = places.len() + let num-cols = display-places.len() let num-rows = display-manners.len() canvas({ @@ -448,10 +542,8 @@ content((x, y), context text(size: scaled-label-font-size * 1pt, font: phonokit-font.get(), manner), anchor: "east") } - // Calculate row positions accounting for optional row insertions (needed for grid drawing) - let fricative-row = if aspirated { 5 } else { 4 } - let affricate-row = if affricates { (if aspirated { 6 } else { 5 }) } else { -1 } - let aspirated-affricate-row = if (affricates and aspirated) { 7 } else { -1 } + // 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 @@ -460,12 +552,20 @@ let y1 = total-height / 2 - scaled-label-height let y2 = -total-height / 2 - // Special handling: Remove lines between dental-alveolar (i=3) and alveolar-postalveolar (i=4) - // EXCEPT in the fricative and affricate rows - if i == 3 or i == 4 { - // Draw line segments for each row, but only draw for fricative and affricate rows + // 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-row or row == affricate-row or row == aspirated-affricate-row { + 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)) @@ -486,26 +586,15 @@ } // Gray out impossible consonant cells - // Format: (row, col, half) where half can be "full", "voiced", "voiceless" - // Calculate row positions accounting for optional row insertions - let plosive-row = 0 - let nasal-row = if aspirated { 2 } else { 1 } - let trill-row = if aspirated { 3 } else { 2 } - let tap-row = if aspirated { 4 } else { 3 } - let fricative-row = if aspirated { 5 } else { 4 } - - // Rows after fricative need additional offset for affricate row(s) - let affricate-offset = 0 - if affricates { - affricate-offset += 1 - if aspirated { - affricate-offset += 1 - } - } - - let lat-fric-row = (if aspirated { 6 } else { 5 }) + affricate-offset - let approx-row = (if aspirated { 7 } else { 6 }) + affricate-offset - let lat-approx-row = (if aspirated { 8 } else { 7 }) + affricate-offset + // 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 @@ -535,17 +624,21 @@ ) // Add aspirated plosive impossible cells if that row exists - if aspirated { + if aspirated-plosive-row >= 0 { impossible-cells = ( impossible-cells + ( - (1, 9, "full"), // Pharyngeal aspirated plosive - physiologically impossible - (1, 10, "full"), // Glottal aspirated plosive - physiologically impossible + (aspirated-plosive-row, 9, "full"), + (aspirated-plosive-row, 10, "full"), ) ) } - for (row, col, half) in impossible-cells { + 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 cell-x = scaled-label-width + (col * scaled-cell-width) let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) @@ -576,6 +669,7 @@ stroke: (paint: gray.lighten(20%), thickness: scaled-line-thickness * 1pt), ) } + } } // Collect consonants by cell @@ -670,134 +764,129 @@ // Draw consonants in cells for (key, pair) in cell-consonants { let parts = key.split("-") - let col = int(parts.at(0)) - let manner = int(parts.at(1)) // Original manner of articulation - let row = manner // Display row (will be adjusted) + let orig-col = int(parts.at(0)) + let orig-manner = int(parts.at(1)) - // Adjust row based on inserted optional rows - if aspirated and row >= 1 { - row = row + 1 // Plosive (aspirated) row inserted at 1 - } - if affricates and row >= 5 { - let affricate-offset = if aspirated { 6 } else { 5 } - if row >= affricate-offset { - row = row + 1 // Affricate row inserted - if aspirated { - row = row + 1 // Affricate (aspirated) row also inserted - } - } - } + // 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 cell-x = scaled-label-width + (col * scaled-cell-width) - let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) - let cell-center-x = cell-x + (scaled-cell-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 = 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 = scaled-cell-width * 0.25 + let cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-width / 2) + let cell-center-y = cell-y - (scaled-cell-height / 2) - 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") - } + // 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 = scaled-cell-width * 0.25 - 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") + 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 in row 1 (after regular plosives) - if aspirated { + // Draw aspirated plosives + if aspirated and aspirated-plosive-row >= 0 { for (key, pair) in cell-aspirated-plosives { - let col = int(key) - let row = 1 // Plosive (aspirated) row - - let cell-x = scaled-label-width + (col * scaled-cell-width) - let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) - let cell-center-x = cell-x + (scaled-cell-width / 2) - let cell-center-y = cell-y - (scaled-cell-height / 2) - - // Only left position (voiceless only for aspirated) - let offset = scaled-cell-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") + 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 cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-width / 2) + let cell-center-y = cell-y - (scaled-cell-height / 2) + + let offset = scaled-cell-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 (after fricatives) - if affricates { - let affricate-row = if aspirated { 6 } else { 5 } + // Draw affricates + if affricates and affricate-row >= 0 { for (key, pair) in cell-affricates { - let col = int(key) - let row = affricate-row - - let cell-x = scaled-label-width + (col * scaled-cell-width) - let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) - let cell-center-x = cell-x + (scaled-cell-width / 2) - let cell-center-y = cell-y - (scaled-cell-height / 2) - - // Offset for left/right positioning - let offset = scaled-cell-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") - } + 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 cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-width / 2) + let cell-center-y = cell-y - (scaled-cell-height / 2) + + let offset = scaled-cell-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") + 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 (after regular affricates) - if aspirated and affricates { - let asp-affricate-row = if aspirated { 7 } else { 6 } // After affricate row + // Draw aspirated affricates + if aspirated and affricates and aspirated-affricate-row >= 0 { for (key, pair) in cell-aspirated-affricates { - let col = int(key) - let row = asp-affricate-row - - let cell-x = scaled-label-width + (col * scaled-cell-width) - let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) - let cell-center-x = cell-x + (scaled-cell-width / 2) - let cell-center-y = cell-y - (scaled-cell-height / 2) - - // Only left position (voiceless only for aspirated) - let offset = scaled-cell-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") + 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 cell-x = scaled-label-width + (col * scaled-cell-width) + let cell-y = total-height / 2 - scaled-label-height - (row * scaled-cell-height) + let cell-center-x = cell-x + (scaled-cell-width / 2) + let cell-center-y = cell-y - (scaled-cell-height / 2) + + let offset = scaled-cell-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.3/gallery/autoseg_example_1.typ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.typ index 24cf7a68ea..72a4d5ba98 100644 --- a/packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.typ +++ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_1.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #autoseg( diff --git a/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ index c619114382..f982226100 100644 --- a/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ +++ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_2.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #autoseg( diff --git a/packages/preview/phonokit/0.5.3/gallery/autoseg_example_3.typ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_3.typ index 7f21b99a2a..e455517997 100644 --- a/packages/preview/phonokit/0.5.3/gallery/autoseg_example_3.typ +++ b/packages/preview/phonokit/0.5.3/gallery/autoseg_example_3.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #autoseg( diff --git a/packages/preview/phonokit/0.5.3/gallery/consonants_example.typ b/packages/preview/phonokit/0.5.3/gallery/consonants_example.typ index dce82005dc..75555dcda8 100644 --- a/packages/preview/phonokit/0.5.3/gallery/consonants_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/consonants_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #consonants("portuguese") diff --git a/packages/preview/phonokit/0.5.3/gallery/feat_geom.typ b/packages/preview/phonokit/0.5.3/gallery/feat_geom.typ index d381e9b18b..5db0156c92 100644 --- a/packages/preview/phonokit/0.5.3/gallery/feat_geom.typ +++ b/packages/preview/phonokit/0.5.3/gallery/feat_geom.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #geom-group( diff --git a/packages/preview/phonokit/0.5.3/gallery/features_example.typ b/packages/preview/phonokit/0.5.3/gallery/features_example.typ index 4544a6b200..b3515deff0 100644 --- a/packages/preview/phonokit/0.5.3/gallery/features_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/features_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #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.3/gallery/grid_example.typ b/packages/preview/phonokit/0.5.3/gallery/grid_example.typ index 552f09b8b6..dfe6e2c39d 100644 --- a/packages/preview/phonokit/0.5.3/gallery/grid_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/grid_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #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.3/gallery/ipa_example.typ b/packages/preview/phonokit/0.5.3/gallery/ipa_example.typ index 4bdeeadd8e..bcef7a6bea 100644 --- a/packages/preview/phonokit/0.5.3/gallery/ipa_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/ipa_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #ipa("/DIs \\s Iz \\s @ \\s sEn.t@ns/") diff --git a/packages/preview/phonokit/0.5.3/gallery/maxent_example.typ b/packages/preview/phonokit/0.5.3/gallery/maxent_example.typ index cb3c68fb90..0919bd5927 100644 --- a/packages/preview/phonokit/0.5.3/gallery/maxent_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/maxent_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, left: 1em, right: 2cm)) #maxent( diff --git a/packages/preview/phonokit/0.5.3/gallery/multi-tier_example.typ b/packages/preview/phonokit/0.5.3/gallery/multi-tier_example.typ index fcdb591af8..1b51d1e83d 100644 --- a/packages/preview/phonokit/0.5.3/gallery/multi-tier_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/multi-tier_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #multi-tier( diff --git a/packages/preview/phonokit/0.5.3/gallery/ot_example.typ b/packages/preview/phonokit/0.5.3/gallery/ot_example.typ index fa2591764b..1fc5c49b8d 100644 --- a/packages/preview/phonokit/0.5.3/gallery/ot_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/ot_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) diff --git a/packages/preview/phonokit/0.5.3/gallery/syllable_example.typ b/packages/preview/phonokit/0.5.3/gallery/syllable_example.typ index e3c74f7f01..09ea0c60e8 100644 --- a/packages/preview/phonokit/0.5.3/gallery/syllable_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/syllable_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #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.3/gallery/vowels_example.typ b/packages/preview/phonokit/0.5.3/gallery/vowels_example.typ index bfe770ad3d..a12012fb35 100644 --- a/packages/preview/phonokit/0.5.3/gallery/vowels_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/vowels_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #set page(height: auto, width: auto, margin: (bottom: 1em, top: 1em, x: 1em)) #vowels( diff --git a/packages/preview/phonokit/0.5.3/gallery/word_example.typ b/packages/preview/phonokit/0.5.3/gallery/word_example.typ index df23b8d62c..6a404f26bb 100644 --- a/packages/preview/phonokit/0.5.3/gallery/word_example.typ +++ b/packages/preview/phonokit/0.5.3/gallery/word_example.typ @@ -1,4 +1,4 @@ -#import "@preview/phonokit:0.5.3": * +#import "phonokit/lib.typ": * #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.3/lib.typ b/packages/preview/phonokit/0.5.3/lib.typ index c394131ab8..8c014fa5e7 100644 --- a/packages/preview/phonokit/0.5.3/lib.typ +++ b/packages/preview/phonokit/0.5.3/lib.typ @@ -76,6 +76,8 @@ /// /// 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)) @@ -104,6 +106,7 @@ /// - 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 /// @@ -120,6 +123,7 @@ /// - 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 /// @@ -138,6 +142,7 @@ /// - 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 /// @@ -155,6 +160,7 @@ /// - 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 /// @@ -174,6 +180,7 @@ /// - 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 /// @@ -196,6 +203,7 @@ /// - 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 /// @@ -256,6 +264,7 @@ /// 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 @@ -263,6 +272,8 @@ /// - 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 /// @@ -291,8 +302,11 @@ /// - 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: 1.2) +/// - 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) @@ -330,7 +344,11 @@ /// - 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 ☞ /// @@ -364,6 +382,8 @@ /// - 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 /// @@ -397,6 +417,7 @@ /// - 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 /// @@ -431,6 +452,7 @@ /// - 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 /// @@ -464,9 +486,10 @@ /// - 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): Random seed for reproducibility (default: 12345) +/// - 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) /// @@ -580,14 +603,15 @@ /// - 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: 0.8) +/// - 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 (string): Optional baseline text below segments (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 /// @@ -651,6 +675,7 @@ /// - 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) /// /// Returns: Multi-tier phonological representation /// @@ -692,6 +717,9 @@ /// - body (content): The example content (typically a table) /// - 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 specification for the table layout (default: ()) /// /// Returns: Numbered example that can be labeled and referenced /// @@ -851,8 +879,10 @@ /// 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: 1.5em) -/// - lift (length): gap between stem bottom and text baseline (default: 0.4em) +/// - 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: /// ``` @@ -913,7 +943,11 @@ /// 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 /// From da28fa9665098549e325714e93079ff57835b948 Mon Sep 17 00:00:00 2001 From: "Guilherme D. Garcia" Date: Mon, 30 Mar 2026 21:19:48 -0400 Subject: [PATCH 5/6] =?UTF-8?q?phonokit:0.5.3=20=E2=80=94=20use=20permalin?= =?UTF-8?q?k=20for=20slides.pdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/preview/phonokit/0.5.3/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/preview/phonokit/0.5.3/README.md b/packages/preview/phonokit/0.5.3/README.md index 29fedc7d2d..5eddc11354 100644 --- a/packages/preview/phonokit/0.5.3/README.md +++ b/packages/preview/phonokit/0.5.3/README.md @@ -82,7 +82,7 @@ ## Manual 🔍 -Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/main/extras/slides.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. +Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/56fea22aa5204890c4aa0747792cb9b44471cc2a/extras/slides.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. ## Fonts From f10808901b54754a348dab7345928e5abb8dfa5e Mon Sep 17 00:00:00 2001 From: "Guilherme D. Garcia" Date: Mon, 30 Mar 2026 22:07:45 -0400 Subject: [PATCH 6/6] =?UTF-8?q?phonokit:0.5.3=20=E2=80=94=20remove=20slide?= =?UTF-8?q?s=20link=20from=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/preview/phonokit/0.5.3/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/preview/phonokit/0.5.3/README.md b/packages/preview/phonokit/0.5.3/README.md index 5eddc11354..a05be8ffee 100644 --- a/packages/preview/phonokit/0.5.3/README.md +++ b/packages/preview/phonokit/0.5.3/README.md @@ -82,7 +82,7 @@ ## Manual 🔍 -Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. [**Here**](https://github.com/guilhermegarcia/phonokit/blob/56fea22aa5204890c4aa0747792cb9b44471cc2a/extras/slides.pdf) you can also find a slide presentation done with Typst where most of the package's functions are illustrated. +Download [**manual**](https://doi.org/10.5281/zenodo.18260076) for a comprehensive demonstration of available functions and their usage. ## Fonts