Repository: SubtitleEdit/subtitleedit (2026-07-21T03:52:12Z)

Issue:
Opening a raw PGS .sup freezes the UI and causes thread-pool growth
## Summary

Opening a raw PGS elementary stream with a `.sup` extension can freeze Subtitle Edit indefinitely, consume one CPU core, grow memory usage, and create hundreds of blocked thread-pool threads.

The file was produced by extracting an `S_HDMV/PGS` Matroska track in **raw extraction** mode. It contains valid PGS display segments/images, but it is not a standalone Blu-ray SUP stream: it starts directly with a PGS segment (`0x16`, PCS) instead of the `PG` (`0x50 0x47`) wrapper that carries PTS/DTS values.

Subtitle Edit correctly fails `FileUtil.IsBluRaySup()`, but then falls through to the generic text subtitle loader. The binary file is decoded as text and passed to `UnknownFormatImporter.ImportSubtitleWithNoLineBreaksWithExtraSpaces()`, which has quadratic behavior because it allocates `text.Substring(i)` for every numeric character.

## Environment

- Subtitle Edit: **5.1.0-rc10** (`1e91104a0276a1fe152b91eb4d623603e5fcf084`)
- OS: Windows 11 x64
- Runtime visible in the dump: .NET 10.0.9
- The same relevant implementation is still present on `main` at `ede7eca95b2aa10fe56297b612e66d43c7f1c34c` (2026-07-20)

## Steps to reproduce

### Real-world input

1. Extract an `S_HDMV/PGS` track from an MKV with gMKVExtractGUI / `mkvextract` using raw extraction mode.
2. Confirm that the resulting `.sup` file starts with a raw PGS segment such as `16 00 ...`, not `50 47` (`PG`).
3. In Subtitle Edit, use **File > Open** and select that `.sup` file.

### Privacy-safe synthetic reproducer

This creates no media content and contains no subtitle images. It only exercises the same erroneous binary-to-text fallback:

```python
from pathlib import Path

# A non-PG binary header followed by many numeric bytes. Numeric bytes trigger
# the Substring(i) + regex path in ImportSubtitleWithNoLineBreaksWithExtraSpaces.
Path("raw_pgs_repro.sup").write_bytes(bytes([0x16, 0x00, 0x13]) + b"0" * (8 * 1024 * 1024))
```

Open `raw_pgs_repro.sup` with **File > Open**.

## Actual behavior

- The main window becomes unresponsive.
- The process continuously consumes roughly one logical CPU core.
- Disk I/O is effectively zero after the file has been read.
- Memory usage grows and fluctuates due to very large temporary string allocations.
- In the observed dump, the process had **492 threads**. Most were blocked waiting for the UI dispatcher.
- Waiting does not produce an error or recovery in a practical amount of time.

The affected real-world input was approximately 12.7 MiB. Its name, media title, user name, and local paths have intentionally been omitted.

## Expected behavior

Subtitle Edit should do one of the following quickly:

1. Reject a raw PGS stream with a clear message explaining that it lacks the standalone SUP `PG`/PTS/DTS wrapper and should be re-extracted without raw mode; or
2. Handle the format if raw PGS support is intended.

Regardless of file type, an unknown/binary file must not block the UI indefinitely or cause unbounded thread-pool growth.

## Dump evidence

The UI thread was captured here:

```text
Nikse.SubtitleEdit.Core.Common.UnknownFormatImporter
    .ImportSubtitleWithNoLineBreaksWithExtraSpaces(System.String)
Nikse.SubtitleEdit.Core.Common.UnknownFormatImporter
    .AutoGuessImport(List<string>, System.String)
Nikse.SubtitleEdit.Features.Main.MainViewModel
    .SubtitleOpen(...)
Nikse.SubtitleEdit.Features.Main.MainViewModel
    .CommandFileOpen()
```

The expensive loop in `src/libse/Common/UnknownFormatImporter.cs` is:

```csharp
while (i < text.Length)
{
    var ch = text[i];
    if (char.IsNumber(ch))
    {
        var match = regex.Match(text.Substring(i));
        // ...
    }
    // ...
    i++;
}
```

For a large binary file containing many bytes that decode as digits, `Substring(i)` repeatedly copies almost the entire remaining string. This produces O(n^2) total copying/allocation before accounting for regex work.

Hundreds of other threads had the repeating stack:

```text
System.Threading.Tasks.Task.InternalWaitCore(...)
Avalonia.Threading.Dispatcher.Invoke(...)
Nikse.SubtitleEdit.Features.Main.MainViewModel.GetFastHash()
UndoRedoManager.CheckForChanges(System.Object)
System.Threading.TimerQueue.FireNextTimers()
```

