diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go
index 276aa4da3..a7d4b4e2a 100644
--- a/model/lyrics_normalize.go
+++ b/model/lyrics_normalize.go
@@ -85,10 +85,47 @@ func normalizeCueLine(line Line, fallbackEnd *int64) Line {
if len(line.Cue) == 0 {
return line
}
- line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd)
+ line.Cue = normalizeCueEndsByAgent(line.Cue, fallbackEnd)
return normalizeLineTiming(line)
}
+// normalizeCueEndsByAgent resolves cue end times independently per agent so that
+// background (or other parallel) layers, whose cues interleave with the main
+// timeline but are stored together in document order, do not clamp each other's
+// ends. Each agent group is normalized in its own document order; results are
+// reassembled into the original cue positions.
+func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue {
+ groups := make(map[string][]int)
+ order := make([]string, 0, 2)
+ for i := range cues {
+ id := cues[i].AgentID
+ if _, ok := groups[id]; !ok {
+ order = append(order, id)
+ }
+ groups[id] = append(groups[id], i)
+ }
+
+ // Single agent: the document order already matches the timeline, so the
+ // straightforward normalization applies without regrouping.
+ if len(order) <= 1 {
+ return NormalizeCueEnds(cues, fallbackEnd)
+ }
+
+ out := slices.Clone(cues)
+ for _, id := range order {
+ idxs := groups[id]
+ group := make([]Cue, len(idxs))
+ for gi, pos := range idxs {
+ group[gi] = cues[pos]
+ }
+ group = NormalizeCueEnds(group, fallbackEnd)
+ for gi, pos := range idxs {
+ out[pos] = group[gi]
+ }
+ }
+ return out
+}
+
// NormalizeCueEnds resolves missing cue end times within a single ordered cue
// group: each end is filled from the next cue's start, then from fallbackEnd,
// and is clamped so it never precedes the cue's own start nor overruns the next
diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go
index 95aab3485..43d4699af 100644
--- a/model/lyrics_ttml.go
+++ b/model/lyrics_ttml.go
@@ -77,8 +77,9 @@ type ttmlDefinedAgent struct {
}
type ttmlPiece struct {
- raw string
- cue *Cue
+ raw string
+ cue *Cue
+ isBreak bool
}
type ttmlParser struct {
@@ -382,7 +383,7 @@ func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, er
func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) {
local := strings.ToLower(start.Name.Local)
if local == "br" {
- return []ttmlPiece{{raw: "\n"}}, nil
+ return []ttmlPiece{{isBreak: true}}, nil
}
ctx := p.childContext(start.Attr, parent)
@@ -442,7 +443,7 @@ func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimin
}
func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) {
- finalized := finalizeTTMLLines(splitTTMLPiecesByNewline(pieces))
+ finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces))
for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 {
finalized = finalized[1:]
}
@@ -488,34 +489,30 @@ func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine {
return finalized
}
-func splitTTMLPiecesByNewline(pieces []ttmlPiece) [][]ttmlPiece {
+func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece {
lines := [][]ttmlPiece{{}}
+ prevEndedWithSpace := true // leading whitespace on a fresh line is dropped
for _, piece := range pieces {
- raw := normalizeTTMLPieceRaw(piece.raw)
- if raw == "" {
+ if piece.isBreak {
+ lines = append(lines, []ttmlPiece{})
+ prevEndedWithSpace = true
continue
}
- start := 0
- for i := 0; i < len(raw); i++ {
- if raw[i] != '\n' {
- continue
- }
- if start < i {
- lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{
- raw: raw[start:i],
- cue: gg.Clone(piece.cue),
- })
- }
- lines = append(lines, []ttmlPiece{})
- start = i + 1
+ raw := normalizeTTMLPieceRaw(piece.raw)
+ // Collapse whitespace across piece boundaries: a piece's leading space is
+ // redundant when the text emitted so far already ends with one.
+ if prevEndedWithSpace {
+ raw = strings.TrimPrefix(raw, " ")
}
- if start < len(raw) {
- lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{
- raw: raw[start:],
- cue: gg.Clone(piece.cue),
- })
+ if raw == "" {
+ continue
}
+ lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{
+ raw: raw,
+ cue: gg.Clone(piece.cue),
+ })
+ prevEndedWithSpace = strings.HasSuffix(raw, " ")
}
return lines
}
@@ -555,11 +552,38 @@ func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) {
return trimmed, cues
}
+// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode
+// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that
+// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so
+// linefeeds and other whitespace runs collapse to a single space. Collapsing is
+// applied unconditionally; xml:space="preserve" is not supported (no lyric
+// source in practice relies on it). Hard line breaks come only from
+// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed
+// indentation between elements does not inject spurious newlines.
func normalizeTTMLPieceRaw(raw string) string {
raw = str.SanitizeText(raw)
- raw = strings.ReplaceAll(raw, "\r\n", "\n")
- raw = strings.ReplaceAll(raw, "\r", "\n")
- return raw
+ return collapseTTMLWhitespace(raw)
+}
+
+func collapseTTMLWhitespace(raw string) string {
+ var b strings.Builder
+ b.Grow(len(raw))
+ prevSpace := false
+ for _, r := range raw {
+ // Only the XML S production (space, tab, CR, LF) is collapsible whitespace.
+ // Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not
+ // whitespace, so they pass through unchanged.
+ if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
+ if !prevSpace {
+ b.WriteByte(' ')
+ prevSpace = true
+ }
+ continue
+ }
+ b.WriteRune(r)
+ prevSpace = false
+ }
+ return b.String()
}
func concatTTMLPieceRaw(pieces []ttmlPiece) string {
diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go
index a175eef0e..bbdf5c7c4 100644
--- a/model/lyrics_ttml_test.go
+++ b/model/lyrics_ttml_test.go
@@ -135,7 +135,7 @@ var _ = Describe("parseTTML", func() {
line := list[0].Line[0]
Expect(line.Start).To(Equal(new(int64(1000))))
- Expect(line.Value).To(Equal("Hello\necho"))
+ Expect(line.Value).To(Equal("Hello echo"))
Expect(line.End).To(Equal(new(int64(3000))))
Expect(line.Cue).To(HaveLen(3))
@@ -269,6 +269,128 @@ var _ = Describe("parseTTML", func() {
})
})
+ Describe("Whitespace handling", func() {
+ It("should collapse pretty-print indentation between spans into single spaces, not line breaks", func() {
+ content := []byte(`
+
+
+
+
+`)
+
+ list, err := parseTTML("xxx", content)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(list).To(HaveLen(1))
+ Expect(list[0].Line).To(HaveLen(1))
+ Expect(list[0].Line[0].Value).To(Equal("first\nsecond"))
+ })
+
+ It("should only collapse XML whitespace, leaving other Unicode spaces intact", func() {
+ // Whitespace collapsing only touches the XML S characters
+ // (space/tab/CR/LF). Other Unicode spaces like U+3000 are left as-is:
+ // the U+3000 inside a span survives, while the pretty-print newline
+ // between spans still collapses to a single space.
+ content := []byte("\n" +
+ "\n" +
+ " \n" +
+ "
\n" +
+ "
\n" +
+ " あ い\n" +
+ " う\n" +
+ "
\n" +
+ "
\n" +
+ " \n" +
+ "")
+
+ list, err := parseTTML("xxx", content)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(list).To(HaveLen(1))
+ Expect(list[0].Line).To(HaveLen(1))
+ Expect(list[0].Line[0].Value).To(Equal("あ い う"))
+ Expect(list[0].Line[0].Cue[0].Value).To(Equal("あ い"))
+ })
+ })
+
+ Describe("Interleaved background cue timing", func() {
+ It("should not corrupt a main cue's end time when a background cue is earlier in time", func() {
+ // Background spans (x-bg) appear after the main spans in document order
+ // but their timings interleave with the main timeline. End-time
+ // normalization must be per agent so the last main cue keeps its real
+ // end instead of collapsing to its own start.
+ content := []byte(`
+
+
+
+
realslow(When youslide)
+
+
+`)
+
+ list, err := parseTTML("xxx", content)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(list).To(HaveLen(1))
+ Expect(list[0].Line).To(HaveLen(1))
+
+ line := list[0].Line[0]
+ Expect(line.Cue).To(HaveLen(4))
+
+ cuesByAgent := map[string][]Cue{}
+ for _, c := range line.Cue {
+ cuesByAgent[c.AgentID] = append(cuesByAgent[c.AgentID], c)
+ }
+
+ mainCues := cuesByAgent["v2"]
+ Expect(mainCues).To(HaveLen(2))
+ Expect(*mainCues[0].End).To(Equal(int64(85934))) // "real"
+ // "slow" must keep its real end (86751), not collapse to its start.
+ Expect(*mainCues[1].Start).To(Equal(int64(85934)))
+ Expect(*mainCues[1].End).To(Equal(int64(86751)))
+
+ bgCues := cuesByAgent["__nd_bg__|v2"]
+ Expect(bgCues).To(HaveLen(2))
+ Expect(*bgCues[0].End).To(Equal(int64(84243))) // "(When you"
+ Expect(*bgCues[1].End).To(Equal(int64(86859))) // "slide)"
+ })
+ })
+
Describe("Ambiguous decimal timing", func() {
It("should prefer absolute timing when values fall inside parent window", func() {
content := []byte(`
@@ -290,7 +412,7 @@ var _ = Describe("parseTTML", func() {
line := list[0].Line[0]
Expect(line.Start).To(Equal(new(int64(43444))))
- Expect(line.Value).To(Equal("go\ngo"))
+ Expect(line.Value).To(Equal("go go"))
Expect(line.End).To(Equal(new(int64(45570))))
Expect(line.Cue).To(HaveLen(2))
Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1}))