calliope-ts 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -13
- 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/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
package/src/display.ts
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
} from './types.js';
|
|
16
16
|
import { isPunctuation } from './parser.js';
|
|
17
17
|
import { syllabifyWord, syllableVowelLengths } from './phonological.js';
|
|
18
|
+
import { computeCaesurae, CaesuraInfo } from './caesura.js';
|
|
19
|
+
import { computeBoundaries } from './calliope/boundaries.js';
|
|
20
|
+
import { summarizePoem, analyzePhonopoetics, type Phonopoetics, type RhymeRel } from './rhyme.js';
|
|
18
21
|
|
|
19
22
|
// ═══════════════════════════════════════════════════════════════════════
|
|
20
23
|
// COLOUR SYSTEM — Conceptually motivated palettes
|
|
@@ -62,6 +65,39 @@ const B_CAESURA_SOFT = chalk.cyan.dim; // inferred caesura (phonologica
|
|
|
62
65
|
const B_FOOT = chalk.gray;
|
|
63
66
|
const B_SILENT = chalk.gray.dim;
|
|
64
67
|
|
|
68
|
+
// ── Graded boundary-strength colour (Wagner Ch.4–5): cold blue (weak) → warm red
|
|
69
|
+
// (strong). The relational grid says boundaries differ in DEGREE, not just kind, so
|
|
70
|
+
// ϕ/ι brackets are tinted along a continuous spectrum by their NSBR-scaled strength
|
|
71
|
+
// (boundaries.ts). κ (clitic-group) boundaries are the weakest tier — a constant dim
|
|
72
|
+
// blue. This makes the boundary-strength dimension VISIBLE in the bracketing view.
|
|
73
|
+
const GRAD_STOPS: [number, [number, number, number]][] = [
|
|
74
|
+
[0.00, [0x6a, 0x8c, 0xc7]], // cold blue
|
|
75
|
+
[0.30, [0x5f, 0xc7, 0xc0]], // teal
|
|
76
|
+
[0.55, [0xd9, 0xc2, 0x4d]], // yellow
|
|
77
|
+
[0.78, [0xe0, 0x91, 0x3f]], // orange
|
|
78
|
+
[1.00, [0xe0, 0x56, 0x4b]], // red
|
|
79
|
+
];
|
|
80
|
+
function gradHex(t: number): string {
|
|
81
|
+
const x = Math.max(0, Math.min(1, t));
|
|
82
|
+
for (let i = 1; i < GRAD_STOPS.length; i++) {
|
|
83
|
+
const [t1, c1] = GRAD_STOPS[i - 1];
|
|
84
|
+
const [t2, c2] = GRAD_STOPS[i];
|
|
85
|
+
if (x <= t2) {
|
|
86
|
+
const f = t2 === t1 ? 0 : (x - t1) / (t2 - t1);
|
|
87
|
+
const r = Math.round(c1[0] + (c2[0] - c1[0]) * f);
|
|
88
|
+
const g = Math.round(c1[1] + (c2[1] - c1[1]) * f);
|
|
89
|
+
const b = Math.round(c1[2] + (c2[2] - c1[2]) * f);
|
|
90
|
+
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return '#e0564b';
|
|
94
|
+
}
|
|
95
|
+
/** Colour for a ϕ/ι bracket given its boundary strength (0..1). */
|
|
96
|
+
function boundaryColour(strength: number): (s: string) => string {
|
|
97
|
+
return (s: string) => chalk.hex(gradHex(strength))(s);
|
|
98
|
+
}
|
|
99
|
+
const B_KAPPA = (s: string) => chalk.hex('#5a6f9e').dim(s); // κ — weakest, dim blue
|
|
100
|
+
|
|
65
101
|
// Word roles
|
|
66
102
|
const W_CONTENT = chalk.white;
|
|
67
103
|
const W_FUNCTION = chalk.gray;
|
|
@@ -116,7 +152,7 @@ function buildColSyls(words: ClsWord[], ius: IntonationalUnit[]): ColSyl[] {
|
|
|
116
152
|
if (isPunctuation(w.lexicalClass)) continue;
|
|
117
153
|
const dep = w.dependency;
|
|
118
154
|
const sylCount = w.syllables.length;
|
|
119
|
-
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
|
|
155
|
+
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
|
|
120
156
|
|
|
121
157
|
for (let si = 0; si < sylCount; si++) {
|
|
122
158
|
const syl = w.syllables[si];
|
|
@@ -149,11 +185,34 @@ function buildColSyls(words: ClsWord[], ius: IntonationalUnit[]): ColSyl[] {
|
|
|
149
185
|
wordRef: w,
|
|
150
186
|
});
|
|
151
187
|
}
|
|
188
|
+
// A 0-syllable possessive enclitic ('s) has no syllable column of its own;
|
|
189
|
+
// append its surface to the preceding syllable so "Nature's" renders WITH its
|
|
190
|
+
// 's instead of as bare "Nature" (and so its κ-boundary does not collapse into
|
|
191
|
+
// the next group — the "Nature first" mis-bracketing the maintainer flagged).
|
|
192
|
+
if (sylCount === 0 && w.lexicalClass === 'POS' && result.length > 0) {
|
|
193
|
+
result[result.length - 1].chunk += w.word;
|
|
194
|
+
}
|
|
152
195
|
}
|
|
153
196
|
}
|
|
154
197
|
}
|
|
155
198
|
}
|
|
156
199
|
|
|
200
|
+
// Bracket-boundary flags via look-around over the SYLLABLE-bearing columns, so a
|
|
201
|
+
// 0-syllable token (possessive 's, an elided clitic) can never swallow a κ/ϕ/ι
|
|
202
|
+
// boundary: a column is first/last in its unit when the adjacent column belongs to a
|
|
203
|
+
// different unit. (Composite key, since cpId/ppId are indices LOCAL to their parent.)
|
|
204
|
+
const uKey = (c: { iuId: number; ppId: number; cpId: number }, lvl: 'cp' | 'pp' | 'iu') =>
|
|
205
|
+
lvl === 'cp' ? `${c.iuId}.${c.ppId}.${c.cpId}` : lvl === 'pp' ? `${c.iuId}.${c.ppId}` : `${c.iuId}`;
|
|
206
|
+
for (let i = 0; i < result.length; i++) {
|
|
207
|
+
const cur = result[i], prev = result[i - 1], next = result[i + 1];
|
|
208
|
+
result[i].isFirstInCP = !prev || uKey(prev, 'cp') !== uKey(cur, 'cp');
|
|
209
|
+
result[i].isLastInCP = !next || uKey(next, 'cp') !== uKey(cur, 'cp');
|
|
210
|
+
result[i].isFirstInPP = !prev || uKey(prev, 'pp') !== uKey(cur, 'pp');
|
|
211
|
+
result[i].isLastInPP = !next || uKey(next, 'pp') !== uKey(cur, 'pp');
|
|
212
|
+
result[i].isFirstInIU = !prev || uKey(prev, 'iu') !== uKey(cur, 'iu');
|
|
213
|
+
result[i].isLastInIU = !next || uKey(next, 'iu') !== uKey(cur, 'iu');
|
|
214
|
+
}
|
|
215
|
+
|
|
157
216
|
return result;
|
|
158
217
|
}
|
|
159
218
|
|
|
@@ -233,6 +292,32 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
|
|
|
233
292
|
lines.push(' ' + lexParts.join(''));
|
|
234
293
|
lines.push('');
|
|
235
294
|
|
|
295
|
+
// ── Layer 3b: Phrase stress (genuine cyclic Compound + Nuclear Stress Rules) ───
|
|
296
|
+
// The real phrase-stress stage (bracketing.ts): the SPE/Hayes cyclic CSR (compound
|
|
297
|
+
// → primary LEFT) + NSR (phrase → primary RIGHT) over the dependency tree's
|
|
298
|
+
// constituent bracketing. 1 = STRONGEST (the utterance nuclear); higher = weaker.
|
|
299
|
+
// Reproduces "Mary 2, ate 3, sweet 4, ice 1, cream 5". An integer prominence
|
|
300
|
+
// ranking, computed INDEPENDENTLY of the relative contour below.
|
|
301
|
+
lines.push(H1('Phrase Stress') + ' ' + chalk.dim('1 = strongest (utterance nuclear) → higher = weaker · 0 = none'));
|
|
302
|
+
lines.push('');
|
|
303
|
+
|
|
304
|
+
const phrParts: string[] = [];
|
|
305
|
+
for (const cs of colSyls) {
|
|
306
|
+
if (cs.isFirstInWord && cs.globalIdx > 0) phrParts.push(' ');
|
|
307
|
+
if (cs.isFirstInWord) {
|
|
308
|
+
const ps = cs.wordRef.phraseStress || 0;
|
|
309
|
+
const colour = ps === 0 ? chalk.dim
|
|
310
|
+
: ps === 1 ? chalk.cyanBright // the utterance nuclear (strongest)
|
|
311
|
+
: ps <= 3 ? chalk.cyan // strong
|
|
312
|
+
: chalk.dim; // weak / deeply demoted
|
|
313
|
+
phrParts.push(colour(String(ps)));
|
|
314
|
+
} else {
|
|
315
|
+
phrParts.push(' '); // continuation syllable — keep word-start alignment
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
lines.push(' ' + phrParts.join(''));
|
|
319
|
+
lines.push('');
|
|
320
|
+
|
|
236
321
|
// ── Layer 4: Relative stress (w/n/m/s) ──────────────────────────
|
|
237
322
|
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
323
|
lines.push('');
|
|
@@ -245,23 +330,37 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
|
|
|
245
330
|
lines.push(' ' + relParts.join(''));
|
|
246
331
|
lines.push('');
|
|
247
332
|
|
|
248
|
-
// ── Layer 5: Phonological bracketing
|
|
249
|
-
lines.push(H1('Phonological Bracketing') + ' ' +
|
|
333
|
+
// ── Layer 5: Phonological bracketing (graded by boundary strength) ──────
|
|
334
|
+
lines.push(H1('Phonological Bracketing') + ' ' + B_KAPPA('[]') + ' κ ' + B_PP('{}') + ' ϕ ' + B_IU('<>') + ' ι' +
|
|
335
|
+
chalk.dim(' — ϕ/ι tint: ') + boundaryColour(0.1)('weak') + chalk.dim('→') + boundaryColour(1)('strong'));
|
|
250
336
|
lines.push('');
|
|
251
337
|
|
|
338
|
+
// Graded boundary strengths (NSBR, boundaries.ts), zipped to the ϕ/ι opens as we
|
|
339
|
+
// walk the syllable columns: each ϕ is tinted by the strength of the break that
|
|
340
|
+
// introduced it (its left-edge boundary); κ stays the weakest dim-blue tier.
|
|
341
|
+
const bounds = computeBoundaries(words, ius);
|
|
342
|
+
let phiOrd = -1;
|
|
343
|
+
let ppColourFn: (s: string) => string = B_PP;
|
|
344
|
+
let iuColourFn: (s: string) => string = B_IU;
|
|
252
345
|
const sylParts: string[] = [];
|
|
253
346
|
let iuOpen = false, ppOpen = false, cpOpen = false;
|
|
254
347
|
for (const cs of colSyls) {
|
|
255
|
-
if (cs.
|
|
256
|
-
|
|
257
|
-
|
|
348
|
+
if (cs.isFirstInPP) {
|
|
349
|
+
phiOrd++;
|
|
350
|
+
const st = bounds.phi[phiOrd]?.strength ?? 0;
|
|
351
|
+
ppColourFn = boundaryColour(st);
|
|
352
|
+
if (cs.isFirstInIU) iuColourFn = boundaryColour(st);
|
|
353
|
+
}
|
|
354
|
+
if (cs.isFirstInIU && !iuOpen) { sylParts.push(iuColourFn('<')); iuOpen = true; }
|
|
355
|
+
if (cs.isFirstInPP && !ppOpen) { sylParts.push(ppColourFn('{')); ppOpen = true; }
|
|
356
|
+
if (cs.isFirstInCP && !cpOpen) { sylParts.push(B_KAPPA('[')); cpOpen = true; }
|
|
258
357
|
|
|
259
358
|
if (cs.isFirstInWord && cs.globalIdx > 0) sylParts.push(' ');
|
|
260
359
|
sylParts.push(relColour(cs.relStress)(cs.chunk));
|
|
261
360
|
|
|
262
|
-
if (cs.isLastInCP && cpOpen) { sylParts.push(
|
|
263
|
-
if (cs.isLastInPP && ppOpen) { sylParts.push(
|
|
264
|
-
if (cs.isLastInIU && iuOpen) { sylParts.push(
|
|
361
|
+
if (cs.isLastInCP && cpOpen) { sylParts.push(B_KAPPA(']')); cpOpen = false; }
|
|
362
|
+
if (cs.isLastInPP && ppOpen) { sylParts.push(ppColourFn('}')); ppOpen = false; }
|
|
363
|
+
if (cs.isLastInIU && iuOpen) { sylParts.push(iuColourFn('>')); iuOpen = false; }
|
|
265
364
|
}
|
|
266
365
|
lines.push(' ' + sylParts.join(''));
|
|
267
366
|
lines.push('');
|
|
@@ -282,7 +381,7 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
|
|
|
282
381
|
for (const w of words) {
|
|
283
382
|
if (isPunctuation(w.lexicalClass)) continue;
|
|
284
383
|
const sylCount = w.syllables.length;
|
|
285
|
-
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
|
|
384
|
+
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
|
|
286
385
|
for (let si = 0; si < sylCount; si++) {
|
|
287
386
|
linearSyls.push({
|
|
288
387
|
chunk: chunks[si] || w.word,
|
|
@@ -300,8 +399,20 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
|
|
|
300
399
|
return 'xXwWnNmMsS'.includes(ch);
|
|
301
400
|
}
|
|
302
401
|
|
|
402
|
+
// Feet whose right edge carries a caesura take the caesura GLYPH as their
|
|
403
|
+
// separator (matching the reading view's "xs ‖ xnw"), never a doubled "‖ |".
|
|
404
|
+
const joinFeet = (feet: string[], caesAfter: boolean[]): string => {
|
|
405
|
+
let out = '';
|
|
406
|
+
for (let i = 0; i < feet.length; i++) {
|
|
407
|
+
out += feet[i];
|
|
408
|
+
if (i < feet.length - 1) out += caesAfter[i] ? ' ' : B_FOOT(' | ');
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
};
|
|
412
|
+
|
|
303
413
|
let sylIdx = 0;
|
|
304
414
|
const footDisplays: string[] = [];
|
|
415
|
+
const footCaes: boolean[] = [];
|
|
305
416
|
let prevWordRef: ClsWord | null = null;
|
|
306
417
|
for (const rawFoot of feetRaw) {
|
|
307
418
|
let footOut = '';
|
|
@@ -320,11 +431,13 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
|
|
|
320
431
|
}
|
|
321
432
|
}
|
|
322
433
|
const ck = caesurae.get(sylIdx); if (ck) footOut += ' ' + caesuraGlyph(ck);
|
|
434
|
+
footCaes.push(!!ck);
|
|
323
435
|
footDisplays.push(footOut);
|
|
324
436
|
}
|
|
325
|
-
lines.push(' ' + H2('Feet: ') + footDisplays
|
|
437
|
+
lines.push(' ' + H2('Feet: ') + joinFeet(footDisplays, footCaes));
|
|
326
438
|
|
|
327
439
|
const stressDisplays: string[] = [];
|
|
440
|
+
const stressCaes: boolean[] = [];
|
|
328
441
|
let rIdx = 0;
|
|
329
442
|
for (const rawFoot of feetRaw) {
|
|
330
443
|
let s = '';
|
|
@@ -337,9 +450,10 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
|
|
|
337
450
|
}
|
|
338
451
|
}
|
|
339
452
|
const ck2 = caesurae.get(rIdx); if (ck2) s += ' ' + caesuraGlyph(ck2);
|
|
453
|
+
stressCaes.push(!!ck2);
|
|
340
454
|
stressDisplays.push(s);
|
|
341
455
|
}
|
|
342
|
-
lines.push(' ' + H2('Stress: ') + stressDisplays
|
|
456
|
+
lines.push(' ' + H2('Stress: ') + joinFeet(stressDisplays, stressCaes));
|
|
343
457
|
lines.push('');
|
|
344
458
|
|
|
345
459
|
// ── Layer 7: Dependencies ───────────────────────────────────────
|
|
@@ -621,7 +735,7 @@ function normWordForm(s: string): string {
|
|
|
621
735
|
/** Colour each orthographic syllable of an original token by its relative stress. */
|
|
622
736
|
function colourToken(tokenText: string, word: ClsWord): string {
|
|
623
737
|
const sylCount = Math.max(1, word.syllables.length);
|
|
624
|
-
|
|
738
|
+
const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix, word.morphPrefix); // partitions the WHOLE token
|
|
625
739
|
const stresses = chunks.map((_, i) => word.syllables[i]?.relativeStress ?? 'w');
|
|
626
740
|
|
|
627
741
|
// Fast path: chunks reconstruct the token exactly (the common case).
|
|
@@ -699,116 +813,21 @@ export function projectStressOntoLine(rawLine: string, words: ClsWord[]): string
|
|
|
699
813
|
}
|
|
700
814
|
|
|
701
815
|
// ═══════════════════════════════════════════════════════════════════════
|
|
702
|
-
// CAESURA
|
|
816
|
+
// CAESURA RENDERING (placement logic now lives in caesura.ts)
|
|
703
817
|
// ═══════════════════════════════════════════════════════════════════════
|
|
704
818
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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('¦');
|
|
819
|
+
/** Render the glyph for a caesura, coloured by its boundary strength (Wagner
|
|
820
|
+
* Ch.4–5): a strong break is a warm-red '‖', a medium one an orange '¦', a weak
|
|
821
|
+
* one a dim '·' — the same cold-blue→warm-red spectrum as the brackets. */
|
|
822
|
+
function caesuraGlyph(info: CaesuraInfo): string {
|
|
823
|
+
const glyph = info.kind === 'hard' ? '‖' : '¦';
|
|
824
|
+
if (info.strength < 0.34) return chalk.hex(gradHex(info.strength))('·');
|
|
825
|
+
return chalk.hex(gradHex(info.strength)).bold(glyph);
|
|
807
826
|
}
|
|
808
827
|
|
|
809
828
|
/** Colour a scansion string ("nws|nns|-wns") letter-by-letter, inserting caesura
|
|
810
829
|
* marks (at foot boundaries) when a caesura map is supplied. */
|
|
811
|
-
function colourScansionMap(scansion: string, caesurae?: Map<number,
|
|
830
|
+
function colourScansionMap(scansion: string, caesurae?: Map<number, CaesuraInfo>): string {
|
|
812
831
|
let out = '';
|
|
813
832
|
let sc = 0; // syllables emitted so far
|
|
814
833
|
const emitted = new Set<number>();
|
|
@@ -842,12 +861,62 @@ const METER_ABBR: Record<string, string> = {
|
|
|
842
861
|
'free verse': 'free',
|
|
843
862
|
};
|
|
844
863
|
|
|
845
|
-
|
|
864
|
+
// ── Meter-family colours ───────────────────────────────────────────
|
|
865
|
+
// One consistent, legible LIGHT tone per metre family, reused EVERYWHERE a
|
|
866
|
+
// metre is named (the reading per-line meter, the top-3 ranking, and the
|
|
867
|
+
// synopsis). The foot-count label (pentameter / octameter…) stays white — we
|
|
868
|
+
// tint only the family word, so the output is informative without being gaudy.
|
|
869
|
+
const METER_HUE: Record<string, (s: string) => string> = {
|
|
870
|
+
iambic: chalk.hex('#7fb8ff'), // light blue
|
|
871
|
+
trochaic: chalk.hex('#ffc24d'), // yellow / orange
|
|
872
|
+
dactylic: chalk.hex('#88e0a0'), // mid / light green
|
|
873
|
+
amphibrachic: chalk.hex('#ff9ec4'), // pinkish
|
|
874
|
+
anapestic: chalk.hex('#ff7a6b'), // reddish
|
|
875
|
+
bacchic: chalk.hex('#c08be6'), // purple / wine
|
|
876
|
+
spondaic: chalk.hex('#b8b8b8'),
|
|
877
|
+
pyrrhic: chalk.hex('#b8b8b8'),
|
|
878
|
+
};
|
|
879
|
+
const METER_FALLBACK = chalk.hex('#cfd8e3'); // free verse / unknown
|
|
880
|
+
|
|
881
|
+
/** Tint a metre-family WORD (the first token of a metre name) by its hue. */
|
|
882
|
+
function meterFamilyColour(family: string): (s: string) => string {
|
|
883
|
+
return METER_HUE[family.toLowerCase()] ?? METER_FALLBACK;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Colour a full metre label ("iambic pentameter"): family tinted, foot-count
|
|
887
|
+
* label left white. Bare "free verse" / multi-word non-families: fallback. */
|
|
888
|
+
function colourMeterLabel(meter: string): string {
|
|
889
|
+
const sp = meter.indexOf(' ');
|
|
890
|
+
if (sp < 0) return meterFamilyColour(meter)(meter);
|
|
891
|
+
const family = meter.slice(0, sp);
|
|
892
|
+
const hue = METER_HUE[family.toLowerCase()];
|
|
893
|
+
if (!hue) return METER_FALLBACK(meter);
|
|
894
|
+
return hue(family) + chalk.whiteBright(meter.slice(sp));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Tint every metre-family word/abbreviation occurring inside a free-form
|
|
898
|
+
* string (used to colour the synopsis values without restructuring them).
|
|
899
|
+
* Longest-first so "iamb" inside "iambic" is not matched before the full word. */
|
|
900
|
+
const _METER_WORD_RE = /\b(iambic|trochaic|dactylic|amphibrachic|anapestic|bacchic|spondaic|pyrrhic|iamb|troch|dact|amph|anap|bacch|spon|pyrr)\b/gi;
|
|
901
|
+
function tintMeterNames(s: string): string {
|
|
902
|
+
return s.replace(_METER_WORD_RE, (w) => {
|
|
903
|
+
const key = w.toLowerCase();
|
|
904
|
+
const fam = key.startsWith('iamb') ? 'iambic' : key.startsWith('troch') ? 'trochaic'
|
|
905
|
+
: key.startsWith('dact') ? 'dactylic' : key.startsWith('amph') ? 'amphibrachic'
|
|
906
|
+
: key.startsWith('anap') ? 'anapestic' : key.startsWith('bacch') ? 'bacchic'
|
|
907
|
+
: key.startsWith('spon') ? 'spondaic' : 'pyrrhic';
|
|
908
|
+
return meterFamilyColour(fam)(w);
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Compact top-3 meter fit scores, e.g. "anap 0.81 · iamb 0.77 · amph 0.74" —
|
|
913
|
+
* each family abbreviation tinted its hue, the score dimmed, no enclosing
|
|
914
|
+
* parentheses (set off from the meter name by a dim "|" at the call site). */
|
|
846
915
|
function formatRanking(ranking?: MeterScore[]): string {
|
|
847
916
|
if (!ranking || ranking.length === 0) return '';
|
|
848
|
-
const top = ranking.slice(0, 3)
|
|
849
|
-
.
|
|
850
|
-
return chalk.dim('
|
|
917
|
+
const top = ranking.slice(0, 3).map(r =>
|
|
918
|
+
meterFamilyColour(r.meter)(METER_ABBR[r.meter] ?? r.meter) + chalk.dim(' ' + r.score.toFixed(2)));
|
|
919
|
+
return top.join(chalk.dim(' · '));
|
|
851
920
|
}
|
|
852
921
|
|
|
853
922
|
/** Divergence notes. After the continuity rename, a near-tie line's BASE
|
|
@@ -863,21 +932,35 @@ function consensusNote(detail: { consensusMeter?: string; standaloneMeter?: stri
|
|
|
863
932
|
return chalk.dim.italic(` ↔ aligns w/ stanza ${detail.consensusMeter}`);
|
|
864
933
|
}
|
|
865
934
|
|
|
866
|
-
/** Non-classical rhythm annotation (dolnik / taktovik / accentual
|
|
867
|
-
*
|
|
868
|
-
*
|
|
935
|
+
/** Non-classical rhythm annotation (dolnik / taktovik / accentual), set by the
|
|
936
|
+
* rhythm layer. Shown as a separate chip AFTER the meter — it supplements the
|
|
937
|
+
* classical reading (in beats), it never replaces it. */
|
|
869
938
|
function rhythmNoteStr(detail: { rhythmNote?: string }): string {
|
|
870
939
|
if (!detail.rhythmNote) return '';
|
|
871
|
-
|
|
940
|
+
const note = detail.rhythmNote;
|
|
941
|
+
// Some notes (the 4/3 accentual) already carry a ♪; don't double it.
|
|
942
|
+
return chalk.magenta.dim(' ' + (note.includes('♪') ? note : '♪ ' + note));
|
|
872
943
|
}
|
|
873
944
|
|
|
874
|
-
/**
|
|
875
|
-
*
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
945
|
+
/** Rhyme chip for a line: the end-rhyme scheme letter with its rhyme TYPE
|
|
946
|
+
* (e.g. "A(perfect)"; '·' = unrhymed), PLUS any pre-caesural INTERNAL rhymes,
|
|
947
|
+
* each parenthesised and cyan with its own type, shown before the end letter:
|
|
948
|
+
* e.g. "(C)(perfect) A(perfect)". */
|
|
949
|
+
function rhymeStr(detail: {
|
|
950
|
+
rhyme?: { letter: string; type?: string; internal?: { letter: string; type?: string }[] };
|
|
951
|
+
}): string {
|
|
952
|
+
const r = detail.rhyme;
|
|
953
|
+
if (!r) return '';
|
|
954
|
+
const parts: string[] = [];
|
|
955
|
+
for (const iw of r.internal ?? []) {
|
|
956
|
+
parts.push(chalk.cyan(`(${iw.letter})`) + (iw.type ? chalk.dim(`(${iw.type})`) : ''));
|
|
957
|
+
}
|
|
958
|
+
if (r.letter && r.letter !== '·') {
|
|
959
|
+
parts.push(chalk.yellowBright(r.letter) + (r.type ? chalk.dim(`(${r.type})`) : ''));
|
|
960
|
+
} else if (parts.length === 0) {
|
|
961
|
+
parts.push(chalk.dim('·'));
|
|
962
|
+
}
|
|
963
|
+
return ' ' + parts.join(' ');
|
|
881
964
|
}
|
|
882
965
|
|
|
883
966
|
/** Non-punctuation, syllable-bearing words across all of a line's sentences. */
|
|
@@ -891,32 +974,102 @@ function collectLineWords(ln: ReadingLine): ClsWord[] {
|
|
|
891
974
|
return ws;
|
|
892
975
|
}
|
|
893
976
|
|
|
977
|
+
/**
|
|
978
|
+
* The Phonopoetics block of the synopsis: end / caesural / head rhymes (each
|
|
979
|
+
* letter coloured by the strongest relative-stress tier it spans), alliteration,
|
|
980
|
+
* and acrostics. Only subsections actually present in the poem are shown.
|
|
981
|
+
*/
|
|
982
|
+
function renderPhonopoetics(p: Phonopoetics): string[] {
|
|
983
|
+
// a rhyme pair "word [A|L1(|kind)] -> word [A|L4]", letter tinted by top stress
|
|
984
|
+
const rel = (r: RhymeRel): string => {
|
|
985
|
+
const L = relColour(r.topStress)(r.letter);
|
|
986
|
+
const D = chalk.dim;
|
|
987
|
+
const kindTag = r.kind === 'end' ? '' : D('|' + r.kind);
|
|
988
|
+
const typ = r.type ? D(` ${r.type}`) : '';
|
|
989
|
+
return chalk.white(r.fromWord) + ' ' + D('[') + L + D('|') + D(r.fromLabel) + kindTag + D(']')
|
|
990
|
+
+ D(' → ') + chalk.white(r.toWord) + ' ' + D('[') + L + D('|') + D(r.toLabel) + D(']') + typ;
|
|
991
|
+
};
|
|
992
|
+
const SEP = chalk.dim(' · ');
|
|
993
|
+
const sub: { label: string; body: string }[] = [];
|
|
994
|
+
if (p.end.length) sub.push({ label: 'End-Rhymes', body: p.end.map(rel).join(SEP) });
|
|
995
|
+
if (p.caesural.length) sub.push({ label: 'Caesural Rhymes', body: p.caesural.map(rel).join(SEP) });
|
|
996
|
+
if (p.head.length) sub.push({ label: 'Head Rhymes', body: p.head.map(rel).join(SEP) });
|
|
997
|
+
if (p.alliteration.length) sub.push({
|
|
998
|
+
label: 'Alliteration',
|
|
999
|
+
body: p.alliteration.map(a => chalk.white(a.words.join(' ')) + chalk.dim(` (${a.label})`)).join(SEP),
|
|
1000
|
+
});
|
|
1001
|
+
if (p.acrostics.length) sub.push({
|
|
1002
|
+
label: 'Acrostic',
|
|
1003
|
+
body: p.acrostics.map(a =>
|
|
1004
|
+
a.firsts.map((f, i) => chalk.dim('[' + a.labels[i] + ':') + chalk.whiteBright(f) + chalk.dim(']')).join('')
|
|
1005
|
+
+ chalk.dim(' → ') + chalk.yellowBright(a.word)).join(SEP),
|
|
1006
|
+
});
|
|
1007
|
+
if (sub.length === 0) return [];
|
|
1008
|
+
|
|
1009
|
+
const out: string[] = ['', chalk.bold.cyan('Phonopoetics:')];
|
|
1010
|
+
const w = Math.max(...sub.map(s => s.label.length)) + 2;
|
|
1011
|
+
for (const s of sub) out.push(' ' + chalk.bold((s.label + ':').padEnd(w)) + s.body);
|
|
1012
|
+
return out;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
894
1015
|
/**
|
|
895
1016
|
* Reading view: the poem itself in its original formatting, each syllable
|
|
896
1017
|
* coloured by 4-tier relative stress, followed by a same-structure block of
|
|
897
1018
|
* per-line stress maps + meter (with top-3 fit scores). This is the whole
|
|
898
1019
|
* output for this mode — not the full per-line analytic dump.
|
|
899
1020
|
*/
|
|
1021
|
+
/** A verse line CLOSED by terminal or clause punctuation is END-STOPPED (a
|
|
1022
|
+
* prosodic pause at the line break); one ending on a word with no boundary
|
|
1023
|
+
* punctuation RUNS ON — enjambment — its intonational unit spilling into the
|
|
1024
|
+
* next line. (Trailing quotes/brackets are ignored when judging the close.) */
|
|
1025
|
+
function lineRunsOn(raw: string): boolean {
|
|
1026
|
+
const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
|
|
1027
|
+
if (!t) return false;
|
|
1028
|
+
return !/[.!?;:,—–…]$/.test(t);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Poem-wide enjambment summary (end-stopped vs run-on line-ends), or null for
|
|
1032
|
+
* a single line. The final line is terminal by position, so only the
|
|
1033
|
+
* line-INTERNAL breaks (lines 1..n-1) are judged. */
|
|
1034
|
+
function summariseEnjambment(stanzas: ReadingStanza[]): string | null {
|
|
1035
|
+
const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
|
|
1036
|
+
if (raws.length < 2) return null;
|
|
1037
|
+
const interior = raws.slice(0, -1);
|
|
1038
|
+
const enjambed: number[] = [];
|
|
1039
|
+
interior.forEach((r, i) => { if (lineRunsOn(r)) enjambed.push(i + 1); });
|
|
1040
|
+
const n = interior.length, k = enjambed.length;
|
|
1041
|
+
if (k === 0) return 'end-stopped throughout';
|
|
1042
|
+
const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
|
|
1043
|
+
return k >= Math.ceil(n / 2)
|
|
1044
|
+
? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
|
|
1045
|
+
: `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
900
1048
|
export function renderReadingView(stanzas: ReadingStanza[]): string {
|
|
901
1049
|
const out: string[] = [];
|
|
902
1050
|
const multiStanza = stanzas.length > 1;
|
|
903
1051
|
|
|
904
1052
|
out.push('');
|
|
905
1053
|
out.push(HR);
|
|
906
|
-
out.push(H1('Reading View') + chalk.dim(' — stress gradient over
|
|
1054
|
+
out.push(H1('Reading View') + chalk.dim(' — stress gradient over input text'));
|
|
907
1055
|
out.push('');
|
|
908
1056
|
|
|
909
1057
|
// ── Block 1: the poem, original formatting, syllables coloured ──
|
|
1058
|
+
// Multi-stanza poems get a right-aligned "Stanza N" counter in the blank line
|
|
1059
|
+
// before each stanza after the first (the gaps between stanzas).
|
|
910
1060
|
for (let s = 0; s < stanzas.length; s++) {
|
|
1061
|
+
if (multiStanza && s > 0) {
|
|
1062
|
+
out.push('');
|
|
1063
|
+
out.push(chalk.dim.italic(('Stanza ' + (s + 1)).padStart(HR.length)));
|
|
1064
|
+
}
|
|
911
1065
|
for (const ln of stanzas[s].lines) {
|
|
912
1066
|
out.push(projectStressOntoLine(ln.raw, collectLineWords(ln)));
|
|
913
1067
|
}
|
|
914
|
-
if (s < stanzas.length - 1) out.push('');
|
|
915
1068
|
}
|
|
916
1069
|
|
|
917
1070
|
out.push('');
|
|
918
1071
|
out.push(HR_THIN);
|
|
919
|
-
out.push(H1('Stress Maps &
|
|
1072
|
+
out.push(H1('Stress Maps, Meter, & Rhymes') + chalk.dim(' — top-3 fit scores per line'));
|
|
920
1073
|
out.push('');
|
|
921
1074
|
|
|
922
1075
|
// ── Block 2: stress maps + meter, same stanza/line structure ──
|
|
@@ -939,16 +1092,47 @@ export function renderReadingView(stanzas: ReadingStanza[]): string {
|
|
|
939
1092
|
const caesurae = computeCaesurae(res.sentence.words, res.phonologicalHierarchy, d.scansion);
|
|
940
1093
|
const map = colourScansionMap(d.scansion, caesurae);
|
|
941
1094
|
const rank = formatRanking(d.ranking);
|
|
942
|
-
out.push(' ' + chalk.bold(label.padEnd(8)) + map + '
|
|
943
|
-
|
|
1095
|
+
out.push(' ' + chalk.bold(label.padEnd(8)) + map + ' ' +
|
|
1096
|
+
colourMeterLabel(d.meter) + (rank ? chalk.dim(' | ') + rank : '') + consensusNote(d) + rhythmNoteStr(d) + rhymeStr(d));
|
|
944
1097
|
}
|
|
945
1098
|
}
|
|
946
1099
|
if (multiStanza && s < stanzas.length - 1) out.push('');
|
|
947
1100
|
}
|
|
948
1101
|
|
|
1102
|
+
// ── Block 3: Legend ──
|
|
1103
|
+
// Kept ABOVE the synopsis: the legend serves the Stress Maps & Meter, and the
|
|
1104
|
+
// Phonopoetics subsection of the synopsis below can run long — left at the
|
|
1105
|
+
// bottom it gets pushed out of the field of view.
|
|
949
1106
|
out.push('');
|
|
950
1107
|
out.push(HR_THIN);
|
|
951
1108
|
out.push(renderLegend());
|
|
1109
|
+
|
|
1110
|
+
// ── Block 4: cumulative poem synopsis (non-interfering meta-measure) ──
|
|
1111
|
+
// Several top conclusions about the poem as a whole, drawn only from the
|
|
1112
|
+
// per-line determinations above — never overriding any of them.
|
|
1113
|
+
const synopsis = summarizePoem(stanzas.map(st => st.lines.flatMap(l => l.results)));
|
|
1114
|
+
if (synopsis.length > 0) {
|
|
1115
|
+
out.push('');
|
|
1116
|
+
out.push(HR_THIN);
|
|
1117
|
+
out.push(H1('Poem Synopsis') + chalk.dim(' In short, we have:'));
|
|
1118
|
+
out.push('');
|
|
1119
|
+
const w = Math.max(...synopsis.map(r => r.label.length)) + 2;
|
|
1120
|
+
for (const row of synopsis) {
|
|
1121
|
+
const label = chalk.bold.cyan((row.label + ':').padEnd(w));
|
|
1122
|
+
// Colour the value so the block is not a wall of white: tint any metre
|
|
1123
|
+
// names their family hue, and highlight the mean-fit %.
|
|
1124
|
+
let val = tintMeterNames(row.value);
|
|
1125
|
+
if (row.label === 'Meter') val = val.replace(/~\d+%/, (m) => chalk.yellow(m));
|
|
1126
|
+
out.push(' ' + label + val);
|
|
1127
|
+
}
|
|
1128
|
+
// Enjambment / end-stop — a poem-wide reading of the line-ends.
|
|
1129
|
+
const enj = summariseEnjambment(stanzas);
|
|
1130
|
+
if (enj) out.push(' ' + chalk.bold.cyan('Enjambment:'.padEnd(w)) + chalk.dim(enj));
|
|
1131
|
+
// Phonopoetics — end / caesural / head rhymes, alliteration, acrostic.
|
|
1132
|
+
out.push(...renderPhonopoetics(analyzePhonopoetics(stanzas.map(st => st.lines.flatMap(l => l.results)))));
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
out.push('');
|
|
952
1136
|
out.push(HR);
|
|
953
1137
|
return out.join('\n');
|
|
954
1138
|
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// engine.ts — the prosody-engine abstraction (type-only; no runtime deps, so
|
|
2
|
+
// the concrete engines can import this without a cycle).
|
|
3
|
+
//
|
|
4
|
+
// Two engines produce the SAME shape — a per-sentence prosodic hierarchy with
|
|
5
|
+
// lexical/phrase/relative stress populated on the words:
|
|
6
|
+
// • "calliope" — the faithful, default, syntax-driven rebuild (Match-Theory
|
|
7
|
+
// hierarchy + Scenario A–O relation-keyed stress);
|
|
8
|
+
// • "clio" — a frozen snapshot of the prior pipeline, the legacy /
|
|
9
|
+
// alternative parse, selectable via the CLI.
|
|
10
|
+
// Everything downstream (metrical scoring, rhyme/form, display, synopsis) is
|
|
11
|
+
// shared and engine-agnostic.
|
|
12
|
+
|
|
13
|
+
import { ClsSentence, IntonationalUnit } from './types.js';
|
|
14
|
+
|
|
15
|
+
export type EngineName = 'calliope' | 'clio';
|
|
16
|
+
|
|
17
|
+
export interface ProsodyEngine {
|
|
18
|
+
readonly name: EngineName;
|
|
19
|
+
/** Populate lexical/phrase/relative stress on `sent.words` and return the
|
|
20
|
+
* sentence's intonational units (the prosodic hierarchy). */
|
|
21
|
+
analyzeSentence(sent: ClsSentence): IntonationalUnit[];
|
|
22
|
+
}
|