From 19fcef583b348e1bdc531fc88c08f9ff15c6ed8b Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 9 Aug 2026 08:23:20 +0200 Subject: [PATCH] Remove per-line and per-word allocations from hot text paths Round 4 of the micro-perf hunt, in the same vein as #13383: the waste is allocations and rescans in helpers that run once per subtitle line, per word or per character. Change casing (the big one) - StrippableText's name loop lower-cased every entry of the name list on every paragraph. For English that list is ~8000 names, so a 200-line subtitle allocated 1.6 million throwaway strings. "lower" is already lower case, so an OrdinalIgnoreCase search finds the same positions - which is what the loop's own continuation search already used. - Hoisted the name-end character set (it concatenated a literal with Environment.NewLine per candidate match) and replaced two sb.ToString().EndsWith(..) suffix tests, which copied the whole accumulated line per character, with an in-place compare. - FixCasingAfterTitles took a Substring of the rest of the line for every character position; it now compares the tail in place. - FixCasing ran RemoveHtmlTags twice on the same text per paragraph. Character counting - CalcCjk.IsCjk allocated a one-character string and ran a regex over it for every character outside two hard-coded ranges - and the grid re-reads CPS and line length on every repaint. Now tests the block ranges directly; CalcCjkTest pins it to the old regex for all 65536 chars. - CalcNoSpaceCpsOnly / CalcNoSpaceOrPunctuationCpsOnly allocated a new calculator per call, throwing away CalcFactory's memoization. Subtitle formats - SubStationAlpha: ported the two fixes [V4+ Styles] already had - style lookup through a HashSet instead of a linear list scan per paragraph, and TrimBuilder instead of copying the finished output twice. - MicroDVD: ten tag branches each counted a tag over the whole line before the cheap StartsWith that rejects it; operands swapped. - SAMI: dropped an uppercased copy of each cue that fed an already ignore-case search, replaced two substring+uppercase character scans, hoisted a character set out of a per-character loop, and built the milliseconds string in the StringBuilder that was already in scope. - SubViewer 2.0's IsMine joined the whole file into one string to look for "[br]", which cannot straddle a line. - Regex.Match(x).Success -> IsMatch(x) in 32 places (allocates a Match plus its group machinery per line, for a bool). Hearing impaired / fix common errors / OCR / spell check - Utilities.IsAllUppercase and HasUppercase replace "s == s.ToUpperInvariant()" and "s != s.ToLowerInvariant()" at 11 sites; both are pinned to the string comparison they replace for every character. - The uppercase whitelist set was rebuilt from settings on every line. - ReInsertHtmlTags did two dictionary probes per character; TryGetValue now. - Helper.FixDash ran RemoveHtmlTags twice per call and counted at most three lines through LINQ with a TrimStart string per line. - OcrFixReplaceList2: four ContainsKey+indexer pairs each building their key twice, two inline char[] allocations and a path scan with a concatenation, all per OCR'd word. - SpellCheckWordLists built both candidate phrases inside the loop over the user phrase list rather than once. Verified with BenchmarkDotNet (Apple M4, .NET 10), same benchmarks run against a stashed baseline: | Benchmark | Before | After | Ratio | Alloc before | Alloc after | |------------------------|-----------|-----------|-------|--------------|-------------| | FixCasingNormal (200) | 53.05 ms | 26.26 ms | 0.50 | 63.16 MB | 1.64 MB | | LoadSami (500) | 1.554 ms | 1.211 ms | 0.78 | 4.47 MB | 2.04 MB | | SubStationAlphaToText | 628.8 us | 473.2 us | 0.75 | 879.7 KB | 617.6 KB | | MicroDvdToText (500) | 189.5 us | 151.4 us | 0.80 | 338.0 KB | 338.0 KB | | CalcCjk CountLatin | 3.172 us | 2.184 us | 0.69 | 2496 B | 1248 B | | RemoveHearingImpaired | 1.159 ms | 1.128 ms | 0.97 | 2.62 MB | 2.61 MB | | AutoBreak (tagged) | 10.375 us | 10.251 us | 0.99 | 11.72 KB | 11.72 KB | The last two are within noise - those changes are allocation hygiene, not a measurable win, and are kept because they are strictly less work. Behaviour: 948 libse + 149 libuilogic tests pass, and a round-trip harness over 14 formats (write, read back, IsMine; plain and styled input) produces byte-identical output before and after. Co-Authored-By: Claude Fable 5 --- src/libse/Common/FixCasing.cs | 25 +- src/libse/Common/StrippableText.cs | 42 +++- .../Common/TextLengthCalculator/CalcCJK.cs | 28 ++- .../CalcNoSpaceCpsOnly.cs | 9 +- .../CalcNoSpaceOrPunctuationCpsOnly.cs | 9 +- src/libse/Common/Utilities.cs | 48 +++- src/libse/Forms/FixCommonErrors/Helper.cs | 20 +- src/libse/Forms/RemoveTextForHI.cs | 71 ++++-- src/libse/SubtitleFormats/DvdStudioPro.cs | 2 +- .../SubtitleFormats/DvdStudioProSpace.cs | 2 +- .../DvdStudioProSpaceGraphic.cs | 2 +- .../SubtitleFormats/DvdStudioProSpaceOne.cs | 2 +- .../DvdStudioProSpaceOneSemicolon.cs | 2 +- src/libse/SubtitleFormats/DvdSubtitle.cs | 2 +- src/libse/SubtitleFormats/Lrc.cs | 4 +- src/libse/SubtitleFormats/Lrc3DigitsMs.cs | 4 +- src/libse/SubtitleFormats/LrcNoEndTime.cs | 4 +- src/libse/SubtitleFormats/MicroDvd.cs | 18 +- src/libse/SubtitleFormats/NVivoTranscript.cs | 4 +- src/libse/SubtitleFormats/Sami.cs | 22 +- src/libse/SubtitleFormats/SubStationAlpha.cs | 12 +- src/libse/SubtitleFormats/SubViewer20.cs | 4 +- src/libse/SubtitleFormats/TMPlayer.cs | 2 +- .../SubtitleFormats/UnknownSubtitle106.cs | 2 +- .../SubtitleFormats/UnknownSubtitle33.cs | 4 +- .../SubtitleFormats/UnknownSubtitle34.cs | 2 +- .../SubtitleFormats/UnknownSubtitle41.cs | 2 +- .../SubtitleFormats/UnknownSubtitle46.cs | 2 +- .../SubtitleFormats/UnknownSubtitle47.cs | 2 +- .../SubtitleFormats/UnknownSubtitle48.cs | 2 +- .../SubtitleFormats/UnknownSubtitle51.cs | 2 +- .../SubtitleFormats/UnknownSubtitle52.cs | 2 +- .../SubtitleFormats/UnknownSubtitle53.cs | 2 +- .../SubtitleFormats/UnknownSubtitle59.cs | 6 +- .../SubtitleFormats/UnknownSubtitle86.cs | 2 +- .../SubtitleFormats/UnknownSubtitle98.cs | 2 +- .../SubtitleFormats/UnknownSubtitle99.cs | 2 +- .../Ocr/FixEngine/OcrFixReplaceList2.cs | 33 ++- .../SpellCheck/SpellCheckWordLists.cs | 11 +- .../benchmarks/HotTextPathRound4Benchmarks.cs | 218 ++++++++++++++++++ tests/libse/Common/CalcCjkTest.cs | 35 +++ .../libse/Common/UtilitiesCasingProbeTest.cs | 40 ++++ 42 files changed, 578 insertions(+), 131 deletions(-) create mode 100644 tests/benchmarks/HotTextPathRound4Benchmarks.cs create mode 100644 tests/libse/Common/CalcCjkTest.cs create mode 100644 tests/libse/Common/UtilitiesCasingProbeTest.cs diff --git a/src/libse/Common/FixCasing.cs b/src/libse/Common/FixCasing.cs index 14425df96db..7ba5b75c677 100644 --- a/src/libse/Common/FixCasing.cs +++ b/src/libse/Common/FixCasing.cs @@ -159,26 +159,29 @@ private static string FixEnglishAloneILowerToUpper(string input) return text; } + private static readonly string[] CasingTitles = { "Mrs.", "Miss.", "Mr.", "Ms.", "Dr." }; + private static readonly string[] CasingNotChangeWords = { "does", "has", "will", "is", "and", "for", "but", "or", "of" }; + private string FixCasingAfterTitles(string input) { var text = input; - var titles = new[] { "Mrs.", "Miss.", "Mr.", "Ms.", "Dr." }; - var notChangeWords = new[] { "does", "has", "will", "is", "and", "for", "but", "or", "of" }; for (int i = 0; i < text.Length - 4; i++) { - var start = text.Substring(i); - foreach (var title in titles) + // Compare against the tail in place - taking a Substring here allocated the + // rest of the line for every character position (quadratic on long lines). + var start = text.AsSpan(i); + foreach (var title in CasingTitles) { - if (start.StartsWith(title, StringComparison.OrdinalIgnoreCase)) + if (start.StartsWith(title.AsSpan(), StringComparison.OrdinalIgnoreCase)) { var idx = i + title.Length; if (idx < text.Length - 2 && text[idx] == ' ') { idx++; var words = text.Substring(idx).Split(' ', '\r', '\n', ',', '"', '?', '!', '.', '\''); - if (words.Length > 0 && !notChangeWords.Contains(words[0])) + if (words.Length > 0 && !CasingNotChangeWords.Contains(words[0])) { - var upper = text[idx].ToString().ToUpperInvariant(); + var upper = char.ToUpperInvariant(text[idx]).ToString(); text = text.Remove(idx, 1).Insert(idx, upper); } } @@ -256,9 +259,13 @@ private string Fix(string original, string lastLine, List nameList, Cult var text = original; if (FixNormal) { - if (FixNormalOnlyAllUppercase && HtmlUtil.RemoveHtmlTags(text, true) != HtmlUtil.RemoveHtmlTags(text, true).ToUpper(subtitleCulture)) + if (FixNormalOnlyAllUppercase) { - return text; + var noTags = HtmlUtil.RemoveHtmlTags(text, true); + if (noTags != noTags.ToUpper(subtitleCulture)) + { + return text; + } } if (text.Length > 1) diff --git a/src/libse/Common/StrippableText.cs b/src/libse/Common/StrippableText.cs index 376aa0defd9..4dceaccc1a5 100644 --- a/src/libse/Common/StrippableText.cs +++ b/src/libse/Common/StrippableText.cs @@ -6,6 +6,36 @@ namespace Nikse.SubtitleEdit.Core.Common { public class StrippableText { + /// + /// Characters allowed to follow a name. Built once - the name loop below tested this + /// per candidate match, and concatenating the literal with Environment.NewLine there + /// allocated a fresh string every time. + /// + private static readonly string NameEndChars = @" ,.!?:;')]- <”""" + Environment.NewLine; + + /// + /// Suffix test that does not copy the whole builder - the casing loop below asks this + /// once per character, and sb.ToString() there allocated the accumulated line each time. + /// + private static bool EndsWith(StringBuilder sb, string value) + { + if (sb.Length < value.Length) + { + return false; + } + + var offset = sb.Length - value.Length; + for (var i = 0; i < value.Length; i++) + { + if (sb[offset + i] != value[i]) + { + return false; + } + } + + return true; + } + public string Pre { get; set; } public string Post { get; set; } public string StrippedText { get; set; } @@ -138,14 +168,16 @@ private void ReplaceNames1Remove(IEnumerable nameList, List repl int idName = 0; foreach (string name in nameList) { - int start = lower.IndexOf(name.ToLowerInvariant(), StringComparison.Ordinal); + // "lower" is already lower case, so an ignore-case search finds the same + // positions as the lower-cased name did - without allocating one string per name. + int start = lower.IndexOf(name, StringComparison.OrdinalIgnoreCase); while (start >= 0 && start < lower.Length) { bool startOk = (start == 0) || (lower[start - 1] == ' ') || (lower[start - 1] == '-') || (lower[start - 1] == '"') || (lower[start - 1] == '\'') || (lower[start - 1] == '>') || (lower[start - 1] == '[') || (lower[start - 1] == '“') || Environment.NewLine.EndsWith(lower[start - 1]); - if (startOk && string.CompareOrdinal(name, "Don") == 0 && lower.Substring(start).StartsWith("don't", StringComparison.Ordinal)) + if (startOk && string.CompareOrdinal(name, "Don") == 0 && lower.AsSpan(start).StartsWith("don't".AsSpan(), StringComparison.Ordinal)) { startOk = false; } @@ -156,7 +188,7 @@ private void ReplaceNames1Remove(IEnumerable nameList, List repl bool endOk = end <= lower.Length; if (endOk) { - endOk = end == lower.Length || (@" ,.!?:;')]- <”""" + Environment.NewLine).Contains(lower[end]); + endOk = end == lower.Length || NameEndChars.Contains(lower[end]); } if (endOk && StrippedText.Length >= start + name.Length) @@ -268,7 +300,7 @@ public void FixCasing(IEnumerable nameList, bool changeNameCases, bool m { sb.Append(s); } - else if ((sb.EndsWith('<') || sb.ToString().EndsWith("') + else if ((sb.EndsWith('<') || EndsWith(sb, "') { // tags sb.Append(s); } @@ -276,7 +308,7 @@ public void FixCasing(IEnumerable nameList, bool changeNameCases, bool m { // tags sb.Append(s); } - else if (sb.ToString().EndsWith("... ", StringComparison.Ordinal)) + else if (EndsWith(sb, "... ")) { sb.Append(s); lastWasBreak = false; diff --git a/src/libse/Common/TextLengthCalculator/CalcCJK.cs b/src/libse/Common/TextLengthCalculator/CalcCJK.cs index 4a03f8b482c..d0dc1a9463d 100644 --- a/src/libse/Common/TextLengthCalculator/CalcCJK.cs +++ b/src/libse/Common/TextLengthCalculator/CalcCJK.cs @@ -91,20 +91,26 @@ public decimal CountCharacters(string text, bool forCps) @"\p{IsCJKUnifiedIdeographs}|" + @"\p{IsHangulSyllables}|" + @"\p{IsCJKCompatibilityForms}", RegexOptions.Compiled); + /// + /// True for the Unicode blocks matches, plus Hiragana. + /// This runs once per character of every line the CJK length calculators measure (the + /// subtitle grid re-reads those on each repaint), so it tests the block ranges directly + /// instead of allocating a one-character string and running the regex over it. + /// CalcCjkTest.IsCjk_MatchesRegexForEveryChar pins it to the regex for all 65536 chars. + /// public static bool IsCjk(char c) { var v = (int)c; - if (v >= 0x3040 && v <= 0x309F) // Hiragana - { - return true; - } - - if (v >= 0x4E00 && v <= 0x9FAF) // Common and uncommon kanji - { - return true; - } - - return CjkCharRegex.IsMatch(c.ToString()); + return v >= 0x1100 && v <= 0x11FF || // Hangul Jamo + v >= 0x2E80 && v <= 0x2EFF || // CJK Radicals Supplement + v >= 0x3000 && v <= 0x303F || // CJK Symbols and Punctuation + v >= 0x3040 && v <= 0x309F || // Hiragana + v >= 0x3200 && v <= 0x32FF || // Enclosed CJK Letters and Months + v >= 0x3300 && v <= 0x33FF || // CJK Compatibility + v >= 0x3400 && v <= 0x4DBF || // CJK Unified Ideographs Extension A + v >= 0x4E00 && v <= 0x9FFF || // CJK Unified Ideographs + v >= 0xAC00 && v <= 0xD7AF || // Hangul Syllables + v >= 0xFE30 && v <= 0xFE4F; // CJK Compatibility Forms } } } diff --git a/src/libse/Common/TextLengthCalculator/CalcNoSpaceCpsOnly.cs b/src/libse/Common/TextLengthCalculator/CalcNoSpaceCpsOnly.cs index 240d3fc801f..35c435efa54 100644 --- a/src/libse/Common/TextLengthCalculator/CalcNoSpaceCpsOnly.cs +++ b/src/libse/Common/TextLengthCalculator/CalcNoSpaceCpsOnly.cs @@ -2,6 +2,11 @@ { public class CalcNoSpaceCpsOnly : ICalcLength { + // Both are stateless; CalcFactory memoizes the strategy lookup, so allocating a fresh + // one per call - on every grid repaint and keystroke - threw that away. + private static readonly CalcNoSpace NoSpace = new CalcNoSpace(); + private static readonly CalcAll All = new CalcAll(); + /// /// Calculate all text excluding space (tags are not counted). /// @@ -9,10 +14,10 @@ public decimal CountCharacters(string text, bool forCps) { if (forCps) { - return new CalcNoSpace().CountCharacters(text, true); + return NoSpace.CountCharacters(text, true); } - return new CalcAll().CountCharacters(text, false); + return All.CountCharacters(text, false); } } } diff --git a/src/libse/Common/TextLengthCalculator/CalcNoSpaceOrPunctuationCpsOnly.cs b/src/libse/Common/TextLengthCalculator/CalcNoSpaceOrPunctuationCpsOnly.cs index 3aae74f6378..ca8f13006ca 100644 --- a/src/libse/Common/TextLengthCalculator/CalcNoSpaceOrPunctuationCpsOnly.cs +++ b/src/libse/Common/TextLengthCalculator/CalcNoSpaceOrPunctuationCpsOnly.cs @@ -2,6 +2,11 @@ { public class CalcNoSpaceOrPunctuationCpsOnly : ICalcLength { + // Both are stateless; CalcFactory memoizes the strategy lookup, so allocating a fresh + // one per call - on every grid repaint and keystroke - threw that away. + private static readonly CalcNoSpaceOrPunctuation NoSpaceOrPunctuation = new CalcNoSpaceOrPunctuation(); + private static readonly CalcAll All = new CalcAll(); + /// /// Calculate all text except punctuation or space (tags are not counted) for cps only. /// Line length calc all characters. @@ -10,10 +15,10 @@ public decimal CountCharacters(string text, bool forCps) { if (forCps) { - return new CalcNoSpaceOrPunctuation().CountCharacters(text, false); + return NoSpaceOrPunctuation.CountCharacters(text, false); } - return new CalcAll().CountCharacters(text, false); + return All.CountCharacters(text, false); } } } diff --git a/src/libse/Common/Utilities.cs b/src/libse/Common/Utilities.cs index 58a558cea63..17af9aaa0ae 100644 --- a/src/libse/Common/Utilities.cs +++ b/src/libse/Common/Utilities.cs @@ -486,9 +486,8 @@ public static string AutoBreakLineMoreThanTwoLines(string text, int maximumLengt foreach (var item in list) { index += item; - if (htmlTags.ContainsKey(index)) + if (htmlTags.TryGetValue(index, out var v)) { - var v = htmlTags[index]; if (v.StartsWith(" htmlTag int six = 0; foreach (var letter in s) { - if (Environment.NewLine.Contains(letter)) + if (letter == '\r' || letter == '\n') { sb.Append(letter); } else { - if (htmlTags.ContainsKey(six)) + // One probe per character instead of two - auto-break runs this per line + // on every split/merge, and per keystroke with auto-break while typing. + if (htmlTags.TryGetValue(six, out var tag)) { - sb.Append(htmlTags[six]); + sb.Append(tag); } sb.Append(letter); six++; @@ -878,9 +879,9 @@ private static string ReInsertHtmlTags(string s, Dictionary htmlTag for (int i = 0; i < 15; i++) { - if (htmlTags.ContainsKey(six + i)) + if (htmlTags.TryGetValue(six + i, out var tag)) { - sb.Append(htmlTags[six + i]); + sb.Append(tag); } } @@ -889,6 +890,39 @@ private static string ReInsertHtmlTags(string s, Dictionary htmlTag return s; } + /// + /// Same answer as s == s.ToUpperInvariant(), without allocating the uppercased + /// copy. The hearing-impaired and casing rules ask this several times per subtitle line. + /// + public static bool IsAllUppercase(string s) + { + for (var i = 0; i < s.Length; i++) + { + if (char.ToUpperInvariant(s[i]) != s[i]) + { + return false; + } + } + + return true; + } + + /// + /// Same answer as s != s.ToLowerInvariant(), without allocating the lowercased copy. + /// + public static bool HasUppercase(string s) + { + for (var i = 0; i < s.Length; i++) + { + if (char.ToLowerInvariant(s[i]) != s[i]) + { + return true; + } + } + + return false; + } + public static string UnbreakLine(string text) { var lines = text.SplitToLines(); diff --git a/src/libse/Forms/FixCommonErrors/Helper.cs b/src/libse/Forms/FixCommonErrors/Helper.cs index 8d5740cde8f..1709529bf98 100644 --- a/src/libse/Forms/FixCommonErrors/Helper.cs +++ b/src/libse/Forms/FixCommonErrors/Helper.cs @@ -405,7 +405,8 @@ public static string FixHyphensRemoveForSingleLine(Subtitle subtitle, string inp else if (text.StartsWith(" line.TrimStart().StartsWith(dash, StringComparison.Ordinal)); + // Counting at most three lines - the LINQ form allocated a closure, a delegate + // and a TrimStart() string per line, per paragraph. + var startHyphenCount = 0; + foreach (var line in noTagLines) + { + if (line.AsSpan().TrimStart().StartsWith(dash.AsSpan(), StringComparison.Ordinal)) + { + startHyphenCount++; + } + } + if (startHyphenCount == 1) { var remove = true; diff --git a/src/libse/Forms/RemoveTextForHI.cs b/src/libse/Forms/RemoveTextForHI.cs index 702f5b79be1..4249b14a235 100644 --- a/src/libse/Forms/RemoveTextForHI.cs +++ b/src/libse/Forms/RemoveTextForHI.cs @@ -19,6 +19,38 @@ public class RemoveTextForHI private IList _interjections; private IList _interjectionsSkipIfStartsWith; + // RemoveLineIfAllUppercase runs once per subtitle line and rebuilt this set from the + // settings every time. Cached against the list instance it was built from, so a + // settings change still takes effect. + private HashSet _uppercaseWhitelist; + private List _uppercaseWhitelistSource; + + private HashSet GetUppercaseWhitelist() + { + var source = Settings.UppercaseWhitelist; + if (_uppercaseWhitelist != null && ReferenceEquals(_uppercaseWhitelistSource, source)) + { + return _uppercaseWhitelist; + } + + var whitelist = new HashSet(StringComparer.OrdinalIgnoreCase); + if (source != null) + { + foreach (var w in source) + { + var trimmedWord = w.Trim(); + if (trimmedWord.Length > 0) + { + whitelist.Add(trimmedWord); + } + } + } + + _uppercaseWhitelistSource = source; + _uppercaseWhitelist = whitelist; + return whitelist; + } + public RemoveTextForHI(RemoveTextForHISettings removeTextForHISettings) { Settings = removeTextForHISettings; @@ -101,7 +133,7 @@ public string RemoveColon(string text) // House 7x01 line 52: and she would like you to do three things: // Okay or remove??? var noTagText = HtmlUtil.RemoveHtmlTags(text); - if (noTagText.Length > 10 && noTagText.IndexOf(':') == noTagText.Length - 1 && noTagText != noTagText.ToUpperInvariant()) + if (noTagText.Length > 10 && noTagText.IndexOf(':') == noTagText.Length - 1 && !Utilities.IsAllUppercase(noTagText)) { return preAssTag + text; } @@ -135,7 +167,7 @@ public string RemoveColon(string text) { var pre = line.Substring(0, indexOfColon); var noTagPre = HtmlUtil.RemoveHtmlTags(pre, true); - if (Settings.RemoveTextBeforeColonOnlyUppercase && noTagPre != noTagPre.ToUpperInvariant()) + if (Settings.RemoveTextBeforeColonOnlyUppercase && !Utilities.IsAllUppercase(noTagPre)) { var remove = true; newText = RemovePartialBeforeColon(line, indexOfColon, newText, count, ref removedInFirstLine, ref removedInSecondLine, ref remove); @@ -151,7 +183,7 @@ public string RemoveColon(string text) if (indexOf > 0 && indexOf < indexOfColon) { var toRemove = s.Substring(indexOf + 1, indexOfColon - indexOf).Trim(); - if (toRemove.Length > 1 && toRemove == toRemove.ToUpperInvariant()) + if (toRemove.Length > 1 && Utilities.IsAllUppercase(toRemove)) { s = s.Remove(indexOf + 1, indexOfColon - indexOf); s = s.Insert(indexOf + 1, " -"); @@ -176,7 +208,7 @@ public string RemoveColon(string text) { if (count == 1 && newText.Length > 1 && removedInFirstLine && !".?!♪♫".Contains(newTextNoHtml[newTextNoHtml.Length - 1]) && newText.LineEndsWithHtmlTag(true) && - line != line.ToUpperInvariant()) + !Utilities.IsAllUppercase(line)) { newText += Environment.NewLine; if (pre.Contains("") && line.Contains("") && !line.Contains("")) @@ -206,7 +238,7 @@ public string RemoveColon(string text) } else if (count == 1 && newTextNoHtml.Length > 1 && indexOfColon > 15 && line.Substring(0, indexOfColon).Contains(' ') && !".?!♪♫".Contains(newTextNoHtml[newTextNoHtml.Length - 1]) && newText.LineEndsWithHtmlTag(true) && - line != line.ToUpperInvariant()) + !Utilities.IsAllUppercase(line)) { newText += Environment.NewLine; if (pre.Contains("") && line.Contains("") && !line.Contains("")) @@ -300,13 +332,13 @@ public string RemoveColon(string text) content = content.Remove(0, "".Length); } - if (count == 0 && !string.IsNullOrEmpty(content) && content[0].ToString() != content[0].ToString().ToUpperInvariant()) + if (count == 0 && !string.IsNullOrEmpty(content) && char.ToUpperInvariant(content[0]) != content[0]) { - content = content[0].ToString().ToUpperInvariant() + content.Remove(0, 1); + content = char.ToUpperInvariant(content[0]) + content.Remove(0, 1); } - else if (count == 1 && !string.IsNullOrEmpty(content) && content[0].ToString() != content[0].ToString().ToUpperInvariant()) + else if (count == 1 && !string.IsNullOrEmpty(content) && char.ToUpperInvariant(content[0]) != content[0]) { - content = content[0].ToString().ToUpperInvariant() + content.Remove(0, 1); + content = char.ToUpperInvariant(content[0]) + content.Remove(0, 1); } newText += Environment.NewLine; @@ -400,7 +432,7 @@ public string RemoveColon(string text) else { var toColonWord = line.Substring(0, indexOfColon); - if (toColonWord == toColonWord.ToUpperInvariant() && line != line.ToUpperInvariant()) + if (Utilities.IsAllUppercase(toColonWord) && !Utilities.IsAllUppercase(line)) { indexOf = indexOfColon; } @@ -459,7 +491,7 @@ public string RemoveColon(string text) var colonIndex = s2.IndexOf(':'); var start = s2.Substring(0, colonIndex); - if (!Settings.RemoveTextBeforeColonOnlyUppercase || start == start.ToUpperInvariant()) + if (!Settings.RemoveTextBeforeColonOnlyUppercase || Utilities.IsAllUppercase(start)) { var endIndex = start.LastIndexOfAny(endChars); if (colonIndex > 0 && colonIndex < s2.Length - 1) @@ -760,7 +792,7 @@ private string RemovePartialBeforeColon(string line, int indexOfColon, string ne var partialRemove = false; if (Settings.RemoveTextBeforeColonOnlyUppercase) { - if (s == s.ToUpperInvariant()) + if (Utilities.IsAllUppercase(s)) { partialRemove = true; } @@ -1550,18 +1582,7 @@ public string RemoveLineIfAllUppercase(string text) return text; } - var whitelist = new HashSet(StringComparer.OrdinalIgnoreCase); - if (Settings.UppercaseWhitelist != null) - { - foreach (var w in Settings.UppercaseWhitelist) - { - var trimmedWord = w.Trim(); - if (trimmedWord.Length > 0) - { - whitelist.Add(trimmedWord); - } - } - } + var whitelist = GetUppercaseWhitelist(); var sb = new StringBuilder(); char[] endTrimChars = { '.', '!', '?', ':' }; @@ -1569,7 +1590,7 @@ public string RemoveLineIfAllUppercase(string text) foreach (var line in text.SplitToLines()) { var lineNoHtml = HtmlUtil.RemoveHtmlTags(line, true); - if (lineNoHtml == lineNoHtml.ToUpperInvariant() && lineNoHtml != lineNoHtml.ToLowerInvariant()) + if (Utilities.IsAllUppercase(lineNoHtml) && Utilities.HasUppercase(lineNoHtml)) { var temp = lineNoHtml.TrimEnd(endTrimChars).Trim().Trim(trimChars); // Single-letter lines (e.g. "I") are always kept; otherwise keep only the diff --git a/src/libse/SubtitleFormats/DvdStudioPro.cs b/src/libse/SubtitleFormats/DvdStudioPro.cs index 25d591e8d49..47a4eff8d97 100644 --- a/src/libse/SubtitleFormats/DvdStudioPro.cs +++ b/src/libse/SubtitleFormats/DvdStudioPro.cs @@ -105,7 +105,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (!string.IsNullOrWhiteSpace(line) && line[0] != '$') { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { string[] threePart = line.Split(new[] { "\t,\t" }, StringSplitOptions.None); var p = new Paragraph(); diff --git a/src/libse/SubtitleFormats/DvdStudioProSpace.cs b/src/libse/SubtitleFormats/DvdStudioProSpace.cs index 3f2066a4556..10eefefa0c9 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpace.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpace.cs @@ -48,7 +48,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (!string.IsNullOrWhiteSpace(line) && line[0] != '$' && !line.StartsWith("//", StringComparison.Ordinal)) { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { string[] toPart = line.Substring(0, 25).Split(new[] { " ," }, StringSplitOptions.None); Paragraph p = new Paragraph(); diff --git a/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs b/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs index 1894c96207f..e41f942cd8e 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs @@ -44,7 +44,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (!string.IsNullOrWhiteSpace(line) && line[0] != '$' && !line.StartsWith("//", StringComparison.Ordinal)) { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { string[] toPart = line.Substring(0, 25).Split(new[] { " ," }, StringSplitOptions.None); Paragraph p = new Paragraph(); diff --git a/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs b/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs index ca1ff1d338c..c559882ce34 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs @@ -52,7 +52,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (!string.IsNullOrWhiteSpace(line) && line[0] != '$' && !line.StartsWith("//", StringComparison.Ordinal)) { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { string[] toPart = line.Substring(0, 24).Trim(',').Split(','); var p = new Paragraph(); diff --git a/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs b/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs index 3ac835b801e..d6bb84b7403 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs @@ -50,7 +50,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string } else if (line[0] != '$' && !line.StartsWith("//", StringComparison.Ordinal)) { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { string[] toPart = line.Substring(0, 24).Trim(',').Split(','); var p = new Paragraph(); diff --git a/src/libse/SubtitleFormats/DvdSubtitle.cs b/src/libse/SubtitleFormats/DvdSubtitle.cs index df17f53a1a3..a4b80c53678 100644 --- a/src/libse/SubtitleFormats/DvdSubtitle.cs +++ b/src/libse/SubtitleFormats/DvdSubtitle.cs @@ -92,7 +92,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string } else { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { try { diff --git a/src/libse/SubtitleFormats/Lrc.cs b/src/libse/SubtitleFormats/Lrc.cs index 50b18ac89d4..0cf5cc7151b 100644 --- a/src/libse/SubtitleFormats/Lrc.cs +++ b/src/libse/SubtitleFormats/Lrc.cs @@ -147,7 +147,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (var rawLine in lines) { var line = rawLine.TrimStart('\uFEFF'); - if (line.StartsWith('[') && RegexTimeCodes.Match(line).Success) + if (line.StartsWith('[') && RegexTimeCodes.IsMatch(line)) { var s = line.Substring(1, 8); var parts = s.Split(splitChars, StringSplitOptions.RemoveEmptyEntries); @@ -280,7 +280,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string for (var i = 0; i < max; i++) { var p = subtitle.Paragraphs[i]; - while (RegexTimeCodes.Match(p.Text).Success) + while (RegexTimeCodes.IsMatch(p.Text)) { var s = p.Text.Substring(1, 8); p.Text = p.Text.Remove(0, 10).Trim(); diff --git a/src/libse/SubtitleFormats/Lrc3DigitsMs.cs b/src/libse/SubtitleFormats/Lrc3DigitsMs.cs index 1c934e362be..10a2ff89b18 100644 --- a/src/libse/SubtitleFormats/Lrc3DigitsMs.cs +++ b/src/libse/SubtitleFormats/Lrc3DigitsMs.cs @@ -138,7 +138,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string char[] splitChars = { ':', '.' }; foreach (var line in lines) { - if (line.StartsWith('[') && RegexTimeCodes.Match(line).Success) + if (line.StartsWith('[') && RegexTimeCodes.IsMatch(line)) { var endBracket = line.IndexOf(']'); var s = line.Substring(1, endBracket-1); @@ -270,7 +270,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string for (var i = 0; i < max; i++) { var p = subtitle.Paragraphs[i]; - while (RegexTimeCodes.Match(p.Text).Success) + while (RegexTimeCodes.IsMatch(p.Text)) { var s = p.Text.Substring(1, 9); p.Text = p.Text.Remove(0, 11).Trim(); diff --git a/src/libse/SubtitleFormats/LrcNoEndTime.cs b/src/libse/SubtitleFormats/LrcNoEndTime.cs index 2114b41b840..35563c808f4 100644 --- a/src/libse/SubtitleFormats/LrcNoEndTime.cs +++ b/src/libse/SubtitleFormats/LrcNoEndTime.cs @@ -124,7 +124,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string char[] splitChars = { ':', '.' }; foreach (var line in lines) { - if (line.StartsWith('[') && RegexTimeCodes.Match(line).Success) + if (line.StartsWith('[') && RegexTimeCodes.IsMatch(line)) { var s = line.Substring(1, 8); var parts = s.Split(splitChars, StringSplitOptions.RemoveEmptyEntries); @@ -257,7 +257,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string for (var i = 0; i < max; i++) { var p = subtitle.Paragraphs[i]; - while (RegexTimeCodes.Match(p.Text).Success) + while (RegexTimeCodes.IsMatch(p.Text)) { var s = p.Text.Substring(1, 8); p.Text = p.Text.Remove(0, 10).Trim(); diff --git a/src/libse/SubtitleFormats/MicroDvd.cs b/src/libse/SubtitleFormats/MicroDvd.cs index b26dbec7e78..2ad13f92dc1 100644 --- a/src/libse/SubtitleFormats/MicroDvd.cs +++ b/src/libse/SubtitleFormats/MicroDvd.cs @@ -282,39 +282,39 @@ public override string ToText(Subtitle subtitle, string title) } string text = lineSb.ToString(); int noOfLines = Utilities.CountTagInText(text, '|') + 1; - if (Utilities.CountTagInText(text, "{y:i}") == noOfLines && text.StartsWith("{y:i}", StringComparison.Ordinal)) + if (text.StartsWith("{y:i}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:i}") == noOfLines) { text = "{Y:i}" + text.Replace("{y:i}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:b}") == noOfLines && text.StartsWith("{y:b}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:b}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:b}") == noOfLines) { text = "{Y:b}" + text.Replace("{y:b}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:u}") == noOfLines && text.StartsWith("{y:u}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:u}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:u}") == noOfLines) { text = "{Y:u}" + text.Replace("{y:u}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:u}{y:i}") == noOfLines && text.StartsWith("{y:u}{y:i}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:u}{y:i}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:u}{y:i}") == noOfLines) { text = "{Y:u}{Y:i}" + text.Replace("{y:u}", string.Empty).Replace("{y:i}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:i}{y:u}") == noOfLines && text.StartsWith("{y:i}{y:u}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:i}{y:u}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:i}{y:u}") == noOfLines) { text = "{Y:i}{Y:u}" + text.Replace("{y:i}", string.Empty).Replace("{y:u}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:i}{y:b}") == noOfLines && text.StartsWith("{y:i}{y:b}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:i}{y:b}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:i}{y:b}") == noOfLines) { text = "{Y:i}{Y:b}" + text.Replace("{y:i}", string.Empty).Replace("{y:b}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:b}{y:i}") == noOfLines && text.StartsWith("{y:b}{y:i}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:b}{y:i}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:b}{y:i}") == noOfLines) { text = "{Y:b}{Y:i}" + text.Replace("{y:i}", string.Empty).Replace("{y:b}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:b}{y:u}") == noOfLines && text.StartsWith("{y:b}{y:u}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:b}{y:u}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:b}{y:u}") == noOfLines) { text = "{Y:b}{Y:u}" + text.Replace("{y:b}", string.Empty).Replace("{y:u}", string.Empty); } - else if (Utilities.CountTagInText(text, "{y:u}{y:b}") == noOfLines && text.StartsWith("{y:u}{y:b}", StringComparison.Ordinal)) + else if (text.StartsWith("{y:u}{y:b}", StringComparison.Ordinal) && Utilities.CountTagInText(text, "{y:u}{y:b}") == noOfLines) { text = "{Y:u}{Y:b}" + text.Replace("{y:u}", string.Empty).Replace("{y:b}", string.Empty); } diff --git a/src/libse/SubtitleFormats/NVivoTranscript.cs b/src/libse/SubtitleFormats/NVivoTranscript.cs index 2bb0e0a19f8..d79bb52c2e9 100644 --- a/src/libse/SubtitleFormats/NVivoTranscript.cs +++ b/src/libse/SubtitleFormats/NVivoTranscript.cs @@ -47,7 +47,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (var line in lines) { var s = line.Trim(); - if (RegexTimeCodes1.Match(s).Success) + if (RegexTimeCodes1.IsMatch(s)) { var timeCode = $"00:{s.Substring(0, 5)}:00"; var arr = s.Split('\t'); @@ -58,7 +58,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string subtitle.Paragraphs.Add(GetParagraph(timeCode, speaker, text)); } } - else if (RegexTimeCodes2.Match(s).Success) + else if (RegexTimeCodes2.IsMatch(s)) { var timeCode = $"{s.Substring(0, 8)}:00"; var arr = s.Split('\t'); diff --git a/src/libse/SubtitleFormats/Sami.cs b/src/libse/SubtitleFormats/Sami.cs index 6018d639306..93513abd77f 100644 --- a/src/libse/SubtitleFormats/Sami.cs +++ b/src/libse/SubtitleFormats/Sami.cs @@ -10,6 +10,12 @@ namespace Nikse.SubtitleEdit.Core.SubtitleFormats { public class Sami : SubtitleFormat { + /// + /// Characters a CSS class name may contain. Built once - the class-name scan below tests + /// this per character and used to concatenate the two literals every time. + /// + private static readonly string ClassNameChars = Utilities.LowercaseLettersWithNumbers + @"'"""; + public override string Extension => ".smi"; public override string Name => "SAMI"; @@ -280,17 +286,19 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string var partial = new StringBuilder(); while (syncStartPos >= 0) { - string millisecondsAsString = string.Empty; + partial.Clear(); while (index < allInput.Length && expectedChars.Contains(allInput[index])) { if (allInput[index] != '"' && allInput[index] != '\'') { - millisecondsAsString += allInput[index]; + partial.Append(allInput[index]); } index++; } + string millisecondsAsString = partial.ToString(); + while (index < allInput.Length && allInput[index] != '>') { index++; @@ -327,7 +335,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string className.Clear(); int startClass = textToLower.IndexOf(" class=", StringComparison.Ordinal); int indexClass = startClass + 7; - while (indexClass < textToLower.Length && (Utilities.LowercaseLettersWithNumbers + @"'""").Contains(textToLower[indexClass])) + while (indexClass < textToLower.Length && ClassNameChars.Contains(textToLower[indexClass])) { className.Append(text[indexClass]); indexClass++; @@ -344,7 +352,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string } int st = sourceIndex - 1; - while (st > 0 && text.Substring(st, 2).ToUpperInvariant() != " 0 && string.Compare(text, st, " lines, string text = text.Substring(0, st) + text.Substring(sourceIndex); } int et = st; - while (et < text.Length - 5 && text.Substring(et, 3).ToUpperInvariant() != "

