@k-l-lambda/lilylet 0.1.75 → 0.1.76

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.
@@ -108,34 +108,34 @@ case 13:
108
108
  this.$ = mergeHeaders($$[$0-2], $$[$0]);
109
109
  break;
110
110
  case 14:
111
- this.$ = ({ title: $$[$0-1].slice(1, -1) });
111
+ this.$ = ({ title: unq($$[$0-1]) });
112
112
  break;
113
113
  case 15:
114
- this.$ = ({ subtitle: $$[$0-1].slice(1, -1) });
114
+ this.$ = ({ subtitle: unq($$[$0-1]) });
115
115
  break;
116
116
  case 16:
117
- this.$ = ({ composer: $$[$0-1].slice(1, -1) });
117
+ this.$ = ({ composer: unq($$[$0-1]) });
118
118
  break;
119
119
  case 17:
120
- this.$ = ({ arranger: $$[$0-1].slice(1, -1) });
120
+ this.$ = ({ arranger: unq($$[$0-1]) });
121
121
  break;
122
122
  case 18:
123
- this.$ = ({ lyricist: $$[$0-1].slice(1, -1) });
123
+ this.$ = ({ lyricist: unq($$[$0-1]) });
124
124
  break;
125
125
  case 19:
126
- this.$ = ({ opus: $$[$0-1].slice(1, -1) });
126
+ this.$ = ({ opus: unq($$[$0-1]) });
127
127
  break;
128
128
  case 20:
129
- this.$ = ({ instrument: $$[$0-1].slice(1, -1) });
129
+ this.$ = ({ instrument: unq($$[$0-1]) });
130
130
  break;
131
131
  case 21:
132
- this.$ = instrumentStaff($$[$0-2], $$[$0-1].slice(1, -1));
132
+ this.$ = instrumentStaff($$[$0-2], unq($$[$0-1]));
133
133
  break;
134
134
  case 22:
135
- this.$ = instrumentStaff($$[$0-3], $$[$0-2].slice(1, -1), $$[$0-1].slice(1, -1));
135
+ this.$ = instrumentStaff($$[$0-3], unq($$[$0-2]), unq($$[$0-1]));
136
136
  break;
137
137
  case 23:
138
- this.$ = ({ genre: $$[$0-1].slice(1, -1) });
138
+ this.$ = ({ genre: unq($$[$0-1]) });
139
139
  break;
140
140
  case 24:
141
141
  this.$ = ({ staves: $$[$0-1].slice(1, -1) });
@@ -214,13 +214,13 @@ case 50:
214
214
  this.$ = harmonyEvent($$[$0].slice(1, -1));
215
215
  break;
216
216
  case 51:
217
- this.$ = markupEvent($$[$0].slice(1, -1));
217
+ this.$ = markupEvent(unq($$[$0]));
218
218
  break;
219
219
  case 52:
220
- this.$ = markupEvent($$[$0].slice(1, -1), 'above');
220
+ this.$ = markupEvent(unq($$[$0]), 'above');
221
221
  break;
222
222
  case 53:
223
- this.$ = markupEvent($$[$0].slice(1, -1), 'below');
223
+ this.$ = markupEvent(unq($$[$0]), 'below');
224
224
  break;
225
225
  case 54:
226
226
  this.$ = dynamicEvent($$[$0].type);
@@ -351,10 +351,10 @@ case 95:
351
351
  numericTimeSignature = false; this.$ = null;
352
352
  break;
353
353
  case 97:
354
- this.$ = ({ text: $$[$0-3].slice(1, -1), beat: $$[$0-2], bpm: Number($$[$0]) });
354
+ this.$ = ({ text: unq($$[$0-3]), beat: $$[$0-2], bpm: Number($$[$0]) });
355
355
  break;
356
356
  case 98:
357
- this.$ = ({ text: $$[$0].slice(1, -1) });
357
+ this.$ = ({ text: unq($$[$0]) });
358
358
  break;
359
359
  case 99:
360
360
  this.$ = ({ beat: $$[$0-2], bpm: Number($$[$0]) });
@@ -525,7 +525,7 @@ case 187:
525
525
  this.$ = navigation('segno');
526
526
  break;
527
527
  case 188:
528
- this.$ = markupMark($$[$0].slice(1, -1));
528
+ this.$ = markupMark(unq($$[$0]));
529
529
  break;
530
530
  }
531
531
  },
