calliope-ts 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +3 -13
  2. package/dist/caesura.d.ts +33 -0
  3. package/dist/caesura.d.ts.map +1 -0
  4. package/dist/caesura.js +202 -0
  5. package/dist/calliope/boundaries.d.ts +19 -0
  6. package/dist/calliope/boundaries.d.ts.map +1 -0
  7. package/dist/calliope/boundaries.js +182 -0
  8. package/dist/calliope/bracketing.d.ts +11 -0
  9. package/dist/calliope/bracketing.d.ts.map +1 -0
  10. package/dist/calliope/bracketing.js +416 -0
  11. package/dist/calliope/deps.d.ts +4 -0
  12. package/dist/calliope/deps.d.ts.map +1 -0
  13. package/dist/calliope/deps.js +181 -0
  14. package/dist/calliope/engine.d.ts +3 -0
  15. package/dist/calliope/engine.d.ts.map +1 -0
  16. package/dist/calliope/engine.js +71 -0
  17. package/dist/calliope/feats.d.ts +9 -0
  18. package/dist/calliope/feats.d.ts.map +1 -0
  19. package/dist/calliope/feats.js +45 -0
  20. package/dist/calliope/names.d.ts +7 -0
  21. package/dist/calliope/names.d.ts.map +1 -0
  22. package/dist/calliope/names.js +42 -0
  23. package/dist/calliope/postag.d.ts +8 -0
  24. package/dist/calliope/postag.d.ts.map +1 -0
  25. package/dist/calliope/postag.js +250 -0
  26. package/dist/calliope/prosodic.d.ts +3 -0
  27. package/dist/calliope/prosodic.d.ts.map +1 -0
  28. package/dist/calliope/prosodic.js +275 -0
  29. package/dist/calliope/relstress.d.ts +4 -0
  30. package/dist/calliope/relstress.d.ts.map +1 -0
  31. package/dist/calliope/relstress.js +688 -0
  32. package/dist/calliope/stressrules.d.ts +28 -0
  33. package/dist/calliope/stressrules.d.ts.map +1 -0
  34. package/dist/calliope/stressrules.js +147 -0
  35. package/dist/calliope/syntax.d.ts +38 -0
  36. package/dist/calliope/syntax.d.ts.map +1 -0
  37. package/dist/calliope/syntax.js +234 -0
  38. package/dist/calliope/udpos.d.ts +13 -0
  39. package/dist/calliope/udpos.d.ts.map +1 -0
  40. package/dist/calliope/udpos.js +156 -0
  41. package/dist/clio/caesura.d.ts +27 -0
  42. package/dist/clio/caesura.d.ts.map +1 -0
  43. package/dist/clio/caesura.js +148 -0
  44. package/dist/clio/depfix.d.ts +13 -0
  45. package/dist/clio/depfix.d.ts.map +1 -0
  46. package/dist/clio/depfix.js +84 -0
  47. package/dist/clio/display.d.ts +32 -0
  48. package/dist/clio/display.d.ts.map +1 -0
  49. package/dist/clio/display.js +976 -0
  50. package/dist/clio/engine.d.ts +3 -0
  51. package/dist/clio/engine.d.ts.map +1 -0
  52. package/dist/clio/engine.js +28 -0
  53. package/dist/clio/parser.d.ts +10 -0
  54. package/dist/clio/parser.d.ts.map +1 -0
  55. package/dist/clio/parser.js +696 -0
  56. package/dist/clio/phonological.d.ts +41 -0
  57. package/dist/clio/phonological.d.ts.map +1 -0
  58. package/dist/clio/phonological.js +788 -0
  59. package/dist/clio/phrasestress.d.ts +6 -0
  60. package/dist/clio/phrasestress.d.ts.map +1 -0
  61. package/dist/clio/phrasestress.js +106 -0
  62. package/dist/clio/pipeline.d.ts +11 -0
  63. package/dist/clio/pipeline.d.ts.map +1 -0
  64. package/dist/clio/pipeline.js +147 -0
  65. package/dist/clio/rhyme.d.ts +65 -0
  66. package/dist/clio/rhyme.d.ts.map +1 -0
  67. package/dist/clio/rhyme.js +761 -0
  68. package/dist/clio/scandroid.d.ts +17 -0
  69. package/dist/clio/scandroid.d.ts.map +1 -0
  70. package/dist/clio/scandroid.js +435 -0
  71. package/dist/clio/scansion.d.ts +46 -0
  72. package/dist/clio/scansion.d.ts.map +1 -0
  73. package/dist/clio/scansion.js +1086 -0
  74. package/dist/clio/semantics.d.ts +44 -0
  75. package/dist/clio/semantics.d.ts.map +1 -0
  76. package/dist/clio/semantics.js +139 -0
  77. package/dist/clio/stress.d.ts +83 -0
  78. package/dist/clio/stress.d.ts.map +1 -0
  79. package/dist/clio/stress.js +1737 -0
  80. package/dist/clio/tagfix.d.ts +6 -0
  81. package/dist/clio/tagfix.d.ts.map +1 -0
  82. package/dist/clio/tagfix.js +101 -0
  83. package/dist/display.d.ts +0 -6
  84. package/dist/display.d.ts.map +1 -1
  85. package/dist/display.js +322 -141
  86. package/dist/engine.d.ts +9 -0
  87. package/dist/engine.d.ts.map +1 -0
  88. package/dist/engine.js +12 -0
  89. package/dist/index.d.ts +4 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +169 -26
  92. package/dist/parser.d.ts +3 -3
  93. package/dist/parser.d.ts.map +1 -1
  94. package/dist/parser.js +320 -563
  95. package/dist/phonological.d.ts +1 -1
  96. package/dist/phonological.d.ts.map +1 -1
  97. package/dist/phonological.js +73 -4
  98. package/dist/phrasestress.d.ts +6 -0
  99. package/dist/phrasestress.d.ts.map +1 -0
  100. package/dist/phrasestress.js +106 -0
  101. package/dist/rhyme.d.ts +40 -1
  102. package/dist/rhyme.d.ts.map +1 -1
  103. package/dist/rhyme.js +435 -7
  104. package/dist/scansion.d.ts +9 -0
  105. package/dist/scansion.d.ts.map +1 -1
  106. package/dist/scansion.js +145 -18
  107. package/dist/semantics.d.ts +44 -0
  108. package/dist/semantics.d.ts.map +1 -0
  109. package/dist/semantics.js +139 -0
  110. package/dist/stress.d.ts +127 -4
  111. package/dist/stress.d.ts.map +1 -1
  112. package/dist/stress.js +834 -61
  113. package/dist/types.d.ts +15 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/package.json +5 -3
  116. package/src/caesura.ts +201 -0
  117. package/src/calliope/boundaries.ts +190 -0
  118. package/src/calliope/bracketing.ts +390 -0
  119. package/src/calliope/deps.ts +160 -0
  120. package/src/calliope/engine.ts +77 -0
  121. package/src/calliope/feats.ts +46 -0
  122. package/src/calliope/names.ts +44 -0
  123. package/src/calliope/postag.ts +253 -0
  124. package/src/calliope/prosodic.ts +262 -0
  125. package/src/calliope/relstress.ts +645 -0
  126. package/src/calliope/stressrules.ts +147 -0
  127. package/src/calliope/syntax.ts +218 -0
  128. package/src/calliope/udpos.ts +152 -0
  129. package/src/clio/caesura.ts +145 -0
  130. package/src/clio/depfix.ts +88 -0
  131. package/src/clio/display.ts +1042 -0
  132. package/src/clio/engine.ts +38 -0
  133. package/src/clio/parser.ts +845 -0
  134. package/src/clio/phonological.ts +849 -0
  135. package/src/clio/phrasestress.ts +108 -0
  136. package/src/clio/pipeline.ts +154 -0
  137. package/src/clio/rhyme.ts +740 -0
  138. package/src/clio/scandroid.ts +434 -0
  139. package/src/clio/scansion.ts +1130 -0
  140. package/src/clio/semantics.ts +134 -0
  141. package/src/clio/stress.ts +1731 -0
  142. package/src/clio/tagfix.ts +104 -0
  143. package/src/display.ts +321 -137
  144. package/src/engine.ts +22 -0
  145. package/src/index.ts +176 -32
  146. package/src/parser.ts +346 -682
  147. package/src/phonological.ts +72 -4
  148. package/src/phrasestress.ts +108 -0
  149. package/src/rhyme.ts +428 -8
  150. package/src/scansion.ts +136 -15
  151. package/src/semantics.ts +134 -0
  152. package/src/stress.ts +794 -64
  153. package/src/types.ts +39 -4
  154. package/tests/DataForHayesLinesOnly.txt +364 -0
  155. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  156. package/tests/basic.test.ts +443 -37
  157. package/tests/bench-hayes.mjs +72 -0
  158. package/tests/wagner-stress.test.ts +188 -0
  159. package/vitest.config.ts +0 -15
