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
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// scandroid.ts — Optional Scandroid integration: provides classic iambic and
|
|
2
|
+
// anapestic scansion algorithms from Hartman’s Scandroid, adapted to TypeScript.
|
|
3
|
+
// This module is purely functional; it does not modify the main pipeline and
|
|
4
|
+
// can be omitted without affecting the phonological scansion.
|
|
5
|
+
// ─── Constants from scanstrings.py ─────────────────────────────────
|
|
6
|
+
const STRESS = '/';
|
|
7
|
+
const SLACK = 'x';
|
|
8
|
+
const PROMOTED = '%';
|
|
9
|
+
const FOOTDIV = '|';
|
|
10
|
+
/** Foot dictionary for iambic lines (Scandroid’s footDict). */
|
|
11
|
+
const IAMBIC_FOOT_DICT = {
|
|
12
|
+
'x/': 'iamb',
|
|
13
|
+
'xx': 'pyrrhic',
|
|
14
|
+
'//': 'spondee',
|
|
15
|
+
'/x': 'trochee',
|
|
16
|
+
'x/x': 'amphibrach',
|
|
17
|
+
'//x': 'palimbacchius',
|
|
18
|
+
'xx/': 'anapest',
|
|
19
|
+
'/': 'defective',
|
|
20
|
+
'/xx': 'dactyl',
|
|
21
|
+
'/x/': 'cretic',
|
|
22
|
+
'x//': 'bacchius',
|
|
23
|
+
'x%': '(iamb)',
|
|
24
|
+
'xx%': '(anapest)',
|
|
25
|
+
'%x': '(trochee)',
|
|
26
|
+
'x/xx': '2nd paeon',
|
|
27
|
+
'xx/x': '3rd paeon',
|
|
28
|
+
};
|
|
29
|
+
/** Foot dictionary for anapestic lines (Scandroid’s AnapSubs). */
|
|
30
|
+
const ANAPESTIC_FOOT_DICT = {
|
|
31
|
+
'xx/': 'anapest',
|
|
32
|
+
'/x/': 'cretic',
|
|
33
|
+
'x//': 'bacchius',
|
|
34
|
+
'x/': 'iamb',
|
|
35
|
+
'x%': '(iamb)',
|
|
36
|
+
'xx%': '(anapest)',
|
|
37
|
+
'//': 'spondee',
|
|
38
|
+
'xx/x': '3rd paeon',
|
|
39
|
+
'x/x': 'amphibrach',
|
|
40
|
+
'///': 'molossus',
|
|
41
|
+
'/x%': '(cretic)',
|
|
42
|
+
'//x': 'palimbacchius',
|
|
43
|
+
};
|
|
44
|
+
// ─── Utility functions (adapted from scanutilities.py) ────────────
|
|
45
|
+
/** Generator-like function to walk through a string in chunks, matching a dictionary. */
|
|
46
|
+
function footFinder(fDict, str, chunkSize, start, end) {
|
|
47
|
+
const result = [];
|
|
48
|
+
let pos = start;
|
|
49
|
+
while (pos < end) {
|
|
50
|
+
const chunk = str.slice(pos, pos + chunkSize);
|
|
51
|
+
if (chunk in fDict) {
|
|
52
|
+
pos += chunkSize;
|
|
53
|
+
result.push({ foot: fDict[chunk], index: pos });
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// signal failure by returning empty array
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
/** Find the longest match of a RegExp in a string (last occurrence of longest length). */
|
|
63
|
+
function longestMatch(rx, s) {
|
|
64
|
+
let start = -1, length = 0;
|
|
65
|
+
let current = 0;
|
|
66
|
+
let m;
|
|
67
|
+
while ((m = rx.exec(s.slice(current))) !== null) {
|
|
68
|
+
const mStart = current + m.index;
|
|
69
|
+
const mEnd = mStart + m[0].length;
|
|
70
|
+
if (mEnd - mStart >= length) {
|
|
71
|
+
start = mStart;
|
|
72
|
+
length = mEnd - mStart;
|
|
73
|
+
}
|
|
74
|
+
current = mStart + 1;
|
|
75
|
+
}
|
|
76
|
+
return start >= 0 ? { start, length } : null;
|
|
77
|
+
}
|
|
78
|
+
/** Compute line length in feet by counting non-adjacent stresses (for anapestic estimation). */
|
|
79
|
+
function altLineLenCalc(marks) {
|
|
80
|
+
const arr = marks.split('');
|
|
81
|
+
for (let i = 0; i < arr.length; i++) {
|
|
82
|
+
if (i === 0 || arr[i - 1] === '/') {
|
|
83
|
+
if (arr[i] === '/')
|
|
84
|
+
arr[i] = 'x';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return arr.filter(ch => ch === '/').length;
|
|
88
|
+
}
|
|
89
|
+
// ─── Complexity measurement (from Scandroid’s _measureComplexity) ──
|
|
90
|
+
function iambicComplexity(footlist, numFeet) {
|
|
91
|
+
if (footlist.length !== numFeet)
|
|
92
|
+
return 100;
|
|
93
|
+
let prevIsTrochee = false;
|
|
94
|
+
let points = 0;
|
|
95
|
+
for (let i = 0; i < footlist.length; i++) {
|
|
96
|
+
let f = footlist[i];
|
|
97
|
+
if (f.startsWith('(') && f.endsWith(')'))
|
|
98
|
+
f = f.slice(1, -1);
|
|
99
|
+
if (['spondee', 'pyrrhic', 'trochee'].includes(f))
|
|
100
|
+
points += 2;
|
|
101
|
+
if (['anapest', 'defective', '3rd paeon', 'amphibrach', 'palimbacchius', '2nd paeon'].includes(f))
|
|
102
|
+
points += 4;
|
|
103
|
+
if (['dactyl', 'cretic', 'bacchius'].includes(f))
|
|
104
|
+
points += 10;
|
|
105
|
+
if (f === 'trochee') {
|
|
106
|
+
if (i === footlist.length - 1)
|
|
107
|
+
points += 6;
|
|
108
|
+
if (prevIsTrochee)
|
|
109
|
+
points += 8;
|
|
110
|
+
prevIsTrochee = true;
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
prevIsTrochee = false;
|
|
114
|
+
if ((f === 'trochee' || f === 'defective') /* bounds test omitted for simplicity */)
|
|
115
|
+
points += 4;
|
|
116
|
+
}
|
|
117
|
+
return points;
|
|
118
|
+
}
|
|
119
|
+
function anapesticComplexity(footlist) {
|
|
120
|
+
if (footlist.length === 0)
|
|
121
|
+
return 100;
|
|
122
|
+
let points = 0;
|
|
123
|
+
for (const f of footlist) {
|
|
124
|
+
if (f === 'bacchius')
|
|
125
|
+
points += 2;
|
|
126
|
+
else if (f === '(anapest)')
|
|
127
|
+
points += 1;
|
|
128
|
+
else if (f === 'iamb' || f === '(iamb)')
|
|
129
|
+
points += 2;
|
|
130
|
+
else if (f === 'cretic')
|
|
131
|
+
points += 4;
|
|
132
|
+
else if (['spondee', 'pyrrhic'].includes(f))
|
|
133
|
+
points += 4;
|
|
134
|
+
else if (['amphibrach', '3rd paeon'].includes(f))
|
|
135
|
+
points += 4;
|
|
136
|
+
else if (['2nd paeon', 'molossus', 'palimbacchius'].includes(f))
|
|
137
|
+
points += 5;
|
|
138
|
+
}
|
|
139
|
+
return points;
|
|
140
|
+
}
|
|
141
|
+
// ─── Iambic Algorithm 1: Corral the Weird ─────────────────────────
|
|
142
|
+
export function scandroidCorralWeird(marks, numFeet) {
|
|
143
|
+
const footlist = [];
|
|
144
|
+
let remaining = marks;
|
|
145
|
+
let lastFoot = '';
|
|
146
|
+
// Step 1: handle terminal slack (extra syllables at end)
|
|
147
|
+
const normLen = numFeet * 2;
|
|
148
|
+
if (remaining.length > normLen + 1 && ['x/xx', 'xx/x'].includes(remaining.slice(-4))) {
|
|
149
|
+
lastFoot = IAMBIC_FOOT_DICT[remaining.slice(-4)];
|
|
150
|
+
remaining = remaining.slice(0, -4);
|
|
151
|
+
}
|
|
152
|
+
else if (remaining.length >= normLen && ['x/x', '//x'].includes(remaining.slice(-3))) {
|
|
153
|
+
lastFoot = IAMBIC_FOOT_DICT[remaining.slice(-3)];
|
|
154
|
+
remaining = remaining.slice(0, -3);
|
|
155
|
+
}
|
|
156
|
+
// Step 2: handle acephalous (headless) line
|
|
157
|
+
if (remaining.length <= normLen - 2 && (remaining.startsWith('/x/x') || remaining.startsWith('/xxx'))) {
|
|
158
|
+
footlist.push('defective');
|
|
159
|
+
remaining = remaining.slice(1);
|
|
160
|
+
}
|
|
161
|
+
const currLen = remaining.length;
|
|
162
|
+
const needFeet = numFeet - footlist.length - (lastFoot ? 1 : 0);
|
|
163
|
+
const targetLen = needFeet * 2;
|
|
164
|
+
if (currLen === targetLen) {
|
|
165
|
+
const feet = footFinder(IAMBIC_FOOT_DICT, remaining, 2, 0, currLen);
|
|
166
|
+
if (feet.length === 0)
|
|
167
|
+
return { footlist: [], scansionMarks: '' };
|
|
168
|
+
footlist.push(...feet.map(f => f.foot));
|
|
169
|
+
}
|
|
170
|
+
else if (currLen < targetLen) {
|
|
171
|
+
// seek a defective foot (single stress)
|
|
172
|
+
const candidate = remaining.indexOf('x//');
|
|
173
|
+
if (candidate === -1 || candidate % 2 !== 0)
|
|
174
|
+
return { footlist: [], scansionMarks: '' };
|
|
175
|
+
const defectivePos = candidate + 2;
|
|
176
|
+
const before = footFinder(IAMBIC_FOOT_DICT, remaining, 2, 0, defectivePos);
|
|
177
|
+
if (before.length === 0)
|
|
178
|
+
return { footlist: [], scansionMarks: '' };
|
|
179
|
+
footlist.push(...before.map(f => f.foot));
|
|
180
|
+
footlist.push('defective');
|
|
181
|
+
const after = footFinder(IAMBIC_FOOT_DICT, remaining, 2, defectivePos + 1, currLen);
|
|
182
|
+
if (after.length === 0)
|
|
183
|
+
return { footlist: [], scansionMarks: '' };
|
|
184
|
+
footlist.push(...after.map(f => f.foot));
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
// need anapests to fill extra syllables
|
|
188
|
+
const need = currLen - targetLen;
|
|
189
|
+
// collect candidate positions for anapest insertion
|
|
190
|
+
const candidates = [];
|
|
191
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
192
|
+
if (remaining.slice(i, i + 4) === '/xx/')
|
|
193
|
+
candidates.push(i + 1);
|
|
194
|
+
}
|
|
195
|
+
if (candidates.length < need) {
|
|
196
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
197
|
+
if (remaining.slice(i, i + 3) === 'xx/')
|
|
198
|
+
candidates.push(i);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
let pos = 0;
|
|
202
|
+
let anapestsUsed = 0;
|
|
203
|
+
while (pos < currLen) {
|
|
204
|
+
if (anapestsUsed < need && candidates.includes(pos)) {
|
|
205
|
+
const chunk = remaining.slice(pos, pos + 3);
|
|
206
|
+
if (!(chunk in IAMBIC_FOOT_DICT))
|
|
207
|
+
return { footlist: [], scansionMarks: '' };
|
|
208
|
+
footlist.push(IAMBIC_FOOT_DICT[chunk]);
|
|
209
|
+
pos += 3;
|
|
210
|
+
anapestsUsed++;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const chunk = remaining.slice(pos, pos + 2);
|
|
214
|
+
if (!(chunk in IAMBIC_FOOT_DICT))
|
|
215
|
+
return { footlist: [], scansionMarks: '' };
|
|
216
|
+
footlist.push(IAMBIC_FOOT_DICT[chunk]);
|
|
217
|
+
pos += 2;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (lastFoot)
|
|
222
|
+
footlist.push(lastFoot);
|
|
223
|
+
// Generate scansion string with foot divisions
|
|
224
|
+
const scansion = footlist.map(f => f.startsWith('(') ? f : f).join('|'); // simplistic
|
|
225
|
+
return { footlist, scansionMarks: scansion };
|
|
226
|
+
}
|
|
227
|
+
// ─── Iambic Algorithm 2: Maximize the Normal ─────────────────────
|
|
228
|
+
export function scandroidMaximizeNormal(marks, numFeet) {
|
|
229
|
+
const possIambRE = /(x[x/])+/;
|
|
230
|
+
const match = longestMatch(possIambRE, marks);
|
|
231
|
+
if (!match)
|
|
232
|
+
return { footlist: [], scansionMarks: '' };
|
|
233
|
+
const { start, length } = match;
|
|
234
|
+
const runEnd = start + length;
|
|
235
|
+
const headMarks = marks.slice(0, start);
|
|
236
|
+
const tailMarks = marks.slice(runEnd);
|
|
237
|
+
const mainMarks = marks.slice(start, runEnd);
|
|
238
|
+
const footlist = [];
|
|
239
|
+
const headFeet = [];
|
|
240
|
+
const tailFeet = [];
|
|
241
|
+
// Scan the regular middle stretch
|
|
242
|
+
const mainFeet = footFinder(IAMBIC_FOOT_DICT, mainMarks, 2, 0, mainMarks.length);
|
|
243
|
+
if (mainFeet.length === 0)
|
|
244
|
+
return { footlist: [], scansionMarks: '' };
|
|
245
|
+
footlist.push(...mainFeet.map(f => f.foot));
|
|
246
|
+
// Scan head
|
|
247
|
+
if (headMarks.length > 0) {
|
|
248
|
+
if (headMarks.length % 2 === 0) {
|
|
249
|
+
const hf = footFinder(IAMBIC_FOOT_DICT, headMarks, 2, 0, headMarks.length);
|
|
250
|
+
if (hf.length === 0)
|
|
251
|
+
return { footlist: [], scansionMarks: '' };
|
|
252
|
+
headFeet.push(...hf.map(f => f.foot));
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
if (headMarks.startsWith('/x')) {
|
|
256
|
+
headFeet.push('defective');
|
|
257
|
+
const rest = headMarks.slice(1);
|
|
258
|
+
if (rest.length > 0) {
|
|
259
|
+
const hf = footFinder(IAMBIC_FOOT_DICT, rest, 2, 0, rest.length);
|
|
260
|
+
if (hf.length === 0)
|
|
261
|
+
return { footlist: [], scansionMarks: '' };
|
|
262
|
+
headFeet.push(...hf.map(f => f.foot));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// try to find an anapest in the head
|
|
267
|
+
const anap = headMarks.indexOf('xx/');
|
|
268
|
+
if (anap === -1)
|
|
269
|
+
return { footlist: [], scansionMarks: '' };
|
|
270
|
+
const before = footFinder(IAMBIC_FOOT_DICT, headMarks, 2, 0, anap);
|
|
271
|
+
if (before.length === 0)
|
|
272
|
+
return { footlist: [], scansionMarks: '' };
|
|
273
|
+
headFeet.push(...before.map(f => f.foot));
|
|
274
|
+
headFeet.push('anapest');
|
|
275
|
+
const after = footFinder(IAMBIC_FOOT_DICT, headMarks, 2, anap + 3, headMarks.length);
|
|
276
|
+
if (after.length === 0)
|
|
277
|
+
return { footlist: [], scansionMarks: '' };
|
|
278
|
+
headFeet.push(...after.map(f => f.foot));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Scan tail
|
|
283
|
+
if (tailMarks.length > 0) {
|
|
284
|
+
let lastFootStr = '';
|
|
285
|
+
let tailPart = tailMarks;
|
|
286
|
+
if (tailPart.slice(-1) === 'x' && tailPart.length > 2 && tailPart.slice(-3) in IAMBIC_FOOT_DICT) {
|
|
287
|
+
lastFootStr = IAMBIC_FOOT_DICT[tailPart.slice(-3)];
|
|
288
|
+
tailPart = tailPart.slice(0, -3);
|
|
289
|
+
}
|
|
290
|
+
const tf = footFinder(IAMBIC_FOOT_DICT, tailPart, 2, 0, tailPart.length);
|
|
291
|
+
if (tf.length === 0)
|
|
292
|
+
return { footlist: [], scansionMarks: '' };
|
|
293
|
+
tailFeet.push(...tf.map(f => f.foot));
|
|
294
|
+
if (lastFootStr)
|
|
295
|
+
tailFeet.push(lastFootStr);
|
|
296
|
+
}
|
|
297
|
+
const completeList = [...headFeet, ...footlist, ...tailFeet];
|
|
298
|
+
// Promote pyrrhics as in Scandroid’s PromotePyrrhics
|
|
299
|
+
for (let i = 0; i < completeList.length; i++) {
|
|
300
|
+
if (completeList[i] === 'pyrrhic') {
|
|
301
|
+
if (i < completeList.length - 1 && completeList[i + 1] === 'spondee') {
|
|
302
|
+
// nothing
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
completeList[i] = '(iamb)';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const scansion = completeList.join('|');
|
|
310
|
+
return { footlist: completeList, scansionMarks: scansion };
|
|
311
|
+
}
|
|
312
|
+
// ─── Anapestic scanning ──────────────────────────────────────────
|
|
313
|
+
export function scandroidAnapestic(marks, numFeet) {
|
|
314
|
+
let remaining = marks;
|
|
315
|
+
if (!numFeet) {
|
|
316
|
+
const [q, r] = [Math.floor(remaining.length / 3), remaining.length % 3];
|
|
317
|
+
let need = q;
|
|
318
|
+
if (r > 0)
|
|
319
|
+
need++;
|
|
320
|
+
need = Math.max(need, altLineLenCalc(remaining));
|
|
321
|
+
numFeet = need;
|
|
322
|
+
}
|
|
323
|
+
// Handle terminal slack (promotions etc.)
|
|
324
|
+
if (remaining.slice(-2) === 'xx')
|
|
325
|
+
remaining = remaining.slice(0, -1) + '%';
|
|
326
|
+
let lastFootStr = '';
|
|
327
|
+
if (remaining && remaining.slice(-1) === 'x') {
|
|
328
|
+
let tailStart = remaining.lastIndexOf('/');
|
|
329
|
+
tailStart = remaining.lastIndexOf('/', tailStart - 1);
|
|
330
|
+
if (tailStart === -1)
|
|
331
|
+
return { footlist: [], scansionMarks: '' };
|
|
332
|
+
const tail = remaining.slice(tailStart);
|
|
333
|
+
if (tail in ANAPESTIC_FOOT_DICT) {
|
|
334
|
+
lastFootStr = ANAPESTIC_FOOT_DICT[tail];
|
|
335
|
+
remaining = remaining.slice(0, tailStart);
|
|
336
|
+
}
|
|
337
|
+
else if (tail.length > 1 && tail.slice(1) in ANAPESTIC_FOOT_DICT) {
|
|
338
|
+
lastFootStr = ANAPESTIC_FOOT_DICT[tail.slice(1)];
|
|
339
|
+
remaining = remaining.slice(0, tailStart + 1);
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
return { footlist: [], scansionMarks: '' };
|
|
343
|
+
}
|
|
344
|
+
// Promote slack runs (long sequences of unstressed)
|
|
345
|
+
const slackRun = remaining.indexOf('xxxx');
|
|
346
|
+
if (slackRun !== -1) {
|
|
347
|
+
remaining = remaining.slice(0, slackRun + 2) + '%' + remaining.slice(slackRun + 3);
|
|
348
|
+
}
|
|
349
|
+
const len = remaining.length;
|
|
350
|
+
const footlist = [];
|
|
351
|
+
if (len === numFeet * 3) {
|
|
352
|
+
const feet = footFinder(ANAPESTIC_FOOT_DICT, remaining, 3, 0, len);
|
|
353
|
+
if (feet.length === 0)
|
|
354
|
+
return { footlist: [], scansionMarks: '' };
|
|
355
|
+
footlist.push(...feet.map(f => f.foot));
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
const needDisyls = (numFeet * 3) - len;
|
|
359
|
+
if (needDisyls > numFeet)
|
|
360
|
+
return { footlist: [], scansionMarks: '' };
|
|
361
|
+
const pattern = '2'.repeat(needDisyls) + '3'.repeat(numFeet - needDisyls);
|
|
362
|
+
const allPerms = uniquePermutations(pattern);
|
|
363
|
+
let validPattern = null;
|
|
364
|
+
for (const pat of allPerms) {
|
|
365
|
+
let okay = true;
|
|
366
|
+
let idx = 0;
|
|
367
|
+
for (const d of pat) {
|
|
368
|
+
const stride = parseInt(d);
|
|
369
|
+
idx += stride;
|
|
370
|
+
if (!'/%'.includes(remaining[idx - 1])) {
|
|
371
|
+
okay = false;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (okay) {
|
|
376
|
+
validPattern = pat;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!validPattern)
|
|
381
|
+
return { footlist: [], scansionMarks: '' };
|
|
382
|
+
let pos = 0;
|
|
383
|
+
for (const d of validPattern) {
|
|
384
|
+
const stride = parseInt(d);
|
|
385
|
+
const chunk = remaining.slice(pos, pos + stride);
|
|
386
|
+
if (chunk in ANAPESTIC_FOOT_DICT) {
|
|
387
|
+
footlist.push(ANAPESTIC_FOOT_DICT[chunk]);
|
|
388
|
+
pos += stride;
|
|
389
|
+
}
|
|
390
|
+
else
|
|
391
|
+
return { footlist: [], scansionMarks: '' };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (lastFootStr)
|
|
395
|
+
footlist.push(lastFootStr);
|
|
396
|
+
const scansion = footlist.join('|');
|
|
397
|
+
return { footlist, scansionMarks: scansion };
|
|
398
|
+
}
|
|
399
|
+
// ─── Helper: unique permutations of a string ────────────────────
|
|
400
|
+
function uniquePermutations(s) {
|
|
401
|
+
if (s.length <= 1 || s.length > 9)
|
|
402
|
+
return [s];
|
|
403
|
+
const results = [];
|
|
404
|
+
function permute(prefix, rest) {
|
|
405
|
+
if (rest.length === 0)
|
|
406
|
+
results.push(prefix);
|
|
407
|
+
const seen = new Set();
|
|
408
|
+
for (let i = 0; i < rest.length; i++) {
|
|
409
|
+
if (seen.has(rest[i]))
|
|
410
|
+
continue;
|
|
411
|
+
seen.add(rest[i]);
|
|
412
|
+
permute(prefix + rest[i], rest.slice(0, i) + rest.slice(i + 1));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
permute('', s);
|
|
416
|
+
return results;
|
|
417
|
+
}
|
|
418
|
+
// ─── Public API: convert our relative stress to Scandroid marks ──
|
|
419
|
+
export function stressToMarks(stressArray) {
|
|
420
|
+
return stressArray.map(s => (s === 's' ? STRESS : SLACK)).join('');
|
|
421
|
+
}
|
|
422
|
+
export function marksToFeetString(footlist) {
|
|
423
|
+
return footlist.join(' | ');
|
|
424
|
+
}
|
|
425
|
+
// ─── Convenience: produce a ScansionResult from footlist ─────────
|
|
426
|
+
export function scansionResultFromFootlist(footlist, meter, complexity) {
|
|
427
|
+
return {
|
|
428
|
+
meter,
|
|
429
|
+
scansion: marksToFeetString(footlist),
|
|
430
|
+
certainty: 0, // not computed
|
|
431
|
+
weightScore: 0,
|
|
432
|
+
maxPossibleWeight: 0,
|
|
433
|
+
algorithm: 'Scandroid',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ClsWord, IntonationalUnit, KeyStress, MetreName, PhonologicalScansionDetail } from './types.js';
|
|
2
|
+
export declare function extractKeyStresses(ius: IntonationalUnit[], words: ClsWord[]): KeyStress[];
|
|
3
|
+
export declare function scoreMeters(keyStresses: KeyStress[], words: ClsWord[], ius?: IntonationalUnit[], force?: MetreName): PhonologicalScansionDetail;
|
|
4
|
+
/** Per-line ictus profile parsed from a scansion string ("ns|wx|ns|ws|ws"). */
|
|
5
|
+
export interface IctusProfile {
|
|
6
|
+
syllables: number;
|
|
7
|
+
ictuses: number;
|
|
8
|
+
intervals: number[];
|
|
9
|
+
anacrusis: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function ictusProfile(scansion: string): IctusProfile;
|
|
12
|
+
/**
|
|
13
|
+
* Stanza-level rhythm classification. Fires only when:
|
|
14
|
+
* (a) syllable counts VARY across the stanza (range ≥ 2) — a steady-count
|
|
15
|
+
* stanza is accentual-syllabic territory and is left to the classical
|
|
16
|
+
* machinery (this is what keeps loose iambics like Frost untouched); and
|
|
17
|
+
* (b) no classical meter dominates confidently (≥60% of lines under one
|
|
18
|
+
* meter at mean certainty ≥70).
|
|
19
|
+
* Then: alternating 4·3 ictuses → ballad; constant ictus count + interval
|
|
20
|
+
* family → dolnik / taktovik / accentual. Single lines (or 2-line stanzas)
|
|
21
|
+
* get only the per-line free-verse refinement below.
|
|
22
|
+
*/
|
|
23
|
+
export declare function applyRhythmLayer(details: PhonologicalScansionDetail[]): void;
|
|
24
|
+
/**
|
|
25
|
+
* Stanza-level consensus (McAleese A2.1 §5b, "where there is a tie, use
|
|
26
|
+
* surrounding patterns"). Each line keeps its own standalone scansion/meter;
|
|
27
|
+
* but when a line's top meter merely *edges out* the stanza's dominant meter (a
|
|
28
|
+
* near-tie, within `tie` of its own best fit), we annotate it with the dominant
|
|
29
|
+
* meter via `consensusMeter` — making the divergence EXPLICIT rather than
|
|
30
|
+
* silently homogenising it. Confident lines (whose own meter clearly beats the
|
|
31
|
+
* dominant) are left untouched, so genuine metrical variation stays visible.
|
|
32
|
+
*
|
|
33
|
+
* Mutates the passed details in place. No-op for <2 lines or a stanza with no
|
|
34
|
+
* unique dominant meter.
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyStanzaConsensus(details: PhonologicalScansionDetail[], tie?: number): void;
|
|
37
|
+
//# sourceMappingURL=scansion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scansion.d.ts","sourceRoot":"","sources":["../src/scansion.ts"],"names":[],"mappings":"AAuBA,OAAO,EACL,OAAO,EACP,gBAAgB,EAGhB,SAAS,EACT,SAAS,EAET,0BAA0B,EAE3B,MAAM,YAAY,CAAC;AAgLpB,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,SAAS,EAAE,CAwCzF;AA2ZD,wBAAgB,WAAW,CACzB,WAAW,EAAE,SAAS,EAAE,EACxB,KAAK,EAAE,OAAO,EAAE,EAChB,GAAG,CAAC,EAAE,gBAAgB,EAAE,EACxB,KAAK,CAAC,EAAE,SAAS,GAChB,0BAA0B,CA4I5B;AAmBD,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,CA8B3D;AAkBD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,0BAA0B,EAAE,GAAG,IAAI,CAmF5E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,0BAA0B,EAAE,EACrC,GAAG,GAAE,MAAc,GAClB,IAAI,CA6DN"}
|