Repository: SubtitleEdit/subtitleedit (2026-06-28T19:51:33Z)

Issue:
Regex-expressions with \r\n not working anymore  since v5
I had before v5 lots of Multiple Replace rules of Regex-type with a test on \r\n embedded, 
i.e. for subtitles with multiple text-lines in a single #.

Such CRLF is not recognized anymore in Multiple Replace rules.
And neither when performing a Ctrl-F Find of regular expression-type on the subtitle text.

f.i. in a .SRT:
«
Two drops in each ear
twice a day.
»
The simple regex-expression "ear\r\ntwice" returns nothing as of v5.
Neither with only \r or \n.

Has maybe the Regex-engine changed since v5?


Discussion:

Patch:
diff --git a/docs/features/edit.md b/docs/features/edit.md
index 880f77dc07..39a6a303d5 100644
--- a/docs/features/edit.md
+++ b/docs/features/edit.md
@@ -32,6 +32,8 @@ Options:
 - Whole word (checkbox)
 - Search type (radio buttons): Case sensitive, Case insensitive, or Regular expression
 
+> **Matching a line break with a regular expression:** use `\n` between the words on the two lines (for example `ear\ntwice`). `\r\n` and `\r` are accepted too and are treated the same as `\n`, so a rule works regardless of how it was written or which platform created it.
+
 <!-- Screenshot: Find window -->
 ![Find](../screenshots/find.png)
 
@@ -71,7 +73,7 @@ Each rule has one of three match types, shown as an icon in the tree:
 |---|---|
 | Case insensitive | Plain text match, ignores case |
 | Case sensitive | Plain text match, exact case |
-| Regular expression | Full .NET regex syntax |
+| Regular expression | Full .NET regex syntax. Use `\n` to match a line break between two lines (`\r\n` and `\r` are accepted too and treated as `\n`). |
 
 ### Managing categories
 
diff --git a/src/libse/Common/RegexUtils.cs b/src/libse/Common/RegexUtils.cs
index 350a45ec50..38e8332c7b 100644
--- a/src/libse/Common/RegexUtils.cs
+++ b/src/libse/Common/RegexUtils.cs
@@ -208,8 +208,12 @@ public static string GetRegExGroup(string pattern)
         }
 
         /// <summary>
-        /// Changes "\\r\\n" and "\\n" to "\n", which hopefully makes it simpler for
-        /// the user who can use both "\\n" and "\\r\\n" for new line.
+        /// Normalizes the new-line escapes a user typed in a regular expression (<c>\r\n</c>, <c>\n</c>
+        /// or <c>\r</c>) to a single line-feed, so a rule matches regardless of which escape was typed
+        /// and regardless of the platform line-ending. Subtitle text is matched line-feed normalized too
+        /// (see <see cref="ReplaceNewLineSafe"/>), keeping the pattern and the text in sync. The previous
+        /// behavior expanded these to Environment.NewLine (CRLF on Windows), which no longer matched the
+        /// line-feed text used in v5, so cross-line regex rules silently stopped working (#11956).
         /// </summary>
         public static string FixNewLine(string pattern)
         {
@@ -218,7 +222,7 @@ public static string FixNewLine(string pattern)
                 return pattern;
             }
 
-            return pattern.Replace("\\r\\n", Environment.NewLine).Replace("\\n", Environment.NewLine);
+            return pattern.Replace("\\r\\n", "\n").Replace("\\n", "\n").Replace("\\r", "\n");
         }
 
         public static string EscapeNewLines(string text)
@@ -229,8 +233,8 @@ public static string EscapeNewLines(string text)
         }
 
         /// <summary>
-        /// Performs replace on regular expression. Line breaks are converted to just "\n" during the replace
-        /// and line breaks are returned as Environment.NewLine.
+        /// Performs replace on regular expression. Line breaks are normalized to "\n" for both the match
+        /// and the returned text, so the pattern and the text agree regardless of \n vs \r\n.
         /// </summary>
         /// <param name="regularExpression">Regular expression to perform replace on</param>
         /// <param name="text">Text perform replace on</param>
@@ -242,8 +246,8 @@ public static string ReplaceNewLineSafe(Regex regularExpression, string text, st
         }
 
         /// <summary>
