diff --git a/src/libse/SubtitleFormats/TimedTextImsc11.cs b/src/libse/SubtitleFormats/TimedTextImsc11.cs index 63cbad402ea..77396d900cf 100644 --- a/src/libse/SubtitleFormats/TimedTextImsc11.cs +++ b/src/libse/SubtitleFormats/TimedTextImsc11.cs @@ -626,7 +626,7 @@ public override void LoadSubtitle(Subtitle subtitle, List 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. @@ -637,9 +637,30 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string subtitle.Renumber(); } + /// + /// Reads one TTML p element into subtitle text. Trailing source whitespace is + /// trimmed here, but the line breaks a trailing
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. + ///
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) @@ -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") { @@ -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)) { diff --git a/tests/libse/SubtitleFormats/TimedTextImsc11Test.cs b/tests/libse/SubtitleFormats/TimedTextImsc11Test.cs index 84d34c78d02..b3abbf34f95 100644 --- a/tests/libse/SubtitleFormats/TimedTextImsc11Test.cs +++ b/tests/libse/SubtitleFormats/TimedTextImsc11Test.cs @@ -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() => + "" + + "" + + "
" + + "

A


B

" + + "

C


" + + "
"; + + [Fact] + public void Trailing_Forced_Line_Breaks_Are_Preserved() + { + // #15289: the reader ran TrimEnd() over ReadParagraph(), so every trailing
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


", "C "); + var indented = "" + + "" + + "
\n" + + "

Hello world

\n" + + "
"; + + 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); + } }