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(""))
+ {
+ foreach (var tag in JoinedTags)
+ {
+ singleLine = singleLine.Replace(tag, " ");
+ }
+ }
+
+ return singleLine;
+ }
+}
diff --git a/se5/LinesUnbreaker/Logic/UnbreakOptions.cs b/se5/LinesUnbreaker/Logic/UnbreakOptions.cs
new file mode 100644
index 00000000..68f1c53d
--- /dev/null
+++ b/se5/LinesUnbreaker/Logic/UnbreakOptions.cs
@@ -0,0 +1,12 @@
+namespace SubtitleEdit.Plugins.LinesUnbreaker.Logic;
+
+public sealed class UnbreakOptions
+{
+ public const int DefaultMaxLength = 43;
+
+ /// Join only when the result, without tags, is at most this many characters.
+ public int MaxLength { get; set; } = DefaultMaxLength;
+ public bool SkipDialogs { get; set; } = true;
+ public bool SkipMoods { get; set; }
+ public bool SkipNarrator { get; set; } = true;
+}
diff --git a/se5/LinesUnbreaker/Program.cs b/se5/LinesUnbreaker/Program.cs
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"
+}