Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/include/mx/api/LyricData.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

#include <optional>
#include <string>
#include <vector>

namespace mx
{
Expand All @@ -37,6 +38,29 @@ enum class LyricExtendType
stop
};

// One additional syllabic/text run appended to a <lyric>, joined to the run before it by
// <elision>. 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 <elision> 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 <elision/>
// (or, if elisionSmufl is set, an <elision smufl="...">).
std::optional<std::string> elisionText;

// The canonical SMuFL glyph name for the <elision>'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<std::string> elisionSmufl;
};

class LyricData
{
public:
Expand All @@ -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 <elision>. Empty for
// an ordinary single-run lyric, which is the common case.
std::vector<LyricTextSegment> continuations;

bool hasExtend;
LyricExtendType extendType;
PositionData positionData;
Expand All @@ -59,11 +88,20 @@ class LyricData
std::optional<Id> 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)
Expand Down
55 changes: 30 additions & 25 deletions src/private/mx/impl/NoteReader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <text>: one api::LyricTextSegment per <elision>-joined
// <syllabic>?/<text> pair, preserving each run's own syllabic value and elision content.
std::vector<api::LyricTextSegment> getLyricContinuations(const core::LyricTextGroup &inGroup)
{
if (inGroup.elisionSyllabicGroup().has_value())
std::vector<api::LyricTextSegment> 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
// <elision> 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
// <elision> 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;
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/private/mx/impl/NoteWriter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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;
Expand Down
145 changes: 145 additions & 0 deletions src/private/mxtest/api/LyricDataTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 &note = 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 <elision/>.
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<size_t>(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
7 changes: 7 additions & 0 deletions src/private/mxtest/api/roundtrip-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>
# run joined by <elision> was flattened into a single string on read (substituting
# an undertie glyph for empty elisions) and the writer never emitted <elision> at
# all, so the extra runs were dropped on write.
lysuite/ly61j_Lyrics_Elisions.xml
synthetic/lyric.3.0.xml
Loading