@@ -0,0 +1,976 @@
1
+ // display.ts — Unified, integrated CLI display for Calliope TS
2
+ // Shows ALL information layers in a single comprehensive view
3
+ import chalk from 'chalk';
4
+ import { isPunctuation } from './parser.js';
5
+ import { syllabifyWord, syllableVowelLengths } from './phonological.js';
6
+ import { computeCaesurae } from './caesura.js';
7
+ import { summarizePoem, analyzePhonopoetics } from './rhyme.js';
8
+ // ═══════════════════════════════════════════════════════════════════════
9
+ // COLOUR SYSTEM — Conceptually motivated palettes
10
+ // ═══════════════════════════════════════════════════════════════════════
11
+ // Lexical stress (numeric 0–3): blue→magenta→red→bold red
12
+ // Represents phonetic prominence from dictionary
13
+ const LEX0 = (s) => chalk.blue(s);
14
+ const LEX1 = (s) => chalk.magenta(s);
15
+ const LEX2 = (s) => chalk.red(s);
16
+ const LEX3 = (s) => chalk.red.bold(s);
17
+ function lexColour(val) {
18
+ if (val === 0)
19
+ return LEX0;
20
+ if (val === 1)
21
+ return LEX1;
22
+ if (val === 2)
23
+ return LEX2;
24
+ return LEX3;
25
+ }
26
+ // Relative / phonological stress (x w n m s): light-grey→cyan→green→yellow→bright red
27
+ // Represents phonological prominence after phrasal rules. `x` = zero-provision
28
+ // (maximally-reduced clitic), one rung below the stressless-overt floor `w`.
29
+ // Light grey (not dark blue) so it stays legible on a black terminal.
30
+ const REL_X = (s) => chalk.hex('#b0b0b0')(s);
31
+ const REL_W = (s) => chalk.cyan(s);
32
+ const REL_N = (s) => chalk.green(s);
33
+ const REL_M = (s) => chalk.yellow(s);
34
+ const REL_S = (s) => chalk.redBright(s);
35
+ function relColour(rel) {
36
+ if (rel === 'x')
37
+ return REL_X;
38
+ if (rel === 'w')
39
+ return REL_W;
40
+ if (rel === 'n')
41
+ return REL_N;
42
+ if (rel === 'm')
43
+ return REL_M;
44
+ if (rel === 's')
45
+ return REL_S;
46
+ return chalk.gray.dim;
47
+ }
48
+ // Phrasal boundaries — distinct palette (purple/blue/green)
49
+ const B_CP = chalk.magentaBright;
50
+ const B_PP = chalk.blueBright;
51
+ const B_IU = chalk.greenBright;
52
+ const B_CAESURA = chalk.whiteBright.bold; // hard caesura (overt: punctuation / IU edge)
53
+ const B_CAESURA_SOFT = chalk.cyan.dim; // inferred caesura (phonological-phrase pause)
54
+ const B_FOOT = chalk.gray;
55
+ const B_SILENT = chalk.gray.dim;
56
+ // Word roles
57
+ const W_CONTENT = chalk.white;
58
+ const W_FUNCTION = chalk.gray;
59
+ const W_DEP = chalk.italic.dim;
60
+ // Section headers
61
+ const H1 = chalk.bold.underline;
62
+ const H2 = chalk.bold;
63
+ const HR = '─'.repeat(70);
64
+ const HR_THIN = '─'.repeat(50);
65
+ function buildColSyls(words, ius) {
66
+ const result = [];
67
+ let globalIdx = 0;
68
+ for (let iuIdx = 0; iuIdx < ius.length; iuIdx++) {
69
+ const iu = ius[iuIdx];
70
+ for (let ppIdx = 0; ppIdx < iu.phonologicalPhrases.length; ppIdx++) {
71
+ const pp = iu.phonologicalPhrases[ppIdx];
72
+ for (let cpIdx = 0; cpIdx < pp.cliticGroups.length; cpIdx++) {
73
+ const cg = pp.cliticGroups[cpIdx];
74
+ for (let tIdx = 0; tIdx < cg.tokens.length; tIdx++) {
75
+ const w = cg.tokens[tIdx];
76
+ if (isPunctuation(w.lexicalClass))
77
+ continue;
78
+ const dep = w.dependency;
79
+ const sylCount = w.syllables.length;
80
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
81
+ for (let si = 0; si < sylCount; si++) {
82
+ const syl = w.syllables[si];
83
+ const lex = syl.lexicalStress ?? syl.stress;
84
+ const rel = syl.relativeStress ?? 'w';
85
+ result.push({
86
+ chunk: chunks[si] || w.word,
87
+ word: w.word,
88
+ pos: w.lexicalClass,
89
+ isContent: w.isContent,
90
+ lexStress: lex,
91
+ relStress: rel,
92
+ cpId: cpIdx,
93
+ ppId: ppIdx,
94
+ iuId: iuIdx,
95
+ isFirstInWord: si === 0,
96
+ isFirstInCP: tIdx === 0 && si === 0,
97
+ isFirstInPP: cpIdx === 0 && tIdx === 0 && si === 0,
98
+ isFirstInIU: ppIdx === 0 && cpIdx === 0 && tIdx === 0 && si === 0,
99
+ isLastInCP: tIdx === cg.tokens.length - 1 && si === sylCount - 1,
100
+ isLastInPP: cpIdx === pp.cliticGroups.length - 1 &&
101
+ tIdx === cg.tokens.length - 1 && si === sylCount - 1,
102
+ isLastInIU: ppIdx === iu.phonologicalPhrases.length - 1 &&
103
+ cpIdx === pp.cliticGroups.length - 1 &&
104
+ tIdx === cg.tokens.length - 1 && si === sylCount - 1,
105
+ depLabel: dep?.dependentType ?? '',
106
+ govWord: dep?.governor?.word ?? '',
107
+ globalIdx: globalIdx++,
108
+ wordRef: w,
109
+ });
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ return result;
116
+ }
117
+ // ═══════════════════════════════════════════════════════════════════════
118
+ // UNIFIED DISPLAY — All layers integrated
119
+ // ═══════════════════════════════════════════════════════════════════════
120
+ export function renderUnifiedDisplay(result, rawLine) {
121
+ const words = result.sentence.words;
122
+ const ius = result.phonologicalHierarchy;
123
+ const detail = result.phonologicalScansion;
124
+ const colSyls = buildColSyls(words, ius);
125
+ const lines = [];
126
+ lines.push('');
127
+ lines.push(HR);
128
+ // ── Layer 1: Original text with word-role coloring ──────────────
129
+ lines.push(H1('Original Text'));
130
+ lines.push('');
131
+ const textParts = [];
132
+ for (const w of words) {
133
+ if (isPunctuation(w.lexicalClass))
134
+ continue;
135
+ const wc = w.isContent ? W_CONTENT : W_FUNCTION;
136
+ const posTag = W_DEP('(' + w.lexicalClass + ')');
137
+ textParts.push(wc(w.word) + posTag);
138
+ }
139
+ lines.push(' ' + textParts.join(' '));
140
+ lines.push('');
141
+ // ── Layer 2: Phrasal structure tree ─────────────────────────────
142
+ lines.push(H1('Phrasal Structure') + ' ' + B_IU('IU') + ' → ' + B_PP('PP') + ' → ' + B_CP('CP'));
143
+ // Mini-legend: only the POS tags & dependencies that occur in THIS line.
144
+ lines.push(...renderLineGlossary(words));
145
+ lines.push('');
146
+ const wordSet = new Set();
147
+ const dedupedEntries = [];
148
+ for (const cs of colSyls) {
149
+ if (!wordSet.has(cs.wordRef)) {
150
+ wordSet.add(cs.wordRef);
151
+ dedupedEntries.push({ col: cs, word: cs.wordRef });
152
+ }
153
+ }
154
+ let lastIU = -1, lastPP = -1;
155
+ for (const we of dedupedEntries) {
156
+ const cs = we.col;
157
+ if (cs.iuId !== lastIU) {
158
+ lines.push(B_IU(' IU' + (cs.iuId + 1)));
159
+ lastIU = cs.iuId;
160
+ lastPP = -1;
161
+ }
162
+ if (cs.ppId !== lastPP) {
163
+ lines.push(B_PP(' PP' + (cs.ppId + 1) + ': {'));
164
+ lastPP = cs.ppId;
165
+ }
166
+ const dep = we.word.dependency;
167
+ const depInfo = dep && dep.governorIndex > 0
168
+ ? W_DEP(' ←' + dep.dependentType)
169
+ : '';
170
+ const wordLabel = W_CONTENT(we.word.word) + W_DEP('(' + we.word.lexicalClass + ')');
171
+ lines.push(' ' + B_CP('[') + wordLabel + depInfo + B_CP(']'));
172
+ }
173
+ lines.push(' ' + B_PP('}'));
174
+ lines.push('');
175
+ // ── Layer 3: Lexical stress (numeric) ───────────────────────────
176
+ lines.push(H1('Lexical Stress') + ' ' + LEX0('0') + LEX1('1') + LEX2('2') + LEX3('3') + ' (0=none 1=secondary 2=primary 3+=boosted)');
177
+ lines.push('');
178
+ const lexParts = [];
179
+ for (const cs of colSyls) {
180
+ if (cs.isFirstInWord && cs.globalIdx > 0)
181
+ lexParts.push(' ');
182
+ lexParts.push(lexColour(cs.lexStress)(String(cs.lexStress)));
183
+ }
184
+ lines.push(' ' + lexParts.join(''));
185
+ lines.push('');
186
+ // ── Layer 3b: Phrase stress (McAleese's integer nuclear ramp) ───
187
+ // The phase that used to be skipped: every word starts at 1; the Compound
188
+ // Stress Rule pins a compound's subordinate; the Nuclear Stress Rule ramps
189
+ // the principal stress of each stressed word L→R to the phrase's peak.
190
+ lines.push(H1('Phrase Stress') + ' ' + chalk.dim('1 = floor (function / compound-subordinate) → N = nuclear peak'));
191
+ lines.push('');
192
+ const phrParts = [];
193
+ for (const cs of colSyls) {
194
+ if (cs.isFirstInWord && cs.globalIdx > 0)
195
+ phrParts.push(' ');
196
+ if (cs.isFirstInWord) {
197
+ const ps = cs.wordRef.phraseStress || 0;
198
+ phrParts.push((ps <= 1 ? chalk.dim : chalk.cyanBright)(String(ps)));
199
+ }
200
+ else {
201
+ phrParts.push(' '); // continuation syllable — keep word-start alignment
202
+ }
203
+ }
204
+ lines.push(' ' + phrParts.join(''));
205
+ lines.push('');
206
+ // ── Layer 4: Relative stress (w/n/m/s) ──────────────────────────
207
+ lines.push(H1('Relative Stress') + ' ' + REL_X('x') + REL_W('w') + REL_N('n') + REL_M('m') + REL_S('s') + ' (zero‑provision→weak→low→moderate→strong)');
208
+ lines.push('');
209
+ const relParts = [];
210
+ for (const cs of colSyls) {
211
+ if (cs.isFirstInWord && cs.globalIdx > 0)
212
+ relParts.push(' ');
213
+ relParts.push(relColour(cs.relStress)(cs.relStress));
214
+ }
215
+ lines.push(' ' + relParts.join(''));
216
+ lines.push('');
217
+ // ── Layer 5: Phonological bracketing ────────────────────────────
218
+ lines.push(H1('Phonological Bracketing') + ' ' + B_CP('[]') + ' CP ' + B_PP('{}') + ' PP ' + B_IU('<>') + ' IU');
219
+ lines.push('');
220
+ const sylParts = [];
221
+ let iuOpen = false, ppOpen = false, cpOpen = false;
222
+ for (const cs of colSyls) {
223
+ if (cs.isFirstInIU && !iuOpen) {
224
+ sylParts.push(B_IU('<'));
225
+ iuOpen = true;
226
+ }
227
+ if (cs.isFirstInPP && !ppOpen) {
228
+ sylParts.push(B_PP('{'));
229
+ ppOpen = true;
230
+ }
231
+ if (cs.isFirstInCP && !cpOpen) {
232
+ sylParts.push(B_CP('['));
233
+ cpOpen = true;
234
+ }
235
+ if (cs.isFirstInWord && cs.globalIdx > 0)
236
+ sylParts.push(' ');
237
+ sylParts.push(relColour(cs.relStress)(cs.chunk));
238
+ if (cs.isLastInCP && cpOpen) {
239
+ sylParts.push(B_CP(']'));
240
+ cpOpen = false;
241
+ }
242
+ if (cs.isLastInPP && ppOpen) {
243
+ sylParts.push(B_PP('}'));
244
+ ppOpen = false;
245
+ }
246
+ if (cs.isLastInIU && iuOpen) {
247
+ sylParts.push(B_IU('>'));
248
+ iuOpen = false;
249
+ }
250
+ }
251
+ lines.push(' ' + sylParts.join(''));
252
+ lines.push('');
253
+ // ── Layer 6: Metrical scansion with caesura ─────────────────────
254
+ lines.push(H1('Metrical Scansion'));
255
+ lines.push('');
256
+ const scansion = detail.scansion;
257
+ const feetRaw = scansion.split('|');
258
+ const linearSyls = [];
259
+ for (const w of words) {
260
+ if (isPunctuation(w.lexicalClass))
261
+ continue;
262
+ const sylCount = w.syllables.length;
263
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
264
+ for (let si = 0; si < sylCount; si++) {
265
+ linearSyls.push({
266
+ chunk: chunks[si] || w.word,
267
+ relStress: w.syllables[si].relativeStress ?? 'w',
268
+ wordRef: w,
269
+ });
270
+ }
271
+ }
272
+ // Caesurae: hard at IU/punctuation breaks, plus one inferred (soft) medial
273
+ // caesura at a phonological-phrase boundary for a punctuation-free line.
274
+ const caesurae = computeCaesurae(words, ius, scansion);
275
+ function isSyllableChar(ch) {
276
+ return 'xXwWnNmMsS'.includes(ch);
277
+ }
278
+ let sylIdx = 0;
279
+ const footDisplays = [];
280
+ let prevWordRef = null;
281
+ for (const rawFoot of feetRaw) {
282
+ let footOut = '';
283
+ for (const ch of rawFoot) {
284
+ if (ch === '-') {
285
+ footOut += B_SILENT('·');
286
+ continue;
287
+ }
288
+ if (!isSyllableChar(ch))
289
+ continue;
290
+ if (sylIdx < linearSyls.length) {
291
+ const ls = linearSyls[sylIdx];
292
+ if (prevWordRef !== null && ls.wordRef !== prevWordRef)
293
+ footOut += ' ';
294
+ footOut += relColour(ls.relStress)(ls.chunk);
295
+ prevWordRef = ls.wordRef;
296
+ sylIdx++;
297
+ }
298
+ }
299
+ const ck = caesurae.get(sylIdx);
300
+ if (ck)
301
+ footOut += ' ' + caesuraGlyph(ck);
302
+ footDisplays.push(footOut);
303
+ }
304
+ lines.push(' ' + H2('Feet: ') + footDisplays.join(B_FOOT(' | ')));
305
+ const stressDisplays = [];
306
+ let rIdx = 0;
307
+ for (const rawFoot of feetRaw) {
308
+ let s = '';
309
+ for (const ch of rawFoot) {
310
+ if (ch === '-') {
311
+ s += B_SILENT('_');
312
+ continue;
313
+ }
314
+ if (!isSyllableChar(ch))
315
+ continue;
316
+ if (rIdx < linearSyls.length) {
317
+ s += relColour(linearSyls[rIdx].relStress)(linearSyls[rIdx].relStress);
318
+ rIdx++;
319
+ }
320
+ }
321
+ const ck2 = caesurae.get(rIdx);
322
+ if (ck2)
323
+ s += ' ' + caesuraGlyph(ck2);
324
+ stressDisplays.push(s);
325
+ }
326
+ lines.push(' ' + H2('Stress: ') + stressDisplays.join(B_FOOT(' | ')));
327
+ lines.push('');
328
+ // ── Layer 7: Dependencies ───────────────────────────────────────
329
+ lines.push(H1('Dependencies'));
330
+ lines.push('');
331
+ for (const we of dedupedEntries) {
332
+ const w = we.word;
333
+ if (isPunctuation(w.lexicalClass))
334
+ continue;
335
+ const dep = w.dependency;
336
+ if (!dep)
337
+ continue;
338
+ if (dep.governorIndex === 0 || dep.dependentType === 'root') {
339
+ lines.push(' ' + B_IU('ROOT →') + ' ' + W_CONTENT(w.word));
340
+ }
341
+ else {
342
+ lines.push(' ' +
343
+ W_FUNCTION(w.word.padEnd(12)) +
344
+ W_DEP('←' + dep.dependentType + '← ') +
345
+ W_CONTENT(dep.governorName));
346
+ }
347
+ }
348
+ lines.push('');
349
+ // ── Layer 8: Summary ────────────────────────────────────────────
350
+ lines.push(H1('Summary'));
351
+ lines.push('');
352
+ lines.push(' ' + H2('Meter: ') + detail.meter + chalk.dim(' (' + detail.footCount + ' feet)') + consensusNote(detail) + rhythmNoteStr(detail));
353
+ const rank = formatRanking(detail.ranking);
354
+ lines.push(' ' + H2('Fit: ') + chalk.yellow(detail.certainty + '%') + (rank ? ' ' + rank : ''));
355
+ lines.push(' ' + H2('Scansion: ') + detail.scansion);
356
+ lines.push(' ' + H2('Summary: ') + detail.summary);
357
+ lines.push('');
358
+ // ── Layer 9: Scandroid comparison (if available) ────────────────
359
+ if (result.scandroidCorral || result.scandroidMaximise) {
360
+ lines.push(H1('Scandroid Comparison'));
361
+ lines.push('');
362
+ if (result.scandroidCorral) {
363
+ lines.push(' ' + H2('CW: ') + result.scandroidCorral.scansion);
364
+ }
365
+ if (result.scandroidMaximise) {
366
+ lines.push(' ' + H2('MN: ') + result.scandroidMaximise.scansion);
367
+ }
368
+ lines.push('');
369
+ }
370
+ // ── Layer 10: Reading projection (stress gradient over the input) ──
371
+ // A reading-view-style colourisation of the verbatim input, so the finalised
372
+ // analysis always shows "something that looks like the input". Falls back to
373
+ // the parsed surface forms when the raw line wasn't supplied.
374
+ lines.push(H1('Reading Projection') + chalk.dim(' — stress gradient over the input'));
375
+ lines.push('');
376
+ const projection = rawLine && rawLine.trim()
377
+ ? projectStressOntoLine(rawLine, words)
378
+ : words.filter(w => !isPunctuation(w.lexicalClass)).map(w => colourToken(w.word, w)).join(' ');
379
+ lines.push(' ' + projection);
380
+ lines.push('');
381
+ // ── Layer 11: Legend ────────────────────────────────────────────
382
+ lines.push(HR_THIN);
383
+ lines.push(renderLegend());
384
+ lines.push(HR);
385
+ return lines.join('\n');
386
+ }
387
+ // ═══════════════════════════════════════════════════════════════════════
388
+ // LEGEND
389
+ // ═══════════════════════════════════════════════════════════════════════
390
+ export function renderLegend() {
391
+ return [
392
+ H2('Legend'),
393
+ ' ' + B_CP('[]') + ' Clitic Phrase ' + B_PP('{}') + ' Phonological Phrase ' + B_IU('<>') + ' Intonational Unit',
394
+ ' ' + LEX0('0') + LEX1('1') + LEX2('2') + LEX3('3') + ' Lexical stress (0=none 1=secondary 2=primary 3+=boosted)',
395
+ ' ' + REL_X('x') + REL_W('w') + REL_N('n') + REL_M('m') + REL_S('s') + ' Relative stress (zero‑provision→weak→low→moderate→strong)',
396
+ ' ' + W_CONTENT('content') + ' ' + W_FUNCTION('function') + ' Word class',
397
+ ' ' + B_CAESURA('‖') + ' Caesura (phrasal break) ' + B_CAESURA_SOFT('¦') + ' Inferred caesura ' +
398
+ B_FOOT('|') + ' Foot boundary ' + B_SILENT('·') + ' Silent beat',
399
+ ].join('\n');
400
+ }
401
+ // Penn Treebank POS tags that FinNLP's en-pos tagger assigns to words (the tags
402
+ // shown as "(TAG)" in the Original Text / Phrasal Structure layers). Pure
403
+ // punctuation/symbol/list tags (, : . ( ) # $ SYM LS) are intentionally omitted —
404
+ // they label no lexical word in the prosodic analysis. Grouped by word class so
405
+ // the distinctions read clearly.
406
+ const POS_GROUPS = [
407
+ { label: 'Nouns', tags: [
408
+ ['NN', { name: 'noun, singular or mass', eg: 'table, water, dust' }],
409
+ ['NNS', { name: 'noun, plural', eg: 'tables, waters' }],
410
+ ['NNP', { name: 'proper noun, singular', eg: 'London, Pound' }],
411
+ ['NNPS', { name: 'proper noun, plural', eg: 'Americans, Smiths' }],
412
+ ] },
413
+ { label: 'Verbs & modals', tags: [
414
+ ['VB', { name: 'verb, base form', eg: 'throw, eat, run' }],
415
+ ['VBD', { name: 'verb, past tense', eg: 'threw, ate, ran' }],
416
+ ['VBG', { name: 'verb, gerund / present part.', eg: 'throwing, eating' }],
417
+ ['VBN', { name: 'verb, past participle', eg: 'thrown, eaten' }],
418
+ ['VBP', { name: 'verb, non-3rd-sg present', eg: '(I) throw, run' }],
419
+ ['VBZ', { name: 'verb, 3rd-sg present', eg: 'throws, runs' }],
420
+ ['MD', { name: 'modal', eg: 'can, will, must' }],
421
+ ] },
422
+ { label: 'Adjectives & adverbs', tags: [
423
+ ['JJ', { name: 'adjective', eg: 'green, large' }],
424
+ ['JJR', { name: 'adjective, comparative', eg: 'greener, larger' }],
425
+ ['JJS', { name: 'adjective, superlative', eg: 'greenest, largest' }],
426
+ ['RB', { name: 'adverb', eg: 'quickly, very' }],
427
+ ['RBR', { name: 'adverb, comparative', eg: 'faster, better' }],
428
+ ['RBS', { name: 'adverb, superlative', eg: 'fastest, best' }],
429
+ ] },
430
+ { label: 'Determiners & numbers', tags: [
431
+ ['DT', { name: 'determiner', eg: 'the, a, an' }],
432
+ ['PDT', { name: 'predeterminer', eg: 'all (the books), both' }],
433
+ ['CD', { name: 'cardinal number', eg: 'one, two, three' }],
434
+ ] },
435
+ { label: 'Pronouns', tags: [
436
+ ['PRP', { name: 'personal pronoun', eg: 'I, you, he, they' }],
437
+ ['PRP$', { name: 'possessive pronoun', eg: 'my, your, their' }],
438
+ ] },
439
+ { label: 'Wh-words', tags: [
440
+ ['WDT', { name: 'wh-determiner', eg: 'which, that' }],
441
+ ['WP', { name: 'wh-pronoun', eg: 'who, what' }],
442
+ ['WP$', { name: 'possessive wh-pronoun', eg: 'whose' }],
443
+ ['WRB', { name: 'wh-adverb', eg: 'when, where, why' }],
444
+ ] },
445
+ { label: 'Function & other', tags: [
446
+ ['IN', { name: 'preposition / subord. conj.', eg: 'in, of, although' }],
447
+ ['TO', { name: 'infinitival "to"', eg: 'to (go)' }],
448
+ ['CC', { name: 'coordinating conjunction', eg: 'and, but, or' }],
449
+ ['RP', { name: 'particle', eg: 'up (give up), off' }],
450
+ ['EX', { name: 'existential "there"', eg: 'there (is)' }],
451
+ ['POS', { name: 'possessive ending', eg: "'s, '" }],
452
+ ['UH', { name: 'interjection', eg: 'oh, wow, ah' }],
453
+ ['FW', { name: 'foreign word', eg: 'je ne sais quoi' }],
454
+ ] },
455
+ ];
456
+ // Grammatical dependency relations AS THE TOOLKIT DISPLAYS THEM (the lowercase
457
+ // labels shown as "←label", after FinNLP's relations are mapped to the
458
+ // Antelope/Universal-Dependencies scheme in parser.ts). Grouped by role.
459
+ const DEP_GROUPS = [
460
+ { label: 'Core arguments', deps: [
461
+ ['nsubj', { name: 'nominal subject', eg: 'I like you' }],
462
+ ['nsubjpass', { name: 'nominal subject (passive)', eg: 'I was given a chance' }],
463
+ ['dobj', { name: 'direct object', eg: 'I like you' }],
464
+ ['iobj', { name: 'indirect object', eg: 'she gave me a book' }],
465
+ ['pobj', { name: 'object of preposition (oblique)', eg: 'to the children' }],
466
+ ] },
467
+ { label: 'Clausal relations', deps: [
468
+ ['ccomp', { name: 'clausal complement', eg: 'ordered to dig' }],
469
+ ['xcomp', { name: 'open clausal complement', eg: 'told us to dig' }],
470
+ ['advcl', { name: 'adverbial clause modifier', eg: 'walking as rain fell' }],
471
+ ['acl', { name: 'clausal modifier of a noun', eg: 'the man you love' }],
472
+ ] },
473
+ { label: 'Modifiers', deps: [
474
+ ['amod', { name: 'adjectival modifier', eg: 'good to him' }],
475
+ ['advmod', { name: 'adverbial modifier', eg: 'genetically modified' }],
476
+ ['nummod', { name: 'numeric modifier', eg: '2 eggs' }],
477
+ ['nmod', { name: 'nominal modifier', eg: 'news of the market' }],
478
+ ['poss', { name: 'possessive / nominal mod.', eg: "Senka's match" }],
479
+ ['det', { name: 'determiner', eg: 'the book' }],
480
+ ] },
481
+ { label: 'Function & markers', deps: [
482
+ ['prep', { name: 'case / preposition marker', eg: 'went to Rome' }],
483
+ ['aux', { name: 'auxiliary', eg: 'am going' }],
484
+ ['auxpass', { name: 'auxiliary (passive)', eg: 'have been marked' }],
485
+ ['cc', { name: 'coordinating conjunction', eg: 'Matt and Alex' }],
486
+ ['mark', { name: 'clause / complement marker', eg: 'if I like it' }],
487
+ ['prt', { name: 'verb particle', eg: 'switched it off' }],
488
+ ['expl', { name: 'expletive', eg: 'there is' }],
489
+ ['discourse', { name: 'discourse element', eg: 'I like that :)' }],
490
+ ['intj', { name: 'interjection', eg: 'pass it, please' }],
491
+ ] },
492
+ { label: 'Other', deps: [
493
+ ['root', { name: 'root (head of the sentence)', eg: 'the main predicate' }],
494
+ ['dep', { name: 'unspecified dependency', eg: '(unresolved)' }],
495
+ ['punct', { name: 'punctuation', eg: 'Guys, calm!' }],
496
+ ] },
497
+ ];
498
+ // Flat lookups (used by the per-line mini-legend).
499
+ const POS_GLOSS = Object.fromEntries(POS_GROUPS.flatMap(g => g.tags));
500
+ const DEP_GLOSS = Object.fromEntries(DEP_GROUPS.flatMap(g => g.deps));
501
+ /** A glossary row, padded on the RAW strings (so chalk colour codes don't skew
502
+ * alignment). `tagWidth` is sized to the widest tag in the table. */
503
+ function glossRow(tag, g, tagWidth) {
504
+ return ' ' + chalk.cyan(tag.padEnd(tagWidth)) + W_CONTENT(g.name.padEnd(32)) + chalk.dim('e.g. ' + g.eg);
505
+ }
506
+ /**
507
+ * The long-form legend triggered from the main menu's "Display Legend" option:
508
+ * the compact legend PLUS full Penn POS-tag and grammatical-dependency glossaries.
509
+ * (These glossaries are deliberately NOT part of the compact in-output legend.)
510
+ */
511
+ export function renderFullLegend() {
512
+ const out = [];
513
+ out.push(renderLegend());
514
+ out.push('');
515
+ out.push(HR_THIN);
516
+ out.push(H1('Part-of-Speech Tags') + chalk.dim(' — Penn Treebank, as tagged by en-pos'));
517
+ const posWidth = Math.max(...POS_GROUPS.flatMap(g => g.tags.map(([t]) => t.length))) + 2;
518
+ for (const grp of POS_GROUPS) {
519
+ out.push('');
520
+ out.push(' ' + H2(grp.label));
521
+ for (const [tag, g] of grp.tags)
522
+ out.push(glossRow(tag, g, posWidth));
523
+ }
524
+ out.push('');
525
+ out.push(HR_THIN);
526
+ out.push(H1('Grammatical Dependencies') + chalk.dim(' — relation of each word to its governor (←label)'));
527
+ const depWidth = Math.max(...DEP_GROUPS.flatMap(g => g.deps.map(([d]) => d.length))) + 2;
528
+ for (const grp of DEP_GROUPS) {
529
+ out.push('');
530
+ out.push(' ' + H2(grp.label));
531
+ for (const [dep, g] of grp.deps)
532
+ out.push(glossRow(dep, g, depWidth));
533
+ }
534
+ return out.join('\n');
535
+ }
536
+ /**
537
+ * A compact per-line mini-legend: only the POS tags and dependency relations that
538
+ * actually occur in THIS line's parse, defined briefly (no examples), for the head
539
+ * of the detailed view's Phrasal Structure section. Fits in one or two lines.
540
+ */
541
+ function renderLineGlossary(words) {
542
+ const posSeen = [];
543
+ const depSeen = [];
544
+ for (const w of words) {
545
+ if (isPunctuation(w.lexicalClass))
546
+ continue;
547
+ if (!posSeen.includes(w.lexicalClass))
548
+ posSeen.push(w.lexicalClass);
549
+ const dep = w.dependency;
550
+ if (dep && dep.governorIndex > 0 && dep.dependentType && !depSeen.includes(dep.dependentType)) {
551
+ depSeen.push(dep.dependentType);
552
+ }
553
+ }
554
+ // Concise gloss for the mini-legend: drop the comma/parenthesis qualifier that
555
+ // the full legend carries ("noun, singular or mass" → "noun").
556
+ const brief = (name) => name.split(/,| \(/)[0].trim();
557
+ const out = [];
558
+ if (posSeen.length) {
559
+ const items = posSeen.map(t => chalk.cyan(t) + chalk.dim('=') + W_FUNCTION(brief(POS_GLOSS[t]?.name ?? t)));
560
+ out.push(' ' + chalk.dim('PoS ') + items.join(chalk.dim(' · ')));
561
+ }
562
+ if (depSeen.length) {
563
+ const items = depSeen.map(d => chalk.cyan(d) + chalk.dim('=') + W_FUNCTION(brief(DEP_GLOSS[d]?.name ?? d)));
564
+ out.push(' ' + chalk.dim('Dep ') + items.join(chalk.dim(' · ')));
565
+ }
566
+ return out;
567
+ }
568
+ /** Surface form reduced to bare lowercase letters (drops apostrophes/hyphens). */
569
+ function normWordForm(s) {
570
+ return s.toLowerCase().replace(/[^a-z]/g, '');
571
+ }
572
+ /** Colour each orthographic syllable of an original token by its relative stress. */
573
+ function colourToken(tokenText, word) {
574
+ const sylCount = Math.max(1, word.syllables.length);
575
+ const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix); // partitions the WHOLE token
576
+ const stresses = chunks.map((_, i) => word.syllables[i]?.relativeStress ?? 'w');
577
+ // Fast path: chunks reconstruct the token exactly (the common case).
578
+ if (chunks.join('') === tokenText) {
579
+ return chunks.map((c, i) => relColour(stresses[i])(c)).join('');
580
+ }
581
+ // Fallback: the syllabifier dropped a delimiter (it strips hyphens), so walk
582
+ // the ORIGINAL token char-by-char, assigning each kept char to its syllable
583
+ // by the chunk lengths and emitting dropped hyphens verbatim. Every original
584
+ // character is emitted exactly once, so nothing is ever lost.
585
+ const lens = chunks.map(c => c.length);
586
+ let out = '';
587
+ let ci = 0;
588
+ let consumed = 0;
589
+ for (const ch of tokenText) {
590
+ if (ch === '-') {
591
+ out += ch;
592
+ continue;
593
+ } // dropped delimiter, verbatim
594
+ while (ci < lens.length - 1 && consumed >= lens[ci]) {
595
+ ci++;
596
+ consumed = 0;
597
+ }
598
+ out += relColour(stresses[ci])(ch);
599
+ consumed++;
600
+ }
601
+ return out;
602
+ }
603
+ /**
604
+ * Project per-syllable stress colours back onto the original line, preserving
605
+ * capitalisation, punctuation, spacing and any extrametrical fragments the
606
+ * pipeline dropped (e.g. possessive "'s"). Word-like tokens are coloured;
607
+ * everything between them (spaces, punctuation, dashes) is emitted verbatim.
608
+ *
609
+ * Alignment is tolerant: it matches each token to the next parsed word by
610
+ * normalised form (equal, or token starts with the word — handling "cat's"),
611
+ * with a small look-ahead resync so a stray/unsyllabified token never derails
612
+ * the rest of the line. No original character is ever dropped.
613
+ */
614
+ export function projectStressOntoLine(rawLine, words) {
615
+ const tokenRe = /[A-Za-z]+(?:['’\-][A-Za-z]+)*/g;
616
+ let out = '';
617
+ let cursor = 0;
618
+ let wi = 0;
619
+ let m;
620
+ while ((m = tokenRe.exec(rawLine)) !== null) {
621
+ const start = m.index;
622
+ const end = start + m[0].length;
623
+ out += rawLine.slice(cursor, start); // gap text, verbatim
624
+ cursor = end;
625
+ const token = m[0];
626
+ const tokNorm = normWordForm(token);
627
+ const matches = (w) => {
628
+ if (!w)
629
+ return false;
630
+ const wn = normWordForm(w.word);
631
+ return wn.length > 0 && (tokNorm === wn || tokNorm.startsWith(wn));
632
+ };
633
+ if (matches(words[wi])) {
634
+ out += colourToken(token, words[wi]);
635
+ wi++;
636
+ }
637
+ else {
638
+ // Resync: a parsed word may have been skipped (e.g. unsyllabified "'s").
639
+ let found = -1;
640
+ for (let k = wi; k < Math.min(words.length, wi + 4); k++) {
641
+ if (matches(words[k])) {
642
+ found = k;
643
+ break;
644
+ }
645
+ }
646
+ if (found >= 0) {
647
+ out += colourToken(token, words[found]);
648
+ wi = found + 1;
649
+ }
650
+ else {
651
+ out += token; // leave verbatim; do not advance the word cursor
652
+ }
653
+ }
654
+ }
655
+ out += rawLine.slice(cursor); // trailing text, verbatim
656
+ return out;
657
+ }
658
+ // ═══════════════════════════════════════════════════════════════════════
659
+ // CAESURA RENDERING (placement logic now lives in caesura.ts)
660
+ // ═══════════════════════════════════════════════════════════════════════
661
+ /** Render the glyph for a caesura kind. */
662
+ function caesuraGlyph(kind) {
663
+ return kind === 'hard' ? B_CAESURA('‖') : B_CAESURA_SOFT('¦');
664
+ }
665
+ /** Colour a scansion string ("nws|nns|-wns") letter-by-letter, inserting caesura
666
+ * marks (at foot boundaries) when a caesura map is supplied. */
667
+ function colourScansionMap(scansion, caesurae) {
668
+ let out = '';
669
+ let sc = 0; // syllables emitted so far
670
+ const emitted = new Set();
671
+ const caesAt = () => {
672
+ if (caesurae && caesurae.has(sc) && !emitted.has(sc)) {
673
+ emitted.add(sc);
674
+ return ' ' + caesuraGlyph(caesurae.get(sc)) + ' ';
675
+ }
676
+ return '';
677
+ };
678
+ for (const ch of scansion) {
679
+ if (ch === '|') {
680
+ const c = caesAt();
681
+ out += c || B_FOOT('|');
682
+ }
683
+ else if (ch === '-') {
684
+ out += B_SILENT('-');
685
+ }
686
+ else if ('xwnms'.includes(ch)) {
687
+ out += caesAt(); // a (rare) mid-foot caesura, inserted inline
688
+ out += relColour(ch)(ch);
689
+ sc++;
690
+ }
691
+ else {
692
+ out += ch;
693
+ }
694
+ }
695
+ return out;
696
+ }
697
+ const METER_ABBR = {
698
+ iambic: 'iamb', trochaic: 'troch', anapestic: 'anap', dactylic: 'dact',
699
+ amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
700
+ 'free verse': 'free',
701
+ };
702
+ // ── Meter-family colours ───────────────────────────────────────────
703
+ // One consistent, legible LIGHT tone per metre family, reused EVERYWHERE a
704
+ // metre is named (the reading per-line meter, the top-3 ranking, and the
705
+ // synopsis). The foot-count label (pentameter / octameter…) stays white — we
706
+ // tint only the family word, so the output is informative without being gaudy.
707
+ const METER_HUE = {
708
+ iambic: chalk.hex('#7fb8ff'), // light blue
709
+ trochaic: chalk.hex('#ffc24d'), // yellow / orange
710
+ dactylic: chalk.hex('#88e0a0'), // mid / light green
711
+ amphibrachic: chalk.hex('#ff9ec4'), // pinkish
712
+ anapestic: chalk.hex('#ff7a6b'), // reddish
713
+ bacchic: chalk.hex('#c08be6'), // purple / wine
714
+ spondaic: chalk.hex('#b8b8b8'),
715
+ pyrrhic: chalk.hex('#b8b8b8'),
716
+ };
717
+ const METER_FALLBACK = chalk.hex('#cfd8e3'); // free verse / unknown
718
+ /** Tint a metre-family WORD (the first token of a metre name) by its hue. */
719
+ function meterFamilyColour(family) {
720
+ return METER_HUE[family.toLowerCase()] ?? METER_FALLBACK;
721
+ }
722
+ /** Colour a full metre label ("iambic pentameter"): family tinted, foot-count
723
+ * label left white. Bare "free verse" / multi-word non-families: fallback. */
724
+ function colourMeterLabel(meter) {
725
+ const sp = meter.indexOf(' ');
726
+ if (sp < 0)
727
+ return meterFamilyColour(meter)(meter);
728
+ const family = meter.slice(0, sp);
729
+ const hue = METER_HUE[family.toLowerCase()];
730
+ if (!hue)
731
+ return METER_FALLBACK(meter);
732
+ return hue(family) + chalk.whiteBright(meter.slice(sp));
733
+ }
734
+ /** Tint every metre-family word/abbreviation occurring inside a free-form
735
+ * string (used to colour the synopsis values without restructuring them).
736
+ * Longest-first so "iamb" inside "iambic" is not matched before the full word. */
737
+ const _METER_WORD_RE = /\b(iambic|trochaic|dactylic|amphibrachic|anapestic|bacchic|spondaic|pyrrhic|iamb|troch|dact|amph|anap|bacch|spon|pyrr)\b/gi;
738
+ function tintMeterNames(s) {
739
+ return s.replace(_METER_WORD_RE, (w) => {
740
+ const key = w.toLowerCase();
741
+ const fam = key.startsWith('iamb') ? 'iambic' : key.startsWith('troch') ? 'trochaic'
742
+ : key.startsWith('dact') ? 'dactylic' : key.startsWith('amph') ? 'amphibrachic'
743
+ : key.startsWith('anap') ? 'anapestic' : key.startsWith('bacch') ? 'bacchic'
744
+ : key.startsWith('spon') ? 'spondaic' : 'pyrrhic';
745
+ return meterFamilyColour(fam)(w);
746
+ });
747
+ }
748
+ /** Compact top-3 meter fit scores, e.g. "anap 0.81 · iamb 0.77 · amph 0.74" —
749
+ * each family abbreviation tinted its hue, the score dimmed, no enclosing
750
+ * parentheses (set off from the meter name by a dim "|" at the call site). */
751
+ function formatRanking(ranking) {
752
+ if (!ranking || ranking.length === 0)
753
+ return '';
754
+ const top = ranking.slice(0, 3).map(r => meterFamilyColour(r.meter)(METER_ABBR[r.meter] ?? r.meter) + chalk.dim(' ' + r.score.toFixed(2)));
755
+ return top.join(chalk.dim(' · '));
756
+ }
757
+ /** Divergence notes. After the continuity rename, a near-tie line's BASE
758
+ * meter is already the stanza/poem-dominant one and `standaloneMeter` records
759
+ * the numerically-best standalone reading ("≈ continuity; standalone:
760
+ * dactylic tetrameter"). `consensusMeter` survives only when the forced
761
+ * re-fit failed — then the old "aligns w/" annotation still applies. */
762
+ function consensusNote(detail) {
763
+ if (detail.standaloneMeter) {
764
+ return chalk.dim.italic(` ≈ continuity; standalone: ${detail.standaloneMeter}`);
765
+ }
766
+ if (!detail.consensusMeter)
767
+ return '';
768
+ return chalk.dim.italic(` ↔ aligns w/ stanza ${detail.consensusMeter}`);
769
+ }
770
+ /** Non-classical rhythm annotation (dolnik / taktovik / accentual), set by the
771
+ * rhythm layer. Shown as a separate chip AFTER the meter — it supplements the
772
+ * classical reading (in beats), it never replaces it. */
773
+ function rhythmNoteStr(detail) {
774
+ if (!detail.rhythmNote)
775
+ return '';
776
+ const note = detail.rhythmNote;
777
+ // Some notes (the 4/3 accentual) already carry a ♪; don't double it.
778
+ return chalk.magenta.dim(' ' + (note.includes('♪') ? note : '♪ ' + note));
779
+ }
780
+ /** Rhyme chip for a line: the end-rhyme scheme letter with its rhyme TYPE
781
+ * (e.g. "A(perfect)"; '·' = unrhymed), PLUS any pre-caesural INTERNAL rhymes,
782
+ * each parenthesised and cyan with its own type, shown before the end letter:
783
+ * e.g. "(C)(perfect) A(perfect)". */
784
+ function rhymeStr(detail) {
785
+ const r = detail.rhyme;
786
+ if (!r)
787
+ return '';
788
+ const parts = [];
789
+ for (const iw of r.internal ?? []) {
790
+ parts.push(chalk.cyan(`(${iw.letter})`) + (iw.type ? chalk.dim(`(${iw.type})`) : ''));
791
+ }
792
+ if (r.letter && r.letter !== '·') {
793
+ parts.push(chalk.yellowBright(r.letter) + (r.type ? chalk.dim(`(${r.type})`) : ''));
794
+ }
795
+ else if (parts.length === 0) {
796
+ parts.push(chalk.dim('·'));
797
+ }
798
+ return ' ' + parts.join(' ');
799
+ }
800
+ /** Non-punctuation, syllable-bearing words across all of a line's sentences. */
801
+ function collectLineWords(ln) {
802
+ const ws = [];
803
+ for (const res of ln.results) {
804
+ for (const w of res.sentence.words) {
805
+ if (!isPunctuation(w.lexicalClass) && w.syllables.length > 0)
806
+ ws.push(w);
807
+ }
808
+ }
809
+ return ws;
810
+ }
811
+ /**
812
+ * The Phonopoetics block of the synopsis: end / caesural / head rhymes (each
813
+ * letter coloured by the strongest relative-stress tier it spans), alliteration,
814
+ * and acrostics. Only subsections actually present in the poem are shown.
815
+ */
816
+ function renderPhonopoetics(p) {
817
+ // a rhyme pair "word [A|L1(|kind)] -> word [A|L4]", letter tinted by top stress
818
+ const rel = (r) => {
819
+ const L = relColour(r.topStress)(r.letter);
820
+ const D = chalk.dim;
821
+ const kindTag = r.kind === 'end' ? '' : D('|' + r.kind);
822
+ const typ = r.type ? D(` ${r.type}`) : '';
823
+ return chalk.white(r.fromWord) + ' ' + D('[') + L + D('|') + D(r.fromLabel) + kindTag + D(']')
824
+ + D(' → ') + chalk.white(r.toWord) + ' ' + D('[') + L + D('|') + D(r.toLabel) + D(']') + typ;
825
+ };
826
+ const SEP = chalk.dim(' · ');
827
+ const sub = [];
828
+ if (p.end.length)
829
+ sub.push({ label: 'End-Rhymes', body: p.end.map(rel).join(SEP) });
830
+ if (p.caesural.length)
831
+ sub.push({ label: 'Caesural Rhymes', body: p.caesural.map(rel).join(SEP) });
832
+ if (p.head.length)
833
+ sub.push({ label: 'Head Rhymes', body: p.head.map(rel).join(SEP) });
834
+ if (p.alliteration.length)
835
+ sub.push({
836
+ label: 'Alliteration',
837
+ body: p.alliteration.map(a => chalk.white(a.words.join(' ')) + chalk.dim(` (${a.label})`)).join(SEP),
838
+ });
839
+ if (p.acrostics.length)
840
+ sub.push({
841
+ label: 'Acrostic',
842
+ body: p.acrostics.map(a => a.firsts.map((f, i) => chalk.dim('[' + a.labels[i] + ':') + chalk.whiteBright(f) + chalk.dim(']')).join('')
843
+ + chalk.dim(' → ') + chalk.yellowBright(a.word)).join(SEP),
844
+ });
845
+ if (sub.length === 0)
846
+ return [];
847
+ const out = ['', chalk.bold.cyan('Phonopoetics:')];
848
+ const w = Math.max(...sub.map(s => s.label.length)) + 2;
849
+ for (const s of sub)
850
+ out.push(' ' + chalk.bold((s.label + ':').padEnd(w)) + s.body);
851
+ return out;
852
+ }
853
+ /**
854
+ * Reading view: the poem itself in its original formatting, each syllable
855
+ * coloured by 4-tier relative stress, followed by a same-structure block of
856
+ * per-line stress maps + meter (with top-3 fit scores). This is the whole
857
+ * output for this mode — not the full per-line analytic dump.
858
+ */
859
+ /** A verse line CLOSED by terminal or clause punctuation is END-STOPPED (a
860
+ * prosodic pause at the line break); one ending on a word with no boundary
861
+ * punctuation RUNS ON — enjambment — its intonational unit spilling into the
862
+ * next line. (Trailing quotes/brackets are ignored when judging the close.) */
863
+ function lineRunsOn(raw) {
864
+ const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
865
+ if (!t)
866
+ return false;
867
+ return !/[.!?;:,—–…]$/.test(t);
868
+ }
869
+ /** Poem-wide enjambment summary (end-stopped vs run-on line-ends), or null for
870
+ * a single line. The final line is terminal by position, so only the
871
+ * line-INTERNAL breaks (lines 1..n-1) are judged. */
872
+ function summariseEnjambment(stanzas) {
873
+ const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
874
+ if (raws.length < 2)
875
+ return null;
876
+ const interior = raws.slice(0, -1);
877
+ const enjambed = [];
878
+ interior.forEach((r, i) => { if (lineRunsOn(r))
879
+ enjambed.push(i + 1); });
880
+ const n = interior.length, k = enjambed.length;
881
+ if (k === 0)
882
+ return 'end-stopped throughout';
883
+ const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
884
+ return k >= Math.ceil(n / 2)
885
+ ? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
886
+ : `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
887
+ }
888
+ export function renderReadingView(stanzas) {
889
+ const out = [];
890
+ const multiStanza = stanzas.length > 1;
891
+ out.push('');
892
+ out.push(HR);
893
+ out.push(H1('Reading View') + chalk.dim(' — stress gradient over input text'));
894
+ out.push('');
895
+ // ── Block 1: the poem, original formatting, syllables coloured ──
896
+ // Multi-stanza poems get a right-aligned "Stanza N" counter in the blank line
897
+ // before each stanza after the first (the gaps between stanzas).
898
+ for (let s = 0; s < stanzas.length; s++) {
899
+ if (multiStanza && s > 0) {
900
+ out.push('');
901
+ out.push(chalk.dim.italic(('Stanza ' + (s + 1)).padStart(HR.length)));
902
+ }
903
+ for (const ln of stanzas[s].lines) {
904
+ out.push(projectStressOntoLine(ln.raw, collectLineWords(ln)));
905
+ }
906
+ }
907
+ out.push('');
908
+ out.push(HR_THIN);
909
+ out.push(H1('Stress Maps, Meter, & Rhymes') + chalk.dim(' — top-3 fit scores per line'));
910
+ out.push('');
911
+ // ── Block 2: stress maps + meter, same stanza/line structure ──
912
+ for (let s = 0; s < stanzas.length; s++) {
913
+ const firstDetail = stanzas[s].lines.flatMap(l => l.results)[0]?.phonologicalScansion;
914
+ const formNote = firstDetail?.formNote ? chalk.green.dim(' ❡ ' + firstDetail.formNote) : '';
915
+ if (multiStanza)
916
+ out.push(H2('Stanza ' + (s + 1)) + formNote);
917
+ else if (formNote)
918
+ out.push(formNote.trim());
919
+ for (let l = 0; l < stanzas[s].lines.length; l++) {
920
+ const ln = stanzas[s].lines[l];
921
+ const baseLabel = multiStanza ? `S${s + 1}L${l + 1}` : `L${l + 1}`;
922
+ if (ln.results.length === 0) {
923
+ out.push(' ' + chalk.dim(baseLabel.padEnd(8) + '(no parse)'));
924
+ continue;
925
+ }
926
+ for (let r = 0; r < ln.results.length; r++) {
927
+ const res = ln.results[r];
928
+ const d = res.phonologicalScansion;
929
+ const label = ln.results.length > 1 ? `${baseLabel}.${r + 1}` : baseLabel;
930
+ const caesurae = computeCaesurae(res.sentence.words, res.phonologicalHierarchy, d.scansion);
931
+ const map = colourScansionMap(d.scansion, caesurae);
932
+ const rank = formatRanking(d.ranking);
933
+ out.push(' ' + chalk.bold(label.padEnd(8)) + map + ' ' +
934
+ colourMeterLabel(d.meter) + (rank ? chalk.dim(' | ') + rank : '') + consensusNote(d) + rhythmNoteStr(d) + rhymeStr(d));
935
+ }
936
+ }
937
+ if (multiStanza && s < stanzas.length - 1)
938
+ out.push('');
939
+ }
940
+ // ── Block 3: Legend ──
941
+ // Kept ABOVE the synopsis: the legend serves the Stress Maps & Meter, and the
942
+ // Phonopoetics subsection of the synopsis below can run long — left at the
943
+ // bottom it gets pushed out of the field of view.
944
+ out.push('');
945
+ out.push(HR_THIN);
946
+ out.push(renderLegend());
947
+ // ── Block 4: cumulative poem synopsis (non-interfering meta-measure) ──
948
+ // Several top conclusions about the poem as a whole, drawn only from the
949
+ // per-line determinations above — never overriding any of them.
950
+ const synopsis = summarizePoem(stanzas.map(st => st.lines.flatMap(l => l.results)));
951
+ if (synopsis.length > 0) {
952
+ out.push('');
953
+ out.push(HR_THIN);
954
+ out.push(H1('Poem Synopsis') + chalk.dim(' In short, we have:'));
955
+ out.push('');
956
+ const w = Math.max(...synopsis.map(r => r.label.length)) + 2;
957
+ for (const row of synopsis) {
958
+ const label = chalk.bold.cyan((row.label + ':').padEnd(w));
959
+ // Colour the value so the block is not a wall of white: tint any metre
960
+ // names their family hue, and highlight the mean-fit %.
961
+ let val = tintMeterNames(row.value);
962
+ if (row.label === 'Meter')
963
+ val = val.replace(/~\d+%/, (m) => chalk.yellow(m));
964
+ out.push(' ' + label + val);
965
+ }
966
+ // Enjambment / end-stop — a poem-wide reading of the line-ends.
967
+ const enj = summariseEnjambment(stanzas);
968
+ if (enj)
969
+ out.push(' ' + chalk.bold.cyan('Enjambment:'.padEnd(w)) + chalk.dim(enj));
970
+ // Phonopoetics — end / caesural / head rhymes, alliteration, acrostic.
971
+ out.push(...renderPhonopoetics(analyzePhonopoetics(stanzas.map(st => st.lines.flatMap(l => l.results)))));
972
+ }
973
+ out.push('');
974
+ out.push(HR);
975
+ return out.join('\n');
976
+ }