From 7b97f301c116e64875862bd9e2abd452153f282f Mon Sep 17 00:00:00 2001 From: Ralph Junglas Date: Tue, 6 Oct 2026 14:46:39 +0200 Subject: [PATCH 001/110] Fix EBU STL import frame-rate race --- src/libse/SubtitleFormats/Ebu.cs | 17 +- .../EbuReadFrameRateTest.cs | 89 + .../EbuSnapshotRegression.csproj | 1 + tests/EbuSnapshotRegression/README.md | 9 + .../TestLibSE/EbuTestReader.generated.cs | 2416 +++++++++++++++++ .../TestLibSE/TestLibSE.csproj | 1 + 6 files changed, 2524 insertions(+), 9 deletions(-) create mode 100644 tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs create mode 100644 tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj create mode 100644 tests/EbuSnapshotRegression/README.md create mode 100644 tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs create mode 100644 tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj diff --git a/src/libse/SubtitleFormats/Ebu.cs b/src/libse/SubtitleFormats/Ebu.cs index 7b9f7109692..f5364460b80 100644 --- a/src/libse/SubtitleFormats/Ebu.cs +++ b/src/libse/SubtitleFormats/Ebu.cs @@ -1386,13 +1386,12 @@ public void LoadSubtitle(Subtitle subtitle, byte[] buffer) Paragraph last = null; byte lastExtensionBlockNumber = 0xff; JustificationCodes = new List(); - Configuration.Settings.General.CurrentFrameRate = header.FrameRate; - if (OverrideReadFrameRate > 20) - { - Configuration.Settings.General.CurrentFrameRate = OverrideReadFrameRate; - } + // Resolve this import once, independently of concurrent video-frame-rate updates. + var overrideReadFrameRate = OverrideReadFrameRate; + var readFrameRate = overrideReadFrameRate > 20 ? overrideReadFrameRate : header.FrameRate; + Configuration.Settings.General.CurrentFrameRate = readFrameRate; - foreach (var tti in ReadTextAndTiming(buffer, header)) + foreach (var tti in ReadTextAndTiming(buffer, header, readFrameRate)) { if (tti.ExtensionBlockNumber != 0xfe) // FEh : Reserved for User Data { @@ -1872,7 +1871,7 @@ private static string GetCharacter(out bool skipNext, EbuGeneralSubtitleInformat /// Read Text and Timing Information (TTI) block. /// Each Text and Timing Information (TTI) block consists of 128 bytes. /// - private IEnumerable ReadTextAndTiming(byte[] buffer, EbuGeneralSubtitleInformation header) + private IEnumerable ReadTextAndTiming(byte[] buffer, EbuGeneralSubtitleInformation header, double readFrameRate) { const int startOfTextAndTimingBlock = 1024; const int ttiSize = 128; @@ -1901,11 +1900,11 @@ private IEnumerable ReadTextAndTiming(byte[] buffer, E TimeCodeInHours = buffer[index + 5 + 0], TimeCodeInMinutes = buffer[index + 5 + 1], TimeCodeInSeconds = buffer[index + 5 + 2], - TimeCodeInMilliseconds = FramesToMillisecondsMax999(buffer[index + 5 + 3]), + TimeCodeInMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 5 + 3], readFrameRate), 999), TimeCodeOutHours = buffer[index + 9 + 0], TimeCodeOutMinutes = buffer[index + 9 + 1], TimeCodeOutSeconds = buffer[index + 9 + 2], - TimeCodeOutMilliseconds = FramesToMillisecondsMax999(buffer[index + 9 + 3]), + TimeCodeOutMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 9 + 3], readFrameRate), 999), VerticalPosition = buffer[index + 13], JustificationCode = buffer[index + 14], CommentFlag = buffer[index + 15] diff --git a/tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs b/tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs new file mode 100644 index 00000000000..cceb12b78df --- /dev/null +++ b/tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs @@ -0,0 +1,89 @@ +using System.Reflection; +using System.Text; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; + +namespace LibSETests.SubtitleFormats; + +[CollectionDefinition("EBU import global state", DisableParallelization = true)] +public class EbuImportGlobalStateCollection { } + +[Collection("EBU import global state")] +public class EbuReadFrameRateTest +{ + private static byte[] Fixture(string diskCode, int count = 151, byte frame = 5) + { + var header = new Ebu.EbuGeneralSubtitleInformation { DiskFormatCode = diskCode, DisplayStandardCode = "0" }; + var bytes = new byte[1024 + 128 * count]; + Encoding.ASCII.GetBytes(header.ToString()).CopyTo(bytes, 0); + for (var i = 0; i < count; i++) + { + var n = 1024 + i * 128; + bytes[n + 1] = (byte)(i + 1); bytes[n + 2] = (byte)((i + 1) >> 8); + bytes[n + 3] = 0xff; bytes[n + 5] = 10; + bytes[n + 7] = (byte)(i % 50); bytes[n + 8] = frame; + bytes[n + 9] = 10; bytes[n + 11] = (byte)(i % 50 + 2); bytes[n + 12] = 17; + bytes[n + 13] = 20; bytes[n + 14] = 2; + Array.Fill(bytes, (byte)0x8f, n + 16, 112); bytes[n + 16] = (byte)'A'; + } + return bytes; + } + + private static Subtitle Load(byte[] bytes, Action? hook = null) + { + var reader = new Ebu(); + typeof(Ebu).GetProperty("BeforeReadTti", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(reader, hook); + var subtitle = new Subtitle(); reader.LoadSubtitle(subtitle, bytes); return subtitle; + } + + private static void WithState(Action body) + { + var fps = Configuration.Settings.General.CurrentFrameRate; var over = Ebu.OverrideReadFrameRate; + try { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Ebu.OverrideReadFrameRate = 0; body(); } + finally { Configuration.Settings.General.CurrentFrameRate = fps; Ebu.OverrideReadFrameRate = over; } + } + + [Theory] + [InlineData(0)][InlineData(100)][InlineData(-1)] + public void GlobalSwitchCannotChangeTimingOrMetadata(int trigger) => WithState(() => + { + var bytes = Fixture("STL25.01"); + Configuration.Settings.General.CurrentFrameRate = 25; + var control = Load(bytes); + var raced = Load(bytes, i => { if (i == trigger || trigger == -1 && i % 25 == 0) + Configuration.Settings.General.CurrentFrameRate = trigger == -1 ? new[] { 23.976, 30.0, 24, 25 }[(i / 25) % 4] : 24000.0 / 1001; }); + Assert.Equal(control.Header, raced.Header); Assert.Equal(control.Paragraphs.Count, raced.Paragraphs.Count); + for (var i = 0; i < control.Paragraphs.Count; i++) + { + var a = control.Paragraphs[i]; var b = raced.Paragraphs[i]; + Assert.Equal(a.StartTime.TotalMilliseconds, b.StartTime.TotalMilliseconds); + Assert.Equal(a.EndTime.TotalMilliseconds, b.EndTime.TotalMilliseconds); + Assert.Equal(a.Text, b.Text); Assert.Equal(a.MarginV, b.MarginV); + Assert.Equal(0, b.StartTime.TotalMilliseconds % 40); Assert.Equal(0, b.EndTime.TotalMilliseconds % 40); + } + }); + + [Theory] + [InlineData("STL24.01", 0, 24)][InlineData("STL25.01", 0, 25)][InlineData("STL30.01", 0, 30)] + [InlineData("STL23.01", 0, 23)][InlineData("STL29.01", 0, 29)] + [InlineData("STL25.01", 23.976, 23.976)][InlineData("STL25.01", 29.97, 29.97)] + [InlineData("STL25.01", 24, 24)][InlineData("STL25.01", 20, 25)] + public void ExistingHeaderAndOverrideSemanticsStayStable(string code, double over, double expected) => WithState(() => + { + Ebu.OverrideReadFrameRate = over; + var bytes = Fixture(code); + var control = Load(bytes); var raced = Load(bytes, _ => Configuration.Settings.General.CurrentFrameRate = 50); + Assert.Equal(Math.Min(999, SubtitleFormat.FramesToMilliseconds(5, expected)), control.Paragraphs[0].StartTime.Milliseconds); + Assert.Equal(control.Header, raced.Header); + Assert.Equal(control.Paragraphs.Select(p => (p.StartTime.TotalMilliseconds, p.EndTime.TotalMilliseconds)), + raced.Paragraphs.Select(p => (p.StartTime.TotalMilliseconds, p.EndTime.TotalMilliseconds))); + Assert.Equal(over, Ebu.OverrideReadFrameRate); + }); + + [Fact] public void Control25AndMax999KeepExistingRounding() => WithState(() => + { + var normal = Load(Fixture("STL25.01")); + Assert.All(normal.Paragraphs, p => { Assert.Equal(0, p.StartTime.TotalMilliseconds % 40); Assert.Equal(0, p.EndTime.TotalMilliseconds % 40); }); + var capped = Load(Fixture("STL24.01", 1, 255)); Assert.Equal(999, capped.Paragraphs[0].StartTime.Milliseconds); + }); +} diff --git a/tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj b/tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj new file mode 100644 index 00000000000..1029d7c3707 --- /dev/null +++ b/tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj @@ -0,0 +1 @@ +net10.0enableenabletruefalse \ No newline at end of file diff --git a/tests/EbuSnapshotRegression/README.md b/tests/EbuSnapshotRegression/README.md new file mode 100644 index 00000000000..b78cb840509 --- /dev/null +++ b/tests/EbuSnapshotRegression/README.md @@ -0,0 +1,9 @@ +# EBU import frame-rate regression tests + +Run `dotnet test tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj`. + +The 13 synthetic cases build STL data in memory and deterministically update the global frame rate immediately before selected TTI blocks. They cover the 25-fps control, changes to 23.976 fps, repeated changes, header and override behavior, and the 999 ms cap. No external subtitle files are required. + +TestLibSE builds a separate assembly from the production LibSE sources, replacing Ebu.cs only in this test assembly with EbuTestReader.generated.cs. The replacement adds an internal BeforeReadTti callback and its invocation. The production project does not compile this hook. The copied reader must remain identical to production after removal of the test-only comment and those two hook lines, ignoring the UTF-8 BOM and trailing whitespace. Compare these files whenever Ebu.cs changes. + +Without the per-import snapshot, 12 of the 13 cases fail; with it all 13 pass. The original real-file validation is intentionally excluded from this portable project. diff --git a/tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs b/tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs new file mode 100644 index 00000000000..be2647b9fa9 --- /dev/null +++ b/tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs @@ -0,0 +1,2416 @@ +// Test-only copy; never compiled into the production LibSE project. +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Interfaces; +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Nikse.SubtitleEdit.Core.SubtitleFormats +{ + /// + /// EBU Subtitling data exchange format. + /// + public class Ebu : SubtitleFormat, IBinaryPersistableSubtitle + { + internal Action BeforeReadTti { get; set; } + public static double OverrideReadFrameRate { get; set; } + + private static readonly Regex FontTagsNoSpace1 = new Regex("[a-zA-z.!?][a-zA-Z-]", RegexOptions.Compiled); + private static readonly Regex FontTagsNoSpace2 = new Regex("[a-zA-z.!?][a-zA-Z-]", RegexOptions.Compiled); + + // "" - two teletext color codes in a row, e.g. a background + // color set right before the text color. Only the last one has any text, and SE has no + // background color for STL, so the empty tag is noise that shifts the text by a space. + private static readonly Regex EmptyFontTag = new Regex("", RegexOptions.Compiled); + + private static readonly Regex FontTagsStartSpace = new Regex("^ ", RegexOptions.Compiled); // " " + private static readonly Regex FontTagsNewLineSpace = new Regex("[\r\n]+ ", RegexOptions.Compiled); // "\r\n " + + private const string LanguageCodeChinese = "75"; + + private static readonly Dictionary SpecialAsciiCodes = new Dictionary + { + { 0xd3, "©" }, + { 0xd4, "™" }, + { 0xd5, "♪" }, + + { 0xe0, "Ω" }, + { 0xe1, "Æ" }, + { 0xe2, "Ð" }, + { 0xe3, "ª" }, + { 0xe4, "Ħ" }, + + { 0xe6, "IJ" }, + { 0xe7, "Ŀ" }, + { 0xe8, "Ł" }, + { 0xe9, "Ø" }, + { 0xea, "Œ" }, + { 0xeb, "º" }, + { 0xec, "Þ" }, + { 0xed, "Ŧ" }, + { 0xee, "Ŋ" }, + { 0xef, "ʼn" }, + + { 0xf0, "ĸ" }, + { 0xf1, "æ" }, + { 0xf2, "đ" }, + { 0xf3, "ð" }, + { 0xf4, "ħ" }, + { 0xf5, "ı" }, + { 0xf6, "ij" }, + { 0xf7, "ŀ" }, + { 0xf8, "ł" }, + { 0xf9, "ø" }, + { 0xfa, "œ" }, + { 0xfb, "ß" }, + { 0xfc, "þ" }, + { 0xfd, "ŧ" }, + { 0xfe, "ŋ" }, + }; + + /// + /// keyed by its (always single-character) value. The + /// writer used to ask ContainsValue and then LINQ-First over all ~33 entries for every + /// character it encoded - two linear scans per character. + /// + private static readonly Dictionary SpecialAsciiCodesByChar = BuildSpecialAsciiCodesByChar(); + + private static Dictionary BuildSpecialAsciiCodesByChar() + { + var result = new Dictionary(); + foreach (var kvp in SpecialAsciiCodes) + { + // First entry wins, like the First(...) it replaces. + if (kvp.Value.Length == 1 && !result.ContainsKey(kvp.Value[0])) + { + result.Add(kvp.Value[0], kvp.Key); + } + } + + return result; + } + + public interface IEbuUiHelper + { + void Initialize(EbuGeneralSubtitleInformation header, byte justificationCode, string fileName, Subtitle subtitle); + bool ShowDialogOk(); + byte JustificationCode { get; set; } + } + + public static IEbuUiHelper EbuUiHelper { get; set; } + + private static readonly Regex RegExprColor = new Regex(@"^[a-f0-9]{6}$", RegexOptions.Compiled); + + public List JustificationCodes = new List(); + + public EbuGeneralSubtitleInformation Header; + + /// + /// GSI block (1024 bytes) + /// + public class EbuGeneralSubtitleInformation + { + public string CodePageNumber { get; set; } // 0..2 + public string DiskFormatCode { get; set; } // 3..10 + public double FrameRateFromSaveDialog { get; set; } + public string DisplayStandardCode { get; set; } // 11 + public string CharacterCodeTableNumber { get; set; } // 12..13 + public string LanguageCode { get; set; } // 14..15 + public string OriginalProgrammeTitle { get; set; } // 16..47 + public string OriginalEpisodeTitle { get; set; } + public string TranslatedProgrammeTitle { get; set; } + public string TranslatedEpisodeTitle { get; set; } + public string TranslatorsName { get; set; } + public string TranslatorsContactDetails { get; set; } + public string SubtitleListReferenceCode { get; set; } + public string CreationDate { get; set; } + public string RevisionDate { get; set; } + public string RevisionNumber { get; set; } + public string TotalNumberOfTextAndTimingInformationBlocks { get; set; } + public string TotalNumberOfSubtitles { get; set; } + public string TotalNumberOfSubtitleGroups { get; set; } + public string MaximumNumberOfDisplayableCharactersInAnyTextRow { get; set; } + public string MaximumNumberOfDisplayableRows { get; set; } + public string TimeCodeStatus { get; set; } + public string TimeCodeStartOfProgramme { get; set; } + public string TimeCodeFirstInCue { get; set; } + public string TotalNumberOfDisks { get; set; } + public string DiskSequenceNumber { get; set; } + public string CountryOfOrigin { get; set; } + public string Publisher { get; set; } + public string EditorsName { get; set; } + public string EditorsContactDetails { get; set; } + public string SpareBytes { get; set; } + public string UserDefinedArea { get; set; } + + public double FrameRate + { + get + { + if (FrameRateFromSaveDialog > 20) + { + return FrameRateFromSaveDialog; + } + + if (DiskFormatCode.StartsWith("STL23", StringComparison.Ordinal)) + { + return 23.0; + } + + if (DiskFormatCode.StartsWith("STL24", StringComparison.Ordinal)) + { + return 24.0; + } + + if (DiskFormatCode.StartsWith("STL25", StringComparison.Ordinal)) + { + return 25.0; + } + + if (DiskFormatCode.StartsWith("STL29", StringComparison.Ordinal)) + { + return 29.0; + } + + if (DiskFormatCode.StartsWith("STL35", StringComparison.Ordinal)) + { + return 35.0; + } + + if (DiskFormatCode.StartsWith("STL48", StringComparison.Ordinal)) + { + return 48.0; + } + + if (DiskFormatCode.StartsWith("STL50", StringComparison.Ordinal)) + { + return 50.0; + } + + if (DiskFormatCode.StartsWith("STL60", StringComparison.Ordinal)) + { + return 60.0; + } + + return 30.0; // should be DiskFormatCode STL30.01 + } + } + + public EbuGeneralSubtitleInformation() + { + CodePageNumber = "437"; + DiskFormatCode = "STL25.01"; + DisplayStandardCode = "0"; // 0=Open subtitling + CharacterCodeTableNumber = "00"; + LanguageCode = "0A"; + OriginalProgrammeTitle = "No Title "; + OriginalEpisodeTitle = " "; + TranslatedProgrammeTitle = string.Empty.PadLeft(32, ' '); + TranslatedEpisodeTitle = string.Empty.PadLeft(32, ' '); + TranslatorsName = string.Empty.PadLeft(32, ' '); + TranslatorsContactDetails = string.Empty.PadLeft(32, ' '); + SubtitleListReferenceCode = "0 "; + CreationDate = "101021"; + RevisionDate = "101021"; + RevisionNumber = "01"; + TotalNumberOfTextAndTimingInformationBlocks = "00725"; + TotalNumberOfSubtitles = "00725"; + TotalNumberOfSubtitleGroups = "001"; + MaximumNumberOfDisplayableCharactersInAnyTextRow = "40"; + MaximumNumberOfDisplayableRows = "23"; + TimeCodeStatus = "1"; + TimeCodeStartOfProgramme = "00000000"; + TimeCodeFirstInCue = "00000001"; + TotalNumberOfDisks = "1"; + DiskSequenceNumber = "1"; + CountryOfOrigin = "USA"; + Publisher = string.Empty.PadLeft(32, ' '); + EditorsName = string.Empty.PadLeft(32, ' '); + EditorsContactDetails = string.Empty.PadLeft(32, ' '); + SpareBytes = string.Empty.PadLeft(75, ' '); + UserDefinedArea = string.Empty.PadLeft(576, ' '); + } + + public override string ToString() + { + var result = CodePageNumber + + DiskFormatCode + + DisplayStandardCode + + CharacterCodeTableNumber + + LanguageCode + + OriginalProgrammeTitle + + OriginalEpisodeTitle + + TranslatedProgrammeTitle + + TranslatedEpisodeTitle + + TranslatorsName + + TranslatorsContactDetails + + SubtitleListReferenceCode + + CreationDate + + RevisionDate + + RevisionNumber + + TotalNumberOfTextAndTimingInformationBlocks + + TotalNumberOfSubtitles + + TotalNumberOfSubtitleGroups + + MaximumNumberOfDisplayableCharactersInAnyTextRow + + MaximumNumberOfDisplayableRows + + TimeCodeStatus + + TimeCodeStartOfProgramme + + TimeCodeFirstInCue + + TotalNumberOfDisks + + DiskSequenceNumber + + CountryOfOrigin + + Publisher + + EditorsName + + EditorsContactDetails + + SpareBytes + + UserDefinedArea; + + if (result.Length == 1024) + { + return result; + } + + return "Length must be 1024 but is " + result.Length; + } + } + + /// + /// TTI block 128 bytes + /// + private class EbuTextTimingInformation + { + public byte SubtitleGroupNumber { get; set; } + public ushort SubtitleNumber { get; set; } + public byte ExtensionBlockNumber { get; set; } + public byte CumulativeStatus { get; set; } + public int TimeCodeInHours { get; set; } + public int TimeCodeInMinutes { get; set; } + public int TimeCodeInSeconds { get; set; } + public int TimeCodeInMilliseconds { get; set; } + public int TimeCodeOutHours { get; set; } + public int TimeCodeOutMinutes { get; set; } + public int TimeCodeOutSeconds { get; set; } + public int TimeCodeOutMilliseconds { get; set; } + public byte VerticalPosition { get; set; } + public byte JustificationCode { get; set; } + public byte CommentFlag { get; set; } + public string TextField { get; set; } + + public EbuTextTimingInformation() + { + SubtitleGroupNumber = 0; + ExtensionBlockNumber = 255; + CumulativeStatus = 0; + VerticalPosition = 0x16; + JustificationCode = 2; + CommentFlag = 0; + } + + /// + /// One extension block carrying the 112 text bytes starting at . + /// Callers emit as many as the overflow needs - a single block capped the whole + /// subtitle at 224 bytes and silently discarded the rest. + /// + public byte[] GetBytesExtra(EbuGeneralSubtitleInformation header, byte[] extraBytes, int offset) + { + var buffer = SaveHeader(header); + for (var i = 0; i < 112; i++) + { + var index = offset + i; + buffer[16 + i] = index < extraBytes.Length ? extraBytes[index] : (byte)0x8f; + } + + return buffer; + } + + public byte[] GetBytes(EbuGeneralSubtitleInformation header, MemoryStream extra) + { + var buffer = SaveHeader(header); + + var encoding = GetEncoding(header.CodePageNumber); + if (header.LanguageCode == LanguageCodeChinese) + { + var lines = HtmlUtil.RemoveHtmlTags(TextField, true).SplitToLines(); + var byteList = new List(); + encoding = Encoding.GetEncoding(1200); // 16-bit Unicode + for (var i = 0; i < lines.Count; i++) + { + var l = lines[i]; + if (i > 0) + { // new line + byteList.Add(0); + byteList.Add(138); + } + byteList.AddRange(encoding.GetBytes(l).ToArray()); + } + + for (var i = 0; i < 112; i++) + { + if (i < byteList.Count) + { + buffer[16 + i] = byteList[i]; + } + else + { + buffer[16 + i] = 0x8f; + } + } + + if (byteList.Count > 112) + { + extra.Write(byteList.ToArray(), 112, byteList.Count - 112); + } + + return buffer; + } + + if (header.CharacterCodeTableNumber == "00") + { + // 0xC1—0xCF combines characters - http://en.wikipedia.org/wiki/ISO/IEC_6937 + try + { + encoding = Encoding.GetEncoding(20269); + } + catch + { + encoding = Encoding.ASCII; + } + } + else if (header.CharacterCodeTableNumber == "01") // Latin/Cyrillic alphabet - from ISO 8859/5-1988 + { + encoding = Encoding.GetEncoding("ISO-8859-5"); + } + else if (header.CharacterCodeTableNumber == "02") // Latin/Arabic alphabet - from ISO 8859/6-1987 + { + encoding = Encoding.GetEncoding("ISO-8859-6"); + } + else if (header.CharacterCodeTableNumber == "03") // Latin/Greek alphabet - from ISO 8859/7-1987 + { + encoding = Encoding.GetEncoding("ISO-8859-7"); // or ISO-8859-1 ? + } + else if (header.CharacterCodeTableNumber == "04") // Latin/Hebrew alphabet - from ISO 8859/8-1988 + { + encoding = Encoding.GetEncoding("ISO-8859-8"); + } + + var textBytes = new List(); + if (header.DisplayStandardCode != "0") // 0=Open subtitling + { + if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox && Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) + { + textBytes.AddRange(new byte[] { 0x0d, 0x0b, 0x0b }); // d=double height, b=start box + } + else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox) + { + textBytes.AddRange(new byte[] { 0x0b, 0x0b }); // b=start box + } + else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) + { + textBytes.AddRange(new byte[] { 0x0d }); // d=double height + } + } + EncodeText(textBytes, TextField, encoding, header.DisplayStandardCode, header.CharacterCodeTableNumber); + + var bytes = textBytes.ToArray(); + + for (var i = 0; i < 112; i++) + { + if (i < bytes.Length) + { + buffer[16 + i] = bytes[i]; + } + else + { + buffer[16 + i] = 0x8f; + } + } + + if (bytes.Length > 112) + { + extra.Write(bytes, 112, bytes.Length - 112); + } + + return buffer; + } + + private byte[] SaveHeader(EbuGeneralSubtitleInformation header) + { + var buffer = new byte[128]; // Text and Timing Information (TTI) block consists of 128 bytes + + buffer[0] = SubtitleGroupNumber; + var temp = BitConverter.GetBytes(SubtitleNumber); + buffer[1] = temp[0]; + buffer[2] = temp[1]; + buffer[3] = ExtensionBlockNumber; + buffer[4] = CumulativeStatus; + + var frames = GetFrameFromMilliseconds(TimeCodeInMilliseconds, header.FrameRate, out var extraSeconds); + var tc = new TimeCode(TimeCodeInHours, TimeCodeInMinutes, TimeCodeInSeconds + extraSeconds, 0); + buffer[5] = (byte)tc.Hours; + buffer[6] = (byte)tc.Minutes; + buffer[7] = (byte)tc.Seconds; + buffer[8] = frames; + + frames = GetFrameFromMilliseconds(TimeCodeOutMilliseconds, header.FrameRate, out extraSeconds); + tc = new TimeCode(TimeCodeOutHours, TimeCodeOutMinutes, TimeCodeOutSeconds + extraSeconds, 0); + buffer[9] = (byte)tc.Hours; + buffer[10] = (byte)tc.Minutes; + buffer[11] = (byte)tc.Seconds; + buffer[12] = frames; + + buffer[13] = VerticalPosition; + buffer[14] = JustificationCode; + buffer[15] = CommentFlag; + return buffer; + } + + private static string FixItalics(string text) + { + var italicOn = false; + var sb = new StringBuilder(); + foreach (var line in HtmlUtil.FixInvalidItalicTags(text).SplitToLines()) + { + var s = line; + if (italicOn && !s.TrimStart().StartsWith("", StringComparison.Ordinal)) + { + s = "" + s; + } + + var endTagIndex = s.LastIndexOf("", StringComparison.Ordinal); + if (s.LastIndexOf("", StringComparison.Ordinal) > endTagIndex) + { + italicOn = true; + } + else if (endTagIndex >= 0) + { + italicOn = false; + } + + if (italicOn) + { + sb.AppendLine(s + ""); + } + else + { + sb.AppendLine(s); + } + } + + return sb.ToString().TrimEnd(); + } + + //TODO: Use bytes directly and not encoding + private static void EncodeText(List textBytes, string input, Encoding encoding, string displayStandardCode, string characterCodeTableNumber) + { + // italic/underline + var italicOn = (byte)0x80; + var italicOff = (byte)0x81; + var underlineOn = (byte)0x82; + var underlineOff = (byte)0x83; + var boxingOn = (byte)0x84; + var boxingOff = (byte)0x85; + + // newline + var newline = new byte[] { 0x8a, 0x8a }; + if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox && Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) + { + newline = new byte[] { 0x0a, 0x0a, 0x8a, 0x8a, 0x0d, 0x0b, 0x0b }; // 0a==end box, 0d==double height, 0b==start box + } + else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox) + { + var temp = new List + { + 0x0a, // 0a==end box, + 0x0a + }; + for (var i = 0; i < Configuration.Settings.SubtitleSettings.EbuStlNewLineRows; i++) + { + temp.Add(0x8a); + } + temp.Add(0x0b); // 0b==start box + temp.Add(0x0b); + newline = temp.ToArray(); + } + else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) + { + newline = new byte[] { 0x8a, 0x8a, 0x0d, 0x0d }; // 0d==double height + } + + if (displayStandardCode == "0") // 0=Open subtitling + { + newline = new byte[] { 0x8A }; //8Ah=CR/LF + } + + byte? lastColor = null; + var sb = new StringBuilder(); + + // remove tags except "font", "italic", "underline" and "box" + var startFont = Guid.NewGuid().ToString(); + var endFont = Guid.NewGuid().ToString(); + var startItalic = Guid.NewGuid().ToString(); + var endItalic = Guid.NewGuid().ToString(); + var startUnderline = Guid.NewGuid().ToString(); + var endUnderline = Guid.NewGuid().ToString(); + var startBox = Guid.NewGuid().ToString(); + var endBox = Guid.NewGuid().ToString(); + var text = FixItalics(input); + text = text.Replace("", endFont); + text = text.Replace("", startItalic); + text = text.Replace("", endItalic); + text = text.Replace("", startItalic); + text = text.Replace("", endItalic); + text = text.Replace("", startUnderline); + text = text.Replace("", endUnderline); + text = text.Replace("", startUnderline); + text = text.Replace("", endUnderline); + text = text.Replace("", startBox); + text = text.Replace("", endBox); + text = text.Replace("", startBox); + text = text.Replace("", endBox); + text = HtmlUtil.RemoveHtmlTags(text, true); + text = text.Replace(startFont, ""); + text = text.Replace(startItalic, ""); + text = text.Replace(endItalic, ""); + text = text.Replace(startUnderline, ""); + text = text.Replace(endUnderline, ""); + text = text.Replace(startBox, ""); + text = text.Replace(endBox, ""); + + text = text.Replace(" ", " "); + var lastWasEndColor = false; + var lastWasStartColor = false; + var list = text.SplitToLines(); + for (var index = 0; index < list.Count; index++) + { + if (index > 0) + { + sb.Append(newline); + textBytes.AddRange(newline); + if (displayStandardCode != "0" && lastColor != null) + { + sb.Append(lastColor); + textBytes.Add(lastColor.Value); + } + } + + var line = list[index]; + var i = 0; + while (i < line.Length) + { + // Slicing instead of line.Substring(i): the old code allocated the whole + // rest of the line for every character it stepped over, so a teletext page + // of styled text copied itself once per character while being encoded. + var newStart = line.AsSpan(i); + if (newStart.StartsWith("', i); + if (end > 0) + { + if (displayStandardCode != "0") + { + lastColor = GetColorByte(encoding, line, i); + if (sb.EndsWith(' ')) + { + sb = new StringBuilder(sb.ToString().TrimEnd(' ')); + if (textBytes.Count > 0 && textBytes[textBytes.Count - 1] == 32) + { + textBytes.RemoveAt(textBytes.Count - 1); + } + } + + if (lastColor != null) + { + sb.Append(lastColor); + textBytes.Add(lastColor.Value); + } + } + + i = end + 1; + } + } + else if (newStart.SequenceEqual("".AsSpan())) + { + i += "".Length; + lastColor = null; + lastWasEndColor = true; + } + else if (newStart.StartsWith("".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + i += "".Length; + + if (displayStandardCode != "0" && line.Length > i + 1) + { + var part = line.AsSpan(i); + if (part.StartsWith(" ".AsSpan(), StringComparison.Ordinal)) + { + i += "".Length; + textBytes.Add(italicOn); + } + else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) + { + i += "".Length; + textBytes.Add(italicOff); + } + else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) + { + i += "".Length; + textBytes.Add(underlineOn); + } + else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) + { + i += "".Length; + textBytes.Add(underlineOff); + } + else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) + { + i += "".Length; + textBytes.Add(boxingOn); + } + else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) + { + i += "".Length; + textBytes.Add(boxingOff); + } + else + { + var ch = line[i]; + + // The one-character string this used to build per character is gone; + // every test below was a single-character comparison. + if (ch == ' ' && lastWasEndColor) + { + } + else if (ch == ' ' && lastWasStartColor) + { + } + else + { + if (ch == '#') + { + sb.Append(ch); + textBytes.Add(0x23); + } + else if (ch == 'Đ') + { + sb.Append(ch); + textBytes.Add(0xe2); + } + else if (ch == '–') // em dash + { + sb.Append(ch); + textBytes.Add(0xd0); + } + else + { + if (characterCodeTableNumber == "00") + { + if (newStart.Length > 1 && line[i + 1] == 'ı' && newStart.StartsWith("ı̂".AsSpan(), StringComparison.CurrentCulture)) // extended unicode char - rewritten as simple 'î' - looks the same as "î" but it's not...) + { + textBytes.AddRange(new byte[] { 0xc3, 0x69 }); // Ãi - simple î + i++; + } + else if ("ÀÈÌÒÙàèìòù".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc1, "ÀÈÌÒÙàèìòù", "AEIOUaeiou")); + } + else if ("ÁĆÉÍĹŃÓŔŚÚÝŹáćéģíĺńóŕśúýź".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc2, "ÁĆÉÍĹŃÓŔŚÚÝŹáćéģíĺńóŕśúýź", "ACEILNORSUYZacegilnorsuyz")); + } + else if ("ÂĈÊĜĤÎĴÔŜÛŴŶâĉêĝĥĵôŝûŵŷîı̂".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc3, "ÂĈÊĜĤÎĴÔŜÛŴŶâĉêĝĥîĵôŝûŵŷ", "ACEGHIJOSUWYaceghijosuwy")); + } + else if ("ÃĨÑÕŨãĩñõũ".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc4, "ÃĨÑÕŨãĩñõũ", "AINOUainou")); + } + else if ("ĀĒĪŌŪāēīōū".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc5, "ĀĒĪŌŪāēīōū", "AEIOUaeiou")); + } + else if ("ĂĞŬăğŭ".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc6, "ĂĞŬăğŭ", "AGUagu")); + } + else if ("ĊĖĠİŻċėġıż".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc7, "ĊĖĠİŻċėġıż", "CEGIZcegiz")); + } + else if ("ÄËÏÖÜŸäëïöüÿ".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc8, "ÄËÏÖÜŸäëïöüÿ", "AEIOUYaeiouy")); + } + else if ("ÅŮåů".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xca, "ÅŮåů", "AUau")); + } + else if ("ÇĢĶĻŅŖŞŢçķļņŗşţ".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xcb, "ÇĢĶĻŅŖŞŢçķļņŗşţ", "CGKLNRSTcklnrst")); + } + else if ("ŐŰőű".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xcd, "ŐŰőű", "OUou")); + } + else if ("ĄĘĮŲąęįų".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xce, "ĄĘĮŲąęįų", "AEIUaeiu")); + } + else if ("ČĎĚĽŇŘŠŤŽčďěľňřšťž".Contains(ch)) + { + textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xcf, "ČĎĚĽŇŘŠŤŽčďěľňřšťž", "CDELNRSTZcdelnrstz")); + } + else if (SpecialAsciiCodesByChar.TryGetValue(ch, out var specialAsciiCode)) + { + textBytes.Add((byte)specialAsciiCode); + } + else + { + sb.Append(ch); + AddEncodedChar(textBytes, encoding, ch); + } + } + else + { + sb.Append(ch); + AddEncodedChar(textBytes, encoding, ch); + } + } + } + + i++; + lastWasEndColor = false; + lastWasStartColor = false; + } + } + } + + if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox && displayStandardCode != "0") + { + textBytes.AddRange(new byte[] { 0x0a, 0x0a }); //a=end box + } + } + + + /// + /// Appends one encoded character. This was encoding.GetBytes(line.Substring(i, 1)) - + /// a one-character string plus a byte array for every character written. + /// + private static void AddEncodedChar(List textBytes, Encoding encoding, char ch) + { + Span chars = stackalloc char[1]; + chars[0] = ch; + Span bytes = stackalloc byte[8]; + var count = encoding.GetBytes(chars, bytes); + for (var k = 0; k < count; k++) + { + textBytes.Add(bytes[k]); + } + } + + private static byte? GetColorByte(Encoding encoding, string line, int i) + { + var end = line.IndexOf('>', i); + if (end > 0) + { + var f = line.Substring(i, end - i); + var colorStart = f.IndexOf(" color=", StringComparison.OrdinalIgnoreCase); + if (colorStart > 1) + { + // The attribute value may be double-quoted, single-quoted or bare + // ("" is common in SubRip files). The old code + // assumed a closing double quote and crashed the whole save on a + // negative Substring length when there was none. + var color = f.Substring(colorStart + " color=".Length).TrimStart(); + if (color.Length > 0 && (color[0] == '"' || color[0] == '\'')) + { + var quote = color[0]; + var closingQuote = color.IndexOf(quote, 1); + color = closingQuote > 0 ? color.Substring(1, closingQuote - 1) : color.Substring(1); + } + else + { + var space = color.IndexOf(' '); + if (space > 0) + { + color = color.Substring(0, space); + } + } + + color = color.Trim().Trim('#'); + if (color.Length > 0) + { + return GetNearestColorCode(color); + } + } + } + + return null; + } + + + + + private static byte[] ReplaceSpecialCharactersWithTwoByteEncoding(Encoding encoding, char ch, byte specialCharacter, string originalCharacters, string newCharacters) + { + if (originalCharacters.Length != newCharacters.Length) + { + throw new ArgumentException("originalCharacters and newCharacters must have equal length"); + } + + for (var i = 0; i < newCharacters.Length; i++) + { + if (originalCharacters[i] == ch) + { + var byteArr = new List { specialCharacter }; + byteArr.AddRange(encoding.GetBytes(newCharacters[i].ToString())); + return byteArr.ToArray(); + } + } + + return encoding.GetBytes(ch.ToString()); + } + + public static byte GetFrameFromMilliseconds(int milliseconds, double frameRate, out byte extraSeconds) + { + extraSeconds = 0; + var fr = Math.Round(milliseconds / (TimeCode.BaseUnit / frameRate)); + if (fr >= frameRate) + { + fr = 0; + extraSeconds = 1; + } + + return (byte)fr; + } + } + + public override string Extension => ".stl"; + + public const string NameOfFormat = "EBU STL"; + + public override string Name => NameOfFormat; + + // Carries the teletext row the line starts on (MarginV) and the row count in the GSI block. + public override bool HasPositionSupport => true; + + internal struct SpecialCharacter + { + internal SpecialCharacter(string character, bool switchOrder = false, int priority = 2) + { + Character = character; + SwitchOrder = switchOrder; + Priority = priority; + } + + internal string Character { get; set; } + internal bool SwitchOrder { get; set; } + internal int Priority { get; set; } + } + + /// + /// True when is a full 1024-character EBU STL header - present after + /// loading an STL file or after the EBU save options dialog has stored one on the subtitle. + /// The disk format code sits at 3..10 ("STLnn.mm") and is matched there rather than by + /// listing frame rates, so every rate the save options dialog offers is recognized (STL23 + /// used to fall through, which threw the whole header away on save). + /// + public static bool IsStlHeader(string header) + { + return header != null && + header.Length == 1024 && + header.Substring(3, 3) == "STL"; + } + + /// + /// The number of rows the vertical position (VP) of a text block counts against. Teletext + /// always has a 23 row page, whatever the header's MNR says. Open subtitling files often + /// carry the number of rows a subtitle may occupy (02) rather than a page height, and + /// laying lines out against a 2 row page would put them mid-screen - so that case gets a + /// 15 row page. Shared by the writer and the video preview so both place a line the same. + /// + public static int GetDisplayRowCount(EbuGeneralSubtitleInformation header) + { + if (header.DisplayStandardCode == "1" || header.DisplayStandardCode == "2") // teletext + { + return 23; + } + + if (header.DisplayStandardCode == "0" && header.MaximumNumberOfDisplayableRows == "02") // open subtitling + { + return 15; + } + + if (int.TryParse(header.MaximumNumberOfDisplayableRows, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rows) && rows > 1) + { + return rows; + } + + return 23; + } + + /// + /// True when is an STL header whose display standard code is + /// teletext (level 1 or 2). Open subtitling (code 0) has no teletext page, so the 40 cell + /// row and its control-code overhead do not apply to it. + /// + public static bool IsTeletextHeader(string header) + { + if (!IsStlHeader(header)) + { + return false; + } + + try + { + var displayStandardCode = ReadHeader(GetEncoding(header.Substring(0, 3)).GetBytes(header)).DisplayStandardCode; + return displayStandardCode == "1" || displayStandardCode == "2"; + } + catch + { + return false; + } + } + + public bool Save(string fileName, Subtitle subtitle) + { + return Save(fileName, subtitle, false); + } + + public bool Save(string fileName, Subtitle subtitle, bool batchMode, EbuGeneralSubtitleInformation header = null) + { + using (var ms = new MemoryStream()) + { + var ok = Save(fileName, ms, subtitle, batchMode, header); + if (ok) + { + ms.Position = 0; + using (var fs = new FileStream(fileName, FileMode.Create, FileAccess.Write)) + { + ms.CopyTo(fs); + } + } + return ok; + } + } + + public bool Save(string fileName, Stream stream, Subtitle subtitle, bool batchMode, EbuGeneralSubtitleInformation header) + { + if (header == null) + { + header = new EbuGeneralSubtitleInformation { LanguageCode = AutoDetectLanguageCode(subtitle) }; + + // In an STL colours only exist as teletext control codes - open subtitling (the + // default) has no colour mechanism at all. A coloured subtitle saved with an + // invented header must be teletext level 1, or every colour would be dropped. + if (subtitle.Paragraphs.Any(p => p.Text != null && p.Text.Contains("= 1 && + teletextPosition <= rows; + + if (hasTeletextPosition) + { + // The text may have been re-wrapped since it was read, so a position that no + // longer leaves room for every row would push the tail off the page. + var extraRows = Math.Max(0, Utilities.GetNumberOfLines(text) - 1) * Configuration.Settings.SubtitleSettings.EbuStlNewLineRows; + if (teletextPosition + extraRows > rows) + { + teletextPosition = Math.Max(1, rows - extraRows); + } + + tti.VerticalPosition = (byte)teletextPosition; + } + else if (text.StartsWith("{\\an7}", StringComparison.Ordinal) || text.StartsWith("{\\an8}", StringComparison.Ordinal) || text.StartsWith("{\\an9}", StringComparison.Ordinal)) + { + tti.VerticalPosition = (byte)Configuration.Settings.SubtitleSettings.EbuStlMarginTop; // top (vertical) + if (header.DisplayStandardCode == "1" || header.DisplayStandardCode == "2") // teletext + { + tti.VerticalPosition++; + } + } + else if (text.StartsWith("{\\an4}", StringComparison.Ordinal) || text.StartsWith("{\\an5}", StringComparison.Ordinal) || text.StartsWith("{\\an6}", StringComparison.Ordinal)) + { + tti.VerticalPosition = (byte)(rows / 2); // middle (vertical) + } + else + { + var numberOfLineBreaks = Math.Max(0, Utilities.GetNumberOfLines(text) - 1); + var startRow = rows - Configuration.Settings.SubtitleSettings.EbuStlMarginBottom - + numberOfLineBreaks * Configuration.Settings.SubtitleSettings.EbuStlNewLineRows; + if (startRow < 0) + { + startRow = 0; + } + + tti.VerticalPosition = (byte)startRow; // bottom (vertical) + } + + tti.JustificationCode = EbuUiHelper.JustificationCode; // use default justification + if (text.StartsWith("{\\an1}", StringComparison.Ordinal) || text.StartsWith("{\\an4}", StringComparison.Ordinal) || text.StartsWith("{\\an7}", StringComparison.Ordinal)) + { + tti.JustificationCode = 1; // 01h=left-justified text + } + else if (text.StartsWith("{\\an3}", StringComparison.Ordinal) || text.StartsWith("{\\an6}", StringComparison.Ordinal) || text.StartsWith("{\\an9}", StringComparison.Ordinal)) + { + tti.JustificationCode = 3; // 03h=right-justified + } + else if (text.StartsWith("{\\an2}", StringComparison.Ordinal) || text.StartsWith("{\\an5}", StringComparison.Ordinal) || text.StartsWith("{\\an8}", StringComparison.Ordinal)) + { + tti.JustificationCode = 2; // 02h=centred text + } + + // replace some unsupported characters + text = text.Replace("„", "\""); // lower quote + text = text.Replace("‚", "'"); // lower apostrophe + text = text.Replace("’", "'"); // right single quotation mark + text = text.Replace("♫", "♪"); // only music single note supported + text = text.Replace("…", "..."); // fix Unicode ellipsis + + tti.SubtitleNumber = (ushort)subtitleNumber; + tti.TextField = text; + int startTag = tti.TextField.IndexOf('}'); + if (tti.TextField.StartsWith("{\\", StringComparison.Ordinal) && startTag > 0 && startTag < 10) + { + tti.TextField = tti.TextField.Remove(0, startTag + 1); + } + + if (!p.StartTime.IsMaxTime) + { + tti.TimeCodeInHours = p.StartTime.Hours; + tti.TimeCodeInMinutes = p.StartTime.Minutes; + tti.TimeCodeInSeconds = p.StartTime.Seconds; + tti.TimeCodeInMilliseconds = p.StartTime.Milliseconds; + } + + if (!p.EndTime.IsMaxTime) + { + tti.TimeCodeOutHours = p.EndTime.Hours; + tti.TimeCodeOutMinutes = p.EndTime.Minutes; + tti.TimeCodeOutSeconds = p.EndTime.Seconds; + tti.TimeCodeOutMilliseconds = p.EndTime.Milliseconds; + } + + var extra = new MemoryStream(); + buffer = tti.GetBytes(header, extra); + if (extra.Length > 0) + { + // As many extension blocks as the overflow needs (112 text bytes each). + // EBN 0xFF marks the last block of the subtitle; earlier blocks are numbered, + // which is what makes LoadSubtitle merge them back together. + var extraBytes = extra.ToArray(); + var extraBlockCount = (extraBytes.Length + 111) / 112; + + buffer[3] = 0; // ExtensionBlockNumber - more blocks follow + stream.Write(buffer, 0, buffer.Length); + numberOfTtiBlocks++; + + for (var extraBlock = 0; extraBlock < extraBlockCount; extraBlock++) + { + var extraBuffer = tti.GetBytesExtra(header, extraBytes, extraBlock * 112); + extraBuffer[3] = extraBlock == extraBlockCount - 1 + ? (byte)0xff + : (byte)Math.Min(extraBlock + 1, 0xef); + stream.Write(extraBuffer, 0, extraBuffer.Length); + numberOfTtiBlocks++; + } + } + else + { + stream.Write(buffer, 0, buffer.Length); + numberOfTtiBlocks++; + } + subtitleNumber++; + } + // Rewrite GSI "Total Number of TTI blocks" (offset 238, 5 ASCII digits) now that the + // real count is known - it was written as the subtitle count, so any file containing + // an extension block under-reported it and tools walking the file by TNB lost the tail. + if (stream.CanSeek && numberOfTtiBlocks > 0) + { + var position = stream.Position; + var tnb = Encoding.ASCII.GetBytes(numberOfTtiBlocks.ToString("D5", CultureInfo.InvariantCulture)); + stream.Seek(238, SeekOrigin.Begin); + stream.Write(tnb, 0, tnb.Length); + stream.Seek(position, SeekOrigin.Begin); + } + + return true; + } + + private static string AutoDetectLanguageCode(Subtitle subtitle) + { + if (subtitle == null || subtitle.Paragraphs.Count == 0) + { + return "00"; // Unknown/not applicable + } + + var languageCode = LanguageAutoDetect.AutoDetectGoogleLanguageOrNull(subtitle); + switch (languageCode) + { + case "sq": return "01"; // Albanian + case "br": return "02"; // Breton + case "ca": return "03"; // Catalan + case "hr": return "04"; // Croatian + case "cy": return "05"; // Welsh + case "cs": return "06"; // Czech + case "da": return "07"; // Danish + case "de": return "08"; // German + case "en": return "09"; // English + case "es": return "0A"; // Spanish + case "eo": return "0B"; // Esperanto + case "et": return "0C"; // Estonian + case "eu": return "0D"; // Basque + case "fo": return "0E"; // Faroese + case "fr": return "0F"; // French + case "fy": return "10"; // Frisian + case "ga": return "11"; // Irish + case "gd": return "12"; // Gaelic + case "gl": return "13"; // Galician + case "is": return "14"; // Icelandic + case "it": return "15"; // Italian + case "Lappish": return "16"; // Lappish + case "la": return "17"; // Latin + case "lv": return "18"; // Latvian": + case "lb": return "19"; // Luxembourgi + case "lt": return "1A"; // Lithuanian + case "hu": return "1B"; // Hungarian + case "mt": return "1C"; // Maltese + case "nl": return "1D"; // Dutch + case "nb": return "1E"; // Norwegian + case "oc": return "1F"; // Occitan + case "pl": return "20"; // Polish + case "pt": return "21"; // Portuguese + case "ro": return "22"; // Romanian + case "rm": return "23"; // Romansh + case "sr": return "24"; // Serbian + case "sk": return "25"; // Slovak + case "sl": return "26"; // Slovenian + case "fi": return "27"; // Finnish + case "sv": return "28"; // Swedish + case "tr": return "29"; // Turkish + case "Flemish": return "2A"; // Flemish + case "Wallon": return "2B"; // Wallon + } + + return "09"; // English - default + } + + public override bool IsMine(List lines, string fileName) + { + if (!string.IsNullOrEmpty(fileName) && File.Exists(fileName)) + { + var fi = new FileInfo(fileName); + if (fi.Length >= 1024 + 128 && fi.Length < 2048000) // not too small or too big + { + try + { + var buffer = FileUtil.ReadBytesShared(fileName, 1024); + var header = ReadHeader(buffer); + if (header.DiskFormatCode.StartsWith("STL23", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL24", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL25", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL29", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL30", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL35", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL48", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL50", StringComparison.Ordinal) || + header.DiskFormatCode.StartsWith("STL60", StringComparison.Ordinal) || + "012 ".Contains(header.DisplayStandardCode) && "437|850|860|863|865".Contains(header.CodePageNumber)) + { + return Utilities.IsInteger(header.CodePageNumber) || fileName.EndsWith(".stl", StringComparison.OrdinalIgnoreCase); + } + } + catch + { + return false; + } + } + } + return false; + } + + public override string ToText(Subtitle subtitle, string title) + { + return "Not supported!"; + } + + /// + /// Drops what only an STL can carry: the teletext box tags and the teletext row in MarginV. + /// + /// + /// No other format knows either of them - the box tags used to end up as visible text in the + /// video preview and in the saved file, and a row number ("20") counts as an ASSA pixel + /// margin, which moved every line by a near random amount. + /// + public override void RemoveNativeFormatting(Subtitle subtitle, SubtitleFormat newFormat) + { + // The other teletext capable formats understand both: EBU-TT maps the box to a black + // span background and the row to a region, DVB teletext boxes everything and places + // lines by row - exchanging a subtitle between them keeps the teletext look. + if (newFormat is EbuTt) + { + return; + } + + var keepRows = newFormat is DvbTeletext; + foreach (var p in subtitle.Paragraphs) + { + if (p.Text != null && p.Text.Contains("", StringComparison.Ordinal)) + { + p.Text = p.Text.Replace("", string.Empty).Replace("", string.Empty); + } + + if (!keepRows) + { + p.MarginV = null; + } + } + } + + public void LoadSubtitle(Subtitle subtitle, byte[] buffer) + { + subtitle.Paragraphs.Clear(); + var header = ReadHeader(buffer); + subtitle.Header = header.ToString(); + if (header.DisplayStandardCode == "1" || header.DisplayStandardCode == "2" || HasTeletextColorCodes(buffer)) + { + SeedTeletextBoxAndDoubleHeightSettings(buffer); + } + Paragraph last = null; + byte lastExtensionBlockNumber = 0xff; + JustificationCodes = new List(); + // Resolve this import once, independently of concurrent video-frame-rate updates. + var overrideReadFrameRate = OverrideReadFrameRate; + var readFrameRate = overrideReadFrameRate > 20 ? overrideReadFrameRate : header.FrameRate; + Configuration.Settings.General.CurrentFrameRate = readFrameRate; + + foreach (var tti in ReadTextAndTiming(buffer, header, readFrameRate)) + { + if (tti.ExtensionBlockNumber != 0xfe) // FEh : Reserved for User Data + { + var p = new Paragraph + { + Text = tti.TextField, + StartTime = new TimeCode(tti.TimeCodeInHours, tti.TimeCodeInMinutes, tti.TimeCodeInSeconds, tti.TimeCodeInMilliseconds), + EndTime = new TimeCode(tti.TimeCodeOutHours, tti.TimeCodeOutMinutes, tti.TimeCodeOutSeconds, tti.TimeCodeOutMilliseconds), + MarginV = tti.VerticalPosition.ToString(CultureInfo.InvariantCulture) + }; + + if (Math.Abs(p.StartTime.TotalMilliseconds) < 0.01 && Math.Abs(p.EndTime.TotalMilliseconds) < 0.01) + { + p.StartTime.TotalMilliseconds = TimeCode.MaxTimeTotalMilliseconds; + p.EndTime.TotalMilliseconds = TimeCode.MaxTimeTotalMilliseconds; + } + + if (lastExtensionBlockNumber != 0xff && last != null) + { + last.Text += p.Text; // merge text + } + else + { + subtitle.Paragraphs.Add(p); + last = p; + } + + p.Text = HtmlUtil.FixInvalidItalicTags(p.Text); + lastExtensionBlockNumber = tti.ExtensionBlockNumber; + } + } + subtitle.Renumber(); + Header = header; + } + + public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitle(subtitle, FileUtil.ReadAllBytesShared(fileName)); + } + + /// + /// The teletext box/double-height write settings are global, but whether a file actually + /// uses boxes or double height is a property of the file: seed the settings from the + /// loaded bytes so a plain load-save round trip does not add control codes that were not + /// there, and so the export dialog shows the file's actual state. + /// + private static void SeedTeletextBoxAndDoubleHeightSettings(byte[] buffer) + { + const int startOfTextAndTimingBlock = 1024; + const int ttiSize = 128; + const byte startBox = 0x0b; + const byte doubleHeight = 0x0d; + + var useBox = false; + var useDoubleHeight = false; + var index = startOfTextAndTimingBlock; + while (index + ttiSize <= buffer.Length && !(useBox && useDoubleHeight)) + { + if (buffer[index + 3] != 0xfe && buffer[index + 15] == 0) // skip user data and comment blocks + { + for (var i = index + 16; i < index + ttiSize; i++) + { + var b = buffer[i]; + if (b == startBox) + { + useBox = true; + } + else if (b == doubleHeight) + { + useDoubleHeight = true; + } + } + } + + index += ttiSize; + } + + Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox = useBox; + Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight = useDoubleHeight; + } + + /// + /// Colors only exist as teletext control codes, so a file that declares open subtitling + /// (DSC=0) is read without them. Some tools - Adobe Premiere among them - write the + /// teletext codes anyway and still stamp the header with 0, and reading those strictly + /// throws every color away. Nothing else uses 00h-1Fh in an open subtitling text field, + /// so their presence is taken as proof the file really is teletext coded. + /// + private static bool HasTeletextColorCodes(byte[] buffer) + { + const int startOfTextAndTimingBlock = 1024; + const int ttiSize = 128; + + var index = startOfTextAndTimingBlock; + while (index + ttiSize <= buffer.Length) + { + if (buffer[index + 3] != 0xfe && buffer[index + 15] == 0) // skip user data and comment blocks + { + for (var i = index + 16; i < index + ttiSize; i++) + { + // 00h (black) is left out on purpose: a writer that pads the text field with + // zero bytes instead of 8Fh would otherwise look like a colored file. + if (buffer[i] >= 0x01 && buffer[i] <= 0x07) + { + return true; + } + } + } + + index += ttiSize; + } + + return false; + } + + public static EbuGeneralSubtitleInformation ReadHeader(byte[] buffer) + { + var enc = GetEncoding(Encoding.ASCII.GetString(buffer, 0, 3)); + var header = new EbuGeneralSubtitleInformation + { + CodePageNumber = enc.GetString(buffer, 0, 3), + DiskFormatCode = enc.GetString(buffer, 3, 8), + DisplayStandardCode = enc.GetString(buffer, 11, 1), + CharacterCodeTableNumber = enc.GetString(buffer, 12, 2), + LanguageCode = enc.GetString(buffer, 14, 2), + OriginalProgrammeTitle = enc.GetString(buffer, 16, 32), + OriginalEpisodeTitle = enc.GetString(buffer, 48, 32), + TranslatedProgrammeTitle = enc.GetString(buffer, 80, 32), + TranslatedEpisodeTitle = enc.GetString(buffer, 112, 32), + TranslatorsName = enc.GetString(buffer, 144, 32), + TranslatorsContactDetails = enc.GetString(buffer, 176, 32), + SubtitleListReferenceCode = enc.GetString(buffer, 208, 16), + CreationDate = enc.GetString(buffer, 224, 6), + RevisionDate = enc.GetString(buffer, 230, 6), + RevisionNumber = enc.GetString(buffer, 236, 2), + TotalNumberOfTextAndTimingInformationBlocks = enc.GetString(buffer, 238, 5), + TotalNumberOfSubtitles = enc.GetString(buffer, 243, 5), + TotalNumberOfSubtitleGroups = enc.GetString(buffer, 248, 3), + MaximumNumberOfDisplayableCharactersInAnyTextRow = enc.GetString(buffer, 251, 2), + MaximumNumberOfDisplayableRows = enc.GetString(buffer, 253, 2), + TimeCodeStatus = enc.GetString(buffer, 255, 1), + TimeCodeStartOfProgramme = enc.GetString(buffer, 256, 8), + TimeCodeFirstInCue = enc.GetString(buffer, 264, 8), + TotalNumberOfDisks = enc.GetString(buffer, 272, 1), + DiskSequenceNumber = enc.GetString(buffer, 273, 1), + CountryOfOrigin = enc.GetString(buffer, 274, 3), + Publisher = enc.GetString(buffer, 277, 32), + EditorsName = enc.GetString(buffer, 309, 32), + EditorsContactDetails = enc.GetString(buffer, 341, 32), + SpareBytes = enc.GetString(buffer, 373, 75), + UserDefinedArea = enc.GetString(buffer, 448, 576) + }; + return header; + } + + public static Encoding GetEncoding(string codePageNumber) + { + try + { + return Encoding.GetEncoding(int.TryParse(codePageNumber, out int cp) ? cp : 437); + } + catch (NotSupportedException) + { + return Encoding.GetEncoding(437); + } + } + + /// + /// Get text with regard code page from header + /// + /// Skip next character + /// EBU header + /// data buffer + /// index to current byte in buffer + /// Character at index + private static string GetCharacter(out bool skipNext, EbuGeneralSubtitleInformation header, byte[] buffer, int index) + { + skipNext = false; + + if (header.LanguageCode == LanguageCodeChinese) + { + skipNext = true; + return Encoding.GetEncoding(1200).GetString(buffer, index, 2); // 16-bit Unicode + } + + if (header.CharacterCodeTableNumber == "00") + { + var b = buffer[index]; + if (SpecialAsciiCodes.TryGetValue(b, out var s)) + { + return s; + } + + Encoding encoding; + //note that 0xC1—0xCF combines characters - http://en.wikipedia.org/wiki/ISO/IEC_6937 + try + { + encoding = Encoding.GetEncoding(20269); + } + catch + { + encoding = Encoding.ASCII; + } + + if (index + 2 > buffer.Length) + { + return string.Empty; + } + + var next = encoding.GetString(buffer, index + 1, 1); + switch (b) + { + case 0xc1: // Grave + skipNext = @"AEIOUaeiou".Contains(next); + switch (next) + { + case "A": return "À"; + case "E": return "È"; + case "I": return "Ì"; + case "O": return "Ò"; + case "U": return "Ù"; + case "a": return "à"; + case "e": return "è"; + case "i": return "ì"; + case "o": return "ò"; + case "u": return "ù"; + } + return string.Empty; + case 0xc2: // Acute + skipNext = @"ACEILNORSUYZacegilnorsuyz".Contains(next); + switch (next) + { + case "A": return "Á"; + case "C": return "Ć"; + case "E": return "É"; + case "I": return "Í"; + case "L": return "Ĺ"; + case "N": return "Ń"; + case "O": return "Ó"; + case "R": return "Ŕ"; + case "S": return "Ś"; + case "U": return "Ú"; + case "Y": return "Ý"; + case "Z": return "Ź"; + case "a": return "á"; + case "c": return "ć"; + case "e": return "é"; + case "g": return "ģ"; + case "i": return "í"; + case "l": return "ĺ"; + case "n": return "ń"; + case "o": return "ó"; + case "r": return "ŕ"; + case "s": return "ś"; + case "u": return "ú"; + case "y": return "ý"; + case "z": return "ź"; + } + return string.Empty; + case 0xc3: // Circumflex + skipNext = @"ACEGHIJOSUWYaceghjosuwyıi".Contains(next); + switch (next) + { + case "A": return "Â"; + case "C": return "Ĉ"; + case "E": return "Ê"; + case "G": return "Ĝ"; + case "H": return "Ĥ"; + case "I": return "Î"; + case "J": return "Ĵ"; + case "O": return "Ô"; + case "S": return "Ŝ"; + case "U": return "Û"; + case "W": return "Ŵ"; + case "Y": return "Ŷ"; + case "a": return "â"; + case "c": return "ĉ"; + case "e": return "ê"; + case "g": return "ĝ"; + case "h": return "ĥ"; + case "j": return "ĵ"; + case "o": return "ô"; + case "s": return "ŝ"; + case "u": return "û"; + case "w": return "ŵ"; + case "y": return "ŷ"; + case "ı": return "ı̂"; + case "i": return "î"; + } + return string.Empty; + case 0xc4: // Tilde + skipNext = @"AINOUainou".Contains(next); + switch (next) + { + case "A": return "Ã"; + case "I": return "Ĩ"; + case "N": return "Ñ"; + case "O": return "Õ"; + case "U": return "Ũ"; + case "a": return "ã"; + case "i": return "ĩ"; + case "n": return "ñ"; + case "o": return "õ"; + case "u": return "ũ"; + } + return string.Empty; + case 0xc5: // Macron + skipNext = @"AEIOUaeiou".Contains(next); + switch (next) + { + case "A": return "Ā"; + case "E": return "Ē"; + case "I": return "Ī"; + case "O": return "Ō"; + case "U": return "Ū"; + case "a": return "ā"; + case "e": return "ē"; + case "i": return "ī"; + case "o": return "ō"; + case "u": return "ū"; + } + return string.Empty; + case 0xc6: // Breve + skipNext = @"AGUagu".Contains(next); + switch (next) + { + case "A": return "Ă"; + case "G": return "Ğ"; + case "U": return "Ŭ"; + case "a": return "ă"; + case "g": return "ğ"; + case "u": return "ŭ"; + } + return string.Empty; + case 0xc7: // Dot + skipNext = @"CEGIZcegiz".Contains(next); + switch (next) + { + case "C": return "Ċ"; + case "E": return "Ė"; + case "G": return "Ġ"; + case "I": return "İ"; + case "Z": return "Ż"; + case "c": return "ċ"; + case "e": return "ė"; + case "g": return "ġ"; + case "i": return "ı"; + case "z": return "ż"; + } + return string.Empty; + case 0xc8: // Umlaut or diæresis + skipNext = @"AEIOUYaeiouy".Contains(next); + switch (next) + { + case "A": return "Ä"; + case "E": return "Ë"; + case "I": return "Ï"; + case "O": return "Ö"; + case "U": return "Ü"; + case "Y": return "Ÿ"; + case "a": return "ä"; + case "e": return "ë"; + case "i": return "ï"; + case "o": return "ö"; + case "u": return "ü"; + case "y": return "ÿ"; + } + return string.Empty; + case 0xca: // Ring + skipNext = @"AUau".Contains(next); + switch (next) + { + case "A": return "Å"; + case "U": return "Ů"; + case "a": return "å"; + case "u": return "ů"; + } + return string.Empty; + case 0xcb: // Cedilla + skipNext = @"CGKLNRSTcklnrst".Contains(next); + switch (next) + { + case "C": return "Ç"; + case "G": return "Ģ"; + case "K": return "Ķ"; + case "L": return "Ļ"; + case "N": return "Ņ"; + case "R": return "Ŗ"; + case "S": return "Ş"; + case "T": return "Ţ"; + case "c": return "ç"; + case "k": return "ķ"; + case "l": return "ļ"; + case "n": return "ņ"; + case "r": return "ŗ"; + case "s": return "ş"; + case "t": return "ţ"; + } + return string.Empty; + case 0xcd: // DoubleAcute + skipNext = @"OUou".Contains(next); + switch (next) + { + case "O": return "Ő"; + case "U": return "Ű"; + case "o": return "ő"; + case "u": return "ű"; + } + return string.Empty; + case 0xce: // Ogonek + skipNext = @"AEIUaeiu".Contains(next); + switch (next) + { + case "A": return "Ą"; + case "E": return "Ę"; + case "I": return "Į"; + case "U": return "Ų"; + case "a": return "ą"; + case "e": return "ę"; + case "i": return "į"; + case "u": return "ų"; + } + return string.Empty; + case 0xcf: // Caron + skipNext = @"CDELNRSTZcdelnrstz".Contains(next); + switch (next) + { + case "C": return "Č"; + case "D": return "Ď"; + case "E": return "Ě"; + case "L": return "Ľ"; + case "N": return "Ň"; + case "R": return "Ř"; + case "S": return "Š"; + case "T": return "Ť"; + case "Z": return "Ž"; + case "c": return "č"; + case "d": return "ď"; + case "e": return "ě"; + case "l": return "ľ"; + case "n": return "ň"; + case "r": return "ř"; + case "s": return "š"; + case "t": return "ť"; + case "z": return "ž"; + } + return string.Empty; + default: + return encoding.GetString(buffer, index, 1); + } + } + + if (header.CharacterCodeTableNumber == "01") // Latin/Cyrillic alphabet - from ISO 8859/5-1988 + { + return Encoding.GetEncoding("ISO-8859-5").GetString(buffer, index, 1); + } + + if (header.CharacterCodeTableNumber == "02") // Latin/Arabic alphabet - from ISO 8859/6-1987 + { + return Encoding.GetEncoding("ISO-8859-6").GetString(buffer, index, 1); + } + + if (header.CharacterCodeTableNumber == "03") // Latin/Greek alphabet - from ISO 8859/7-1987 + { + return Encoding.GetEncoding("ISO-8859-7").GetString(buffer, index, 1); // or ISO-8859-1 ? + } + + if (header.CharacterCodeTableNumber == "04") // Latin/Hebrew alphabet - from ISO 8859/8-1988 + { + return Encoding.GetEncoding("ISO-8859-8").GetString(buffer, index, 1); + } + + return string.Empty; + } + + /// + /// Read Text and Timing Information (TTI) block. + /// Each Text and Timing Information (TTI) block consists of 128 bytes. + /// + private IEnumerable ReadTextAndTiming(byte[] buffer, EbuGeneralSubtitleInformation header, double readFrameRate) + { + const int startOfTextAndTimingBlock = 1024; + const int ttiSize = 128; + const byte italicsOn = 0x80; + const byte italicsOff = 0x81; + const byte underlineOn = 0x82; + const byte underlineOff = 0x83; + const byte boxingOn = 0x84; + const byte boxingOff = 0x85; + + var list = new List(); + var hasTeletextColorCodes = header.DisplayStandardCode == "0" && HasTeletextColorCodes(buffer); + var index = startOfTextAndTimingBlock; + var sb = new StringBuilder(); + // EBN 0xFF marks the LAST block of a subtitle, so a block continues the previous one + // when the previous block's EBN was not 0xFF - the same rule LoadSubtitle merges on. + byte previousExtensionBlockNumber = 0xff; + while (index + ttiSize <= buffer.Length) + { + BeforeReadTti?.Invoke((index - startOfTextAndTimingBlock) / ttiSize); + var tti = new EbuTextTimingInformation + { + SubtitleGroupNumber = buffer[index], + SubtitleNumber = (ushort)(buffer[index + 2] * 256 + buffer[index + 1]), + ExtensionBlockNumber = buffer[index + 3], + CumulativeStatus = buffer[index + 4], + TimeCodeInHours = buffer[index + 5 + 0], + TimeCodeInMinutes = buffer[index + 5 + 1], + TimeCodeInSeconds = buffer[index + 5 + 2], + TimeCodeInMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 5 + 3], readFrameRate), 999), + TimeCodeOutHours = buffer[index + 9 + 0], + TimeCodeOutMinutes = buffer[index + 9 + 1], + TimeCodeOutSeconds = buffer[index + 9 + 2], + TimeCodeOutMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 9 + 3], readFrameRate), 999), + VerticalPosition = buffer[index + 13], + JustificationCode = buffer[index + 14], + CommentFlag = buffer[index + 15] + }; + JustificationCodes.Add(tti.JustificationCode); + + // Text block + // - has a fixed length of 112 byte + // - 8Ah = new line + // - unused space = 8Fh + var i = index + 16; // text block start at 17th byte (index 16) + var open = header.DisplayStandardCode != "1" && header.DisplayStandardCode != "2"; + var closed = header.DisplayStandardCode != "0" || hasTeletextColorCodes; + var max = i + 112; + sb.Clear(); + var lastWasNewLine = false; + while (i < max) + { + var b = buffer[i]; + if (b <= 0x1f) // Closed - Teletext control codes + { + if (closed) + { + var tag = GetColorOrTag(b); + if (!string.IsNullOrEmpty(tag)) + { + CloseFontTagIfNewColor(sb, tag); + } + } + } + else if (b >= 0x20 && b <= 0x7f) // Both - Character codes + { + var ch = GetCharacter(out var skipNext, header, buffer, i); + sb.Append(ch); + if (skipNext) + { + i++; + } + } + else if (b >= 0x80 && b <= 0x85) // Open - italic/underline/boxing + { + if (open) + { + if (b == italicsOn && header.LanguageCode != LanguageCodeChinese) + { + sb.Append(""); + } + else if (b == italicsOff && header.LanguageCode != LanguageCodeChinese) + { + sb.Append(""); + } + else if (b == underlineOn && header.LanguageCode != LanguageCodeChinese) + { + sb.Append(""); + } + else if (b == underlineOff && header.LanguageCode != LanguageCodeChinese) + { + sb.Append(""); + } + else if (b == boxingOn && header.LanguageCode != LanguageCodeChinese) + { + sb.Append(""); + } + else if (b == boxingOff && header.LanguageCode != LanguageCodeChinese) + { + sb.Append(""); + } + } + } + else if (b >= 0x86 && b <= 0x89) // Both - Reserved for future use + { + } + else if (b == 0x8a) // Both - CR/LF + { + if (!lastWasNewLine) + { + AddMissingClosingTag(sb); + sb.AppendLine(); + lastWasNewLine = true; + i++; + continue; + } + } + else if (b >= 0x8b && b <= 0x8e) // Both - Reserved for future use + { + } + else if (b == 0x8f) // Both - unused space + { + } + else if (b >= 0x90 && b <= 0x9f) // Both - Reserved for future use + { + } + else if (b >= 0xa1 && b <= 0xff) // Both - Character codes + { + var ch = GetCharacter(out var skipNext, header, buffer, i); + if (sb.EndsWith('>') && sb.ToString().EndsWith("")) + { + if (ch != " ") + { + sb.Append(' '); + } + } + + sb.Append(ch); + if (skipNext) + { + i++; + } + } + + lastWasNewLine = false; + i++; + } + + AddMissingClosingTag(sb); + tti.TextField = FixSpacesAndTags(sb.ToString()); + + if (!int.TryParse(header.MaximumNumberOfDisplayableRows, out var rows)) + { + rows = 23; + } + + // Only the first block of a subtitle carries the alignment tag. A continuation + // block (EBN != 0xFF) is appended to the previous paragraph's text by + // LoadSubtitle, so tagging it too spliced an "{\anN}" into mid-sentence. + var isContinuationBlock = previousExtensionBlockNumber != 0xff; + if (tti.ExtensionBlockNumber != 0xfe) // FEh is user data, not part of the chain + { + previousExtensionBlockNumber = tti.ExtensionBlockNumber; + } + + if (!isContinuationBlock) + { + if (tti.VerticalPosition < 3) + { + if (tti.JustificationCode == 1) // left + { + tti.TextField = "{\\an7}" + tti.TextField; + } + else if (tti.JustificationCode == 3) // right + { + tti.TextField = "{\\an9}" + tti.TextField; + } + else + { + tti.TextField = "{\\an8}" + tti.TextField; + } + } + else if (tti.VerticalPosition <= rows / 2 + 1) + { + if (tti.JustificationCode == 1) // left + { + tti.TextField = "{\\an4}" + tti.TextField; + } + else if (tti.JustificationCode == 3) // right + { + tti.TextField = "{\\an6}" + tti.TextField; + } + else + { + tti.TextField = "{\\an5}" + tti.TextField; + } + } + else + { + if (tti.JustificationCode == 1) // left + { + tti.TextField = "{\\an1}" + tti.TextField; + } + else if (tti.JustificationCode == 3) // right + { + tti.TextField = "{\\an3}" + tti.TextField; + } + } + } + index += ttiSize; + list.Add(tti); + } + + return list; + } + + private static void AddMissingClosingTag(StringBuilder sb) + { + var s = sb.ToString(); + var startTags = Utilities.CountTagInText(s, ""); + if (startTags > endTags) + { + sb.Append(""); + } + } + + private static void CloseFontTagIfNewColor(StringBuilder sb, string tag) + { + var previousText = sb.ToString(); + if (string.IsNullOrEmpty(previousText)) + { + if (!string.IsNullOrEmpty(tag) && !tag.Contains("\"White\"")) + { + if (sb.Length > 0 && !sb.EndsWith(' ')) + { + sb.Append(' '); + } + + sb.Append(tag); + } + + return; + } + + var lastFontStartTag = previousText.LastIndexOf(" 0 && !sb.EndsWith(' ')) + { + sb.Append(' '); + } + + sb.Append(tag); + } + + return; + } + + var lastFontEndTag = previousText.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + if (lastFontEndTag > lastFontStartTag) + { + if (!string.IsNullOrEmpty(tag) && !tag.Contains("\"White\"")) + { + if (sb.Length > 0 && !sb.EndsWith(' ')) + { + sb.Append(' '); + } + + sb.Append(tag); + } + + return; + } + + if (previousText.TrimEnd(' ').EndsWith(Environment.NewLine)) + { + var text = sb.ToString(); + sb.Clear(); + sb.Append(text.TrimEnd()); + sb.Append("" + Environment.NewLine); + } + else if (previousText.EndsWith(' ')) + { + var text = sb.ToString(); + sb.Clear(); + sb.Append(text.TrimEnd(' ')); + sb.Append(" "); + } + else + { + sb.Append(" "); + } + + if (!string.IsNullOrEmpty(tag) && !tag.Contains("\"White\"")) + { + if (sb.Length > 0 && !sb.EndsWith(' ')) + { + sb.Append(' '); + } + + sb.Append(tag); + } + } + + /// + /// The teletext colour code an STL file would carry for - a colour + /// name ("Red") or six hex digits, with or without a leading '#' - or null when the value is + /// not a colour at all. The eight teletext colours are the corners of the RGB cube, so + /// anything else is snapped to the nearest one; Save writes what this returns, and the UI + /// asks it what a colour will become before it writes a tag (via GetNearestColorName). + /// + internal static byte? GetNearestColorCode(string color) + { + color = color.Trim().TrimStart('#').ToLowerInvariant(); + if (color == "black" || color == "000000") + { + return 0x00; // black + } + + if (color == "red" || color == "ff0000") + { + return 0x01; // red + } + + if (color == "green" || color == "00ff00") + { + return 0x02; // green + } + + if (color == "yellow" || color == "ffff00") + { + return 0x03; // yellow + } + + if (color == "blue" || color == "0000ff") + { + return 0x04; // blue + } + + if (color == "magenta" || color == "ff00ff") + { + return 0x05; // magenta + } + + if (color == "cyan" || color == "00ffff") + { + return 0x06; // cyan + } + + if (color == "white" || color == "ffffff") + { + return 0x07; // white + } + + if (color.Length == 6) + { + if (RegExprColor.IsMatch(color)) + { + const int maxDiff = 130; + var r = int.Parse(color.Substring(0, 2), NumberStyles.HexNumber); + var g = int.Parse(color.Substring(2, 2), NumberStyles.HexNumber); + var b = int.Parse(color.Substring(4, 2), NumberStyles.HexNumber); + if (r < maxDiff && g < maxDiff && b < maxDiff) + { + return 0x00; // black + } + + if (r > 255 - maxDiff && g < maxDiff && b < maxDiff) + { + return 0x01; // red + } + + if (r < maxDiff && g > 255 - maxDiff && b < maxDiff) + { + return 0x02; // green + } + + if (r > 255 - maxDiff && g > 255 - maxDiff && b < maxDiff) + { + return 0x03; // yellow + } + + if (r < maxDiff && g < maxDiff && b > 255 - maxDiff) + { + return 0x04; // blue + } + + if (r > 255 - maxDiff && g < maxDiff && b > 255 - maxDiff) + { + return 0x05; // magenta + } + + if (r < maxDiff && g > 255 - maxDiff && b > 255 - maxDiff) + { + return 0x06; // cyan + } + + if (r > 255 - maxDiff && g > 255 - maxDiff && b > 255 - maxDiff) + { + return 0x07; // white + } + } + } + + return null; + } + + /// + /// The name of the teletext colour is nearest to - the same name + /// the STL reader writes into the text - or null when it is not a colour at all. + /// + public static string GetNearestColorName(string color) + { + var code = GetNearestColorCode(color); + return code == null ? null : GetColorName(code.Value); + } + + private static string GetColorName(byte b) + { + switch (b) + { + case 0x00: + return "Black"; + case 0x01: + return "Red"; + case 0x02: + return "Green"; + case 0x03: + return "Yellow"; + case 0x04: + return "Blue"; + case 0x05: + return "Magenta"; + case 0x06: + return "Cyan"; + case 0x07: + return "White"; + } + + return null; + } + + private static string GetColorOrTag(byte b) + { + //case 0x0a: + // return ""; + //case 0x0b: + // return ""; + var name = GetColorName(b); + return name == null ? null : ""; + } + + private static string FixSpacesAndTags(string text) + { + text = EmptyFontTag.Replace(text, string.Empty).Trim(); + while (text.Contains(" ")) + { + text = text.Replace(" ", " "); + } + + var match = FontTagsNoSpace1.Match(text); + while (match.Success) + { + text = text.Remove(match.Index, match.Length).Insert(match.Index, match.Value.Replace(" ", string.Empty).Contains("", string.Empty); + } + + var lines = text.SplitToLines(); + var sb = new StringBuilder(); + foreach (var line in lines) + { + sb.Append(line); + var count = Utilities.CountTagInText(line, "")) + { + sb.Append(""); + } + + if (Configuration.Settings.SubtitleSettings.EbuStlRemoveEmptyLines && + HtmlUtil.RemoveHtmlTags(line).Length == 0) + { + continue; + } + + sb.AppendLine(); + } + + text = sb.ToString().TrimEnd(); + + while (text.Contains(Environment.NewLine + " ")) + { + text = text.Replace(Environment.NewLine + " ", Environment.NewLine); + } + + // remove starting white spaces + match = FontTagsStartSpace.Match(text); + while (match.Success) + { + text = text.Remove(match.Index + match.Length - 1, 1); + match = FontTagsStartSpace.Match(text); + } + + // remove starting white spaces on 2+ line + match = FontTagsNewLineSpace.Match(text); + while (match.Success) + { + text = text.Remove(match.Index + match.Length - 1, 1); + match = FontTagsNewLineSpace.Match(text); + } + + text = text.Replace(" ", " "); + + text = HtmlUtil.FixInvalidItalicTags(text); + + return text; + } + + public override bool IsTextBased => false; + + public bool Save(string fileName, Stream stream, Subtitle subtitle, bool batchMode) + { + return Save(fileName, stream, subtitle, batchMode, null); + } + } +} diff --git a/tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj b/tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj new file mode 100644 index 00000000000..f5840d51336 --- /dev/null +++ b/tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj @@ -0,0 +1 @@ +net10.0disablefalseNikse.SubtitleEdit.Corelibsefalsetrue$(NoWarn);1591Nikse.SubtitleEdit.Core.Properties.Resources.resources \ No newline at end of file From 3d05ce0e2c293009c30b65c990fb7500d949148c Mon Sep 17 00:00:00 2001 From: niksedk Date: Tue, 6 Oct 2026 16:42:31 +0200 Subject: [PATCH 002/110] Drop EbuSnapshotRegression test project The copied Ebu.cs reader would drift from production and the project is not in SubtitleEdit.sln, so CI never ran it. Co-Authored-By: Claude Opus 5.5 --- .../EbuReadFrameRateTest.cs | 89 - .../EbuSnapshotRegression.csproj | 1 - tests/EbuSnapshotRegression/README.md | 9 - .../TestLibSE/EbuTestReader.generated.cs | 2416 ----------------- .../TestLibSE/TestLibSE.csproj | 1 - 5 files changed, 2516 deletions(-) delete mode 100644 tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs delete mode 100644 tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj delete mode 100644 tests/EbuSnapshotRegression/README.md delete mode 100644 tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs delete mode 100644 tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj diff --git a/tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs b/tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs deleted file mode 100644 index cceb12b78df..00000000000 --- a/tests/EbuSnapshotRegression/EbuReadFrameRateTest.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Reflection; -using System.Text; -using Nikse.SubtitleEdit.Core.Common; -using Nikse.SubtitleEdit.Core.SubtitleFormats; - -namespace LibSETests.SubtitleFormats; - -[CollectionDefinition("EBU import global state", DisableParallelization = true)] -public class EbuImportGlobalStateCollection { } - -[Collection("EBU import global state")] -public class EbuReadFrameRateTest -{ - private static byte[] Fixture(string diskCode, int count = 151, byte frame = 5) - { - var header = new Ebu.EbuGeneralSubtitleInformation { DiskFormatCode = diskCode, DisplayStandardCode = "0" }; - var bytes = new byte[1024 + 128 * count]; - Encoding.ASCII.GetBytes(header.ToString()).CopyTo(bytes, 0); - for (var i = 0; i < count; i++) - { - var n = 1024 + i * 128; - bytes[n + 1] = (byte)(i + 1); bytes[n + 2] = (byte)((i + 1) >> 8); - bytes[n + 3] = 0xff; bytes[n + 5] = 10; - bytes[n + 7] = (byte)(i % 50); bytes[n + 8] = frame; - bytes[n + 9] = 10; bytes[n + 11] = (byte)(i % 50 + 2); bytes[n + 12] = 17; - bytes[n + 13] = 20; bytes[n + 14] = 2; - Array.Fill(bytes, (byte)0x8f, n + 16, 112); bytes[n + 16] = (byte)'A'; - } - return bytes; - } - - private static Subtitle Load(byte[] bytes, Action? hook = null) - { - var reader = new Ebu(); - typeof(Ebu).GetProperty("BeforeReadTti", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(reader, hook); - var subtitle = new Subtitle(); reader.LoadSubtitle(subtitle, bytes); return subtitle; - } - - private static void WithState(Action body) - { - var fps = Configuration.Settings.General.CurrentFrameRate; var over = Ebu.OverrideReadFrameRate; - try { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Ebu.OverrideReadFrameRate = 0; body(); } - finally { Configuration.Settings.General.CurrentFrameRate = fps; Ebu.OverrideReadFrameRate = over; } - } - - [Theory] - [InlineData(0)][InlineData(100)][InlineData(-1)] - public void GlobalSwitchCannotChangeTimingOrMetadata(int trigger) => WithState(() => - { - var bytes = Fixture("STL25.01"); - Configuration.Settings.General.CurrentFrameRate = 25; - var control = Load(bytes); - var raced = Load(bytes, i => { if (i == trigger || trigger == -1 && i % 25 == 0) - Configuration.Settings.General.CurrentFrameRate = trigger == -1 ? new[] { 23.976, 30.0, 24, 25 }[(i / 25) % 4] : 24000.0 / 1001; }); - Assert.Equal(control.Header, raced.Header); Assert.Equal(control.Paragraphs.Count, raced.Paragraphs.Count); - for (var i = 0; i < control.Paragraphs.Count; i++) - { - var a = control.Paragraphs[i]; var b = raced.Paragraphs[i]; - Assert.Equal(a.StartTime.TotalMilliseconds, b.StartTime.TotalMilliseconds); - Assert.Equal(a.EndTime.TotalMilliseconds, b.EndTime.TotalMilliseconds); - Assert.Equal(a.Text, b.Text); Assert.Equal(a.MarginV, b.MarginV); - Assert.Equal(0, b.StartTime.TotalMilliseconds % 40); Assert.Equal(0, b.EndTime.TotalMilliseconds % 40); - } - }); - - [Theory] - [InlineData("STL24.01", 0, 24)][InlineData("STL25.01", 0, 25)][InlineData("STL30.01", 0, 30)] - [InlineData("STL23.01", 0, 23)][InlineData("STL29.01", 0, 29)] - [InlineData("STL25.01", 23.976, 23.976)][InlineData("STL25.01", 29.97, 29.97)] - [InlineData("STL25.01", 24, 24)][InlineData("STL25.01", 20, 25)] - public void ExistingHeaderAndOverrideSemanticsStayStable(string code, double over, double expected) => WithState(() => - { - Ebu.OverrideReadFrameRate = over; - var bytes = Fixture(code); - var control = Load(bytes); var raced = Load(bytes, _ => Configuration.Settings.General.CurrentFrameRate = 50); - Assert.Equal(Math.Min(999, SubtitleFormat.FramesToMilliseconds(5, expected)), control.Paragraphs[0].StartTime.Milliseconds); - Assert.Equal(control.Header, raced.Header); - Assert.Equal(control.Paragraphs.Select(p => (p.StartTime.TotalMilliseconds, p.EndTime.TotalMilliseconds)), - raced.Paragraphs.Select(p => (p.StartTime.TotalMilliseconds, p.EndTime.TotalMilliseconds))); - Assert.Equal(over, Ebu.OverrideReadFrameRate); - }); - - [Fact] public void Control25AndMax999KeepExistingRounding() => WithState(() => - { - var normal = Load(Fixture("STL25.01")); - Assert.All(normal.Paragraphs, p => { Assert.Equal(0, p.StartTime.TotalMilliseconds % 40); Assert.Equal(0, p.EndTime.TotalMilliseconds % 40); }); - var capped = Load(Fixture("STL24.01", 1, 255)); Assert.Equal(999, capped.Paragraphs[0].StartTime.Milliseconds); - }); -} diff --git a/tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj b/tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj deleted file mode 100644 index 1029d7c3707..00000000000 --- a/tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj +++ /dev/null @@ -1 +0,0 @@ -net10.0enableenabletruefalse \ No newline at end of file diff --git a/tests/EbuSnapshotRegression/README.md b/tests/EbuSnapshotRegression/README.md deleted file mode 100644 index b78cb840509..00000000000 --- a/tests/EbuSnapshotRegression/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# EBU import frame-rate regression tests - -Run `dotnet test tests/EbuSnapshotRegression/EbuSnapshotRegression.csproj`. - -The 13 synthetic cases build STL data in memory and deterministically update the global frame rate immediately before selected TTI blocks. They cover the 25-fps control, changes to 23.976 fps, repeated changes, header and override behavior, and the 999 ms cap. No external subtitle files are required. - -TestLibSE builds a separate assembly from the production LibSE sources, replacing Ebu.cs only in this test assembly with EbuTestReader.generated.cs. The replacement adds an internal BeforeReadTti callback and its invocation. The production project does not compile this hook. The copied reader must remain identical to production after removal of the test-only comment and those two hook lines, ignoring the UTF-8 BOM and trailing whitespace. Compare these files whenever Ebu.cs changes. - -Without the per-import snapshot, 12 of the 13 cases fail; with it all 13 pass. The original real-file validation is intentionally excluded from this portable project. diff --git a/tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs b/tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs deleted file mode 100644 index be2647b9fa9..00000000000 --- a/tests/EbuSnapshotRegression/TestLibSE/EbuTestReader.generated.cs +++ /dev/null @@ -1,2416 +0,0 @@ -// Test-only copy; never compiled into the production LibSE project. -using Nikse.SubtitleEdit.Core.Common; -using Nikse.SubtitleEdit.Core.Interfaces; -using SkiaSharp; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace Nikse.SubtitleEdit.Core.SubtitleFormats -{ - /// - /// EBU Subtitling data exchange format. - /// - public class Ebu : SubtitleFormat, IBinaryPersistableSubtitle - { - internal Action BeforeReadTti { get; set; } - public static double OverrideReadFrameRate { get; set; } - - private static readonly Regex FontTagsNoSpace1 = new Regex("[a-zA-z.!?][a-zA-Z-]", RegexOptions.Compiled); - private static readonly Regex FontTagsNoSpace2 = new Regex("[a-zA-z.!?][a-zA-Z-]", RegexOptions.Compiled); - - // "" - two teletext color codes in a row, e.g. a background - // color set right before the text color. Only the last one has any text, and SE has no - // background color for STL, so the empty tag is noise that shifts the text by a space. - private static readonly Regex EmptyFontTag = new Regex("", RegexOptions.Compiled); - - private static readonly Regex FontTagsStartSpace = new Regex("^ ", RegexOptions.Compiled); // " " - private static readonly Regex FontTagsNewLineSpace = new Regex("[\r\n]+ ", RegexOptions.Compiled); // "\r\n " - - private const string LanguageCodeChinese = "75"; - - private static readonly Dictionary SpecialAsciiCodes = new Dictionary - { - { 0xd3, "©" }, - { 0xd4, "™" }, - { 0xd5, "♪" }, - - { 0xe0, "Ω" }, - { 0xe1, "Æ" }, - { 0xe2, "Ð" }, - { 0xe3, "ª" }, - { 0xe4, "Ħ" }, - - { 0xe6, "IJ" }, - { 0xe7, "Ŀ" }, - { 0xe8, "Ł" }, - { 0xe9, "Ø" }, - { 0xea, "Œ" }, - { 0xeb, "º" }, - { 0xec, "Þ" }, - { 0xed, "Ŧ" }, - { 0xee, "Ŋ" }, - { 0xef, "ʼn" }, - - { 0xf0, "ĸ" }, - { 0xf1, "æ" }, - { 0xf2, "đ" }, - { 0xf3, "ð" }, - { 0xf4, "ħ" }, - { 0xf5, "ı" }, - { 0xf6, "ij" }, - { 0xf7, "ŀ" }, - { 0xf8, "ł" }, - { 0xf9, "ø" }, - { 0xfa, "œ" }, - { 0xfb, "ß" }, - { 0xfc, "þ" }, - { 0xfd, "ŧ" }, - { 0xfe, "ŋ" }, - }; - - /// - /// keyed by its (always single-character) value. The - /// writer used to ask ContainsValue and then LINQ-First over all ~33 entries for every - /// character it encoded - two linear scans per character. - /// - private static readonly Dictionary SpecialAsciiCodesByChar = BuildSpecialAsciiCodesByChar(); - - private static Dictionary BuildSpecialAsciiCodesByChar() - { - var result = new Dictionary(); - foreach (var kvp in SpecialAsciiCodes) - { - // First entry wins, like the First(...) it replaces. - if (kvp.Value.Length == 1 && !result.ContainsKey(kvp.Value[0])) - { - result.Add(kvp.Value[0], kvp.Key); - } - } - - return result; - } - - public interface IEbuUiHelper - { - void Initialize(EbuGeneralSubtitleInformation header, byte justificationCode, string fileName, Subtitle subtitle); - bool ShowDialogOk(); - byte JustificationCode { get; set; } - } - - public static IEbuUiHelper EbuUiHelper { get; set; } - - private static readonly Regex RegExprColor = new Regex(@"^[a-f0-9]{6}$", RegexOptions.Compiled); - - public List JustificationCodes = new List(); - - public EbuGeneralSubtitleInformation Header; - - /// - /// GSI block (1024 bytes) - /// - public class EbuGeneralSubtitleInformation - { - public string CodePageNumber { get; set; } // 0..2 - public string DiskFormatCode { get; set; } // 3..10 - public double FrameRateFromSaveDialog { get; set; } - public string DisplayStandardCode { get; set; } // 11 - public string CharacterCodeTableNumber { get; set; } // 12..13 - public string LanguageCode { get; set; } // 14..15 - public string OriginalProgrammeTitle { get; set; } // 16..47 - public string OriginalEpisodeTitle { get; set; } - public string TranslatedProgrammeTitle { get; set; } - public string TranslatedEpisodeTitle { get; set; } - public string TranslatorsName { get; set; } - public string TranslatorsContactDetails { get; set; } - public string SubtitleListReferenceCode { get; set; } - public string CreationDate { get; set; } - public string RevisionDate { get; set; } - public string RevisionNumber { get; set; } - public string TotalNumberOfTextAndTimingInformationBlocks { get; set; } - public string TotalNumberOfSubtitles { get; set; } - public string TotalNumberOfSubtitleGroups { get; set; } - public string MaximumNumberOfDisplayableCharactersInAnyTextRow { get; set; } - public string MaximumNumberOfDisplayableRows { get; set; } - public string TimeCodeStatus { get; set; } - public string TimeCodeStartOfProgramme { get; set; } - public string TimeCodeFirstInCue { get; set; } - public string TotalNumberOfDisks { get; set; } - public string DiskSequenceNumber { get; set; } - public string CountryOfOrigin { get; set; } - public string Publisher { get; set; } - public string EditorsName { get; set; } - public string EditorsContactDetails { get; set; } - public string SpareBytes { get; set; } - public string UserDefinedArea { get; set; } - - public double FrameRate - { - get - { - if (FrameRateFromSaveDialog > 20) - { - return FrameRateFromSaveDialog; - } - - if (DiskFormatCode.StartsWith("STL23", StringComparison.Ordinal)) - { - return 23.0; - } - - if (DiskFormatCode.StartsWith("STL24", StringComparison.Ordinal)) - { - return 24.0; - } - - if (DiskFormatCode.StartsWith("STL25", StringComparison.Ordinal)) - { - return 25.0; - } - - if (DiskFormatCode.StartsWith("STL29", StringComparison.Ordinal)) - { - return 29.0; - } - - if (DiskFormatCode.StartsWith("STL35", StringComparison.Ordinal)) - { - return 35.0; - } - - if (DiskFormatCode.StartsWith("STL48", StringComparison.Ordinal)) - { - return 48.0; - } - - if (DiskFormatCode.StartsWith("STL50", StringComparison.Ordinal)) - { - return 50.0; - } - - if (DiskFormatCode.StartsWith("STL60", StringComparison.Ordinal)) - { - return 60.0; - } - - return 30.0; // should be DiskFormatCode STL30.01 - } - } - - public EbuGeneralSubtitleInformation() - { - CodePageNumber = "437"; - DiskFormatCode = "STL25.01"; - DisplayStandardCode = "0"; // 0=Open subtitling - CharacterCodeTableNumber = "00"; - LanguageCode = "0A"; - OriginalProgrammeTitle = "No Title "; - OriginalEpisodeTitle = " "; - TranslatedProgrammeTitle = string.Empty.PadLeft(32, ' '); - TranslatedEpisodeTitle = string.Empty.PadLeft(32, ' '); - TranslatorsName = string.Empty.PadLeft(32, ' '); - TranslatorsContactDetails = string.Empty.PadLeft(32, ' '); - SubtitleListReferenceCode = "0 "; - CreationDate = "101021"; - RevisionDate = "101021"; - RevisionNumber = "01"; - TotalNumberOfTextAndTimingInformationBlocks = "00725"; - TotalNumberOfSubtitles = "00725"; - TotalNumberOfSubtitleGroups = "001"; - MaximumNumberOfDisplayableCharactersInAnyTextRow = "40"; - MaximumNumberOfDisplayableRows = "23"; - TimeCodeStatus = "1"; - TimeCodeStartOfProgramme = "00000000"; - TimeCodeFirstInCue = "00000001"; - TotalNumberOfDisks = "1"; - DiskSequenceNumber = "1"; - CountryOfOrigin = "USA"; - Publisher = string.Empty.PadLeft(32, ' '); - EditorsName = string.Empty.PadLeft(32, ' '); - EditorsContactDetails = string.Empty.PadLeft(32, ' '); - SpareBytes = string.Empty.PadLeft(75, ' '); - UserDefinedArea = string.Empty.PadLeft(576, ' '); - } - - public override string ToString() - { - var result = CodePageNumber + - DiskFormatCode + - DisplayStandardCode + - CharacterCodeTableNumber + - LanguageCode + - OriginalProgrammeTitle + - OriginalEpisodeTitle + - TranslatedProgrammeTitle + - TranslatedEpisodeTitle + - TranslatorsName + - TranslatorsContactDetails + - SubtitleListReferenceCode + - CreationDate + - RevisionDate + - RevisionNumber + - TotalNumberOfTextAndTimingInformationBlocks + - TotalNumberOfSubtitles + - TotalNumberOfSubtitleGroups + - MaximumNumberOfDisplayableCharactersInAnyTextRow + - MaximumNumberOfDisplayableRows + - TimeCodeStatus + - TimeCodeStartOfProgramme + - TimeCodeFirstInCue + - TotalNumberOfDisks + - DiskSequenceNumber + - CountryOfOrigin + - Publisher + - EditorsName + - EditorsContactDetails + - SpareBytes + - UserDefinedArea; - - if (result.Length == 1024) - { - return result; - } - - return "Length must be 1024 but is " + result.Length; - } - } - - /// - /// TTI block 128 bytes - /// - private class EbuTextTimingInformation - { - public byte SubtitleGroupNumber { get; set; } - public ushort SubtitleNumber { get; set; } - public byte ExtensionBlockNumber { get; set; } - public byte CumulativeStatus { get; set; } - public int TimeCodeInHours { get; set; } - public int TimeCodeInMinutes { get; set; } - public int TimeCodeInSeconds { get; set; } - public int TimeCodeInMilliseconds { get; set; } - public int TimeCodeOutHours { get; set; } - public int TimeCodeOutMinutes { get; set; } - public int TimeCodeOutSeconds { get; set; } - public int TimeCodeOutMilliseconds { get; set; } - public byte VerticalPosition { get; set; } - public byte JustificationCode { get; set; } - public byte CommentFlag { get; set; } - public string TextField { get; set; } - - public EbuTextTimingInformation() - { - SubtitleGroupNumber = 0; - ExtensionBlockNumber = 255; - CumulativeStatus = 0; - VerticalPosition = 0x16; - JustificationCode = 2; - CommentFlag = 0; - } - - /// - /// One extension block carrying the 112 text bytes starting at . - /// Callers emit as many as the overflow needs - a single block capped the whole - /// subtitle at 224 bytes and silently discarded the rest. - /// - public byte[] GetBytesExtra(EbuGeneralSubtitleInformation header, byte[] extraBytes, int offset) - { - var buffer = SaveHeader(header); - for (var i = 0; i < 112; i++) - { - var index = offset + i; - buffer[16 + i] = index < extraBytes.Length ? extraBytes[index] : (byte)0x8f; - } - - return buffer; - } - - public byte[] GetBytes(EbuGeneralSubtitleInformation header, MemoryStream extra) - { - var buffer = SaveHeader(header); - - var encoding = GetEncoding(header.CodePageNumber); - if (header.LanguageCode == LanguageCodeChinese) - { - var lines = HtmlUtil.RemoveHtmlTags(TextField, true).SplitToLines(); - var byteList = new List(); - encoding = Encoding.GetEncoding(1200); // 16-bit Unicode - for (var i = 0; i < lines.Count; i++) - { - var l = lines[i]; - if (i > 0) - { // new line - byteList.Add(0); - byteList.Add(138); - } - byteList.AddRange(encoding.GetBytes(l).ToArray()); - } - - for (var i = 0; i < 112; i++) - { - if (i < byteList.Count) - { - buffer[16 + i] = byteList[i]; - } - else - { - buffer[16 + i] = 0x8f; - } - } - - if (byteList.Count > 112) - { - extra.Write(byteList.ToArray(), 112, byteList.Count - 112); - } - - return buffer; - } - - if (header.CharacterCodeTableNumber == "00") - { - // 0xC1—0xCF combines characters - http://en.wikipedia.org/wiki/ISO/IEC_6937 - try - { - encoding = Encoding.GetEncoding(20269); - } - catch - { - encoding = Encoding.ASCII; - } - } - else if (header.CharacterCodeTableNumber == "01") // Latin/Cyrillic alphabet - from ISO 8859/5-1988 - { - encoding = Encoding.GetEncoding("ISO-8859-5"); - } - else if (header.CharacterCodeTableNumber == "02") // Latin/Arabic alphabet - from ISO 8859/6-1987 - { - encoding = Encoding.GetEncoding("ISO-8859-6"); - } - else if (header.CharacterCodeTableNumber == "03") // Latin/Greek alphabet - from ISO 8859/7-1987 - { - encoding = Encoding.GetEncoding("ISO-8859-7"); // or ISO-8859-1 ? - } - else if (header.CharacterCodeTableNumber == "04") // Latin/Hebrew alphabet - from ISO 8859/8-1988 - { - encoding = Encoding.GetEncoding("ISO-8859-8"); - } - - var textBytes = new List(); - if (header.DisplayStandardCode != "0") // 0=Open subtitling - { - if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox && Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) - { - textBytes.AddRange(new byte[] { 0x0d, 0x0b, 0x0b }); // d=double height, b=start box - } - else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox) - { - textBytes.AddRange(new byte[] { 0x0b, 0x0b }); // b=start box - } - else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) - { - textBytes.AddRange(new byte[] { 0x0d }); // d=double height - } - } - EncodeText(textBytes, TextField, encoding, header.DisplayStandardCode, header.CharacterCodeTableNumber); - - var bytes = textBytes.ToArray(); - - for (var i = 0; i < 112; i++) - { - if (i < bytes.Length) - { - buffer[16 + i] = bytes[i]; - } - else - { - buffer[16 + i] = 0x8f; - } - } - - if (bytes.Length > 112) - { - extra.Write(bytes, 112, bytes.Length - 112); - } - - return buffer; - } - - private byte[] SaveHeader(EbuGeneralSubtitleInformation header) - { - var buffer = new byte[128]; // Text and Timing Information (TTI) block consists of 128 bytes - - buffer[0] = SubtitleGroupNumber; - var temp = BitConverter.GetBytes(SubtitleNumber); - buffer[1] = temp[0]; - buffer[2] = temp[1]; - buffer[3] = ExtensionBlockNumber; - buffer[4] = CumulativeStatus; - - var frames = GetFrameFromMilliseconds(TimeCodeInMilliseconds, header.FrameRate, out var extraSeconds); - var tc = new TimeCode(TimeCodeInHours, TimeCodeInMinutes, TimeCodeInSeconds + extraSeconds, 0); - buffer[5] = (byte)tc.Hours; - buffer[6] = (byte)tc.Minutes; - buffer[7] = (byte)tc.Seconds; - buffer[8] = frames; - - frames = GetFrameFromMilliseconds(TimeCodeOutMilliseconds, header.FrameRate, out extraSeconds); - tc = new TimeCode(TimeCodeOutHours, TimeCodeOutMinutes, TimeCodeOutSeconds + extraSeconds, 0); - buffer[9] = (byte)tc.Hours; - buffer[10] = (byte)tc.Minutes; - buffer[11] = (byte)tc.Seconds; - buffer[12] = frames; - - buffer[13] = VerticalPosition; - buffer[14] = JustificationCode; - buffer[15] = CommentFlag; - return buffer; - } - - private static string FixItalics(string text) - { - var italicOn = false; - var sb = new StringBuilder(); - foreach (var line in HtmlUtil.FixInvalidItalicTags(text).SplitToLines()) - { - var s = line; - if (italicOn && !s.TrimStart().StartsWith("", StringComparison.Ordinal)) - { - s = "" + s; - } - - var endTagIndex = s.LastIndexOf("", StringComparison.Ordinal); - if (s.LastIndexOf("", StringComparison.Ordinal) > endTagIndex) - { - italicOn = true; - } - else if (endTagIndex >= 0) - { - italicOn = false; - } - - if (italicOn) - { - sb.AppendLine(s + ""); - } - else - { - sb.AppendLine(s); - } - } - - return sb.ToString().TrimEnd(); - } - - //TODO: Use bytes directly and not encoding - private static void EncodeText(List textBytes, string input, Encoding encoding, string displayStandardCode, string characterCodeTableNumber) - { - // italic/underline - var italicOn = (byte)0x80; - var italicOff = (byte)0x81; - var underlineOn = (byte)0x82; - var underlineOff = (byte)0x83; - var boxingOn = (byte)0x84; - var boxingOff = (byte)0x85; - - // newline - var newline = new byte[] { 0x8a, 0x8a }; - if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox && Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) - { - newline = new byte[] { 0x0a, 0x0a, 0x8a, 0x8a, 0x0d, 0x0b, 0x0b }; // 0a==end box, 0d==double height, 0b==start box - } - else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox) - { - var temp = new List - { - 0x0a, // 0a==end box, - 0x0a - }; - for (var i = 0; i < Configuration.Settings.SubtitleSettings.EbuStlNewLineRows; i++) - { - temp.Add(0x8a); - } - temp.Add(0x0b); // 0b==start box - temp.Add(0x0b); - newline = temp.ToArray(); - } - else if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) - { - newline = new byte[] { 0x8a, 0x8a, 0x0d, 0x0d }; // 0d==double height - } - - if (displayStandardCode == "0") // 0=Open subtitling - { - newline = new byte[] { 0x8A }; //8Ah=CR/LF - } - - byte? lastColor = null; - var sb = new StringBuilder(); - - // remove tags except "font", "italic", "underline" and "box" - var startFont = Guid.NewGuid().ToString(); - var endFont = Guid.NewGuid().ToString(); - var startItalic = Guid.NewGuid().ToString(); - var endItalic = Guid.NewGuid().ToString(); - var startUnderline = Guid.NewGuid().ToString(); - var endUnderline = Guid.NewGuid().ToString(); - var startBox = Guid.NewGuid().ToString(); - var endBox = Guid.NewGuid().ToString(); - var text = FixItalics(input); - text = text.Replace("", endFont); - text = text.Replace("", startItalic); - text = text.Replace("", endItalic); - text = text.Replace("", startItalic); - text = text.Replace("", endItalic); - text = text.Replace("", startUnderline); - text = text.Replace("", endUnderline); - text = text.Replace("", startUnderline); - text = text.Replace("", endUnderline); - text = text.Replace("", startBox); - text = text.Replace("", endBox); - text = text.Replace("", startBox); - text = text.Replace("", endBox); - text = HtmlUtil.RemoveHtmlTags(text, true); - text = text.Replace(startFont, ""); - text = text.Replace(startItalic, ""); - text = text.Replace(endItalic, ""); - text = text.Replace(startUnderline, ""); - text = text.Replace(endUnderline, ""); - text = text.Replace(startBox, ""); - text = text.Replace(endBox, ""); - - text = text.Replace(" ", " "); - var lastWasEndColor = false; - var lastWasStartColor = false; - var list = text.SplitToLines(); - for (var index = 0; index < list.Count; index++) - { - if (index > 0) - { - sb.Append(newline); - textBytes.AddRange(newline); - if (displayStandardCode != "0" && lastColor != null) - { - sb.Append(lastColor); - textBytes.Add(lastColor.Value); - } - } - - var line = list[index]; - var i = 0; - while (i < line.Length) - { - // Slicing instead of line.Substring(i): the old code allocated the whole - // rest of the line for every character it stepped over, so a teletext page - // of styled text copied itself once per character while being encoded. - var newStart = line.AsSpan(i); - if (newStart.StartsWith("', i); - if (end > 0) - { - if (displayStandardCode != "0") - { - lastColor = GetColorByte(encoding, line, i); - if (sb.EndsWith(' ')) - { - sb = new StringBuilder(sb.ToString().TrimEnd(' ')); - if (textBytes.Count > 0 && textBytes[textBytes.Count - 1] == 32) - { - textBytes.RemoveAt(textBytes.Count - 1); - } - } - - if (lastColor != null) - { - sb.Append(lastColor); - textBytes.Add(lastColor.Value); - } - } - - i = end + 1; - } - } - else if (newStart.SequenceEqual("".AsSpan())) - { - i += "".Length; - lastColor = null; - lastWasEndColor = true; - } - else if (newStart.StartsWith("".AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - i += "".Length; - - if (displayStandardCode != "0" && line.Length > i + 1) - { - var part = line.AsSpan(i); - if (part.StartsWith(" ".AsSpan(), StringComparison.Ordinal)) - { - i += "".Length; - textBytes.Add(italicOn); - } - else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) - { - i += "".Length; - textBytes.Add(italicOff); - } - else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) - { - i += "".Length; - textBytes.Add(underlineOn); - } - else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) - { - i += "".Length; - textBytes.Add(underlineOff); - } - else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) - { - i += "".Length; - textBytes.Add(boxingOn); - } - else if (newStart.StartsWith("".AsSpan(), StringComparison.Ordinal)) - { - i += "".Length; - textBytes.Add(boxingOff); - } - else - { - var ch = line[i]; - - // The one-character string this used to build per character is gone; - // every test below was a single-character comparison. - if (ch == ' ' && lastWasEndColor) - { - } - else if (ch == ' ' && lastWasStartColor) - { - } - else - { - if (ch == '#') - { - sb.Append(ch); - textBytes.Add(0x23); - } - else if (ch == 'Đ') - { - sb.Append(ch); - textBytes.Add(0xe2); - } - else if (ch == '–') // em dash - { - sb.Append(ch); - textBytes.Add(0xd0); - } - else - { - if (characterCodeTableNumber == "00") - { - if (newStart.Length > 1 && line[i + 1] == 'ı' && newStart.StartsWith("ı̂".AsSpan(), StringComparison.CurrentCulture)) // extended unicode char - rewritten as simple 'î' - looks the same as "î" but it's not...) - { - textBytes.AddRange(new byte[] { 0xc3, 0x69 }); // Ãi - simple î - i++; - } - else if ("ÀÈÌÒÙàèìòù".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc1, "ÀÈÌÒÙàèìòù", "AEIOUaeiou")); - } - else if ("ÁĆÉÍĹŃÓŔŚÚÝŹáćéģíĺńóŕśúýź".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc2, "ÁĆÉÍĹŃÓŔŚÚÝŹáćéģíĺńóŕśúýź", "ACEILNORSUYZacegilnorsuyz")); - } - else if ("ÂĈÊĜĤÎĴÔŜÛŴŶâĉêĝĥĵôŝûŵŷîı̂".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc3, "ÂĈÊĜĤÎĴÔŜÛŴŶâĉêĝĥîĵôŝûŵŷ", "ACEGHIJOSUWYaceghijosuwy")); - } - else if ("ÃĨÑÕŨãĩñõũ".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc4, "ÃĨÑÕŨãĩñõũ", "AINOUainou")); - } - else if ("ĀĒĪŌŪāēīōū".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc5, "ĀĒĪŌŪāēīōū", "AEIOUaeiou")); - } - else if ("ĂĞŬăğŭ".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc6, "ĂĞŬăğŭ", "AGUagu")); - } - else if ("ĊĖĠİŻċėġıż".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc7, "ĊĖĠİŻċėġıż", "CEGIZcegiz")); - } - else if ("ÄËÏÖÜŸäëïöüÿ".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xc8, "ÄËÏÖÜŸäëïöüÿ", "AEIOUYaeiouy")); - } - else if ("ÅŮåů".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xca, "ÅŮåů", "AUau")); - } - else if ("ÇĢĶĻŅŖŞŢçķļņŗşţ".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xcb, "ÇĢĶĻŅŖŞŢçķļņŗşţ", "CGKLNRSTcklnrst")); - } - else if ("ŐŰőű".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xcd, "ŐŰőű", "OUou")); - } - else if ("ĄĘĮŲąęįų".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xce, "ĄĘĮŲąęįų", "AEIUaeiu")); - } - else if ("ČĎĚĽŇŘŠŤŽčďěľňřšťž".Contains(ch)) - { - textBytes.AddRange(ReplaceSpecialCharactersWithTwoByteEncoding(encoding, ch, 0xcf, "ČĎĚĽŇŘŠŤŽčďěľňřšťž", "CDELNRSTZcdelnrstz")); - } - else if (SpecialAsciiCodesByChar.TryGetValue(ch, out var specialAsciiCode)) - { - textBytes.Add((byte)specialAsciiCode); - } - else - { - sb.Append(ch); - AddEncodedChar(textBytes, encoding, ch); - } - } - else - { - sb.Append(ch); - AddEncodedChar(textBytes, encoding, ch); - } - } - } - - i++; - lastWasEndColor = false; - lastWasStartColor = false; - } - } - } - - if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox && displayStandardCode != "0") - { - textBytes.AddRange(new byte[] { 0x0a, 0x0a }); //a=end box - } - } - - - /// - /// Appends one encoded character. This was encoding.GetBytes(line.Substring(i, 1)) - - /// a one-character string plus a byte array for every character written. - /// - private static void AddEncodedChar(List textBytes, Encoding encoding, char ch) - { - Span chars = stackalloc char[1]; - chars[0] = ch; - Span bytes = stackalloc byte[8]; - var count = encoding.GetBytes(chars, bytes); - for (var k = 0; k < count; k++) - { - textBytes.Add(bytes[k]); - } - } - - private static byte? GetColorByte(Encoding encoding, string line, int i) - { - var end = line.IndexOf('>', i); - if (end > 0) - { - var f = line.Substring(i, end - i); - var colorStart = f.IndexOf(" color=", StringComparison.OrdinalIgnoreCase); - if (colorStart > 1) - { - // The attribute value may be double-quoted, single-quoted or bare - // ("" is common in SubRip files). The old code - // assumed a closing double quote and crashed the whole save on a - // negative Substring length when there was none. - var color = f.Substring(colorStart + " color=".Length).TrimStart(); - if (color.Length > 0 && (color[0] == '"' || color[0] == '\'')) - { - var quote = color[0]; - var closingQuote = color.IndexOf(quote, 1); - color = closingQuote > 0 ? color.Substring(1, closingQuote - 1) : color.Substring(1); - } - else - { - var space = color.IndexOf(' '); - if (space > 0) - { - color = color.Substring(0, space); - } - } - - color = color.Trim().Trim('#'); - if (color.Length > 0) - { - return GetNearestColorCode(color); - } - } - } - - return null; - } - - - - - private static byte[] ReplaceSpecialCharactersWithTwoByteEncoding(Encoding encoding, char ch, byte specialCharacter, string originalCharacters, string newCharacters) - { - if (originalCharacters.Length != newCharacters.Length) - { - throw new ArgumentException("originalCharacters and newCharacters must have equal length"); - } - - for (var i = 0; i < newCharacters.Length; i++) - { - if (originalCharacters[i] == ch) - { - var byteArr = new List { specialCharacter }; - byteArr.AddRange(encoding.GetBytes(newCharacters[i].ToString())); - return byteArr.ToArray(); - } - } - - return encoding.GetBytes(ch.ToString()); - } - - public static byte GetFrameFromMilliseconds(int milliseconds, double frameRate, out byte extraSeconds) - { - extraSeconds = 0; - var fr = Math.Round(milliseconds / (TimeCode.BaseUnit / frameRate)); - if (fr >= frameRate) - { - fr = 0; - extraSeconds = 1; - } - - return (byte)fr; - } - } - - public override string Extension => ".stl"; - - public const string NameOfFormat = "EBU STL"; - - public override string Name => NameOfFormat; - - // Carries the teletext row the line starts on (MarginV) and the row count in the GSI block. - public override bool HasPositionSupport => true; - - internal struct SpecialCharacter - { - internal SpecialCharacter(string character, bool switchOrder = false, int priority = 2) - { - Character = character; - SwitchOrder = switchOrder; - Priority = priority; - } - - internal string Character { get; set; } - internal bool SwitchOrder { get; set; } - internal int Priority { get; set; } - } - - /// - /// True when is a full 1024-character EBU STL header - present after - /// loading an STL file or after the EBU save options dialog has stored one on the subtitle. - /// The disk format code sits at 3..10 ("STLnn.mm") and is matched there rather than by - /// listing frame rates, so every rate the save options dialog offers is recognized (STL23 - /// used to fall through, which threw the whole header away on save). - /// - public static bool IsStlHeader(string header) - { - return header != null && - header.Length == 1024 && - header.Substring(3, 3) == "STL"; - } - - /// - /// The number of rows the vertical position (VP) of a text block counts against. Teletext - /// always has a 23 row page, whatever the header's MNR says. Open subtitling files often - /// carry the number of rows a subtitle may occupy (02) rather than a page height, and - /// laying lines out against a 2 row page would put them mid-screen - so that case gets a - /// 15 row page. Shared by the writer and the video preview so both place a line the same. - /// - public static int GetDisplayRowCount(EbuGeneralSubtitleInformation header) - { - if (header.DisplayStandardCode == "1" || header.DisplayStandardCode == "2") // teletext - { - return 23; - } - - if (header.DisplayStandardCode == "0" && header.MaximumNumberOfDisplayableRows == "02") // open subtitling - { - return 15; - } - - if (int.TryParse(header.MaximumNumberOfDisplayableRows, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rows) && rows > 1) - { - return rows; - } - - return 23; - } - - /// - /// True when is an STL header whose display standard code is - /// teletext (level 1 or 2). Open subtitling (code 0) has no teletext page, so the 40 cell - /// row and its control-code overhead do not apply to it. - /// - public static bool IsTeletextHeader(string header) - { - if (!IsStlHeader(header)) - { - return false; - } - - try - { - var displayStandardCode = ReadHeader(GetEncoding(header.Substring(0, 3)).GetBytes(header)).DisplayStandardCode; - return displayStandardCode == "1" || displayStandardCode == "2"; - } - catch - { - return false; - } - } - - public bool Save(string fileName, Subtitle subtitle) - { - return Save(fileName, subtitle, false); - } - - public bool Save(string fileName, Subtitle subtitle, bool batchMode, EbuGeneralSubtitleInformation header = null) - { - using (var ms = new MemoryStream()) - { - var ok = Save(fileName, ms, subtitle, batchMode, header); - if (ok) - { - ms.Position = 0; - using (var fs = new FileStream(fileName, FileMode.Create, FileAccess.Write)) - { - ms.CopyTo(fs); - } - } - return ok; - } - } - - public bool Save(string fileName, Stream stream, Subtitle subtitle, bool batchMode, EbuGeneralSubtitleInformation header) - { - if (header == null) - { - header = new EbuGeneralSubtitleInformation { LanguageCode = AutoDetectLanguageCode(subtitle) }; - - // In an STL colours only exist as teletext control codes - open subtitling (the - // default) has no colour mechanism at all. A coloured subtitle saved with an - // invented header must be teletext level 1, or every colour would be dropped. - if (subtitle.Paragraphs.Any(p => p.Text != null && p.Text.Contains("= 1 && - teletextPosition <= rows; - - if (hasTeletextPosition) - { - // The text may have been re-wrapped since it was read, so a position that no - // longer leaves room for every row would push the tail off the page. - var extraRows = Math.Max(0, Utilities.GetNumberOfLines(text) - 1) * Configuration.Settings.SubtitleSettings.EbuStlNewLineRows; - if (teletextPosition + extraRows > rows) - { - teletextPosition = Math.Max(1, rows - extraRows); - } - - tti.VerticalPosition = (byte)teletextPosition; - } - else if (text.StartsWith("{\\an7}", StringComparison.Ordinal) || text.StartsWith("{\\an8}", StringComparison.Ordinal) || text.StartsWith("{\\an9}", StringComparison.Ordinal)) - { - tti.VerticalPosition = (byte)Configuration.Settings.SubtitleSettings.EbuStlMarginTop; // top (vertical) - if (header.DisplayStandardCode == "1" || header.DisplayStandardCode == "2") // teletext - { - tti.VerticalPosition++; - } - } - else if (text.StartsWith("{\\an4}", StringComparison.Ordinal) || text.StartsWith("{\\an5}", StringComparison.Ordinal) || text.StartsWith("{\\an6}", StringComparison.Ordinal)) - { - tti.VerticalPosition = (byte)(rows / 2); // middle (vertical) - } - else - { - var numberOfLineBreaks = Math.Max(0, Utilities.GetNumberOfLines(text) - 1); - var startRow = rows - Configuration.Settings.SubtitleSettings.EbuStlMarginBottom - - numberOfLineBreaks * Configuration.Settings.SubtitleSettings.EbuStlNewLineRows; - if (startRow < 0) - { - startRow = 0; - } - - tti.VerticalPosition = (byte)startRow; // bottom (vertical) - } - - tti.JustificationCode = EbuUiHelper.JustificationCode; // use default justification - if (text.StartsWith("{\\an1}", StringComparison.Ordinal) || text.StartsWith("{\\an4}", StringComparison.Ordinal) || text.StartsWith("{\\an7}", StringComparison.Ordinal)) - { - tti.JustificationCode = 1; // 01h=left-justified text - } - else if (text.StartsWith("{\\an3}", StringComparison.Ordinal) || text.StartsWith("{\\an6}", StringComparison.Ordinal) || text.StartsWith("{\\an9}", StringComparison.Ordinal)) - { - tti.JustificationCode = 3; // 03h=right-justified - } - else if (text.StartsWith("{\\an2}", StringComparison.Ordinal) || text.StartsWith("{\\an5}", StringComparison.Ordinal) || text.StartsWith("{\\an8}", StringComparison.Ordinal)) - { - tti.JustificationCode = 2; // 02h=centred text - } - - // replace some unsupported characters - text = text.Replace("„", "\""); // lower quote - text = text.Replace("‚", "'"); // lower apostrophe - text = text.Replace("’", "'"); // right single quotation mark - text = text.Replace("♫", "♪"); // only music single note supported - text = text.Replace("…", "..."); // fix Unicode ellipsis - - tti.SubtitleNumber = (ushort)subtitleNumber; - tti.TextField = text; - int startTag = tti.TextField.IndexOf('}'); - if (tti.TextField.StartsWith("{\\", StringComparison.Ordinal) && startTag > 0 && startTag < 10) - { - tti.TextField = tti.TextField.Remove(0, startTag + 1); - } - - if (!p.StartTime.IsMaxTime) - { - tti.TimeCodeInHours = p.StartTime.Hours; - tti.TimeCodeInMinutes = p.StartTime.Minutes; - tti.TimeCodeInSeconds = p.StartTime.Seconds; - tti.TimeCodeInMilliseconds = p.StartTime.Milliseconds; - } - - if (!p.EndTime.IsMaxTime) - { - tti.TimeCodeOutHours = p.EndTime.Hours; - tti.TimeCodeOutMinutes = p.EndTime.Minutes; - tti.TimeCodeOutSeconds = p.EndTime.Seconds; - tti.TimeCodeOutMilliseconds = p.EndTime.Milliseconds; - } - - var extra = new MemoryStream(); - buffer = tti.GetBytes(header, extra); - if (extra.Length > 0) - { - // As many extension blocks as the overflow needs (112 text bytes each). - // EBN 0xFF marks the last block of the subtitle; earlier blocks are numbered, - // which is what makes LoadSubtitle merge them back together. - var extraBytes = extra.ToArray(); - var extraBlockCount = (extraBytes.Length + 111) / 112; - - buffer[3] = 0; // ExtensionBlockNumber - more blocks follow - stream.Write(buffer, 0, buffer.Length); - numberOfTtiBlocks++; - - for (var extraBlock = 0; extraBlock < extraBlockCount; extraBlock++) - { - var extraBuffer = tti.GetBytesExtra(header, extraBytes, extraBlock * 112); - extraBuffer[3] = extraBlock == extraBlockCount - 1 - ? (byte)0xff - : (byte)Math.Min(extraBlock + 1, 0xef); - stream.Write(extraBuffer, 0, extraBuffer.Length); - numberOfTtiBlocks++; - } - } - else - { - stream.Write(buffer, 0, buffer.Length); - numberOfTtiBlocks++; - } - subtitleNumber++; - } - // Rewrite GSI "Total Number of TTI blocks" (offset 238, 5 ASCII digits) now that the - // real count is known - it was written as the subtitle count, so any file containing - // an extension block under-reported it and tools walking the file by TNB lost the tail. - if (stream.CanSeek && numberOfTtiBlocks > 0) - { - var position = stream.Position; - var tnb = Encoding.ASCII.GetBytes(numberOfTtiBlocks.ToString("D5", CultureInfo.InvariantCulture)); - stream.Seek(238, SeekOrigin.Begin); - stream.Write(tnb, 0, tnb.Length); - stream.Seek(position, SeekOrigin.Begin); - } - - return true; - } - - private static string AutoDetectLanguageCode(Subtitle subtitle) - { - if (subtitle == null || subtitle.Paragraphs.Count == 0) - { - return "00"; // Unknown/not applicable - } - - var languageCode = LanguageAutoDetect.AutoDetectGoogleLanguageOrNull(subtitle); - switch (languageCode) - { - case "sq": return "01"; // Albanian - case "br": return "02"; // Breton - case "ca": return "03"; // Catalan - case "hr": return "04"; // Croatian - case "cy": return "05"; // Welsh - case "cs": return "06"; // Czech - case "da": return "07"; // Danish - case "de": return "08"; // German - case "en": return "09"; // English - case "es": return "0A"; // Spanish - case "eo": return "0B"; // Esperanto - case "et": return "0C"; // Estonian - case "eu": return "0D"; // Basque - case "fo": return "0E"; // Faroese - case "fr": return "0F"; // French - case "fy": return "10"; // Frisian - case "ga": return "11"; // Irish - case "gd": return "12"; // Gaelic - case "gl": return "13"; // Galician - case "is": return "14"; // Icelandic - case "it": return "15"; // Italian - case "Lappish": return "16"; // Lappish - case "la": return "17"; // Latin - case "lv": return "18"; // Latvian": - case "lb": return "19"; // Luxembourgi - case "lt": return "1A"; // Lithuanian - case "hu": return "1B"; // Hungarian - case "mt": return "1C"; // Maltese - case "nl": return "1D"; // Dutch - case "nb": return "1E"; // Norwegian - case "oc": return "1F"; // Occitan - case "pl": return "20"; // Polish - case "pt": return "21"; // Portuguese - case "ro": return "22"; // Romanian - case "rm": return "23"; // Romansh - case "sr": return "24"; // Serbian - case "sk": return "25"; // Slovak - case "sl": return "26"; // Slovenian - case "fi": return "27"; // Finnish - case "sv": return "28"; // Swedish - case "tr": return "29"; // Turkish - case "Flemish": return "2A"; // Flemish - case "Wallon": return "2B"; // Wallon - } - - return "09"; // English - default - } - - public override bool IsMine(List lines, string fileName) - { - if (!string.IsNullOrEmpty(fileName) && File.Exists(fileName)) - { - var fi = new FileInfo(fileName); - if (fi.Length >= 1024 + 128 && fi.Length < 2048000) // not too small or too big - { - try - { - var buffer = FileUtil.ReadBytesShared(fileName, 1024); - var header = ReadHeader(buffer); - if (header.DiskFormatCode.StartsWith("STL23", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL24", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL25", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL29", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL30", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL35", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL48", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL50", StringComparison.Ordinal) || - header.DiskFormatCode.StartsWith("STL60", StringComparison.Ordinal) || - "012 ".Contains(header.DisplayStandardCode) && "437|850|860|863|865".Contains(header.CodePageNumber)) - { - return Utilities.IsInteger(header.CodePageNumber) || fileName.EndsWith(".stl", StringComparison.OrdinalIgnoreCase); - } - } - catch - { - return false; - } - } - } - return false; - } - - public override string ToText(Subtitle subtitle, string title) - { - return "Not supported!"; - } - - /// - /// Drops what only an STL can carry: the teletext box tags and the teletext row in MarginV. - /// - /// - /// No other format knows either of them - the box tags used to end up as visible text in the - /// video preview and in the saved file, and a row number ("20") counts as an ASSA pixel - /// margin, which moved every line by a near random amount. - /// - public override void RemoveNativeFormatting(Subtitle subtitle, SubtitleFormat newFormat) - { - // The other teletext capable formats understand both: EBU-TT maps the box to a black - // span background and the row to a region, DVB teletext boxes everything and places - // lines by row - exchanging a subtitle between them keeps the teletext look. - if (newFormat is EbuTt) - { - return; - } - - var keepRows = newFormat is DvbTeletext; - foreach (var p in subtitle.Paragraphs) - { - if (p.Text != null && p.Text.Contains("", StringComparison.Ordinal)) - { - p.Text = p.Text.Replace("", string.Empty).Replace("", string.Empty); - } - - if (!keepRows) - { - p.MarginV = null; - } - } - } - - public void LoadSubtitle(Subtitle subtitle, byte[] buffer) - { - subtitle.Paragraphs.Clear(); - var header = ReadHeader(buffer); - subtitle.Header = header.ToString(); - if (header.DisplayStandardCode == "1" || header.DisplayStandardCode == "2" || HasTeletextColorCodes(buffer)) - { - SeedTeletextBoxAndDoubleHeightSettings(buffer); - } - Paragraph last = null; - byte lastExtensionBlockNumber = 0xff; - JustificationCodes = new List(); - // Resolve this import once, independently of concurrent video-frame-rate updates. - var overrideReadFrameRate = OverrideReadFrameRate; - var readFrameRate = overrideReadFrameRate > 20 ? overrideReadFrameRate : header.FrameRate; - Configuration.Settings.General.CurrentFrameRate = readFrameRate; - - foreach (var tti in ReadTextAndTiming(buffer, header, readFrameRate)) - { - if (tti.ExtensionBlockNumber != 0xfe) // FEh : Reserved for User Data - { - var p = new Paragraph - { - Text = tti.TextField, - StartTime = new TimeCode(tti.TimeCodeInHours, tti.TimeCodeInMinutes, tti.TimeCodeInSeconds, tti.TimeCodeInMilliseconds), - EndTime = new TimeCode(tti.TimeCodeOutHours, tti.TimeCodeOutMinutes, tti.TimeCodeOutSeconds, tti.TimeCodeOutMilliseconds), - MarginV = tti.VerticalPosition.ToString(CultureInfo.InvariantCulture) - }; - - if (Math.Abs(p.StartTime.TotalMilliseconds) < 0.01 && Math.Abs(p.EndTime.TotalMilliseconds) < 0.01) - { - p.StartTime.TotalMilliseconds = TimeCode.MaxTimeTotalMilliseconds; - p.EndTime.TotalMilliseconds = TimeCode.MaxTimeTotalMilliseconds; - } - - if (lastExtensionBlockNumber != 0xff && last != null) - { - last.Text += p.Text; // merge text - } - else - { - subtitle.Paragraphs.Add(p); - last = p; - } - - p.Text = HtmlUtil.FixInvalidItalicTags(p.Text); - lastExtensionBlockNumber = tti.ExtensionBlockNumber; - } - } - subtitle.Renumber(); - Header = header; - } - - public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) - { - LoadSubtitle(subtitle, FileUtil.ReadAllBytesShared(fileName)); - } - - /// - /// The teletext box/double-height write settings are global, but whether a file actually - /// uses boxes or double height is a property of the file: seed the settings from the - /// loaded bytes so a plain load-save round trip does not add control codes that were not - /// there, and so the export dialog shows the file's actual state. - /// - private static void SeedTeletextBoxAndDoubleHeightSettings(byte[] buffer) - { - const int startOfTextAndTimingBlock = 1024; - const int ttiSize = 128; - const byte startBox = 0x0b; - const byte doubleHeight = 0x0d; - - var useBox = false; - var useDoubleHeight = false; - var index = startOfTextAndTimingBlock; - while (index + ttiSize <= buffer.Length && !(useBox && useDoubleHeight)) - { - if (buffer[index + 3] != 0xfe && buffer[index + 15] == 0) // skip user data and comment blocks - { - for (var i = index + 16; i < index + ttiSize; i++) - { - var b = buffer[i]; - if (b == startBox) - { - useBox = true; - } - else if (b == doubleHeight) - { - useDoubleHeight = true; - } - } - } - - index += ttiSize; - } - - Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox = useBox; - Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight = useDoubleHeight; - } - - /// - /// Colors only exist as teletext control codes, so a file that declares open subtitling - /// (DSC=0) is read without them. Some tools - Adobe Premiere among them - write the - /// teletext codes anyway and still stamp the header with 0, and reading those strictly - /// throws every color away. Nothing else uses 00h-1Fh in an open subtitling text field, - /// so their presence is taken as proof the file really is teletext coded. - /// - private static bool HasTeletextColorCodes(byte[] buffer) - { - const int startOfTextAndTimingBlock = 1024; - const int ttiSize = 128; - - var index = startOfTextAndTimingBlock; - while (index + ttiSize <= buffer.Length) - { - if (buffer[index + 3] != 0xfe && buffer[index + 15] == 0) // skip user data and comment blocks - { - for (var i = index + 16; i < index + ttiSize; i++) - { - // 00h (black) is left out on purpose: a writer that pads the text field with - // zero bytes instead of 8Fh would otherwise look like a colored file. - if (buffer[i] >= 0x01 && buffer[i] <= 0x07) - { - return true; - } - } - } - - index += ttiSize; - } - - return false; - } - - public static EbuGeneralSubtitleInformation ReadHeader(byte[] buffer) - { - var enc = GetEncoding(Encoding.ASCII.GetString(buffer, 0, 3)); - var header = new EbuGeneralSubtitleInformation - { - CodePageNumber = enc.GetString(buffer, 0, 3), - DiskFormatCode = enc.GetString(buffer, 3, 8), - DisplayStandardCode = enc.GetString(buffer, 11, 1), - CharacterCodeTableNumber = enc.GetString(buffer, 12, 2), - LanguageCode = enc.GetString(buffer, 14, 2), - OriginalProgrammeTitle = enc.GetString(buffer, 16, 32), - OriginalEpisodeTitle = enc.GetString(buffer, 48, 32), - TranslatedProgrammeTitle = enc.GetString(buffer, 80, 32), - TranslatedEpisodeTitle = enc.GetString(buffer, 112, 32), - TranslatorsName = enc.GetString(buffer, 144, 32), - TranslatorsContactDetails = enc.GetString(buffer, 176, 32), - SubtitleListReferenceCode = enc.GetString(buffer, 208, 16), - CreationDate = enc.GetString(buffer, 224, 6), - RevisionDate = enc.GetString(buffer, 230, 6), - RevisionNumber = enc.GetString(buffer, 236, 2), - TotalNumberOfTextAndTimingInformationBlocks = enc.GetString(buffer, 238, 5), - TotalNumberOfSubtitles = enc.GetString(buffer, 243, 5), - TotalNumberOfSubtitleGroups = enc.GetString(buffer, 248, 3), - MaximumNumberOfDisplayableCharactersInAnyTextRow = enc.GetString(buffer, 251, 2), - MaximumNumberOfDisplayableRows = enc.GetString(buffer, 253, 2), - TimeCodeStatus = enc.GetString(buffer, 255, 1), - TimeCodeStartOfProgramme = enc.GetString(buffer, 256, 8), - TimeCodeFirstInCue = enc.GetString(buffer, 264, 8), - TotalNumberOfDisks = enc.GetString(buffer, 272, 1), - DiskSequenceNumber = enc.GetString(buffer, 273, 1), - CountryOfOrigin = enc.GetString(buffer, 274, 3), - Publisher = enc.GetString(buffer, 277, 32), - EditorsName = enc.GetString(buffer, 309, 32), - EditorsContactDetails = enc.GetString(buffer, 341, 32), - SpareBytes = enc.GetString(buffer, 373, 75), - UserDefinedArea = enc.GetString(buffer, 448, 576) - }; - return header; - } - - public static Encoding GetEncoding(string codePageNumber) - { - try - { - return Encoding.GetEncoding(int.TryParse(codePageNumber, out int cp) ? cp : 437); - } - catch (NotSupportedException) - { - return Encoding.GetEncoding(437); - } - } - - /// - /// Get text with regard code page from header - /// - /// Skip next character - /// EBU header - /// data buffer - /// index to current byte in buffer - /// Character at index - private static string GetCharacter(out bool skipNext, EbuGeneralSubtitleInformation header, byte[] buffer, int index) - { - skipNext = false; - - if (header.LanguageCode == LanguageCodeChinese) - { - skipNext = true; - return Encoding.GetEncoding(1200).GetString(buffer, index, 2); // 16-bit Unicode - } - - if (header.CharacterCodeTableNumber == "00") - { - var b = buffer[index]; - if (SpecialAsciiCodes.TryGetValue(b, out var s)) - { - return s; - } - - Encoding encoding; - //note that 0xC1—0xCF combines characters - http://en.wikipedia.org/wiki/ISO/IEC_6937 - try - { - encoding = Encoding.GetEncoding(20269); - } - catch - { - encoding = Encoding.ASCII; - } - - if (index + 2 > buffer.Length) - { - return string.Empty; - } - - var next = encoding.GetString(buffer, index + 1, 1); - switch (b) - { - case 0xc1: // Grave - skipNext = @"AEIOUaeiou".Contains(next); - switch (next) - { - case "A": return "À"; - case "E": return "È"; - case "I": return "Ì"; - case "O": return "Ò"; - case "U": return "Ù"; - case "a": return "à"; - case "e": return "è"; - case "i": return "ì"; - case "o": return "ò"; - case "u": return "ù"; - } - return string.Empty; - case 0xc2: // Acute - skipNext = @"ACEILNORSUYZacegilnorsuyz".Contains(next); - switch (next) - { - case "A": return "Á"; - case "C": return "Ć"; - case "E": return "É"; - case "I": return "Í"; - case "L": return "Ĺ"; - case "N": return "Ń"; - case "O": return "Ó"; - case "R": return "Ŕ"; - case "S": return "Ś"; - case "U": return "Ú"; - case "Y": return "Ý"; - case "Z": return "Ź"; - case "a": return "á"; - case "c": return "ć"; - case "e": return "é"; - case "g": return "ģ"; - case "i": return "í"; - case "l": return "ĺ"; - case "n": return "ń"; - case "o": return "ó"; - case "r": return "ŕ"; - case "s": return "ś"; - case "u": return "ú"; - case "y": return "ý"; - case "z": return "ź"; - } - return string.Empty; - case 0xc3: // Circumflex - skipNext = @"ACEGHIJOSUWYaceghjosuwyıi".Contains(next); - switch (next) - { - case "A": return "Â"; - case "C": return "Ĉ"; - case "E": return "Ê"; - case "G": return "Ĝ"; - case "H": return "Ĥ"; - case "I": return "Î"; - case "J": return "Ĵ"; - case "O": return "Ô"; - case "S": return "Ŝ"; - case "U": return "Û"; - case "W": return "Ŵ"; - case "Y": return "Ŷ"; - case "a": return "â"; - case "c": return "ĉ"; - case "e": return "ê"; - case "g": return "ĝ"; - case "h": return "ĥ"; - case "j": return "ĵ"; - case "o": return "ô"; - case "s": return "ŝ"; - case "u": return "û"; - case "w": return "ŵ"; - case "y": return "ŷ"; - case "ı": return "ı̂"; - case "i": return "î"; - } - return string.Empty; - case 0xc4: // Tilde - skipNext = @"AINOUainou".Contains(next); - switch (next) - { - case "A": return "Ã"; - case "I": return "Ĩ"; - case "N": return "Ñ"; - case "O": return "Õ"; - case "U": return "Ũ"; - case "a": return "ã"; - case "i": return "ĩ"; - case "n": return "ñ"; - case "o": return "õ"; - case "u": return "ũ"; - } - return string.Empty; - case 0xc5: // Macron - skipNext = @"AEIOUaeiou".Contains(next); - switch (next) - { - case "A": return "Ā"; - case "E": return "Ē"; - case "I": return "Ī"; - case "O": return "Ō"; - case "U": return "Ū"; - case "a": return "ā"; - case "e": return "ē"; - case "i": return "ī"; - case "o": return "ō"; - case "u": return "ū"; - } - return string.Empty; - case 0xc6: // Breve - skipNext = @"AGUagu".Contains(next); - switch (next) - { - case "A": return "Ă"; - case "G": return "Ğ"; - case "U": return "Ŭ"; - case "a": return "ă"; - case "g": return "ğ"; - case "u": return "ŭ"; - } - return string.Empty; - case 0xc7: // Dot - skipNext = @"CEGIZcegiz".Contains(next); - switch (next) - { - case "C": return "Ċ"; - case "E": return "Ė"; - case "G": return "Ġ"; - case "I": return "İ"; - case "Z": return "Ż"; - case "c": return "ċ"; - case "e": return "ė"; - case "g": return "ġ"; - case "i": return "ı"; - case "z": return "ż"; - } - return string.Empty; - case 0xc8: // Umlaut or diæresis - skipNext = @"AEIOUYaeiouy".Contains(next); - switch (next) - { - case "A": return "Ä"; - case "E": return "Ë"; - case "I": return "Ï"; - case "O": return "Ö"; - case "U": return "Ü"; - case "Y": return "Ÿ"; - case "a": return "ä"; - case "e": return "ë"; - case "i": return "ï"; - case "o": return "ö"; - case "u": return "ü"; - case "y": return "ÿ"; - } - return string.Empty; - case 0xca: // Ring - skipNext = @"AUau".Contains(next); - switch (next) - { - case "A": return "Å"; - case "U": return "Ů"; - case "a": return "å"; - case "u": return "ů"; - } - return string.Empty; - case 0xcb: // Cedilla - skipNext = @"CGKLNRSTcklnrst".Contains(next); - switch (next) - { - case "C": return "Ç"; - case "G": return "Ģ"; - case "K": return "Ķ"; - case "L": return "Ļ"; - case "N": return "Ņ"; - case "R": return "Ŗ"; - case "S": return "Ş"; - case "T": return "Ţ"; - case "c": return "ç"; - case "k": return "ķ"; - case "l": return "ļ"; - case "n": return "ņ"; - case "r": return "ŗ"; - case "s": return "ş"; - case "t": return "ţ"; - } - return string.Empty; - case 0xcd: // DoubleAcute - skipNext = @"OUou".Contains(next); - switch (next) - { - case "O": return "Ő"; - case "U": return "Ű"; - case "o": return "ő"; - case "u": return "ű"; - } - return string.Empty; - case 0xce: // Ogonek - skipNext = @"AEIUaeiu".Contains(next); - switch (next) - { - case "A": return "Ą"; - case "E": return "Ę"; - case "I": return "Į"; - case "U": return "Ų"; - case "a": return "ą"; - case "e": return "ę"; - case "i": return "į"; - case "u": return "ų"; - } - return string.Empty; - case 0xcf: // Caron - skipNext = @"CDELNRSTZcdelnrstz".Contains(next); - switch (next) - { - case "C": return "Č"; - case "D": return "Ď"; - case "E": return "Ě"; - case "L": return "Ľ"; - case "N": return "Ň"; - case "R": return "Ř"; - case "S": return "Š"; - case "T": return "Ť"; - case "Z": return "Ž"; - case "c": return "č"; - case "d": return "ď"; - case "e": return "ě"; - case "l": return "ľ"; - case "n": return "ň"; - case "r": return "ř"; - case "s": return "š"; - case "t": return "ť"; - case "z": return "ž"; - } - return string.Empty; - default: - return encoding.GetString(buffer, index, 1); - } - } - - if (header.CharacterCodeTableNumber == "01") // Latin/Cyrillic alphabet - from ISO 8859/5-1988 - { - return Encoding.GetEncoding("ISO-8859-5").GetString(buffer, index, 1); - } - - if (header.CharacterCodeTableNumber == "02") // Latin/Arabic alphabet - from ISO 8859/6-1987 - { - return Encoding.GetEncoding("ISO-8859-6").GetString(buffer, index, 1); - } - - if (header.CharacterCodeTableNumber == "03") // Latin/Greek alphabet - from ISO 8859/7-1987 - { - return Encoding.GetEncoding("ISO-8859-7").GetString(buffer, index, 1); // or ISO-8859-1 ? - } - - if (header.CharacterCodeTableNumber == "04") // Latin/Hebrew alphabet - from ISO 8859/8-1988 - { - return Encoding.GetEncoding("ISO-8859-8").GetString(buffer, index, 1); - } - - return string.Empty; - } - - /// - /// Read Text and Timing Information (TTI) block. - /// Each Text and Timing Information (TTI) block consists of 128 bytes. - /// - private IEnumerable ReadTextAndTiming(byte[] buffer, EbuGeneralSubtitleInformation header, double readFrameRate) - { - const int startOfTextAndTimingBlock = 1024; - const int ttiSize = 128; - const byte italicsOn = 0x80; - const byte italicsOff = 0x81; - const byte underlineOn = 0x82; - const byte underlineOff = 0x83; - const byte boxingOn = 0x84; - const byte boxingOff = 0x85; - - var list = new List(); - var hasTeletextColorCodes = header.DisplayStandardCode == "0" && HasTeletextColorCodes(buffer); - var index = startOfTextAndTimingBlock; - var sb = new StringBuilder(); - // EBN 0xFF marks the LAST block of a subtitle, so a block continues the previous one - // when the previous block's EBN was not 0xFF - the same rule LoadSubtitle merges on. - byte previousExtensionBlockNumber = 0xff; - while (index + ttiSize <= buffer.Length) - { - BeforeReadTti?.Invoke((index - startOfTextAndTimingBlock) / ttiSize); - var tti = new EbuTextTimingInformation - { - SubtitleGroupNumber = buffer[index], - SubtitleNumber = (ushort)(buffer[index + 2] * 256 + buffer[index + 1]), - ExtensionBlockNumber = buffer[index + 3], - CumulativeStatus = buffer[index + 4], - TimeCodeInHours = buffer[index + 5 + 0], - TimeCodeInMinutes = buffer[index + 5 + 1], - TimeCodeInSeconds = buffer[index + 5 + 2], - TimeCodeInMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 5 + 3], readFrameRate), 999), - TimeCodeOutHours = buffer[index + 9 + 0], - TimeCodeOutMinutes = buffer[index + 9 + 1], - TimeCodeOutSeconds = buffer[index + 9 + 2], - TimeCodeOutMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 9 + 3], readFrameRate), 999), - VerticalPosition = buffer[index + 13], - JustificationCode = buffer[index + 14], - CommentFlag = buffer[index + 15] - }; - JustificationCodes.Add(tti.JustificationCode); - - // Text block - // - has a fixed length of 112 byte - // - 8Ah = new line - // - unused space = 8Fh - var i = index + 16; // text block start at 17th byte (index 16) - var open = header.DisplayStandardCode != "1" && header.DisplayStandardCode != "2"; - var closed = header.DisplayStandardCode != "0" || hasTeletextColorCodes; - var max = i + 112; - sb.Clear(); - var lastWasNewLine = false; - while (i < max) - { - var b = buffer[i]; - if (b <= 0x1f) // Closed - Teletext control codes - { - if (closed) - { - var tag = GetColorOrTag(b); - if (!string.IsNullOrEmpty(tag)) - { - CloseFontTagIfNewColor(sb, tag); - } - } - } - else if (b >= 0x20 && b <= 0x7f) // Both - Character codes - { - var ch = GetCharacter(out var skipNext, header, buffer, i); - sb.Append(ch); - if (skipNext) - { - i++; - } - } - else if (b >= 0x80 && b <= 0x85) // Open - italic/underline/boxing - { - if (open) - { - if (b == italicsOn && header.LanguageCode != LanguageCodeChinese) - { - sb.Append(""); - } - else if (b == italicsOff && header.LanguageCode != LanguageCodeChinese) - { - sb.Append(""); - } - else if (b == underlineOn && header.LanguageCode != LanguageCodeChinese) - { - sb.Append(""); - } - else if (b == underlineOff && header.LanguageCode != LanguageCodeChinese) - { - sb.Append(""); - } - else if (b == boxingOn && header.LanguageCode != LanguageCodeChinese) - { - sb.Append(""); - } - else if (b == boxingOff && header.LanguageCode != LanguageCodeChinese) - { - sb.Append(""); - } - } - } - else if (b >= 0x86 && b <= 0x89) // Both - Reserved for future use - { - } - else if (b == 0x8a) // Both - CR/LF - { - if (!lastWasNewLine) - { - AddMissingClosingTag(sb); - sb.AppendLine(); - lastWasNewLine = true; - i++; - continue; - } - } - else if (b >= 0x8b && b <= 0x8e) // Both - Reserved for future use - { - } - else if (b == 0x8f) // Both - unused space - { - } - else if (b >= 0x90 && b <= 0x9f) // Both - Reserved for future use - { - } - else if (b >= 0xa1 && b <= 0xff) // Both - Character codes - { - var ch = GetCharacter(out var skipNext, header, buffer, i); - if (sb.EndsWith('>') && sb.ToString().EndsWith("")) - { - if (ch != " ") - { - sb.Append(' '); - } - } - - sb.Append(ch); - if (skipNext) - { - i++; - } - } - - lastWasNewLine = false; - i++; - } - - AddMissingClosingTag(sb); - tti.TextField = FixSpacesAndTags(sb.ToString()); - - if (!int.TryParse(header.MaximumNumberOfDisplayableRows, out var rows)) - { - rows = 23; - } - - // Only the first block of a subtitle carries the alignment tag. A continuation - // block (EBN != 0xFF) is appended to the previous paragraph's text by - // LoadSubtitle, so tagging it too spliced an "{\anN}" into mid-sentence. - var isContinuationBlock = previousExtensionBlockNumber != 0xff; - if (tti.ExtensionBlockNumber != 0xfe) // FEh is user data, not part of the chain - { - previousExtensionBlockNumber = tti.ExtensionBlockNumber; - } - - if (!isContinuationBlock) - { - if (tti.VerticalPosition < 3) - { - if (tti.JustificationCode == 1) // left - { - tti.TextField = "{\\an7}" + tti.TextField; - } - else if (tti.JustificationCode == 3) // right - { - tti.TextField = "{\\an9}" + tti.TextField; - } - else - { - tti.TextField = "{\\an8}" + tti.TextField; - } - } - else if (tti.VerticalPosition <= rows / 2 + 1) - { - if (tti.JustificationCode == 1) // left - { - tti.TextField = "{\\an4}" + tti.TextField; - } - else if (tti.JustificationCode == 3) // right - { - tti.TextField = "{\\an6}" + tti.TextField; - } - else - { - tti.TextField = "{\\an5}" + tti.TextField; - } - } - else - { - if (tti.JustificationCode == 1) // left - { - tti.TextField = "{\\an1}" + tti.TextField; - } - else if (tti.JustificationCode == 3) // right - { - tti.TextField = "{\\an3}" + tti.TextField; - } - } - } - index += ttiSize; - list.Add(tti); - } - - return list; - } - - private static void AddMissingClosingTag(StringBuilder sb) - { - var s = sb.ToString(); - var startTags = Utilities.CountTagInText(s, ""); - if (startTags > endTags) - { - sb.Append(""); - } - } - - private static void CloseFontTagIfNewColor(StringBuilder sb, string tag) - { - var previousText = sb.ToString(); - if (string.IsNullOrEmpty(previousText)) - { - if (!string.IsNullOrEmpty(tag) && !tag.Contains("\"White\"")) - { - if (sb.Length > 0 && !sb.EndsWith(' ')) - { - sb.Append(' '); - } - - sb.Append(tag); - } - - return; - } - - var lastFontStartTag = previousText.LastIndexOf(" 0 && !sb.EndsWith(' ')) - { - sb.Append(' '); - } - - sb.Append(tag); - } - - return; - } - - var lastFontEndTag = previousText.LastIndexOf("", StringComparison.OrdinalIgnoreCase); - if (lastFontEndTag > lastFontStartTag) - { - if (!string.IsNullOrEmpty(tag) && !tag.Contains("\"White\"")) - { - if (sb.Length > 0 && !sb.EndsWith(' ')) - { - sb.Append(' '); - } - - sb.Append(tag); - } - - return; - } - - if (previousText.TrimEnd(' ').EndsWith(Environment.NewLine)) - { - var text = sb.ToString(); - sb.Clear(); - sb.Append(text.TrimEnd()); - sb.Append("" + Environment.NewLine); - } - else if (previousText.EndsWith(' ')) - { - var text = sb.ToString(); - sb.Clear(); - sb.Append(text.TrimEnd(' ')); - sb.Append(" "); - } - else - { - sb.Append(" "); - } - - if (!string.IsNullOrEmpty(tag) && !tag.Contains("\"White\"")) - { - if (sb.Length > 0 && !sb.EndsWith(' ')) - { - sb.Append(' '); - } - - sb.Append(tag); - } - } - - /// - /// The teletext colour code an STL file would carry for - a colour - /// name ("Red") or six hex digits, with or without a leading '#' - or null when the value is - /// not a colour at all. The eight teletext colours are the corners of the RGB cube, so - /// anything else is snapped to the nearest one; Save writes what this returns, and the UI - /// asks it what a colour will become before it writes a tag (via GetNearestColorName). - /// - internal static byte? GetNearestColorCode(string color) - { - color = color.Trim().TrimStart('#').ToLowerInvariant(); - if (color == "black" || color == "000000") - { - return 0x00; // black - } - - if (color == "red" || color == "ff0000") - { - return 0x01; // red - } - - if (color == "green" || color == "00ff00") - { - return 0x02; // green - } - - if (color == "yellow" || color == "ffff00") - { - return 0x03; // yellow - } - - if (color == "blue" || color == "0000ff") - { - return 0x04; // blue - } - - if (color == "magenta" || color == "ff00ff") - { - return 0x05; // magenta - } - - if (color == "cyan" || color == "00ffff") - { - return 0x06; // cyan - } - - if (color == "white" || color == "ffffff") - { - return 0x07; // white - } - - if (color.Length == 6) - { - if (RegExprColor.IsMatch(color)) - { - const int maxDiff = 130; - var r = int.Parse(color.Substring(0, 2), NumberStyles.HexNumber); - var g = int.Parse(color.Substring(2, 2), NumberStyles.HexNumber); - var b = int.Parse(color.Substring(4, 2), NumberStyles.HexNumber); - if (r < maxDiff && g < maxDiff && b < maxDiff) - { - return 0x00; // black - } - - if (r > 255 - maxDiff && g < maxDiff && b < maxDiff) - { - return 0x01; // red - } - - if (r < maxDiff && g > 255 - maxDiff && b < maxDiff) - { - return 0x02; // green - } - - if (r > 255 - maxDiff && g > 255 - maxDiff && b < maxDiff) - { - return 0x03; // yellow - } - - if (r < maxDiff && g < maxDiff && b > 255 - maxDiff) - { - return 0x04; // blue - } - - if (r > 255 - maxDiff && g < maxDiff && b > 255 - maxDiff) - { - return 0x05; // magenta - } - - if (r < maxDiff && g > 255 - maxDiff && b > 255 - maxDiff) - { - return 0x06; // cyan - } - - if (r > 255 - maxDiff && g > 255 - maxDiff && b > 255 - maxDiff) - { - return 0x07; // white - } - } - } - - return null; - } - - /// - /// The name of the teletext colour is nearest to - the same name - /// the STL reader writes into the text - or null when it is not a colour at all. - /// - public static string GetNearestColorName(string color) - { - var code = GetNearestColorCode(color); - return code == null ? null : GetColorName(code.Value); - } - - private static string GetColorName(byte b) - { - switch (b) - { - case 0x00: - return "Black"; - case 0x01: - return "Red"; - case 0x02: - return "Green"; - case 0x03: - return "Yellow"; - case 0x04: - return "Blue"; - case 0x05: - return "Magenta"; - case 0x06: - return "Cyan"; - case 0x07: - return "White"; - } - - return null; - } - - private static string GetColorOrTag(byte b) - { - //case 0x0a: - // return ""; - //case 0x0b: - // return ""; - var name = GetColorName(b); - return name == null ? null : ""; - } - - private static string FixSpacesAndTags(string text) - { - text = EmptyFontTag.Replace(text, string.Empty).Trim(); - while (text.Contains(" ")) - { - text = text.Replace(" ", " "); - } - - var match = FontTagsNoSpace1.Match(text); - while (match.Success) - { - text = text.Remove(match.Index, match.Length).Insert(match.Index, match.Value.Replace(" ", string.Empty).Contains("", string.Empty); - } - - var lines = text.SplitToLines(); - var sb = new StringBuilder(); - foreach (var line in lines) - { - sb.Append(line); - var count = Utilities.CountTagInText(line, "")) - { - sb.Append(""); - } - - if (Configuration.Settings.SubtitleSettings.EbuStlRemoveEmptyLines && - HtmlUtil.RemoveHtmlTags(line).Length == 0) - { - continue; - } - - sb.AppendLine(); - } - - text = sb.ToString().TrimEnd(); - - while (text.Contains(Environment.NewLine + " ")) - { - text = text.Replace(Environment.NewLine + " ", Environment.NewLine); - } - - // remove starting white spaces - match = FontTagsStartSpace.Match(text); - while (match.Success) - { - text = text.Remove(match.Index + match.Length - 1, 1); - match = FontTagsStartSpace.Match(text); - } - - // remove starting white spaces on 2+ line - match = FontTagsNewLineSpace.Match(text); - while (match.Success) - { - text = text.Remove(match.Index + match.Length - 1, 1); - match = FontTagsNewLineSpace.Match(text); - } - - text = text.Replace(" ", " "); - - text = HtmlUtil.FixInvalidItalicTags(text); - - return text; - } - - public override bool IsTextBased => false; - - public bool Save(string fileName, Stream stream, Subtitle subtitle, bool batchMode) - { - return Save(fileName, stream, subtitle, batchMode, null); - } - } -} diff --git a/tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj b/tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj deleted file mode 100644 index f5840d51336..00000000000 --- a/tests/EbuSnapshotRegression/TestLibSE/TestLibSE.csproj +++ /dev/null @@ -1 +0,0 @@ -net10.0disablefalseNikse.SubtitleEdit.Corelibsefalsetrue$(NoWarn);1591Nikse.SubtitleEdit.Core.Properties.Resources.resources \ No newline at end of file From 9e0524ae34a8dc5c79d07e67475d14ae0a952e0c Mon Sep 17 00:00:00 2001 From: niksedk Date: Tue, 6 Oct 2026 18:02:02 +0200 Subject: [PATCH 003/110] Add "custom milliseconds back/forward and pause" video shortcuts (#15741) Eight new shortcuts, one per existing "Video, custom milliseconds ... back/forward" slot. They use the same milliseconds as their twin but pause playback first (like the Pause shortcut, resetting any play selection), so a jog/shuttle step leaves the video paused on the new position. No default keys. The Shortcuts dialog picker blocks for the custom video moves are now one table, so editing a slot retitles both the plain and the pause variant. Co-Authored-By: Claude Opus 5.5 --- docs/features/shortcuts.md | 2 +- docs/features/video-player.md | 1 + src/ui/Assets/Languages/English.json | 8 + src/ui/Features/Main/MainViewModel.cs | 78 +++++++- .../Options/Shortcuts/ShortcutsViewModel.cs | 169 +++++------------- .../Logic/Config/Language/LanguageGeneral.cs | 16 ++ src/ui/Logic/ShortcutsMain.cs | 16 ++ .../VideoMoveCustomAndPauseShortcutsTests.cs | 31 ++++ 8 files changed, 191 insertions(+), 130 deletions(-) create mode 100644 tests/UI/Logic/VideoMoveCustomAndPauseShortcutsTests.cs diff --git a/docs/features/shortcuts.md b/docs/features/shortcuts.md index d97299145e9..11bcd256c46 100644 --- a/docs/features/shortcuts.md +++ b/docs/features/shortcuts.md @@ -48,7 +48,7 @@ Some commands have additional configuration beyond the shortcut key: - **Set color 1–8** — Choose a color for each color shortcut - **Surround with 1–8** — Define the left/right text to surround selected text with (this replaces the *Shortcut toggle custom start/end* setting from Subtitle Edit 4), and its **Behavior**: *Toggle* adds the text, or removes it when it is already there; *Add* adds it every time, so pressing the shortcut twice adds it twice; *Remove (all)* only removes it, all at once; *Remove (one each time)* removes one pair per press, so it undoes *Add* step by step. **Works on** picks *Selection, else whole text* (the selected part of the text box, otherwise the whole text of each selected subtitle) or *Each line* (every line of a subtitle gets its own pair, e.g. `[Hello]` / `[Bye]`) -- **Video move custom 1–4 back/forward** — Set the number of milliseconds to skip +- **Video move custom 1–4 back/forward** — Set the number of milliseconds to skip. Each one also has a **…and pause** variant that uses the same milliseconds but pauses playback first, so a jog/shuttle step leaves the video paused on the new position - **Set actor 1–10** — Define the actor name assigned by each actor shortcut - **Custom search 1–5** — Set the name and URL for each search slot - **Custom shortcut 1–8** — Build your own shortcut from steps (see below) diff --git a/docs/features/video-player.md b/docs/features/video-player.md index 29186a17c3f..fd6a0ec5ac9 100644 --- a/docs/features/video-player.md +++ b/docs/features/video-player.md @@ -49,6 +49,7 @@ The layout applies to every video player, including the ones in dialogs like **S | 1 second back | Jump 1 second backward | | 1 second forward | Jump 1 second forward | | Custom back/forward | Configurable jump amounts | +| Custom back/forward and pause | Same jump amounts, but pause playback first | > **Note:** Actual key bindings depend on your shortcut configuration. See **Options → Shortcuts**. diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index fba4c4920a0..740cd7be33d 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -745,6 +745,14 @@ "videoCustom3ForwardX": "Video, custom milliseconds ({0:#,###,##0}) forward, 3", "videoCustom4BackX": "Video, custom milliseconds ({0:#,###,##0}) back, 4", "videoCustom4ForwardX": "Video, custom milliseconds ({0:#,###,##0}) forward, 4", + "videoCustom1BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 1", + "videoCustom1ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 1", + "videoCustom2BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 2", + "videoCustom2ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 2", + "videoCustom3BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 3", + "videoCustom3ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 3", + "videoCustom4BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 4", + "videoCustom4ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 4", "videoExtension": "Video file extension", "videoFile": "Video file", "videoFileGenerated": "Video file generated", diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index d2b095adbcb..f805f6a6625 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -19690,6 +19690,14 @@ await _windowService.ShowDialogAsync n.Command == node.ShortCut.Action) is { Command: not null } seekNode) { var result = await _windowService.ShowDialogAsync(Window, vm => { - vm.Initialize(_videoSeekSlots[0]); + vm.Initialize(_videoSeekSlots[seekNode.Slot]); }); if (result.OkPressed) { - _videoSeekSlots[0] = result.Milliseconds; + _videoSeekSlots[seekNode.Slot] = result.Milliseconds; - var flatNodeBack = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom1BackCommand); - if (flatNodeBack != null) + // The plain move and its "...and pause" twin share the slot, so retitle both. + foreach (var sameSlot in GetVideoSeekNodes(MainViewModel).Where(n => n.Slot == seekNode.Slot)) { - flatNodeBack.Title = string.Format(Se.Language.General.VideoCustom1BackX, _videoSeekSlots[0]); + var flatNode = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == sameSlot.Command); + if (flatNode != null) + { + flatNode.Title = string.Format(sameSlot.TitleFormat, _videoSeekSlots[seekNode.Slot]); + } } } } - else if (node.ShortCut.Action == MainViewModel.VideoMoveCustom1ForwardCommand) - { - var result = await _windowService.ShowDialogAsync(Window, vm => - { - vm.Initialize(_videoSeekSlots[1]); - }); - if (result.OkPressed) - { - _videoSeekSlots[1] = result.Milliseconds; - - var flatNodeForward = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom1ForwardCommand); - if (flatNodeForward != null) - { - flatNodeForward.Title = string.Format(Se.Language.General.VideoCustom1ForwardX, _videoSeekSlots[1]); - } - } - } - else if (node.ShortCut.Action == MainViewModel.VideoMoveCustom2BackCommand) - { - var result = await _windowService.ShowDialogAsync(Window, vm => - { - vm.Initialize(_videoSeekSlots[2]); - }); - if (result.OkPressed) - { - _videoSeekSlots[2] = result.Milliseconds; - - var flatNodeBack = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom2BackCommand); - if (flatNodeBack != null) - { - flatNodeBack.Title = string.Format(Se.Language.General.VideoCustom2BackX, _videoSeekSlots[2]); - } - } - } - else if (node.ShortCut.Action == MainViewModel.VideoMoveCustom2ForwardCommand) - { - var result = await _windowService.ShowDialogAsync(Window, vm => - { - vm.Initialize(_videoSeekSlots[3]); - }); - if (result.OkPressed) - { - _videoSeekSlots[3] = result.Milliseconds; - - var flatNodeForward = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom2ForwardCommand); - if (flatNodeForward != null) - { - flatNodeForward.Title = string.Format(Se.Language.General.VideoCustom2ForwardX, _videoSeekSlots[3]); - } - } - } - else if (node.ShortCut.Action == MainViewModel.VideoMoveCustom3BackCommand) - { - var result = await _windowService.ShowDialogAsync(Window, vm => - { - vm.Initialize(_videoSeekSlots[4]); - }); - if (result.OkPressed) - { - _videoSeekSlots[4] = result.Milliseconds; - - var flatNodeBack = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom3BackCommand); - if (flatNodeBack != null) - { - flatNodeBack.Title = string.Format(Se.Language.General.VideoCustom3BackX, _videoSeekSlots[4]); - } - } - } - else if (node.ShortCut.Action == MainViewModel.VideoMoveCustom3ForwardCommand) - { - var result = await _windowService.ShowDialogAsync(Window, vm => - { - vm.Initialize(_videoSeekSlots[5]); - }); - if (result.OkPressed) - { - _videoSeekSlots[5] = result.Milliseconds; - - var flatNodeForward = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom3ForwardCommand); - if (flatNodeForward != null) - { - flatNodeForward.Title = string.Format(Se.Language.General.VideoCustom3ForwardX, _videoSeekSlots[5]); - } - } - } - else if (node.ShortCut.Action == MainViewModel.VideoMoveCustom4BackCommand) - { - var result = await _windowService.ShowDialogAsync(Window, vm => - { - vm.Initialize(_videoSeekSlots[6]); - }); - if (result.OkPressed) - { - _videoSeekSlots[6] = result.Milliseconds; - - var flatNodeBack = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom4BackCommand); - if (flatNodeBack != null) - { - flatNodeBack.Title = string.Format(Se.Language.General.VideoCustom4BackX, _videoSeekSlots[6]); - } - } - } - else if (node.ShortCut.Action == MainViewModel.VideoMoveCustom4ForwardCommand) - { - var result = await _windowService.ShowDialogAsync(Window, vm => - { - vm.Initialize(_videoSeekSlots[7]); - }); - if (result.OkPressed) - { - _videoSeekSlots[7] = result.Milliseconds; + } - var flatNodeForward = FlatNodes.FirstOrDefault(n => n?.ShortCut?.Action == MainViewModel.VideoMoveCustom4ForwardCommand); - if (flatNodeForward != null) - { - flatNodeForward.Title = string.Format(Se.Language.General.VideoCustom4ForwardX, _videoSeekSlots[7]); - } - } - } + /// + /// The video "move custom" shortcuts and the index holding their + /// milliseconds. Each "...and pause" variant uses the same slot as its plain move. + /// + private static (IRelayCommand Command, int Slot, string TitleFormat)[] GetVideoSeekNodes(MainViewModel vm) + { + var l = Se.Language.General; + return + [ + (vm.VideoMoveCustom1BackCommand, 0, l.VideoCustom1BackX), + (vm.VideoMoveCustom1BackAndPauseCommand, 0, l.VideoCustom1BackAndPauseX), + (vm.VideoMoveCustom1ForwardCommand, 1, l.VideoCustom1ForwardX), + (vm.VideoMoveCustom1ForwardAndPauseCommand, 1, l.VideoCustom1ForwardAndPauseX), + (vm.VideoMoveCustom2BackCommand, 2, l.VideoCustom2BackX), + (vm.VideoMoveCustom2BackAndPauseCommand, 2, l.VideoCustom2BackAndPauseX), + (vm.VideoMoveCustom2ForwardCommand, 3, l.VideoCustom2ForwardX), + (vm.VideoMoveCustom2ForwardAndPauseCommand, 3, l.VideoCustom2ForwardAndPauseX), + (vm.VideoMoveCustom3BackCommand, 4, l.VideoCustom3BackX), + (vm.VideoMoveCustom3BackAndPauseCommand, 4, l.VideoCustom3BackAndPauseX), + (vm.VideoMoveCustom3ForwardCommand, 5, l.VideoCustom3ForwardX), + (vm.VideoMoveCustom3ForwardAndPauseCommand, 5, l.VideoCustom3ForwardAndPauseX), + (vm.VideoMoveCustom4BackCommand, 6, l.VideoCustom4BackX), + (vm.VideoMoveCustom4BackAndPauseCommand, 6, l.VideoCustom4BackAndPauseX), + (vm.VideoMoveCustom4ForwardCommand, 7, l.VideoCustom4ForwardX), + (vm.VideoMoveCustom4ForwardAndPauseCommand, 7, l.VideoCustom4ForwardAndPauseX), + ]; } /// diff --git a/src/ui/Logic/Config/Language/LanguageGeneral.cs b/src/ui/Logic/Config/Language/LanguageGeneral.cs index c62fe2a878b..c0a3abec43a 100644 --- a/src/ui/Logic/Config/Language/LanguageGeneral.cs +++ b/src/ui/Logic/Config/Language/LanguageGeneral.cs @@ -745,6 +745,14 @@ public class LanguageGeneral public string VideoCustom3ForwardX { get; set; } public string VideoCustom4BackX { get; set; } public string VideoCustom4ForwardX { get; set; } + public string VideoCustom1BackAndPauseX { get; set; } + public string VideoCustom1ForwardAndPauseX { get; set; } + public string VideoCustom2BackAndPauseX { get; set; } + public string VideoCustom2ForwardAndPauseX { get; set; } + public string VideoCustom3BackAndPauseX { get; set; } + public string VideoCustom3ForwardAndPauseX { get; set; } + public string VideoCustom4BackAndPauseX { get; set; } + public string VideoCustom4ForwardAndPauseX { get; set; } public string VideoExtension { get; set; } public string VideoFile { get; set; } public string VideoFileGenerated { get; set; } @@ -1586,6 +1594,14 @@ public LanguageGeneral() VideoCustom3ForwardX = "Video, custom milliseconds ({0:#,###,##0}) forward, 3"; VideoCustom4BackX = "Video, custom milliseconds ({0:#,###,##0}) back, 4"; VideoCustom4ForwardX = "Video, custom milliseconds ({0:#,###,##0}) forward, 4"; + VideoCustom1BackAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) back and pause, 1"; + VideoCustom1ForwardAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 1"; + VideoCustom2BackAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) back and pause, 2"; + VideoCustom2ForwardAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 2"; + VideoCustom3BackAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) back and pause, 3"; + VideoCustom3ForwardAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 3"; + VideoCustom4BackAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) back and pause, 4"; + VideoCustom4ForwardAndPauseX = "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 4"; VideoExtension = "Video file extension"; VideoFile = "Video file"; VideoFileGenerated = "Video file generated"; diff --git a/src/ui/Logic/ShortcutsMain.cs b/src/ui/Logic/ShortcutsMain.cs index 3eb4c6692ae..bf5cc54b75e 100644 --- a/src/ui/Logic/ShortcutsMain.cs +++ b/src/ui/Logic/ShortcutsMain.cs @@ -460,6 +460,14 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.VideoMoveCustom3ForwardCommand), string.Format(Se.Language.General.VideoCustom3ForwardX, Se.Settings.Video.MoveVideoPositionCustom3Forward) }, { nameof(MainViewModel.VideoMoveCustom4BackCommand), string.Format(Se.Language.General.VideoCustom4BackX, Se.Settings.Video.MoveVideoPositionCustom4Back) }, { nameof(MainViewModel.VideoMoveCustom4ForwardCommand), string.Format(Se.Language.General.VideoCustom4ForwardX, Se.Settings.Video.MoveVideoPositionCustom4Forward) }, + { nameof(MainViewModel.VideoMoveCustom1BackAndPauseCommand), string.Format(Se.Language.General.VideoCustom1BackAndPauseX, Se.Settings.Video.MoveVideoPositionCustom1Back) }, + { nameof(MainViewModel.VideoMoveCustom1ForwardAndPauseCommand), string.Format(Se.Language.General.VideoCustom1ForwardAndPauseX, Se.Settings.Video.MoveVideoPositionCustom1Forward) }, + { nameof(MainViewModel.VideoMoveCustom2BackAndPauseCommand), string.Format(Se.Language.General.VideoCustom2BackAndPauseX, Se.Settings.Video.MoveVideoPositionCustom2Back) }, + { nameof(MainViewModel.VideoMoveCustom2ForwardAndPauseCommand), string.Format(Se.Language.General.VideoCustom2ForwardAndPauseX, Se.Settings.Video.MoveVideoPositionCustom2Forward) }, + { nameof(MainViewModel.VideoMoveCustom3BackAndPauseCommand), string.Format(Se.Language.General.VideoCustom3BackAndPauseX, Se.Settings.Video.MoveVideoPositionCustom3Back) }, + { nameof(MainViewModel.VideoMoveCustom3ForwardAndPauseCommand), string.Format(Se.Language.General.VideoCustom3ForwardAndPauseX, Se.Settings.Video.MoveVideoPositionCustom3Forward) }, + { nameof(MainViewModel.VideoMoveCustom4BackAndPauseCommand), string.Format(Se.Language.General.VideoCustom4BackAndPauseX, Se.Settings.Video.MoveVideoPositionCustom4Back) }, + { nameof(MainViewModel.VideoMoveCustom4ForwardAndPauseCommand), string.Format(Se.Language.General.VideoCustom4ForwardAndPauseX, Se.Settings.Video.MoveVideoPositionCustom4Forward) }, { nameof(MainViewModel.WaveformSetStartAndOffsetTheRestCommand), Se.Language.General.SetStartAndOffsetTheRest }, { nameof(MainViewModel.WaveformSetEndAndOffsetTheRestCommand), Se.Language.General.SetEndAndOffsetTheRest }, @@ -1007,6 +1015,14 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.VideoMoveCustom3ForwardCommand, nameof(vm.VideoMoveCustom3ForwardCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.VideoMoveCustom4BackCommand, nameof(vm.VideoMoveCustom4BackCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.VideoMoveCustom4ForwardCommand, nameof(vm.VideoMoveCustom4ForwardCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom1BackAndPauseCommand, nameof(vm.VideoMoveCustom1BackAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom1ForwardAndPauseCommand, nameof(vm.VideoMoveCustom1ForwardAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom2BackAndPauseCommand, nameof(vm.VideoMoveCustom2BackAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom2ForwardAndPauseCommand, nameof(vm.VideoMoveCustom2ForwardAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom3BackAndPauseCommand, nameof(vm.VideoMoveCustom3BackAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom3ForwardAndPauseCommand, nameof(vm.VideoMoveCustom3ForwardAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom4BackAndPauseCommand, nameof(vm.VideoMoveCustom4BackAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.VideoMoveCustom4ForwardAndPauseCommand, nameof(vm.VideoMoveCustom4ForwardAndPauseCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.WaveformSetStartAndOffsetTheRestCommand, nameof(vm.WaveformSetStartAndOffsetTheRestCommand), ShortcutCategory.Waveform); AddShortcut(shortcuts, vm.WaveformSetEndAndOffsetTheRestCommand, nameof(vm.WaveformSetEndAndOffsetTheRestCommand), ShortcutCategory.Waveform); diff --git a/tests/UI/Logic/VideoMoveCustomAndPauseShortcutsTests.cs b/tests/UI/Logic/VideoMoveCustomAndPauseShortcutsTests.cs new file mode 100644 index 00000000000..1c7637f9d08 --- /dev/null +++ b/tests/UI/Logic/VideoMoveCustomAndPauseShortcutsTests.cs @@ -0,0 +1,31 @@ +using Nikse.SubtitleEdit.Logic; + +namespace UITests.Logic; + +/// +/// #15741: every "Video, custom milliseconds ... back/forward" shortcut has an "...and pause" twin +/// that moves by the same milliseconds, and its title says so. +/// +public class VideoMoveCustomAndPauseShortcutsTests +{ + [Theory] + [InlineData(1, "Back")] + [InlineData(1, "Forward")] + [InlineData(2, "Back")] + [InlineData(2, "Forward")] + [InlineData(3, "Back")] + [InlineData(3, "Forward")] + [InlineData(4, "Back")] + [InlineData(4, "Forward")] + public void PauseVariantHasTitleWithSameMilliseconds(int slot, string direction) + { + var lookup = ShortcutsMain.CommandTranslationLookup; + var plain = lookup[$"VideoMoveCustom{slot}{direction}Command"]; + var pause = lookup[$"VideoMoveCustom{slot}{direction}AndPauseCommand"]; + + var directionText = direction.ToLowerInvariant(); + Assert.Contains($" {directionText}, {slot}", plain); + Assert.Contains($" {directionText} and pause, {slot}", pause); + Assert.Equal(plain.Replace($" {directionText}, ", $" {directionText} and pause, "), pause); + } +} From 64f9e344aec4fcd78d541e9e2add484416f5f071 Mon Sep 17 00:00:00 2001 From: niksedk Date: Tue, 6 Oct 2026 18:08:17 +0200 Subject: [PATCH 004/110] Batch convert: ask before adding video files from a folder (#15742) Add folder / dropping a folder now prompts when the scan finds video files (mkv, mp4, ts...): add their subtitle tracks, subtitle files only, or cancel, with "Do not ask again". Batch convert settings gets a matching Ask / Add their subtitle tracks / Skip option, and the settings dialog is regrouped into Output / Adding files / OCR / Other sections. Co-Authored-By: Claude Opus 5.5 --- .../BatchConvertSettingsViewModel.cs | 21 ++++ .../BatchConvertSettingsWindow.cs | 105 +++++++++++------- .../BatchConvert/BatchConvertViewModel.cs | 77 +++++++++++++ .../Language/Tools/LanguageBatchConvert.cs | 24 ++++ src/ui/Logic/Config/SeBatchConvert.cs | 13 +++ .../BatchConvertScanFolderTests.cs | 14 +++ 6 files changed, 212 insertions(+), 42 deletions(-) diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs index cc705808761..5bd9d418177 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs @@ -32,6 +32,8 @@ public partial class BatchConvertSettingsViewModel : ObservableObject [ObservableProperty] private bool _keepSourceTimestamp; [ObservableProperty] private bool _preventSleep; [ObservableProperty] private bool _scanFolderRecursive; + [ObservableProperty] private ObservableCollection _scanFolderVideoFilesOptions; + [ObservableProperty] private string? _selectedScanFolderVideoFilesOption; [ObservableProperty] private ObservableCollection _targetEncodings; [ObservableProperty] private string? _selectedTargetEncoding; @@ -93,12 +95,27 @@ public partial class BatchConvertSettingsViewModel : ObservableObject private readonly IFolderHelper _folderHelper; private readonly IWindowService _windowService; + private static readonly string[] ScanFolderVideoFilesValues = + { + SeBatchConvert.ScanFolderVideoFilesAsk, + SeBatchConvert.ScanFolderVideoFilesInclude, + SeBatchConvert.ScanFolderVideoFilesSkip, + }; + public BatchConvertSettingsViewModel(IFolderHelper folderHelper, IWindowService windowService) { var encodings = EncodingHelper.GetEncodings().Select(p => p.DisplayName).ToList(); encodings.Insert(0, EncodingHelper.TryToUseSourceEncoding); TargetEncodings = new ObservableCollection(encodings); + // same order as ScanFolderVideoFilesValues + ScanFolderVideoFilesOptions = new ObservableCollection + { + Se.Language.Tools.BatchConvert.VideoFilesAsk, + Se.Language.Tools.BatchConvert.VideoFilesInclude, + Se.Language.Tools.BatchConvert.VideoFilesSkip, + }; + OcrEngines = new ObservableCollection { "nOcr", "BinaryOcr", "Tesseract", "Ollama", "llama.cpp" }; if (AppleVisionOcr.IsAvailable()) { @@ -216,6 +233,8 @@ private void LoadSettings() SelectedOcrEngine = OcrEngines.FirstOrDefault(p => p == Se.Settings.Tools.BatchConvert.OcrEngine) ?? OcrEngines.First(); VobSubIsolateColors = Se.Settings.Tools.BatchConvert.VobSubIsolateColors; ScanFolderRecursive = Se.Settings.Tools.BatchConvert.ScanFolderRecursive; + var videoFilesIndex = Array.IndexOf(ScanFolderVideoFilesValues, Se.Settings.Tools.BatchConvert.ScanFolderVideoFiles); + SelectedScanFolderVideoFilesOption = ScanFolderVideoFilesOptions[Math.Max(0, videoFilesIndex)]; } private void SaveSettings() @@ -230,6 +249,8 @@ private void SaveSettings() Se.Settings.Tools.BatchConvert.OcrEngine = SelectedOcrEngine ?? "nOcr"; Se.Settings.Tools.BatchConvert.VobSubIsolateColors = VobSubIsolateColors; Se.Settings.Tools.BatchConvert.ScanFolderRecursive = ScanFolderRecursive; + var selectedVideoFilesIndex = SelectedScanFolderVideoFilesOption == null ? 0 : ScanFolderVideoFilesOptions.IndexOf(SelectedScanFolderVideoFilesOption); + Se.Settings.Tools.BatchConvert.ScanFolderVideoFiles = ScanFolderVideoFilesValues[Math.Max(0, selectedVideoFilesIndex)]; var ocrEngine = SelectedOcrEngine; if (ocrEngine == "Tesseract") diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs index e893598b142..706701540cb 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs @@ -21,11 +21,12 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) vm.Window = this; DataContext = vm; - var labelTargetEncoding = UiUtil.MakeLabel(Se.Language.General.TargetEncoding).WithMarginLeft(5); + var labelTargetEncoding = UiUtil.MakeLabel(Se.Language.General.TargetEncoding); var comboBoxTargetEncoding = UiUtil.MakeComboBox(vm.TargetEncodings, vm, nameof(vm.SelectedTargetEncoding)); var panelTargetEncoding = new StackPanel { Orientation = Orientation.Horizontal, + Spacing = 5, Children = { labelTargetEncoding, comboBoxTargetEncoding } }; @@ -61,6 +62,15 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) [!CheckBox.IsCheckedProperty] = new Binding(nameof(vm.ScanFolderRecursive)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }, }; + var labelScanFolderVideoFiles = UiUtil.MakeLabel(Se.Language.Tools.BatchConvert.VideoFilesWhenAddingFolder); + var comboBoxScanFolderVideoFiles = UiUtil.MakeComboBox(vm.ScanFolderVideoFilesOptions, vm, nameof(vm.SelectedScanFolderVideoFilesOption)); + var panelScanFolderVideoFiles = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + Children = { labelScanFolderVideoFiles, comboBoxScanFolderVideoFiles } + }; + var checkBoxUseSourceFolder = new RadioButton { Content = Se.Language.General.UseSourceFolder, @@ -91,6 +101,7 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) var panelOutputFolder = new StackPanel { Orientation = Orientation.Horizontal, + Margin = new Avalonia.Thickness(28, 0, 0, 0), // under the "Use output folder" radio button VerticalAlignment = VerticalAlignment.Center, Spacing = 5, Children = @@ -148,7 +159,6 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) var panelOcrEngine = new StackPanel { Orientation = Orientation.Horizontal, - Margin = new Avalonia.Thickness(0, 30, 0, 0), Children = { labelOcrEngine, comboBoxOcrEngine, labelOcLanguage, comboBoxTesseractLanguages, labelTesseractEngineMode, comboBoxTesseractEngineMode, comboBoxPaddleLanguages, labelBinaryOcrDatabase, comboBoxBinaryOcrDatabases, labelBinaryOcrFallback, comboBoxBinaryOcrFallback, labelNOcrDatabase, comboBoxNOcrDatabases, labelNOcrFallback, comboBoxNOcrFallback, labelOllamaModel, comboBoxOllamaModels, buttonOllamaModelBrowse, labelLlamaCppModel, comboBoxLlamaCppModels, labelCrispEmbedBackend, comboBoxCrispEmbedBackends, labelCrispEmbedModel, comboBoxCrispEmbedModels, labelAppleVisionLanguage, comboBoxAppleVisionLanguages } }; comboBoxOcrEngine.SelectionChanged += (s, e) => vm.OnOcrEngineChanged(); @@ -167,7 +177,7 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) var panelLanguagePostFix = new StackPanel { Orientation = Orientation.Horizontal, - Margin = new Avalonia.Thickness(0, 30, 0, 0), + Spacing = 5, Children = { labelLanguagePostFix, comboBoxLanguagePostFix } }; @@ -175,54 +185,65 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); var panelButtons = UiUtil.MakeButtonBar(buttonOk, buttonCancel); - var grid = new Grid + var panelSections = new StackPanel { - RowDefinitions = - { - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - }, - ColumnDefinitions = - { - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, - }, + Orientation = Orientation.Vertical, + Spacing = 12, Margin = UiUtil.MakeWindowMargin(), - ColumnSpacing = 10, - RowSpacing = 10, - Width = double.NaN, - HorizontalAlignment = HorizontalAlignment.Stretch, + Children = + { + MakeSection(Se.Language.Tools.BatchConvert.SettingsSectionOutput, + panelTargetEncoding, + checkBoxUseSourceFolder, + checkBoxUseOutputFolder, + panelOutputFolder, + checkBoxOverwrite, + checkBoxKeepSourceTimestamp, + panelLanguagePostFix), + MakeSection(Se.Language.Tools.BatchConvert.SettingsSectionAddingFiles, + checkBoxScanFolderRecursive, + panelScanFolderVideoFiles), + MakeSection(Se.Language.Tools.BatchConvert.SettingsSectionImageBasedSubtitles, + panelOcrEngine, + checkBoxVobSubIsolateColors), + MakeSection(Se.Language.Tools.BatchConvert.SettingsSectionOther, + checkBoxPreventSleep), + panelButtons, + } }; - grid.Add(panelTargetEncoding, 0, 0); - grid.Add(checkBoxOverwrite, 1, 0); - grid.Add(checkBoxKeepSourceTimestamp, 2, 0); - grid.Add(checkBoxUseSourceFolder, 3, 0); - grid.Add(checkBoxUseOutputFolder, 4, 0); - grid.Add(panelOutputFolder, 5, 0); - grid.Add(panelOcrEngine, 6, 0); - grid.Add(checkBoxVobSubIsolateColors, 7, 0); - grid.Add(panelLanguagePostFix, 8, 0); - grid.Add(checkBoxScanFolderRecursive, 9, 0); - grid.Add(checkBoxPreventSleep, 10, 0); - grid.Add(panelButtons, 11, 0); - - - Content = grid; + Content = panelSections; UiUtil.FocusOnFirstActivation(this, comboBoxTargetEncoding); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (s, e) => vm.OnKeyDown(e); } + // A bold title over a bordered group - keeps the long list of options scannable. + private static Control MakeSection(string title, params Control[] controls) + { + var panel = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 8, + Margin = new Avalonia.Thickness(5), + }; + foreach (var control in controls) + { + panel.Children.Add(control); + } + + return new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 4, + Children = + { + UiUtil.MakeLabel(title).WithBold(), + UiUtil.MakeBorderForControl(panel), + } + }; + } + // Model combo item template: a dot (green = downloaded, grey = not downloaded yet) plus the // model's download size - same treatment as the OCR window's CrispEmbed model combo. private static FuncDataTemplate MakeCrispEmbedModelItemTemplate() diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs index b848bd0f5e5..dcf3462c433 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs @@ -2277,6 +2277,21 @@ private async Task AddFilesAndFoldersAsync(IReadOnlyList fileNames, IRea return; // cancelled during the scan - add nothing } + var videoFiles = scanned.Where(IsScanFolderVideoFile).ToList(); + if (videoFiles.Count > 0) + { + var includeVideoFiles = await AskIncludeScannedVideoFilesAsync(videoFiles.Count); + if (includeVideoFiles == null) + { + return; // cancelled - add nothing + } + + if (includeVideoFiles == false) + { + scanned.RemoveAll(IsScanFolderVideoFile); + } + } + allFileNames.AddRange(scanned); } @@ -2288,6 +2303,68 @@ private async Task AddFilesAndFoldersAsync(IReadOnlyList fileNames, IRea await AddFilesAsync(allFileNames); } + /// + /// Whether video files found by a folder scan should be added (their embedded subtitle tracks), + /// following the "Video files when adding a folder" setting - asks when it is "Ask" (#15742). + /// Returns null when the prompt was cancelled. + /// + private async Task AskIncludeScannedVideoFilesAsync(int videoFileCount) + { + var mode = Se.Settings.Tools.BatchConvert.ScanFolderVideoFiles; + if (mode == SeBatchConvert.ScanFolderVideoFilesInclude) + { + return true; + } + + if (mode == SeBatchConvert.ScanFolderVideoFilesSkip || Window == null) + { + return false; + } + + var (choice, doNotAskAgain) = await MessageBox.ShowWithDoNotAskAgain( + Window, + Se.Language.Tools.BatchConvert.Title, + string.Format(Se.Language.Tools.BatchConvert.FolderContainsXVideoFiles, videoFileCount), + Se.Language.Tools.BatchConvert.DoNotAskAgainVideoFiles, + MessageBoxButtons.Cancel, + MessageBoxIcon.Question, + custom1: Se.Language.Tools.BatchConvert.SkipVideoFiles, + custom2: Se.Language.Tools.BatchConvert.AddVideoFiles); + + if (choice != MessageBoxResult.Custom1 && choice != MessageBoxResult.Custom2) + { + return null; + } + + var include = choice == MessageBoxResult.Custom2; + if (doNotAskAgain) + { + Se.Settings.Tools.BatchConvert.ScanFolderVideoFiles = include + ? SeBatchConvert.ScanFolderVideoFilesInclude + : SeBatchConvert.ScanFolderVideoFilesSkip; + Se.SaveSettings(); + } + + return include; + } + + private static readonly HashSet ScanFolderVideoExtensions = MakeScanFolderVideoExtensions(); + + // The extensions the folder scan only takes because of includeVideoFiles - minus .sup, which + // is a subtitle file (Blu-ray/HD DVD/DVD images) and not a video. + private static HashSet MakeScanFolderVideoExtensions() + { + var extensions = new HashSet(FileHelper.GetOpenSubtitleExtensions(true), StringComparer.OrdinalIgnoreCase); + extensions.ExceptWith(FileHelper.GetOpenSubtitleExtensions(false)); + extensions.Remove(".sup"); + return extensions; + } + + internal static bool IsScanFolderVideoFile(string fileName) + { + return ScanFolderVideoExtensions.Contains(Path.GetExtension(fileName)); + } + /// /// Collects the files the batch converter can open from (and their /// subfolders when is set). Walking a deep tree or a network share diff --git a/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs b/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs index 7bcc1fa4ed6..75ddb4f277c 100644 --- a/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs +++ b/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs @@ -59,6 +59,18 @@ public class LanguageBatchConvert public string AddFolderRecursiveDotDotDot { get; set; } public string SelectFolderToConvert { get; set; } public string IncludeSubfolders { get; set; } + public string VideoFilesWhenAddingFolder { get; set; } + public string VideoFilesAsk { get; set; } + public string VideoFilesInclude { get; set; } + public string VideoFilesSkip { get; set; } + public string FolderContainsXVideoFiles { get; set; } + public string AddVideoFiles { get; set; } + public string SkipVideoFiles { get; set; } + public string DoNotAskAgainVideoFiles { get; set; } + public string SettingsSectionOutput { get; set; } + public string SettingsSectionAddingFiles { get; set; } + public string SettingsSectionImageBasedSubtitles { get; set; } + public string SettingsSectionOther { get; set; } public string KeepSourceFileTimestamp { get; set; } public string PreventSleepWhileConverting { get; set; } public string ScanningFolderX { get; set; } @@ -145,6 +157,18 @@ public LanguageBatchConvert() AddFolderRecursiveDotDotDot = "Add folder recursive..."; SelectFolderToConvert = "Select folder with files to convert"; IncludeSubfolders = "Include subfolders when adding a folder"; + VideoFilesWhenAddingFolder = "Video files (mkv, mp4, ts...) when adding a folder"; + VideoFilesAsk = "Ask"; + VideoFilesInclude = "Add their subtitle tracks"; + VideoFilesSkip = "Skip"; + FolderContainsXVideoFiles = "The folder contains {0} video file(s) (mkv, mp4, ts...).\n\nAdd their embedded subtitle tracks too, or only the subtitle files?"; + AddVideoFiles = "Add video files"; + SkipVideoFiles = "Subtitle files only"; + DoNotAskAgainVideoFiles = "Do not ask again (change in Batch convert settings)"; + SettingsSectionOutput = "Output"; + SettingsSectionAddingFiles = "Adding files"; + SettingsSectionImageBasedSubtitles = "Image-based subtitles (OCR)"; + SettingsSectionOther = "Other"; KeepSourceFileTimestamp = "Keep source file date/time on output files"; PreventSleepWhileConverting = "Prevent computer from sleeping while converting"; ScanningFolderX = "Scanning {0}..."; diff --git a/src/ui/Logic/Config/SeBatchConvert.cs b/src/ui/Logic/Config/SeBatchConvert.cs index afa32684430..b9c838eb167 100644 --- a/src/ui/Logic/Config/SeBatchConvert.cs +++ b/src/ui/Logic/Config/SeBatchConvert.cs @@ -195,6 +195,19 @@ public class SeBatchConvert /// public bool ScanFolderRecursive { get; set; } + /// + /// What "Add folder" (and dropping a folder on the file list) does with video files + /// (.mkv/.mp4/.ts/...) it finds: prompts each time, + /// adds their embedded subtitle tracks, + /// leaves them out like SE 4 did (#15742). Video files + /// added or dropped one by one are always added. + /// + public string ScanFolderVideoFiles { get; set; } = ScanFolderVideoFilesAsk; + + public const string ScanFolderVideoFilesAsk = "Ask"; + public const string ScanFolderVideoFilesInclude = "Include"; + public const string ScanFolderVideoFilesSkip = "Skip"; + public bool ImageAdjustBrightnessOn { get; set; } public double ImageAdjustBrightness { get; set; } public double ImageAdjustContrast { get; set; } diff --git a/tests/UI/Features/Tools/BatchConvert/BatchConvertScanFolderTests.cs b/tests/UI/Features/Tools/BatchConvert/BatchConvertScanFolderTests.cs index 8069cae7a6c..467ede19901 100644 --- a/tests/UI/Features/Tools/BatchConvert/BatchConvertScanFolderTests.cs +++ b/tests/UI/Features/Tools/BatchConvert/BatchConvertScanFolderTests.cs @@ -30,6 +30,20 @@ private static List Scan(string folder, bool recursive, CancellationToke return fileNames; } + [Theory] + [InlineData("movie.mkv", true)] + [InlineData("movie.MP4", true)] + [InlineData("stream.ts", true)] + [InlineData("movie.srt", false)] + [InlineData("subs.mks", false)] // subtitle-only Matroska + [InlineData("bluray.sup", false)] // image subtitles, not a video + [InlineData("vobsub.sub", false)] + public void IsScanFolderVideoFile_OnlyVideoContainers(string fileName, bool expected) + { + // "Add folder" asks/skips these (#15742) - subtitle files must never be filtered out + Assert.Equal(expected, BatchConvertViewModel.IsScanFolderVideoFile(fileName)); + } + [Fact] public void ScanFolder_NotRecursive_TakesSubtitleFilesInTheFolderOnly() { From 388c55681a30a9bbbb759cf96e6b33a7923710b7 Mon Sep 17 00:00:00 2001 From: niksedk Date: Tue, 6 Oct 2026 18:30:25 +0200 Subject: [PATCH 005/110] Keep SetVideoPositionSeconds single-parameter PlayheadFrameStepTests invokes it by reflection, which does not fill optional parameters; the paused seek now lives in SeekVideoPositionSeconds. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/MainViewModel.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index f805f6a6625..785be4339d6 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -23326,7 +23326,7 @@ private void MoveVideoPositionMs(int ms, bool pause = false) // mpv reports the pause asynchronously, so tell the seek it is paused: it then scrolls // the waveform to the target as for any other paused move. - SetVideoPositionSeconds(target, paused: pause); + SeekVideoPositionSeconds(target, paused: pause); // SetVideoPositionSeconds cleared the tracker (it is the shared choke point for // every position change); re-arm it here so the next relative step continues @@ -23334,7 +23334,13 @@ private void MoveVideoPositionMs(int ms, bool pause = false) _relativeSeekTargetSeconds = target; } - private void SetVideoPositionSeconds(double newPosition, bool paused = false) + private void SetVideoPositionSeconds(double newPosition) => SeekVideoPositionSeconds(newPosition, paused: false); + + /// + /// , but says playback was just + /// paused: mpv reports that asynchronously, so the waveform is scrolled as for a paused seek. + /// + private void SeekVideoPositionSeconds(double newPosition, bool paused) { // Any position change other than a chained relative step invalidates the // relative-seek tracker so the next small step resyncs to the real position. From 4e64fcd7d5b051143bbddcc3f60db8957285aadb Mon Sep 17 00:00:00 2001 From: niksedk Date: Tue, 6 Oct 2026 18:34:03 +0200 Subject: [PATCH 006/110] Batch convert: parse added files with a few workers, batch UI updates BatchConvertFileLoader parses "Add files"/"Add folder" with 4 workers and reports results in file order, one UI update per ~100 ms instead of two posts per file. Text/binary format detection stays serialized behind a lock - the shared SubtitleFormat instances keep per-instance state (a parallel test fails 5/5 without it). Containers (MKV/MP4/TS) probe fully in parallel. Benchmarks: BatchConvertAddFiles*Benchmarks in tests/benchmarks. Co-Authored-By: Claude Opus 5.5 --- .../BatchConvert/BatchConvertFileLoader.cs | 98 ++++++++ .../BatchConvert/BatchConvertViewModel.cs | 76 ++++-- .../BatchConvertFileLoaderTests.cs | 174 +++++++++++++ .../BatchConvertLoadOnlyFormatTests.cs | 13 +- .../BatchConvertAddFilesBenchmarks.cs | 234 ++++++++++++++++++ tests/benchmarks/README.md | 3 + 6 files changed, 559 insertions(+), 39 deletions(-) create mode 100644 src/ui/Features/Tools/BatchConvert/BatchConvertFileLoader.cs create mode 100644 tests/UI/Features/Tools/BatchConvert/BatchConvertFileLoaderTests.cs create mode 100644 tests/benchmarks/BatchConvertAddFilesBenchmarks.cs diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertFileLoader.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertFileLoader.cs new file mode 100644 index 00000000000..552376d1db4 --- /dev/null +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertFileLoader.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Tools.BatchConvert; + +/// +/// Parses the files for "Add files"/"Add folder" with a few workers and hands the results back in +/// file order, batched - one progress callback per flushInterval instead of one per file. +/// Several workers hide per-file latency (network shares, USB/HDD, cold cache); batching keeps the +/// UI thread from being flooded with one post per file when thousands of files are added. +/// +internal static class BatchConvertFileLoader +{ + public const int DefaultMaxDegreeOfParallelism = 4; + public static readonly TimeSpan DefaultFlushInterval = TimeSpan.FromMilliseconds(100); + + // Marks a flushed slot - non-null, so it is not mistaken for "not parsed yet". + private static class Empty + { + public static readonly IReadOnlyList Instance = Array.Empty(); + } + + /// Items parsed since the last callback, in file order. + /// Files parsed so far (including any not yet in file order). + /// The file a worker started on most recently. + public readonly record struct Progress(List NewItems, int CompletedCount, string CurrentFileName); + + /// + /// Parses with on up to + /// threads and reports via , + /// which is called on a worker thread (never concurrently, in order) - post from it to reach + /// the UI thread. A last callback always follows the final file. On cancellation the files + /// parsed in order up to the first unfinished one are still reported. + /// + public static void Load( + IReadOnlyList fileNames, + Func> parse, + int maxDegreeOfParallelism, + TimeSpan flushInterval, + Action> onProgress, + CancellationToken token) + { + var results = new IReadOnlyList?[fileNames.Count]; + var flushLock = new Lock(); + var sinceFlush = Stopwatch.StartNew(); + var nextToFlush = 0; + var completed = 0; + var currentFileName = string.Empty; + + void Flush(bool force) + { + lock (flushLock) + { + if (!force && sinceFlush.Elapsed < flushInterval) + { + return; + } + + sinceFlush.Restart(); + var newItems = new List(); + while (nextToFlush < results.Length && Volatile.Read(ref results[nextToFlush]) is { } items) + { + newItems.AddRange(items); + results[nextToFlush] = Empty.Instance; // drop the reference - the caller owns the items now + nextToFlush++; + } + + onProgress(new Progress(newItems, Volatile.Read(ref completed), Volatile.Read(ref currentFileName))); + } + } + + try + { + Parallel.For(0, fileNames.Count, new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism), + CancellationToken = token, + }, i => + { + Volatile.Write(ref currentFileName, fileNames[i]); + Flush(force: false); // lets the status show a slow file while it is being read + + Volatile.Write(ref results[i], parse(fileNames[i])); + Interlocked.Increment(ref completed); + Flush(force: false); + }); + } + catch (OperationCanceledException) + { + // cancelled - report what was parsed in order so far + } + + Flush(force: true); + } +} diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs index b848bd0f5e5..15e859e1359 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs @@ -2441,26 +2441,26 @@ await Task.Run(() => { lock (_addFileLock) { - var number = 0; - foreach (var fileName in fileNames) - { - if (token.IsCancellationRequested) + // A few workers hide per-file latency (network share, USB/HDD), and results come + // back in file order, batched - one UI update per ~100 ms instead of two per file. + BatchConvertFileLoader.Load( + fileNames, + AddFile, + BatchConvertFileLoader.DefaultMaxDegreeOfParallelism, + BatchConvertFileLoader.DefaultFlushInterval, + progress => Dispatcher.UIThread.Post(() => { - break; - } + if (progress.NewItems.Count > 0) + { + _allBatchItems.AddRange(progress.NewItems); + AddFilteredItems(progress.NewItems); + MakeBatchItemsInfo(); + } - number++; - var current = number; - Dispatcher.UIThread.Post(() => AddingFilesStatus = string.Format("{0}/{1}: {2}", current, fileNames.Count, Path.GetFileName(fileName))); - var added = AddFile(fileName); - Dispatcher.UIThread.Post(() => - { - _allBatchItems.AddRange(added); - AddFilteredItems(added); - AddingFilesProgressValue = current; - MakeBatchItemsInfo(); - }); - } + AddingFilesProgressValue = progress.CompletedCount; + AddingFilesStatus = string.Format("{0}/{1}: {2}", progress.CompletedCount, fileNames.Count, Path.GetFileName(progress.CurrentFileName)); + }), + token); } }); @@ -2563,11 +2563,18 @@ private static IReadOnlyList BuildExtraFormatPick } } + // Text/binary format detection runs IsMine/LoadSubtitle on the shared SubtitleFormat + // instances, which keep per-instance state (error counts, ...) - so only one file at a time + // may go through it. The container probes (Matroska, MP4, TS, ...) use their own instances + // and run in parallel. + private static readonly Lock FormatDetectionLock = new(); + // Parses a file and returns the resulting item(s) without touching any shared collection, - // so it is safe to call from a background thread. Callers append the returned items to - // _allBatchItems and the visible BatchItems on the UI thread - _allBatchItems is read there - // (filtering, the info label), so mutating it from the parse thread would race those reads. - private List AddFile(string fileName) + // so it is safe to call from several background threads at once (see BatchConvertFileLoader). + // Callers append the returned items to _allBatchItems and the visible BatchItems on the UI + // thread - _allBatchItems is read there (filtering, the info label), so mutating it from the + // parse threads would race those reads. + internal static List AddFile(string fileName) { var added = new List(); var ext = Path.GetExtension(fileName).ToLowerInvariant(); @@ -2720,7 +2727,24 @@ private List AddFile(string fileName) return added; } - if (format == Se.Language.General.Unknown && fileInfo.Length < 20_000_000) + if (format == Se.Language.General.Unknown) + { + lock (FormatDetectionLock) + { + (subtitle, format) = DetectTextOrBinaryFormat(fileName, fileInfo.Length); + } + } + + var batchItem = new BatchConvertItem(fileName, fileInfo.Length, format, subtitle); + added.Add(batchItem); + return added; + } + + private static (Subtitle? Subtitle, string Format) DetectTextOrBinaryFormat(string fileName, long fileLength) + { + Subtitle? subtitle = null; + var format = Se.Language.General.Unknown; + if (fileLength < 20_000_000) { subtitle = Subtitle.Parse(fileName); if (subtitle != null) @@ -2746,7 +2770,7 @@ private List AddFile(string fileName) // The load-only text formats (WSB, FTE, the JSON "load only" types, ...) - like File > Open. // This replaces a second, identical Subtitle.Parse that could only fail again. - if (format == Se.Language.General.Unknown && fileInfo.Length < 20_000_000) + if (format == Se.Language.General.Unknown && fileLength < 20_000_000) { subtitle = LoadOnlyTextFormatLoader.TryLoad(fileName, LanguageAutoDetect.GetEncodingFromFile(fileName)); if (subtitle != null) @@ -2755,9 +2779,7 @@ private List AddFile(string fileName) } } - var batchItem = new BatchConvertItem(fileName, fileInfo.Length, format, subtitle); - added.Add(batchItem); - return added; + return (subtitle, format); } private static string MakeMkvTrackInfoString(MatroskaTrackInfo track) diff --git a/tests/UI/Features/Tools/BatchConvert/BatchConvertFileLoaderTests.cs b/tests/UI/Features/Tools/BatchConvert/BatchConvertFileLoaderTests.cs new file mode 100644 index 00000000000..b953d606604 --- /dev/null +++ b/tests/UI/Features/Tools/BatchConvert/BatchConvertFileLoaderTests.cs @@ -0,0 +1,174 @@ +using Nikse.SubtitleEdit.Features.Tools.BatchConvert; + +namespace UITests.Features.Tools.BatchConvert; + +public class BatchConvertFileLoaderTests +{ + private static List MakeFileNames(int count) => + Enumerable.Range(0, count).Select(i => $"file{i:0000}.srt").ToList(); + + // Parses slower for some files than others, so workers finish out of order. + private static IReadOnlyList JitteryParse(string fileName) + { + var number = int.Parse(fileName.AsSpan(4, 4)); + Thread.Sleep(number % 3); + return number % 5 == 0 + ? new[] { fileName + "#1", fileName + "#2" } // a container with two tracks + : new[] { fileName }; + } + + [Theory] + [InlineData(1)] + [InlineData(4)] + [InlineData(8)] + public void Load_ReportsEveryItemInFileOrder(int workers) + { + var fileNames = MakeFileNames(200); + var reported = new List(); + var lastCompleted = 0; + + BatchConvertFileLoader.Load(fileNames, JitteryParse, workers, TimeSpan.FromMilliseconds(5), p => + { + reported.AddRange(p.NewItems); + lastCompleted = p.CompletedCount; + }, TestContext.Current.CancellationToken); + + var expected = fileNames.SelectMany(JitteryParseNoSleep).ToList(); + Assert.Equal(expected, reported); + Assert.Equal(fileNames.Count, lastCompleted); + } + + private static IEnumerable JitteryParseNoSleep(string fileName) + { + var number = int.Parse(fileName.AsSpan(4, 4)); + return number % 5 == 0 ? new[] { fileName + "#1", fileName + "#2" } : new[] { fileName }; + } + + [Fact] + public void Load_BatchesProgressCallbacks() + { + // 500 instant files with a long flush interval: a handful of callbacks, not one per file + var fileNames = MakeFileNames(500); + var callbacks = 0; + var items = 0; + + BatchConvertFileLoader.Load(fileNames, f => new[] { f }, 4, TimeSpan.FromSeconds(10), p => + { + callbacks++; + items += p.NewItems.Count; + }, TestContext.Current.CancellationToken); + + Assert.Equal(500, items); + Assert.True(callbacks <= 2, $"{callbacks} callbacks"); + } + + [Fact] + public void Load_ProgressCallbacksNeverOverlap() + { + var fileNames = MakeFileNames(300); + var inCallback = 0; + var overlapped = false; + + BatchConvertFileLoader.Load(fileNames, JitteryParse, 8, TimeSpan.Zero, _ => + { + if (Interlocked.Increment(ref inCallback) > 1) + { + overlapped = true; + } + + Thread.SpinWait(100); + Interlocked.Decrement(ref inCallback); + }, TestContext.Current.CancellationToken); + + Assert.False(overlapped); + } + + [Fact] + public void Load_Cancelled_ReportsOnlyAnInOrderPrefix() + { + var fileNames = MakeFileNames(1000); + using var cts = new CancellationTokenSource(); + var reported = new List(); + var parsed = 0; + + BatchConvertFileLoader.Load(fileNames, f => + { + if (Interlocked.Increment(ref parsed) == 50) + { + cts.Cancel(); + } + + return JitteryParse(f); + }, 4, TimeSpan.Zero, p => reported.AddRange(p.NewItems), cts.Token); + + Assert.True(reported.Count < fileNames.Count); + var expectedPrefix = fileNames.SelectMany(JitteryParseNoSleep).Take(reported.Count); + Assert.Equal(expectedPrefix, reported); + } + + [Fact] + public void Load_NoFiles_StillReportsOnce() + { + var callbacks = 0; + BatchConvertFileLoader.Load(new List(), f => new[] { f }, 4, TimeSpan.Zero, _ => callbacks++, TestContext.Current.CancellationToken); + Assert.Equal(1, callbacks); + } +} + +public class BatchConvertAddFileParallelTests +{ + // AddFile runs on several loader threads at once; format detection goes through the shared + // SubtitleFormat instances, so parallel must detect exactly what sequential does. + [Fact] + public void AddFile_InParallel_DetectsSameFormatsAsSequential() + { + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); // as the app does at startup (binary formats need code page 850) + var dir = Directory.CreateTempSubdirectory("se-batch-add-parallel"); + try + { + var subtitle = new Nikse.SubtitleEdit.Core.Common.Subtitle(); + for (var i = 0; i < 40; i++) + { + subtitle.Paragraphs.Add(new Nikse.SubtitleEdit.Core.Common.Paragraph($"Line {i}\nsecond line", i * 3000, i * 3000 + 2000)); + } + + var formats = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat.AllSubtitleFormats + .Where(f => f.IsTextBased && !f.Name.StartsWith("Unknown", StringComparison.Ordinal)) + .Take(60) + .ToList(); + var fileNames = new List(); + for (var copy = 0; copy < 4; copy++) + { + for (var i = 0; i < formats.Count; i++) + { + string text; + try + { + text = formats[i].ToText(subtitle, "test"); + } + catch + { + continue; + } + + var fileName = Path.Combine(dir.FullName, $"{copy}_{i}{formats[i].Extension}"); + File.WriteAllText(fileName, text); + fileNames.Add(fileName); + } + } + + var sequential = fileNames.Select(f => string.Join("|", BatchConvertViewModel.AddFile(f).Select(p => p.Format))).ToList(); + + var parallel = new List(); + BatchConvertFileLoader.Load(fileNames, BatchConvertViewModel.AddFile, 8, TimeSpan.Zero, + p => parallel.AddRange(p.NewItems), TestContext.Current.CancellationToken); + var parallelByFile = fileNames.Select(f => string.Join("|", parallel.Where(p => p.FileName == f).Select(p => p.Format))).ToList(); + + Assert.Equal(sequential, parallelByFile); + } + finally + { + dir.Delete(recursive: true); + } + } +} diff --git a/tests/UI/Features/Tools/BatchConvert/BatchConvertLoadOnlyFormatTests.cs b/tests/UI/Features/Tools/BatchConvert/BatchConvertLoadOnlyFormatTests.cs index 236bd03a145..e3f11e3ace8 100644 --- a/tests/UI/Features/Tools/BatchConvert/BatchConvertLoadOnlyFormatTests.cs +++ b/tests/UI/Features/Tools/BatchConvert/BatchConvertLoadOnlyFormatTests.cs @@ -1,11 +1,9 @@ using Avalonia.Headless.XUnit; -using Microsoft.Extensions.DependencyInjection; using Nikse.SubtitleEdit; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.SubtitleFormats; using Nikse.SubtitleEdit.Features.Tools.BatchConvert; using Nikse.SubtitleEdit.UiLogic.BatchConvert; -using System.Reflection; using System.Text; namespace UITests.Features.Tools.BatchConvert; @@ -27,16 +25,7 @@ public BatchConvertLoadOnlyFormatTests() Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } - private static List AddFile(string fileName) - { - var services = new ServiceCollection(); - services.AddSubtitleEditServices(); - using var provider = services.BuildServiceProvider(); - var vm = provider.GetRequiredService(); - return (List)typeof(BatchConvertViewModel) - .GetMethod("AddFile", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(vm, [fileName])!; - } + private static List AddFile(string fileName) => BatchConvertViewModel.AddFile(fileName); [AvaloniaFact] public async Task LoadOnlyJsonFile_IsAddedWithItsFormat_AndConverts() diff --git a/tests/benchmarks/BatchConvertAddFilesBenchmarks.cs b/tests/benchmarks/BatchConvertAddFilesBenchmarks.cs new file mode 100644 index 00000000000..7997a802a5a --- /dev/null +++ b/tests/benchmarks/BatchConvertAddFilesBenchmarks.cs @@ -0,0 +1,234 @@ +using System.Collections.ObjectModel; +using System.Text; +using BenchmarkDotNet.Attributes; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Tools.BatchConvert; +using Nikse.SubtitleEdit.UiLogic.BatchConvert; + +namespace Nikse.SubtitleEdit.Benchmarks; + +// Batch convert "Add files" / "Add folder" (#15742). The old code parsed one file at a time and +// posted two UI updates per file; BatchConvertFileLoader parses with a few workers (format +// detection stays serialized - the SubtitleFormat instances are shared) and posts one UI update +// per ~100 ms. + +/// Builds the temp folders the add-files benchmarks parse. +internal static class BatchConvertAddFilesData +{ + public static string FindFixturesDirectory() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var fixtures = Path.Combine(dir.FullName, "tests", "seconv", "Fixtures"); + if (Directory.Exists(fixtures)) + { + return fixtures; + } + + dir = dir.Parent; + } + + throw new DirectoryNotFoundException("tests/seconv/Fixtures not found above " + AppContext.BaseDirectory); + } + + /// "Text": 300 subtitle files (SubRip/ASSA/WebVTT, 800 lines). "Containers": 20 copies each of the + /// MKV/MP4/TS/SUP fixtures. "Mixed": both. + public static List Create(string mix, out DirectoryInfo dir) + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + Configuration.DataDirectory = BenchmarkSubtitles.FindDataDirectory(); + dir = Directory.CreateTempSubdirectory("se-batch-add-bench"); + var fileNames = new List(); + + if (mix is "Text" or "Mixed") + { + var subtitle = new Subtitle(); + for (var i = 0; i < 800; i++) + { + subtitle.Paragraphs.Add(new Paragraph($"Line number {i} of the subtitle\nand a second line.", i * 3000, i * 3000 + 2000)); + } + + var formats = new SubtitleFormat[] { new SubRip(), new AdvancedSubStationAlpha(), new WebVTT() }; + for (var i = 0; i < 300; i++) + { + var format = formats[i % formats.Length]; + var fileName = Path.Combine(dir.FullName, $"text{i:000}{format.Extension}"); + File.WriteAllText(fileName, format.ToText(subtitle, "bench")); + fileNames.Add(fileName); + } + } + + if (mix is "Containers" or "Mixed") + { + var fixtures = FindFixturesDirectory(); + var containers = new[] { "container_text.mkv", "container_image.mkv", "container_text_undeclared_lang.mkv", "container_text.mp4", "container_cea608_708.mp4", "container_teletext.ts", "sample.sup" }; + for (var copy = 0; copy < 20; copy++) + { + foreach (var name in containers) + { + var fileName = Path.Combine(dir.FullName, $"c{copy:00}_{name}"); + File.Copy(Path.Combine(fixtures, name), fileName); + fileNames.Add(fileName); + } + } + } + + return fileNames; + } + + // Both keep every item (with its parsed Subtitle), as the batch convert list does - the + // retained subtitles are a large part of the GC cost of adding text files. + + /// The old AddFilesAsync loop, minus the UI posts. + public static int Sequential(List fileNames, Func> parse) + { + var all = new List(); + foreach (var fileName in fileNames) + { + all.AddRange(parse(fileName)); + } + + return all.Count; + } + + public static int Queue(List fileNames, Func> parse, int workers) + { + var all = new List(); + BatchConvertFileLoader.Load(fileNames, parse, workers, BatchConvertFileLoader.DefaultFlushInterval, + p => all.AddRange(p.NewItems), CancellationToken.None); + return all.Count; + } +} + +/// +/// Real AddFile on local files (warm OS cache): the CPU side of adding files. Text files +/// mostly serialize on the format-detection lock; containers parse fully in parallel. +/// +[MemoryDiagnoser] +public class BatchConvertAddFilesBenchmarks +{ + private List _fileNames = null!; + private DirectoryInfo _dir = null!; + + [Params("Text", "Containers", "Mixed")] + public string Mix { get; set; } = "Mixed"; + + [GlobalSetup] + public void Setup() + { + _fileNames = BatchConvertAddFilesData.Create(Mix, out _dir); + BatchConvertAddFilesData.Sequential(_fileNames, BatchConvertViewModel.AddFile); // warm the OS cache + format list + } + + [GlobalCleanup] + public void Cleanup() => _dir.Delete(recursive: true); + + [Benchmark(Baseline = true)] + public int Sequential() => BatchConvertAddFilesData.Sequential(_fileNames, BatchConvertViewModel.AddFile); + + [Benchmark] + public int Queue1() => BatchConvertAddFilesData.Queue(_fileNames, BatchConvertViewModel.AddFile, 1); + + [Benchmark] + public int Queue2() => BatchConvertAddFilesData.Queue(_fileNames, BatchConvertViewModel.AddFile, 2); + + [Benchmark] + public int Queue4() => BatchConvertAddFilesData.Queue(_fileNames, BatchConvertViewModel.AddFile, 4); + + [Benchmark] + public int Queue8() => BatchConvertAddFilesData.Queue(_fileNames, BatchConvertViewModel.AddFile, 8); +} + +/// +/// Slow storage (network share, USB/HDD): the real AddFile on the container files plus a +/// simulated per-file I/O wait outside the format-detection lock - where the MKV/MP4/TS reads are. +/// +public class BatchConvertAddFilesSlowStorageBenchmarks +{ + private List _fileNames = null!; + private DirectoryInfo _dir = null!; + + [Params(2, 10)] + public int LatencyMs { get; set; } + + [GlobalSetup] + public void Setup() => _fileNames = BatchConvertAddFilesData.Create("Containers", out _dir); + + [GlobalCleanup] + public void Cleanup() => _dir.Delete(recursive: true); + + private IReadOnlyList SlowAddFile(string fileName) + { + Thread.Sleep(LatencyMs); + return BatchConvertViewModel.AddFile(fileName); + } + + [Benchmark(Baseline = true)] + public int Sequential() => BatchConvertAddFilesData.Sequential(_fileNames, SlowAddFile); + + [Benchmark] + public int Queue4() => BatchConvertAddFilesData.Queue(_fileNames, SlowAddFile, 4); + + [Benchmark] + public int Queue8() => BatchConvertAddFilesData.Queue(_fileNames, SlowAddFile, 8); +} + +/// +/// The UI-thread side: what each post did with the parsed items - append to the all-items list +/// and the visible collection, then rebuild the "N files" info, which scans every item. Per file +/// (old) that scan makes adding N files O(N²); batched it runs once per ~100 ms flush. Does not +/// include the grid's own layout work, which batching also cuts (one layout per flush). +/// +public class BatchConvertAddFilesUiUpdateBenchmarks +{ + private List _items = null!; + + [Params(1000, 10000)] + public int Files { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(0, Files) + .Select(i => new BatchConvertItem($"/folder/file{i}.srt", 50_000, "SubRip", null)) + .ToList(); + } + + private static string MakeInfo(List all) + { + // as MakeBatchItemsInfo + var hasTransportStream = all.Any(p => p.Format != null && p.Format.StartsWith("Transport Stream", StringComparison.Ordinal)); + return hasTransportStream + " " + all.Count; + } + + private int Run(int batchSize) + { + var all = new List(); + var visible = new ObservableCollection(); + var changes = 0; + visible.CollectionChanged += (_, _) => changes++; + var info = string.Empty; + for (var start = 0; start < _items.Count; start += batchSize) + { + var batch = _items.GetRange(start, Math.Min(batchSize, _items.Count - start)); + all.AddRange(batch); + foreach (var item in batch) + { + visible.Add(item); + } + + info = MakeInfo(all); + } + + return info.Length + changes; + } + + [Benchmark(Baseline = true)] + public int PerFile() => Run(1); + + // ~100 ms of parsing at the measured ~1-2 ms per text file + [Benchmark] + public int Batched100() => Run(100); +} diff --git a/tests/benchmarks/README.md b/tests/benchmarks/README.md index fc56807f914..b0a57637030 100644 --- a/tests/benchmarks/README.md +++ b/tests/benchmarks/README.md @@ -40,6 +40,9 @@ Reports land in `tests/benchmarks/BenchmarkDotNet.Artifacts/results/` (gitignore | `TimeCodeCellRepaintBenchmarks` | The grid's time-code cells as the app drives them: three cell bindings plus the accessible-name MultiBinding over the same three values. | | `WaveformPerFrameTextBenchmarks` | A playback frame zoomed far out, and one with a video loaded whose waveform has not been generated yet. | | `ColoredSyntaxHighlightingBenchmarks` | The syntax highlighting converter over rows carrying colours (font tags, ASSA colour overrides), in both formatting modes. | +| `BatchConvertAddFilesBenchmarks` | Batch convert "Add files"/"Add folder": the real per-file parse over text files, container fixtures (MKV/MP4/TS/SUP) and a mix - sequential vs the `BatchConvertFileLoader` queue with 1-8 workers. | +| `BatchConvertAddFilesSlowStorageBenchmarks` | The same over container files with a simulated per-file I/O wait (network share, USB/HDD). | +| `BatchConvertAddFilesUiUpdateBenchmarks` | The UI-thread work per add: one update per file vs one per ~100 ms batch (list appends + the "N files" info scan). | Benchmarks that model a collection the app gets from Avalonia (e.g. `DataGrid.SelectedItems`) reproduce its interface surface rather than substituting a `List` - `SelectedItems` only From 2eb35d3ff1a817cbc712bf9de58a6d473c50893d Mon Sep 17 00:00:00 2001 From: FunkyKnilch Date: Tue, 6 Oct 2026 22:31:42 +0200 Subject: [PATCH 007/110] Update German Translation --- src/ui/Assets/Languages/German.json | 316 ++++++++++++++++++---------- 1 file changed, 203 insertions(+), 113 deletions(-) diff --git a/src/ui/Assets/Languages/German.json b/src/ui/Assets/Languages/German.json index 6e31a05cbf4..9f170a07510 100644 --- a/src/ui/Assets/Languages/German.json +++ b/src/ui/Assets/Languages/German.json @@ -1,7 +1,7 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-rc7", - "translatedBy": "Übersetzt von Christoph Kitsche. Überarbeitet von Netspark und Stephan Lenhart.", + "version": "v5.3.0-beta23", + "translatedBy": "Übersetzt von Christoph Kitsche. Überarbeitet von Netspark, Stephan Lenhart und FunkyKnilch.", "cultureName": "de-DE", "general": { "abort": "Abbrechen", @@ -19,7 +19,7 @@ "after": "Nachher", "alignment": "Ausrichtung", "alignmentDotDotDot": "Ausrichtung...", - "alignmentX": "Ausrichtung „{0}“ für ausgewählte Zeilen festlegen", + "alignmentX": "Ausrichtung \"{0}\" für ausgewählte Zeilen festlegen", "all": "Alle", "allFiles": "Alle Dateien", "alphaAdjustment": "Alpha-Anpassung", @@ -143,7 +143,7 @@ "copySubtitle": "Untertitel kopieren", "copyTextOnly": "Kopieren (nur Text)", "copyTextToClipboard": "Text in die Zwischenablage kopieren", - "couldNotSaveFileXErrorY": "Die Datei „{0}“ konnte nicht gespeichert werden.\nFehler: {1}", + "couldNotSaveFileXErrorY": "Die Datei \"{0}\" konnte nicht gespeichert werden.\nFehler: {1}", "couldNotOpenFileXErrorY": "Datei \"{0}\" konnte nicht geöffnet werden. Fehler: {1}", "count": "Zählen", "cps": "Zeichen/Sek.", @@ -224,7 +224,7 @@ "fileNameX": "Dateiname: {0}", "fileSaved": "Datei gespeichert", "fileSavedToX": "Datei gespeichert unter {0}", - "fileXAlreadyExists": "Datei „{0}“ existiert bereits.", + "fileXAlreadyExists": "Datei \"{0}\" existiert bereits.", "fileXOfY": "Datei {0} von {1}", "filter": "Filter", "filterByLayer": "Nach Ebene filtern", @@ -251,7 +251,7 @@ "forward": "Vorwärts", "foundNoMatches": "Keine Treffer gefunden", "foundOneMatch": "1 Treffer", - "foundXInLineYZ": "„{0}“ gefunden in Zeile {1}, Position {2}", + "foundXInLineYZ": "\"{0}\" gefunden in Zeile {1}, Position {2}", "foundXMatches": "{0} Treffer", "frameRate": "Bildrate", "frames": "Frames", @@ -411,7 +411,7 @@ "outputFolder": "Ausgabeordner", "outputProperties": "Ausgabeeigenschaften", "overwriteExistingFiles": "Vorhandene Dateien überschreiben?", - "overwriteFilesInFolderX": "Dateien im Ordner „{0}“ überschreiben", + "overwriteFilesInFolderX": "Dateien im Ordner \"{0}\" überschreiben", "overwriteQuestion": "Überschreiben?", "padding": "Einrückung", "parameters": "Parameter", @@ -447,7 +447,7 @@ "playSelectedLinesWithLoop": "Ausgewählte Zeilen in Dauerschleife abspielen", "playSelectedLinesAndFocusWaveform": "Ausgewählte Zeilen abspielen und Wellenform fokussieren", "playSelectedLinesWithLoopAndFocusWaveform": "Ausgewählte Zeilen in Schleife abspielen und Wellenform fokussieren", - "pleaseEnterAValidValueForX": "Bitte geben Sie einen gültigen Wert für „{0}“ ein.", + "pleaseEnterAValidValueForX": "Bitte geben Sie einen gültigen Wert für \"{0}\" ein.", "pleaseWait": "Bitte warten...", "position": "Position", "positionX": "Position: {0}", @@ -513,8 +513,8 @@ "saveOriginalAsTitle": "Original speichern unter", "saveTranslationAsTitle": "Übersetzung speichern unter", "saveVideoAsVideoTitle": "Video speichern unter", - "savedChangesToX": "„{0}“ gespeichert", - "savedChangesToXAndY": "„{0}“ und „{1}“ gespeichert", + "savedChangesToX": "\"{0}\" gespeichert", + "savedChangesToXAndY": "\"{0}\" und \"{1}\" gespeichert", "savingDotDotDot": "Speichere...", "script": "Skript", "search": "Suchen", @@ -562,22 +562,22 @@ "shortcut": "Tastenkombination", "shortcuts": "Tastenkürzel", "show": "Anzeigen", - "showActorColumn": "Spalte „Schauspieler“ anzeigen", - "showCpsColumn": "Spalte „Chars/sec“ anzeigen", - "showDurationColumn": "Spalte „Dauer“ anzeigen", - "showGapColumn": "Spalte „Abstand“ anzeigen", + "showActorColumn": "Spalte \"Schauspieler\" anzeigen", + "showCpsColumn": "Spalte \"Chars/sec\" anzeigen", + "showDurationColumn": "Spalte \"Dauer\" anzeigen", + "showGapColumn": "Spalte \"Abstand\" anzeigen", "showForcedColumn": "Spalte \"Erzwungen\" anzeigen", - "showStartColumn": "Spalte „Start“ anzeigen", - "showHideColumn": "Spalte „Ausblenden“ anzeigen", + "showStartColumn": "Spalte \"Start\" anzeigen", + "showHideColumn": "Spalte \"Ausblenden\" anzeigen", "showHistory": "Verlauf (für Rückgängig) anzeigen", - "showLayerColumn": "Spalte „Ebene“ anzeigen", + "showLayerColumn": "Spalte \"Ebene\" anzeigen", "showPreview": "Vorschau anzeigen", "showShotChangesList": "Liste der Shotänderungen anzeigen", - "showStyleColumn": "Spalte „Stil“ anzeigen", + "showStyleColumn": "Spalte \"Stil\" anzeigen", "showTeletext": "TT anzeigen", "showTimeCodes": "Zeitstempel anzeigen", - "showWpmColumn": "Spalte „Wörter/Minute“ anzeigen", - "showPixelWidthColumn": "Spalte „Pixelbreite“ anzeigen", + "showWpmColumn": "Spalte \"Wörter/Minute\" anzeigen", + "showPixelWidthColumn": "Spalte \"Pixelbreite\" anzeigen", "shrink": "Schrumpfen", "singleMode": "Einzelmodus", "textTotalLength": "Text - Gesamtlänge", @@ -587,7 +587,7 @@ "skip": "Überspringen", "skipAll": "Alle überspringen", "skipOnce": "Einmal überspringen", - "smart": "Smart", + "smart": "Intelligent", "snapSelectedLinesEndToPreviousShotChange": "Ende ausgewählter Zeilen an vorherigen Szenenwechsel anpassen", "snapSelectedLinesStartToNextShotChange": "Start ausgewählter Zeilen an nächsten Szenenwechsel anpassen", "snapSelectedLinesToNearestShotChange": "Start/Ende ausgewählter Zeilen an nächsten Szenenwechsel anpassen", @@ -623,7 +623,7 @@ "status": "Status", "stereo": "Stereo", "spreadsheetFiles": "Tabellendateien", - "stop": "Stop", + "stop": "Stopp", "strikeout": "Durchgestrichen", "style": "Stil", "styleExaggeration": "Stilübertreibung", @@ -720,7 +720,7 @@ "videoExtension": "Videodateierweiterung", "videoFile": "Videodatei", "videoFileGenerated": "Videodatei erstellt", - "videoFileGeneratedX": "Videodatei erzeugt: „{0}“", + "videoFileGeneratedX": "Videodatei erzeugt: \"{0}\"", "videoFiles": "Videodateien", "videoAndAudioFiles": "Video- und Audiodateien", "videoOffset": "Video-Offset", @@ -749,12 +749,12 @@ "xOfYFiles": "{0:#,###,##0} von {1:#,###,##0} Dateien", "xFilesConvertedInY": "{0:#,###,##0} Dateien konvertiert in {1}", "xNoLinesUpdated": "{0}: keine Zeile(n) aktualisiert", - "xNotFound": "„{0}“ nicht gefunden", + "xNotFound": "\"{0}\" nicht gefunden", "xOfYLinesUpdated": "{0}: {1} von {2} Zeile(n) aktualisiert", "xOfYLinesUpdatedAndZNeighborsAdjusted": "{0}: {1} von {2} Zeile(n) aktualisiert, {3} Nachbar(n) angepasst", "noBookmarksFound": "Keine weiteren Lesezeichen gefunden", "xRequiresAnApiKey": "{0} benötigt einen API-Schlüssel", - "xRequiresAValidUrl": "{0} erfordert eine gültige URL (z. B. „http://...“ oder „https://...“)", + "xRequiresAValidUrl": "{0} erfordert eine gültige URL (z. B. \"http://...\" oder \"https://...\")", "yes": "Ja", "zeroBytes": "0 Bytes", "copyToClipboard": "In Zwischenablage kopieren", @@ -951,7 +951,7 @@ "saveAsHint": "Untertitel unter neuem Namen speichern {0}", "findHint": "Text in Untertiteln suchen {0}", "replaceHint": "Text suchen und ersetzen {0}", - "multipleReplaceHint": "Mehrfachersetzung {0}", + "multipleReplaceHint": "Mehrfach ersetzen {0}", "spellCheckHint": "Untertitel auf Rechtschreibfehler prüfen {0}", "fixCommonErrorsHint": "Häufige Untertitelfehler beheben {0}", "removeTextForHiHint": "Text für Hörgeschädigte entfernen {0}", @@ -990,6 +990,8 @@ "selectCurrentLineWhilePlayingHint": "Aktuellen Untertitel während der Wiedergabe auswählen {0}", "videoPosition": "Videoposition {0}", "videoPositionTextBox": "Textfeld für Videoposition {0}", + "toolbarLineBreakX": "Zeilenumbruch {0} (Rest der Symbolleiste wird in neuer Zeile fortgesetzt)", + "initialText": "Ursprünglicher Text der ausgewählten Zeile", "hideWaveformToolbar": "Symbolleiste ausblenden {0}", "resetZoomAndSpeed": "Zoom und Wiedergabegeschwindigkeit zurücksetzen {0}", "removeBlankLines": "Leere Zeilen entfernen {0}", @@ -1003,6 +1005,9 @@ "textPlayHint": "Aktuellen Untertitel abspielen und am Ende stoppen {0}", "textPauseHint": "Wiedergabe pausieren {0}", "textNextHint": "Nächsten Untertitel abspielen und am Ende stoppen {0}", + "moveSelectedLines": "Ausgewählte Zeilen verschieben (Zurück/Vorwärts Schaltflächen)", + "moveSelectedLinesAndFollowing": "Ausgewählte und nachfolgende Zeilen verschieben (Zurück/Vorwärts Schaltflächen)", + "moveAllLines": "Alle Zeilen verschieben (Zurück/Vorwärts Schaltflächen)", "guessStartNoLineSelected": "Start schätzen: keine Zeile ausgewählt", "guessStartTimeCodesLocked": "Start schätzen: Zeitstempel sind gesperrt", "guessStartNoWaveform": "Start schätzen: keine Wellenform", @@ -1018,7 +1023,7 @@ "guessEndNoSilenceFoundNearLineX": "Ende schätzen: keine Stille in der Nähe von Zeile {0} gefunden", "guessEndMovedLineXByYMs": "Ende schätzen: Ende von Zeile {0} um {1} ms verschoben" }, - "audioTrackIsNowX": "Audiospur ist jetzt „{0}“", + "audioTrackIsNowX": "Audiospur ist jetzt \"{0}\"", "audioTrackX": "Audiospur {0}", "autoBreakHint": "Ausgewählte Zeilen automatisch umbrechen", "charactersPerSecond": "Zeichen/Sek: {0}", @@ -1029,10 +1034,10 @@ "deleteTextAndShiftCellsUp": "Text löschen und Zellen nach oben verschieben", "endTimeMustBeAfterStartTime": "Die Endzeit muss nach der Startzeit liegen.", "errorLoad7Zip": "Diese Datei scheint eine komprimierte 7-Zip-Datei zu sein.\nSubtitle Edit kann keine komprimierten Dateien öffnen.", - "errorLoadBinaryZeroes": "Diese Datei enthält nur Nullzeichen.\n\nFalls Sie diese Datei mit Subtitle Edit bearbeitet haben, dürfte eine Sicherung vorliegen unter Menüpunkt „Datei -> Automatische Sicherung wiederherstellen...“\t\t", + "errorLoadBinaryZeroes": "Diese Datei enthält nur Nullzeichen.\n\nFalls Sie diese Datei mit Subtitle Edit bearbeitet haben, dürfte eine Sicherung vorliegen unter Menüpunkt \"Datei -> Automatische Sicherung wiederherstellen...\"\t\t", "errorLoadGZip": "Diese Datei scheint eine komprimierte GZip-Datei zu sein.\nSubtitle Edit kann keine komprimierten Dateien öffnen.", "errorLoadLargeFile": "Diese Datei ist zu groß, um als Untertiteldatei geöffnet zu werden.", - "errorLoadVideoFilePrompt": "Diese Datei scheint eine Videodatei zu sein:\n{0}\n\nSie können ein Video über „Video -> Video öffnen“ oder durch Ziehen und Ablegen eines Videos auf den Videoplayer öffnen.\n\nMöchten Sie sie als Videodatei öffnen?", + "errorLoadVideoFilePrompt": "Diese Datei scheint eine Videodatei zu sein:\n{0}\n\nSie können ein Video über \"Video -> Video öffnen\" oder durch Ziehen und Ablegen eines Videos auf den Videoplayer öffnen.\n\nMöchten Sie sie als Videodatei öffnen?", "errorLoadJpg": "Bei dieser Datei scheint es sich um eine JPG-Bilddatei zu handeln.\nUntertitel bearbeiten kann keine Bilddateien öffnen.", "errorLoadPng": "Bei dieser Datei handelt es sich offenbar um eine PNG-Bilddatei.\nUntertitel bearbeiten kann keine Bilddateien öffnen.", "errorLoadRar": "Diese Datei scheint eine komprimierte 7-Zip-Datei zu sein.\nSubtitle Edit kann keine komprimierten Dateien öffnen.", @@ -1049,11 +1054,11 @@ "insertEmptyTextAndShiftCellsDown": "Leertext einfügen und Zellen nach unten verschieben", "insertTextFromSubtitleDotDotDot": "Text aus Untertitel einfügen...", "copyTextFromOriginalToCurrent": "Text vom Original in aktuellen Untertitel kopieren", - "insertedXTextsFromSubtitleY": "{0} Texte aus der Untertiteldatei „{1}“ eingefügt", + "insertedXTextsFromSubtitleY": "{0} Texte aus der Untertiteldatei \"{1}\" eingefügt", "italicHint": "Ausgewählte Zeilen/Text kursiv", "joinedSubtitleLoaded": "Verbundener Untertitel geladen", "lineXTextAndTimingChanged": "Zeile {0}: Text und Timing geändert", - "lineXTextChangedFromYToZ": "Zeile {0}: Text von „{1}“ in „{2}“ geändert", + "lineXTextChangedFromYToZ": "Zeile {0}: Text von \"{1}\" in \"{2}\" geändert", "lineXTimingChanged": "Zeile {0}: Timing geändert", "loadingWaveInfoFromCache": "Wave-Informationen werden aus dem Cache geladen...", "clickToGenerateWaveform": "Klicken, um Wellenform zu erzeugen", @@ -1072,14 +1077,14 @@ "redoPerformedXActionLeft": "Wiederhergestellt (verbleibende Aktionen: {0})", "removedUnicodeControlCharactersX": "Unicode-Steuerzeichen aus {0} Zeilen entfernt", "removedXBlankLines": "{0} Leerzeilen entfernt", - "replacedXWithYCountZ": "„{0}“ durch „{1}“ ersetzt ({2} Vorkommen)", + "replacedXWithYCountZ": "\"{0}\" durch \"{1}\" ersetzt ({2} Vorkommen)", "reversedStartAndEndingsForRightToLeftX": "Anfang und Ende für rechts-nach-links in {0} Zeilen getauscht", - "ruleProfileIsX": "Regelprofil ist jetzt „{0}“", + "ruleProfileIsX": "Regelprofil ist jetzt \"{0}\"", "saveLanguageFile": "Sprachdatei speichern", "saveXFileAs": "Speicher {0}-Datei unter", "shiftTextCellsDown": "Zellen nach untern verschieben", "singleLineLength": "Einzelzeilenlänge:", - "speedIsNowX": "Geschwindigkeit ist jetzt „{0}“", + "speedIsNowX": "Geschwindigkeit ist jetzt \"{0}\"", "subtitleGridFormattingX": "Rasterformatierung: {0}", "subtitleImportedFromMatroskaFile": "Untertitel importiert aus Matroska Datei", "textDown": "Text nach unten", @@ -1098,8 +1103,8 @@ "xLinesSelectedOfY": "{0} Zeilen ausgewählt von {1}", "xLinesSwitched": "{0} Zeilen gewechselt", "xShotChangedLoaded": "{0} Szenenwechsel geladen", - "youtubeDlDownloadedSuccessfully": "„yt-dlp“ erfolgreich heruntergeladen.", - "youtubeDlNotInstalledDownloadNow": "„yt-dlp“ ist nicht installiert und wird zum Abspielen von Online-Videos benötigt.\n\nJetzt herunterladen?", + "youtubeDlDownloadedSuccessfully": "\"yt-dlp\" erfolgreich heruntergeladen.", + "youtubeDlNotInstalledDownloadNow": "\"yt-dlp\" ist nicht installiert und wird zum Abspielen von Online-Videos benötigt.\n\nJetzt herunterladen?", "promptInsertSubtitleOverlap": "Die eingefügten Zeilen überlappen vorhandene Zeilen.\n\nTrotzdem einfügen?", "youtubeDlOutdatedDownloadNow": "\"yt-dlp\" ist veraltet und funktioniert möglicherweise nicht mit Online-Videos.\n\nDie aktuelle Version jetzt herunterladen?", "insertUnicodeSymbol": "Unicode-Symbol einfügen", @@ -1123,8 +1128,8 @@ "allowEditHintAllLines": "Das gesamte Original ist auf dem Bildschirm, beim Speichern bleiben also alle Zeilen erhalten.", "allowEditHintMatchingOnlyX": "Beim Speichern des Originals würden nur die angezeigten Zeilen geschrieben - die anderen {0} gingen verloren.", "videoOpenedChangeLayoutQuestion": "Eine Videodatei wurde geöffnet.\n\nMöchten Sie das Layout ändern, um das Videofenster anzuzeigen?", - "sortedByStartTime": "Nach „Einblenden“-Zeit sortiert", - "sortedByEndTime": "Nach „Ausblenden“-Zeit sortiert", + "sortedByStartTime": "Nach \"Einblenden\"-Zeit sortiert", + "sortedByEndTime": "Nach \"Ausblenden\"-Zeit sortiert", "sortedByNumber": "Nach Nummer sortiert", "sortedByDuration": "Nach Dauer sortiert", "sortedByGap": "Nach Abstand sortiert", @@ -1144,11 +1149,11 @@ "snappedXTimesToFrames": "{0} Zeitstempel bei {1:0.###} fps an Frames angepasst", "languageFileSavedToX": "Sprachdatei gespeichert unter {0}", "fileExportedInFormatXToY": "Datei im Format {0} exportiert nach {1}", - "fileExportedInFormatXToFileY": "Datei im Format „{0}“ exportiert in Datei „{1}“", + "fileExportedInFormatXToFileY": "Datei im Format \"{0}\" exportiert in Datei \"{1}\"", "fixedXLines": "{0} Zeilen korrigiert", "transcriptionCompletedWithXLines": "Transkription abgeschlossen mit {0} Zeilen", - "openSubtitleFileX": "Untertiteldatei „{0}“ öffnen?", - "replacedXOccurrences": "{0} Vorkommen ersetzt", + "openSubtitleFileX": "Untertiteldatei \"{0}\" öffnen?", + "replacedXOccurrences": "{0} Ersetzungen vorgenommen", "ffmpegDownloadedAndInstalledToX": "ffmpeg heruntergeladen und installiert unter {0}", "nothingToSave": "Nichts zu speichern", "formatLimitWarningTitle": "Formatgrenzen überschritten", @@ -1250,7 +1255,7 @@ "gapMs": "Abstand (ms)", "useFixedDuration": "Feste Dauer verwenden", "fixedDurationMs": "Feste Dauer (ms)", - "alignViaSpeechToText": "Zeitstempel per „Sprache zu Text“ ausrichten...", + "alignViaSpeechToText": "Zeitstempel per \"Sprache zu Text\" ausrichten...", "alignViaForcedAligner": "Zeitstempel per Forced Aligner ausrichten...", "forcedAlignerProgress": "Ausrichten... Fenster {0} von {1}, {2} von {3} Zeilen", "forcedAlignerSetupTitle": "Zeitstempel per Forced Aligner ausrichten", @@ -1289,7 +1294,7 @@ "titleExportCustomFormat": "Benutzerdefiniertes Textformat exportieren", "editCustomFormat": "Benutzerdefiniertes Textformat bearbeiten", "newCustomFormat": "Neues benutzerdefiniertes Textformat", - "deleteSelectedCustomTextFormatX": "Ausgewähltes benutzerdefiniertes Textformat „{0}“ löschen?", + "deleteSelectedCustomTextFormatX": "Ausgewähltes benutzerdefiniertes Textformat \"{0}\" löschen?", "timeCodeFormat": "Zeitstempelformat", "newLineFormat": "Neues Zeilenformat", "pleaseEnterNameForTheCustomFormat": "Bitte geben Sie einen Namen für das benutzerdefinierte Format ein", @@ -1407,7 +1412,7 @@ "xBackups": "{0} Sicherungen", "openAutoBackupFolder": "Auto-Sicherungen-Ordner öffnen", "restoreAutoBackupFile": "Auto-Sicherungsdatei wiederherstellen", - "restoreXFromY": "Möchten Sie „{0}“ von {1} wiederherstellen?", + "restoreXFromY": "Möchten Sie \"{0}\" von {1} wiederherstellen?", "deleteAllSubtitleBackups": "Möchten Sie alle Untertitel-Sicherungsdateien löschen?", "deleteAll": "Alle löschen" }, @@ -1476,7 +1481,52 @@ "ignoreFormatting": "Formatierung ignorieren", "ignoreFormattingHint": "Zeilen, die sich nur in Formatierungs-Tags wie oder {\\an8} unterscheiden, gelten nicht als unterschiedlich", "showOnlyDifferencesInText": "Nur nach Unterschieden im Text suchen", - "loadXFromFile": "Lade „{0}“ aus der Datei", + "ignoreNumbering": "Nummerierung ignorieren", + "ignoreNumberingHint": "Zeilen, die sich nur in ihrer Nummer unterscheiden, gelten nicht als unterschiedlich", + "ignoreTimeDifferenceMs": "Zeittoleranz (ms)", + "ignoreTimeDifferenceMsHint": "Start- und Endzeiten mit einer Abweichung von höchstens dieser Anzahl an Millisekunden gelten nicht als unterschiedlich", + "compareDifferences": "Unterschiede", + "compareTextDifferences": "Text-Unterschiede", + "compareCurrent": "Aktuell", + "compareReference": "Referenz", + "compareEditable": "Editierbar", + "compareEditableHint": "Hier vorgenommene Änderungen werden beim Klicken auf \"Anwenden\" in den Untertitel übernommen", + "compareReadOnly": "Schreibgeschützt", + "compareTakeFromReference": "Text und Zeiten von Referenzdatei übernehmen", + "compareInsertFromReference": "Diese Zeile in den aktuellen Untertitel einfügen", + "compareDeleteFromCurrent": "Diese Zeile aus dem aktuellen Untertitel löschen", + "compareTakeText": "Text übernehmen", + "compareTakeTiming": "Zeiten übernehmen", + "compareEditLine": "Zeile bearbeiten", + "compareEditHint": "Strg+Enter zum Speichern, Esc zum Abbrechen", + "compareEdited": "Geändert", + "compareOnePendingChange": "1 ausstehende Änderung", + "compareXPendingChanges": "{0} ausstehende Änderungen", + "compareDiscardXChanges": "Die im Vergleich vorgenommenen Änderungen ({0}) verwerfen?", + "compareXChangesApplied": "Vergleich: {0} Änderung(en) übernommen", + "compareChangeTextX": "#{0} Text aus der Referenz", + "compareChangeTimingX": "#{0} Zeiten aus der Referenz", + "compareChangeTextAndTimingX": "#{0} Text und Zeiten aus der Referenz", + "compareChangeEditedX": "#{0} geändert", + "compareChangeInsertedX": "#{0} eingefügt", + "compareChangeDeletedX": "#{0} gelöscht", + "compareSyncPoint": "Synchronisationspunkt", + "compareSyncPointHint": "Synchronisationspunkt – Diese beiden Zeilen werden immer als Paar angezeigt; die Zeilen darüber und darunter werden unabhängig voneinander ausgerichtet", + "compareSyncPickCurrent": "Synchronisationspunkt: Diese aktuelle Zeile verwenden", + "compareSyncPickReference": "Synchronisationspunkt: Diese Referenzzeile verwenden", + "compareSyncRemove": "Synchronisationspunkt entfernen", + "compareClearXSyncPoints": "Synchronisationspunkte löschen ({0})", + "compareSyncCurrentPickedX": "Aktuelle Zeile #{0} als Synchronisationspunkt gewählt – Wählen Sie die passende Referenzzeile und klicken Sie auf \"Synchronisieren\"", + "compareSyncReferencePickedX": "Referenzzeile #{0} als Synchronisationspunkt gewählt – Wählen Sie die passende aktuelle Zeile und klicken Sie auf \"Synchronisieren\"", + "compareSyncWithCurrentX": "Synchronisation mit aktueller Zeile #{0}", + "compareSyncWithReferenceX": "Synchronisation mit Referenzzeile #{0}", + "compareSyncApply": "Synchronisieren", + "compareSyncPointSetXY": "Synchronisationspunkt gesetzt: Aktuelle Zeile #{0} und Referenzzeile #{1} werden nun als Paar dargestellt", + "compareSyncAlreadyPairedXY": "Aktuelle Zeile #{0} und Referenzzeile #{1} werden bereits als Paar mit identischem Start angezeigt – keine Synchronisation erforderlich", + "compareSyncPointShiftedXYZW": "Synchronisationspunkt gesetzt: Aktuelle Zeilen #{0}–#{1} um {2} verschoben, um mit Referenzzeile #{3} zu beginnen", + "compareChangeSyncShiftXYZ": "#{0}–#{1} um {2} verschoben (Synchronisationspunkt)", + "compareTimingDeltaHint": "Wie viel später (+) oder früher (−) die Referenz im Vergleich zur aktuellen Zeile beginnt und endet", + "loadXFromFile": "Lade \"{0}\" aus der Datei", "saveCompareHtmlTitle": "Vergleichs-HTML-Datei speichern", "pickMatroskaTrackX": "Matroska-Track auswählen - {0}", "pickTransportStreamTrackX": "Transportstream-Spur wählen - {0}", @@ -1551,8 +1601,8 @@ "appliedRules": "Angewendete Regeln", "findRule": "Suchregel", "xLinesAffected": "{0:#,##0} Zeilen betroffen", - "deleteCategoryConfirm": "Kategorie „{0}“ löschen?", - "deleteRuleConfirm": "Regel „{0}“ löschen?", + "deleteCategoryConfirm": "Kategorie \"{0}\" löschen?", + "deleteRuleConfirm": "Regel \"{0}\" löschen?", "findWhat": "Suchen nach", "descriptionOptional": "Beschreibung (optional)", "invalidRegularExpressionX": "Ungültiger regulärer Ausdruck: {0}", @@ -1569,7 +1619,9 @@ "replaceIn": "Ersetzen/suchen in", "replaceInTextAndOriginal": "Text und Originaltext", "replaceInTextOnly": "Nur Text", - "replaceInOriginalOnly": "Nur Originaltext" + "replaceInOriginalOnly": "Nur Originaltext", + "replacedOneOccurrence": "1 Ersetzung vorgenommen", + "replacedXOccurrences": "{0} Ersetzungen vorgenommen" }, "regularExpressionContextMenu": { "wordBoundary": "Wortgrenze (\\b)", @@ -1598,7 +1650,7 @@ "xLinesYSubtitlesZErrors": "{0} Zeilen, {1} Untertitel, {2} Fehler", "couldNotParseAsX": "Konnte nicht als {0} gelesen werden", "selectedXCharactersYLines": "Ausgewählt: {0} Zeichen in {1} Zeile(n)", - "replacedXOccurrences": "{0} Vorkommen ersetzt", + "replacedXOccurrences": "{0} Ersetzungen vorgenommen", "invalidRegularExpression": "Ungültiger regulärer Ausdruck", "regularExpressionTooSlowX": "Der reguläre Ausdruck wurde nach {0} Sekunden abgebrochen - versuchen Sie ein einfacheres Muster", "moveLineUp": "Zeile nach oben verschieben", @@ -1677,7 +1729,7 @@ "removeLineBreaksAll": "Zeilenumbrüche in kurzen Texten (außer Dialoge) entfernen", "removeLineBreaksPixelWidth": "Zeilenumbrüche aus Untertiteln entfernen, die in eine Zeile passen (Pixelbreite)", "fixUppercaseIInsideLowercaseWords": "Großes 'i' in klein geschriebenen Wörtern (OCR Fehler) korrigieren", - "fixDoubleApostrophes": "Doppelte Apostrophe ('' ) zu Anführungszeichen („“) korrigieren", + "fixDoubleApostrophes": "Doppelte Apostrophe ('' ) zu Anführungszeichen (\"\") korrigieren", "addPeriods": "Punkt am Ende einfügen, wenn nächste Zeile mit Großbuchstaben beginnt.", "startWithUppercaseLetterAfterParagraph": "Mit Großbuchstaben nach einem Punkt beginnen", "startWithUppercaseLetterAfterPeriodInsideParagraph": "Mit Großbuchstaben nach Punkten beginnen", @@ -1691,11 +1743,11 @@ "fixTurkishAnsi": "Wandle türkische ANSI Buchstaben (Isländisch) nach Unicode", "fixDanishLetterI": "Dänischen Buchstaben 'i' korrigieren", "fixSpanishInvertedQuestionAndExclamationMarks": "Spanische umgedrehte Frage- und Ausrufezeichen korrigieren", - "addMissingQuote": "Fehlendes Anführungszeichen hinzufügen („“)", - "addMissingQuotes": "Fehlende Anführungszeichen hinzufügen („“)", + "addMissingQuote": "Fehlendes Anführungszeichen hinzufügen (\"\")", + "addMissingQuotes": "Fehlende Anführungszeichen hinzufügen (\"\")", "removeHyphensSingleLine": "Dialogstriche in einzelnen Zeilen entfernen", "fixHyphensInDialogs": "Bindestrich in Dialogen über Stil korrigieren: {0}", - "addMissingQuotesExample": "„Wie geht's?“ -> „Wie geht's?“", + "addMissingQuotesExample": "\"Wie geht's?\" -> \"Wie geht's?\"", "removedEmptyLinesUnusedLineBreaksExample": "Hat nur eine gültige Zeile!
-> Hat nur eine gültige Zeile!", "fixCommasExample": ",, -> ,", "removeLineBreaksExample": "Foo
bar! -> Foo bar!", @@ -1715,7 +1767,7 @@ "xFixedBut": "{0} Problem(e) behoben, aber der Untertitel enthält weiterhin Fehler - siehe Protokoll für Details", "xCouldBeFixedBut": "{0} Problem(e) konnten behoben werden, aber der Untertitel enthält immer noch Fehler – Einzelheiten finden Sie im Protokoll", "numberOfImportantLogMessages": "{0} wichtige Logmeldungen!", - "fixedOkXY": "Korrigiert und OK - „{0}“: {1}", + "fixedOkXY": "Korrigiert und OK - \"{0}\": {1}", "xFixesApplied": "Korrekturen angewendet: {0}", "xFixesYSelected": "{0} Korrekturen - {1} ausgewählt", "fixFirstLetterToUppercaseAfterParagraph": "Ersten Buchstaben nach Punkt groß schreiben", @@ -1748,12 +1800,12 @@ "fixDoubleGreaterThan": "Entferne '>>'", "fixEllipsesStart": "Entferne '...' am Anfang", "fixMissingOpenBracket": "Korrigiere fehlende [", - "fixMusicNotation": "Musiksymbole „♫“ mit bevorzugten Symbolen ersetzen", - "fixDoubleDashExample": "„Hey-- das geht!“ -> „Hey... das geht!“", - "fixDoubleGreaterThanExample": "„>> Robert: Sehr gut!“ -> „Robert: Sehr gut!“", - "fixEllipsesStartExample": "„... und dann haben wir“ -> „und dann haben wir“", - "fixMissingOpenBracketExample": "„ruft] Pass auf!“ -> „[ruft] Pass auf!“", - "fixMusicNotationExample": "„♫ 99 Luftballons“ -> „♪ 99 Luftballons“", + "fixMusicNotation": "Musiksymbole \"♫\" mit bevorzugten Symbolen ersetzen", + "fixDoubleDashExample": "\"Hey-- das geht!\" -> \"Hey... das geht!\"", + "fixDoubleGreaterThanExample": "\">> Robert: Sehr gut!\" -> \"Robert: Sehr gut!\"", + "fixEllipsesStartExample": "\"... und dann haben wir\" -> \"und dann haben wir\"", + "fixMissingOpenBracketExample": "\"ruft] Pass auf!\" -> \"[ruft] Pass auf!\"", + "fixMusicNotationExample": "\"♫ 99 Luftballons\" -> \"♪ 99 Luftballons\"", "fixOcrErrorExample": "NIcht -> Nicht", "fixSpaceBetweenNumbersExample": "1 100 -> 1100", "fixDialogsOneLineExample": "Hallo John! - Hallo Ida! -> Hallo John!
- Hallo Ida!", @@ -1844,6 +1896,11 @@ "presetDefault": "Standard", "presetNetflix": "Netflix", "presetSdi": "SDI", + "saveAsProfile": "Profil speichern...", + "deleteProfile": "Profil löschen", + "profileName": "Profilname", + "overwriteProfileX": "Ein Profil mit dem Namen \"{0}\" existiert bereits. Überschreiben?", + "deleteProfileX": "Profil \"{0}\" löschen?", "general": "Allgemein", "gap": "Abstand", "gapSuffix": "Frames (aktualisiert auch die unten stehenden Abstandsfelder ungleich null)", @@ -2000,7 +2057,7 @@ "rebalanceOnlyLinesTooLongHint": "Vorhandene Zeilenumbrüche unangetastet lassen, wenn alle Zeilen bereits innerhalb der maximalen Einzelzeilenlänge liegen - ein absichtlich unausgeglichener Untertitel kann redaktionell korrekt sein", "splitLongLine": "Lange Zeile teilen", "rebalanceLongLine": "Lange Zeile neu ausgleichen", - "splitIntoXLines": "In {0} Zeilen teilen: „{1}“ → „{2}...“", + "splitIntoXLines": "In {0} Zeilen teilen: \"{1}\" → \"{2}...\"", "linesSplitX": "Geteilte Zeilen: {0}", "linesSplitXLinesRebalancedY": "Geteilte Zeilen: {0}, neu ausgeglichene Zeilen: {1}", "unbreakLinesShorterThanHint": "Text, der kürzer ist als dieser Wert, bleibt auf einer Zeile (oder wird dazu verbunden) - setzen Sie ihn auf die maximale Einzelzeilenlänge, damit jeder Text, der auf eine Zeile passt, ungebrochen bleibt" @@ -2081,7 +2138,7 @@ "dualSpeakersHyphenWithoutSpaceSecondOnly": "Zwei Sprecher: Bindestrich ohne Leerzeichen verwenden, um nur den zweiten Sprecher zu kennzeichnen", "minimumTwoFramesGapOverlapping": "Mindestens zwei Frames Abstand (Überlappend)", "minimumTwoFramesGap": "Mindestens zwei Frames Abstand", - "frameRateIsInvalidX": "Bildrate ist ungültig: „{0}“", + "frameRateIsInvalidX": "Bildrate ist ungültig: \"{0}\"", "frameRateIsInvalid": "Bildrate ist ungültig", "minimumDurationJapanese": "Mindestdauer: 0,5 Sekunden", "minimumDuration": "Mindestdauer: 5/6 Sekunde (833 ms)", @@ -2190,16 +2247,16 @@ "wordNotFound": "Wort nicht gefunden", "loadSourceImage": "Auch Quellbilder anzeigen...", "lineXofY": "Rechtschreibprüfung - Zeile {0} von {1}", - "changeWordFromXToY": "Wort von „{0}“ in „{1}“ ändern", - "changeAllWordsFromXToY": "Alle Wörter von „{0}“ in „{1}“ ändern", - "ignoreWordXOnce": "Wort „{0}“ einmal ignorieren", - "ignoreWordXAlways": "Wort „{0}“ immer ignorieren", - "wordXAddedToNamesList": "Wort „{0}“ zur Namensliste hinzugefügt", - "wordXAddedToUserDictionary": "Wort „{0}“ zum Benutzerwörterbuch hinzugefügt", - "useSuggestionX": "Vorschlag „{0}“ verwenden", - "useSuggestionXAlways": "Vorschlag „{0}“ immer verwenden", - "addXToUserDictionary": "„{0}“ zum Benutzerwörterbuch hinzufügen", - "ignoreAllX": "Alle „{0}“ ignorieren", + "changeWordFromXToY": "Wort von \"{0}\" in \"{1}\" ändern", + "changeAllWordsFromXToY": "Alle Wörter von \"{0}\" in \"{1}\" ändern", + "ignoreWordXOnce": "Wort \"{0}\" einmal ignorieren", + "ignoreWordXAlways": "Wort \"{0}\" immer ignorieren", + "wordXAddedToNamesList": "Wort \"{0}\" zur Namensliste hinzugefügt", + "wordXAddedToUserDictionary": "Wort \"{0}\" zum Benutzerwörterbuch hinzugefügt", + "useSuggestionX": "Vorschlag \"{0}\" verwenden", + "useSuggestionXAlways": "Vorschlag \"{0}\" immer verwenden", + "addXToUserDictionary": "\"{0}\" zum Benutzerwörterbuch hinzufügen", + "ignoreAllX": "Alle \"{0}\" ignorieren", "pickSpellCheckDictionaryDotDotDot": "Rechtschreibwörterbuch wählen...", "chooseSpellCheckDictionary": "Rechtschreibwörterbuch wählen", "pickLiveSpellCheckDictionary": "Live-Rechtschreibwörterbuch wählen", @@ -2342,7 +2399,7 @@ "preparingMergeDotDotDot": "Zusammenführung wird vorbereitet...", "importVoiceDotDotDot": "Stimme importieren...", "voiceImportSuccessTitle": "Stimme importiert", - "voiceXImported": "Stimme „{0}“ erfolgreich importiert", + "voiceXImported": "Stimme \"{0}\" erfolgreich importiert", "voiceXCouldNotBeImported": "Stimme '{0}' konnte nicht importiert werden - siehe Protokoll für Details", "importPiperVoiceTitle": "Piper-Stimmmodell öffnen (.onnx)", "piperVoiceConfigMissingTitle": "Konfigurationsdatei fehlt", @@ -2406,12 +2463,12 @@ "deleteTempFiles": "Erzeugte Clips beim Schließen löschen", "deleteTempFilesDescription": "Löscht den Erzeugungsordner, wenn das Fenster \"Text zu Sprache\" geschlossen wird. Gespeicherte Audio- und Videodateien bleiben erhalten.", "edgeTtsRate": "Edge-TTS-Rate", - "edgeTtsRateDescription": "Sprechgeschwindigkeit für Edge-TTS, z. B. „+50%“, „-30%“ oder „+0%“ für Standard.", + "edgeTtsRateDescription": "Sprechgeschwindigkeit für Edge-TTS, z. B. \"+50%\", \"-30%\" oder \"+0%\" für Standard.", "edgeTtsPitch": "Edge-TTS-Tonhöhe", - "edgeTtsPitchDescription": "Tonhöhenanpassung für Edge-TTS, z. B. „+10Hz“, „-5Hz“ oder „+0Hz“ für Standard.", + "edgeTtsPitchDescription": "Tonhöhenanpassung für Edge-TTS, z. B. \"+10Hz\", \"-5Hz\" oder \"+0Hz\" für Standard.", "edgeTtsVolume": "Edge-TTS-Lautstärke", - "edgeTtsVolumeDescription": "Lautstärkeanpassung für Edge-TTS, z. B. „+20%“, „-10%“ oder „+0%“ für Standard.", - "downloadPiperPrompt": "„Text zu Sprache“ erfordert Piper.\n\nPip er herunterladen und verwenden?", + "edgeTtsVolumeDescription": "Lautstärkeanpassung für Edge-TTS, z. B. \"+20%\", \"-10%\" oder \"+0%\" für Standard.", + "downloadPiperPrompt": "\"Text zu Sprache\" erfordert Piper.\n\nPip er herunterladen und verwenden?", "omniVoiceTtsSettings": "OmniVoice TTS-Einstellungen", "reDownloadOmniVoiceTts": "OmniVoice TTS erneut herunterladen", "selectTheBuildToDownload": "\nWählen Sie den herunterzuladenden Build:", @@ -2423,12 +2480,12 @@ "chatterboxTtsSettings": "Chatterbox TTS-Einstellungen", "piperSettings": "Piper-Einstellungen", "voiceInstruction": "Stimmgestaltung", - "voiceInstructionHint": "Optional - z. B. „Sprechen Sie in einem ruhigen und freundlichen Ton“", + "voiceInstructionHint": "Optional - z. B. \"Sprechen Sie in einem ruhigen und freundlichen Ton\"", "voiceGender": "Geschlecht", "voiceAge": "Alter", "voicePitch": "Tonhöhe", "voiceAccent": "Akzent", - "voiceInstructionClonedVoiceNote": "Die Stimmgestaltung betrifft nur die „Standard“-Stimme - eine geklonte Stimme behält ihre eigenen Eigenschaften.", + "voiceInstructionClonedVoiceNote": "Die Stimmgestaltung betrifft nur die \"Standard\"-Stimme - eine geklonte Stimme behält ihre eigenen Eigenschaften.", "actorVoicesTitle": "TTS - Besetzung", "actorVoicesSubtitle": "Weisen Sie jedem Akteur oder jeder Stimme eine TTS-Stimme (und optional eine Stimmgestaltungsanweisung) zu.", "actorVoicesAssignedXOfY": "{0} von {1} zugewiesen", @@ -2438,7 +2495,7 @@ "setupCast": "Besetzung...", "setupCastHint": "Weisen Sie jedem Akteur (ASSA) oder jeder Stimme (WebVTT) eine TTS-Stimme zu.", "actorVoicesRowSettingsTitle": "TTS - Stimmeinstellungen", - "voiceSettingsForX": "Stimmeinstellungen für „{0}“", + "voiceSettingsForX": "Stimmeinstellungen für \"{0}\"", "voiceInstructionFreeTextHint": "Freier Text, der von der Engine verwendet wird, um den Ton der Stimme zu formen.", "voiceDesign": "Stimmgestaltung", "noActorsFoundMessage": "Keine Akteure gefunden. Legen Sie zuerst das Akteur-Feld in den Untertitelzeilen fest.", @@ -2672,6 +2729,7 @@ "toggleShotChange": "Szenenwechsel umschalten", "resetWaveformZoomAndSpeed": "Wellenform-Zoom und -Geschwindigkeit zurücksetzen", "showOriginalSubtitle": "Original-Untertitel anzeigen", + "timelineGroupTracksBy": "Untertitelspuren gruppieren nach", "showOnlyWaveform": "Nur Wellenform anzeigen", "showOnlySpectrogram": "Nur Spektrogramm anzeigen", "showWaveformAndSpectrogram": "Wellenform und Spektrogramm anzeigen", @@ -2827,9 +2885,9 @@ "subtitleGridLiveSpellCheck": "Live-Rechtschreibprüfung in der Untertiteltabelle", "subtitleGridCenterText": "Text im Untertitelraster zentrieren", "subtitleGridShowFormatting": "Formatierten (HTML/ASSA) Text in Untertiteltabelle anzeigen", - "showUpDownStartTime": "Auf-/Ab-Steuerelement für „Anzeigen“ anzeigen", - "showUpDownEndTime": "Auf-/Ab-Steuerelement für „Ausblenden“ anzeigen", - "showUpDownDuration": "Auf-/Ab-Steuerung für „Dauer“ anzeigen", + "showUpDownStartTime": "Auf-/Ab-Steuerelement für \"Anzeigen\" anzeigen", + "showUpDownEndTime": "Auf-/Ab-Steuerelement für \"Ausblenden\" anzeigen", + "showUpDownDuration": "Auf-/Ab-Steuerung für \"Dauer\" anzeigen", "showUpDownLabels": "Beschriftungen für Auf-/Ab-Steuerelemente anzeigen (Anzeigen/Ausblenden/Dauer)", "showButtonHints": "Schaltflächenhinweise anzeigen", "gridCompactMode": "Kompaktmodus für das Raster verwenden", @@ -2867,14 +2925,21 @@ "autoBreakPreferBottomPercent": "Prozentsatz für untenlastigen automatischen Umbruch", "useDoNotBreakAfterList": "Liste \"Nicht umbrechen nach\" verwenden", "newEmptyDefaultMs": "Standarddauer neuer Untertitel (ms)", - "timeCodeUpDownStepMs": "Schrittweite Zeit auf/ab (ms)", + "timeCodeUpDownStepMs": "Schrittweite für Zeit hoch/runter (ms)", + "moveSelectedLinesStepMs": "Schrittweite für Tastenkürzel \"Ausgewählte Zeilen verschieben\" (ms)", + "moveStartEndStepMs": "Schrittweite für Tastenkürzel \"Start/Ende verschieben\" (ms)", + "moveLinesShortenNeighbor": "Zeilen verschieben: Vorherige/Nächste Zeile kürzen anstatt zu überlappen", "promptBeforeDelete": "Vor dem Löschen fragen", "rememberPositionAndSize": "Fensterposition und Größe speichern", + "titleBarFullFileName": "Vollständigen Dateipfad in der Titelleiste anzeigen", "openLastFileOnStart": "Zuletzt verwendete Datei beim Start öffnen", "autoSave": "Automatisch speichern (geöffnete Datei beim Bearbeiten speichern)", - "autoBackupOn": "Auto-Sicherung", + "autoBackupOn": "Automatische Sicherung", "autoBackupIntervalMinutes": "Auto-Sicherungsintervall (Minuten)", "autoBackupDeleteAfterDays": "Sicherung entfernen nach (Tage)", + "settingsBackupOn": "Einstellungen für automatische Sicherung", + "settingsBackupIntervalDays": "Sicherungsintervall (Tage, 0 = bei jedem Start)", + "settingsBackupMaxCount": "Anzahl zu behaltender Sicherungen", "autoConvertToUtf8": "Beim Öffnen automatisch in UTF-8 konvertieren", "autoTrimWhiteSpace": "Leerzeichen automatisch entfernen", "removeBlankLinesWhenOpening": "Leerzeilen beim Öffnen von Untertiteln entfernen", @@ -2891,7 +2956,9 @@ "colorGapTooShort": "Abstand farblich markieren, falls zu kurz", "errorBackgroundColor": "Farbe Fehler", "waveformDrawGridLines": "Gitter anzeigen", + "waveformUseSkiaRenderer": "Experimentellen schnellen Renderer verwenden", "waveformCenterVideoPositionAlsoWhenPaused": "Videoposition auch im Pausenzustand zentrieren", + "waveformSelectCurrentSubtitleWhilePaused": "Aktuellen Untertitel auch bei Pause auswählen", "waveformShowToolbar": "Symbolleiste anzeigen", "waveformShowToolbarEdit": "Symbolleiste anpassen...", "waveformShowToolbarEditLabel": "Symbolleisten-Elemente", @@ -2939,14 +3006,22 @@ "webVttUseXTimestampMap": "WebVTT: X-TIMESTAMP-MAP verwenden (Timecodes beim Laden versetzen)", "assaAutoSetResolution": "ASSA: Auflösung (PlayResX/PlayResY) beim Öffnen eines Videos übernehmen", "assaAutoSetResolutionPrompt": "ASSA: Vor Anpassung der Auflösung an das Video nachfragen (sonst automatisch neu abtasten)", + "videoControls": "Video-Steuerelemente", + "videoControlsEdit": "Steuerelemente anpassen…", + "videoControlsPositionText": "Anzeige Position/Dauer", + "videoControlsVideoFileName": "Video-Dateiname", + "videoControlsPlayerName": "Videoplayer-Name", "showStopButton": "Stopp Knopf anzeigen", "showFullscreenButton": "Vollbild Knopf anzeigen", "fullscreenHideControls": "Bedienelemente bei Vollbild ausblenden", + "showSecondarySubtitleDialog": "Stil-Dialog beim Öffnen einer zweiten Untertiteldatei anzeigen", + "rememberSecondarySubtitleFile": "Zweite Untertiteldatei merken", "autoOpenVideoFile": "Videodatei beim Öffnen des Untertitels automatisch öffnen", "downloadMpv": "mpv herunterladen", "downloadVlc": "VLC herunterladen", "allowSingleLetterShortcutsInTextbox": "Einzelbuchstaben-Hotkeys im Textfeld erlauben", - "spellCheckEnglishTreatInApostropheAsIng": "Rechtschreibprüfung: Wortendung „in“ als „ing“ behandeln (nur Englisch)", + "allowTextNavigationShortcutsInTextbox": "Tastenkürzel auf Textnavigations-Tasten (Strg+Links/Rechts, Pos1/Ende) im Textfeld zulassen", + "spellCheckEnglishTreatInApostropheAsIng": "Rechtschreibprüfung: Wortendung \"in\" als \"ing\" behandeln (nur Englisch)", "goToLineNumberSetsVideoPosition": "Gehe-zu-Zeile legt auch die Videoposition fest", "adjustAllTimesRememberLineSelectionChoice": "Alle Zeiten anpassen, Auswahl der Zeilen merken", "mergeKeepEndTime": "Zeilen verbinden: Endzeit beibehalten (Überlappung mit nächstem Untertitel erlauben)", @@ -2959,6 +3034,7 @@ "showAssaLayer": "ASSA-Ebenenfeld anzeigen", "waveformCursorColor": "Wellenform Cursorfarbe", "waveformShotChangeColor": "Wellenform Farbe für Szenenwechsel", + "waveformGridColor": "Wellenform-Gitterfarbe", "waveformParagraphLeftColor": "Wellenform Farbe des linken Randes", "waveformParagraphRightColor": "Wellenform Farbe des rechten Randes", "waveformFancyHighColor": "Wellenform Farbe für Frequenzspitzen", @@ -2986,13 +3062,17 @@ "resetSyntaxColoring": "Syntaxfärbung zurücksetzen", "resetWaveform": "Wellenform zurücksetzen", "resetRules": "Regeln zurücksetzen", + "timeCodeMode": "Zeitstempel-Modus", + "timeCodeModeTime": "Zeit (hh:mm:ss,zzz)", + "timeCodeModeFrames": "Frames (hh:mm:ss:ff)", + "timeCodeModeFrameNumbers": "Frame-Nummern", "useFrameMode": "Bild-Modus verwenden (hh.mm.ss.ff)", "textBoxLimitNewLines": "Anzahl der Zeilen im Textfeld begrenzen", "mpvOpenGl": "libmpv - OpenGL", "mpvSoftwareRendering": "libmpv – Software-Rendering (langsam)", "mpvWidRendering": "libmpv – Natives Fenster-ID-Rendering", "waveFormsAndSpectrogramFoldersContainsX": "Die Wellenform und Spektrogramm-Ordner enthalten {0}", - "deleteWaveformAndSpectrogramFoldersQuestion": "„Wellenform“- und „Spektrogramm“-Dateien löschen?", + "deleteWaveformAndSpectrogramFoldersQuestion": "\"Wellenform\"- und \"Spektrogramm\"-Dateien löschen?", "waveformGenerateSpectrogram": "Spektrogramm erstellen", "waveformAutoGenerate": "Wellenform beim Öffnen eines Videos automatisch erzeugen", "waveformSpectrogramMode": "Spektrogramm-Modus (funktioniert zur Generierungszeit)", @@ -3002,7 +3082,10 @@ "waveformExtractAudioSampleRate": "Audio-Abtastrate bei Extraktion", "waveformExtractAudioSampleRateOriginal": "Original (Quelle beibehalten)", "waveformExtractAudioBitRate": "Audio-Bitrate bei Extraktion (MP3/M4A)", + "ffmpegUseCenterChannelOnly": "Nur Center-Kanal für 5.1-Audio verwenden (FFmpeg)", "vlcWidRendering": "libVLC - Rendering über native Fenster-ID", + "ffmpegSoftwareRendering": "FFmpeg", + "downloadFfmpegLibs": "FFmpeg-Bibliotheken herunterladen (für den FFmpeg-Videoplayer)", "subtitleGridEnterKeyAction": "Aktion bei Enter im Untertitelraster", "subtitleSingleClickAction": "Einfachklick-Aktion im Untertitelraster", "subtitleDoubleClickAction": "Doppelklick-Aktion im Untertitelraster", @@ -3040,14 +3123,17 @@ "saveAsBehaviorUseSubtitleFileName": "Untertitel-Dateiname verwenden", "saveAsAppendLanguageCodeTwoLetter": "Zwei Buchstaben", "saveAsAppendLanguageCodeLanguageName": "Name der Sprache", - "splitOddLineActionWeightTop": "Weight top", - "splitOddLineActionWeightBottom": "Weight bottom", - "splitOddLinesAction": "Split odd lines action", - "ocrUseWordSplitList": "OCR: use word split list", - "ocrGuessUnknownWords": "OCR: try to guess unknown words", - "speechToTextSelectedLinesPromptFirstTimeOnly": "Speech to text: selected lines, prompt for language/engine first time only", - "multipleReplaceShowDotDotDotButtons": "Multiple replace: show context menu buttons", - "gridFocusTextboxAfterInsertNew": "Grid: focus text box after insert new subtitle", + "splitOddLineActionWeightTop": "Gewichtung: Oben", + "splitOddLineActionWeightBottom": "Gewichtung: Unten", + "splitOddLinesAction": "Aktion für das Teilen ungerader Zeilen", + "ocrUseWordSplitList": "OCR: Worttrennungsliste verwenden", + "ocrGuessUnknownWords": "OCR: Versuchen, unbekannte Wörter zu erraten", + "spellCheckRememberUseAlwaysList": "Rechtschreibprüfung: Liste \"Immer verwenden\" merken", + "fixShortDisplayTimesAllowMoveStartTime": "Häufige Fehler korrigieren: Beim Anpassen zu kurzer Anzeigedauern die Startzeit verschieben", + "speechToTextSelectedLinesPromptFirstTimeOnly": "Sprache-zu-Text: Ausgewählte Zeilen, Sprache/Engine nur beim ersten Mal abfragen", + "multipleReplaceShowDotDotDotButtons": "Mehrfach Ersetzen: Kontextmenü-Schaltflächen anzeigen", + "gridFocusTextboxAfterInsertNew": "Untertitelraster: Textfeld nach dem Einfügen neuer Untertitel fokussieren", + "undoRedoGoToChangedLine": "Rückgängig/Wiederholen: Zur geänderten Zeile (und Videoposition) springen", "musicSymbol": "Musik Symbol", "musicSymbolsToReplace": "Zu ersetzende Musiksymbole (getrennt durch Komma)", "textToSpeechPromptMergeContinuationLines": "Text zu Sprache: Nachfrage zum Zusammenfügen von Folgezeilen", @@ -3081,7 +3167,9 @@ "waveformColorTheme": "Farbthema:", "waveformSaveAsCustomTheme": "Als benutzerdefiniertes Thema speichern...", "waveformLoadThemeDotDotDot": "Thema laden...", - "waveformExportThemeDotDotDot": "Thema exportieren..." + "waveformExportThemeDotDotDot": "Thema exportieren...", + "waveformSaveCustomThemeTitle": "Benutzerdefiniertes Thema speichern", + "waveformThemeHighContrast": "Hoher Kontrast" }, "shortcuts": { "searchShortcuts": "Tastenkombinationen durchsuchen...", @@ -3194,7 +3282,7 @@ "listViewColumnTextDown": "Spalte, Text nach unten", "autoTranslateSelectedLines": "Ausgewählte Zeilen automatisch übersetzen...", "setAssaResolution": "ASSA-Auflösung setzen (PlayResX/PlayResY)", - "setShortcutForX": "Tastenkombination für „{0}“ festlegen", + "setShortcutForX": "Tastenkombination für \"{0}\" festlegen", "commandFileNewKeepVideo": "Öffnen (Video behalten)", "fileOpenOriginal": "Original Untertitel öffnen", "fileCloseOriginal": "Original Untertitel schließen", @@ -3299,8 +3387,8 @@ "copySubtitleOriginalPathToClipboard": "Untertitelpfad des Originals in Zwischenablage kopieren", "focusTextBox": "Textfeld fokussieren", "sortByNumber": "Nach Nummer sortieren", - "sortByStartTime": "Nach „Einblenden“-Zeit sortieren", - "sortByEndTime": "Nach „Ausblenden“-Zeit sortieren", + "sortByStartTime": "Nach \"Einblenden\"-Zeit sortieren", + "sortByEndTime": "Nach \"Ausblenden\"-Zeit sortieren", "sortByDuration": "Nach Dauer sortieren", "sortByGap": "Nach Abstand sortieren", "sortByText": "Nach Text sortieren - alphabetisch", @@ -3371,25 +3459,25 @@ "getPluginsTitle": "Plugins beziehen", "disabled": "Deaktiviert", "notSupportedOnThisOs": "Auf diesem Betriebssystem nicht unterstützt", - "removePluginXConfirm": "Plugin „{0}“ entfernen? Dadurch wird der Plugin-Ordner gelöscht:{1}", - "couldNotRemovePluginX": "Plugin „{0}“ konnte nicht entfernt werden: {1}", - "pluginXHasNoExecutable": "Plugin „{0}“ hat keine ausführbare Datei für dieses Betriebssystem.", - "pluginXFailed": "Plugin „{0}“ fehlgeschlagen.", - "pluginXReportedError": "Plugin „{0}“ hat einen Fehler gemeldet.", + "removePluginXConfirm": "Plugin \"{0}\" entfernen? Dadurch wird der Plugin-Ordner gelöscht:{1}", + "couldNotRemovePluginX": "Plugin \"{0}\" konnte nicht entfernt werden: {1}", + "pluginXHasNoExecutable": "Plugin \"{0}\" hat keine ausführbare Datei für dieses Betriebssystem.", + "pluginXFailed": "Plugin \"{0}\" fehlgeschlagen.", + "pluginXReportedError": "Plugin \"{0}\" hat einen Fehler gemeldet.", "pluginXDone": "{0} fertig", - "pluginXReturnedUnparsableSubtitle": "Plugin „{0}“ hat einen Untertitel zurückgegeben, der nicht verarbeitet werden konnte.", + "pluginXReturnedUnparsableSubtitle": "Plugin \"{0}\" hat einen Untertitel zurückgegeben, der nicht verarbeitet werden konnte.", "loadingPluginList": "Plugin-Liste wird geladen...", "noPluginsAvailable": "Keine Plugins verfügbar.", "xPluginsAvailable": "{0} Plugins verfügbar.", "couldNotLoadPluginList": "Plugin-Liste konnte nicht geladen werden: {0}", "downloadingXPercent": "Wird heruntergeladen {0}%...", - "pluginXInstalled": "„{0}“ installiert.", - "couldNotInstallPluginX": "„{0}“ konnte nicht installiert werden: {1}", + "pluginXInstalled": "\"{0}\" installiert.", + "couldNotInstallPluginX": "\"{0}\" konnte nicht installiert werden: {1}", "install": "Installieren", "reinstall": "Neu installieren", "installedX": "Installiert ({0})", "updateAvailableXToY": "Update verfügbar ({0} → {1})", - "applyPluginToWhichLinesX": "„{0}“ anwenden auf:", + "applyPluginToWhichLinesX": "\"{0}\" anwenden auf:", "applyToSelectedLinesX": "Ausgewählte Zeilen ({0})", "applyToAllLinesX": "Alle Zeilen ({0})", "updateAllXAvailable": "Alle aktualisieren ({0})", @@ -3476,15 +3564,17 @@ "oneColor": "Eine Farbe (weiß)", "darknessThreshold": "Dunkelheitsschwelle", "editExportDotDotDot": "Bearbeiten/exportieren...", - "editBinaryOcrDatabase": "„Binärer Bildvergleich“-Datenbank bearbeiten", - "binaryImageCompareDatabase": "„Binärer Bildvergleich“-Datenbank", - "removeXFromUnknownWordsList": "„{0}“ aus Liste unbekannter Wörter entfernen", + "editBinaryOcrDatabase": "\"Binärer Bildvergleich\"-Datenbank bearbeiten", + "binaryImageCompareDatabase": "\"Binärer Bildvergleich\"-Datenbank", + "removeXFromUnknownWordsList": "\"{0}\" aus Liste unbekannter Wörter entfernen", "downloadingPaddleOcrEngineDotDotDot": "Paddle OCR-Engine wird heruntergeladen...", "downloadingPaddleOcrModelsDotDotDot": "Paddle OCR-Modelle werden heruntergeladen...", "paddleOcr": "Paddle OCR", - "binaryImageCompareInspectImageMatches": "„Binärer Bildvergleich“ - Bildübereinstimmungen prüfen", + "binaryImageCompareInspectImageMatches": "\"Binärer Bildvergleich\" - Bildübereinstimmungen prüfen", "saveBlankTextTitle": "Leeren Text speichern?", "saveBlankTextPrompt": "Leeren Text für Bild speichern?", + "drawnLinesDoNotMatchTitle": "Linien stimmen nicht mit dem Bild überein", + "drawnLinesDoNotMatchPrompt": "Einige der gezeichneten Linien stimmen nicht mit dem Bild überein (grüne Linien müssen auf dem Zeichen liegen, rote außerhalb). Daher wird dieses Zeichen nicht erkannt.\n\nTrotzdem speichern?", "yesAndNeverAskAgain": "Ja und nie wieder fragen", "importTextFromSubtitleDotDotDot": "Text aus Untertitel importieren...", "importTextFromSubtitleOverwritePrompt": "Einige Zeilen haben bereits OCR-Text. Vorhandenen Text überschreiben?", @@ -3657,7 +3747,7 @@ "renameCategory": "Kategorie umbenennen...", "deleteCategory": "Kategorie löschen", "moveToCategoryDotDotDot": "In Kategorie verschieben...", - "deleteCategoryQuestion": "Kategorie „{0}“ löschen? Darin enthaltene Stile werden in die Standardkategorie verschoben.", + "deleteCategoryQuestion": "Kategorie \"{0}\" löschen? Darin enthaltene Stile werden in die Standardkategorie verschoben.", "stylesTitleX": "Stile – {0}", "propertiesTitleX": "Eigenschaften – {0}", "attachmentsTitleX": "Anhänge – {0}", From 6c6dcb6f963af9de2783c1288ff92d7b96830c25 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Tue, 6 Oct 2026 22:25:53 +0100 Subject: [PATCH 008/110] Add string.CountLines() and use it instead of SplitToLines().Count CountLines uses the same line break rules as SplitToLines ("\r\r\n" is two breaks, #8854) but only counts, so callers that just need the number of lines no longer allocate a list plus a substring per line. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/libse/Common/StringExtensions.cs | 27 +++++++++++++ src/libse/Forms/SplitLongLinesHelper.cs | 2 +- .../Translate/MergeAndSplitHelper.cs | 6 +-- .../AssistedMoveCandidateGenerator.cs | 2 +- src/ui/Features/Main/MainViewModel.cs | 8 ++-- .../SplitBreakLongLinesViewModel.cs | 2 +- .../NetflixCheckNumberOfLines.cs | 6 +-- tests/benchmarks/CountLinesBenchmarks.cs | 38 +++++++++++++++++++ tests/libse/Core/StringExtensionsTest.cs | 18 +++++++++ 9 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 tests/benchmarks/CountLinesBenchmarks.cs diff --git a/src/libse/Common/StringExtensions.cs b/src/libse/Common/StringExtensions.cs index 721595e90b8..ad9c9f8f5b9 100644 --- a/src/libse/Common/StringExtensions.cs +++ b/src/libse/Common/StringExtensions.cs @@ -132,6 +132,33 @@ public static bool Contains(this string source, string value, StringComparison c public static List SplitToLines(this string s) => s.SplitToLines(s.Length); + /// + /// Returns the number of lines would return, using the same + /// line break rules ("\r\r\n" is two breaks, #8854), without allocating the lines. + /// + public static int CountLines(this string s) + { + var span = s.AsSpan(); + var count = 1; + while (true) + { + var idx = span.IndexOfAny('\r', '\n', '\u2028'); + if (idx < 0) + { + return count; + } + + count++; + var skip = idx + 1; + if (span[idx] == '\r' && skip < span.Length && span[skip] == '\n') + { + skip++; + } + + span = span.Slice(skip); + } + } + /// /// Rewrites every line break to , so text coming from /// the outside (a paste, an API answer) uses the same line break as text SE builds diff --git a/src/libse/Forms/SplitLongLinesHelper.cs b/src/libse/Forms/SplitLongLinesHelper.cs index e9dc8c0d63c..e120897c326 100644 --- a/src/libse/Forms/SplitLongLinesHelper.cs +++ b/src/libse/Forms/SplitLongLinesHelper.cs @@ -65,7 +65,7 @@ public static Subtitle SplitLongLinesInSubtitle(Subtitle subtitle, int totalLine text = Utilities.AutoBreakLine(oldParagraph.Text, language, true); } - if (noHtmlLines.Count == 1 && text.SplitToLines().Count <= 2 && noHtmlLines[0].Length > singleLineMaxCharacters && !QualifiesForSplit(text, singleLineMaxCharacters, totalLineMaxCharacters)) + if (noHtmlLines.Count == 1 && text.CountLines() <= 2 && noHtmlLines[0].Length > singleLineMaxCharacters && !QualifiesForSplit(text, singleLineMaxCharacters, totalLineMaxCharacters)) { oldParagraph.Text = text; continue; diff --git a/src/libuilogic/Translate/MergeAndSplitHelper.cs b/src/libuilogic/Translate/MergeAndSplitHelper.cs index 65a01637f19..40ceb88c30d 100644 --- a/src/libuilogic/Translate/MergeAndSplitHelper.cs +++ b/src/libuilogic/Translate/MergeAndSplitHelper.cs @@ -487,7 +487,7 @@ private static int TrySplitByLineCount( Action applyRowUpdate) { var translatedLines = mergedTranslation.SplitToLines(); - if (translatedLines.Count != mergeResult.Text.SplitToLines().Count) + if (translatedLines.Count != mergeResult.Text.CountLines()) { return 0; } @@ -500,7 +500,7 @@ private static int TrySplitByLineCount( foreach (var mergeItem in mergeResult.MergeResultItems) { var numberOfParagraphs = mergeItem.EndIndex - mergeItem.StartIndex + 1; - var numberOfLines = mergeItem.Text.SplitToLines().Count; + var numberOfLines = mergeItem.Text.CountLines(); var translatedText = BuildTranslatedText(translatedLines, ref translatedLinesIdx, numberOfLines); @@ -1130,7 +1130,7 @@ private static List SplitContinuousText(string text, MergeResultItem ite private static List? SplitByPreservedLineBreaks(string text, MergeResultItem item) { var replyLines = text.SplitToLines(); - var lineCounts = item.Paragraphs.Select(p => p.Text.SplitToLines().Count).ToList(); + var lineCounts = item.Paragraphs.Select(p => p.Text.CountLines()).ToList(); if (replyLines.Count != lineCounts.Sum()) { return null; diff --git a/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs b/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs index 7bb05a538eb..529268b371a 100644 --- a/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs +++ b/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs @@ -408,7 +408,7 @@ private static (TimeSpan firstEnd, TimeSpan secondStart) ComputeTimeSplit( } var newText = upDown.S1 + Environment.NewLine + upDown.S2; - if (newText.SplitToLines().Count > 2) + if (newText.CountLines() > 2) { newText = Utilities.AutoBreakLine(Utilities.UnbreakLine(newText), languageCode); } diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 785be4339d6..3ef5b7e57d8 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -15071,7 +15071,7 @@ private void SortByWpm() [RelayCommand] private void SortByNumberOfLines() { - SortSubtitlesBy(p => (p.Text ?? string.Empty).SplitToLines().Count, Se.Language.Main.SortedByNumberOfLines); + SortSubtitlesBy(p => (p.Text ?? string.Empty).CountLines(), Se.Language.Main.SortedByNumberOfLines); } [RelayCommand] @@ -32793,7 +32793,7 @@ internal void OnKeyDownHandler(object? sender, KeyEventArgs keyEventArgs) keyEventArgs.KeyModifiers == KeyModifiers.None && Se.Settings.General.SubtitleTextBoxLimitNewLines) { - var newLineCount = EditTextBox.Text.SplitToLines().Count; + var newLineCount = EditTextBox.Text.CountLines(); if (newLineCount >= Se.Settings.General.MaxNumberOfLines) { keyEventArgs.Handled = true; @@ -35153,7 +35153,7 @@ partial void OnSelectedSubtitleChanged(SubtitleLineViewModel? value) if (value != null) { _teletextLineCountSubtitleId = value.Id; - _teletextLineCountLastSeen = (value.Text ?? string.Empty).SplitToLines().Count; + _teletextLineCountLastSeen = (value.Text ?? string.Empty).CountLines(); } } @@ -35165,7 +35165,7 @@ partial void OnSelectedSubtitleChanged(SubtitleLineViewModel? value) /// private void AdjustTeletextRowForLineCountChange(SubtitleLineViewModel subtitle) { - var newLineCount = (subtitle.Text ?? string.Empty).SplitToLines().Count; + var newLineCount = (subtitle.Text ?? string.Empty).CountLines(); // The edit box also raises TextChanged when the selection swaps its content to another // row - only line-count changes within the same row are edits. diff --git a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs index 249bf055e72..3fca56fbef3 100644 --- a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs +++ b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs @@ -288,7 +288,7 @@ public static bool HasSentenceEndingLineBreak(string? text) private static int GetPlainLineCount(string? text) { - return HtmlUtil.RemoveHtmlTags(text ?? string.Empty, true).SplitToLines().Count; + return HtmlUtil.RemoveHtmlTags(text ?? string.Empty, true).CountLines(); } public static bool HasLineTooLong(string? text, int singleLineMaxLength, int maxNumberOfLines) diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumberOfLines.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumberOfLines.cs index fa8d7e3463e..34914968520 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumberOfLines.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumberOfLines.cs @@ -21,18 +21,18 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { foreach (var p in subtitle.Paragraphs) { - if (p.Text.SplitToLines().Count > 2) + if (p.Text.CountLines() > 2) { var fixedParagraph = new Paragraph(p, false); fixedParagraph.Text = Utilities.AutoBreakLine(fixedParagraph.Text, controller.SingleLineMaxLength, controller.SingleLineMaxLength - 3, controller.Language); - if (fixedParagraph.Text.SplitToLines().Count > 2) + if (fixedParagraph.Text.CountLines() > 2) { fixedParagraph = null; // cannot fix text } var comment = Se.Language.Tools.NetflixCheckAndFix.TwoLinesMaximum; controller.AddRecord(p, fixedParagraph, comment, string.Empty, fixedParagraph != null); } - else if (p.Text.SplitToLines().Count == 2 && p.Text.Contains(Environment.NewLine) && + else if (p.Text.CountLines() == 2 && p.Text.Contains(Environment.NewLine) && p.Text.Replace(Environment.NewLine, " ").Replace(" ", " ").CountCharacters(false) <= controller.SingleLineMaxLength && p.Text != Utilities.AutoBreakLine(p.Text, controller.SingleLineMaxLength, controller.SingleLineMaxLength + 1, controller.Language)) { diff --git a/tests/benchmarks/CountLinesBenchmarks.cs b/tests/benchmarks/CountLinesBenchmarks.cs new file mode 100644 index 00000000000..adc73e54c5b --- /dev/null +++ b/tests/benchmarks/CountLinesBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using Nikse.SubtitleEdit.Core.Common; + +namespace Nikse.SubtitleEdit.Benchmarks; + +/// +/// Line counting as the Netflix check, sort-by-number-of-lines, the teletext line-count watch and +/// the translate merge/split helper do it: SplitToLines().Count allocates a list plus one +/// substring per line only to read the count; CountLines() scans the same breaks and +/// allocates nothing. +/// +[MemoryDiagnoser] +public class CountLinesBenchmarks +{ + private string _text = string.Empty; + + [Params("OneLine", "TwoLines", "ThreeLines", "Long")] + public string Shape { get; set; } = "TwoLines"; + + [GlobalSetup] + public void Setup() + { + var nl = Environment.NewLine; + _text = Shape switch + { + "OneLine" => "It was the best of times, it was the worst of times.", + "TwoLines" => "- Are you coming with us?" + nl + "- No, I will stay right here.", + "ThreeLines" => "It was the best of times," + nl + "it was the worst of times," + nl + "it was the age of wisdom.", + _ => string.Join(nl, Enumerable.Repeat("It was the best of times, it was the worst of times.", 50)), + }; + } + + [Benchmark(Baseline = true)] + public int SplitToLinesCount() => _text.SplitToLines().Count; + + [Benchmark] + public int CountLines() => _text.CountLines(); +} diff --git a/tests/libse/Core/StringExtensionsTest.cs b/tests/libse/Core/StringExtensionsTest.cs index 0109250da90..288e5475a04 100644 --- a/tests/libse/Core/StringExtensionsTest.cs +++ b/tests/libse/Core/StringExtensionsTest.cs @@ -92,6 +92,24 @@ public void SplitToLines1() Assert.Equal(2, input.SplitToLines().Count); } + [Theory] + [InlineData("")] + [InlineData("a")] + [InlineData("a\r\nb")] + [InlineData("a\nb\n")] + [InlineData("a\rb")] + [InlineData("a\u2028b")] + [InlineData("\r\r\n")] + [InlineData("\n\n\r")] + [InlineData("\r\n\r\n")] + [InlineData("\n\nLine3\r\n\r\nLine5\r")] + [InlineData("a\n\n\rb")] + [InlineData("e\r\r\ne\n\ne\ne\re\r\ne\n\re")] + public void CountLinesMatchesSplitToLines(string input) + { + Assert.Equal(input.SplitToLines().Count, input.CountLines()); + } + [Fact] public void SplitToLinesEmptyLines1() { From 59bbc310d39d4c6d787d1e57aca998f3e18e6969 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 05:50:02 +0200 Subject: [PATCH 009/110] Option to remember waveform zoom across restarts (#15748) New Settings > Waveform checkbox "Remember zoom (horizontal and vertical)", off by default. When on, horizontal and vertical zoom are saved on close, restored when the waveform is created, and no longer reset to 100% when an empty waveform is generated. "Reset waveform zoom & speed" still resets. Co-Authored-By: Claude Opus 5.5 --- src/ui/Assets/Languages/English.json | 3 ++- src/ui/Features/Main/Layout/InitWaveform.cs | 7 ++++++- src/ui/Features/Main/MainViewModel.cs | 15 ++++++++++++--- src/ui/Features/Options/Settings/SettingsPage.cs | 3 ++- .../Options/Settings/SettingsViewModel.cs | 5 ++++- .../Config/Language/Options/LanguageSettings.cs | 4 +++- src/ui/Logic/Config/SeWaveform.cs | 9 +++++++++ 7 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 740cd7be33d..04ad4459c29 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1,4 +1,4 @@ -{ +{ "title": "Subtitle Edit", "version": "v5.3.0-beta23", "translatedBy": "", @@ -3412,6 +3412,7 @@ "waveformUseSkiaRenderer": "Use experimental fast renderer", "waveformCenterVideoPositionAlsoWhenPaused": "Center video position also while paused", "waveformSelectCurrentSubtitleWhilePaused": "Select current subtitle also while paused", + "waveformRememberZoom": "Remember zoom (horizontal and vertical)", "waveformShowToolbar": "Show toolbar", "waveformShowToolbarEdit": "Edit toolbar...", "waveformShowToolbarEditLabel": "Toolbar items", diff --git a/src/ui/Features/Main/Layout/InitWaveform.cs b/src/ui/Features/Main/Layout/InitWaveform.cs index f31bafe224c..45b64f67d3c 100644 --- a/src/ui/Features/Main/Layout/InitWaveform.cs +++ b/src/ui/Features/Main/Layout/InitWaveform.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -96,6 +96,11 @@ internal static Grid MakeWaveform(MainViewModel vm, bool withTimelineTracks, out vm.AudioVisualizer.FocusOnMouseOver = settings.FocusOnMouseOver; vm.AudioVisualizer.IsReadOnly = Se.Settings.General.LockTimeCodes; vm.AudioVisualizer.WaveformHeightPercentage = settings.SpectrogramCombinedWaveformHeight; + if (settings.RememberZoom) + { + vm.AudioVisualizer.ZoomFactor = Math.Clamp(settings.ZoomFactor, AudioVisualizer.MinZoomFactor, AudioVisualizer.MaxZoomFactor); + vm.AudioVisualizer.VerticalZoomFactor = Math.Clamp(settings.VerticalZoomFactor, AudioVisualizer.MinZoomFactor, AudioVisualizer.MaxZoomFactor); + } // The toggle may have been pressed in a layout without a waveform; a waveform // built later must come up on the same side of it as the video preview, or the // two previews show different texts (see SetOriginalTextInPreview). diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 785be4339d6..5ab370bde66 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.UiLogic.Export; +using Nikse.SubtitleEdit.UiLogic.Export; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -28551,6 +28551,11 @@ internal async void OnClosing(object? sender, WindowClosingEventArgs e) Se.Settings.General.SelectCurrentSubtitleWhilePlaying = SelectCurrentSubtitleWhilePlaying; Se.Settings.Waveform.ShowToolbar = IsWaveformToolbarVisible; Se.Settings.Waveform.CenterVideoPosition = WaveformCenter; + if (Se.Settings.Waveform.RememberZoom && AudioVisualizer != null) + { + Se.Settings.Waveform.ZoomFactor = AudioVisualizer.ZoomFactor; + Se.Settings.Waveform.VerticalZoomFactor = AudioVisualizer.VerticalZoomFactor; + } UiUtil.SaveWindowPosition(Window); Se.Settings.General.UndockVideoControls = Se.Settings.General.RememberPositionAndSize && AreVideoControlsUndocked; @@ -29762,8 +29767,12 @@ private async Task AddEmptyWaveform() if (vp.VideoPlayer.Duration > 0) { var peakWaveFileName = WavePeakGenerator2.GetPeakWaveFileName(_videoFileName ?? string.Empty, _audioTrack?.FfIndex ?? -1); - AudioVisualizer.ZoomFactor = 1.0; - AudioVisualizer.VerticalZoomFactor = 1.0; + if (!Se.Settings.Waveform.RememberZoom) + { + AudioVisualizer.ZoomFactor = 1.0; + AudioVisualizer.VerticalZoomFactor = 1.0; + } + AudioVisualizer.WavePeaks = WavePeakGenerator2.GenerateEmptyPeaks(peakWaveFileName, (int)vp.VideoPlayer.Duration); } } diff --git a/src/ui/Features/Options/Settings/SettingsPage.cs b/src/ui/Features/Options/Settings/SettingsPage.cs index 7f346768ccb..41f92a07a4f 100644 --- a/src/ui/Features/Options/Settings/SettingsPage.cs +++ b/src/ui/Features/Options/Settings/SettingsPage.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -773,6 +773,7 @@ private List CreateSections() () => UiUtil.MakeComboBox(_vm.WaveformMouseWheelVideoPositionSteps, _vm, nameof(_vm.SelectedWaveformMouseWheelVideoPositionStep))), MakeCheckboxSetting(Se.Language.Options.Settings.WaveformCenterVideoPositionAlsoWhenPaused, nameof(_vm.WaveformCenterVideoPositionAlsoWhenPaused)), MakeCheckboxSetting(Se.Language.Options.Settings.WaveformSelectCurrentSubtitleWhilePaused, nameof(_vm.WaveformSelectCurrentSubtitleWhilePaused)), + MakeCheckboxSetting(Se.Language.Options.Settings.WaveformRememberZoom, nameof(_vm.WaveformRememberZoom)), MakeCheckboxSetting(Se.Language.Options.Settings.WaveformDrawGridLines, nameof(_vm.WaveformDrawGridLines)), MakeCheckboxSetting(Se.Language.Options.Settings.WaveformUseSkiaRenderer, nameof(_vm.WaveformUseSkiaRenderer)), // SE 4 parity: the per-paragraph footer in the waveform ("#43 01:10" and the diff --git a/src/ui/Features/Options/Settings/SettingsViewModel.cs b/src/ui/Features/Options/Settings/SettingsViewModel.cs index 72cc5e41c2d..5e2030b041f 100644 --- a/src/ui/Features/Options/Settings/SettingsViewModel.cs +++ b/src/ui/Features/Options/Settings/SettingsViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Animation; using Avalonia.Animation.Easings; using Avalonia.Controls; @@ -277,6 +277,7 @@ partial void OnSelectedTimeCodeModeChanged(TimeCodeModeDisplay value) [ObservableProperty] private bool _waveformCenterVideoPosition; [ObservableProperty] private bool _waveformCenterVideoPositionAlsoWhenPaused; [ObservableProperty] private bool _waveformSelectCurrentSubtitleWhilePaused; + [ObservableProperty] private bool _waveformRememberZoom; [ObservableProperty] private ObservableCollection _waveformDrawStyles; [ObservableProperty] private string _selectedWaveformDrawStyle; @@ -986,6 +987,7 @@ private void LoadSettings() WaveformCenterVideoPosition = Se.Settings.Waveform.CenterVideoPosition; WaveformCenterVideoPositionAlsoWhenPaused = Se.Settings.Waveform.CenterVideoPositionAlsoWhenPaused; WaveformSelectCurrentSubtitleWhilePaused = Se.Settings.Waveform.SelectCurrentSubtitleWhilePaused; + WaveformRememberZoom = Se.Settings.Waveform.RememberZoom; WaveformShowToolbar = Se.Settings.Waveform.ShowToolbar; WaveformShowOriginalSubtitle = Se.Settings.Waveform.ShowOriginalSubtitle; @@ -1874,6 +1876,7 @@ private void SaveSettings() Se.Settings.Waveform.CenterVideoPosition = WaveformCenterVideoPosition; Se.Settings.Waveform.CenterVideoPositionAlsoWhenPaused = WaveformCenterVideoPositionAlsoWhenPaused; Se.Settings.Waveform.SelectCurrentSubtitleWhilePaused = WaveformSelectCurrentSubtitleWhilePaused; + Se.Settings.Waveform.RememberZoom = WaveformRememberZoom; Se.Settings.Waveform.FocusTextBoxAfterInsertNew = WaveformFocusTextboxAfterInsertNew; if (SelectedWaveformDrawStyle == Se.Language.General.Classic) diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs index 74f05de20db..2e8b2a6ddac 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Core.Enums; +using Nikse.SubtitleEdit.Core.Enums; using System; namespace Nikse.SubtitleEdit.Logic.Config.Language.Options; @@ -131,6 +131,7 @@ public class LanguageSettings public string WaveformUseSkiaRenderer { get; set; } public string WaveformCenterVideoPositionAlsoWhenPaused { get; set; } public string WaveformSelectCurrentSubtitleWhilePaused { get; set; } + public string WaveformRememberZoom { get; set; } public string WaveformShowToolbar { get; set; } public string WaveformShowToolbarEdit { get; set; } public string WaveformShowToolbarEditLabel { get; set; } @@ -489,6 +490,7 @@ public LanguageSettings() WaveformFocusOnMouseOver = "Focus on mouse over"; WaveformCenterVideoPositionAlsoWhenPaused = "Center video position also while paused"; WaveformSelectCurrentSubtitleWhilePaused = "Select current subtitle also while paused"; + WaveformRememberZoom = "Remember zoom (horizontal and vertical)"; WaveformShowToolbar = "Show toolbar"; WaveformShowToolbarEdit = "Edit toolbar..."; WaveformShowToolbarEditLabel = "Toolbar items"; diff --git a/src/ui/Logic/Config/SeWaveform.cs b/src/ui/Logic/Config/SeWaveform.cs index af551a6523a..d21bfd5c296 100644 --- a/src/ui/Logic/Config/SeWaveform.cs +++ b/src/ui/Logic/Config/SeWaveform.cs @@ -19,6 +19,12 @@ public class SeWaveform // With the above on, also select the line under the cursor while scrubbing paused. // Off by default: SE 4 never changed the selection while paused (#15513). public bool SelectCurrentSubtitleWhilePaused { get; set; } + + // Keep the main waveform's horizontal/vertical zoom across restarts (#15748). + // Off by default: SE always opened at 100% zoom. + public bool RememberZoom { get; set; } + public double ZoomFactor { get; set; } + public double VerticalZoomFactor { get; set; } public bool DrawGridLines { get; set; } /// @@ -137,6 +143,9 @@ public SeWaveform() TimelineTrackGrouping = "None"; TimelineShowThumbnails = true; DrawGridLines = false; + RememberZoom = false; + ZoomFactor = 1.0; + VerticalZoomFactor = 1.0; UseSkiaRenderer = false; FocusTextBoxAfterInsertNew = true; SpectrogramCombinedWaveformHeight = 50; From 5171c0ec7f2644e76b7f2c93aa58a1465ff801dd Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 05:50:10 +0200 Subject: [PATCH 010/110] Flatpak: move runtime from EOL freedesktop 24.08 to 26.08 org.freedesktop.Platform 24.08 (and its GL.default extensions) is end of life. Bump the manifests, nuget-sources generator and CI workflows to 26.08, and pin the shared-framework packs to 10.0.12 - the runtime the dotnet10//26.08 extension ships - so the offline restore matches. Fixes #15754 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-flatpak.yml | 6 ++--- .github/workflows/build-ui.yml | 24 +++++++++---------- installer/flatpak/dk.nikse.subtitleedit.yaml | 2 +- .../flathub/dk.nikse.subtitleedit.yaml | 2 +- .../flathub/prepare-flathub-release.sh | 2 +- installer/flatpak/generate-nuget-sources.sh | 6 ++--- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-flatpak.yml b/.github/workflows/build-flatpak.yml index b1adbb3008c..402bb3eecea 100644 --- a/.github/workflows/build-flatpak.yml +++ b/.github/workflows/build-flatpak.yml @@ -17,13 +17,13 @@ jobs: regenerate-nuget-sources: runs-on: ubuntu-latest env: - # The runtime version the flatpak org.freedesktop.Sdk.Extension.dotnet10//24.08 + # The runtime version the flatpak org.freedesktop.Sdk.Extension.dotnet10//26.08 # extension ships. generate-nuget-sources.sh pins the pre-fetched shared-framework # packs to this exact version; otherwise a self-contained restore rolls them forward # to the newest patch on nuget.org, which mismatches the older runtime the flatpak SDK # pins and breaks the offline restore inside the sandbox (NU1102). Bump this to match # whenever the flathub dotnet10 extension updates its runtime. - DOTNET_RUNTIME_VERSION: '10.0.8' + DOTNET_RUNTIME_VERSION: '10.0.12' steps: - uses: actions/checkout@v5 @@ -58,7 +58,7 @@ jobs: needs: regenerate-nuget-sources runs-on: ubuntu-latest container: - image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 + image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-26.08 options: --privileged steps: diff --git a/.github/workflows/build-ui.yml b/.github/workflows/build-ui.yml index 5799a270faf..4ddfe485746 100644 --- a/.github/workflows/build-ui.yml +++ b/.github/workflows/build-ui.yml @@ -112,17 +112,17 @@ jobs: uses: actions/cache@v4 with: path: ~/.local/share/flatpak - key: flatpak-fd24.08-dotnet10-v2 + key: flatpak-fd26.08-dotnet10-v2 restore-keys: | - flatpak-fd24.08-dotnet10- + flatpak-fd26.08-dotnet10- - name: Install/update Freedesktop SDK + dotnet extension run: | flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo flatpak --user install -y --noninteractive flathub \ - org.freedesktop.Platform//24.08 \ - org.freedesktop.Sdk//24.08 \ - org.freedesktop.Sdk.Extension.dotnet10//24.08 + org.freedesktop.Platform//26.08 \ + org.freedesktop.Sdk//26.08 \ + org.freedesktop.Sdk.Extension.dotnet10//26.08 flatpak --user update -y --noninteractive - name: Regenerate nuget-sources.json @@ -149,7 +149,7 @@ jobs: needs: regenerate-flatpak-nuget-sources runs-on: ubuntu-latest container: - image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 + image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-26.08 options: --privileged steps: @@ -229,17 +229,17 @@ jobs: uses: actions/cache@v4 with: path: ~/.local/share/flatpak - key: flatpak-fd24.08-dotnet10-v2 + key: flatpak-fd26.08-dotnet10-v2 restore-keys: | - flatpak-fd24.08-dotnet10- + flatpak-fd26.08-dotnet10- - name: Install/update Freedesktop SDK + dotnet extension run: | flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo flatpak --user install -y --noninteractive flathub \ - org.freedesktop.Platform//24.08 \ - org.freedesktop.Sdk//24.08 \ - org.freedesktop.Sdk.Extension.dotnet10//24.08 + org.freedesktop.Platform//26.08 \ + org.freedesktop.Sdk//26.08 \ + org.freedesktop.Sdk.Extension.dotnet10//26.08 flatpak --user update -y --noninteractive # Checks out the pinned commit directly in this checkout (safe: this job's @@ -310,7 +310,7 @@ jobs: runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} container: - image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 + image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-26.08 options: --privileged steps: diff --git a/installer/flatpak/dk.nikse.subtitleedit.yaml b/installer/flatpak/dk.nikse.subtitleedit.yaml index bfe4f5946f9..2cc6498f280 100644 --- a/installer/flatpak/dk.nikse.subtitleedit.yaml +++ b/installer/flatpak/dk.nikse.subtitleedit.yaml @@ -1,6 +1,6 @@ app-id: dk.nikse.subtitleedit runtime: org.freedesktop.Platform -runtime-version: '24.08' +runtime-version: '26.08' sdk: org.freedesktop.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.dotnet10 diff --git a/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml b/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml index 09605c42b01..a19b6513072 100644 --- a/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml +++ b/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml @@ -1,6 +1,6 @@ app-id: dk.nikse.subtitleedit runtime: org.freedesktop.Platform -runtime-version: '24.08' +runtime-version: '26.08' sdk: org.freedesktop.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.dotnet10 diff --git a/installer/flatpak/flathub/prepare-flathub-release.sh b/installer/flatpak/flathub/prepare-flathub-release.sh index c5982b18795..f698847f23c 100755 --- a/installer/flatpak/flathub/prepare-flathub-release.sh +++ b/installer/flatpak/flathub/prepare-flathub-release.sh @@ -31,7 +31,7 @@ # anything to flathub/flathub, or push to flathub/dk.nikse.subtitleedit. # # Requires: git, python3 (used to parse/update the manifest's tag/commit fields), and -# either (a) flatpak + org.freedesktop.Sdk.Extension.dotnet10//24.08, or (b) a system +# either (a) flatpak + org.freedesktop.Sdk.Extension.dotnet10//26.08, or (b) a system # dotnet 10 SDK install (see ../generate-nuget-sources.sh for details on both paths). # # Usage (from repo root): diff --git a/installer/flatpak/generate-nuget-sources.sh b/installer/flatpak/generate-nuget-sources.sh index b9c8b6bd2c8..74e70295ed0 100644 --- a/installer/flatpak/generate-nuget-sources.sh +++ b/installer/flatpak/generate-nuget-sources.sh @@ -10,8 +10,8 @@ # Run this script whenever NuGet dependencies change, then commit both # nuget-sources.json and src/ui/packages.lock.json alongside the manifest. # -# Requires (preferred): flatpak, org.freedesktop.Sdk//24.08, -# org.freedesktop.Sdk.Extension.dotnet10//24.08, python3 +# Requires (preferred): flatpak, org.freedesktop.Sdk//26.08, +# org.freedesktop.Sdk.Extension.dotnet10//26.08, python3 # Requires (fallback): dotnet >= 10, python3 # # Usage (from repo root): @@ -34,7 +34,7 @@ GENERATOR="$SCRIPT_DIR/flatpak-dotnet-generator.py" GENERATOR_COMMIT="3fc0620788a1dda1a3a539b8f972edadce8260ab" GENERATOR_URL="https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/${GENERATOR_COMMIT}/dotnet/flatpak-dotnet-generator.py" -FREEDESKTOP_VERSION="24.08" +FREEDESKTOP_VERSION="26.08" DOTNET_VERSION="10" cd "$REPO_ROOT" From f1dc376fd02bc46640f8e570195aa5336d0a925c Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 05:50:16 +0200 Subject: [PATCH 011/110] Fix doubled BOM Co-Authored-By: Claude Opus 5.5 --- src/ui/Assets/Languages/English.json | 2 +- src/ui/Features/Main/Layout/InitWaveform.cs | 2 +- src/ui/Features/Main/MainViewModel.cs | 2 +- src/ui/Features/Options/Settings/SettingsPage.cs | 2 +- src/ui/Features/Options/Settings/SettingsViewModel.cs | 2 +- src/ui/Logic/Config/Language/Options/LanguageSettings.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 04ad4459c29..8fab92301b0 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1,4 +1,4 @@ -{ +{ "title": "Subtitle Edit", "version": "v5.3.0-beta23", "translatedBy": "", diff --git a/src/ui/Features/Main/Layout/InitWaveform.cs b/src/ui/Features/Main/Layout/InitWaveform.cs index 45b64f67d3c..071dab839e0 100644 --- a/src/ui/Features/Main/Layout/InitWaveform.cs +++ b/src/ui/Features/Main/Layout/InitWaveform.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Primitives; diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 5ab370bde66..057bbda7da3 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.UiLogic.Export; +using Nikse.SubtitleEdit.UiLogic.Export; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; diff --git a/src/ui/Features/Options/Settings/SettingsPage.cs b/src/ui/Features/Options/Settings/SettingsPage.cs index 41f92a07a4f..caa93acf49d 100644 --- a/src/ui/Features/Options/Settings/SettingsPage.cs +++ b/src/ui/Features/Options/Settings/SettingsPage.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Primitives; diff --git a/src/ui/Features/Options/Settings/SettingsViewModel.cs b/src/ui/Features/Options/Settings/SettingsViewModel.cs index 5e2030b041f..679f4e21bc5 100644 --- a/src/ui/Features/Options/Settings/SettingsViewModel.cs +++ b/src/ui/Features/Options/Settings/SettingsViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Animation; using Avalonia.Animation.Easings; using Avalonia.Controls; diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs index 2e8b2a6ddac..d37f26918e5 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Core.Enums; +using Nikse.SubtitleEdit.Core.Enums; using System; namespace Nikse.SubtitleEdit.Logic.Config.Language.Options; From 387c7c46c268d26db7d0d0efff42844a6b71efe0 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 05:54:47 +0200 Subject: [PATCH 012/110] SMPTE timing: frame numbers follow the NDF time code clock (#15753) In SMPTE timing the subtitle's times are on the non-drop-frame time code clock (the video is stretched by 1.001), but the frame helpers still divided by 24000/1001, so frame numbers drifted 87 frames per hour at 23.976: frame-number time code mode, frame-count formats (MicroDVD, ...), custom text "frames" tokens. They now use the whole-number rate (24/30/60) in SMPTE timing for 23.976/29.97/59.94. Toggling SMPTE timing on a frame-count format keeps every line on the same frame, so reopening a recent file in SMPTE timing doesn't shift it by 0.1 %. Co-Authored-By: Claude Opus 5.5 --- src/libse/SubtitleFormats/SubtitleFormat.cs | 32 +++++++-- src/ui/Features/Main/MainViewModel.cs | 42 +++++++++++ .../SubtitleFormatFunctionsTest.cs | 69 +++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/libse/SubtitleFormats/SubtitleFormat.cs b/src/libse/SubtitleFormats/SubtitleFormat.cs index 825d8e4de58..47099dfe3af 100644 --- a/src/libse/SubtitleFormats/SubtitleFormat.cs +++ b/src/libse/SubtitleFormats/SubtitleFormat.cs @@ -597,7 +597,31 @@ public static int MillisecondsToFrames(double milliseconds) public static int MillisecondsToFrames(double milliseconds, double frameRate) { - return (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetFrameForCalculation(frameRate)), MidpointRounding.AwayFromZero); + return (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetTimeCodeFrameRate(frameRate)), MidpointRounding.AwayFromZero); + } + + /// + /// True when SMPTE timing is on and is 23.976, 29.97 or 59.94. + /// The subtitle's times are then on the non-drop-frame time code clock - 24/30/60 frames per + /// time code second, with the video stretched by 1.001 to match - so frame 86400 at 23.976 is + /// 01:00:00:00, i.e. 3600000 ms, not 3603600 ms (#15753). + /// + public static bool IsSmpteTimeCodeClock(double frameRate) + { + return Configuration.Settings.General.CurrentVideoIsSmpte && + (Math.Abs(frameRate - 23.976) < 0.001 || + Math.Abs(frameRate - 29.97) < 0.001 || + Math.Abs(frameRate - 59.94) < 0.001); + } + + /// + /// The frame rate subtitle times and frame numbers convert with: the whole-number time code + /// rate in SMPTE timing (see ), otherwise the exact + /// NTSC rate from . + /// + public static double GetTimeCodeFrameRate(double frameRate) + { + return IsSmpteTimeCodeClock(frameRate) ? Math.Round(frameRate) : GetFrameForCalculation(frameRate); } public static double GetFrameForCalculation(double frameRate) @@ -620,7 +644,7 @@ public static double GetFrameForCalculation(double frameRate) public static int MillisecondsToFramesMaxFrameRate(double milliseconds) { - var frames = (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetFrameForCalculation(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); + var frames = (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); if (frames >= Configuration.Settings.General.CurrentFrameRate) { frames = (int)(Configuration.Settings.General.CurrentFrameRate - 0.01); @@ -636,12 +660,12 @@ public static int FramesToMilliseconds(double frames) public static int FramesToMilliseconds(double frames, double frameRate) { - return (int)Math.Round(frames * (TimeCode.BaseUnit / GetFrameForCalculation(frameRate)), MidpointRounding.AwayFromZero); + return (int)Math.Round(frames * (TimeCode.BaseUnit / GetTimeCodeFrameRate(frameRate)), MidpointRounding.AwayFromZero); } public static int FramesToMillisecondsMax999(double frames) { - var ms = (int)Math.Round(frames * (TimeCode.BaseUnit / GetFrameForCalculation(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); + var ms = (int)Math.Round(frames * (TimeCode.BaseUnit / GetTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); return Math.Min(ms, 999); } diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 785be4339d6..8cecf16ca5c 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -11497,6 +11497,7 @@ private void ToggleSmpteTiming() return; } + var frameNumbers = GetFrameNumbersOfFrameBasedFormat(); if (IsSmpteTimingEnabled) { IsSmpteTimingEnabled = false; @@ -11514,6 +11515,8 @@ private void ToggleSmpteTiming() } } + RestoreFrameNumbers(frameNumbers); + var vp = GetVideoPlayerControl(); if (vp != null) { @@ -11524,6 +11527,45 @@ private void ToggleSmpteTiming() _vlcReloader.SmpteMode = IsSmpteTimingEnabled; } + /// + /// In a format that stores frame numbers (MicroDVD and friends) the frame number is the + /// truth, but SMPTE timing changes how many milliseconds a 23.976/29.97/59.94 frame is + /// (#15753). Read the frame numbers before the toggle so + /// can put every line back on the same frame - otherwise toggling, or reopening a recent + /// file in SMPTE timing, would move every line by 0.1 %. + /// + private List<(SubtitleLineViewModel Line, int StartFrame, int EndFrame)>? GetFrameNumbersOfFrameBasedFormat() + { + var frameRate = Configuration.Settings.General.CurrentFrameRate; + if (SelectedSubtitleFormat is not { IsFrameBased: true } || + SubtitleFormat.GetFrameForCalculation(frameRate).Equals(frameRate)) + { + // Not an NTSC rate: SMPTE timing doesn't change the frame length. + return null; + } + + return Subtitles + .Select(p => (p, SubtitleFormat.MillisecondsToFrames(p.StartTime.TotalMilliseconds), SubtitleFormat.MillisecondsToFrames(p.EndTime.TotalMilliseconds))) + .ToList(); + } + + private void RestoreFrameNumbers(List<(SubtitleLineViewModel Line, int StartFrame, int EndFrame)>? frameNumbers) + { + if (frameNumbers == null) + { + return; + } + + foreach (var (line, startFrame, endFrame) in frameNumbers) + { + line.SetTimes( + TimeSpan.FromMilliseconds(SubtitleFormat.FramesToMilliseconds(startFrame)), + TimeSpan.FromMilliseconds(SubtitleFormat.FramesToMilliseconds(endFrame))); + } + + _updateAudioVisualizer = true; + } + /// /// SMPTE mode is session state on this view model, but BDN XML export, DOST export and the /// Netflix shot-change check all read it off the libse singleton - and nothing ever wrote that diff --git a/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs b/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs index 71ebff2c4d0..4ffd8b838ae 100644 --- a/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs +++ b/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs @@ -27,4 +27,73 @@ public void FramesToMilliseconds1() var ms = SubtitleFormat.FramesToMilliseconds(1); Assert.Equal(40, ms); } + + [Theory] + [InlineData(23.976, 86400, 3603600)] + [InlineData(29.97, 108000, 3603600)] + [InlineData(59.94, 216000, 3603600)] + [InlineData(25, 90000, 3600000)] + public void FramesToMillisecondsWithoutSmpteUsesRealTime(double frameRate, int frames, int expectedMs) + { + Configuration.Settings.General.CurrentVideoIsSmpte = false; + Assert.Equal(expectedMs, SubtitleFormat.FramesToMilliseconds(frames, frameRate)); + Assert.Equal(frames, SubtitleFormat.MillisecondsToFrames(expectedMs, frameRate)); + } + + [Theory] + [InlineData(23.976, 86400)] + [InlineData(23.976023976, 86400)] + [InlineData(29.97, 108000)] + [InlineData(59.94, 216000)] + public void SmpteTimingCountsNonDropFrameTimeCode(double frameRate, int framesInOneHour) + { + // #15753: in SMPTE timing frame 86400 at 23.976 is time code 01:00:00:00 (the video is + // stretched by 1.001 to match), so frame numbers convert at the whole-number rate. + var old = Configuration.Settings.General.CurrentVideoIsSmpte; + try + { + Configuration.Settings.General.CurrentVideoIsSmpte = true; + Assert.Equal(3600000, SubtitleFormat.FramesToMilliseconds(framesInOneHour, frameRate)); + Assert.Equal(framesInOneHour, SubtitleFormat.MillisecondsToFrames(3600000, frameRate)); + } + finally + { + Configuration.Settings.General.CurrentVideoIsSmpte = old; + } + } + + [Fact] + public void SmpteTimingLeavesWholeFrameRatesAlone() + { + var old = Configuration.Settings.General.CurrentVideoIsSmpte; + try + { + Configuration.Settings.General.CurrentVideoIsSmpte = true; + Assert.Equal(3600000, SubtitleFormat.FramesToMilliseconds(90000, 25)); + Assert.Equal(3600000, SubtitleFormat.FramesToMilliseconds(86400, 24)); + } + finally + { + Configuration.Settings.General.CurrentVideoIsSmpte = old; + } + } + + [Fact] + public void SmpteTimingFramePartOfTimeCodeStaysBelowFrameRate() + { + var oldSmpte = Configuration.Settings.General.CurrentVideoIsSmpte; + var oldRate = Configuration.Settings.General.CurrentFrameRate; + try + { + Configuration.Settings.General.CurrentVideoIsSmpte = true; + Configuration.Settings.General.CurrentFrameRate = 23.976; + Assert.Equal(23, SubtitleFormat.MillisecondsToFramesMaxFrameRate(999)); + Assert.Equal(958, SubtitleFormat.FramesToMillisecondsMax999(23)); + } + finally + { + Configuration.Settings.General.CurrentVideoIsSmpte = oldSmpte; + Configuration.Settings.General.CurrentFrameRate = oldRate; + } + } } From 9931441bb06ba0bdafadaaf8ef2e5b7590cd16d5 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 05:56:07 +0200 Subject: [PATCH 013/110] Fix ASSA override tags piling up when moving words (#15756) MoveWordUpDown treated every "{\...}" block (except \i1) as an opener that never closes. So the colour was copied onto every moved word, the block in front of the word stayed behind, and moving a word back and forth piled the tags up. The reset "{\c&H(style)&}" that Set colour adds was copied as one more colour too. ASSA text now goes through its own path, which tracks the tags in effect: - Lines of one subtitle: overrides carry across the line break, so only the break moves (no tag added to the second line). - Between subtitles: the word gets only the tags in effect, the colour is closed before the next subtitle's own text, and moving it back merges. Tags for the whole subtitle (\an, \pos, \move, \fad, \clip) are no longer copied into the other subtitle. Co-Authored-By: Claude Opus 5.5 --- src/libse/Forms/MoveWordUpDown.cs | 21 +- src/libse/Forms/MoveWordUpDownAssa.cs | 615 ++++++++++++++++++ .../AssistedMoveCandidateGenerator.cs | 2 +- src/ui/Features/Main/MainViewModel.cs | 2 +- tests/libse/Forms/MoveWordUpDownTest.cs | 108 ++- 5 files changed, 738 insertions(+), 10 deletions(-) create mode 100644 src/libse/Forms/MoveWordUpDownAssa.cs diff --git a/src/libse/Forms/MoveWordUpDown.cs b/src/libse/Forms/MoveWordUpDown.cs index bdb559189e7..a7aa8126751 100644 --- a/src/libse/Forms/MoveWordUpDown.cs +++ b/src/libse/Forms/MoveWordUpDown.cs @@ -6,7 +6,7 @@ namespace Nikse.SubtitleEdit.Core.Forms { - public class MoveWordUpDown + public partial class MoveWordUpDown { public string S1 { get; private set; } public string S2 { get; private set; } @@ -18,6 +18,12 @@ public class MoveWordUpDown /// public bool AutoBreak { get; set; } = true; + /// + /// S1 and S2 are the two lines of one subtitle. ASSA override tags carry across the line + /// break, so a moved word needs no tags of its own: only the line break moves. + /// + public bool SameSubtitle { get; set; } + public MoveWordUpDown(string s1, string s2) { S1 = s1 ?? string.Empty; @@ -34,6 +40,12 @@ public void MoveWordUp() return; } + if (UseAssaPath()) + { + MoveWordUpAssa(); + return; + } + var s2Trimmed = S2.Trim(); // Parse to find the first word with its surrounding tags @@ -260,6 +272,12 @@ public void MoveWordDown() return; } + if (UseAssaPath()) + { + MoveWordDownAssa(); + return; + } + var s1Trimmed = S1.Trim(); // Find start of last word and any active tags @@ -408,6 +426,7 @@ public void MoveWordDown() } private static readonly Regex EmptyFontTagRegex = new Regex(@"]*>\s*", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex HtmlTagRegex = new Regex(@" + /// Word moves for text with ASSA override blocks ("{\c&H0000FF&}"). An override has no + /// closing tag: it stays in effect until the end of the subtitle. So the moved word gets the + /// tags in effect where it was, and nothing more, instead of a copy of every block. Before + /// this, every block was a never-closed opener, and moving a word back and forth piled the + /// tags up (issue #15756). + /// + public partial class MoveWordUpDown + { + private enum TokenKind + { + Text, + Block, + Space, + } + + private sealed class Token + { + public TokenKind Kind { get; } + public string Value { get; } + + public Token(TokenKind kind, string value) + { + Kind = kind; + Value = value; + } + } + + private sealed class AssaTag + { + public string Name { get; } + public string Value { get; } + public string Key { get; } + + public AssaTag(string name, string value) + { + Name = name; + Value = value; + Key = CanonicalKey(name); + } + + public override string ToString() => "\\" + Name + Value; + } + + /// + /// Override tags in effect, one stack per tag. A stack, so a block right after a word + /// (SE closes a color selection with "{\c&H(style color)&}") restores the value + /// before it instead of counting as a new color. + /// + private sealed class TagState + { + private readonly List _order = new List(); + private readonly Dictionary> _stacks = new Dictionary>(); + + public bool IsEmpty => _order.Count == 0; + + public bool Has(string key) => _stacks.ContainsKey(key); + + public AssaTag Top(string key) => _stacks.TryGetValue(key, out var stack) ? stack[stack.Count - 1] : null; + + public List Snapshot() => _order.Select(Top).ToList(); + + public TagState Flatten() + { + var state = new TagState(); + foreach (var tag in Snapshot()) + { + state.Push(tag); + } + + return state; + } + + public void Apply(AssaTag tag, bool isSuffix) + { + if (tag.Key == "r") + { + _order.Clear(); + _stacks.Clear(); + if (tag.Value.Length > 0) + { + Push(tag); + } + + return; + } + + if (IsReset(tag)) + { + Remove(tag.Key); + } + else if (isSuffix && _stacks.TryGetValue(tag.Key, out var stack)) + { + stack.RemoveAt(stack.Count - 1); + if (stack.Count == 0) + { + Remove(tag.Key); + } + } + else + { + Push(tag); + } + } + + public bool SameAs(TagState other) + { + if (_order.Count != other._order.Count) + { + return false; + } + + foreach (var key in _order) + { + var otherTag = other.Top(key); + if (otherTag == null || !string.Equals(Top(key).Value, otherTag.Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private void Push(AssaTag tag) + { + if (!_stacks.TryGetValue(tag.Key, out var stack)) + { + stack = new List(); + _stacks.Add(tag.Key, stack); + _order.Add(tag.Key); + } + + stack.Add(tag); + } + + private void Remove(string key) + { + _stacks.Remove(key); + _order.Remove(key); + } + } + + // Longest first, so "\bord" is not read as "\b" + "ord". + private static readonly string[] AssaTagNames = new[] + { + "xbord", "ybord", "xshad", "yshad", "iclip", "alpha", + "fscx", "fscy", "bord", "shad", "blur", "move", "fade", "clip", + "pos", "org", "fsp", "fax", "fay", "frx", "fry", "frz", "pbo", "fad", + "an", "fn", "fs", "fe", "fr", "be", "kf", "ko", + "a", "b", "c", "i", "k", "K", "p", "q", "r", "s", "t", "u", + }; + + // Tags for the whole subtitle (position, alignment, fade, clip): they stay where they are. + private static readonly HashSet EventLevelKeys = new HashSet(StringComparer.Ordinal) + { + "pos", "move", "org", "an", "a", "fade", "clip", "iclip", "q", + }; + + // Karaoke, animation and drawing tags belong to the word they precede and travel with it. + private static readonly HashSet WordLocalKeys = new HashSet(StringComparer.Ordinal) + { + "k", "kf", "ko", "K", "t", "p", "pbo", + }; + + private static string CanonicalKey(string name) + { + switch (name) + { + case "c": return "1c"; + case "fr": return "frz"; + case "fad": return "fade"; + default: return name; + } + } + + private static bool IsToggle(string key) => key == "i" || key == "b" || key == "u" || key == "s"; + + private static bool IsReset(AssaTag tag) => tag.Value.Length == 0 || (IsToggle(tag.Key) && tag.Value == "0"); + + // "{\c}" at the very end of a line changes nothing; "{\i0}" is kept as the usual close. + private static bool IsNoOpAtLineEnd(AssaTag tag) => tag.Value.Length == 0 && !IsToggle(tag.Key) && tag.Key != "r"; + + private static AssaTag ResetOf(AssaTag tag) => new AssaTag(tag.Name, IsToggle(tag.Key) ? "0" : string.Empty); + + private bool UseAssaPath() + { + var text = S1 + S2; + return text.Contains("{\\") && !HtmlTagRegex.IsMatch(text); + } + + private static List Tokenize(string s) + { + var tokens = new List(); + var i = 0; + while (i < s.Length) + { + var start = i; + if (s[i] == '{' && s.IndexOf('}', i) > i) + { + i = s.IndexOf('}', i) + 1; + tokens.Add(new Token(TokenKind.Block, s.Substring(start, i - start))); + } + else if (char.IsWhiteSpace(s[i])) + { + while (i < s.Length && char.IsWhiteSpace(s[i])) + { + i++; + } + + tokens.Add(new Token(TokenKind.Space, s.Substring(start, i - start))); + } + else + { + i++; + while (i < s.Length && !char.IsWhiteSpace(s[i]) && s[i] != '{') + { + i++; + } + + tokens.Add(new Token(TokenKind.Text, s.Substring(start, i - start))); + } + } + + return tokens; + } + + private static List ParseBlock(string block) + { + var tags = new List(); + var content = block.Substring(1, block.Length - 2); + if (!content.StartsWith("\\", StringComparison.Ordinal)) + { + return tags; // comment block + } + + var i = 0; + while (i < content.Length) + { + if (content[i] != '\\') + { + i++; + continue; + } + + i++; + string name; + if (i + 1 < content.Length && char.IsDigit(content[i]) && (content[i + 1] == 'c' || content[i + 1] == 'a')) + { + name = content.Substring(i, 2); + } + else + { + name = AssaTagNames.FirstOrDefault(n => string.CompareOrdinal(content, i, n, 0, n.Length) == 0); + if (name == null) + { + var end = i; + while (end < content.Length && char.IsLetter(content[end])) + { + end++; + } + + name = content.Substring(i, end - i); + } + } + + i += name.Length; + var valueStart = i; + var depth = 0; + while (i < content.Length && (content[i] != '\\' || depth > 0)) + { + if (content[i] == '(') + { + depth++; + } + else if (content[i] == ')' && depth > 0) + { + depth--; + } + + i++; + } + + if (name.Length > 0) + { + tags.Add(new AssaTag(name, content.Substring(valueStart, i - valueStart))); + } + } + + return tags; + } + + private static string BuildBlock(IEnumerable tags) + { + var sb = new StringBuilder(); + foreach (var tag in tags) + { + sb.Append(tag); + } + + return sb.Length == 0 ? string.Empty : "{" + sb + "}"; + } + + private static string Join(List tokens, int start, int end) + { + var sb = new StringBuilder(); + for (var k = start; k < end; k++) + { + sb.Append(tokens[k].Value); + } + + return sb.ToString(); + } + + private static bool HasText(List tokens, int start, int end) + { + for (var k = start; k < end; k++) + { + if (tokens[k].Kind == TokenKind.Text) + { + return true; + } + } + + return false; + } + + /// + /// A block glued to the end of a word ("word{\c&HFFFFFF&} next") closes what the + /// word opened, so it restores the earlier value. + /// + private static bool IsSuffixBlock(List tokens, int index) + { + var before = index - 1; + while (before >= 0 && tokens[before].Kind == TokenKind.Block) + { + before--; + } + + var after = index + 1; + while (after < tokens.Count && tokens[after].Kind == TokenKind.Block) + { + after++; + } + + return before >= 0 && tokens[before].Kind == TokenKind.Text && (after >= tokens.Count || tokens[after].Kind == TokenKind.Space); + } + + private static TagState Simulate(List tokens, int start, int end, TagState state = null) + { + state = state ?? new TagState(); + for (var k = start; k < end; k++) + { + if (tokens[k].Kind != TokenKind.Block) + { + continue; + } + + var isSuffix = IsSuffixBlock(tokens, k); + foreach (var tag in ParseBlock(tokens[k].Value)) + { + if (!EventLevelKeys.Contains(tag.Key) && !WordLocalKeys.Contains(tag.Key)) + { + state.Apply(tag, isSuffix); + } + } + } + + return state; + } + + private static List BlockTags(List tokens, int start, int end) + { + var tags = new List(); + for (var k = start; k < end; k++) + { + if (tokens[k].Kind == TokenKind.Block) + { + tags.AddRange(ParseBlock(tokens[k].Value)); + } + } + + return tags; + } + + private static int LastTextIndex(List tokens) + { + for (var k = tokens.Count - 1; k >= 0; k--) + { + if (tokens[k].Kind == TokenKind.Text) + { + return k; + } + } + + return -1; + } + + private static int FirstTextIndex(List tokens) + { + return tokens.FindIndex(t => t.Kind == TokenKind.Text); + } + + /// Tags that turn into . + private static List Diff(TagState from, TagState to) + { + var result = new List(); + var toR = to.Top("r"); + var fromR = from.Top("r"); + if (toR != null || fromR != null) + { + if (toR == null || fromR == null || !string.Equals(toR.Value, fromR.Value, StringComparison.Ordinal)) + { + if (toR == null) + { + result.Add(new AssaTag("r", string.Empty)); + } + + result.AddRange(to.Snapshot()); + return result; + } + } + + foreach (var tag in from.Snapshot()) + { + if (!to.Has(tag.Key)) + { + result.Add(ResetOf(tag)); + } + } + + foreach (var tag in to.Snapshot()) + { + var old = from.Top(tag.Key); + if (old == null || !string.Equals(old.Value, tag.Value, StringComparison.OrdinalIgnoreCase)) + { + result.Add(tag); + } + } + + return result; + } + + /// The word's tokens, without the closing tags that do nothing at the end of a line. + private static string JoinForLineEnd(List word, bool dropSuffix) + { + var lastText = LastTextIndex(word); + var sb = new StringBuilder(Join(word, 0, lastText + 1)); + if (!dropSuffix) + { + for (var k = lastText + 1; k < word.Count; k++) + { + sb.Append(BuildBlock(ParseBlock(word[k].Value).Where(t => !IsNoOpAtLineEnd(t)))); + } + } + + return sb.ToString(); + } + + private void MoveWordDownAssa() + { + var t1 = Tokenize(S1.Trim()); + var lastText = LastTextIndex(t1); + if (lastText < 0) + { + return; + } + + var chunkStart = lastText; + while (chunkStart > 0 && t1[chunkStart - 1].Kind != TokenKind.Space) + { + chunkStart--; + } + + if (SameSubtitle) + { + // Overrides carry across the line break, so just move the break. + S2 = (Join(t1, chunkStart, t1.Count).Trim() + " " + S2.Trim()).Trim(); + S1 = Join(t1, 0, chunkStart).Trim(); + S2 = AutoBreakIfNeeded(S2); + return; + } + + var wordStart = t1.FindIndex(chunkStart, t => t.Kind == TokenKind.Text); + var leadTags = BlockTags(t1, chunkStart, wordStart); + var events = leadTags.Where(t => EventLevelKeys.Contains(t.Key)).ToList(); + var locals = leadTags.Where(t => WordLocalKeys.Contains(t.Key)).ToList(); + var word = t1.Skip(wordStart).Where(t => t.Kind != TokenKind.Space).ToList(); + + var startState = Simulate(t1, 0, wordStart); + var endState = Simulate(word, 0, word.Count, startState.Flatten()); + + // What stays on the first line + if (!HasText(t1, 0, chunkStart)) + { + S1 = string.Empty; + } + else + { + var restState = Simulate(t1, 0, chunkStart); + var closing = BlockTags(word, LastTextIndex(word) + 1, word.Count) + .Where(t => restState.Has(t.Key) && !IsNoOpAtLineEnd(t)); + S1 = Join(t1, 0, chunkStart).Trim() + BuildBlock(events) + BuildBlock(closing); + } + + // The word goes in front of the second line + var t2 = Tokenize(S2.Trim()); + var firstText2 = FirstTextIndex(t2); + var lead2End = firstText2 < 0 ? t2.Count : firstText2; + var lead2Tags = BlockTags(t2, 0, lead2End); + var prefix = BuildBlock(lead2Tags.Where(t => EventLevelKeys.Contains(t.Key)) + .Concat(startState.Snapshot()) + .Concat(locals)); + + if (firstText2 < 0) + { + S2 = prefix + JoinForLineEnd(word, false); + } + else + { + var lead2State = Simulate(t2, 0, firstText2); + var lead2Locals = lead2Tags.Where(t => WordLocalKeys.Contains(t.Key)); + var rest2 = Join(t2, firstText2, t2.Count); + if (endState.SameAs(lead2State)) + { + S2 = prefix + Join(word, 0, word.Count) + " " + BuildBlock(lead2Locals) + rest2; + } + else + { + var closer = endState.Snapshot().Where(t => !lead2State.Has(t.Key)).Select(ResetOf); + var lead2Inline = lead2Tags.Where(t => !EventLevelKeys.Contains(t.Key)); + S2 = prefix + Join(word, 0, word.Count) + BuildBlock(closer) + " " + BuildBlock(lead2Inline) + rest2; + } + } + + S2 = AutoBreakIfNeeded(S2); + } + + private void MoveWordUpAssa() + { + var t2 = Tokenize(S2.Trim()); + var firstText = FirstTextIndex(t2); + if (firstText < 0) + { + return; + } + + var wordEnd = t2.FindIndex(firstText, t => t.Kind == TokenKind.Space); + if (wordEnd < 0) + { + wordEnd = t2.Count; + } + + if (SameSubtitle) + { + // Overrides carry across the line break, so just move the break. + S1 = (S1.Trim() + " " + Join(t2, 0, wordEnd).Trim()).Trim(); + S2 = Join(t2, wordEnd, t2.Count).Trim(); + S1 = AutoBreakIfNeeded(S1); + return; + } + + var leadTags = BlockTags(t2, 0, firstText); + var events = leadTags.Where(t => EventLevelKeys.Contains(t.Key)).ToList(); + var locals = leadTags.Where(t => WordLocalKeys.Contains(t.Key)).ToList(); + var word = t2.GetRange(firstText, wordEnd - firstText); + + var startState = Simulate(t2, 0, firstText); + var endState = Simulate(word, 0, word.Count, startState.Flatten()); + + // The word goes at the end of the first line + var t1 = Tokenize(S1.Trim()); + var lastText1 = LastTextIndex(t1); + var toggleClose = endState.Snapshot().Where(t => IsToggle(t.Key)).Select(ResetOf).ToList(); + if (lastText1 < 0) + { + S1 = BuildBlock(startState.Snapshot().Concat(locals)) + JoinForLineEnd(word, false) + BuildBlock(toggleClose); + } + else + { + var hasTrail = lastText1 + 1 < t1.Count; + var beforeTrail = Simulate(t1, 0, lastText1 + 1); + if (hasTrail && !startState.IsEmpty && beforeTrail.SameAs(startState)) + { + // "Hello {\i1}world{\i0}" + "{\i1}today" -> "Hello {\i1}world today{\i0}" + S1 = Join(t1, 0, lastText1 + 1) + " " + BuildBlock(locals) + JoinForLineEnd(word, true) + Join(t1, lastText1 + 1, t1.Count).Trim(); + } + else + { + var diff = Diff(Simulate(t1, 0, t1.Count), startState); + S1 = S1.Trim() + " " + BuildBlock(diff.Concat(locals)) + JoinForLineEnd(word, false) + BuildBlock(toggleClose); + } + } + + S1 = AutoBreakIfNeeded(S1); + + // What stays on the second line + if (!HasText(t2, wordEnd, t2.Count)) + { + S2 = string.Empty; + return; + } + + var restState = Simulate(t2, 0, wordEnd); + S2 = BuildBlock(events.Concat(restState.Snapshot())) + Join(t2, wordEnd, t2.Count).Trim(); + } + } +} diff --git a/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs b/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs index 7bb05a538eb..e21366908af 100644 --- a/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs +++ b/src/ui/Features/Main/AssistedMove/AssistedMoveCandidateGenerator.cs @@ -392,7 +392,7 @@ private static (TimeSpan firstEnd, TimeSpan secondStart) ComputeTimeSplit( return null; } - var upDown = new MoveWordUpDown(lines[0].Trim(), lines[1].Trim()); + var upDown = new MoveWordUpDown(lines[0].Trim(), lines[1].Trim()) { SameSubtitle = true }; if (moveDown) { upDown.MoveWordDown(); diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 785be4339d6..fb1b3c8e0d8 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -21442,7 +21442,7 @@ private void MoveWordBetweenLinesOfCurrent(bool up) // The user places the line break by hand here, so no auto-break: re-breaking an // over-long line rebalanced the text and undid the move (issue #15496). - var upDown = new MoveWordUpDown(lines[0].Trim(), lines[1].Trim()) { AutoBreak = false }; + var upDown = new MoveWordUpDown(lines[0].Trim(), lines[1].Trim()) { AutoBreak = false, SameSubtitle = true }; if (up) { upDown.MoveWordUp(); diff --git a/tests/libse/Forms/MoveWordUpDownTest.cs b/tests/libse/Forms/MoveWordUpDownTest.cs index 2f38ff091a6..a467f9602d3 100644 --- a/tests/libse/Forms/MoveWordUpDownTest.cs +++ b/tests/libse/Forms/MoveWordUpDownTest.cs @@ -120,7 +120,7 @@ public void MoveWordUp_WithAssTagAtStart() var mover = new MoveWordUpDown("First", "{\\an8}Second word"); mover.MoveWordUp(); - Assert.Equal("First {\\an8}Second", mover.S1); + Assert.Equal("First Second", mover.S1); Assert.Equal("{\\an8}word", mover.S2); } @@ -231,7 +231,7 @@ public void MoveWordDown_WithAssTagAtStart() mover.MoveWordDown(); Assert.Equal("{\\an8}Hello", mover.S1); - Assert.Equal("{\\an8}world today", mover.S2); + Assert.Equal("world today", mover.S2); } [Fact] @@ -302,8 +302,8 @@ public void MoveWordUp_PreservesAssTagStructure() var mover = new MoveWordUpDown("Test", "{\\an8}{\\i1}word one{\\i0}"); mover.MoveWordUp(); - Assert.Equal("Test {\\an8}{\\i1}word{\\i0}", mover.S1); - Assert.Equal("{\\an8}{\\i1}one{\\i0}", mover.S2); + Assert.Equal("Test {\\i1}word{\\i0}", mover.S1); + Assert.Equal("{\\an8\\i1}one{\\i0}", mover.S2); } [Fact] @@ -313,7 +313,7 @@ public void MoveWordDown_PreservesAssTagStructure() mover.MoveWordDown(); Assert.Equal("{\\an8}{\\i1}word{\\i0}", mover.S1); - Assert.Equal("{\\an8}{\\i1}one{\\i0} Test", mover.S2); + Assert.Equal("{\\i1}one{\\i0} Test", mover.S2); } [Fact] @@ -402,7 +402,7 @@ public void MoveWordUp_ComplexAssTag() var mover = new MoveWordUpDown("Test", "{\\pos(100,200)\\c&H00FFFF&}Hello world"); mover.MoveWordUp(); - Assert.Equal("Test {\\pos(100,200)\\c&H00FFFF&}Hello", mover.S1); + Assert.Equal("Test {\\c&H00FFFF&}Hello", mover.S1); Assert.Equal("{\\pos(100,200)\\c&H00FFFF&}world", mover.S2); } @@ -413,7 +413,7 @@ public void MoveWordDown_ComplexAssTag() mover.MoveWordDown(); Assert.Equal("{\\pos(100,200)\\c&H00FFFF&}Hello", mover.S1); - Assert.Equal("{\\pos(100,200)\\c&H00FFFF&}world Test", mover.S2); + Assert.Equal("{\\c&H00FFFF&}world{\\c} Test", mover.S2); } [Fact] @@ -495,4 +495,98 @@ public void MoveWordDown_RemovesEmptyFontTag() Assert.Equal("This is", mover.S1); Assert.Equal("rather long", mover.S2); } + + [Fact] + public void MoveWordDown_SameSubtitle_AssaColorNotRepeated() + { + var mover = new MoveWordUpDown("{\\c&H0000FF&}Hello world", string.Empty) { AutoBreak = false, SameSubtitle = true }; + mover.MoveWordDown(); + + Assert.Equal("{\\c&H0000FF&}Hello", mover.S1); + Assert.Equal("world", mover.S2); + + mover.MoveWordUp(); + + Assert.Equal("{\\c&H0000FF&}Hello world", mover.S1); + Assert.Equal(string.Empty, mover.S2); + } + + [Fact] + public void MoveWordDown_SameSubtitle_AssaColorWithClose() + { + var mover = new MoveWordUpDown("Say {\\c&H0000FF&}hello world{\\c&HFFFFFF&}", "now") { AutoBreak = false, SameSubtitle = true }; + mover.MoveWordDown(); + + Assert.Equal("Say {\\c&H0000FF&}hello", mover.S1); + Assert.Equal("world{\\c&HFFFFFF&} now", mover.S2); + } + + [Fact] + public void MoveWordDown_AssaColor_ClosesColorBeforeNextText() + { + var mover = new MoveWordUpDown("{\\c&H0000FF&}Hello world", "Next line"); + mover.MoveWordDown(); + + Assert.Equal("{\\c&H0000FF&}Hello", mover.S1); + Assert.Equal("{\\c&H0000FF&}world{\\c} Next line", mover.S2); + } + + [Fact] + public void MoveWordDownAndUp_AssaColor_RoundTripDoesNotPileUpTags() + { + var mover = new MoveWordUpDown("{\\c&H0000FF&}Hello world", "Next line"); + for (var i = 0; i < 3; i++) + { + mover.MoveWordDown(); + mover.MoveWordUp(); + } + + Assert.Equal("{\\c&H0000FF&}Hello world", mover.S1); + Assert.Equal("Next line", mover.S2); + } + + [Fact] + public void MoveWordDownAndUp_AssaColorWithClose_RoundTrip() + { + var mover = new MoveWordUpDown("{\\c&H0000FF&}Hello world{\\c&HFFFFFF&}", "Next line"); + mover.MoveWordDown(); + + Assert.Equal("{\\c&H0000FF&}Hello{\\c&HFFFFFF&}", mover.S1); + Assert.Equal("{\\c&H0000FF&}world{\\c&HFFFFFF&} Next line", mover.S2); + + mover.MoveWordUp(); + + Assert.Equal("{\\c&H0000FF&}Hello world{\\c&HFFFFFF&}", mover.S1); + Assert.Equal("Next line", mover.S2); + } + + [Fact] + public void MoveWordDown_AssaColor_MergesWithSameColorInNext() + { + var mover = new MoveWordUpDown("{\\c&H0000FF&}Hello world", "{\\c&H0000FF&}Next line"); + mover.MoveWordDown(); + + Assert.Equal("{\\c&H0000FF&}Hello", mover.S1); + Assert.Equal("{\\c&H0000FF&}world Next line", mover.S2); + } + + [Fact] + public void MoveWordUp_AssaColor_PlainWordAfterColoredText() + { + var mover = new MoveWordUpDown("{\\c&H0000FF&}Hello", "world now"); + mover.MoveWordUp(); + + Assert.Equal("{\\c&H0000FF&}Hello {\\c}world", mover.S1); + Assert.Equal("now", mover.S2); + } + + [Fact] + public void MoveWordUp_AssaColor_RestKeepsColor() + { + var mover = new MoveWordUpDown("Hello", "{\\an8\\c&H0000FF&}world now"); + mover.MoveWordUp(); + + Assert.Equal("Hello {\\c&H0000FF&}world", mover.S1); + Assert.Equal("{\\an8\\c&H0000FF&}now", mover.S2); + } } From 1f2ea66ae880b7b18bfdccadd6883d67f36f97b7 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 06:01:13 +0200 Subject: [PATCH 014/110] SMPTE timing: FCP 7 XML frame counts follow the time code clock (#15753) Final Cut Pro XML (and its two text/test variants) count frames with their own TotalSeconds * frameRate math, so they kept 23.976 in SMPTE timing. They now use the whole-number rate there via SubtitleFormat.ToTimeCodeFrameRate, and StoresFrameNumbers lets the SMPTE toggle keep their lines on the same frame like MicroDVD. Co-Authored-By: Claude Opus 5.5 --- .../SubtitleFormats/FinalCutProTest2Xml.cs | 8 +++-- .../SubtitleFormats/FinalCutProTextXml.cs | 8 +++-- src/libse/SubtitleFormats/FinalCutProXml.cs | 8 +++-- src/libse/SubtitleFormats/SubtitleFormat.cs | 17 +++++++++++ src/ui/Features/Main/MainViewModel.cs | 4 +-- .../SubtitleFormatFunctionsTest.cs | 29 +++++++++++++++++++ 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs b/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs index 1664f4779c4..09f269141fd 100644 --- a/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs +++ b/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs @@ -12,6 +12,8 @@ public class FinalCutProTest2Xml : SubtitleFormat { public override string Extension => ".xml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Test2 Xml"; public static string GetFrameRateAsString() @@ -69,7 +71,7 @@ public override string ToText(Subtitle subtitle, string title) int duration = 0; if (subtitle.Paragraphs.Count > 0) { - duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * Configuration.Settings.General.CurrentFrameRate); + duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)); } string seString = "Subtitle Edit at " + DateTime.Now.ToShortDateString() + " " + DateTime.Now.ToShortTimeString(); @@ -130,7 +132,7 @@ public override string ToText(Subtitle subtitle, string title) generatorItem.InnerXml = xmlTrackStructure.Replace("[NUMBER]", number.ToString()).Replace("[FONTSTYLE]", fontStyle).Replace("[FONTSIZE]", Configuration.Settings.SubtitleSettings.FcpFontSize.ToString(CultureInfo.InvariantCulture)); - double frameRate = Configuration.Settings.General.CurrentFrameRate; + double frameRate = ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate); XmlNode start = generatorItem.SelectSingleNode("generatoritem/start"); start.InnerText = ((int)Math.Round(p.StartTime.TotalSeconds * frameRate)).ToString(); @@ -317,7 +319,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string text = "" + text + ""; } - subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / frameRate) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / frameRate) * 1000, CultureInfo.InvariantCulture))); + subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture))); } } } diff --git a/src/libse/SubtitleFormats/FinalCutProTextXml.cs b/src/libse/SubtitleFormats/FinalCutProTextXml.cs index 78ad3b1db37..f2f61d7f5e1 100644 --- a/src/libse/SubtitleFormats/FinalCutProTextXml.cs +++ b/src/libse/SubtitleFormats/FinalCutProTextXml.cs @@ -12,6 +12,8 @@ public class FinalCutProTestXml : SubtitleFormat { public override string Extension => ".xml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Test Xml"; public static string GetFrameRateAsString() @@ -82,7 +84,7 @@ public override string ToText(Subtitle subtitle, string title) int duration = 0; if (subtitle.Paragraphs.Count > 0) { - duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * Configuration.Settings.General.CurrentFrameRate); + duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)); } string xmlStructure = @@ -213,7 +215,7 @@ public override string ToText(Subtitle subtitle, string title) generatorItem.InnerXml = xmlTrackStructure.Replace("[NUMBER]", number.ToString()).Replace("[FONTSTYLE]", fontStyle); - double frameRate = Configuration.Settings.General.CurrentFrameRate; + double frameRate = ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate); XmlNode start = generatorItem.SelectSingleNode("generatoritem/start"); start.InnerText = ((int)Math.Round(p.StartTime.TotalSeconds * frameRate)).ToString(); @@ -385,7 +387,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string text = "" + text + ""; } - subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / frameRate) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / frameRate) * 1000, CultureInfo.InvariantCulture))); + subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture))); } } } diff --git a/src/libse/SubtitleFormats/FinalCutProXml.cs b/src/libse/SubtitleFormats/FinalCutProXml.cs index 28964a9768b..82282ae51ca 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml.cs @@ -12,6 +12,8 @@ public class FinalCutProXml : SubtitleFormat { public override string Extension => ".xml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml"; public static string GetFrameRateAsString() @@ -69,7 +71,7 @@ public override string ToText(Subtitle subtitle, string title) int duration = 0; if (subtitle.Paragraphs.Count > 0) { - duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * Configuration.Settings.General.CurrentFrameRate); + duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)); } string xmlStructure = @@ -440,7 +442,7 @@ public override string ToText(Subtitle subtitle, string title) var generatorItem = template.CloneNode(true); generatorItem.Attributes["id"].Value = "Outline Text" + number.ToString(CultureInfo.InvariantCulture); - double frameRate = Configuration.Settings.General.CurrentFrameRate; + double frameRate = ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate); XmlNode start = generatorItem.SelectSingleNode("start"); start.InnerText = ((int)Math.Round(p.StartTime.TotalSeconds * frameRate)).ToString(); @@ -674,7 +676,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string text = "" + text + ""; } - subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / frameRate) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / frameRate) * 1000, CultureInfo.InvariantCulture))); + subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture))); } } } diff --git a/src/libse/SubtitleFormats/SubtitleFormat.cs b/src/libse/SubtitleFormats/SubtitleFormat.cs index 47099dfe3af..573a8b327c3 100644 --- a/src/libse/SubtitleFormats/SubtitleFormat.cs +++ b/src/libse/SubtitleFormats/SubtitleFormat.cs @@ -535,6 +535,13 @@ public abstract string Name public bool IsFrameBased => !IsTimeBased; + /// + /// True when the file stores absolute frame numbers - every frame-based format, plus time based + /// ones like FCP 7 XML that write frame counts. Toggling SMPTE timing keeps these lines on the + /// same frame (#15753). + /// + public virtual bool StoresFrameNumbers => IsFrameBased; + private string _friendlyName; private string _friendlyNameExtension; @@ -624,6 +631,16 @@ public static double GetTimeCodeFrameRate(double frameRate) return IsSmpteTimeCodeClock(frameRate) ? Math.Round(frameRate) : GetFrameForCalculation(frameRate); } + /// + /// For formats that count frames with their own math (FCP 7 XML): the whole-number rate in + /// SMPTE timing (see ), otherwise + /// unchanged. + /// + public static double ToTimeCodeFrameRate(double frameRate) + { + return IsSmpteTimeCodeClock(frameRate) ? Math.Round(frameRate) : frameRate; + } + public static double GetFrameForCalculation(double frameRate) { if (Math.Abs(frameRate - 23.976) < 0.001) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 8cecf16ca5c..599e24a0427 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -11528,7 +11528,7 @@ private void ToggleSmpteTiming() } /// - /// In a format that stores frame numbers (MicroDVD and friends) the frame number is the + /// In a format that stores frame numbers (MicroDVD, FCP 7 XML...) the frame number is the /// truth, but SMPTE timing changes how many milliseconds a 23.976/29.97/59.94 frame is /// (#15753). Read the frame numbers before the toggle so /// can put every line back on the same frame - otherwise toggling, or reopening a recent @@ -11537,7 +11537,7 @@ private void ToggleSmpteTiming() private List<(SubtitleLineViewModel Line, int StartFrame, int EndFrame)>? GetFrameNumbersOfFrameBasedFormat() { var frameRate = Configuration.Settings.General.CurrentFrameRate; - if (SelectedSubtitleFormat is not { IsFrameBased: true } || + if (SelectedSubtitleFormat is not { StoresFrameNumbers: true } || SubtitleFormat.GetFrameForCalculation(frameRate).Equals(frameRate)) { // Not an NTSC rate: SMPTE timing doesn't change the frame length. diff --git a/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs b/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs index 4ffd8b838ae..ba6a546036c 100644 --- a/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs +++ b/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs @@ -96,4 +96,33 @@ public void SmpteTimingFramePartOfTimeCodeStaysBelowFrameRate() Configuration.Settings.General.CurrentFrameRate = oldRate; } } + + [Fact] + public void SmpteTimingFinalCutPro7XmlCountsTimeCodeFrames() + { + var oldSmpte = Configuration.Settings.General.CurrentVideoIsSmpte; + var oldRate = Configuration.Settings.General.CurrentFrameRate; + try + { + Configuration.Settings.General.CurrentVideoIsSmpte = true; + Configuration.Settings.General.CurrentFrameRate = 23.976; + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hi", 3600000, 3602000)); + var format = new FinalCutProXml(); + var xml = format.ToText(subtitle, "test"); + Assert.Contains("86400", xml); + Assert.Contains("86448", xml); + + var loaded = new Subtitle(); + format.LoadSubtitle(loaded, xml.SplitToLines(), null); + Assert.Single(loaded.Paragraphs); + Assert.Equal(3600000, loaded.Paragraphs[0].StartTime.TotalMilliseconds, 0.5); + Assert.Equal(3602000, loaded.Paragraphs[0].EndTime.TotalMilliseconds, 0.5); + } + finally + { + Configuration.Settings.General.CurrentVideoIsSmpte = oldSmpte; + Configuration.Settings.General.CurrentFrameRate = oldRate; + } + } } From bee52ffdaef6918fd96369213b12f1ac4ad3641b Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 06:09:23 +0200 Subject: [PATCH 015/110] Add "Play with speech": speak lines in sync while the video plays Discussion #15755 asks for a live TTS player: watch a foreign-language course while the translated subtitles are spoken, instead of reading them. Speak from current line ignores time codes and pauses the video, so it cannot do this. Video > Play with speech... plays the video and speaks each line when the video reaches its start. Clips are generated three lines ahead, one at a time. A clip longer than its line is sped up (max 1.5x); if it still runs into the next line the video waits for it (option). The video's volume is lowered while speaking (option). Pausing the video holds the speech, seeking restarts from the new position, and the waveform toolbar speed applies to both. Escape, Stop speaking or the command again stops it. Reuses the Speak from current line prompt (fast engines only) with the two playback options added, and its clip generation and mpv helpers. Co-Authored-By: Claude Opus 5.5 --- docs/features/text-to-speech.md | 18 + src/ui/Assets/Languages/English.json | 18 + src/ui/Features/Main/Layout/InitMenu.cs | 5 + .../Features/Main/Layout/InitNativeMacMenu.cs | 1 + src/ui/Features/Main/MainViewModel.cs | 93 ++++ .../SpeakFromLine/PlayWithSpeechReader.cs | 426 ++++++++++++++++++ .../SpeakFromLine/PlayWithSpeechVideo.cs | 65 +++ .../SpeakFromLine/SpeakFromLineReader.cs | 18 +- .../SpeakFromLine/SpeakFromLineViewModel.cs | 23 + .../SpeakFromLine/SpeakFromLineWindow.cs | 24 +- .../Language/Video/LanguageTextToSpeech.cs | 12 + src/ui/Logic/Config/SeVideoTextToSpeech.cs | 4 + src/ui/Logic/ShortcutsMain.cs | 2 + .../PlayWithSpeechReaderTests.cs | 88 ++++ 14 files changed, 787 insertions(+), 10 deletions(-) create mode 100644 src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReader.cs create mode 100644 src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechVideo.cs create mode 100644 tests/UI/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReaderTests.cs diff --git a/docs/features/text-to-speech.md b/docs/features/text-to-speech.md index b63050eea93..5bea77315c1 100644 --- a/docs/features/text-to-speech.md +++ b/docs/features/text-to-speech.md @@ -76,6 +76,24 @@ While reading, the selected line, the video position and the waveform follow the - **Speed:** the **Speed** dropdown in the waveform toolbar sets how fast the lines are read, and a change is heard right away - **Stop:** press **Escape**, click another line, choose **Stop speaking** in the grid's right-click menu, or run the command (or shortcut) again +## Play With Speech + +Watch a video with its subtitles spoken, like a live dub - handy for a course or a film in a language you do not speak, so you can watch the picture instead of reading. **Play with speech...** plays the video and speaks each line when the video reaches it. Nothing is saved; for an audio track to keep, use **Text to speech** above. + +- **Menu:** **Video → Play with speech...** +- **Shortcut:** Configurable (no default) + +1. Open the video and the (translated) subtitle, and move the video to where you want to start +2. Choose **Play with speech...** +3. Pick an engine, a voice and (for Supertonic) a language - the same fast engines as **Speak from current line** - and the options below, then click **OK** + +- **Lower video volume while speaking:** the video's own sound is turned down while a line is spoken, and back up between lines +- **Pause video when speech runs into the next line:** a spoken line that is longer than its subtitle is first sped up (up to 1.5x). If it still runs into the next line, the video waits for it to finish, so no line is cut off or skipped. With this off, the video keeps playing and a line the video has already passed is skipped + +The next few lines are generated ahead while the video plays. If the engine falls behind - for example while a local engine starts - the video waits for it. Pausing the video pauses the speech, seeking (clicking the waveform, a line or the position slider) continues from the new position, and the waveform toolbar's **Speed** applies to both the video and the speech. The subtitle is read as it was when you started - edits made while playing are spoken after a restart. + +- **Stop:** press **Escape**, choose **Stop speaking** in the grid's right-click menu, or run the command (or shortcut) again + ## Supported Engines - **Piper** — Local, open-source TTS (Windows and Linux). Custom voice models are supported: any Piper voice is an `.onnx` model with an `.onnx.json` config beside it, and **Import voice...** in the voice settings dialog (the settings button next to **Test voice**) copies such a pair into the Piper folder (`TextToSpeech/Piper` in the data folder). It then appears in the voice list as *Custom - name*; a pair copied into the folder by hand is picked up the same way diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 740cd7be33d..35842312c31 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -2188,6 +2188,18 @@ "addFolderRecursiveDotDotDot": "Add folder recursive...", "selectFolderToConvert": "Select folder with files to convert", "includeSubfolders": "Include subfolders when adding a folder", + "videoFilesWhenAddingFolder": "Video files (mkv, mp4, ts...) when adding a folder", + "videoFilesAsk": "Ask", + "videoFilesInclude": "Add their subtitle tracks", + "videoFilesSkip": "Skip", + "folderContainsXVideoFiles": "The folder contains {0} video file(s) (mkv, mp4, ts...).\n\nAdd their embedded subtitle tracks too, or only the subtitle files?", + "addVideoFiles": "Add video files", + "skipVideoFiles": "Subtitle files only", + "doNotAskAgainVideoFiles": "Do not ask again (change in Batch convert settings)", + "settingsSectionOutput": "Output", + "settingsSectionAddingFiles": "Adding files", + "settingsSectionImageBasedSubtitles": "Image-based subtitles (OCR)", + "settingsSectionOther": "Other", "keepSourceFileTimestamp": "Keep source file date/time on output files", "preventSleepWhileConverting": "Prevent computer from sleeping while converting", "scanningFolderX": "Scanning {0}...", @@ -2691,6 +2703,12 @@ "speakFromCurrentLineHint": "Reads the lines aloud one after another, starting at the current line. Press Escape or click another line to stop.", "stopSpeaking": "Stop speaking", "speakingLineX": "Speaking line {0}... (Escape to stop)", + "playWithSpeechTitle": "Play with speech", + "playWithSpeechDotDotDot": "Play with speech...", + "playWithSpeechHint": "Plays the video and speaks each line when the video reaches it. Pause and seek as usual; press Escape to stop.", + "lowerVideoVolumeWhileSpeaking": "Lower video volume while speaking", + "pauseVideoWhenSpeechRunsLate": "Pause video when speech runs into the next line", + "waitingForSpeechLineX": "Generating speech for line {0}... (Escape to stop)", "voiceKindClone": "Cloned voice", "voiceKindPreset": "Built-in", "voiceKindModel": "Voice model", diff --git a/src/ui/Features/Main/Layout/InitMenu.cs b/src/ui/Features/Main/Layout/InitMenu.cs index cf5f0677a07..cedbf554321 100644 --- a/src/ui/Features/Main/Layout/InitMenu.cs +++ b/src/ui/Features/Main/Layout/InitMenu.cs @@ -793,6 +793,11 @@ public static void Make(MainViewModel vm) Command = vm.SpeakFromCurrentLineCommand, }, new MenuItem + { + Header = Se.Language.Video.TextToSpeech.PlayWithSpeechDotDotDot, + Command = vm.PlayWithSpeechCommand, + }, + new MenuItem { Header = Se.Language.Video.TextToSpeech.VoiceManagerMenuItem, Command = vm.ShowVideoVoiceManagerCommand, diff --git a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs index 6bf8699cdb5..23757d8693a 100644 --- a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs +++ b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs @@ -367,6 +367,7 @@ public static void MakeStructure(NativeMenu root, Window window) videoItems.Items.Add(Item(Clean(l.SpeechToText), v => v.ShowSpeechToTextWhisperCommand)); videoItems.Items.Add(Item(Clean(l.TextToSpeech), v => v.ShowVideoTextToSpeechCommand)); videoItems.Items.Add(Item(Clean(Se.Language.Video.TextToSpeech.SpeakFromCurrentLineDotDotDot), v => v.SpeakFromCurrentLineCommand)); + videoItems.Items.Add(Item(Clean(Se.Language.Video.TextToSpeech.PlayWithSpeechDotDotDot), v => v.PlayWithSpeechCommand)); videoItems.Items.Add(Item(Clean(Se.Language.Video.TextToSpeech.VoiceManagerMenuItem), v => v.ShowVideoVoiceManagerCommand)); videoItems.Items.Add(Item(Clean(l.VideoOcr), v => v.ShowVideoOcrCommand)); videoItems.Items.Add(new NativeMenuItemSeparator()); diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 785be4339d6..95c3b2f8f58 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -12225,6 +12225,99 @@ await reader.RunAsync( } } + /// + /// Plays the video and speaks each line when the video reaches it, like a live dub (#15755). + /// Running it again or Escape stops it; pausing and seeking work as usual. + /// + /// Concurrent executions allowed: the second run is the stop. + [RelayCommand(AllowConcurrentExecutions = true)] + private async Task PlayWithSpeech() + { + if (_speakFromLineCts != null) + { + StopSpeakFromLine(); + return; + } + + if (Window == null) + { + return; + } + + if (GetVideoPlayerControl() == null || string.IsNullOrEmpty(_videoFileName)) + { + await MessageBox.Show(Window, Se.Language.Video.TextToSpeech.PlayWithSpeechTitle, Se.Language.General.NoVideoLoaded, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var vm = await ShowDialogAsync(v => v.InitializePlayWithSpeech()); + _shortcutManager.ClearKeys(); + if (!vm.OkPressed || vm.SelectedEngine == null || vm.SelectedVoice == null || _speakFromLineCts != null) + { + return; + } + + var rows = Subtitles + .Where(p => !p.IsReferenceOnly) + .OrderBy(p => p.StartTime.TotalSeconds) + .ToList(); + var lines = rows + .Select(p => new SpeechLine(p.StartTime.TotalSeconds, p.EndTime.TotalSeconds, SpeakFromLineReader.GetSpeakableText(p.Text))) + .ToList(); + if (lines.Count == 0) + { + return; + } + + var cts = new CancellationTokenSource(); + _speakFromLineCts = cts; + IsSpeakingFromLine = true; + + var reader = new PlayWithSpeechReader(vm.SelectedEngine, vm.SelectedVoice, vm.HasLanguageParameter ? vm.SelectedLanguage : null, + vm.LowerVideoVolume, vm.PauseVideoWhenLate); + var video = new PlayWithSpeechVideo( + GetVideoPlayerControl, + PlayVideo, + PauseVideoAndFreezePlayhead, + () => TryGetSelectedPlaybackSpeed(out var speed) ? speed : 1.0, + () => !string.IsNullOrEmpty(_videoFileName)); + try + { + await reader.RunAsync( + lines, + video, + i => ShowStatus(string.Format(Se.Language.Video.TextToSpeech.SpeakingLineX, Subtitles.IndexOf(rows[i]) + 1), 10_000), + i => ShowStatus(string.Format(Se.Language.Video.TextToSpeech.WaitingForSpeechLineX, Subtitles.IndexOf(rows[i]) + 1), 10_000), + cts.Token); + } + catch (OperationCanceledException) + { + // stopped by the user + } + catch (Exception ex) + { + Se.LogError(ex, "Play with speech failed"); + if (!cts.IsCancellationRequested) + { + await MessageBox.Show(Window, Se.Language.General.Error, ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + finally + { + if (ReferenceEquals(_speakFromLineCts, cts)) + { + _speakFromLineCts = null; + IsSpeakingFromLine = false; + } + + cts.Dispose(); + if (_speakFromLineCts == null) + { + ShowStatus(string.Empty); + } + } + } + private void StopSpeakFromLine() { var cts = _speakFromLineCts; diff --git a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReader.cs b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReader.cs new file mode 100644 index 00000000000..71562da2b9f --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReader.cs @@ -0,0 +1,426 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.VideoPlayers.LibMpvDynamic; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.SpeakFromLine; + +/// One line to speak: its time codes in seconds and its speakable text. +public sealed record SpeechLine(double Start, double End, string Text); + +/// The video that "Play with speech" follows. +public interface IPlayWithSpeechVideo +{ + bool HasVideo { get; } + double Position { get; } + bool IsPlaying { get; } + double Speed { get; } + double Volume { get; set; } + void Play(); + void Pause(); +} + +/// +/// Speaks the lines while the video plays: each line is spoken when the video reaches its start, +/// like a live dub. Clips are generated a few lines ahead. A clip longer than its line is sped +/// up (up to ); if it still runs into the next line, the video waits +/// for it. Pausing the video pauses the speech, and seeking restarts from the new position. +/// +public sealed class PlayWithSpeechReader +{ + public const double MaxSpeedUp = 1.5; + + private const int TickMilliseconds = 40; + private const int LookAhead = 3; + private const double DuckFactor = 0.3; + + // A line whose start the video passed less than this ago is still spoken - mainly so the + // line the video is parked on when playing starts is not skipped. + internal const double StartTolerance = 0.3; + + private readonly ITtsEngine _engine; + private readonly Voice _voice; + private readonly TtsLanguage? _language; + private readonly bool _lowerVideoVolume; + private readonly bool _pauseVideoWhenLate; + + public PlayWithSpeechReader(ITtsEngine engine, Voice voice, TtsLanguage? language, bool lowerVideoVolume, bool pauseVideoWhenLate) + { + _engine = engine; + _voice = voice; + _language = language; + _lowerVideoVolume = lowerVideoVolume; + _pauseVideoWhenLate = pauseVideoWhenLate; + } + + /// + /// Plays the video with speech until is cancelled or the + /// video is closed. must be sorted by start time. + /// + /// Called when line i starts to be spoken. + /// Called when the video is held for line i's clip to be generated. + public async Task RunAsync(IReadOnlyList lines, IPlayWithSpeechVideo video, Action onLineStarting, Action onWaiting, CancellationToken cancellationToken) + { + var folder = Path.Combine(Path.GetTempPath(), "se-speak-" + Guid.NewGuid()); + Directory.CreateDirectory(folder); + LibMpvDynamicPlayer? player = null; + var clips = new Dictionary>(); + var allClips = new List>(); + var generationGate = new SemaphoreSlim(1, 1); + var generationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var speaking = -1; + var slot = 0.0; + var speechRate = 1.0; + var rateKnown = false; + var appliedPlayerSpeed = -1.0; + var speechHeld = false; + var videoPausedByUs = false; + var ducked = false; + var volumeBeforeDuck = 0.0; + long clipPlayStartedMs = 0; + var playRequestedMs = long.MinValue / 2; + var stopwatch = Stopwatch.StartNew(); + var stallDetector = new SpeakFromLineReader.PlaybackStallDetector(SpeakFromLineReader.StallTimeoutMilliseconds); + + void Duck() + { + if (_lowerVideoVolume && !ducked) + { + volumeBeforeDuck = video.Volume; + video.Volume = volumeBeforeDuck * DuckFactor; + ducked = true; + } + } + + void Unduck() + { + if (ducked) + { + video.Volume = volumeBeforeDuck; + ducked = false; + } + } + + // The player takes a moment to report playing after Play - count it as playing meanwhile, + // or that moment would look like the user pausing. + void PlayVideo() + { + video.Play(); + playRequestedMs = stopwatch.ElapsedMilliseconds; + } + + void StopClip() + { + if (speaking >= 0) + { + player?.Pause(); + speaking = -1; + } + + speechHeld = false; + Unduck(); + } + + void EnsureGenerated(int from) + { + for (var i = from; i < lines.Count && i < from + LookAhead; i++) + { + if (!clips.ContainsKey(i)) + { + var task = GenerateQueuedAsync(lines[i].Text, folder, generationGate, generationCts.Token); + clips[i] = task; + allClips.Add(task); + } + } + } + + try + { + player = SpeakFromLineReader.CreatePlayer(); + var next = FindNextLine(lines, video.Position); + EnsureGenerated(next); + var lastPosition = video.Position; + var lastTickMs = stopwatch.ElapsedMilliseconds; + PlayVideo(); + + while (true) + { + await Task.Delay(TickMilliseconds, cancellationToken); + if (!video.HasVideo) + { + return; + } + + var nowMs = stopwatch.ElapsedMilliseconds; + var elapsedSeconds = (nowMs - lastTickMs) / 1000.0; + lastTickMs = nowMs; + var position = video.Position; + var isPlaying = video.IsPlaying || nowMs - playRequestedMs < 500; + var videoSpeed = video.Speed > 0 ? video.Speed : 1.0; + + if (IsSeek(lastPosition, position, elapsedSeconds, isPlaying, videoSpeed)) + { + StopClip(); + if (videoPausedByUs) + { + videoPausedByUs = false; + PlayVideo(); + } + + // Clips queued for the old position are no longer needed. + generationCts.Cancel(); + generationCts.Dispose(); + generationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + clips.Clear(); + next = FindNextLine(lines, position); + } + + lastPosition = position; + EnsureGenerated(next); + + if (speaking >= 0) + { + // The user paused the video: hold the speech with it. + if (!isPlaying && !videoPausedByUs) + { + if (!speechHeld) + { + player.Pause(); + speechHeld = true; + } + + continue; + } + + if (speechHeld) + { + speechHeld = false; + player.Play(); + clipPlayStartedMs = nowMs; + stallDetector = new SpeakFromLineReader.PlaybackStallDetector(SpeakFromLineReader.StallTimeoutMilliseconds); + } + + if (!rateKnown && player.Duration > 0) + { + speechRate = GetSpeechRate(player.Duration, slot, MaxSpeedUp); + rateKnown = true; + } + + var playerSpeed = speechRate * videoSpeed; + if (Math.Abs(playerSpeed - appliedPlayerSpeed) > 0.001) + { + player.Speed = playerSpeed; + appliedPlayerSpeed = playerSpeed; + } + + // mpv pauses itself at the clip's end ("keep-open"); the grace period skips the + // moment right after loading or resuming, before it reports playing. + var finished = nowMs - clipPlayStartedMs > 300 && player.IsPaused; + if (!finished && stallDetector.IsStalled(player.Position, nowMs)) + { + Se.LogError(new InvalidOperationException($"Playback did not progress for {SpeakFromLineReader.StallTimeoutMilliseconds} ms"), + $"Play with speech: skipping line {speaking + 1}"); + finished = true; + } + + if (finished) + { + speaking = -1; + Unduck(); + if (videoPausedByUs) + { + videoPausedByUs = false; + PlayVideo(); + continue; // let the video move before deciding on the next line + } + } + else + { + if (_pauseVideoWhenLate && isPlaying && !videoPausedByUs && next < lines.Count && position >= lines[next].Start) + { + video.Pause(); + videoPausedByUs = true; + } + + continue; + } + } + + if (next >= lines.Count || (!isPlaying && !videoPausedByUs)) + { + continue; + } + + var line = lines[next]; + if (position < line.Start) + { + continue; + } + + if (!_pauseVideoWhenLate && position > line.End) + { + next++; // the video is already past this line + continue; + } + + var clip = clips[next]; + if (!clip.IsCompleted) + { + if (isPlaying && !videoPausedByUs) + { + video.Pause(); + videoPausedByUs = true; + onWaiting(next); + } + + continue; + } + + var fileName = await clip; // a failed generation ends the run with its error + cancellationToken.ThrowIfCancellationRequested(); + var index = next; + next++; + if (fileName == null) + { + continue; // nothing to say on this line + } + + slot = GetSlot(lines, index); + speechRate = 1.0; + rateKnown = false; + await player.LoadAudio(fileName); + appliedPlayerSpeed = videoSpeed; + player.Speed = videoSpeed; + + // LoadAudio keeps the file open at its end, which pauses mpv - and the pause + // carries over to the next file, so un-pause explicitly. + player.Play(); + clipPlayStartedMs = stopwatch.ElapsedMilliseconds; + stallDetector = new SpeakFromLineReader.PlaybackStallDetector(SpeakFromLineReader.StallTimeoutMilliseconds); + speaking = index; + Duck(); + onLineStarting(index); + + if (videoPausedByUs) + { + videoPausedByUs = false; + PlayVideo(); + } + + lastPosition = video.Position; // the load took a while - not a seek + lastTickMs = stopwatch.ElapsedMilliseconds; + } + } + finally + { + try + { + Unduck(); + } + catch + { + // ignore - the video may be gone + } + + generationCts.Cancel(); + foreach (var clip in allClips) + { + try + { + await clip; + } + catch + { + // ignore + } + } + + generationCts.Dispose(); + SpeakFromLineReader.DisposePlayer(player); + try + { + Directory.Delete(folder, true); + } + catch + { + // ignore + } + } + } + + /// Generates clips one at a time, in the order they were asked for. + private async Task GenerateQueuedAsync(string text, string folder, SemaphoreSlim gate, CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken); + try + { + return await SpeakFromLineReader.GenerateClipAsync(_engine, _voice, _language, text, folder, cancellationToken); + } + finally + { + gate.Release(); + } + } + + /// The first line still to be spoken at . + internal static int FindNextLine(IReadOnlyList lines, double position) + { + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].Start >= position - StartTolerance) + { + return i; + } + } + + return lines.Count; + } + + /// + /// The time line has to be spoken in: until its end, or until the + /// next line starts when they overlap. + /// + internal static double GetSlot(IReadOnlyList lines, int index) + { + var line = lines[index]; + var end = line.End; + if (index + 1 < lines.Count && lines[index + 1].Start > line.Start && lines[index + 1].Start < end) + { + end = lines[index + 1].Start; + } + + return Math.Max(0.2, end - line.Start); + } + + /// How much to speed up a clip so it fits its slot - never slower, at most . + internal static double GetSpeechRate(double clipDuration, double slot, double maxSpeedUp) + { + if (clipDuration <= slot || slot <= 0) + { + return 1.0; + } + + return Math.Min(maxSpeedUp, clipDuration / slot); + } + + /// + /// Whether the video position jumped (the user seeked) rather than moved by playing. + /// + internal static bool IsSeek(double lastPosition, double position, double elapsedSeconds, bool isPlaying, double speed) + { + if (position < lastPosition - 0.25) + { + return true; + } + + var expectedMove = isPlaying ? elapsedSeconds * speed : 0; + return position > lastPosition + expectedMove * 1.5 + (isPlaying ? 0.75 : 0.25); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechVideo.cs b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechVideo.cs new file mode 100644 index 00000000000..daf71a5619b --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechVideo.cs @@ -0,0 +1,65 @@ +using Nikse.SubtitleEdit.Controls.VideoPlayer; +using System; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.SpeakFromLine; + +/// +/// The main window's video player for . The player control is +/// looked up on every call, so going fullscreen or undocking keeps working. +/// +public sealed class PlayWithSpeechVideo : IPlayWithSpeechVideo +{ + private readonly Func _getControl; + private readonly Action _play; + private readonly Action _pause; + private readonly Func _getSpeed; + private readonly Func _isVideoOpen; + + /// Starts playback the way the main window does (keeps the playhead in step). + /// Pauses playback the way the main window does. + /// The playback speed picked in the waveform toolbar. + public PlayWithSpeechVideo(Func getControl, Action play, Action pause, Func getSpeed, Func isVideoOpen) + { + _isVideoOpen = isVideoOpen; + _getControl = getControl; + _play = play; + _pause = pause; + _getSpeed = getSpeed; + } + + public bool HasVideo => _getControl() != null && _isVideoOpen(); + public double Position => _getControl()?.VideoPlayer.Position ?? 0; + public bool IsPlaying => _getControl()?.VideoPlayer.IsPlaying ?? false; + public double Speed => _getSpeed(); + + public double Volume + { + get => _getControl()?.Volume ?? 0; + set + { + var control = _getControl(); + if (control != null) + { + control.Volume = value; + } + } + } + + public void Play() + { + var control = _getControl(); + if (control != null) + { + _play(control); + } + } + + public void Pause() + { + var control = _getControl(); + if (control != null) + { + _pause(control); + } + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineReader.cs b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineReader.cs index e1b7f94cea6..57ca7b6f2ca 100644 --- a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineReader.cs +++ b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineReader.cs @@ -103,7 +103,11 @@ public static string GetSpeakableText(string text) return Utilities.UnbreakLine(HtmlUtil.RemoveHtmlTags(text, alsoSsaTags: true)).Trim(); } - private Task GenerateAsync(string text, string folder, CancellationToken cancellationToken) + private Task GenerateAsync(string text, string folder, CancellationToken cancellationToken) => + GenerateClipAsync(_engine, _voice, _language, text, folder, cancellationToken); + + /// Generates one line's clip; null when there is nothing to say. + internal static Task GenerateClipAsync(ITtsEngine engine, Voice voice, TtsLanguage? language, string text, string folder, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(text)) { @@ -113,12 +117,12 @@ public static string GetSpeakableText(string text) // Off the UI thread: the local engines start their server synchronously on first use. return Task.Run(async () => { - var result = await _engine.Speak(text, folder, _voice, _language, null, null, cancellationToken); + var result = await engine.Speak(text, folder, voice, language, null, null, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (result.Error || string.IsNullOrEmpty(result.FileName) || !File.Exists(result.FileName)) { throw new InvalidOperationException(string.IsNullOrEmpty(result.ErrorMessage) - ? $"{_engine.Name} did not produce any audio." + ? $"{engine.Name} did not produce any audio." : result.ErrorMessage); } @@ -126,7 +130,7 @@ public static string GetSpeakableText(string text) }, cancellationToken); } - private static LibMpvDynamicPlayer CreatePlayer() + internal static LibMpvDynamicPlayer CreatePlayer() { var player = new LibMpvDynamicPlayer(); player.LoadLib(); @@ -187,7 +191,7 @@ private static async Task PlayAndWaitAsync(LibMpvDynamicPlayer player, str } } - private const int StallTimeoutMilliseconds = 5000; + internal const int StallTimeoutMilliseconds = 5000; /// /// Reports when the playback position has not moved for a while - mpv goes idle without @@ -217,7 +221,7 @@ public bool IsStalled(double position, long elapsedMilliseconds) } } - private static void DisposePlayer(LibMpvDynamicPlayer? player) + internal static void DisposePlayer(LibMpvDynamicPlayer? player) { if (player == null) { @@ -248,7 +252,7 @@ private static void DisposePlayer(LibMpvDynamicPlayer? player) }); } - private static void TryDelete(string fileName) + internal static void TryDelete(string fileName) { try { diff --git a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineViewModel.cs b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineViewModel.cs index e0485ea5a09..29bad10a290 100644 --- a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineViewModel.cs @@ -28,6 +28,11 @@ public partial class SpeakFromLineViewModel : ObservableObject [ObservableProperty] private bool _hasLanguageParameter; [ObservableProperty] private string _engineDescription; [ObservableProperty] private bool _isBusy; + [ObservableProperty] private bool _isPlayWithSpeech; + [ObservableProperty] private string _title; + [ObservableProperty] private string _hint; + [ObservableProperty] private bool _lowerVideoVolume; + [ObservableProperty] private bool _pauseVideoWhenLate; public ObservableCollection Engines { get; } public ObservableCollection Voices { get; } @@ -46,8 +51,12 @@ public SpeakFromLineViewModel(ITtsDownloadService ttsDownloadService, IWindowSer Voices = new ObservableCollection(); Languages = new ObservableCollection(); _engineDescription = string.Empty; + _title = Se.Language.Video.TextToSpeech.SpeakFromCurrentLineTitle; + _hint = Se.Language.Video.TextToSpeech.SpeakFromCurrentLineHint; var settings = Se.Settings.Video.TextToSpeech; + _lowerVideoVolume = settings.PlayWithSpeechLowerVideoVolume; + _pauseVideoWhenLate = settings.PlayWithSpeechPauseVideoWhenLate; // Supertonic by default: the fastest local engine, and one download covers 31 languages. SelectedEngine = Engines.FirstOrDefault(e => e.Name == settings.SpeakFromLineEngine) ?? Engines.FirstOrDefault(e => e is SupertonicCrispAsr) @@ -62,6 +71,14 @@ public SpeakFromLineViewModel(ITtsDownloadService ttsDownloadService, IWindowSer public static bool IsFastEngine(ITtsEngine engine) => engine is Piper or EdgeTts or KokoroTtsCpp or SupertonicCrispAsr; + /// Turns the dialog into the "Play with speech" prompt, which adds its playback options. + public void InitializePlayWithSpeech() + { + IsPlayWithSpeech = true; + Title = Se.Language.Video.TextToSpeech.PlayWithSpeechTitle; + Hint = Se.Language.Video.TextToSpeech.PlayWithSpeechHint; + } + partial void OnSelectedEngineChanged(ITtsEngine? value) { if (value != null) @@ -213,6 +230,12 @@ private async Task Ok() settings.SpeakFromLineLanguage = SelectedLanguage.Name; } + if (IsPlayWithSpeech) + { + settings.PlayWithSpeechLowerVideoVolume = LowerVideoVolume; + settings.PlayWithSpeechPauseVideoWhenLate = PauseVideoWhenLate; + } + OkPressed = true; Window.Close(); } diff --git a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineWindow.cs b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineWindow.cs index 8aea4f313cc..c603a354271 100644 --- a/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/SpeakFromLine/SpeakFromLineWindow.cs @@ -1,5 +1,6 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Layout; @@ -16,7 +17,7 @@ public class SpeakFromLineWindow : Window public SpeakFromLineWindow(SpeakFromLineViewModel vm) { UiUtil.InitializeWindow(this, GetType().Name); - Title = Se.Language.Video.TextToSpeech.SpeakFromCurrentLineTitle; + Bind(TitleProperty, new Binding(nameof(vm.Title))); CanResize = false; SizeToContent = SizeToContent.WidthAndHeight; MinWidth = 400; @@ -54,13 +55,26 @@ public SpeakFromLineWindow(SpeakFromLineViewModel vm) var labelHint = new TextBlock { - Text = Se.Language.Video.TextToSpeech.SpeakFromCurrentLineHint, + [!TextBlock.TextProperty] = new Binding(nameof(vm.Hint)), Opacity = 0.8, MaxWidth = 420, TextWrapping = TextWrapping.Wrap, HorizontalAlignment = HorizontalAlignment.Left, }; + var checkBoxLowerVolume = new CheckBox + { + Content = Se.Language.Video.TextToSpeech.LowerVideoVolumeWhileSpeaking, + [!ToggleButton.IsCheckedProperty] = new Binding(nameof(vm.LowerVideoVolume)), + [!IsVisibleProperty] = new Binding(nameof(vm.IsPlayWithSpeech)), + }; + var checkBoxPauseWhenLate = new CheckBox + { + Content = Se.Language.Video.TextToSpeech.PauseVideoWhenSpeechRunsLate, + [!ToggleButton.IsCheckedProperty] = new Binding(nameof(vm.PauseVideoWhenLate)), + [!IsVisibleProperty] = new Binding(nameof(vm.IsPlayWithSpeech)), + }; + var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); buttonOk.Bind(IsEnabledProperty, new Binding(nameof(vm.IsBusy)) { Converter = Avalonia.Data.Converters.BoolConverters.Not }); var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); @@ -76,6 +90,8 @@ public SpeakFromLineWindow(SpeakFromLineViewModel vm) new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, }, ColumnDefinitions = { @@ -95,7 +111,9 @@ public SpeakFromLineWindow(SpeakFromLineViewModel vm) grid.Add(comboBoxVoices, 3, 1); grid.Add(labelLanguage, 4, 0); grid.Add(comboBoxLanguages, 4, 1); - grid.Add(buttonPanel, 5, 0, 1, 2); + grid.Add(checkBoxLowerVolume, 5, 1); + grid.Add(checkBoxPauseWhenLate, 6, 1); + grid.Add(buttonPanel, 7, 0, 1, 2); Content = grid; diff --git a/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs b/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs index cebee6f31a1..cb9247e5d00 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs @@ -74,6 +74,12 @@ public class LanguageTextToSpeech public string SpeakFromCurrentLineHint { get; set; } public string StopSpeaking { get; set; } public string SpeakingLineX { get; set; } + public string PlayWithSpeechTitle { get; set; } + public string PlayWithSpeechDotDotDot { get; set; } + public string PlayWithSpeechHint { get; set; } + public string LowerVideoVolumeWhileSpeaking { get; set; } + public string PauseVideoWhenSpeechRunsLate { get; set; } + public string WaitingForSpeechLineX { get; set; } public string VoiceKindClone { get; set; } public string VoiceKindPreset { get; set; } public string VoiceKindModel { get; set; } @@ -305,6 +311,12 @@ public LanguageTextToSpeech() SpeakFromCurrentLineHint = "Reads the lines aloud one after another, starting at the current line. Press Escape or click another line to stop."; StopSpeaking = "Stop speaking"; SpeakingLineX = "Speaking line {0}... (Escape to stop)"; + PlayWithSpeechTitle = "Play with speech"; + PlayWithSpeechDotDotDot = "Play with speech..."; + PlayWithSpeechHint = "Plays the video and speaks each line when the video reaches it. Pause and seek as usual; press Escape to stop."; + LowerVideoVolumeWhileSpeaking = "Lower video volume while speaking"; + PauseVideoWhenSpeechRunsLate = "Pause video when speech runs into the next line"; + WaitingForSpeechLineX = "Generating speech for line {0}... (Escape to stop)"; VoiceKindClone = "Cloned voice"; VoiceKindPreset = "Built-in"; VoiceKindModel = "Voice model"; diff --git a/src/ui/Logic/Config/SeVideoTextToSpeech.cs b/src/ui/Logic/Config/SeVideoTextToSpeech.cs index f74bdb52f5b..1eda691f913 100644 --- a/src/ui/Logic/Config/SeVideoTextToSpeech.cs +++ b/src/ui/Logic/Config/SeVideoTextToSpeech.cs @@ -17,6 +17,8 @@ public class SeVideoTextToSpeech public string SpeakFromLineEngine { get; set; } public string SpeakFromLineVoice { get; set; } public string SpeakFromLineLanguage { get; set; } + public bool PlayWithSpeechLowerVideoVolume { get; set; } + public bool PlayWithSpeechPauseVideoWhenLate { get; set; } public string AllTalkUrl { get; set; } public string AzureApiKey { get; set; } public string AzureRegion { get; set; } @@ -211,6 +213,8 @@ public SeVideoTextToSpeech() SpeakFromLineEngine = string.Empty; SpeakFromLineVoice = string.Empty; SpeakFromLineLanguage = string.Empty; + PlayWithSpeechLowerVideoVolume = true; + PlayWithSpeechPauseVideoWhenLate = true; AllTalkUrl = "http://127.0.0.1:7851"; MurfApiKey = string.Empty; MurfStyle = "Conversational"; diff --git a/src/ui/Logic/ShortcutsMain.cs b/src/ui/Logic/ShortcutsMain.cs index bf5cc54b75e..8fd0acfb187 100644 --- a/src/ui/Logic/ShortcutsMain.cs +++ b/src/ui/Logic/ShortcutsMain.cs @@ -355,6 +355,7 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.ShowVideoTextToSpeechCommand), Se.Language.Options.Shortcuts.TextToSpeech }, { nameof(MainViewModel.ShowVideoVoiceManagerCommand), Se.Language.Video.TextToSpeech.VoiceManagerTitle }, { nameof(MainViewModel.SpeakFromCurrentLineCommand), Se.Language.Video.TextToSpeech.SpeakFromCurrentLineTitle }, + { nameof(MainViewModel.PlayWithSpeechCommand), Se.Language.Video.TextToSpeech.PlayWithSpeechTitle }, { nameof(MainViewModel.ShowVideoOcrCommand), Se.Language.Options.Shortcuts.VideoOcr }, { nameof(MainViewModel.ShowVideoBurnInCommand), Se.Language.Options.Shortcuts.BurnIn }, { nameof(MainViewModel.ShowVideoTransparentSubtitlesCommand), Se.Language.Options.Shortcuts.GenerateTransparent }, @@ -975,6 +976,7 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.ShowVideoTextToSpeechCommand, nameof(vm.ShowVideoTextToSpeechCommand), ShortcutCategory.General, ShortcutGroup.Ai); AddShortcut(shortcuts, vm.ShowVideoVoiceManagerCommand, nameof(vm.ShowVideoVoiceManagerCommand), ShortcutCategory.General, ShortcutGroup.Ai); AddShortcut(shortcuts, vm.SpeakFromCurrentLineCommand, nameof(vm.SpeakFromCurrentLineCommand), ShortcutCategory.General, ShortcutGroup.Ai); + AddShortcut(shortcuts, vm.PlayWithSpeechCommand, nameof(vm.PlayWithSpeechCommand), ShortcutCategory.General, ShortcutGroup.Ai); AddShortcut(shortcuts, vm.ShowVideoOcrCommand, nameof(vm.ShowVideoOcrCommand), ShortcutCategory.General, ShortcutGroup.Ai); AddShortcut(shortcuts, vm.ShowVideoBurnInCommand, nameof(vm.ShowVideoBurnInCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.ShowVideoTransparentSubtitlesCommand, nameof(vm.ShowVideoTransparentSubtitlesCommand), ShortcutCategory.General, ShortcutGroup.Video); diff --git a/tests/UI/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReaderTests.cs b/tests/UI/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReaderTests.cs new file mode 100644 index 00000000000..24f67ec9588 --- /dev/null +++ b/tests/UI/Features/Video/TextToSpeech/SpeakFromLine/PlayWithSpeechReaderTests.cs @@ -0,0 +1,88 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.SpeakFromLine; + +namespace UITests.Features.Video.TextToSpeech.SpeakFromLine; + +public class PlayWithSpeechReaderTests +{ + private static readonly SpeechLine[] Lines = + [ + new(1.0, 3.0, "one"), + new(4.0, 6.0, "two"), + new(5.5, 8.0, "three (overlaps two)"), + ]; + + [Fact] + public void FindNextLine_BeforeFirst_IsFirst() + { + Assert.Equal(0, PlayWithSpeechReader.FindNextLine(Lines, 0)); + } + + [Fact] + public void FindNextLine_JustPastStart_StillSpeaksThatLine() + { + Assert.Equal(1, PlayWithSpeechReader.FindNextLine(Lines, 4.2)); + } + + [Fact] + public void FindNextLine_InsideLine_SkipsToNext() + { + Assert.Equal(1, PlayWithSpeechReader.FindNextLine(Lines, 2.0)); + } + + [Fact] + public void FindNextLine_AfterLast_IsCount() + { + Assert.Equal(3, PlayWithSpeechReader.FindNextLine(Lines, 7.0)); + } + + [Fact] + public void GetSlot_UsesLineDuration() + { + Assert.Equal(2.0, PlayWithSpeechReader.GetSlot(Lines, 0), 3); + } + + [Fact] + public void GetSlot_OverlappingNextLine_EndsAtNextStart() + { + Assert.Equal(1.5, PlayWithSpeechReader.GetSlot(Lines, 1), 3); + } + + [Fact] + public void GetSpeechRate_ClipFits_NormalSpeed() + { + Assert.Equal(1.0, PlayWithSpeechReader.GetSpeechRate(1.5, 2.0, 1.5)); + } + + [Fact] + public void GetSpeechRate_ClipTooLong_SpedUpToFit() + { + Assert.Equal(1.25, PlayWithSpeechReader.GetSpeechRate(2.5, 2.0, 1.5), 3); + } + + [Fact] + public void GetSpeechRate_ClipFarTooLong_CappedAtMax() + { + Assert.Equal(1.5, PlayWithSpeechReader.GetSpeechRate(6.0, 2.0, 1.5), 3); + } + + [Fact] + public void IsSeek_NormalPlayback_IsNotSeek() + { + Assert.False(PlayWithSpeechReader.IsSeek(10.0, 10.04, 0.04, true, 1.0)); + Assert.False(PlayWithSpeechReader.IsSeek(10.0, 10.5, 0.25, true, 2.0)); // a slow tick at 2x + } + + [Fact] + public void IsSeek_JumpForwardOrBack_IsSeek() + { + Assert.True(PlayWithSpeechReader.IsSeek(10.0, 30.0, 0.04, true, 1.0)); + Assert.True(PlayWithSpeechReader.IsSeek(10.0, 5.0, 0.04, true, 1.0)); + } + + [Fact] + public void IsSeek_PausedAndMoved_IsSeek() + { + Assert.True(PlayWithSpeechReader.IsSeek(10.0, 11.0, 0.04, false, 1.0)); + Assert.False(PlayWithSpeechReader.IsSeek(10.0, 10.0, 0.04, false, 1.0)); + } +} From 54b83b5b54cdc899f223fd6db3bc76f02d611201 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 06:15:10 +0200 Subject: [PATCH 016/110] SMPTE timing: Final Cut Pro X formats write real media time (#15753) FCP X stores rational media time (a 23.976 frame is 1001/24000 s), but in SMPTE timing the subtitle's times are on the time code clock. All FCP X formats (1.3-1.14, X Xml, Chapter Marker, Captions, Gap, Name, 1.4 Text) now write a 1.001-stretched copy and divide by 1.001 after loading - only in SMPTE timing at 23.976/29.97/59.94. StoresFrameNumbers is set on them so the SMPTE toggle keeps their lines on the same frame. Co-Authored-By: Claude Opus 5.5 --- src/libse/SubtitleFormats/FinalCutProXCM.cs | 10 ++++ src/libse/SubtitleFormats/FinalCutProXXml.cs | 10 ++++ src/libse/SubtitleFormats/FinalCutProXml13.cs | 10 ++++ src/libse/SubtitleFormats/FinalCutProXml14.cs | 10 ++++ .../SubtitleFormats/FinalCutProXml14Text.cs | 10 ++++ src/libse/SubtitleFormats/FinalCutProXml15.cs | 10 ++++ .../SubtitleFormats/FinalCutProXmlCaptions.cs | 9 ++++ .../SubtitleFormats/FinalCutProXmlGap.cs | 10 ++++ .../SubtitleFormats/FinalCutProXmlName.cs | 10 ++++ src/libse/SubtitleFormats/SubtitleFormat.cs | 46 ++++++++++++++-- .../SubtitleFormatFunctionsTest.cs | 53 +++++++++++++++++++ 11 files changed, 185 insertions(+), 3 deletions(-) diff --git a/src/libse/SubtitleFormats/FinalCutProXCM.cs b/src/libse/SubtitleFormats/FinalCutProXCM.cs index 5328ef35fd7..470cda0f693 100644 --- a/src/libse/SubtitleFormats/FinalCutProXCM.cs +++ b/src/libse/SubtitleFormats/FinalCutProXCM.cs @@ -13,10 +13,14 @@ public class FinalCutProXCM : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro X Chapter Marker"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -96,6 +100,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXXml.cs b/src/libse/SubtitleFormats/FinalCutProXXml.cs index 834d23e76ef..eab3a28da2c 100644 --- a/src/libse/SubtitleFormats/FinalCutProXXml.cs +++ b/src/libse/SubtitleFormats/FinalCutProXXml.cs @@ -13,10 +13,14 @@ public class FinalCutProXXml : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro X Xml"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -120,6 +124,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml13.cs b/src/libse/SubtitleFormats/FinalCutProXml13.cs index 38ef8d0087c..66c004d144f 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml13.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml13.cs @@ -13,10 +13,14 @@ public class FinalCutProXml13 : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml 1.3"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -106,6 +110,12 @@ public override string ToText(Subtitle subtitle, string title) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml14.cs b/src/libse/SubtitleFormats/FinalCutProXml14.cs index 559c16fdbce..d101766e2a3 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml14.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml14.cs @@ -13,10 +13,14 @@ public class FinalCutProXml14 : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml 1.4"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -94,6 +98,12 @@ public override string ToText(Subtitle subtitle, string title) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml14Text.cs b/src/libse/SubtitleFormats/FinalCutProXml14Text.cs index 1314591d00c..e5feef8cc03 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml14Text.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml14Text.cs @@ -13,10 +13,14 @@ public class FinalCutProXml14Text : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml 1.4 Text"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -128,6 +132,12 @@ private static bool IsNearleWholeNumber(double number) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml15.cs b/src/libse/SubtitleFormats/FinalCutProXml15.cs index ebe85fb11fa..f9a1a29ec41 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml15.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml15.cs @@ -63,6 +63,9 @@ public FinalCutProXml15() public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml " + FcpXmlVersion; internal static string GetFrameDuration() @@ -150,6 +153,7 @@ internal static string GetNdfDf() public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); string xmlStructure = "" + Environment.NewLine + "" + Environment.NewLine + @@ -442,6 +446,12 @@ private static string ToColorString(SKColor fontColor) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs b/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs index 79cd9a2a268..1ee635812c3 100644 --- a/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs +++ b/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs @@ -17,10 +17,13 @@ public class FinalCutProXmlCaptions : SubtitleFormat { public override string Extension => ".fcpxml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml Captions"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); var frameDuration = FinalCutProXml15.GetFrameDuration(); // e.g. "1001/24000s" var arr = frameDuration.TrimEnd('s').Split('/'); var frameNumerator = long.Parse(arr[0]); @@ -295,6 +298,12 @@ private static string GetFcpFormatName() } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; var x = JoinLines(lines); diff --git a/src/libse/SubtitleFormats/FinalCutProXmlGap.cs b/src/libse/SubtitleFormats/FinalCutProXmlGap.cs index 4270b7d1406..ba55adbad87 100644 --- a/src/libse/SubtitleFormats/FinalCutProXmlGap.cs +++ b/src/libse/SubtitleFormats/FinalCutProXmlGap.cs @@ -13,10 +13,14 @@ public class FinalCutProXmlGap : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Xml Gap"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -92,6 +96,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXmlName.cs b/src/libse/SubtitleFormats/FinalCutProXmlName.cs index 02c41b6f408..29af024794f 100644 --- a/src/libse/SubtitleFormats/FinalCutProXmlName.cs +++ b/src/libse/SubtitleFormats/FinalCutProXmlName.cs @@ -31,11 +31,15 @@ public FinalCutProXmlName() public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml Name"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); string xmlStructure = "" + Environment.NewLine + "" + Environment.NewLine + @@ -112,6 +116,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/SubtitleFormat.cs b/src/libse/SubtitleFormats/SubtitleFormat.cs index 573a8b327c3..9b20ce07e85 100644 --- a/src/libse/SubtitleFormats/SubtitleFormat.cs +++ b/src/libse/SubtitleFormats/SubtitleFormat.cs @@ -536,9 +536,9 @@ public abstract string Name public bool IsFrameBased => !IsTimeBased; /// - /// True when the file stores absolute frame numbers - every frame-based format, plus time based - /// ones like FCP 7 XML that write frame counts. Toggling SMPTE timing keeps these lines on the - /// same frame (#15753). + /// True when the file pins lines to video frames - every frame-based format, plus time based ones + /// that write frame counts (FCP 7 XML) or frame-aligned media time (FCP X). Toggling SMPTE timing + /// keeps these lines on the same frame (#15753). /// public virtual bool StoresFrameNumbers => IsFrameBased; @@ -641,6 +641,46 @@ public static double ToTimeCodeFrameRate(double frameRate) return IsSmpteTimeCodeClock(frameRate) ? Math.Round(frameRate) : frameRate; } + /// + /// Final Cut Pro X stores real media time (a 23.976 frame is 1001/24000 s), but in SMPTE + /// timing the subtitle's times are on the time code clock, 1.001 faster (see + /// ). Returns a copy on media time to write, or + /// itself when SMPTE timing is off. + /// + protected static Subtitle ToMediaTime(Subtitle subtitle) + { + if (!IsSmpteTimeCodeClock(Configuration.Settings.General.CurrentFrameRate)) + { + return subtitle; + } + + var copy = new Subtitle(subtitle, false); + foreach (var p in copy.Paragraphs) + { + p.StartTime.TotalMilliseconds *= 1.001; + p.EndTime.TotalMilliseconds *= 1.001; + } + + return copy; + } + + /// + /// The reverse of for a subtitle just read from media time. + /// + protected static void FromMediaTime(Subtitle subtitle) + { + if (!IsSmpteTimeCodeClock(Configuration.Settings.General.CurrentFrameRate)) + { + return; + } + + foreach (var p in subtitle.Paragraphs) + { + p.StartTime.TotalMilliseconds /= 1.001; + p.EndTime.TotalMilliseconds /= 1.001; + } + } + public static double GetFrameForCalculation(double frameRate) { if (Math.Abs(frameRate - 23.976) < 0.001) diff --git a/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs b/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs index ba6a546036c..49aefe8d0a2 100644 --- a/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs +++ b/tests/libse/SubtitleFormats/SubtitleFormatFunctionsTest.cs @@ -125,4 +125,57 @@ public void SmpteTimingFinalCutPro7XmlCountsTimeCodeFrames() Configuration.Settings.General.CurrentFrameRate = oldRate; } } + + public static TheoryData FinalCutProXFormats => new TheoryData + { + new FinalCutProXml15(), + new FinalCutProXml114(), + new FinalCutProXml14(), + new FinalCutProXml13(), + new FinalCutProXXml(), + new FinalCutProXmlGap(), + new FinalCutProXmlName(), + new FinalCutProXml14Text(), + new FinalCutProXmlCaptions(), + }; + + [Theory] + [MemberData(nameof(FinalCutProXFormats))] + public void SmpteTimingFinalCutProXWritesMediaTime(SubtitleFormat format) + { + // #15753: FCP X stores real media time, so in SMPTE timing time code 01:00:00:00 is written + // as 3603.6 s (frame 86400 at 1001/24000 s) and read back as 01:00:00:00. + var oldSmpte = Configuration.Settings.General.CurrentVideoIsSmpte; + var oldRate = Configuration.Settings.General.CurrentFrameRate; + try + { + Configuration.Settings.General.CurrentFrameRate = 23.976; + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hi", 3600000, 3602000)); + + Configuration.Settings.General.CurrentVideoIsSmpte = false; + var plain = format.ToText(subtitle, "test"); + Configuration.Settings.General.CurrentVideoIsSmpte = true; + var smpte = format.ToText(subtitle, "test"); + Assert.NotEqual(plain, smpte); + + var stretched = new Subtitle(); + stretched.Paragraphs.Add(new Paragraph("Hi", 3603600, 3605602)); + Configuration.Settings.General.CurrentVideoIsSmpte = false; + Assert.Equal(format.ToText(stretched, "test"), smpte); + + Configuration.Settings.General.CurrentFrameRate = 23.976; + Configuration.Settings.General.CurrentVideoIsSmpte = true; + var loaded = new Subtitle(); + format.LoadSubtitle(loaded, smpte.SplitToLines(), null); + Assert.Single(loaded.Paragraphs); + Assert.Equal(3600000, loaded.Paragraphs[0].StartTime.TotalMilliseconds, 1); + Assert.Equal(3602000, loaded.Paragraphs[0].EndTime.TotalMilliseconds, 1); + } + finally + { + Configuration.Settings.General.CurrentVideoIsSmpte = oldSmpte; + Configuration.Settings.General.CurrentFrameRate = oldRate; + } + } } From 2c3e050dedf03a0fa93131ac13485263622d99b1 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 06:37:33 +0200 Subject: [PATCH 017/110] Close translation on a mismatched original keeps the original's timing (#15749) With the original's non-matching lines shown, a matched row only borrows its original line's text under the translation's time codes. Close translation promoted the rows in place, so the original's text got the translation's timing and the promoted display-only rows overlapped it - a line split in two in the original showed both halves stacked. Close translation now makes the original itself (edits folded in) the working subtitle. Folding rows back into the original (capture and save) keeps each matched line's own time codes and style instead of the row's. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/MainViewModel.cs | 92 +++++++++++++++---- .../Main/MainReadOnlyOriginalTests.cs | 78 ++++++++++++++++ 2 files changed, 154 insertions(+), 16 deletions(-) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 9640fd4a011..8fa82b17f0a 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -3816,13 +3816,14 @@ private void CaptureOriginalFromRows() // Every original line lives in exactly one row here: matched ones in their working row, the // rest in the display-only rows - so this round-trips the whole original. var format = _subtitleOriginal.OriginalFormat ?? SelectedSubtitleFormat; + var linkedLines = GetOriginalLinesById(); var captured = new Subtitle(_subtitleOriginal); captured.Paragraphs.Clear(); foreach (var line in Subtitles) { if (line.IsReferenceOnly || !string.IsNullOrEmpty(line.OriginalText)) { - var p = line.ToParagraphOriginal(format); + var p = ToOriginalParagraph(line, format, linkedLines); captured.Paragraphs.Add(p); // The capture creates fresh paragraphs (fresh ids), so re-point the row's sticky @@ -3834,6 +3835,49 @@ private void CaptureOriginalFromRows() _subtitleOriginal = captured; } + /// + /// The original's lines by id, for - empty unless the + /// original's non-matching lines are on screen (the only mode where a row and its original line + /// can have different time codes). + /// + private Dictionary GetOriginalLinesById() + { + var linesById = new Dictionary(); + if (!IsShowingOriginalNonMatchingLines || _subtitleOriginal == null) + { + return linesById; + } + + foreach (var p in _subtitleOriginal.Paragraphs) + { + if (p.Id is { } id) + { + linesById[id] = p; + } + } + + return linesById; + } + + /// + /// The original line a row holds. A working row that displays an original line matched by time + /// code only borrows that line's text - its time codes and style are the translation's - so the + /// line keeps its own time codes and style from the original. Taking them from the row gave a + /// line split in two in the original the translation's single time span, overlapping its second + /// half (#15749). Display-only rows are the original's lines themselves, nudged timings included. + /// + private static Paragraph ToOriginalParagraph(SubtitleLineViewModel line, SubtitleFormat format, Dictionary linesById) + { + if (!line.IsReferenceOnly && + line.ReferenceParagraphId is { } id && + linesById.TryGetValue(id, out var originalLine)) + { + return new Paragraph(originalLine) { Text = line.OriginalText.TrimEnd() }; + } + + return line.ToParagraphOriginal(format); + } + /// /// Makes the pre-translation text in the rows' original column the original subtitle. A /// translation made from the current rows leaves that text in the rows only, while the original @@ -4167,26 +4211,41 @@ private async Task FileCloseTranslation() } } - foreach (var subtitle in Subtitles) + if (IsShowingOriginalNonMatchingLines) { - subtitle.Text = subtitle.OriginalText; - subtitle.OriginalText = string.Empty; + // The original does not line up with the rows: a matched row only borrows its original + // line's text, under the translation's time codes. Promoting the rows in place gave the + // original's text the translation's timing, with the display-only rows overlapping it - + // a line split in two in the original showed both halves stacked (#15749). The original + // itself, edits folded in, becomes the working subtitle instead. + CaptureOriginalFromRows(); + var promoted = new Subtitle(_subtitleOriginal); + promoted.Sort(SubtitleSortCriteria.StartTime); + if (_subtitleOriginal.OriginalFormat?.GetType() != SelectedSubtitleFormat.GetType() || + string.IsNullOrEmpty(promoted.Header)) + { + // The rows stay in the current format, so its header (ASSA styles) stays too. + promoted.Header = _subtitle.Header; + promoted.Footer = _subtitle.Footer; + } - // A display-only reference row carries a line of the original, so the promotion turns - // it into an ordinary line - left flagged, it would be dropped from every save by - // GetUpdateSubtitle and keep its dimmed, read-only appearance. - subtitle.IsReferenceOnly = false; - subtitle.ReferenceParagraphId = null; // the original it pointed into is gone - } + promoted.OriginalFormat = _subtitle.OriginalFormat; + promoted.FileName = _subtitle.FileName; - if (IsShowingOriginalNonMatchingLines) - { - // The mode is over: the original is now the working subtitle. Rebuild the rows so the - // promoted reference rows take a number and settle into their final rendering. IsShowingOriginalNonMatchingLines = false; - _subtitle = GetUpdateSubtitle(); + _subtitle = promoted; SetSubtitles(_subtitle, null); } + else + { + foreach (var subtitle in Subtitles) + { + subtitle.Text = subtitle.OriginalText; + subtitle.OriginalText = string.Empty; + subtitle.IsReferenceOnly = false; + subtitle.ReferenceParagraphId = null; // the original it pointed into is gone + } + } IsEditOriginalMode = false; // there is no original left to edit _subtitleOriginalBeforeEditMode = null; @@ -28069,6 +28128,7 @@ public Subtitle GetUpdateSubtitleOriginal() _subtitleOriginal.OriginalFormat ??= SelectedSubtitleFormat; var originalFormat = _subtitleOriginal.OriginalFormat ?? SelectedSubtitleFormat; + var linkedLines = GetOriginalLinesById(); _subtitleOriginal.Paragraphs.Clear(); foreach (var line in Subtitles) { @@ -28086,7 +28146,7 @@ public Subtitle GetUpdateSubtitleOriginal() continue; } - var p = line.ToParagraphOriginal(originalFormat); + var p = ToOriginalParagraph(line, originalFormat, linkedLines); _subtitleOriginal.Paragraphs.Add(p); // Fresh paragraphs mean fresh ids - keep the row's sticky link pointing at the line it diff --git a/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs b/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs index ee8603b7a69..147753b3187 100644 --- a/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs +++ b/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs @@ -753,6 +753,84 @@ public async Task EditableOriginal_RemoveTranslation_PromotesTheReferenceOnlyRow } } + /// + /// A line split in two in the original is matched to the translation's single line by time code + /// only - the row keeps the translation's time span. "Remove translation" must hand back the + /// original's own time codes, not the row's, or the second half overlaps the first and both show + /// stacked on screen (#15749). + /// + [AvaloniaFact] + public async Task EditableOriginal_RemoveTranslation_KeepsTheOriginalTimeCodes() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Es culpa mía que las cosas hayan salido así.", string.Empty, 38820, 41800); + AddLine(vm, "He dañado a esta familia.", string.Empty, 43950, 45700); + + var original = new Subtitle(); + original.Paragraphs.Add(new Paragraph("Es toda mi culpa que", 38830, 39890)); + original.Paragraphs.Add(new Paragraph("ella haya resultado así.", 40170, 41570)); + original.Paragraphs.Add(new Paragraph("Yo destruí a esta familia.", 43950, 45700)); + var match = ImportOriginalHelper.MatchOriginalLines(vm.Subtitles, original); + InvokeImportOriginalSubtitle(vm, "original.srt", original, match, isReadOnly: false); + Assert.True(vm.IsShowingOriginalNonMatchingLines); + SetPrivateField(vm, "_changeSubtitleHash", vm.GetFastHash()); + + await InvokeFileCloseTranslation(vm); + + Assert.DoesNotContain(vm.Subtitles, p => p.IsReferenceOnly); + var saved = vm.GetUpdateSubtitle(); + Assert.Equal(3, saved.Paragraphs.Count); + for (var i = 0; i < original.Paragraphs.Count; i++) + { + Assert.Equal(original.Paragraphs[i].Text, saved.Paragraphs[i].Text); + Assert.Equal(original.Paragraphs[i].StartTime.TotalMilliseconds, saved.Paragraphs[i].StartTime.TotalMilliseconds); + Assert.Equal(original.Paragraphs[i].EndTime.TotalMilliseconds, saved.Paragraphs[i].EndTime.TotalMilliseconds); + } + } + finally + { + CloseWindow(window, vm); + } + } + + /// + /// Editing a matched row's original text and saving the original writes the edit with the + /// original line's own time codes - not the translation row's (#15749). + /// + [AvaloniaFact] + public void EditableOriginal_SaveOriginal_KeepsTheOriginalTimeCodesOfMatchedLines() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", string.Empty, 0, 2500); + AddLine(vm, "Translated two", string.Empty, 4000, 6000); + + var original = new Subtitle(); + original.Paragraphs.Add(new Paragraph("Reference one", 100, 1900)); + original.Paragraphs.Add(new Paragraph("Reference only - no translation", 2000, 3900)); + original.Paragraphs.Add(new Paragraph("Reference two", 4000, 6000)); + var match = ImportOriginalHelper.MatchOriginalLines(vm.Subtitles, original); + InvokeImportOriginalSubtitle(vm, "original.srt", original, match, isReadOnly: false); + + var firstRow = vm.Subtitles.First(p => !p.IsReferenceOnly); + Assert.Equal("Reference one", firstRow.OriginalText); + firstRow.OriginalText = "Reference one, edited"; + + var saved = vm.GetUpdateSubtitleOriginal(); + Assert.Equal(3, saved.Paragraphs.Count); + Assert.Equal("Reference one, edited", saved.Paragraphs[0].Text); + Assert.Equal(100, saved.Paragraphs[0].StartTime.TotalMilliseconds); + Assert.Equal(1900, saved.Paragraphs[0].EndTime.TotalMilliseconds); + } + finally + { + CloseWindow(window, vm); + } + } + /// /// The Delete command must skip reference-only rows in the selection: they belong to the /// original, and deleting one from an editable original would drop the line for good on the From 863faa8bdd9bbd27748325a0a169487c60a06ff0 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 07:23:20 +0200 Subject: [PATCH 018/110] Ask before Close translation drops a translation with no unsaved changes (#15749) Close translation only prompted when the translation had unsaved changes; otherwise it silently replaced the subtitle being edited with the original. It sits next to Close original in the File menu, so a slip went unnoticed. It now asks Yes/No first (the save prompt still covers the unsaved case). Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/MainViewModel.cs | 16 +++++ .../Config/Language/Main/LanguageMain.cs | 2 + .../Main/MainReadOnlyOriginalTests.cs | 68 ++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 9640fd4a011..15b88dc9699 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -4166,6 +4166,22 @@ private async Task FileCloseTranslation() return; } } + else if (!IsEmpty && Window != null) + { + // Nothing unsaved, but the whole translation still leaves the grid - and it sits right + // next to "Close original" in the File menu, so a slip used to swap the subtitle being + // edited for the original without a word (#15749). The save prompt above already asks. + var name = string.IsNullOrEmpty(_subtitleFileName) ? Se.Language.General.Untitled : Path.GetFileName(_subtitleFileName); + var originalName = string.IsNullOrEmpty(_subtitleFileNameOriginal) ? Se.Language.General.Untitled : Path.GetFileName(_subtitleFileNameOriginal); + var answer = await MessageBox.Show(Window, Se.Language.Options.Shortcuts.FileCloseTranslation, + string.Format(Se.Language.Main.CloseTranslationQuestionXY, name, originalName), + MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (answer != MessageBoxResult.Yes) + { + _shortcutManager.ClearKeys(); + return; + } + } foreach (var subtitle in Subtitles) { diff --git a/src/ui/Logic/Config/Language/Main/LanguageMain.cs b/src/ui/Logic/Config/Language/Main/LanguageMain.cs index 9626f7bb6c9..b154e7928d6 100644 --- a/src/ui/Logic/Config/Language/Main/LanguageMain.cs +++ b/src/ui/Logic/Config/Language/Main/LanguageMain.cs @@ -104,6 +104,7 @@ public class LanguageMain public string OriginalIsReadOnlyNotSaved { get; set; } public string OriginalIsEmptyNotSaved { get; set; } public string OriginalIsReadOnlyReference { get; set; } + public string CloseTranslationQuestionXY { get; set; } public string AllowEditOfOriginalSubtitle { get; set; } public string ShowAllOriginalLinesX { get; set; } public string ShowAllOriginalLinesHint { get; set; } @@ -267,6 +268,7 @@ public LanguageMain() OriginalIsReadOnlyNotSaved = "The original subtitle is a read-only reference and was not saved"; OriginalIsEmptyNotSaved = "The original subtitle has no text at all and was not saved"; OriginalIsReadOnlyReference = "The original subtitle is open as a read-only reference"; + CloseTranslationQuestionXY = "Close the translation \"{0}\"?\n\nThe original subtitle \"{1}\" becomes the subtitle you edit. The translation file itself is not changed."; AllowEditOfOriginalSubtitle = "Allow edit of original subtitle"; ShowAllOriginalLinesX = "Show all original lines ({0} have no match here)"; ShowAllOriginalLinesHint = "The lines with no match are shown as extra rows, so you can see what the translation is missing."; diff --git a/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs b/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs index ee8603b7a69..12f27b7068a 100644 --- a/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs +++ b/tests/UI/Features/Main/MainReadOnlyOriginalTests.cs @@ -7,6 +7,7 @@ using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Features.Main.MainHelpers; +using Nikse.SubtitleEdit.Features.Shared; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; @@ -753,6 +754,54 @@ public async Task EditableOriginal_RemoveTranslation_PromotesTheReferenceOnlyRow } } + /// + /// "Close translation" with nothing unsaved used to drop the translation without a word - and it + /// sits next to "Close original" in the File menu (#15749). It now asks, and No keeps everything. + /// + [AvaloniaFact] + public async Task CloseTranslation_WithoutUnsavedChanges_AsksAndNoKeepsTheTranslation() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", string.Empty, 0, 2000); + AddLine(vm, "Translated two", string.Empty, 4000, 6000); + + var reference = BuildSampleReference(); + var match = ImportOriginalHelper.MatchOriginalLines(vm.Subtitles, reference); + InvokeImportOriginalSubtitle(vm, "reference.srt", reference, match, isReadOnly: false); + SetPrivateField(vm, "_changeSubtitleHash", vm.GetFastHash()); + + var asked = false; + var method = typeof(MainViewModel).GetMethod( + "FileCloseTranslation", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("FileCloseTranslation not found"); + var task = (Task)method.Invoke(vm, null)!; + Dispatcher.UIThread.RunJobs(); + + var messageBox = window.OwnedWindows.OfType() + .FirstOrDefault(p => p.Title == Se.Language.Options.Shortcuts.FileCloseTranslation); + if (messageBox != null) + { + asked = true; + messageBox.Close(MessageBoxResult.No); + Dispatcher.UIThread.RunJobs(); + } + + await task; + + Assert.True(asked); + Assert.True(vm.ShowColumnOriginalText); + Assert.True(vm.IsShowingOriginalNonMatchingLines); + var saved = vm.GetUpdateSubtitle(); + Assert.Equal(new[] { "Translated one", "Translated two" }, saved.Paragraphs.Select(p => p.Text)); + } + finally + { + CloseWindow(window, vm); + } + } + /// /// The Delete command must skip reference-only rows in the selection: they belong to the /// original, and deleting one from an editable original would drop the line for good on the @@ -1012,13 +1061,28 @@ private static void InvokeFileCloseOriginal(MainViewModel vm) ((Task)method.Invoke(vm, null)!).GetAwaiter().GetResult(); } - private static async Task InvokeFileCloseTranslation(MainViewModel vm) + /// + /// Runs "Close translation" and answers its "close the translation?" question (#15749) when it + /// is asked - only for a translation with no unsaved changes. + /// + private static async Task InvokeFileCloseTranslation(MainViewModel vm, MessageBoxResult answer = MessageBoxResult.Yes) { var method = typeof(MainViewModel).GetMethod( "FileCloseTranslation", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("FileCloseTranslation not found"); - await (Task)method.Invoke(vm, null)!; + var task = (Task)method.Invoke(vm, null)!; + Dispatcher.UIThread.RunJobs(); + + var messageBox = vm.Window?.OwnedWindows.OfType() + .FirstOrDefault(p => p.Title == Se.Language.Options.Shortcuts.FileCloseTranslation); + if (messageBox != null) + { + messageBox.Close(answer); + Dispatcher.UIThread.RunJobs(); + } + + await task; } private static async Task InvokeDeleteSelectedItems(MainViewModel vm) From 9a5fd97f770ead5f9a22842cc022ce8056a59981 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 07:39:44 +0200 Subject: [PATCH 019/110] Prepare v5.3.0-beta24 Co-Authored-By: Claude Opus 5.5 --- change-log.txt | 18 ++++++++++++++++++ src/ui/Assets/Languages/English.json | 3 ++- src/ui/Logic/Config/Se.cs | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/change-log.txt b/change-log.txt index f0ef7da818d..7c8f10b6045 100644 --- a/change-log.txt +++ b/change-log.txt @@ -3,6 +3,24 @@ Subtitle Edit Changelog ----------------------------------------------------------------------------------------------------- +v5.3.0-beta24 (7th of October 2026) + +* Add "Play with speech": speak lines in sync while the video plays - thx Jose4D +* Add "custom milliseconds back/forward and pause" video shortcuts - thx accessallareas +* Add option to remember waveform zoom across restarts - thx bichitoxxx +* Batch convert: ask before adding video files from a folder - thx jsinfate +* Batch convert: faster adding of many files +* Close translation: ask before closing, keep the original's timing on a mismatched original - thx bichitoxxx +* SMPTE timing: frame numbers follow the NDF time code clock (FCP 7 XML + Final Cut Pro X) - thx smt-joen +* Fix ASSA override tags piling up when moving words - thx rRobis +* Fix EBU STL import frame-rate race - thx Triathlon-rally +* Update Flatpak runtime to freedesktop 26.08 (24.08 is end-of-life) - thx Gensplejs +* Update German translation - thx FunkyKnilch +* Update Japanese translation - thx hisui3393 +* Update Korean translation - thx 12si27 +* Update Polish translation - thx potplayer-fanpack + + v5.3.0-beta23 (6th of October 2026) * Add batch convert functions: Remove Unicode control characters, Renumber, Merge continuation lines, Convert actors diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index e75cac29db3..b3c09ee1ee5 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.3.0-beta23", + "version": "v5.3.0-beta24", "translatedBy": "", "cultureName": "en-US", "general": { @@ -1172,6 +1172,7 @@ "originalIsReadOnlyNotSaved": "The original subtitle is a read-only reference and was not saved", "originalIsEmptyNotSaved": "The original subtitle has no text at all and was not saved", "originalIsReadOnlyReference": "The original subtitle is open as a read-only reference", + "closeTranslationQuestionXY": "Close the translation \"{0}\"?\n\nThe original subtitle \"{1}\" becomes the subtitle you edit. The translation file itself is not changed.", "allowEditOfOriginalSubtitle": "Allow edit of original subtitle", "showAllOriginalLinesX": "Show all original lines ({0} have no match here)", "showAllOriginalLinesHint": "The lines with no match are shown as extra rows, so you can see what the translation is missing.", diff --git a/src/ui/Logic/Config/Se.cs b/src/ui/Logic/Config/Se.cs index 70296f0d8ef..243a5597a1f 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -21,7 +21,7 @@ public class Se internal const int CurrentShortcutsMigrationVersion = 6; internal const int CurrentLayoutMigrationVersion = 2; - public static string Version { get; set; } = "v5.3.0-beta23"; + public static string Version { get; set; } = "v5.3.0-beta24"; public SeGeneral General { get; set; } = new(); public List Shortcuts { get; set; } = new(); From d1a38c2f3e10be7224fa6bf0d977fabe044e0b9b Mon Sep 17 00:00:00 2001 From: bovirus <1262554+bovirus@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:30:17 +0200 Subject: [PATCH 020/110] Update Italian.json --- src/ui/Assets/Languages/Italian.json | 54 +++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/ui/Assets/Languages/Italian.json b/src/ui/Assets/Languages/Italian.json index 4edc11848f3..c030a808696 100644 --- a/src/ui/Assets/Languages/Italian.json +++ b/src/ui/Assets/Languages/Italian.json @@ -1,7 +1,7 @@ { "title": "SubtitleEdit", - "version": "v. 5.3.0-beta22", - "translatedBy": "v. 05.10.2026 di bovirus e GitGianluc", + "version": "v. 5.3.0-beta24", + "translatedBy": "v. 07.10.2026 di bovirus e GitGianluc", "cultureName": "it-IT", "general": { "abort": "Annulla", @@ -443,6 +443,7 @@ "percent": "Percentuale", "pickLayer": "Imposta livello", "pickOllamaModel": "Scegli modello Ollama", + "pickModel": "Scegli modello", "llamaCppUseRemoteServer": "Usa server esterno (URL)", "llamaCppEngineSettings": "Impostazioni motore lama.cpp", "llamaCppEngineSettingsSubtitle": "Build server locale llama.cpp", @@ -744,6 +745,14 @@ "videoCustom3ForwardX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti, 3", "videoCustom4BackX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro, 4", "videoCustom4ForwardX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti, 4", + "videoCustom1BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 1", + "videoCustom1ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 1", + "videoCustom2BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 2", + "videoCustom2ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 2", + "videoCustom3BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 3", + "videoCustom3ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 3", + "videoCustom4BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 4", + "videoCustom4ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 4", "videoExtension": "Estensione file video", "videoFile": "File video", "videoFileGenerated": "Generato file video", @@ -1163,6 +1172,7 @@ "originalIsReadOnlyNotSaved": "Il sottotitolo originale è in sola lettura come riferimento e non è stato salvato", "originalIsEmptyNotSaved": "Il sottotitolo originale non contiene testo e non è stato salvato", "originalIsReadOnlyReference": "Il sottotitolo originale è aperto in sola lettura come riferimento", + "closeTranslationQuestionXY": "Vuoi chiudere la traduzione '{0}'?\r\n\r\nIl sottotitolo originale '{1}' diventa il sottotitolo che modifichi. Il file traduzione non verrà modificato.", "allowEditOfOriginalSubtitle": "Consenti modifica sottotitolo originale", "showAllOriginalLinesX": "Visualizza tutte le righe originali ({0} non hanno qui corrispondenze)", "showAllOriginalLinesHint": "Le righe senza corrispondenza vengono visualizzate come righe aggiuntive per visualizzare le parti mancanti nella traduzione.", @@ -2179,6 +2189,18 @@ "addFolderRecursiveDotDotDot": "Aggiungi cartella ricorsiva...", "selectFolderToConvert": "Seleziona la cartella con i file da convertire", "includeSubfolders": "Quando aggiungi una cartella includi le sottocartelle", + "videoFilesWhenAddingFolder": "File video (mkv, mp4, ts...) quando si aggiunge una cartella", + "videoFilesAsk": "Chiedi", + "videoFilesInclude": "Aggiungi le loro tracce sottotitoli", + "videoFilesSkip": "Salta", + "folderContainsXVideoFiles": "La cartella contiene '{0}' file video (mkv, mp4, ts...).\r\n\r\nVuoi aggiungere anche le tracce sottotitoli incorporate o solo i file sottotitoli?", + "addVideoFiles": "Aggiungi file video", + "skipVideoFiles": "Solo file sottotitoli", + "doNotAskAgainVideoFiles": "Non chiedere più (modifica impostazioni conversione batch)", + "settingsSectionOutput": "Output", + "settingsSectionAddingFiles": "Aggiunta file", + "settingsSectionImageBasedSubtitles": "Sottotitoli basati su immagini (OCR)", + "settingsSectionOther": "Altro", "keepSourceFileTimestamp": "Usa data/ora file sorgente per i file destinazione", "preventSleepWhileConverting": "Impedisce al computer di andare in stop durante la conversione", "scanningFolderX": "Scansione {0}...", @@ -2194,7 +2216,20 @@ "transportStreamOnlyTeletext": "Solo televideo", "transportStreamGetSizeFromVideo": "Rileva dimensioni dal video...", "twoLetterLanguageCodeUppercase": "Codice lingua a due lettere (maiuscolo)", - "threeLetterLanguageCodeUppercase": "Codice lingua a tre lettere (maiuscolo)" + "threeLetterLanguageCodeUppercase": "Codice lingua a tre lettere (maiuscolo)", + "formatContainsDotDotDot": "Il formato contiene...", + "extensionIsDotDotDot": "L'estensione è...", + "statusIsError": "Lo stato è errore", + "forcedTracksOnly": "Solo tracce forzate", + "xConvertedYFailedInZ": "{0:#,###,##0} convertito, {1:#,###,##0} non riuscito in {2}", + "xNotProcessed": "{0:#,###,##0} non elaborato", + "removeUnicodeControlCharactersTitle": "Rimuovi caratteri di controllo Unicode", + "removeUnicodeControlCharactersInfo": "Rimuove dal testo i caratteri di controllo Unicode invisibili (LRM, RLM, LRE, RLE, PDF, LRO e RLO) e sostituisce gli spazi di non interruzione con spazi normali.", + "preset": "Profilo", + "savePresetDotDotDot": "Salva profilo...", + "deletePreset": "Elimina profilo", + "presetName": "Nome profilo", + "deletePresetX": "Vuoi eliminare il profilo '{0}'?" }, "changeCasing": { "fixNames": "Correggi nomi", @@ -2669,8 +2704,14 @@ "speakFromCurrentLineHint": "Legge ad alta voce le righe una dopo l'altra, iniziando dalla riga attuale.\r\nPremi Esc o fai clic su un'altra riga per interrompere.", "stopSpeaking": "Stop lettura", "speakingLineX": "Lettura riga '{0}'... ('Esc' per interrompere)", + "playWithSpeechTitle": "Riproduci con sintesi vocale", + "playWithSpeechDotDotDot": "Riproduci con sintesi vocale...", + "playWithSpeechHint": "Riproduce il video e pronuncia ogni riga quando il video la raggiunge. Mette in pausa e cerca come al solito; premi 'Esc' per interrompere.", + "lowerVideoVolumeWhileSpeaking": "Abbassa il volume del video durante il parlato", + "pauseVideoWhenSpeechRunsLate": "Mette in pausa il video quando il parlato passa alla riga successiva", + "waitingForSpeechLineX": "Generazione vocale per la riga {0}... ('Esc' per interrompere)", "voiceKindClone": "Voce clonata", - "voiceKindPreset": "Integrato", + "voiceKindPreset": "Integrata", "voiceKindModel": "Modello vocale", "voiceKindOnline": "Online", "transcript": "Trascrizione", @@ -3199,7 +3240,8 @@ "syncPoints": "Punti sincronizzazione", "pointSync": "Sincronizza punti", "pointSyncViaOther": "Sincronizzazione punti tramite altri sottotitoli", - "syncPointCandidateInfo": "Sono evidenziate le righe che iniziano dopo {0}+ secondi di silenzio : spesso costituiscono punti di sincronizzazione affidabili", + "syncPointCandidateAfterInfo": "Sono evidenziate le righe seguite da {0}+ secondi di silenzio : spesso costituiscono punti di sincronizzazione affidabili", + "gapAfter": "Gap dopo", "adjustAllShortcuts": "Tasti rapidi:\r\n\r\n• Maiuscolo + sinistra/destra: sposta di 10 ms\r\n• Ctrl + sinistra/destra: sposta di 100 ms\r\n• Alt + sinistra/destra: sposta di 500 ms", "adjustAllShortcutsFrames": "Tasti rapidi:\r\n\r\n• Maiusc + sinistra/destra: sposta di 1 fotogramma\r\n• Ctrl + sinistra/destra: sposta di 10 fotogrammi\r\n• Alt + sinistra/destra: sposta di 1 secondo", "offsetInSeconds": "Offset in secondi", @@ -3389,6 +3431,7 @@ "waveformUseSkiaRenderer": "Usa renderer veloce sperimentale", "waveformCenterVideoPositionAlsoWhenPaused": "Centra posizione video anche durante la pausa", "waveformSelectCurrentSubtitleWhilePaused": "Seleziona il sottotitolo attuale durante la pausa", + "waveformRememberZoom": "Ricorda valore zoom (orizzontale e verticale)", "waveformShowToolbar": "Visualizza barra strumenti", "waveformShowToolbarEdit": "Modifica barra strumenti...", "waveformShowToolbarEditLabel": "Barra strumenti forma d'onda/spettrogramma", @@ -3454,6 +3497,7 @@ "adjustAllTimesRememberLineSelectionChoice": "Regola tutti i tempi, ricorda la scelta selezione riga", "mergeKeepEndTime": "Unisci righe: conserva tempistica finale (consenti sovrapposizione con il sottotitolo successivo)", "mergeKeepEndTimeOnlyAssa": "Unisci righe: conserva tempistica finale solo con 'Advanced Sub Station Alpha'", + "mergeAutoBreak": "Unisci righe: testo unito con interruzione automatica (se disattivato mantiene le interruzioni di riga proprie delle righe)", "filesAndLogs": "File e registri", "showErrorLogFile": "Visualizza file registro errori", "showToolsLogFile": "Visualizza file registro Whisper", From aec53f2148d8fcd9e7a92f5277f715d1244803fc Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 16:43:59 +0200 Subject: [PATCH 021/110] Fix common errors: readable action text in light theme (#15768) The action pill colors were dark-theme pastels (~2:1 contrast on white). Use darker shades for the text on the light theme (>= 5:1 against the tinted pill), keeping the same hue per action. Co-Authored-By: Claude Opus 5.5 --- .../FixCommonErrorsViewModel.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs index a6b38c7d8b9..d30a4575851 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs @@ -389,20 +389,37 @@ public void SetFixesSelected(IEnumerable fixes, bool isSelected) Color.FromRgb(0x9a, 0xa3, 0xad), // gray }; + // Darker shades of ChipPalette for action text on the light theme - the pastels above are + // tuned for a dark background and are hard to read on white (#15768). Each one is at least + // 4.5:1 against its own tinted pill background. + private static readonly Color[] ChipPaletteLight = + { + Color.FromRgb(0x8a, 0x5a, 0x00), // amber + Color.FromRgb(0x6a, 0x3d, 0xb8), // violet + Color.FromRgb(0x00, 0x6b, 0x7d), // cyan + Color.FromRgb(0xa8, 0x2e, 0x66), // pink + Color.FromRgb(0x1e, 0x7a, 0x34), // green + Color.FromRgb(0x1a, 0x5f, 0xb4), // blue + Color.FromRgb(0xa8, 0x46, 0x10), // orange + Color.FromRgb(0x55, 0x5e, 0x68), // gray + }; + private readonly Dictionary _actionPaletteIndex = new(); public IBrush GetActionBrush(string actionDisplay) { - return new SolidColorBrush(GetActionColor(actionDisplay)); + var index = GetActionPaletteIndex(actionDisplay); + var palette = UiTheme.IsDarkThemeEnabled() ? ChipPalette : ChipPaletteLight; + return new SolidColorBrush(palette[index]); } public IBrush GetActionBackgroundBrush(string actionDisplay) { - var c = GetActionColor(actionDisplay); + var c = ChipPalette[GetActionPaletteIndex(actionDisplay)]; return new SolidColorBrush(Color.FromArgb(0x20, c.R, c.G, c.B)); } - private Color GetActionColor(string actionDisplay) + private int GetActionPaletteIndex(string actionDisplay) { if (!_actionPaletteIndex.TryGetValue(actionDisplay, out var index)) { @@ -410,7 +427,7 @@ private Color GetActionColor(string actionDisplay) _actionPaletteIndex[actionDisplay] = index; } - return ChipPalette[index % ChipPalette.Length]; + return index % ChipPalette.Length; } private void AddFix(FixDisplayItem item) From 67588f20c2d5519caeb8a19a3a0dda1b1f13aad1 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 16:46:20 +0200 Subject: [PATCH 022/110] AI review: readable category and warning colors in light theme Same issue as #15768: the category pill text/icons and the warning note used dark-theme pastels (~2:1 on white). Use darker shades of the same hues on the light theme (>= 5:1). Co-Authored-By: Claude Opus 5.5 --- .../Features/Tools/AiReview/AiReviewWindow.cs | 2 +- .../Tools/AiReview/ReviewSuggestionItem.cs | 49 ++++++++++++------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/ui/Features/Tools/AiReview/AiReviewWindow.cs b/src/ui/Features/Tools/AiReview/AiReviewWindow.cs index 4a5c3cd7fd7..204a0c54505 100644 --- a/src/ui/Features/Tools/AiReview/AiReviewWindow.cs +++ b/src/ui/Features/Tools/AiReview/AiReviewWindow.cs @@ -201,7 +201,7 @@ public AiReviewWindow(AiReviewViewModel vm) }), }; - var warningBrush = new SolidColorBrush(Color.FromRgb(0xf0, 0xa6, 0x3c)); + var warningBrush = ReviewSuggestionItem.GetWarningBrush(); var warningText = MakeBoundTextBlock(nameof(vm.WarningNoteText)); warningText.Foreground = warningBrush; var warningNote = new StackPanel diff --git a/src/ui/Features/Tools/AiReview/ReviewSuggestionItem.cs b/src/ui/Features/Tools/AiReview/ReviewSuggestionItem.cs index 0a980f5d201..0a19f4fcfe8 100644 --- a/src/ui/Features/Tools/AiReview/ReviewSuggestionItem.cs +++ b/src/ui/Features/Tools/AiReview/ReviewSuggestionItem.cs @@ -1,6 +1,7 @@ using Avalonia.Media; using Avalonia.Media.Immutable; using CommunityToolkit.Mvvm.ComponentModel; +using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; namespace Nikse.SubtitleEdit.Features.Tools.AiReview; @@ -33,25 +34,33 @@ public partial class ReviewSuggestionItem : ObservableObject _ => Se.Language.Tools.AiReview.CategoryOther, }; - public IBrush CategoryBrush => Category switch - { - ReviewCategory.Spelling => CategoryBrushes.Spelling, - ReviewCategory.Grammar => CategoryBrushes.Grammar, - ReviewCategory.Punctuation => CategoryBrushes.Punctuation, - ReviewCategory.Casing => CategoryBrushes.Casing, - _ => CategoryBrushes.Other, - }; + public IBrush CategoryBrush => GetBrushForCategory(Category); - public IBrush WarningBrush => IsWarning ? CategoryBrushes.Warning : Brushes.Transparent; + public IBrush WarningBrush => IsWarning ? GetWarningBrush() : Brushes.Transparent; - public static IBrush GetBrushForCategory(ReviewCategory category) => category switch - { - ReviewCategory.Spelling => CategoryBrushes.Spelling, - ReviewCategory.Grammar => CategoryBrushes.Grammar, - ReviewCategory.Punctuation => CategoryBrushes.Punctuation, - ReviewCategory.Casing => CategoryBrushes.Casing, - _ => CategoryBrushes.Other, - }; + // The pastel brushes are tuned for the dark theme and are hard to read on white, so the + // light theme gets darker shades of the same hues for text and icons (#15768). + public static IBrush GetBrushForCategory(ReviewCategory category) => UiTheme.IsDarkThemeEnabled() + ? category switch + { + ReviewCategory.Spelling => CategoryBrushes.Spelling, + ReviewCategory.Grammar => CategoryBrushes.Grammar, + ReviewCategory.Punctuation => CategoryBrushes.Punctuation, + ReviewCategory.Casing => CategoryBrushes.Casing, + _ => CategoryBrushes.Other, + } + : category switch + { + ReviewCategory.Spelling => CategoryBrushes.SpellingLight, + ReviewCategory.Grammar => CategoryBrushes.GrammarLight, + ReviewCategory.Punctuation => CategoryBrushes.PunctuationLight, + ReviewCategory.Casing => CategoryBrushes.CasingLight, + _ => CategoryBrushes.OtherLight, + }; + + public static IBrush GetWarningBrush() => UiTheme.IsDarkThemeEnabled() + ? CategoryBrushes.Warning + : CategoryBrushes.WarningLight; public string CategoryIconName => Category switch { @@ -79,6 +88,12 @@ private static class CategoryBrushes public static readonly IBrush Casing = new ImmutableSolidColorBrush(Color.FromRgb(0xe8, 0x8c, 0xb0)); public static readonly IBrush Other = new ImmutableSolidColorBrush(Color.FromRgb(0x9a, 0xa3, 0xad)); public static readonly IBrush Warning = new ImmutableSolidColorBrush(Color.FromRgb(0xf0, 0xa6, 0x3c)); + public static readonly IBrush SpellingLight = new ImmutableSolidColorBrush(Color.FromRgb(0x8a, 0x5a, 0x00)); + public static readonly IBrush GrammarLight = new ImmutableSolidColorBrush(Color.FromRgb(0x6a, 0x3d, 0xb8)); + public static readonly IBrush PunctuationLight = new ImmutableSolidColorBrush(Color.FromRgb(0x00, 0x6b, 0x7d)); + public static readonly IBrush CasingLight = new ImmutableSolidColorBrush(Color.FromRgb(0xa8, 0x2e, 0x66)); + public static readonly IBrush OtherLight = new ImmutableSolidColorBrush(Color.FromRgb(0x55, 0x5e, 0x68)); + public static readonly IBrush WarningLight = new ImmutableSolidColorBrush(Color.FromRgb(0x9e, 0x55, 0x00)); public static readonly IBrush SpellingBackground = new ImmutableSolidColorBrush(Color.FromArgb(0x20, 0xe8, 0xb0, 0x4c)); public static readonly IBrush GrammarBackground = new ImmutableSolidColorBrush(Color.FromArgb(0x20, 0xb4, 0x8c, 0xe8)); public static readonly IBrush PunctuationBackground = new ImmutableSolidColorBrush(Color.FromArgb(0x20, 0x5f, 0xc6, 0xd8)); From 1355da9ebf3b4becf65f207f53c42d0c5e32b83e Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 16:52:06 +0200 Subject: [PATCH 023/110] Restore auto-backup: readable extension pill text in light theme Same issue as #15768: the extension pills used dark-theme pastels (~2:1 on white). Use darker shades of the same hues on the light theme. Co-Authored-By: Claude Opus 5.5 --- .../RestoreAutoBackupWindow.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/ui/Features/Files/RestoreAutoBackup/RestoreAutoBackupWindow.cs b/src/ui/Features/Files/RestoreAutoBackup/RestoreAutoBackupWindow.cs index 0cb0286c4a6..6c11fbfd413 100644 --- a/src/ui/Features/Files/RestoreAutoBackup/RestoreAutoBackupWindow.cs +++ b/src/ui/Features/Files/RestoreAutoBackup/RestoreAutoBackupWindow.cs @@ -347,7 +347,9 @@ private static StackPanel MakeTabHeader(string iconName, string text) return new Border(); } - var color = GetExtensionColor(item.Extension); + var index = GetExtensionPaletteIndex(item.Extension); + var color = ExtensionPalette[index]; + var textColor = UiTheme.IsDarkThemeEnabled() ? color : ExtensionPaletteLight[index]; return new Border { Background = Brushes.Transparent, @@ -363,7 +365,7 @@ private static StackPanel MakeTabHeader(string iconName, string text) { Text = item.Extension, FontSize = UiUtil.ScaledFontSize(12), - Foreground = new SolidColorBrush(color), + Foreground = new SolidColorBrush(textColor), VerticalAlignment = VerticalAlignment.Center, }, }, @@ -381,7 +383,19 @@ private static StackPanel MakeTabHeader(string iconName, string text) Color.FromRgb(0x4c, 0x9c, 0xe8), // blue }; - private static Color GetExtensionColor(string extension) + // Darker shades of ExtensionPalette for the pill text on the light theme - the pastels are + // tuned for a dark background and are hard to read on white (#15768). + private static readonly Color[] ExtensionPaletteLight = + { + Color.FromRgb(0x00, 0x6b, 0x7d), // cyan + Color.FromRgb(0x6a, 0x3d, 0xb8), // violet + Color.FromRgb(0x8a, 0x5a, 0x00), // amber + Color.FromRgb(0x1e, 0x7a, 0x34), // green + Color.FromRgb(0xa8, 0x2e, 0x66), // pink + Color.FromRgb(0x1a, 0x5f, 0xb4), // blue + }; + + private static int GetExtensionPaletteIndex(string extension) { var hash = 0; foreach (var ch in extension.ToLowerInvariant()) @@ -389,6 +403,6 @@ private static Color GetExtensionColor(string extension) hash = hash * 31 + ch; } - return ExtensionPalette[System.Math.Abs(hash) % ExtensionPalette.Length]; + return System.Math.Abs(hash) % ExtensionPalette.Length; } } From b47d51555487eaa165fe69feb6d49aade3523646 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 16:53:57 +0200 Subject: [PATCH 024/110] Spell check: apply change-all pairs only to misspelled words (#15767) Persisted "use always" pairs (_UseAlways.xml, shared with SE4) were applied to every word, so a stray "and" -> "&" pair silently rewrote every correctly spelled "and". Pairs now only replace words the dictionary rejects. Scan-time replacements also went through ChangeWord, whose no-current-result guard skipped them (while counting the word as correct) until the first flagged word of a run - so pairs worked only "sometimes". Co-Authored-By: Claude Opus 5.5 --- .../Features/SpellCheck/SpellCheckManager.cs | 24 +++-- .../SpellCheckChangeAllCorrectWordTests.cs | 100 ++++++++++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 tests/UI/Features/SpellCheck/SpellCheckChangeAllCorrectWordTests.cs diff --git a/src/ui/Features/SpellCheck/SpellCheckManager.cs b/src/ui/Features/SpellCheck/SpellCheckManager.cs index 1b3b41d45ee..91bfc6cdbce 100644 --- a/src/ui/Features/SpellCheck/SpellCheckManager.cs +++ b/src/ui/Features/SpellCheck/SpellCheckManager.cs @@ -86,6 +86,11 @@ public void ChangeWord(string fromWord, string toWord, SpellCheckWord spellCheck return; } + ReplaceWord(fromWord, toWord, spellCheckWord, p); + } + + private void ReplaceWord(string fromWord, string toWord, SpellCheckWord spellCheckWord, SubtitleLineViewModel p) + { var text = p.Text.Remove(spellCheckWord.Index, spellCheckWord.Length); text = text.Insert(spellCheckWord.Index, toWord); p.Text = text; @@ -188,23 +193,28 @@ private bool IsWordCorrect(SpellCheckWord spellCheckWord, SubtitleLineViewModel isCorrect = isCorrect || IsLowercaseOnlyWordAtSentenceStart(spellCheckWord, text); - if (ChangeAllDictionary.ContainsKey(word) && NotSameSpecialEnding(words[wordIndex], ChangeAllDictionary[word], text)) + if (isCorrect) { - ChangeWord(word, ChangeAllDictionary[word], words[wordIndex], p); + NoOfCorrectWords++; return true; } - if (word.EndsWith('\'') && ChangeAllDictionary.ContainsKey(word.TrimEnd('\''))) + // Change-all pairs only replace misspelled words. The pairs persist in _UseAlways.xml + // (shared with SE4), so a stray pair like "and" -> "&" used to silently rewrite every + // correctly spelled "and". ReplaceWord, not ChangeWord: ChangeWord's no-current-result guard + // made a fresh run skip these (yet count them as correct) until the first flagged word. + if (ChangeAllDictionary.ContainsKey(word) && NotSameSpecialEnding(words[wordIndex], ChangeAllDictionary[word], text)) { - ChangeWord(word, ChangeAllDictionary[word.TrimEnd('\'')] + word.Remove(0, word.TrimEnd('\'').Length), words[wordIndex], p); + ReplaceWord(word, ChangeAllDictionary[word], words[wordIndex], p); return true; } - if (isCorrect) + if (word.EndsWith('\'') && ChangeAllDictionary.ContainsKey(word.TrimEnd('\''))) { - NoOfCorrectWords++; + ReplaceWord(word, ChangeAllDictionary[word.TrimEnd('\'')] + word.Remove(0, word.TrimEnd('\'').Length), words[wordIndex], p); + return true; } - return isCorrect; + return false; } } diff --git a/tests/UI/Features/SpellCheck/SpellCheckChangeAllCorrectWordTests.cs b/tests/UI/Features/SpellCheck/SpellCheckChangeAllCorrectWordTests.cs new file mode 100644 index 00000000000..7c93b6c74fe --- /dev/null +++ b/tests/UI/Features/SpellCheck/SpellCheckChangeAllCorrectWordTests.cs @@ -0,0 +1,100 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.SpellCheck; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.ObjectModel; +using System.IO; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; + +namespace UITests.Features.SpellCheck; + +// A persisted "use always" pair must only replace misspelled words - a stray "and" -> "&" pair in +// en_US_UseAlways.xml silently rewrote every correctly spelled "and". (#15767) +public class SpellCheckChangeAllCorrectWordTests : IDisposable +{ + private readonly string _originalDictionariesFolder; + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly bool _originalRememberUseAlwaysList; + private readonly string _tempDictionariesFolder; + + public SpellCheckChangeAllCorrectWordTests() + { + _originalDictionariesFolder = Se.DictionariesFolder; + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + _originalRememberUseAlwaysList = Configuration.Settings.Tools.RememberUseAlwaysList; + _tempDictionariesFolder = Path.Combine(Path.GetTempPath(), "SeChangeAllCorrect_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDictionariesFolder); + + var repoRoot = FindRepoRoot(); + File.Copy(Path.Combine(repoRoot, "Dictionaries", "en_US.dic"), Path.Combine(_tempDictionariesFolder, "en_US.dic")); + File.Copy(Path.Combine(repoRoot, "Dictionaries", "en_US.aff"), Path.Combine(_tempDictionariesFolder, "en_US.aff")); + File.WriteAllText(Path.Combine(_tempDictionariesFolder, "en_US_UseAlways.xml"), + ""); + + Se.DictionariesFolder = _tempDictionariesFolder; + SpellCheckConfig.DictionariesFolder = () => _tempDictionariesFolder; + Configuration.Settings.Tools.RememberUseAlwaysList = true; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Dictionaries"))) + { + dir = dir.Parent; + } + + return dir!.FullName; + } + + private SpellCheckManager MakeManager() + { + var manager = new SpellCheckManager(); + manager.Initialize(Path.Combine(_tempDictionariesFolder, "en_US.dic"), "en"); + return manager; + } + + [Fact] + public void PersistedPair_DoesNotReplaceCorrectlySpelledWord() + { + var subtitles = new ObservableCollection + { + new() { Text = "Salt and pepper." }, + }; + + var results = MakeManager().CheckSpelling(subtitles); + + Assert.Empty(results); + Assert.Equal("Salt and pepper.", subtitles[0].Text); + } + + [Fact] + public void PersistedPair_StillReplacesMisspelledWord() + { + var subtitles = new ObservableCollection + { + new() { Text = "Ths line and that line." }, + }; + + var results = MakeManager().CheckSpelling(subtitles); + + Assert.Empty(results); + Assert.Equal("This line and that line.", subtitles[0].Text); + } + + public void Dispose() + { + Se.DictionariesFolder = _originalDictionariesFolder; + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + Configuration.Settings.Tools.RememberUseAlwaysList = _originalRememberUseAlwaysList; + try + { + Directory.Delete(_tempDictionariesFolder, true); + } + catch + { + // ignore + } + } +} From 9aca374106b3972c50e3b5fb3db692ee76a58f86 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 16:55:49 +0200 Subject: [PATCH 025/110] Spell check: clear stale suggestion when a word has none DoSpellCheck kept the previous word's SelectedSuggestion when the new word had no suggestions, so Use / Use always applied (and Use always persisted) an unrelated replacement. Use / Use always also ignore an empty suggestion instead of deleting the word. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/SpellCheck/SpellCheckViewModel.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs index 852d02101e0..e53efc91451 100644 --- a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs +++ b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs @@ -902,7 +902,7 @@ private async Task BrowseDictionary() [RelayCommand] private void SuggestionUseOnce() { - if (SelectedSuggestion == null || SelectedParagraph == null) + if (string.IsNullOrWhiteSpace(SelectedSuggestion) || SelectedParagraph == null) { return; } @@ -917,7 +917,7 @@ private void SuggestionUseOnce() [RelayCommand] private void SuggestionUseAlways() { - if (SelectedSuggestion == null || SelectedParagraph == null) + if (string.IsNullOrWhiteSpace(SelectedSuggestion) || SelectedParagraph == null) { return; } @@ -1119,10 +1119,11 @@ private void DoSpellCheck() } AreSuggestionsAvailable = true; - if (suggestions.Count > 0) - { - SelectedSuggestion = suggestions[0]; - } + + // Clear when there are no suggestions - otherwise the previous word's suggestion stayed + // selected and "Use"/"Use always" applied it to this unrelated word (and "Use always" + // persisted that pair to _UseAlways.xml). (#15767) + SelectedSuggestion = suggestions.Count > 0 ? suggestions[0] : string.Empty; var lineIndex = Paragraphs.IndexOf(results[0].Paragraph) + 1; LineText = string.Format(Se.Language.SpellCheck.LineXofY, lineIndex, Paragraphs.Count); From 72aae68bdc20823683858e47fd4e0d0322cf0df6 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 17:00:51 +0200 Subject: [PATCH 026/110] Spell check: clear change-all pairs when switching dictionary Initialize cleared SkipAllList but kept ChangeAllDictionary, so switching dictionary mid-session kept the previous language's use-always pairs active on top of the new language's. Co-Authored-By: Claude Opus 5.5 --- src/libuilogic/SpellCheck/SpellChecker.cs | 4 + ...pellCheckChangeAllDictionarySwitchTests.cs | 87 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/UI/Features/SpellCheck/SpellCheckChangeAllDictionarySwitchTests.cs diff --git a/src/libuilogic/SpellCheck/SpellChecker.cs b/src/libuilogic/SpellCheck/SpellChecker.cs index ddd951d4b14..15b743e0a50 100644 --- a/src/libuilogic/SpellCheck/SpellChecker.cs +++ b/src/libuilogic/SpellCheck/SpellChecker.cs @@ -124,6 +124,10 @@ private static bool TryGetDictionaryCulture(string fileName, out CultureInfo cul public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) { SkipAllList.Clear(); + + // Change-all pairs are per language (_UseAlways.xml, re-applied below) - switching + // dictionary mid-session used to keep the previous language's pairs active. (#15767) + ChangeAllDictionary.Clear(); _twoLetterLanguageCode = twoLetterLanguageCode ?? string.Empty; // The Voikko marker path need not exist (bundled / system dictionary); TryCreate validates. diff --git a/tests/UI/Features/SpellCheck/SpellCheckChangeAllDictionarySwitchTests.cs b/tests/UI/Features/SpellCheck/SpellCheckChangeAllDictionarySwitchTests.cs new file mode 100644 index 00000000000..97986c68f66 --- /dev/null +++ b/tests/UI/Features/SpellCheck/SpellCheckChangeAllDictionarySwitchTests.cs @@ -0,0 +1,87 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.SpellCheck; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.ObjectModel; +using System.IO; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; + +namespace UITests.Features.SpellCheck; + +// Switching dictionary mid-session must drop the previous language's change-all pairs. (#15767) +public class SpellCheckChangeAllDictionarySwitchTests : IDisposable +{ + private readonly string _originalDictionariesFolder; + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly bool _originalRememberUseAlwaysList; + private readonly string _tempDictionariesFolder; + + public SpellCheckChangeAllDictionarySwitchTests() + { + _originalDictionariesFolder = Se.DictionariesFolder; + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + _originalRememberUseAlwaysList = Configuration.Settings.Tools.RememberUseAlwaysList; + _tempDictionariesFolder = Path.Combine(Path.GetTempPath(), "SeChangeAllSwitch_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDictionariesFolder); + + var repoRoot = FindRepoRoot(); + foreach (var name in new[] { "en_US", "en_GB" }) + { + File.Copy(Path.Combine(repoRoot, "Dictionaries", "en_US.dic"), Path.Combine(_tempDictionariesFolder, name + ".dic")); + File.Copy(Path.Combine(repoRoot, "Dictionaries", "en_US.aff"), Path.Combine(_tempDictionariesFolder, name + ".aff")); + } + + File.WriteAllText(Path.Combine(_tempDictionariesFolder, "en_US_UseAlways.xml"), + ""); + + Se.DictionariesFolder = _tempDictionariesFolder; + SpellCheckConfig.DictionariesFolder = () => _tempDictionariesFolder; + Configuration.Settings.Tools.RememberUseAlwaysList = true; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Dictionaries"))) + { + dir = dir.Parent; + } + + return dir!.FullName; + } + + [Fact] + public void SwitchingDictionary_DropsPreviousLanguagePairs() + { + var manager = new SpellCheckManager(); + manager.Initialize(Path.Combine(_tempDictionariesFolder, "en_US.dic"), "en"); + manager.Initialize(Path.Combine(_tempDictionariesFolder, "en_GB.dic"), "en"); + + var subtitles = new ObservableCollection + { + new() { Text = "Ths line is fine." }, + }; + + var results = manager.CheckSpelling(subtitles); + + Assert.Single(results); + Assert.Equal("Ths", results[0].Word.Text); + Assert.Equal("Ths line is fine.", subtitles[0].Text); + } + + public void Dispose() + { + Se.DictionariesFolder = _originalDictionariesFolder; + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + Configuration.Settings.Tools.RememberUseAlwaysList = _originalRememberUseAlwaysList; + try + { + Directory.Delete(_tempDictionariesFolder, true); + } + catch + { + // ignore + } + } +} From 2fa9cc778dba9d06e258626f0514f0b04a2e358e Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 17:03:53 +0200 Subject: [PATCH 027/110] Spell check: clear undo history when switching dictionary Undo of Skip all / Change all / Add to names / Add to dictionary reverses the word-list change in the currently loaded language, which after a switch is the wrong one. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/SpellCheck/SpellCheckViewModel.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs index 852d02101e0..b9b88dadbdf 100644 --- a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs +++ b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs @@ -169,6 +169,10 @@ internal void OnDictionaryChanged() return; } + // Undo of Skip all / Change all / Add to names / Add to dictionary reverses the word-list + // change in the currently loaded language, which after a switch is the wrong one. (#15767) + ClearUndo(); + if (_spellCheckManager.WordSpellChecker != null) { if (Dictionaries.Count > 0) @@ -994,6 +998,14 @@ internal void OnKeyDown(KeyEventArgs e) private bool CanUndo() => _undoList.Count > 0; + private void ClearUndo() + { + _undoList.Clear(); + IsUndoVisible = false; + UndoText = string.Empty; + UndoCommand.NotifyCanExecuteChanged(); + } + private void PushUndo(string description, SpellCheckUndoAction action, string actionWord) { _undoList.Add(new SpellCheckUndoItem From 4452ba21b9480106acbc868c7503222b9b33b85f Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 17:04:25 +0200 Subject: [PATCH 028/110] Readable colored text in light theme: Split/break, Batch convert, Statistics, Translation error Follow-up to #15768: these used dark-theme tones for text (~1.8-3:1 on white). Use darker shades of the same hues on the light theme (>= 4.6:1); backgrounds, fills and chart lines are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Files/Statistics/StatisticsWindow.cs | 35 ++++++++++++++++--- .../SplitBreakLongLinesWindow.cs | 7 +++- .../Translate/TranslationErrorWindow.cs | 3 +- .../BatchConvertStatusColorConverter.cs | 17 +++++++-- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/ui/Features/Files/Statistics/StatisticsWindow.cs b/src/ui/Features/Files/Statistics/StatisticsWindow.cs index d1c0cf9b6bb..3c4531b4899 100644 --- a/src/ui/Features/Files/Statistics/StatisticsWindow.cs +++ b/src/ui/Features/Files/Statistics/StatisticsWindow.cs @@ -23,6 +23,33 @@ public class StatisticsWindow : Window private static readonly Color SeriousColor = Color.Parse("#e0784a"); private static readonly Color CriticalColor = Color.Parse("#d05252"); + // Text/icon variants: on the light theme the tones above are too faint on white (#15768), + // so darker shades of the same hues are used there. Backgrounds and chart lines keep the base tones. + private static Color ForText(Color color) + { + if (UiTheme.IsDarkThemeEnabled()) + { + return color; + } + + if (color == GoodColor) + { + return Color.Parse("#1e7a4a"); + } + + if (color == WarningColor) + { + return Color.Parse("#8a5a00"); + } + + if (color == SeriousColor) + { + return Color.Parse("#a8460f"); + } + + return color == CriticalColor ? Color.Parse("#b42828") : color; + } + private const double HistogramHeight = 150; public StatisticsWindow(StatisticsViewModel vm) @@ -133,7 +160,7 @@ private static StackPanel MakeDashboard(StatisticsViewModel vm) private static Control MakeCopyButton(IRelayCommand command) { - var goodBrush = new SolidColorBrush(GoodColor); + var goodBrush = new SolidColorBrush(ForText(GoodColor)); var feedback = new TextBlock { @@ -364,7 +391,7 @@ private static Control MakeChecks(StatisticsViewModel vm) var icon = new ContentControl { FontSize = UiUtil.ScaledFontSize(12), - Foreground = new SolidColorBrush(color), + Foreground = new SolidColorBrush(ForText(color)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; @@ -390,7 +417,7 @@ private static Control MakeChecks(StatisticsViewModel vm) Text = check.Count.ToString("#,##0", CultureInfo.CurrentCulture), FontSize = UiUtil.ScaledFontSize(12), FontWeight = FontWeight.SemiBold, - Foreground = new SolidColorBrush(color), + Foreground = new SolidColorBrush(ForText(color)), VerticalAlignment = VerticalAlignment.Center, }, 0, 2); @@ -550,7 +577,7 @@ private static Grid MakeThresholdLine(double cps, Color color) Text = cps.ToString("0.##", CultureInfo.CurrentCulture), FontSize = UiUtil.ScaledFontSize(10), FontWeight = FontWeight.SemiBold, - Foreground = new SolidColorBrush(color), + Foreground = new SolidColorBrush(ForText(color)), // Fixed width so the label measures inside the zero-width pin panel. Width = 50, VerticalAlignment = VerticalAlignment.Top, diff --git a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs index 95ea146cbb9..4972f9c4e7b 100644 --- a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs +++ b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs @@ -226,6 +226,11 @@ private static Grid MakeFixesView(SplitBreakLongLinesViewModel vm) var isSplit = item.Name == Se.Language.Tools.SplitBreakLongLines.SplitLongLine; var color = isSplit ? Color.FromRgb(0x5f, 0xc6, 0xd8) : Color.FromRgb(0xb4, 0x8c, 0xe8); + // The pastels are tuned for the dark theme; darker shades of the same + // hues keep the pill text readable on the light theme (#15768). + var textColor = UiTheme.IsDarkThemeEnabled() + ? color + : isSplit ? Color.FromRgb(0x00, 0x6b, 0x7d) : Color.FromRgb(0x6a, 0x3d, 0xb8); return new Border { Background = Brushes.Transparent, @@ -241,7 +246,7 @@ private static Grid MakeFixesView(SplitBreakLongLinesViewModel vm) { Text = item.Name, FontSize = UiUtil.ScaledFontSize(12), - Foreground = new SolidColorBrush(color), + Foreground = new SolidColorBrush(textColor), VerticalAlignment = VerticalAlignment.Center, }, }, diff --git a/src/ui/Features/Translate/TranslationErrorWindow.cs b/src/ui/Features/Translate/TranslationErrorWindow.cs index ab71a74f1b3..62711fb190b 100644 --- a/src/ui/Features/Translate/TranslationErrorWindow.cs +++ b/src/ui/Features/Translate/TranslationErrorWindow.cs @@ -28,7 +28,8 @@ public TranslationErrorWindow(TranslationErrorViewModel vm) { Text = "⚠", FontSize = UiUtil.ScaledFontSize(32), - Foreground = new SolidColorBrush(Color.Parse("#E8A020")), + // Darker amber on the light theme - the dark-theme tone is too faint on white (#15768). + Foreground = new SolidColorBrush(Color.Parse(UiTheme.IsDarkThemeEnabled() ? "#E8A020" : "#9E5500")), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 12, 0), }; diff --git a/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs b/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs index 08f1d0f9863..38e3f0a52f3 100644 --- a/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs +++ b/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs @@ -23,6 +23,12 @@ public class BatchConvertStatusColorConverter : IValueConverter private static readonly SolidColorBrush CancelledForeground = new(Color.Parse("#8494a4")); private static readonly SolidColorBrush CancelledBackground = new(Color.Parse("#8494a4"), 0.16); + // Darker text shades for the light theme - the tones above are only ~2.5:1 against their + // own tinted badge on white (#15768). + private static readonly SolidColorBrush SuccessForegroundLight = new(Color.Parse("#1e7a4a")); + private static readonly SolidColorBrush ErrorForegroundLight = new(Color.Parse("#b42828")); + private static readonly SolidColorBrush CancelledForegroundLight = new(Color.Parse("#55606c")); + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { var status = value as string; @@ -35,12 +41,12 @@ public class BatchConvertStatusColorConverter : IValueConverter if (status == Se.Language.General.Converted) { - return isBackground ? SuccessBackground : SuccessForeground; + return isBackground ? SuccessBackground : PickForeground(SuccessForeground, SuccessForegroundLight); } if (status == Se.Language.General.Cancelled) { - return isBackground ? CancelledBackground : CancelledForeground; + return isBackground ? CancelledBackground : PickForeground(CancelledForeground, CancelledForegroundLight); } // "Error: {0}" - match on the part before the placeholder so translated texts work too. @@ -55,13 +61,18 @@ public class BatchConvertStatusColorConverter : IValueConverter (errorPrefix.Length > 0 && status.StartsWith(errorPrefix, StringComparison.OrdinalIgnoreCase)) || status.StartsWith("BinaryOcr database not found", StringComparison.Ordinal)) { - return isBackground ? ErrorBackground : ErrorForeground; + return isBackground ? ErrorBackground : PickForeground(ErrorForeground, ErrorForegroundLight); } // In-progress statuses (OCR percentages etc.) keep the default text color, no badge. return DefaultBrush(isBackground); } + private static IBrush PickForeground(IBrush dark, IBrush light) + { + return UiTheme.IsDarkThemeEnabled() ? dark : light; + } + // An explicit theme text brush, not UnsetValue: a binding that yields UnsetValue falls // back to the property default (black for TextBlock.Foreground), not the inherited // foreground, so in-progress statuses rendered black on the dark theme. From 87bf379f10c937c5c3cc2b7941297156606fe5a4 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 17:16:40 +0200 Subject: [PATCH 029/110] Spell check: add an editor for the "Use always" list New window to view and edit _UseAlways.xml - the pairs saved by "Change all" / "Use always", which until now could only be fixed by hand-editing the XML (#15767). - Language picker (installed dictionaries plus leftover lists), search, count badge - Add / update / remove pairs, saved on OK - Pairs whose word is spelled correctly are flagged as never used, with a Remove unused button - Opened from a button next to the spell check dictionary picker (the running check reloads the list) and from Options > Word lists - UseAlwaysListFile shared by the spell checker and the editor Co-Authored-By: Claude Opus 5.5 --- docs/features/spell-check.md | 20 + .../SpellCheck/SpellCheckWordLists.cs | 51 +- src/libuilogic/SpellCheck/SpellChecker.cs | 19 + .../SpellCheck/UseAlwaysListFile.cs | 64 +++ src/ui/DependencyInjectionExtensions.cs | 2 + .../Options/WordLists/WordListsViewModel.cs | 23 +- .../Options/WordLists/WordListsWindow.cs | 6 +- .../Features/SpellCheck/ISpellCheckManager.cs | 1 + .../SpellCheck/SpellCheckViewModel.cs | 16 + .../Features/SpellCheck/SpellCheckWindow.cs | 3 +- .../UseAlwaysList/UseAlwaysLanguageItem.cs | 21 + .../UseAlwaysList/UseAlwaysListViewModel.cs | 462 +++++++++++++++++ .../UseAlwaysList/UseAlwaysListWindow.cs | 489 ++++++++++++++++++ .../UseAlwaysList/UseAlwaysPairItem.cs | 21 + .../Language/SpellCheck/LanguageSpellCheck.cs | 28 + .../WordLists/WordListsLanguageScanTests.cs | 2 +- .../Features/SpellCheck/UseAlwaysListTests.cs | 182 +++++++ tests/benchmarks/HotPathRound8Benchmarks.cs | 1 + 18 files changed, 1368 insertions(+), 43 deletions(-) create mode 100644 src/libuilogic/SpellCheck/UseAlwaysListFile.cs create mode 100644 src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysLanguageItem.cs create mode 100644 src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListViewModel.cs create mode 100644 src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListWindow.cs create mode 100644 src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysPairItem.cs create mode 100644 tests/UI/Features/SpellCheck/UseAlwaysListTests.cs diff --git a/docs/features/spell-check.md b/docs/features/spell-check.md index 579c5667752..94d7ecc771a 100644 --- a/docs/features/spell-check.md +++ b/docs/features/spell-check.md @@ -36,6 +36,26 @@ When a misspelled word is found, the spell checker provides a list of suggested - **Use once** — Replace with the selected suggestion for this occurrence - **Use always** — Replace all occurrences with the selected suggestion +## "Use always" list + +**Change all** and **Use always** save the correction per language in the "Use always" list +(`_UseAlways.xml` in the dictionary folder). A saved correction is only applied to words +the dictionary flags as misspelled, so a correctly spelled word is never replaced. + +To view or edit the list, click the list button next to the dictionary dropdown in the spell +check window, or use **Edit "Use always" list...** in **Options → Word lists**: + +- Pick the language, and search to filter the pairs +- Select a pair to edit it, or type a misspelled word and its replacement and click **Add** +- Remove a pair with its trash button (or **Delete**) +- Pairs whose word is spelled correctly (or is a name or user word) are marked with a warning + icon - spell check never flags those words, so the pair is never used. **Remove unused** deletes + them all +- Changes are saved when you click **OK** + +The list is only used and saved when **Spell check: remember "Use always" list** is turned on in +**Settings → Tools**. + ## Dictionaries Spell check requires a dictionary to be installed. If no dictionary is found, you will be prompted to download one. diff --git a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs index 65782cc56c7..62030d02111 100644 --- a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs +++ b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs @@ -178,33 +178,27 @@ private void LoadUseAlwaysList() return; } - var fileName = GetUseAlwaysListFileName(); - var xmlDoc = new XmlDocument(); - if (File.Exists(fileName)) + foreach (var pair in UseAlwaysListFile.Load(GetUseAlwaysListFileName())) { - xmlDoc.Load(fileName); - var xmlNodeList = xmlDoc.DocumentElement?.SelectNodes("Pair"); - if (xmlNodeList != null) + if (!_useAlwaysList.ContainsKey(pair.Key)) { - foreach (XmlNode item in xmlNodeList) - { - if (item.Attributes?["from"] != null && item.Attributes["to"] != null) - { - var to = item.Attributes["to"]?.Value; - var from = item.Attributes["from"]?.Value; - if (to != null && from != null && !_useAlwaysList.ContainsKey(from)) - { - _useAlwaysList.Add(from, to); - } - } - } + _useAlwaysList.Add(pair.Key, pair.Value); } } } + /// + /// Re-reads <lang>_UseAlways.xml, e.g. after it was edited in the "use always" list editor. + /// + public void ReloadUseAlwaysList() + { + _useAlwaysList.Clear(); + LoadUseAlwaysList(); + } + private string GetUseAlwaysListFileName() { - return Path.Combine(_dictionaryFolder, _languageName + "_UseAlways.xml"); + return UseAlwaysListFile.GetFileName(_dictionaryFolder, _languageName); } public void UseAlwaysListRemove(string key) @@ -228,9 +222,6 @@ private void SaveUseAlwaysList(string? newKey = null, string? newValue = null, s return; } - var xmlDoc = new XmlDocument(); - xmlDoc.LoadXml(""); - if (newKey != null && newValue != null && !_useAlwaysList.ContainsKey(newKey.Trim())) { _useAlwaysList.Add(newKey.Trim(), newValue.Trim()); @@ -240,21 +231,7 @@ private void SaveUseAlwaysList(string? newKey = null, string? newValue = null, s _useAlwaysList.Remove(oldKey.Trim()); } - foreach (KeyValuePair kvp in _useAlwaysList) - { - XmlNode node = xmlDoc.CreateElement("Pair"); - var f = xmlDoc.CreateAttribute("from"); - f.Value = kvp.Key; - var t = xmlDoc.CreateAttribute("to"); - t.Value = kvp.Value; - if (node.Attributes != null) - { - node.Attributes.Append(f); - node.Attributes.Append(t); - } - xmlDoc.DocumentElement?.AppendChild(node); - } - xmlDoc.Save(GetUseAlwaysListFileName()); + UseAlwaysListFile.Save(GetUseAlwaysListFileName(), _useAlwaysList); } public void RemoveUserWord(string word) diff --git a/src/libuilogic/SpellCheck/SpellChecker.cs b/src/libuilogic/SpellCheck/SpellChecker.cs index ddd951d4b14..20a25bcd44a 100644 --- a/src/libuilogic/SpellCheck/SpellChecker.cs +++ b/src/libuilogic/SpellCheck/SpellChecker.cs @@ -275,6 +275,25 @@ public void RemoveIgnoreWord(string word) } } + /// + /// Reloads the persisted "use always" pairs, e.g. after the list was edited in its editor window. + /// Session-only pairs (when the list is not remembered) are dropped too. + /// + public void ReloadUseAlwaysList() + { + ChangeAllDictionary.Clear(); + if (WordLists == null) + { + return; + } + + WordLists.ReloadUseAlwaysList(); + foreach (var pair in WordLists.GetUseAlwaysList()) + { + ChangeAllDictionary[pair.Key] = pair.Value; + } + } + public void RemoveChangeAllWord(string fromWord) { if (string.IsNullOrEmpty(fromWord)) diff --git a/src/libuilogic/SpellCheck/UseAlwaysListFile.cs b/src/libuilogic/SpellCheck/UseAlwaysListFile.cs new file mode 100644 index 00000000000..fd915249141 --- /dev/null +++ b/src/libuilogic/SpellCheck/UseAlwaysListFile.cs @@ -0,0 +1,64 @@ +using System.Xml; + +namespace Nikse.SubtitleEdit.UiLogic.SpellCheck; + +/// +/// Reads and writes the spell check "use always" list (<lang>_UseAlways.xml): the pairs stored by +/// "Change all" / "Use always" that replace a misspelled word automatically. Shared by the spell +/// checker and the list editor. +/// +public static class UseAlwaysListFile +{ + public static string GetFileName(string dictionaryFolder, string languageName) + { + return Path.Combine(dictionaryFolder, languageName + "_UseAlways.xml"); + } + + public static Dictionary Load(string fileName) + { + var result = new Dictionary(); + if (!File.Exists(fileName)) + { + return result; + } + + var xmlDoc = new XmlDocument(); + xmlDoc.Load(fileName); + var xmlNodeList = xmlDoc.DocumentElement?.SelectNodes("Pair"); + if (xmlNodeList == null) + { + return result; + } + + foreach (XmlNode item in xmlNodeList) + { + var from = item.Attributes?["from"]?.Value; + var to = item.Attributes?["to"]?.Value; + if (to != null && from != null && !result.ContainsKey(from)) + { + result.Add(from, to); + } + } + + return result; + } + + public static void Save(string fileName, IEnumerable> pairs) + { + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(""); + foreach (var kvp in pairs) + { + XmlNode node = xmlDoc.CreateElement("Pair"); + var f = xmlDoc.CreateAttribute("from"); + f.Value = kvp.Key; + var t = xmlDoc.CreateAttribute("to"); + t.Value = kvp.Value; + node.Attributes?.Append(f); + node.Attributes?.Append(t); + xmlDoc.DocumentElement?.AppendChild(node); + } + + xmlDoc.Save(fileName); + } +} diff --git a/src/ui/DependencyInjectionExtensions.cs b/src/ui/DependencyInjectionExtensions.cs index e0de67330c5..6f005755e1e 100644 --- a/src/ui/DependencyInjectionExtensions.cs +++ b/src/ui/DependencyInjectionExtensions.cs @@ -121,6 +121,7 @@ using Nikse.SubtitleEdit.Features.SpellCheck.FindDoubleLines; using Nikse.SubtitleEdit.Features.SpellCheck.FindDoubleWords; using Nikse.SubtitleEdit.Features.SpellCheck.GetDictionaries; +using Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; using Nikse.SubtitleEdit.Features.Sync.AdjustAllTimes; using Nikse.SubtitleEdit.Features.Sync.ChangeFrameRate; using Nikse.SubtitleEdit.Features.Sync.ChangeSpeed; @@ -447,6 +448,7 @@ public static void AddSubtitleEditServices(this IServiceCollection collection) collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); + collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); diff --git a/src/ui/Features/Options/WordLists/WordListsViewModel.cs b/src/ui/Features/Options/WordLists/WordListsViewModel.cs index 9a75e4e0e2e..2339faf33fb 100644 --- a/src/ui/Features/Options/WordLists/WordListsViewModel.cs +++ b/src/ui/Features/Options/WordLists/WordListsViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -6,6 +6,7 @@ using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.Dictionaries; using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Dictionaries; @@ -43,10 +44,12 @@ public partial class WordListsViewModel : ObservableObject public Window? Window { get; set; } private IFolderHelper _folderHelper; + private readonly IWindowService _windowService; - public WordListsViewModel(IFolderHelper folderHelper) + public WordListsViewModel(IFolderHelper folderHelper, IWindowService windowService) { _folderHelper = folderHelper; + _windowService = windowService; Languages = new ObservableCollection(); Names = new ObservableCollection(); @@ -333,6 +336,22 @@ private void OpenDictionariesFolder() _folderHelper.OpenFolder(Window!, dictionariesPath); } + [RelayCommand] + private async Task EditUseAlwaysList() + { + if (Window == null) + { + return; + } + + var lang = SelectedLanguage; + var dictionaryFileName = lang == null ? null : Path.Combine(Se.DictionariesFolder, lang.CultureSpecificCode + ".dic"); + await _windowService.ShowDialogAsync(Window, vm => + { + vm.Initialize(dictionaryFileName); + }); + } + [RelayCommand] private void Ok() { diff --git a/src/ui/Features/Options/WordLists/WordListsWindow.cs b/src/ui/Features/Options/WordLists/WordListsWindow.cs index b288b9bb403..04e674b77d4 100644 --- a/src/ui/Features/Options/WordLists/WordListsWindow.cs +++ b/src/ui/Features/Options/WordLists/WordListsWindow.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; @@ -35,6 +35,8 @@ public WordListsWindow(WordListsViewModel vm) var panelLanguage = UiUtil.MakeHorizontalPanel(labelLanguage, comboLanguages); var linkLabelOpenDictionariesFolder = UiUtil.MakeLink(Se.Language.General.OpenDictionaryFolder, vm.OpenDictionariesFolderCommand); + var linkLabelEditUseAlwaysList = UiUtil.MakeLink(Se.Language.SpellCheck.EditUseAlwaysList, vm.EditUseAlwaysListCommand); + var panelLinks = UiUtil.MakeHorizontalPanel(linkLabelOpenDictionariesFolder, linkLabelEditUseAlwaysList).WithSpacing(20); var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); var panelButtons = UiUtil.MakeButtonBar(buttonOk); @@ -65,7 +67,7 @@ public WordListsWindow(WordListsViewModel vm) grid.Add(MakeViewNames(vm), 1, 0); grid.Add(MakeViewWords(vm), 1, 1); grid.Add(MakeViewOcrFixes(vm), 1, 2); - grid.Add(linkLabelOpenDictionariesFolder, 2, 0, 1, 3); + grid.Add(panelLinks, 2, 0, 1, 3); grid.Add(panelButtons, 3, 0, 1, 3); Content = grid; diff --git a/src/ui/Features/SpellCheck/ISpellCheckManager.cs b/src/ui/Features/SpellCheck/ISpellCheckManager.cs index 52613f01fea..d78b37370d5 100644 --- a/src/ui/Features/SpellCheck/ISpellCheckManager.cs +++ b/src/ui/Features/SpellCheck/ISpellCheckManager.cs @@ -22,6 +22,7 @@ public interface ISpellCheckManager : ISpellChecker void AdToUserDictionary(string currentWord); void RemoveIgnoreWord(string word); void RemoveChangeAllWord(string fromWord); + void ReloadUseAlwaysList(); void RemoveFromNames(string word); void RemoveFromUserDictionary(string word); List GetDictionaryLanguages(string dictionaryFolder); diff --git a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs index e53efc91451..a55efcee482 100644 --- a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs +++ b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs @@ -17,6 +17,7 @@ using Nikse.SubtitleEdit.Features.Shared; using Nikse.SubtitleEdit.Features.SpellCheck.EditWholeText; using Nikse.SubtitleEdit.Features.SpellCheck.GetDictionaries; +using Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; @@ -888,6 +889,21 @@ private async Task GoogleIt() await Window!.Launcher.LaunchUriAsync(new Uri("https://www.google.com/search?q=" + HttpUtility.UrlEncode(CurrentWord))); } + [RelayCommand] + private async Task EditUseAlwaysList() + { + var result = await _windowService.ShowDialogAsync(Window!, vm => + { + vm.Initialize(SelectedDictionary?.DictionaryFileName); + }); + + if (result.OkPressed) + { + // Pick up the edited pairs for the rest of this run. + _spellCheckManager.ReloadUseAlwaysList(); + } + } + [RelayCommand] private async Task BrowseDictionary() { diff --git a/src/ui/Features/SpellCheck/SpellCheckWindow.cs b/src/ui/Features/SpellCheck/SpellCheckWindow.cs index b54143eafbe..66c4f0a951a 100644 --- a/src/ui/Features/SpellCheck/SpellCheckWindow.cs +++ b/src/ui/Features/SpellCheck/SpellCheckWindow.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Data; @@ -323,6 +323,7 @@ private static Grid MakeSuggestions(SpellCheckViewModel vm) { comboBoxDictionary, buttonDictionaryBrowse, + UiUtil.MakeButton(vm.EditUseAlwaysListCommand, IconNames.FormatListChecks, Se.Language.SpellCheck.EditUseAlwaysList), } }; diff --git a/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysLanguageItem.cs b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysLanguageItem.cs new file mode 100644 index 00000000000..c1597af1f8d --- /dev/null +++ b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysLanguageItem.cs @@ -0,0 +1,21 @@ +namespace Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; + +public class UseAlwaysLanguageItem +{ + public string Name { get; } + + /// Five-letter language name used in the file name, e.g. "en_US" for en_US_UseAlways.xml. + public string Code { get; } + + /// Hunspell dictionary for this language, if installed - used to spot pairs that can never apply. + public string? DictionaryFileName { get; } + + public UseAlwaysLanguageItem(string name, string code, string? dictionaryFileName) + { + Name = name; + Code = code; + DictionaryFileName = dictionaryFileName; + } + + public override string ToString() => Name; +} diff --git a/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListViewModel.cs b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListViewModel.cs new file mode 100644 index 00000000000..516151d9704 --- /dev/null +++ b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListViewModel.cs @@ -0,0 +1,462 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; + +/// +/// Editor for the spell check "use always" list (<lang>_UseAlways.xml) - the pairs stored by +/// "Change all" / "Use always". Until now the only way to get rid of a bad pair (like "and" -> "&") +/// was to hand-edit the XML file. (#15767) +/// +public partial class UseAlwaysListViewModel : ObservableObject +{ + [ObservableProperty] private ObservableCollection _languages; + [ObservableProperty] private UseAlwaysLanguageItem? _selectedLanguage; + [ObservableProperty] private ObservableCollection _pairs; + [ObservableProperty] private UseAlwaysPairItem? _selectedPair; + [ObservableProperty] private string _searchText; + [ObservableProperty] private string _editFrom; + [ObservableProperty] private string _editTo; + [ObservableProperty] private bool _isEditingExisting; + [ObservableProperty] private string _countText; + [ObservableProperty] private string _unusedText; + [ObservableProperty] private bool _hasUnused; + [ObservableProperty] private bool _isEmpty; + [ObservableProperty] private bool _isNoMatch; + [ObservableProperty] private bool _isRememberOff; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + private readonly IFolderHelper _folderHelper; + private readonly Dictionary> _pairsByLanguage = new(); + private readonly HashSet _dirtyLanguages = new(); + private readonly Dictionary> _spellCheckers = new(); + private List _currentPairs = new(); + + public UseAlwaysListViewModel(IFolderHelper folderHelper) + { + _folderHelper = folderHelper; + Languages = new ObservableCollection(); + Pairs = new ObservableCollection(); + SearchText = string.Empty; + EditFrom = string.Empty; + EditTo = string.Empty; + CountText = string.Empty; + UnusedText = string.Empty; + IsRememberOff = !Se.Settings.Tools.SpellCheckRememberUseAlwaysList; + } + + /// + /// Loads the languages and selects the one matching + /// (the dictionary in use in the spell check window), else the last used / English. + /// + public void Initialize(string? dictionaryFileName) + { + LoadLanguages(); + + var code = dictionaryFileName != null ? GetLanguageCode(dictionaryFileName) : null; + SelectedLanguage = + Languages.FirstOrDefault(l => code != null && l.Code == code) ?? + Languages.FirstOrDefault(l => l.Code == Se.Settings.Options.LastLanguage) ?? + Languages.FirstOrDefault(l => l.Code.StartsWith("en", StringComparison.Ordinal)) ?? + Languages.FirstOrDefault(); + } + + private static string GetLanguageCode(string dictionaryFileName) + { + // Same five-letter name SpellChecker.Initialize uses for _UseAlways.xml. + var name = Path.GetFileNameWithoutExtension(dictionaryFileName); + return SpellCheckDictionaryDisplay.GetFiveLetterLanguageName(name) ?? "en_US"; + } + + private void LoadLanguages() + { + Languages.Clear(); + var folder = Se.DictionariesFolder; + var items = new List(); + + foreach (var dictionary in new SpellChecker().GetDictionaryLanguages(folder)) + { + var code = GetLanguageCode(dictionary.DictionaryFileName); + if (items.All(i => i.Code != code)) + { + items.Add(new UseAlwaysLanguageItem(dictionary.Name, code, dictionary.DictionaryFileName)); + } + } + + // Lists left behind without an installed dictionary (e.g. from Subtitle Edit 4) are + // still applied once that dictionary comes back, so they must be visible here too. + if (Directory.Exists(folder)) + { + foreach (var file in Directory.GetFiles(folder, "*_UseAlways.xml")) + { + var name = Path.GetFileName(file); + var code = name.Substring(0, name.Length - "_UseAlways.xml".Length); + if (code.Length > 0 && items.All(i => i.Code != code)) + { + items.Add(new UseAlwaysLanguageItem("[" + code + "]", code, null)); + } + } + } + + foreach (var item in items.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)) + { + Languages.Add(item); + } + } + + partial void OnSelectedLanguageChanged(UseAlwaysLanguageItem? value) + { + _currentPairs = value == null ? new List() : GetPairs(value); + SearchText = string.Empty; + ClearEditor(); + RefreshPairs(); + + if (value != null) + { + _ = MarkUnusedPairsAsync(value, _currentPairs.ToList()); + } + } + + partial void OnSearchTextChanged(string value) => RefreshPairs(); + + partial void OnSelectedPairChanged(UseAlwaysPairItem? value) + { + if (value != null) + { + EditFrom = value.From; + EditTo = value.To; + } + } + + partial void OnEditFromChanged(string value) + { + var from = value.Trim(); + IsEditingExisting = from.Length > 0 && _currentPairs.Any(p => p.From == from); + } + + private List GetPairs(UseAlwaysLanguageItem language) + { + if (_pairsByLanguage.TryGetValue(language.Code, out var cached)) + { + return cached; + } + + var list = new List(); + try + { + var fileName = UseAlwaysListFile.GetFileName(Se.DictionariesFolder, language.Code); + list.AddRange(UseAlwaysListFile.Load(fileName).Select(p => new UseAlwaysPairItem(p.Key, p.Value))); + } + catch (Exception exception) + { + Se.LogError(exception, "Unable to load spell check \"use always\" list for " + language.Code); + } + + list.Sort(ComparePairs); + _pairsByLanguage[language.Code] = list; + return list; + } + + private static int ComparePairs(UseAlwaysPairItem a, UseAlwaysPairItem b) + { + var result = string.Compare(a.From, b.From, StringComparison.OrdinalIgnoreCase); + return result != 0 ? result : string.CompareOrdinal(a.From, b.From); + } + + private void RefreshPairs() + { + var search = SearchText.Trim(); + var selected = SelectedPair; + + Pairs.Clear(); + foreach (var pair in _currentPairs) + { + if (search.Length == 0 || + pair.From.Contains(search, StringComparison.CurrentCultureIgnoreCase) || + pair.To.Contains(search, StringComparison.CurrentCultureIgnoreCase)) + { + Pairs.Add(pair); + } + } + + if (selected != null && Pairs.Contains(selected)) + { + SelectedPair = selected; + } + + UpdateCounts(); + } + + private void UpdateCounts() + { + IsEmpty = _currentPairs.Count == 0; + IsNoMatch = !IsEmpty && Pairs.Count == 0; + CountText = string.Format(Se.Language.SpellCheck.XPairs, _currentPairs.Count); + + var unused = _currentPairs.Count(p => p.IsUnused); + HasUnused = unused > 0; + UnusedText = string.Format(Se.Language.SpellCheck.XUnusedPairs, unused); + } + + private Task GetSpellChecker(UseAlwaysLanguageItem language) + { + if (string.IsNullOrEmpty(language.DictionaryFileName)) + { + return Task.FromResult(null); + } + + if (!_spellCheckers.TryGetValue(language.Code, out var task)) + { + var dictionaryFileName = language.DictionaryFileName; + var twoLetter = language.Code.Length >= 2 ? language.Code.Substring(0, 2) : language.Code; + task = Task.Run(() => + { + try + { + var checker = new SpellChecker(); + return checker.Initialize(dictionaryFileName, twoLetter) ? checker : null; + } + catch (Exception exception) + { + Se.LogError(exception, "Unable to load spell check dictionary " + dictionaryFileName); + return null; + } + }); + _spellCheckers[language.Code] = task; + } + + return task; + } + + private async Task MarkUnusedPairsAsync(UseAlwaysLanguageItem language, List pairs) + { + var checker = await GetSpellChecker(language); + if (checker == null) + { + return; + } + + var results = await Task.Run(() => pairs.Select(p => checker.IsWordCorrect(p.From)).ToList()); + Dispatcher.UIThread.Post(() => + { + for (var i = 0; i < pairs.Count; i++) + { + pairs[i].IsUnused = results[i]; + } + + if (SelectedLanguage == language) + { + UpdateCounts(); + } + }); + } + + private void ClearEditor() + { + SelectedPair = null; + EditFrom = string.Empty; + EditTo = string.Empty; + } + + private void MarkDirty() + { + if (SelectedLanguage != null) + { + _dirtyLanguages.Add(SelectedLanguage.Code); + } + } + + [RelayCommand] + private async Task AddOrUpdate() + { + var language = SelectedLanguage; + var from = EditFrom.Trim(); + var to = EditTo.Trim(); + if (language == null || from.Length == 0 || to.Length == 0) + { + return; + } + + if (from == to) + { + if (Window != null) + { + await MessageBox.Show(Window, Se.Language.General.Error, Se.Language.SpellCheck.UseAlwaysSameWord, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + return; + } + + var pair = _currentPairs.FirstOrDefault(p => p.From == from); + if (pair == null) + { + pair = new UseAlwaysPairItem(from, to); + var index = 0; + while (index < _currentPairs.Count && ComparePairs(_currentPairs[index], pair) < 0) + { + index++; + } + + _currentPairs.Insert(index, pair); + } + else + { + pair.To = to; + } + + MarkDirty(); + SearchText = string.Empty; + RefreshPairs(); + SelectedPair = pair; + IsEditingExisting = true; + _ = MarkUnusedPairsAsync(language, new List { pair }); + } + + [RelayCommand] + private void Remove(UseAlwaysPairItem? item) + { + var pair = item ?? SelectedPair; + if (pair == null) + { + return; + } + + var index = Pairs.IndexOf(pair); + _currentPairs.Remove(pair); + MarkDirty(); + RefreshPairs(); + + if (Pairs.Count == 0) + { + ClearEditor(); + } + else + { + SelectedPair = Pairs[Math.Clamp(index, 0, Pairs.Count - 1)]; + } + } + + [RelayCommand] + private void RemoveUnused() + { + if (_currentPairs.RemoveAll(p => p.IsUnused) > 0) + { + MarkDirty(); + ClearEditor(); + RefreshPairs(); + } + } + + [RelayCommand] + private void New() + { + ClearEditor(); + } + + [RelayCommand] + private void OpenDictionariesFolder() + { + if (Window == null) + { + return; + } + + var folder = Se.DictionariesFolder; + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + _ = _folderHelper.OpenFolder(Window, folder); + } + + [RelayCommand] + private async Task Ok() + { + foreach (var code in _dirtyLanguages) + { + try + { + var fileName = UseAlwaysListFile.GetFileName(Se.DictionariesFolder, code); + UseAlwaysListFile.Save(fileName, _pairsByLanguage[code].Select(p => new KeyValuePair(p.From, p.To))); + } + catch (Exception exception) + { + Se.LogError(exception, "Unable to save spell check \"use always\" list for " + code); + if (Window != null) + { + await MessageBox.Show(Window, Se.Language.General.Error, exception.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + return; + } + } + + if (SelectedLanguage != null) + { + Se.Settings.Options.LastLanguage = SelectedLanguage.Code; + } + + OkPressed = true; + Close(); + } + + [RelayCommand] + private void Cancel() + { + Close(); + } + + private void Close() + { + Dispatcher.UIThread.Post(() => Window?.Close()); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Close(); + } + else if (UiUtil.IsHelp(e)) + { + e.Handled = true; + UiUtil.ShowHelp("features/spell-check"); + } + } + + internal void EditTextBoxKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + e.Handled = true; + _ = AddOrUpdate(); + } + } + + internal void GridKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Delete) + { + e.Handled = true; + Remove(null); + } + } +} diff --git a/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListWindow.cs b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListWindow.cs new file mode 100644 index 00000000000..0e2fdffda6a --- /dev/null +++ b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysListWindow.cs @@ -0,0 +1,489 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Optris.Icons.Avalonia; + +namespace Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; + +public class UseAlwaysListWindow : Window +{ + private const string AccentColor = "#7fa8f0"; // same blue as the spell check list in Word lists + private const string WarningColor = "#e0a030"; + + private readonly UseAlwaysListViewModel _vm; + + public UseAlwaysListWindow(UseAlwaysListViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = Se.Language.SpellCheck.UseAlwaysListTitle; + CanResize = true; + Width = 760; + Height = 640; + MinWidth = 560; + MinHeight = 420; + + _vm = vm; + vm.Window = this; + DataContext = vm; + + var comboLanguages = UiUtil.MakeComboBox(vm.Languages, vm, nameof(vm.SelectedLanguage)); + comboLanguages.MinWidth = 220; + comboLanguages.MaxWidth = 320; + Avalonia.Automation.AutomationProperties.SetName(comboLanguages, Se.Language.General.Language); + + var textBoxSearch = new TextBox + { + PlaceholderText = Se.Language.General.Search, + [!TextBox.TextProperty] = new Binding(nameof(vm.SearchText)) { Mode = BindingMode.TwoWay }, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + [Avalonia.Automation.AutomationProperties.NameProperty] = Se.Language.General.Search, + }.WithSearchAndClearIcons(); + + var toolbar = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + ColumnSpacing = 10, + }; + toolbar.Add(comboLanguages, 0, 0); + toolbar.Add(textBoxSearch, 0, 1); + toolbar.Add(MakeBadges(vm), 0, 2); + + var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); + var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); + var footer = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + }; + var linkOpenFolder = UiUtil.MakeLink(Se.Language.General.OpenDictionaryFolder, vm.OpenDictionariesFolderCommand); + linkOpenFolder.VerticalAlignment = VerticalAlignment.Center; + footer.Add(linkOpenFolder, 0, 0); + footer.Add(UiUtil.MakeButtonBar(buttonOk, buttonCancel), 0, 1); + + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, // header + new RowDefinition { Height = GridLength.Auto }, // "remember" off warning + new RowDefinition { Height = GridLength.Auto }, // language + search + badges + new RowDefinition { Height = GridLength.Star }, // pairs + new RowDefinition { Height = GridLength.Auto }, // add / edit card + new RowDefinition { Height = GridLength.Auto }, // footer + }, + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + }, + Margin = UiUtil.MakeWindowMargin(), + RowSpacing = 12, + }; + + grid.Add(MakeHeader(), 0, 0); + grid.Add(MakeRememberOffBanner(vm), 1, 0); + grid.Add(toolbar, 2, 0); + grid.Add(MakePairsView(vm), 3, 0); + grid.Add(MakeEditCard(vm), 4, 0); + grid.Add(footer, 5, 0); + + Content = grid; + + UiUtil.FocusOnFirstActivation(this, textBoxSearch); // an input, not a button - a focused button clicks on bare Space + Closing += delegate { UiUtil.SaveWindowPosition(this); }; + Loaded += delegate { UiUtil.RestoreWindowPosition(this); }; + } + + private static Control MakeGlyph(string iconName, string colorHex, double size, double iconFontSize) + { + var icon = new ContentControl + { + FontSize = UiUtil.ScaledFontSize(iconFontSize), + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + Attached.SetIcon(icon, iconName); + icon.Classes.Add(UiTheme.IconOnAccentClassName); + + return new Border + { + Width = size, + Height = size, + CornerRadius = new CornerRadius(size / 4), + Background = new SolidColorBrush(Color.Parse(colorHex)), + VerticalAlignment = VerticalAlignment.Top, + Child = icon, + }; + } + + private static Control MakeIcon(string iconName, double fontSize, IBrush? foreground = null, double opacity = 1.0) + { + var icon = new ContentControl + { + FontSize = UiUtil.ScaledFontSize(fontSize), + Opacity = opacity, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + if (foreground != null) + { + icon.Foreground = foreground; + } + + Attached.SetIcon(icon, iconName); + return icon; + } + + private static Control MakeHeader() + { + var title = new TextBlock + { + Text = Se.Language.SpellCheck.UseAlwaysListHeader, + FontSize = UiUtil.ScaledFontSize(17), + FontWeight = FontWeight.SemiBold, + }; + + var description = new TextBlock + { + Text = Se.Language.SpellCheck.UseAlwaysListDescription, + TextWrapping = TextWrapping.Wrap, + Opacity = 0.75, + }; + + var texts = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 3, + VerticalAlignment = VerticalAlignment.Center, + Children = { title, description }, + }; + + var header = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star }, + }, + ColumnSpacing = 14, + }; + header.Add(MakeGlyph(IconNames.Spellcheck, AccentColor, 40, 22), 0, 0); + header.Add(texts, 0, 1); + return header; + } + + private static Control MakeRememberOffBanner(UseAlwaysListViewModel vm) + { + var warning = new SolidColorBrush(Color.Parse(WarningColor)); + var banner = new Border + { + CornerRadius = new CornerRadius(6), + Background = new SolidColorBrush(Color.Parse(WarningColor), 0.12), + BorderBrush = new SolidColorBrush(Color.Parse(WarningColor), 0.45), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 7), + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = + { + MakeIcon(IconNames.Alert, 15, warning), + new TextBlock + { + Text = Se.Language.SpellCheck.UseAlwaysRememberOff, + TextWrapping = TextWrapping.Wrap, + VerticalAlignment = VerticalAlignment.Center, + }, + }, + }, + }; + banner.Bind(Visual.IsVisibleProperty, new Binding(nameof(vm.IsRememberOff))); + return banner; + } + + private static Border MakeBadge(string textPropertyPath, string colorHex, string? iconName) + { + var color = Color.Parse(colorHex); + var foreground = new SolidColorBrush(color); + var text = new TextBlock + { + FontSize = UiUtil.ScaledFontSize(11), + FontWeight = FontWeight.SemiBold, + Foreground = foreground, + VerticalAlignment = VerticalAlignment.Center, + [!TextBlock.TextProperty] = new Binding(textPropertyPath), + }; + + var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; + if (iconName != null) + { + panel.Children.Add(MakeIcon(iconName, 11, foreground)); + } + + panel.Children.Add(text); + + return new Border + { + Background = new SolidColorBrush(color, 0.16), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(9, 3, 9, 3), + VerticalAlignment = VerticalAlignment.Center, + Child = panel, + }; + } + + private static Control MakeBadges(UseAlwaysListViewModel vm) + { + var unusedBadge = MakeBadge(nameof(vm.UnusedText), WarningColor, IconNames.Alert); + unusedBadge.Bind(Visual.IsVisibleProperty, new Binding(nameof(vm.HasUnused))); + ToolTip.SetTip(unusedBadge, Se.Language.SpellCheck.UseAlwaysUnusedHint); + + var buttonRemoveUnused = new Button + { + Content = Se.Language.SpellCheck.RemoveUnused, + Command = vm.RemoveUnusedCommand, + VerticalAlignment = VerticalAlignment.Center, + }.WithIconLeft(IconNames.Trash).WithBindIsVisible(nameof(vm.HasUnused)); + ToolTip.SetTip(buttonRemoveUnused, Se.Language.SpellCheck.UseAlwaysUnusedHint); + + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + MakeBadge(nameof(vm.CountText), AccentColor, null), + unusedBadge, + buttonRemoveUnused, + }, + }; + } + + private static Control MakePairsView(UseAlwaysListViewModel vm) + { + var table = TableViewExtras.MakeTableView(alwaysSelected: false, multiSelect: false); + table.Height = double.NaN; + table.Margin = new Thickness(2); + table.ItemsSource = vm.Pairs; + Avalonia.Automation.AutomationProperties.SetName(table, Se.Language.SpellCheck.UseAlwaysListHeader); + + var warning = new SolidColorBrush(Color.Parse(WarningColor)); + + table.Columns.Add(new SeTableViewColumn + { + Header = string.Empty, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(34), + CellTemplate = new FuncDataTemplate((_, _) => + { + var icon = MakeIcon(IconNames.Alert, 15, warning); + icon.Bind(Visual.IsVisibleProperty, new Binding(nameof(UseAlwaysPairItem.IsUnused))); + ToolTip.SetTip(icon, Se.Language.SpellCheck.UseAlwaysUnusedHint); + Avalonia.Automation.AutomationProperties.SetName(icon, Se.Language.SpellCheck.UseAlwaysUnusedHint); + return icon; + }), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.SpellCheck.UseAlwaysWord, + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(1, GridUnitType.Star), + CellTemplate = new FuncDataTemplate((_, _) => new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + [!TextBlock.TextProperty] = new Binding(nameof(UseAlwaysPairItem.From)), + [!TextBlock.OpacityProperty] = new Binding(nameof(UseAlwaysPairItem.IsUnused)) + { + Converter = new Avalonia.Data.Converters.FuncValueConverter(unused => unused ? 0.6 : 1.0), + }, + }), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = string.Empty, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(34), + CellTemplate = new FuncDataTemplate((_, _) => MakeIcon(IconNames.ArrowRightThick, 14, null, 0.45)), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.ReplaceWith, + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(1, GridUnitType.Star), + CellTemplate = new FuncDataTemplate((_, _) => new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + FontWeight = FontWeight.SemiBold, + [!TextBlock.TextProperty] = new Binding(nameof(UseAlwaysPairItem.To)), + }), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = string.Empty, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(46), + CellTemplate = new FuncDataTemplate((item, _) => + { + var button = UiUtil.MakeButton(vm.RemoveCommand, IconNames.Trash, Se.Language.General.Remove); + button.CommandParameter = item; + button.Margin = new Thickness(2); + button.Padding = new Thickness(6, 2); + button.Opacity = 0.75; + button.HorizontalAlignment = HorizontalAlignment.Center; + return button; + }), + }); + + table.Bind(TableView.SelectedItemProperty, new Binding(nameof(vm.SelectedPair)) { Source = vm, Mode = BindingMode.TwoWay }); + table.KeyDown += (_, e) => vm.GridKeyDown(e); + + var emptyState = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(24), + Children = + { + MakeIcon(IconNames.FormatListChecks, 40, null, 0.35), + new TextBlock + { + Text = Se.Language.SpellCheck.UseAlwaysEmpty, + FontSize = UiUtil.ScaledFontSize(15), + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }, + new TextBlock + { + Text = Se.Language.SpellCheck.UseAlwaysEmptyHint, + Opacity = 0.65, + HorizontalAlignment = HorizontalAlignment.Center, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }, + }, + }; + emptyState.Bind(Visual.IsVisibleProperty, new Binding(nameof(vm.IsEmpty))); + + var noMatch = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + MakeIcon(IconNames.Find, 18, null, 0.45), + new TextBlock { Text = Se.Language.SpellCheck.UseAlwaysNoMatch, Opacity = 0.65, VerticalAlignment = VerticalAlignment.Center }, + }, + }; + noMatch.Bind(Visual.IsVisibleProperty, new Binding(nameof(vm.IsNoMatch))); + + var panel = new Panel { Children = { table, emptyState, noMatch } }; + return UiUtil.MakeBorderForControlNoPadding(panel); + } + + private static Control MakeEditCard(UseAlwaysListViewModel vm) + { + var textBoxFrom = new TextBox + { + PlaceholderText = Se.Language.SpellCheck.UseAlwaysWord, + [!TextBox.TextProperty] = new Binding(nameof(vm.EditFrom)) { Mode = BindingMode.TwoWay }, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + [Avalonia.Automation.AutomationProperties.NameProperty] = Se.Language.SpellCheck.UseAlwaysWord, + }; + textBoxFrom.KeyDown += (_, e) => vm.EditTextBoxKeyDown(e); + + var textBoxTo = new TextBox + { + PlaceholderText = Se.Language.General.ReplaceWith, + [!TextBox.TextProperty] = new Binding(nameof(vm.EditTo)) { Mode = BindingMode.TwoWay }, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + [Avalonia.Automation.AutomationProperties.NameProperty] = Se.Language.General.ReplaceWith, + }; + textBoxTo.KeyDown += (_, e) => vm.EditTextBoxKeyDown(e); + + // Same command, but a pencil instead of a plus when the word already has a pair. + var buttonAdd = new Button + { + Content = Se.Language.General.Add, + Command = vm.AddOrUpdateCommand, + VerticalAlignment = VerticalAlignment.Center, + }.WithIconLeft(IconNames.Plus); + buttonAdd.Bind(Visual.IsVisibleProperty, new Binding(nameof(vm.IsEditingExisting)) { Converter = Avalonia.Data.Converters.BoolConverters.Not }); + + var buttonUpdate = new Button + { + Content = Se.Language.General.Update, + Command = vm.AddOrUpdateCommand, + VerticalAlignment = VerticalAlignment.Center, + }.WithIconLeft(IconNames.Pencil).WithBindIsVisible(nameof(vm.IsEditingExisting)); + + var panelAddOrUpdate = new Panel { Children = { buttonAdd, buttonUpdate } }; + + var buttonNew = UiUtil.MakeButton(vm.NewCommand, IconNames.NewText, Se.Language.General.New); + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + ColumnSpacing = 8, + }; + grid.Add(textBoxFrom, 0, 0); + grid.Add(MakeIcon(IconNames.ArrowRightThick, 16, null, 0.5), 0, 1); + grid.Add(textBoxTo, 0, 2); + grid.Add(panelAddOrUpdate, 0, 3); + grid.Add(buttonNew, 0, 4); + + return new Border + { + CornerRadius = new CornerRadius(8), + Background = new SolidColorBrush(Color.FromArgb(18, 128, 128, 128)), + BorderBrush = new SolidColorBrush(Color.FromArgb(48, 128, 128, 128)), + BorderThickness = new Thickness(1), + Padding = new Thickness(12, 10), + Child = grid, + }; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + _vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysPairItem.cs b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysPairItem.cs new file mode 100644 index 00000000000..7aa02afebcf --- /dev/null +++ b/src/ui/Features/SpellCheck/UseAlwaysList/UseAlwaysPairItem.cs @@ -0,0 +1,21 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; + +public partial class UseAlwaysPairItem : ObservableObject +{ + [ObservableProperty] private string _from; + [ObservableProperty] private string _to; + + // The "from" word is spelled correctly (or is a name / user word), so spell check never + // flags it and the pair can never be applied. (#15767) + [ObservableProperty] private bool _isUnused; + + public UseAlwaysPairItem(string from, string to) + { + _from = from; + _to = to; + } + + public override string ToString() => $"{From} -> {To}"; +} diff --git a/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs b/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs index f600910645f..1d4a2561b94 100644 --- a/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs +++ b/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs @@ -45,6 +45,20 @@ public class LanguageSpellCheck public string NamesX { get; set; } public string AddedToDictionaryX { get; set; } public string DoNotShowThisAgain { get; set; } + public string EditUseAlwaysList { get; set; } + public string UseAlwaysListTitle { get; set; } + public string UseAlwaysListHeader { get; set; } + public string UseAlwaysListDescription { get; set; } + public string UseAlwaysWord { get; set; } + public string XPairs { get; set; } + public string XUnusedPairs { get; set; } + public string UseAlwaysUnusedHint { get; set; } + public string RemoveUnused { get; set; } + public string UseAlwaysEmpty { get; set; } + public string UseAlwaysEmptyHint { get; set; } + public string UseAlwaysNoMatch { get; set; } + public string UseAlwaysSameWord { get; set; } + public string UseAlwaysRememberOff { get; set; } public LanguageSpellCheck() { @@ -89,5 +103,19 @@ public LanguageSpellCheck() NamesX = "Names: {0}"; AddedToDictionaryX = "Added to dictionary: {0}"; DoNotShowThisAgain = "Do not show this message again"; + EditUseAlwaysList = "Edit \"Use always\" list..."; + UseAlwaysListTitle = "Spell check - \"Use always\" list"; + UseAlwaysListHeader = "\"Use always\" list"; + UseAlwaysListDescription = "Words spell check replaces automatically, saved by \"Change all\" and \"Use always\". A pair is only used when the word is misspelled."; + UseAlwaysWord = "Misspelled word"; + XPairs = "{0:#,##0} pairs"; + XUnusedPairs = "{0:#,##0} never used"; + UseAlwaysUnusedHint = "Never used - this word is spelled correctly (or is a name or user word), so spell check never flags it"; + RemoveUnused = "Remove unused"; + UseAlwaysEmpty = "No \"Use always\" pairs for this language yet"; + UseAlwaysEmptyHint = "Pairs are added when you click \"Change all\" or \"Use always\" in spell check."; + UseAlwaysNoMatch = "No pairs match the search"; + UseAlwaysSameWord = "The replacement must be different from the word."; + UseAlwaysRememberOff = "\"Remember 'Use always' list\" is turned off in Settings, so spell check does not use or save this list."; } } \ No newline at end of file diff --git a/tests/UI/Features/Options/WordLists/WordListsLanguageScanTests.cs b/tests/UI/Features/Options/WordLists/WordListsLanguageScanTests.cs index 09c1e345c59..5a4fcbab2b6 100644 --- a/tests/UI/Features/Options/WordLists/WordListsLanguageScanTests.cs +++ b/tests/UI/Features/Options/WordLists/WordListsLanguageScanTests.cs @@ -50,7 +50,7 @@ private void Touch(string fileName) File.WriteAllText(Path.Combine(_folder, fileName), ""); } - private static WordListsViewModel MakeViewModel() => new(new StubFolderHelper()); + private static WordListsViewModel MakeViewModel() => new(new StubFolderHelper(), new StubWindowService()); [AvaloniaFact] public void OcrFixList_PutsTheLanguageInTheDropdown() diff --git a/tests/UI/Features/SpellCheck/UseAlwaysListTests.cs b/tests/UI/Features/SpellCheck/UseAlwaysListTests.cs new file mode 100644 index 00000000000..4202d45853e --- /dev/null +++ b/tests/UI/Features/SpellCheck/UseAlwaysListTests.cs @@ -0,0 +1,182 @@ +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.SpellCheck; +using Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; + +namespace UITests.Features.SpellCheck; + +// The "use always" list editor (#15767): view, add, update and remove the pairs in +// _UseAlways.xml, and flag pairs that can never apply because the word is spelled correctly. +public class UseAlwaysListTests : IDisposable +{ + private readonly string _originalDictionariesFolder; + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly bool _originalRememberUseAlwaysList; + private readonly string _folder; + + public UseAlwaysListTests() + { + _originalDictionariesFolder = Se.DictionariesFolder; + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + _originalRememberUseAlwaysList = Configuration.Settings.Tools.RememberUseAlwaysList; + _folder = Path.Combine(Path.GetTempPath(), "SeUseAlwaysList_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_folder); + + var repoRoot = new DirectoryInfo(AppContext.BaseDirectory); + while (!Directory.Exists(Path.Combine(repoRoot.FullName, "Dictionaries"))) + { + repoRoot = repoRoot.Parent!; + } + + File.Copy(Path.Combine(repoRoot.FullName, "Dictionaries", "en_US.dic"), Path.Combine(_folder, "en_US.dic")); + File.Copy(Path.Combine(repoRoot.FullName, "Dictionaries", "en_US.aff"), Path.Combine(_folder, "en_US.aff")); + + Se.DictionariesFolder = _folder; + SpellCheckConfig.DictionariesFolder = () => _folder; + Configuration.Settings.Tools.RememberUseAlwaysList = true; + } + + private string FileName => UseAlwaysListFile.GetFileName(_folder, "en_US"); + + private void WritePairs(string xmlPairs) => File.WriteAllText(FileName, "" + xmlPairs + ""); + + private UseAlwaysListViewModel MakeViewModel() + { + var vm = new UseAlwaysListViewModel(null!); + vm.Initialize(Path.Combine(_folder, "en_US.dic")); + return vm; + } + + private static void WaitFor(Func condition) + { + var until = DateTime.UtcNow.AddSeconds(15); + while (!condition() && DateTime.UtcNow < until) + { + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(20); + } + } + + [Fact] + public void File_RoundTrips() + { + UseAlwaysListFile.Save(FileName, new[] + { + new System.Collections.Generic.KeyValuePair("and", "&"), + new System.Collections.Generic.KeyValuePair("teh", "the"), + }); + + var pairs = UseAlwaysListFile.Load(FileName); + + Assert.Equal(2, pairs.Count); + Assert.Equal("&", pairs["and"]); + Assert.Equal("the", pairs["teh"]); + } + + [AvaloniaFact] + public void Editor_SelectsLanguageAndFlagsCorrectlySpelledWords() + { + WritePairs(""); + + var vm = MakeViewModel(); + WaitFor(() => vm.HasUnused); + + Assert.Equal("en_US", vm.SelectedLanguage?.Code); + Assert.Equal(new[] { "and", "teh" }, vm.Pairs.Select(p => p.From)); + Assert.True(vm.Pairs.Single(p => p.From == "and").IsUnused); + Assert.False(vm.Pairs.Single(p => p.From == "teh").IsUnused); + } + + [AvaloniaFact] + public void Editor_AddUpdateRemove_SavedOnOk() + { + WritePairs(""); + var vm = MakeViewModel(); + + vm.RemoveCommand.Execute(vm.Pairs.Single(p => p.From == "and")); + + vm.EditFrom = "recieve"; + vm.EditTo = "receive"; + vm.AddOrUpdateCommand.Execute(null); + + vm.EditFrom = "teh"; + Assert.True(vm.IsEditingExisting); + vm.EditTo = "The"; + vm.AddOrUpdateCommand.Execute(null); + + vm.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.True(vm.OkPressed); + var saved = UseAlwaysListFile.Load(FileName); + Assert.Equal(2, saved.Count); + Assert.Equal("receive", saved["recieve"]); + Assert.Equal("The", saved["teh"]); + Assert.False(saved.ContainsKey("and")); + } + + [AvaloniaFact] + public void Editor_Cancel_DoesNotSave() + { + WritePairs(""); + var vm = MakeViewModel(); + + vm.RemoveCommand.Execute(vm.Pairs.Single()); + vm.CancelCommand.Execute(null); + + Assert.Equal("&", UseAlwaysListFile.Load(FileName)["and"]); + } + + [AvaloniaFact] + public void Editor_RemoveUnused_KeepsUsefulPairs() + { + WritePairs(""); + var vm = MakeViewModel(); + WaitFor(() => vm.HasUnused); + + vm.RemoveUnusedCommand.Execute(null); + + Assert.Equal(new[] { "teh" }, vm.Pairs.Select(p => p.From)); + Assert.False(vm.HasUnused); + } + + [Fact] + public void SpellCheck_ReloadUseAlwaysList_PicksUpEditedFile() + { + var manager = new SpellCheckManager(); + manager.Initialize(Path.Combine(_folder, "en_US.dic"), "en"); + + WritePairs(""); + manager.ReloadUseAlwaysList(); + + var subtitles = new ObservableCollection { new() { Text = "Ths line is fine." } }; + var results = manager.CheckSpelling(subtitles); + + Assert.Empty(results); + Assert.Equal("This line is fine.", subtitles[0].Text); + } + + public void Dispose() + { + Se.DictionariesFolder = _originalDictionariesFolder; + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + Configuration.Settings.Tools.RememberUseAlwaysList = _originalRememberUseAlwaysList; + try + { + Directory.Delete(_folder, true); + } + catch + { + // ignore + } + } +} diff --git a/tests/benchmarks/HotPathRound8Benchmarks.cs b/tests/benchmarks/HotPathRound8Benchmarks.cs index daa9ba4c44b..0f52330b8a3 100644 --- a/tests/benchmarks/HotPathRound8Benchmarks.cs +++ b/tests/benchmarks/HotPathRound8Benchmarks.cs @@ -285,6 +285,7 @@ public void AddToNames(string currentWord) { } public void AdToUserDictionary(string currentWord) { } public void RemoveIgnoreWord(string word) { } public void RemoveChangeAllWord(string fromWord) { } + public void ReloadUseAlwaysList() { } public void RemoveFromNames(string word) { } public void RemoveFromUserDictionary(string word) { } public List GetDictionaryLanguages(string dictionaryFolder) => new(); From 5ed19a304832432465aeda8dcad948272d1ea83e Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 17:23:09 +0200 Subject: [PATCH 030/110] Spell check: fit the dictionary row to the column The new "Use always" list button pushed the fixed 200 px combo box row (and the suggestions under it) past the window's right edge. The combo box now stretches to the space left by the two buttons. Co-Authored-By: Claude Opus 5.5 --- .../Features/SpellCheck/SpellCheckWindow.cs | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/ui/Features/SpellCheck/SpellCheckWindow.cs b/src/ui/Features/SpellCheck/SpellCheckWindow.cs index 66c4f0a951a..6d5e4ad9922 100644 --- a/src/ui/Features/SpellCheck/SpellCheckWindow.cs +++ b/src/ui/Features/SpellCheck/SpellCheckWindow.cs @@ -301,7 +301,8 @@ private static Grid MakeSuggestions(SpellCheckViewModel vm) [!ComboBox.ItemsSourceProperty] = new Binding(nameof(SpellCheckViewModel.Dictionaries)) { Mode = BindingMode.OneWay }, [!ComboBox.SelectedItemProperty] = new Binding(nameof(SpellCheckViewModel.SelectedDictionary)) { Mode = BindingMode.TwoWay }, VerticalAlignment = VerticalAlignment.Center, - Width = 200, + HorizontalAlignment = HorizontalAlignment.Stretch, + MinWidth = 120, }; comboBoxDictionary.SelectionChanged += (_, _) => vm.OnDictionaryChanged(); @@ -314,18 +315,22 @@ private static Grid MakeSuggestions(SpellCheckViewModel vm) HorizontalContentAlignment = HorizontalAlignment.Center, }; - var panelDictionary = new StackPanel + // The combo box takes whatever width the column leaves after the two buttons - a fixed + // width pushed the row (and the suggestions below it) past the window's right edge. + var panelDictionary = new Grid { - Orientation = Orientation.Horizontal, - VerticalAlignment = VerticalAlignment.Center, - Spacing = 5, - Children = + ColumnDefinitions = { - comboBoxDictionary, - buttonDictionaryBrowse, - UiUtil.MakeButton(vm.EditUseAlwaysListCommand, IconNames.FormatListChecks, Se.Language.SpellCheck.EditUseAlwaysList), - } + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + ColumnSpacing = 5, + VerticalAlignment = VerticalAlignment.Center, }; + panelDictionary.Add(comboBoxDictionary, 0, 0); + panelDictionary.Add(buttonDictionaryBrowse, 0, 1); + panelDictionary.Add(UiUtil.MakeButton(vm.EditUseAlwaysListCommand, IconNames.FormatListChecks, Se.Language.SpellCheck.EditUseAlwaysList), 0, 2); var labelSuggestions = new Label { @@ -389,7 +394,7 @@ private static Grid MakeSuggestions(SpellCheckViewModel vm) { ColumnDefinitions = { - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, }, RowDefinitions = { From fa356d18e345f9284d4ac2c9e62fc7dbe5720925 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 20:04:45 +0200 Subject: [PATCH 031/110] Settings: add "Show recent files" to disable recent files (#15779) Like SE4's "Show recent files (for reopen)". When off, no subtitle or video recent files are recorded, the Reopen / recent videos menus are hidden, "Open last recent file on start" is greyed out and skipped, and the stored lists are cleared on save. The SE4 settings import carries the value over. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/Layout/InitMenu.cs | 4 ++-- src/ui/Features/Main/Layout/InitNativeMacMenu.cs | 4 ++-- src/ui/Features/Main/MainViewModel.cs | 15 ++++++++++++--- src/ui/Features/Options/Settings/SettingsPage.cs | 4 +++- .../Options/Settings/SettingsViewModel.cs | 11 +++++++++++ .../Config/Language/Options/LanguageSettings.cs | 2 ++ src/ui/Logic/Config/SeFile.cs | 1 + src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs | 1 + 8 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/ui/Features/Main/Layout/InitMenu.cs b/src/ui/Features/Main/Layout/InitMenu.cs index cedbf554321..98ff167e9bd 100644 --- a/src/ui/Features/Main/Layout/InitMenu.cs +++ b/src/ui/Features/Main/Layout/InitMenu.cs @@ -1165,7 +1165,7 @@ public static void ApplyFontSize(Menu menu) public static void UpdateRecentFiles(MainViewModel vm) { - var files = Se.Settings.File.RecentFiles.Where(p => !string.IsNullOrEmpty(p.SubtitleFileName) && System.IO.File.Exists(p.SubtitleFileName)).ToList(); + var files = Se.Settings.File.RecentFiles.Where(p => Se.Settings.File.ShowRecentFiles && !string.IsNullOrEmpty(p.SubtitleFileName) && System.IO.File.Exists(p.SubtitleFileName)).ToList(); PopulateRecentMenu( vm.MenuReopen, files, @@ -1193,7 +1193,7 @@ public static void UpdateRecentFiles(MainViewModel vm) public static void UpdateRecentVideos(MainViewModel vm) { var files = Se.Settings.Video.RecentFiles - .Where(f => !string.IsNullOrWhiteSpace(f)) + .Where(f => Se.Settings.File.ShowRecentFiles && !string.IsNullOrWhiteSpace(f)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); diff --git a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs index 23757d8693a..53012e32d16 100644 --- a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs +++ b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs @@ -750,7 +750,7 @@ private static void PopulateRecentNativeMenu( public static void UpdateRecentFiles(MainViewModel vm) { var files = Se.Settings.File.RecentFiles - .Where(p => !string.IsNullOrEmpty(p.SubtitleFileName) && System.IO.File.Exists(p.SubtitleFileName)) + .Where(p => Se.Settings.File.ShowRecentFiles && !string.IsNullOrEmpty(p.SubtitleFileName) && System.IO.File.Exists(p.SubtitleFileName)) .ToList(); PopulateRecentNativeMenu( @@ -776,7 +776,7 @@ public static void UpdateRecentFiles(MainViewModel vm) public static void UpdateRecentVideos(MainViewModel vm) { var files = Se.Settings.Video.RecentFiles - .Where(f => !string.IsNullOrWhiteSpace(f)) + .Where(f => Se.Settings.File.ShowRecentFiles && !string.IsNullOrWhiteSpace(f)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 54c9a6cb9d0..51a5e9a4d00 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -14682,6 +14682,15 @@ public void ApplySettings() Layout.InitNativeMacMenu.UpdatePluginsMenuVisibility(this); } + // "Show recent files" may have been turned off (lists cleared) or back on. + InitMenu.UpdateRecentFiles(this); + if (OperatingSystem.IsMacOS()) + { + Layout.InitNativeMacMenu.UpdateRecentFiles(this); + } + + UpdateRecentVideoMenus(); + LockTimeCodes = Se.Settings.General.LockTimeCodes; UpdateTimeCodeModeText(); IsWaveformToolbarVisible = Se.Settings.Waveform.ShowToolbar; @@ -28676,7 +28685,7 @@ private static bool TryGetRecentVideoFileName(string subtitleFileName, out strin private void AddToRecentFiles(bool updateMenu, int? selectedLine = null) { - if (_loading) + if (_loading || !Se.Settings.File.ShowRecentFiles) { return; } @@ -29010,7 +29019,7 @@ internal void OnLoaded() await OpenPendingStartupFilesAsync(delayForLayout: true); }); } - else if (Se.Settings.File.OpenLastFileOnStart) + else if (Se.Settings.File.OpenLastFileOnStart && Se.Settings.File.ShowRecentFiles) { var first = Se.Settings.File.RecentFiles.FirstOrDefault(); if (first != null && File.Exists(first.SubtitleFileName)) @@ -29368,7 +29377,7 @@ private async Task VideoOpenFile(string videoFileName, int desiredAudioTrackId = private void AddToRecentVideoFiles(string videoFileName) { - if (string.IsNullOrEmpty(videoFileName) || IsValidUrl(videoFileName)) + if (string.IsNullOrEmpty(videoFileName) || IsValidUrl(videoFileName) || !Se.Settings.File.ShowRecentFiles) { return; } diff --git a/src/ui/Features/Options/Settings/SettingsPage.cs b/src/ui/Features/Options/Settings/SettingsPage.cs index caa93acf49d..385a4d756ea 100644 --- a/src/ui/Features/Options/Settings/SettingsPage.cs +++ b/src/ui/Features/Options/Settings/SettingsPage.cs @@ -428,7 +428,9 @@ private List CreateSections() MakeCheckboxSetting(Se.Language.General.LockTimeCodes, nameof(_vm.LockTimeCodes)), MakeCheckboxSetting(Se.Language.Options.Settings.RememberPositionAndSize, nameof(_vm.RememberPositionAndSize)), MakeCheckboxSetting(Se.Language.Options.Settings.TitleBarFullFileName, nameof(_vm.TitleBarFullFileName)), - MakeCheckboxSetting(Se.Language.Options.Settings.OpenLastFileOnStart, nameof(_vm.OpenLastFileOnStart)), + MakeCheckboxSetting(Se.Language.Options.Settings.ShowRecentFiles, nameof(_vm.ShowRecentFiles)), + MakeCheckboxSetting(Se.Language.Options.Settings.OpenLastFileOnStart, nameof(_vm.OpenLastFileOnStart), + new Binding(nameof(_vm.ShowRecentFiles)) { Source = _vm }), MakeCheckboxSetting(Se.Language.Options.Settings.AutoConvertToUtf8, nameof(_vm.AutoConvertToUtf8)), MakeCheckboxSetting(Se.Language.Options.Settings.ForceCrLfOnSave, nameof(_vm.ForceCrLfOnSave)), MakeCheckboxSetting(Se.Language.Options.Settings.ShowFormatLimitWarning, nameof(_vm.ShowFormatLimitWarning)), diff --git a/src/ui/Features/Options/Settings/SettingsViewModel.cs b/src/ui/Features/Options/Settings/SettingsViewModel.cs index 679f4e21bc5..ba89c114279 100644 --- a/src/ui/Features/Options/Settings/SettingsViewModel.cs +++ b/src/ui/Features/Options/Settings/SettingsViewModel.cs @@ -130,6 +130,7 @@ partial void OnMaxLinesChanged(int? value) [ObservableProperty] private bool _lockTimeCodes; [ObservableProperty] private bool _rememberPositionAndSize; [ObservableProperty] private bool _titleBarFullFileName; + [ObservableProperty] private bool _showRecentFiles; [ObservableProperty] private bool _openLastFileOnStart; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsMsMode))] @@ -828,6 +829,7 @@ private void LoadSettings() LockTimeCodes = general.LockTimeCodes; RememberPositionAndSize = general.RememberPositionAndSize; TitleBarFullFileName = general.TitleBarFullFileName; + ShowRecentFiles = Se.Settings.File.ShowRecentFiles; OpenLastFileOnStart = Se.Settings.File.OpenLastFileOnStart; AutoSave = general.AutoSave; AutoBackupOn = general.AutoBackupOn; @@ -1740,6 +1742,15 @@ private void SaveSettings() general.LockTimeCodes = LockTimeCodes; general.RememberPositionAndSize = RememberPositionAndSize; general.TitleBarFullFileName = TitleBarFullFileName; + Se.Settings.File.ShowRecentFiles = ShowRecentFiles; + if (!ShowRecentFiles) + { + // Turning recent files off means "do not remember what I opened" - drop what is + // already stored too, not just stop adding to it. + Se.Settings.File.RecentFiles = new List(); + Se.Settings.Video.RecentFiles = new List(); + } + Se.Settings.File.OpenLastFileOnStart = OpenLastFileOnStart; general.AutoSave = AutoSave; general.AutoBackupOn = AutoBackupOn; diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs index d37f26918e5..b3813efa343 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs @@ -104,6 +104,7 @@ public class LanguageSettings public string PromptBeforeDelete { get; set; } public string RememberPositionAndSize { get; set; } public string TitleBarFullFileName { get; set; } + public string ShowRecentFiles { get; set; } public string OpenLastFileOnStart { get; set; } public string AutoSave { get; set; } public string AutoBackupOn { get; set; } @@ -462,6 +463,7 @@ public LanguageSettings() PromptBeforeDelete = "Prompt before delete"; RememberPositionAndSize = "Remember window position and size"; TitleBarFullFileName = "Show full file path in title bar"; + ShowRecentFiles = "Show recent files (for reopen)"; OpenLastFileOnStart = "Open last recent file on start"; AutoSave = "Auto-save (save the open file while editing)"; AutoBackupOn = "Auto-backup"; diff --git a/src/ui/Logic/Config/SeFile.cs b/src/ui/Logic/Config/SeFile.cs index 1d882be7cff..acd4f89b58f 100644 --- a/src/ui/Logic/Config/SeFile.cs +++ b/src/ui/Logic/Config/SeFile.cs @@ -7,6 +7,7 @@ namespace Nikse.SubtitleEdit.Logic.Config; public class SeFile { + public bool ShowRecentFiles { get; set; } = true; public bool OpenLastFileOnStart { get; set; } = true; public int RecentFilesMaximum { get; set; } = 25; public List RecentFiles { get; set; } = new(); diff --git a/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs b/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs index cc6f8cf4357..90a2b62b17f 100644 --- a/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs +++ b/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs @@ -129,6 +129,7 @@ public static void ApplyRules(Se4SettingsFile file) SetEnumName(general, "DialogStyle", v => g.DialogStyle = v); SetEnumName(general, "ContinuationStyle", v => g.ContinuationStyle = v); SetBool(general, "TitleBarFullFileName", v => g.TitleBarFullFileName = v); + SetBool(general, "ShowRecentFiles", v => Se.Settings.File.ShowRecentFiles = v); var cpsStrategy = Value(general, "CpsLineLengthStrategy"); if (!string.IsNullOrWhiteSpace(cpsStrategy)) From e206db89edfd238ec94216bc93c84802965e3f18 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 7 Oct 2026 20:43:52 +0200 Subject: [PATCH 032/110] Video player: use ffmpeg's duration for .ts/.mpg files MPEG transport/program streams store no duration, so mpv estimates it and re-estimates after seeks - a 1.5 h TV recording jumped to ~8 h while scrubbing, squashing the position slider (#15740). Probe ffmpeg once in the background on open and use its value for the slider range and total time; bitrate-based ffmpeg estimates are ignored and the player's value is kept. Co-Authored-By: Claude Opus 5.5 --- .../VideoPlayer/VideoPlayerControl.cs | 43 ++++++++++++++++++- src/ui/Logic/Media/FfmpegMediainfo2.cs | 41 ++++++++++++++++++ tests/UI/Logic/FfmpegDurationTests.cs | 26 +++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/UI/Logic/FfmpegDurationTests.cs diff --git a/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs b/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs index ff0f57976ff..bbd53cbfddc 100644 --- a/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs +++ b/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs @@ -193,6 +193,13 @@ public bool IsFullScreen private int _slowPollCounter; private IVideoPlayer _videoPlayerInstance; private string _videoFileName; + + // Duration ffmpeg reported for the open file, 0 when unknown. MPEG transport/program + // streams store no duration, so mpv estimates one and re-estimates it after seeks - + // a 1.5 h TV recording could jump to 8 h mid-scrub and squash the slider (#15740). + // When set, it replaces the player's value for the slider range and total time. + private double _probedDuration; + private int _durationProbeGeneration; private readonly Grid _gridProgress; // Reference to the controls grid private DispatcherTimer? _autoHideTimer; private DateTime _lastActivityTime; @@ -952,6 +959,7 @@ internal async Task Open(string videoFileName, double startPositionSeconds = 0) // the new Maximum — firing ValueChanged and seeking mpv to EOF. SetPositionDisplayOnly(0); Duration = 0; + StartDurationProbe(videoFileName); await _videoPlayerInstance.LoadFile(videoFileName, startPositionSeconds); @@ -1179,6 +1187,38 @@ internal void Close() _textBlockVideoFileName.Text = string.Empty; SetPositionDisplayOnly(0); Duration = 0; + StartDurationProbe(null); + } + + private static readonly HashSet DurationProbeExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".ts", ".m2ts", ".mts", ".m2t", ".tp", ".trp", ".mpg", ".mpeg", ".vob", + }; + + private void StartDurationProbe(string? videoFileName) + { + _probedDuration = 0; + var generation = ++_durationProbeGeneration; + if (string.IsNullOrEmpty(videoFileName) || + !DurationProbeExtensions.Contains(System.IO.Path.GetExtension(videoFileName))) + { + return; + } + + _ = Task.Run(() => + { + var seconds = Logic.Media.FfmpegMediaInfo2.TryGetDurationSeconds(videoFileName); + if (seconds is > 0) + { + Dispatcher.UIThread.Post(() => + { + if (generation == _durationProbeGeneration && !IsDisposed) + { + _probedDuration = seconds.Value; + } + }); + } + }); } /// @@ -1368,7 +1408,8 @@ private void StartPositionTimer() if (_slowPollCounter >= 5) { _slowPollCounter = 0; - Duration = _videoPlayerInstance.Duration; + var playerDuration = _videoPlayerInstance.Duration; + Duration = _probedDuration > 0 && playerDuration > 0 ? _probedDuration : playerDuration; SetPlayPauseIcon(_videoPlayerInstance.IsPlaying); // The ffmpeg player only knows its decoder (hardware vs. software) once the diff --git a/src/ui/Logic/Media/FfmpegMediainfo2.cs b/src/ui/Logic/Media/FfmpegMediainfo2.cs index d951a04ee99..950ac67f616 100644 --- a/src/ui/Logic/Media/FfmpegMediainfo2.cs +++ b/src/ui/Logic/Media/FfmpegMediainfo2.cs @@ -54,6 +54,47 @@ public static FfmpegMediaInfo2 Parse(string videoFileName) return ParseLog(log); } + /// + /// Returns the duration ffmpeg reports for the file, or null when ffmpeg is unavailable, + /// prints no duration, or only guessed it from the bitrate. Runs ffmpeg once (no retry). + /// + public static double? TryGetDurationSeconds(string videoFileName) + { + if (Configuration.IsRunningOnWindows && + (string.IsNullOrEmpty(Se.Settings.General.FfmpegPath) || !File.Exists(Se.Settings.General.FfmpegPath))) + { + return null; + } + + try + { + return ParseDurationSeconds(RunFfmpegOnce(videoFileName, out _)); + } + catch + { + return null; + } + } + + internal static double? ParseDurationSeconds(string log) + { + // A bitrate-based estimate is the same kind of guess the player makes, so it is + // no better than what we already have. + if (log.Contains("Estimating duration from bitrate", StringComparison.Ordinal)) + { + return null; + } + + var match = DurationRegex.Match(log); + if (!match.Success) + { + return null; + } + + var ms = TimeCode.ParseToMilliseconds(match.Value.Split(' ')[1]); + return ms > 0 ? ms / TimeCode.BaseUnit : null; + } + public long GetTotalFrames() { if (Duration == null) diff --git a/tests/UI/Logic/FfmpegDurationTests.cs b/tests/UI/Logic/FfmpegDurationTests.cs new file mode 100644 index 00000000000..34b77104680 --- /dev/null +++ b/tests/UI/Logic/FfmpegDurationTests.cs @@ -0,0 +1,26 @@ +using Nikse.SubtitleEdit.Logic.Media; + +namespace UITests.Logic; + +public class FfmpegDurationTests +{ + [Fact] + public void ParseDurationSeconds_ReadsCentiseconds() + { + var log = "Input #0, mpegts, from 'Shrek.ts':\n Duration: 01:35:02.99, start: 1.400000, bitrate: 4021 kb/s\n"; + Assert.Equal(5702.99, FfmpegMediaInfo2.ParseDurationSeconds(log)!.Value, 3); + } + + [Fact] + public void ParseDurationSeconds_BitrateEstimate_ReturnsNull() + { + var log = "[mpeg @ 0x1] Estimating duration from bitrate, this may be inaccurate\n Duration: 07:56:43.30, start: 0.000000, bitrate: 900 kb/s\n"; + Assert.Null(FfmpegMediaInfo2.ParseDurationSeconds(log)); + } + + [Fact] + public void ParseDurationSeconds_NotAvailable_ReturnsNull() + { + Assert.Null(FfmpegMediaInfo2.ParseDurationSeconds(" Duration: N/A, bitrate: N/A\n")); + } +} From 99f5011d8b20842709e406938ce6ef15820de49e Mon Sep 17 00:00:00 2001 From: MaryJaneInChain Date: Thu, 8 Oct 2026 03:56:38 +0800 Subject: [PATCH 033/110] Linux: fix startup crash when Skia reports no default font family Skia's fontconfig matcher (SkiaSharp 3.119) only accepts fontconfig's default font if its family is among the first 16 names of the substituted pattern. On zh_CN the default is typically a CJK font that wins on language but sits far down the sans-serif list, so Skia drops it and SKTypeface.Default has an empty family name. Avalonia then falls back to the alphabetically first system font, which is an X11 bitmap font when xorg-fonts-75dpi/100dpi are installed, and startup fails with "Could not create glyphTypeface. Font family: $Default". When Skia's default family is empty, recover the font fontconfig picked via character matching (which has no such limit) and pass it as FontManagerOptions.DefaultFamilyName; fall back to the embedded Inter font if that yields nothing usable. Systems where Skia's default works are unaffected. Co-Authored-By: Claude Opus 5.5 --- src/ui/Program.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/ui/Program.cs b/src/ui/Program.cs index 7e34783ee95..103d0c03e4a 100644 --- a/src/ui/Program.cs +++ b/src/ui/Program.cs @@ -17,6 +17,7 @@ using Optris.Icons.Avalonia; using Optris.Icons.Avalonia.FontAwesome; using Optris.Icons.Avalonia.MaterialDesign; +using SkiaSharp; using System; using System.Linq; using System.Runtime.InteropServices; @@ -153,6 +154,7 @@ public static void Main(string[] args) .WithInterFont() .With(new FontManagerOptions { + DefaultFamilyName = GetLinuxDefaultFontFamilyOverride(), FontFallbacks = new[] { new FontFallback { FontFamily = new FontFamily("Noto Sans CJK SC") }, @@ -307,6 +309,34 @@ public static void Main(string[] args) [LibraryImport("kernel32.dll")] private static partial uint SetErrorMode(uint mode); + /// + /// Returns a default font family for Avalonia when Skia has none, or null when Skia's own + /// default is usable. Skia's fontconfig matcher (still in SkiaSharp 3.119) only accepts + /// fontconfig's default font if its family is among the first 16 names of the substituted + /// pattern. On e.g. zh_CN the default is a CJK font that wins on language but sits far + /// down the sans-serif list, so Skia drops it and reports an empty default family. + /// Avalonia then falls back to the alphabetically first system font - often an X11 bitmap + /// font it cannot load - and startup dies with "Could not create glyphTypeface. Font + /// family: $Default". Character matching has no such limit, so it recovers the font + /// fontconfig really picked; the embedded Inter font covers systems where even that + /// yields nothing usable. + /// + private static string? GetLinuxDefaultFontFamilyOverride() + { + if (!string.IsNullOrEmpty(SKTypeface.Default.FamilyName)) + { + return null; + } + + using var typeface = SKFontManager.Default.MatchCharacter('a'); + if (typeface == null || typeface.TableCount == 0 || string.IsNullOrEmpty(typeface.FamilyName)) + { + return "fonts:Inter#Inter"; // no tables = bitmap font (PCF/BDF), which Avalonia cannot load + } + + return typeface.FamilyName; + } + /// /// Makes dead-key accents (á, ê, õ, ...) work on Linux. Avalonia's ibus D-Bus client /// (enabled via EnableIme below) races key events against ibus and loses: the dead key From e771e0accaca3f600603b64190fa7f25875b4a1c Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 8 Oct 2026 06:14:16 +0200 Subject: [PATCH 034/110] UI scale: size submenu and context menu items for popups at startup The "already scaled by the transform" reset style matched every MenuItem under the LayoutTransformControl, which includes submenu and context menu items (logical descendants) even though they render in popups outside the transform. Only ApplyScaleToExistingMenus' local values hid it, and that pass finds no windows at startup and misses rebuilt context flyouts, so at 70% UI / 150% font popups came out at 21 px instead of 14.7 until a scale change. Limit the reset to direct Menu children (the menu bar items). Co-Authored-By: Claude Opus 5.5 --- src/ui/Logic/UiTheme.cs | 6 +- tests/UI/Logic/MenuPopupScaleTests.cs | 83 +++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/UI/Logic/MenuPopupScaleTests.cs diff --git a/src/ui/Logic/UiTheme.cs b/src/ui/Logic/UiTheme.cs index d99732430e0..063c3d0c65a 100644 --- a/src/ui/Logic/UiTheme.cs +++ b/src/ui/Logic/UiTheme.cs @@ -346,8 +346,10 @@ private static void ApplyMenuScaleStyle(double factor) menuItemStyle.Setters.Add(new Setter(Layoutable.MinHeightProperty, 32.0 * factor)); styles.Add(menuItemStyle); - // Reset MenuItems inside LayoutTransformControl (already scaled by transform) - var ltcMenuItemStyle = new Style(x => x.OfType().Descendant().OfType()); + // Reset top-level menu bar items (inside the LayoutTransformControl, already scaled + // by the transform). Only direct Menu children: submenu and context menu items are + // logical descendants of the LTC too, but render in popups outside the transform. + var ltcMenuItemStyle = new Style(x => x.OfType().Child().OfType()); ltcMenuItemStyle.Setters.Add(new Setter(TemplatedControl.FontSizeProperty, DefaultFontSize * FontScale)); ltcMenuItemStyle.Setters.Add(new Setter(Layoutable.MinHeightProperty, 32.0)); styles.Add(ltcMenuItemStyle); diff --git a/tests/UI/Logic/MenuPopupScaleTests.cs b/tests/UI/Logic/MenuPopupScaleTests.cs new file mode 100644 index 00000000000..7a26d38b5d4 --- /dev/null +++ b/tests/UI/Logic/MenuPopupScaleTests.cs @@ -0,0 +1,83 @@ +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace UITests.Logic; + +/// +/// Submenu and context menu items render in popups outside the window's LayoutTransformControl, +/// but are logical descendants of it. The "already scaled by the transform" reset style once +/// matched them too, so at startup (before any per-item sizes were set) popups ignored the UI +/// scale: at 70% UI / 150% font they came out at 21 px instead of 14.7 (PR #14818 comment). +/// Only the top-level menu bar items sit inside the transform. +/// +public class MenuPopupScaleTests : IDisposable +{ + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + Se.Settings.Appearance.FontScale = 1.0; + UiTheme.SetLayoutScale(1.0); + } + + [AvaloniaTheory] + [InlineData(0.7, 1.5)] + [InlineData(1.5, 1.0)] + public void PopupMenuItems_FollowLayoutAndFontScale(double layoutScale, double fontScale) + { + Se.Settings.Appearance.FontScale = fontScale; + UiTheme.SetLayoutScale(layoutScale); + + var subItem = new MenuItem { Header = "Open" }; + var topItem = new MenuItem { Header = "File" }; + topItem.Items.Add(subItem); + var menu = new Menu(); + menu.Items.Add(topItem); + + var flyoutItem = new MenuItem { Header = "Delete" }; + var flyout = new MenuFlyout(); + flyout.Items.Add(flyoutItem); + var host = new Border { Width = 200, Height = 100, Background = Brushes.Gray, ContextFlyout = flyout }; + + var panel = new StackPanel(); + panel.Children.Add(menu); + panel.Children.Add(host); + + // Same shape as the main window: content pre-wrapped in a LayoutTransformControl, + // created after the menu style was applied (as at startup). + var window = new Window { Width = 400, Height = 300, Content = new LayoutTransformControl { Child = panel } }; + _windows.Add(window); + UiTheme.ApplyScaleToWindow(window); + window.Show(); + window.UpdateLayout(); + + topItem.Open(); + flyout.ShowAt(host); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + + // Top-level item: scaled by the transform, so only the font scale is baked in. + Assert.Equal(14.0 * fontScale, topItem.FontSize, precision: 3); + Assert.Equal(32.0, topItem.MinHeight, precision: 3); + + // Popup items: outside the transform, so both factors are baked in. + Assert.Equal(14.0 * layoutScale * fontScale, subItem.FontSize, precision: 3); + Assert.Equal(32.0 * layoutScale, subItem.MinHeight, precision: 3); + Assert.Equal(14.0 * layoutScale * fontScale, flyoutItem.FontSize, precision: 3); + Assert.Equal(32.0 * layoutScale, flyoutItem.MinHeight, precision: 3); + + topItem.Close(); + flyout.Hide(); + } +} From 2f8799963f96b5fd5871563c3c396d723098c9b3 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 8 Oct 2026 06:15:44 +0200 Subject: [PATCH 035/110] Plugins: keep returned time codes after the frame-mode change tick A structured plugin response was put in the grid as new rows without an undo baseline, so the next change tick treated every line as edited and snapped it to CurrentFrameRate (EBU STL is always in frame mode). ArteCheck returns 25 fps time codes; with an STL30 file or a 30/29.97/23.976 fps video they were moved to other frames, and the next ArteCheck run found the same corrections again. Apply structured responses as a tool step (one undo step named after the plugin's UndoDescription), and skip identical responses. Based on #15738. Co-Authored-By: Ralph Junglas Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/MainViewModel.cs | 26 +- .../Features/Main/PluginToolLifecycleTests.cs | 227 ++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 tests/UI/Features/Main/PluginToolLifecycleTests.cs diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 51a5e9a4d00..25168eb21e9 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -9349,9 +9349,31 @@ await MessageBox.Show(Window!, Se.Language.General.Error, var idx = SelectedSubtitleIndex ?? 0; - if (pluginSubtitle.Paragraphs is { Count: > 0 } && pluginSubtitle.Header != null) + if (pluginSubtitle.Paragraphs is { Count: > 0 }) { - _subtitle.Header = pluginSubtitle.Header; + var current = GetUpdateSubtitle(); + var currentParagraphs = JsonSerializer.Serialize(current.Paragraphs.Select(ToPluginParagraph).ToList(), PluginJsonContext.Default.Options); + var resultParagraphs = JsonSerializer.Serialize(subtitle.Paragraphs.Select(ToPluginParagraph).ToList(), PluginJsonContext.Default.Options); + if ((pluginSubtitle.Header ?? current.Header) == current.Header && currentParagraphs == resultParagraphs) + { + return true; + } + + // Structured results are completed tool changes, not pending manual edits. + // Record their exact times before ordinary change detection can snap them again. + var toolApply = BeginToolApply(string.IsNullOrWhiteSpace(response.UndoDescription) + ? plugin.Manifest.Name : response.UndoDescription); + EndToolApply(toolApply, () => + { + if (pluginSubtitle.Header != null) + { + _subtitle.Header = pluginSubtitle.Header; + } + + SetSubtitlesKeepingOriginal(subtitle); + SelectAndScrollToRow(Math.Min(idx, Math.Max(0, Subtitles.Count - 1))); + }); + return true; } // Not the plain rebuild: that blanks the original column, and with an original captured diff --git a/tests/UI/Features/Main/PluginToolLifecycleTests.cs b/tests/UI/Features/Main/PluginToolLifecycleTests.cs new file mode 100644 index 00000000000..a305e175b00 --- /dev/null +++ b/tests/UI/Features/Main/PluginToolLifecycleTests.cs @@ -0,0 +1,227 @@ +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Plugins; +using Nikse.SubtitleEdit.Logic.UndoRedo; + +namespace UITests.Features.Main; + +public sealed class PluginToolLifecycleTests : IDisposable +{ + private readonly SettingsScope _settings = new("General.UseFrameMode", "General.UseFrameNumbersPersisted", "General.CurrentFrameRate"); + private Window? _window; + private MainViewModel? _vm; + private IUndoRedoManager? _undo; + private static readonly string Header = new Ebu.EbuGeneralSubtitleInformation().ToString(); + + [AvaloniaFact] + public async Task StructuredResponse_SurvivesChangeTickAndUndoRedo() + { + var vm = Create(); + var response = Response(newHeader: "850" + Header[3..]); + response.Subtitle!.Paragraphs = new() + { + new() { StartMs = 36_000_000, EndMs = 36_000_200, Text = "", MarginV = "22" }, + new() { StartMs = 36_001_000, EndMs = 36_002_000, Text = "Corrected text", MarginV = "20" }, + }; + + Assert.True(await Apply(vm, response)); + _undo!.CheckForChanges(null); // The actual path that previously snapped 200 back to 209. + Assert.Equal(36_000_200, vm.Subtitles[0].EndTime.TotalMilliseconds); + Assert.Equal(response.Subtitle.Header, vm.GetUpdateSubtitle().Header); + Assert.Equal("Plugin correction", _undo.UndoList.Last().Description); + Assert.Equal(2, _undo.UndoCount); + + vm.UndoCommand.Execute(null); + Assert.Equal(36_000_209, vm.Subtitles[0].EndTime.TotalMilliseconds); + Assert.Single(vm.Subtitles); + Assert.Equal("Before", vm.Subtitles[0].Text); + Assert.Equal(Header, vm.GetUpdateSubtitle().Header); + + vm.RedoCommand.Execute(null); + _undo.CheckForChanges(null); + Assert.Equal(36_000_200, vm.Subtitles[0].EndTime.TotalMilliseconds); + Assert.Equal(2, vm.Subtitles.Count); + Assert.Equal("Corrected text", vm.Subtitles[1].Text); + Assert.Equal(36_001_000, vm.Subtitles[1].StartTime.TotalMilliseconds); + Assert.Equal(36_002_000, vm.Subtitles[1].EndTime.TotalMilliseconds); + Assert.Equal("20", vm.GetUpdateSubtitle().Paragraphs[1].MarginV); + Assert.Equal(response.Subtitle.Header, vm.GetUpdateSubtitle().Header); + + var request = (PluginRequest)typeof(MainViewModel).GetMethod("BuildPluginRequest", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(vm, new object[] { Plugin(), new List() })!; + Assert.Equal(36_000_200, request.Subtitle.Paragraphs![0].EndMs); + Assert.Equal(24000.0 / 1001.0, Configuration.Settings.General.CurrentFrameRate); + } + + [AvaloniaFact] + public async Task StructuredResponse_25FpsTimesSurviveChangeTickAt30Fps() + { + // ArteCheck returns 25 fps time codes (40 ms grid) for an STL30 file; the change tick + // snapped every returned line to 30 fps frames, so the next check found them all again. + var vm = Create(); + Se.Settings.General.CurrentFrameRate = 30; + Configuration.Settings.General.CurrentFrameRate = 30; + var response = Response(); + response.Subtitle!.Paragraphs = new() + { + new() { StartMs = 36_000_000, EndMs = 36_000_200, Text = "", MarginV = "22" }, + new() { StartMs = 36_000_520, EndMs = 36_002_080, Text = "One", MarginV = "22" }, + new() { StartMs = 36_002_280, EndMs = 36_004_040, Text = "Two", MarginV = "22" }, + }; + + Assert.True(await Apply(vm, response)); + _undo!.CheckForChanges(null); + + var request = (PluginRequest)typeof(MainViewModel).GetMethod("BuildPluginRequest", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(vm, new object[] { Plugin(), new List() })!; + Assert.Equal( + response.Subtitle.Paragraphs.Select(p => (p.StartMs, p.EndMs)), + request.Subtitle.Paragraphs!.Select(p => (p.StartMs, p.EndMs))); + } + + [AvaloniaFact] + public async Task StructuredResponse_ThenManualEdit_StillSnaps() + { + var vm = Create(); + var response = Response(); + response.Subtitle!.Paragraphs![0].EndMs = 36_000_200; + Assert.True(await Apply(vm, response)); + _undo!.CheckForChanges(null); + Assert.Equal(36_000_200, vm.Subtitles[0].EndTime.TotalMilliseconds); + + vm.Subtitles[0].EndTime = TimeSpan.FromMilliseconds(36_000_201); + _undo.CheckForChanges(null); + Assert.Equal(36_000_209, vm.Subtitles[0].EndTime.TotalMilliseconds); + } + + [AvaloniaFact] + public async Task StructuredResponse_NoChange_DoesNotAddUndoOrReplaceRows() + { + var vm = Create(); + var row = vm.Subtitles[0]; + Assert.True(await Apply(vm, Response())); + _undo!.CheckForChanges(null); + Assert.Equal(1, _undo.UndoCount); + Assert.Same(row, vm.Subtitles[0]); + } + + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredResponse_TextOrHeaderOnly_IsUndoable(bool headerOnly) + { + var vm = Create(); + var response = Response(newHeader: headerOnly ? "850" + Header[3..] : null); + if (!headerOnly) response.Subtitle!.Paragraphs![0].Text = "After"; + Assert.True(await Apply(vm, response)); + _undo!.CheckForChanges(null); + Assert.Equal(2, _undo.UndoCount); + vm.UndoCommand.Execute(null); + Assert.Equal("Before", vm.Subtitles[0].Text); + Assert.Equal(Header, vm.GetUpdateSubtitle().Header); + vm.RedoCommand.Execute(null); + Assert.Equal(headerOnly ? "Before" : "After", vm.Subtitles[0].Text); + Assert.Equal(headerOnly ? response.Subtitle!.Header : Header, vm.GetUpdateSubtitle().Header); + } + + [AvaloniaFact] + public async Task StructuredResponse_RemovesParagraphs_UndoRestoresThem() + { + var vm = Create(); + vm.Subtitles.Add(new SubtitleLineViewModel(new Paragraph("Removed", 36_002_000, 36_003_000), null!)); + _undo!.Do(vm.MakeUndoRedoObject("Before removal")); + Assert.True(await Apply(vm, Response())); + _undo.CheckForChanges(null); + Assert.Single(vm.Subtitles); + vm.UndoCommand.Execute(null); + Assert.Equal(2, vm.Subtitles.Count); + Assert.Equal("Removed", vm.Subtitles[1].Text); + vm.RedoCommand.Execute(null); + Assert.Single(vm.Subtitles); + } + + [AvaloniaFact] + public async Task StructuredResponse_MetadataOnly_IsUndoable() + { + var vm = Create(); + var response = Response(); + response.Subtitle!.Paragraphs![0].MarginV = "20"; + Assert.True(await Apply(vm, response)); + _undo!.CheckForChanges(null); + Assert.Equal(2, _undo.UndoCount); + Assert.Equal("20", vm.GetUpdateSubtitle().Paragraphs[0].MarginV); + vm.UndoCommand.Execute(null); + Assert.Equal("22", vm.GetUpdateSubtitle().Paragraphs[0].MarginV); + vm.RedoCommand.Execute(null); + Assert.Equal("20", vm.GetUpdateSubtitle().Paragraphs[0].MarginV); + } + + private MainViewModel Create() + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + Locator.Services = services.BuildServiceProvider(); + _window = new Window { Width = 1200, Height = 800 }; + MainView.NextHostWindow = _window; + var view = new MainView(); + _window.Content = view; + _window.Show(); + Dispatcher.UIThread.RunJobs(); + _vm = (MainViewModel)view.DataContext!; + _vm.SelectedSubtitleFormat = _vm.SubtitleFormats.First(f => f is Ebu); + _vm.Subtitles.Clear(); + _vm.Subtitles.Add(new SubtitleLineViewModel(new Paragraph("Before", 36_000_000, 36_000_209) { MarginV = "22" }, null!) { Number = 1 }); + var subtitle = (Subtitle)typeof(MainViewModel).GetField("_subtitle", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(_vm)!; + subtitle.Header = Header; + Se.Settings.General.UseFrameMode = true; + Se.Settings.General.UseFrameNumbersPersisted = false; + Se.Settings.General.CurrentFrameRate = 24000.0 / 1001.0; + Configuration.Settings.General.CurrentFrameRate = 24000.0 / 1001.0; + _undo = (IUndoRedoManager)typeof(MainViewModel).GetField("_undoRedoManager", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(_vm)!; + _undo.Reset(); + _undo.SetupChangeDetection(_vm, TimeSpan.FromHours(1)); + _undo.Do(_vm.MakeUndoRedoObject("Before plugin")); + _undo.StartChangeDetection(); + return _vm; + } + + private static PluginResponse Response(string? newHeader = null) => new() + { + Status = PluginConstants.StatusOk, + UndoDescription = "Plugin correction", + Subtitle = new PluginSubtitle + { + Header = newHeader, + Paragraphs = new() { new() { StartMs = 36_000_000, EndMs = 36_000_209, Text = "Before", MarginV = "22" } }, + }, + }; + + private static InstalledPlugin Plugin() => new() + { + Manifest = new PluginManifest { Name = "Test plugin" }, FolderPath = "", ManifestPath = "", + }; + + private static Task Apply(MainViewModel vm, PluginResponse response) => + (Task)typeof(MainViewModel).GetMethod("ApplyPluginSubtitle", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(vm, new object[] { Plugin(), response })!; + + public void Dispose() + { + _undo?.Dispose(); + if (_window != null && _vm != null) + { + _window.Closing -= _vm.OnClosing; + foreach (var owned in _window.OwnedWindows.ToArray()) owned.Close(); + _window.Close(); + } + _settings.Dispose(); + } +} From ff2b567b1b543ab7a5944026a4e7896e44376ceb Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 8 Oct 2026 06:17:50 +0200 Subject: [PATCH 036/110] Visual sync: seek keys work wherever focus is in a pane (#15789) Keys were only handled when the player, waveform or combo box itself had focus, but focus normally sits on a child (player buttons, position slider) or the buttons under the player, so seek keys were dropped and only the slider's own arrow handling worked. - Route keys to the pane with keyboard focus anywhere inside it; with focus outside both panes, to the only playing video, else the pane last clicked or focused. Bare Space on Sync/OK/Cancel still clicks. - Honour the main window's video seek shortcuts (one second - bare Left/Right by default -, 100/500 ms, one frame, custom 1-4) before the built-in SE 4 Ctrl/Alt steps. Co-Authored-By: Claude Opus 5.5 --- .../Sync/VisualSync/VisualSyncViewModel.cs | 96 ++++++++++++++++--- .../Sync/VisualSync/VisualSyncWindow.cs | 8 ++ .../Sync/SyncWaveformVerticalZoomKeyTests.cs | 65 ++++++++++++- .../Features/Sync/VisualSyncSeekKeysTests.cs | 47 ++++++++- 4 files changed, 198 insertions(+), 18 deletions(-) diff --git a/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs b/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs index 6294573d105..940575bb6f7 100644 --- a/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs +++ b/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs @@ -42,6 +42,16 @@ public partial class VisualSyncViewModel : ObservableObject public ComboBox ComboBoxLeft { get; set; } public ComboBox ComboBoxRight { get; set; } + // Whole left/right column (player, waveform, combo box, buttons). Keys go to the pane that has + // focus anywhere inside it - focus normally sits on a child such as the player's position + // slider or a button, never on the player control itself (#15789). + public Control? PaneLeft { get; set; } + public Control? PaneRight { get; set; } + + // The pane last clicked or focused: keys pressed with focus outside both panes (nothing + // focused, or the Sync/OK/Cancel bar) go to the playing video, else to this one. + public bool IsRightPaneActive { get; set; } + private readonly IWindowService _windowService; private readonly IFileHelper _fileHelper; @@ -554,15 +564,36 @@ private async Task PlayAndBack(VideoPlayerControl videoPlayer, int milliseconds) private bool IsLeftFocused() { - return AudioVisualizerLeft.IsFocused || - VideoPlayerControlLeft.IsFocused || - ComboBoxLeft.IsFocused; + return PaneLeft?.IsKeyboardFocusWithin == true; } + private bool IsRightFocused() { - return AudioVisualizerRight.IsFocused || - VideoPlayerControlRight.IsFocused || - ComboBoxRight.IsFocused; + return PaneRight?.IsKeyboardFocusWithin == true; + } + + /// + /// The pane keys act on: the one with focus inside it, else the only playing video, else the + /// pane last clicked or focused. + /// + private bool IsRightPaneTarget() + { + if (IsLeftFocused()) + { + return false; + } + + if (IsRightFocused()) + { + return true; + } + + if (VideoPlayerControlLeft.IsPlaying != VideoPlayerControlRight.IsPlaying) + { + return VideoPlayerControlRight.IsPlaying; + } + + return IsRightPaneActive; } public void AudioVisualizerLeftPositionChanged(object sender, AudioVisualizer.PositionEventArgs e) @@ -712,14 +743,24 @@ internal void OnKeyDownHandler(object? sender, KeyEventArgs e) UiUtil.ShowHelp("features/visual-sync"); } - if (IsLeftFocused()) + // Arrows in an open drop-down pick a line, and bare Space on a button outside the panes + // (Sync/OK/Cancel) clicks it. + if (e.Handled || + ComboBoxLeft.IsDropDownOpen || + ComboBoxRight.IsDropDownOpen || + (e.Key == Key.Space && e.KeyModifiers == KeyModifiers.None && e.Source is Button or SplitButton && !IsLeftFocused() && !IsRightFocused())) { - HandlePaneKeys(e, VideoPlayerControlLeft, AudioVisualizerLeft); + return; } - else if (IsRightFocused()) + + if (IsRightPaneTarget()) { HandlePaneKeys(e, VideoPlayerControlRight, AudioVisualizerRight); } + else + { + HandlePaneKeys(e, VideoPlayerControlLeft, AudioVisualizerLeft); + } } private void HandlePaneKeys(KeyEventArgs e, VideoPlayerControl videoPlayer, AudioVisualizer audioVisualizer) @@ -753,8 +794,10 @@ private void HandlePaneKeys(KeyEventArgs e, VideoPlayerControl videoPlayer, Audi /// /// How far a key moves the focused video, in seconds, or null for other keys. /// The main window's "Move start/end X ms back/forward" shortcuts step by the X ms setting - /// (finer than a frame, to hit waveform edges); the built-in arrow steps are SE 4's: - /// Ctrl = 100 ms, Alt = 500 ms, Ctrl+Shift = 1 s. A user binding wins over a built-in step. + /// (finer than a frame, to hit waveform edges); its video seek shortcuts (one second - bare + /// Left/Right by default -, 100/500 ms, one frame, custom 1-4) step as they do there; the + /// built-in arrow steps are SE 4's: Ctrl = 100 ms, Alt = 500 ms, Ctrl+Shift = 1 s. + /// A user binding wins over a built-in step. /// internal static double? GetSeekSeconds(KeyEventArgs e) { @@ -771,6 +814,14 @@ private void HandlePaneKeys(KeyEventArgs e, VideoPlayerControl videoPlayer, Audi return stepSeconds; } + foreach (var (actionName, defaultKeys, seconds) in GetVideoSeekShortcuts()) + { + if (MainShortcutKeys.Matches(e, actionName, defaultKeys)) + { + return seconds; + } + } + var direction = e.Key switch { Key.Left => -1, @@ -791,6 +842,29 @@ private void HandlePaneKeys(KeyEventArgs e, VideoPlayerControl videoPlayer, Audi }; } + /// The main window's video seek commands with their default keys and step in seconds. + private static IEnumerable<(string ActionName, string[] DefaultKeys, double Seconds)> GetVideoSeekShortcuts() + { + var frameSeconds = Se.Settings.General.CurrentFrameRate >= 10 ? 1.0 / Se.Settings.General.CurrentFrameRate : 0.04; + var video = Se.Settings.Video; + yield return (nameof(MainViewModel.VideoOneSecondBackCommand), [nameof(Key.Left)], -1.0); + yield return (nameof(MainViewModel.VideoOneSecondForwardCommand), [nameof(Key.Right)], 1.0); + yield return (nameof(MainViewModel.Video100MsBackCommand), [], -0.1); + yield return (nameof(MainViewModel.Video100MsForwardCommand), [], 0.1); + yield return (nameof(MainViewModel.Video500MsBackCommand), [], -0.5); + yield return (nameof(MainViewModel.Video500MsForwardCommand), [], 0.5); + yield return (nameof(MainViewModel.VideoOneFrameBackCommand), [], -frameSeconds); + yield return (nameof(MainViewModel.VideoOneFrameForwardCommand), [], frameSeconds); + yield return (nameof(MainViewModel.VideoMoveCustom1BackCommand), [], -video.MoveVideoPositionCustom1Back / 1000.0); + yield return (nameof(MainViewModel.VideoMoveCustom1ForwardCommand), [], video.MoveVideoPositionCustom1Forward / 1000.0); + yield return (nameof(MainViewModel.VideoMoveCustom2BackCommand), [], -video.MoveVideoPositionCustom2Back / 1000.0); + yield return (nameof(MainViewModel.VideoMoveCustom2ForwardCommand), [], video.MoveVideoPositionCustom2Forward / 1000.0); + yield return (nameof(MainViewModel.VideoMoveCustom3BackCommand), [], -video.MoveVideoPositionCustom3Back / 1000.0); + yield return (nameof(MainViewModel.VideoMoveCustom3ForwardCommand), [], video.MoveVideoPositionCustom3Forward / 1000.0); + yield return (nameof(MainViewModel.VideoMoveCustom4BackCommand), [], -video.MoveVideoPositionCustom4Back / 1000.0); + yield return (nameof(MainViewModel.VideoMoveCustom4ForwardCommand), [], video.MoveVideoPositionCustom4Forward / 1000.0); + } + /// /// Mirrors the main window's waveform vertical zoom (Shift +/-) for whichever pane has focus: /// scales that waveform's amplitude in place instead of resizing the split panel, so zooming diff --git a/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs b/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs index 556be8f843e..a7f2afedd37 100644 --- a/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs +++ b/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs @@ -225,6 +225,14 @@ public VisualSyncWindow(VisualSyncViewModel vm) grid.Add(UiUtil.MakeBorderForControl(gridRight), 1, 1); grid.Add(buttonPanel, 2, 0, 1, 2); + // Seek/play keys follow the pane last clicked or focused once focus leaves both (#15789). + vm.PaneLeft = gridLeft; + vm.PaneRight = gridRight; + gridLeft.AddHandler(PointerPressedEvent, (_, _) => vm.IsRightPaneActive = false, RoutingStrategies.Tunnel, handledEventsToo: true); + gridRight.AddHandler(PointerPressedEvent, (_, _) => vm.IsRightPaneActive = true, RoutingStrategies.Tunnel, handledEventsToo: true); + gridLeft.GotFocus += (_, _) => vm.IsRightPaneActive = false; + gridRight.GotFocus += (_, _) => vm.IsRightPaneActive = true; + Content = grid; // The lent waveform arrives a beat after construction, and goes away again when a diff --git a/tests/UI/Features/Sync/SyncWaveformVerticalZoomKeyTests.cs b/tests/UI/Features/Sync/SyncWaveformVerticalZoomKeyTests.cs index f25caf9a50c..1228f703b31 100644 --- a/tests/UI/Features/Sync/SyncWaveformVerticalZoomKeyTests.cs +++ b/tests/UI/Features/Sync/SyncWaveformVerticalZoomKeyTests.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.Input; +using Avalonia.VisualTree; using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Features.Sync.PointSync.SetSyncPoint; @@ -9,6 +10,7 @@ using Nikse.SubtitleEdit.Logic.Media; using System; using System.Collections.Generic; +using System.Linq; namespace UITests.Features.Sync; @@ -94,10 +96,7 @@ public void SetSyncPoint_PlusWithoutShift_DoesNotZoom() Assert.Equal(1.0, vm.AudioVisualizer.VerticalZoomFactor, 6); } - [AvaloniaTheory] - [InlineData(Key.OemPlus, 0.9)] - [InlineData(Key.OemMinus, 1.1)] - public void VisualSync_ShiftMainRowPlusOrMinus_ZoomsTheFocusedPane(Key key, double expectedZoom) + private VisualSyncViewModel ShowVisualSync() { var vm = new VisualSyncViewModel(new WindowService(new NullServiceProvider()), new FileHelper(), new VideoPreviewSubtitle(new MpvReloader(), new VlcReloader()), @@ -107,6 +106,15 @@ public void VisualSync_ShiftMainRowPlusOrMinus_ZoomsTheFocusedPane(Key key, doub _windows.Add(window); vm.IsAudioVisualizerVisible = true; window.Show(); + return vm; + } + + [AvaloniaTheory] + [InlineData(Key.OemPlus, 0.9)] + [InlineData(Key.OemMinus, 1.1)] + public void VisualSync_ShiftMainRowPlusOrMinus_ZoomsTheFocusedPane(Key key, double expectedZoom) + { + var vm = ShowVisualSync(); vm.AudioVisualizerRight.Focus(); Assert.True(vm.AudioVisualizerRight.IsFocused); var e = ShiftKey(key); @@ -117,4 +125,53 @@ public void VisualSync_ShiftMainRowPlusOrMinus_ZoomsTheFocusedPane(Key key, doub Assert.Equal(expectedZoom, vm.AudioVisualizerRight.VerticalZoomFactor, 6); Assert.Equal(1.0, vm.AudioVisualizerLeft.VerticalZoomFactor, 6); // only the focused pane zooms } + + // Focus normally sits on a child of a pane (a button under the player, the player's position + // slider), never on the player itself - keys used to be dropped then (#15789). + [AvaloniaFact] + public void VisualSync_FocusOnButtonInsidePane_KeysGoToThatPane() + { + var vm = ShowVisualSync(); + var button = vm.PaneRight!.GetVisualDescendants().OfType private static void BindHighlight(Button button, AssaDrawViewModel vm, Func isOn, string propertyName) { - button.Classes.Set("accent", isOn()); + button.Classes.Set("active", isOn()); vm.PropertyChanged += (_, e) => { if (e.PropertyName == propertyName) { - button.Classes.Set("accent", isOn()); + button.Classes.Set("active", isOn()); } }; } @@ -522,42 +699,66 @@ private static Border CreateToolStrip(AssaDrawViewModel vm) { Orientation = Orientation.Vertical, Spacing = 4, + HorizontalAlignment = HorizontalAlignment.Center, }; - void AddTool(string icon, string tooltip, System.Windows.Input.ICommand command, DrawingTool tool) + void AddTool(string icon, string tooltip, System.Windows.Input.ICommand command, DrawingTool tool, string shortcut) { - var button = CreateToolButton(icon, tooltip, command, 38); + var button = CreateToolButton(icon, tooltip + " (" + shortcut + ")", command, 38, shortcut); BindHighlight(button, vm, () => vm.CurrentTool == tool, nameof(vm.CurrentTool)); panel.Children.Add(button); } - AddTool("fa-solid fa-arrow-pointer", Se.Language.Assa.DrawSelectTool, vm.SelectToolCommand, DrawingTool.Select); - AddTool("fa-solid fa-pen", Se.Language.Assa.DrawLineTool, vm.LineToolCommand, DrawingTool.Line); - AddTool("fa-solid fa-bezier-curve", Se.Language.Assa.DrawBezierTool, vm.BezierToolCommand, DrawingTool.Bezier); - AddTool("fa-regular fa-square", Se.Language.Assa.DrawRectangleTool, vm.RectangleToolCommand, DrawingTool.Rectangle); - AddTool("fa-regular fa-circle", Se.Language.Assa.DrawCircleTool, vm.CircleToolCommand, DrawingTool.Circle); + AddTool("fa-solid fa-arrow-pointer", Se.Language.Assa.DrawSelectTool, vm.SelectToolCommand, DrawingTool.Select, "V"); + panel.Children.Add(MakeStripSeparator()); + AddTool("fa-solid fa-pen-nib", Se.Language.Assa.DrawLineTool, vm.LineToolCommand, DrawingTool.Line, "L"); + AddTool("fa-solid fa-bezier-curve", Se.Language.Assa.DrawBezierTool, vm.BezierToolCommand, DrawingTool.Bezier, "B"); + AddTool("fa-regular fa-square", Se.Language.Assa.DrawRectangleTool, vm.RectangleToolCommand, DrawingTool.Rectangle, "R"); + AddTool("fa-regular fa-circle", Se.Language.Assa.DrawCircleTool, vm.CircleToolCommand, DrawingTool.Circle, "C"); + panel.Children.Add(MakeStripSeparator()); + panel.Children.Add(CreateToolButton("fa-solid fa-magnifying-glass-plus", Se.Language.Assa.DrawZoomIn, vm.ZoomInCommand, 38)); + panel.Children.Add(CreateToolButton("fa-solid fa-magnifying-glass-minus", Se.Language.Assa.DrawZoomOut, vm.ZoomOutCommand, 38)); + panel.Children.Add(CreateToolButton("fa-solid fa-expand", Se.Language.Assa.DrawResetView, vm.ResetViewCommand, 38)); - panel.Children.Add(new Border + // Current layer color, like the fill swatch of a tool palette + var swatch = new Border { - Height = 1, - Width = 26, - Background = UiUtil.GetBorderBrush(), - Margin = new Thickness(0, 6), + Width = 24, + Height = 24, + CornerRadius = new CornerRadius(5), + BorderBrush = Brushes.White, + BorderThickness = new Thickness(2), HorizontalAlignment = HorizontalAlignment.Center, - }); + [!Border.BackgroundProperty] = new Binding(nameof(vm.LayerColor)) { Converter = new FuncValueConverter(c => new SolidColorBrush(c)) }, + }; - panel.Children.Add(CreateToolButton("fa-solid fa-magnifying-glass-plus", Se.Language.Assa.DrawZoomIn, vm.ZoomInCommand, 38)); - panel.Children.Add(CreateToolButton("fa-solid fa-magnifying-glass-minus", Se.Language.Assa.DrawZoomOut, vm.ZoomOutCommand, 38)); - panel.Children.Add(CreateToolButton("fa-solid fa-expand", Se.Language.Assa.DrawResetView, vm.ResetViewCommand, 38)); + var dock = new DockPanel { LastChildFill = false }; + DockPanel.SetDock(panel, Dock.Top); + dock.Children.Add(panel); + swatch.Margin = new Thickness(0, 0, 0, 10); + DockPanel.SetDock(swatch, Dock.Bottom); + dock.Children.Add(swatch); return new Border { - Child = panel, - BorderBrush = UiUtil.GetBorderBrush(), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(6), - Padding = new Thickness(5), - Margin = new Thickness(0, 0, 8, 0), + Child = dock, + Width = 54, + Background = PanelBrush, + BorderBrush = LineBrush, + BorderThickness = new Thickness(0, 0, 1, 0), + Padding = new Thickness(0, 10, 0, 0), + }; + } + + private static Border MakeStripSeparator() + { + return new Border + { + Height = 1, + Width = 26, + Background = SeparatorBrush, + Margin = new Thickness(0, 4), + HorizontalAlignment = HorizontalAlignment.Center, }; } @@ -569,35 +770,16 @@ private Grid CreateContentArea(AssaDrawViewModel vm, out AssaDrawCanvas canvas) { new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, - new ColumnDefinition { Width = new GridLength(270, GridUnitType.Pixel) }, + new ColumnDefinition { Width = new GridLength(296, GridUnitType.Pixel) }, }, - RowDefinitions = - { - new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, - }, - Margin = new Thickness(0, 0, 0, 8), }; - var toolStrip = CreateToolStrip(vm); - contentGrid.Children.Add(toolStrip); - - // Drawing canvas - var canvasBorder = new Border - { - BorderBrush = UiUtil.GetBorderBrush(), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(6), - ClipToBounds = true, - Margin = new Thickness(0, 0, 8, 0), - }; + contentGrid.Children.Add(CreateToolStrip(vm)); canvas = new AssaDrawCanvas(); - canvasBorder.Child = canvas; + Grid.SetColumn(canvas, 1); + contentGrid.Children.Add(canvas); - Grid.SetColumn(canvasBorder, 1); - contentGrid.Children.Add(canvasBorder); - - // Side panel var sidePanel = CreateSidePanel(vm); Grid.SetColumn(sidePanel, 2); contentGrid.Children.Add(sidePanel); @@ -612,8 +794,9 @@ private static TextBlock MakeSectionHeader(string text) Text = text.ToUpperInvariant(), FontSize = 11, FontWeight = FontWeight.SemiBold, - Opacity = 0.65, - Margin = new Thickness(0, 0, 0, 6), + LetterSpacing = 0.6, + Foreground = DimTextBrush, + VerticalAlignment = VerticalAlignment.Center, }; } @@ -631,18 +814,23 @@ private Border CreateSidePanel(AssaDrawViewModel vm) }, }; - // Header - var headerLabel = MakeSectionHeader(Se.Language.Assa.DrawShapes); - headerLabel.Margin = new Thickness(10, 10, 10, 2); - Grid.SetRow(headerLabel, 0); - panelGrid.Children.Add(headerLabel); + // Header with quick actions + var header = new DockPanel { Margin = new Thickness(12, 10, 8, 4) }; + var headerButtons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2 }; + headerButtons.Children.Add(CreateToolButton("fa-regular fa-clone", Se.Language.General.Duplicate, vm.DuplicateShapeCommand, 24)); + headerButtons.Children.Add(CreateToolButton("fa-solid fa-trash", WithoutShortcut(Se.Language.Assa.DrawDeleteShape), vm.DeleteShapeCommand, 24)); + DockPanel.SetDock(headerButtons, Dock.Right); + header.Children.Add(headerButtons); + header.Children.Add(MakeSectionHeader(Se.Language.Assa.DrawShapes)); + Grid.SetRow(header, 0); + panelGrid.Children.Add(header); // Layers > shapes > points; selection is two-way so canvas picks show up here var treeView = new TreeView { [!TreeView.ItemsSourceProperty] = new Binding(nameof(vm.ShapeTreeItems)), [!TreeView.SelectedItemProperty] = new Binding(nameof(vm.SelectedTreeItem)) { Mode = BindingMode.TwoWay }, - Margin = new Thickness(4), + Margin = new Thickness(6, 0, 6, 6), }; treeView.Styles.Add(new Style(x => x.OfType()) { @@ -653,39 +841,82 @@ private Border CreateSidePanel(AssaDrawViewModel vm) }); var hiddenToOpacity = new FuncValueConverter(hidden => hidden ? 0.45 : 1.0); + var hiddenToIcon = new FuncValueConverter(hidden => hidden ? "fa-solid fa-eye-slash" : "fa-solid fa-eye"); treeView.ItemTemplate = new FuncTreeDataTemplate( (_, _) => { - var row = new StackPanel + var row = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnSpacing = 6, + }; + + var eye = CreateToolButton("fa-solid fa-eye", Se.Language.Assa.DrawHideShape, vm.ToggleItemVisibilityCommand, 22); + eye.Bind(Button.CommandParameterProperty, new Binding(".")); + eye.Bind(Button.IsVisibleProperty, new Binding(nameof(ShapeTreeItem.CanToggleVisibility))); + if (eye.Content is Optris.Icons.Avalonia.Icon eyeIcon) + { + eyeIcon.FontSize = 10; + eyeIcon.Bind(Optris.Icons.Avalonia.Icon.ValueProperty, new Binding(nameof(ShapeTreeItem.IsHidden)) { Converter = hiddenToIcon }); + } + + row.Children.Add(eye); + + var content = new StackPanel { Orientation = Orientation.Horizontal, - Spacing = 6, + Spacing = 7, [!StackPanel.OpacityProperty] = new Binding(nameof(ShapeTreeItem.IsHidden)) { Converter = hiddenToOpacity }, }; - row.Children.Add(new Optris.Icons.Avalonia.Icon + var icon = new Optris.Icons.Avalonia.Icon { FontSize = 11, Width = 14, - Opacity = 0.7, + Foreground = DimTextBrush, VerticalAlignment = VerticalAlignment.Center, [!Optris.Icons.Avalonia.Icon.ValueProperty] = new Binding(nameof(ShapeTreeItem.IconName)), - }); - row.Children.Add(new Border + }; + Grid.SetColumn(icon, 1); + row.Children.Add(icon); + + var swatch = new Border { - Width = 12, - Height = 12, - CornerRadius = new CornerRadius(3), - BorderBrush = UiUtil.GetBorderBrush(), - BorderThickness = new Thickness(1), + Width = 13, + Height = 13, + CornerRadius = new CornerRadius(4), VerticalAlignment = VerticalAlignment.Center, [!Border.BackgroundProperty] = new Binding(nameof(ShapeTreeItem.Swatch)), [!Border.IsVisibleProperty] = new Binding(nameof(ShapeTreeItem.HasSwatch)), - }); - row.Children.Add(new TextBlock + }; + Grid.SetColumn(swatch, 2); + row.Children.Add(swatch); + + content.Children.Add(new TextBlock { VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, [!TextBlock.TextProperty] = new Binding(nameof(ShapeTreeItem.Name)), }); + Grid.SetColumn(content, 3); + row.Children.Add(content); + + var meta = new TextBlock + { + FontSize = 11, + Foreground = FaintTextBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + [!TextBlock.TextProperty] = new Binding(nameof(ShapeTreeItem.Meta)), + }; + Grid.SetColumn(meta, 4); + row.Children.Add(meta); return row; }, item => item.Children); @@ -697,198 +928,269 @@ private Border CreateSidePanel(AssaDrawViewModel vm) Grid.SetRow(treeView, 1); panelGrid.Children.Add(treeView); - // Point editor panel - var pointEditorPanel = CreatePointEditorPanel(vm); - Grid.SetRow(pointEditorPanel, 2); - panelGrid.Children.Add(pointEditorPanel); + var shapePanel = CreateShapePropertiesPanel(vm); + Grid.SetRow(shapePanel, 2); + panelGrid.Children.Add(shapePanel); - // Shape editor panel (for shape-specific actions) - var shapeActionsPanel = CreateShapeActionsPanel(vm); - Grid.SetRow(shapeActionsPanel, 3); - panelGrid.Children.Add(shapeActionsPanel); + var layerPanel = CreateLayerPropertiesPanel(vm); + Grid.SetRow(layerPanel, 3); + panelGrid.Children.Add(layerPanel); - // Layer color editor panel - var layerEditorPanel = CreateLayerEditorPanel(vm); - Grid.SetRow(layerEditorPanel, 4); - panelGrid.Children.Add(layerEditorPanel); + var pointPanel = CreatePointPropertiesPanel(vm); + Grid.SetRow(pointPanel, 4); + panelGrid.Children.Add(pointPanel); return new Border { Child = panelGrid, - BorderBrush = UiUtil.GetBorderBrush(), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(6), + Background = PanelBrush, + BorderBrush = LineBrush, + BorderThickness = new Thickness(1, 0, 0, 0), }; } - private static Border MakePropertySection(Control content, string isVisibleProperty) + private static Border MakePropertySection(string title, Control content, string isVisibleProperty) { + var panel = new StackPanel { Spacing = 8 }; + panel.Children.Add(MakeSectionHeader(title)); + panel.Children.Add(content); return new Border { - Child = content, - BorderBrush = UiUtil.GetBorderBrush(), + Child = panel, + BorderBrush = LineBrush, BorderThickness = new Thickness(0, 1, 0, 0), - Padding = new Thickness(10, 8, 10, 10), + Padding = new Thickness(12, 10, 12, 12), [!Border.IsVisibleProperty] = new Binding(isVisibleProperty), }; } - private static Border CreatePointEditorPanel(AssaDrawViewModel vm) + /// + /// A "Label [field] [field]" row of the properties panel. + /// + private static Grid MakePropertyRow(string label, params Control[] fields) { - var panel = new StackPanel { Orientation = Orientation.Vertical }; - panel.Children.Add(MakeSectionHeader(Se.Language.Assa.DrawSelectedPoint)); - - // X and Y on their own rows - side by side the boxes were too narrow for 4 digits - var grid = new Grid + var grid = new Grid { ColumnSpacing = 6 }; + grid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(70))); + grid.Children.Add(MakeDimLabel(label, new Thickness(0))); + for (var i = 0; i < fields.Length; i++) { - ColumnDefinitions = - { - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, - }, - RowDefinitions = - { - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - }, - RowSpacing = 6, - }; + grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Star)); + Grid.SetColumn(fields[i], i + 1); + grid.Children.Add(fields[i]); + } - var xLabel = new TextBlock { Text = "X", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), Opacity = 0.75 }; - var xBox = new NumericUpDown - { - Minimum = -10000, - Maximum = 10000, - Increment = 1, - [!NumericUpDown.ValueProperty] = new Binding(nameof(vm.PointX)) { Mode = BindingMode.TwoWay }, - }; - var yLabel = new TextBlock { Text = "Y", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), Opacity = 0.75 }; - var yBox = new NumericUpDown - { - Minimum = -10000, - Maximum = 10000, - Increment = 1, - [!NumericUpDown.ValueProperty] = new Binding(nameof(vm.PointY)) { Mode = BindingMode.TwoWay }, - }; - grid.Children.Add(xLabel); - Grid.SetColumn(xBox, 1); - grid.Children.Add(xBox); - Grid.SetRow(yLabel, 1); - grid.Children.Add(yLabel); - Grid.SetRow(yBox, 1); - Grid.SetColumn(yBox, 1); - grid.Children.Add(yBox); - panel.Children.Add(grid); - - return MakePropertySection(panel, nameof(vm.IsPointSelected)); + return grid; } - private static Border CreateShapeActionsPanel(AssaDrawViewModel vm) + private static Border CreateShapePropertiesPanel(AssaDrawViewModel vm) { - var panel = new StackPanel { Orientation = Orientation.Vertical, Spacing = 6 }; - panel.Children.Add(MakeSectionHeader(Se.Language.Assa.DrawSelectedShape)); - - panel.Children.Add(new Button + var rows = new StackPanel { Spacing = 6 }; + rows.Children.Add(MakePropertyRow(Se.Language.Assa.DrawPosition, + MakeField(nameof(vm.ShapeX), -10000, 10000, prefix: "X", format: "0.#"), + MakeField(nameof(vm.ShapeY), -10000, 10000, prefix: "Y", format: "0.#"))); + rows.Children.Add(MakePropertyRow(Se.Language.Assa.DrawSize, + MakeField(nameof(vm.ShapeWidth), 0, 10000, prefix: "W", format: "0.#"), + MakeField(nameof(vm.ShapeHeight), 0, 10000, prefix: "H", format: "0.#"))); + + var colorButton = UiUtil.MakeColorPickerButton(vm, nameof(vm.LayerColor)); + colorButton.HorizontalAlignment = HorizontalAlignment.Left; + rows.Children.Add(MakePropertyRow(Se.Language.Assa.DrawLayer, + MakeField(nameof(vm.ShapeLayer), 0, 1000), + colorButton)); + + var eraser = new ToggleSwitch { - Content = Se.Language.Assa.DrawChangeLayer, - Command = vm.ChangeLayerCommand, - HorizontalAlignment = HorizontalAlignment.Left, - }); - - panel.Children.Add(new CheckBox - { - Content = Se.Language.Assa.DrawUseShapeForErase, - [!CheckBox.IsCheckedProperty] = new Binding(nameof(vm.ShapeIsEraser)) { Mode = BindingMode.TwoWay }, - }); + OnContent = null, + OffContent = null, + MinWidth = 0, + VerticalAlignment = VerticalAlignment.Center, + [!ToggleSwitch.IsCheckedProperty] = new Binding(nameof(vm.ShapeIsEraser)) { Mode = BindingMode.TwoWay }, + }; + AutomationProperties.SetName(eraser, Se.Language.Assa.DrawUseShapeForErase); + var eraserRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + eraserRow.Children.Add(eraser); + eraserRow.Children.Add(new TextBlock { Text = Se.Language.Assa.DrawUseShapeForErase, VerticalAlignment = VerticalAlignment.Center, FontSize = 12.5 }); + rows.Children.Add(eraserRow); - return MakePropertySection(panel, nameof(vm.IsShapeSelected)); + return MakePropertySection(Se.Language.Assa.DrawSelectedShape, rows, nameof(vm.IsShapeSelected)); } - private static Border CreateLayerEditorPanel(AssaDrawViewModel vm) + private static Border CreateLayerPropertiesPanel(AssaDrawViewModel vm) { - var panel = new StackPanel { Orientation = Orientation.Vertical, Spacing = 6 }; - panel.Children.Add(MakeSectionHeader(Se.Language.Assa.DrawSelectedLayer)); - var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; row.Children.Add(UiUtil.MakeColorPickerButton(vm, nameof(vm.LayerColor))); - row.Children.Add(new Button + var changeLayer = new Button { Content = Se.Language.Assa.DrawChangeLayer, Command = vm.ChangeLayerCommand, VerticalAlignment = VerticalAlignment.Center, - }); - panel.Children.Add(row); + }; + row.Children.Add(changeLayer); + return MakePropertySection(Se.Language.Assa.DrawSelectedLayer, row, nameof(vm.IsLayerSelected)); + } - return MakePropertySection(panel, nameof(vm.IsLayerSelected)); + private static Border CreatePointPropertiesPanel(AssaDrawViewModel vm) + { + var row = MakePropertyRow(Se.Language.Assa.DrawPosition, + MakeField(nameof(vm.PointX), -10000, 10000, prefix: "X", format: "0.#"), + MakeField(nameof(vm.PointY), -10000, 10000, prefix: "Y", format: "0.#")); + return MakePropertySection(Se.Language.Assa.DrawSelectedPoint, row, nameof(vm.IsPointSelected)); } private static Border CreateStatusBar(AssaDrawViewModel vm) { - var statusPanel = new StackPanel + var left = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 18, + VerticalAlignment = VerticalAlignment.Center, }; static StackPanel MakeStatusItem(string icon, Control text) { var item = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; - item.Children.Add(new Optris.Icons.Avalonia.Icon { Value = icon, FontSize = 11, Opacity = 0.6, VerticalAlignment = VerticalAlignment.Center }); + item.Children.Add(new Optris.Icons.Avalonia.Icon { Value = icon, FontSize = 11, Foreground = FaintTextBrush, VerticalAlignment = VerticalAlignment.Center }); item.Children.Add(text); return item; } - statusPanel.Children.Add(MakeStatusItem("fa-solid fa-crosshairs", new TextBlock + TextBlock StatusText(string property) => new() { - [!TextBlock.TextProperty] = new Binding(nameof(vm.PositionText)), + FontSize = 12, + Foreground = DimTextBrush, VerticalAlignment = VerticalAlignment.Center, - })); + [!TextBlock.TextProperty] = new Binding(property), + }; + + left.Children.Add(MakeStatusItem("fa-solid fa-crosshairs", StatusText(nameof(vm.PositionText)))); - statusPanel.Children.Add(MakeStatusItem("fa-solid fa-magnifying-glass", new TextBlock + var toolLabel = StatusText(nameof(vm.CurrentTool)); + toolLabel.Bind(TextBlock.TextProperty, new Binding(nameof(vm.CurrentTool)) { - [!TextBlock.TextProperty] = new Binding(nameof(vm.ZoomText)), + Converter = new FuncValueConverter(tool => string.Format(Se.Language.Assa.DrawToolX, tool)), + }); + left.Children.Add(MakeStatusItem("fa-solid fa-pen-ruler", toolLabel)); + + var selection = MakeStatusItem("fa-solid fa-draw-polygon", StatusText(nameof(vm.SelectionText))); + selection.Bind(StackPanel.IsVisibleProperty, new Binding(nameof(vm.SelectionText)) { Converter = StringConverters.IsNotNullOrEmpty }); + left.Children.Add(selection); + + left.Children.Add(new TextBlock + { + [!TextBlock.TextProperty] = new Binding(nameof(vm.PreviewStatusText)), + Foreground = Brushes.OrangeRed, + FontSize = 12, VerticalAlignment = VerticalAlignment.Center, - })); + }); - // Current tool indicator - var toolLabel = new TextBlock + var help = new TextBlock { + Text = Se.Language.Assa.DrawHelpText, + Foreground = FaintTextBrush, + FontSize = 12, VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(18, 0), }; - toolLabel.Bind(TextBlock.TextProperty, new Binding(nameof(vm.CurrentTool)) + ToolTip.SetTip(help, Se.Language.Assa.DrawHelpText); + + // Zoom: - slider + percent Fit + var zoom = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center }; + zoom.Children.Add(CreateToolButton("fa-solid fa-minus", WithoutShortcut(Se.Language.Assa.DrawZoomOut), vm.ZoomOutCommand, 22)); + zoom.Children.Add(new Slider { - Converter = new FuncValueConverter( - tool => string.Format(Se.Language.Assa.DrawToolX, tool)) + Minimum = 10, + Maximum = 400, + Width = 110, + VerticalAlignment = VerticalAlignment.Center, + [!Slider.ValueProperty] = new Binding(nameof(vm.ZoomPercent)) { Mode = BindingMode.TwoWay }, }); - statusPanel.Children.Add(MakeStatusItem("fa-solid fa-pen-ruler", toolLabel)); - - var previewStatusLabel = new TextBlock + zoom.Children.Add(CreateToolButton("fa-solid fa-plus", WithoutShortcut(Se.Language.Assa.DrawZoomIn), vm.ZoomInCommand, 22)); + zoom.Children.Add(new TextBlock { - [!TextBlock.TextProperty] = new Binding(nameof(vm.PreviewStatusText)), - Foreground = Brushes.OrangeRed, + Width = 44, + FontSize = 12, + TextAlignment = TextAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + [!TextBlock.TextProperty] = new Binding(nameof(vm.ZoomPercent)) { StringFormat = "{0:0}%" }, + }); + var fit = new Button + { + Content = WithoutShortcut(Se.Language.Assa.DrawResetView), + Command = vm.ResetViewCommand, + FontSize = 12, + Padding = new Thickness(10, 2), + MinHeight = 0, + Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, }; - statusPanel.Children.Add(previewStatusLabel); + zoom.Children.Add(fit); - // Help text - var helpLabel = new TextBlock + var grid = new Grid { - Text = Se.Language.Assa.DrawHelpText, - Foreground = UiUtil.GetTextColor(0.6), + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + }; + grid.Children.Add(left); + Grid.SetColumn(help, 1); + grid.Children.Add(help); + Grid.SetColumn(zoom, 2); + grid.Children.Add(zoom); + + return new Border + { + Child = grid, + Background = StatusBrush, + BorderBrush = LineBrush, + BorderThickness = new Thickness(0, 1, 0, 0), + Padding = new Thickness(12, 4), + MinHeight = 32, + }; + } + + private static Border CreateButtonBar(AssaDrawViewModel vm) + { + var code = new TextBlock + { + FontFamily = new FontFamily("Menlo, Consolas, Cascadia Mono, DejaVu Sans Mono, monospace"), + FontSize = 12, + Foreground = FaintTextBrush, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 0, 16, 0), + [!TextBlock.TextProperty] = new Binding(nameof(vm.CodePreview)), + }; + + var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); + buttonOk.Classes.Add("primary"); + var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 }; + buttons.Children.Add(buttonCancel); + buttons.Children.Add(buttonOk); + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, }; - statusPanel.Children.Add(helpLabel); + grid.Children.Add(code); + Grid.SetColumn(buttons, 1); + grid.Children.Add(buttons); return new Border { - Child = statusPanel, - BorderBrush = UiUtil.GetBorderBrush(), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(6), - Padding = new Thickness(10, 5), - Margin = new Thickness(0, 0, 0, 10), - ClipToBounds = true, + Child = grid, + Background = ButtonBarBrush, + BorderBrush = LineBrush, + BorderThickness = new Thickness(0, 1, 0, 0), + Padding = new Thickness(16, 10), }; } } diff --git a/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs b/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs index c30ef5c2d15..57a18009aef 100644 --- a/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs +++ b/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs @@ -42,6 +42,9 @@ public class LanguageAssa public string DrawRotateClockwise { get; set; } public string DrawConvertToCurve { get; set; } public string DrawBackground { get; set; } + public string DrawPosition { get; set; } + public string DrawSize { get; set; } + public string DrawLayer { get; set; } public string DrawBackgroundVideoFrame { get; set; } public string DrawBackgroundVideoFrameAt { get; set; } public string DrawBackgroundImage { get; set; } @@ -329,6 +332,9 @@ public LanguageAssa() DrawRotateClockwise = "Rotate 90° clockwise"; DrawConvertToCurve = "Convert segment to curve"; DrawBackground = "Background"; + DrawPosition = "Position"; + DrawSize = "Size"; + DrawLayer = "Layer"; DrawBackgroundVideoFrame = "Video frame (current position)"; DrawBackgroundVideoFrameAt = "Video frame at..."; DrawBackgroundImage = "Image file..."; diff --git a/tests/UI/Features/Assa/AssaDrawShapeEditingTests.cs b/tests/UI/Features/Assa/AssaDrawShapeEditingTests.cs index 6736b8db43f..cbb056c65e1 100644 --- a/tests/UI/Features/Assa/AssaDrawShapeEditingTests.cs +++ b/tests/UI/Features/Assa/AssaDrawShapeEditingTests.cs @@ -132,6 +132,26 @@ public void DeletePoint_FirstPointOfBezier_NextEndPointBecomesStart() Assert.Equal("m 30 0 l 30 30", shape.ToAssa()); } + [Fact] + public void ShapeFields_ShowBounds_AndEditingThemMovesScalesAndRelayers() + { + var vm = MakeViewModel("{\\p1}m 100 100 l 300 100 300 200 100 200{\\p0}"); + var shape = vm.Shapes[0]; + vm.SelectShape(shape); + Assert.Equal(100, vm.ShapeX); + Assert.Equal(200, vm.ShapeWidth); + Assert.Equal(100, vm.ShapeHeight); + + vm.ShapeX = 150; + vm.ShapeWidth = 400; + vm.ShapeLayer = 5; + + Assert.Equal("m 150 100 l 550 100 550 200 150 200", shape.ToAssa()); + Assert.Equal(5, shape.Layer); + Assert.Same(shape, vm.SelectedTreeItem?.Shape); + Assert.Contains("550", vm.CodePreview); + } + [Fact] public void ToggleShapeVisibility_SelectionSurvivesTreeRebuild() { From 4bdc545fc66432a01bab128501c63fd826c4b607 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 10 Oct 2026 07:18:17 +0200 Subject: [PATCH 108/110] ASSA draw: fix empty background menu The Background toolbar button used a MenuFlyout whose items were set in its Opening event, which opened as an empty sliver. The menu is now built on click and opened under the button, like the canvas menu. Co-Authored-By: Claude Opus 5.5 --- .../Features/Assa/AssaDraw/AssaDrawWindow.cs | 11 ++-- .../Features/Assa/AssaDrawBackgroundTests.cs | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs b/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs index f30bef71600..c7d3a54e198 100644 --- a/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs +++ b/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs @@ -516,9 +516,14 @@ private static Border CreateToolbar(AssaDrawViewModel vm) // Background: video frame or image to trace over, with its opacity var backgroundButton = CreateToolButton("fa-regular fa-image", Se.Language.Assa.DrawBackground, null); BindHighlight(backgroundButton, vm, () => vm.HasBackground, nameof(vm.HasBackground)); - var backgroundFlyout = new MenuFlyout(); - backgroundFlyout.Opening += (_, _) => backgroundFlyout.ItemsSource = MakeBackgroundItems(vm); - backgroundButton.Flyout = backgroundFlyout; + // Items depend on the current state (video loaded, background set), so the menu is built on + // click - a MenuFlyout filled in its Opening event showed up empty. + var backgroundMenu = new ContextMenu { Placement = PlacementMode.BottomEdgeAlignedLeft }; + backgroundButton.Click += (_, _) => + { + backgroundMenu.ItemsSource = MakeBackgroundItems(vm); + backgroundMenu.Open(backgroundButton); + }; leftPanel.Children.Add(backgroundButton); var opacitySlider = new Slider { diff --git a/tests/UI/Features/Assa/AssaDrawBackgroundTests.cs b/tests/UI/Features/Assa/AssaDrawBackgroundTests.cs index 9887aa7b90d..2fd2faf8198 100644 --- a/tests/UI/Features/Assa/AssaDrawBackgroundTests.cs +++ b/tests/UI/Features/Assa/AssaDrawBackgroundTests.cs @@ -1,4 +1,11 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.VisualTree; +using Nikse.SubtitleEdit.Logic; using Avalonia.Threading; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.SubtitleFormats; @@ -108,6 +115,50 @@ public void OpacityAndStretch_AreRemembered_AndReachTheCanvas() } } + private sealed class PickFileHelper(string fileName) : StubFileHelper + { + public override Task PickOpenFile(Visual sender, string title, string extensionTitle, string extension, string extensionTitle2 = "", string extension2 = "", string? suggestedStartFolder = null) + => Task.FromResult(fileName); + } + + /// + /// The toolbar button used a MenuFlyout filled in its Opening event, which opened empty. + /// + [AvaloniaFact] + public void BackgroundButton_OpensMenu_AndImageItemSetsBackground() + { + var png = MakePng(640, 360); + var vm = new AssaDrawViewModel(new PickFileHelper(png), new StubWindowService()); + vm.Initialize(new Subtitle { Header = AdvancedSubStationAlpha.DefaultHeader }, [], 1920, 1080); + var window = new AssaDrawWindow(vm) { Width = 1400, Height = 860 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + try + { + var button = window.GetVisualDescendants().OfType internal static bool AnimateScrollToSection { get; set; } = true; + internal bool DeferSectionRefresh { get; private set; } - public async void ScrollElementIntoView(ScrollViewer scrollViewer, Control target, NavigationMethod? focusFirstControl = null) + private bool _isSectionTransitionRunning; + private (Func RefreshContent, NavigationMethod? FocusMethod)? _pendingSectionTransition; + + public async void ScrollElementIntoView(ScrollViewer scrollViewer, Func refreshContent, NavigationMethod? focusFirstControl = null) { - await Dispatcher.UIThread.InvokeAsync(async () => + // Keep only the latest request and run one fade/content transition at a time. + _pendingSectionTransition = (refreshContent, focusFirstControl); + if (_isSectionTransitionRunning) { - await Task.Yield(); // Ensures target has been laid out - - // Fade out - //await FadeToAsync(ScrollView, 0, TimeSpan.FromMilliseconds(100)); - if (AnimateScrollToSection) - { - await RunFadeAnimation(ScrollView, from: 1, to: 0, TimeSpan.FromMilliseconds(100)); - } - - - await Task.Yield(); // Ensures target has been laid out - scrollViewer.ScrollToHome(); - await Task.Yield(); // Ensures target has been laid out - - var targetPosition = target.TranslatePoint(new Point(0, 0), scrollViewer); - if (targetPosition.HasValue) - { - scrollViewer.Offset = new Vector(scrollViewer.Offset.X, targetPosition.Value.Y); - } - - if (focusFirstControl.HasValue) - { - FocusFirstTabStop(target, focusFirstControl.Value); - } + return; + } - await Task.Yield(); // Ensures target has been laid out - if (AnimateScrollToSection) + _isSectionTransitionRunning = true; + try + { + await Dispatcher.UIThread.InvokeAsync(async () => { - await RunFadeAnimation(ScrollView, from: 0, to: 1, TimeSpan.FromMilliseconds(200)); - } - }, DispatcherPriority.Background); + while (_pendingSectionTransition.HasValue) + { + if (AnimateScrollToSection) + { + await RunFadeAnimation(ScrollView, from: 1, to: 0, TimeSpan.FromMilliseconds(100)); + } + + // Include any requests received during fade-out in this content swap. + var request = _pendingSectionTransition.Value; + _pendingSectionTransition = null; + var target = request.RefreshContent(); + await Task.Yield(); // Ensures target has been laid out + scrollViewer.ScrollToHome(); + await Task.Yield(); // Ensures target has been laid out + + var targetPosition = target?.TranslatePoint(new Point(0, 0), scrollViewer); + if (targetPosition.HasValue) + { + scrollViewer.Offset = new Vector(scrollViewer.Offset.X, targetPosition.Value.Y); + } + + if (target != null && request.FocusMethod.HasValue && !_pendingSectionTransition.HasValue) + { + FocusFirstTabStop(target, request.FocusMethod.Value); + } + + await Task.Yield(); // Ensures target has been laid out + if (AnimateScrollToSection) + { + await RunFadeAnimation(ScrollView, from: 0, to: 1, TimeSpan.FromMilliseconds(200)); + } + // Requests received during fade-in are coalesced into the next transition. + } + }, DispatcherPriority.Background); + } + finally + { + _isSectionTransitionRunning = false; + } } private static void FocusFirstTabStop(Control container, NavigationMethod navigationMethod) @@ -2851,17 +2873,34 @@ internal void NavigateSectionHistory(bool back) private void ShowSection(SettingsSection section, NavigationMethod navigationMethod) { - SelectedSection = section; // the page rebuilds the content to this section + // Keep selection/history immediate; refresh the content after fade-out. + DeferSectionRefresh = AnimateScrollToSection && section.IsVisible; + try + { + SelectedSection = section; + } + finally + { + DeferSectionRefresh = false; + } // Hidden by the search filter (no matching settings) - nothing to scroll or focus. - if (section.Panel == null || !section.IsVisible) + if (!section.IsVisible) { return; } // Move focus into the section, not only the view - with focus left on the category // button, the categories did nothing for a screen reader or keyboard user (#12087). - ScrollElementIntoView(ScrollView, section.Panel, navigationMethod); + ScrollElementIntoView(ScrollView, () => + { + if (AnimateScrollToSection && Window != null && UiTheme.GetUnscaledContent(Window) is SettingsPage page) + { + page.RefreshSections(); + } + + return SelectedSection?.Panel; + }, navigationMethod); } [RelayCommand]