@k-l-lambda/lilylet 0.1.76 → 0.1.78
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/lib/abc/grammar.jison.js +85 -79
- package/lib/lilylet/abcDecoder.js +167 -0
- package/lib/lilylet/grammar.jison.js +120 -109
- package/lib/lilylet/highlight.js +3 -1
- package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +3 -1
- package/lib/lilylet/meiEncoder.js +62 -7
- package/lib/lilylet/musicXmlDecoder.js +119 -24
- package/lib/lilylet/musicXmlEncoder.js +85 -18
- package/lib/lilylet/musicXmlTypes.d.ts +3 -1
- package/lib/lilylet/musicXmlUtils.js +1 -1
- package/lib/lilylet/serializer.js +14 -0
- package/lib/lilylet/types.d.ts +6 -2
- package/lib/lilylet/types.js +1 -0
- package/package.json +3 -1
- package/source/abc/abc.jison +8 -4
- package/source/abc/grammar.jison.js +85 -79
- package/source/lilylet/abcDecoder.ts +189 -0
- package/source/lilylet/grammar.jison.js +120 -109
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +10 -1
- package/source/lilylet/measureLayoutFromXml.ts +3 -1
- package/source/lilylet/meiEncoder.ts +83 -7
- package/source/lilylet/musicXmlDecoder.ts +117 -23
- package/source/lilylet/musicXmlEncoder.ts +89 -16
- package/source/lilylet/musicXmlTypes.ts +5 -1
- package/source/lilylet/musicXmlUtils.ts +1 -1
- package/source/lilylet/serializer.ts +14 -0
- package/source/lilylet/types.ts +10 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Converts LilyletDoc to MusicXML format.
|
|
5
5
|
* Produces valid MusicXML 4.0 partwise documents.
|
|
6
6
|
*/
|
|
7
|
-
import { StemDirection, Accidental, HairpinType, PedalType, } from "./types.js";
|
|
7
|
+
import { StemDirection, Accidental, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
|
|
8
8
|
import { DIVISIONS, DIVISION_TO_TYPE, calculateDuration, } from "./musicXmlUtils.js";
|
|
9
9
|
import { parseStaffLayout } from "./staffLayout.js";
|
|
10
10
|
// === Constants and Reverse Mappings ===
|
|
@@ -78,6 +78,7 @@ const DYNAMIC_TO_XML = {
|
|
|
78
78
|
fff: 'fff',
|
|
79
79
|
sfz: 'sfz',
|
|
80
80
|
rfz: 'rfz',
|
|
81
|
+
fp: 'fp',
|
|
81
82
|
};
|
|
82
83
|
// Barline style to MusicXML
|
|
83
84
|
const BARLINE_TO_XML = {
|
|
@@ -202,6 +203,7 @@ const encodeAttributes = (level, options) => {
|
|
|
202
203
|
const encodeNotations = (marks, level) => {
|
|
203
204
|
const articulations = [];
|
|
204
205
|
const ornaments = [];
|
|
206
|
+
const technical = [];
|
|
205
207
|
const otherNotations = [];
|
|
206
208
|
for (const mark of marks) {
|
|
207
209
|
switch (mark.markType) {
|
|
@@ -212,17 +214,25 @@ const encodeNotations = (marks, level) => {
|
|
|
212
214
|
}
|
|
213
215
|
break;
|
|
214
216
|
case 'ornament':
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
217
|
+
// fermata / arpeggio live at the <notations> level; breath-mark is an
|
|
218
|
+
// <articulations> child; the rest are true <ornaments>. (shortFermata is
|
|
219
|
+
// a fermata with an "angled" shape — decoder maps it back.)
|
|
220
|
+
if (mark.type === 'fermata') {
|
|
221
|
+
otherNotations.push('<fermata/>');
|
|
222
|
+
}
|
|
223
|
+
else if (mark.type === 'shortFermata') {
|
|
224
|
+
otherNotations.push('<fermata>angled</fermata>');
|
|
225
|
+
}
|
|
226
|
+
else if (mark.type === 'arpeggio') {
|
|
227
|
+
otherNotations.push('<arpeggiate/>');
|
|
228
|
+
}
|
|
229
|
+
else if (mark.type === 'breath') {
|
|
230
|
+
articulations.push('<breath-mark/>');
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const ornXml = ORNAMENT_TO_XML[mark.type];
|
|
234
|
+
if (ornXml)
|
|
224
235
|
ornaments.push(`<${ornXml}/>`);
|
|
225
|
-
}
|
|
226
236
|
}
|
|
227
237
|
break;
|
|
228
238
|
case 'tie':
|
|
@@ -234,12 +244,18 @@ const encodeNotations = (marks, level) => {
|
|
|
234
244
|
case 'tuplet':
|
|
235
245
|
otherNotations.push(`<tuplet type="${mark.start ? 'start' : 'stop'}"/>`);
|
|
236
246
|
break;
|
|
247
|
+
case 'glissando':
|
|
248
|
+
// Lilylet \glissando is start-only (the line auto-connects to the next
|
|
249
|
+
// note); emit just the start spanner so the decoder reads it back.
|
|
250
|
+
otherNotations.push('<glissando type="start" number="1"/>');
|
|
251
|
+
break;
|
|
237
252
|
case 'fingering':
|
|
238
|
-
|
|
253
|
+
technical.push(`<fingering>${mark.finger}</fingering>`);
|
|
239
254
|
break;
|
|
240
255
|
}
|
|
241
256
|
}
|
|
242
|
-
if (articulations.length === 0 && ornaments.length === 0 &&
|
|
257
|
+
if (articulations.length === 0 && ornaments.length === 0 &&
|
|
258
|
+
technical.length === 0 && otherNotations.length === 0) {
|
|
243
259
|
return '';
|
|
244
260
|
}
|
|
245
261
|
let xml = `${indent(level)}<notations>\n`;
|
|
@@ -260,6 +276,13 @@ const encodeNotations = (marks, level) => {
|
|
|
260
276
|
}
|
|
261
277
|
xml += `${indent(level + 1)}</ornaments>\n`;
|
|
262
278
|
}
|
|
279
|
+
if (technical.length > 0) {
|
|
280
|
+
xml += `${indent(level + 1)}<technical>\n`;
|
|
281
|
+
for (const tech of technical) {
|
|
282
|
+
xml += `${indent(level + 2)}${tech}\n`;
|
|
283
|
+
}
|
|
284
|
+
xml += `${indent(level + 1)}</technical>\n`;
|
|
285
|
+
}
|
|
263
286
|
xml += `${indent(level)}</notations>\n`;
|
|
264
287
|
return xml;
|
|
265
288
|
};
|
|
@@ -369,8 +392,14 @@ const encodeRestWithTuplet = (event, voice, staff, level, isFirst, isLast) => {
|
|
|
369
392
|
/**
|
|
370
393
|
* Encode direction element (dynamics, tempo, etc.)
|
|
371
394
|
*/
|
|
372
|
-
const encodeDirection = (marks, level) => {
|
|
395
|
+
const encodeDirection = (marks, level, staff = 0, emitStaff = false) => {
|
|
373
396
|
let xml = '';
|
|
397
|
+
// In a grand-staff part, a <direction> must name its <staff> or the decoder
|
|
398
|
+
// can't route the mark back to the right staff (it would attach to the current
|
|
399
|
+
// voice/staff and drift — observed on chopin dynamics and cross-measure pedals).
|
|
400
|
+
const staffTag = (emitStaff && staff > 0)
|
|
401
|
+
? `${indent(level + 1)}<staff>${staff}</staff>\n`
|
|
402
|
+
: '';
|
|
374
403
|
for (const mark of marks) {
|
|
375
404
|
if (mark.markType === 'dynamic') {
|
|
376
405
|
const dynXml = DYNAMIC_TO_XML[mark.type];
|
|
@@ -381,6 +410,7 @@ const encodeDirection = (marks, level) => {
|
|
|
381
410
|
xml += `${indent(level + 3)}<${dynXml}/>\n`;
|
|
382
411
|
xml += `${indent(level + 2)}</dynamics>\n`;
|
|
383
412
|
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
413
|
+
xml += staffTag;
|
|
384
414
|
xml += `${indent(level)}</direction>\n`;
|
|
385
415
|
}
|
|
386
416
|
}
|
|
@@ -400,6 +430,7 @@ const encodeDirection = (marks, level) => {
|
|
|
400
430
|
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
401
431
|
xml += `${indent(level + 2)}<wedge type="${wedgeType}"/>\n`;
|
|
402
432
|
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
433
|
+
xml += staffTag;
|
|
403
434
|
xml += `${indent(level)}</direction>\n`;
|
|
404
435
|
}
|
|
405
436
|
}
|
|
@@ -416,9 +447,31 @@ const encodeDirection = (marks, level) => {
|
|
|
416
447
|
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
417
448
|
xml += `${indent(level + 2)}<pedal type="${pedalType}"/>\n`;
|
|
418
449
|
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
450
|
+
xml += staffTag;
|
|
419
451
|
xml += `${indent(level)}</direction>\n`;
|
|
420
452
|
}
|
|
421
453
|
}
|
|
454
|
+
else if (mark.markType === 'markup') {
|
|
455
|
+
// Text direction → <words>. The decoder reads any non-tempo <words> back
|
|
456
|
+
// as a markup mark (text-as-markup convention).
|
|
457
|
+
const placement = mark.placement ? ` placement="${mark.placement}"` : '';
|
|
458
|
+
xml += `${indent(level)}<direction${placement}>\n`;
|
|
459
|
+
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
460
|
+
xml += `${indent(level + 2)}<words>${escapeXml(mark.content)}</words>\n`;
|
|
461
|
+
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
462
|
+
xml += staffTag;
|
|
463
|
+
xml += `${indent(level)}</direction>\n`;
|
|
464
|
+
}
|
|
465
|
+
else if (mark.markType === 'navigation') {
|
|
466
|
+
// \coda / \segno glyph → <direction-type><coda|segno>.
|
|
467
|
+
const glyph = mark.type === NavigationMarkType.coda ? 'coda' : 'segno';
|
|
468
|
+
xml += `${indent(level)}<direction>\n`;
|
|
469
|
+
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
470
|
+
xml += `${indent(level + 2)}<${glyph}/>\n`;
|
|
471
|
+
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
472
|
+
xml += staffTag;
|
|
473
|
+
xml += `${indent(level)}</direction>\n`;
|
|
474
|
+
}
|
|
422
475
|
}
|
|
423
476
|
return xml;
|
|
424
477
|
};
|
|
@@ -536,20 +589,26 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
|
|
|
536
589
|
for (const event of voice.events) {
|
|
537
590
|
switch (event.type) {
|
|
538
591
|
case 'note': {
|
|
539
|
-
// Check for direction marks (dynamics, hairpins, pedals)
|
|
540
|
-
const directionMarks = event.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' ||
|
|
592
|
+
// Check for direction marks (dynamics, hairpins, pedals, text, nav)
|
|
593
|
+
const directionMarks = event.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' ||
|
|
594
|
+
m.markType === 'pedal' || m.markType === 'markup' ||
|
|
595
|
+
m.markType === 'navigation') || [];
|
|
541
596
|
if (directionMarks.length > 0) {
|
|
542
|
-
xml += encodeDirection(directionMarks, level + 1);
|
|
597
|
+
xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
|
|
543
598
|
}
|
|
544
599
|
// Encode main note
|
|
545
600
|
xml += encodeNote(event, voiceNum, currentStaff, level + 1);
|
|
546
601
|
const dur = calculateDuration(event.duration);
|
|
547
602
|
voicePosition += dur;
|
|
548
|
-
// Encode chord notes
|
|
603
|
+
// Encode chord notes. Marks live on the parent NoteEvent (the whole
|
|
604
|
+
// chord) and are emitted on the first note only — re-emitting them on
|
|
605
|
+
// each member would multiply them on decode (the decoder aggregates
|
|
606
|
+
// every member's marks back onto the one chord event).
|
|
549
607
|
for (let i = 1; i < event.pitches.length; i++) {
|
|
550
608
|
const chordEvent = {
|
|
551
609
|
...event,
|
|
552
610
|
pitches: [event.pitches[i]],
|
|
611
|
+
marks: undefined,
|
|
553
612
|
};
|
|
554
613
|
xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
|
|
555
614
|
}
|
|
@@ -582,6 +641,14 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
|
|
|
582
641
|
const isFirst = ti === 0;
|
|
583
642
|
const isLast = ti === tupletEvents.length - 1;
|
|
584
643
|
if (subEvent.type === 'note') {
|
|
644
|
+
// Direction marks (dynamics, hairpins, pedals, text, nav) — same
|
|
645
|
+
// as the plain-note case; without this they vanish inside tuplets.
|
|
646
|
+
const directionMarks = subEvent.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' ||
|
|
647
|
+
m.markType === 'pedal' || m.markType === 'markup' ||
|
|
648
|
+
m.markType === 'navigation') || [];
|
|
649
|
+
if (directionMarks.length > 0) {
|
|
650
|
+
xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
|
|
651
|
+
}
|
|
585
652
|
// Add tuplet notation marks
|
|
586
653
|
const tupletMarks = [];
|
|
587
654
|
if (isFirst)
|
|
@@ -56,7 +56,7 @@ export interface MusicXmlNotations {
|
|
|
56
56
|
}[];
|
|
57
57
|
articulations?: string[];
|
|
58
58
|
ornaments?: string[];
|
|
59
|
-
fermata?:
|
|
59
|
+
fermata?: 'normal' | 'short';
|
|
60
60
|
arpeggiate?: boolean;
|
|
61
61
|
tremolo?: {
|
|
62
62
|
type: 'single' | 'start' | 'stop';
|
|
@@ -66,6 +66,7 @@ export interface MusicXmlNotations {
|
|
|
66
66
|
type: 'start' | 'stop';
|
|
67
67
|
number: number;
|
|
68
68
|
};
|
|
69
|
+
glissando?: boolean;
|
|
69
70
|
}
|
|
70
71
|
export interface MusicXmlNote {
|
|
71
72
|
isChord: boolean;
|
|
@@ -117,6 +118,7 @@ export interface MusicXmlDirection {
|
|
|
117
118
|
pedal?: MusicXmlPedal;
|
|
118
119
|
metronome?: MusicXmlMetronome;
|
|
119
120
|
words?: MusicXmlWords[];
|
|
121
|
+
rehearsal?: string;
|
|
120
122
|
octaveShift?: MusicXmlOctaveShift;
|
|
121
123
|
coda?: boolean;
|
|
122
124
|
segno?: boolean;
|
|
@@ -339,7 +339,7 @@ const DYNAMIC_MAP = {
|
|
|
339
339
|
fz: DynamicType.sfz,
|
|
340
340
|
sfp: DynamicType.sfz,
|
|
341
341
|
sfpp: DynamicType.sfz,
|
|
342
|
-
fp: DynamicType.
|
|
342
|
+
fp: DynamicType.fp, // forte-piano
|
|
343
343
|
};
|
|
344
344
|
export const convertDynamic = (name) => {
|
|
345
345
|
return DYNAMIC_MAP[name.toLowerCase()];
|
|
@@ -114,6 +114,7 @@ const ORNAMENT_MAP = {
|
|
|
114
114
|
fermata: '\\fermata',
|
|
115
115
|
shortFermata: '\\shortfermata',
|
|
116
116
|
arpeggio: '\\arpeggio',
|
|
117
|
+
breath: '\\breathe',
|
|
117
118
|
};
|
|
118
119
|
// Dynamic to Lilylet notation
|
|
119
120
|
const DYNAMIC_MAP = {
|
|
@@ -232,6 +233,19 @@ const serializeMarks = (marks) => {
|
|
|
232
233
|
case 'fingering':
|
|
233
234
|
parts.push('-' + mark.finger);
|
|
234
235
|
break;
|
|
236
|
+
case 'markup': {
|
|
237
|
+
// Text direction attached to a note (e.g. ^\markup "cantabile").
|
|
238
|
+
// Placement: above → ^, below → _, unspecified → - (default).
|
|
239
|
+
const prefix = mark.placement === 'above' ? '^' : mark.placement === 'below' ? '_' : '-';
|
|
240
|
+
parts.push(prefix + '\\markup "' + escapeString(mark.content) + '"');
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case 'navigation':
|
|
244
|
+
parts.push(mark.type === 'coda' ? '\\coda' : '\\segno');
|
|
245
|
+
break;
|
|
246
|
+
case 'glissando':
|
|
247
|
+
parts.push('\\glissando');
|
|
248
|
+
break;
|
|
235
249
|
}
|
|
236
250
|
}
|
|
237
251
|
return parts.join('');
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -39,7 +39,8 @@ export declare enum OrnamentType {
|
|
|
39
39
|
prall = "prall",
|
|
40
40
|
fermata = "fermata",
|
|
41
41
|
shortFermata = "shortFermata",
|
|
42
|
-
arpeggio = "arpeggio"
|
|
42
|
+
arpeggio = "arpeggio",
|
|
43
|
+
breath = "breath"
|
|
43
44
|
}
|
|
44
45
|
export declare enum DynamicType {
|
|
45
46
|
ppp = "ppp",
|
|
@@ -150,7 +151,10 @@ export interface MarkupMark {
|
|
|
150
151
|
content: string;
|
|
151
152
|
placement?: Placement;
|
|
152
153
|
}
|
|
153
|
-
export
|
|
154
|
+
export interface Glissando {
|
|
155
|
+
markType: 'glissando';
|
|
156
|
+
}
|
|
157
|
+
export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark | Glissando;
|
|
154
158
|
export interface KeySignature {
|
|
155
159
|
pitch: Phonet;
|
|
156
160
|
accidental?: Accidental;
|
package/lib/lilylet/types.js
CHANGED
|
@@ -47,6 +47,7 @@ export var OrnamentType;
|
|
|
47
47
|
OrnamentType["fermata"] = "fermata";
|
|
48
48
|
OrnamentType["shortFermata"] = "shortFermata";
|
|
49
49
|
OrnamentType["arpeggio"] = "arpeggio";
|
|
50
|
+
OrnamentType["breath"] = "breath";
|
|
50
51
|
})(OrnamentType || (OrnamentType = {}));
|
|
51
52
|
export var DynamicType;
|
|
52
53
|
(function (DynamicType) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.78",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
42
42
|
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
43
43
|
"test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
|
|
44
|
+
"test:xml-roundtrip": "tsx ./tests/musicxml-roundtrip.ts",
|
|
44
45
|
"test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
|
|
45
46
|
"train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
|
|
46
47
|
"build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
|
|
49
50
|
"test:measures": "tsx ./tests/unit/measureLayout.test.ts",
|
|
50
51
|
"test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
|
|
52
|
+
"test:abc-repeats": "tsx ./tests/unit/abcRepeatLayout.test.ts",
|
|
51
53
|
"build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
|
|
52
54
|
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
53
55
|
"ts": "tsx"
|
package/source/abc/abc.jison
CHANGED
|
@@ -542,15 +542,18 @@ bar
|
|
|
542
542
|
| ':' '|' ']' -> ':|]'
|
|
543
543
|
| '|' N -> '|' + $2
|
|
544
544
|
| '|' N volta_rest -> '|' + $2 + $3
|
|
545
|
-
| ':' '|' N -> ':|' + $
|
|
546
|
-
| ':' '|' N volta_rest -> ':|' + $
|
|
545
|
+
| ':' '|' N -> ':|' + $3
|
|
546
|
+
| ':' '|' N volta_rest -> ':|' + $3 + $4
|
|
547
547
|
| '&' -> '&'
|
|
548
548
|
;
|
|
549
549
|
|
|
550
|
-
// Additional volta endings after the first number
|
|
550
|
+
// Additional volta endings after the first number: comma-separated "1,2,3", or a
|
|
551
|
+
// hyphen range "1-3". Mirrors abc2xml, which normalizes "[1-2" to number="1,2".
|
|
551
552
|
volta_rest
|
|
552
553
|
: ',' N -> ',' + $2
|
|
554
|
+
| '-' N -> ',' + $2
|
|
553
555
|
| volta_rest ',' N -> $1 + ',' + $3
|
|
556
|
+
| volta_rest '-' N -> $1 + ',' + $3
|
|
554
557
|
;
|
|
555
558
|
|
|
556
559
|
music
|
|
@@ -566,7 +569,8 @@ music
|
|
|
566
569
|
| music NAME -> $1
|
|
567
570
|
| music '^' NAME -> $1
|
|
568
571
|
| music '^' articulation_letter -> $1
|
|
569
|
-
| music '[' N -> $1
|
|
572
|
+
| music '[' N -> $1 ? [...$1, {volta: String($3)}] : [{volta: String($3)}]
|
|
573
|
+
| music '[' N volta_rest -> $1 ? [...$1, {volta: String($3) + $4}] : [{volta: String($3) + $4}]
|
|
570
574
|
;
|
|
571
575
|
|
|
572
576
|
// Articulation-class letters (P macro: HJLMOPRSTuv). After a stray '^' inside a
|