From f5b2013374c088af57f8bade0a86730ccbfb4916 Mon Sep 17 00:00:00 2001
From: Chirag Jangir <184008125+chiragjangiir@users.noreply.github.com>
Date: Tue, 13 Oct 2026 23:57:50 +0530
Subject: [PATCH] IMSC 1.1: keep trailing
forced line breaks
The reader built each cue as ReadParagraph(...).TrimEnd(), which also
removed the newlines a trailing
adds - so "C
" loaded
as "C" and the forced breaks were lost on import (#15289).
Trim only the text behind the last forced break instead: everything before
it already ends in a line break and is content, while the tail is still
source formatting. A paragraph without a
trims exactly as before, and
nested spans are still read raw, so a space inside the last span survives.
Part of #15289
Generated with Codebuff
Co-Authored-By: Codebuff
---
src/libse/SubtitleFormats/TimedTextImsc11.cs | 32 ++++++++-
.../SubtitleFormats/TimedTextImsc11Test.cs | 65 +++++++++++++++++++
2 files changed, 95 insertions(+), 2 deletions(-)
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() =>
+ "" +
+ "" +
+ "";
+
+ [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);
+ }
}