-        /// Performs replace on regular expression. Line breaks are converted to just "\n" during the replace
-        /// and line breaks are returned as Environment.NewLine.
+        /// Performs replace on regular expression. Line breaks are normalized to "\n" for both the match
+        /// and the returned text, so the pattern and the text agree regardless of \n vs \r\n.
         /// </summary>
         /// <param name="regularExpression">Regular expression to perform replace on</param>
         /// <param name="text">Text perform replace on</param>
@@ -253,13 +257,15 @@ public static string ReplaceNewLineSafe(Regex regularExpression, string text, st
         /// <returns>Input string with regular expression replace applied</returns>
         public static string ReplaceNewLineSafe(Regex regularExpression, string text, string replaceWith, int count, int startIndex)
         {
-            text = regularExpression.Replace(string.Join(Environment.NewLine, text.SplitToLines()), replaceWith, count, startIndex);
-            return string.Join(Environment.NewLine, text.SplitToLines());
+            // Match/return with line-feed-normalized line breaks so a pattern's \n (see FixNewLine)
+            // matches regardless of whether the in-memory text uses \n or \r\n (#11956).
+            text = regularExpression.Replace(string.Join("\n", text.SplitToLines()), replaceWith, count, startIndex);
+            return string.Join("\n", text.SplitToLines());
         }
 
         public static int CountNewLineSafe(Regex regularExpression, string text)
         {
-            return regularExpression.Matches(string.Join(Environment.NewLine, text.SplitToLines())).Count;
+            return regularExpression.Matches(string.Join("\n", text.SplitToLines())).Count;
         }
     }
 }
