Skip to main content

sim_lib_music_notation/
musicxml_import.rs

1//! Bounded MusicXML `score-partwise` import.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use num_rational::Ratio;
6use roxmltree::{Document, Node, ParsingOptions};
7use sim_lib_music_core::{Counterpoint, Melody, MelodyItem, Music, Score, Time};
8
9use crate::{
10    model::{
11        MusicXmlLimits, NotationError, NotationIdentity, NotationIdentityKind, NotationLoss,
12        NotationLossKind, NotationReport, loss_diagnostic, musicxml_error,
13    },
14    musicxml_note::parse_note,
15    musicxml_support::*,
16};
17
18/// Imports the bounded MusicXML partwise profile with explicit resource limits.
19pub fn import_musicxml_partwise_report(
20    source: &[u8],
21    limits: MusicXmlLimits,
22) -> Result<NotationReport<Score>, NotationError> {
23    check_limit("bytes", source.len(), limits.bytes)?;
24    let source = std::str::from_utf8(source).map_err(|_| NotationError::InvalidMusicXmlUtf8)?;
25    if source.contains("<!DOCTYPE") {
26        return Err(musicxml_error(
27            "DTD declarations and entity definitions are outside the bounded MusicXML profile",
28            None,
29        ));
30    }
31    let nodes_limit = u32::try_from(limits.nodes).unwrap_or(u32::MAX);
32    let document = Document::parse_with_options(
33        source,
34        ParsingOptions {
35            allow_dtd: false,
36            nodes_limit,
37            entity_resolver: None,
38        },
39    )
40    .map_err(|error| NotationError::InvalidMusicXml(error.to_string()))?;
41    check_tree_limits(&document, limits)?;
42
43    let root = document.root_element();
44    ensure_named(root, "score-partwise")?;
45    ensure_namespace(root)?;
46    ensure_attrs(root, &["version"])?;
47    if root.attribute("version") != Some("4.0") {
48        return Err(node_error(
49            root,
50            "bounded MusicXML profile requires score-partwise version=\"4.0\"",
51        ));
52    }
53    ensure_children(root, &["part-list", "part"])?;
54
55    let part_list = unique_child(root, "part-list")?;
56    let part_names = parse_part_list(part_list, limits.parts)?;
57    let part_nodes = children_named(root, "part").collect::<Vec<_>>();
58    check_limit("parts", part_nodes.len(), limits.parts)?;
59    if part_nodes.is_empty() {
60        return Err(node_error(
61            root,
62            "score-partwise requires at least one part",
63        ));
64    }
65    if part_nodes.len() != part_names.len() {
66        return Err(node_error(
67            root,
68            "part-list and score part counts must agree",
69        ));
70    }
71
72    let mut state = ImportState::new(limits);
73    let mut parts = Vec::with_capacity(part_nodes.len());
74    for (part_index, part) in part_nodes.into_iter().enumerate() {
75        parts.push(parse_part(part, part_index, &part_names, &mut state)?);
76    }
77    let globals = merge_globals(&parts)?;
78    let body = if parts.len() == 1 {
79        let part = parts.remove(0);
80        if part.name != "Music" {
81            state.losses.push(NotationLoss {
82                kind: NotationLossKind::PartName,
83                canonical_path: Some("part/0".to_owned()),
84                detail: format!(
85                    "single MusicXML part name {:?} is not carried by a Melody score body",
86                    part.name
87                ),
88            });
89        }
90        Music::Melody(part.melody)
91    } else {
92        Music::Counterpoint(Counterpoint::new(
93            parts.iter().map(|part| part.melody.clone()).collect(),
94            parts.iter().map(|part| part.name.clone()).collect(),
95        )?)
96    };
97    let score = Score::new(globals.tempo, globals.time_signature, globals.key, body)?;
98    let diagnostics = state.losses.iter().map(loss_diagnostic).collect();
99    Ok(NotationReport {
100        value: score,
101        diagnostics,
102        identities: state.identities,
103        losses: state.losses,
104    })
105}
106
107/// Imports the bounded profile and discards exchange sidecar evidence.
108pub fn import_musicxml_partwise(
109    source: &[u8],
110    limits: MusicXmlLimits,
111) -> Result<Score, NotationError> {
112    Ok(import_musicxml_partwise_report(source, limits)?.value)
113}
114
115struct PartImport {
116    id: String,
117    name: String,
118    melody: Melody,
119    globals: Globals,
120}
121
122#[derive(Clone, PartialEq, Eq)]
123struct Globals {
124    tempo: u32,
125    time_signature: (u8, u8),
126    key: Option<String>,
127}
128
129struct ImportState {
130    limits: MusicXmlLimits,
131    events: usize,
132    ids: BTreeSet<String>,
133    identities: Vec<NotationIdentity>,
134    losses: Vec<NotationLoss>,
135}
136
137impl ImportState {
138    fn new(limits: MusicXmlLimits) -> Self {
139        Self {
140            limits,
141            events: 0,
142            ids: BTreeSet::new(),
143            identities: Vec::new(),
144            losses: Vec::new(),
145        }
146    }
147
148    fn retain_id(
149        &mut self,
150        kind: NotationIdentityKind,
151        canonical_path: String,
152        xml_id: String,
153        node: Node<'_, '_>,
154    ) -> Result<(), NotationError> {
155        validate_xml_id_at(&xml_id, node)?;
156        if !self.ids.insert(xml_id.clone()) {
157            return Err(node_error(node, format!("duplicate XML id {xml_id}")));
158        }
159        self.identities.push(NotationIdentity {
160            kind,
161            canonical_path,
162            xml_id,
163        });
164        Ok(())
165    }
166
167    fn next_event(&mut self) -> Result<(), NotationError> {
168        self.events = self.events.saturating_add(1);
169        if self.events > self.limits.events {
170            return Err(NotationError::MusicXmlLimit {
171                limit: "events",
172                actual: self.events,
173                maximum: self.limits.events,
174            });
175        }
176        Ok(())
177    }
178}
179
180fn parse_part_list(
181    node: Node<'_, '_>,
182    parts_limit: usize,
183) -> Result<BTreeMap<String, String>, NotationError> {
184    ensure_attrs(node, &[])?;
185    ensure_children(node, &["score-part"])?;
186    let mut parts = BTreeMap::new();
187    for score_part in children_named(node, "score-part") {
188        check_limit("parts", parts.len().saturating_add(1), parts_limit)?;
189        ensure_attrs(score_part, &["id"])?;
190        ensure_children(score_part, &["part-name"])?;
191        let id = required_attr(score_part, "id")?;
192        validate_xml_id_at(id, score_part)?;
193        let name = required_text(unique_child(score_part, "part-name")?)?.to_owned();
194        if name.trim().is_empty() {
195            return Err(node_error(score_part, "part-name cannot be empty"));
196        }
197        if parts.insert(id.to_owned(), name).is_some() {
198            return Err(node_error(score_part, format!("duplicate part id {id}")));
199        }
200    }
201    if parts.is_empty() {
202        return Err(node_error(
203            node,
204            "part-list requires at least one score-part",
205        ));
206    }
207    Ok(parts)
208}
209
210fn parse_part(
211    node: Node<'_, '_>,
212    part_index: usize,
213    part_names: &BTreeMap<String, String>,
214    state: &mut ImportState,
215) -> Result<PartImport, NotationError> {
216    ensure_attrs(node, &["id"])?;
217    ensure_children(node, &["measure"])?;
218    let id = required_attr(node, "id")?.to_owned();
219    let name = part_names
220        .get(&id)
221        .ok_or_else(|| node_error(node, format!("part id {id} is absent from part-list")))?
222        .clone();
223    state.retain_id(
224        NotationIdentityKind::Part,
225        format!("part/{part_index}"),
226        id.clone(),
227        node,
228    )?;
229    let measures = children_named(node, "measure").collect::<Vec<_>>();
230    if measures.is_empty() {
231        return Err(node_error(node, "part requires at least one measure"));
232    }
233    let mut divisions = None;
234    let mut time_signature = None;
235    let mut key = None;
236    let mut tempo = None;
237    let mut items = Vec::new();
238    for (measure_index, measure) in measures.into_iter().enumerate() {
239        ensure_attrs(measure, &["number"])?;
240        let number = required_attr(measure, "number")?;
241        if number != (measure_index + 1).to_string() {
242            return Err(node_error(
243                measure,
244                "measure numbers must be contiguous decimal integers starting at 1",
245            ));
246        }
247        ensure_children(measure, &["attributes", "direction", "note"])?;
248        for child in measure.children().filter(Node::is_element) {
249            if !items.is_empty() && matches!(child.tag_name().name(), "attributes" | "direction") {
250                return Err(node_error(
251                    child,
252                    "global attributes and tempo must precede all part events",
253                ));
254            }
255            match child.tag_name().name() {
256                "attributes" => parse_attributes(
257                    child,
258                    &mut divisions,
259                    &mut time_signature,
260                    &mut key,
261                    state,
262                    &format!("part/{part_index}"),
263                )?,
264                "direction" => parse_direction(child, &mut tempo)?,
265                "note" => {
266                    let event_index = items.len();
267                    state.next_event()?;
268                    let divisions = divisions.ok_or_else(|| {
269                        node_error(child, "attributes/divisions must precede note events")
270                    })?;
271                    let path = format!("part/{part_index}/event/{event_index}");
272                    let ((item, spelling_loss), id) = parse_note(
273                        child,
274                        divisions,
275                        format!("P{}-E{}", part_index + 1, event_index + 1),
276                        key.as_deref(),
277                    )?;
278                    if let Some(detail) = spelling_loss {
279                        state.losses.push(NotationLoss {
280                            kind: NotationLossKind::PitchSpelling,
281                            canonical_path: Some(path.clone()),
282                            detail,
283                        });
284                    }
285                    state.retain_id(NotationIdentityKind::Event, path, id, child)?;
286                    items.push(item);
287                }
288                _ => unreachable!("child vocabulary checked above"),
289            }
290        }
291        let meter = time_signature.unwrap_or((4, 4));
292        let expected = Ratio::new(i64::from(meter.0), i64::from(meter.1));
293        let start = measure_start(&items, measure_index, expected)?;
294        let actual = items[start..]
295            .iter()
296            .fold(Ratio::from_integer(0), |sum, item| {
297                sum + item_duration(item)
298            });
299        if actual != expected {
300            return Err(node_error(
301                measure,
302                format!(
303                    "bounded profile requires complete measures; measure {} has duration {actual}, expected {expected}",
304                    measure_index + 1
305                ),
306            ));
307        }
308    }
309    let time_signature = match time_signature {
310        Some(value) => value,
311        None => {
312            state.losses.push(NotationLoss {
313                kind: NotationLossKind::DefaultedTimeSignature,
314                canonical_path: Some(format!("part/{part_index}")),
315                detail: "MusicXML omitted time signature; canonical Score uses 4/4".to_owned(),
316            });
317            (4, 4)
318        }
319    };
320    let tempo = match tempo {
321        Some(value) => value,
322        None => {
323            state.losses.push(NotationLoss {
324                kind: NotationLossKind::DefaultedTempo,
325                canonical_path: Some(format!("part/{part_index}")),
326                detail: "MusicXML omitted tempo; canonical Score uses 120 BPM".to_owned(),
327            });
328            120
329        }
330    };
331    Ok(PartImport {
332        id,
333        name,
334        melody: Melody::new(items)?,
335        globals: Globals {
336            tempo,
337            time_signature,
338            key,
339        },
340    })
341}
342
343fn measure_start(
344    items: &[MelodyItem],
345    measure_index: usize,
346    duration: Time,
347) -> Result<usize, NotationError> {
348    let target = duration * Ratio::from_integer(measure_index as i64);
349    let mut elapsed = Ratio::from_integer(0);
350    for (index, item) in items.iter().enumerate() {
351        if elapsed == target {
352            return Ok(index);
353        }
354        elapsed += item_duration(item);
355        if elapsed > target {
356            return Err(musicxml_error(
357                "an event crosses a measure boundary in the bounded profile",
358                None,
359            ));
360        }
361    }
362    if elapsed == target {
363        Ok(items.len())
364    } else {
365        Err(musicxml_error(
366            "measure boundaries do not match the active meter",
367            None,
368        ))
369    }
370}
371
372fn parse_attributes(
373    node: Node<'_, '_>,
374    divisions: &mut Option<i64>,
375    time_signature: &mut Option<(u8, u8)>,
376    key: &mut Option<String>,
377    state: &mut ImportState,
378    path: &str,
379) -> Result<(), NotationError> {
380    ensure_attrs(node, &[])?;
381    ensure_children(node, &["divisions", "key", "time", "clef"])?;
382    for child in node.children().filter(Node::is_element) {
383        match child.tag_name().name() {
384            "divisions" => {
385                let parsed = parse_positive_i64(required_text(child)?, child, "divisions")?;
386                if parsed > MAX_DIVISIONS {
387                    return Err(node_error(
388                        child,
389                        format!("divisions exceeds profile maximum {MAX_DIVISIONS}"),
390                    ));
391                }
392                set_consistent(divisions, parsed, child, "divisions")?;
393            }
394            "time" => {
395                ensure_attrs(child, &[])?;
396                ensure_children(child, &["beats", "beat-type"])?;
397                let beats = parse_u8(
398                    required_text(unique_child(child, "beats")?)?,
399                    child,
400                    "time beats",
401                )?;
402                let beat_type = parse_u8(
403                    required_text(unique_child(child, "beat-type")?)?,
404                    child,
405                    "time beat-type",
406                )?;
407                if beats == 0 || beat_type == 0 {
408                    return Err(node_error(child, "time signature values must be positive"));
409                }
410                set_consistent(time_signature, (beats, beat_type), child, "time signature")?;
411            }
412            "key" => {
413                ensure_attrs(child, &[])?;
414                ensure_children(child, &["fifths", "mode"])?;
415                let fifths = required_text(unique_child(child, "fifths")?)?
416                    .parse::<i8>()
417                    .map_err(|_| node_error(child, "key fifths must be an integer"))?;
418                let mode = unique_optional_child(child, "mode")?
419                    .map(required_text)
420                    .transpose()?
421                    .unwrap_or("major");
422                let parsed = key_from_fifths(fifths, mode)
423                    .ok_or_else(|| node_error(child, "unsupported key signature"))?;
424                set_consistent(key, parsed, child, "key signature")?;
425            }
426            "clef" => {
427                ensure_attrs(child, &[])?;
428                ensure_children(child, &["sign", "line", "clef-octave-change"])?;
429                state.losses.push(NotationLoss {
430                    kind: NotationLossKind::Clef,
431                    canonical_path: Some(path.to_owned()),
432                    detail:
433                        "MusicXML clef is layout metadata and is not carried by canonical Score"
434                            .to_owned(),
435                });
436            }
437            _ => unreachable!("child vocabulary checked above"),
438        }
439    }
440    Ok(())
441}
442
443fn parse_direction(node: Node<'_, '_>, tempo: &mut Option<u32>) -> Result<(), NotationError> {
444    ensure_attrs(node, &[])?;
445    ensure_children(node, &["sound"])?;
446    let sound = unique_child(node, "sound")?;
447    ensure_attrs(sound, &["tempo"])?;
448    ensure_children(sound, &[])?;
449    let value = required_attr(sound, "tempo")?
450        .parse::<u32>()
451        .map_err(|_| node_error(sound, "sound tempo must be a positive integer"))?;
452    if value == 0 {
453        return Err(node_error(sound, "sound tempo must be positive"));
454    }
455    set_consistent(tempo, value, sound, "tempo")
456}
457
458fn merge_globals(parts: &[PartImport]) -> Result<Globals, NotationError> {
459    let first = parts
460        .first()
461        .expect("caller checks non-empty score parts")
462        .globals
463        .clone();
464    for part in &parts[1..] {
465        if part.globals != first {
466            return Err(musicxml_error(
467                format!(
468                    "part {} changes global tempo, key, or time signature",
469                    part.id
470                ),
471                None,
472            ));
473        }
474    }
475    Ok(first)
476}