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