Skip to main content

croma_core/
to_abc.rs

1//! Canonical `Score` -> ABC writer (the reverse of the MusicXML writer).
2//!
3//! Emits ABC that is a `croma fmt` fixed point and round-trips through
4//! `parse_document` + `lower_score` with an identical structural projection.
5use crate::model::{
6    AlignedLyric, AnnotationPlacementModel, ClefChangeModel, EventAttachments, Fraction,
7    HarmonyKindText, KeySignatureModel, Measure, MeterModel, SlurRole, TempoBeatRole, TempoModel,
8    TieRole, TupletAttachment, TupletRole,
9};
10// The written->stored octave shift for a voice's `clef=` (`±8`/`±15`),
11// `octave=` and `middle=` modifiers is the parser's own
12// (`lower::voice::voice_octave_shift`). Stored pitches already carry it, so the
13// writer SUBTRACTS it to recover the written octave (the re-parse re-applies
14// the echoed modifiers).
15use crate::lower::voice::{clef_octave_shift, voice_octave_param, voice_octave_shift};
16use crate::{Accidental, BarlineKind, Pitch, Rational, RestVisibility, Score, TimedEventKind};
17
18#[derive(Debug, Clone, Copy, Default)]
19pub struct AbcWriteOptions {
20    /// Prefix the output with a `%` comment block explaining the compact
21    /// `[I:cr <code> …]` carrier codes the document actually uses. Off by
22    /// default: the block is purely explanatory and never changes what the
23    /// file means (`%` starts a comment in ABC 2.1).
24    pub legend: bool,
25}
26
27/// One entry per compact carrier code the writer can emit, in the order the
28/// legend lists them. Only codes that actually occur in the produced ABC are
29/// included in the emitted legend (see [`used_carrier_codes`]).
30pub(crate) const LEGEND_LINES: [(&str, &str); 8] = [
31    (
32        "dp",
33        "[I:cr dp=<a|b>]                  direction placement (above/below)",
34    ),
35    (
36        "ht",
37        "[I:cr ht text=\"…\"|text-hex=…]    chord-symbol text (text-hex when it contains ']', '%', or a control character)",
38    ),
39    (
40        "htx",
41        "[I:cr htx]                       chord symbol with no text",
42    ),
43    (
44        "le",
45        "[I:cr le=<n>]                    lyric extend (melisma), verse n",
46    ),
47    ("mr", "[I:cr mr]                        meter restatement"),
48    ("kr", "[I:cr kr]                        key restatement"),
49    (
50        "ec",
51        "[I:cr ec t=<s|d> l=<l|r> n=\"…\"]  repeat-ending close",
52    ),
53    ("mf", "[I:cr mf]                        MusicXML <forward>"),
54];
55
56/// Scans already-emitted ABC for `[I:cr <code>` carriers and returns the
57/// codes actually used, in registry (`LEGEND_LINES`) order. Detecting from
58/// the produced text (rather than re-deriving from the score model) keeps
59/// the legend honest about what the writer actually emitted.
60fn used_carrier_codes(abc: &str) -> Vec<&'static str> {
61    const NEEDLE: &str = "[I:cr ";
62    let mut found = [false; LEGEND_LINES.len()];
63    let mut search_from = 0;
64    while let Some(relative) = abc[search_from..].find(NEEDLE) {
65        let start = search_from + relative + NEEDLE.len();
66        let rest = &abc[start..];
67        let end = rest.find([']', ' ', '=']).unwrap_or(rest.len());
68        let code = &rest[..end];
69        if let Some(index) = LEGEND_LINES
70            .iter()
71            .position(|(registered, _)| *registered == code)
72        {
73            found[index] = true;
74        }
75        // Advance past the needle even when no code matched, so a
76        // pathological input can't loop forever re-finding the same spot.
77        search_from = start;
78    }
79    LEGEND_LINES
80        .iter()
81        .zip(found)
82        .filter_map(|((code, _), used)| used.then_some(*code))
83        .collect()
84}
85
86/// Renders the `%`-comment legend block for `codes`, or an empty string when
87/// `codes` is empty (the legend must never appear as a bare header).
88fn legend_block(codes: &[&'static str]) -> String {
89    if codes.is_empty() {
90        return String::new();
91    }
92    let mut out = String::from("% croma carriers used in this file:\n");
93    for code in codes {
94        let (_, line) = LEGEND_LINES
95            .iter()
96            .find(|(registered, _)| registered == code)
97            .expect("code came from LEGEND_LINES");
98        out.push_str(&format!("%   {line}\n"));
99    }
100    out
101}
102
103/// Emit canonical ABC for `score`. Output is a `croma fmt` fixed point.
104pub fn write_abc(score: &Score, options: AbcWriteOptions) -> String {
105    let mut out = String::new();
106    let meta = &score.metadata;
107    out.push_str(&format!("X:{}\n", meta.reference.text.trim()));
108    // `%%score` staff-grouping directives; the last voice-bearing one wins on
109    // re-parse, so re-emitting all in order is exact.
110    for directive in &meta.directives {
111        out.push_str(&format!("%%score {}\n", directive.value.text.trim()));
112    }
113    if let Some(title) = &meta.title {
114        out.push_str(&format!(
115            "T:{}\n",
116            crate::text::encode_text_string(title.text.trim(), false)
117        ));
118    }
119    // `C:` composer field, after `T:` in canonical ABC order. The reader captures
120    // `<creator type="composer">` into `composers`, and the ABC parser reads `C:`
121    // back into `composers`, so emitting it makes structured composer metadata
122    // survive a MusicXML -> ABC -> MusicXML round trip.
123    for composer in &meta.composers {
124        out.push_str(&format!(
125            "C:{}\n",
126            crate::text::encode_text_string(composer.text.trim(), false)
127        ));
128    }
129    // `M:` is optional in ABC; a tune without one must not gain a synthetic
130    // meter.
131    if let Some(meter) = &meta.meter {
132        if let Some(instruction) = time_symbol_instruction(meter) {
133            out.push_str(&format!("I:{instruction}\n"));
134        }
135        out.push_str(&format!("M:{}\n", meter.display));
136    }
137    let unit = unit_length(score);
138    out.push_str(&format!("L:{}/{}\n", unit.numerator, unit.denominator));
139    if let Some(q) = tempo_field(score) {
140        out.push_str(&format!("Q:{q}\n"));
141    }
142    let key_display = meta
143        .key
144        .as_ref()
145        .map(|k| k.display.clone())
146        .unwrap_or_else(|| "C".to_string());
147    out.push_str(&format!("K:{key_display}\n"));
148    out.push_str(&write_body(score, unit));
149    // Post-tune lyrics (`W:`) round-trip to identical <credit> entries.
150    for line in &meta.post_tune_lyrics {
151        out.push_str(&format!(
152            "W:{}\n",
153            crate::text::encode_text_string(&line.text, false)
154        ));
155    }
156    if !out.ends_with('\n') {
157        out.push('\n');
158    }
159    if options.legend {
160        let legend = legend_block(&used_carrier_codes(&out));
161        if !legend.is_empty() {
162            out.insert_str(0, &legend);
163        }
164    }
165    out
166}
167
168/// Canonical display text for a mid-tune `Q:` tempo: optional quoted text,
169/// then `n/d=bpm` when a numeric beat is present. Re-parses to the same
170/// `TempoModel`, keeping the round-trip stable.
171fn tempo_display(tempo: &crate::model::TempoModel) -> String {
172    let mut out = String::new();
173    if let Some(text) = &tempo.text {
174        out.push('"');
175        out.push_str(&abc_quoted_text(text));
176        out.push('"');
177    }
178    if let Some(beat) = &tempo.beat {
179        if !out.is_empty() {
180            out.push(' ');
181        }
182        out.push_str(&format!(
183            "{}/{}={}",
184            beat.beat_numerator, beat.beat_denominator, beat.bpm
185        ));
186    }
187    out
188}
189
190fn sound_tempo_instruction(tempo: &TempoModel) -> Option<String> {
191    let beat = tempo.beat?;
192    let mut out = format!(
193        "croma-sound-tempo bpm={} beat-n={} beat-d={}",
194        beat.bpm, beat.beat_numerator, beat.beat_denominator
195    );
196    if let Some(text) = &tempo.text {
197        if needs_hex_inline_carrier(text) {
198            out.push_str(&format!(" text-hex={}", crate::hex::encode_hex_utf8(text)));
199        } else {
200            out.push_str(&format!(" text=\"{}\"", abc_carrier_quoted(text)));
201        }
202    }
203    Some(out)
204}
205
206fn tempo_instruction(tempo: &TempoModel) -> Option<String> {
207    if tempo.text.is_none() && tempo.beat.is_none() {
208        return None;
209    }
210    let role = match tempo.beat_role {
211        TempoBeatRole::PrintedMetronome => "printed",
212        TempoBeatRole::PlaybackSoundOnly => "sound",
213    };
214    let mut out = format!("croma-tempo role={role}");
215    if let Some(text) = &tempo.text {
216        if needs_hex_inline_carrier(text) {
217            out.push_str(&format!(" text-hex={}", crate::hex::encode_hex_utf8(text)));
218        } else {
219            out.push_str(&format!(" text=\"{}\"", abc_carrier_quoted(text)));
220        }
221    }
222    if let Some(beat) = tempo.beat {
223        out.push_str(&format!(
224            " bpm={} beat-n={} beat-d={}",
225            beat.bpm, beat.beat_numerator, beat.beat_denominator
226        ));
227    }
228    Some(out)
229}
230
231fn tempo_needs_instruction(tempo: &TempoModel) -> bool {
232    tempo.text.as_deref().is_some_and(needs_hex_inline_carrier)
233}
234
235fn harmony_text_instruction(kind_text: &HarmonyKindText) -> Option<String> {
236    match kind_text {
237        // ABC-native chords carry no provenance; the writer rebuilds `text=` from
238        // the chord string, so no carrier is emitted.
239        HarmonyKindText::AbcNative => None,
240        HarmonyKindText::Textless => Some("cr htx".to_owned()),
241        HarmonyKindText::Text(value) => {
242            if needs_hex_inline_carrier(value) {
243                Some(format!(
244                    "cr ht text-hex={}",
245                    crate::hex::encode_hex_utf8(value)
246                ))
247            } else {
248                Some(format!("cr ht text=\"{}\"", abc_carrier_quoted(value)))
249            }
250        }
251    }
252}
253
254fn direction_placement_instruction(placement: AnnotationPlacementModel) -> String {
255    let placement = match placement {
256        AnnotationPlacementModel::Below => "b",
257        AnnotationPlacementModel::Above
258        | AnnotationPlacementModel::Left
259        | AnnotationPlacementModel::Right
260        | AnnotationPlacementModel::Free => "a",
261    };
262    format!("cr dp={placement}")
263}
264
265fn lyric_extend_instruction(verse: u32) -> String {
266    format!("cr le={verse}")
267}
268
269fn lyric_duplicate_instruction(lyric: &AlignedLyric) -> String {
270    let mut out = format!("croma-lyric-duplicate verse={}", lyric.verse);
271    if needs_hex_inline_carrier(&lyric.text) {
272        out.push_str(&format!(
273            " text-hex={}",
274            crate::hex::encode_hex_utf8(&lyric.text)
275        ));
276    } else {
277        out.push_str(&format!(" text=\"{}\"", abc_carrier_quoted(&lyric.text)));
278    }
279    if lyric.same_note_extend {
280        out.push_str(" extend=1");
281    }
282    out
283}
284
285fn needs_hex_inline_carrier(text: &str) -> bool {
286    text.chars().any(|c| c == ']' || c == '%' || c.is_control())
287}
288
289fn meter_restatement_instruction() -> &'static str {
290    "cr mr"
291}
292
293fn key_restatement_instruction() -> &'static str {
294    "cr kr"
295}
296
297fn time_symbol_instruction(meter: &MeterModel) -> Option<String> {
298    let symbol = meter.time_symbol.as_deref()?;
299    if !matches!(symbol, "common" | "cut") {
300        return None;
301    }
302    if (symbol == "common" && meter.display.trim() == "C")
303        || (symbol == "cut" && meter.display.trim() == "C|")
304    {
305        return None;
306    }
307    Some(format!("croma-time-symbol symbol={symbol}"))
308}
309
310fn musicxml_forward_instruction() -> &'static str {
311    "cr mf"
312}
313
314fn musicxml_sequence_backup_instruction(duration: Fraction) -> String {
315    format!(
316        "croma-musicxml-sequence-backup n={} d={}",
317        duration.numerator, duration.denominator
318    )
319}
320
321fn abc_tuplet_actual_supported(actual_notes: u32) -> bool {
322    (2..=9).contains(&actual_notes)
323}
324
325fn musicxml_tuplet_instruction(tuplet: TupletAttachment) -> String {
326    let role = match tuplet.role {
327        TupletRole::Start => "start",
328        TupletRole::Continue => "continue",
329        TupletRole::Stop => "stop",
330    };
331    format!(
332        "croma-musicxml-tuplet id={} actual={} normal={} role={}",
333        tuplet.pair_id, tuplet.actual_notes, tuplet.normal_notes, role
334    )
335}
336
337fn musicxml_after_grace_instruction() -> &'static str {
338    "croma-after-grace"
339}
340
341fn clef_cursor_instruction(clef: &ClefChangeModel) -> Option<String> {
342    let cursor_back = clef.musicxml_cursor_back?;
343    let mut out = String::from("croma-clef-cursor");
344    if needs_hex_inline_carrier(&clef.clef.text) {
345        out.push_str(&format!(
346            " clef-hex={}",
347            crate::hex::encode_hex_utf8(&clef.clef.text)
348        ));
349    } else {
350        out.push_str(&format!(
351            " clef=\"{}\"",
352            abc_carrier_quoted(&clef.clef.text)
353        ));
354    }
355    out.push_str(&format!(
356        " back-n={} back-d={}",
357        cursor_back.numerator, cursor_back.denominator
358    ));
359    if let Some(pre_backup) = clef.musicxml_cursor_pre_backup {
360        out.push_str(&format!(
361            " pre-back-n={} pre-back-d={}",
362            pre_backup.numerator, pre_backup.denominator
363        ));
364    }
365    Some(out)
366}
367
368/// The clef token for an inline `[K:clef=..]` field, or `None` when the text
369/// cannot be written as one. An inline field is single-line and ends at the
370/// first `]`, so a clef carrying a control character or a `]` would corrupt the
371/// music line; such text is left unwritten rather than emitted broken. Every
372/// clef croma itself produces (`treble`/`bass`/`alto`/`tenor`/`perc`, with an
373/// optional `±8`/`±15` suffix) is a plain token and passes.
374fn inline_clef_field_text(clef: &ClefChangeModel) -> Option<&str> {
375    let text = clef.clef.text.trim();
376    let writable = !text.is_empty() && !text.contains(']') && !text.chars().any(char::is_control);
377    writable.then_some(text)
378}
379
380fn barline_style_instruction(kind: BarlineKind) -> Option<&'static str> {
381    match kind {
382        BarlineKind::Dashed => Some("croma-barline-style style=dashed"),
383        _ => None,
384    }
385}
386
387fn xvoice_slur_instruction(pair: u32, role: SlurRole) -> String {
388    let role = match role {
389        SlurRole::Start => "start",
390        SlurRole::Stop => "stop",
391    };
392    format!("croma-xvoice-slur pair={pair} role={role}")
393}
394
395/// One end of a cross-voice slur to project onto a specific event: which
396/// `attachments.slurs` entry to suppress (its `(`/`)` cannot span two `V:`
397/// streams) and the shared `pair=`/`role` carrier to emit in its place.
398#[derive(Clone, Copy)]
399struct XvoiceSlurEmit {
400    slur_index: usize,
401    pair: u32,
402    role: SlurRole,
403}
404
405type XvoiceSlurMap = std::collections::HashMap<(usize, usize), Vec<XvoiceSlurEmit>>;
406
407/// Locate slur ends that ABC `(`/`)` cannot express because they do not pair
408/// within their own `V:` stream — a slur reaching into or out of another voice.
409///
410/// Each voice's own slurs pair by the same LIFO stack the lowering uses, so a
411/// voice whose slurs balance (the overwhelming majority, and every pure-ABC
412/// score) yields nothing and the normal `(`/`)` projection is left untouched.
413/// What stays unmatched is a *dangling* end: a stop popped on an empty stack or
414/// a start never closed. Those are the ends ABC drops today — projecting only
415/// per-note slur types, the round-trip just needs each dangling end to keep its
416/// type, so each is re-paired (a dangling stop with a dangling start sharing the
417/// source `<slur number>`/`pair_id`) and carried. Whichever start pairs with
418/// whichever stop is immaterial to the per-note projection; pairing by document
419/// order simply gives the carried ends matching `<slur number>`s.
420///
421/// The result is keyed by `(global voice index, event index)`.
422fn detect_cross_voice_slurs(score: &Score) -> XvoiceSlurMap {
423    struct Dangler {
424        voice: usize,
425        event: usize,
426        slur_index: usize,
427        pair_id: u32,
428        measure: u32,
429    }
430    let mut dangling_starts: Vec<Dangler> = Vec::new();
431    let mut dangling_stops: Vec<Dangler> = Vec::new();
432    for (voice, voice_model) in score
433        .parts
434        .iter()
435        .flat_map(|part| part.voices.iter())
436        .enumerate()
437    {
438        let mut open: Vec<Dangler> = Vec::new();
439        for (event, timed) in voice_model.events.iter().enumerate() {
440            for (slur_index, slur) in timed.attachments.slurs.iter().enumerate() {
441                let dangler = Dangler {
442                    voice,
443                    event,
444                    slur_index,
445                    pair_id: slur.pair_id,
446                    measure: timed.measure.index,
447                };
448                match slur.role {
449                    SlurRole::Start => open.push(dangler),
450                    SlurRole::Stop => {
451                        if open.pop().is_none() {
452                            dangling_stops.push(dangler);
453                        }
454                    }
455                }
456            }
457        }
458        dangling_starts.append(&mut open);
459    }
460    let mut map = XvoiceSlurMap::new();
461    if dangling_starts.is_empty() || dangling_stops.is_empty() {
462        return map;
463    }
464    // Pair the dangling ends. Both ends of one source slur carry the same
465    // `<slur number>` (`pair_id`), so group by it and pair a dangling stop with a
466    // dangling start in document order — `(measure, voice, event)`, the order
467    // MusicXML lays a measure out (voice by voice, each behind a `<backup>`).
468    let mut starts: std::collections::BTreeMap<u32, Vec<Dangler>> =
469        std::collections::BTreeMap::new();
470    for dangler in dangling_starts {
471        starts.entry(dangler.pair_id).or_default().push(dangler);
472    }
473    let mut stops: std::collections::BTreeMap<u32, Vec<Dangler>> =
474        std::collections::BTreeMap::new();
475    for dangler in dangling_stops {
476        stops.entry(dangler.pair_id).or_default().push(dangler);
477    }
478    let order = |d: &Dangler| (d.measure, d.voice, d.event);
479    let mut next_pair = 1u32;
480    for (pair_id, mut group_starts) in starts {
481        let Some(mut group_stops) = stops.remove(&pair_id) else {
482            continue;
483        };
484        group_starts.sort_by_key(&order);
485        group_stops.sort_by_key(&order);
486        for (start, stop) in group_starts.into_iter().zip(group_stops) {
487            let pair = next_pair;
488            next_pair += 1;
489            map.entry((start.voice, start.event))
490                .or_default()
491                .push(XvoiceSlurEmit {
492                    slur_index: start.slur_index,
493                    pair,
494                    role: SlurRole::Start,
495                });
496            map.entry((stop.voice, stop.event))
497                .or_default()
498                .push(XvoiceSlurEmit {
499                    slur_index: stop.slur_index,
500                    pair,
501                    role: SlurRole::Stop,
502                });
503        }
504    }
505    map
506}
507
508/// The inline carrier(s) for an event's cross-voice slur ends, emitted in place
509/// of the suppressed `(`/`)`.
510fn xvoice_slur_prefix(emits: &[XvoiceSlurEmit]) -> String {
511    let mut out = String::new();
512    for emit in emits {
513        out.push_str(&format!(
514            "[I:{}]",
515            xvoice_slur_instruction(emit.pair, emit.role)
516        ));
517    }
518    out
519}
520
521/// A copy of `attachments` with the cross-voice slur ends removed, so the normal
522/// slur projection no longer emits a `(`/`)` for them.
523fn strip_xvoice_slurs(
524    attachments: &EventAttachments,
525    emits: &[XvoiceSlurEmit],
526) -> EventAttachments {
527    let mut stripped = attachments.clone();
528    let mut indexes: Vec<usize> = emits.iter().map(|emit| emit.slur_index).collect();
529    indexes.sort_unstable_by(|a, b| b.cmp(a));
530    for index in indexes {
531        if index < stripped.slurs.len() {
532            stripped.slurs.remove(index);
533        }
534    }
535    stripped
536}
537
538fn ending_close_instruction(model: &crate::model::RepeatEndingCloseModel) -> Option<String> {
539    let close_type = match model.close_type {
540        crate::model::RepeatEndingCloseType::Stop => "s",
541        crate::model::RepeatEndingCloseType::Discontinue => "d",
542    };
543    let location = match model.location {
544        crate::model::RepeatEndingCloseLocation::Left => "l",
545        crate::model::RepeatEndingCloseLocation::Right => "r",
546    };
547    let number = ending_number_value(&model.endings)?;
548    Some(format!("cr ec t={close_type} l={location} n=\"{number}\""))
549}
550
551fn ending_number_value(parts: &[crate::model::RepeatEndingPartModel]) -> Option<String> {
552    use crate::model::RepeatEndingPartModel::{Range, Single, Text};
553    let values = parts
554        .iter()
555        .map(|part| match part {
556            Single(value) => Some(value.to_string()),
557            Range { start, end } => Some(format!("{start}-{end}")),
558            Text(_) => None,
559        })
560        .collect::<Option<Vec<_>>>()?;
561    (!values.is_empty()).then(|| values.join(","))
562}
563
564fn initial_key_instruction(key: &KeySignatureModel) -> String {
565    let mut out = format!("croma-initial-key fifths={}", key.fifths);
566    if !key.explicit_accidentals.is_empty() {
567        let accidentals = key
568            .explicit_accidentals
569            .iter()
570            .map(|accidental| format!("{}:{}", accidental.step, accidental.accidental.alter()))
571            .collect::<Vec<_>>()
572            .join(",");
573        out.push_str(&format!(" accidentals={accidentals}"));
574    }
575    out
576}
577
578fn initial_key_carrier(score: &Score, key: &KeySignatureModel) -> Option<String> {
579    if score
580        .metadata
581        .key
582        .as_ref()
583        .is_some_and(|header| key.structurally_matches(header))
584    {
585        return None;
586    }
587    Some(initial_key_instruction(key))
588}
589
590fn initial_meter_instruction(meter: &MeterModel) -> String {
591    let mut out = format!(
592        "croma-initial-meter display=\"{}\"",
593        abc_carrier_quoted(&meter.display)
594    );
595    if let Some(symbol) = meter.time_symbol.as_deref()
596        && matches!(symbol, "common" | "cut")
597    {
598        out.push_str(&format!(" symbol={symbol}"));
599    }
600    out
601}
602
603fn initial_meter_carrier(score: &Score, meter: &MeterModel) -> Option<String> {
604    if score
605        .metadata
606        .meter
607        .as_ref()
608        .is_some_and(|header| meter.structurally_matches(header))
609    {
610        return None;
611    }
612    Some(initial_meter_instruction(meter))
613}
614
615fn measure_number_instruction(display_number: &str) -> String {
616    let mut out = "croma-measure-number".to_owned();
617    if needs_hex_inline_carrier(display_number) {
618        out.push_str(&format!(
619            " n-hex={}",
620            crate::hex::encode_hex_utf8(display_number)
621        ));
622    } else if display_number.chars().any(char::is_whitespace) {
623        out.push_str(&format!(" n=\"{}\"", abc_carrier_quoted(display_number)));
624    } else {
625        out.push_str(&format!(" n={display_number}"));
626    }
627    out
628}
629
630fn measure_number_carrier(measure: &Measure) -> Option<String> {
631    let display_number = measure.display_number.as_deref()?.trim();
632    if display_number.is_empty() {
633        return None;
634    }
635    let canonical_number = measure.id.index.saturating_add(1).to_string();
636    (display_number != canonical_number).then(|| measure_number_instruction(display_number))
637}
638
639fn barline_starts_measure(kind: BarlineKind) -> bool {
640    matches!(
641        kind,
642        BarlineKind::Regular | BarlineKind::Initial | BarlineKind::RepeatStart
643    )
644}
645
646fn carrier_follows_leading_barline(kind: &TimedEventKind, joined: Option<&str>) -> bool {
647    matches!(kind, TimedEventKind::Barline(barline) if joined.is_some() || barline_starts_measure(barline.kind))
648}
649
650fn abc_quoted_text(text: &str) -> String {
651    let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
652    crate::text::encode_text_string(&collapsed, true)
653}
654
655/// Unit note length: ABC 2.1 default — measure duration < 3/4 → 1/16, else 1/8.
656fn unit_length(score: &Score) -> Rational {
657    let small = Rational::new(1, 16);
658    let normal = Rational::new(1, 8);
659    match score.metadata.meter.as_ref().and_then(|m| m.duration) {
660        Some(dur) if (dur.numerator as u64) * 4 < (dur.denominator as u64) * 3 => small,
661        _ => normal,
662    }
663}
664
665fn tempo_field(score: &Score) -> Option<String> {
666    let tempo = score.metadata.tempo_model.as_ref()?;
667    if tempo.beat_role == TempoBeatRole::PlaybackSoundOnly || tempo_needs_instruction(tempo) {
668        return None;
669    }
670    let display = tempo_display(tempo);
671    (!display.is_empty()).then_some(display)
672}
673
674fn initial_tempo_instruction(score: &Score) -> Option<String> {
675    let tempo = score.metadata.tempo_model.as_ref()?;
676    if tempo.beat_role == TempoBeatRole::PlaybackSoundOnly || !tempo_needs_instruction(tempo) {
677        return None;
678    }
679    tempo_instruction(tempo)
680}
681
682fn write_body(score: &Score, unit: Rational) -> String {
683    // Single default voice: no `V:` header line (the dominant corpus shape).
684    let single = score.parts.len() == 1
685        && score.parts[0].voices.len() == 1
686        && score.parts[0].voices[0].id.value == "1"
687        && score.parts[0].voices[0].properties == crate::model::VoicePropertiesModel::default();
688    let xvoice = detect_cross_voice_slurs(score);
689    let mut body = String::new();
690    let mut voice_global_index = 0usize;
691    for (part_index, part) in score.parts.iter().enumerate() {
692        for (voice_index, voice) in part.voices.iter().enumerate() {
693            if !single {
694                body.push_str(&voice_header_line(voice));
695            }
696            if voice_index == 0 {
697                body.push_str(&musicxml_instrument_directive_lines(part));
698            }
699            body.push_str(&midi_directive_lines(voice));
700            if part_index == 0
701                && voice_index == 0
702                && let Some(instruction) = initial_tempo_instruction(score)
703            {
704                body.push_str(&format!("[I:{instruction}] "));
705            }
706            if let Some(key) = &voice.initial_key
707                && let Some(instruction) = initial_key_carrier(score, key)
708            {
709                body.push_str(&format!("[I:{instruction}] "));
710            }
711            if let Some(meter) = &voice.initial_meter
712                && let Some(instruction) = initial_meter_carrier(score, meter)
713            {
714                body.push_str(&format!("[I:{instruction}] "));
715            }
716            body.push_str(&write_voice(voice, unit, voice_global_index, &xvoice));
717            voice_global_index += 1;
718        }
719    }
720    body
721}
722
723/// The `V:` header line for a voice: id plus its retained properties in a
724/// fixed canonical order, each echoing the model field under its parser key.
725fn voice_header_line(voice: &crate::model::Voice) -> String {
726    use crate::model::StemDirectionModel;
727    let p = &voice.properties;
728    let mut s = format!("V:{}", voice.id.value);
729    for (key, value) in [
730        ("name", &p.name),
731        ("nm", &p.nm),
732        ("subname", &p.subname),
733        ("snm", &p.snm),
734    ] {
735        if let Some(text) = value {
736            s.push_str(&format!(" {key}=\"{}\"", abc_carrier_quoted(&text.text)));
737        }
738    }
739    if let Some(clef) = &p.clef {
740        s.push_str(&format!(" clef={}", clef.text));
741    }
742    if let Some(stem) = &p.stem {
743        s.push_str(match stem {
744            StemDirectionModel::Up => " stem=up",
745            StemDirectionModel::Down => " stem=down",
746        });
747    }
748    if let Some(octave) = &p.octave {
749        s.push_str(&format!(" octave={}", octave.text));
750    }
751    if let Some(transpose) = &p.transpose {
752        s.push_str(&format!(" transpose={}", transpose.text));
753    }
754    if let Some(middle) = &p.middle {
755        s.push_str(&format!(" middle={}", middle.text));
756    }
757    s.push('\n');
758    s
759}
760
761/// Re-emit a voice's score-meaningful `%%MIDI` directives as line-start tune
762/// directives, inverting the forward translation in `lower`. Placed directly
763/// after the voice's `V:` switch so the re-parse scopes them to this voice.
764/// Each line is the canonical spelling the forward parser reads back into the
765/// same [`MidiInstrumentModel`]:
766/// - `program <channel> <prog>` when both are set, else `program <prog>`,
767///   else a standalone `channel <n>`;
768/// - `control 7 <vol>` (CC7 volume) / `control 10 <pan>` (CC10 pan);
769/// - `midi-unpitched <n>` for MusicXML-origin unpitched percussion maps;
770/// - `transpose <n>` for `%%MIDI transpose`.
771///
772/// Program/channel values are written in the same conventions the parser reads
773/// (0-based GM program, 1-16 channel), so the round-trip is value-for-value.
774fn midi_directive_lines(voice: &crate::model::Voice) -> String {
775    let mut s = String::new();
776    if let Some(midi) = &voice.midi_instrument {
777        match (midi.channel, midi.program) {
778            (Some(channel), Some(program)) => {
779                s.push_str(&format!("%%MIDI program {channel} {program}\n"));
780            }
781            (None, Some(program)) => s.push_str(&format!("%%MIDI program {program}\n")),
782            (Some(channel), None) => s.push_str(&format!("%%MIDI channel {channel}\n")),
783            (None, None) => {}
784        }
785        if let Some(volume) = midi.volume_cc {
786            s.push_str(&format!("%%MIDI control 7 {volume}\n"));
787        }
788        if let Some(pan) = midi.pan_cc {
789            s.push_str(&format!("%%MIDI control 10 {pan}\n"));
790        }
791        if let Some(unpitched) = midi.midi_unpitched {
792            s.push_str(&format!("%%MIDI midi-unpitched {unpitched}\n"));
793        }
794    }
795    if let Some(transpose) = voice.midi_transpose {
796        s.push_str(&format!("%%MIDI transpose {transpose}\n"));
797    }
798    s
799}
800
801fn musicxml_instrument_directive_lines(part: &crate::model::Part) -> String {
802    let mut s = String::new();
803    for instrument in &part.instruments {
804        if instrument.id.trim().is_empty() {
805            continue;
806        }
807        s.push_str("%%croma-musicxml-instrument");
808        s.push_str(&format!(
809            " id=\"{}\"",
810            abc_carrier_quoted(instrument.id.as_str())
811        ));
812        if let Some(name) = &instrument.name {
813            s.push_str(&format!(
814                " name=\"{}\"",
815                abc_carrier_quoted(name.text.as_str())
816            ));
817        }
818        if let Some(midi) = &instrument.midi {
819            if let Some(channel) = midi.channel {
820                s.push_str(&format!(" channel={channel}"));
821            }
822            if let Some(program) = midi.program {
823                s.push_str(&format!(" program={program}"));
824            }
825            if let Some(volume) = midi.volume_cc {
826                s.push_str(&format!(" volume-cc={volume}"));
827            }
828            if let Some(pan) = midi.pan_cc {
829                s.push_str(&format!(" pan-cc={pan}"));
830            }
831            if let Some(unpitched) = midi.midi_unpitched {
832                s.push_str(&format!(" midi-unpitched={unpitched}"));
833            }
834        }
835        s.push('\n');
836    }
837    s
838}
839
840/// Quote a string for a `name="..."`-style ABC carrier value: neutralise any
841/// control character (a foreign `<part-name>`/instrument name may carry a display
842/// line-break, e.g. "S\nA", which would split the single-line `V:`/`%%` header and
843/// corrupt every following note) to a space, then escape `\` and `"`. ABC header
844/// names are single-line, so a multi-line name's break is inexpressible and
845/// normalised to a space rather than preserved.
846fn abc_carrier_quoted(text: &str) -> String {
847    text.chars()
848        .map(|c| if c.is_control() { ' ' } else { c })
849        .collect::<String>()
850        .replace('\\', "\\\\")
851        .replace('"', "\\\"")
852}
853
854/// A pitch moved back to its written octave for emission. Saturating, like
855/// the lowering-side addition, so a boundary-saturated stored octave cannot
856/// overflow back out of i8.
857fn shifted(pitch: &Pitch, shift: i8) -> Pitch {
858    Pitch {
859        octave: pitch.octave.saturating_sub(shift),
860        ..*pitch
861    }
862}
863
864fn write_voice(
865    voice: &crate::model::Voice,
866    unit: Rational,
867    voice_global_index: usize,
868    xvoice: &XvoiceSlurMap,
869) -> String {
870    let mut out = String::new();
871    let shift = voice_octave_shift(&voice.properties);
872    // Every pitch below is written for THIS one shift, so a mid-tune clef change
873    // to a clef with a different `±8`/`±15` component must not move the shift when
874    // the ABC is re-read. `octave=` on the same inline field cancels the
875    // difference; `effective_octave_param` tracks what is currently in force so a
876    // change that needs no compensation emits a bare `clef=`.
877    let baseline_clef_shift = voice
878        .properties
879        .clef
880        .as_ref()
881        .map_or(0, |clef| i32::from(clef_octave_shift(clef.text.as_str())));
882    let baseline_octave_param = voice_octave_param(&voice.properties);
883    let mut effective_octave_param = baseline_octave_param;
884    // Overlay segments (`&`) grouped by the measure they belong to; spliced
885    // before that measure's closing barline, in segment order.
886    let mut overlays: std::collections::BTreeMap<u32, Vec<&crate::model::OverlaySegment>> =
887        std::collections::BTreeMap::new();
888    for measure in &voice.measures {
889        for segment in &measure.overlays {
890            overlays
891                .entry(segment.measure_index)
892                .or_default()
893                .push(segment);
894        }
895    }
896    let (markers, scales) = tuplet_layout(&voice.events);
897    let measure_number_carriers = voice
898        .measures
899        .iter()
900        .filter_map(|measure| {
901            measure_number_carrier(measure).map(|carrier| (measure.id.index, carrier))
902        })
903        .collect::<std::collections::BTreeMap<_, _>>();
904    // Adjacent barline pairs that lowering splits out of ONE source token must
905    // be re-joined on emission: `||:` -> [Double, RepeatStart] and `[|:` ->
906    // [Initial, RepeatStart]. Both halves of a split token share the SAME
907    // source span — that distinguishes them from a real two-token `|| |:` pair
908    // (different spans), which must stay split or its phantom empty measure
909    // would collapse. Emitting a split pair as two spaced tokens creates a
910    // phantom empty measure when the pair leads the tune.
911    let mut joined: Vec<Option<&'static str>> = vec![None; voice.events.len()];
912    let mut skip = vec![false; voice.events.len()];
913    for i in 0..voice.events.len().saturating_sub(1) {
914        if skip[i] {
915            continue;
916        }
917        let (TimedEventKind::Barline(a), TimedEventKind::Barline(b)) =
918            (&voice.events[i].kind, &voice.events[i + 1].kind)
919        else {
920            continue;
921        };
922        if a.span != b.span {
923            continue;
924        }
925        match (a.kind, b.kind) {
926            (BarlineKind::Double, BarlineKind::RepeatStart) => {
927                joined[i] = Some("||:");
928                skip[i + 1] = true;
929            }
930            (BarlineKind::Initial, BarlineKind::RepeatStart) => {
931                joined[i] = Some("[|:");
932                skip[i + 1] = true;
933            }
934            _ => {}
935        }
936    }
937    let mut current_measure: Option<u32> = None;
938    let mut measure_event_seen = false;
939    for (idx, event) in voice.events.iter().enumerate() {
940        if skip[idx] {
941            continue;
942        }
943        let mut measure_carrier_after_barline = None;
944        // Measure transition without a closing barline: flush that measure's
945        // overlays before anything from the new measure is emitted.
946        let m = event.measure.index;
947        if current_measure != Some(m) {
948            if let Some(prev) = current_measure
949                && let Some(segments) = overlays.remove(&prev)
950            {
951                for segment in segments {
952                    out.push_str(&overlay_str(segment, unit, shift));
953                }
954            }
955            let missing_start = current_measure.map_or(0, |prev| prev.saturating_add(1));
956            write_sparse_voice_measure_gaps(&mut out, missing_start, m);
957            current_measure = Some(m);
958            measure_event_seen = false;
959            if let Some(carrier) = measure_number_carriers.get(&m) {
960                if carrier_follows_leading_barline(&event.kind, joined[idx]) {
961                    measure_carrier_after_barline = Some(carrier.as_str());
962                } else {
963                    out.push_str(&format!("[I:{carrier}] "));
964                }
965            }
966        }
967        // A barline that is not the very first event of its measure closes it
968        // (even a barline-only measure: `| & ... |`); flush overlays before it.
969        if measure_event_seen
970            && matches!(event.kind, TimedEventKind::Barline(_))
971            && let Some(segments) = overlays.remove(&m)
972        {
973            for segment in segments {
974                out.push_str(&overlay_str(segment, unit, shift));
975            }
976        }
977        measure_event_seen = true;
978        // Tuplet markers `(p:q:r` open before the first note/rest/chord of the
979        // group. Nested groups can contribute more than one marker here.
980        out.push_str(&markers[idx]);
981        let tuplet = scales[idx];
982        // Cross-voice slur ends (rare): emit each as an inline `[I:croma-xvoice-
983        // slur ...]` carrier and render the event WITHOUT the `(`/`)` that cannot
984        // span two `V:` streams. For every other event this is a no-op borrow.
985        let xvoice_emits: &[XvoiceSlurEmit] = xvoice
986            .get(&(voice_global_index, idx))
987            .map_or(&[], Vec::as_slice);
988        out.push_str(&xvoice_slur_prefix(xvoice_emits));
989        let stripped_attachments;
990        let attachments: &EventAttachments = if xvoice_emits.is_empty() {
991            &event.attachments
992        } else {
993            stripped_attachments = strip_xvoice_slurs(&event.attachments, xvoice_emits);
994            &stripped_attachments
995        };
996        match &event.kind {
997            TimedEventKind::Note(note) => {
998                let written = shifted(&note.pitch, shift);
999                out.push_str(&event_prefix(attachments));
1000                out.push_str(note_accidental(
1001                    note.written_accidental.as_ref().map(|m| m.kind),
1002                ));
1003                out.push_str(&pitch_str(&written));
1004                out.push_str(&length_str(notated_duration(event.duration, tuplet), unit));
1005                out.push_str(&event_suffix(attachments));
1006                out.push(' ');
1007            }
1008            TimedEventKind::Rest(rest) => {
1009                out.push_str(&event_prefix(attachments));
1010                out.push(match rest.visibility {
1011                    RestVisibility::Visible => 'z',
1012                    RestVisibility::Invisible => 'x',
1013                });
1014                out.push_str(&length_str(notated_duration(event.duration, tuplet), unit));
1015                out.push_str(&event_suffix(attachments));
1016                out.push(' ');
1017            }
1018            TimedEventKind::Chord(chord) => {
1019                // Slurs can be recorded on the chord event, on individual
1020                // members, or (redundantly) on both. Merge them, deduping by
1021                // (pair_id, role), so `(`/`)` are emitted exactly once per
1022                // distinct slur. Ties are NOT merged: a tie binds a specific
1023                // member (`[dg-]` ties only g), so each is emitted inline after
1024                // its member. A whole-chord tie (`[CE]2-`) also records ties on
1025                // every member, so inline emission reproduces its sound exactly.
1026                let mut merged = attachments.clone();
1027                merged.ties.clear();
1028                for member in &chord.members {
1029                    for slur in &member.attachments.slurs {
1030                        if !merged
1031                            .slurs
1032                            .iter()
1033                            .any(|x| x.pair_id == slur.pair_id && x.role == slur.role)
1034                        {
1035                            merged.slurs.push(*slur);
1036                        }
1037                    }
1038                }
1039                out.push_str(&event_prefix(&merged));
1040                // Per-member lengths; factor out a shared length to the outer
1041                // `[...]L` form when every member matches (e.g. `[CEG]2`), else
1042                // emit each member's own length (e.g. `[d3f]`).
1043                let lengths: Vec<String> = chord
1044                    .members
1045                    .iter()
1046                    .map(|m| length_str(notated_duration(m.duration, tuplet), unit))
1047                    .collect();
1048                let uniform = lengths.windows(2).all(|w| w[0] == w[1]);
1049                out.push('[');
1050                for (member_index, (member, len)) in chord.members.iter().zip(&lengths).enumerate()
1051                {
1052                    let written = shifted(&member.pitch, shift);
1053                    out.push_str(&chord_member_prefix(
1054                        &member.attachments,
1055                        (member_index == 0).then_some(&merged),
1056                    ));
1057                    out.push_str(note_accidental(
1058                        member.written_accidental.as_ref().map(|m| m.kind),
1059                    ));
1060                    out.push_str(&pitch_str(&written));
1061                    if !uniform {
1062                        out.push_str(len);
1063                    }
1064                    if member
1065                        .attachments
1066                        .ties
1067                        .iter()
1068                        .any(|t| t.role == TieRole::Start)
1069                    {
1070                        out.push('-');
1071                    }
1072                }
1073                out.push(']');
1074                if let Some(len) = lengths.first().filter(|_| uniform) {
1075                    out.push_str(len);
1076                }
1077                out.push_str(&event_suffix(&merged));
1078                out.push(' ');
1079            }
1080            TimedEventKind::Barline(b) => {
1081                if let Some(instruction) = barline_style_instruction(b.kind) {
1082                    out.push_str(&format!("[I:{instruction}] "));
1083                }
1084                out.push_str(joined[idx].unwrap_or_else(|| barline_str(b.kind)));
1085                out.push(' ');
1086                if let Some(carrier) = measure_carrier_after_barline {
1087                    out.push_str(&format!("[I:{carrier}] "));
1088                }
1089            }
1090            TimedEventKind::RepeatEnding(r) => {
1091                out.push_str(&ending_str(r));
1092                out.push(' ');
1093            }
1094            TimedEventKind::RepeatEndingClose(close) => {
1095                if let Some(instruction) = ending_close_instruction(close) {
1096                    out.push_str(&format!("[I:{instruction}] "));
1097                }
1098            }
1099            TimedEventKind::Spacer => {
1100                out.push_str(&event_prefix(attachments));
1101                out.push('y');
1102                out.push_str(&event_suffix(attachments));
1103                out.push(' ');
1104            }
1105            // Mid-tune changes re-emit inline; `display` is the verbatim
1106            // source text (modes, C/C|, exp-accidental lists, clef tokens).
1107            // The parser re-applies them at this position, reproducing the
1108            // baked-in alters / meter state downstream.
1109            TimedEventKind::KeyChange(key) => {
1110                if key.preserve_restatement {
1111                    out.push_str(&format!("[I:{}] ", key_restatement_instruction()));
1112                }
1113                out.push_str(&format!("[K:{}] ", key.display));
1114            }
1115            TimedEventKind::MeterChange(meter) => {
1116                if let Some(instruction) = time_symbol_instruction(meter) {
1117                    out.push_str(&format!("[I:{instruction}] "));
1118                }
1119                if meter.preserve_restatement {
1120                    out.push_str(&format!("[I:{}] ", meter_restatement_instruction()));
1121                }
1122                out.push_str(&format!("[M:{}] ", meter.display));
1123            }
1124            TimedEventKind::ClefChange(clef) => {
1125                // A clef change planted by the ABC->MusicXML direction carries the
1126                // cursor metadata the carrier needs; the carrier holds the clef
1127                // text itself, so it fully replaces the inline field.
1128                if let Some(instruction) = clef_cursor_instruction(clef) {
1129                    out.push_str(&format!("[I:{instruction}] "));
1130                } else if let Some(text) = inline_clef_field_text(clef) {
1131                    // Issue #267: every OTHER clef change (notably each one the
1132                    // MusicXML reader reconstructs, which never has cursor
1133                    // metadata) is an ordinary ABC inline clef field. Without this
1134                    // the event emitted nothing at all and the change was lost.
1135                    let required = baseline_octave_param + baseline_clef_shift
1136                        - i32::from(clef_octave_shift(text));
1137                    if required == effective_octave_param {
1138                        out.push_str(&format!("[K:clef={text}] "));
1139                    } else if (-9..=9).contains(&required) {
1140                        out.push_str(&format!("[K:clef={text} octave={required}] "));
1141                        effective_octave_param = required;
1142                    }
1143                    // A compensation outside the parser's ±9 `octave=` clamp cannot
1144                    // be written, so the change stays unexpressed rather than
1145                    // shifting every following pitch by an octave.
1146                }
1147            }
1148            TimedEventKind::TempoChange(tempo) => {
1149                if tempo.beat_role == TempoBeatRole::PlaybackSoundOnly
1150                    && let Some(instruction) = sound_tempo_instruction(tempo)
1151                {
1152                    out.push_str(&format!("[I:{instruction}] "));
1153                } else if tempo_needs_instruction(tempo)
1154                    && let Some(instruction) = tempo_instruction(tempo)
1155                {
1156                    out.push_str(&format!("[I:{instruction}] "));
1157                } else {
1158                    out.push_str(&format!("[Q:{}] ", tempo_display(tempo)));
1159                }
1160            }
1161            TimedEventKind::SectionLabel(label) => {
1162                // A foreign <rehearsal> may carry control characters (e.g. a
1163                // trailing newline "pre chorus\n"). Both `[P:...]` and whole-line
1164                // `P:...` fields are single-line, so a raw newline would split the
1165                // line and corrupt the following music. Section labels are
1166                // single-line in ABC, so normalise any control char to a space and
1167                // trim — the break is inexpressible, not preserved.
1168                let label = label
1169                    .chars()
1170                    .map(|c| if c.is_control() { ' ' } else { c })
1171                    .collect::<String>();
1172                let label = label.trim();
1173                // If the label contains `[` or `]` it cannot be safely emitted
1174                // as an inline field `[P:{label}]` — the parser reads up to the
1175                // first `]`, truncating the label.  Emit as a whole-line body
1176                // field instead: trim any trailing space from the preceding
1177                // music content, start the body field on a new line (or at the
1178                // beginning if there is no preceding content), then resume on
1179                // yet another new line for the music that follows.
1180                // Whole-line body fields allow `]` in the value (ABC 2.1 §4.3).
1181                if label.contains('[') || label.contains(']') {
1182                    let trimmed = out.trim_end_matches(' ').to_owned();
1183                    out.clear();
1184                    if !trimmed.is_empty() {
1185                        out.push_str(&trimmed);
1186                        out.push('\n');
1187                    }
1188                    out.push_str(&format!(
1189                        "P:{}\n",
1190                        crate::text::encode_text_string(label, false)
1191                    ));
1192                } else {
1193                    out.push_str(&format!(
1194                        "[P:{}] ",
1195                        crate::text::encode_text_string(label, false)
1196                    ));
1197                }
1198            }
1199        }
1200    }
1201    // Flush overlays of the final measure (and any not reached via barlines).
1202    for (_index, segments) in std::mem::take(&mut overlays) {
1203        for segment in segments {
1204            out.push_str(&overlay_str(segment, unit, shift));
1205        }
1206    }
1207    let mut body = format!("{}\n", out.trim_end());
1208    // Verse lines first, then symbol lines: each group must be internally
1209    // adjacent (adjacency drives verse/layer numbering on re-parse), and both
1210    // align over the same single block of notes emitted above.
1211    let align_events = lyric_align_events(voice);
1212    for line in lyric_lines(&align_events) {
1213        body.push_str(&line);
1214        body.push('\n');
1215    }
1216    for line in symbol_lines(&align_events) {
1217        body.push_str(&line);
1218        body.push('\n');
1219    }
1220    body
1221}
1222
1223/// Preserve sparse MusicXML-origin voices that first appear after measure 1 (or
1224/// skip interior measures). ABC voice lines start at their first token, so a
1225/// missing measure index must be held with an inert spacer measure; `y` anchors
1226/// the measure on re-parse but emits no MusicXML note/rest.
1227fn write_sparse_voice_measure_gaps(out: &mut String, start: u32, end: u32) {
1228    for _ in start..end {
1229        out.push_str("y | ");
1230    }
1231}
1232
1233/// Per-event tuplet open markers (`"(p:q:r"` at each group's first event).
1234type TupletMarkers = Vec<String>;
1235/// Per-event product tuplet (actual, normal) ratio used to scale a notated length.
1236type TupletScales = Vec<Option<TupletScale>>;
1237
1238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1239enum TupletScale {
1240    Ratio(u32, u32),
1241    Overflow,
1242}
1243
1244/// Tuplet layout: for each event index, the open markers (`"(p:q:r"` at the
1245/// groups' first event) and the ratio product that scales that event's notated
1246/// length.
1247///
1248/// Groups are keyed by `pair_id`; the span `r` runs from the first to the last
1249/// attached event inclusive. Rests carry tuplet attachments like notes do, so a
1250/// rest-led tuplet's first index is its `Start` rest. A pair seen here always
1251/// includes its `Start`: tuplets opened inside an overlay are discarded at the
1252/// barline (`finish_open_tuplets_at_boundary`) and never reach voice events.
1253fn tuplet_layout(events: &[crate::TimedEvent]) -> (TupletMarkers, TupletScales) {
1254    use std::collections::BTreeMap;
1255    // pair_id -> (actual, normal, start_index, stop_index)
1256    let mut groups: BTreeMap<u32, (u32, u32, usize, usize)> = BTreeMap::new();
1257    for (i, event) in events.iter().enumerate() {
1258        for tuplet in &event.attachments.tuplets {
1259            let entry = groups.entry(tuplet.pair_id).or_insert((
1260                tuplet.actual_notes,
1261                tuplet.normal_notes,
1262                i,
1263                i,
1264            ));
1265            entry.0 = tuplet.actual_notes;
1266            entry.1 = tuplet.normal_notes;
1267            entry.2 = entry.2.min(i);
1268            entry.3 = entry.3.max(i);
1269        }
1270    }
1271    let mut markers = vec![String::new(); events.len()];
1272    let mut scales = vec![None; events.len()];
1273    for (_pid, (actual, normal, start, stop)) in groups {
1274        if !abc_tuplet_actual_supported(actual) {
1275            continue;
1276        }
1277        // `r` counts the notes/rests/chords the group covers; an interior
1278        // spacer consumes no tuplet slot on re-parse and must not inflate it.
1279        let span = events[start..=stop]
1280            .iter()
1281            .filter(|e| {
1282                matches!(
1283                    e.kind,
1284                    TimedEventKind::Note(_) | TimedEventKind::Rest(_) | TimedEventKind::Chord(_)
1285                )
1286            })
1287            .count();
1288        markers[start].push_str(&format!("({actual}:{normal}:{span}"));
1289        for slot in scales.iter_mut().take(stop + 1).skip(start) {
1290            multiply_tuplet_scale(slot, actual, normal);
1291        }
1292    }
1293    (markers, scales)
1294}
1295
1296fn multiply_tuplet_scale(slot: &mut Option<TupletScale>, actual: u32, normal: u32) {
1297    match slot {
1298        Some(TupletScale::Ratio(active_actual, active_normal)) => {
1299            *slot =
1300                crate::model::checked_ratio_product(*active_actual, *active_normal, actual, normal)
1301                    .map(|(actual, normal)| TupletScale::Ratio(actual, normal))
1302                    .or(Some(TupletScale::Overflow));
1303        }
1304        Some(TupletScale::Overflow) => {}
1305        None => *slot = Some(TupletScale::Ratio(actual, normal)),
1306    }
1307}
1308
1309/// Attachments emitted BEFORE a note/rest head.
1310///
1311/// Order matters for binding: a grace group, slur `(`, or decoration that
1312/// precedes a grace group binds to the *grace* note, not the main note (ABC 2.1
1313/// §4.11/§4.20). So grace comes first, then the event's own slur-opens, quoted
1314/// text, and decorations, which therefore bind to the main note head:
1315/// `{gf}("Gm"!trill!note`.
1316fn event_prefix(attachments: &crate::EventAttachments) -> String {
1317    use crate::model::SlurRole;
1318    let mut out = String::new();
1319    // Grace groups FIRST: the canonical order is `({gf}("F"!deco!note`. The
1320    // parser now also accepts quoted text before a grace group (`"F"{AB}c`
1321    // binds "F" to `c`), but this emission order stays canonical.
1322    for grace in &attachments.grace_groups {
1323        // A slur recorded on the grace group binds to its first grace note, so
1324        // its `(` opens before the group (`({gf}` ...).
1325        for slur in &grace.slurs {
1326            if slur.role == SlurRole::Start {
1327                out.push('(');
1328            }
1329        }
1330        // Grace pitches are stored UNSHIFTED by lowering (the voice octave
1331        // shift applies only to main notes and chord members), so they emit
1332        // as stored — shifting them here would drift one octave per
1333        // round-trip in `clef=±8`/`octave=`/`middle=` voices.
1334        out.push_str(&grace_str(grace));
1335    }
1336    if let Some(instrument) = &attachments.instrument {
1337        out.push_str(&format!(
1338            "[I:croma-note-instrument id=\"{}\"]",
1339            abc_carrier_quoted(instrument.id.as_str())
1340        ));
1341    }
1342    if attachments.musicxml_forward {
1343        out.push_str(&format!("[I:{}]", musicxml_forward_instruction()));
1344    }
1345    if let Some(duration) = attachments.musicxml_sequence_backup {
1346        out.push_str(&format!(
1347            "[I:{}]",
1348            musicxml_sequence_backup_instruction(duration)
1349        ));
1350    }
1351    let mut musicxml_tuplets: Vec<_> = attachments
1352        .tuplets
1353        .iter()
1354        .copied()
1355        .filter(|tuplet| !abc_tuplet_actual_supported(tuplet.actual_notes))
1356        .collect();
1357    musicxml_tuplets.sort_by_key(|tuplet| {
1358        let role_order = match tuplet.role {
1359            TupletRole::Start => 0u8,
1360            TupletRole::Continue => 1,
1361            TupletRole::Stop => 2,
1362        };
1363        (tuplet.pair_id, role_order)
1364    });
1365    for tuplet in musicxml_tuplets {
1366        out.push_str(&format!("[I:{}]", musicxml_tuplet_instruction(tuplet)));
1367    }
1368    let mut primary_lyric_verses = Vec::new();
1369    for lyric in &attachments.lyrics {
1370        if lyric.control != crate::model::LyricControl::Syllable {
1371            continue;
1372        }
1373        if primary_lyric_verses.contains(&lyric.verse) {
1374            out.push_str(&format!("[I:{}]", lyric_duplicate_instruction(lyric)));
1375        } else {
1376            primary_lyric_verses.push(lyric.verse);
1377            if lyric.same_note_extend {
1378                out.push_str(&format!("[I:{}]", lyric_extend_instruction(lyric.verse)));
1379            }
1380        }
1381    }
1382    // Event slur-opens next, then quoted strings — both `"G7"(DE)` and
1383    // `("G7"DE)` now parse with the chord symbol bound to `D`; the slur-first
1384    // order stays canonical.
1385    for slur in &attachments.slurs {
1386        if slur.role == SlurRole::Start {
1387            out.push('(');
1388        }
1389    }
1390    // Quoted strings: chord symbols (`"Gm"`) and annotations (`"^text"`). The
1391    // annotation `text` already carries its placement char, and chord-symbol
1392    // text never starts with one, so the parser re-distinguishes them; both
1393    // simply re-emit as `"<text>"`.
1394    for chord_symbol in &attachments.chord_symbols {
1395        if let Some(instruction) = harmony_text_instruction(&chord_symbol.musicxml_harmony_text) {
1396            out.push_str(&format!("[I:{instruction}]"));
1397        }
1398        out.push_str(&quoted_str(&chord_symbol.text));
1399    }
1400    // `s:`-aligned chord symbols re-emit INLINE: the exporter routes both
1401    // through the same <harmony> path (inline ones first, aligned ones after),
1402    // so inlining them here — after the event's own inline chord symbols —
1403    // reproduces the MusicXML byte-for-byte. The other aligned kinds
1404    // (Decoration/Annotation/Raw) render differently inline and are re-emitted
1405    // as `s:` lines instead (see `symbol_lines`).
1406    for symbol in &attachments.symbols {
1407        if symbol.kind == crate::model::AlignedSymbolKind::ChordSymbol {
1408            out.push_str(&quoted_str(&symbol.text));
1409        }
1410    }
1411    for annotation in &attachments.annotations {
1412        out.push_str(&quoted_text_string(&annotation.text));
1413    }
1414    for deco in &attachments.decorations {
1415        if let Some(placement) = deco.placement {
1416            out.push_str(&format!(
1417                "[I:{}]",
1418                direction_placement_instruction(placement)
1419            ));
1420        }
1421        out.push_str(&decoration_str(&deco.name));
1422    }
1423    out
1424}
1425
1426/// A quoted ABC string. The parser stores quoted text in source-escaped form
1427/// (an interior quote is kept as `\"`), so the text re-emits verbatim —
1428/// escaping again would corrupt it.
1429fn quoted_str(text: &str) -> String {
1430    format!("\"{text}\"")
1431}
1432
1433/// A quoted annotation: an ABC 2.1 §8.2 text string, encoded so the parser
1434/// decodes it back to `text` (a `"` inside becomes `&quot;`).
1435fn quoted_text_string(text: &str) -> String {
1436    format!("\"{}\"", crate::text::encode_text_string(text, true))
1437}
1438
1439/// Reconstructed `w:` lyric lines.
1440///
1441/// The writer emits the whole tune as ONE music line, so each verse is a single
1442/// `w:` line over one alignment block covering every lyric-bearing event
1443/// (single notes and chords; rests/spacers/barlines consume no position).
1444/// Verse numbers come from line ADJACENCY on re-parse, so verse `k` is line `k`
1445/// and gap verses are held with a placeholder all-skip line. Per event: a
1446/// Syllable token (plus `-` for each Hyphen attachment, which the parser stores
1447/// on the same note), `_` for an Extender, `*` for no lyric. Lines carrying
1448/// syllables are padded with `*` to the full block length so re-parsing is
1449/// warning-free (an under-filled syllable line warns).
1450#[derive(Debug, Clone, Copy)]
1451struct LyricAlignEvent<'a> {
1452    source_start: usize,
1453    attachments: &'a EventAttachments,
1454}
1455
1456fn lyric_align_events(voice: &crate::model::Voice) -> Vec<LyricAlignEvent<'_>> {
1457    let mut alignable = Vec::new();
1458    for event in &voice.events {
1459        if matches!(
1460            event.kind,
1461            TimedEventKind::Note(_) | TimedEventKind::Chord(_)
1462        ) {
1463            alignable.push(LyricAlignEvent {
1464                source_start: event.source.start,
1465                attachments: &event.attachments,
1466            });
1467        }
1468    }
1469    for measure in &voice.measures {
1470        for segment in &measure.overlays {
1471            for event in &segment.events {
1472                if event.alignable {
1473                    alignable.push(LyricAlignEvent {
1474                        source_start: event.span.start,
1475                        attachments: &event.attachments,
1476                    });
1477                }
1478            }
1479        }
1480    }
1481    alignable.sort_by_key(|event| event.source_start);
1482    alignable
1483}
1484
1485fn lyric_lines(events: &[LyricAlignEvent<'_>]) -> Vec<String> {
1486    use crate::model::LyricControl;
1487    let total = events.len();
1488    let mut verses: std::collections::BTreeMap<u32, Vec<Option<String>>> =
1489        std::collections::BTreeMap::new();
1490    let mut max_verse = 0u32;
1491    for (pos, event) in events.iter().enumerate() {
1492        // Chord lyrics are duplicated onto the first member; read the
1493        // event-level copy only.
1494        let mut syllable_verses = std::collections::BTreeSet::new();
1495        for lyric in &event.attachments.lyrics {
1496            max_verse = max_verse.max(lyric.verse);
1497            let slot = &mut verses
1498                .entry(lyric.verse)
1499                .or_insert_with(|| vec![None; total])[pos];
1500            match lyric.control {
1501                LyricControl::Syllable => {
1502                    if syllable_verses.insert(lyric.verse) {
1503                        slot.get_or_insert_with(String::new)
1504                            .push_str(&lyric_escape(&lyric.text));
1505                    }
1506                }
1507                LyricControl::Hyphen => {
1508                    // At most one written hyphen per slot: extra Hyphen
1509                    // attachments are XML-invisible, and a second `-` in the
1510                    // token would re-parse as a Skip and shift every later
1511                    // syllable one note right.
1512                    let token = slot.get_or_insert_with(String::new);
1513                    if !token.ends_with('-') {
1514                        token.push('-');
1515                    }
1516                }
1517                LyricControl::Extender => *slot = Some("_".to_string()),
1518                // Skip is never stored on a note (a `*` advances without
1519                // attaching); defensive no-op.
1520                LyricControl::Skip => {}
1521            }
1522        }
1523    }
1524    if verses.is_empty() {
1525        return Vec::new();
1526    }
1527    (1..=max_verse)
1528        .map(|v| match verses.get(&v) {
1529            Some(tokens) => {
1530                let body: Vec<String> = tokens
1531                    .iter()
1532                    .map(|t| match t {
1533                        // An orphan-Hyphen slot (hyphens with no syllable) is
1534                        // XML-invisible and unencodable; a bare `--` would even
1535                        // re-parse as TWO skips and shift every later syllable.
1536                        // One `*` keeps the position and drops the orphan.
1537                        Some(tok) if tok.chars().all(|c| c == '-') => "*".to_string(),
1538                        Some(tok) => tok.clone(),
1539                        None => "*".to_string(),
1540                    })
1541                    .collect();
1542                format!("w:{}", body.join(" "))
1543            }
1544            // A verse with no lyrics anywhere: hold its number with one skip.
1545            None => "w:*".to_string(),
1546        })
1547        .collect()
1548}
1549
1550/// Escape a stored lyric syllable back to `w:` token text: a stored space was
1551/// written `~`, and the token metacharacters are backslash-escaped.
1552fn lyric_escape(text: &str) -> String {
1553    let mut out = String::with_capacity(text.len());
1554    let mut chars = text.chars().peekable();
1555    while let Some(c) = chars.next() {
1556        match c {
1557            // A `+:` continuation line stores its join as a newline inside the
1558            // syllable; a raw newline would re-parse as music. Only ASCII
1559            // whitespace/control folds into the `~` space form — the parser's
1560            // token separators are space/tab only, so Unicode whitespace (e.g.
1561            // U+00A0 in mojibake lyrics) is opaque text and passes through.
1562            c if c.is_ascii_whitespace() || c.is_ascii_control() => out.push('~'),
1563            '\\' | '-' | '*' | '_' | '|' | '~' | '%' => {
1564                out.push('\\');
1565                out.push(c);
1566            }
1567            // An `&` that could start an entity is escaped like any §8.2 text.
1568            '&' if chars
1569                .peek()
1570                .is_some_and(|next| next.is_ascii_alphabetic() || *next == '#') =>
1571            {
1572                out.push_str("\\&");
1573            }
1574            _ => out.push(c),
1575        }
1576    }
1577    out
1578}
1579
1580/// Reconstructed `s:` symbol lines for the aligned symbols that cannot be
1581/// inlined (Decoration/Annotation/Raw — inline forms render different MusicXML,
1582/// e.g. `<notations>` instead of `<direction><words>`; ChordSymbol is inlined
1583/// in `event_prefix` instead).
1584///
1585/// The writer emits the whole tune as ONE music line, so there is a single
1586/// alignment block: every single note and chord consumes one `s:` position
1587/// (rests/spacers/barlines do not). One `s:` line is emitted per layer, all
1588/// adjacent (adjacency is what makes the parser number layers 1, 2, ...), with
1589/// `*` padding for empty positions and trailing `*` runs trimmed.
1590fn symbol_lines(events: &[LyricAlignEvent<'_>]) -> Vec<String> {
1591    use crate::model::AlignedSymbolKind;
1592    // (layer -> tokens per alignable position)
1593    let mut layers: std::collections::BTreeMap<u32, Vec<Option<String>>> =
1594        std::collections::BTreeMap::new();
1595    let mut position = 0usize;
1596    let total = events.len();
1597    for event in events {
1598        // Chord-aligned symbols are duplicated onto the first member; read the
1599        // event-level copy only.
1600        for symbol in &event.attachments.symbols {
1601            let token = match symbol.kind {
1602                AlignedSymbolKind::ChordSymbol => continue, // inlined
1603                AlignedSymbolKind::Decoration => format!("!{}!", symbol.text),
1604                AlignedSymbolKind::Annotation => quoted_text_string(&symbol.text),
1605                AlignedSymbolKind::Raw => symbol.text.clone(),
1606            };
1607            layers
1608                .entry(symbol.layer)
1609                .or_insert_with(|| vec![None; total])[position] = Some(token);
1610        }
1611        position += 1;
1612    }
1613    layers
1614        .into_values()
1615        .map(|tokens| {
1616            let last = tokens
1617                .iter()
1618                .rposition(Option::is_some)
1619                .map_or(0, |i| i + 1);
1620            let body: Vec<String> = tokens[..last]
1621                .iter()
1622                .map(|t| t.clone().unwrap_or_else(|| "*".to_string()))
1623                .collect();
1624            format!("s:{}", body.join(" "))
1625        })
1626        .filter(|line| line != "s:")
1627        .collect()
1628}
1629
1630/// Canonical ABC for a decoration. Shorthands and long forms both normalize to
1631/// the same canonical name on parse (`~`/`!roll!` -> "roll"), so the long
1632/// `!name!` form re-parses to an identical decoration; `.` (staccato) keeps its
1633/// shorthand, which is how the parser canonicalizes it.
1634fn decoration_str(name: &str) -> String {
1635    if name == "." {
1636        ".".to_string()
1637    } else {
1638        format!("!{name}!")
1639    }
1640}
1641
1642/// Decorations that belong to a specific chord member emit inside `[...]`.
1643/// The first member often duplicates the event-level attachment used by whole
1644/// chord decorations, so skip names already emitted by the chord prefix.
1645fn chord_member_prefix(
1646    attachments: &crate::EventAttachments,
1647    event_attachments: Option<&crate::EventAttachments>,
1648) -> String {
1649    let mut out = String::new();
1650    for decoration in &attachments.decorations {
1651        if event_attachments.is_some_and(|event_attachments| {
1652            event_attachments
1653                .decorations
1654                .iter()
1655                .any(|event_decoration| event_decoration.name == decoration.name)
1656        }) {
1657            continue;
1658        }
1659        if let Some(placement) = decoration.placement {
1660            out.push_str(&format!(
1661                "[I:{}]",
1662                direction_placement_instruction(placement)
1663            ));
1664        }
1665        out.push_str(&decoration_str(&decoration.name));
1666    }
1667    out
1668}
1669
1670/// Attachments emitted AFTER a note/rest (length suffix already written): the
1671/// tie marker, one `)` per slur stop, then any after-grace/trill termination.
1672fn event_suffix(attachments: &crate::EventAttachments) -> String {
1673    use crate::model::SlurRole;
1674    let mut out = String::new();
1675    if attachments.ties.iter().any(|t| t.role == TieRole::Start) {
1676        out.push('-');
1677    }
1678    for slur in &attachments.slurs {
1679        if slur.role == SlurRole::Stop {
1680            out.push(')');
1681        }
1682    }
1683    for grace in &attachments.after_grace_groups {
1684        for slur in &grace.slurs {
1685            if slur.role == SlurRole::Start {
1686                out.push('(');
1687            }
1688        }
1689        out.push_str(&format!("[I:{}]", musicxml_after_grace_instruction()));
1690        out.push_str(&grace_str(grace));
1691    }
1692    out
1693}
1694
1695/// Canonical ABC first/second-ending marker, e.g. `[1`, `[2`, `[1,3`, `[1-2`.
1696fn ending_str(model: &crate::model::RepeatEndingModel) -> String {
1697    use crate::model::RepeatEndingPartModel::{Range, Single, Text};
1698    let parts: Vec<String> = model
1699        .endings
1700        .iter()
1701        .map(|p| match p {
1702            Single(n) => n.to_string(),
1703            Range { start, end } => format!("{start}-{end}"),
1704            Text(text) => format!("\"{}\"", escape_abc_quotes(text)),
1705        })
1706        .collect();
1707    format!("[{}", parts.join(","))
1708}
1709
1710fn escape_abc_quotes(text: &str) -> String {
1711    text.replace('\\', "\\\\").replace('"', "\\\"")
1712}
1713
1714/// Canonical ABC text for a barline kind.
1715fn barline_str(kind: BarlineKind) -> &'static str {
1716    match kind {
1717        BarlineKind::Regular => "|",
1718        BarlineKind::Double => "||",
1719        BarlineKind::Final => "|]",
1720        BarlineKind::RepeatStart => "|:",
1721        BarlineKind::RepeatEnd => ":|",
1722        BarlineKind::RepeatBoth => "::",
1723        BarlineKind::Dotted => ".|",
1724        BarlineKind::Dashed => "|",
1725        BarlineKind::Invisible => "[|]",
1726        // Initial emits as a plain bar: it never renders a <barline> element
1727        // and segments measures exactly like Regular, so `|` is structurally
1728        // faithful. The Initial+RepeatStart split pair is re-joined as `[|:`
1729        // above.
1730        BarlineKind::Initial => "|",
1731        // Liberal must re-parse as Liberal: an empty measure between `|` and a
1732        // Liberal is preserved, while between two plain `|` it is coalesced
1733        // away — so substituting `|` can drop a measure. The original liberal
1734        // spelling is not stored; `|||` is the most innocuous spelling that
1735        // classifies as Liberal.
1736        BarlineKind::Liberal => "|||",
1737    }
1738}
1739
1740/// ABC glyph for an explicitly written accidental.
1741/// Accidental prefix for a note: the originally written accidental's glyph,
1742/// or nothing. Every other alter (key signature, measure carry, tie carry
1743/// across barlines) is reproduced by the parser's own accidental propagation
1744/// on re-parse, so no synthesized glyph is ever needed.
1745fn note_accidental(written: Option<Accidental>) -> &'static str {
1746    written.map(Accidental::abc_sign).unwrap_or("")
1747}
1748
1749/// Render one `&` overlay segment: `& ` plus its events, grouping consecutive
1750/// same-source chord members into `[...]` (mirroring the semantic lowering).
1751fn overlay_str(segment: &crate::model::OverlaySegment, unit: Rational, shift: i8) -> String {
1752    use crate::model::TimelineEventKind;
1753    let mut out = String::from("& ");
1754    let events = &segment.events;
1755    let (ov_markers, ov_scales) = overlay_tuplet_layout(events);
1756    let mut i = 0;
1757    while i < events.len() {
1758        let event = &events[i];
1759        out.push_str(&ov_markers[i]);
1760        let tuplet = ov_scales[i];
1761        match &event.kind {
1762            TimelineEventKind::Note { .. } => {
1763                let mut end = i + 1;
1764                while end < events.len() && overlay_same_chord(event, &events[end]) {
1765                    end += 1;
1766                }
1767                let group = &events[i..end];
1768                let chord = group.len() > 1;
1769                let mut lead = event.attachments.clone();
1770                if chord {
1771                    lead.ties.clear();
1772                }
1773                out.push_str(&event_prefix(&lead));
1774                let lengths: Vec<String> = group
1775                    .iter()
1776                    .map(|e| length_str(notated_duration(e.duration, tuplet), unit))
1777                    .collect();
1778                let uniform = lengths.windows(2).all(|w| w[0] == w[1]);
1779                if chord {
1780                    out.push('[');
1781                }
1782                for (e, len) in group.iter().zip(&lengths) {
1783                    let TimelineEventKind::Note {
1784                        step,
1785                        octave,
1786                        accidental,
1787                        ..
1788                    } = &e.kind
1789                    else {
1790                        continue;
1791                    };
1792                    out.push_str(note_accidental(*accidental));
1793                    out.push_str(&pitch_letter_str(*step, *octave - shift));
1794                    if !chord || !uniform {
1795                        out.push_str(len);
1796                    }
1797                    if chord && e.attachments.ties.iter().any(|t| t.role == TieRole::Start) {
1798                        out.push('-');
1799                    }
1800                }
1801                if chord {
1802                    out.push(']');
1803                    if let Some(len) = lengths.first().filter(|_| uniform) {
1804                        out.push_str(len);
1805                    }
1806                }
1807                out.push_str(&event_suffix(&lead));
1808                out.push(' ');
1809                i = end;
1810                continue;
1811            }
1812            TimelineEventKind::Rest { visibility, .. } => {
1813                out.push_str(&event_prefix(&event.attachments));
1814                out.push(match visibility {
1815                    RestVisibility::Visible => 'z',
1816                    RestVisibility::Invisible => 'x',
1817                });
1818                out.push_str(&length_str(notated_duration(event.duration, tuplet), unit));
1819                out.push_str(&event_suffix(&event.attachments));
1820                out.push(' ');
1821            }
1822            TimelineEventKind::Spacer => {
1823                out.push_str(&event_prefix(&event.attachments));
1824                out.push('y');
1825                out.push_str(&event_suffix(&event.attachments));
1826                out.push(' ');
1827            }
1828            TimelineEventKind::Barline { kind } => {
1829                out.push_str(barline_str(*kind));
1830                out.push(' ');
1831            }
1832            TimelineEventKind::VariantEnding { .. }
1833            | TimelineEventKind::VariantEndingClose { .. }
1834            | TimelineEventKind::KeyChange(_)
1835            | TimelineEventKind::MeterChange(_)
1836            | TimelineEventKind::ClefChange(_)
1837            | TimelineEventKind::TempoChange(_)
1838            | TimelineEventKind::SectionLabel(_) => {}
1839        }
1840        i += 1;
1841    }
1842    out
1843}
1844
1845/// Tuplet layout over an overlay segment's events — the same pair-id
1846/// reconstruction as `tuplet_layout`, over `VoiceTimedEvent`s, plus a
1847/// Start-presence guard for pairs that straddle in from the main voice.
1848fn overlay_tuplet_layout(
1849    events: &[crate::model::VoiceTimedEvent],
1850) -> (TupletMarkers, TupletScales) {
1851    use crate::model::{TimelineEventKind, TupletRole};
1852    use std::collections::BTreeMap;
1853    let mut groups: BTreeMap<u32, (u32, u32, usize, usize, bool)> = BTreeMap::new();
1854    for (i, event) in events.iter().enumerate() {
1855        for tuplet in &event.attachments.tuplets {
1856            let entry = groups.entry(tuplet.pair_id).or_insert((
1857                tuplet.actual_notes,
1858                tuplet.normal_notes,
1859                i,
1860                i,
1861                false,
1862            ));
1863            entry.0 = tuplet.actual_notes;
1864            entry.1 = tuplet.normal_notes;
1865            entry.2 = entry.2.min(i);
1866            entry.3 = entry.3.max(i);
1867            if tuplet.role == TupletRole::Start {
1868                entry.4 = true;
1869            }
1870        }
1871    }
1872    let mut markers = vec![String::new(); events.len()];
1873    let mut scales = vec![None; events.len()];
1874    for (_pid, (actual, normal, start, stop, has_start)) in groups {
1875        // Unlike `tuplet_layout`, a pair here can lack its Start: a tuplet
1876        // straddling an `&` (`C (3DE & FGA z |`) keeps Start/Continue in the
1877        // main voice events and leaves a Stop-only pair in the overlay
1878        // segment. Emitting a marker at that Stop would open a bogus tuplet,
1879        // so such pairs are skipped (the events still carry their already
1880        // tuplet-scaled durations, which round-trip exactly).
1881        if !has_start || !abc_tuplet_actual_supported(actual) {
1882            continue;
1883        }
1884        let span = events[start..=stop]
1885            .iter()
1886            .filter(|e| {
1887                matches!(
1888                    e.kind,
1889                    TimelineEventKind::Note { .. } | TimelineEventKind::Rest { .. }
1890                )
1891            })
1892            .count();
1893        markers[start].push_str(&format!("({actual}:{normal}:{span}"));
1894        for slot in scales.iter_mut().take(stop + 1).skip(start) {
1895            multiply_tuplet_scale(slot, actual, normal);
1896        }
1897    }
1898    (markers, scales)
1899}
1900
1901/// True when `next` continues the chord group opened at `first` (same source
1902/// token, same onset, flagged as a chord member).
1903fn overlay_same_chord(
1904    first: &crate::model::VoiceTimedEvent,
1905    next: &crate::model::VoiceTimedEvent,
1906) -> bool {
1907    use crate::model::TimelineEventKind;
1908    first.source_order == next.source_order
1909        && first.onset == next.onset
1910        && matches!(next.kind, TimelineEventKind::Note { chord: true, .. })
1911}
1912
1913/// Pitch letter plus octave marks (middle C = octave 4: `C`=4, `c`=5).
1914fn pitch_str(pitch: &Pitch) -> String {
1915    pitch_letter_str(pitch.step, pitch.octave)
1916}
1917
1918/// Pitch letter plus octave marks for a bare step/octave pair (middle C =
1919/// octave 4: `C`=4, `c`=5).
1920fn pitch_letter_str(step: char, octave: i8) -> String {
1921    let letter = step.to_ascii_uppercase();
1922    // Widen before the repeat-count subtraction: stored octaves reach the i8
1923    // bounds (lowering clamps long octave-mark runs to ±128), so `4 - octave`
1924    // overflows i8 for octaves below -123 (debug panic / release capacity
1925    // overflow in `croma dump abc`).
1926    let octave = i32::from(octave);
1927    if octave >= 5 {
1928        let mut s = letter.to_ascii_lowercase().to_string();
1929        s.push_str(&"'".repeat((octave - 5) as usize));
1930        s
1931    } else {
1932        let mut s = letter.to_string();
1933        s.push_str(&",".repeat((4 - octave) as usize));
1934        s
1935    }
1936}
1937
1938/// Length suffix for `duration` relative to `unit`: `mult = duration / unit`.
1939fn length_str(duration: Rational, unit: Rational) -> String {
1940    let mult = Rational::new(
1941        duration.numerator.saturating_mul(unit.denominator),
1942        duration.denominator.saturating_mul(unit.numerator),
1943    );
1944    length_ratio_str(mult)
1945}
1946
1947/// The NOTATED duration to render for an event: inside a tuplet, the stored
1948/// duration is the compressed (played) value, but ABC writes the pre-compression
1949/// notated length and the parser re-applies the ratio — so scale back up by
1950/// `actual/normal`. Outside a tuplet this is the duration unchanged.
1951fn notated_duration(duration: Rational, tuplet: Option<TupletScale>) -> Rational {
1952    match tuplet {
1953        Some(TupletScale::Ratio(actual, normal)) => {
1954            scaled_rational(duration, actual, normal).unwrap_or(duration)
1955        }
1956        Some(TupletScale::Overflow) => duration,
1957        None => duration,
1958    }
1959}
1960
1961fn scaled_rational(duration: Rational, actual: u32, normal: u32) -> Option<Rational> {
1962    let numerator = u64::from(duration.numerator) * u64::from(actual);
1963    let denominator = u64::from(duration.denominator) * u64::from(normal);
1964    crate::model::ratio_to_u32(numerator, denominator)
1965        .map(|(numerator, denominator)| Rational::new(numerator, denominator))
1966}
1967
1968/// ABC length suffix for an already-reduced multiplier of the unit length.
1969fn length_ratio_str(mult: Rational) -> String {
1970    match (mult.numerator, mult.denominator) {
1971        (1, 1) => String::new(),
1972        (n, 1) => n.to_string(),
1973        (1, 2) => "/".to_string(),
1974        (1, d) => format!("/{d}"),
1975        (n, d) => format!("{n}/{d}"),
1976    }
1977}
1978
1979/// ABC grace group: `{...}` (or `{/...}` for an acciaccatura/slashed group).
1980/// Grace-note lengths are relative to the grace base unit via `length_multiplier`.
1981fn grace_str(group: &crate::model::GraceGroupAttachment) -> String {
1982    use crate::model::{GraceEventKind, SlurRole};
1983    let mut out = String::from("{");
1984    let mut trailing_slur_stops = String::new();
1985    if group.slash.is_some() {
1986        out.push('/');
1987    }
1988    for grace in &group.events {
1989        for slur in &grace.slurs {
1990            if slur.role == SlurRole::Start {
1991                out.push('(');
1992            }
1993        }
1994        match &grace.kind {
1995            GraceEventKind::Note(note) => {
1996                for deco in &note.decorations {
1997                    out.push_str(&decoration_str(&deco.name));
1998                }
1999                out.push_str(note_accidental(
2000                    note.written_accidental.as_ref().map(|m| m.kind),
2001                ));
2002                out.push_str(&pitch_str(&note.pitch));
2003                out.push_str(&length_ratio_str(note.length_multiplier));
2004            }
2005            GraceEventKind::Rest(_) => out.push('z'),
2006            GraceEventKind::Chord(members) => {
2007                out.push('[');
2008                for note in members {
2009                    for deco in &note.decorations {
2010                        out.push_str(&decoration_str(&deco.name));
2011                    }
2012                    out.push_str(note_accidental(
2013                        note.written_accidental.as_ref().map(|m| m.kind),
2014                    ));
2015                    out.push_str(&pitch_str(&note.pitch));
2016                    out.push_str(&length_ratio_str(note.length_multiplier));
2017                }
2018                out.push(']');
2019            }
2020        }
2021        for slur in &grace.slurs {
2022            if slur.role == SlurRole::Stop {
2023                if slur.span.start >= group.span.end {
2024                    trailing_slur_stops.push(')');
2025                } else {
2026                    out.push(')');
2027                }
2028            }
2029        }
2030    }
2031    out.push('}');
2032    out.push_str(&trailing_slur_stops);
2033    out
2034}
2035
2036#[cfg(test)]
2037#[path = "to_abc_tests.rs"]
2038mod tests;