diff --git a/.github/workflows/hi-colorer.yml b/.github/workflows/hi-colorer.yml new file mode 100644 index 00000000..4b2d4f39 --- /dev/null +++ b/.github/workflows/hi-colorer.yml @@ -0,0 +1,200 @@ +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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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/.github/workflows/hi2uc.yml b/.github/workflows/hi2uc.yml new file mode 100644 index 00000000..c8abc101 --- /dev/null +++ b/.github/workflows/hi2uc.yml @@ -0,0 +1,200 @@ +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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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/.github/workflows/lines-unbreaker.yml b/.github/workflows/lines-unbreaker.yml new file mode 100644 index 00000000..094cc421 --- /dev/null +++ b/.github/workflows/lines-unbreaker.yml @@ -0,0 +1,200 @@ +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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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/.github/workflows/tight-hyphen.yml b/.github/workflows/tight-hyphen.yml new file mode 100644 index 00000000..46aaada9 --- /dev/null +++ b/.github/workflows/tight-hyphen.yml @@ -0,0 +1,200 @@ +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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.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 1c319710..07dc37ac 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,14 @@ 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. | | [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/HI2UC.Tests/CasingTests.cs b/se5/HI2UC.Tests/CasingTests.cs new file mode 100644 index 00000000..f9d96f57 --- /dev/null +++ b/se5/HI2UC.Tests/CasingTests.cs @@ -0,0 +1,106 @@ +using SubtitleEdit.Plugins.HI2UC.Logic; +using SubtitleEdit.Plugins.HI2UC.Logic.Strategies; + +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..d7aa5358 --- /dev/null +++ b/se5/HI2UC.Tests/MainViewModelTests.cs @@ -0,0 +1,141 @@ +using SubtitleEdit.Plugins.HI2UC.Logic; +using SubtitleEdit.Plugins.HI2UC.ViewModels; +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..af4b5f89 --- /dev/null +++ b/se5/HI2UC/App.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HI2UC.Views; +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/Controls/SubtitleTextBlock.cs b/se5/HI2UC/Controls/SubtitleTextBlock.cs new file mode 100644 index 00000000..353718ea --- /dev/null +++ b/se5/HI2UC/Controls/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.Controls; + +/// +/// 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/Controls/TextDiff.cs b/se5/HI2UC/Controls/TextDiff.cs new file mode 100644 index 00000000..dbc1a53e --- /dev/null +++ b/se5/HI2UC/Controls/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.HI2UC.Controls; + +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/HI2UC.csproj b/se5/HI2UC/HI2UC.csproj new file mode 100644 index 00000000..e1afe75a --- /dev/null +++ b/se5/HI2UC/HI2UC.csproj @@ -0,0 +1,29 @@ + + + + WinExe + net8.0 + 14 + enable + enable + HI2UC + SubtitleEdit.Plugins.HI2UC + true + app.manifest + + + + + + + + + + + + + + + + + diff --git a/se5/HI2UC/Logic/ChunkReader.cs b/se5/HI2UC/Logic/ChunkReader.cs new file mode 100644 index 00000000..cf5f9a3b --- /dev/null +++ b/se5/HI2UC/Logic/ChunkReader.cs @@ -0,0 +1,61 @@ +namespace SubtitleEdit.Plugins.HI2UC.Logic; + +/// 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/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/Logic/MoodCasingConverter.cs b/se5/HI2UC/Logic/MoodCasingConverter.cs new file mode 100644 index 00000000..5f8d84fb --- /dev/null +++ b/se5/HI2UC/Logic/MoodCasingConverter.cs @@ -0,0 +1,184 @@ +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 +{ + 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/Logic/NarratorCasingConverter.cs b/se5/HI2UC/Logic/NarratorCasingConverter.cs new file mode 100644 index 00000000..ccd95778 --- /dev/null +++ b/se5/HI2UC/Logic/NarratorCasingConverter.cs @@ -0,0 +1,94 @@ +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 +{ + 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/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/Logic/TextUtils.cs b/se5/HI2UC/Logic/TextUtils.cs new file mode 100644 index 00000000..4a2ed1f1 --- /dev/null +++ b/se5/HI2UC/Logic/TextUtils.cs @@ -0,0 +1,58 @@ +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.HI2UC.Logic; + +/// 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/Program.cs b/se5/HI2UC/Program.cs new file mode 100644 index 00000000..9eb500e9 --- /dev/null +++ b/se5/HI2UC/Program.cs @@ -0,0 +1,17 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.HI2UC.ViewModels; +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/ViewModels/ChangeItem.cs b/se5/HI2UC/ViewModels/ChangeItem.cs new file mode 100644 index 00000000..b5210ff6 --- /dev/null +++ b/se5/HI2UC/ViewModels/ChangeItem.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.HI2UC.Controls; + +namespace SubtitleEdit.Plugins.HI2UC.ViewModels; + +/// One subtitle line whose hearing-impaired text changes case. +public sealed partial class ChangeItem : ObservableObject +{ + [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; + 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/ViewModels/MainViewModel.cs b/se5/HI2UC/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..4cadda2c --- /dev/null +++ b/se5/HI2UC/ViewModels/MainViewModel.cs @@ -0,0 +1,333 @@ +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.ViewModels; + +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] 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; + _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]; + + 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/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/Views/MainWindow.axaml b/se5/HI2UC/Views/MainWindow.axaml new file mode 100644 index 00000000..6aef67f4 --- /dev/null +++ b/se5/HI2UC/Views/MainWindow.axaml @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/HI2UC/Views/MainWindow.axaml.cs b/se5/HI2UC/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..b04f70be --- /dev/null +++ b/se5/HI2UC/Views/MainWindow.axaml.cs @@ -0,0 +1,59 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HI2UC.ViewModels; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.HI2UC.Views; + +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/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/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..8ec756cd --- /dev/null +++ b/se5/HIColorer.Tests/HiColorerTests.cs @@ -0,0 +1,93 @@ +using SubtitleEdit.Plugins.HIColorer.Logic; + +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..07dbfd1f --- /dev/null +++ b/se5/HIColorer.Tests/MainViewModelTests.cs @@ -0,0 +1,111 @@ +using Avalonia.Media; +using SubtitleEdit.Plugins.HIColorer.Logic; +using SubtitleEdit.Plugins.HIColorer.ViewModels; +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..17da0a98 --- /dev/null +++ b/se5/HIColorer/App.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HIColorer.Views; +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/Controls/SubtitleTextBlock.cs b/se5/HIColorer/Controls/SubtitleTextBlock.cs new file mode 100644 index 00000000..9f92467c --- /dev/null +++ b/se5/HIColorer/Controls/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.Controls; + +/// +/// 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/Controls/TextDiff.cs b/se5/HIColorer/Controls/TextDiff.cs new file mode 100644 index 00000000..377d121d --- /dev/null +++ b/se5/HIColorer/Controls/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.HIColorer.Controls; + +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/HIColorer.csproj b/se5/HIColorer/HIColorer.csproj new file mode 100644 index 00000000..ae774180 --- /dev/null +++ b/se5/HIColorer/HIColorer.csproj @@ -0,0 +1,30 @@ + + + + WinExe + net8.0 + 14 + enable + enable + HIColorer + SubtitleEdit.Plugins.HIColorer + true + app.manifest + + + + + + + + + + + + + + + + + + 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/Logic/HiColorer.cs b/se5/HIColorer/Logic/HiColorer.cs new file mode 100644 index 00000000..3edbe025 --- /dev/null +++ b/se5/HIColorer/Logic/HiColorer.cs @@ -0,0 +1,249 @@ +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.HIColorer.Logic; + +/// 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/Program.cs b/se5/HIColorer/Program.cs new file mode 100644 index 00000000..fadfecf7 --- /dev/null +++ b/se5/HIColorer/Program.cs @@ -0,0 +1,17 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.HIColorer.ViewModels; +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/ViewModels/ChangeItem.cs b/se5/HIColorer/ViewModels/ChangeItem.cs new file mode 100644 index 00000000..52be9e65 --- /dev/null +++ b/se5/HIColorer/ViewModels/ChangeItem.cs @@ -0,0 +1,28 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.HIColorer.Controls; + +namespace SubtitleEdit.Plugins.HIColorer.ViewModels; + +/// One subtitle line whose hearing-impaired text is colored. +public sealed partial class ChangeItem : ObservableObject +{ + [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; + 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/ViewModels/MainViewModel.cs b/se5/HIColorer/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..dbc4810f --- /dev/null +++ b/se5/HIColorer/ViewModels/MainViewModel.cs @@ -0,0 +1,391 @@ +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.ViewModels; + +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] 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; + _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(); + + 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/Views/MainWindow.axaml b/se5/HIColorer/Views/MainWindow.axaml new file mode 100644 index 00000000..2ad2f1ee --- /dev/null +++ b/se5/HIColorer/Views/MainWindow.axaml @@ -0,0 +1,376 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/HIColorer/Views/MainWindow.axaml.cs b/se5/HIColorer/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..bbe785fd --- /dev/null +++ b/se5/HIColorer/Views/MainWindow.axaml.cs @@ -0,0 +1,59 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.HIColorer.ViewModels; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.HIColorer.Views; + +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/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/LinesUnbreaker.Tests/LineUnbreakerTests.cs b/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs new file mode 100644 index 00000000..ae9d0784 --- /dev/null +++ b/se5/LinesUnbreaker.Tests/LineUnbreakerTests.cs @@ -0,0 +1,99 @@ +using SubtitleEdit.Plugins.LinesUnbreaker.Logic; + +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..58896ce0 --- /dev/null +++ b/se5/LinesUnbreaker.Tests/MainViewModelTests.cs @@ -0,0 +1,108 @@ +using SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; +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..f153e2ef --- /dev/null +++ b/se5/LinesUnbreaker/App.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.LinesUnbreaker.Views; +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/Controls/SubtitleTextBlock.cs b/se5/LinesUnbreaker/Controls/SubtitleTextBlock.cs new file mode 100644 index 00000000..a8cc7d35 --- /dev/null +++ b/se5/LinesUnbreaker/Controls/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.Controls; + +/// +/// 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/Controls/TextDiff.cs b/se5/LinesUnbreaker/Controls/TextDiff.cs new file mode 100644 index 00000000..06d5f8e0 --- /dev/null +++ b/se5/LinesUnbreaker/Controls/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.LinesUnbreaker.Controls; + +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/LinesUnbreaker.csproj b/se5/LinesUnbreaker/LinesUnbreaker.csproj new file mode 100644 index 00000000..82c37438 --- /dev/null +++ b/se5/LinesUnbreaker/LinesUnbreaker.csproj @@ -0,0 +1,29 @@ + + + + WinExe + net8.0 + 14 + enable + enable + LinesUnbreaker + SubtitleEdit.Plugins.LinesUnbreaker + true + app.manifest + + + + + + + + + + + + + + + + + diff --git a/se5/LinesUnbreaker/Logic/Line.cs b/se5/LinesUnbreaker/Logic/Line.cs new file mode 100644 index 00000000..885bfc77 --- /dev/null +++ b/se5/LinesUnbreaker/Logic/Line.cs @@ -0,0 +1,42 @@ +namespace SubtitleEdit.Plugins.LinesUnbreaker.Logic; + +/// 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/Logic/LineUnbreaker.cs b/se5/LinesUnbreaker/Logic/LineUnbreaker.cs new file mode 100644 index 00000000..2f25895b --- /dev/null +++ b/se5/LinesUnbreaker/Logic/LineUnbreaker.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; + +namespace SubtitleEdit.Plugins.LinesUnbreaker.Logic; + +/// 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("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 new file mode 100644 index 00000000..34ed0e86 --- /dev/null +++ b/se5/LinesUnbreaker/Program.cs @@ -0,0 +1,17 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; +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/ViewModels/ChangeItem.cs b/se5/LinesUnbreaker/ViewModels/ChangeItem.cs new file mode 100644 index 00000000..864cbb7f --- /dev/null +++ b/se5/LinesUnbreaker/ViewModels/ChangeItem.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.LinesUnbreaker.Controls; +using SubtitleEdit.Plugins.LinesUnbreaker.Logic; + +namespace SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; + +/// One subtitle whose lines are joined into one. +public sealed partial class ChangeItem : ObservableObject +{ + [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; + 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/ViewModels/MainViewModel.cs b/se5/LinesUnbreaker/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..1a2ae4d1 --- /dev/null +++ b/se5/LinesUnbreaker/ViewModels/MainViewModel.cs @@ -0,0 +1,325 @@ +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.ViewModels; + +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] 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; + _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; + } + + 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/Views/MainWindow.axaml b/se5/LinesUnbreaker/Views/MainWindow.axaml new file mode 100644 index 00000000..5f19f960 --- /dev/null +++ b/se5/LinesUnbreaker/Views/MainWindow.axaml @@ -0,0 +1,344 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/LinesUnbreaker/Views/MainWindow.axaml.cs b/se5/LinesUnbreaker/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..c59ac605 --- /dev/null +++ b/se5/LinesUnbreaker/Views/MainWindow.axaml.cs @@ -0,0 +1,59 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.LinesUnbreaker.ViewModels; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.LinesUnbreaker.Views; + +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/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 b4dde0ed..ce02b19b 100644 --- a/se5/Plugins.slnx +++ b/se5/Plugins.slnx @@ -10,10 +10,14 @@ + + + + @@ -25,10 +29,14 @@ + + + + @@ -42,10 +50,14 @@ + + + + diff --git a/se5/TightHyphen.Tests/HyphenTightenerTests.cs b/se5/TightHyphen.Tests/HyphenTightenerTests.cs new file mode 100644 index 00000000..fe6ca34f --- /dev/null +++ b/se5/TightHyphen.Tests/HyphenTightenerTests.cs @@ -0,0 +1,38 @@ +using SubtitleEdit.Plugins.TightHyphen.Logic; + +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..4c1d12a5 --- /dev/null +++ b/se5/TightHyphen.Tests/MainViewModelTests.cs @@ -0,0 +1,81 @@ +using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.ViewModels; +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..6caebafb --- /dev/null +++ b/se5/TightHyphen/App.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.Views; + +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/Controls/SubtitleTextBlock.cs b/se5/TightHyphen/Controls/SubtitleTextBlock.cs new file mode 100644 index 00000000..5e453a92 --- /dev/null +++ b/se5/TightHyphen/Controls/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.Controls; + +/// +/// 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/Controls/TextDiff.cs b/se5/TightHyphen/Controls/TextDiff.cs new file mode 100644 index 00000000..2cedf086 --- /dev/null +++ b/se5/TightHyphen/Controls/TextDiff.cs @@ -0,0 +1,138 @@ +namespace SubtitleEdit.Plugins.TightHyphen.Controls; + +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/Logic/HyphenTightener.cs b/se5/TightHyphen/Logic/HyphenTightener.cs new file mode 100644 index 00000000..a913cf1f --- /dev/null +++ b/se5/TightHyphen/Logic/HyphenTightener.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; + +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 +{ + [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/Program.cs b/se5/TightHyphen/Program.cs new file mode 100644 index 00000000..2a88c8f4 --- /dev/null +++ b/se5/TightHyphen/Program.cs @@ -0,0 +1,17 @@ +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.ViewModels; +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/TightHyphen.csproj b/se5/TightHyphen/TightHyphen.csproj new file mode 100644 index 00000000..c4f97e01 --- /dev/null +++ b/se5/TightHyphen/TightHyphen.csproj @@ -0,0 +1,29 @@ + + + + WinExe + net8.0 + 14 + enable + enable + TightHyphen + SubtitleEdit.Plugins.TightHyphen + true + app.manifest + + + + + + + + + + + + + + + + + diff --git a/se5/TightHyphen/ViewModels/ChangeItem.cs b/se5/TightHyphen/ViewModels/ChangeItem.cs new file mode 100644 index 00000000..cf0955a7 --- /dev/null +++ b/se5/TightHyphen/ViewModels/ChangeItem.cs @@ -0,0 +1,28 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.TightHyphen.Controls; + +namespace SubtitleEdit.Plugins.TightHyphen.ViewModels; + +/// One subtitle line whose dialog dash is tightened. +public sealed partial class ChangeItem : ObservableObject +{ + [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; + 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/ViewModels/MainViewModel.cs b/se5/TightHyphen/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..215d540b --- /dev/null +++ b/se5/TightHyphen/ViewModels/MainViewModel.cs @@ -0,0 +1,275 @@ +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.ViewModels; + +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] 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; + _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(); + + 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/Views/MainWindow.axaml b/se5/TightHyphen/Views/MainWindow.axaml new file mode 100644 index 00000000..e1f8501c --- /dev/null +++ b/se5/TightHyphen/Views/MainWindow.axaml @@ -0,0 +1,333 @@ + + + + + + + + + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se5/TightHyphen/Views/MainWindow.axaml.cs b/se5/TightHyphen/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..ebe0b9aa --- /dev/null +++ b/se5/TightHyphen/Views/MainWindow.axaml.cs @@ -0,0 +1,59 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.TightHyphen.ViewModels; + +namespace SubtitleEdit.Plugins.TightHyphen.Views; + +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/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" +}