`UndoRedoManager` schedules change detection every 250 ms. While the UI thread is blocked in the importer, callbacks continue entering `CheckForChanges()` and synchronously wait in `Dispatcher.UIThread.Invoke(GetFastHash)`. This explains the thread count and memory growth; it is a secondary amplification of the UI-thread parsing bug.

## Why the raw PGS file reaches the text importer

`FileUtil.IsBluRaySup()` only checks the first two bytes for `PG`:

```csharp
return buffer[0] == 0x50 && buffer[1] == 0x47;
```

That is correct for a standalone SUP file. However, when a `.sup` file fails this check, `MainViewModel.SubtitleOpen()` continues into generic text-format detection instead of rejecting binary input. Raw PGS begins directly with segment type/length bytes and therefore takes this fallback.

## Suggested fixes

- Do not send binary `.sup` input that failed SUP detection to the generic text importer. Detect/reject raw PGS segment streams, or at minimum reject binary-looking input with an actionable error.
- Remove the repeated substring allocation. For example, use a regex anchored at the requested start position (`\G`) with `Regex.Match(text, i)`, or parse the time code directly from the original span/string.
- Run expensive unknown-format detection off the UI thread and make it cancellable or size/time bounded.
- Prevent overlapping undo change-detection callbacks (for example with an interlocked in-progress guard), and pause change detection while a file is being opened.

[track12_.zip](https://github.com/user-attachments/files/30205245/track12_.zip)



Discussion:

Patch:
diff --git a/src/libse/BluRaySup/BluRaySupParser.cs b/src/libse/BluRaySup/BluRaySupParser.cs
index 8d019b7dea..1eec4726ce 100644
--- a/src/libse/BluRaySup/BluRaySupParser.cs
+++ b/src/libse/BluRaySup/BluRaySupParser.cs
@@ -485,6 +485,45 @@ public static List<PcsData> ParseBluRaySup(string fileName, StringBuilder log)
             }
         }
 
+        /// <summary>
+        /// Parses a raw PGS elementary stream: display segments without the "PG" + PTS/DTS
+        /// wrapper, exactly as they are stored inside a Matroska S_HDMV/PGS track (and as
+        /// dumped by raw-mode extraction). Such a stream carries no timestamps - they live
+        /// in the container - so all returned start/end times are zero and the caller must
+        /// assign timing (issue #12683).
+        /// </summary>
+        /// <param name="fileName">Raw PGS file name</param>
+        /// <param name="log">Parsing info is logged here</param>
+        /// <returns>List of BluRaySupPictures with untimed display sets</returns>
+        public static List<PcsData> ParseRawPgsSegmentStream(string fileName, StringBuilder log)
+        {
+            using (var fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+            {
+                var lastPalettes = new Dictionary<int, List<PaletteInfo>>();
+                var lastBitmapObjects = new Dictionary<int, List<OdsData>>();
+                return ParseBluRaySup(fs, log, true, lastPalettes, lastBitmapObjects);
+            }
+        }
+
+        /// <summary>
+        /// Assigns sequential placeholder timings to untimed display sets (see
+        /// <see cref="ParseRawPgsSegmentStream"/>): 3 seconds shown, 1 second gap.
+        /// The result is deliberately uniform so it is obvious the timing is synthetic
+        /// and must be adjusted manually after OCR.
+        /// </summary>
+        public static void SetPlaceholderTimings(List<PcsData> subtitles)
+        {
+            const long ticksPerMillisecond = 90; // PTS is a 90 kHz clock
+            const long durationMs = 3000;
+            const long gapMs = 1000;
+            for (var i = 0; i < subtitles.Count; i++)
+            {
+                var startMs = i * (durationMs + gapMs);
+                subtitles[i].StartTime = startMs * ticksPerMillisecond;
+                subtitles[i].EndTime = (startMs + durationMs) * ticksPerMillisecond;
+            }
+        }
+
         public static List<PcsData> ParseBluRaySupFromMatroska(MatroskaTrackInfo matroskaSubtitleInfo, MatroskaFile matroska)
         {
             var sub = matroska.GetSubtitle(matroskaSubtitleInfo.TrackNumber, null);
diff --git a/src/libse/Common/FileUtil.cs b/src/libse/Common/FileUtil.cs
index ce141d476a..42498cf7cc 100644
--- a/src/libse/Common/FileUtil.cs
+++ b/src/libse/Common/FileUtil.cs
@@ -381,6 +381,47 @@ public static bool IsBluRaySup(string fileName)
             }
         }
 