diff --git a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs
index 5211704723..9647a9538b 100644
--- a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs
+++ b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs
@@ -1127,7 +1127,9 @@ private void GeneratePreview()
                 else if (item.SearchType == ReplaceExpression.SearchRegEx)
                 {
                     var r = _compiledRegExList[item.FindWhat];
-                    if (r.IsMatch(newText))
+                    // Match against line-feed-normalized text so a pattern's \n line break matches even
+                    // when the paragraph text uses \r\n (the pattern is FixNewLine'd to \n) (#11956).
+                    if (r.IsMatch(string.Join("\n", newText.SplitToLines())))
                     {
                         hit = true;
                         ruleInfo = string.IsNullOrEmpty(ruleInfo) ? item.RuleInfo : $"{ruleInfo} + {item.RuleInfo}";
diff --git a/src/ui/Logic/FindService.cs b/src/ui/Logic/FindService.cs
index 07e185b0a5..c0580d9181 100644
--- a/src/ui/Logic/FindService.cs
+++ b/src/ui/Logic/FindService.cs
@@ -349,6 +349,7 @@ private void AddToSearchHistory(string searchText)
     private (bool replaced, string newText, int replacementCount) ReplaceWithRegex(string line, string searchText, string replaceText, int startIndex, int maxReplacements)
     {
         replaceText = RegexUtils.FixNewLine(replaceText);
+        searchText = RegexUtils.FixNewLine(searchText); // \r\n / \r in the pattern -> \n to match the text (#11956)
         try
         {
             var regex = new Regex(searchText);
@@ -541,7 +542,7 @@ private void AddToSearchHistory(string searchText)
             var searchLine = startIndex > 0 ? line.Substring(startIndex) : line;
             var originalLength = searchLine.Length;
             searchLine = NormalizeLineEndingsForRegex(searchLine, out var indexMap);
-            var match = Regex.Match(searchLine, searchText);
+            var match = Regex.Match(searchLine, RegexUtils.FixNewLine(searchText));
 
             if (match.Success)
             {
@@ -566,7 +567,7 @@ private void AddToSearchHistory(string searchText)
 
             // Advance by 1 after each match so overlapping matches are found
             // (e.g. two long lines sharing the \n between them).
-            var regex = new Regex(searchText);
+            var regex = new Regex(RegexUtils.FixNewLine(searchText));
             Match? lastMatch = null;
             var pos = 0;
             while (pos < searchLine.Length)
@@ -603,7 +604,7 @@ private int CountMatchesInLine(string line, string searchText, bool wholeWord, F
                 try
                 {
                     var searchLine = NormalizeLineEndingsForRegex(line);
-                    return Regex.Matches(searchLine, searchText).Count;
+                    return Regex.Matches(searchLine, RegexUtils.FixNewLine(searchText)).Count;
                 }
                 catch (ArgumentException)
                 {
@@ -631,7 +632,7 @@ private List<FindMatch> GetAllMatchesInLine(string line, string searchText, bool
                 try
                 {
                     var searchLine = NormalizeLineEndingsForRegex(line, out var indexMap);
-                    var regexMatches = Regex.Matches(searchLine, searchText);
+                    var regexMatches = Regex.Matches(searchLine, RegexUtils.FixNewLine(searchText));
                     foreach (Match match in regexMatches)
                     {
                         matches.Add(new FindMatch(MapNormalizedIndex(indexMap, match.Index, line.Length), match.Value));


Test patch:
diff --git a/tests/UI/Logic/FindServiceTests.cs b/tests/UI/Logic/FindServiceTests.cs
index 40a3aee4a7..14f5ec1df1 100644
--- a/tests/UI/Logic/FindServiceTests.cs
+++ b/tests/UI/Logic/FindServiceTests.cs
@@ -5,6 +5,23 @@ namespace UITests.Logic;
 
 public class FindServiceTests
 {
+    // #11956: a regex with \r\n (or \n / \r) must match a line break in the (line-feed) subtitle
+    // text. Previously \r\n patterns matched nothing because the text uses \n.
+    [Theory]
+    [InlineData(@"ear\r\ntwice")]
+    [InlineData(@"ear\ntwice")]
+    [InlineData(@"ear\rtwice")]
+    public void Regex_NewLineEscapes_MatchAcrossLineBreak(string pattern)
+    {
+        var text = "Two drops in each ear\ntwice a day.";
+        var service = new FindService();
+        service.Initialize([text], 0, false, FindService.FindMode.RegularExpression);
+
+        Assert.Equal(0, service.FindNext(pattern, [text], 0, 0));
+        Assert.Equal(1, service.Count(pattern, [text], false, FindService.FindMode.RegularExpression));
+        Assert.Single(service.FindAll(pattern));
+    }
+
     [Fact]
     public void RegexMultilineEndAnchorMatchesCrLfLineEndings()
     {
diff --git a/tests/libse/Common/RegexUtilsTest.cs b/tests/libse/Common/RegexUtilsTest.cs
new file mode 100644
index 0000000000..446fbfbdee
--- /dev/null
+++ b/tests/libse/Common/RegexUtilsTest.cs
@@ -0,0 +1,42 @@
+using Nikse.SubtitleEdit.Core.Common;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace LibSETests.Common;
+
+public class RegexUtilsTest
+{
+    // #11956: \r\n, \n and \r in a regex pattern all normalize to a single line-feed, so a cross-line
+    // rule matches the line-feed text v5 uses, regardless of which escape the user typed.
+    [Theory]
+    [InlineData(@"ear\r\ntwice", "ear\ntwice")]
+    [InlineData(@"ear\ntwice", "ear\ntwice")]
+    [InlineData(@"ear\rtwice", "ear\ntwice")]
+    public void FixNewLine_NormalizesEscapesToLineFeed(string pattern, string expected)
+    {
+        Assert.Equal(expected, RegexUtils.FixNewLine(pattern));
+    }
+
+    [Fact]
+    public void ReplaceNewLineSafe_CrLfPattern_MatchesLineFeedText()
+    {
+        var regex = new Regex(RegexUtils.FixNewLine(@"ear\r\ntwice"));
+        var result = RegexUtils.ReplaceNewLineSafe(regex, "each ear\ntwice a day", "ear twice");
+        Assert.Equal("each ear twice a day", result);
+    }
+
+    [Fact]
+    public void ReplaceNewLineSafe_LineFeedPattern_MatchesCrLfText()
+    {
+        var regex = new Regex(RegexUtils.FixNewLine(@"ear\ntwice"));
+        var result = RegexUtils.ReplaceNewLineSafe(regex, "each ear\r\ntwice a day", "ear twice");
+        Assert.Equal("each ear twice a day", result);
+    }
+
+    [Fact]
+    public void CountNewLineSafe_CountsMatchAcrossLineBreak()
+    {
+        var regex = new Regex(RegexUtils.FixNewLine(@"a\r\nb"));
+        Assert.Equal(1, RegexUtils.CountNewLineSafe(regex, "xa\nbx"));
+    }
+}