@@ -808,6 +808,13 @@ parse: function parse(input) {
808
808
  const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
809
809
  const markupMark = (content) => ({ markType: 'markup', content });
810
810
 
811
+ // Strip the surrounding quotes from a STRING token and unescape the control
812
+ // sequences the serializer emits (escapeString): \n \r \t \" \\ . A single
813
+ // left-to-right pass over each \x pair avoids the sequential-replace bug where
814
+ // an escaped backslash (\\) followed by n would be misread as a newline.
815
+ const UNESCAPE = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
816
+ const unq = (s) => s.slice(1, -1).replace(/\\(.)/g, (m, c) => (c in UNESCAPE ? UNESCAPE[c] : m));
817
+
811
818
  // Build an { instruments: { <key>: { name, shortName? } } } fragment from a
812
819
  // `[instrument-<key>` header token. <key> is a staff-layout group key — a single
813
820
  // staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
@@ -622,26 +622,29 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
622
622
  */
623
623
  const encodeMetadata = (metadata) => {
624
624
  const entries = [];
625
+ // LilyPond strings escape backslash and double-quote with a backslash. An
626
+ // unescaped " (or \) inside a header value otherwise breaks the \header block.
627
+ const ly = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
625
628
  if (metadata.title) {
626
- entries.push(` title = "${metadata.title}"`);
629
+ entries.push(` title = "${ly(metadata.title)}"`);
627
630
  }
628
631
  if (metadata.subtitle) {
629
- entries.push(` subtitle = "${metadata.subtitle}"`);
632
+ entries.push(` subtitle = "${ly(metadata.subtitle)}"`);
630
633
  }
631
634
  if (metadata.composer) {
632
- entries.push(` composer = "${metadata.composer}"`);
635
+ entries.push(` composer = "${ly(metadata.composer)}"`);
633
636
  }
634
637
  if (metadata.arranger) {
635
- entries.push(` arranger = "${metadata.arranger}"`);
638
+ entries.push(` arranger = "${ly(metadata.arranger)}"`);
636
639
  }
637
640
  if (metadata.lyricist) {
638
- entries.push(` poet = "${metadata.lyricist}"`);
641
+ entries.push(` poet = "${ly(metadata.lyricist)}"`);
639
642
  }
640
643
  if (metadata.opus) {
641
- entries.push(` opus = "${metadata.opus}"`);
644
+ entries.push(` opus = "${ly(metadata.opus)}"`);
642
645
  }
643
646
  if (metadata.instrument) {
644
- entries.push(` instrument = "${metadata.instrument}"`);
647
+ entries.push(` instrument = "${ly(metadata.instrument)}"`);
645
648
  }
646
649
  entries.push(' tagline = ##f');
647
650
  return entries.join('\n');
@@ -64,5 +64,16 @@ export interface VoltaEndingSpan {
64
64
  measures: number[];
65
65
  }
66
66
  export declare const collectVoltaSpans: (layout: MeasureLayout) => VoltaEndingSpan[];
67
+ export interface LayoutSegment {
68
+ id: string;
69
+ kind: "section" | "ending";
70
+ endingNumber?: number;
71
+ measures: number[];
72
+ }
73
+ export interface SegmentDecomposition {
74
+ segments: LayoutSegment[];
75
+ order: string[];
76
+ }
77
+ export declare const decomposeToSegments: (layout: MeasureLayout) => SegmentDecomposition | null;
67
78
  /** Serialize an AST back to the canonical index-wise DSL string. */
68
79
  export declare const serializeMeasureLayout: (layout: MeasureLayout) => string;
@@ -362,6 +362,103 @@ export const collectVoltaSpans = (layout) => {
362
362
  walk(layout);
363
363
  return spans;
364
364
  };
365
+ export const decomposeToSegments = (layout) => {
366
+ const segments = [];
367
+ let hasEnding = false;
368
+ let counter = 0;
369
+ const newId = (k) => `seg-${k}-${counter++}`;
370
+ // Indices a node covers in notation order (each measure once, ascending).
371
+ const indicesOf = (node) => {
372
+ switch (node.kind) {
373
+ case "single": return [node.measure];
374
+ case "block": return [].concat(...node.seq.map(indicesOf));
375
+ case "volta": return [].concat(...node.body.map(indicesOf), ...(node.alternates ? [].concat(...node.alternates.map(a => [].concat(...a.map(indicesOf)))) : []));
376
+ case "aba": return [].concat(indicesOf(node.main), ...node.rest.map(indicesOf));
377
+ }
378
+ };
379
+ const contiguousAsc = (xs) => xs.length > 0 && xs.every((v, i) => i === 0 || v === xs[i - 1] + 1);
380
+ // Accumulate a run of plain (non-volta/non-aba) measures into one section,
381
+ // flushing whenever a structural node (volta/aba) interrupts the run.
382
+ let run = [];
383
+ const flushRun = () => {
384
+ if (run.length === 0)
385
+ return;
386
+ if (!contiguousAsc(run))
387
+ throw new Error("non-contiguous section run");
388
+ segments.push({ id: newId("sec"), kind: "section", measures: run });
389
+ run = [];
390
+ };
391
+ // Emit segments for a node in document order. Plain singles/blocks extend the
392
+ // current run; a volta flushes the run then emits body-section + ending(s); an
393
+ // aba flushes then recurses into main and rest (so the replayed main is its own
394
+ // segment run). Throws on any non-contiguous group → caller falls back to flat.
395
+ const emit = (node) => {
396
+ switch (node.kind) {
397
+ case "single":
398
+ run.push(node.measure);
399
+ break;
400
+ case "block":
401
+ node.seq.forEach(emit);
402
+ break;
403
+ case "aba":
404
+ flushRun();
405
+ emit(node.main);
406
+ flushRun();
407
+ node.rest.forEach(emit);
408
+ flushRun();
409
+ break;
410
+ case "volta": {
411
+ flushRun();
412
+ const body = indicesOf({ kind: "block", seq: node.body });
413
+ if (!contiguousAsc(body))
414
+ throw new Error("non-contiguous volta body");
415
+ segments.push({ id: newId("body"), kind: "section", measures: body });
416
+ if (node.alternates) {
417
+ hasEnding = true;
418
+ node.alternates.forEach((alt, ai) => {
419
+ const m = indicesOf({ kind: "block", seq: alt });
420
+ if (!contiguousAsc(m))
421
+ throw new Error("non-contiguous ending");
422
+ segments.push({ id: newId("end"), kind: "ending", endingNumber: ai + 1, measures: m });
423
+ });
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ };
429
+ try {
430
+ emit(layout);
431
+ flushRun();
432
+ }
433
+ catch {
434
+ return null;
435
+ }
436
+ if (!hasEnding)
437
+ return null; // no voltas → flat measure-level path is fine
438
+ // Map each measure index to the segment whose `measures` contains it. A volta
439
+ // body measure belongs to its body section; alternate measures to their ending.
440
+ const segOfMeasure = new Map();
441
+ for (const s of segments)
442
+ for (const m of s.measures)
443
+ if (!segOfMeasure.has(m))
444
+ segOfMeasure.set(m, s.id);
445
+ // Performance order over segment ids: walk the flat Full expansion and collapse
446
+ // consecutive measures that map to the same segment into one segment ref. This
447
+ // reproduces the exact playback order (body repeats → body id appears twice,
448
+ // ABA replay → main segment id reappears), keyed off the proven expander.
449
+ const flat = expandMeasureLayout(layout, LayoutType.Full);
450
+ const order = [];
451
+ for (const m of flat) {
452
+ const id = segOfMeasure.get(m);
453
+ if (id === undefined)
454
+ return null; // a played measure isn't in any segment → bail to flat
455
+ if (order.length === 0 || order[order.length - 1] !== id)
456
+ order.push(id);
457
+ else if (segments.find(s => s.id === id).measures[0] === m)
458
+ order.push(id); // same seg restarting (repeat) → new ref
459
+ }
460
+ return { segments, order };
461
+ };
365
462
  // ── Serializer ───────────────────────────────────────────────────────────
366
463
  // Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
367
464
  // "A..B" ranges (lotus's seqToCode).
@@ -1,7 +1,7 @@
1
1
  import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
2
2
  import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
3
3
  import { gmProgramOf } from "./gmInstruments.js";
4
- import { parseMeasureLayout, expandMeasureLayout, collectVoltaSpans } from "./measureLayout.js";
4
+ import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout.js";
5
5
  // MEI key signatures: positive = sharps, negative = flats
6
6
  const KEY_SIGS = {
7
7
  0: "0",
@@ -2294,31 +2294,70 @@ const encode = (doc, options = {}) => {
2294
2294
  // below — so remember where to insert it and build it afterwards).
2295
2295
  const expansionInsertPos = mei.length;
2296
2296
  const measureIds = [];
2297
- // Volta (1st/2nd-ending) house brackets. The measure-layout directive already
2298
- // encodes which measures form each alternate; <expansion> replays them but does
2299
- // NOT draw the 1./2. brackets. So derive each alternate's contiguous measure
2300
- // span and wrap that run of <measure>s in an <ending n="N"> container (verovio's
2301
- // own MusicXML import does the same). Keyed by the 1-based measure index where
2302
- // an ending opens / closes.
2303
- const endingOpenAt = new Map(); // measure index → ending number
2304
- const endingCloseAt = new Set(); // measure index after which </ending> closes
2297
+ // Volta playback + house brackets. A flat measure-level <expansion> conflicts
2298
+ // with <ending> wrappers in verovio (it then drops the volta measures). Verovio
2299
+ // instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
2300
+ // whose plist references those segment ids. So when the layout decomposes into
2301
+ // volta segments, emit that structure; otherwise keep the simpler flat path.
2302
+ // See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
2303
+ // Helper to check if a measure has any musical content
2304
+ const measureHasContent = (measure) => {
2305
+ for (const part of measure.parts) {
2306
+ for (const voice of part.voices) {
2307
+ for (const event of voice.events) {
2308
+ // Check for actual musical content (not just context changes or pitch resets)
2309
+ if (event.type === 'note' || event.type === 'rest' ||
2310
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2311
+ return true;
2312
+ }
2313
+ }
2314
+ }
2315
+ }
2316
+ return false;
2317
+ };
2318
+ // Filter out trailing empty measures (done before segment setup so the segment
2319
+ // guard below knows the true emitted measure count).
2320
+ let measures = doc.measures;
2321
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2322
+ measures = measures.slice(0, -1);
2323
+ }
2324
+ let segDecomp = null;
2325
+ const segOpenAt = new Map(); // measure index → opening tag for its segment
2326
+ const segCloseAt = new Map(); // measure index → closing tag after it
2327
+ const segIdById = new Map(); // layout segment id → emitted xml:id
2305
2328
  if (doc.metadata?.measureLayout) {
2306
2329
  try {
2307
- const spans = collectVoltaSpans(parseMeasureLayout(doc.metadata.measureLayout));
2308
- for (const s of spans) {
2309
- // Only wrap a contiguous ascending run (a house is always adjacent bars).
2310
- const lo = s.measures[0];
2311
- const hi = s.measures[s.measures.length - 1];
2312
- const contiguous = s.measures.every((m, k) => m === lo + k);
2313
- if (contiguous) {
2314
- endingOpenAt.set(lo, s.number);
2315
- endingCloseAt.add(hi);
2316
- }
2330
+ segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
2331
+ }
2332
+ catch {
2333
+ segDecomp = null;
2334
+ }
2335
+ }
2336
+ // A layout that references a measure beyond the emitted range (e.g. a volta
2337
+ // alternate whose bar was a trailing empty measure that got trimmed) cannot be
2338
+ // emitted as nested sections without a dangling <ending>/<section> id or an
2339
+ // unbalanced wrapper. Drop to the flat measure-level expansion path, which
2340
+ // already warns-and-skips out-of-range refs.
2341
+ if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
2342
+ segDecomp = null;
2343
+ }
2344
+ if (segDecomp) {
2345
+ const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2346
+ for (const seg of segDecomp.segments) {
2347
+ const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
2348
+ segIdById.set(seg.id, xmlId);
2349
+ const lo = seg.measures[0];
2350
+ const hi = seg.measures[seg.measures.length - 1];
2351
+ if (seg.kind === "ending") {
2352
+ const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
2353
+ segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
2354
+ }
2355
+ else {
2356
+ segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
2317
2357
  }
2358
+ segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
2318
2359
  }
2319
- catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
2320
2360
  }
2321
- let endingOpen = false; // whether an <ending> is currently open in the output
2322
2361
  // Track tie state across measures for cross-measure ties
2323
2362
  const tieState = {};
2324
2363
  // Track slur state across measures for cross-measure slurs
@@ -2344,26 +2383,6 @@ const encode = (doc, options = {}) => {
2344
2383
  transState[globalStaff] = `${t.diat},${t.semi}`;
2345
2384
  }
2346
2385
  }
2347
- // Helper to check if a measure has any musical content
2348
- const measureHasContent = (measure) => {
2349
- for (const part of measure.parts) {
2350
- for (const voice of part.voices) {
2351
- for (const event of voice.events) {
2352
- // Check for actual musical content (not just context changes or pitch resets)
2353
- if (event.type === 'note' || event.type === 'rest' ||
2354
- event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2355
- return true;
2356
- }
2357
- }
2358
- }
2359
- }
2360
- return false;
2361
- };
2362
- // Filter out trailing empty measures
2363
- let measures = doc.measures;
2364
- while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2365
- measures = measures.slice(0, -1);
2366
- }
2367
2386
  // Encode measures
2368
2387
  measures.forEach((measure, mi) => {
2369
2388
  // Check for key signature change and output scoreDef if needed
@@ -2427,31 +2446,33 @@ const encode = (doc, options = {}) => {
2427
2446
  }
2428
2447
  const mIndex = mi + 1;
2429
2448
  const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2430
- // Open an <ending> house bracket if this measure starts an alternate.
2431
- const openNum = endingOpenAt.get(mIndex);
2432
- if (openNum !== undefined && !endingOpen) {
2433
- const lendsym = openNum === 1 ? ' lendsym="angledown"' : '';
2434
- mei += `${mIndent}<ending xml:id="${generateId('ending')}" n="${openNum}"${lendsym}>\n`;
2435
- endingOpen = true;
2436
- }
2437
- const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
2449
+ // When the layout decomposes into volta segments, wrap each measure run in
2450
+ // its <section>/<ending> container (opened/closed by measure index).
2451
+ const openTag = segOpenAt.get(mIndex);
2452
+ if (openTag)
2453
+ mei += openTag;
2454
+ const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2438
2455
  mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
2439
- // Close the <ending> after the measure that ends this alternate.
2440
- if (endingOpen && endingCloseAt.has(mIndex)) {
2441
- mei += `${mIndent}</ending>\n`;
2442
- endingOpen = false;
2443
- }
2456
+ const closeTag = segCloseAt.get(mIndex);
2457
+ if (closeTag)
2458
+ mei += closeTag;
2444
2459
  });
2445
- // Defensive: never leave an <ending> unclosed at the end of the section.
2446
- if (endingOpen) {
2447
- mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`;
2448
- endingOpen = false;
2449
- }
2450
2460
  // Emit an <expansion> describing playback order from the measure-layout
2451
- // directive ([measures "..."]). Verovio's expand="…" consumes its plist to
2452
- // unfold repeats for MIDI/time-map output. Inserted as the first child of
2453
- // <section>; ids were collected in the measure loop above.
2454
- if (doc.metadata?.measureLayout) {
2461
+ // directive ([measures "..."]). Verovio consumes its plist to unfold repeats
2462
+ // for MIDI/time-map output. Inserted as the first child of <section>.
2463
+ // - voltas present (segDecomp): plist references the SECTION/ENDING segment
2464
+ // ids in performed order (a body segment id repeats per pass). This is the
2465
+ // only form verovio plays correctly alongside <ending> house brackets.
2466
+ // - otherwise: the simpler flat measure-level plist (one ref per played bar).
2467
+ if (segDecomp) {
2468
+ const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x) => x !== undefined).map(id => `#${id}`);
2469
+ if (refs.length > 0) {
2470
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2471
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2472
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2473
+ }
2474
+ }
2475
+ else if (doc.metadata?.measureLayout) {
2455
2476
  try {
2456
2477
  const layout = parseMeasureLayout(doc.metadata.measureLayout);
2457
2478
  const order = expandMeasureLayout(layout);
@@ -507,7 +507,7 @@ const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) =
507
507
  case 'markup': {
508
508
  const mk = event;
509
509
  const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
510
- return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
510
+ return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
511
511
  }
512
512
  case 'dynamic': {
513
513
  const dynStr = DYNAMIC_MAP[event.dynamicType];
@@ -841,7 +841,12 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
841
841
  };
842
842
  // Escape string for serialization (quotes and backslashes)
843
843
  const escapeString = (str) => {
844
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
844
+ return str
845
+ .replace(/\\/g, '\\\\')
846
+ .replace(/"/g, '\\"')
847
+ .replace(/\n/g, '\\n')
848
+ .replace(/\r/g, '\\r')
849
+ .replace(/\t/g, '\\t');
845
850
  };
846
851
  // Serialize metadata
847
852
  const serializeMetadata = (metadata) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.75",
3
+ "version": "0.1.76",
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",
@@ -108,34 +108,34 @@ case 13:
108
108
  this.$ = mergeHeaders($$[$0-2], $$[$0]);
109
109
  break;
110
110
  case 14:
111
- this.$ = ({ title: $$[$0-1].slice(1, -1) });
111
+ this.$ = ({ title: unq($$[$0-1]) });
112
112
  break;
113
113
  case 15:
114
- this.$ = ({ subtitle: $$[$0-1].slice(1, -1) });
114
+ this.$ = ({ subtitle: unq($$[$0-1]) });
115
115
  break;
116
116
  case 16:
117
- this.$ = ({ composer: $$[$0-1].slice(1, -1) });
117
+ this.$ = ({ composer: unq($$[$0-1]) });
118
118
  break;
119
119
  case 17:
120
- this.$ = ({ arranger: $$[$0-1].slice(1, -1) });
120
+ this.$ = ({ arranger: unq($$[$0-1]) });
121
121
  break;
122
122
  case 18:
123
- this.$ = ({ lyricist: $$[$0-1].slice(1, -1) });
123
+ this.$ = ({ lyricist: unq($$[$0-1]) });
124
124
  break;
125
125
  case 19:
126
- this.$ = ({ opus: $$[$0-1].slice(1, -1) });
126
+ this.$ = ({ opus: unq($$[$0-1]) });
127
127
  break;
128
128
  case 20:
129
- this.$ = ({ instrument: $$[$0-1].slice(1, -1) });
129
+ this.$ = ({ instrument: unq($$[$0-1]) });
130
130
  break;
131
131
  case 21:
132
- this.$ = instrumentStaff($$[$0-2], $$[$0-1].slice(1, -1));
132
+ this.$ = instrumentStaff($$[$0-2], unq($$[$0-1]));
133
133
  break;
134
134
  case 22:
135
- this.$ = instrumentStaff($$[$0-3], $$[$0-2].slice(1, -1), $$[$0-1].slice(1, -1));
135
+ this.$ = instrumentStaff($$[$0-3], unq($$[$0-2]), unq($$[$0-1]));
136
136
  break;
137
137
  case 23:
138
- this.$ = ({ genre: $$[$0-1].slice(1, -1) });
138
+ this.$ = ({ genre: unq($$[$0-1]) });
139
139
  break;
140
140
  case 24:
141
141
  this.$ = ({ staves: $$[$0-1].slice(1, -1) });
@@ -214,13 +214,13 @@ case 50:
214
214
  this.$ = harmonyEvent($$[$0].slice(1, -1));
215
215
  break;
216
216
  case 51:
217
- this.$ = markupEvent($$[$0].slice(1, -1));
217
+ this.$ = markupEvent(unq($$[$0]));
218
218
  break;
219
219
  case 52:
220
- this.$ = markupEvent($$[$0].slice(1, -1), 'above');
220
+ this.$ = markupEvent(unq($$[$0]), 'above');
221
221
  break;
222
222
  case 53:
223
- this.$ = markupEvent($$[$0].slice(1, -1), 'below');
223
+ this.$ = markupEvent(unq($$[$0]), 'below');
224
224
  break;
225
225
  case 54:
226
226
  this.$ = dynamicEvent($$[$0].type);
@@ -351,10 +351,10 @@ case 95:
351
351
  numericTimeSignature = false; this.$ = null;
352
352
  break;
353
353
  case 97:
354
- this.$ = ({ text: $$[$0-3].slice(1, -1), beat: $$[$0-2], bpm: Number($$[$0]) });
354
+ this.$ = ({ text: unq($$[$0-3]), beat: $$[$0-2], bpm: Number($$[$0]) });
355
355
  break;
356
356
  case 98:
357
- this.$ = ({ text: $$[$0].slice(1, -1) });
357
+ this.$ = ({ text: unq($$[$0]) });
358
358
  break;
359
359
  case 99:
360
360
  this.$ = ({ beat: $$[$0-2], bpm: Number($$[$0]) });
@@ -525,7 +525,7 @@ case 187:
525
525
  this.$ = navigation('segno');
526
526
  break;
527
527
  case 188:
528
- this.$ = markupMark($$[$0].slice(1, -1));
528
+ this.$ = markupMark(unq($$[$0]));
529
529
  break;
530
530
  }
531
531
  },
@@ -808,6 +808,13 @@ parse: function parse(input) {
808
808
  const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
809
809
  const markupMark = (content) => ({ markType: 'markup', content });
810
810
 
811
+ // Strip the surrounding quotes from a STRING token and unescape the control
812
+ // sequences the serializer emits (escapeString): \n \r \t \" \\ . A single
813
+ // left-to-right pass over each \x pair avoids the sequential-replace bug where
814
+ // an escaped backslash (\\) followed by n would be misread as a newline.
815
+ const UNESCAPE = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
816
+ const unq = (s) => s.slice(1, -1).replace(/\\(.)/g, (m, c) => (c in UNESCAPE ? UNESCAPE[c] : m));
817
+
811
818
  // Build an { instruments: { <key>: { name, shortName? } } } fragment from a
812
819
  // `[instrument-<key>` header token. <key> is a staff-layout group key — a single
813
820
  // staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
@@ -130,6 +130,13 @@
130
130
  const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
131
131
  const markupMark = (content) => ({ markType: 'markup', content });
132
132
 
133
+ // Strip the surrounding quotes from a STRING token and unescape the control
134
+ // sequences the serializer emits (escapeString): \n \r \t \" \\ . A single
135
+ // left-to-right pass over each \x pair avoids the sequential-replace bug where
136
+ // an escaped backslash (\\) followed by n would be misread as a newline.
137
+ const UNESCAPE = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
138
+ const unq = (s) => s.slice(1, -1).replace(/\\(.)/g, (m, c) => (c in UNESCAPE ? UNESCAPE[c] : m));
139
+
133
140
  // Build an { instruments: { <key>: { name, shortName? } } } fragment from a
134
141
  // `[instrument-<key>` header token. <key> is a staff-layout group key — a single
135
142
  // staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
@@ -391,16 +398,16 @@ headers
391
398
  ;
392
399
 
393
400
  header
394
- : HEADER_TITLE STRING ']' -> ({ title: $2.slice(1, -1) })
395
- | HEADER_SUBTITLE STRING ']' -> ({ subtitle: $2.slice(1, -1) })
396
- | HEADER_COMPOSER STRING ']' -> ({ composer: $2.slice(1, -1) })
397
- | HEADER_ARRANGER STRING ']' -> ({ arranger: $2.slice(1, -1) })
398
- | HEADER_LYRICIST STRING ']' -> ({ lyricist: $2.slice(1, -1) })
399
- | HEADER_OPUS STRING ']' -> ({ opus: $2.slice(1, -1) })
400
- | HEADER_INSTRUMENT STRING ']' -> ({ instrument: $2.slice(1, -1) })
401
- | HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, $2.slice(1, -1))
402
- | HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, $2.slice(1, -1), $3.slice(1, -1))
403
- | HEADER_GENRE STRING ']' -> ({ genre: $2.slice(1, -1) })
401
+ : HEADER_TITLE STRING ']' -> ({ title: unq($2) })
402
+ | HEADER_SUBTITLE STRING ']' -> ({ subtitle: unq($2) })
403
+ | HEADER_COMPOSER STRING ']' -> ({ composer: unq($2) })
404
+ | HEADER_ARRANGER STRING ']' -> ({ arranger: unq($2) })
405
+ | HEADER_LYRICIST STRING ']' -> ({ lyricist: unq($2) })
406
+ | HEADER_OPUS STRING ']' -> ({ opus: unq($2) })
407
+ | HEADER_INSTRUMENT STRING ']' -> ({ instrument: unq($2) })
408
+ | HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, unq($2))
409
+ | HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, unq($2), unq($3))
410
+ | HEADER_GENRE STRING ']' -> ({ genre: unq($2) })
404
411
  | HEADER_STAVES STRING ']' -> ({ staves: $2.slice(1, -1) })
405
412
  | HEADER_MEASURES STRING ']' -> ({ measureLayout: $2.slice(1, -1) })
406
413
  | HEADER_AUTOBEAM STRING ']' -> ({ autoBeam: $2.slice(1, -1) })
@@ -486,9 +493,9 @@ harmony_event
486
493
  ;
487
494
 
488
495
  markup_event
489
- : CMD_MARKUP STRING -> markupEvent($2.slice(1, -1))
490
- | '^' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'above')
491
- | '_' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'below')
496
+ : CMD_MARKUP STRING -> markupEvent(unq($2))
497
+ | '^' CMD_MARKUP STRING -> markupEvent(unq($3), 'above')
498
+ | '_' CMD_MARKUP STRING -> markupEvent(unq($3), 'below')
492
499
  ;