+        /// <summary>
+        /// Checks if a file is a raw PGS elementary stream, i.e. a sequence of PGS display
+        /// segments without the "PG" + PTS/DTS headers a standalone Blu-ray .sup file has.
+        /// Such files come from extracting an S_HDMV/PGS Matroska track in raw mode; the
+        /// timestamps live in the container, so the raw stream cannot be timed (issue #12683).
+        /// </summary>
+        /// <param name="fileName">The file to check.</param>
+        /// <returns>True if the file starts with a chain of valid raw PGS segments, otherwise false.</returns>
+        public static bool IsRawPgsSegmentStream(string fileName)
+        {
+            using (var fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+            {
+                var buffer = new byte[64 * 1024];
+                var bytesRead = fs.Read(buffer, 0, buffer.Length);
+
+                // Each raw segment is: type (1 byte) + payload size (2 bytes, big endian) + payload.
+                // Require a chain of valid segment types to avoid false positives on other binary
+                // files that happen to start with one plausible byte.
+                var validSegments = 0;
+                var position = 0;
+                while (position + 3 <= bytesRead)
+                {
+                    var segmentType = buffer[position];
+                    if (segmentType != 0x14 && // PDS (palette)
+                        segmentType != 0x15 && // ODS (object/bitmap)
+                        segmentType != 0x16 && // PCS (presentation composition)
+                        segmentType != 0x17 && // WDS (window)
+                        segmentType != 0x80)   // END of display set
+                    {
+                        return false;
+                    }
+
+                    var payloadSize = (buffer[position + 1] << 8) + buffer[position + 2];
+                    position += 3 + payloadSize;
+                    validSegments++;
+                }
+
+                return validSegments >= 3;
+            }
+        }
+
         /// <summary>
         /// Determines if a file is a transport stream by inspecting its contents.
         /// </summary>
diff --git a/src/libse/Common/UnknownFormatImporter.cs b/src/libse/Common/UnknownFormatImporter.cs
index 6ef806ba9f..19b1e0c2fd 100644
--- a/src/libse/Common/UnknownFormatImporter.cs
+++ b/src/libse/Common/UnknownFormatImporter.cs
@@ -976,7 +976,10 @@ private static TimeCode DecodeTime(string[] parts)
 
         private static Subtitle ImportSubtitleWithNoLineBreaks(string text)
         {
-            var regex = new Regex(@"^\d+ \d+:\d+:\d+[.,:;]\d+ --> \d+:\d+:\d+[.,:;]\d+\b", RegexOptions.Compiled); // e.g.: 1 00:00:01.502 --> 00:00:03.604
+            // \G anchors at the startat position passed to Match(text, i) below - matching
+            // in place instead of allocating text.Substring(i) per digit, which made this
+            // loop O(n²) on large inputs with many numeric characters (issue #12683).
+            var regex = new Regex(@"\G\d+ \d+:\d+:\d+[.,:;]\d+ --> \d+:\d+:\d+[.,:;]\d+\b", RegexOptions.Compiled); // e.g.: 1 00:00:01.502 --> 00:00:03.604
             var subtitle = new Subtitle();
             int i = 0;
             var sb = new StringBuilder();
@@ -986,7 +989,7 @@ private static Subtitle ImportSubtitleWithNoLineBreaks(string text)
                 var ch = text[i];
                 if (char.IsNumber(ch))
                 {
-                    var match = regex.Match(text.Substring(i));
+                    var match = regex.Match(text, i);
                     if (match.Success)
                     {
                         if (p != null)
@@ -1008,6 +1011,12 @@ private static Subtitle ImportSubtitleWithNoLineBreaks(string text)
                             continue;
                         }
                     }
+
+                    if (char.IsDigit(ch))
+                    {
+                        AppendDigitRun(text, sb, ref i);
+                        continue;
+                    }
                 }
                 sb.Append(ch);
                 i++;
@@ -1021,9 +1030,28 @@ private static Subtitle ImportSubtitleWithNoLineBreaks(string text)
             return subtitle;
         }
 
