Skip to content

Commit b7f261e

Browse files
committed
Enhance TTML lyrics parsing and improve document normalization
### Lyrics Parsing & Normalization - **TTML Support**: Improved support for Apple-style TTML files containing XML declarations and multiple namespaces. - **Document Normalization**: Introduced `normalizeTtmlDocument` to strip leading whitespace, Byte Order Marks (BOM), and format characters before parsing. It now correctly handles and bypasses `<?xml ... ?>` declarations to focus on the `<tt>` root element. - **Time Expression Parsing**: Updated `TtmlLyricsParser` to support raw decimal second values (e.g., `"7.531"`) in addition to standard clock and offset time formats. - **Parser Integration**: Updated `LyricsUtils` to automatically detect and route potential TTML content—even when prefixed with XML declarations—to the `TtmlLyricsParser`. ### Robustness & Security - **Validation**: Added safeguards to ensure malformed TTML documents do not fall back to being treated as plain text if parsing fails. - **Stability**: Added checks for empty normalized content and ensured strict paragraph limits are enforced during TTML to Enhanced LRC conversion. ### Testing - Added comprehensive test cases in `LyricsImportSecurityTest` and `LyricsUtilsTest` to verify handling of complex Apple TTML files, XML declarations, and malformed XML inputs.
1 parent 1843f46 commit b7f261e

4 files changed

Lines changed: 135 additions & 1 deletion

File tree

app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,13 @@ object LyricsUtils {
468468
return Lyrics(plain = emptyList(), synced = emptyList())
469469
}
470470

471+
val normalizedInput = stripLeadingLyricsDocumentNoise(lyricsText)
472+
if (looksLikeTtmlDocument(normalizedInput)) {
473+
val converted = TtmlLyricsParser.parseToEnhancedLrc(normalizedInput)
474+
?: return Lyrics(plain = emptyList(), synced = emptyList())
475+
return parseLyrics(converted)
476+
}
477+
471478
val syncedLines = mutableListOf<SyncedLine>()
472479
val plainLines = mutableListOf<String>()
473480
var isSynced = false
@@ -727,6 +734,28 @@ object LyricsUtils {
727734
}
728735
}
729736