493
500
 
494
501
  dynamic_event
@@ -606,8 +613,8 @@ partial_cmd
606
613
  ;
607
614
 
608
615
  tempo_cmd
609
- : CMD_TEMPO STRING duration '=' NUMBER -> ({ text: $2.slice(1, -1), beat: $3, bpm: Number($5) })
610
- | CMD_TEMPO STRING -> ({ text: $2.slice(1, -1) })
616
+ : CMD_TEMPO STRING duration '=' NUMBER -> ({ text: unq($2), beat: $3, bpm: Number($5) })
617
+ | CMD_TEMPO STRING -> ({ text: unq($2) })
611
618
  | CMD_TEMPO duration '=' NUMBER -> ({ beat: $2, bpm: Number($4) })
612
619
  ;
613
620
 
@@ -769,5 +776,5 @@ navigation_mark
769
776
  ;
770
777
 
771
778
  markup_mark
772
- : CMD_MARKUP STRING -> markupMark($2.slice(1, -1))
779
+ : CMD_MARKUP STRING -> markupMark(unq($2))
773
780
  ;
@@ -751,26 +751,30 @@ const encodeVoice = (
751
751
  const encodeMetadata = (metadata: Metadata): string => {
752
752
  const entries: string[] = [];
753
753
 
754
+ // LilyPond strings escape backslash and double-quote with a backslash. An
755
+ // unescaped " (or \) inside a header value otherwise breaks the \header block.
756
+ const ly = (s: string): string => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
757
+
754
758
  if (metadata.title) {
755
- entries.push(` title = "${metadata.title}"`);
759
+ entries.push(` title = "${ly(metadata.title)}"`);
756
760
  }
757
761
  if (metadata.subtitle) {
758
- entries.push(` subtitle = "${metadata.subtitle}"`);
762
+ entries.push(` subtitle = "${ly(metadata.subtitle)}"`);
759
763
  }
760
764
  if (metadata.composer) {
761
- entries.push(` composer = "${metadata.composer}"`);
765
+ entries.push(` composer = "${ly(metadata.composer)}"`);
762
766
  }
763
767
  if (metadata.arranger) {
764
- entries.push(` arranger = "${metadata.arranger}"`);
768
+ entries.push(` arranger = "${ly(metadata.arranger)}"`);
765
769
  }
766
770
  if (metadata.lyricist) {
767
- entries.push(` poet = "${metadata.lyricist}"`);
771
+ entries.push(` poet = "${ly(metadata.lyricist)}"`);
768
772
  }
769
773
  if (metadata.opus) {
770
- entries.push(` opus = "${metadata.opus}"`);
774
+ entries.push(` opus = "${ly(metadata.opus)}"`);
771
775
  }
772
776
  if (metadata.instrument) {
773
- entries.push(` instrument = "${metadata.instrument}"`);
777
+ entries.push(` instrument = "${ly(metadata.instrument)}"`);
774
778
  }
775
779
 
776
780
  entries.push(' tagline = ##f');
@@ -412,6 +412,98 @@ export const collectVoltaSpans = (layout: MeasureLayout): VoltaEndingSpan[] => {
412
412
  };
413
413
 
414
414
 
415
+ // ── Segment decomposition (for MEI nested sections + section-level expansion) ──
416
+ // Verovio draws volta house brackets only from <ending> containers, and only
417
+ // plays an <expansion> correctly when the plist references SECTION/ENDING ids
418
+ // (not bare measures) over a properly nested structure. So decompose the layout
419
+ // into contiguous measure groups — a "section" for plain runs and volta bodies,
420
+ // an "ending" for each volta alternate — and a performance order over those
421
+ // segment ids (a body segment id repeats once per pass). Returns null when the
422
+ // layout has no voltas (caller uses the simpler flat measure-level expansion) or
423
+ // can't be cleanly decomposed (non-contiguous segment → fall back to flat).
424
+
425
+ export interface LayoutSegment { id: string; kind: "section" | "ending"; endingNumber?: number; measures: number[]; }
426
+ export interface SegmentDecomposition { segments: LayoutSegment[]; order: string[]; }
427
+
428
+ export const decomposeToSegments = (layout: MeasureLayout): SegmentDecomposition | null => {
429
+ const segments: LayoutSegment[] = [];
430
+ let hasEnding = false;
431
+ let counter = 0;
432
+ const newId = (k: string) => `seg-${k}-${counter++}`;
433
+
434
+ // Indices a node covers in notation order (each measure once, ascending).
435
+ const indicesOf = (node: MeasureLayout): number[] => {
436
+ switch (node.kind) {
437
+ case "single": return [node.measure];
438
+ case "block": return ([] as number[]).concat(...node.seq.map(indicesOf));
439
+ case "volta": return ([] as number[]).concat(...node.body.map(indicesOf),
440
+ ...(node.alternates ? ([] as number[]).concat(...node.alternates.map(a => ([] as number[]).concat(...a.map(indicesOf)))) : []));
441
+ case "aba": return ([] as number[]).concat(indicesOf(node.main), ...node.rest.map(indicesOf));
442
+ }
443
+ };
444
+ const contiguousAsc = (xs: number[]): boolean => xs.length > 0 && xs.every((v, i) => i === 0 || v === xs[i - 1] + 1);
445
+
446
+ // Accumulate a run of plain (non-volta/non-aba) measures into one section,
447
+ // flushing whenever a structural node (volta/aba) interrupts the run.
448
+ let run: number[] = [];
449
+ const flushRun = (): void => {
450
+ if (run.length === 0) return;
451
+ if (!contiguousAsc(run)) throw new Error("non-contiguous section run");
452
+ segments.push({ id: newId("sec"), kind: "section", measures: run });
453
+ run = [];
454
+ };
455
+
456
+ // Emit segments for a node in document order. Plain singles/blocks extend the
457
+ // current run; a volta flushes the run then emits body-section + ending(s); an
458
+ // aba flushes then recurses into main and rest (so the replayed main is its own
459
+ // segment run). Throws on any non-contiguous group → caller falls back to flat.
460
+ const emit = (node: MeasureLayout): void => {
461
+ switch (node.kind) {
462
+ case "single": run.push(node.measure); break;
463
+ case "block": node.seq.forEach(emit); break;
464
+ case "aba": flushRun(); emit(node.main); flushRun(); node.rest.forEach(emit); flushRun(); break;
465
+ case "volta": {
466
+ flushRun();
467
+ const body = indicesOf({ kind: "block", seq: node.body });
468
+ if (!contiguousAsc(body)) throw new Error("non-contiguous volta body");
469
+ segments.push({ id: newId("body"), kind: "section", measures: body });
470
+ if (node.alternates) {
471
+ hasEnding = true;
472
+ node.alternates.forEach((alt, ai) => {
473
+ const m = indicesOf({ kind: "block", seq: alt });
474
+ if (!contiguousAsc(m)) throw new Error("non-contiguous ending");
475
+ segments.push({ id: newId("end"), kind: "ending", endingNumber: ai + 1, measures: m });
476
+ });
477
+ }
478
+ break;
479
+ }
480
+ }
481
+ };
482
+
483
+ try { emit(layout); flushRun(); } catch { return null; }
484
+ if (!hasEnding) return null; // no voltas → flat measure-level path is fine
485
+
486
+ // Map each measure index to the segment whose `measures` contains it. A volta
487
+ // body measure belongs to its body section; alternate measures to their ending.
488
+ const segOfMeasure = new Map<number, string>();
489
+ for (const s of segments) for (const m of s.measures) if (!segOfMeasure.has(m)) segOfMeasure.set(m, s.id);
490
+
491
+ // Performance order over segment ids: walk the flat Full expansion and collapse
492
+ // consecutive measures that map to the same segment into one segment ref. This
493
+ // reproduces the exact playback order (body repeats → body id appears twice,
494
+ // ABA replay → main segment id reappears), keyed off the proven expander.
495
+ const flat = expandMeasureLayout(layout, LayoutType.Full);
496
+ const order: string[] = [];
497
+ for (const m of flat) {
498
+ const id = segOfMeasure.get(m);
499
+ if (id === undefined) return null; // a played measure isn't in any segment → bail to flat
500
+ if (order.length === 0 || order[order.length - 1] !== id) order.push(id);
501
+ else if (segments.find(s => s.id === id)!.measures[0] === m) order.push(id); // same seg restarting (repeat) → new ref
502
+ }
503
+ return { segments, order };
504
+ };
505
+
506
+
415
507
  // ── Serializer ───────────────────────────────────────────────────────────
416
508
  // Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
417
509
  // "A..B" ranges (lotus's seqToCode).
@@ -29,7 +29,7 @@ import {
29
29
  } from "./types";
30
30
  import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
31
31
  import { gmProgramOf } from "./gmInstruments";
32
- import { parseMeasureLayout, expandMeasureLayout, collectVoltaSpans } from "./measureLayout";
32
+ import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout";
33
33
 
34
34
 
35
35
  // MEI key signatures: positive = sharps, negative = flats
@@ -2744,27 +2744,68 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2744
2744
  const expansionInsertPos = mei.length;
2745
2745
  const measureIds: string[] = [];
2746
2746
 
2747
- // Volta (1st/2nd-ending) house brackets. The measure-layout directive already
2748
- // encodes which measures form each alternate; <expansion> replays them but does
2749
- // NOT draw the 1./2. brackets. So derive each alternate's contiguous measure
2750
- // span and wrap that run of <measure>s in an <ending n="N"> container (verovio's
2751
- // own MusicXML import does the same). Keyed by the 1-based measure index where
2752
- // an ending opens / closes.
2753
- const endingOpenAt = new Map<number, number>(); // measure index → ending number
2754
- const endingCloseAt = new Set<number>(); // measure index after which </ending> closes
2747
+ // Volta playback + house brackets. A flat measure-level <expansion> conflicts
2748
+ // with <ending> wrappers in verovio (it then drops the volta measures). Verovio
2749
+ // instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
2750
+ // whose plist references those segment ids. So when the layout decomposes into
2751
+ // volta segments, emit that structure; otherwise keep the simpler flat path.
2752
+ // See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
2753
+ // Helper to check if a measure has any musical content
2754
+ const measureHasContent = (measure: Measure): boolean => {
2755
+ for (const part of measure.parts) {
2756
+ for (const voice of part.voices) {
2757
+ for (const event of voice.events) {
2758
+ // Check for actual musical content (not just context changes or pitch resets)
2759
+ if (event.type === 'note' || event.type === 'rest' ||
2760
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2761
+ return true;
2762
+ }
2763
+ }
2764
+ }
2765
+ }
2766
+ return false;
2767
+ };
2768
+
2769
+ // Filter out trailing empty measures (done before segment setup so the segment
2770
+ // guard below knows the true emitted measure count).
2771
+ let measures = doc.measures;
2772
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2773
+ measures = measures.slice(0, -1);
2774
+ }
2775
+
2776
+ let segDecomp: ReturnType<typeof decomposeToSegments> = null;
2777
+ const segOpenAt = new Map<number, string>(); // measure index → opening tag for its segment
2778
+ const segCloseAt = new Map<number, string>(); // measure index → closing tag after it
2779
+ const segIdById = new Map<string, string>(); // layout segment id → emitted xml:id
2755
2780
  if (doc.metadata?.measureLayout) {
2756
2781
  try {
2757
- const spans = collectVoltaSpans(parseMeasureLayout(doc.metadata.measureLayout));
2758
- for (const s of spans) {
2759
- // Only wrap a contiguous ascending run (a house is always adjacent bars).
2760
- const lo = s.measures[0];
2761
- const hi = s.measures[s.measures.length - 1];
2762
- const contiguous = s.measures.every((m, k) => m === lo + k);
2763
- if (contiguous) { endingOpenAt.set(lo, s.number); endingCloseAt.add(hi); }
2782
+ segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
2783
+ } catch { segDecomp = null; }
2784
+ }
2785
+ // A layout that references a measure beyond the emitted range (e.g. a volta
2786
+ // alternate whose bar was a trailing empty measure that got trimmed) cannot be
2787
+ // emitted as nested sections without a dangling <ending>/<section> id or an
2788
+ // unbalanced wrapper. Drop to the flat measure-level expansion path, which
2789
+ // already warns-and-skips out-of-range refs.
2790
+ if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
2791
+ segDecomp = null;
2792
+ }
2793
+ if (segDecomp) {
2794
+ const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2795
+ for (const seg of segDecomp.segments) {
2796
+ const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
2797
+ segIdById.set(seg.id, xmlId);
2798
+ const lo = seg.measures[0];
2799
+ const hi = seg.measures[seg.measures.length - 1];
2800
+ if (seg.kind === "ending") {
2801
+ const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
2802
+ segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
2803
+ } else {
2804
+ segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
2764
2805
  }
2765
- } catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
2806
+ segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
2807
+ }
2766
2808
  }