+        /// <summary>
+        /// Appends the run of decimal digits starting at <paramref name="i"/> and advances past it.
+        /// The time code regexes' leading \d+ must consume digits up to the first non-digit no
+        /// matter where in the run the match attempt starts, so a failed attempt at the run's
+        /// first digit fails at every later digit too - retrying there just made digit-heavy
+        /// binary input quadratic (issue #12683).
+        /// </summary>
+        private static void AppendDigitRun(string text, StringBuilder sb, ref int i)
+        {
+            do
+            {
+                sb.Append(text[i]);
+                i++;
+            }
+            while (i < text.Length && char.IsDigit(text[i]));
+        }
+
         private static Subtitle ImportSubtitleWithNoLineBreaksWithExtraSpaces(string text)
         {
-            var regex = new Regex(@"^(\d+: *)?\d+ *: *\d+[.,:;] *\d+ *-{0,3}> *(\d+: *)?\d+ *: *\d+[.,:;] *\d+\b", RegexOptions.Compiled); // e.g.: 1 00:00:01.502 --> 00:00:03.604
+            // \G anchors at the startat position passed to Match(text, i) below - see
+            // ImportSubtitleWithNoLineBreaks for why Substring(i) is not used here.
+            var regex = new Regex(@"\G(\d+: *)?\d+ *: *\d+[.,:;] *\d+ *-{0,3}> *(\d+: *)?\d+ *: *\d+[.,:;] *\d+\b", RegexOptions.Compiled); // e.g.: 1 00:00:01.502 --> 00:00:03.604
             var subtitle = new Subtitle();
             int i = 0;
             var sb = new StringBuilder();
@@ -1033,7 +1061,7 @@ private static Subtitle ImportSubtitleWithNoLineBreaksWithExtraSpaces(string tex
                 var ch = text[i];
                 if (char.IsNumber(ch))
                 {
-                    var match = regex.Match(text.Substring(i));
+                    var match = regex.Match(text, i);
                     if (match.Success)
                     {
                         if (p != null)
@@ -1055,6 +1083,12 @@ private static Subtitle ImportSubtitleWithNoLineBreaksWithExtraSpaces(string tex
                             continue;
                         }
                     }
+
+                    if (char.IsDigit(ch))
+                    {
+                        AppendDigitRun(text, sb, ref i);
+                        continue;
+                    }
                 }
                 sb.Append(ch);
                 i++;
diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs
index f088f41467..b265d3090b 100644
--- a/src/ui/Features/Main/MainViewModel.cs
+++ b/src/ui/Features/Main/MainViewModel.cs
@@ -15563,6 +15563,52 @@ await MessageBox.Show(Window!,
                 }
             }
 
+            // A .sup that failed IsBluRaySup may be a raw PGS elementary stream (Matroska track
+            // extracted in raw mode - no "PG"/PTS/DTS headers). The images decode fine, but the
+            // timestamps live in the container and are gone, so offer OCR with placeholder
+            // timings instead of letting megabytes of binary fall through to the generic text
+            // importer (issue #12683).
+            if ((ext == ".sup" || ext == ".pgs") && FileUtil.IsRawPgsSegmentStream(fileName))
+            {
+                var rawPgsAnswer = await MessageBox.Show(Window!,
+                    Se.Language.General.Error,
+                    Se.Language.Main.ErrorLoadRawPgsPrompt,
+                    MessageBoxButtons.YesNo,
+                    MessageBoxIcon.Question);
+                if (rawPgsAnswer != MessageBoxResult.Yes)
+                {
+                    return;
+                }
+
+                var rawPgsLog = new StringBuilder();
+                var rawPgsSubtitles = BluRaySupParser.ParseRawPgsSegmentStream(fileName, rawPgsLog);
+                if (rawPgsSubtitles.Count == 0)
+                {
+                    await MessageBox.Show(Window!,
+                        Se.Language.General.Error,
+                        Se.Language.General.NoSubtitlesFound,
+                        MessageBoxButtons.OK,
+                        MessageBoxIcon.Error);
+                    return;
+                }
+
+                BluRaySupParser.SetPlaceholderTimings(rawPgsSubtitles);
+                Dispatcher.UIThread.Post(async () =>
+                {
+                    var result = await ShowDialogAsync<OcrWindow, OcrViewModel>(vm => { vm.Initialize(rawPgsSubtitles, fileName); });
+
+                    if (result.OkPressed)
+                    {
+                        VideoCloseFile();
+                        _subtitleFileName = Utilities.GetPathAndFileNameWithoutExtension(fileName);
+                        _converted = true;
+                        ReplaceSubtitles(result.OcredSubtitle);
+                        SelectAndScrollToRow(0);
+                    }
+                });
+                return;
+            }
+
             if ((ext == ".mp4" || ext == ".m4v" || ext == ".3gp" || ext == ".mov" || ext == ".cmaf" || ext == ".m4a" || ext == ".m4b") &&
                 fileSize > 2000 || ext == ".m4s")
             {
diff --git a/src/ui/Logic/Config/Language/Main/LanguageMain.cs b/src/ui/Logic/Config/Language/Main/LanguageMain.cs
index b969e59b83..c3546a2227 100644
--- a/src/ui/Logic/Config/Language/Main/LanguageMain.cs
+++ b/src/ui/Logic/Config/Language/Main/LanguageMain.cs
@@ -24,6 +24,7 @@ public class LanguageMain
     public string ErrorLoadJpg { get; set; }
     public string ErrorLoadPng { get; set; }
     public string ErrorLoadRar { get; set; }
+    public string ErrorLoadRawPgsPrompt { get; set; }
     public string ErrorLoadSrr { get; set; }
     public string ErrorLoadTorrent { get; set; }
     public string ErrorLoadZip { get; set; }
@@ -153,6 +154,7 @@ public LanguageMain()
         ErrorLoadJpg = "This file seems to be a JPG image file.\n\nSubtitle Edit cannot open image files.";
         ErrorLoadPng = "This file seems to be a PNG image file.\n\nSubtitle Edit cannot open image files.";
         ErrorLoadRar = "This file seems to be a compressed 7-Zip file.\n\nSubtitle Edit cannot open compressed files.";
+        ErrorLoadRawPgsPrompt = "This file seems to be a raw PGS stream without the Blu-ray SUP \"PG\" headers, so it contains no timestamps.\n\nThis usually happens when a Matroska PGS track is extracted in \"raw\" mode. For correct timings, re-extract the track in normal track mode (e.g. \"mkvextract tracks\" without --raw), or open the original Matroska file directly in Subtitle Edit.\n\nDo you want to import the subtitle images anyway, with auto-generated placeholder timings that must be fixed manually?";
         ErrorLoadSrr = "This file seems to be a ReScene SRR file.\n\nSubtitle Edit cannot open SRR files.";
         ErrorLoadTorrent = "This file seems to be a BitTorrent file.\n\nSubtitle Edit cannot open torrent files.";
         ErrorLoadZip = "This file seems to be a compressed ZIP file.\n\nSubtitle Edit cannot open compressed files.";
diff --git a/src/ui/Logic/UndoRedo/UndoRedoManager.cs b/src/ui/Logic/UndoRedo/UndoRedoManager.cs
index efd95c403c..6d5ec1afd8 100644
--- a/src/ui/Logic/UndoRedo/UndoRedoManager.cs
+++ b/src/ui/Logic/UndoRedo/UndoRedoManager.cs
@@ -27,6 +27,13 @@ public sealed class UndoRedoManager : IUndoRedoManager
     // threads (plain int reads aren't guaranteed visible on weak memory
     // architectures).
     private int _disposed;
+    // Reentrancy gate for CheckForChanges. The timer fires every 250ms on the thread
+    // pool regardless of whether the previous tick finished; when the UI thread is
+    // busy for a long stretch (e.g. parsing a large file), each tick would otherwise
+    // pile up blocked in the client's dispatcher marshaling and force the pool to
+    // inject hundreds of threads — issue #12683. Interlocked so overlapping ticks
+    // return immediately instead of queueing.
+    private int _checkForChangesInProgress;
     // Narrowed from 1s → 250ms to reduce the window in which two distinct user
     // actions (e.g. text edit + waveform drag) get bundled into a single undo
     // entry — issue #11280. CheckForChanges is gated by IsTyping() and
@@ -258,6 +265,12 @@ public void CheckForChanges(object? state)
             return;
         }
 
+        // See the field comment: only one tick may run at a time.
+        if (Interlocked.CompareExchange(ref _checkForChangesInProgress, 1, 0) != 0)
+        {
+            return;
+        }
+
         try
         {
             // Both GetFastHash and MakeUndoRedoObject enumerate the subtitle
@@ -307,6 +320,10 @@ public void CheckForChanges(object? state)
             // Log so they're at least diagnosable.
             Se.LogError(ex, "UndoRedoManager.CheckForChanges");
         }
+        finally
+        {
+            Volatile.Write(ref _checkForChangesInProgress, 0);
+        }
     }
 
     // Hash on UndoRedoItem and the value returned by GetFastHash are both int;


