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/dist/display.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { isPunctuation } from './parser.js';
|
|
5
5
|
import { syllabifyWord, syllableVowelLengths } from './phonological.js';
|
|
6
|
+
import { computeCaesurae } from './caesura.js';
|
|
7
|
+
import { computeBoundaries } from './calliope/boundaries.js';
|
|
8
|
+
import { summarizePoem, analyzePhonopoetics } from './rhyme.js';
|
|
6
9
|
// ═══════════════════════════════════════════════════════════════════════
|
|
7
10
|
// COLOUR SYSTEM — Conceptually motivated palettes
|
|
8
11
|
// ═══════════════════════════════════════════════════════════════════════
|
|
@@ -51,6 +54,38 @@ const B_CAESURA = chalk.whiteBright.bold; // hard caesura (overt: punctuation /
|
|
|
51
54
|
const B_CAESURA_SOFT = chalk.cyan.dim; // inferred caesura (phonological-phrase pause)
|
|
52
55
|
const B_FOOT = chalk.gray;
|
|
53
56
|
const B_SILENT = chalk.gray.dim;
|
|
57
|
+
// ── Graded boundary-strength colour (Wagner Ch.4–5): cold blue (weak) → warm red
|
|
58
|
+
// (strong). The relational grid says boundaries differ in DEGREE, not just kind, so
|
|
59
|
+
// ϕ/ι brackets are tinted along a continuous spectrum by their NSBR-scaled strength
|
|
60
|
+
// (boundaries.ts). κ (clitic-group) boundaries are the weakest tier — a constant dim
|
|
61
|
+
// blue. This makes the boundary-strength dimension VISIBLE in the bracketing view.
|
|
62
|
+
const GRAD_STOPS = [
|
|
63
|
+
[0.00, [0x6a, 0x8c, 0xc7]], // cold blue
|
|
64
|
+
[0.30, [0x5f, 0xc7, 0xc0]], // teal
|
|
65
|
+
[0.55, [0xd9, 0xc2, 0x4d]], // yellow
|
|
66
|
+
[0.78, [0xe0, 0x91, 0x3f]], // orange
|
|
67
|
+
[1.00, [0xe0, 0x56, 0x4b]], // red
|
|
68
|
+
];
|
|
69
|
+
function gradHex(t) {
|
|
70
|
+
const x = Math.max(0, Math.min(1, t));
|
|
71
|
+
for (let i = 1; i < GRAD_STOPS.length; i++) {
|
|
72
|
+
const [t1, c1] = GRAD_STOPS[i - 1];
|
|
73
|
+
const [t2, c2] = GRAD_STOPS[i];
|
|
74
|
+
if (x <= t2) {
|
|
75
|
+
const f = t2 === t1 ? 0 : (x - t1) / (t2 - t1);
|
|
76
|
+
const r = Math.round(c1[0] + (c2[0] - c1[0]) * f);
|
|
77
|
+
const g = Math.round(c1[1] + (c2[1] - c1[1]) * f);
|
|
78
|
+
const b = Math.round(c1[2] + (c2[2] - c1[2]) * f);
|
|
79
|
+
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return '#e0564b';
|
|
83
|
+
}
|
|
84
|
+
/** Colour for a ϕ/ι bracket given its boundary strength (0..1). */
|
|
85
|
+
function boundaryColour(strength) {
|
|
86
|
+
return (s) => chalk.hex(gradHex(strength))(s);
|
|
87
|
+
}
|
|
88
|
+
const B_KAPPA = (s) => chalk.hex('#5a6f9e').dim(s); // κ — weakest, dim blue
|
|
54
89
|
// Word roles
|
|
55
90
|
const W_CONTENT = chalk.white;
|
|
56
91
|
const W_FUNCTION = chalk.gray;
|
|
@@ -75,7 +110,7 @@ function buildColSyls(words, ius) {
|
|
|
75
110
|
continue;
|
|
76
111
|
const dep = w.dependency;
|
|
77
112
|
const sylCount = w.syllables.length;
|
|
78
|
-
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
|
|
113
|
+
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
|
|
79
114
|
for (let si = 0; si < sylCount; si++) {
|
|
80
115
|
const syl = w.syllables[si];
|
|
81
116
|
const lex = syl.lexicalStress ?? syl.stress;
|
|
@@ -106,10 +141,31 @@ function buildColSyls(words, ius) {
|
|
|
106
141
|
wordRef: w,
|
|
107
142
|
});
|
|
108
143
|
}
|
|
144
|
+
// A 0-syllable possessive enclitic ('s) has no syllable column of its own;
|
|
145
|
+
// append its surface to the preceding syllable so "Nature's" renders WITH its
|
|
146
|
+
// 's instead of as bare "Nature" (and so its κ-boundary does not collapse into
|
|
147
|
+
// the next group — the "Nature first" mis-bracketing the maintainer flagged).
|
|
148
|
+
if (sylCount === 0 && w.lexicalClass === 'POS' && result.length > 0) {
|
|
149
|
+
result[result.length - 1].chunk += w.word;
|
|
150
|
+
}
|
|
109
151
|
}
|
|
110
152
|
}
|
|
111
153
|
}
|
|
112
154
|
}
|
|
155
|
+
// Bracket-boundary flags via look-around over the SYLLABLE-bearing columns, so a
|
|
156
|
+
// 0-syllable token (possessive 's, an elided clitic) can never swallow a κ/ϕ/ι
|
|
157
|
+
// boundary: a column is first/last in its unit when the adjacent column belongs to a
|
|
158
|
+
// different unit. (Composite key, since cpId/ppId are indices LOCAL to their parent.)
|
|
159
|
+
const uKey = (c, lvl) => lvl === 'cp' ? `${c.iuId}.${c.ppId}.${c.cpId}` : lvl === 'pp' ? `${c.iuId}.${c.ppId}` : `${c.iuId}`;
|
|
160
|
+
for (let i = 0; i < result.length; i++) {
|
|
161
|
+
const cur = result[i], prev = result[i - 1], next = result[i + 1];
|
|
162
|
+
result[i].isFirstInCP = !prev || uKey(prev, 'cp') !== uKey(cur, 'cp');
|
|
163
|
+
result[i].isLastInCP = !next || uKey(next, 'cp') !== uKey(cur, 'cp');
|
|
164
|
+
result[i].isFirstInPP = !prev || uKey(prev, 'pp') !== uKey(cur, 'pp');
|
|
165
|
+
result[i].isLastInPP = !next || uKey(next, 'pp') !== uKey(cur, 'pp');
|
|
166
|
+
result[i].isFirstInIU = !prev || uKey(prev, 'iu') !== uKey(cur, 'iu');
|
|
167
|
+
result[i].isLastInIU = !next || uKey(next, 'iu') !== uKey(cur, 'iu');
|
|
168
|
+
}
|
|
113
169
|
return result;
|
|
114
170
|
}
|
|
115
171
|
// ═══════════════════════════════════════════════════════════════════════
|
|
@@ -181,6 +237,32 @@ export function renderUnifiedDisplay(result, rawLine) {
|
|
|
181
237
|
}
|
|
182
238
|
lines.push(' ' + lexParts.join(''));
|
|
183
239
|
lines.push('');
|
|
240
|
+
// ── Layer 3b: Phrase stress (genuine cyclic Compound + Nuclear Stress Rules) ───
|
|
241
|
+
// The real phrase-stress stage (bracketing.ts): the SPE/Hayes cyclic CSR (compound
|
|
242
|
+
// → primary LEFT) + NSR (phrase → primary RIGHT) over the dependency tree's
|
|
243
|
+
// constituent bracketing. 1 = STRONGEST (the utterance nuclear); higher = weaker.
|
|
244
|
+
// Reproduces "Mary 2, ate 3, sweet 4, ice 1, cream 5". An integer prominence
|
|
245
|
+
// ranking, computed INDEPENDENTLY of the relative contour below.
|
|
246
|
+
lines.push(H1('Phrase Stress') + ' ' + chalk.dim('1 = strongest (utterance nuclear) → higher = weaker · 0 = none'));
|
|
247
|
+
lines.push('');
|
|
248
|
+
const phrParts = [];
|
|
249
|
+
for (const cs of colSyls) {
|
|
250
|
+
if (cs.isFirstInWord && cs.globalIdx > 0)
|
|
251
|
+
phrParts.push(' ');
|
|
252
|
+
if (cs.isFirstInWord) {
|
|
253
|
+
const ps = cs.wordRef.phraseStress || 0;
|
|
254
|
+
const colour = ps === 0 ? chalk.dim
|
|
255
|
+
: ps === 1 ? chalk.cyanBright // the utterance nuclear (strongest)
|
|
256
|
+
: ps <= 3 ? chalk.cyan // strong
|
|
257
|
+
: chalk.dim; // weak / deeply demoted
|
|
258
|
+
phrParts.push(colour(String(ps)));
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
phrParts.push(' '); // continuation syllable — keep word-start alignment
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
lines.push(' ' + phrParts.join(''));
|
|
265
|
+
lines.push('');
|
|
184
266
|
// ── Layer 4: Relative stress (w/n/m/s) ──────────────────────────
|
|
185
267
|
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)');
|
|
186
268
|
lines.push('');
|
|
@@ -192,37 +274,52 @@ export function renderUnifiedDisplay(result, rawLine) {
|
|
|
192
274
|
}
|
|
193
275
|
lines.push(' ' + relParts.join(''));
|
|
194
276
|
lines.push('');
|
|
195
|
-
// ── Layer 5: Phonological bracketing
|
|
196
|
-
lines.push(H1('Phonological Bracketing') + ' ' +
|
|
277
|
+
// ── Layer 5: Phonological bracketing (graded by boundary strength) ──────
|
|
278
|
+
lines.push(H1('Phonological Bracketing') + ' ' + B_KAPPA('[]') + ' κ ' + B_PP('{}') + ' ϕ ' + B_IU('<>') + ' ι' +
|
|
279
|
+
chalk.dim(' — ϕ/ι tint: ') + boundaryColour(0.1)('weak') + chalk.dim('→') + boundaryColour(1)('strong'));
|
|
197
280
|
lines.push('');
|
|
281
|
+
// Graded boundary strengths (NSBR, boundaries.ts), zipped to the ϕ/ι opens as we
|
|
282
|
+
// walk the syllable columns: each ϕ is tinted by the strength of the break that
|
|
283
|
+
// introduced it (its left-edge boundary); κ stays the weakest dim-blue tier.
|
|
284
|
+
const bounds = computeBoundaries(words, ius);
|
|
285
|
+
let phiOrd = -1;
|
|
286
|
+
let ppColourFn = B_PP;
|
|
287
|
+
let iuColourFn = B_IU;
|
|
198
288
|
const sylParts = [];
|
|
199
289
|
let iuOpen = false, ppOpen = false, cpOpen = false;
|
|
200
290
|
for (const cs of colSyls) {
|
|
291
|
+
if (cs.isFirstInPP) {
|
|
292
|
+
phiOrd++;
|
|
293
|
+
const st = bounds.phi[phiOrd]?.strength ?? 0;
|
|
294
|
+
ppColourFn = boundaryColour(st);
|
|
295
|
+
if (cs.isFirstInIU)
|
|
296
|
+
iuColourFn = boundaryColour(st);
|
|
297
|
+
}
|
|
201
298
|
if (cs.isFirstInIU && !iuOpen) {
|
|
202
|
-
sylParts.push(
|
|
299
|
+
sylParts.push(iuColourFn('<'));
|
|
203
300
|
iuOpen = true;
|
|
204
301
|
}
|
|
205
302
|
if (cs.isFirstInPP && !ppOpen) {
|
|
206
|
-
sylParts.push(
|
|
303
|
+
sylParts.push(ppColourFn('{'));
|
|
207
304
|
ppOpen = true;
|
|
208
305
|
}
|
|
209
306
|
if (cs.isFirstInCP && !cpOpen) {
|
|
210
|
-
sylParts.push(
|
|
307
|
+
sylParts.push(B_KAPPA('['));
|
|
211
308
|
cpOpen = true;
|
|
212
309
|
}
|
|
213
310
|
if (cs.isFirstInWord && cs.globalIdx > 0)
|
|
214
311
|
sylParts.push(' ');
|
|
215
312
|
sylParts.push(relColour(cs.relStress)(cs.chunk));
|
|
216
313
|
if (cs.isLastInCP && cpOpen) {
|
|
217
|
-
sylParts.push(
|
|
314
|
+
sylParts.push(B_KAPPA(']'));
|
|
218
315
|
cpOpen = false;
|
|
219
316
|
}
|
|
220
317
|
if (cs.isLastInPP && ppOpen) {
|
|
221
|
-
sylParts.push(
|
|
318
|
+
sylParts.push(ppColourFn('}'));
|
|
222
319
|
ppOpen = false;
|
|
223
320
|
}
|
|
224
321
|
if (cs.isLastInIU && iuOpen) {
|
|
225
|
-
sylParts.push(
|
|
322
|
+
sylParts.push(iuColourFn('>'));
|
|
226
323
|
iuOpen = false;
|
|
227
324
|
}
|
|
228
325
|
}
|
|
@@ -238,7 +335,7 @@ export function renderUnifiedDisplay(result, rawLine) {
|
|
|
238
335
|
if (isPunctuation(w.lexicalClass))
|
|
239
336
|
continue;
|
|
240
337
|
const sylCount = w.syllables.length;
|
|
241
|
-
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
|
|
338
|
+
const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
|
|
242
339
|
for (let si = 0; si < sylCount; si++) {
|
|
243
340
|
linearSyls.push({
|
|
244
341
|
chunk: chunks[si] || w.word,
|
|
@@ -253,8 +350,20 @@ export function renderUnifiedDisplay(result, rawLine) {
|
|
|
253
350
|
function isSyllableChar(ch) {
|
|
254
351
|
return 'xXwWnNmMsS'.includes(ch);
|
|
255
352
|
}
|
|
353
|
+
// Feet whose right edge carries a caesura take the caesura GLYPH as their
|
|
354
|
+
// separator (matching the reading view's "xs ‖ xnw"), never a doubled "‖ |".
|
|
355
|
+
const joinFeet = (feet, caesAfter) => {
|
|
356
|
+
let out = '';
|
|
357
|
+
for (let i = 0; i < feet.length; i++) {
|
|
358
|
+
out += feet[i];
|
|
359
|
+
if (i < feet.length - 1)
|
|
360
|
+
out += caesAfter[i] ? ' ' : B_FOOT(' | ');
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
};
|
|
256
364
|
let sylIdx = 0;
|
|
257
365
|
const footDisplays = [];
|
|
366
|
+
const footCaes = [];
|
|
258
367
|
let prevWordRef = null;
|
|
259
368
|
for (const rawFoot of feetRaw) {
|
|
260
369
|
let footOut = '';
|
|
@@ -277,10 +386,12 @@ export function renderUnifiedDisplay(result, rawLine) {
|
|
|
277
386
|
const ck = caesurae.get(sylIdx);
|
|
278
387
|
if (ck)
|
|
279
388
|
footOut += ' ' + caesuraGlyph(ck);
|
|
389
|
+
footCaes.push(!!ck);
|
|
280
390
|
footDisplays.push(footOut);
|
|
281
391
|
}
|
|
282
|
-
lines.push(' ' + H2('Feet: ') + footDisplays
|
|
392
|
+
lines.push(' ' + H2('Feet: ') + joinFeet(footDisplays, footCaes));
|
|
283
393
|
const stressDisplays = [];
|
|
394
|
+
const stressCaes = [];
|
|
284
395
|
let rIdx = 0;
|
|
285
396
|
for (const rawFoot of feetRaw) {
|
|
286
397
|
let s = '';
|
|
@@ -299,9 +410,10 @@ export function renderUnifiedDisplay(result, rawLine) {
|
|
|
299
410
|
const ck2 = caesurae.get(rIdx);
|
|
300
411
|
if (ck2)
|
|
301
412
|
s += ' ' + caesuraGlyph(ck2);
|
|
413
|
+
stressCaes.push(!!ck2);
|
|
302
414
|
stressDisplays.push(s);
|
|
303
415
|
}
|
|
304
|
-
lines.push(' ' + H2('Stress: ') + stressDisplays
|
|
416
|
+
lines.push(' ' + H2('Stress: ') + joinFeet(stressDisplays, stressCaes));
|
|
305
417
|
lines.push('');
|
|
306
418
|
// ── Layer 7: Dependencies ───────────────────────────────────────
|
|
307
419
|
lines.push(H1('Dependencies'));
|
|
@@ -550,7 +662,7 @@ function normWordForm(s) {
|
|
|
550
662
|
/** Colour each orthographic syllable of an original token by its relative stress. */
|
|
551
663
|
function colourToken(tokenText, word) {
|
|
552
664
|
const sylCount = Math.max(1, word.syllables.length);
|
|
553
|
-
const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix); // partitions the WHOLE token
|
|
665
|
+
const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix, word.morphPrefix); // partitions the WHOLE token
|
|
554
666
|
const stresses = chunks.map((_, i) => word.syllables[i]?.relativeStress ?? 'w');
|
|
555
667
|
// Fast path: chunks reconstruct the token exactly (the common case).
|
|
556
668
|
if (chunks.join('') === tokenText) {
|
|
@@ -633,113 +745,17 @@ export function projectStressOntoLine(rawLine, words) {
|
|
|
633
745
|
out += rawLine.slice(cursor); // trailing text, verbatim
|
|
634
746
|
return out;
|
|
635
747
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
return set;
|
|
648
|
-
}
|
|
649
|
-
// A new phonological/syntactic phrase opens at these POS tags: prepositions &
|
|
650
|
-
// subordinators (IN), infinitival "to" (TO), coordinators (CC), wh/relativizers,
|
|
651
|
-
// verb-particles (RP), and the predicate's verb/modal. The major medial caesura
|
|
652
|
-
// of a line falls immediately BEFORE such a word — far more reliably read off the
|
|
653
|
-
// (robust) POS tags than off FinNLP's (noisy) phonological-phrase grouping, which
|
|
654
|
-
// mis-bracketed e.g. "The epic | feast…". Determiners/articles are excluded (they
|
|
655
|
-
// continue a phrase a preposition already opened: "as | one empty bag").
|
|
656
|
-
const PHRASE_ONSET_POS = new Set([
|
|
657
|
-
'IN', 'TO', 'CC', 'WDT', 'WP', 'WP$', 'WRB', 'RP',
|
|
658
|
-
'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', 'MD',
|
|
659
|
-
]);
|
|
660
|
-
// Directional/spatial adverbs that open a phrase ("snowed-in OUT of many routes",
|
|
661
|
-
// "drifting DOWN to sleep"). FinNLP often tags these RB rather than RP/IN, so the
|
|
662
|
-
// POS set alone misses them; a small curated lemma list recovers them with low
|
|
663
|
-
// over-fire risk (a generic RB like "very"/"quickly" is NOT a phrase onset).
|
|
664
|
-
const DIRECTIONAL_ONSET = new Set([
|
|
665
|
-
'out', 'in', 'up', 'down', 'off', 'away', 'back', 'forth', 'over',
|
|
666
|
-
'around', 'along', 'through', 'apart', 'aside', 'onward', 'onwards',
|
|
667
|
-
]);
|
|
668
|
-
/** True if a word opens a new phonological/syntactic phrase (a caesura candidate). */
|
|
669
|
-
function isPhraseOnset(w) {
|
|
670
|
-
if (PHRASE_ONSET_POS.has(w.lexicalClass))
|
|
671
|
-
return true;
|
|
672
|
-
return w.lexicalClass === 'RB' && DIRECTIONAL_ONSET.has(w.word.toLowerCase());
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Caesura positions for a line, keyed by the syllable index AFTER which the pause
|
|
676
|
-
* falls (= number of syllables to its left).
|
|
677
|
-
* • 'hard' — an overt break at an Intonational-Unit boundary (comma, dash, colon,
|
|
678
|
-
* semicolon …): the punctuation projection.
|
|
679
|
-
* • 'soft' — a single INFERRED medial caesura for a punctuation-free line (Kiparsky
|
|
680
|
-
* 1975, via McAleese: "phonological phrasing determines the location of caesurae
|
|
681
|
-
* in verse"). Candidates are the boundaries just before a phrase/clause onset
|
|
682
|
-
* (PHRASE_ONSET_POS); the one nearest the line's midpoint that lies in the
|
|
683
|
-
* central third AND coincides with a foot boundary wins — so it is medial, never
|
|
684
|
-
* mid-foot, and consistent across structurally-parallel lines. Read in LINEAR
|
|
685
|
-
* order (robust to clitic-group reordering); needs a line of ≥ 8 syllables.
|
|
686
|
-
*/
|
|
687
|
-
function computeCaesurae(words, ius, scansion) {
|
|
688
|
-
const caes = new Map();
|
|
689
|
-
const iuOf = new Map();
|
|
690
|
-
for (let i = 0; i < ius.length; i++) {
|
|
691
|
-
for (const pp of ius[i].phonologicalPhrases) {
|
|
692
|
-
for (const cg of pp.cliticGroups)
|
|
693
|
-
for (const tok of cg.tokens)
|
|
694
|
-
iuOf.set(tok, i);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
let cum = 0;
|
|
698
|
-
let prevIu;
|
|
699
|
-
let prevWasContentful = false;
|
|
700
|
-
const onsetPositions = [];
|
|
701
|
-
for (const w of words) {
|
|
702
|
-
if (isPunctuation(w.lexicalClass) || w.syllables.length === 0)
|
|
703
|
-
continue;
|
|
704
|
-
const iu = iuOf.get(w);
|
|
705
|
-
if (prevIu !== undefined && iu !== undefined && iu !== prevIu) {
|
|
706
|
-
caes.set(cum, 'hard'); // IU boundary → hard caesura
|
|
707
|
-
}
|
|
708
|
-
// A phrase-onset word that is NOT the line's first word opens a candidate
|
|
709
|
-
// caesura immediately before it.
|
|
710
|
-
if (prevWasContentful && isPhraseOnset(w))
|
|
711
|
-
onsetPositions.push(cum);
|
|
712
|
-
cum += w.syllables.length;
|
|
713
|
-
prevIu = iu;
|
|
714
|
-
prevWasContentful = true;
|
|
715
|
-
}
|
|
716
|
-
const total = cum;
|
|
717
|
-
// Infer ONE medial caesura only when the line carries no overt (hard) break.
|
|
718
|
-
if (caes.size === 0 && total >= 8 && onsetPositions.length > 0) {
|
|
719
|
-
const footEdges = scansion ? footBoundarySet(scansion) : null;
|
|
720
|
-
const mid = total / 2;
|
|
721
|
-
const lo = Math.max(2, Math.ceil(total / 3));
|
|
722
|
-
const hi = Math.floor((2 * total) / 3);
|
|
723
|
-
let best = -1, bestDist = Infinity;
|
|
724
|
-
for (const c of onsetPositions) {
|
|
725
|
-
if (c < lo || c > hi)
|
|
726
|
-
continue; // medial third only
|
|
727
|
-
if (footEdges && !footEdges.has(c))
|
|
728
|
-
continue; // align to a foot boundary
|
|
729
|
-
const d = Math.abs(c - mid);
|
|
730
|
-
if (d < bestDist) {
|
|
731
|
-
bestDist = d;
|
|
732
|
-
best = c;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
if (best > 0)
|
|
736
|
-
caes.set(best, 'soft');
|
|
737
|
-
}
|
|
738
|
-
return caes;
|
|
739
|
-
}
|
|
740
|
-
/** Render the glyph for a caesura kind. */
|
|
741
|
-
function caesuraGlyph(kind) {
|
|
742
|
-
return kind === 'hard' ? B_CAESURA('‖') : B_CAESURA_SOFT('¦');
|
|
748
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
749
|
+
// CAESURA RENDERING (placement logic now lives in caesura.ts)
|
|
750
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
751
|
+
/** Render the glyph for a caesura, coloured by its boundary strength (Wagner
|
|
752
|
+
* Ch.4–5): a strong break is a warm-red '‖', a medium one an orange '¦', a weak
|
|
753
|
+
* one a dim '·' — the same cold-blue→warm-red spectrum as the brackets. */
|
|
754
|
+
function caesuraGlyph(info) {
|
|
755
|
+
const glyph = info.kind === 'hard' ? '‖' : '¦';
|
|
756
|
+
if (info.strength < 0.34)
|
|
757
|
+
return chalk.hex(gradHex(info.strength))('·');
|
|
758
|
+
return chalk.hex(gradHex(info.strength)).bold(glyph);
|
|
743
759
|
}
|
|
744
760
|
/** Colour a scansion string ("nws|nns|-wns") letter-by-letter, inserting caesura
|
|
745
761
|
* marks (at foot boundaries) when a caesura map is supplied. */
|
|
@@ -778,13 +794,60 @@ const METER_ABBR = {
|
|
|
778
794
|
amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
|
|
779
795
|
'free verse': 'free',
|
|
780
796
|
};
|
|
781
|
-
|
|
797
|
+
// ── Meter-family colours ───────────────────────────────────────────
|
|
798
|
+
// One consistent, legible LIGHT tone per metre family, reused EVERYWHERE a
|
|
799
|
+
// metre is named (the reading per-line meter, the top-3 ranking, and the
|
|
800
|
+
// synopsis). The foot-count label (pentameter / octameter…) stays white — we
|
|
801
|
+
// tint only the family word, so the output is informative without being gaudy.
|
|
802
|
+
const METER_HUE = {
|
|
803
|
+
iambic: chalk.hex('#7fb8ff'), // light blue
|
|
804
|
+
trochaic: chalk.hex('#ffc24d'), // yellow / orange
|
|
805
|
+
dactylic: chalk.hex('#88e0a0'), // mid / light green
|
|
806
|
+
amphibrachic: chalk.hex('#ff9ec4'), // pinkish
|
|
807
|
+
anapestic: chalk.hex('#ff7a6b'), // reddish
|
|
808
|
+
bacchic: chalk.hex('#c08be6'), // purple / wine
|
|
809
|
+
spondaic: chalk.hex('#b8b8b8'),
|
|
810
|
+
pyrrhic: chalk.hex('#b8b8b8'),
|
|
811
|
+
};
|
|
812
|
+
const METER_FALLBACK = chalk.hex('#cfd8e3'); // free verse / unknown
|
|
813
|
+
/** Tint a metre-family WORD (the first token of a metre name) by its hue. */
|
|
814
|
+
function meterFamilyColour(family) {
|
|
815
|
+
return METER_HUE[family.toLowerCase()] ?? METER_FALLBACK;
|
|
816
|
+
}
|
|
817
|
+
/** Colour a full metre label ("iambic pentameter"): family tinted, foot-count
|
|
818
|
+
* label left white. Bare "free verse" / multi-word non-families: fallback. */
|
|
819
|
+
function colourMeterLabel(meter) {
|
|
820
|
+
const sp = meter.indexOf(' ');
|
|
821
|
+
if (sp < 0)
|
|
822
|
+
return meterFamilyColour(meter)(meter);
|
|
823
|
+
const family = meter.slice(0, sp);
|
|
824
|
+
const hue = METER_HUE[family.toLowerCase()];
|
|
825
|
+
if (!hue)
|
|
826
|
+
return METER_FALLBACK(meter);
|
|
827
|
+
return hue(family) + chalk.whiteBright(meter.slice(sp));
|
|
828
|
+
}
|
|
829
|
+
/** Tint every metre-family word/abbreviation occurring inside a free-form
|
|
830
|
+
* string (used to colour the synopsis values without restructuring them).
|
|
831
|
+
* Longest-first so "iamb" inside "iambic" is not matched before the full word. */
|
|
832
|
+
const _METER_WORD_RE = /\b(iambic|trochaic|dactylic|amphibrachic|anapestic|bacchic|spondaic|pyrrhic|iamb|troch|dact|amph|anap|bacch|spon|pyrr)\b/gi;
|
|
833
|
+
function tintMeterNames(s) {
|
|
834
|
+
return s.replace(_METER_WORD_RE, (w) => {
|
|
835
|
+
const key = w.toLowerCase();
|
|
836
|
+
const fam = key.startsWith('iamb') ? 'iambic' : key.startsWith('troch') ? 'trochaic'
|
|
837
|
+
: key.startsWith('dact') ? 'dactylic' : key.startsWith('amph') ? 'amphibrachic'
|
|
838
|
+
: key.startsWith('anap') ? 'anapestic' : key.startsWith('bacch') ? 'bacchic'
|
|
839
|
+
: key.startsWith('spon') ? 'spondaic' : 'pyrrhic';
|
|
840
|
+
return meterFamilyColour(fam)(w);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
/** Compact top-3 meter fit scores, e.g. "anap 0.81 · iamb 0.77 · amph 0.74" —
|
|
844
|
+
* each family abbreviation tinted its hue, the score dimmed, no enclosing
|
|
845
|
+
* parentheses (set off from the meter name by a dim "|" at the call site). */
|
|
782
846
|
function formatRanking(ranking) {
|
|
783
847
|
if (!ranking || ranking.length === 0)
|
|
784
848
|
return '';
|
|
785
|
-
const top = ranking.slice(0, 3)
|
|
786
|
-
|
|
787
|
-
return chalk.dim('(' + top.join(' · ') + ')');
|
|
849
|
+
const top = ranking.slice(0, 3).map(r => meterFamilyColour(r.meter)(METER_ABBR[r.meter] ?? r.meter) + chalk.dim(' ' + r.score.toFixed(2)));
|
|
850
|
+
return top.join(chalk.dim(' · '));
|
|
788
851
|
}
|
|
789
852
|
/** Divergence notes. After the continuity rename, a near-tie line's BASE
|
|
790
853
|
* meter is already the stanza/poem-dominant one and `standaloneMeter` records
|
|
@@ -799,23 +862,35 @@ function consensusNote(detail) {
|
|
|
799
862
|
return '';
|
|
800
863
|
return chalk.dim.italic(` ↔ aligns w/ stanza ${detail.consensusMeter}`);
|
|
801
864
|
}
|
|
802
|
-
/** Non-classical rhythm annotation (dolnik / taktovik / accentual
|
|
803
|
-
*
|
|
804
|
-
*
|
|
865
|
+
/** Non-classical rhythm annotation (dolnik / taktovik / accentual), set by the
|
|
866
|
+
* rhythm layer. Shown as a separate chip AFTER the meter — it supplements the
|
|
867
|
+
* classical reading (in beats), it never replaces it. */
|
|
805
868
|
function rhythmNoteStr(detail) {
|
|
806
869
|
if (!detail.rhythmNote)
|
|
807
870
|
return '';
|
|
808
|
-
|
|
871
|
+
const note = detail.rhythmNote;
|
|
872
|
+
// Some notes (the 4/3 accentual) already carry a ♪; don't double it.
|
|
873
|
+
return chalk.magenta.dim(' ' + (note.includes('♪') ? note : '♪ ' + note));
|
|
809
874
|
}
|
|
810
|
-
/**
|
|
811
|
-
*
|
|
875
|
+
/** Rhyme chip for a line: the end-rhyme scheme letter with its rhyme TYPE
|
|
876
|
+
* (e.g. "A(perfect)"; '·' = unrhymed), PLUS any pre-caesural INTERNAL rhymes,
|
|
877
|
+
* each parenthesised and cyan with its own type, shown before the end letter:
|
|
878
|
+
* e.g. "(C)(perfect) A(perfect)". */
|
|
812
879
|
function rhymeStr(detail) {
|
|
813
|
-
|
|
880
|
+
const r = detail.rhyme;
|
|
881
|
+
if (!r)
|
|
814
882
|
return '';
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
883
|
+
const parts = [];
|
|
884
|
+
for (const iw of r.internal ?? []) {
|
|
885
|
+
parts.push(chalk.cyan(`(${iw.letter})`) + (iw.type ? chalk.dim(`(${iw.type})`) : ''));
|
|
886
|
+
}
|
|
887
|
+
if (r.letter && r.letter !== '·') {
|
|
888
|
+
parts.push(chalk.yellowBright(r.letter) + (r.type ? chalk.dim(`(${r.type})`) : ''));
|
|
889
|
+
}
|
|
890
|
+
else if (parts.length === 0) {
|
|
891
|
+
parts.push(chalk.dim('·'));
|
|
892
|
+
}
|
|
893
|
+
return ' ' + parts.join(' ');
|
|
819
894
|
}
|
|
820
895
|
/** Non-punctuation, syllable-bearing words across all of a line's sentences. */
|
|
821
896
|
function collectLineWords(ln) {
|
|
@@ -828,30 +903,105 @@ function collectLineWords(ln) {
|
|
|
828
903
|
}
|
|
829
904
|
return ws;
|
|
830
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* The Phonopoetics block of the synopsis: end / caesural / head rhymes (each
|
|
908
|
+
* letter coloured by the strongest relative-stress tier it spans), alliteration,
|
|
909
|
+
* and acrostics. Only subsections actually present in the poem are shown.
|
|
910
|
+
*/
|
|
911
|
+
function renderPhonopoetics(p) {
|
|
912
|
+
// a rhyme pair "word [A|L1(|kind)] -> word [A|L4]", letter tinted by top stress
|
|
913
|
+
const rel = (r) => {
|
|
914
|
+
const L = relColour(r.topStress)(r.letter);
|
|
915
|
+
const D = chalk.dim;
|
|
916
|
+
const kindTag = r.kind === 'end' ? '' : D('|' + r.kind);
|
|
917
|
+
const typ = r.type ? D(` ${r.type}`) : '';
|
|
918
|
+
return chalk.white(r.fromWord) + ' ' + D('[') + L + D('|') + D(r.fromLabel) + kindTag + D(']')
|
|
919
|
+
+ D(' → ') + chalk.white(r.toWord) + ' ' + D('[') + L + D('|') + D(r.toLabel) + D(']') + typ;
|
|
920
|
+
};
|
|
921
|
+
const SEP = chalk.dim(' · ');
|
|
922
|
+
const sub = [];
|
|
923
|
+
if (p.end.length)
|
|
924
|
+
sub.push({ label: 'End-Rhymes', body: p.end.map(rel).join(SEP) });
|
|
925
|
+
if (p.caesural.length)
|
|
926
|
+
sub.push({ label: 'Caesural Rhymes', body: p.caesural.map(rel).join(SEP) });
|
|
927
|
+
if (p.head.length)
|
|
928
|
+
sub.push({ label: 'Head Rhymes', body: p.head.map(rel).join(SEP) });
|
|
929
|
+
if (p.alliteration.length)
|
|
930
|
+
sub.push({
|
|
931
|
+
label: 'Alliteration',
|
|
932
|
+
body: p.alliteration.map(a => chalk.white(a.words.join(' ')) + chalk.dim(` (${a.label})`)).join(SEP),
|
|
933
|
+
});
|
|
934
|
+
if (p.acrostics.length)
|
|
935
|
+
sub.push({
|
|
936
|
+
label: 'Acrostic',
|
|
937
|
+
body: p.acrostics.map(a => a.firsts.map((f, i) => chalk.dim('[' + a.labels[i] + ':') + chalk.whiteBright(f) + chalk.dim(']')).join('')
|
|
938
|
+
+ chalk.dim(' → ') + chalk.yellowBright(a.word)).join(SEP),
|
|
939
|
+
});
|
|
940
|
+
if (sub.length === 0)
|
|
941
|
+
return [];
|
|
942
|
+
const out = ['', chalk.bold.cyan('Phonopoetics:')];
|
|
943
|
+
const w = Math.max(...sub.map(s => s.label.length)) + 2;
|
|
944
|
+
for (const s of sub)
|
|
945
|
+
out.push(' ' + chalk.bold((s.label + ':').padEnd(w)) + s.body);
|
|
946
|
+
return out;
|
|
947
|
+
}
|
|
831
948
|
/**
|
|
832
949
|
* Reading view: the poem itself in its original formatting, each syllable
|
|
833
950
|
* coloured by 4-tier relative stress, followed by a same-structure block of
|
|
834
951
|
* per-line stress maps + meter (with top-3 fit scores). This is the whole
|
|
835
952
|
* output for this mode — not the full per-line analytic dump.
|
|
836
953
|
*/
|
|
954
|
+
/** A verse line CLOSED by terminal or clause punctuation is END-STOPPED (a
|
|
955
|
+
* prosodic pause at the line break); one ending on a word with no boundary
|
|
956
|
+
* punctuation RUNS ON — enjambment — its intonational unit spilling into the
|
|
957
|
+
* next line. (Trailing quotes/brackets are ignored when judging the close.) */
|
|
958
|
+
function lineRunsOn(raw) {
|
|
959
|
+
const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
|
|
960
|
+
if (!t)
|
|
961
|
+
return false;
|
|
962
|
+
return !/[.!?;:,—–…]$/.test(t);
|
|
963
|
+
}
|
|
964
|
+
/** Poem-wide enjambment summary (end-stopped vs run-on line-ends), or null for
|
|
965
|
+
* a single line. The final line is terminal by position, so only the
|
|
966
|
+
* line-INTERNAL breaks (lines 1..n-1) are judged. */
|
|
967
|
+
function summariseEnjambment(stanzas) {
|
|
968
|
+
const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
|
|
969
|
+
if (raws.length < 2)
|
|
970
|
+
return null;
|
|
971
|
+
const interior = raws.slice(0, -1);
|
|
972
|
+
const enjambed = [];
|
|
973
|
+
interior.forEach((r, i) => { if (lineRunsOn(r))
|
|
974
|
+
enjambed.push(i + 1); });
|
|
975
|
+
const n = interior.length, k = enjambed.length;
|
|
976
|
+
if (k === 0)
|
|
977
|
+
return 'end-stopped throughout';
|
|
978
|
+
const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
|
|
979
|
+
return k >= Math.ceil(n / 2)
|
|
980
|
+
? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
|
|
981
|
+
: `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
|
|
982
|
+
}
|
|
837
983
|
export function renderReadingView(stanzas) {
|
|
838
984
|
const out = [];
|
|
839
985
|
const multiStanza = stanzas.length > 1;
|
|
840
986
|
out.push('');
|
|
841
987
|
out.push(HR);
|
|
842
|
-
out.push(H1('Reading View') + chalk.dim(' — stress gradient over
|
|
988
|
+
out.push(H1('Reading View') + chalk.dim(' — stress gradient over input text'));
|
|
843
989
|
out.push('');
|
|
844
990
|
// ── Block 1: the poem, original formatting, syllables coloured ──
|
|
991
|
+
// Multi-stanza poems get a right-aligned "Stanza N" counter in the blank line
|
|
992
|
+
// before each stanza after the first (the gaps between stanzas).
|
|
845
993
|
for (let s = 0; s < stanzas.length; s++) {
|
|
994
|
+
if (multiStanza && s > 0) {
|
|
995
|
+
out.push('');
|
|
996
|
+
out.push(chalk.dim.italic(('Stanza ' + (s + 1)).padStart(HR.length)));
|
|
997
|
+
}
|
|
846
998
|
for (const ln of stanzas[s].lines) {
|
|
847
999
|
out.push(projectStressOntoLine(ln.raw, collectLineWords(ln)));
|
|
848
1000
|
}
|
|
849
|
-
if (s < stanzas.length - 1)
|
|
850
|
-
out.push('');
|
|
851
1001
|
}
|
|
852
1002
|
out.push('');
|
|
853
1003
|
out.push(HR_THIN);
|
|
854
|
-
out.push(H1('Stress Maps &
|
|
1004
|
+
out.push(H1('Stress Maps, Meter, & Rhymes') + chalk.dim(' — top-3 fit scores per line'));
|
|
855
1005
|
out.push('');
|
|
856
1006
|
// ── Block 2: stress maps + meter, same stanza/line structure ──
|
|
857
1007
|
for (let s = 0; s < stanzas.length; s++) {
|
|
@@ -875,16 +1025,47 @@ export function renderReadingView(stanzas) {
|
|
|
875
1025
|
const caesurae = computeCaesurae(res.sentence.words, res.phonologicalHierarchy, d.scansion);
|
|
876
1026
|
const map = colourScansionMap(d.scansion, caesurae);
|
|
877
1027
|
const rank = formatRanking(d.ranking);
|
|
878
|
-
out.push(' ' + chalk.bold(label.padEnd(8)) + map + '
|
|
879
|
-
|
|
1028
|
+
out.push(' ' + chalk.bold(label.padEnd(8)) + map + ' ' +
|
|
1029
|
+
colourMeterLabel(d.meter) + (rank ? chalk.dim(' | ') + rank : '') + consensusNote(d) + rhythmNoteStr(d) + rhymeStr(d));
|
|
880
1030
|
}
|
|
881
1031
|
}
|
|
882
1032
|
if (multiStanza && s < stanzas.length - 1)
|
|
883
1033
|
out.push('');
|
|
884
1034
|
}
|
|
1035
|
+
// ── Block 3: Legend ──
|
|
1036
|
+
// Kept ABOVE the synopsis: the legend serves the Stress Maps & Meter, and the
|
|
1037
|
+
// Phonopoetics subsection of the synopsis below can run long — left at the
|
|
1038
|
+
// bottom it gets pushed out of the field of view.
|
|
885
1039
|
out.push('');
|
|
886
1040
|
out.push(HR_THIN);
|
|
887
1041
|
out.push(renderLegend());
|
|
1042
|
+
// ── Block 4: cumulative poem synopsis (non-interfering meta-measure) ──
|
|
1043
|
+
// Several top conclusions about the poem as a whole, drawn only from the
|
|
1044
|
+
// per-line determinations above — never overriding any of them.
|
|
1045
|
+
const synopsis = summarizePoem(stanzas.map(st => st.lines.flatMap(l => l.results)));
|
|
1046
|
+
if (synopsis.length > 0) {
|
|
1047
|
+
out.push('');
|
|
1048
|
+
out.push(HR_THIN);
|
|
1049
|
+
out.push(H1('Poem Synopsis') + chalk.dim(' In short, we have:'));
|
|
1050
|
+
out.push('');
|
|
1051
|
+
const w = Math.max(...synopsis.map(r => r.label.length)) + 2;
|
|
1052
|
+
for (const row of synopsis) {
|
|
1053
|
+
const label = chalk.bold.cyan((row.label + ':').padEnd(w));
|
|
1054
|
+
// Colour the value so the block is not a wall of white: tint any metre
|
|
1055
|
+
// names their family hue, and highlight the mean-fit %.
|
|
1056
|
+
let val = tintMeterNames(row.value);
|
|
1057
|
+
if (row.label === 'Meter')
|
|
1058
|
+
val = val.replace(/~\d+%/, (m) => chalk.yellow(m));
|
|
1059
|
+
out.push(' ' + label + val);
|
|
1060
|
+
}
|
|
1061
|
+
// Enjambment / end-stop — a poem-wide reading of the line-ends.
|
|
1062
|
+
const enj = summariseEnjambment(stanzas);
|
|
1063
|
+
if (enj)
|
|
1064
|
+
out.push(' ' + chalk.bold.cyan('Enjambment:'.padEnd(w)) + chalk.dim(enj));
|
|
1065
|
+
// Phonopoetics — end / caesural / head rhymes, alliteration, acrostic.
|
|
1066
|
+
out.push(...renderPhonopoetics(analyzePhonopoetics(stanzas.map(st => st.lines.flatMap(l => l.results)))));
|
|
1067
|
+
}
|
|
1068
|
+
out.push('');
|
|
888
1069
|
out.push(HR);
|
|
889
1070
|
return out.join('\n');
|
|
890
1071
|
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ClsSentence, IntonationalUnit } from './types.js';
|
|
2
|
+
export type EngineName = 'calliope' | 'clio';
|
|
3
|
+
export interface ProsodyEngine {
|
|
4
|
+
readonly name: EngineName;
|
|
5
|
+
/** Populate lexical/phrase/relative stress on `sent.words` and return the
|
|
6
|
+
* sentence's intonational units (the prosodic hierarchy). */
|
|
7
|
+
analyzeSentence(sent: ClsSentence): IntonationalUnit[];
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE3D,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,MAAM,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B;kEAC8D;IAC9D,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,gBAAgB,EAAE,CAAC;CACxD"}
|