1use 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
18pub 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
107pub 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}