Test patch:
diff --git a/tests/UI/Logic/UndoRedo/UndoRedoManagerTests.cs b/tests/UI/Logic/UndoRedo/UndoRedoManagerTests.cs
index 9e6b5a3668..af973877ac 100644
--- a/tests/UI/Logic/UndoRedo/UndoRedoManagerTests.cs
+++ b/tests/UI/Logic/UndoRedo/UndoRedoManagerTests.cs
@@ -22,6 +22,24 @@ public UndoRedoItem MakeUndoRedoObject(string description) =>
             MakeItem(description, Hash, Subtitles);
     }
 
+    private sealed class BlockingHashClient : IUndoRedoClient
+    {
+        public ManualResetEventSlim HashEntered { get; } = new();
+        public ManualResetEventSlim ReleaseHash { get; } = new();
+        public int HashCalls;
+
+        public int GetFastHash()
+        {
+            Interlocked.Increment(ref HashCalls);
+            HashEntered.Set();
+            ReleaseHash.Wait(TimeSpan.FromSeconds(10));
+            return 1;
+        }
+
+        public bool IsTyping() => false;
+        public UndoRedoItem MakeUndoRedoObject(string description) => MakeItem(description, 1);
+    }
+
     // -----------------------------------------------------------------------
     // Helpers
     // -----------------------------------------------------------------------
