diff --git a/src/include/mx/api/LyricData.h b/src/include/mx/api/LyricData.h index 28e50457a..43f7bba35 100644 --- a/src/include/mx/api/LyricData.h +++ b/src/include/mx/api/LyricData.h @@ -11,6 +11,7 @@ #include #include +#include namespace mx { @@ -37,6 +38,29 @@ enum class LyricExtendType stop }; +// One additional syllabic/text run appended to a , joined to the run before it by +// . A syllable is occasionally written as more than one run - for example a source +// that embeds a non-breaking space or an undertie (‿) inside otherwise ordinary lyric text. +// The common case of a lyric with a single run needs none of this; see LyricData::continuations. +class LyricTextSegment +{ + public: + LyricSyllabic syllabic{LyricSyllabic::unspecified}; + std::string text; + + // The element's content, copied verbatim from the character that joins this run + // to the previous one (commonly U+00A0, U+005F, or U+203F). Absent writes a bare + // (or, if elisionSmufl is set, an ). + std::optional elisionText; + + // The canonical SMuFL glyph name for the 's smufl attribute -- any name starting + // with "lyrics" is schema-legal (e.g. "lyricsElision", "lyricsElisionWide"), so this is a + // plain name rather than a closed set. MusicXML only consults it when elisionText is absent + // or empty, but both may be set at once -- for round-trip fidelity, a source that sets both + // is preserved as both rather than collapsed to one. + std::optional elisionSmufl; +}; + class LyricData { public: @@ -50,6 +74,11 @@ class LyricData std::string verseNumber; std::string verseName; LyricSyllabic syllabic; + + // Additional runs after the first, each joined to the one before it by . Empty for + // an ordinary single-run lyric, which is the common case. + std::vector continuations; + bool hasExtend; LyricExtendType extendType; PositionData positionData; @@ -59,11 +88,20 @@ class LyricData std::optional id; }; +MXAPI_EQUALS_BEGIN(LyricTextSegment) +MXAPI_EQUALS_MEMBER(syllabic) +MXAPI_EQUALS_MEMBER(text) +MXAPI_EQUALS_MEMBER(elisionText) +MXAPI_EQUALS_MEMBER(elisionSmufl) +MXAPI_EQUALS_END; +MXAPI_NOT_EQUALS_AND_VECTORS(LyricTextSegment); + MXAPI_EQUALS_BEGIN(LyricData) MXAPI_EQUALS_MEMBER(text) MXAPI_EQUALS_MEMBER(verseNumber) MXAPI_EQUALS_MEMBER(verseName) MXAPI_EQUALS_MEMBER(syllabic) +MXAPI_EQUALS_MEMBER(continuations) MXAPI_EQUALS_MEMBER(hasExtend) MXAPI_EQUALS_MEMBER(extendType) MXAPI_EQUALS_MEMBER(positionData) diff --git a/src/private/mx/impl/NoteReader.cpp b/src/private/mx/impl/NoteReader.cpp index c714ec141..47c567195 100644 --- a/src/private/mx/impl/NoteReader.cpp +++ b/src/private/mx/impl/NoteReader.cpp @@ -10,6 +10,7 @@ #include "mx/core/generated/LyricTextGroup.h" #include "mx/core/generated/Notations.h" #include "mx/core/generated/NotationsChoice.h" +#include "mx/core/generated/SmuflLyricsGlyphName.h" #include "mx/core/generated/StartStopContinue.h" #include "mx/core/generated/Syllabic.h" #include "mx/core/generated/TextElementData.h" @@ -110,35 +111,38 @@ api::PrintData getLyricPrintData(const core::Lyric &inLyric, const core::TextEle return outPrintData; } -std::string getElisionDisplayText(const core::LyricSyllableGroup &inGroup) +// Appends the runs after the first : one api::LyricTextSegment per -joined +// ?/ pair, preserving each run's own syllabic value and elision content. +std::vector getLyricContinuations(const core::LyricTextGroup &inGroup) { - if (inGroup.elisionSyllabicGroup().has_value()) + std::vector result; + + for (const auto &group : inGroup.lyricSyllableGroup()) { - const auto &elision = inGroup.elisionSyllabicGroup()->elision(); - const auto &value = elision.value(); - if (!value.empty()) + api::LyricTextSegment segment; + segment.text = group.text().value(); + + if (group.elisionSyllabicGroup().has_value()) { - return value; + const auto &elisionSyllabicGroup = *group.elisionSyllabicGroup(); + const auto &elision = elisionSyllabicGroup.elision(); + if (!elision.value().empty()) + { + segment.elisionText = elision.value(); + } + // Preserved independently of elisionText for round-trip fidelity, even though + // MusicXML only consults smufl when the text content is empty. + if (elision.smufl().has_value()) + { + segment.elisionSmufl = elision.smufl()->toString(); + } + if (elisionSyllabicGroup.syllabic().has_value()) + { + segment.syllabic = convertLyricSyllabic(*elisionSyllabicGroup.syllabic()); + } } - } - - // UTF-8 for U+203F (undertie), the conventional elision joiner, substituted when - // has no text of its own. - return "\xE2\x80\xBF"; -} - -// Flattens elided syllables into one string: mx::api models a lyric as a single text, so -// structure is intentionally collapsed and cannot be round-tripped. -std::string getLyricDisplayText(const core::LyricTextGroup &inGroup) -{ - std::string result; - - result += inGroup.text().value(); - for (const auto &group : inGroup.lyricSyllableGroup()) - { - result += getElisionDisplayText(group); - result += group.text().value(); + result.emplace_back(std::move(segment)); } return result; @@ -545,11 +549,12 @@ void NoteReader::setLyric() case core::LyricChoice::Kind::lyricTextGroup: { const auto &textGroup = textChoice.asLyricTextGroup(); - lyricData.text = getLyricDisplayText(textGroup); + lyricData.text = textGroup.text().value(); lyricData.printData = getLyricPrintData(lyric, &textGroup.text()); lyricData.syllabic = textGroup.syllabic().has_value() ? convertLyricSyllabic(*textGroup.syllabic()) : api::LyricSyllabic::unspecified; + lyricData.continuations = getLyricContinuations(textGroup); if (textGroup.extend().has_value()) { lyricData.hasExtend = true; diff --git a/src/private/mx/impl/NoteWriter.cpp b/src/private/mx/impl/NoteWriter.cpp index 7316d411e..24deff2ea 100644 --- a/src/private/mx/impl/NoteWriter.cpp +++ b/src/private/mx/impl/NoteWriter.cpp @@ -9,6 +9,8 @@ #include "mx/core/generated/BeamLevel.h" #include "mx/core/generated/CueNoteGroup.h" #include "mx/core/generated/DisplayStepOctaveGroup.h" +#include "mx/core/generated/Elision.h" +#include "mx/core/generated/ElisionSyllabicGroup.h" #include "mx/core/generated/Extend.h" #include "mx/core/generated/FormattedText.h" #include "mx/core/generated/FullNoteGroupChoice.h" @@ -17,12 +19,14 @@ #include "mx/core/generated/GraceNoteChoice.h" #include "mx/core/generated/GraceNoteGroup.h" #include "mx/core/generated/LyricChoice.h" +#include "mx/core/generated/LyricSyllableGroup.h" #include "mx/core/generated/LyricTextGroup.h" #include "mx/core/generated/NonNegativeDecimal.h" #include "mx/core/generated/NormalNoteGroup.h" #include "mx/core/generated/Pitch.h" #include "mx/core/generated/Rest.h" #include "mx/core/generated/SmuflGlyphName.h" +#include "mx/core/generated/SmuflLyricsGlyphName.h" #include "mx/core/generated/Syllabic.h" #include "mx/core/generated/TextElementData.h" #include "mx/core/generated/Tied.h" @@ -619,6 +623,34 @@ void NoteWriter::setLyrics() const textGroup.setSyllabic(convertLyricSyllabicForNoteWriter(lyricData.syllabic)); } textGroup.setText(std::move(text)); + for (const auto &segment : lyricData.continuations) + { + core::Elision elision; + if (segment.elisionText.has_value()) + { + elision.setValue(*segment.elisionText); + } + if (segment.elisionSmufl.has_value()) + { + elision.setSmufl(core::SmuflLyricsGlyphName::parse(*segment.elisionSmufl)); + } + + core::ElisionSyllabicGroup elisionSyllabicGroup; + elisionSyllabicGroup.setElision(std::move(elision)); + if (segment.syllabic != api::LyricSyllabic::unspecified) + { + elisionSyllabicGroup.setSyllabic(convertLyricSyllabicForNoteWriter(segment.syllabic)); + } + + core::TextElementData segmentText; + segmentText.setValue(segment.text); + setAttributesFromFontData(lyricData.printData.fontData, segmentText); + + core::LyricSyllableGroup syllableGroup; + syllableGroup.setElisionSyllabicGroup(std::move(elisionSyllabicGroup)); + syllableGroup.setText(std::move(segmentText)); + textGroup.addLyricSyllableGroup(std::move(syllableGroup)); + } if (lyricData.hasExtend) { core::Extend extend; diff --git a/src/private/mxtest/api/LyricDataTest.cpp b/src/private/mxtest/api/LyricDataTest.cpp index a617a387f..85b3b0e5d 100644 --- a/src/private/mxtest/api/LyricDataTest.cpp +++ b/src/private/mxtest/api/LyricDataTest.cpp @@ -169,4 +169,149 @@ TEST(unspecifiedSyllabicIsOmitted, LyricData) T_END; +TEST(elidedSyllablesRoundTripThroughApi, LyricData) +{ + ScoreData score; + score.ticksPerQuarter = 4; + score.parts.emplace_back(); + auto &part = score.parts.back(); + part.measures.emplace_back(); + auto &measure = part.measures.back(); + measure.staves.emplace_back(); + auto &staff = measure.staves.back(); + auto &voice = staff.voices[0]; + voice.notes.emplace_back(); + auto ¬e = voice.notes.back(); + note.durationData.durationTimeTicks = 4; + note.durationData.durationName = DurationName::quarter; + note.durationData.isDurationNameSpecified = true; + + LyricData lyric; + lyric.text = "str"; + lyric.syllabic = LyricSyllabic::single; + + LyricTextSegment textJoined; + textJoined.text = "en"; + textJoined.syllabic = LyricSyllabic::single; + textJoined.elisionText = "\xC2\xA0"; // U+00A0 NBSP + lyric.continuations.emplace_back(textJoined); + + LyricTextSegment smuflJoined; + smuflJoined.text = "gth"; + smuflJoined.syllabic = LyricSyllabic::single; + smuflJoined.elisionSmufl = std::string{"lyricsElisionWide"}; + lyric.continuations.emplace_back(smuflJoined); + + LyricTextSegment bareJoined; + bareJoined.text = "!"; + bareJoined.syllabic = LyricSyllabic::single; + // Neither elisionText nor elisionSmufl: writes a bare . + lyric.continuations.emplace_back(bareJoined); + + LyricTextSegment bothJoined; + bothJoined.text = "?"; + bothJoined.syllabic = LyricSyllabic::single; + // MusicXML only consults smufl when the text content is empty, but a source can legally set + // both; round-trip fidelity means neither is dropped. + bothJoined.elisionText = "\xC2\xA0"; + bothJoined.elisionSmufl = std::string{"lyricsElisionNarrow"}; + lyric.continuations.emplace_back(bothJoined); + + LyricTextSegment otherLyricsGlyphJoined; + otherLyricsGlyphJoined.text = "."; + otherLyricsGlyphJoined.syllabic = LyricSyllabic::single; + // The schema only requires a "lyrics" prefix (pattern lyrics\c+), not one of the three + // elision-specific names, so an unrelated "lyrics*" glyph name must round-trip too. + otherLyricsGlyphJoined.elisionSmufl = std::string{"lyricsHyphenBaseline"}; + lyric.continuations.emplace_back(otherLyricsGlyphJoined); + + note.lyrics.emplace_back(lyric); + + const auto xml = mxtest::toXml(score); + auto xmlNote = mxtest::api::lyric_data_test::firstNote(xml); + auto xmlLyric = xmlNote.child("lyric"); + + CHECK_EQUAL(std::string{"str"}, std::string{xmlLyric.child("text").text().get()}); + + auto elisions = xmlLyric.children("elision"); + auto elisionIt = elisions.begin(); + CHECK(elisionIt != elisions.end()); + CHECK_EQUAL(std::string{"\xC2\xA0"}, std::string{elisionIt->text().get()}); + CHECK(std::string{elisionIt->attribute("smufl").value()}.empty()); + ++elisionIt; + CHECK(elisionIt != elisions.end()); + CHECK(std::string{elisionIt->text().get()}.empty()); + CHECK_EQUAL(std::string{"lyricsElisionWide"}, std::string{elisionIt->attribute("smufl").value()}); + ++elisionIt; + CHECK(elisionIt != elisions.end()); + CHECK(std::string{elisionIt->text().get()}.empty()); + CHECK(std::string{elisionIt->attribute("smufl").value()}.empty()); + ++elisionIt; + CHECK(elisionIt != elisions.end()); + CHECK_EQUAL(std::string{"\xC2\xA0"}, std::string{elisionIt->text().get()}); + CHECK_EQUAL(std::string{"lyricsElisionNarrow"}, std::string{elisionIt->attribute("smufl").value()}); + ++elisionIt; + CHECK(elisionIt != elisions.end()); + CHECK(std::string{elisionIt->text().get()}.empty()); + CHECK_EQUAL(std::string{"lyricsHyphenBaseline"}, std::string{elisionIt->attribute("smufl").value()}); + + auto texts = xmlLyric.children("text"); + auto textIt = texts.begin(); + CHECK_EQUAL(std::string{"str"}, std::string{textIt->text().get()}); + ++textIt; + CHECK(textIt != texts.end()); + CHECK_EQUAL(std::string{"en"}, std::string{textIt->text().get()}); + ++textIt; + CHECK(textIt != texts.end()); + CHECK_EQUAL(std::string{"gth"}, std::string{textIt->text().get()}); + ++textIt; + CHECK(textIt != texts.end()); + CHECK_EQUAL(std::string{"!"}, std::string{textIt->text().get()}); + ++textIt; + CHECK(textIt != texts.end()); + CHECK_EQUAL(std::string{"?"}, std::string{textIt->text().get()}); + ++textIt; + CHECK(textIt != texts.end()); + CHECK_EQUAL(std::string{"."}, std::string{textIt->text().get()}); + + const auto out = mxtest::fromXml(xml); + const auto &outNote = out.parts.at(0).measures.at(0).staves.at(0).voices.at(0).notes.at(0); + const auto &outLyric = outNote.lyrics.at(0); + + CHECK_EQUAL(std::string{"str"}, outLyric.text); + CHECK(outLyric.syllabic == LyricSyllabic::single); + CHECK_EQUAL(static_cast(5), outLyric.continuations.size()); + + CHECK_EQUAL(std::string{"en"}, outLyric.continuations.at(0).text); + CHECK(outLyric.continuations.at(0).syllabic == LyricSyllabic::single); + CHECK(outLyric.continuations.at(0).elisionText.has_value()); + CHECK_EQUAL(std::string{"\xC2\xA0"}, *outLyric.continuations.at(0).elisionText); + CHECK(!outLyric.continuations.at(0).elisionSmufl.has_value()); + + CHECK_EQUAL(std::string{"gth"}, outLyric.continuations.at(1).text); + CHECK(!outLyric.continuations.at(1).elisionText.has_value()); + CHECK(outLyric.continuations.at(1).elisionSmufl.has_value()); + CHECK_EQUAL(std::string{"lyricsElisionWide"}, *outLyric.continuations.at(1).elisionSmufl); + + CHECK_EQUAL(std::string{"!"}, outLyric.continuations.at(2).text); + CHECK(!outLyric.continuations.at(2).elisionText.has_value()); + CHECK(!outLyric.continuations.at(2).elisionSmufl.has_value()); + + // Both set at once must round-trip as both, not collapse to just the winning one. + CHECK_EQUAL(std::string{"?"}, outLyric.continuations.at(3).text); + CHECK(outLyric.continuations.at(3).elisionText.has_value()); + CHECK_EQUAL(std::string{"\xC2\xA0"}, *outLyric.continuations.at(3).elisionText); + CHECK(outLyric.continuations.at(3).elisionSmufl.has_value()); + CHECK_EQUAL(std::string{"lyricsElisionNarrow"}, *outLyric.continuations.at(3).elisionSmufl); + + // A "lyrics*" glyph name unrelated to elision must not be dropped just because it isn't one + // of the three elision-specific names. + CHECK_EQUAL(std::string{"."}, outLyric.continuations.at(4).text); + CHECK(!outLyric.continuations.at(4).elisionText.has_value()); + CHECK(outLyric.continuations.at(4).elisionSmufl.has_value()); + CHECK_EQUAL(std::string{"lyricsHyphenBaseline"}, *outLyric.continuations.at(4).elisionSmufl); +} + +T_END; + #endif diff --git a/src/private/mxtest/api/roundtrip-baseline.txt b/src/private/mxtest/api/roundtrip-baseline.txt index 36787ec58..2cbca3dfb 100644 --- a/src/private/mxtest/api/roundtrip-baseline.txt +++ b/src/private/mxtest/api/roundtrip-baseline.txt @@ -702,3 +702,10 @@ synthetic/source.3.0.xml recsuite/Chant.xml synthetic/direction.3.1.xml synthetic/direction.4.0.xml + +# Unblocked by LyricData::continuations: a lyric split across more than one +# run joined by was flattened into a single string on read (substituting +# an undertie glyph for empty elisions) and the writer never emitted at +# all, so the extra runs were dropped on write. +lysuite/ly61j_Lyrics_Elisions.xml +synthetic/lyric.3.0.xml