737+
private fun stripLeadingLyricsDocumentNoise(value: String): String {
738+
return value.trimStart { char ->
739+
char.isWhitespace() ||
740+
char == '\uFEFF' ||
741+
Character.getType(char).toByte() == Character.FORMAT
742+
}
743+
}
744+
745+
private fun looksLikeTtmlDocument(value: String): Boolean {
746+
if (value.startsWith("<tt", ignoreCase = true)) {
747+
return true
748+
}
749+
if (!value.startsWith("<?xml", ignoreCase = true)) {
750+
return false
751+
}
752+
753+
val afterDeclaration = value.substringAfter("?>", missingDelimiterValue = "")
754+
return afterDeclaration
755+
.trimStart()
756+
.startsWith("<tt", ignoreCase = true)
757+
}
758+
730759
private fun sanitizeLrcLine(rawLine: String): String {
731760
if (rawLine.isEmpty()) return rawLine
732761

app/src/main/java/com/theveloper/pixelplay/utils/TtmlLyricsParser.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ internal object TtmlLyricsParser {
1414

1515
fun parseToEnhancedLrc(ttmlText: String): String? {
1616
return runCatching {
17+
val normalizedTtml = normalizeTtmlDocument(ttmlText)
18+
if (normalizedTtml.isBlank()) {
19+
return null
20+
}
21+
1722
val builder = DocumentBuilderFactory.newInstance().apply {
1823
isNamespaceAware = true
1924
isXIncludeAware = false
@@ -29,7 +34,7 @@ internal object TtmlLyricsParser {
2934
setEntityResolver(EntityResolver { _, _ -> InputSource(StringReader("")) })
3035
}
3136

32-
val document = builder.parse(InputSource(StringReader(ttmlText)))
37+
val document = builder.parse(InputSource(StringReader(normalizedTtml)))
3338
val paragraphNodes = document.getElementsByTagNameNS("*", "p")
3439
if (paragraphNodes.length == 0 || paragraphNodes.length > MAX_TTML_PARAGRAPHS) {
3540
return null
@@ -49,6 +54,20 @@ internal object TtmlLyricsParser {
4954
}.getOrNull()
5055
}
5156

57+
private fun normalizeTtmlDocument(raw: String): String {
58+
val withoutLeadingNoise = raw.trimStart { char ->
59+
char.isWhitespace() ||
60+
char == '\uFEFF' ||
61+
Character.getType(char).toByte() == Character.FORMAT
62+
}
63+
64+
return if (withoutLeadingNoise.startsWith("<?xml", ignoreCase = true)) {
65+
withoutLeadingNoise.substringAfter("?>", missingDelimiterValue = withoutLeadingNoise).trimStart()
66+
} else {
67+
withoutLeadingNoise
68+
}
69+
}
70+
5271
private fun resolveParagraphStartMs(paragraph: Element): Int? {
5372
parseTimeExpression(paragraph.getAttribute("begin"))?.let { return it }
5473

@@ -143,6 +162,10 @@ internal object TtmlLyricsParser {
143162
return (seconds * 1000).roundToInt()
144163
}
145164

165+
normalized.toDoubleOrNull()?.let { seconds ->
166+
return (seconds * 1000).roundToInt()
167+
}
168+
146169
val parts = normalized.split(':')
147170
return when (parts.size) {
148171
2 -> {

app/src/test/java/com/theveloper/pixelplay/utils/LyricsImportSecurityTest.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,39 @@ class LyricsImportSecurityTest {
158158
assertThat(valid.value.parsedLyrics.synced!!.first().words).hasSize(3)
159159
}
160160

161+
@Test
162+
fun validateImportedLyricsFile_acceptsAppleTtmlWithXmlDeclarationAndNamespaces() {
163+
val ttml = """
164+
<?xml version='1.0' encoding='utf-8'?>
165+
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal" xmlns:ttm="http://www.w3.org/ns/ttml#metadata" itunes:timing="Word" xml:lang="en">
166+
<head>
167+
<metadata>
168+
<ttm:agent type="person" xml:id="v1">
169+
<ttm:name type="full">Chase Atlantic</ttm:name>
170+
</ttm:agent>
171+
</metadata>
172+
</head>
173+
<body dur="0:15.000">
174+
<div begin="7.531" end="12.005" itunes:songPart="Verse">
175+
<p begin="7.531" end="12.005" itunes:key="L1" ttm:agent="v1"><span begin="7.531" end="7.782">Yeah,</span> <span begin="9.208" end="9.443">I</span> <span begin="9.443" end="9.675">bet</span></p>
176+
</div>
177+
</body>
178+
</tt>
179+
""".trimIndent()
180+
181+
val result = LyricsImportSecurity.validateImportedLyricsFile(
182+
fileName = "track.ttml",
183+
mimeType = "application/ttml+xml",
184+
inputStream = ttml.byteInputStream()
185+
)
186+
187+
assertThat(result).isInstanceOf(LyricsImportValidationResult.Valid::class.java)
188+
val valid = result as LyricsImportValidationResult.Valid
189+
assertThat(valid.value.sanitizedContent).isEqualTo("[00:07.53]<00:07.53>Yeah, <00:09.20>I <00:09.44>bet")
190+
assertThat(valid.value.parsedLyrics.synced).hasSize(1)
191+
assertThat(valid.value.parsedLyrics.synced!!.first().line).isEqualTo("Yeah, I bet")
192+
}
193+
161194
@Test
162195
fun validateImportedLyricsFile_rejectsTtmlWithDoctype() {
163196
val maliciousTtml = """

app/src/test/java/com/theveloper/pixelplay/utils/LyricsUtilsTest.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,55 @@ class LyricsUtilsTest {
162162
assertEquals("", synced.last().line)
163163
}
164164

165+
@Test
166+
fun parseLyrics_convertsAppleTtmlWithXmlDeclarationAndNamespaces() {
167+
val ttml = """
168+
<?xml version='1.0' encoding='utf-8'?>
169+
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal" xmlns:ttm="http://www.w3.org/ns/ttml#metadata" itunes:timing="Word" xml:lang="en">
170+
<head>
171+
<metadata>
172+
<ttm:agent type="person" xml:id="v1">
173+
<ttm:name type="full">Chase Atlantic</ttm:name>
174+
</ttm:agent>
175+
</metadata>
176+
</head>
177+
<body dur="0:15.000">
178+
<div begin="7.531" end="12.005" itunes:songPart="Verse">
179+
<p begin="7.531" end="12.005" itunes:key="L1" ttm:agent="v1"><span begin="7.531" end="7.782">Yeah,</span> <span begin="9.208" end="9.443">I</span> <span begin="9.443" end="9.675">bet</span></p>
180+
</div>
181+
</body>
182+
</tt>
183+
""".trimIndent()
184+
185+
val lyrics = LyricsUtils.parseLyrics(ttml)
186+
val synced = requireNotNull(lyrics.synced)
187+
val first = synced.first()
188+
189+
assertEquals(1, synced.size)
190+
assertEquals(7_530, first.time)
191+
assertEquals("Yeah, I bet", first.line)
192+
assertEquals(listOf("Yeah,", "I", "bet"), requireNotNull(first.words).map { it.word })
193+
}
194+
195+
@Test
196+
fun parseLyrics_doesNotExposeBrokenTtmlAsPlainText() {
197+
val malformedTtml = """
198+
<?xml version='1.0' encoding='utf-8'?>
199+
<tt xmlns="http://www.w3.org/ns/ttml">
200+
<body>
201+
<div>
202+
<p begin="00:01.000">Hello
203+
</div>
204+
</body>
205+
</tt>
206+
""".trimIndent()
207+
208+
val lyrics = LyricsUtils.parseLyrics(malformedTtml)
209+
210+
assertTrue(lyrics.synced.isNullOrEmpty())
211+
assertTrue(lyrics.plain.isNullOrEmpty())
212+
}
213+
165214
@Test
166215
fun parseLyrics_stripsAdditionalLrcTimestampsFromLines() {
167216
val lrc = """

0 commit comments

Comments
 (0)