2767
- let endingOpen = false; // whether an <ending> is currently open in the output
2768
2809
 
2769
2810
  // Track tie state across measures for cross-measure ties
2770
2811
  const tieState: TieState = {};
@@ -2796,28 +2837,6 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2796
2837
  }
2797
2838
  }
2798
2839
 
2799
- // Helper to check if a measure has any musical content
2800
- const measureHasContent = (measure: Measure): boolean => {
2801
- for (const part of measure.parts) {
2802
- for (const voice of part.voices) {
2803
- for (const event of voice.events) {
2804
- // Check for actual musical content (not just context changes or pitch resets)
2805
- if (event.type === 'note' || event.type === 'rest' ||
2806
- event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2807
- return true;
2808
- }
2809
- }
2810
- }
2811
- }
2812
- return false;
2813
- };
2814
-
2815
- // Filter out trailing empty measures
2816
- let measures = doc.measures;
2817
- while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2818
- measures = measures.slice(0, -1);
2819
- }
2820
-
2821
2840
  // Encode measures
2822
2841
  measures.forEach((measure, mi) => {
2823
2842
  // Check for key signature change and output scoreDef if needed
@@ -2877,29 +2896,32 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2877
2896
  }
2878
2897
  const mIndex = mi + 1;
2879
2898
  const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2880
- // Open an <ending> house bracket if this measure starts an alternate.
2881
- const openNum = endingOpenAt.get(mIndex);
2882
- if (openNum !== undefined && !endingOpen) {
2883
- const lendsym = openNum === 1 ? ' lendsym="angledown"' : '';
2884
- mei += `${mIndent}<ending xml:id="${generateId('ending')}" n="${openNum}"${lendsym}>\n`;
2885
- endingOpen = true;
2886
- }
2887
- const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
2899
+ // When the layout decomposes into volta segments, wrap each measure run in
2900
+ // its <section>/<ending> container (opened/closed by measure index).
2901
+ const openTag = segOpenAt.get(mIndex);
2902
+ if (openTag) mei += openTag;
2903
+ const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2888
2904
  mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
2889
- // Close the <ending> after the measure that ends this alternate.
2890
- if (endingOpen && endingCloseAt.has(mIndex)) {
2891
- mei += `${mIndent}</ending>\n`;
2892
- endingOpen = false;
2893
- }
2905
+ const closeTag = segCloseAt.get(mIndex);
2906
+ if (closeTag) mei += closeTag;
2894
2907
  });