@@ -483,6 +501,29 @@ public void CheckForChanges_DoesNotAddEntry_WhenIsTyping()
         Assert.Equal(0, manager.UndoCount);
     }
 
+    [Fact]
+    public void CheckForChanges_OverlappingTick_ReturnsWithoutCallingClient()
+    {
+        // Regression for issue #12683: the 250ms timer keeps firing while a previous
+        // tick is still blocked inside the client (marshaling to a busy UI thread);
+        // each overlapping tick stacked another blocked thread-pool thread — the
+        // reported dump had 492 threads. Overlapping ticks must bail immediately.
+        var client = new BlockingHashClient();
+        var manager = new UndoRedoManager();
+        manager.SetupChangeDetection(client, TimeSpan.FromHours(1));
+        manager.StartChangeDetection();
+
+        var firstTick = Task.Run(() => manager.CheckForChanges(null));
+        Assert.True(client.HashEntered.Wait(TimeSpan.FromSeconds(10)));
+
+        manager.CheckForChanges(null); // overlapping tick while the first is blocked
+
+        Assert.Equal(1, Volatile.Read(ref client.HashCalls));
+
+        client.ReleaseHash.Set();
+        Assert.True(firstTick.Wait(TimeSpan.FromSeconds(10)));
+    }
+
     // -----------------------------------------------------------------------
     // Reset()
     // -----------------------------------------------------------------------