" && text.Substring(et, 4).ToUpperInvariant() != "

") + while (et < text.Length - 5 && + string.Compare(text, et, "

", 0, 3, StringComparison.OrdinalIgnoreCase) != 0 && + string.Compare(text, et, "

", 0, 4, StringComparison.OrdinalIgnoreCase) != 0) { et++; } @@ -373,7 +383,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string text = text.Replace("", string.Empty).Replace("", string.Empty).TrimEnd(); text = text.Replace("", string.Empty).Replace("", string.Empty).TrimEnd(); - int endSyncPos = text.ToUpperInvariant().IndexOf("", StringComparison.OrdinalIgnoreCase); + int endSyncPos = text.IndexOf("", StringComparison.OrdinalIgnoreCase); if (text.IndexOf('>') > 0 && (text.IndexOf('>') < endSyncPos || endSyncPos == -1)) { text = text.Remove(0, text.IndexOf('>') + 1); diff --git a/src/libse/SubtitleFormats/SubStationAlpha.cs b/src/libse/SubtitleFormats/SubStationAlpha.cs index f486cd15757..1ec267adb49 100644 --- a/src/libse/SubtitleFormats/SubStationAlpha.cs +++ b/src/libse/SubtitleFormats/SubStationAlpha.cs @@ -114,6 +114,11 @@ [V4 Styles] boldStyle )); } + + // Style lookup is one probe per paragraph; a styled file carries hundreds of styles, + // so scanning the list linearly for each of them added up (same fix as [V4+ Styles]). + var styleSet = new HashSet(styles); + foreach (var p in subtitle.Paragraphs) { var start = string.Format(timeCodeFormat, p.StartTime.Hours, p.StartTime.Minutes, p.StartTime.Seconds, p.StartTime.Milliseconds / 10); @@ -149,7 +154,7 @@ [V4 Styles] effect = p.Effect; } - if (!string.IsNullOrEmpty(p.Extra) && isValidSsaHeader && styles.Contains(p.Extra)) + if (!string.IsNullOrEmpty(p.Extra) && isValidSsaHeader && styleSet.Contains(p.Extra)) { style = p.Extra; } @@ -177,7 +182,10 @@ [V4 Styles] sb.AppendLine(subtitle.Footer); } - return sb.ToString().Trim() + Environment.NewLine; + // Trim inside the builder instead of "sb.ToString().Trim() + newline", which + // allocated the whole output twice more (same fix as [V4+ Styles]). + TrimBuilder(sb); + return sb.Append(Environment.NewLine).ToString(); } private static SsaStyle GetDefaultStyle() diff --git a/src/libse/SubtitleFormats/SubViewer20.cs b/src/libse/SubtitleFormats/SubViewer20.cs index d991ff4d099..14c0a05c542 100644 --- a/src/libse/SubtitleFormats/SubViewer20.cs +++ b/src/libse/SubtitleFormats/SubViewer20.cs @@ -23,7 +23,9 @@ private enum ExpectingLine public override bool IsMine(List lines, string fileName) { var sbv = new YouTubeSbv(); - if (sbv.IsMine(lines, fileName) && !string.Join(string.Empty, lines.ToArray()).Contains("[br]")) + // "[br]" cannot straddle a line, so probe the lines directly - joining them built + // the whole file into one string on every ".sub" probe. + if (sbv.IsMine(lines, fileName) && !lines.Exists(l => l.Contains("[br]", StringComparison.Ordinal))) { return false; } diff --git a/src/libse/SubtitleFormats/TMPlayer.cs b/src/libse/SubtitleFormats/TMPlayer.cs index adcc031af45..82b3bf0dd29 100644 --- a/src/libse/SubtitleFormats/TMPlayer.cs +++ b/src/libse/SubtitleFormats/TMPlayer.cs @@ -66,7 +66,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (string line in lines) { bool success = false; - if (line.IndexOf(':') > 0 && RegexTimeCodes.Match(line).Success) + if (line.IndexOf(':') > 0 && RegexTimeCodes.IsMatch(line)) { try { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle106.cs b/src/libse/SubtitleFormats/UnknownSubtitle106.cs index db8c5ef7c60..980fa2c161a 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle106.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle106.cs @@ -43,7 +43,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (var line in lines) { var s = line.Trim(); - if (RegexTimeCodes.Match(s).Success) + if (RegexTimeCodes.IsMatch(s)) { var arr = s.Substring(0, 7).Split(':'); if (arr.Length == 3 && diff --git a/src/libse/SubtitleFormats/UnknownSubtitle33.cs b/src/libse/SubtitleFormats/UnknownSubtitle33.cs index b4fb660b2d5..69a539a6c94 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle33.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle33.cs @@ -76,7 +76,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (string line in lines) { string s = line.Trim(); - if (s.Length > 4 && s[2] == ':' && RegexTimeCodes.Match(s).Success) + if (s.Length > 4 && s[2] == ':' && RegexTimeCodes.IsMatch(s)) { if (p != null && !string.IsNullOrEmpty(p.Text)) { @@ -103,7 +103,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string _errorCount++; } } - else if (p != null && RegexNumberAndText.Match(s).Success) + else if (p != null && RegexNumberAndText.IsMatch(s)) { if (p.Text.Length > 1000) { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle34.cs b/src/libse/SubtitleFormats/UnknownSubtitle34.cs index d7c69dfdb2a..53ffe5fdb05 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle34.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle34.cs @@ -52,7 +52,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (string line in lines) { string s = line.Trim(); - if (s.IndexOf(':') > 0 && RegexTimeCodes.Match(s).Success && !UnknownSubtitle59.RegexTimeCodes.IsMatch(s)) + if (s.IndexOf(':') > 0 && RegexTimeCodes.IsMatch(s) && !UnknownSubtitle59.RegexTimeCodes.IsMatch(s)) { if (p != null && !string.IsNullOrEmpty(p.Text)) { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle41.cs b/src/libse/SubtitleFormats/UnknownSubtitle41.cs index 881234245f4..e7b4be01944 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle41.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle41.cs @@ -69,7 +69,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string } else { - if (line.Length > 1 && line.Length < 11 && RegexTimeCodes.Match(line).Success) + if (line.Length > 1 && line.Length < 11 && RegexTimeCodes.IsMatch(line)) { p = new Paragraph(); sb.Clear(); diff --git a/src/libse/SubtitleFormats/UnknownSubtitle46.cs b/src/libse/SubtitleFormats/UnknownSubtitle46.cs index c909e46a035..499ec6bcbe0 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle46.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle46.cs @@ -43,7 +43,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string string s = line.Trim(); string[] arr = line.Split(); var timeCode = arr[arr.Length - 1]; - if (line.IndexOf(':') > 0 && (RegexTimeCodesAm.Match(timeCode).Success || RegexTimeCodesPm.Match(timeCode).Success)) + if (line.IndexOf(':') > 0 && (RegexTimeCodesAm.IsMatch(timeCode) || RegexTimeCodesPm.IsMatch(timeCode))) { try { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle47.cs b/src/libse/SubtitleFormats/UnknownSubtitle47.cs index 5327fc766be..f947aa9c4ce 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle47.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle47.cs @@ -41,7 +41,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (var line in lines) { var s = line.Trim(); - if (RegexTimeCodes.Match(s).Success) + if (RegexTimeCodes.IsMatch(s)) { try { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle48.cs b/src/libse/SubtitleFormats/UnknownSubtitle48.cs index 4a1335a314d..6da5acde54d 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle48.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle48.cs @@ -51,7 +51,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string _errorCount = 0; foreach (string line in lines) { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { string[] parts = line.Split(new[] { ' ' }, StringSplitOptions.None); var p = new Paragraph(); diff --git a/src/libse/SubtitleFormats/UnknownSubtitle51.cs b/src/libse/SubtitleFormats/UnknownSubtitle51.cs index 7077a7f97b8..a084d5cb8f9 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle51.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle51.cs @@ -50,7 +50,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string continue; } - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { string[] threePart = line.Split(new[] { ',' }, StringSplitOptions.None); p = new Paragraph(); diff --git a/src/libse/SubtitleFormats/UnknownSubtitle52.cs b/src/libse/SubtitleFormats/UnknownSubtitle52.cs index e5f31740009..960895ee6be 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle52.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle52.cs @@ -93,7 +93,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { try { - if (RegexTimeCodes.Match(line).Success) + if (RegexTimeCodes.IsMatch(line)) { started = true; if (p != null) diff --git a/src/libse/SubtitleFormats/UnknownSubtitle53.cs b/src/libse/SubtitleFormats/UnknownSubtitle53.cs index 0923c2d6be2..4993b5f971f 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle53.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle53.cs @@ -43,7 +43,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (string line in lines) { string s = line.Trim(); - if (RegexTimeCodes.Match(s).Success) + if (RegexTimeCodes.IsMatch(s)) { if (!string.IsNullOrEmpty(p?.Text)) { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle59.cs b/src/libse/SubtitleFormats/UnknownSubtitle59.cs index e8a16a7a3fd..1af4b968895 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle59.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle59.cs @@ -54,7 +54,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string string s = line.Trim(); if (s.Length > 7 && char.IsDigit(s[0]) && char.IsDigit(s[1]) && s[2] == ':') { - if (RegexTimeCodes.Match(s).Success || RegexTimeCodes2.IsMatch(s)) + if (RegexTimeCodes.IsMatch(s) || RegexTimeCodes2.IsMatch(s)) { if (RegexTimeCodes2.IsMatch(s)) { @@ -94,7 +94,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string _errorCount++; } } - else if (RegexStartOnly.Match(s).Success) + else if (RegexStartOnly.IsMatch(s)) { try { @@ -122,7 +122,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string _errorCount++; } } - else if (RegexEndOnly.Match(s).Success) + else if (RegexEndOnly.IsMatch(s)) { try { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle86.cs b/src/libse/SubtitleFormats/UnknownSubtitle86.cs index 87e41dd66f1..d32010e94d1 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle86.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle86.cs @@ -33,7 +33,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { bool success = false; string s = line.TrimStart(); - if (s.StartsWith('[') && RegexTimeCodes.Match(s).Success) + if (s.StartsWith('[') && RegexTimeCodes.IsMatch(s)) { try { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle98.cs b/src/libse/SubtitleFormats/UnknownSubtitle98.cs index 128dfdde4b5..dc200b6f709 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle98.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle98.cs @@ -49,7 +49,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (string line in lines) { string s = line.Trim(); - if (RegexTimeCodes.Match(s).Success) + if (RegexTimeCodes.IsMatch(s)) { try { diff --git a/src/libse/SubtitleFormats/UnknownSubtitle99.cs b/src/libse/SubtitleFormats/UnknownSubtitle99.cs index aa42968005d..cb6b1c0e924 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle99.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle99.cs @@ -51,7 +51,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string foreach (string line in lines) { string s = line.Trim(); - if (RegexTimeCodes.Match(s).Success) + if (RegexTimeCodes.IsMatch(s)) { try { diff --git a/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs b/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs index fa0c343b3ec..00c7a985af9 100644 --- a/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs +++ b/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs @@ -31,12 +31,21 @@ public class OcrFixReplaceList2 private const string ReplaceListFileNamePostFix = "_OCRFixReplaceList.xml"; + // These are probed once per OCR'd word, so keep them off the per-call path: + // the arrays used to be allocated inline and the Greek check re-concatenated the + // file-name suffix and rescanned the whole path every time. + private static readonly char[] Digits2To9 = { '2', '3', '4', '5', '6', '7', '8', '9' }; + private static readonly char[] Digits1To9 = { '1', '2', '3', '4', '5', '6', '7', '8', '9' }; + private readonly bool _isGreekReplaceList; + public string ErrorMessage { get; set; } public OcrFixReplaceList2(string replaceListXmlFileName) { ErrorMessage = string.Empty; _replaceListXmlFileName = replaceListXmlFileName; + _isGreekReplaceList = replaceListXmlFileName != null && + replaceListXmlFileName.Contains("\\ell" + ReplaceListFileNamePostFix); WordReplaceList = new Dictionary(); PartialLineWordBoundaryReplaceList = new Dictionary(); _partialLineAlwaysReplaceList = new Dictionary(); @@ -601,7 +610,7 @@ public string FixCommonWordErrors(string input) word = word.Replace("fl", "fl"); word = word.Replace("ffi", "ffi"); word = word.Replace("ffl", "ffl"); - if (!_replaceListXmlFileName.Contains("\\ell" + ReplaceListFileNamePostFix)) + if (!_isGreekReplaceList) { word = word.Replace('ν', 'v'); // first 'v' is U+03BD GREEK SMALL LETTER NU } @@ -739,27 +748,29 @@ private bool GetReplaceWord(string pre, string word, string post, out string res return false; } - if (WordReplaceList.ContainsKey(pre + word + post)) + // One hash probe per candidate key, and each key built once. This runs for every + // word of every OCR'd line, twice per word when the hardcoded rules retry. + if (WordReplaceList.TryGetValue(pre + word + post, out var replacement)) { - result = WordReplaceList[pre + word + post]; + result = replacement; return true; } - if (WordReplaceList.ContainsKey(pre + word)) + if (WordReplaceList.TryGetValue(pre + word, out replacement)) { - result = WordReplaceList[pre + word] + post; + result = replacement + post; return true; } - if (WordReplaceList.ContainsKey(word + post)) + if (WordReplaceList.TryGetValue(word + post, out replacement)) { - result = pre + WordReplaceList[word + post]; + result = pre + replacement; return true; } - if (WordReplaceList.ContainsKey(word)) + if (WordReplaceList.TryGetValue(word, out replacement)) { - result = pre + WordReplaceList[word] + post; + result = pre + replacement + post; return true; } @@ -793,7 +804,7 @@ public static string FixIor1InsideLowerCaseWord(string input) return word; } - if (word.Contains(new[] { '2', '3', '4', '5', '6', '7', '8', '9' })) + if (word.Contains(Digits2To9)) { return word; } @@ -841,7 +852,7 @@ public static string Fix0InsideLowerCaseWord(string input) return word; } - if (word.Contains(new[] { '1', '2', '3', '4', '5', '6', '7', '8', '9' }) || + if (word.Contains(Digits1To9) || word.EndsWith("a.m", StringComparison.Ordinal) || word.EndsWith("p.m", StringComparison.Ordinal) || word.EndsWith("am", StringComparison.Ordinal) || diff --git a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs index 63a09453bd5..51e952c2de6 100644 --- a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs +++ b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs @@ -278,14 +278,13 @@ public bool IsWordInUserPhrases(int index, List words) next = Utilities.NormalizeUserDictionaryWord(words[index + 1].Text); } + // Both phrases are the same for every entry in the list - building them inside the + // loop allocated two strings per user phrase, per word checked. + var withNext = current + " " + next; + var withPrev = prev + " " + current; foreach (string userPhrase in _userPhraseList) { - if (userPhrase == current + " " + next) - { - return true; - } - - if (userPhrase == prev + " " + current) + if (userPhrase == withNext || userPhrase == withPrev) { return true; } diff --git a/tests/benchmarks/HotTextPathRound4Benchmarks.cs b/tests/benchmarks/HotTextPathRound4Benchmarks.cs new file mode 100644 index 00000000000..74a42909e4a --- /dev/null +++ b/tests/benchmarks/HotTextPathRound4Benchmarks.cs @@ -0,0 +1,218 @@ +using BenchmarkDotNet.Attributes; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Common.TextLengthCalculator; +using Nikse.SubtitleEdit.Core.Forms; +using Nikse.SubtitleEdit.Core.SubtitleFormats; + +namespace Nikse.SubtitleEdit.Benchmarks; + +/// +/// Round 4 of the micro-perf hunt: helpers that run once per subtitle line, per word or per +/// character. Everything here goes through the public API that exists on both sides of the +/// change, so the same benchmarks can be run against a stashed baseline for before/after numbers. +/// +internal static class BenchmarkSubtitles +{ + internal static string FindDataDirectory() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "Dictionaries", "names.xml"))) + { + return dir.FullName + Path.DirectorySeparatorChar; + } + + dir = dir.Parent; + } + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Subtitle Edit") + Path.DirectorySeparatorChar; + } + + internal static readonly string[] Sentences = + { + "It was the best of times, it was the worst of times.", + "MAN: Are you coming with us, John?", + "- No, Mrs. smith.\r\n- Then stay here and wait for peter.", + "I told you already, this is not going to work out the way you think.", + "WOMAN 2: Hello there, dr. jones.", + "Somewhere in Denmark,\r\na quiet evening begins.", + "[DOOR SLAMS]", + "THIS WHOLE LINE IS SHOUTED.", + }; + + internal static Subtitle Build(int lineCount) + { + var subtitle = new Subtitle(); + for (var i = 0; i < lineCount; i++) + { + subtitle.Paragraphs.Add(new Paragraph( + Sentences[i % Sentences.Length], + i * 2000, + i * 2000 + 1800)); + } + + subtitle.Renumber(); + return subtitle; + } +} + +/// +/// "Change casing" runs StrippableText over every paragraph with the full name list (~8000 +/// entries for English). The name loop lower-cased every name on every paragraph. +/// +[MemoryDiagnoser] +public class ChangeCasingBenchmarks +{ + private Subtitle _subtitle = null!; + + [GlobalSetup] + public void Setup() + { + Configuration.DataDirectory = BenchmarkSubtitles.FindDataDirectory(); + _subtitle = BenchmarkSubtitles.Build(200); + } + + [Benchmark] + public int FixCasingNormal() + { + var subtitle = new Subtitle(_subtitle); + var fixCasing = new FixCasing("en") { FixNormal = true }; + fixCasing.Fix(subtitle); + return subtitle.Paragraphs.Count; + } +} + +/// +/// The CJK length calculators ask IsCjk once per character; the grid re-reads CPS and line +/// length on every repaint. IsCjk used to allocate a one-char string and run a regex on it. +/// +[MemoryDiagnoser] +public class CjkLengthBenchmarks +{ + private const string Latin = "It was the best of times, it was the worst of times."; + private const string Japanese = "彼は静かな夕暮れの中を歩いていた。それは長い一日の終わりだった。"; + private readonly CalcCjk _calc = new CalcCjk(); + + [Benchmark] + public decimal CountLatin() => _calc.CountCharacters(Latin, true); + + [Benchmark] + public decimal CountJapanese() => _calc.CountCharacters(Japanese, true); +} + +/// +/// Auto-break runs per line on split/merge and per keystroke with "auto break while typing". +/// Re-inserting the html tags did two dictionary probes per character. +/// +[MemoryDiagnoser] +public class AutoBreakBenchmarks +{ + private const string Tagged = "It was the best of times, it was the worst of times, and nobody knew it yet."; + private const string Plain = "It was the best of times, it was the worst of times, and nobody knew it yet."; + + [Benchmark] + public string AutoBreakTagged() => Utilities.AutoBreakLine(Tagged); + + [Benchmark] + public string AutoBreakPlain() => Utilities.AutoBreakLine(Plain); +} + +/// +/// "Remove text for hearing impaired" over a whole file. The all-uppercase probes allocated an +/// uppercased copy of the line, and the whitelist set was rebuilt per line. +/// +[MemoryDiagnoser] +public class RemoveTextForHiBenchmarks +{ + private Subtitle _subtitle = null!; + private RemoveTextForHI _lib = null!; + + [GlobalSetup] + public void Setup() + { + Configuration.DataDirectory = BenchmarkSubtitles.FindDataDirectory(); + _subtitle = BenchmarkSubtitles.Build(500); + _lib = new RemoveTextForHI(new RemoveTextForHISettings(_subtitle)); + } + + [Benchmark] + public int RemoveHearingImpaired() + { + var count = 0; + for (var i = 0; i < _subtitle.Paragraphs.Count; i++) + { + var text = _lib.RemoveTextFromHearImpaired(_subtitle.Paragraphs[i].Text, _subtitle, i, "en"); + count += text.Length; + } + + return count; + } +} + +/// +/// Loading a SAMI file: the cue loop uppercased whole cues, rebuilt a character set per +/// character of a class name and grew the milliseconds string one character at a time. +/// +[MemoryDiagnoser] +public class SamiLoadBenchmarks +{ + private List _lines = null!; + + [GlobalSetup] + public void Setup() + { + var subtitle = BenchmarkSubtitles.Build(500); + var text = new Sami().ToText(subtitle, "benchmark"); + _lines = text.SplitToLines().ToList(); + } + + [Benchmark] + public int LoadSami() + { + var subtitle = new Subtitle(); + new Sami().LoadSubtitle(subtitle, _lines, null); + return subtitle.Paragraphs.Count; + } +} + +/// +/// Writing SubStation Alpha and MicroDVD. SSA scanned the style list per paragraph and copied +/// the finished output twice; MicroDVD counted tags over the whole line before the cheap +/// prefix test that rejects it. +/// +[MemoryDiagnoser] +public class FormatWriteBenchmarks +{ + private Subtitle _styled = null!; + private Subtitle _plain = null!; + + [GlobalSetup] + public void Setup() + { + _styled = BenchmarkSubtitles.Build(500); + var header = new System.Text.StringBuilder(); + header.AppendLine("[V4 Styles]"); + header.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding"); + for (var i = 0; i < 120; i++) + { + header.AppendLine($"Style: Style{i},Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,1,2,2,2,10,10,10,0,1"); + } + + _styled.Header = header.ToString(); + for (var i = 0; i < _styled.Paragraphs.Count; i++) + { + _styled.Paragraphs[i].Extra = "Style" + i % 120; + } + + _plain = BenchmarkSubtitles.Build(500); + } + + [Benchmark] + public int SubStationAlphaToText() => new SubStationAlpha().ToText(_styled, "benchmark").Length; + + [Benchmark] + public int MicroDvdToText() => new MicroDvd().ToText(_plain, "benchmark").Length; +} diff --git a/tests/libse/Common/CalcCjkTest.cs b/tests/libse/Common/CalcCjkTest.cs new file mode 100644 index 00000000000..ee50b4c658c --- /dev/null +++ b/tests/libse/Common/CalcCjkTest.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; +using Nikse.SubtitleEdit.Core.Common.TextLengthCalculator; + +namespace LibSETests.Common; + +public class CalcCjkTest +{ + // IsCjk used to allocate a one-character string and run CjkCharRegex over it, once per + // character of every measured line. It now tests the block ranges directly, so pin the + // rewrite to the regex it replaced across the whole BMP - a single mistyped range would + // otherwise silently shift CJK character counts. + [Fact] + public void IsCjk_MatchesRegexForEveryChar() + { + var regex = new Regex(@"\p{IsHangulJamo}|" + + @"\p{IsCJKRadicalsSupplement}|" + + @"\p{IsCJKSymbolsandPunctuation}|" + + @"\p{IsEnclosedCJKLettersandMonths}|" + + @"\p{IsCJKCompatibility}|" + + @"\p{IsCJKUnifiedIdeographsExtensionA}|" + + @"\p{IsCJKUnifiedIdeographs}|" + + @"\p{IsHangulSyllables}|" + + @"\p{IsCJKCompatibilityForms}"); + + for (var i = 0; i <= char.MaxValue; i++) + { + var c = (char)i; + + // Hiragana was a hard-coded fast path in the old implementation, on top of the regex. + var expected = regex.IsMatch(c.ToString()) || (i >= 0x3040 && i <= 0x309F); + + Assert.True(expected == CalcCjk.IsCjk(c), $"U+{i:X4} expected {expected}"); + } + } +} diff --git a/tests/libse/Common/UtilitiesCasingProbeTest.cs b/tests/libse/Common/UtilitiesCasingProbeTest.cs new file mode 100644 index 00000000000..1adede755f2 --- /dev/null +++ b/tests/libse/Common/UtilitiesCasingProbeTest.cs @@ -0,0 +1,40 @@ +using Nikse.SubtitleEdit.Core.Common; + +namespace LibSETests.Common; + +public class UtilitiesCasingProbeTest +{ + // IsAllUppercase / HasUppercase replace "s == s.ToUpperInvariant()" and + // "s != s.ToLowerInvariant()" in the per-line hearing-impaired and casing rules, so they + // must answer identically for every character - including the ones where the invariant + // case mapping does something surprising (Turkish dotless i, Cherokee, Deseret, ...). + [Fact] + public void CasingProbes_MatchStringComparisonForEveryChar() + { + for (var i = 0; i <= char.MaxValue; i++) + { + var s = ((char)i).ToString(); + + Assert.True(s == s.ToUpperInvariant() == Utilities.IsAllUppercase(s), $"IsAllUppercase U+{i:X4}"); + Assert.True(s != s.ToLowerInvariant() == Utilities.HasUppercase(s), $"HasUppercase U+{i:X4}"); + } + } + + [Theory] + [InlineData("")] + [InlineData("HELLO THERE!")] + [InlineData("Hello there!")] + [InlineData("hello there!")] + [InlineData("123 - ?!")] + [InlineData("MAN:")] + [InlineData("ÆØÅ ÖÜ")] + [InlineData("æøå öü")] + [InlineData("ΑΘΗΝΑ")] + [InlineData("Ω ω")] + [InlineData("ß STRASSE")] + public void CasingProbes_MatchStringComparisonForLines(string s) + { + Assert.Equal(s == s.ToUpperInvariant(), Utilities.IsAllUppercase(s)); + Assert.Equal(s != s.ToLowerInvariant(), Utilities.HasUppercase(s)); + } +}