2895
- // Defensive: never leave an <ending> unclosed at the end of the section.
2896
- if (endingOpen) { mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`; endingOpen = false; }
2897
2908
 
2898
2909
  // Emit an <expansion> describing playback order from the measure-layout
2899
- // directive ([measures "..."]). Verovio's expand="…" consumes its plist to
2900
- // unfold repeats for MIDI/time-map output. Inserted as the first child of
2901
- // <section>; ids were collected in the measure loop above.
2902
- if (doc.metadata?.measureLayout) {
2910
+ // directive ([measures "..."]). Verovio consumes its plist to unfold repeats
2911
+ // for MIDI/time-map output. Inserted as the first child of <section>.
2912
+ // - voltas present (segDecomp): plist references the SECTION/ENDING segment
2913
+ // ids in performed order (a body segment id repeats per pass). This is the
2914
+ // only form verovio plays correctly alongside <ending> house brackets.
2915
+ // - otherwise: the simpler flat measure-level plist (one ref per played bar).
2916
+ if (segDecomp) {
2917
+ const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x): x is string => x !== undefined).map(id => `#${id}`);
2918
+ if (refs.length > 0) {
2919
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2920
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2921
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2922
+ }
2923
+ }
2924
+ else if (doc.metadata?.measureLayout) {
2903
2925
  try {
2904
2926
  const layout = parseMeasureLayout(doc.metadata.measureLayout);
2905
2927
  const order = expandMeasureLayout(layout);
@@ -631,7 +631,7 @@ const serializeEvent = (
631
631
  case 'markup': {
632
632
  const mk = event as MarkupEvent;
633
633
  const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
634
- return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
634
+ return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
635
635
  }
636
636
  case 'dynamic': {
637
637
  const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
@@ -1011,7 +1011,12 @@ const serializeMeasure = (
1011
1011
 
1012
1012
  // Escape string for serialization (quotes and backslashes)
1013
1013
  const escapeString = (str: string): string => {
1014
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1014
+ return str
1015
+ .replace(/\\/g, '\\\\')
1016
+ .replace(/"/g, '\\"')
1017
+ .replace(/\n/g, '\\n')
1018
+ .replace(/\r/g, '\\r')
1019
+ .replace(/\t/g, '\\t');
1015
1020
  };
1016
1021
 
1017
1022
  // Serialize metadata