diff --git a/tests/libse/BluRaySup/BluRaySupParserRawPgsTest.cs b/tests/libse/BluRaySup/BluRaySupParserRawPgsTest.cs
new file mode 100644
index 0000000000..8a25f0328c
--- /dev/null
+++ b/tests/libse/BluRaySup/BluRaySupParserRawPgsTest.cs
@@ -0,0 +1,128 @@
+using Nikse.SubtitleEdit.Core.BluRaySup;
+using Nikse.SubtitleEdit.Core.Common;
+using SkiaSharp;
+using System.Text;
+
+namespace LibSETests.BluRaySup;
+
+public class BluRaySupParserRawPgsTest
+{
+    private static byte[] CreateSupContent(int count)
+    {
+        var sup = new List<byte>();
+        for (var i = 0; i < count; i++)
+        {
+            using var bitmap = new SKBitmap(64, 32);
+            using (var canvas = new SKCanvas(bitmap))
+            {
+                canvas.Clear(SKColors.White);
+                canvas.DrawRect(new SKRect(4, 4, 60, 28), new SKPaint { Color = SKColors.Yellow });
+            }
+
+            var pic = new BluRaySupPicture
+            {
+                StartTime = 1000 + i * 4000,
+                EndTime = 3000 + i * 4000,
+                Width = 720,
+                Height = 480,
+                CompositionNumber = i * 2,
+            };
+            sup.AddRange(BluRaySupPicture.CreateSupFrame(pic, bitmap, SKColors.White, 25, 10, 10, BluRayContentAlignment.BottomCenter));
+        }
+
+        return sup.ToArray();
+    }
+
+    // A standalone SUP segment is "PG" + PTS(4) + DTS(4) + type(1) + size(2) + payload;
+    // the raw elementary form (Matroska raw-mode extraction, issue #12683) is just
+    // type + size + payload.
+    private static byte[] StripPgHeaders(byte[] sup)
+    {
+        var raw = new List<byte>();
+        var position = 0;
+        while (position + 13 <= sup.Length)
+        {
+            Assert.Equal(0x50, sup[position]);
+            Assert.Equal(0x47, sup[position + 1]);
+            var size = (sup[position + 11] << 8) + sup[position + 12];
+            raw.AddRange(sup.Skip(position + 10).Take(3 + size));
+            position += 13 + size;
+        }
+
+        Assert.Equal(sup.Length, position); // segments must chain exactly to the end
+        return raw.ToArray();
+    }
+
+    private static void WithTempFile(byte[] content, Action<string> assert)
+    {
+        var path = Path.GetTempFileName();
+        try
+        {
+            File.WriteAllBytes(path, content);
+            assert(path);
+        }
+        finally
+        {
+            File.Delete(path);
+        }
+    }
+
+    [Fact]
+    public void RawPgsStreamRoundTripsThroughSnifferAndParser()
+    {
+        var sup = CreateSupContent(2);
+        var raw = StripPgHeaders(sup);
+
+        // The proper SUP is detected as SUP, not as raw PGS - and vice versa.
+        WithTempFile(sup, path =>
+        {
+            Assert.True(FileUtil.IsBluRaySup(path));
+            Assert.False(FileUtil.IsRawPgsSegmentStream(path));
+        });
+
+        WithTempFile(raw, path =>
+        {
+            Assert.False(FileUtil.IsBluRaySup(path));
+            Assert.True(FileUtil.IsRawPgsSegmentStream(path));
+
+            var pcsDataList = BluRaySupParser.ParseRawPgsSegmentStream(path, new StringBuilder());
+            var withImages = pcsDataList.Where(p => p.PcsObjects.Count > 0).ToList();
+            Assert.Equal(2, withImages.Count);
+
+            // Raw streams carry no PTS, so everything is untimed until the caller
+            // assigns placeholder timings.
+            Assert.All(pcsDataList, p => Assert.Equal(0, p.StartTime));
+
+            foreach (var pcsData in withImages)
+            {
+                using var decoded = pcsData.GetBitmap();
+                Assert.Equal(64, decoded.Width);
+                Assert.Equal(32, decoded.Height);
+            }
+        });
+    }
+
+    [Fact]
+    public void SetPlaceholderTimingsAssignsSequentialNonOverlappingTimes()
+    {
+        var sup = CreateSupContent(3);
+        var raw = StripPgHeaders(sup);
+
+        WithTempFile(raw, path =>
+        {
+            var pcsDataList = BluRaySupParser.ParseRawPgsSegmentStream(path, new StringBuilder());
+            Assert.True(pcsDataList.Count >= 3);
+
+            BluRaySupParser.SetPlaceholderTimings(pcsDataList);
+
+            Assert.Equal(0, pcsDataList[0].StartTimeCode.TotalMilliseconds, 3);
+            Assert.Equal(3000, pcsDataList[0].EndTimeCode.TotalMilliseconds, 3);
+            Assert.Equal(4000, pcsDataList[1].StartTimeCode.TotalMilliseconds, 3);
+            for (var i = 1; i < pcsDataList.Count; i++)
+            {
+                Assert.True(pcsDataList[i].StartTime > pcsDataList[i - 1].EndTime,
+                    $"Placeholder timings must not overlap (display set {i})");
+            }
+        });
+    }
+}
diff --git a/tests/libse/Common/FileUtilTest.cs b/tests/libse/Common/FileUtilTest.cs
index adbf65791d..0ad76a8e7e 100644
--- a/tests/libse/Common/FileUtilTest.cs
+++ b/tests/libse/Common/FileUtilTest.cs
@@ -5,6 +5,75 @@ namespace LibSETests.Common;
 
 public class FileUtilTest
 {
+    private static byte[] MakeRawPgsSegment(byte segmentType, int payloadSize)
+    {
+        var segment = new byte[3 + payloadSize];
+        segment[0] = segmentType;
+        segment[1] = (byte)(payloadSize >> 8);
+        segment[2] = (byte)(payloadSize & 0xff);
+        return segment;
+    }
+
+    private static void WithTempFile(byte[] content, Action<string> assert)
+    {
+        var path = Path.GetTempFileName();
+        try
+        {
+            File.WriteAllBytes(path, content);
+            assert(path);
+        }
+        finally
+        {
+            File.Delete(path);
+        }
+    }
+
+    [Fact]
+    public void IsRawPgsSegmentStreamDetectsRawSegmentChain()
+    {
+        // PCS + WDS + PDS + ODS + END - the segment sequence of one display set as it
+        // appears in a Matroska S_HDMV/PGS track extracted in raw mode (issue #12683).
+        var bytes = MakeRawPgsSegment(0x16, 19)
+            .Concat(MakeRawPgsSegment(0x17, 10))
+            .Concat(MakeRawPgsSegment(0x14, 50))
+            .Concat(MakeRawPgsSegment(0x15, 200))
+            .Concat(MakeRawPgsSegment(0x80, 0))
+            .ToArray();
+
+        WithTempFile(bytes, path => Assert.True(FileUtil.IsRawPgsSegmentStream(path)));
+    }
+
+    [Fact]
+    public void IsRawPgsSegmentStreamRejectsBluRaySup()
+    {
+        // A proper standalone .sup starts with the "PG" magic, not a bare segment type.
+        var bytes = new byte[] { 0x50, 0x47, 0, 0, 0, 0, 0, 0, 0, 0, 0x16, 0, 19 }
+            .Concat(new byte[19])
+            .ToArray();
+
+        WithTempFile(bytes, path => Assert.False(FileUtil.IsRawPgsSegmentStream(path)));
+    }
+
+    [Fact]
+    public void IsRawPgsSegmentStreamRejectsText()
+    {
+        var bytes = Encoding.UTF8.GetBytes("1\n00:00:01,000 --> 00:00:02,000\nHello\n");
+
+        WithTempFile(bytes, path => Assert.False(FileUtil.IsRawPgsSegmentStream(path)));
+    }
+
+    [Fact]
+    public void IsRawPgsSegmentStreamRejectsSingleSegmentFollowedByGarbage()
+    {
+        // One plausible segment header followed by non-PGS bytes must not count -
+        // requiring a chain of valid segments keeps false positives out.
+        var bytes = new byte[] { 0x16, 0x00, 0x13 }
+            .Concat(Enumerable.Repeat((byte)'0', 1000))
+            .ToArray();
+
+        WithTempFile(bytes, path => Assert.False(FileUtil.IsRawPgsSegmentStream(path)));
+    }
+
     // The UTF-16LE BOM is FF FE; ReadAllLinesShared used to test FE FF (the UTF-16BE BOM),
     // so the BOM was never stripped and U+FEFF leaked into the first line.
     [Fact]
diff --git a/tests/libse/Common/UnknownFormatImporterTest.cs b/tests/libse/Common/UnknownFormatImporterTest.cs
new file mode 100644
index 0000000000..9206a673cb
--- /dev/null
+++ b/tests/libse/Common/UnknownFormatImporterTest.cs
@@ -0,0 +1,67 @@
+using Nikse.SubtitleEdit.Core.Common;
+using System.Diagnostics;
+
+namespace LibSETests.Common;
+
+public class UnknownFormatImporterTest
+{
+    [Fact]
+    public void AutoGuessImportParsesSubtitleWithNoLineBreaks()
+    {
+        var importer = new UnknownFormatImporter();
+        var text = "1 00:00:01.502 --> 00:00:03.604 Hello there my good friend. " +
+                   "2 00:00:04.000 --> 00:00:06.000 How are you doing today? " +
+                   "3 00:00:07.000 --> 00:00:09.000 I am doing fine.";
+
+        var subtitle = importer.AutoGuessImport([text], "test.txt");
+
+        Assert.Equal(3, subtitle.Paragraphs.Count);
+        Assert.Equal(1502, subtitle.Paragraphs[0].StartTime.TotalMilliseconds, 3);
+        Assert.Equal(3604, subtitle.Paragraphs[0].EndTime.TotalMilliseconds, 3);
+        // The winning importer does not consume the "2"/"3" sequence numbers, so they
+        // trail the preceding paragraph's text - long-standing behavior, asserted here
+        // so the O(n²)→O(n) rewrite of the no-line-break importers is provably neutral.
+        Assert.Equal("Hello there my good friend. 2", subtitle.Paragraphs[0].Text);
+        Assert.Equal("How are you doing today? 3", subtitle.Paragraphs[1].Text);
+        Assert.Equal("I am doing fine.", subtitle.Paragraphs[2].Text);
+    }
+
+    [Fact]
+    public void AutoGuessImportParsesSubtitleWithNoLineBreaksWithExtraSpaces()
+    {
+        var importer = new UnknownFormatImporter();
+        var text = "00: 00 : 01, 502 --> 00: 00 : 03, 604 Hello there my good friend. " +
+                   "00: 00 : 04, 000 --> 00: 00 : 06, 000 How are you doing today? " +
+                   "00: 00 : 07, 000 --> 00: 00 : 09, 000 I am doing fine.";
+
+        var subtitle = importer.AutoGuessImport([text], "test.txt");
+
+        Assert.Equal(3, subtitle.Paragraphs.Count);
+        Assert.Equal(1502, subtitle.Paragraphs[0].StartTime.TotalMilliseconds, 3);
+        Assert.Equal(3604, subtitle.Paragraphs[0].EndTime.TotalMilliseconds, 3);
+        Assert.Equal("Hello there my good friend.", subtitle.Paragraphs[0].Text);
+        Assert.Equal("How are you doing today?", subtitle.Paragraphs[1].Text);
+        Assert.Equal("I am doing fine.", subtitle.Paragraphs[2].Text);
+    }
+
+    [Fact]
+    public void AutoGuessImportIsFastOnDigitHeavyBinaryInput()
+    {
+        // Regression for issue #12683: a raw PGS .sup decoded as text reached the
+        // no-line-break importers, whose per-digit Substring(i) + greedy \d+ retry
+        // at every position of a digit run made megabytes of digit-heavy binary
+        // quadratic - the UI froze for hours. Linear behavior finishes in well
+        // under a second; the generous bound only guards against the quadratic case
+        // (2M digits was on the order of 10^12 operations before the fix).
+        var importer = new UnknownFormatImporter();
+        var text = new string('0', 2_000_000) + " --> ";
+
+        var stopwatch = Stopwatch.StartNew();
+        var subtitle = importer.AutoGuessImport([text], "test.sup");
+        stopwatch.Stop();
+
+        Assert.Empty(subtitle.Paragraphs);
+        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(20),
+            $"AutoGuessImport took {stopwatch.Elapsed} on digit-heavy input - quadratic behavior is back?");
+    }
+}