From b362af205379151f155bc637432fe23b3e8b40ce Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 17 Oct 2026 12:27:14 +0100 Subject: [PATCH 1/6] Add SE5 port of Hearing impaired to uppercase (HI2UC) Avalonia port of the SE4 HI2UC plugin. The casing logic is carried over and moods and narrators are now applied to the same line one after the other. The saved style is used again, settings are stored by SE, and there is batch convert support. The analytics call is removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/hi2uc.yml | 196 +++++++++++++++ README.md | 1 + se5/HI2UC.Tests/CasingTests.cs | 105 ++++++++ se5/HI2UC.Tests/HI2UC.Tests.csproj | 27 ++ se5/HI2UC.Tests/MainViewModelTests.cs | 140 +++++++++++ se5/HI2UC/App.axaml | 8 + se5/HI2UC/App.axaml.cs | 12 + se5/HI2UC/CasingStrategies.cs | 210 ++++++++++++++++ se5/HI2UC/ChangeItem.cs | 30 +++ se5/HI2UC/ChunkReader.cs | 61 +++++ se5/HI2UC/HI2UC.csproj | 27 ++ se5/HI2UC/HiCasing.cs | 65 +++++ se5/HI2UC/MainViewModel.cs | 332 +++++++++++++++++++++++++ se5/HI2UC/MainWindow.axaml | 342 ++++++++++++++++++++++++++ se5/HI2UC/MainWindow.axaml.cs | 58 +++++ se5/HI2UC/MoodCasingConverter.cs | 182 ++++++++++++++ se5/HI2UC/NarratorCasingConverter.cs | 92 +++++++ se5/HI2UC/Program.cs | 16 ++ se5/HI2UC/README.md | 55 +++++ se5/HI2UC/SubtitleTextBlock.cs | 201 +++++++++++++++ se5/HI2UC/TextDiff.cs | 138 +++++++++++ se5/HI2UC/TextUtils.cs | 58 +++++ se5/HI2UC/app.manifest | 10 + se5/HI2UC/plugin.json | 13 + se5/Plugins.slnx | 3 + 25 files changed, 2382 insertions(+) create mode 100644 .github/workflows/hi2uc.yml create mode 100644 se5/HI2UC.Tests/CasingTests.cs create mode 100644 se5/HI2UC.Tests/HI2UC.Tests.csproj create mode 100644 se5/HI2UC.Tests/MainViewModelTests.cs create mode 100644 se5/HI2UC/App.axaml create mode 100644 se5/HI2UC/App.axaml.cs create mode 100644 se5/HI2UC/CasingStrategies.cs create mode 100644 se5/HI2UC/ChangeItem.cs create mode 100644 se5/HI2UC/ChunkReader.cs create mode 100644 se5/HI2UC/HI2UC.csproj create mode 100644 se5/HI2UC/HiCasing.cs create mode 100644 se5/HI2UC/MainViewModel.cs create mode 100644 se5/HI2UC/MainWindow.axaml create mode 100644 se5/HI2UC/MainWindow.axaml.cs create mode 100644 se5/HI2UC/MoodCasingConverter.cs create mode 100644 se5/HI2UC/NarratorCasingConverter.cs create mode 100644 se5/HI2UC/Program.cs create mode 100644 se5/HI2UC/README.md create mode 100644 se5/HI2UC/SubtitleTextBlock.cs create mode 100644 se5/HI2UC/TextDiff.cs create mode 100644 se5/HI2UC/TextUtils.cs create mode 100644 se5/HI2UC/app.manifest create mode 100644 se5/HI2UC/plugin.json diff --git a/.github/workflows/hi2uc.yml b/.github/workflows/hi2uc.yml new file mode 100644 index 00000000..0f195b4d --- /dev/null +++ b/.github/workflows/hi2uc.yml @@ -0,0 +1,196 @@ +name: Build HI2UC (SE5) + +on: + push: + branches: [main] + paths: + - 'se5/HI2UC/**' + - 'se5/HI2UC.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/hi2uc.yml' + pull_request: + paths: + - 'se5/HI2UC/**' + - 'se5/HI2UC.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/hi2uc.yml' + workflow_dispatch: + inputs: + release: + description: 'Publish a GitHub release. Bumps the minor in plugin.json and commits it back.' + type: boolean + default: false + version: + description: 'Optional explicit version (e.g. 1.2.0). Overrides the auto-minor-bump.' + required: false + type: string + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set.outputs.version }} + tag: ${{ steps.set.outputs.tag }} + ref: ${{ steps.set.outputs.ref }} + release: ${{ steps.set.outputs.release }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version (and bump on release) + id: set + run: | + set -e + current=$(jq -r .version se5/HI2UC/plugin.json) + release_flag="false" + ref="${{ github.sha }}" + version="$current" + tag="" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.release }}" = "true" ]; then + release_flag="true" + if [ -n "${{ inputs.version }}" ]; then + version="${{ inputs.version }}" + else + IFS=. read -r major minor patch <<< "$current" + version="$major.$((minor + 1)).0" + fi + + tmp=$(mktemp) + jq --arg v "$version" '.version = $v' se5/HI2UC/plugin.json > "$tmp" + mv "$tmp" se5/HI2UC/plugin.json + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add se5/HI2UC/plugin.json + git commit -m "Bump HI2UC to $version [skip ci]" + + # Retry-with-rebase: if a concurrent release dispatched for a different + # plugin won the push first, pull --rebase and try again. + attempts=0 + while ! git push 2>/tmp/push.err; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 5 ]; then + echo "Failed to push after $attempts attempts:" >&2 + cat /tmp/push.err >&2 + exit 1 + fi + echo "Push rejected (attempt $attempts), pulling --rebase and retrying..." + git pull --rebase --no-edit + done + ref=$(git rev-parse HEAD) + + IFS=. read -r major minor patch <<< "$version" + tag="se5-hi2uc-v$major.$minor" + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "release=$release_flag" >> "$GITHUB_OUTPUT" + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Test + run: dotnet test se5/HI2UC.Tests/HI2UC.Tests.csproj -c Release + + build: + needs: [prepare, test] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + os: windows + exe: HI2UC.exe + - rid: win-arm64 + os: windows + exe: HI2UC.exe + - rid: linux-x64 + os: linux + exe: HI2UC + - rid: linux-arm64 + os: linux + exe: HI2UC + - rid: osx-x64 + os: macos + exe: HI2UC + - rid: osx-arm64 + os: macos + exe: HI2UC + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Publish self-contained (${{ matrix.rid }}) + working-directory: se5/HI2UC + run: | + dotnet publish HI2UC.csproj \ + -c Release \ + -r ${{ matrix.rid }} \ + --self-contained true \ + -p:DebugType=None \ + -p:DebugSymbols=false \ + -o staging/HI2UC + + - name: Rewrite plugin.json for ${{ matrix.os }} + working-directory: se5/HI2UC + run: | + jq ' + del(.runtime, .entry) | + .executables = { "${{ matrix.os }}": "${{ matrix.exe }}" } + ' plugin.json > staging/HI2UC/plugin.json + + - name: Package zip + working-directory: se5/HI2UC/staging + run: zip -r "$GITHUB_WORKSPACE/HI2UC-${{ matrix.rid }}.zip" HI2UC + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: HI2UC-${{ matrix.rid }} + path: HI2UC-${{ matrix.rid }}.zip + + release: + name: Publish GitHub Release + needs: [prepare, build] + if: needs.prepare.outputs.release == 'true' + runs-on: ubuntu-latest + steps: + - name: Download all zips + uses: actions/download-artifact@v4 + with: + path: dist + pattern: HI2UC-* + merge-multiple: true + + - name: Create release and upload zips + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ needs.prepare.outputs.tag }}" dist/HI2UC-*.zip \ + --repo "${{ github.repository }}" \ + --target "${{ needs.prepare.outputs.ref }}" \ + --title "HI2UC v${{ needs.prepare.outputs.version }}" \ + --notes "Self-contained HI2UC plugin builds for win/linux/osx (x64 + arm64)." diff --git a/README.md b/README.md index 1c319710..94ce5ff4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ index [`se5-plugins.json`](se5-plugins.json). | [British to American](se5/BritishToAmerican) | Converts British English spellings to American English. | | [Fix encoding errors](se5/FixEncodingErrors) | Repairs UTF-8 text read with the wrong code page: résumé → résumé, don’t → don’t. | | [Haxor](se5/Haxor) | Translates text to "haxor" - the minimal reference plugin. | +| [Hearing impaired to uppercase](se5/HI2UC) | Changes the case of moods in ( ) / [ ] and speaker labels: (laughs) John: Hi. → (LAUGHS) JOHN: Hi. | | [Italic each line](se5/ItalicEachLine) | Switches italic tags between one tag per subtitle and a tag on each line. | | [Persian Subtitle Fixes](se5/PersianErrors) | Fixes common errors in Persian (Farsi) subtitles - 1,500+ rules in 12 groups. | | [Remove Unicode characters](se5/RemoveUnicodeCharacters) | Finds non-ANSI characters and lets you remove or replace each one. | diff --git a/se5/HI2UC.Tests/CasingTests.cs b/se5/HI2UC.Tests/CasingTests.cs new file mode 100644 index 00000000..b102b6b4 --- /dev/null +++ b/se5/HI2UC.Tests/CasingTests.cs @@ -0,0 +1,105 @@ +using SubtitleEdit.Plugins.HI2UC; + +namespace HI2UC.Tests; + +public class CasingTests +{ + private static readonly HiOptions Upper = new(); + + [Fact] + public void Uppercase_SkipsTags() + { + var sut = new UppercaseConverter(new ChunkReader()); + Assert.Equal("FOOBAR: MMM.", sut.Execute("Foobar: Mmm.")); + } + + [Fact] + public void Uppercase_PlainText() + { + var sut = new UppercaseConverter(new ChunkReader()); + Assert.Equal("CREATURE GROWLING", sut.Execute("creature growling")); + } + + [Fact] + public void Uppercase_MultiLineWithTags() + { + var sut = new UppercaseConverter(new ChunkReader()); + var input = "- (CREATURE)\n- (objects crashing and)"; + var expected = "- (CREATURE)\n- (OBJECTS CRASHING AND)"; + Assert.Equal(expected, sut.Execute(input)); + Assert.Equal(expected, sut.Execute(expected)); + } + + [Fact] + public void ChunkReader_UnclosedTag_DoesNotThrow() + { + var sut = new UppercaseConverter(new ChunkReader()); + Assert.Equal("ABC [LAUGHS] Okay.", HiCasing.Convert("[laughs] Okay.", Upper).After); + } + + [Fact] + public void Narrator_LabelOnly() + { + Assert.Equal("JOHN: Where are you?", HiCasing.Convert("John: Where are you?", Upper).After); + } + + [Fact] + public void Narrator_EachLine_KeepsLineBreak() + { + Assert.Equal("- JOHN: Hi.\r\n- MARY: Hello.", HiCasing.Convert("- John: Hi.\r\n- Mary: Hello.", Upper).After); + Assert.Equal("- JOHN: Hi.\n- MARY: Hello.", HiCasing.Convert("- John: Hi.\n- Mary: Hello.", Upper).After); + } + + [Theory] + [InlineData("See you at 12:30.")] + [InlineData("Visit http://example.com")] + [InlineData("Why? Because: no.")] + [InlineData("Subtitles corrected by: someone")] + [InlineData("Line one\nline two")] + public void Narrator_LeavesNonLabels(string text) + { + Assert.Equal(text, HiCasing.Convert(text, new HiOptions { Moods = false }).After); + } + + [Fact] + public void MoodsAndNarrator_BothApplyToTheSameLine() + { + var result = HiCasing.Convert("(laughs) John: Hi.", Upper); + Assert.Equal("(LAUGHS) JOHN: Hi.", result.After); + Assert.True(result.MoodsChanged); + Assert.True(result.NarratorChanged); + } + + [Fact] + public void Unbalanced_IsFlagged() + { + var result = HiCasing.Convert("(laughs) and (sighs", Upper); + Assert.True(result.Unbalanced); + Assert.Contains("Unbalanced", result.Comment); + } + + [Fact] + public void NothingToDo_IsUnchanged() + { + var result = HiCasing.Convert("(LAUGHS) Okay.", Upper); + Assert.Equal("(LAUGHS) Okay.", result.After); + Assert.False(result.MoodsChanged); + } +} diff --git a/se5/HI2UC.Tests/HI2UC.Tests.csproj b/se5/HI2UC.Tests/HI2UC.Tests.csproj new file mode 100644 index 00000000..a877cba0 --- /dev/null +++ b/se5/HI2UC.Tests/HI2UC.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/se5/HI2UC.Tests/MainViewModelTests.cs b/se5/HI2UC.Tests/MainViewModelTests.cs new file mode 100644 index 00000000..cc40f6f4 --- /dev/null +++ b/se5/HI2UC.Tests/MainViewModelTests.cs @@ -0,0 +1,140 @@ +using SubtitleEdit.Plugins.HI2UC; +using SubtitleEdit.Plugins.Shared; +using System.Text.Json; + +namespace HI2UC.Tests; + +public class MainViewModelTests +{ + private static PluginRequest Request(string requestType, string? settingsJson, params string[] texts) => new() + { + RequestType = requestType, + Subtitle = new PluginSubtitle + { + Format = "Advanced Sub Station Alpha", + Paragraphs = texts.Select((t, i) => new PluginParagraph + { + StartMs = i * 1000, EndMs = i * 1000 + 900, Text = t, Style = "Default", Actor = "Actor" + i, + }).ToList(), + }, + Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(), + SettingsVersion = settingsJson == null ? null : 1, + }; + + [Fact] + public void Run_ListsChangedLines_KeepsParagraphFields() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "(laughs)", "Plain", "John: Hi.")); + Assert.Equal(2, vm.ChangeCount); + + var response = vm.BuildResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + var paragraphs = response.Subtitle!.Paragraphs!; + Assert.Equal(new[] { "(LAUGHS)", "Plain", "JOHN: Hi." }, paragraphs.Select(p => p.Text)); + Assert.Equal("Actor2", paragraphs[2].Actor); + Assert.Equal("Default", paragraphs[2].Style); + Assert.Equal("Advanced Sub Station Alpha", response.Subtitle.Format); + } + + [Fact] + public void Run_UncheckedLinesAreNotApplied() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "(laughs)", "(sighs)")); + vm.Changes[0].IsSelected = false; + + var response = vm.BuildResponse(); + Assert.Equal(new[] { "(laughs)", "(SIGHS)" }, response.Subtitle!.Paragraphs!.Select(p => p.Text)); + } + + [Fact] + public void Run_NothingChecked_IsCancelled() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "(laughs)")); + vm.SelectNoneCommand.Execute(null); + Assert.False(vm.CanApply); + Assert.Equal(PluginStatus.Cancelled, vm.BuildResponse().Status); + } + + [Fact] + public void Run_SavedStyleIsApplied() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, "{\"style\":\"TitleCase\",\"moods\":true,\"narrator\":false}", "(door slams)", "john: Hi.")); + Assert.Equal(HiStyle.TitleCase, vm.SelectedStyle.Style); + Assert.False(vm.Narrator); + Assert.Equal(new[] { "(Door Slams)" }, vm.Changes.Select(c => c.After)); + } + + [Fact] + public void Run_SelectedLinesOnly() + { + var request = Request(PluginRequestType.Run, null, "(aa)", "(bb)", "(cc)"); + request.SelectedIndices = new List { 1 }; + var vm = new MainViewModel(request); + Assert.True(vm.SelectedLinesOnly); + Assert.Equal(new[] { 1 }, vm.Changes.Select(c => c.Index)); + + vm.AllLines = true; + Assert.Equal(3, vm.ChangeCount); + } + + [Fact] + public void Run_LegacyRequest_FallsBackToSubRip() + { + var request = new PluginRequest + { + RequestType = PluginRequestType.Run, + Subtitle = new PluginSubtitle { Format = "SubRip", SubRip = "1\r\n00:00:01,000 --> 00:00:02,000\r\n(laughs)\r\n\r\n" }, + }; + + var response = new MainViewModel(request).BuildResponse(); + Assert.Contains("(LAUGHS)", response.Subtitle!.Native); + Assert.Null(response.Subtitle.Paragraphs); + } + + [Fact] + public void Cancel_ReturnsSettings() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "(laughs)")); + vm.SelectedStyle = vm.StyleOptions.First(o => o.Style == HiStyle.LowerCase); + vm.Moods = false; + + var response = vm.BuildCancelResponse(); + Assert.Equal(PluginStatus.Cancelled, response.Status); + Assert.Equal("LowerCase", response.Settings!.Value.GetProperty("style").GetString()); + Assert.False(response.Settings!.Value.GetProperty("moods").GetBoolean()); + Assert.Equal(1, response.SettingsVersion); + } + + [Fact] + public void Batch_ChangesAllLines_WithoutSettings() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, "{\"style\":\"UpperCase\",\"selectedLinesOnly\":true}", "(laughs)", "Mary: Hi.")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Equal(new[] { "(LAUGHS)", "MARY: Hi." }, response.Subtitle!.Paragraphs!.Select(p => p.Text)); + Assert.Null(response.Settings); + Assert.Null(response.SettingsVersion); + } + + [Fact] + public void Batch_NothingToChange_ReturnsOkWithoutSubtitle() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "Plain", "(LAUGHS)")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } + + [Fact] + public void Configure_ReturnsSettingsOnly_KeepsSavedScope() + { + var vm = new MainViewModel(Request(PluginRequestType.Configure, "{\"style\":\"UpperCase\",\"selectedLinesOnly\":true}")); + Assert.True(vm.IsConfigure); + Assert.True(vm.CanApply); + vm.SelectedStyle = vm.StyleOptions.First(o => o.Style == HiStyle.SentenceCase); + + var response = vm.BuildResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + Assert.Equal("SentenceCase", response.Settings!.Value.GetProperty("style").GetString()); + Assert.True(response.Settings!.Value.GetProperty("selectedLinesOnly").GetBoolean()); + } +} diff --git a/se5/HI2UC/App.axaml b/se5/HI2UC/App.axaml new file mode 100644 index 00000000..dc356f0c --- /dev/null +++ b/se5/HI2UC/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/se5/HI2UC/App.axaml.cs b/se5/HI2UC/App.axaml.cs new file mode 100644 index 00000000..dde3c64f --- /dev/null +++ b/se5/HI2UC/App.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.HI2UC; + +public partial class App : PluginApp +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + protected override Window CreateMainWindow(PluginRequest request) => new MainWindow(request); +} diff --git a/se5/HI2UC/CasingStrategies.cs b/se5/HI2UC/CasingStrategies.cs new file mode 100644 index 00000000..366ca531 --- /dev/null +++ b/se5/HI2UC/CasingStrategies.cs @@ -0,0 +1,210 @@ +using System.Globalization; +using System.Text; + +namespace SubtitleEdit.Plugins.HI2UC; + +/// How the hearing-impaired text is cased. +public enum HiStyle +{ + UpperCase, + UpperLowerCase, + TitleCase, + SentenceCase, + LowerCase, +} + +public interface IConverterStrategy +{ + string Name { get; } + string Example { get; } + string Execute(string input); +} + +public static class CasingStrategies +{ + private static readonly ChunkReader ChunkReader = new(); + + public static IConverterStrategy Create(HiStyle style) => style switch + { + HiStyle.UpperLowerCase => new UpperLowercase(), + HiStyle.TitleCase => new TitleWordsConverter(ChunkReader), + HiStyle.SentenceCase => new SentenceCaseConverter(), + HiStyle.LowerCase => new LowercaseConverter(), + _ => new UppercaseConverter(ChunkReader), + }; + + public static string CapitalizeFirstLetter(this string s, CultureInfo? ci = null) + { + var si = new StringInfo(s); + ci ??= CultureInfo.CurrentCulture; + + if (si.LengthInTextElements > 0) + { + s = si.SubstringByTextElements(0, 1).ToUpper(ci); + } + + if (si.LengthInTextElements > 1) + { + s += si.SubstringByTextElements(1).ToLower(ci); + } + + return s; + } +} + +public class UppercaseConverter : IConverterStrategy +{ + private readonly ChunkReader _chunkReader; + + public UppercaseConverter(ChunkReader chunkReader) => _chunkReader = chunkReader; + + public string Name => "Uppercase"; + public string Example => "Foobar → FOOBAR"; + + public string Execute(string input) + { + var sb = new StringBuilder(); + foreach (var chunk in _chunkReader.Read(input)) + { + var content = input.Substring(chunk.Start, chunk.End - chunk.Start); + sb.Append(chunk.IsTag ? content : content.ToUpper(CultureInfo.CurrentCulture)); + } + + return sb.ToString(); + } + + public override string ToString() => Name; +} + +public class UpperLowercase : IConverterStrategy +{ + public string Name => "Toggle case"; + public string Example => "Foobar → FoObAr"; + + public string Execute(string input) + { + var buffer = new char[input.Length]; + var len = input.Length; + var writeTrack = 0; + var caseState = true; + for (var i = 0; i < len; i++) + { + var ch = input[i]; + // skip tags + if (TextUtils.IsOpenTag(ch)) + { + var closingPair = TextUtils.GetClosingPair(ch); + // look for closing tag + while (ch != closingPair) + { + buffer[writeTrack++] = ch; + if (++i == len) + { + return input; + } + + ch = input[i]; + } + } + + if (!char.IsLetter(ch)) + { + buffer[writeTrack++] = ch; + } + else + { + buffer[writeTrack++] = caseState ? char.ToUpper(ch) : char.ToLower(ch); + caseState = !caseState; + } + } + + return new string(buffer, 0, writeTrack); + } + + public override string ToString() => Name; +} + +public class TitleWordsConverter : IConverterStrategy +{ + private static readonly char[] WordSplitChars = { ' ' }; + private readonly ChunkReader _chunkReader; + + public TitleWordsConverter(ChunkReader chunkReader) => _chunkReader = chunkReader; + + public string Name => "Title case"; + public string Example => "foobar foobar → Foobar Foobar"; + + public string Execute(string input) + { + var sb = new StringBuilder(); + foreach (var chunk in _chunkReader.Read(input)) + { + var content = input.Substring(chunk.Start, chunk.End - chunk.Start); + sb.Append(chunk.IsTag + ? content + : string.Join(" ", content.Split(WordSplitChars).Select(word => word.CapitalizeFirstLetter()))); + } + + return sb.ToString(); + } + + public override string ToString() => Name; +} + +public class SentenceCaseConverter : IConverterStrategy +{ + public string Name => "Sentence case"; + public string Example => "FOOBAR FOOBAR → Foobar foobar"; + + public string Execute(string input) + { + input = input.ToLower(CultureInfo.CurrentCulture); + + // skip tag + var firstLetterIndex = FindFirstLetterIndex(input); + if (firstLetterIndex > 0) + { + // first part can be tag or not + return input.Substring(0, firstLetterIndex) + input.Substring(firstLetterIndex).CapitalizeFirstLetter(); + } + + return input.CapitalizeFirstLetter(); + } + + private static int FindFirstLetterIndex(string input) + { + for (var i = 0; i < input.Length; i++) + { + var ch = input[i]; + if (TextUtils.IsOpenTag(ch)) + { + // skip tag + var closingPairIndex = input.IndexOf(TextUtils.GetClosingPair(ch), i + 1); + if (closingPairIndex < i) + { + return -1; + } + + i = closingPairIndex; + } + else if (char.IsLetter(ch)) + { + return i; + } + } + + return -1; + } + + public override string ToString() => Name; +} + +public class LowercaseConverter : IConverterStrategy +{ + public string Name => "Lowercase"; + public string Example => "Foobar → foobar"; + + public string Execute(string input) => input.ToLower(CultureInfo.CurrentCulture); + + public override string ToString() => Name; +} diff --git a/se5/HI2UC/ChangeItem.cs b/se5/HI2UC/ChangeItem.cs new file mode 100644 index 00000000..a9c025c8 --- /dev/null +++ b/se5/HI2UC/ChangeItem.cs @@ -0,0 +1,30 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SubtitleEdit.Plugins.HI2UC; + +/// One subtitle line whose hearing-impaired text changes case. +public sealed partial class ChangeItem : ObservableObject +{ + [ObservableProperty] private bool _isSelected = true; + + public ChangeItem(int index, string before, string after, string timeRange, string comment, bool isWarning) + { + Index = index; + Before = before; + After = after; + TimeRange = timeRange; + Comment = comment; + IsWarning = isWarning; + (BeforeSegments, AfterSegments) = TextDiff.Compute(before.Replace("\r\n", "\n"), after.Replace("\r\n", "\n")); + } + + public int Index { get; } + public string Before { get; } + public string After { get; } + public string Number => "#" + (Index + 1); + public string TimeRange { get; } + public string Comment { get; } + public bool IsWarning { get; } + public IReadOnlyList BeforeSegments { get; } + public IReadOnlyList AfterSegments { get; } +} diff --git a/se5/HI2UC/ChunkReader.cs b/se5/HI2UC/ChunkReader.cs new file mode 100644 index 00000000..9a3cea37 --- /dev/null +++ b/se5/HI2UC/ChunkReader.cs @@ -0,0 +1,61 @@ +namespace SubtitleEdit.Plugins.HI2UC; + +/// Splits text into word and tag chunks, so casing never touches a tag like <font color="...">. +public class ChunkReader +{ + private static bool IsWordBoundary(char ch) => ch == ' ' || ch == '-' || ch == '<'; + + public IEnumerable Read(string input) + { + var len = input.Length; + var rangeStart = 0; + for (var i = 0; i < len; i++) + { + var ch = input[i]; + + if (TextUtils.IsOpenTag(ch)) + { + // process any pending + if (rangeStart != i) + { + yield return new Chunk(rangeStart, i, false); + rangeStart = i; + } + + var closingPair = TextUtils.GetClosingPair(ch); + while (ch != closingPair) + { + i++; + + // end reached, no closing: the rest is text + if (i == len) + { + yield return new Chunk(rangeStart, len, false); + yield break; + } + + ch = input[i]; + } + + // closing pair found + yield return new Chunk(rangeStart, i + 1, true); + + // update the start to be the current end + rangeStart = i + 1; + } + else if (IsWordBoundary(ch) && i > rangeStart) + { + yield return new Chunk(rangeStart, i, false); + rangeStart = i; + } + } + + // still got pending range to process + if (len - rangeStart > 0) + { + yield return new Chunk(rangeStart, len, false); + } + } + + public readonly record struct Chunk(int Start, int End, bool IsTag); +} diff --git a/se5/HI2UC/HI2UC.csproj b/se5/HI2UC/HI2UC.csproj new file mode 100644 index 00000000..5ba7be6d --- /dev/null +++ b/se5/HI2UC/HI2UC.csproj @@ -0,0 +1,27 @@ + + + + WinExe + net8.0 + enable + enable + HI2UC + SubtitleEdit.Plugins.HI2UC + true + app.manifest + + + + + + + + + + + + + + + + diff --git a/se5/HI2UC/HiCasing.cs b/se5/HI2UC/HiCasing.cs new file mode 100644 index 00000000..5e3b12c7 --- /dev/null +++ b/se5/HI2UC/HiCasing.cs @@ -0,0 +1,65 @@ +namespace SubtitleEdit.Plugins.HI2UC; + +public sealed class HiOptions +{ + public HiStyle Style { get; set; } = HiStyle.UpperCase; + public bool Moods { get; set; } = true; + public bool Narrator { get; set; } = true; +} + +/// The result for one subtitle text; flags mismatched ()/[] that may need a manual look. +public sealed record HiResult(string After, bool MoodsChanged, bool NarratorChanged, bool Unbalanced) +{ + public string Comment + { + get + { + var parts = new List(); + if (MoodsChanged) + { + parts.Add("Moods"); + } + + if (NarratorChanged) + { + parts.Add("Narrator"); + } + + if (Unbalanced) + { + parts.Add("Unbalanced ( ) or [ ] - check this line"); + } + + return string.Join(" · ", parts); + } + } +} + +public static class HiCasing +{ + /// Cases moods first, then the narrator label, on the same text. + public static HiResult Convert(string text, HiOptions options) + { + var strategy = CasingStrategies.Create(options.Style); + var after = text; + + var moodsChanged = false; + if (options.Moods) + { + var moods = new MoodCasingConverter(strategy).Convert(after); + moodsChanged = moods != after; + after = moods; + } + + var narratorChanged = false; + if (options.Narrator) + { + var narrator = new NarratorCasingConverter(strategy).Convert(after); + narratorChanged = narrator != after; + after = narrator; + } + + var unbalanced = options.Moods && !MoodCasingConverter.HasBalancedParentheses(text); + return new HiResult(after, moodsChanged, narratorChanged, unbalanced); + } +} diff --git a/se5/HI2UC/MainViewModel.cs b/se5/HI2UC/MainViewModel.cs new file mode 100644 index 00000000..8ca9e366 --- /dev/null +++ b/se5/HI2UC/MainViewModel.cs @@ -0,0 +1,332 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.Shared; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text.Json; + +namespace SubtitleEdit.Plugins.HI2UC; + +public sealed record StyleOption(HiStyle Style, string Name, string Example) +{ + public override string ToString() => Name; +} + +public sealed partial class MainViewModel : ObservableObject +{ + private const int SettingsVersion = 1; + + private readonly PluginRequest _request; + private readonly bool _legacyRequest; + private readonly List _paragraphs; + private readonly long _videoOffsetMs; + private readonly bool _isConfigure; + private bool _loading; + + [ObservableProperty] private StyleOption _selectedStyle; + [ObservableProperty] private bool _moods = true; + [ObservableProperty] private bool _narrator = true; + [ObservableProperty] private bool _selectedLinesOnly; + [ObservableProperty] private int _selectedCount; + + public MainViewModel(PluginRequest request) + { + _request = request; + _isConfigure = request.RequestType == PluginRequestType.Configure; + _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; + _legacyRequest = request.Subtitle.Paragraphs == null; + _paragraphs = _legacyRequest + ? SubRipParser.Parse(request.Subtitle.SubRip) + .Select(b => new PluginParagraph { StartMs = b.StartMs, EndMs = b.EndMs, Text = b.Text }).ToList() + : request.Subtitle.Paragraphs!.Select(p => p.Clone()).ToList(); + SelectedIndices = request.SelectedIndices.Where(i => i >= 0 && i < _paragraphs.Count).Distinct().Order().ToList(); + + StyleOptions = Enum.GetValues().Select(style => + { + var strategy = CasingStrategies.Create(style); + return new StyleOption(style, strategy.Name, strategy.Example); + }).ToList(); + _selectedStyle = StyleOptions[0]; + + _loading = true; + LoadSettings(); + _loading = false; + Analyze(); + } + + public ObservableCollection Changes { get; } = new(); + public IReadOnlyList StyleOptions { get; } + public IReadOnlyList SelectedIndices { get; } + + public HiOptions Options => new() { Style = SelectedStyle.Style, Moods = Moods, Narrator = Narrator }; + + public string SubtitleInfo + { + get + { + if (_isConfigure) + { + return "Settings for Batch convert"; + } + + var name = string.IsNullOrEmpty(_request.Subtitle.FileName) ? "Untitled" : Path.GetFileName(_request.Subtitle.FileName); + var format = string.IsNullOrEmpty(_request.Subtitle.Format) ? string.Empty : $" · {_request.Subtitle.Format}"; + return $"{name}{format} · {_paragraphs.Count:N0} lines"; + } + } + + /// Opened from Batch convert's Configure button: only the options matter, there is no subtitle. + public bool IsConfigure => _isConfigure; + public bool IsNotConfigure => !_isConfigure; + + public bool HasSelectedLines => SelectedIndices.Count > 0; + public string SelectedLinesText => SelectedIndices.Count == 1 ? "Selected line (1)" : $"Selected lines ({SelectedIndices.Count:N0})"; + public bool AllLines { get => !SelectedLinesOnly; set => SelectedLinesOnly = !value; } + public int ChangeCount => Changes.Count; + public bool HasChanges => Changes.Count > 0; + public bool IsClean => Changes.Count == 0 && !_isConfigure; + public bool CanApply => SelectedCount > 0 || _isConfigure; + public string StyleExample => SelectedStyle.Example; + + public string CleanText => !Moods && !Narrator + ? "Turn on moods or narrators to change anything." + : $"All hearing-impaired text is already in {SelectedStyle.Name.ToLowerInvariant()}."; + + public string StatusText => _isConfigure + ? "Used for every file in Batch convert" + : Changes.Count == 0 + ? $"{LinesInScope:N0} lines checked" + : $"{SelectedCount:N0} of {Changes.Count:N0} lines selected"; + + private int LinesInScope => SelectedLinesOnly && HasSelectedLines ? SelectedIndices.Count : _paragraphs.Count; + + partial void OnSelectedStyleChanged(StyleOption value) + { + OnPropertyChanged(nameof(StyleExample)); + OnPropertyChanged(nameof(CleanText)); + Analyze(); + } + + partial void OnMoodsChanged(bool value) + { + OnPropertyChanged(nameof(CleanText)); + Analyze(); + } + + partial void OnNarratorChanged(bool value) + { + OnPropertyChanged(nameof(CleanText)); + Analyze(); + } + + partial void OnSelectedLinesOnlyChanged(bool value) + { + OnPropertyChanged(nameof(AllLines)); + Analyze(); + } + + partial void OnSelectedCountChanged(int value) + { + OnPropertyChanged(nameof(CanApply)); + OnPropertyChanged(nameof(StatusText)); + } + + private IEnumerable IndicesInScope => + SelectedLinesOnly && HasSelectedLines ? SelectedIndices : Enumerable.Range(0, _paragraphs.Count); + + private void Analyze() + { + if (_loading) + { + return; + } + + var options = Options; + Changes.Clear(); + foreach (var index in IndicesInScope) + { + var text = _paragraphs[index].Text ?? string.Empty; + var result = HiCasing.Convert(text, options); + if (result.After != text) + { + var item = new ChangeItem(index, text, result.After, FormatTimeRange(_paragraphs[index]), result.Comment, result.Unbalanced); + item.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ChangeItem.IsSelected)) + { + SelectedCount = Changes.Count(c => c.IsSelected); + } + }; + Changes.Add(item); + } + } + + SelectedCount = Changes.Count; + OnPropertyChanged(nameof(ChangeCount)); + OnPropertyChanged(nameof(HasChanges)); + OnPropertyChanged(nameof(IsClean)); + OnPropertyChanged(nameof(StatusText)); + } + + [RelayCommand] + private void SelectAll() => SetSelection(_ => true); + + [RelayCommand] + private void SelectNone() => SetSelection(_ => false); + + [RelayCommand] + private void InvertSelection() => SetSelection(c => !c.IsSelected); + + private void SetSelection(Func selected) + { + foreach (var change in Changes) + { + change.IsSelected = selected(change); + } + } + + /// + /// A "batch" request (Batch convert): change every line with the saved options, without UI. + /// No changes is "ok" without a subtitle. + /// + public PluginResponse BuildBatchResponse() + { + if (Changes.Count == 0) + { + return new PluginResponse { Status = PluginStatus.Ok, Message = "Hearing impaired to uppercase: nothing to change." }; + } + + var response = BuildResponse(); + response.Settings = null; + response.SettingsVersion = null; + return response; + } + + /// A "configure" request: hand back the settings only, for the next Batch convert run. + public PluginResponse BuildConfigureResponse() => + new() { Status = PluginStatus.Ok, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + /// The response for OK: the selected lines with their new casing. + public PluginResponse BuildResponse() + { + if (_isConfigure) + { + return BuildConfigureResponse(); + } + + var selected = Changes.Where(c => c.IsSelected).ToList(); + if (selected.Count == 0) + { + return BuildCancelResponse(); + } + + var texts = _paragraphs.Select(p => p.Text ?? string.Empty).ToArray(); + foreach (var change in selected) + { + texts[change.Index] = change.After; + } + + PluginSubtitle subtitle; + if (_legacyRequest) + { + subtitle = new PluginSubtitle + { + Format = "SubRip", + Native = SubRipParser.Serialize(_paragraphs.Select((p, i) => new SrtBlock + { + StartMs = (long)Math.Round(p.StartMs), + EndMs = (long)Math.Round(p.EndMs), + Text = texts[i], + }).ToList()), + }; + } + else + { + subtitle = new PluginSubtitle + { + Format = _request.Subtitle.Format, + Paragraphs = _paragraphs.Select((p, i) => + { + var copy = p.Clone(); + copy.Text = texts[i]; + return copy; + }).ToList(), + }; + } + + return new PluginResponse + { + Status = PluginStatus.Ok, + Message = selected.Count == 1 + ? "Hearing impaired to uppercase: 1 line changed." + : $"Hearing impaired to uppercase: {selected.Count:N0} lines changed.", + UndoDescription = "Hearing impaired to uppercase", + Subtitle = subtitle, + Settings = BuildSettings(), + SettingsVersion = SettingsVersion, + }; + } + + public PluginResponse BuildCancelResponse() => + new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + private JsonElement BuildSettings() + { + var payload = new Dictionary + { + ["style"] = SelectedStyle.Style.ToString(), + ["moods"] = Moods, + ["narrator"] = Narrator, + ["selectedLinesOnly"] = SelectedLinesOnly, + }; + + // Configure has no lines selected, so keep the scope a normal run saved. + if (_isConfigure && _request.SettingsVersion == SettingsVersion && _request.Settings is { ValueKind: JsonValueKind.Object } stored + && stored.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + payload["selectedLinesOnly"] = selectedOnly.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(payload); + } + + private void LoadSettings() + { + // Lines selected in Subtitle Edit mean "work on these" - default to them. + SelectedLinesOnly = HasSelectedLines; + if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings) + { + return; + } + + if (settings.TryGetProperty("style", out var style) && style.ValueKind == JsonValueKind.String && + Enum.TryParse(style.GetString(), out var value) && StyleOptions.FirstOrDefault(o => o.Style == value) is { } option) + { + SelectedStyle = option; + } + + if (settings.TryGetProperty("moods", out var moods) && moods.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + Moods = moods.GetBoolean(); + } + + if (settings.TryGetProperty("narrator", out var narrator) && narrator.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + Narrator = narrator.GetBoolean(); + } + + if (settings.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + SelectedLinesOnly = selectedOnly.GetBoolean() && HasSelectedLines; + } + } + + private string FormatTimeRange(PluginParagraph p) => + $"{FormatTime(p.StartMs + _videoOffsetMs)} → {FormatTime(p.EndMs + _videoOffsetMs)}"; + + private static string FormatTime(double ms) + { + var time = TimeSpan.FromMilliseconds(Math.Max(0, ms)); + return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00},{3:000}", (int)time.TotalHours, time.Minutes, time.Seconds, time.Milliseconds); + } +} diff --git a/se5/HI2UC/MainWindow.axaml b/se5/HI2UC/MainWindow.axaml new file mode 100644 index 00000000..090fc5f0 --- /dev/null +++ b/se5/HI2UC/MainWindow.axaml @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/HI2UC/MainWindow.axaml.cs b/se5/HI2UC/MainWindow.axaml.cs new file mode 100644 index 00000000..471cef81 --- /dev/null +++ b/se5/HI2UC/MainWindow.axaml.cs @@ -0,0 +1,58 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.HI2UC; + +public partial class MainWindow : Window +{ + private readonly MainViewModel _vm; + + public MainWindow() : this(new PluginRequest()) { } + + public MainWindow(PluginRequest request) + { + AvaloniaXamlLoader.Load(this); + _vm = new MainViewModel(request); + DataContext = _vm; + App.Response = _vm.BuildCancelResponse(); + KeyDown += OnKeyDown; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + this.BringToForeground(); + } + + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + OnCancel(sender, e); + } + } + + private void OnCancel(object? sender, RoutedEventArgs e) + { + App.Response = _vm.BuildCancelResponse(); + Close(); + } + + private void OnOk(object? sender, RoutedEventArgs e) + { + try + { + App.Response = _vm.BuildResponse(); + } + catch (Exception exception) + { + App.Response = new PluginResponse { Status = PluginStatus.Error, Message = "Hearing impaired to uppercase failed: " + exception.Message }; + } + + Close(); + } +} diff --git a/se5/HI2UC/MoodCasingConverter.cs b/se5/HI2UC/MoodCasingConverter.cs new file mode 100644 index 00000000..a38ba355 --- /dev/null +++ b/se5/HI2UC/MoodCasingConverter.cs @@ -0,0 +1,182 @@ +namespace SubtitleEdit.Plugins.HI2UC; + +/// Cases the text inside (...) and [...] - sounds and moods like "(door slams)". +public class MoodCasingConverter +{ + private static readonly char[] MoodChars = { '(', '[' }; + + private readonly IConverterStrategy _converterStrategy; + + public MoodCasingConverter(IConverterStrategy converterStrategy) => _converterStrategy = converterStrategy; + + public string Convert(string text) + { + text = RemoveInvalidParentheses(text); + + if (!HasValidMood(text)) + { + return text; + } + + var openSymbolIndex = text.IndexOfAny(MoodChars); + while (openSymbolIndex >= 0) + { + var closingPair = GetClosingPair(text[openSymbolIndex]); + var closeSymbolIndex = text.IndexOf(closingPair, openSymbolIndex + 1); // ] or ) + // There must be at least one char inside the brackets. + if (closeSymbolIndex < openSymbolIndex + 2) + { + break; + } + + var mood = text.Substring(openSymbolIndex + 1, closeSymbolIndex - (openSymbolIndex + 1)); + var moodToken = new Mood(mood, text[openSymbolIndex], text[closeSymbolIndex]); + if (moodToken.IsConvertible()) + { + // take out the mood (including open/close symbol), convert, reinsert and restore the wrapping () or [] + text = text.Remove(openSymbolIndex, closeSymbolIndex - openSymbolIndex + 1); + text = text.Insert(openSymbolIndex, moodToken.Tokenize(_converterStrategy.Execute(mood))); + } + + openSymbolIndex = text.IndexOfAny(MoodChars, closeSymbolIndex + 1); // ( or [ + } + + return text; + } + + public static bool HasBalancedParentheses(string input) + { + var countParentheses = 0; + var countBrackets = 0; + for (var i = input.Length - 1; i >= 0; i--) + { + switch (input[i]) + { + case '(': + countParentheses++; + break; + case ')': + countParentheses--; + break; + case '[': + countBrackets++; + break; + case ']': + countBrackets--; + break; + } + + // even if you check to the end there won't be enough to balance + if (i - countBrackets < 0 || i - countParentheses < 0) + { + return false; + } + } + + return countBrackets == 0 && countParentheses == 0; + } + + private static string RemoveInvalidParentheses(string input) => + input.Replace("()", string.Empty).Replace("[]", string.Empty).Replace("( )", string.Empty).Replace("[ ]", string.Empty); + + private static char GetClosingPair(char openToken) => openToken switch + { + '(' => ')', + '[' => ']', + '{' => '}', + _ => '\0', + }; + + private static bool HasValidMood(string text) + { + if (string.IsNullOrEmpty(text)) + { + return false; + } + + var moodStartIndex = text.IndexOfAny(MoodChars); + if (moodStartIndex < 0) + { + return false; + } + + // skip closing html tag at end + var lastCharIndex = text.Length - 1; + while (lastCharIndex > 0 && text[lastCharIndex] == '>') + { + lastCharIndex = text.LastIndexOf('<', lastCharIndex - 1) - 1; + } + + if (lastCharIndex < 0) + { + return false; + } + + // open parenthesis/bracket followed by a tag + if (moodStartIndex + 1 < text.Length && text[moodStartIndex + 1] == '<') + { + return false; + } + + // ensure the mood char is not the last character + return moodStartIndex + 1 < text.Length; + } + + private readonly struct Mood + { + private readonly string _content; + private readonly char _startToken; + private readonly char _endToken; + + public Mood(string content, char startToken, char endToken) + { + _content = content; + _startToken = startToken; + _endToken = endToken; + } + + public bool IsConvertible() + { + if (string.IsNullOrWhiteSpace(_content)) + { + return false; + } + + var indexFromStart = 0; + var len = _content.Length; + + // skip all adjacent open tags e.g. ... + while (_content[indexFromStart] == '<') + { + var tagCloseIndex = _content.IndexOf('>', indexFromStart) + 1; + if (tagCloseIndex <= indexFromStart) + { + return true; + } + + indexFromStart = tagCloseIndex; // the next char after the closing + if (indexFromStart >= len) + { + return false; + } + } + + // skip all adjacent closing tags + var indexFromEnd = len - 1; + while (_content[indexFromEnd] == '>') + { + var tagOpenIndex = _content.LastIndexOf('<', indexFromEnd - 1) - 1; + if (tagOpenIndex < 0 || tagOpenIndex <= indexFromStart) + { + return false; + } + + indexFromEnd = tagOpenIndex; + } + + return indexFromStart < indexFromEnd; + } + + public string Tokenize(string input) => $"{_startToken}{input}{_endToken}"; + } +} diff --git a/se5/HI2UC/NarratorCasingConverter.cs b/se5/HI2UC/NarratorCasingConverter.cs new file mode 100644 index 00000000..849b56c9 --- /dev/null +++ b/se5/HI2UC/NarratorCasingConverter.cs @@ -0,0 +1,92 @@ +namespace SubtitleEdit.Plugins.HI2UC; + +/// Cases the speaker label before a colon - "John: Hi." becomes "JOHN: Hi.". +public class NarratorCasingConverter +{ + private static readonly char[] LineCloseChars = { '!', '?', '¿', '¡' }; + private static readonly char[] Symbols = { '.', '!', '?', ')', ']' }; + + private readonly IConverterStrategy _converterStrategy; + + public NarratorCasingConverter(IConverterStrategy converterStrategy) => _converterStrategy = converterStrategy; + + public string Convert(string text) + { + var noTagText = TextUtils.RemoveTags(text, true).TrimEnd().TrimEnd('"'); + if (noTagText.StartsWith("http", StringComparison.Ordinal) || !text.Contains(':')) + { + return text; + } + + var changed = false; + var lines = text.SplitToLines(); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var noTagLine = TextUtils.RemoveTags(line, true); + if (!noTagLine.Contains(':')) + { + continue; + } + + if (IsConvertible(noTagLine, noTagLine.IndexOf(':'))) + { + // Find index from original text. + var colonIdx = line.IndexOf(':') + 1; + var converted = _converterStrategy.Execute(line.Substring(0, colonIdx)) + line.Substring(colonIdx); + if (converted != line) + { + lines[i] = converted; + changed = true; + } + } + } + + return changed ? string.Join(TextUtils.DetectNewLine(text), lines) : text; + } + + private static bool IsConvertible(string noTagsLine, int colonIdx) + { + if (colonIdx <= 1 || colonIdx + 1 >= noTagsLine.Length) + { + return false; + } + + var noTagCapturedText = noTagsLine.Substring(0, colonIdx + 1); + if (string.IsNullOrWhiteSpace(noTagCapturedText)) + { + return false; + } + + // e.g: 12:30am... or the slash in https:// + var charAfterColon = noTagsLine[colonIdx + 1]; + if (char.IsDigit(charAfterColon) || charAfterColon == '/') + { + return false; + } + + // ignore: - where it's safest. BRAN: No. + var symbolIdx = noTagCapturedText.LastIndexOfAny(Symbols, colonIdx); + if (symbolIdx > 0) + { + // text before the symbol + var preText = noTagCapturedText.Substring(0, symbolIdx).Trim(); + // text after the symbol, without the colon + var textAfterSymbols = noTagCapturedText.Substring(symbolIdx + 1, colonIdx - symbolIdx - 1).Trim(); + + // the label after the symbol is already uppercase - the text before it is not a label + if (textAfterSymbols.Equals(textAfterSymbols.ToUpper()) && !preText.Equals(preText.ToUpper())) + { + return false; + } + } + + // Foobar[?!] Narrator: Hello ("." is allowed: names like "Mr. Smith:") + return !noTagCapturedText.ContainsAny(LineCloseChars) && + !noTagCapturedText.StartsWith("http", StringComparison.OrdinalIgnoreCase) && + !noTagCapturedText.EndsWith("improved by:", StringComparison.OrdinalIgnoreCase) && + !noTagCapturedText.EndsWith("corrected by:", StringComparison.OrdinalIgnoreCase) && + !noTagCapturedText.EndsWith("https:", StringComparison.OrdinalIgnoreCase) && + !noTagCapturedText.EndsWith("http:", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/se5/HI2UC/Program.cs b/se5/HI2UC/Program.cs new file mode 100644 index 00000000..568fdcf4 --- /dev/null +++ b/se5/HI2UC/Program.cs @@ -0,0 +1,16 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.Shared; +using System; + +namespace SubtitleEdit.Plugins.HI2UC; + +public static class Program +{ + [STAThread] + public static int Main(string[] args) + { + IconProvider.Current.Register(); + return PluginBootstrap.Run(args, request => new MainViewModel(request).BuildBatchResponse()); + } +} diff --git a/se5/HI2UC/README.md b/se5/HI2UC/README.md new file mode 100644 index 00000000..68acf212 --- /dev/null +++ b/se5/HI2UC/README.md @@ -0,0 +1,55 @@ +# Hearing impaired to uppercase (Subtitle Edit 5 plugin) + +Changes the case of hearing-impaired text: + +| Before | After | +|---|---| +| `(laughs) John: Hi.` | `(LAUGHS) JOHN: Hi.` | +| `[door slams]` | `[DOOR SLAMS]` | +| `- Mary: Wait!` | `- MARY: Wait!` | + +It is the SE5 port of the SE4 *HI2UC* plugin by ivandrofly. + +Built on Avalonia and the shared [`Plugin-Shared`](../Plugin-Shared/) library. + +## What changes + +- **Moods**: text inside `( )` and `[ ]`. Empty brackets like `()` are removed. + Lines with unbalanced brackets are still changed, but the line is flagged so you can check it. +- **Narrators**: the speaker label before a colon, on each line. These are left alone: + - times (`12:30`) + - links (`http://`) + - text with `?` or `!` before the colon + - credits like `Corrected by:` +- Tags like ``, `` and `{\an8}` are never changed. + +Styles: *Uppercase*, *Toggle case*, *Title case*, *Sentence case* and *Lowercase*. + +## UI + +- Pick the style, and whether to change moods, narrators or both. +- *All lines* / *Selected lines*: lines selected in Subtitle Edit are the default scope. +- Every changed line is a checkable before/after card. *OK* applies the checked + lines as one "Hearing impaired to uppercase" undo step. +- Style, options and scope are remembered between runs. + +Works on the lines exactly as Subtitle Edit holds them, so the styles, actors and +other fields of formats like ASSA are kept. On older SE versions it falls back to SubRip. + +## Batch convert + +Hearing impaired to uppercase supports Subtitle Edit's **Batch convert** (Tools → Batch convert → Run plugin). This needs a Subtitle Edit version with plugin batch support. + +- **Configure...** opens the window without a subtitle, to pick the style and options. +- During the batch, every line of each file is changed without showing the window. Files with nothing to change are left as they are. + +## Changes from the SE4 plugin + +- A line with both a mood and a narrator now gets both changes. Before, the second change overwrote the first. +- The saved style is used again on the next run. +- Lines with nothing to change no longer appear in the list as warnings. +- No usage data is sent anywhere. + +## Build + +See `.github/workflows/hi2uc.yml`. diff --git a/se5/HI2UC/SubtitleTextBlock.cs b/se5/HI2UC/SubtitleTextBlock.cs new file mode 100644 index 00000000..651988dc --- /dev/null +++ b/se5/HI2UC/SubtitleTextBlock.cs @@ -0,0 +1,201 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using System.Text; + +namespace SubtitleEdit.Plugins.HI2UC; + +/// +/// Shows one side of a . With off the raw text is shown with +/// tags colored and changed text tinted; with it on the tags are hidden and italic text is drawn in italics. +/// +public sealed class SubtitleTextBlock : TextBlock +{ + public static readonly StyledProperty?> SegmentsProperty = + AvaloniaProperty.Register?>(nameof(Segments)); + + public static readonly StyledProperty RenderedProperty = + AvaloniaProperty.Register(nameof(Rendered)); + + private static readonly IBrush RemovedBrush = new SolidColorBrush(Color.Parse("#55E5484D")); + private static readonly IBrush AddedBrush = new SolidColorBrush(Color.Parse("#552FA36B")); + private static readonly IBrush TagBrush = new SolidColorBrush(Color.Parse("#0EA5E9")); + private static readonly IBrush ItalicBrush = new SolidColorBrush(Color.Parse("#22EA580C")); + + static SubtitleTextBlock() + { + SegmentsProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + RenderedProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + } + + public IReadOnlyList? Segments + { + get => GetValue(SegmentsProperty); + set => SetValue(SegmentsProperty, value); + } + + public bool Rendered + { + get => GetValue(RenderedProperty); + set => SetValue(RenderedProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + private void Rebuild() + { + var segments = Segments ?? Array.Empty(); + Inlines = Rendered ? BuildRendered(string.Concat(segments.Select(s => s.Text))) : BuildRaw(segments); + } + + /// Raw text: tags in the tag color, removed/added text on a tint. + private static InlineCollection BuildRaw(IReadOnlyList segments) + { + var inlines = new InlineCollection(); + var inTag = false; + foreach (var segment in segments) + { + var background = segment.Kind switch + { + DiffKind.Removed => RemovedBrush, + DiffKind.Added => AddedBrush, + _ => null, + }; + + var run = new StringBuilder(); + var runIsTag = inTag; + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) { Background = background }; + if (runIsTag) + { + inline.Foreground = TagBrush; + inline.FontWeight = FontWeight.SemiBold; + } + + inlines.Add(inline); + run.Clear(); + } + } + + foreach (var c in segment.Text) + { + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + if (background != null) + { + // A changed line break would otherwise be invisible. + inlines.Add(new Run("↵") { Background = background, Foreground = TagBrush }); + } + + inlines.Add(new LineBreak()); + continue; + } + + var isTag = inTag || c == '<'; + if (isTag != runIsTag) + { + Flush(); + runIsTag = isTag; + } + + run.Append(c); + if (c == '<') + { + inTag = true; + } + else if (c == '>') + { + inTag = false; + Flush(); + runIsTag = false; + } + } + + Flush(); + } + + return inlines; + } + + /// Rendered text: tags hidden, <i> drawn in italics on a soft tint, <b>/<u> applied too. + private static InlineCollection BuildRendered(string text) + { + var inlines = new InlineCollection(); + var italic = 0; + var bold = 0; + var underline = 0; + var run = new StringBuilder(); + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) + { + FontStyle = italic > 0 ? FontStyle.Italic : FontStyle.Normal, + FontWeight = bold > 0 ? FontWeight.Bold : FontWeight.Normal, + }; + if (underline > 0) + { + inline.TextDecorations = Avalonia.Media.TextDecorations.Underline; + } + + if (italic > 0) + { + inline.Background = ItalicBrush; + } + + inlines.Add(inline); + run.Clear(); + } + } + + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + inlines.Add(new LineBreak()); + continue; + } + + var end = c is '<' or '{' ? text.IndexOf(c == '<' ? '>' : '}', i) : -1; + if (end < 0) + { + run.Append(c); + continue; + } + + Flush(); + var tag = text.Substring(i, end - i + 1).ToLowerInvariant(); + i = end; + switch (tag) + { + case "": italic++; break; + case "": italic = Math.Max(0, italic - 1); break; + case "": bold++; break; + case "": bold = Math.Max(0, bold - 1); break; + case "": underline++; break; + case "": underline = Math.Max(0, underline - 1); break; + } + } + + Flush(); + return inlines; + } +} diff --git a/se5/HI2UC/TextDiff.cs b/se5/HI2UC/TextDiff.cs new file mode 100644 index 00000000..96a39abd --- /dev/null +++ b/se5/HI2UC/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.HI2UC; + +public enum DiffKind +{ + Same, + Removed, + Added, +} + +public sealed record DiffSegment(string Text, DiffKind Kind); + +/// Character diff (LCS) used to highlight what a fix changed. +public static class TextDiff +{ + private const int MaxCells = 1_000_000; + + /// Returns the segments of the old text (same/removed) and of the new text (same/added). + public static (List Before, List After) Compute(string before, string after) + { + // Same number of lines: diff line by line, so line breaks never pair up with the wrong line. + var beforeLines = before.Split('\n'); + var afterLines = after.Split('\n'); + if (beforeLines.Length > 1 && beforeLines.Length == afterLines.Length) + { + var beforeSegments = new List(); + var afterSegments = new List(); + for (var i = 0; i < beforeLines.Length; i++) + { + if (i > 0) + { + Add(beforeSegments, "\n", DiffKind.Same); + Add(afterSegments, "\n", DiffKind.Same); + } + + var (b, a) = ComputeLine(beforeLines[i], afterLines[i]); + b.ForEach(segment => Add(beforeSegments, segment.Text, segment.Kind)); + a.ForEach(segment => Add(afterSegments, segment.Text, segment.Kind)); + } + + return (beforeSegments, afterSegments); + } + + return ComputeLine(before, after); + } + + private static (List Before, List After) ComputeLine(string before, string after) + { + var prefix = 0; + while (prefix < before.Length && prefix < after.Length && before[prefix] == after[prefix]) + { + prefix++; + } + + var suffix = 0; + while (suffix < before.Length - prefix && suffix < after.Length - prefix && + before[before.Length - 1 - suffix] == after[after.Length - 1 - suffix]) + { + suffix++; + } + + var a = before.Substring(prefix, before.Length - prefix - suffix); + var b = after.Substring(prefix, after.Length - prefix - suffix); + var beforeSegments = new List(); + var afterSegments = new List(); + Add(beforeSegments, before[..prefix], DiffKind.Same); + Add(afterSegments, after[..prefix], DiffKind.Same); + + if ((long)a.Length * b.Length > MaxCells) + { + Add(beforeSegments, a, DiffKind.Removed); + Add(afterSegments, b, DiffKind.Added); + } + else + { + Lcs(a, b, beforeSegments, afterSegments); + } + + Add(beforeSegments, before[(before.Length - suffix)..], DiffKind.Same); + Add(afterSegments, after[(after.Length - suffix)..], DiffKind.Same); + return (beforeSegments, afterSegments); + } + + private static void Lcs(string a, string b, List beforeSegments, List afterSegments) + { + var lengths = new int[a.Length + 1, b.Length + 1]; + for (var i = a.Length - 1; i >= 0; i--) + { + for (var j = b.Length - 1; j >= 0; j--) + { + lengths[i, j] = a[i] == b[j] + ? lengths[i + 1, j + 1] + 1 + : Math.Max(lengths[i + 1, j], lengths[i, j + 1]); + } + } + + int x = 0, y = 0; + while (x < a.Length && y < b.Length) + { + if (a[x] == b[y]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Same); + Add(afterSegments, b[y].ToString(), DiffKind.Same); + x++; + y++; + } + else if (lengths[x + 1, y] >= lengths[x, y + 1]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Removed); + x++; + } + else + { + Add(afterSegments, b[y].ToString(), DiffKind.Added); + y++; + } + } + + Add(beforeSegments, a[x..], DiffKind.Removed); + Add(afterSegments, b[y..], DiffKind.Added); + } + + private static void Add(List segments, string text, DiffKind kind) + { + if (text.Length == 0) + { + return; + } + + if (segments.Count > 0 && segments[^1].Kind == kind) + { + segments[^1] = segments[^1] with { Text = segments[^1].Text + text }; + } + else + { + segments.Add(new DiffSegment(text, kind)); + } + } +} diff --git a/se5/HI2UC/TextUtils.cs b/se5/HI2UC/TextUtils.cs new file mode 100644 index 00000000..aff1f6b1 --- /dev/null +++ b/se5/HI2UC/TextUtils.cs @@ -0,0 +1,58 @@ +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.HI2UC; + +/// Text helpers carried over from the SE4 plugin's shared code (HtmlUtils, StringUtils, StringExtensions). +internal static class TextUtils +{ + private static readonly string[] HtmlTags = { "i", "b", "u", "p", "font", "\u0456" }; + private static readonly Regex TagRegex = new(@"<\s*(?:/\s*)?(\w+)[^>]*>", RegexOptions.Compiled); + + public static bool IsOpenTag(char ch) => ch == '<' || ch == '{'; + + public static char GetClosingPair(char ch) => ch == '<' ? '>' : '}'; + + /// Removes <i>, <b>, <u>, <font> ... tags, and optionally ASSA {...} tags. + public static string RemoveTags(string s, bool alsoSsaTags = false) + { + if (s.Length < 3) + { + return s; + } + + if (alsoSsaTags) + { + s = RemoveSsaTags(s); + } + + return s.Contains('<') + ? TagRegex.Replace(s, m => HtmlTags.Contains(m.Groups[1].Value, StringComparer.OrdinalIgnoreCase) ? string.Empty : m.Value) + : s; + } + + public static string RemoveSsaTags(string s) + { + var idx = s.IndexOf('{'); + while (idx >= 0) + { + var endIdx = s.IndexOf('}', idx + 1); + if (endIdx < 0) + { + break; + } + + s = s.Remove(idx, endIdx - idx + 1); + idx = s.IndexOf('{', idx); + } + + return s; + } + + public static string[] SplitToLines(this string source) => + source.Replace("\r\r\n", "\n").Replace("\r\n", "\n").Replace('\r', '\n').Replace('\u2028', '\n').Split('\n'); + + /// The line break used in the text, so lines are joined back the same way. + public static string DetectNewLine(string text) => text.Contains("\r\n") ? "\r\n" : "\n"; + + public static bool ContainsAny(this string s, char[] chars) => s.IndexOfAny(chars) >= 0; +} diff --git a/se5/HI2UC/app.manifest b/se5/HI2UC/app.manifest new file mode 100644 index 00000000..b018e2fc --- /dev/null +++ b/se5/HI2UC/app.manifest @@ -0,0 +1,10 @@ + + + + + + true/pm + PerMonitorV2 + + + diff --git a/se5/HI2UC/plugin.json b/se5/HI2UC/plugin.json new file mode 100644 index 00000000..4fb07eb5 --- /dev/null +++ b/se5/HI2UC/plugin.json @@ -0,0 +1,13 @@ +{ + "apiVersion": 1, + "name": "Hearing impaired to uppercase", + "description": "Changes the case of hearing-impaired text: sounds and moods in ( ) or [ ] and speaker labels before a colon - (laughs) John: Hi. → (LAUGHS) JOHN: Hi. Uppercase, title, sentence, lower or toggle case. Shows a checkable before/after preview.", + "version": "1.0.0", + "author": "ivandrofly", + "url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/HI2UC", + "menu": "Tools", + "minSeVersion": "5.0.0", + "batch": true, + "runtime": "dotnet", + "entry": "HI2UC.dll" +} diff --git a/se5/Plugins.slnx b/se5/Plugins.slnx index b4dde0ed..7d360390 100644 --- a/se5/Plugins.slnx +++ b/se5/Plugins.slnx @@ -10,6 +10,7 @@ + @@ -25,6 +26,7 @@ + @@ -42,6 +44,7 @@ + From f379a6fb5ca727f0e97cd429725bbb36f689ce70 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 17 Oct 2026 12:29:51 +0100 Subject: [PATCH 2/6] Add SE5 port of Lines unbreaker Avalonia port of the SE4 LinesUnbreaker plugin. Only the checked subtitles are joined, the length check ignores tags, the maximum length defaults to SE's line length rule, settings are stored by SE, and there is batch convert support. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/lines-unbreaker.yml | 196 ++++++++++ README.md | 1 + .../LineUnbreakerTests.cs | 99 +++++ .../LinesUnbreaker.Tests.csproj | 27 ++ .../MainViewModelTests.cs | 108 ++++++ se5/LinesUnbreaker/App.axaml | 8 + se5/LinesUnbreaker/App.axaml.cs | 12 + se5/LinesUnbreaker/ChangeItem.cs | 29 ++ se5/LinesUnbreaker/Line.cs | 42 +++ se5/LinesUnbreaker/LineUnbreaker.cs | 74 ++++ se5/LinesUnbreaker/LinesUnbreaker.csproj | 27 ++ se5/LinesUnbreaker/MainViewModel.cs | 318 ++++++++++++++++ se5/LinesUnbreaker/MainWindow.axaml | 343 ++++++++++++++++++ se5/LinesUnbreaker/MainWindow.axaml.cs | 58 +++ se5/LinesUnbreaker/Program.cs | 16 + se5/LinesUnbreaker/README.md | 49 +++ se5/LinesUnbreaker/SubtitleTextBlock.cs | 201 ++++++++++ se5/LinesUnbreaker/TextDiff.cs | 138 +++++++ se5/LinesUnbreaker/app.manifest | 10 + se5/LinesUnbreaker/plugin.json | 13 + se5/Plugins.slnx | 3 + 21 files changed, 1772 insertions(+) create mode 100644 .github/workflows/lines-unbreaker.yml create mode 100644 se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs create mode 100644 se5/LinesUnbreaker.Tests/LinesUnbreaker.Tests.csproj create mode 100644 se5/LinesUnbreaker.Tests/MainViewModelTests.cs create mode 100644 se5/LinesUnbreaker/App.axaml create mode 100644 se5/LinesUnbreaker/App.axaml.cs create mode 100644 se5/LinesUnbreaker/ChangeItem.cs create mode 100644 se5/LinesUnbreaker/Line.cs create mode 100644 se5/LinesUnbreaker/LineUnbreaker.cs create mode 100644 se5/LinesUnbreaker/LinesUnbreaker.csproj create mode 100644 se5/LinesUnbreaker/MainViewModel.cs create mode 100644 se5/LinesUnbreaker/MainWindow.axaml create mode 100644 se5/LinesUnbreaker/MainWindow.axaml.cs create mode 100644 se5/LinesUnbreaker/Program.cs create mode 100644 se5/LinesUnbreaker/README.md create mode 100644 se5/LinesUnbreaker/SubtitleTextBlock.cs create mode 100644 se5/LinesUnbreaker/TextDiff.cs create mode 100644 se5/LinesUnbreaker/app.manifest create mode 100644 se5/LinesUnbreaker/plugin.json diff --git a/.github/workflows/lines-unbreaker.yml b/.github/workflows/lines-unbreaker.yml new file mode 100644 index 00000000..319861dd --- /dev/null +++ b/.github/workflows/lines-unbreaker.yml @@ -0,0 +1,196 @@ +name: Build LinesUnbreaker (SE5) + +on: + push: + branches: [main] + paths: + - 'se5/LinesUnbreaker/**' + - 'se5/LinesUnbreaker.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/lines-unbreaker.yml' + pull_request: + paths: + - 'se5/LinesUnbreaker/**' + - 'se5/LinesUnbreaker.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/lines-unbreaker.yml' + workflow_dispatch: + inputs: + release: + description: 'Publish a GitHub release. Bumps the minor in plugin.json and commits it back.' + type: boolean + default: false + version: + description: 'Optional explicit version (e.g. 1.2.0). Overrides the auto-minor-bump.' + required: false + type: string + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set.outputs.version }} + tag: ${{ steps.set.outputs.tag }} + ref: ${{ steps.set.outputs.ref }} + release: ${{ steps.set.outputs.release }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version (and bump on release) + id: set + run: | + set -e + current=$(jq -r .version se5/LinesUnbreaker/plugin.json) + release_flag="false" + ref="${{ github.sha }}" + version="$current" + tag="" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.release }}" = "true" ]; then + release_flag="true" + if [ -n "${{ inputs.version }}" ]; then + version="${{ inputs.version }}" + else + IFS=. read -r major minor patch <<< "$current" + version="$major.$((minor + 1)).0" + fi + + tmp=$(mktemp) + jq --arg v "$version" '.version = $v' se5/LinesUnbreaker/plugin.json > "$tmp" + mv "$tmp" se5/LinesUnbreaker/plugin.json + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add se5/LinesUnbreaker/plugin.json + git commit -m "Bump LinesUnbreaker to $version [skip ci]" + + # Retry-with-rebase: if a concurrent release dispatched for a different + # plugin won the push first, pull --rebase and try again. + attempts=0 + while ! git push 2>/tmp/push.err; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 5 ]; then + echo "Failed to push after $attempts attempts:" >&2 + cat /tmp/push.err >&2 + exit 1 + fi + echo "Push rejected (attempt $attempts), pulling --rebase and retrying..." + git pull --rebase --no-edit + done + ref=$(git rev-parse HEAD) + + IFS=. read -r major minor patch <<< "$version" + tag="se5-lines-unbreaker-v$major.$minor" + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "release=$release_flag" >> "$GITHUB_OUTPUT" + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Test + run: dotnet test se5/LinesUnbreaker.Tests/LinesUnbreaker.Tests.csproj -c Release + + build: + needs: [prepare, test] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + os: windows + exe: LinesUnbreaker.exe + - rid: win-arm64 + os: windows + exe: LinesUnbreaker.exe + - rid: linux-x64 + os: linux + exe: LinesUnbreaker + - rid: linux-arm64 + os: linux + exe: LinesUnbreaker + - rid: osx-x64 + os: macos + exe: LinesUnbreaker + - rid: osx-arm64 + os: macos + exe: LinesUnbreaker + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Publish self-contained (${{ matrix.rid }}) + working-directory: se5/LinesUnbreaker + run: | + dotnet publish LinesUnbreaker.csproj \ + -c Release \ + -r ${{ matrix.rid }} \ + --self-contained true \ + -p:DebugType=None \ + -p:DebugSymbols=false \ + -o staging/LinesUnbreaker + + - name: Rewrite plugin.json for ${{ matrix.os }} + working-directory: se5/LinesUnbreaker + run: | + jq ' + del(.runtime, .entry) | + .executables = { "${{ matrix.os }}": "${{ matrix.exe }}" } + ' plugin.json > staging/LinesUnbreaker/plugin.json + + - name: Package zip + working-directory: se5/LinesUnbreaker/staging + run: zip -r "$GITHUB_WORKSPACE/LinesUnbreaker-${{ matrix.rid }}.zip" LinesUnbreaker + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: LinesUnbreaker-${{ matrix.rid }} + path: LinesUnbreaker-${{ matrix.rid }}.zip + + release: + name: Publish GitHub Release + needs: [prepare, build] + if: needs.prepare.outputs.release == 'true' + runs-on: ubuntu-latest + steps: + - name: Download all zips + uses: actions/download-artifact@v4 + with: + path: dist + pattern: LinesUnbreaker-* + merge-multiple: true + + - name: Create release and upload zips + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ needs.prepare.outputs.tag }}" dist/LinesUnbreaker-*.zip \ + --repo "${{ github.repository }}" \ + --target "${{ needs.prepare.outputs.ref }}" \ + --title "LinesUnbreaker v${{ needs.prepare.outputs.version }}" \ + --notes "Self-contained LinesUnbreaker plugin builds for win/linux/osx (x64 + arm64)." diff --git a/README.md b/README.md index 94ce5ff4..e47b4889 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ index [`se5-plugins.json`](se5-plugins.json). | [Haxor](se5/Haxor) | Translates text to "haxor" - the minimal reference plugin. | | [Hearing impaired to uppercase](se5/HI2UC) | Changes the case of moods in ( ) / [ ] and speaker labels: (laughs) John: Hi. → (LAUGHS) JOHN: Hi. | | [Italic each line](se5/ItalicEachLine) | Switches italic tags between one tag per subtitle and a tag on each line. | +| [Lines unbreaker](se5/LinesUnbreaker) | Joins short multi-line subtitles into one line when the result fits. | | [Persian Subtitle Fixes](se5/PersianErrors) | Fixes common errors in Persian (Farsi) subtitles - 1,500+ rules in 12 groups. | | [Remove Unicode characters](se5/RemoveUnicodeCharacters) | Finds non-ANSI characters and lets you remove or replace each one. | | [Split dialogs](se5/SplitDialogs) | Splits dialog lines ("- Hi!" / "- Hello.") into one subtitle per speaker. | diff --git a/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs b/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs new file mode 100644 index 00000000..2eee1a1d --- /dev/null +++ b/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs @@ -0,0 +1,99 @@ +using SubtitleEdit.Plugins.LinesUnbreaker; + +namespace LinesUnbreaker.Tests; + +public class LineUnbreakerTests +{ + private static readonly UnbreakOptions Defaults = new(); + + [Fact] + public void JoinsShortLines() + { + Assert.Equal("Where are you going?", LineUnbreaker.Unbreak("Where are\nyou going?", Defaults)); + Assert.Equal("Where are you going?", LineUnbreaker.Unbreak("Where are\r\nyou going?", Defaults)); + } + + [Fact] + public void SingleLine_IsUnchanged() + { + Assert.Equal("Hello there.", LineUnbreaker.Unbreak("Hello there.", Defaults)); + } + + [Fact] + public void TooLong_IsUnchanged() + { + var text = "This first line is quite long already\nand so is this second one."; + Assert.Equal(text, LineUnbreaker.Unbreak(text, Defaults)); + } + + [Fact] + public void TagsAreNotCounted() + { + // 20 visible characters, far more with the tags. + var text = "Where are\nyou going?"; + var options = new UnbreakOptions { MaxLength = 20 }; + Assert.Equal("Where are you going?", LineUnbreaker.Unbreak(text, options)); + } + + [Fact] + public void MaxLength_IsInclusive() + { + Assert.Equal("abcd efgh", LineUnbreaker.Unbreak("abcd\nefgh", new UnbreakOptions { MaxLength = 9 })); + Assert.Equal("abcd\nefgh", LineUnbreaker.Unbreak("abcd\nefgh", new UnbreakOptions { MaxLength = 8 })); + } + + [Fact] + public void ItalicOnEachLine_BecomesOneTag() + { + Assert.Equal("Where are you going?", LineUnbreaker.Unbreak("Where are\nyou going?", Defaults)); + } + + [Fact] + public void ExtraSpaces_AreCollapsed() + { + Assert.Equal("Where are you?", LineUnbreaker.Unbreak("Where are \n you?", Defaults)); + } + + [Fact] + public void Dialog_IsSkippedByDefault() + { + var text = "- Hi!\n- Hello."; + Assert.Equal(text, LineUnbreaker.Unbreak(text, Defaults)); + Assert.Equal("- Hi! - Hello.", LineUnbreaker.Unbreak(text, new UnbreakOptions { SkipDialogs = false })); + } + + [Fact] + public void ItalicDialog_IsSkipped() + { + var text = "- Hi!\n- Hello."; + Assert.Equal(text, LineUnbreaker.Unbreak(text, Defaults)); + } + + [Fact] + public void Narrator_IsSkippedByDefault() + { + var text = "JOHN: Wait\nfor me."; + Assert.Equal(text, LineUnbreaker.Unbreak(text, Defaults)); + Assert.Equal("JOHN: Wait for me.", LineUnbreaker.Unbreak(text, new UnbreakOptions { SkipNarrator = false })); + } + + [Fact] + public void Time_IsNotANarrator() + { + Assert.Equal("See you at 12:30 tomorrow.", LineUnbreaker.Unbreak("See you at 12:30\ntomorrow.", Defaults)); + } + + [Fact] + public void Moods_SkippedOnlyWhenAsked() + { + var text = "(sighs) Well,\nmaybe."; + Assert.Equal("(sighs) Well, maybe.", LineUnbreaker.Unbreak(text, Defaults)); + Assert.Equal(text, LineUnbreaker.Unbreak(text, new UnbreakOptions { SkipMoods = true })); + } + + [Fact] + public void VisibleLength_IgnoresTagsAndBreaks() + { + Assert.Equal(6, LineUnbreaker.VisibleLength("{\\an8}abc\r\ndef")); + } +} diff --git a/se5/LinesUnbreaker.Tests/LinesUnbreaker.Tests.csproj b/se5/LinesUnbreaker.Tests/LinesUnbreaker.Tests.csproj new file mode 100644 index 00000000..6b661ddd --- /dev/null +++ b/se5/LinesUnbreaker.Tests/LinesUnbreaker.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/se5/LinesUnbreaker.Tests/MainViewModelTests.cs b/se5/LinesUnbreaker.Tests/MainViewModelTests.cs new file mode 100644 index 00000000..087e9e87 --- /dev/null +++ b/se5/LinesUnbreaker.Tests/MainViewModelTests.cs @@ -0,0 +1,108 @@ +using SubtitleEdit.Plugins.LinesUnbreaker; +using SubtitleEdit.Plugins.Shared; +using System.Text.Json; + +namespace LinesUnbreaker.Tests; + +public class MainViewModelTests +{ + private static PluginRequest Request(string requestType, string? settingsJson, params string[] texts) => new() + { + RequestType = requestType, + Subtitle = new PluginSubtitle + { + Format = "Advanced Sub Station Alpha", + Paragraphs = texts.Select((t, i) => new PluginParagraph + { + StartMs = i * 1000, EndMs = i * 1000 + 900, Text = t, Style = "Default", Actor = "Actor" + i, + }).ToList(), + }, + Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(), + SettingsVersion = settingsJson == null ? null : 1, + }; + + [Fact] + public void Run_OnlyCheckedSubtitlesAreJoined() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "One\ntwo", "Three\nfour", "Single")); + Assert.Equal(2, vm.ChangeCount); + vm.Changes[1].IsSelected = false; + + var response = vm.BuildResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + var paragraphs = response.Subtitle!.Paragraphs!; + Assert.Equal(new[] { "One two", "Three\nfour", "Single" }, paragraphs.Select(p => p.Text)); + Assert.Equal("Actor0", paragraphs[0].Actor); + Assert.Equal("Default", paragraphs[0].Style); + } + + [Fact] + public void MaxLength_DefaultsToSubtitleEditRule() + { + var request = Request(PluginRequestType.Run, null, "abcd\nefgh"); + request.Rules = new PluginRules { SubtitleLineMaximumLength = 8 }; + var vm = new MainViewModel(request); + Assert.Equal(8, vm.MaxLength); + Assert.False(vm.HasChanges); + + vm.MaxLengthValue = 9; + Assert.Equal(9, vm.MaxLength); + Assert.True(vm.HasChanges); + } + + [Fact] + public void SavedSettings_WinOverRule() + { + var request = Request(PluginRequestType.Run, "{\"maxLength\":30,\"skipDialogs\":false,\"skipMoods\":true,\"skipNarrator\":false}", "- A\n- B"); + request.Rules = new PluginRules { SubtitleLineMaximumLength = 43 }; + var vm = new MainViewModel(request); + Assert.Equal(30, vm.MaxLength); + Assert.False(vm.SkipDialogs); + Assert.True(vm.SkipMoods); + Assert.False(vm.SkipNarrator); + Assert.Equal("- A - B", vm.Changes.Single().After); + } + + [Fact] + public void Cancel_ReturnsSettings() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "a\nb")); + vm.MaxLength = 50; + vm.SkipMoods = true; + + var response = vm.BuildCancelResponse(); + Assert.Equal(PluginStatus.Cancelled, response.Status); + Assert.Equal(50, response.Settings!.Value.GetProperty("maxLength").GetInt32()); + Assert.True(response.Settings!.Value.GetProperty("skipMoods").GetBoolean()); + } + + [Fact] + public void Batch_JoinsAll_WithoutSettings() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "One\ntwo", "- Hi!\n- Hello.")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Equal(new[] { "One two", "- Hi!\n- Hello." }, response.Subtitle!.Paragraphs!.Select(p => p.Text)); + Assert.Null(response.Settings); + } + + [Fact] + public void Batch_NothingToChange_ReturnsOkWithoutSubtitle() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "Single")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } + + [Fact] + public void Configure_ReturnsSettingsOnly() + { + var vm = new MainViewModel(Request(PluginRequestType.Configure, null)); + Assert.True(vm.CanApply); + vm.MaxLength = 37; + + var response = vm.BuildResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + Assert.Equal(37, response.Settings!.Value.GetProperty("maxLength").GetInt32()); + } +} diff --git a/se5/LinesUnbreaker/App.axaml b/se5/LinesUnbreaker/App.axaml new file mode 100644 index 00000000..b7d42a51 --- /dev/null +++ b/se5/LinesUnbreaker/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/se5/LinesUnbreaker/App.axaml.cs b/se5/LinesUnbreaker/App.axaml.cs new file mode 100644 index 00000000..aa63a5b2 --- /dev/null +++ b/se5/LinesUnbreaker/App.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +public partial class App : PluginApp +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + protected override Window CreateMainWindow(PluginRequest request) => new MainWindow(request); +} diff --git a/se5/LinesUnbreaker/ChangeItem.cs b/se5/LinesUnbreaker/ChangeItem.cs new file mode 100644 index 00000000..fd6d716e --- /dev/null +++ b/se5/LinesUnbreaker/ChangeItem.cs @@ -0,0 +1,29 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +/// One subtitle whose lines are joined into one. +public sealed partial class ChangeItem : ObservableObject +{ + [ObservableProperty] private bool _isSelected = true; + + public ChangeItem(int index, string before, string after, string timeRange) + { + Index = index; + Before = before; + After = after; + TimeRange = timeRange; + Length = LineUnbreaker.VisibleLength(after); + (BeforeSegments, AfterSegments) = TextDiff.Compute(before.Replace("\r\n", "\n"), after.Replace("\r\n", "\n")); + } + + public int Index { get; } + public string Before { get; } + public string After { get; } + public string Number => "#" + (Index + 1); + public string TimeRange { get; } + public int Length { get; } + public string LengthText => $"{Length} characters"; + public IReadOnlyList BeforeSegments { get; } + public IReadOnlyList AfterSegments { get; } +} diff --git a/se5/LinesUnbreaker/Line.cs b/se5/LinesUnbreaker/Line.cs new file mode 100644 index 00000000..6a5797a3 --- /dev/null +++ b/se5/LinesUnbreaker/Line.cs @@ -0,0 +1,42 @@ +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +/// One line of a subtitle, classified on its text without tags: dialog, mood or narrator. +public readonly struct Line +{ + public Line(string line) + { + Content = line; + var noTagLine = LineUnbreaker.RemoveTags(line); + HasMood = noTagLine.Contains('(') || noTagLine.Contains('['); + IsDialog = noTagLine.TrimStart().StartsWith('-'); + HasNarrator = CheckNarrator(noTagLine); + } + + public string Content { get; } + public bool IsDialog { get; } + public bool HasMood { get; } + public bool HasNarrator { get; } + + private static bool CheckNarrator(string line) + { + var colonIndex = line.IndexOf(':'); + + // doesn't have colon or colon is last char + if (colonIndex < 0 || colonIndex + 1 == line.Length) + { + return false; + } + + // e.g. 12:30 + if (char.IsDigit(line[colonIndex + 1])) + { + return false; + } + + // ? . , shouldn't exist in a narrator label + var narratorCandidate = line.Substring(0, colonIndex); + return narratorCandidate.IndexOfAny(new[] { '?', '.', ',' }) < 0; + } + + public override string ToString() => Content; +} diff --git a/se5/LinesUnbreaker/LineUnbreaker.cs b/se5/LinesUnbreaker/LineUnbreaker.cs new file mode 100644 index 00000000..3d68d2dd --- /dev/null +++ b/se5/LinesUnbreaker/LineUnbreaker.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +public sealed class UnbreakOptions +{ + public const int DefaultMaxLength = 43; + + /// Join only when the result, without tags, is at most this many characters. + public int MaxLength { get; set; } = DefaultMaxLength; + public bool SkipDialogs { get; set; } = true; + public bool SkipMoods { get; set; } + public bool SkipNarrator { get; set; } = true; +} + +/// Joins short multi-line subtitles into one line. +public static partial class LineUnbreaker +{ + private static readonly string[] JoinedTags = { " ", " ", " " }; + + [GeneratedRegex(@"<\s*(?:/\s*)?(?:i|b|u|p|font)(?:\s[^>]*)?>", RegexOptions.IgnoreCase)] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"\{[^}]*\}")] + private static partial Regex AssaTagRegex(); + + [GeneratedRegex(" {2,}")] + private static partial Regex SpacesRegex(); + + /// The text joined to one line, or the text itself when it should stay as it is. + public static string Unbreak(string text, UnbreakOptions options) + { + var lines = SplitToLines(text).Select(l => new Line(l)).ToArray(); + if (lines.Count(l => l.Content.Trim().Length > 0) < 2) + { + return text; + } + + if ((options.SkipDialogs && lines.Any(l => l.IsDialog)) || + (options.SkipMoods && lines.Any(l => l.HasMood)) || + (options.SkipNarrator && lines.Any(l => l.HasNarrator))) + { + return text; + } + + var joined = Join(lines.Select(l => l.Content)); + return VisibleLength(joined) <= options.MaxLength ? joined : text; + } + + /// The number of characters shown on screen: tags and line breaks are not counted. + public static int VisibleLength(string text) => + RemoveTags(text).Replace("\r", string.Empty).Replace("\n", string.Empty).Length; + + public static string RemoveTags(string text) => AssaTagRegex().Replace(HtmlTagRegex().Replace(text, string.Empty), string.Empty); + + public static string[] SplitToLines(string text) => + text.Replace("\r\r\n", "\n").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + private static string Join(IEnumerable lines) + { + var singleLine = SpacesRegex().Replace(string.Join(" ", lines.Select(l => l.Trim()).Where(l => l.Length > 0)), " "); + + // "One two" becomes "One two" + if (singleLine.Contains(" + + + WinExe + net8.0 + enable + enable + LinesUnbreaker + SubtitleEdit.Plugins.LinesUnbreaker + true + app.manifest + + + + + + + + + + + + + + + + diff --git a/se5/LinesUnbreaker/MainViewModel.cs b/se5/LinesUnbreaker/MainViewModel.cs new file mode 100644 index 00000000..1e50e88f --- /dev/null +++ b/se5/LinesUnbreaker/MainViewModel.cs @@ -0,0 +1,318 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.Shared; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text.Json; + +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +public sealed partial class MainViewModel : ObservableObject +{ + private const int SettingsVersion = 1; + + private readonly PluginRequest _request; + private readonly bool _legacyRequest; + private readonly List _paragraphs; + private readonly long _videoOffsetMs; + private readonly bool _isConfigure; + private bool _loading; + + [ObservableProperty] private int _maxLength = UnbreakOptions.DefaultMaxLength; + [ObservableProperty] private bool _skipDialogs = true; + [ObservableProperty] private bool _skipMoods; + [ObservableProperty] private bool _skipNarrator = true; + [ObservableProperty] private bool _selectedLinesOnly; + [ObservableProperty] private int _selectedCount; + + public MainViewModel(PluginRequest request) + { + _request = request; + _isConfigure = request.RequestType == PluginRequestType.Configure; + _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; + _legacyRequest = request.Subtitle.Paragraphs == null; + _paragraphs = _legacyRequest + ? SubRipParser.Parse(request.Subtitle.SubRip) + .Select(b => new PluginParagraph { StartMs = b.StartMs, EndMs = b.EndMs, Text = b.Text }).ToList() + : request.Subtitle.Paragraphs!.Select(p => p.Clone()).ToList(); + SelectedIndices = request.SelectedIndices.Where(i => i >= 0 && i < _paragraphs.Count).Distinct().Order().ToList(); + + // Default to Subtitle Edit's "single line max. length". + if (request.Rules is { SubtitleLineMaximumLength: > 0 } rules) + { + _maxLength = rules.SubtitleLineMaximumLength; + } + + _loading = true; + LoadSettings(); + _loading = false; + Analyze(); + } + + public ObservableCollection Changes { get; } = new(); + public IReadOnlyList SelectedIndices { get; } + + public UnbreakOptions Options => new() { MaxLength = MaxLength, SkipDialogs = SkipDialogs, SkipMoods = SkipMoods, SkipNarrator = SkipNarrator }; + + public string SubtitleInfo + { + get + { + if (_isConfigure) + { + return "Settings for Batch convert"; + } + + var name = string.IsNullOrEmpty(_request.Subtitle.FileName) ? "Untitled" : Path.GetFileName(_request.Subtitle.FileName); + var format = string.IsNullOrEmpty(_request.Subtitle.Format) ? string.Empty : $" · {_request.Subtitle.Format}"; + return $"{name}{format} · {_paragraphs.Count:N0} lines"; + } + } + + /// Opened from Batch convert's Configure button: only the options matter, there is no subtitle. + public bool IsConfigure => _isConfigure; + public bool IsNotConfigure => !_isConfigure; + + public bool HasSelectedLines => SelectedIndices.Count > 0; + public string SelectedLinesText => SelectedIndices.Count == 1 ? "Selected line (1)" : $"Selected lines ({SelectedIndices.Count:N0})"; + public bool AllLines { get => !SelectedLinesOnly; set => SelectedLinesOnly = !value; } + public int ChangeCount => Changes.Count; + public bool HasChanges => Changes.Count > 0; + public bool IsClean => Changes.Count == 0 && !_isConfigure; + public bool CanApply => SelectedCount > 0 || _isConfigure; + /// for the NumericUpDown, which works with decimals. + public decimal? MaxLengthValue + { + get => MaxLength; + set => MaxLength = Math.Clamp((int)(value ?? UnbreakOptions.DefaultMaxLength), 1, 999); + } + + public string CleanText => $"No subtitle with more than one line fits in {MaxLength} characters."; + + public string StatusText => _isConfigure + ? "Used for every file in Batch convert" + : Changes.Count == 0 + ? $"{LinesInScope:N0} lines checked" + : $"{SelectedCount:N0} of {Changes.Count:N0} lines selected"; + + private int LinesInScope => SelectedLinesOnly && HasSelectedLines ? SelectedIndices.Count : _paragraphs.Count; + + partial void OnMaxLengthChanged(int value) + { + OnPropertyChanged(nameof(MaxLengthValue)); + OnPropertyChanged(nameof(CleanText)); + Analyze(); + } + + partial void OnSkipDialogsChanged(bool value) => Analyze(); + partial void OnSkipMoodsChanged(bool value) => Analyze(); + partial void OnSkipNarratorChanged(bool value) => Analyze(); + + partial void OnSelectedLinesOnlyChanged(bool value) + { + OnPropertyChanged(nameof(AllLines)); + Analyze(); + } + + partial void OnSelectedCountChanged(int value) + { + OnPropertyChanged(nameof(CanApply)); + OnPropertyChanged(nameof(StatusText)); + } + + private IEnumerable IndicesInScope => + SelectedLinesOnly && HasSelectedLines ? SelectedIndices : Enumerable.Range(0, _paragraphs.Count); + + private void Analyze() + { + if (_loading) + { + return; + } + + var options = Options; + Changes.Clear(); + foreach (var index in IndicesInScope) + { + var text = _paragraphs[index].Text ?? string.Empty; + var after = LineUnbreaker.Unbreak(text, options); + if (after != text) + { + var item = new ChangeItem(index, text, after, FormatTimeRange(_paragraphs[index])); + item.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ChangeItem.IsSelected)) + { + SelectedCount = Changes.Count(c => c.IsSelected); + } + }; + Changes.Add(item); + } + } + + SelectedCount = Changes.Count; + OnPropertyChanged(nameof(ChangeCount)); + OnPropertyChanged(nameof(HasChanges)); + OnPropertyChanged(nameof(IsClean)); + OnPropertyChanged(nameof(StatusText)); + } + + [RelayCommand] + private void SelectAll() => SetSelection(_ => true); + + [RelayCommand] + private void SelectNone() => SetSelection(_ => false); + + [RelayCommand] + private void InvertSelection() => SetSelection(c => !c.IsSelected); + + private void SetSelection(Func selected) + { + foreach (var change in Changes) + { + change.IsSelected = selected(change); + } + } + + /// + /// A "batch" request (Batch convert): unbreak every short subtitle with the saved options, without UI. + /// No changes is "ok" without a subtitle. + /// + public PluginResponse BuildBatchResponse() + { + if (Changes.Count == 0) + { + return new PluginResponse { Status = PluginStatus.Ok, Message = "Lines unbreaker: nothing to unbreak." }; + } + + var response = BuildResponse(); + response.Settings = null; + response.SettingsVersion = null; + return response; + } + + /// A "configure" request: hand back the settings only, for the next Batch convert run. + public PluginResponse BuildConfigureResponse() => + new() { Status = PluginStatus.Ok, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + /// The response for OK: the selected subtitles joined to one line. + public PluginResponse BuildResponse() + { + if (_isConfigure) + { + return BuildConfigureResponse(); + } + + var selected = Changes.Where(c => c.IsSelected).ToList(); + if (selected.Count == 0) + { + return BuildCancelResponse(); + } + + var texts = _paragraphs.Select(p => p.Text ?? string.Empty).ToArray(); + foreach (var change in selected) + { + texts[change.Index] = change.After; + } + + PluginSubtitle subtitle; + if (_legacyRequest) + { + subtitle = new PluginSubtitle + { + Format = "SubRip", + Native = SubRipParser.Serialize(_paragraphs.Select((p, i) => new SrtBlock + { + StartMs = (long)Math.Round(p.StartMs), + EndMs = (long)Math.Round(p.EndMs), + Text = texts[i], + }).ToList()), + }; + } + else + { + subtitle = new PluginSubtitle + { + Format = _request.Subtitle.Format, + Paragraphs = _paragraphs.Select((p, i) => + { + var copy = p.Clone(); + copy.Text = texts[i]; + return copy; + }).ToList(), + }; + } + + return new PluginResponse + { + Status = PluginStatus.Ok, + Message = selected.Count == 1 + ? "Lines unbreaker: 1 subtitle unbroken." + : $"Lines unbreaker: {selected.Count:N0} subtitles unbroken.", + UndoDescription = "Lines unbreaker", + Subtitle = subtitle, + Settings = BuildSettings(), + SettingsVersion = SettingsVersion, + }; + } + + public PluginResponse BuildCancelResponse() => + new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + private JsonElement BuildSettings() + { + var payload = new Dictionary + { + ["maxLength"] = MaxLength, + ["skipDialogs"] = SkipDialogs, + ["skipMoods"] = SkipMoods, + ["skipNarrator"] = SkipNarrator, + ["selectedLinesOnly"] = SelectedLinesOnly, + }; + + // Configure has no lines selected, so keep the scope a normal run saved. + if (_isConfigure && _request.SettingsVersion == SettingsVersion && _request.Settings is { ValueKind: JsonValueKind.Object } stored + && stored.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + payload["selectedLinesOnly"] = selectedOnly.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(payload); + } + + private void LoadSettings() + { + // Lines selected in Subtitle Edit mean "work on these" - default to them. + SelectedLinesOnly = HasSelectedLines; + if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings) + { + return; + } + + if (settings.TryGetProperty("maxLength", out var maxLength) && maxLength.TryGetInt32(out var length) && length > 0) + { + MaxLength = length; + } + + SkipDialogs = ReadBool(settings, "skipDialogs", SkipDialogs); + SkipMoods = ReadBool(settings, "skipMoods", SkipMoods); + SkipNarrator = ReadBool(settings, "skipNarrator", SkipNarrator); + + if (settings.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + SelectedLinesOnly = selectedOnly.GetBoolean() && HasSelectedLines; + } + } + + private static bool ReadBool(JsonElement settings, string name, bool fallback) => + settings.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : fallback; + + private string FormatTimeRange(PluginParagraph p) => + $"{FormatTime(p.StartMs + _videoOffsetMs)} → {FormatTime(p.EndMs + _videoOffsetMs)}"; + + private static string FormatTime(double ms) + { + var time = TimeSpan.FromMilliseconds(Math.Max(0, ms)); + return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00},{3:000}", (int)time.TotalHours, time.Minutes, time.Seconds, time.Milliseconds); + } +} diff --git a/se5/LinesUnbreaker/MainWindow.axaml b/se5/LinesUnbreaker/MainWindow.axaml new file mode 100644 index 00000000..f23f7870 --- /dev/null +++ b/se5/LinesUnbreaker/MainWindow.axaml @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/LinesUnbreaker/MainWindow.axaml.cs b/se5/LinesUnbreaker/MainWindow.axaml.cs new file mode 100644 index 00000000..607497fe --- /dev/null +++ b/se5/LinesUnbreaker/MainWindow.axaml.cs @@ -0,0 +1,58 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +public partial class MainWindow : Window +{ + private readonly MainViewModel _vm; + + public MainWindow() : this(new PluginRequest()) { } + + public MainWindow(PluginRequest request) + { + AvaloniaXamlLoader.Load(this); + _vm = new MainViewModel(request); + DataContext = _vm; + App.Response = _vm.BuildCancelResponse(); + KeyDown += OnKeyDown; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + this.BringToForeground(); + } + + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + OnCancel(sender, e); + } + } + + private void OnCancel(object? sender, RoutedEventArgs e) + { + App.Response = _vm.BuildCancelResponse(); + Close(); + } + + private void OnOk(object? sender, RoutedEventArgs e) + { + try + { + App.Response = _vm.BuildResponse(); + } + catch (Exception exception) + { + App.Response = new PluginResponse { Status = PluginStatus.Error, Message = "Lines unbreaker failed: " + exception.Message }; + } + + Close(); + } +} diff --git a/se5/LinesUnbreaker/Program.cs b/se5/LinesUnbreaker/Program.cs new file mode 100644 index 00000000..b1073eb3 --- /dev/null +++ b/se5/LinesUnbreaker/Program.cs @@ -0,0 +1,16 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.Shared; +using System; + +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +public static class Program +{ + [STAThread] + public static int Main(string[] args) + { + IconProvider.Current.Register(); + return PluginBootstrap.Run(args, request => new MainViewModel(request).BuildBatchResponse()); + } +} diff --git a/se5/LinesUnbreaker/README.md b/se5/LinesUnbreaker/README.md new file mode 100644 index 00000000..8576dd81 --- /dev/null +++ b/se5/LinesUnbreaker/README.md @@ -0,0 +1,49 @@ +# Lines unbreaker (Subtitle Edit 5 plugin) + +Joins subtitles with two or more short lines into one line, when the result fits on one line: + +| Before | After | +|---|---| +| `Where are`
`you going?` | `Where are you going?` | +| `Wait`
`for me.` | `Wait for me.` | + +It is the SE5 port of the SE4 *LinesUnbreaker* plugin by ivandrofly. + +Built on Avalonia and the shared [`Plugin-Shared`](../Plugin-Shared/) library. + +## Rules + +- A subtitle is joined only when the joined text is at most *N* characters long. Tags like `` and `{\an8}` are not counted. + *N* defaults to Subtitle Edit's *single line max. length*. +- Lines are joined with one space. Extra spaces are removed, and ` ` at the join becomes a single space. +- You can skip: + - **Dialogs**: a line starts with `-`. Skipped by default. + - **Moods**: `( )` or `[ ]` in a line. + - **Narrators**: `John:` at the start of a line. Skipped by default. Times like `12:30` are not narrators. + +## UI + +- Every subtitle that would be joined is a checkable before/after card, showing the new length. + *OK* applies the checked subtitles as one "Lines unbreaker" undo step. +- *All lines* / *Selected lines*: lines selected in Subtitle Edit are the default scope. +- The maximum length, the skip options and the scope are remembered between runs. + +Works on the lines exactly as Subtitle Edit holds them, so the styles, actors and +other fields of formats like ASSA are kept. On older SE versions it falls back to SubRip. + +## Batch convert + +Lines unbreaker supports Subtitle Edit's **Batch convert** (Tools → Batch convert → Run plugin). + +- **Configure...** opens the window without a subtitle, to set the maximum length and what to skip. +- During the batch, every short subtitle in each file is joined without showing the window. + +## Changes from the SE4 plugin + +- Only the checked subtitles are joined. Before, every listed subtitle was joined. +- The length check ignores tags, the same way the length is shown. +- Dialog lines in italics (`- Hi!`) are recognised as dialogs. + +## Build + +See `.github/workflows/lines-unbreaker.yml`. diff --git a/se5/LinesUnbreaker/SubtitleTextBlock.cs b/se5/LinesUnbreaker/SubtitleTextBlock.cs new file mode 100644 index 00000000..3f11e9da --- /dev/null +++ b/se5/LinesUnbreaker/SubtitleTextBlock.cs @@ -0,0 +1,201 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using System.Text; + +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +/// +/// Shows one side of a . With off the raw text is shown with +/// tags colored and changed text tinted; with it on the tags are hidden and italic text is drawn in italics. +/// +public sealed class SubtitleTextBlock : TextBlock +{ + public static readonly StyledProperty?> SegmentsProperty = + AvaloniaProperty.Register?>(nameof(Segments)); + + public static readonly StyledProperty RenderedProperty = + AvaloniaProperty.Register(nameof(Rendered)); + + private static readonly IBrush RemovedBrush = new SolidColorBrush(Color.Parse("#55E5484D")); + private static readonly IBrush AddedBrush = new SolidColorBrush(Color.Parse("#552FA36B")); + private static readonly IBrush TagBrush = new SolidColorBrush(Color.Parse("#14B8A6")); + private static readonly IBrush ItalicBrush = new SolidColorBrush(Color.Parse("#22EA580C")); + + static SubtitleTextBlock() + { + SegmentsProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + RenderedProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + } + + public IReadOnlyList? Segments + { + get => GetValue(SegmentsProperty); + set => SetValue(SegmentsProperty, value); + } + + public bool Rendered + { + get => GetValue(RenderedProperty); + set => SetValue(RenderedProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + private void Rebuild() + { + var segments = Segments ?? Array.Empty(); + Inlines = Rendered ? BuildRendered(string.Concat(segments.Select(s => s.Text))) : BuildRaw(segments); + } + + /// Raw text: tags in the tag color, removed/added text on a tint. + private static InlineCollection BuildRaw(IReadOnlyList segments) + { + var inlines = new InlineCollection(); + var inTag = false; + foreach (var segment in segments) + { + var background = segment.Kind switch + { + DiffKind.Removed => RemovedBrush, + DiffKind.Added => AddedBrush, + _ => null, + }; + + var run = new StringBuilder(); + var runIsTag = inTag; + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) { Background = background }; + if (runIsTag) + { + inline.Foreground = TagBrush; + inline.FontWeight = FontWeight.SemiBold; + } + + inlines.Add(inline); + run.Clear(); + } + } + + foreach (var c in segment.Text) + { + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + if (background != null) + { + // A changed line break would otherwise be invisible. + inlines.Add(new Run("↵") { Background = background, Foreground = TagBrush }); + } + + inlines.Add(new LineBreak()); + continue; + } + + var isTag = inTag || c == '<'; + if (isTag != runIsTag) + { + Flush(); + runIsTag = isTag; + } + + run.Append(c); + if (c == '<') + { + inTag = true; + } + else if (c == '>') + { + inTag = false; + Flush(); + runIsTag = false; + } + } + + Flush(); + } + + return inlines; + } + + /// Rendered text: tags hidden, <i> drawn in italics on a soft tint, <b>/<u> applied too. + private static InlineCollection BuildRendered(string text) + { + var inlines = new InlineCollection(); + var italic = 0; + var bold = 0; + var underline = 0; + var run = new StringBuilder(); + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) + { + FontStyle = italic > 0 ? FontStyle.Italic : FontStyle.Normal, + FontWeight = bold > 0 ? FontWeight.Bold : FontWeight.Normal, + }; + if (underline > 0) + { + inline.TextDecorations = Avalonia.Media.TextDecorations.Underline; + } + + if (italic > 0) + { + inline.Background = ItalicBrush; + } + + inlines.Add(inline); + run.Clear(); + } + } + + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + inlines.Add(new LineBreak()); + continue; + } + + var end = c is '<' or '{' ? text.IndexOf(c == '<' ? '>' : '}', i) : -1; + if (end < 0) + { + run.Append(c); + continue; + } + + Flush(); + var tag = text.Substring(i, end - i + 1).ToLowerInvariant(); + i = end; + switch (tag) + { + case "": italic++; break; + case "": italic = Math.Max(0, italic - 1); break; + case "": bold++; break; + case "": bold = Math.Max(0, bold - 1); break; + case "": underline++; break; + case "": underline = Math.Max(0, underline - 1); break; + } + } + + Flush(); + return inlines; + } +} diff --git a/se5/LinesUnbreaker/TextDiff.cs b/se5/LinesUnbreaker/TextDiff.cs new file mode 100644 index 00000000..daafff06 --- /dev/null +++ b/se5/LinesUnbreaker/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.LinesUnbreaker; + +public enum DiffKind +{ + Same, + Removed, + Added, +} + +public sealed record DiffSegment(string Text, DiffKind Kind); + +/// Character diff (LCS) used to highlight what a fix changed. +public static class TextDiff +{ + private const int MaxCells = 1_000_000; + + /// Returns the segments of the old text (same/removed) and of the new text (same/added). + public static (List Before, List After) Compute(string before, string after) + { + // Same number of lines: diff line by line, so line breaks never pair up with the wrong line. + var beforeLines = before.Split('\n'); + var afterLines = after.Split('\n'); + if (beforeLines.Length > 1 && beforeLines.Length == afterLines.Length) + { + var beforeSegments = new List(); + var afterSegments = new List(); + for (var i = 0; i < beforeLines.Length; i++) + { + if (i > 0) + { + Add(beforeSegments, "\n", DiffKind.Same); + Add(afterSegments, "\n", DiffKind.Same); + } + + var (b, a) = ComputeLine(beforeLines[i], afterLines[i]); + b.ForEach(segment => Add(beforeSegments, segment.Text, segment.Kind)); + a.ForEach(segment => Add(afterSegments, segment.Text, segment.Kind)); + } + + return (beforeSegments, afterSegments); + } + + return ComputeLine(before, after); + } + + private static (List Before, List After) ComputeLine(string before, string after) + { + var prefix = 0; + while (prefix < before.Length && prefix < after.Length && before[prefix] == after[prefix]) + { + prefix++; + } + + var suffix = 0; + while (suffix < before.Length - prefix && suffix < after.Length - prefix && + before[before.Length - 1 - suffix] == after[after.Length - 1 - suffix]) + { + suffix++; + } + + var a = before.Substring(prefix, before.Length - prefix - suffix); + var b = after.Substring(prefix, after.Length - prefix - suffix); + var beforeSegments = new List(); + var afterSegments = new List(); + Add(beforeSegments, before[..prefix], DiffKind.Same); + Add(afterSegments, after[..prefix], DiffKind.Same); + + if ((long)a.Length * b.Length > MaxCells) + { + Add(beforeSegments, a, DiffKind.Removed); + Add(afterSegments, b, DiffKind.Added); + } + else + { + Lcs(a, b, beforeSegments, afterSegments); + } + + Add(beforeSegments, before[(before.Length - suffix)..], DiffKind.Same); + Add(afterSegments, after[(after.Length - suffix)..], DiffKind.Same); + return (beforeSegments, afterSegments); + } + + private static void Lcs(string a, string b, List beforeSegments, List afterSegments) + { + var lengths = new int[a.Length + 1, b.Length + 1]; + for (var i = a.Length - 1; i >= 0; i--) + { + for (var j = b.Length - 1; j >= 0; j--) + { + lengths[i, j] = a[i] == b[j] + ? lengths[i + 1, j + 1] + 1 + : Math.Max(lengths[i + 1, j], lengths[i, j + 1]); + } + } + + int x = 0, y = 0; + while (x < a.Length && y < b.Length) + { + if (a[x] == b[y]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Same); + Add(afterSegments, b[y].ToString(), DiffKind.Same); + x++; + y++; + } + else if (lengths[x + 1, y] >= lengths[x, y + 1]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Removed); + x++; + } + else + { + Add(afterSegments, b[y].ToString(), DiffKind.Added); + y++; + } + } + + Add(beforeSegments, a[x..], DiffKind.Removed); + Add(afterSegments, b[y..], DiffKind.Added); + } + + private static void Add(List segments, string text, DiffKind kind) + { + if (text.Length == 0) + { + return; + } + + if (segments.Count > 0 && segments[^1].Kind == kind) + { + segments[^1] = segments[^1] with { Text = segments[^1].Text + text }; + } + else + { + segments.Add(new DiffSegment(text, kind)); + } + } +} diff --git a/se5/LinesUnbreaker/app.manifest b/se5/LinesUnbreaker/app.manifest new file mode 100644 index 00000000..75d8c0c5 --- /dev/null +++ b/se5/LinesUnbreaker/app.manifest @@ -0,0 +1,10 @@ + + + + + + true/pm + PerMonitorV2 + + + diff --git a/se5/LinesUnbreaker/plugin.json b/se5/LinesUnbreaker/plugin.json new file mode 100644 index 00000000..f8c22c6f --- /dev/null +++ b/se5/LinesUnbreaker/plugin.json @@ -0,0 +1,13 @@ +{ + "apiVersion": 1, + "name": "Lines unbreaker", + "description": "Joins subtitles with two or more short lines into one line when the result fits within a maximum length (tags not counted). Can skip dialogs, moods and narrators. Shows a checkable before/after preview.", + "version": "1.0.0", + "author": "ivandrofly", + "url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/LinesUnbreaker", + "menu": "Tools", + "minSeVersion": "5.0.0", + "batch": true, + "runtime": "dotnet", + "entry": "LinesUnbreaker.dll" +} diff --git a/se5/Plugins.slnx b/se5/Plugins.slnx index 7d360390..811e8f34 100644 --- a/se5/Plugins.slnx +++ b/se5/Plugins.slnx @@ -12,6 +12,7 @@ + @@ -28,6 +29,7 @@ + @@ -46,6 +48,7 @@ + From 7f968ba1b2d4e52c1adae9bd91866ca1bc509150 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 17 Oct 2026 12:31:28 +0100 Subject: [PATCH 3/6] Add SE5 port of Tight hyphen Avalonia port of the SE4 TightHyphen plugin. The dash logic is now a separate class, lines can be checked before applying, a dash followed by a number is left alone, line breaks are kept, and there is batch convert support. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/tight-hyphen.yml | 196 +++++++++++ README.md | 1 + se5/LinesUnbreaker/MainViewModel.cs | 1 + se5/Plugins.slnx | 3 + se5/TightHyphen.Tests/HyphenTightenerTests.cs | 38 ++ se5/TightHyphen.Tests/MainViewModelTests.cs | 81 +++++ .../TightHyphen.Tests.csproj | 27 ++ se5/TightHyphen/App.axaml | 8 + se5/TightHyphen/App.axaml.cs | 12 + se5/TightHyphen/ChangeItem.cs | 26 ++ se5/TightHyphen/HyphenTightener.cs | 73 ++++ se5/TightHyphen/MainViewModel.cs | 272 ++++++++++++++ se5/TightHyphen/MainWindow.axaml | 332 ++++++++++++++++++ se5/TightHyphen/MainWindow.axaml.cs | 58 +++ se5/TightHyphen/Program.cs | 16 + se5/TightHyphen/README.md | 42 +++ se5/TightHyphen/SubtitleTextBlock.cs | 201 +++++++++++ se5/TightHyphen/TextDiff.cs | 138 ++++++++ se5/TightHyphen/TightHyphen.csproj | 27 ++ se5/TightHyphen/app.manifest | 10 + se5/TightHyphen/plugin.json | 13 + 21 files changed, 1575 insertions(+) create mode 100644 .github/workflows/tight-hyphen.yml create mode 100644 se5/TightHyphen.Tests/HyphenTightenerTests.cs create mode 100644 se5/TightHyphen.Tests/MainViewModelTests.cs create mode 100644 se5/TightHyphen.Tests/TightHyphen.Tests.csproj create mode 100644 se5/TightHyphen/App.axaml create mode 100644 se5/TightHyphen/App.axaml.cs create mode 100644 se5/TightHyphen/ChangeItem.cs create mode 100644 se5/TightHyphen/HyphenTightener.cs create mode 100644 se5/TightHyphen/MainViewModel.cs create mode 100644 se5/TightHyphen/MainWindow.axaml create mode 100644 se5/TightHyphen/MainWindow.axaml.cs create mode 100644 se5/TightHyphen/Program.cs create mode 100644 se5/TightHyphen/README.md create mode 100644 se5/TightHyphen/SubtitleTextBlock.cs create mode 100644 se5/TightHyphen/TextDiff.cs create mode 100644 se5/TightHyphen/TightHyphen.csproj create mode 100644 se5/TightHyphen/app.manifest create mode 100644 se5/TightHyphen/plugin.json diff --git a/.github/workflows/tight-hyphen.yml b/.github/workflows/tight-hyphen.yml new file mode 100644 index 00000000..ce356616 --- /dev/null +++ b/.github/workflows/tight-hyphen.yml @@ -0,0 +1,196 @@ +name: Build TightHyphen (SE5) + +on: + push: + branches: [main] + paths: + - 'se5/TightHyphen/**' + - 'se5/TightHyphen.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/tight-hyphen.yml' + pull_request: + paths: + - 'se5/TightHyphen/**' + - 'se5/TightHyphen.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/tight-hyphen.yml' + workflow_dispatch: + inputs: + release: + description: 'Publish a GitHub release. Bumps the minor in plugin.json and commits it back.' + type: boolean + default: false + version: + description: 'Optional explicit version (e.g. 1.2.0). Overrides the auto-minor-bump.' + required: false + type: string + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set.outputs.version }} + tag: ${{ steps.set.outputs.tag }} + ref: ${{ steps.set.outputs.ref }} + release: ${{ steps.set.outputs.release }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version (and bump on release) + id: set + run: | + set -e + current=$(jq -r .version se5/TightHyphen/plugin.json) + release_flag="false" + ref="${{ github.sha }}" + version="$current" + tag="" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.release }}" = "true" ]; then + release_flag="true" + if [ -n "${{ inputs.version }}" ]; then + version="${{ inputs.version }}" + else + IFS=. read -r major minor patch <<< "$current" + version="$major.$((minor + 1)).0" + fi + + tmp=$(mktemp) + jq --arg v "$version" '.version = $v' se5/TightHyphen/plugin.json > "$tmp" + mv "$tmp" se5/TightHyphen/plugin.json + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add se5/TightHyphen/plugin.json + git commit -m "Bump TightHyphen to $version [skip ci]" + + # Retry-with-rebase: if a concurrent release dispatched for a different + # plugin won the push first, pull --rebase and try again. + attempts=0 + while ! git push 2>/tmp/push.err; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 5 ]; then + echo "Failed to push after $attempts attempts:" >&2 + cat /tmp/push.err >&2 + exit 1 + fi + echo "Push rejected (attempt $attempts), pulling --rebase and retrying..." + git pull --rebase --no-edit + done + ref=$(git rev-parse HEAD) + + IFS=. read -r major minor patch <<< "$version" + tag="se5-tight-hyphen-v$major.$minor" + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "release=$release_flag" >> "$GITHUB_OUTPUT" + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Test + run: dotnet test se5/TightHyphen.Tests/TightHyphen.Tests.csproj -c Release + + build: + needs: [prepare, test] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + os: windows + exe: TightHyphen.exe + - rid: win-arm64 + os: windows + exe: TightHyphen.exe + - rid: linux-x64 + os: linux + exe: TightHyphen + - rid: linux-arm64 + os: linux + exe: TightHyphen + - rid: osx-x64 + os: macos + exe: TightHyphen + - rid: osx-arm64 + os: macos + exe: TightHyphen + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Publish self-contained (${{ matrix.rid }}) + working-directory: se5/TightHyphen + run: | + dotnet publish TightHyphen.csproj \ + -c Release \ + -r ${{ matrix.rid }} \ + --self-contained true \ + -p:DebugType=None \ + -p:DebugSymbols=false \ + -o staging/TightHyphen + + - name: Rewrite plugin.json for ${{ matrix.os }} + working-directory: se5/TightHyphen + run: | + jq ' + del(.runtime, .entry) | + .executables = { "${{ matrix.os }}": "${{ matrix.exe }}" } + ' plugin.json > staging/TightHyphen/plugin.json + + - name: Package zip + working-directory: se5/TightHyphen/staging + run: zip -r "$GITHUB_WORKSPACE/TightHyphen-${{ matrix.rid }}.zip" TightHyphen + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: TightHyphen-${{ matrix.rid }} + path: TightHyphen-${{ matrix.rid }}.zip + + release: + name: Publish GitHub Release + needs: [prepare, build] + if: needs.prepare.outputs.release == 'true' + runs-on: ubuntu-latest + steps: + - name: Download all zips + uses: actions/download-artifact@v4 + with: + path: dist + pattern: TightHyphen-* + merge-multiple: true + + - name: Create release and upload zips + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ needs.prepare.outputs.tag }}" dist/TightHyphen-*.zip \ + --repo "${{ github.repository }}" \ + --target "${{ needs.prepare.outputs.ref }}" \ + --title "TightHyphen v${{ needs.prepare.outputs.version }}" \ + --notes "Self-contained TightHyphen plugin builds for win/linux/osx (x64 + arm64)." diff --git a/README.md b/README.md index e47b4889..b8602684 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ index [`se5-plugins.json`](se5-plugins.json). | [Persian Subtitle Fixes](se5/PersianErrors) | Fixes common errors in Persian (Farsi) subtitles - 1,500+ rules in 12 groups. | | [Remove Unicode characters](se5/RemoveUnicodeCharacters) | Finds non-ANSI characters and lets you remove or replace each one. | | [Split dialogs](se5/SplitDialogs) | Splits dialog lines ("- Hi!" / "- Hello.") into one subtitle per speaker. | +| [Tight hyphen](se5/TightHyphen) | Removes the space after a dialog dash: "- Hi!" → "-Hi!". | | [Transliterate](se5/Transliterate) | Converts between scripts: Serbian, Montenegrin, Macedonian, Uzbek Cyrillic ↔ Latin; Russian, Ukrainian, Belarusian, Bulgarian, Kazakh, Greek, Korean → Latin. | | [Typewriter effect](se5/TypewriterEffect) | Reveals the text character by character in short timed parts. | | [Word censor](se5/WordCensor) | Censors offensive words (grawlix, custom text, or a random replacement). | diff --git a/se5/LinesUnbreaker/MainViewModel.cs b/se5/LinesUnbreaker/MainViewModel.cs index 1e50e88f..8551f99a 100644 --- a/se5/LinesUnbreaker/MainViewModel.cs +++ b/se5/LinesUnbreaker/MainViewModel.cs @@ -80,6 +80,7 @@ public string SubtitleInfo public bool HasChanges => Changes.Count > 0; public bool IsClean => Changes.Count == 0 && !_isConfigure; public bool CanApply => SelectedCount > 0 || _isConfigure; + /// for the NumericUpDown, which works with decimals. public decimal? MaxLengthValue { diff --git a/se5/Plugins.slnx b/se5/Plugins.slnx index 811e8f34..9165f456 100644 --- a/se5/Plugins.slnx +++ b/se5/Plugins.slnx @@ -16,6 +16,7 @@ + @@ -33,6 +34,7 @@ + @@ -52,6 +54,7 @@ + diff --git a/se5/TightHyphen.Tests/HyphenTightenerTests.cs b/se5/TightHyphen.Tests/HyphenTightenerTests.cs new file mode 100644 index 00000000..68e54855 --- /dev/null +++ b/se5/TightHyphen.Tests/HyphenTightenerTests.cs @@ -0,0 +1,38 @@ +using SubtitleEdit.Plugins.TightHyphen; + +namespace TightHyphen.Tests; + +public class HyphenTightenerTests +{ + [Theory] + [InlineData("- Hi!\n- Hello.", "-Hi!\n-Hello.")] + [InlineData("- Hi!\r\n- Hello.", "-Hi!\r\n-Hello.")] + [InlineData("- Two spaces", "-Two spaces")] + [InlineData("- Only one line", "-Only one line")] + [InlineData("Hi!\n- Hello.", "Hi!\n-Hello.")] + public void Tightens(string input, string expected) + { + Assert.Equal(expected, HyphenTightener.Tighten(input)); + } + + [Theory] + [InlineData("- Hi!\n- Hello.", "-Hi!\n-Hello.")] + [InlineData("- Hi!", "-Hi!")] + [InlineData("{\\an8}- Hi!", "{\\an8}-Hi!")] + public void Tightens_AfterTags(string input, string expected) + { + Assert.Equal(expected, HyphenTightener.Tighten(input)); + } + + [Theory] + [InlineData("-Hi!\n-Hello.")] + [InlineData("Well - maybe.")] + [InlineData("Not a dialog")] + [InlineData("- 5 dollars?")] + [InlineData("- ")] + [InlineData("")] + public void LeavesUnchanged(string input) + { + Assert.Equal(input, HyphenTightener.Tighten(input)); + } +} diff --git a/se5/TightHyphen.Tests/MainViewModelTests.cs b/se5/TightHyphen.Tests/MainViewModelTests.cs new file mode 100644 index 00000000..0cb4e15e --- /dev/null +++ b/se5/TightHyphen.Tests/MainViewModelTests.cs @@ -0,0 +1,81 @@ +using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen; +using System.Text.Json; + +namespace TightHyphen.Tests; + +public class MainViewModelTests +{ + private static PluginRequest Request(string requestType, string? settingsJson, params string[] texts) => new() + { + RequestType = requestType, + Subtitle = new PluginSubtitle + { + Format = "Advanced Sub Station Alpha", + Paragraphs = texts.Select((t, i) => new PluginParagraph + { + StartMs = i * 1000, EndMs = i * 1000 + 900, Text = t, Style = "Default", Actor = "Actor" + i, + }).ToList(), + }, + Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(), + SettingsVersion = settingsJson == null ? null : 1, + }; + + [Fact] + public void Run_OnlyCheckedLinesChange_KeepsFields() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "- A\n- B", "Plain", "- C")); + Assert.Equal(2, vm.ChangeCount); + vm.Changes[1].IsSelected = false; + + var response = vm.BuildResponse(); + var paragraphs = response.Subtitle!.Paragraphs!; + Assert.Equal(new[] { "-A\n-B", "Plain", "- C" }, paragraphs.Select(p => p.Text)); + Assert.Equal("Actor0", paragraphs[0].Actor); + Assert.Equal("Tight hyphen", response.UndoDescription); + } + + [Fact] + public void Run_SelectedLinesOnly() + { + var request = Request(PluginRequestType.Run, null, "- A", "- B"); + request.SelectedIndices = new List { 1 }; + var vm = new MainViewModel(request); + Assert.Equal(new[] { 1 }, vm.Changes.Select(c => c.Index)); + } + + [Fact] + public void Run_NothingToChange_IsClean() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "-A", "Plain")); + Assert.True(vm.IsClean); + Assert.False(vm.CanApply); + } + + [Fact] + public void Batch_ChangesAll_WithoutSettings() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "- A\n- B")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Equal("-A\n-B", response.Subtitle!.Paragraphs![0].Text); + Assert.Null(response.Settings); + } + + [Fact] + public void Batch_NothingToChange_ReturnsOkWithoutSubtitle() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "-A")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } + + [Fact] + public void Configure_ReturnsOkWithoutSubtitle() + { + var vm = new MainViewModel(Request(PluginRequestType.Configure, null)); + Assert.True(vm.CanApply); + var response = vm.BuildResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } +} diff --git a/se5/TightHyphen.Tests/TightHyphen.Tests.csproj b/se5/TightHyphen.Tests/TightHyphen.Tests.csproj new file mode 100644 index 00000000..d8cbc94d --- /dev/null +++ b/se5/TightHyphen.Tests/TightHyphen.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/se5/TightHyphen/App.axaml b/se5/TightHyphen/App.axaml new file mode 100644 index 00000000..86b82167 --- /dev/null +++ b/se5/TightHyphen/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/se5/TightHyphen/App.axaml.cs b/se5/TightHyphen/App.axaml.cs new file mode 100644 index 00000000..b2ca4ccd --- /dev/null +++ b/se5/TightHyphen/App.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.TightHyphen; + +public partial class App : PluginApp +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + protected override Window CreateMainWindow(PluginRequest request) => new MainWindow(request); +} diff --git a/se5/TightHyphen/ChangeItem.cs b/se5/TightHyphen/ChangeItem.cs new file mode 100644 index 00000000..55044857 --- /dev/null +++ b/se5/TightHyphen/ChangeItem.cs @@ -0,0 +1,26 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SubtitleEdit.Plugins.TightHyphen; + +/// One subtitle line whose dialog dash is tightened. +public sealed partial class ChangeItem : ObservableObject +{ + [ObservableProperty] private bool _isSelected = true; + + public ChangeItem(int index, string before, string after, string timeRange) + { + Index = index; + Before = before; + After = after; + TimeRange = timeRange; + (BeforeSegments, AfterSegments) = TextDiff.Compute(before.Replace("\r\n", "\n"), after.Replace("\r\n", "\n")); + } + + public int Index { get; } + public string Before { get; } + public string After { get; } + public string Number => "#" + (Index + 1); + public string TimeRange { get; } + public IReadOnlyList BeforeSegments { get; } + public IReadOnlyList AfterSegments { get; } +} diff --git a/se5/TightHyphen/HyphenTightener.cs b/se5/TightHyphen/HyphenTightener.cs new file mode 100644 index 00000000..e286031a --- /dev/null +++ b/se5/TightHyphen/HyphenTightener.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.TightHyphen; + +/// Removes the space after a dialog dash at the start of a line: "- Hi!" becomes "-Hi!". +public static partial class HyphenTightener +{ + [GeneratedRegex(@"<\s*(?:/\s*)?(?:i|b|u|p|font)(?:\s[^>]*)?>|\{[^}]*\}", RegexOptions.IgnoreCase)] + private static partial Regex TagRegex(); + + public static string Tighten(string text) + { + var newLine = text.Contains("\r\n") ? "\r\n" : "\n"; + var lines = text.Replace("\r\n", "\n").Split('\n'); + var changed = false; + for (var i = 0; i < lines.Length; i++) + { + var tightened = TightenLine(lines[i]); + if (tightened != lines[i]) + { + lines[i] = tightened; + changed = true; + } + } + + return changed ? string.Join(newLine, lines) : text; + } + + private static string TightenLine(string line) + { + // The dash must start the visible text: "- Hi", "- Hi", "{\an8}- Hi". + var noTags = TagRegex().Replace(line, string.Empty); + if (!noTags.StartsWith("- ", StringComparison.Ordinal)) + { + return line; + } + + var idx = FindLeadingDash(line); + if (idx < 0) + { + return line; + } + + var rest = line.Substring(idx + 1).TrimStart(' '); + + // "- 5 dollars" would turn into a negative number. + if (rest.Length == 0 || char.IsDigit(rest[0])) + { + return line; + } + + return line.Substring(0, idx + 1) + rest; + } + + /// Index of the dash after any leading tags. + private static int FindLeadingDash(string line) + { + var i = 0; + while (i < line.Length) + { + var match = TagRegex().Match(line, i); + if (match.Success && match.Index == i) + { + i += match.Length; + continue; + } + + return line[i] == '-' ? i : -1; + } + + return -1; + } +} diff --git a/se5/TightHyphen/MainViewModel.cs b/se5/TightHyphen/MainViewModel.cs new file mode 100644 index 00000000..50bb5816 --- /dev/null +++ b/se5/TightHyphen/MainViewModel.cs @@ -0,0 +1,272 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.Shared; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text.Json; + +namespace SubtitleEdit.Plugins.TightHyphen; + +public sealed partial class MainViewModel : ObservableObject +{ + private const int SettingsVersion = 1; + + private readonly PluginRequest _request; + private readonly bool _legacyRequest; + private readonly List _paragraphs; + private readonly long _videoOffsetMs; + private readonly bool _isConfigure; + private bool _loading; + + [ObservableProperty] private bool _selectedLinesOnly; + [ObservableProperty] private int _selectedCount; + + public MainViewModel(PluginRequest request) + { + _request = request; + _isConfigure = request.RequestType == PluginRequestType.Configure; + _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; + _legacyRequest = request.Subtitle.Paragraphs == null; + _paragraphs = _legacyRequest + ? SubRipParser.Parse(request.Subtitle.SubRip) + .Select(b => new PluginParagraph { StartMs = b.StartMs, EndMs = b.EndMs, Text = b.Text }).ToList() + : request.Subtitle.Paragraphs!.Select(p => p.Clone()).ToList(); + SelectedIndices = request.SelectedIndices.Where(i => i >= 0 && i < _paragraphs.Count).Distinct().Order().ToList(); + + _loading = true; + LoadSettings(); + _loading = false; + Analyze(); + } + + public ObservableCollection Changes { get; } = new(); + public IReadOnlyList SelectedIndices { get; } + + public string SubtitleInfo + { + get + { + if (_isConfigure) + { + return "Settings for Batch convert"; + } + + var name = string.IsNullOrEmpty(_request.Subtitle.FileName) ? "Untitled" : Path.GetFileName(_request.Subtitle.FileName); + var format = string.IsNullOrEmpty(_request.Subtitle.Format) ? string.Empty : $" · {_request.Subtitle.Format}"; + return $"{name}{format} · {_paragraphs.Count:N0} lines"; + } + } + + /// Opened from Batch convert's Configure button: there is nothing to set, there is no subtitle. + public bool IsConfigure => _isConfigure; + public bool IsNotConfigure => !_isConfigure; + + public bool HasSelectedLines => SelectedIndices.Count > 0; + public string SelectedLinesText => SelectedIndices.Count == 1 ? "Selected line (1)" : $"Selected lines ({SelectedIndices.Count:N0})"; + public bool AllLines { get => !SelectedLinesOnly; set => SelectedLinesOnly = !value; } + public int ChangeCount => Changes.Count; + public bool HasChanges => Changes.Count > 0; + public bool IsClean => Changes.Count == 0 && !_isConfigure; + public bool CanApply => SelectedCount > 0 || _isConfigure; + + public string CleanText => "No dialog dash is followed by a space."; + + public string StatusText => _isConfigure + ? "Used for every file in Batch convert" + : Changes.Count == 0 + ? $"{LinesInScope:N0} lines checked" + : $"{SelectedCount:N0} of {Changes.Count:N0} lines selected"; + + private int LinesInScope => SelectedLinesOnly && HasSelectedLines ? SelectedIndices.Count : _paragraphs.Count; + + partial void OnSelectedLinesOnlyChanged(bool value) + { + OnPropertyChanged(nameof(AllLines)); + Analyze(); + } + + partial void OnSelectedCountChanged(int value) + { + OnPropertyChanged(nameof(CanApply)); + OnPropertyChanged(nameof(StatusText)); + } + + private IEnumerable IndicesInScope => + SelectedLinesOnly && HasSelectedLines ? SelectedIndices : Enumerable.Range(0, _paragraphs.Count); + + private void Analyze() + { + if (_loading) + { + return; + } + + Changes.Clear(); + foreach (var index in IndicesInScope) + { + var text = _paragraphs[index].Text ?? string.Empty; + var after = HyphenTightener.Tighten(text); + if (after != text) + { + var item = new ChangeItem(index, text, after, FormatTimeRange(_paragraphs[index])); + item.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ChangeItem.IsSelected)) + { + SelectedCount = Changes.Count(c => c.IsSelected); + } + }; + Changes.Add(item); + } + } + + SelectedCount = Changes.Count; + OnPropertyChanged(nameof(ChangeCount)); + OnPropertyChanged(nameof(HasChanges)); + OnPropertyChanged(nameof(IsClean)); + OnPropertyChanged(nameof(StatusText)); + } + + [RelayCommand] + private void SelectAll() => SetSelection(_ => true); + + [RelayCommand] + private void SelectNone() => SetSelection(_ => false); + + [RelayCommand] + private void InvertSelection() => SetSelection(c => !c.IsSelected); + + private void SetSelection(Func selected) + { + foreach (var change in Changes) + { + change.IsSelected = selected(change); + } + } + + /// + /// A "batch" request (Batch convert): tighten the dash in every line, without UI. + /// No changes is "ok" without a subtitle. + /// + public PluginResponse BuildBatchResponse() + { + if (Changes.Count == 0) + { + return new PluginResponse { Status = PluginStatus.Ok, Message = "Tight hyphen: nothing to change." }; + } + + var response = BuildResponse(); + response.Settings = null; + response.SettingsVersion = null; + return response; + } + + /// A "configure" request: hand back the settings only, for the next Batch convert run. + public PluginResponse BuildConfigureResponse() => + new() { Status = PluginStatus.Ok, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + /// The response for OK: the selected lines with tight dashes. + public PluginResponse BuildResponse() + { + if (_isConfigure) + { + return BuildConfigureResponse(); + } + + var selected = Changes.Where(c => c.IsSelected).ToList(); + if (selected.Count == 0) + { + return BuildCancelResponse(); + } + + var texts = _paragraphs.Select(p => p.Text ?? string.Empty).ToArray(); + foreach (var change in selected) + { + texts[change.Index] = change.After; + } + + PluginSubtitle subtitle; + if (_legacyRequest) + { + subtitle = new PluginSubtitle + { + Format = "SubRip", + Native = SubRipParser.Serialize(_paragraphs.Select((p, i) => new SrtBlock + { + StartMs = (long)Math.Round(p.StartMs), + EndMs = (long)Math.Round(p.EndMs), + Text = texts[i], + }).ToList()), + }; + } + else + { + subtitle = new PluginSubtitle + { + Format = _request.Subtitle.Format, + Paragraphs = _paragraphs.Select((p, i) => + { + var copy = p.Clone(); + copy.Text = texts[i]; + return copy; + }).ToList(), + }; + } + + return new PluginResponse + { + Status = PluginStatus.Ok, + Message = selected.Count == 1 + ? "Tight hyphen: 1 line changed." + : $"Tight hyphen: {selected.Count:N0} lines changed.", + UndoDescription = "Tight hyphen", + Subtitle = subtitle, + Settings = BuildSettings(), + SettingsVersion = SettingsVersion, + }; + } + + public PluginResponse BuildCancelResponse() => + new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + private JsonElement BuildSettings() + { + var payload = new Dictionary + { + ["selectedLinesOnly"] = SelectedLinesOnly, + }; + + // Configure has no lines selected, so keep the scope a normal run saved. + if (_isConfigure && _request.SettingsVersion == SettingsVersion && _request.Settings is { ValueKind: JsonValueKind.Object } stored + && stored.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + payload["selectedLinesOnly"] = selectedOnly.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(payload); + } + + private void LoadSettings() + { + // Lines selected in Subtitle Edit mean "work on these" - default to them. + SelectedLinesOnly = HasSelectedLines; + if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings) + { + return; + } + + if (settings.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + SelectedLinesOnly = selectedOnly.GetBoolean() && HasSelectedLines; + } + } + + private string FormatTimeRange(PluginParagraph p) => + $"{FormatTime(p.StartMs + _videoOffsetMs)} → {FormatTime(p.EndMs + _videoOffsetMs)}"; + + private static string FormatTime(double ms) + { + var time = TimeSpan.FromMilliseconds(Math.Max(0, ms)); + return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00},{3:000}", (int)time.TotalHours, time.Minutes, time.Seconds, time.Milliseconds); + } +} diff --git a/se5/TightHyphen/MainWindow.axaml b/se5/TightHyphen/MainWindow.axaml new file mode 100644 index 00000000..fea85fa5 --- /dev/null +++ b/se5/TightHyphen/MainWindow.axaml @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/TightHyphen/MainWindow.axaml.cs b/se5/TightHyphen/MainWindow.axaml.cs new file mode 100644 index 00000000..52ab29c9 --- /dev/null +++ b/se5/TightHyphen/MainWindow.axaml.cs @@ -0,0 +1,58 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.TightHyphen; + +public partial class MainWindow : Window +{ + private readonly MainViewModel _vm; + + public MainWindow() : this(new PluginRequest()) { } + + public MainWindow(PluginRequest request) + { + AvaloniaXamlLoader.Load(this); + _vm = new MainViewModel(request); + DataContext = _vm; + App.Response = _vm.BuildCancelResponse(); + KeyDown += OnKeyDown; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + this.BringToForeground(); + } + + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + OnCancel(sender, e); + } + } + + private void OnCancel(object? sender, RoutedEventArgs e) + { + App.Response = _vm.BuildCancelResponse(); + Close(); + } + + private void OnOk(object? sender, RoutedEventArgs e) + { + try + { + App.Response = _vm.BuildResponse(); + } + catch (Exception exception) + { + App.Response = new PluginResponse { Status = PluginStatus.Error, Message = "Tight hyphen failed: " + exception.Message }; + } + + Close(); + } +} diff --git a/se5/TightHyphen/Program.cs b/se5/TightHyphen/Program.cs new file mode 100644 index 00000000..53ecad07 --- /dev/null +++ b/se5/TightHyphen/Program.cs @@ -0,0 +1,16 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.Shared; +using System; + +namespace SubtitleEdit.Plugins.TightHyphen; + +public static class Program +{ + [STAThread] + public static int Main(string[] args) + { + IconProvider.Current.Register(); + return PluginBootstrap.Run(args, request => new MainViewModel(request).BuildBatchResponse()); + } +} diff --git a/se5/TightHyphen/README.md b/se5/TightHyphen/README.md new file mode 100644 index 00000000..e2cc64d9 --- /dev/null +++ b/se5/TightHyphen/README.md @@ -0,0 +1,42 @@ +# Tight hyphen (Subtitle Edit 5 plugin) + +Removes the space after a dialog dash at the start of a line: + +| Before | After | +|---|---| +| `- Where are you going?`
`- Home.` | `-Where are you going?`
`-Home.` | +| `- Hi!` | `-Hi!` | + +It is the SE5 port of the SE4 *TightHyphen* plugin by ivandrofly. + +Built on Avalonia and the shared [`Plugin-Shared`](../Plugin-Shared/) library. + +## Rules + +- Only a dash that starts the visible text of a line is changed. It may come after tags like ``, `` or `{\an8}`. +- Every space after the dash is removed. +- A dash followed by a number (`- 5 dollars`) is left alone, so it does not read as a minus sign. + +## UI + +- Every changed line is a checkable before/after card. *OK* applies the checked + lines as one "Tight hyphen" undo step. +- *All lines* / *Selected lines*: lines selected in Subtitle Edit are the default scope. + +Works on the lines exactly as Subtitle Edit holds them, so the styles, actors and +other fields of formats like ASSA are kept. On older SE versions it falls back to SubRip. + +## Batch convert + +Tight hyphen supports Subtitle Edit's **Batch convert** (Tools → Batch convert → Run plugin). +It has no options. Every line of each file is changed without showing the window. + +## Changes from the SE4 plugin + +- Each line can be checked or unchecked before applying. +- `- 5` is no longer turned into `-5`. +- Line breaks are kept as they were. + +## Build + +See `.github/workflows/tight-hyphen.yml`. diff --git a/se5/TightHyphen/SubtitleTextBlock.cs b/se5/TightHyphen/SubtitleTextBlock.cs new file mode 100644 index 00000000..728002cc --- /dev/null +++ b/se5/TightHyphen/SubtitleTextBlock.cs @@ -0,0 +1,201 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using System.Text; + +namespace SubtitleEdit.Plugins.TightHyphen; + +/// +/// Shows one side of a . With off the raw text is shown with +/// tags colored and changed text tinted; with it on the tags are hidden and italic text is drawn in italics. +/// +public sealed class SubtitleTextBlock : TextBlock +{ + public static readonly StyledProperty?> SegmentsProperty = + AvaloniaProperty.Register?>(nameof(Segments)); + + public static readonly StyledProperty RenderedProperty = + AvaloniaProperty.Register(nameof(Rendered)); + + private static readonly IBrush RemovedBrush = new SolidColorBrush(Color.Parse("#55E5484D")); + private static readonly IBrush AddedBrush = new SolidColorBrush(Color.Parse("#552FA36B")); + private static readonly IBrush TagBrush = new SolidColorBrush(Color.Parse("#F43F5E")); + private static readonly IBrush ItalicBrush = new SolidColorBrush(Color.Parse("#22EA580C")); + + static SubtitleTextBlock() + { + SegmentsProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + RenderedProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + } + + public IReadOnlyList? Segments + { + get => GetValue(SegmentsProperty); + set => SetValue(SegmentsProperty, value); + } + + public bool Rendered + { + get => GetValue(RenderedProperty); + set => SetValue(RenderedProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + private void Rebuild() + { + var segments = Segments ?? Array.Empty(); + Inlines = Rendered ? BuildRendered(string.Concat(segments.Select(s => s.Text))) : BuildRaw(segments); + } + + /// Raw text: tags in the tag color, removed/added text on a tint. + private static InlineCollection BuildRaw(IReadOnlyList segments) + { + var inlines = new InlineCollection(); + var inTag = false; + foreach (var segment in segments) + { + var background = segment.Kind switch + { + DiffKind.Removed => RemovedBrush, + DiffKind.Added => AddedBrush, + _ => null, + }; + + var run = new StringBuilder(); + var runIsTag = inTag; + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) { Background = background }; + if (runIsTag) + { + inline.Foreground = TagBrush; + inline.FontWeight = FontWeight.SemiBold; + } + + inlines.Add(inline); + run.Clear(); + } + } + + foreach (var c in segment.Text) + { + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + if (background != null) + { + // A changed line break would otherwise be invisible. + inlines.Add(new Run("↵") { Background = background, Foreground = TagBrush }); + } + + inlines.Add(new LineBreak()); + continue; + } + + var isTag = inTag || c == '<'; + if (isTag != runIsTag) + { + Flush(); + runIsTag = isTag; + } + + run.Append(c); + if (c == '<') + { + inTag = true; + } + else if (c == '>') + { + inTag = false; + Flush(); + runIsTag = false; + } + } + + Flush(); + } + + return inlines; + } + + /// Rendered text: tags hidden, <i> drawn in italics on a soft tint, <b>/<u> applied too. + private static InlineCollection BuildRendered(string text) + { + var inlines = new InlineCollection(); + var italic = 0; + var bold = 0; + var underline = 0; + var run = new StringBuilder(); + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) + { + FontStyle = italic > 0 ? FontStyle.Italic : FontStyle.Normal, + FontWeight = bold > 0 ? FontWeight.Bold : FontWeight.Normal, + }; + if (underline > 0) + { + inline.TextDecorations = Avalonia.Media.TextDecorations.Underline; + } + + if (italic > 0) + { + inline.Background = ItalicBrush; + } + + inlines.Add(inline); + run.Clear(); + } + } + + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + inlines.Add(new LineBreak()); + continue; + } + + var end = c is '<' or '{' ? text.IndexOf(c == '<' ? '>' : '}', i) : -1; + if (end < 0) + { + run.Append(c); + continue; + } + + Flush(); + var tag = text.Substring(i, end - i + 1).ToLowerInvariant(); + i = end; + switch (tag) + { + case "": italic++; break; + case "": italic = Math.Max(0, italic - 1); break; + case "": bold++; break; + case "": bold = Math.Max(0, bold - 1); break; + case "": underline++; break; + case "": underline = Math.Max(0, underline - 1); break; + } + } + + Flush(); + return inlines; + } +} diff --git a/se5/TightHyphen/TextDiff.cs b/se5/TightHyphen/TextDiff.cs new file mode 100644 index 00000000..4fd3c28c --- /dev/null +++ b/se5/TightHyphen/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.TightHyphen; + +public enum DiffKind +{ + Same, + Removed, + Added, +} + +public sealed record DiffSegment(string Text, DiffKind Kind); + +/// Character diff (LCS) used to highlight what a fix changed. +public static class TextDiff +{ + private const int MaxCells = 1_000_000; + + /// Returns the segments of the old text (same/removed) and of the new text (same/added). + public static (List Before, List After) Compute(string before, string after) + { + // Same number of lines: diff line by line, so line breaks never pair up with the wrong line. + var beforeLines = before.Split('\n'); + var afterLines = after.Split('\n'); + if (beforeLines.Length > 1 && beforeLines.Length == afterLines.Length) + { + var beforeSegments = new List(); + var afterSegments = new List(); + for (var i = 0; i < beforeLines.Length; i++) + { + if (i > 0) + { + Add(beforeSegments, "\n", DiffKind.Same); + Add(afterSegments, "\n", DiffKind.Same); + } + + var (b, a) = ComputeLine(beforeLines[i], afterLines[i]); + b.ForEach(segment => Add(beforeSegments, segment.Text, segment.Kind)); + a.ForEach(segment => Add(afterSegments, segment.Text, segment.Kind)); + } + + return (beforeSegments, afterSegments); + } + + return ComputeLine(before, after); + } + + private static (List Before, List After) ComputeLine(string before, string after) + { + var prefix = 0; + while (prefix < before.Length && prefix < after.Length && before[prefix] == after[prefix]) + { + prefix++; + } + + var suffix = 0; + while (suffix < before.Length - prefix && suffix < after.Length - prefix && + before[before.Length - 1 - suffix] == after[after.Length - 1 - suffix]) + { + suffix++; + } + + var a = before.Substring(prefix, before.Length - prefix - suffix); + var b = after.Substring(prefix, after.Length - prefix - suffix); + var beforeSegments = new List(); + var afterSegments = new List(); + Add(beforeSegments, before[..prefix], DiffKind.Same); + Add(afterSegments, after[..prefix], DiffKind.Same); + + if ((long)a.Length * b.Length > MaxCells) + { + Add(beforeSegments, a, DiffKind.Removed); + Add(afterSegments, b, DiffKind.Added); + } + else + { + Lcs(a, b, beforeSegments, afterSegments); + } + + Add(beforeSegments, before[(before.Length - suffix)..], DiffKind.Same); + Add(afterSegments, after[(after.Length - suffix)..], DiffKind.Same); + return (beforeSegments, afterSegments); + } + + private static void Lcs(string a, string b, List beforeSegments, List afterSegments) + { + var lengths = new int[a.Length + 1, b.Length + 1]; + for (var i = a.Length - 1; i >= 0; i--) + { + for (var j = b.Length - 1; j >= 0; j--) + { + lengths[i, j] = a[i] == b[j] + ? lengths[i + 1, j + 1] + 1 + : Math.Max(lengths[i + 1, j], lengths[i, j + 1]); + } + } + + int x = 0, y = 0; + while (x < a.Length && y < b.Length) + { + if (a[x] == b[y]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Same); + Add(afterSegments, b[y].ToString(), DiffKind.Same); + x++; + y++; + } + else if (lengths[x + 1, y] >= lengths[x, y + 1]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Removed); + x++; + } + else + { + Add(afterSegments, b[y].ToString(), DiffKind.Added); + y++; + } + } + + Add(beforeSegments, a[x..], DiffKind.Removed); + Add(afterSegments, b[y..], DiffKind.Added); + } + + private static void Add(List segments, string text, DiffKind kind) + { + if (text.Length == 0) + { + return; + } + + if (segments.Count > 0 && segments[^1].Kind == kind) + { + segments[^1] = segments[^1] with { Text = segments[^1].Text + text }; + } + else + { + segments.Add(new DiffSegment(text, kind)); + } + } +} diff --git a/se5/TightHyphen/TightHyphen.csproj b/se5/TightHyphen/TightHyphen.csproj new file mode 100644 index 00000000..81cd631e --- /dev/null +++ b/se5/TightHyphen/TightHyphen.csproj @@ -0,0 +1,27 @@ + + + + WinExe + net8.0 + enable + enable + TightHyphen + SubtitleEdit.Plugins.TightHyphen + true + app.manifest + + + + + + + + + + + + + + + + diff --git a/se5/TightHyphen/app.manifest b/se5/TightHyphen/app.manifest new file mode 100644 index 00000000..442abb69 --- /dev/null +++ b/se5/TightHyphen/app.manifest @@ -0,0 +1,10 @@ + + + + + + true/pm + PerMonitorV2 + + + diff --git a/se5/TightHyphen/plugin.json b/se5/TightHyphen/plugin.json new file mode 100644 index 00000000..a74c993c --- /dev/null +++ b/se5/TightHyphen/plugin.json @@ -0,0 +1,13 @@ +{ + "apiVersion": 1, + "name": "Tight hyphen", + "description": "Removes the space after a dialog dash at the start of a line: \"- Hi!\" becomes \"-Hi!\". Works after tags like and ASSA override tags, and leaves \"- 5\" alone. Shows a checkable before/after preview.", + "version": "1.0.0", + "author": "ivandrofly", + "url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/TightHyphen", + "menu": "Tools", + "minSeVersion": "5.0.0", + "batch": true, + "runtime": "dotnet", + "entry": "TightHyphen.dll" +} From b374355adbbb865479d39915208088a3e597fb34 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 17 Oct 2026 12:34:35 +0100 Subject: [PATCH 4/6] Add SE5 port of Hearing impaired colorer Avalonia port of the SE4 HIColorer plugin, with no libse or System.Drawing. It adds a color picker for each category, a preview that shows the colors, and per-line checkboxes. It fixes the music toggle (it was wired to narrator) and the colors that were never saved. Font tags are replaced only on lines that get colored, and there is batch convert support. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/hi-colorer.yml | 196 +++++++++++ README.md | 1 + se5/HIColorer.Tests/HIColorer.Tests.csproj | 27 ++ se5/HIColorer.Tests/HiColorerTests.cs | 93 +++++ se5/HIColorer.Tests/MainViewModelTests.cs | 110 ++++++ se5/HIColorer/App.axaml | 9 + se5/HIColorer/App.axaml.cs | 12 + se5/HIColorer/ChangeItem.cs | 26 ++ se5/HIColorer/HIColorer.csproj | 28 ++ se5/HIColorer/HiColorer.cs | 272 +++++++++++++++ se5/HIColorer/MainViewModel.cs | 381 +++++++++++++++++++++ se5/HIColorer/MainWindow.axaml | 375 ++++++++++++++++++++ se5/HIColorer/MainWindow.axaml.cs | 58 ++++ se5/HIColorer/Program.cs | 16 + se5/HIColorer/README.md | 62 ++++ se5/HIColorer/SubtitleTextBlock.cs | 221 ++++++++++++ se5/HIColorer/TextDiff.cs | 138 ++++++++ se5/HIColorer/app.manifest | 10 + se5/HIColorer/plugin.json | 13 + se5/Plugins.slnx | 3 + 20 files changed, 2051 insertions(+) create mode 100644 .github/workflows/hi-colorer.yml create mode 100644 se5/HIColorer.Tests/HIColorer.Tests.csproj create mode 100644 se5/HIColorer.Tests/HiColorerTests.cs create mode 100644 se5/HIColorer.Tests/MainViewModelTests.cs create mode 100644 se5/HIColorer/App.axaml create mode 100644 se5/HIColorer/App.axaml.cs create mode 100644 se5/HIColorer/ChangeItem.cs create mode 100644 se5/HIColorer/HIColorer.csproj create mode 100644 se5/HIColorer/HiColorer.cs create mode 100644 se5/HIColorer/MainViewModel.cs create mode 100644 se5/HIColorer/MainWindow.axaml create mode 100644 se5/HIColorer/MainWindow.axaml.cs create mode 100644 se5/HIColorer/Program.cs create mode 100644 se5/HIColorer/README.md create mode 100644 se5/HIColorer/SubtitleTextBlock.cs create mode 100644 se5/HIColorer/TextDiff.cs create mode 100644 se5/HIColorer/app.manifest create mode 100644 se5/HIColorer/plugin.json diff --git a/.github/workflows/hi-colorer.yml b/.github/workflows/hi-colorer.yml new file mode 100644 index 00000000..79940318 --- /dev/null +++ b/.github/workflows/hi-colorer.yml @@ -0,0 +1,196 @@ +name: Build HIColorer (SE5) + +on: + push: + branches: [main] + paths: + - 'se5/HIColorer/**' + - 'se5/HIColorer.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/hi-colorer.yml' + pull_request: + paths: + - 'se5/HIColorer/**' + - 'se5/HIColorer.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/hi-colorer.yml' + workflow_dispatch: + inputs: + release: + description: 'Publish a GitHub release. Bumps the minor in plugin.json and commits it back.' + type: boolean + default: false + version: + description: 'Optional explicit version (e.g. 1.2.0). Overrides the auto-minor-bump.' + required: false + type: string + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set.outputs.version }} + tag: ${{ steps.set.outputs.tag }} + ref: ${{ steps.set.outputs.ref }} + release: ${{ steps.set.outputs.release }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version (and bump on release) + id: set + run: | + set -e + current=$(jq -r .version se5/HIColorer/plugin.json) + release_flag="false" + ref="${{ github.sha }}" + version="$current" + tag="" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.release }}" = "true" ]; then + release_flag="true" + if [ -n "${{ inputs.version }}" ]; then + version="${{ inputs.version }}" + else + IFS=. read -r major minor patch <<< "$current" + version="$major.$((minor + 1)).0" + fi + + tmp=$(mktemp) + jq --arg v "$version" '.version = $v' se5/HIColorer/plugin.json > "$tmp" + mv "$tmp" se5/HIColorer/plugin.json + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add se5/HIColorer/plugin.json + git commit -m "Bump HIColorer to $version [skip ci]" + + # Retry-with-rebase: if a concurrent release dispatched for a different + # plugin won the push first, pull --rebase and try again. + attempts=0 + while ! git push 2>/tmp/push.err; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 5 ]; then + echo "Failed to push after $attempts attempts:" >&2 + cat /tmp/push.err >&2 + exit 1 + fi + echo "Push rejected (attempt $attempts), pulling --rebase and retrying..." + git pull --rebase --no-edit + done + ref=$(git rev-parse HEAD) + + IFS=. read -r major minor patch <<< "$version" + tag="se5-hi-colorer-v$major.$minor" + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "release=$release_flag" >> "$GITHUB_OUTPUT" + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Test + run: dotnet test se5/HIColorer.Tests/HIColorer.Tests.csproj -c Release + + build: + needs: [prepare, test] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + os: windows + exe: HIColorer.exe + - rid: win-arm64 + os: windows + exe: HIColorer.exe + - rid: linux-x64 + os: linux + exe: HIColorer + - rid: linux-arm64 + os: linux + exe: HIColorer + - rid: osx-x64 + os: macos + exe: HIColorer + - rid: osx-arm64 + os: macos + exe: HIColorer + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Publish self-contained (${{ matrix.rid }}) + working-directory: se5/HIColorer + run: | + dotnet publish HIColorer.csproj \ + -c Release \ + -r ${{ matrix.rid }} \ + --self-contained true \ + -p:DebugType=None \ + -p:DebugSymbols=false \ + -o staging/HIColorer + + - name: Rewrite plugin.json for ${{ matrix.os }} + working-directory: se5/HIColorer + run: | + jq ' + del(.runtime, .entry) | + .executables = { "${{ matrix.os }}": "${{ matrix.exe }}" } + ' plugin.json > staging/HIColorer/plugin.json + + - name: Package zip + working-directory: se5/HIColorer/staging + run: zip -r "$GITHUB_WORKSPACE/HIColorer-${{ matrix.rid }}.zip" HIColorer + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: HIColorer-${{ matrix.rid }} + path: HIColorer-${{ matrix.rid }}.zip + + release: + name: Publish GitHub Release + needs: [prepare, build] + if: needs.prepare.outputs.release == 'true' + runs-on: ubuntu-latest + steps: + - name: Download all zips + uses: actions/download-artifact@v4 + with: + path: dist + pattern: HIColorer-* + merge-multiple: true + + - name: Create release and upload zips + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ needs.prepare.outputs.tag }}" dist/HIColorer-*.zip \ + --repo "${{ github.repository }}" \ + --target "${{ needs.prepare.outputs.ref }}" \ + --title "HIColorer v${{ needs.prepare.outputs.version }}" \ + --notes "Self-contained HIColorer plugin builds for win/linux/osx (x64 + arm64)." diff --git a/README.md b/README.md index b8602684..07dc37ac 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ index [`se5-plugins.json`](se5-plugins.json). | [British to American](se5/BritishToAmerican) | Converts British English spellings to American English. | | [Fix encoding errors](se5/FixEncodingErrors) | Repairs UTF-8 text read with the wrong code page: résumé → résumé, don’t → don’t. | | [Haxor](se5/Haxor) | Translates text to "haxor" - the minimal reference plugin. | +| [Hearing impaired colorer](se5/HIColorer) | Colors narrators, moods and music lines with font tags, or removes font colors. | | [Hearing impaired to uppercase](se5/HI2UC) | Changes the case of moods in ( ) / [ ] and speaker labels: (laughs) John: Hi. → (LAUGHS) JOHN: Hi. | | [Italic each line](se5/ItalicEachLine) | Switches italic tags between one tag per subtitle and a tag on each line. | | [Lines unbreaker](se5/LinesUnbreaker) | Joins short multi-line subtitles into one line when the result fits. | diff --git a/se5/HIColorer.Tests/HIColorer.Tests.csproj b/se5/HIColorer.Tests/HIColorer.Tests.csproj new file mode 100644 index 00000000..68182d21 --- /dev/null +++ b/se5/HIColorer.Tests/HIColorer.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/se5/HIColorer.Tests/HiColorerTests.cs b/se5/HIColorer.Tests/HiColorerTests.cs new file mode 100644 index 00000000..db696ba4 --- /dev/null +++ b/se5/HIColorer.Tests/HiColorerTests.cs @@ -0,0 +1,93 @@ +using SubtitleEdit.Plugins.HIColorer; + +namespace HIColorer.Tests; + +public class HiColorerTests +{ + private static readonly ColorOptions Options = new() { NarratorColor = "#ffff00", MoodsColor = "#00ffff", MusicColor = "#ff00ff" }; + + [Fact] + public void Moods_AreColored() + { + Assert.Equal("(door slams) Hi.", HiColorer.Paint("(door slams) Hi.", Options)); + Assert.Equal("Hi [laughs]", HiColorer.Paint("Hi [laughs]", Options)); + } + + [Fact] + public void Narrator_IsColored_EachLine() + { + Assert.Equal("- MAN: Baby.\n- JUNE: No.", + HiColorer.Paint("- MAN: Baby.\n- JUNE: No.", Options)); + } + + [Fact] + public void Narrator_KeepsCrLf() + { + Assert.Equal("MAN: Hi.\r\nBye.", HiColorer.Paint("MAN: Hi.\r\nBye.", Options)); + } + + [Fact] + public void Narrator_InsideItalic_KeepsTagsOutsideTheColor() + { + Assert.Equal("MAN: Hi.", HiColorer.Paint("MAN: Hi.", Options)); + Assert.Equal("MAN: Hi.", HiColorer.Paint("MAN: Hi.", Options)); + } + + [Theory] + [InlineData("See you at 12:30.")] + [InlineData("Subtitles by: someone")] + [InlineData("Visit https://example.com")] + [InlineData("(laughs) John: Hi")] + public void Narrator_Ignored(string text) + { + var options = new ColorOptions { Moods = false, Music = false }; + Assert.Equal(text, HiColorer.Paint(text, options)); + } + + [Fact] + public void Narrator_NameContainingBy_IsColored() + { + Assert.Equal("BOBBY: Hey.", HiColorer.Paint("BOBBY: Hey.", Options)); + } + + [Fact] + public void Music_WholeTextIsColored() + { + Assert.Equal("♪ La la la ♪", HiColorer.Paint("♪ La la la ♪", Options)); + Assert.Equal("♫ Hum ♫", HiColorer.Paint("♫ Hum ♫", Options)); + } + + [Fact] + public void Music_OnlyWhenEnabled() + { + var options = new ColorOptions { Music = false }; + Assert.Equal("♪ La la ♪", HiColorer.Paint("♪ La la ♪", options)); + } + + [Fact] + public void Recolor_ReplacesOldColor() + { + Assert.Equal("(sighs)", HiColorer.Paint("(sighs)", Options)); + } + + [Fact] + public void AlreadyColored_IsUnchanged() + { + var text = "(sighs)"; + Assert.Equal(text, HiColorer.Paint(text, Options)); + } + + [Fact] + public void LineWithNothingToColor_KeepsItsFontTags() + { + var text = "Red words"; + Assert.Equal(text, HiColorer.Paint(text, Options)); + } + + [Fact] + public void Remove_StripsFontTagsOnly() + { + Assert.Equal("(sighs) Hi", HiColorer.Apply("(sighs) Hi", ColorMode.Remove, Options)); + Assert.Equal("Plain", HiColorer.Apply("Plain", ColorMode.Remove, Options)); + } +} diff --git a/se5/HIColorer.Tests/MainViewModelTests.cs b/se5/HIColorer.Tests/MainViewModelTests.cs new file mode 100644 index 00000000..32199987 --- /dev/null +++ b/se5/HIColorer.Tests/MainViewModelTests.cs @@ -0,0 +1,110 @@ +using Avalonia.Media; +using SubtitleEdit.Plugins.HIColorer; +using SubtitleEdit.Plugins.Shared; +using System.Text.Json; + +namespace HIColorer.Tests; + +public class MainViewModelTests +{ + private static PluginRequest Request(string requestType, string? settingsJson, params string[] texts) => new() + { + RequestType = requestType, + Subtitle = new PluginSubtitle + { + Format = "Advanced Sub Station Alpha", + Paragraphs = texts.Select((t, i) => new PluginParagraph + { + StartMs = i * 1000, EndMs = i * 1000 + 900, Text = t, Style = "Default", Actor = "Actor" + i, + }).ToList(), + }, + Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(), + SettingsVersion = settingsJson == null ? null : 1, + }; + + [Fact] + public void Run_ColorsCheckedLines_KeepsFields() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "(sighs)", "Plain", "JOHN: Hi.")); + Assert.Equal(2, vm.ChangeCount); + vm.Changes[1].IsSelected = false; + + var response = vm.BuildResponse(); + var paragraphs = response.Subtitle!.Paragraphs!; + Assert.Equal($"(sighs)", paragraphs[0].Text); + Assert.Equal("Plain", paragraphs[1].Text); + Assert.Equal("JOHN: Hi.", paragraphs[2].Text); + Assert.Equal("Actor0", paragraphs[0].Actor); + } + + [Fact] + public void ChangingAColor_UpdatesThePreview() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "(sighs)")); + vm.MoodsColor = Color.Parse("#123456"); + Assert.Equal("(sighs)", vm.Changes.Single().After); + } + + [Fact] + public void MusicToggle_OnlyAffectsMusic() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "♪ La ♪", "JOHN: Hi.")); + vm.Music = false; + Assert.Equal(new[] { 1 }, vm.Changes.Select(c => c.Index)); + Assert.True(vm.Narrator); + } + + [Fact] + public void RemoveMode_StripsColors() + { + var vm = new MainViewModel(Request(PluginRequestType.Run, null, "Red", "Plain")); + Assert.False(vm.HasChanges); + + vm.IsRemoveMode = true; + Assert.Equal("Red", vm.Changes.Single().After); + } + + [Fact] + public void SavedSettings_AreLoaded_AndReturned() + { + var settings = "{\"mode\":\"Color\",\"narrator\":false,\"moodsColor\":\"#abcdef\",\"music\":true,\"showRendered\":false}"; + var vm = new MainViewModel(Request(PluginRequestType.Run, settings, "(sighs)", "JOHN: Hi.")); + Assert.False(vm.Narrator); + Assert.False(vm.ShowRendered); + Assert.Equal("(sighs)", vm.Changes.Single().After); + + var response = vm.BuildCancelResponse(); + Assert.Equal("#abcdef", response.Settings!.Value.GetProperty("moodsColor").GetString()); + Assert.False(response.Settings!.Value.GetProperty("narrator").GetBoolean()); + Assert.Equal(1, response.SettingsVersion); + } + + [Fact] + public void Batch_UsesSavedMode_WithoutSettings() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, "{\"mode\":\"Remove\"}", "(sighs)")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Equal("(sighs)", response.Subtitle!.Paragraphs![0].Text); + Assert.Null(response.Settings); + } + + [Fact] + public void Batch_NothingToChange_ReturnsOkWithoutSubtitle() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "Plain")).BuildBatchResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } + + [Fact] + public void Configure_ReturnsSettingsOnly() + { + var vm = new MainViewModel(Request(PluginRequestType.Configure, null)); + vm.NarratorColor = Color.Parse("#010203"); + + var response = vm.BuildResponse(); + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + Assert.Equal("#010203", response.Settings!.Value.GetProperty("narratorColor").GetString()); + } +} diff --git a/se5/HIColorer/App.axaml b/se5/HIColorer/App.axaml new file mode 100644 index 00000000..bbc9edaf --- /dev/null +++ b/se5/HIColorer/App.axaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/se5/HIColorer/App.axaml.cs b/se5/HIColorer/App.axaml.cs new file mode 100644 index 00000000..14825a8a --- /dev/null +++ b/se5/HIColorer/App.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.HIColorer; + +public partial class App : PluginApp +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + protected override Window CreateMainWindow(PluginRequest request) => new MainWindow(request); +} diff --git a/se5/HIColorer/ChangeItem.cs b/se5/HIColorer/ChangeItem.cs new file mode 100644 index 00000000..7ac5e041 --- /dev/null +++ b/se5/HIColorer/ChangeItem.cs @@ -0,0 +1,26 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SubtitleEdit.Plugins.HIColorer; + +/// One subtitle line whose hearing-impaired text is colored. +public sealed partial class ChangeItem : ObservableObject +{ + [ObservableProperty] private bool _isSelected = true; + + public ChangeItem(int index, string before, string after, string timeRange) + { + Index = index; + Before = before; + After = after; + TimeRange = timeRange; + (BeforeSegments, AfterSegments) = TextDiff.Compute(before.Replace("\r\n", "\n"), after.Replace("\r\n", "\n")); + } + + public int Index { get; } + public string Before { get; } + public string After { get; } + public string Number => "#" + (Index + 1); + public string TimeRange { get; } + public IReadOnlyList BeforeSegments { get; } + public IReadOnlyList AfterSegments { get; } +} diff --git a/se5/HIColorer/HIColorer.csproj b/se5/HIColorer/HIColorer.csproj new file mode 100644 index 00000000..3bff945b --- /dev/null +++ b/se5/HIColorer/HIColorer.csproj @@ -0,0 +1,28 @@ + + + + WinExe + net8.0 + enable + enable + HIColorer + SubtitleEdit.Plugins.HIColorer + true + app.manifest + + + + + + + + + + + + + + + + + diff --git a/se5/HIColorer/HiColorer.cs b/se5/HIColorer/HiColorer.cs new file mode 100644 index 00000000..9171b1d8 --- /dev/null +++ b/se5/HIColorer/HiColorer.cs @@ -0,0 +1,272 @@ +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.HIColorer; + +public enum ColorMode +{ + /// Color narrators, moods and music. + Color, + + /// Remove every <font> tag. + Remove, +} + +public sealed class ColorOptions +{ + public const string DefaultNarratorColor = "#ffff00"; + public const string DefaultMoodsColor = "#00ffff"; + public const string DefaultMusicColor = "#ff80ff"; + + public bool Narrator { get; set; } = true; + public string NarratorColor { get; set; } = DefaultNarratorColor; + public bool Moods { get; set; } = true; + public string MoodsColor { get; set; } = DefaultMoodsColor; + public bool Music { get; set; } = true; + public string MusicColor { get; set; } = DefaultMusicColor; +} + +/// Colors hearing-impaired text: narrator labels, moods in ( ) / [ ] and music lines (♪ ♫). +public static partial class HiColorer +{ + private const string WriteFormat = "{1}"; + + [GeneratedRegex(@"<\s*/?\s*font(?:\s[^>]*)?>", RegexOptions.IgnoreCase)] + private static partial Regex FontTagRegex(); + + [GeneratedRegex(@"<[^>]*>|\{[^}]*\}")] + private static partial Regex AnyTagRegex(); + + [GeneratedRegex(@"\b(?:by|https?)\b", RegexOptions.IgnoreCase)] + private static partial Regex NarratorIgnoreRegex(); + + public static string Apply(string text, ColorMode mode, ColorOptions options) => + mode == ColorMode.Remove ? RemoveColors(text) : Paint(text, options); + + public static string RemoveColors(string text) => + text.Contains(" + /// Colors the text. Old <font> tags are removed first, but only on lines that get colored - + /// a text with nothing to color is returned as it is. + /// + public static string Paint(string text, ColorOptions options) + { + var plain = RemoveColors(text); + var painted = plain; + if (options.Moods) + { + painted = PaintMoods(painted, options.MoodsColor); + } + + if (options.Narrator) + { + painted = PaintNarrators(painted, options.NarratorColor); + } + + if (options.Music) + { + painted = PaintMusic(painted, options.MusicColor); + } + + return painted == plain ? text : painted; + } + + private static string ApplyColor(string color, string text) => string.Format(WriteFormat, color, text.Trim()); + + private static string PaintMoods(string text, string color) + { + text = PaintMoods(text, color, '(', ')'); + return PaintMoods(text, color, '[', ']'); + } + + private static string PaintMoods(string text, string color, char openBracket, char closeBracket) + { + var idx = text.IndexOf(openBracket); + while (idx >= 0) + { + var endIdx = text.IndexOf(closeBracket, idx + 1); + + // mood not closed properly + if (endIdx < idx + 1) + { + break; + } + + var mood = text.Substring(idx, endIdx - idx + 1); + var coloredMood = ApplyColor(color, mood); + text = text.Remove(idx, mood.Length).Insert(idx, coloredMood); + idx = text.IndexOf(openBracket, idx + coloredMood.Length); + } + + return text; + } + + private static string PaintNarrators(string text, string color) + { + if (!text.Contains(':')) + { + return text; + } + + var newLine = text.Contains("\r\n") ? "\r\n" : "\n"; + var lines = text.Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var lineNoTags = AnyTagRegex().Replace(line, string.Empty).TrimEnd('"', '\\').TrimEnd(); + if (!ShouldColorNarrator(lineNoTags, isFirstLine: i == 0)) + { + continue; + } + + //- MAN: Baby, I put it right over there. + var colonIdx = IndexOutsideTags(line, ':'); + var start = FirstWordCharOutsideTags(line, colonIdx); + var end = LastVisibleCharBefore(line, colonIdx); + if (start < 0 || end < start) + { + continue; + } + + var narrator = line.Substring(start, end - start + 1); + lines[i] = line.Substring(0, start) + ApplyColor(color, narrator) + line.Substring(end + 1); + } + + return string.Join(newLine, lines); + } + + private static bool ShouldColorNarrator(string textNoTags, bool isFirstLine) + { + var colonIdx = textNoTags.IndexOf(':'); + + // too short + if (colonIdx <= 1) + { + return false; + } + + // only "Name: text" or "Name:" at the end of the line - not "Re:start" + if (colonIdx + 1 < textNoTags.Length && !char.IsWhiteSpace(textNoTags[colonIdx + 1])) + { + return false; + } + + // (foobar) foobar: hello world! + var pre = textNoTags.Substring(0, colonIdx).TrimStart(); + if (pre.IndexOfAny(new[] { ')', ']' }) >= 0) + { + return false; + } + + // ':' as the last character is only a narrator on the first line + if (!isFirstLine && colonIdx + 1 == textNoTags.Length) + { + return false; + } + + // e.g. "Corrected by:", "https:" + return !NarratorIgnoreRegex().IsMatch(pre); + } + + private static string PaintMusic(string text, string color) + { + // skip any formatting tag e.g: html or assa + var i = 0; + while (i < text.Length && (text[i] == '<' || text[i] == '{')) + { + var close = text.IndexOf(text[i] == '<' ? '>' : '}', i + 1); + if (close < 0) + { + return text; + } + + i = close + 1; + } + + // the whole text already has a color (e.g. both mood and music in the same text) + var isMusic = i < text.Length && (text[i] == '♪' || text[i] == '♫'); + return isMusic && !text.StartsWith("The last character before that is not a tag or a space, so closing tags stay outside the color. + private static int LastVisibleCharBefore(string text, int index) + { + var i = index - 1; + while (i >= 0) + { + if (text[i] == '>' || text[i] == '}') + { + var open = text.LastIndexOf(text[i] == '>' ? '<' : '{', i); + if (open < 0) + { + return i; + } + + i = open - 1; + continue; + } + + if (!char.IsWhiteSpace(text[i])) + { + return i; + } + + i--; + } + + return -1; + } + + private static bool TrySkipTag(string text, ref int i) + { + if (text[i] != '<' && text[i] != '{') + { + return false; + } + + var close = text.IndexOf(text[i] == '<' ? '>' : '}', i + 1); + if (close < 0) + { + return false; + } + + i = close; + return true; + } +} diff --git a/se5/HIColorer/MainViewModel.cs b/se5/HIColorer/MainViewModel.cs new file mode 100644 index 00000000..550435be --- /dev/null +++ b/se5/HIColorer/MainViewModel.cs @@ -0,0 +1,381 @@ +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.Shared; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text.Json; + +namespace SubtitleEdit.Plugins.HIColorer; + +public sealed partial class MainViewModel : ObservableObject +{ + private const int SettingsVersion = 1; + + private readonly PluginRequest _request; + private readonly bool _legacyRequest; + private readonly List _paragraphs; + private readonly long _videoOffsetMs; + private readonly bool _isConfigure; + private bool _loading; + + [ObservableProperty] private ColorMode _mode = ColorMode.Color; + [ObservableProperty] private bool _narrator = true; + [ObservableProperty] private Color _narratorColor = Color.Parse(ColorOptions.DefaultNarratorColor); + [ObservableProperty] private bool _moods = true; + [ObservableProperty] private Color _moodsColor = Color.Parse(ColorOptions.DefaultMoodsColor); + [ObservableProperty] private bool _music = true; + [ObservableProperty] private Color _musicColor = Color.Parse(ColorOptions.DefaultMusicColor); + [ObservableProperty] private bool _showRendered = true; + [ObservableProperty] private bool _selectedLinesOnly; + [ObservableProperty] private int _selectedCount; + + public MainViewModel(PluginRequest request) + { + _request = request; + _isConfigure = request.RequestType == PluginRequestType.Configure; + _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; + _legacyRequest = request.Subtitle.Paragraphs == null; + _paragraphs = _legacyRequest + ? SubRipParser.Parse(request.Subtitle.SubRip) + .Select(b => new PluginParagraph { StartMs = b.StartMs, EndMs = b.EndMs, Text = b.Text }).ToList() + : request.Subtitle.Paragraphs!.Select(p => p.Clone()).ToList(); + SelectedIndices = request.SelectedIndices.Where(i => i >= 0 && i < _paragraphs.Count).Distinct().Order().ToList(); + + _loading = true; + LoadSettings(); + _loading = false; + Analyze(); + } + + public ObservableCollection Changes { get; } = new(); + public IReadOnlyList SelectedIndices { get; } + + public ColorOptions Options => new() + { + Narrator = Narrator, + NarratorColor = ToHex(NarratorColor), + Moods = Moods, + MoodsColor = ToHex(MoodsColor), + Music = Music, + MusicColor = ToHex(MusicColor), + }; + + public bool IsColorMode + { + get => Mode == ColorMode.Color; + set + { + if (value) + { + Mode = ColorMode.Color; + } + } + } + + public bool IsRemoveMode + { + get => Mode == ColorMode.Remove; + set + { + if (value) + { + Mode = ColorMode.Remove; + } + } + } + + public bool ShowTags + { + get => !ShowRendered; + set => ShowRendered = !value; + } + + public string SubtitleInfo + { + get + { + if (_isConfigure) + { + return "Settings for Batch convert"; + } + + var name = string.IsNullOrEmpty(_request.Subtitle.FileName) ? "Untitled" : Path.GetFileName(_request.Subtitle.FileName); + var format = string.IsNullOrEmpty(_request.Subtitle.Format) ? string.Empty : $" · {_request.Subtitle.Format}"; + return $"{name}{format} · {_paragraphs.Count:N0} lines"; + } + } + + /// Opened from Batch convert's Configure button: only the options matter, there is no subtitle. + public bool IsConfigure => _isConfigure; + public bool IsNotConfigure => !_isConfigure; + + public bool HasSelectedLines => SelectedIndices.Count > 0; + public string SelectedLinesText => SelectedIndices.Count == 1 ? "Selected line (1)" : $"Selected lines ({SelectedIndices.Count:N0})"; + public bool AllLines { get => !SelectedLinesOnly; set => SelectedLinesOnly = !value; } + public int ChangeCount => Changes.Count; + public bool HasChanges => Changes.Count > 0; + public bool IsClean => Changes.Count == 0 && !_isConfigure; + public bool CanApply => SelectedCount > 0 || _isConfigure; + + public string CleanText => Mode == ColorMode.Remove + ? "No line has a tag." + : !Narrator && !Moods && !Music + ? "Turn on narrators, moods or music to color anything." + : "All hearing-impaired text already has these colors."; + + public string StatusText => _isConfigure + ? "Used for every file in Batch convert" + : Changes.Count == 0 + ? $"{LinesInScope:N0} lines checked" + : $"{SelectedCount:N0} of {Changes.Count:N0} lines selected"; + + private int LinesInScope => SelectedLinesOnly && HasSelectedLines ? SelectedIndices.Count : _paragraphs.Count; + + partial void OnModeChanged(ColorMode value) + { + OnPropertyChanged(nameof(IsColorMode)); + OnPropertyChanged(nameof(IsRemoveMode)); + OnPropertyChanged(nameof(CleanText)); + Analyze(); + } + + partial void OnNarratorChanged(bool value) => OptionChanged(); + partial void OnNarratorColorChanged(Color value) => OptionChanged(); + partial void OnMoodsChanged(bool value) => OptionChanged(); + partial void OnMoodsColorChanged(Color value) => OptionChanged(); + partial void OnMusicChanged(bool value) => OptionChanged(); + partial void OnMusicColorChanged(Color value) => OptionChanged(); + partial void OnShowRenderedChanged(bool value) => OnPropertyChanged(nameof(ShowTags)); + + private void OptionChanged() + { + OnPropertyChanged(nameof(CleanText)); + Analyze(); + } + + partial void OnSelectedLinesOnlyChanged(bool value) + { + OnPropertyChanged(nameof(AllLines)); + Analyze(); + } + + partial void OnSelectedCountChanged(int value) + { + OnPropertyChanged(nameof(CanApply)); + OnPropertyChanged(nameof(StatusText)); + } + + private IEnumerable IndicesInScope => + SelectedLinesOnly && HasSelectedLines ? SelectedIndices : Enumerable.Range(0, _paragraphs.Count); + + private void Analyze() + { + if (_loading) + { + return; + } + + var options = Options; + var mode = Mode; + Changes.Clear(); + foreach (var index in IndicesInScope) + { + var text = _paragraphs[index].Text ?? string.Empty; + var after = HiColorer.Apply(text, mode, options); + if (after != text) + { + var item = new ChangeItem(index, text, after, FormatTimeRange(_paragraphs[index])); + item.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ChangeItem.IsSelected)) + { + SelectedCount = Changes.Count(c => c.IsSelected); + } + }; + Changes.Add(item); + } + } + + SelectedCount = Changes.Count; + OnPropertyChanged(nameof(ChangeCount)); + OnPropertyChanged(nameof(HasChanges)); + OnPropertyChanged(nameof(IsClean)); + OnPropertyChanged(nameof(StatusText)); + } + + [RelayCommand] + private void SelectAll() => SetSelection(_ => true); + + [RelayCommand] + private void SelectNone() => SetSelection(_ => false); + + [RelayCommand] + private void InvertSelection() => SetSelection(c => !c.IsSelected); + + private void SetSelection(Func selected) + { + foreach (var change in Changes) + { + change.IsSelected = selected(change); + } + } + + /// + /// A "batch" request (Batch convert): color (or uncolor) every line with the saved options, without UI. + /// No changes is "ok" without a subtitle. + /// + public PluginResponse BuildBatchResponse() + { + if (Changes.Count == 0) + { + return new PluginResponse { Status = PluginStatus.Ok, Message = "Hearing impaired colorer: nothing to change." }; + } + + var response = BuildResponse(); + response.Settings = null; + response.SettingsVersion = null; + return response; + } + + /// A "configure" request: hand back the settings only, for the next Batch convert run. + public PluginResponse BuildConfigureResponse() => + new() { Status = PluginStatus.Ok, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + /// The response for OK: the selected lines with their new colors. + public PluginResponse BuildResponse() + { + if (_isConfigure) + { + return BuildConfigureResponse(); + } + + var selected = Changes.Where(c => c.IsSelected).ToList(); + if (selected.Count == 0) + { + return BuildCancelResponse(); + } + + var texts = _paragraphs.Select(p => p.Text ?? string.Empty).ToArray(); + foreach (var change in selected) + { + texts[change.Index] = change.After; + } + + PluginSubtitle subtitle; + if (_legacyRequest) + { + subtitle = new PluginSubtitle + { + Format = "SubRip", + Native = SubRipParser.Serialize(_paragraphs.Select((p, i) => new SrtBlock + { + StartMs = (long)Math.Round(p.StartMs), + EndMs = (long)Math.Round(p.EndMs), + Text = texts[i], + }).ToList()), + }; + } + else + { + subtitle = new PluginSubtitle + { + Format = _request.Subtitle.Format, + Paragraphs = _paragraphs.Select((p, i) => + { + var copy = p.Clone(); + copy.Text = texts[i]; + return copy; + }).ToList(), + }; + } + + return new PluginResponse + { + Status = PluginStatus.Ok, + Message = selected.Count == 1 + ? "Hearing impaired colorer: 1 line changed." + : $"Hearing impaired colorer: {selected.Count:N0} lines changed.", + UndoDescription = "Hearing impaired colorer", + Subtitle = subtitle, + Settings = BuildSettings(), + SettingsVersion = SettingsVersion, + }; + } + + public PluginResponse BuildCancelResponse() => + new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + private JsonElement BuildSettings() + { + var payload = new Dictionary + { + ["mode"] = Mode.ToString(), + ["narrator"] = Narrator, + ["narratorColor"] = ToHex(NarratorColor), + ["moods"] = Moods, + ["moodsColor"] = ToHex(MoodsColor), + ["music"] = Music, + ["musicColor"] = ToHex(MusicColor), + ["showRendered"] = ShowRendered, + ["selectedLinesOnly"] = SelectedLinesOnly, + }; + + // Configure has no lines selected, so keep the scope a normal run saved. + if (_isConfigure && _request.SettingsVersion == SettingsVersion && _request.Settings is { ValueKind: JsonValueKind.Object } stored + && stored.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + payload["selectedLinesOnly"] = selectedOnly.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(payload); + } + + private void LoadSettings() + { + // Lines selected in Subtitle Edit mean "work on these" - default to them. + SelectedLinesOnly = HasSelectedLines; + if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings) + { + return; + } + + if (settings.TryGetProperty("mode", out var mode) && mode.ValueKind == JsonValueKind.String && Enum.TryParse(mode.GetString(), out var modeValue)) + { + Mode = modeValue; + } + + Narrator = ReadBool(settings, "narrator", Narrator); + NarratorColor = ReadColor(settings, "narratorColor", NarratorColor); + Moods = ReadBool(settings, "moods", Moods); + MoodsColor = ReadColor(settings, "moodsColor", MoodsColor); + Music = ReadBool(settings, "music", Music); + MusicColor = ReadColor(settings, "musicColor", MusicColor); + ShowRendered = ReadBool(settings, "showRendered", ShowRendered); + + if (settings.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + SelectedLinesOnly = selectedOnly.GetBoolean() && HasSelectedLines; + } + } + + private static bool ReadBool(JsonElement settings, string name, bool fallback) => + settings.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : fallback; + + private static Color ReadColor(JsonElement settings, string name, Color fallback) => + settings.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String && Color.TryParse(value.GetString(), out var color) + ? color + : fallback; + + /// "#rrggbb", the way Subtitle Edit writes font colors. + public static string ToHex(Color color) => $"#{color.R:x2}{color.G:x2}{color.B:x2}"; + + private string FormatTimeRange(PluginParagraph p) => + $"{FormatTime(p.StartMs + _videoOffsetMs)} → {FormatTime(p.EndMs + _videoOffsetMs)}"; + + private static string FormatTime(double ms) + { + var time = TimeSpan.FromMilliseconds(Math.Max(0, ms)); + return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00},{3:000}", (int)time.TotalHours, time.Minutes, time.Seconds, time.Milliseconds); + } +} diff --git a/se5/HIColorer/MainWindow.axaml b/se5/HIColorer/MainWindow.axaml new file mode 100644 index 00000000..31a3b8ef --- /dev/null +++ b/se5/HIColorer/MainWindow.axaml @@ -0,0 +1,375 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/HIColorer/MainWindow.axaml.cs b/se5/HIColorer/MainWindow.axaml.cs new file mode 100644 index 00000000..6bf13f3d --- /dev/null +++ b/se5/HIColorer/MainWindow.axaml.cs @@ -0,0 +1,58 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.HIColorer; + +public partial class MainWindow : Window +{ + private readonly MainViewModel _vm; + + public MainWindow() : this(new PluginRequest()) { } + + public MainWindow(PluginRequest request) + { + AvaloniaXamlLoader.Load(this); + _vm = new MainViewModel(request); + DataContext = _vm; + App.Response = _vm.BuildCancelResponse(); + KeyDown += OnKeyDown; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + this.BringToForeground(); + } + + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + OnCancel(sender, e); + } + } + + private void OnCancel(object? sender, RoutedEventArgs e) + { + App.Response = _vm.BuildCancelResponse(); + Close(); + } + + private void OnOk(object? sender, RoutedEventArgs e) + { + try + { + App.Response = _vm.BuildResponse(); + } + catch (Exception exception) + { + App.Response = new PluginResponse { Status = PluginStatus.Error, Message = "Hearing impaired colorer failed: " + exception.Message }; + } + + Close(); + } +} diff --git a/se5/HIColorer/Program.cs b/se5/HIColorer/Program.cs new file mode 100644 index 00000000..92b274ee --- /dev/null +++ b/se5/HIColorer/Program.cs @@ -0,0 +1,16 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.Shared; +using System; + +namespace SubtitleEdit.Plugins.HIColorer; + +public static class Program +{ + [STAThread] + public static int Main(string[] args) + { + IconProvider.Current.Register(); + return PluginBootstrap.Run(args, request => new MainViewModel(request).BuildBatchResponse()); + } +} diff --git a/se5/HIColorer/README.md b/se5/HIColorer/README.md new file mode 100644 index 00000000..ca2a5352 --- /dev/null +++ b/se5/HIColorer/README.md @@ -0,0 +1,62 @@ +# Hearing impaired colorer (Subtitle Edit 5 plugin) + +Colors hearing-impaired text with `` tags: + +| Before | After | +|---|---| +| `- MAN: Baby.` | `- MAN: Baby.` | +| `(door slams) Hi.` | `(door slams) Hi.` | +| `♪ La la la ♪` | `♪ La la la ♪` | + +It is the SE5 port of the SE4 *HIColorer* plugin by ivandrofly. + +Built on Avalonia and the shared [`Plugin-Shared`](../Plugin-Shared/) library. + +## What gets colored + +- **Narrators**: the speaker label before a colon, on each line. These are not narrators: + - times (`12:30`) + - links + - credits (`Subtitles by:`) + - labels that come after a mood (`(laughs) John:`) +- **Moods**: everything in `( )` or `[ ]`, brackets included. +- **Music**: a whole subtitle that starts with ♪ or ♫. + +Each has its own color and can be turned off. + +On a line that gets colored, old `` tags are replaced, so running the plugin +again with new colors recolors the line. Lines with nothing to color keep their tags. + +**Remove colors** removes every `` tag and keeps the text, italics and other tags. + +## UI + +- *Add colors* / *Remove colors*, plus a color picker for narrators, moods and music. +- *Colors* shows the preview as it will look; *Tags* shows the raw tags. +- Every changed line is a checkable before/after card. *OK* applies the checked + lines as one "Hearing impaired colorer" undo step. +- *All lines* / *Selected lines*: lines selected in Subtitle Edit are the default scope. +- Colors and options are remembered between runs. + +Works on the lines exactly as Subtitle Edit holds them, so the styles, actors and +other fields of formats like ASSA are kept. On older SE versions it falls back to SubRip. + +## Batch convert + +Hearing impaired colorer supports Subtitle Edit's **Batch convert** (Tools → Batch convert → Run plugin). + +- **Configure...** opens the window without a subtitle, to pick the mode and colors. +- During the batch, every line of each file is changed without showing the window. + +## Changes from the SE4 plugin + +- The *Music* checkbox now turns music on and off. Before, the narrator checkbox controlled it. +- Colors are remembered. Before, they were never saved and started as black. +- Font tags are only replaced on lines that get colored. Before, they were removed from every line. +- A narrator inside `...` keeps valid tag nesting. +- Names like *Bobby* are no longer skipped because they contain "by". +- There is a before/after preview, and each line can be checked or unchecked. + +## Build + +See `.github/workflows/hi-colorer.yml`. diff --git a/se5/HIColorer/SubtitleTextBlock.cs b/se5/HIColorer/SubtitleTextBlock.cs new file mode 100644 index 00000000..ed70d746 --- /dev/null +++ b/se5/HIColorer/SubtitleTextBlock.cs @@ -0,0 +1,221 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using System.Text; +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.HIColorer; + +/// +/// Shows one side of a . With off the raw text is shown with +/// tags colored and changed text tinted; with it on the tags are hidden and italic text is drawn in italics. +/// +public sealed class SubtitleTextBlock : TextBlock +{ + public static readonly StyledProperty?> SegmentsProperty = + AvaloniaProperty.Register?>(nameof(Segments)); + + public static readonly StyledProperty RenderedProperty = + AvaloniaProperty.Register(nameof(Rendered)); + + private static readonly IBrush RemovedBrush = new SolidColorBrush(Color.Parse("#55E5484D")); + private static readonly IBrush AddedBrush = new SolidColorBrush(Color.Parse("#552FA36B")); + private static readonly IBrush TagBrush = new SolidColorBrush(Color.Parse("#A855F7")); + private static readonly IBrush ItalicBrush = new SolidColorBrush(Color.Parse("#22EA580C")); + + static SubtitleTextBlock() + { + SegmentsProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + RenderedProperty.Changed.AddClassHandler((block, _) => block.Rebuild()); + } + + public IReadOnlyList? Segments + { + get => GetValue(SegmentsProperty); + set => SetValue(SegmentsProperty, value); + } + + public bool Rendered + { + get => GetValue(RenderedProperty); + set => SetValue(RenderedProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + private void Rebuild() + { + var segments = Segments ?? Array.Empty(); + Inlines = Rendered ? BuildRendered(string.Concat(segments.Select(s => s.Text))) : BuildRaw(segments); + } + + /// Raw text: tags in the tag color, removed/added text on a tint. + private static InlineCollection BuildRaw(IReadOnlyList segments) + { + var inlines = new InlineCollection(); + var inTag = false; + foreach (var segment in segments) + { + var background = segment.Kind switch + { + DiffKind.Removed => RemovedBrush, + DiffKind.Added => AddedBrush, + _ => null, + }; + + var run = new StringBuilder(); + var runIsTag = inTag; + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) { Background = background }; + if (runIsTag) + { + inline.Foreground = TagBrush; + inline.FontWeight = FontWeight.SemiBold; + } + + inlines.Add(inline); + run.Clear(); + } + } + + foreach (var c in segment.Text) + { + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + if (background != null) + { + // A changed line break would otherwise be invisible. + inlines.Add(new Run("↵") { Background = background, Foreground = TagBrush }); + } + + inlines.Add(new LineBreak()); + continue; + } + + var isTag = inTag || c == '<'; + if (isTag != runIsTag) + { + Flush(); + runIsTag = isTag; + } + + run.Append(c); + if (c == '<') + { + inTag = true; + } + else if (c == '>') + { + inTag = false; + Flush(); + runIsTag = false; + } + } + + Flush(); + } + + return inlines; + } + + private static readonly Regex FontColorRegex = new(@"color\s*=\s*[""']?([^""'\s>]+)", RegexOptions.Compiled); + + /// Rendered text: tags hidden, <i> drawn in italics on a soft tint, <b>/<u> and <font color> applied too. + private static InlineCollection BuildRendered(string text) + { + var inlines = new InlineCollection(); + var italic = 0; + var bold = 0; + var underline = 0; + var colors = new Stack(); + var run = new StringBuilder(); + void Flush() + { + if (run.Length > 0) + { + var inline = new Run(run.ToString()) + { + FontStyle = italic > 0 ? FontStyle.Italic : FontStyle.Normal, + FontWeight = bold > 0 ? FontWeight.Bold : FontWeight.Normal, + }; + if (underline > 0) + { + inline.TextDecorations = Avalonia.Media.TextDecorations.Underline; + } + + if (italic > 0) + { + inline.Background = ItalicBrush; + } + + if (colors.Count > 0 && colors.Peek() is { } color) + { + inline.Foreground = color; + } + + inlines.Add(inline); + run.Clear(); + } + } + + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == '\r') + { + continue; + } + + if (c == '\n') + { + Flush(); + inlines.Add(new LineBreak()); + continue; + } + + var end = c is '<' or '{' ? text.IndexOf(c == '<' ? '>' : '}', i) : -1; + if (end < 0) + { + run.Append(c); + continue; + } + + Flush(); + var tag = text.Substring(i, end - i + 1).ToLowerInvariant(); + i = end; + switch (tag) + { + case "": italic++; break; + case "": italic = Math.Max(0, italic - 1); break; + case "": bold++; break; + case "": bold = Math.Max(0, bold - 1); break; + case "": underline++; break; + case "": underline = Math.Max(0, underline - 1); break; + case "": colors.TryPop(out _); break; + default: + if (tag.StartsWith(" 0 ? colors.Peek() : null); + } + + break; + } + } + + Flush(); + return inlines; + } +} diff --git a/se5/HIColorer/TextDiff.cs b/se5/HIColorer/TextDiff.cs new file mode 100644 index 00000000..40573190 --- /dev/null +++ b/se5/HIColorer/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.HIColorer; + +public enum DiffKind +{ + Same, + Removed, + Added, +} + +public sealed record DiffSegment(string Text, DiffKind Kind); + +/// Character diff (LCS) used to highlight what a fix changed. +public static class TextDiff +{ + private const int MaxCells = 1_000_000; + + /// Returns the segments of the old text (same/removed) and of the new text (same/added). + public static (List Before, List After) Compute(string before, string after) + { + // Same number of lines: diff line by line, so line breaks never pair up with the wrong line. + var beforeLines = before.Split('\n'); + var afterLines = after.Split('\n'); + if (beforeLines.Length > 1 && beforeLines.Length == afterLines.Length) + { + var beforeSegments = new List(); + var afterSegments = new List(); + for (var i = 0; i < beforeLines.Length; i++) + { + if (i > 0) + { + Add(beforeSegments, "\n", DiffKind.Same); + Add(afterSegments, "\n", DiffKind.Same); + } + + var (b, a) = ComputeLine(beforeLines[i], afterLines[i]); + b.ForEach(segment => Add(beforeSegments, segment.Text, segment.Kind)); + a.ForEach(segment => Add(afterSegments, segment.Text, segment.Kind)); + } + + return (beforeSegments, afterSegments); + } + + return ComputeLine(before, after); + } + + private static (List Before, List After) ComputeLine(string before, string after) + { + var prefix = 0; + while (prefix < before.Length && prefix < after.Length && before[prefix] == after[prefix]) + { + prefix++; + } + + var suffix = 0; + while (suffix < before.Length - prefix && suffix < after.Length - prefix && + before[before.Length - 1 - suffix] == after[after.Length - 1 - suffix]) + { + suffix++; + } + + var a = before.Substring(prefix, before.Length - prefix - suffix); + var b = after.Substring(prefix, after.Length - prefix - suffix); + var beforeSegments = new List(); + var afterSegments = new List(); + Add(beforeSegments, before[..prefix], DiffKind.Same); + Add(afterSegments, after[..prefix], DiffKind.Same); + + if ((long)a.Length * b.Length > MaxCells) + { + Add(beforeSegments, a, DiffKind.Removed); + Add(afterSegments, b, DiffKind.Added); + } + else + { + Lcs(a, b, beforeSegments, afterSegments); + } + + Add(beforeSegments, before[(before.Length - suffix)..], DiffKind.Same); + Add(afterSegments, after[(after.Length - suffix)..], DiffKind.Same); + return (beforeSegments, afterSegments); + } + + private static void Lcs(string a, string b, List beforeSegments, List afterSegments) + { + var lengths = new int[a.Length + 1, b.Length + 1]; + for (var i = a.Length - 1; i >= 0; i--) + { + for (var j = b.Length - 1; j >= 0; j--) + { + lengths[i, j] = a[i] == b[j] + ? lengths[i + 1, j + 1] + 1 + : Math.Max(lengths[i + 1, j], lengths[i, j + 1]); + } + } + + int x = 0, y = 0; + while (x < a.Length && y < b.Length) + { + if (a[x] == b[y]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Same); + Add(afterSegments, b[y].ToString(), DiffKind.Same); + x++; + y++; + } + else if (lengths[x + 1, y] >= lengths[x, y + 1]) + { + Add(beforeSegments, a[x].ToString(), DiffKind.Removed); + x++; + } + else + { + Add(afterSegments, b[y].ToString(), DiffKind.Added); + y++; + } + } + + Add(beforeSegments, a[x..], DiffKind.Removed); + Add(afterSegments, b[y..], DiffKind.Added); + } + + private static void Add(List segments, string text, DiffKind kind) + { + if (text.Length == 0) + { + return; + } + + if (segments.Count > 0 && segments[^1].Kind == kind) + { + segments[^1] = segments[^1] with { Text = segments[^1].Text + text }; + } + else + { + segments.Add(new DiffSegment(text, kind)); + } + } +} diff --git a/se5/HIColorer/app.manifest b/se5/HIColorer/app.manifest new file mode 100644 index 00000000..8f971ea6 --- /dev/null +++ b/se5/HIColorer/app.manifest @@ -0,0 +1,10 @@ + + + + + + true/pm + PerMonitorV2 + + + diff --git a/se5/HIColorer/plugin.json b/se5/HIColorer/plugin.json new file mode 100644 index 00000000..e02d7a9e --- /dev/null +++ b/se5/HIColorer/plugin.json @@ -0,0 +1,13 @@ +{ + "apiVersion": 1, + "name": "Hearing impaired colorer", + "description": "Colors hearing-impaired text with tags: narrator labels (JOHN:), moods in ( ) or [ ], and music lines (♪). Pick a color for each, or remove all font colors. Shows a checkable before/after preview with the colors.", + "version": "1.0.0", + "author": "ivandrofly", + "url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/HIColorer", + "menu": "Tools", + "minSeVersion": "5.0.0", + "batch": true, + "runtime": "dotnet", + "entry": "HIColorer.dll" +} diff --git a/se5/Plugins.slnx b/se5/Plugins.slnx index 9165f456..ce02b19b 100644 --- a/se5/Plugins.slnx +++ b/se5/Plugins.slnx @@ -10,6 +10,7 @@ + @@ -29,6 +30,7 @@ + @@ -49,6 +51,7 @@ + From e45c3dd34181903a8b8aa3e5432a663ba900f7f4 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 17 Oct 2026 12:42:54 +0100 Subject: [PATCH 5/6] Organize the SE5 ports into Views, ViewModels, Controls and Logic folders Moves each plugin's files into folders with matching namespaces and splits files that held several types into one file per type. The window XAML, usings and tests are updated to match. No behavior change. Co-Authored-By: Claude Opus 5.5 (1M context) --- se5/HI2UC.Tests/CasingTests.cs | 3 +- se5/HI2UC.Tests/MainViewModelTests.cs | 3 +- se5/HI2UC/App.axaml.cs | 1 + se5/HI2UC/CasingStrategies.cs | 210 ------------------ se5/HI2UC/{ => Controls}/SubtitleTextBlock.cs | 2 +- .../Controls}/TextDiff.cs | 2 +- se5/HI2UC/HiCasing.cs | 65 ------ se5/HI2UC/{ => Logic}/ChunkReader.cs | 2 +- se5/HI2UC/Logic/HiCasing.cs | 32 +++ se5/HI2UC/Logic/HiOptions.cs | 8 + se5/HI2UC/Logic/HiResult.cs | 29 +++ se5/HI2UC/Logic/HiStyle.cs | 11 + se5/HI2UC/{ => Logic}/MoodCasingConverter.cs | 4 +- .../{ => Logic}/NarratorCasingConverter.cs | 4 +- .../Logic/Strategies/CasingStrategies.cs | 35 +++ .../Logic/Strategies/IConverterStrategy.cs | 8 + .../Logic/Strategies/LowercaseConverter.cs | 13 ++ .../Logic/Strategies/SentenceCaseConverter.cs | 51 +++++ .../Logic/Strategies/TitleWordsConverter.cs | 30 +++ se5/HI2UC/Logic/Strategies/UpperLowercase.cs | 49 ++++ .../Logic/Strategies/UppercaseConverter.cs | 28 +++ se5/HI2UC/{ => Logic}/TextUtils.cs | 2 +- se5/HI2UC/Program.cs | 1 + se5/HI2UC/{ => ViewModels}/ChangeItem.cs | 3 +- se5/HI2UC/{ => ViewModels}/MainViewModel.cs | 9 +- se5/HI2UC/ViewModels/StyleOption.cs | 8 + se5/HI2UC/{ => Views}/MainWindow.axaml | 13 +- se5/HI2UC/{ => Views}/MainWindow.axaml.cs | 3 +- se5/HIColorer.Tests/HiColorerTests.cs | 2 +- se5/HIColorer.Tests/MainViewModelTests.cs | 3 +- se5/HIColorer/App.axaml.cs | 1 + .../{ => Controls}/SubtitleTextBlock.cs | 2 +- .../Controls}/TextDiff.cs | 2 +- se5/HIColorer/Logic/ColorMode.cs | 10 + se5/HIColorer/Logic/ColorOptions.cs | 15 ++ se5/HIColorer/{ => Logic}/HiColorer.cs | 25 +-- se5/HIColorer/Program.cs | 1 + se5/HIColorer/{ => ViewModels}/ChangeItem.cs | 3 +- .../{ => ViewModels}/MainViewModel.cs | 3 +- se5/HIColorer/{ => Views}/MainWindow.axaml | 17 +- se5/HIColorer/{ => Views}/MainWindow.axaml.cs | 3 +- .../LineUnbreakerTests.cs | 2 +- .../MainViewModelTests.cs | 2 +- se5/LinesUnbreaker/App.axaml.cs | 1 + .../{ => Controls}/SubtitleTextBlock.cs | 2 +- .../Controls}/TextDiff.cs | 2 +- se5/LinesUnbreaker/{ => Logic}/Line.cs | 2 +- .../{ => Logic}/LineUnbreaker.cs | 13 +- se5/LinesUnbreaker/Logic/UnbreakOptions.cs | 12 + se5/LinesUnbreaker/Program.cs | 1 + .../{ => ViewModels}/ChangeItem.cs | 4 +- .../{ => ViewModels}/MainViewModel.cs | 3 +- .../{ => Views}/MainWindow.axaml | 13 +- .../{ => Views}/MainWindow.axaml.cs | 3 +- se5/TightHyphen.Tests/HyphenTightenerTests.cs | 2 +- se5/TightHyphen.Tests/MainViewModelTests.cs | 2 +- se5/TightHyphen/App.axaml.cs | 1 + .../{ => Controls}/SubtitleTextBlock.cs | 2 +- .../Controls}/TextDiff.cs | 2 +- .../{ => Logic}/HyphenTightener.cs | 2 +- se5/TightHyphen/Program.cs | 1 + .../{ => ViewModels}/ChangeItem.cs | 3 +- .../{ => ViewModels}/MainViewModel.cs | 3 +- se5/TightHyphen/{ => Views}/MainWindow.axaml | 13 +- .../{ => Views}/MainWindow.axaml.cs | 3 +- 65 files changed, 434 insertions(+), 376 deletions(-) delete mode 100644 se5/HI2UC/CasingStrategies.cs rename se5/HI2UC/{ => Controls}/SubtitleTextBlock.cs (99%) rename se5/{TightHyphen => HI2UC/Controls}/TextDiff.cs (98%) delete mode 100644 se5/HI2UC/HiCasing.cs rename se5/HI2UC/{ => Logic}/ChunkReader.cs (97%) create mode 100644 se5/HI2UC/Logic/HiCasing.cs create mode 100644 se5/HI2UC/Logic/HiOptions.cs create mode 100644 se5/HI2UC/Logic/HiResult.cs create mode 100644 se5/HI2UC/Logic/HiStyle.cs rename se5/HI2UC/{ => Logic}/MoodCasingConverter.cs (98%) rename se5/HI2UC/{ => Logic}/NarratorCasingConverter.cs (97%) create mode 100644 se5/HI2UC/Logic/Strategies/CasingStrategies.cs create mode 100644 se5/HI2UC/Logic/Strategies/IConverterStrategy.cs create mode 100644 se5/HI2UC/Logic/Strategies/LowercaseConverter.cs create mode 100644 se5/HI2UC/Logic/Strategies/SentenceCaseConverter.cs create mode 100644 se5/HI2UC/Logic/Strategies/TitleWordsConverter.cs create mode 100644 se5/HI2UC/Logic/Strategies/UpperLowercase.cs create mode 100644 se5/HI2UC/Logic/Strategies/UppercaseConverter.cs rename se5/HI2UC/{ => Logic}/TextUtils.cs (97%) rename se5/HI2UC/{ => ViewModels}/ChangeItem.cs (91%) rename se5/HI2UC/{ => ViewModels}/MainViewModel.cs (98%) create mode 100644 se5/HI2UC/ViewModels/StyleOption.cs rename se5/HI2UC/{ => Views}/MainWindow.axaml (97%) rename se5/HI2UC/{ => Views}/MainWindow.axaml.cs (93%) rename se5/HIColorer/{ => Controls}/SubtitleTextBlock.cs (99%) rename se5/{LinesUnbreaker => HIColorer/Controls}/TextDiff.cs (98%) create mode 100644 se5/HIColorer/Logic/ColorMode.cs create mode 100644 se5/HIColorer/Logic/ColorOptions.cs rename se5/HIColorer/{ => Logic}/HiColorer.cs (90%) rename se5/HIColorer/{ => ViewModels}/ChangeItem.cs (89%) rename se5/HIColorer/{ => ViewModels}/MainViewModel.cs (99%) rename se5/HIColorer/{ => Views}/MainWindow.axaml (96%) rename se5/HIColorer/{ => Views}/MainWindow.axaml.cs (93%) rename se5/LinesUnbreaker/{ => Controls}/SubtitleTextBlock.cs (99%) rename se5/{HIColorer => LinesUnbreaker/Controls}/TextDiff.cs (98%) rename se5/LinesUnbreaker/{ => Logic}/Line.cs (95%) rename se5/LinesUnbreaker/{ => Logic}/LineUnbreaker.cs (84%) create mode 100644 se5/LinesUnbreaker/Logic/UnbreakOptions.cs rename se5/LinesUnbreaker/{ => ViewModels}/ChangeItem.cs (86%) rename se5/LinesUnbreaker/{ => ViewModels}/MainViewModel.cs (99%) rename se5/LinesUnbreaker/{ => Views}/MainWindow.axaml (97%) rename se5/LinesUnbreaker/{ => Views}/MainWindow.axaml.cs (92%) rename se5/TightHyphen/{ => Controls}/SubtitleTextBlock.cs (99%) rename se5/{HI2UC => TightHyphen/Controls}/TextDiff.cs (98%) rename se5/TightHyphen/{ => Logic}/HyphenTightener.cs (97%) rename se5/TightHyphen/{ => ViewModels}/ChangeItem.cs (89%) rename se5/TightHyphen/{ => ViewModels}/MainViewModel.cs (98%) rename se5/TightHyphen/{ => Views}/MainWindow.axaml (96%) rename se5/TightHyphen/{ => Views}/MainWindow.axaml.cs (92%) diff --git a/se5/HI2UC.Tests/CasingTests.cs b/se5/HI2UC.Tests/CasingTests.cs index b102b6b4..f9d96f57 100644 --- a/se5/HI2UC.Tests/CasingTests.cs +++ b/se5/HI2UC.Tests/CasingTests.cs @@ -1,4 +1,5 @@ -using SubtitleEdit.Plugins.HI2UC; +using SubtitleEdit.Plugins.HI2UC.Logic; +using SubtitleEdit.Plugins.HI2UC.Logic.Strategies; namespace HI2UC.Tests; diff --git a/se5/HI2UC.Tests/MainViewModelTests.cs b/se5/HI2UC.Tests/MainViewModelTests.cs index cc40f6f4..d7aa5358 100644 --- a/se5/HI2UC.Tests/MainViewModelTests.cs +++ b/se5/HI2UC.Tests/MainViewModelTests.cs @@ -1,4 +1,5 @@ -using SubtitleEdit.Plugins.HI2UC; +using SubtitleEdit.Plugins.HI2UC.Logic; +using SubtitleEdit.Plugins.HI2UC.ViewModels; using SubtitleEdit.Plugins.Shared; using System.Text.Json; diff --git a/se5/HI2UC/App.axaml.cs b/se5/HI2UC/App.axaml.cs index dde3c64f..af4b5f89 100644 --- a/se5/HI2UC/App.axaml.cs +++ b/se5/HI2UC/App.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HI2UC.Views; using SubtitleEdit.Plugins.Shared; namespace SubtitleEdit.Plugins.HI2UC; diff --git a/se5/HI2UC/CasingStrategies.cs b/se5/HI2UC/CasingStrategies.cs deleted file mode 100644 index 366ca531..00000000 --- a/se5/HI2UC/CasingStrategies.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System.Globalization; -using System.Text; - -namespace SubtitleEdit.Plugins.HI2UC; - -/// How the hearing-impaired text is cased. -public enum HiStyle -{ - UpperCase, - UpperLowerCase, - TitleCase, - SentenceCase, - LowerCase, -} - -public interface IConverterStrategy -{ - string Name { get; } - string Example { get; } - string Execute(string input); -} - -public static class CasingStrategies -{ - private static readonly ChunkReader ChunkReader = new(); - - public static IConverterStrategy Create(HiStyle style) => style switch - { - HiStyle.UpperLowerCase => new UpperLowercase(), - HiStyle.TitleCase => new TitleWordsConverter(ChunkReader), - HiStyle.SentenceCase => new SentenceCaseConverter(), - HiStyle.LowerCase => new LowercaseConverter(), - _ => new UppercaseConverter(ChunkReader), - }; - - public static string CapitalizeFirstLetter(this string s, CultureInfo? ci = null) - { - var si = new StringInfo(s); - ci ??= CultureInfo.CurrentCulture; - - if (si.LengthInTextElements > 0) - { - s = si.SubstringByTextElements(0, 1).ToUpper(ci); - } - - if (si.LengthInTextElements > 1) - { - s += si.SubstringByTextElements(1).ToLower(ci); - } - - return s; - } -} - -public class UppercaseConverter : IConverterStrategy -{ - private readonly ChunkReader _chunkReader; - - public UppercaseConverter(ChunkReader chunkReader) => _chunkReader = chunkReader; - - public string Name => "Uppercase"; - public string Example => "Foobar → FOOBAR"; - - public string Execute(string input) - { - var sb = new StringBuilder(); - foreach (var chunk in _chunkReader.Read(input)) - { - var content = input.Substring(chunk.Start, chunk.End - chunk.Start); - sb.Append(chunk.IsTag ? content : content.ToUpper(CultureInfo.CurrentCulture)); - } - - return sb.ToString(); - } - - public override string ToString() => Name; -} - -public class UpperLowercase : IConverterStrategy -{ - public string Name => "Toggle case"; - public string Example => "Foobar → FoObAr"; - - public string Execute(string input) - { - var buffer = new char[input.Length]; - var len = input.Length; - var writeTrack = 0; - var caseState = true; - for (var i = 0; i < len; i++) - { - var ch = input[i]; - // skip tags - if (TextUtils.IsOpenTag(ch)) - { - var closingPair = TextUtils.GetClosingPair(ch); - // look for closing tag - while (ch != closingPair) - { - buffer[writeTrack++] = ch; - if (++i == len) - { - return input; - } - - ch = input[i]; - } - } - - if (!char.IsLetter(ch)) - { - buffer[writeTrack++] = ch; - } - else - { - buffer[writeTrack++] = caseState ? char.ToUpper(ch) : char.ToLower(ch); - caseState = !caseState; - } - } - - return new string(buffer, 0, writeTrack); - } - - public override string ToString() => Name; -} - -public class TitleWordsConverter : IConverterStrategy -{ - private static readonly char[] WordSplitChars = { ' ' }; - private readonly ChunkReader _chunkReader; - - public TitleWordsConverter(ChunkReader chunkReader) => _chunkReader = chunkReader; - - public string Name => "Title case"; - public string Example => "foobar foobar → Foobar Foobar"; - - public string Execute(string input) - { - var sb = new StringBuilder(); - foreach (var chunk in _chunkReader.Read(input)) - { - var content = input.Substring(chunk.Start, chunk.End - chunk.Start); - sb.Append(chunk.IsTag - ? content - : string.Join(" ", content.Split(WordSplitChars).Select(word => word.CapitalizeFirstLetter()))); - } - - return sb.ToString(); - } - - public override string ToString() => Name; -} - -public class SentenceCaseConverter : IConverterStrategy -{ - public string Name => "Sentence case"; - public string Example => "FOOBAR FOOBAR → Foobar foobar"; - - public string Execute(string input) - { - input = input.ToLower(CultureInfo.CurrentCulture); - - // skip tag - var firstLetterIndex = FindFirstLetterIndex(input); - if (firstLetterIndex > 0) - { - // first part can be tag or not - return input.Substring(0, firstLetterIndex) + input.Substring(firstLetterIndex).CapitalizeFirstLetter(); - } - - return input.CapitalizeFirstLetter(); - } - - private static int FindFirstLetterIndex(string input) - { - for (var i = 0; i < input.Length; i++) - { - var ch = input[i]; - if (TextUtils.IsOpenTag(ch)) - { - // skip tag - var closingPairIndex = input.IndexOf(TextUtils.GetClosingPair(ch), i + 1); - if (closingPairIndex < i) - { - return -1; - } - - i = closingPairIndex; - } - else if (char.IsLetter(ch)) - { - return i; - } - } - - return -1; - } - - public override string ToString() => Name; -} - -public class LowercaseConverter : IConverterStrategy -{ - public string Name => "Lowercase"; - public string Example => "Foobar → foobar"; - - public string Execute(string input) => input.ToLower(CultureInfo.CurrentCulture); - - public override string ToString() => Name; -} diff --git a/se5/HI2UC/SubtitleTextBlock.cs b/se5/HI2UC/Controls/SubtitleTextBlock.cs similarity index 99% rename from se5/HI2UC/SubtitleTextBlock.cs rename to se5/HI2UC/Controls/SubtitleTextBlock.cs index 651988dc..353718ea 100644 --- a/se5/HI2UC/SubtitleTextBlock.cs +++ b/se5/HI2UC/Controls/SubtitleTextBlock.cs @@ -4,7 +4,7 @@ using Avalonia.Media; using System.Text; -namespace SubtitleEdit.Plugins.HI2UC; +namespace SubtitleEdit.Plugins.HI2UC.Controls; /// /// Shows one side of a . With off the raw text is shown with diff --git a/se5/TightHyphen/TextDiff.cs b/se5/HI2UC/Controls/TextDiff.cs similarity index 98% rename from se5/TightHyphen/TextDiff.cs rename to se5/HI2UC/Controls/TextDiff.cs index 4fd3c28c..dbc1a53e 100644 --- a/se5/TightHyphen/TextDiff.cs +++ b/se5/HI2UC/Controls/TextDiff.cs @@ -1,4 +1,4 @@ -namespace SubtitleEdit.Plugins.TightHyphen; +namespace SubtitleEdit.Plugins.HI2UC.Controls; public enum DiffKind { diff --git a/se5/HI2UC/HiCasing.cs b/se5/HI2UC/HiCasing.cs deleted file mode 100644 index 5e3b12c7..00000000 --- a/se5/HI2UC/HiCasing.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace SubtitleEdit.Plugins.HI2UC; - -public sealed class HiOptions -{ - public HiStyle Style { get; set; } = HiStyle.UpperCase; - public bool Moods { get; set; } = true; - public bool Narrator { get; set; } = true; -} - -/// The result for one subtitle text; flags mismatched ()/[] that may need a manual look. -public sealed record HiResult(string After, bool MoodsChanged, bool NarratorChanged, bool Unbalanced) -{ - public string Comment - { - get - { - var parts = new List(); - if (MoodsChanged) - { - parts.Add("Moods"); - } - - if (NarratorChanged) - { - parts.Add("Narrator"); - } - - if (Unbalanced) - { - parts.Add("Unbalanced ( ) or [ ] - check this line"); - } - - return string.Join(" · ", parts); - } - } -} - -public static class HiCasing -{ - /// Cases moods first, then the narrator label, on the same text. - public static HiResult Convert(string text, HiOptions options) - { - var strategy = CasingStrategies.Create(options.Style); - var after = text; - - var moodsChanged = false; - if (options.Moods) - { - var moods = new MoodCasingConverter(strategy).Convert(after); - moodsChanged = moods != after; - after = moods; - } - - var narratorChanged = false; - if (options.Narrator) - { - var narrator = new NarratorCasingConverter(strategy).Convert(after); - narratorChanged = narrator != after; - after = narrator; - } - - var unbalanced = options.Moods && !MoodCasingConverter.HasBalancedParentheses(text); - return new HiResult(after, moodsChanged, narratorChanged, unbalanced); - } -} diff --git a/se5/HI2UC/ChunkReader.cs b/se5/HI2UC/Logic/ChunkReader.cs similarity index 97% rename from se5/HI2UC/ChunkReader.cs rename to se5/HI2UC/Logic/ChunkReader.cs index 9a3cea37..cf5f9a3b 100644 --- a/se5/HI2UC/ChunkReader.cs +++ b/se5/HI2UC/Logic/ChunkReader.cs @@ -1,4 +1,4 @@ -namespace SubtitleEdit.Plugins.HI2UC; +namespace SubtitleEdit.Plugins.HI2UC.Logic; /// Splits text into word and tag chunks, so casing never touches a tag like <font color="...">. public class ChunkReader diff --git a/se5/HI2UC/Logic/HiCasing.cs b/se5/HI2UC/Logic/HiCasing.cs new file mode 100644 index 00000000..c1149e25 --- /dev/null +++ b/se5/HI2UC/Logic/HiCasing.cs @@ -0,0 +1,32 @@ +using SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +namespace SubtitleEdit.Plugins.HI2UC.Logic; + +public static class HiCasing +{ + /// Cases moods first, then the narrator label, on the same text. + public static HiResult Convert(string text, HiOptions options) + { + var strategy = CasingStrategies.Create(options.Style); + var after = text; + + var moodsChanged = false; + if (options.Moods) + { + var moods = new MoodCasingConverter(strategy).Convert(after); + moodsChanged = moods != after; + after = moods; + } + + var narratorChanged = false; + if (options.Narrator) + { + var narrator = new NarratorCasingConverter(strategy).Convert(after); + narratorChanged = narrator != after; + after = narrator; + } + + var unbalanced = options.Moods && !MoodCasingConverter.HasBalancedParentheses(text); + return new HiResult(after, moodsChanged, narratorChanged, unbalanced); + } +} diff --git a/se5/HI2UC/Logic/HiOptions.cs b/se5/HI2UC/Logic/HiOptions.cs new file mode 100644 index 00000000..bc891be2 --- /dev/null +++ b/se5/HI2UC/Logic/HiOptions.cs @@ -0,0 +1,8 @@ +namespace SubtitleEdit.Plugins.HI2UC.Logic; + +public sealed class HiOptions +{ + public HiStyle Style { get; set; } = HiStyle.UpperCase; + public bool Moods { get; set; } = true; + public bool Narrator { get; set; } = true; +} diff --git a/se5/HI2UC/Logic/HiResult.cs b/se5/HI2UC/Logic/HiResult.cs new file mode 100644 index 00000000..039cc78c --- /dev/null +++ b/se5/HI2UC/Logic/HiResult.cs @@ -0,0 +1,29 @@ +namespace SubtitleEdit.Plugins.HI2UC.Logic; + +/// The result for one subtitle text; flags mismatched ()/[] that may need a manual look. +public sealed record HiResult(string After, bool MoodsChanged, bool NarratorChanged, bool Unbalanced) +{ + public string Comment + { + get + { + var parts = new List(); + if (MoodsChanged) + { + parts.Add("Moods"); + } + + if (NarratorChanged) + { + parts.Add("Narrator"); + } + + if (Unbalanced) + { + parts.Add("Unbalanced ( ) or [ ] - check this line"); + } + + return string.Join(" · ", parts); + } + } +} diff --git a/se5/HI2UC/Logic/HiStyle.cs b/se5/HI2UC/Logic/HiStyle.cs new file mode 100644 index 00000000..5d493fc5 --- /dev/null +++ b/se5/HI2UC/Logic/HiStyle.cs @@ -0,0 +1,11 @@ +namespace SubtitleEdit.Plugins.HI2UC.Logic; + +/// How the hearing-impaired text is cased. +public enum HiStyle +{ + UpperCase, + UpperLowerCase, + TitleCase, + SentenceCase, + LowerCase, +} diff --git a/se5/HI2UC/MoodCasingConverter.cs b/se5/HI2UC/Logic/MoodCasingConverter.cs similarity index 98% rename from se5/HI2UC/MoodCasingConverter.cs rename to se5/HI2UC/Logic/MoodCasingConverter.cs index a38ba355..5f8d84fb 100644 --- a/se5/HI2UC/MoodCasingConverter.cs +++ b/se5/HI2UC/Logic/MoodCasingConverter.cs @@ -1,4 +1,6 @@ -namespace SubtitleEdit.Plugins.HI2UC; +using SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +namespace SubtitleEdit.Plugins.HI2UC.Logic; /// Cases the text inside (...) and [...] - sounds and moods like "(door slams)". public class MoodCasingConverter diff --git a/se5/HI2UC/NarratorCasingConverter.cs b/se5/HI2UC/Logic/NarratorCasingConverter.cs similarity index 97% rename from se5/HI2UC/NarratorCasingConverter.cs rename to se5/HI2UC/Logic/NarratorCasingConverter.cs index 849b56c9..ccd95778 100644 --- a/se5/HI2UC/NarratorCasingConverter.cs +++ b/se5/HI2UC/Logic/NarratorCasingConverter.cs @@ -1,4 +1,6 @@ -namespace SubtitleEdit.Plugins.HI2UC; +using SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +namespace SubtitleEdit.Plugins.HI2UC.Logic; /// Cases the speaker label before a colon - "John: Hi." becomes "JOHN: Hi.". public class NarratorCasingConverter diff --git a/se5/HI2UC/Logic/Strategies/CasingStrategies.cs b/se5/HI2UC/Logic/Strategies/CasingStrategies.cs new file mode 100644 index 00000000..ffb0ca15 --- /dev/null +++ b/se5/HI2UC/Logic/Strategies/CasingStrategies.cs @@ -0,0 +1,35 @@ +using System.Globalization; + +namespace SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +public static class CasingStrategies +{ + private static readonly ChunkReader ChunkReader = new(); + + public static IConverterStrategy Create(HiStyle style) => style switch + { + HiStyle.UpperLowerCase => new UpperLowercase(), + HiStyle.TitleCase => new TitleWordsConverter(ChunkReader), + HiStyle.SentenceCase => new SentenceCaseConverter(), + HiStyle.LowerCase => new LowercaseConverter(), + _ => new UppercaseConverter(ChunkReader), + }; + + public static string CapitalizeFirstLetter(this string s, CultureInfo? ci = null) + { + var si = new StringInfo(s); + ci ??= CultureInfo.CurrentCulture; + + if (si.LengthInTextElements > 0) + { + s = si.SubstringByTextElements(0, 1).ToUpper(ci); + } + + if (si.LengthInTextElements > 1) + { + s += si.SubstringByTextElements(1).ToLower(ci); + } + + return s; + } +} diff --git a/se5/HI2UC/Logic/Strategies/IConverterStrategy.cs b/se5/HI2UC/Logic/Strategies/IConverterStrategy.cs new file mode 100644 index 00000000..c3ec57b3 --- /dev/null +++ b/se5/HI2UC/Logic/Strategies/IConverterStrategy.cs @@ -0,0 +1,8 @@ +namespace SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +public interface IConverterStrategy +{ + string Name { get; } + string Example { get; } + string Execute(string input); +} diff --git a/se5/HI2UC/Logic/Strategies/LowercaseConverter.cs b/se5/HI2UC/Logic/Strategies/LowercaseConverter.cs new file mode 100644 index 00000000..84640c8a --- /dev/null +++ b/se5/HI2UC/Logic/Strategies/LowercaseConverter.cs @@ -0,0 +1,13 @@ +using System.Globalization; + +namespace SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +public class LowercaseConverter : IConverterStrategy +{ + public string Name => "Lowercase"; + public string Example => "Foobar → foobar"; + + public string Execute(string input) => input.ToLower(CultureInfo.CurrentCulture); + + public override string ToString() => Name; +} diff --git a/se5/HI2UC/Logic/Strategies/SentenceCaseConverter.cs b/se5/HI2UC/Logic/Strategies/SentenceCaseConverter.cs new file mode 100644 index 00000000..18fc9fd8 --- /dev/null +++ b/se5/HI2UC/Logic/Strategies/SentenceCaseConverter.cs @@ -0,0 +1,51 @@ +using System.Globalization; + +namespace SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +public class SentenceCaseConverter : IConverterStrategy +{ + public string Name => "Sentence case"; + public string Example => "FOOBAR FOOBAR → Foobar foobar"; + + public string Execute(string input) + { + input = input.ToLower(CultureInfo.CurrentCulture); + + // skip tag + var firstLetterIndex = FindFirstLetterIndex(input); + if (firstLetterIndex > 0) + { + // first part can be tag or not + return input.Substring(0, firstLetterIndex) + input.Substring(firstLetterIndex).CapitalizeFirstLetter(); + } + + return input.CapitalizeFirstLetter(); + } + + private static int FindFirstLetterIndex(string input) + { + for (var i = 0; i < input.Length; i++) + { + var ch = input[i]; + if (TextUtils.IsOpenTag(ch)) + { + // skip tag + var closingPairIndex = input.IndexOf(TextUtils.GetClosingPair(ch), i + 1); + if (closingPairIndex < i) + { + return -1; + } + + i = closingPairIndex; + } + else if (char.IsLetter(ch)) + { + return i; + } + } + + return -1; + } + + public override string ToString() => Name; +} diff --git a/se5/HI2UC/Logic/Strategies/TitleWordsConverter.cs b/se5/HI2UC/Logic/Strategies/TitleWordsConverter.cs new file mode 100644 index 00000000..0ea970a2 --- /dev/null +++ b/se5/HI2UC/Logic/Strategies/TitleWordsConverter.cs @@ -0,0 +1,30 @@ +using System.Text; + +namespace SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +public class TitleWordsConverter : IConverterStrategy +{ + private static readonly char[] WordSplitChars = { ' ' }; + private readonly ChunkReader _chunkReader; + + public TitleWordsConverter(ChunkReader chunkReader) => _chunkReader = chunkReader; + + public string Name => "Title case"; + public string Example => "foobar foobar → Foobar Foobar"; + + public string Execute(string input) + { + var sb = new StringBuilder(); + foreach (var chunk in _chunkReader.Read(input)) + { + var content = input.Substring(chunk.Start, chunk.End - chunk.Start); + sb.Append(chunk.IsTag + ? content + : string.Join(" ", content.Split(WordSplitChars).Select(word => word.CapitalizeFirstLetter()))); + } + + return sb.ToString(); + } + + public override string ToString() => Name; +} diff --git a/se5/HI2UC/Logic/Strategies/UpperLowercase.cs b/se5/HI2UC/Logic/Strategies/UpperLowercase.cs new file mode 100644 index 00000000..9323552b --- /dev/null +++ b/se5/HI2UC/Logic/Strategies/UpperLowercase.cs @@ -0,0 +1,49 @@ +namespace SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +public class UpperLowercase : IConverterStrategy +{ + public string Name => "Toggle case"; + public string Example => "Foobar → FoObAr"; + + public string Execute(string input) + { + var buffer = new char[input.Length]; + var len = input.Length; + var writeTrack = 0; + var caseState = true; + for (var i = 0; i < len; i++) + { + var ch = input[i]; + // skip tags + if (TextUtils.IsOpenTag(ch)) + { + var closingPair = TextUtils.GetClosingPair(ch); + // look for closing tag + while (ch != closingPair) + { + buffer[writeTrack++] = ch; + if (++i == len) + { + return input; + } + + ch = input[i]; + } + } + + if (!char.IsLetter(ch)) + { + buffer[writeTrack++] = ch; + } + else + { + buffer[writeTrack++] = caseState ? char.ToUpper(ch) : char.ToLower(ch); + caseState = !caseState; + } + } + + return new string(buffer, 0, writeTrack); + } + + public override string ToString() => Name; +} diff --git a/se5/HI2UC/Logic/Strategies/UppercaseConverter.cs b/se5/HI2UC/Logic/Strategies/UppercaseConverter.cs new file mode 100644 index 00000000..17de84b8 --- /dev/null +++ b/se5/HI2UC/Logic/Strategies/UppercaseConverter.cs @@ -0,0 +1,28 @@ +using System.Globalization; +using System.Text; + +namespace SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +public class UppercaseConverter : IConverterStrategy +{ + private readonly ChunkReader _chunkReader; + + public UppercaseConverter(ChunkReader chunkReader) => _chunkReader = chunkReader; + + public string Name => "Uppercase"; + public string Example => "Foobar → FOOBAR"; + + public string Execute(string input) + { + var sb = new StringBuilder(); + foreach (var chunk in _chunkReader.Read(input)) + { + var content = input.Substring(chunk.Start, chunk.End - chunk.Start); + sb.Append(chunk.IsTag ? content : content.ToUpper(CultureInfo.CurrentCulture)); + } + + return sb.ToString(); + } + + public override string ToString() => Name; +} diff --git a/se5/HI2UC/TextUtils.cs b/se5/HI2UC/Logic/TextUtils.cs similarity index 97% rename from se5/HI2UC/TextUtils.cs rename to se5/HI2UC/Logic/TextUtils.cs index aff1f6b1..4a2ed1f1 100644 --- a/se5/HI2UC/TextUtils.cs +++ b/se5/HI2UC/Logic/TextUtils.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace SubtitleEdit.Plugins.HI2UC; +namespace SubtitleEdit.Plugins.HI2UC.Logic; /// Text helpers carried over from the SE4 plugin's shared code (HtmlUtils, StringUtils, StringExtensions). internal static class TextUtils diff --git a/se5/HI2UC/Program.cs b/se5/HI2UC/Program.cs index 568fdcf4..9eb500e9 100644 --- a/se5/HI2UC/Program.cs +++ b/se5/HI2UC/Program.cs @@ -1,5 +1,6 @@ using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.HI2UC.ViewModels; using SubtitleEdit.Plugins.Shared; using System; diff --git a/se5/HI2UC/ChangeItem.cs b/se5/HI2UC/ViewModels/ChangeItem.cs similarity index 91% rename from se5/HI2UC/ChangeItem.cs rename to se5/HI2UC/ViewModels/ChangeItem.cs index a9c025c8..8ccf97f0 100644 --- a/se5/HI2UC/ChangeItem.cs +++ b/se5/HI2UC/ViewModels/ChangeItem.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.HI2UC.Controls; -namespace SubtitleEdit.Plugins.HI2UC; +namespace SubtitleEdit.Plugins.HI2UC.ViewModels; /// One subtitle line whose hearing-impaired text changes case. public sealed partial class ChangeItem : ObservableObject diff --git a/se5/HI2UC/MainViewModel.cs b/se5/HI2UC/ViewModels/MainViewModel.cs similarity index 98% rename from se5/HI2UC/MainViewModel.cs rename to se5/HI2UC/ViewModels/MainViewModel.cs index 8ca9e366..81a99580 100644 --- a/se5/HI2UC/MainViewModel.cs +++ b/se5/HI2UC/ViewModels/MainViewModel.cs @@ -1,16 +1,13 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.HI2UC.Logic; +using SubtitleEdit.Plugins.HI2UC.Logic.Strategies; using SubtitleEdit.Plugins.Shared; using System.Collections.ObjectModel; using System.Globalization; using System.Text.Json; -namespace SubtitleEdit.Plugins.HI2UC; - -public sealed record StyleOption(HiStyle Style, string Name, string Example) -{ - public override string ToString() => Name; -} +namespace SubtitleEdit.Plugins.HI2UC.ViewModels; public sealed partial class MainViewModel : ObservableObject { diff --git a/se5/HI2UC/ViewModels/StyleOption.cs b/se5/HI2UC/ViewModels/StyleOption.cs new file mode 100644 index 00000000..d1494e95 --- /dev/null +++ b/se5/HI2UC/ViewModels/StyleOption.cs @@ -0,0 +1,8 @@ +using SubtitleEdit.Plugins.HI2UC.Logic; + +namespace SubtitleEdit.Plugins.HI2UC.ViewModels; + +public sealed record StyleOption(HiStyle Style, string Name, string Example) +{ + public override string ToString() => Name; +} diff --git a/se5/HI2UC/MainWindow.axaml b/se5/HI2UC/Views/MainWindow.axaml similarity index 97% rename from se5/HI2UC/MainWindow.axaml rename to se5/HI2UC/Views/MainWindow.axaml index 090fc5f0..6aef67f4 100644 --- a/se5/HI2UC/MainWindow.axaml +++ b/se5/HI2UC/Views/MainWindow.axaml @@ -3,9 +3,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:i="https://github.com/projektanker/icons.avalonia" - xmlns:local="using:SubtitleEdit.Plugins.HI2UC" - x:Class="SubtitleEdit.Plugins.HI2UC.MainWindow" - x:DataType="local:MainViewModel" + xmlns:vm="using:SubtitleEdit.Plugins.HI2UC.ViewModels" + xmlns:controls="using:SubtitleEdit.Plugins.HI2UC.Controls" + x:Class="SubtitleEdit.Plugins.HI2UC.Views.MainWindow" + x:DataType="vm:MainViewModel" mc:Ignorable="d" Title="Hearing impaired to uppercase" Width="1080" Height="780" @@ -307,7 +308,7 @@ - + @@ -324,11 +325,11 @@ Classes.warning="{Binding IsWarning}" /> - + - + diff --git a/se5/HI2UC/MainWindow.axaml.cs b/se5/HI2UC/Views/MainWindow.axaml.cs similarity index 93% rename from se5/HI2UC/MainWindow.axaml.cs rename to se5/HI2UC/Views/MainWindow.axaml.cs index 471cef81..b04f70be 100644 --- a/se5/HI2UC/MainWindow.axaml.cs +++ b/se5/HI2UC/Views/MainWindow.axaml.cs @@ -2,9 +2,10 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HI2UC.ViewModels; using SubtitleEdit.Plugins.Shared; -namespace SubtitleEdit.Plugins.HI2UC; +namespace SubtitleEdit.Plugins.HI2UC.Views; public partial class MainWindow : Window { diff --git a/se5/HIColorer.Tests/HiColorerTests.cs b/se5/HIColorer.Tests/HiColorerTests.cs index db696ba4..8ec756cd 100644 --- a/se5/HIColorer.Tests/HiColorerTests.cs +++ b/se5/HIColorer.Tests/HiColorerTests.cs @@ -1,4 +1,4 @@ -using SubtitleEdit.Plugins.HIColorer; +using SubtitleEdit.Plugins.HIColorer.Logic; namespace HIColorer.Tests; diff --git a/se5/HIColorer.Tests/MainViewModelTests.cs b/se5/HIColorer.Tests/MainViewModelTests.cs index 32199987..07dbfd1f 100644 --- a/se5/HIColorer.Tests/MainViewModelTests.cs +++ b/se5/HIColorer.Tests/MainViewModelTests.cs @@ -1,5 +1,6 @@ using Avalonia.Media; -using SubtitleEdit.Plugins.HIColorer; +using SubtitleEdit.Plugins.HIColorer.Logic; +using SubtitleEdit.Plugins.HIColorer.ViewModels; using SubtitleEdit.Plugins.Shared; using System.Text.Json; diff --git a/se5/HIColorer/App.axaml.cs b/se5/HIColorer/App.axaml.cs index 14825a8a..17da0a98 100644 --- a/se5/HIColorer/App.axaml.cs +++ b/se5/HIColorer/App.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HIColorer.Views; using SubtitleEdit.Plugins.Shared; namespace SubtitleEdit.Plugins.HIColorer; diff --git a/se5/HIColorer/SubtitleTextBlock.cs b/se5/HIColorer/Controls/SubtitleTextBlock.cs similarity index 99% rename from se5/HIColorer/SubtitleTextBlock.cs rename to se5/HIColorer/Controls/SubtitleTextBlock.cs index ed70d746..9f92467c 100644 --- a/se5/HIColorer/SubtitleTextBlock.cs +++ b/se5/HIColorer/Controls/SubtitleTextBlock.cs @@ -5,7 +5,7 @@ using System.Text; using System.Text.RegularExpressions; -namespace SubtitleEdit.Plugins.HIColorer; +namespace SubtitleEdit.Plugins.HIColorer.Controls; /// /// Shows one side of a . With off the raw text is shown with diff --git a/se5/LinesUnbreaker/TextDiff.cs b/se5/HIColorer/Controls/TextDiff.cs similarity index 98% rename from se5/LinesUnbreaker/TextDiff.cs rename to se5/HIColorer/Controls/TextDiff.cs index daafff06..377d121d 100644 --- a/se5/LinesUnbreaker/TextDiff.cs +++ b/se5/HIColorer/Controls/TextDiff.cs @@ -1,4 +1,4 @@ -namespace SubtitleEdit.Plugins.LinesUnbreaker; +namespace SubtitleEdit.Plugins.HIColorer.Controls; public enum DiffKind { diff --git a/se5/HIColorer/Logic/ColorMode.cs b/se5/HIColorer/Logic/ColorMode.cs new file mode 100644 index 00000000..b8519a24 --- /dev/null +++ b/se5/HIColorer/Logic/ColorMode.cs @@ -0,0 +1,10 @@ +namespace SubtitleEdit.Plugins.HIColorer.Logic; + +public enum ColorMode +{ + /// Color narrators, moods and music. + Color, + + /// Remove every <font> tag. + Remove, +} diff --git a/se5/HIColorer/Logic/ColorOptions.cs b/se5/HIColorer/Logic/ColorOptions.cs new file mode 100644 index 00000000..95b7a5a0 --- /dev/null +++ b/se5/HIColorer/Logic/ColorOptions.cs @@ -0,0 +1,15 @@ +namespace SubtitleEdit.Plugins.HIColorer.Logic; + +public sealed class ColorOptions +{ + public const string DefaultNarratorColor = "#ffff00"; + public const string DefaultMoodsColor = "#00ffff"; + public const string DefaultMusicColor = "#ff80ff"; + + public bool Narrator { get; set; } = true; + public string NarratorColor { get; set; } = DefaultNarratorColor; + public bool Moods { get; set; } = true; + public string MoodsColor { get; set; } = DefaultMoodsColor; + public bool Music { get; set; } = true; + public string MusicColor { get; set; } = DefaultMusicColor; +} diff --git a/se5/HIColorer/HiColorer.cs b/se5/HIColorer/Logic/HiColorer.cs similarity index 90% rename from se5/HIColorer/HiColorer.cs rename to se5/HIColorer/Logic/HiColorer.cs index 9171b1d8..3edbe025 100644 --- a/se5/HIColorer/HiColorer.cs +++ b/se5/HIColorer/Logic/HiColorer.cs @@ -1,29 +1,6 @@ using System.Text.RegularExpressions; -namespace SubtitleEdit.Plugins.HIColorer; - -public enum ColorMode -{ - /// Color narrators, moods and music. - Color, - - /// Remove every <font> tag. - Remove, -} - -public sealed class ColorOptions -{ - public const string DefaultNarratorColor = "#ffff00"; - public const string DefaultMoodsColor = "#00ffff"; - public const string DefaultMusicColor = "#ff80ff"; - - public bool Narrator { get; set; } = true; - public string NarratorColor { get; set; } = DefaultNarratorColor; - public bool Moods { get; set; } = true; - public string MoodsColor { get; set; } = DefaultMoodsColor; - public bool Music { get; set; } = true; - public string MusicColor { get; set; } = DefaultMusicColor; -} +namespace SubtitleEdit.Plugins.HIColorer.Logic; /// Colors hearing-impaired text: narrator labels, moods in ( ) / [ ] and music lines (♪ ♫). public static partial class HiColorer diff --git a/se5/HIColorer/Program.cs b/se5/HIColorer/Program.cs index 92b274ee..fadfecf7 100644 --- a/se5/HIColorer/Program.cs +++ b/se5/HIColorer/Program.cs @@ -1,5 +1,6 @@ using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.HIColorer.ViewModels; using SubtitleEdit.Plugins.Shared; using System; diff --git a/se5/HIColorer/ChangeItem.cs b/se5/HIColorer/ViewModels/ChangeItem.cs similarity index 89% rename from se5/HIColorer/ChangeItem.cs rename to se5/HIColorer/ViewModels/ChangeItem.cs index 7ac5e041..59b80e49 100644 --- a/se5/HIColorer/ChangeItem.cs +++ b/se5/HIColorer/ViewModels/ChangeItem.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.HIColorer.Controls; -namespace SubtitleEdit.Plugins.HIColorer; +namespace SubtitleEdit.Plugins.HIColorer.ViewModels; /// One subtitle line whose hearing-impaired text is colored. public sealed partial class ChangeItem : ObservableObject diff --git a/se5/HIColorer/MainViewModel.cs b/se5/HIColorer/ViewModels/MainViewModel.cs similarity index 99% rename from se5/HIColorer/MainViewModel.cs rename to se5/HIColorer/ViewModels/MainViewModel.cs index 550435be..433e339a 100644 --- a/se5/HIColorer/MainViewModel.cs +++ b/se5/HIColorer/ViewModels/MainViewModel.cs @@ -1,12 +1,13 @@ using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.HIColorer.Logic; using SubtitleEdit.Plugins.Shared; using System.Collections.ObjectModel; using System.Globalization; using System.Text.Json; -namespace SubtitleEdit.Plugins.HIColorer; +namespace SubtitleEdit.Plugins.HIColorer.ViewModels; public sealed partial class MainViewModel : ObservableObject { diff --git a/se5/HIColorer/MainWindow.axaml b/se5/HIColorer/Views/MainWindow.axaml similarity index 96% rename from se5/HIColorer/MainWindow.axaml rename to se5/HIColorer/Views/MainWindow.axaml index 31a3b8ef..2ad2f1ee 100644 --- a/se5/HIColorer/MainWindow.axaml +++ b/se5/HIColorer/Views/MainWindow.axaml @@ -3,9 +3,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:i="https://github.com/projektanker/icons.avalonia" - xmlns:local="using:SubtitleEdit.Plugins.HIColorer" - x:Class="SubtitleEdit.Plugins.HIColorer.MainWindow" - x:DataType="local:MainViewModel" + xmlns:vm="using:SubtitleEdit.Plugins.HIColorer.ViewModels" + xmlns:controls="using:SubtitleEdit.Plugins.HIColorer.Controls" + x:Class="SubtitleEdit.Plugins.HIColorer.Views.MainWindow" + x:DataType="vm:MainViewModel" mc:Ignorable="d" Title="Hearing impaired colorer" Width="1080" Height="780" @@ -339,7 +340,7 @@ - + @@ -355,13 +356,13 @@ - + - + diff --git a/se5/HIColorer/MainWindow.axaml.cs b/se5/HIColorer/Views/MainWindow.axaml.cs similarity index 93% rename from se5/HIColorer/MainWindow.axaml.cs rename to se5/HIColorer/Views/MainWindow.axaml.cs index 6bf13f3d..bbe785fd 100644 --- a/se5/HIColorer/MainWindow.axaml.cs +++ b/se5/HIColorer/Views/MainWindow.axaml.cs @@ -2,9 +2,10 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HIColorer.ViewModels; using SubtitleEdit.Plugins.Shared; -namespace SubtitleEdit.Plugins.HIColorer; +namespace SubtitleEdit.Plugins.HIColorer.Views; public partial class MainWindow : Window { diff --git a/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs b/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs index 2eee1a1d..ae9d0784 100644 --- a/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs +++ b/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs @@ -1,4 +1,4 @@ -using SubtitleEdit.Plugins.LinesUnbreaker; +using SubtitleEdit.Plugins.LinesUnbreaker.Logic; namespace LinesUnbreaker.Tests; diff --git a/se5/LinesUnbreaker.Tests/MainViewModelTests.cs b/se5/LinesUnbreaker.Tests/MainViewModelTests.cs index 087e9e87..58896ce0 100644 --- a/se5/LinesUnbreaker.Tests/MainViewModelTests.cs +++ b/se5/LinesUnbreaker.Tests/MainViewModelTests.cs @@ -1,4 +1,4 @@ -using SubtitleEdit.Plugins.LinesUnbreaker; +using SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; using SubtitleEdit.Plugins.Shared; using System.Text.Json; diff --git a/se5/LinesUnbreaker/App.axaml.cs b/se5/LinesUnbreaker/App.axaml.cs index aa63a5b2..f153e2ef 100644 --- a/se5/LinesUnbreaker/App.axaml.cs +++ b/se5/LinesUnbreaker/App.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.LinesUnbreaker.Views; using SubtitleEdit.Plugins.Shared; namespace SubtitleEdit.Plugins.LinesUnbreaker; diff --git a/se5/LinesUnbreaker/SubtitleTextBlock.cs b/se5/LinesUnbreaker/Controls/SubtitleTextBlock.cs similarity index 99% rename from se5/LinesUnbreaker/SubtitleTextBlock.cs rename to se5/LinesUnbreaker/Controls/SubtitleTextBlock.cs index 3f11e9da..a8cc7d35 100644 --- a/se5/LinesUnbreaker/SubtitleTextBlock.cs +++ b/se5/LinesUnbreaker/Controls/SubtitleTextBlock.cs @@ -4,7 +4,7 @@ using Avalonia.Media; using System.Text; -namespace SubtitleEdit.Plugins.LinesUnbreaker; +namespace SubtitleEdit.Plugins.LinesUnbreaker.Controls; /// /// Shows one side of a . With off the raw text is shown with diff --git a/se5/HIColorer/TextDiff.cs b/se5/LinesUnbreaker/Controls/TextDiff.cs similarity index 98% rename from se5/HIColorer/TextDiff.cs rename to se5/LinesUnbreaker/Controls/TextDiff.cs index 40573190..06d5f8e0 100644 --- a/se5/HIColorer/TextDiff.cs +++ b/se5/LinesUnbreaker/Controls/TextDiff.cs @@ -1,4 +1,4 @@ -namespace SubtitleEdit.Plugins.HIColorer; +namespace SubtitleEdit.Plugins.LinesUnbreaker.Controls; public enum DiffKind { diff --git a/se5/LinesUnbreaker/Line.cs b/se5/LinesUnbreaker/Logic/Line.cs similarity index 95% rename from se5/LinesUnbreaker/Line.cs rename to se5/LinesUnbreaker/Logic/Line.cs index 6a5797a3..885bfc77 100644 --- a/se5/LinesUnbreaker/Line.cs +++ b/se5/LinesUnbreaker/Logic/Line.cs @@ -1,4 +1,4 @@ -namespace SubtitleEdit.Plugins.LinesUnbreaker; +namespace SubtitleEdit.Plugins.LinesUnbreaker.Logic; /// One line of a subtitle, classified on its text without tags: dialog, mood or narrator. public readonly struct Line diff --git a/se5/LinesUnbreaker/LineUnbreaker.cs b/se5/LinesUnbreaker/Logic/LineUnbreaker.cs similarity index 84% rename from se5/LinesUnbreaker/LineUnbreaker.cs rename to se5/LinesUnbreaker/Logic/LineUnbreaker.cs index 3d68d2dd..2f25895b 100644 --- a/se5/LinesUnbreaker/LineUnbreaker.cs +++ b/se5/LinesUnbreaker/Logic/LineUnbreaker.cs @@ -1,17 +1,6 @@ using System.Text.RegularExpressions; -namespace SubtitleEdit.Plugins.LinesUnbreaker; - -public sealed class UnbreakOptions -{ - public const int DefaultMaxLength = 43; - - /// Join only when the result, without tags, is at most this many characters. - public int MaxLength { get; set; } = DefaultMaxLength; - public bool SkipDialogs { get; set; } = true; - public bool SkipMoods { get; set; } - public bool SkipNarrator { get; set; } = true; -} +namespace SubtitleEdit.Plugins.LinesUnbreaker.Logic; /// Joins short multi-line subtitles into one line. public static partial class LineUnbreaker diff --git a/se5/LinesUnbreaker/Logic/UnbreakOptions.cs b/se5/LinesUnbreaker/Logic/UnbreakOptions.cs new file mode 100644 index 00000000..68f1c53d --- /dev/null +++ b/se5/LinesUnbreaker/Logic/UnbreakOptions.cs @@ -0,0 +1,12 @@ +namespace SubtitleEdit.Plugins.LinesUnbreaker.Logic; + +public sealed class UnbreakOptions +{ + public const int DefaultMaxLength = 43; + + /// Join only when the result, without tags, is at most this many characters. + public int MaxLength { get; set; } = DefaultMaxLength; + public bool SkipDialogs { get; set; } = true; + public bool SkipMoods { get; set; } + public bool SkipNarrator { get; set; } = true; +} diff --git a/se5/LinesUnbreaker/Program.cs b/se5/LinesUnbreaker/Program.cs index b1073eb3..34ed0e86 100644 --- a/se5/LinesUnbreaker/Program.cs +++ b/se5/LinesUnbreaker/Program.cs @@ -1,5 +1,6 @@ using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; using SubtitleEdit.Plugins.Shared; using System; diff --git a/se5/LinesUnbreaker/ChangeItem.cs b/se5/LinesUnbreaker/ViewModels/ChangeItem.cs similarity index 86% rename from se5/LinesUnbreaker/ChangeItem.cs rename to se5/LinesUnbreaker/ViewModels/ChangeItem.cs index fd6d716e..c4336fec 100644 --- a/se5/LinesUnbreaker/ChangeItem.cs +++ b/se5/LinesUnbreaker/ViewModels/ChangeItem.cs @@ -1,6 +1,8 @@ using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.LinesUnbreaker.Controls; +using SubtitleEdit.Plugins.LinesUnbreaker.Logic; -namespace SubtitleEdit.Plugins.LinesUnbreaker; +namespace SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; /// One subtitle whose lines are joined into one. public sealed partial class ChangeItem : ObservableObject diff --git a/se5/LinesUnbreaker/MainViewModel.cs b/se5/LinesUnbreaker/ViewModels/MainViewModel.cs similarity index 99% rename from se5/LinesUnbreaker/MainViewModel.cs rename to se5/LinesUnbreaker/ViewModels/MainViewModel.cs index 8551f99a..066b8a62 100644 --- a/se5/LinesUnbreaker/MainViewModel.cs +++ b/se5/LinesUnbreaker/ViewModels/MainViewModel.cs @@ -1,11 +1,12 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.LinesUnbreaker.Logic; using SubtitleEdit.Plugins.Shared; using System.Collections.ObjectModel; using System.Globalization; using System.Text.Json; -namespace SubtitleEdit.Plugins.LinesUnbreaker; +namespace SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; public sealed partial class MainViewModel : ObservableObject { diff --git a/se5/LinesUnbreaker/MainWindow.axaml b/se5/LinesUnbreaker/Views/MainWindow.axaml similarity index 97% rename from se5/LinesUnbreaker/MainWindow.axaml rename to se5/LinesUnbreaker/Views/MainWindow.axaml index f23f7870..5f19f960 100644 --- a/se5/LinesUnbreaker/MainWindow.axaml +++ b/se5/LinesUnbreaker/Views/MainWindow.axaml @@ -3,9 +3,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:i="https://github.com/projektanker/icons.avalonia" - xmlns:local="using:SubtitleEdit.Plugins.LinesUnbreaker" - x:Class="SubtitleEdit.Plugins.LinesUnbreaker.MainWindow" - x:DataType="local:MainViewModel" + xmlns:vm="using:SubtitleEdit.Plugins.LinesUnbreaker.ViewModels" + xmlns:controls="using:SubtitleEdit.Plugins.LinesUnbreaker.Controls" + x:Class="SubtitleEdit.Plugins.LinesUnbreaker.Views.MainWindow" + x:DataType="vm:MainViewModel" mc:Ignorable="d" Title="Lines unbreaker" Width="1080" Height="780" @@ -309,7 +310,7 @@ - + @@ -325,11 +326,11 @@ - + - + diff --git a/se5/LinesUnbreaker/MainWindow.axaml.cs b/se5/LinesUnbreaker/Views/MainWindow.axaml.cs similarity index 92% rename from se5/LinesUnbreaker/MainWindow.axaml.cs rename to se5/LinesUnbreaker/Views/MainWindow.axaml.cs index 607497fe..c59ac605 100644 --- a/se5/LinesUnbreaker/MainWindow.axaml.cs +++ b/se5/LinesUnbreaker/Views/MainWindow.axaml.cs @@ -2,9 +2,10 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; using SubtitleEdit.Plugins.Shared; -namespace SubtitleEdit.Plugins.LinesUnbreaker; +namespace SubtitleEdit.Plugins.LinesUnbreaker.Views; public partial class MainWindow : Window { diff --git a/se5/TightHyphen.Tests/HyphenTightenerTests.cs b/se5/TightHyphen.Tests/HyphenTightenerTests.cs index 68e54855..fe6ca34f 100644 --- a/se5/TightHyphen.Tests/HyphenTightenerTests.cs +++ b/se5/TightHyphen.Tests/HyphenTightenerTests.cs @@ -1,4 +1,4 @@ -using SubtitleEdit.Plugins.TightHyphen; +using SubtitleEdit.Plugins.TightHyphen.Logic; namespace TightHyphen.Tests; diff --git a/se5/TightHyphen.Tests/MainViewModelTests.cs b/se5/TightHyphen.Tests/MainViewModelTests.cs index 0cb4e15e..4c1d12a5 100644 --- a/se5/TightHyphen.Tests/MainViewModelTests.cs +++ b/se5/TightHyphen.Tests/MainViewModelTests.cs @@ -1,5 +1,5 @@ using SubtitleEdit.Plugins.Shared; -using SubtitleEdit.Plugins.TightHyphen; +using SubtitleEdit.Plugins.TightHyphen.ViewModels; using System.Text.Json; namespace TightHyphen.Tests; diff --git a/se5/TightHyphen/App.axaml.cs b/se5/TightHyphen/App.axaml.cs index b2ca4ccd..6caebafb 100644 --- a/se5/TightHyphen/App.axaml.cs +++ b/se5/TightHyphen/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.Views; namespace SubtitleEdit.Plugins.TightHyphen; diff --git a/se5/TightHyphen/SubtitleTextBlock.cs b/se5/TightHyphen/Controls/SubtitleTextBlock.cs similarity index 99% rename from se5/TightHyphen/SubtitleTextBlock.cs rename to se5/TightHyphen/Controls/SubtitleTextBlock.cs index 728002cc..5e453a92 100644 --- a/se5/TightHyphen/SubtitleTextBlock.cs +++ b/se5/TightHyphen/Controls/SubtitleTextBlock.cs @@ -4,7 +4,7 @@ using Avalonia.Media; using System.Text; -namespace SubtitleEdit.Plugins.TightHyphen; +namespace SubtitleEdit.Plugins.TightHyphen.Controls; /// /// Shows one side of a . With off the raw text is shown with diff --git a/se5/HI2UC/TextDiff.cs b/se5/TightHyphen/Controls/TextDiff.cs similarity index 98% rename from se5/HI2UC/TextDiff.cs rename to se5/TightHyphen/Controls/TextDiff.cs index 96a39abd..2cedf086 100644 --- a/se5/HI2UC/TextDiff.cs +++ b/se5/TightHyphen/Controls/TextDiff.cs @@ -1,4 +1,4 @@ -namespace SubtitleEdit.Plugins.HI2UC; +namespace SubtitleEdit.Plugins.TightHyphen.Controls; public enum DiffKind { diff --git a/se5/TightHyphen/HyphenTightener.cs b/se5/TightHyphen/Logic/HyphenTightener.cs similarity index 97% rename from se5/TightHyphen/HyphenTightener.cs rename to se5/TightHyphen/Logic/HyphenTightener.cs index e286031a..a913cf1f 100644 --- a/se5/TightHyphen/HyphenTightener.cs +++ b/se5/TightHyphen/Logic/HyphenTightener.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace SubtitleEdit.Plugins.TightHyphen; +namespace SubtitleEdit.Plugins.TightHyphen.Logic; /// Removes the space after a dialog dash at the start of a line: "- Hi!" becomes "-Hi!". public static partial class HyphenTightener diff --git a/se5/TightHyphen/Program.cs b/se5/TightHyphen/Program.cs index 53ecad07..2a88c8f4 100644 --- a/se5/TightHyphen/Program.cs +++ b/se5/TightHyphen/Program.cs @@ -1,6 +1,7 @@ using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.MaterialDesign; using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.ViewModels; using System; namespace SubtitleEdit.Plugins.TightHyphen; diff --git a/se5/TightHyphen/ChangeItem.cs b/se5/TightHyphen/ViewModels/ChangeItem.cs similarity index 89% rename from se5/TightHyphen/ChangeItem.cs rename to se5/TightHyphen/ViewModels/ChangeItem.cs index 55044857..6a34423f 100644 --- a/se5/TightHyphen/ChangeItem.cs +++ b/se5/TightHyphen/ViewModels/ChangeItem.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.TightHyphen.Controls; -namespace SubtitleEdit.Plugins.TightHyphen; +namespace SubtitleEdit.Plugins.TightHyphen.ViewModels; /// One subtitle line whose dialog dash is tightened. public sealed partial class ChangeItem : ObservableObject diff --git a/se5/TightHyphen/MainViewModel.cs b/se5/TightHyphen/ViewModels/MainViewModel.cs similarity index 98% rename from se5/TightHyphen/MainViewModel.cs rename to se5/TightHyphen/ViewModels/MainViewModel.cs index 50bb5816..06118139 100644 --- a/se5/TightHyphen/MainViewModel.cs +++ b/se5/TightHyphen/ViewModels/MainViewModel.cs @@ -1,11 +1,12 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.Logic; using System.Collections.ObjectModel; using System.Globalization; using System.Text.Json; -namespace SubtitleEdit.Plugins.TightHyphen; +namespace SubtitleEdit.Plugins.TightHyphen.ViewModels; public sealed partial class MainViewModel : ObservableObject { diff --git a/se5/TightHyphen/MainWindow.axaml b/se5/TightHyphen/Views/MainWindow.axaml similarity index 96% rename from se5/TightHyphen/MainWindow.axaml rename to se5/TightHyphen/Views/MainWindow.axaml index fea85fa5..e1f8501c 100644 --- a/se5/TightHyphen/MainWindow.axaml +++ b/se5/TightHyphen/Views/MainWindow.axaml @@ -3,9 +3,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:i="https://github.com/projektanker/icons.avalonia" - xmlns:local="using:SubtitleEdit.Plugins.TightHyphen" - x:Class="SubtitleEdit.Plugins.TightHyphen.MainWindow" - x:DataType="local:MainViewModel" + xmlns:vm="using:SubtitleEdit.Plugins.TightHyphen.ViewModels" + xmlns:controls="using:SubtitleEdit.Plugins.TightHyphen.Controls" + x:Class="SubtitleEdit.Plugins.TightHyphen.Views.MainWindow" + x:DataType="vm:MainViewModel" mc:Ignorable="d" Title="Tight hyphen" Width="1080" Height="780" @@ -298,7 +299,7 @@ - + @@ -314,11 +315,11 @@ - + - + diff --git a/se5/TightHyphen/MainWindow.axaml.cs b/se5/TightHyphen/Views/MainWindow.axaml.cs similarity index 92% rename from se5/TightHyphen/MainWindow.axaml.cs rename to se5/TightHyphen/Views/MainWindow.axaml.cs index 52ab29c9..ebe0b9aa 100644 --- a/se5/TightHyphen/MainWindow.axaml.cs +++ b/se5/TightHyphen/Views/MainWindow.axaml.cs @@ -3,8 +3,9 @@ using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.ViewModels; -namespace SubtitleEdit.Plugins.TightHyphen; +namespace SubtitleEdit.Plugins.TightHyphen.Views; public partial class MainWindow : Window { From e7e949c426741f9476d2b983af07ed5a2bcce197 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 17 Oct 2026 12:49:26 +0100 Subject: [PATCH 6/6] Use partial properties for [ObservableProperty] Switches the four SE5 ports from [ObservableProperty] fields to partial properties. Default values move into the constructors. The plugins use C# 14 with CommunityToolkit.Mvvm 8.4.2 and still target net8.0. Their workflows now install the .NET 10 SDK, plus the .NET 8 runtime for the tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/hi-colorer.yml | 12 ++++--- .github/workflows/hi2uc.yml | 12 ++++--- .github/workflows/lines-unbreaker.yml | 12 ++++--- .github/workflows/tight-hyphen.yml | 12 ++++--- se5/HI2UC/HI2UC.csproj | 2 ++ se5/HI2UC/ViewModels/ChangeItem.cs | 3 +- se5/HI2UC/ViewModels/MainViewModel.cs | 18 ++++++----- se5/HIColorer/HIColorer.csproj | 2 ++ se5/HIColorer/ViewModels/ChangeItem.cs | 3 +- se5/HIColorer/ViewModels/MainViewModel.cs | 31 ++++++++++++------- se5/LinesUnbreaker/LinesUnbreaker.csproj | 2 ++ se5/LinesUnbreaker/ViewModels/ChangeItem.cs | 3 +- .../ViewModels/MainViewModel.cs | 21 ++++++++----- se5/TightHyphen/TightHyphen.csproj | 2 ++ se5/TightHyphen/ViewModels/ChangeItem.cs | 3 +- se5/TightHyphen/ViewModels/MainViewModel.cs | 8 +++-- 16 files changed, 97 insertions(+), 49 deletions(-) diff --git a/.github/workflows/hi-colorer.yml b/.github/workflows/hi-colorer.yml index 79940318..4b2d4f39 100644 --- a/.github/workflows/hi-colorer.yml +++ b/.github/workflows/hi-colorer.yml @@ -98,10 +98,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Test run: dotnet test se5/HIColorer.Tests/HIColorer.Tests.csproj -c Release @@ -138,10 +140,12 @@ jobs: with: ref: ${{ needs.prepare.outputs.ref }} - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Publish self-contained (${{ matrix.rid }}) working-directory: se5/HIColorer diff --git a/.github/workflows/hi2uc.yml b/.github/workflows/hi2uc.yml index 0f195b4d..c8abc101 100644 --- a/.github/workflows/hi2uc.yml +++ b/.github/workflows/hi2uc.yml @@ -98,10 +98,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Test run: dotnet test se5/HI2UC.Tests/HI2UC.Tests.csproj -c Release @@ -138,10 +140,12 @@ jobs: with: ref: ${{ needs.prepare.outputs.ref }} - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Publish self-contained (${{ matrix.rid }}) working-directory: se5/HI2UC diff --git a/.github/workflows/lines-unbreaker.yml b/.github/workflows/lines-unbreaker.yml index 319861dd..094cc421 100644 --- a/.github/workflows/lines-unbreaker.yml +++ b/.github/workflows/lines-unbreaker.yml @@ -98,10 +98,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Test run: dotnet test se5/LinesUnbreaker.Tests/LinesUnbreaker.Tests.csproj -c Release @@ -138,10 +140,12 @@ jobs: with: ref: ${{ needs.prepare.outputs.ref }} - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Publish self-contained (${{ matrix.rid }}) working-directory: se5/LinesUnbreaker diff --git a/.github/workflows/tight-hyphen.yml b/.github/workflows/tight-hyphen.yml index ce356616..46aaada9 100644 --- a/.github/workflows/tight-hyphen.yml +++ b/.github/workflows/tight-hyphen.yml @@ -98,10 +98,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Test run: dotnet test se5/TightHyphen.Tests/TightHyphen.Tests.csproj -c Release @@ -138,10 +140,12 @@ jobs: with: ref: ${{ needs.prepare.outputs.ref }} - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.x - name: Publish self-contained (${{ matrix.rid }}) working-directory: se5/TightHyphen diff --git a/se5/HI2UC/HI2UC.csproj b/se5/HI2UC/HI2UC.csproj index 5ba7be6d..e1afe75a 100644 --- a/se5/HI2UC/HI2UC.csproj +++ b/se5/HI2UC/HI2UC.csproj @@ -3,6 +3,7 @@ WinExe net8.0 + 14 enable enable HI2UC @@ -16,6 +17,7 @@ + diff --git a/se5/HI2UC/ViewModels/ChangeItem.cs b/se5/HI2UC/ViewModels/ChangeItem.cs index 8ccf97f0..b5210ff6 100644 --- a/se5/HI2UC/ViewModels/ChangeItem.cs +++ b/se5/HI2UC/ViewModels/ChangeItem.cs @@ -6,10 +6,11 @@ namespace SubtitleEdit.Plugins.HI2UC.ViewModels; /// One subtitle line whose hearing-impaired text changes case. public sealed partial class ChangeItem : ObservableObject { - [ObservableProperty] private bool _isSelected = true; + [ObservableProperty] public partial bool IsSelected { get; set; } public ChangeItem(int index, string before, string after, string timeRange, string comment, bool isWarning) { + IsSelected = true; Index = index; Before = before; After = after; diff --git a/se5/HI2UC/ViewModels/MainViewModel.cs b/se5/HI2UC/ViewModels/MainViewModel.cs index 81a99580..4cadda2c 100644 --- a/se5/HI2UC/ViewModels/MainViewModel.cs +++ b/se5/HI2UC/ViewModels/MainViewModel.cs @@ -20,14 +20,19 @@ public sealed partial class MainViewModel : ObservableObject private readonly bool _isConfigure; private bool _loading; - [ObservableProperty] private StyleOption _selectedStyle; - [ObservableProperty] private bool _moods = true; - [ObservableProperty] private bool _narrator = true; - [ObservableProperty] private bool _selectedLinesOnly; - [ObservableProperty] private int _selectedCount; + [ObservableProperty] public partial StyleOption SelectedStyle { get; set; } + [ObservableProperty] public partial bool Moods { get; set; } + [ObservableProperty] public partial bool Narrator { get; set; } + [ObservableProperty] public partial bool SelectedLinesOnly { get; set; } + [ObservableProperty] public partial int SelectedCount { get; set; } public MainViewModel(PluginRequest request) { + // Property changes during setup must not analyze yet. + _loading = true; + Moods = true; + Narrator = true; + _request = request; _isConfigure = request.RequestType == PluginRequestType.Configure; _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; @@ -43,9 +48,8 @@ public MainViewModel(PluginRequest request) var strategy = CasingStrategies.Create(style); return new StyleOption(style, strategy.Name, strategy.Example); }).ToList(); - _selectedStyle = StyleOptions[0]; + SelectedStyle = StyleOptions[0]; - _loading = true; LoadSettings(); _loading = false; Analyze(); diff --git a/se5/HIColorer/HIColorer.csproj b/se5/HIColorer/HIColorer.csproj index 3bff945b..ae774180 100644 --- a/se5/HIColorer/HIColorer.csproj +++ b/se5/HIColorer/HIColorer.csproj @@ -3,6 +3,7 @@ WinExe net8.0 + 14 enable enable HIColorer @@ -16,6 +17,7 @@ + diff --git a/se5/HIColorer/ViewModels/ChangeItem.cs b/se5/HIColorer/ViewModels/ChangeItem.cs index 59b80e49..52be9e65 100644 --- a/se5/HIColorer/ViewModels/ChangeItem.cs +++ b/se5/HIColorer/ViewModels/ChangeItem.cs @@ -6,10 +6,11 @@ namespace SubtitleEdit.Plugins.HIColorer.ViewModels; /// One subtitle line whose hearing-impaired text is colored. public sealed partial class ChangeItem : ObservableObject { - [ObservableProperty] private bool _isSelected = true; + [ObservableProperty] public partial bool IsSelected { get; set; } public ChangeItem(int index, string before, string after, string timeRange) { + IsSelected = true; Index = index; Before = before; After = after; diff --git a/se5/HIColorer/ViewModels/MainViewModel.cs b/se5/HIColorer/ViewModels/MainViewModel.cs index 433e339a..dbc4810f 100644 --- a/se5/HIColorer/ViewModels/MainViewModel.cs +++ b/se5/HIColorer/ViewModels/MainViewModel.cs @@ -20,19 +20,29 @@ public sealed partial class MainViewModel : ObservableObject private readonly bool _isConfigure; private bool _loading; - [ObservableProperty] private ColorMode _mode = ColorMode.Color; - [ObservableProperty] private bool _narrator = true; - [ObservableProperty] private Color _narratorColor = Color.Parse(ColorOptions.DefaultNarratorColor); - [ObservableProperty] private bool _moods = true; - [ObservableProperty] private Color _moodsColor = Color.Parse(ColorOptions.DefaultMoodsColor); - [ObservableProperty] private bool _music = true; - [ObservableProperty] private Color _musicColor = Color.Parse(ColorOptions.DefaultMusicColor); - [ObservableProperty] private bool _showRendered = true; - [ObservableProperty] private bool _selectedLinesOnly; - [ObservableProperty] private int _selectedCount; + [ObservableProperty] public partial ColorMode Mode { get; set; } + [ObservableProperty] public partial bool Narrator { get; set; } + [ObservableProperty] public partial Color NarratorColor { get; set; } + [ObservableProperty] public partial bool Moods { get; set; } + [ObservableProperty] public partial Color MoodsColor { get; set; } + [ObservableProperty] public partial bool Music { get; set; } + [ObservableProperty] public partial Color MusicColor { get; set; } + [ObservableProperty] public partial bool ShowRendered { get; set; } + [ObservableProperty] public partial bool SelectedLinesOnly { get; set; } + [ObservableProperty] public partial int SelectedCount { get; set; } public MainViewModel(PluginRequest request) { + // Property changes during setup must not analyze yet. + _loading = true; + Narrator = true; + NarratorColor = Color.Parse(ColorOptions.DefaultNarratorColor); + Moods = true; + MoodsColor = Color.Parse(ColorOptions.DefaultMoodsColor); + Music = true; + MusicColor = Color.Parse(ColorOptions.DefaultMusicColor); + ShowRendered = true; + _request = request; _isConfigure = request.RequestType == PluginRequestType.Configure; _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; @@ -43,7 +53,6 @@ public MainViewModel(PluginRequest request) : request.Subtitle.Paragraphs!.Select(p => p.Clone()).ToList(); SelectedIndices = request.SelectedIndices.Where(i => i >= 0 && i < _paragraphs.Count).Distinct().Order().ToList(); - _loading = true; LoadSettings(); _loading = false; Analyze(); diff --git a/se5/LinesUnbreaker/LinesUnbreaker.csproj b/se5/LinesUnbreaker/LinesUnbreaker.csproj index 57c0bc59..82c37438 100644 --- a/se5/LinesUnbreaker/LinesUnbreaker.csproj +++ b/se5/LinesUnbreaker/LinesUnbreaker.csproj @@ -3,6 +3,7 @@ WinExe net8.0 + 14 enable enable LinesUnbreaker @@ -16,6 +17,7 @@ + diff --git a/se5/LinesUnbreaker/ViewModels/ChangeItem.cs b/se5/LinesUnbreaker/ViewModels/ChangeItem.cs index c4336fec..864cbb7f 100644 --- a/se5/LinesUnbreaker/ViewModels/ChangeItem.cs +++ b/se5/LinesUnbreaker/ViewModels/ChangeItem.cs @@ -7,10 +7,11 @@ namespace SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; /// One subtitle whose lines are joined into one. public sealed partial class ChangeItem : ObservableObject { - [ObservableProperty] private bool _isSelected = true; + [ObservableProperty] public partial bool IsSelected { get; set; } public ChangeItem(int index, string before, string after, string timeRange) { + IsSelected = true; Index = index; Before = before; After = after; diff --git a/se5/LinesUnbreaker/ViewModels/MainViewModel.cs b/se5/LinesUnbreaker/ViewModels/MainViewModel.cs index 066b8a62..1a2ae4d1 100644 --- a/se5/LinesUnbreaker/ViewModels/MainViewModel.cs +++ b/se5/LinesUnbreaker/ViewModels/MainViewModel.cs @@ -19,15 +19,21 @@ public sealed partial class MainViewModel : ObservableObject private readonly bool _isConfigure; private bool _loading; - [ObservableProperty] private int _maxLength = UnbreakOptions.DefaultMaxLength; - [ObservableProperty] private bool _skipDialogs = true; - [ObservableProperty] private bool _skipMoods; - [ObservableProperty] private bool _skipNarrator = true; - [ObservableProperty] private bool _selectedLinesOnly; - [ObservableProperty] private int _selectedCount; + [ObservableProperty] public partial int MaxLength { get; set; } + [ObservableProperty] public partial bool SkipDialogs { get; set; } + [ObservableProperty] public partial bool SkipMoods { get; set; } + [ObservableProperty] public partial bool SkipNarrator { get; set; } + [ObservableProperty] public partial bool SelectedLinesOnly { get; set; } + [ObservableProperty] public partial int SelectedCount { get; set; } public MainViewModel(PluginRequest request) { + // Property changes during setup must not analyze yet. + _loading = true; + MaxLength = UnbreakOptions.DefaultMaxLength; + SkipDialogs = true; + SkipNarrator = true; + _request = request; _isConfigure = request.RequestType == PluginRequestType.Configure; _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; @@ -41,10 +47,9 @@ public MainViewModel(PluginRequest request) // Default to Subtitle Edit's "single line max. length". if (request.Rules is { SubtitleLineMaximumLength: > 0 } rules) { - _maxLength = rules.SubtitleLineMaximumLength; + MaxLength = rules.SubtitleLineMaximumLength; } - _loading = true; LoadSettings(); _loading = false; Analyze(); diff --git a/se5/TightHyphen/TightHyphen.csproj b/se5/TightHyphen/TightHyphen.csproj index 81cd631e..c4f97e01 100644 --- a/se5/TightHyphen/TightHyphen.csproj +++ b/se5/TightHyphen/TightHyphen.csproj @@ -3,6 +3,7 @@ WinExe net8.0 + 14 enable enable TightHyphen @@ -16,6 +17,7 @@ + diff --git a/se5/TightHyphen/ViewModels/ChangeItem.cs b/se5/TightHyphen/ViewModels/ChangeItem.cs index 6a34423f..cf0955a7 100644 --- a/se5/TightHyphen/ViewModels/ChangeItem.cs +++ b/se5/TightHyphen/ViewModels/ChangeItem.cs @@ -6,10 +6,11 @@ namespace SubtitleEdit.Plugins.TightHyphen.ViewModels; /// One subtitle line whose dialog dash is tightened. public sealed partial class ChangeItem : ObservableObject { - [ObservableProperty] private bool _isSelected = true; + [ObservableProperty] public partial bool IsSelected { get; set; } public ChangeItem(int index, string before, string after, string timeRange) { + IsSelected = true; Index = index; Before = before; After = after; diff --git a/se5/TightHyphen/ViewModels/MainViewModel.cs b/se5/TightHyphen/ViewModels/MainViewModel.cs index 06118139..215d540b 100644 --- a/se5/TightHyphen/ViewModels/MainViewModel.cs +++ b/se5/TightHyphen/ViewModels/MainViewModel.cs @@ -19,11 +19,14 @@ public sealed partial class MainViewModel : ObservableObject private readonly bool _isConfigure; private bool _loading; - [ObservableProperty] private bool _selectedLinesOnly; - [ObservableProperty] private int _selectedCount; + [ObservableProperty] public partial bool SelectedLinesOnly { get; set; } + [ObservableProperty] public partial int SelectedCount { get; set; } public MainViewModel(PluginRequest request) { + // Property changes during setup must not analyze yet. + _loading = true; + _request = request; _isConfigure = request.RequestType == PluginRequestType.Configure; _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; @@ -34,7 +37,6 @@ public MainViewModel(PluginRequest request) : request.Subtitle.Paragraphs!.Select(p => p.Clone()).ToList(); SelectedIndices = request.SelectedIndices.Where(i => i >= 0 && i < _paragraphs.Count).Distinct().Order().ToList(); - _loading = true; LoadSettings(); _loading = false; Analyze();