calliope-ts 0.0.2 → 0.0.4
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.
- package/README.md +32 -20
- package/dist/caesura.d.ts +33 -0
- package/dist/caesura.d.ts.map +1 -0
- package/dist/caesura.js +202 -0
- package/dist/calliope/boundaries.d.ts +19 -0
- package/dist/calliope/boundaries.d.ts.map +1 -0
- package/dist/calliope/boundaries.js +182 -0
- package/dist/calliope/bracketing.d.ts +11 -0
- package/dist/calliope/bracketing.d.ts.map +1 -0
- package/dist/calliope/bracketing.js +416 -0
- package/dist/calliope/deps.d.ts +4 -0
- package/dist/calliope/deps.d.ts.map +1 -0
- package/dist/calliope/deps.js +181 -0
- package/dist/calliope/engine.d.ts +3 -0
- package/dist/calliope/engine.d.ts.map +1 -0
- package/dist/calliope/engine.js +71 -0
- package/dist/calliope/feats.d.ts +9 -0
- package/dist/calliope/feats.d.ts.map +1 -0
- package/dist/calliope/feats.js +45 -0
- package/dist/calliope/names.d.ts +7 -0
- package/dist/calliope/names.d.ts.map +1 -0
- package/dist/calliope/names.js +42 -0
- package/dist/calliope/postag.d.ts +8 -0
- package/dist/calliope/postag.d.ts.map +1 -0
- package/dist/calliope/postag.js +250 -0
- package/dist/calliope/prosodic.d.ts +3 -0
- package/dist/calliope/prosodic.d.ts.map +1 -0
- package/dist/calliope/prosodic.js +275 -0
- package/dist/calliope/relstress.d.ts +4 -0
- package/dist/calliope/relstress.d.ts.map +1 -0
- package/dist/calliope/relstress.js +688 -0
- package/dist/calliope/stressrules.d.ts +28 -0
- package/dist/calliope/stressrules.d.ts.map +1 -0
- package/dist/calliope/stressrules.js +147 -0
- package/dist/calliope/syntax.d.ts +38 -0
- package/dist/calliope/syntax.d.ts.map +1 -0
- package/dist/calliope/syntax.js +234 -0
- package/dist/calliope/udpos.d.ts +13 -0
- package/dist/calliope/udpos.d.ts.map +1 -0
- package/dist/calliope/udpos.js +156 -0
- package/dist/clio/caesura.d.ts +27 -0
- package/dist/clio/caesura.d.ts.map +1 -0
- package/dist/clio/caesura.js +148 -0
- package/dist/clio/depfix.d.ts +13 -0
- package/dist/clio/depfix.d.ts.map +1 -0
- package/dist/clio/depfix.js +84 -0
- package/dist/clio/display.d.ts +32 -0
- package/dist/clio/display.d.ts.map +1 -0
- package/dist/clio/display.js +976 -0
- package/dist/clio/engine.d.ts +3 -0
- package/dist/clio/engine.d.ts.map +1 -0
- package/dist/clio/engine.js +28 -0
- package/dist/clio/parser.d.ts +10 -0
- package/dist/clio/parser.d.ts.map +1 -0
- package/dist/clio/parser.js +696 -0
- package/dist/clio/phonological.d.ts +41 -0
- package/dist/clio/phonological.d.ts.map +1 -0
- package/dist/clio/phonological.js +788 -0
- package/dist/clio/phrasestress.d.ts +6 -0
- package/dist/clio/phrasestress.d.ts.map +1 -0
- package/dist/clio/phrasestress.js +106 -0
- package/dist/clio/pipeline.d.ts +11 -0
- package/dist/clio/pipeline.d.ts.map +1 -0
- package/dist/clio/pipeline.js +147 -0
- package/dist/clio/rhyme.d.ts +65 -0
- package/dist/clio/rhyme.d.ts.map +1 -0
- package/dist/clio/rhyme.js +761 -0
- package/dist/clio/scandroid.d.ts +17 -0
- package/dist/clio/scandroid.d.ts.map +1 -0
- package/dist/clio/scandroid.js +435 -0
- package/dist/clio/scansion.d.ts +46 -0
- package/dist/clio/scansion.d.ts.map +1 -0
- package/dist/clio/scansion.js +1086 -0
- package/dist/clio/semantics.d.ts +44 -0
- package/dist/clio/semantics.d.ts.map +1 -0
- package/dist/clio/semantics.js +139 -0
- package/dist/clio/stress.d.ts +83 -0
- package/dist/clio/stress.d.ts.map +1 -0
- package/dist/clio/stress.js +1737 -0
- package/dist/clio/tagfix.d.ts +6 -0
- package/dist/clio/tagfix.d.ts.map +1 -0
- package/dist/clio/tagfix.js +101 -0
- package/dist/display.d.ts +0 -6
- package/dist/display.d.ts.map +1 -1
- package/dist/display.js +322 -141
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +12 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +169 -26
- package/dist/parser.d.ts +3 -3
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +320 -563
- package/dist/phonological.d.ts +1 -1
- package/dist/phonological.d.ts.map +1 -1
- package/dist/phonological.js +73 -4
- package/dist/phrasestress.d.ts +6 -0
- package/dist/phrasestress.d.ts.map +1 -0
- package/dist/phrasestress.js +106 -0
- package/dist/rhyme.d.ts +40 -1
- package/dist/rhyme.d.ts.map +1 -1
- package/dist/rhyme.js +435 -7
- package/dist/scansion.d.ts +9 -0
- package/dist/scansion.d.ts.map +1 -1
- package/dist/scansion.js +145 -18
- package/dist/semantics.d.ts +44 -0
- package/dist/semantics.d.ts.map +1 -0
- package/dist/semantics.js +139 -0
- package/dist/stress.d.ts +127 -4
- package/dist/stress.d.ts.map +1 -1
- package/dist/stress.js +834 -61
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/caesura.ts +201 -0
- package/src/calliope/boundaries.ts +190 -0
- package/src/calliope/bracketing.ts +390 -0
- package/src/calliope/deps.ts +160 -0
- package/src/calliope/engine.ts +77 -0
- package/src/calliope/feats.ts +46 -0
- package/src/calliope/names.ts +44 -0
- package/src/calliope/postag.ts +253 -0
- package/src/calliope/prosodic.ts +262 -0
- package/src/calliope/relstress.ts +645 -0
- package/src/calliope/stressrules.ts +147 -0
- package/src/calliope/syntax.ts +218 -0
- package/src/calliope/udpos.ts +152 -0
- package/src/calliope_src_contents.md +19049 -0
- package/src/clio/caesura.ts +145 -0
- package/src/clio/depfix.ts +88 -0
- package/src/clio/display.ts +1042 -0
- package/src/clio/engine.ts +38 -0
- package/src/clio/parser.ts +845 -0
- package/src/clio/phonological.ts +849 -0
- package/src/clio/phrasestress.ts +108 -0
- package/src/clio/pipeline.ts +154 -0
- package/src/clio/rhyme.ts +740 -0
- package/src/clio/scandroid.ts +434 -0
- package/src/clio/scansion.ts +1130 -0
- package/src/clio/semantics.ts +134 -0
- package/src/clio/stress.ts +1731 -0
- package/src/clio/tagfix.ts +104 -0
- package/src/display.ts +321 -137
- package/src/engine.ts +22 -0
- package/src/index.ts +176 -32
- package/src/parser.ts +346 -682
- package/src/phonological.ts +72 -4
- package/src/phrasestress.ts +108 -0
- package/src/rhyme.ts +428 -8
- package/src/scansion.ts +136 -15
- package/src/semantics.ts +134 -0
- package/src/stress.ts +794 -64
- package/src/types.ts +39 -4
- package/tests/DataForHayesLinesOnly.txt +364 -0
- package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
- package/tests/basic.test.ts +443 -37
- package/tests/bench-hayes.mjs +72 -0
- package/tests/wagner-stress.test.ts +188 -0
- 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
|
+
}
|