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
32 changes: 30 additions & 2 deletions src/libse/SubtitleFormats/TimedTextImsc11.cs
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ public override void LoadSubtitle(Subtitle subtitle, List<string> lines, string
assStyle = GetAssStyleFromRegion(region.InnerText, headIndex);
}

var text = assStyle + ReadParagraph(node, headIndex).TrimEnd();
var text = assStyle + ReadParagraph(node, headIndex);

// Keep the region name: the alignment tag above only snaps to the screen thirds,
// the video preview positions the line from the region box itself.
Expand All @@ -637,9 +637,30 @@ public override void LoadSubtitle(Subtitle subtitle, List<string> lines, string
subtitle.Renumber();
}

/// <summary>
/// Reads one TTML <c>p</c> element into subtitle text. Trailing source whitespace is
/// trimmed here, but the line breaks a trailing <br/> adds are content: in TTML they are
/// forced breaks with layout meaning, so dropping them changes the cue (#15289). Nested
/// spans are read raw - only the finished paragraph is trimmed, so a space inside the
/// last span still survives.
/// </summary>
private static string ReadParagraph(XmlNode node, TtmlHeadIndex headIndex)
{
var raw = BuildParagraph(node, headIndex, out var forcedBreakEnd);
if (forcedBreakEnd < 0)
{
return raw.TrimEnd();
}

// Everything before the last forced break already ends in a line break, so only the
// text behind it can be source formatting - that tail is trimmed as before.
return raw[..forcedBreakEnd] + raw[forcedBreakEnd..].TrimEnd();
}

private static string BuildParagraph(XmlNode node, TtmlHeadIndex headIndex, out int forcedBreakEnd)
{
var pText = new StringBuilder();
forcedBreakEnd = -1;
foreach (XmlNode child in node.ChildNodes)
{
if (child.NodeType == XmlNodeType.Text)
Expand All @@ -649,6 +670,7 @@ private static string ReadParagraph(XmlNode node, TtmlHeadIndex headIndex)
else if (child.Name == "br" || child.Name == "tt:br")
{
pText.AppendLine();
forcedBreakEnd = pText.Length;
}
else if (child.Name == "#significant-whitespace" || child.Name == "tt:#significant-whitespace")
{
Expand Down Expand Up @@ -787,7 +809,13 @@ private static string ReadParagraph(XmlNode node, TtmlHeadIndex headIndex)
pText.Append(">");
}

pText.Append(ReadParagraph(child, headIndex));
var contentStart = pText.Length;
var content = BuildParagraph(child, headIndex, out var nestedBreakEnd);
pText.Append(content);
if (nestedBreakEnd >= 0)
{
forcedBreakEnd = contentStart + nestedBreakEnd;
}

if (!string.IsNullOrEmpty(fontFamily) || !string.IsNullOrEmpty(color))
{
Expand Down
65 changes: 65 additions & 0 deletions tests/libse/SubtitleFormats/TimedTextImsc11Test.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,69 @@ public void IsMine_Accepts_All_Imsc_Extensions_And_Keeps_Consecutive_Line_Breaks
Configuration.Settings.SubtitleSettings.TimedTextImsc11FileExtension = oldExtension;
}
}

private static string TrailingBreaksXml() =>
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<tt xmlns=\"http://www.w3.org/ns/ttml\" xmlns:ttp=\"http://www.w3.org/ns/ttml#parameter\" xml:lang=\"en\" ttp:profile=\"http://www.w3.org/ns/ttml/profile/imsc1/text\" ttp:timeBase=\"media\">" +
"<body><div>" +
"<p begin=\"00:00:01.000\" end=\"00:00:03.000\">A<br/><br/><br/>B</p>" +
"<p begin=\"00:00:04.000\" end=\"00:00:06.000\">C<br/><br/><br/></p>" +
"</div></body></tt>";

[Fact]
public void Trailing_Forced_Line_Breaks_Are_Preserved()
{
// #15289: the reader ran TrimEnd() over ReadParagraph(), so every trailing <br/> was
// dropped - three of them is a layout-significant blank line in TTML.
var sut = new TimedTextImsc11();
var subtitle = new Subtitle();

sut.LoadSubtitle(subtitle, TrailingBreaksXml().SplitToLines(), "test.xml");

Assert.Equal(2, subtitle.Paragraphs.Count);
Assert.Equal("A" + Environment.NewLine + Environment.NewLine + Environment.NewLine + "B",
subtitle.Paragraphs[0].Text);
Assert.Equal("C" + Environment.NewLine + Environment.NewLine + Environment.NewLine,
subtitle.Paragraphs[1].Text);
}

[Fact]
public void Trailing_Source_Whitespace_Is_Still_Trimmed()
{
// The TrimEnd() also kept source formatting out of the text: an inline trailing space and
// an indented paragraph must both come back without trailing whitespace.
var sut = new TimedTextImsc11();
var compact = TrailingBreaksXml().Replace("C<br/><br/><br/>", "C ");
var indented = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<tt xmlns=\"http://www.w3.org/ns/ttml\" xmlns:ttp=\"http://www.w3.org/ns/ttml#parameter\" xml:lang=\"en\" ttp:profile=\"http://www.w3.org/ns/ttml/profile/imsc1/text\" ttp:timeBase=\"media\">" +
"<body><div>\n" +
" <p begin=\"00:00:01.000\" end=\"00:00:03.000\">Hello world</p>\n" +
"</div></body></tt>";

var compactSubtitle = new Subtitle();
sut.LoadSubtitle(compactSubtitle, compact.SplitToLines(), "test.xml");
var indentedSubtitle = new Subtitle();
sut.LoadSubtitle(indentedSubtitle, indented.SplitToLines(), "test.xml");

Assert.Equal("C", compactSubtitle.Paragraphs[1].Text);
Assert.Equal("Hello world", indentedSubtitle.Paragraphs[0].Text);
}

[Fact]
public void LoadSaveLoad_Keeps_Trailing_Forced_Line_Breaks()
{
var sut = new TimedTextImsc11();
var subtitle = new Subtitle();
sut.LoadSubtitle(subtitle, TrailingBreaksXml().SplitToLines(), "test.xml");

var saved = sut.ToText(subtitle, "title");
var reloaded = new Subtitle();
new TimedTextImsc11().LoadSubtitle(reloaded, saved.SplitToLines(), "test.xml");

Assert.Equal(subtitle.Paragraphs.Count, reloaded.Paragraphs.Count);
Assert.Equal("A" + Environment.NewLine + Environment.NewLine + Environment.NewLine + "B",
reloaded.Paragraphs[0].Text);
Assert.Equal("C" + Environment.NewLine + Environment.NewLine + Environment.NewLine,
reloaded.Paragraphs[1].Text);
}
}
Loading