diff --git a/.github/workflows/typewriter.yml b/.github/workflows/typewriter.yml
new file mode 100644
index 0000000..7aca494
--- /dev/null
+++ b/.github/workflows/typewriter.yml
@@ -0,0 +1,107 @@
+name: Build TypewriterEffect (SE5)
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'se5/TypewriterEffect/**'
+ - '.github/workflows/typewriter.yml'
+ pull_request:
+ paths:
+ - 'se5/TypewriterEffect/**'
+ - '.github/workflows/typewriter.yml'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Release tag to publish the zips under (e.g. se5-typewriter-v1.0). Leave empty to build artifacts only.'
+ required: false
+ type: string
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: win-x64
+ os: windows
+ exe: TypewriterEffect.exe
+ - rid: win-arm64
+ os: windows
+ exe: TypewriterEffect.exe
+ - rid: linux-x64
+ os: linux
+ exe: TypewriterEffect
+ - rid: linux-arm64
+ os: linux
+ exe: TypewriterEffect
+ - rid: osx-x64
+ os: macos
+ exe: TypewriterEffect
+ - rid: osx-arm64
+ os: macos
+ exe: TypewriterEffect
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Publish self-contained (${{ matrix.rid }})
+ working-directory: se5/TypewriterEffect
+ run: |
+ dotnet publish TypewriterEffect.csproj \
+ -c Release \
+ -r ${{ matrix.rid }} \
+ --self-contained true \
+ -p:PublishSingleFile=true \
+ -p:IncludeNativeLibrariesForSelfExtract=true \
+ -o staging/TypewriterEffect
+
+ - name: Rewrite plugin.json for ${{ matrix.os }}
+ working-directory: se5/TypewriterEffect
+ run: |
+ jq '
+ del(.runtime, .entry) |
+ .executables = { "${{ matrix.os }}": "${{ matrix.exe }}" }
+ ' plugin.json > staging/TypewriterEffect/plugin.json
+
+ - name: Package zip
+ working-directory: se5/TypewriterEffect/staging
+ run: zip -r "$GITHUB_WORKSPACE/TypewriterEffect-${{ matrix.rid }}.zip" TypewriterEffect
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: TypewriterEffect-${{ matrix.rid }}
+ path: TypewriterEffect-${{ matrix.rid }}.zip
+
+ release:
+ name: Publish GitHub Release
+ needs: build
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.tag != ''
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Download all zips
+ uses: actions/download-artifact@v4
+ with:
+ path: dist
+ pattern: TypewriterEffect-*
+ merge-multiple: true
+
+ - name: Create release and upload zips
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "${{ github.event.inputs.tag }}" dist/TypewriterEffect-*.zip \
+ --repo "${{ github.repository }}" \
+ --target "${{ github.sha }}" \
+ --title "TypewriterEffect ${{ github.event.inputs.tag }}" \
+ --notes "Self-contained TypewriterEffect plugin builds for win/linux/osx (x64 + arm64)."
diff --git a/se5-plugins.json b/se5-plugins.json
index ae76ba2..add5a84 100644
--- a/se5-plugins.json
+++ b/se5-plugins.json
@@ -1,5 +1,22 @@
{
"plugins": [
+ {
+ "name": "Typewriter effect",
+ "description": "Splits each subtitle line into short timed parts that progressively reveal the text, character by character.",
+ "version": "1.0.0",
+ "author": "Subtitle Edit",
+ "url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/TypewriterEffect",
+ "date": "2026-05-17",
+ "minSeVersion": "5.0.0",
+ "downloads": {
+ "win-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-win-x64.zip",
+ "win-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-win-arm64.zip",
+ "linux-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-linux-x64.zip",
+ "linux-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-linux-arm64.zip",
+ "osx-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-osx-x64.zip",
+ "osx-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-osx-arm64.zip"
+ }
+ },
{
"name": "Haxor",
"description": "Translates the text of the selected lines (or all lines) to haxor.",
diff --git a/se5/TypewriterEffect/App.axaml b/se5/TypewriterEffect/App.axaml
new file mode 100644
index 0000000..c802d73
--- /dev/null
+++ b/se5/TypewriterEffect/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/se5/TypewriterEffect/App.axaml.cs b/se5/TypewriterEffect/App.axaml.cs
new file mode 100644
index 0000000..6e1699b
--- /dev/null
+++ b/se5/TypewriterEffect/App.axaml.cs
@@ -0,0 +1,34 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Styling;
+
+namespace SubtitleEdit.Plugins.TypewriterEffect;
+
+public partial class App : Application
+{
+ /// Set by Program.Main before Avalonia starts.
+ public static PluginRequest? PendingRequest;
+
+ /// Filled in by MainWindow when the user clicks OK or Cancel.
+ public static PluginResponse? Response;
+
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (PendingRequest is not null)
+ {
+ RequestedThemeVariant = string.Equals(PendingRequest.Theme, "Dark", System.StringComparison.OrdinalIgnoreCase)
+ ? ThemeVariant.Dark
+ : ThemeVariant.Light;
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow(PendingRequest);
+ }
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/se5/TypewriterEffect/MainWindow.axaml b/se5/TypewriterEffect/MainWindow.axaml
new file mode 100644
index 0000000..bfc130c
--- /dev/null
+++ b/se5/TypewriterEffect/MainWindow.axaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/se5/TypewriterEffect/MainWindow.axaml.cs b/se5/TypewriterEffect/MainWindow.axaml.cs
new file mode 100644
index 0000000..270738a
--- /dev/null
+++ b/se5/TypewriterEffect/MainWindow.axaml.cs
@@ -0,0 +1,90 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using System;
+using System.Text.Json;
+
+namespace SubtitleEdit.Plugins.TypewriterEffect;
+
+public partial class MainWindow : Window
+{
+ private readonly PluginRequest _request;
+
+ // Parameterless constructor only exists for the XAML designer.
+ public MainWindow() : this(new PluginRequest()) { }
+
+ public MainWindow(PluginRequest request)
+ {
+ InitializeComponent();
+ _request = request;
+
+ var selectedCount = request.SelectedIndices.Count;
+ InfoLabel.Text = selectedCount > 0
+ ? $"Each of the {selectedCount} selected line(s) will be split into several short lines that progressively reveal the text."
+ : "Every line will be split into several short lines that progressively reveal the text.";
+
+ EndDelayInput.Value = (decimal)LoadEndDelaySetting(request.Settings, defaultValue: 0.5);
+ }
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ private void OnCancel(object? sender, RoutedEventArgs e)
+ {
+ App.Response = new PluginResponse { Status = "cancelled" };
+ Close();
+ }
+
+ private void OnOk(object? sender, RoutedEventArgs e)
+ {
+ var endDelaySec = (double)(EndDelayInput.Value ?? 0m);
+
+ try
+ {
+ var blocks = SubRipParser.Parse(_request.Subtitle.SubRip);
+ var selected = new HashSet(_request.SelectedIndices);
+ var (resultBlocks, changed) = TypewriterEngine.Apply(blocks, selected, endDelaySec);
+
+ App.Response = new PluginResponse
+ {
+ Status = "ok",
+ Message = changed == 0
+ ? "No lines changed."
+ : $"Typewriter applied to {changed} line(s).",
+ UndoDescription = "Typewriter effect",
+ Subtitle = new PluginSubtitle
+ {
+ Format = "SubRip",
+ Native = SubRipParser.Serialize(resultBlocks),
+ },
+ Settings = BuildSettings(endDelaySec),
+ };
+ }
+ catch (Exception ex)
+ {
+ App.Response = new PluginResponse { Status = "error", Message = ex.Message };
+ }
+
+ Close();
+ }
+
+ private static double LoadEndDelaySetting(JsonElement? settings, double defaultValue)
+ {
+ if (settings is null || settings.Value.ValueKind != JsonValueKind.Object)
+ {
+ return defaultValue;
+ }
+ if (settings.Value.TryGetProperty("endDelay", out var value) &&
+ value.ValueKind == JsonValueKind.Number &&
+ value.TryGetDouble(out var parsed))
+ {
+ return parsed;
+ }
+ return defaultValue;
+ }
+
+ private static JsonElement BuildSettings(double endDelay)
+ {
+ using var doc = JsonDocument.Parse($"{{\"endDelay\":{endDelay.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)}}}");
+ return doc.RootElement.Clone();
+ }
+}
diff --git a/se5/TypewriterEffect/PluginContract.cs b/se5/TypewriterEffect/PluginContract.cs
new file mode 100644
index 0000000..5c1e16e
--- /dev/null
+++ b/se5/TypewriterEffect/PluginContract.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+
+namespace SubtitleEdit.Plugins.TypewriterEffect;
+
+// Mirrors the Subtitle Edit 5 plugin JSON contract.
+// See https://github.com/SubtitleEdit/subtitleedit/blob/main/docs/plugin.md
+
+public sealed class PluginRequest
+{
+ public int ApiVersion { get; set; } = 1;
+ public string RequestType { get; set; } = "run";
+ public string ResponseFilePath { get; set; } = string.Empty;
+ public string TempDirectory { get; set; } = string.Empty;
+ public PluginSubtitle Subtitle { get; set; } = new();
+ public List SelectedIndices { get; set; } = new();
+ public string VideoFileName { get; set; } = string.Empty;
+ public double FrameRate { get; set; }
+ public string UiLanguage { get; set; } = string.Empty;
+ public string Theme { get; set; } = string.Empty;
+ public string SeVersion { get; set; } = string.Empty;
+ public JsonElement? Settings { get; set; }
+}
+
+public sealed class PluginSubtitle
+{
+ public string Format { get; set; } = string.Empty;
+ public string FileName { get; set; } = string.Empty;
+ public string Native { get; set; } = string.Empty;
+ public string SubRip { get; set; } = string.Empty;
+}
+
+public sealed class PluginResponse
+{
+ public int ApiVersion { get; set; } = 1;
+ public string Status { get; set; } = "cancelled";
+ public string? Message { get; set; }
+ public PluginSubtitle? Subtitle { get; set; }
+ public JsonElement? Settings { get; set; }
+ public string? UndoDescription { get; set; }
+}
diff --git a/se5/TypewriterEffect/Program.cs b/se5/TypewriterEffect/Program.cs
new file mode 100644
index 0000000..b307493
--- /dev/null
+++ b/se5/TypewriterEffect/Program.cs
@@ -0,0 +1,63 @@
+using Avalonia;
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SubtitleEdit.Plugins.TypewriterEffect;
+
+// A Subtitle Edit 5 plugin is just an executable:
+// 1. read the request file (its path is the first command-line argument),
+// 2. show the Avalonia window,
+// 3. write the response file (path is given in the request),
+// 4. exit with code 0.
+public static class Program
+{
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = true,
+ };
+
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ if (args.Length < 1)
+ {
+ Console.Error.WriteLine("Usage: TypewriterEffect ");
+ return 1;
+ }
+
+ PluginRequest? request;
+ try
+ {
+ request = JsonSerializer.Deserialize(File.ReadAllText(args[0]), JsonOpts);
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine("Could not read request: " + exception.Message);
+ return 1;
+ }
+
+ if (request is null || string.IsNullOrEmpty(request.ResponseFilePath))
+ {
+ Console.Error.WriteLine("Invalid request.");
+ return 1;
+ }
+
+ App.PendingRequest = request;
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(Array.Empty());
+
+ var response = App.Response ?? new PluginResponse { Status = "cancelled" };
+ File.WriteAllText(request.ResponseFilePath, JsonSerializer.Serialize(response, JsonOpts));
+ return 0;
+ }
+
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/se5/TypewriterEffect/README.md b/se5/TypewriterEffect/README.md
new file mode 100644
index 0000000..7dbe92c
--- /dev/null
+++ b/se5/TypewriterEffect/README.md
@@ -0,0 +1,37 @@
+# Typewriter effect (Subtitle Edit 5 plugin)
+
+Splits each selected subtitle line into a sequence of short, timed lines that
+progressively reveal the text — character by character — over the same on-screen
+duration. Optionally holds the fully-typed line for an *end delay* before the
+original end time.
+
+Ported from the SE4 *Effect → Typewriter…* dialog, repackaged as a standalone
+Avalonia 11 desktop app that communicates with Subtitle Edit through the
+[SE5 plugin JSON contract](https://github.com/SubtitleEdit/subtitleedit/blob/main/docs/plugin.md).
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `plugin.json` | Manifest. Currently uses `runtime: dotnet` (the build workflow rewrites it to per-platform `executables` for release zips). |
+| `PluginContract.cs` | Request/response DTOs (the JSON contract). |
+| `Program.cs` | Reads the request, boots Avalonia, writes the response. |
+| `App.axaml(.cs)` | Avalonia application. Picks `Dark` or `Light` theme variant based on `request.theme`. |
+| `MainWindow.axaml(.cs)` | The settings dialog (end-delay numeric input + OK/Cancel). |
+| `SubRipParser.cs` | Minimal SRT parser/serializer. |
+| `TypewriterEngine.cs` | Generates the exploded paragraphs from the original ones. |
+
+## Build (local)
+
+```
+dotnet publish TypewriterEffect.csproj -c Release -r --self-contained -o publish
+```
+
+## CI builds
+
+`.github/workflows/typewriter.yml` does a matrix self-contained publish for the
+six supported RIDs (`win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`,
+`osx-x64`, `osx-arm64`), rewrites `plugin.json` to use the per-OS `executables`
+block, and (on manual dispatch with a tag) attaches all six zips to a GitHub
+release. The `se5-plugins.json` index then points at the release URLs via its
+per-platform `downloads` map.
diff --git a/se5/TypewriterEffect/SubRipParser.cs b/se5/TypewriterEffect/SubRipParser.cs
new file mode 100644
index 0000000..e280530
--- /dev/null
+++ b/se5/TypewriterEffect/SubRipParser.cs
@@ -0,0 +1,88 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SubtitleEdit.Plugins.TypewriterEffect;
+
+public sealed class SrtBlock
+{
+ public long StartMs { get; set; }
+ public long EndMs { get; set; }
+ public string Text { get; set; } = string.Empty;
+}
+
+public static class SubRipParser
+{
+ private static readonly Regex TimeLine = new(
+ @"^(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})",
+ RegexOptions.Compiled);
+
+ public static List Parse(string srt)
+ {
+ var result = new List();
+ if (string.IsNullOrWhiteSpace(srt))
+ {
+ return result;
+ }
+
+ var normalized = srt.Replace("\r\n", "\n").Trim('\n');
+ var rawBlocks = Regex.Split(normalized, @"\n[ \t]*\n");
+ foreach (var raw in rawBlocks)
+ {
+ var lines = raw.Split('\n');
+ // lines[0] = sequence number, lines[1] = times, lines[2..] = text
+ if (lines.Length < 3)
+ {
+ continue;
+ }
+
+ var timeMatch = TimeLine.Match(lines[1]);
+ if (!timeMatch.Success)
+ {
+ continue;
+ }
+
+ var startMs = ToMs(timeMatch, 1);
+ var endMs = ToMs(timeMatch, 5);
+ var text = string.Join("\n", lines, 2, lines.Length - 2);
+ result.Add(new SrtBlock { StartMs = startMs, EndMs = endMs, Text = text });
+ }
+
+ return result;
+ }
+
+ public static string Serialize(IList blocks)
+ {
+ var sb = new StringBuilder();
+ for (var i = 0; i < blocks.Count; i++)
+ {
+ sb.Append(i + 1).Append('\n');
+ sb.Append(FormatTime(blocks[i].StartMs)).Append(" --> ").Append(FormatTime(blocks[i].EndMs)).Append('\n');
+ sb.Append(blocks[i].Text).Append('\n').Append('\n');
+ }
+
+ return sb.ToString().Replace("\n", "\r\n");
+ }
+
+ private static long ToMs(Match m, int firstGroup)
+ {
+ var h = int.Parse(m.Groups[firstGroup].Value, CultureInfo.InvariantCulture);
+ var mn = int.Parse(m.Groups[firstGroup + 1].Value, CultureInfo.InvariantCulture);
+ var s = int.Parse(m.Groups[firstGroup + 2].Value, CultureInfo.InvariantCulture);
+ var ms = int.Parse(m.Groups[firstGroup + 3].Value, CultureInfo.InvariantCulture);
+ return ((h * 60L + mn) * 60 + s) * 1000 + ms;
+ }
+
+ private static string FormatTime(long ms)
+ {
+ if (ms < 0)
+ {
+ ms = 0;
+ }
+ var h = ms / 3_600_000;
+ var mn = (ms / 60_000) % 60;
+ var s = (ms / 1000) % 60;
+ var msr = ms % 1000;
+ return $"{h:D2}:{mn:D2}:{s:D2},{msr:D3}";
+ }
+}
diff --git a/se5/TypewriterEffect/TypewriterEffect.csproj b/se5/TypewriterEffect/TypewriterEffect.csproj
new file mode 100644
index 0000000..ba50523
--- /dev/null
+++ b/se5/TypewriterEffect/TypewriterEffect.csproj
@@ -0,0 +1,22 @@
+
+
+
+ WinExe
+ net8.0
+ enable
+ enable
+ TypewriterEffect
+ SubtitleEdit.Plugins.TypewriterEffect
+ true
+ true
+ app.manifest
+
+
+
+
+
+
+
+
+
+
diff --git a/se5/TypewriterEffect/TypewriterEngine.cs b/se5/TypewriterEffect/TypewriterEngine.cs
new file mode 100644
index 0000000..6f656e0
--- /dev/null
+++ b/se5/TypewriterEffect/TypewriterEngine.cs
@@ -0,0 +1,219 @@
+using System.Text;
+
+namespace SubtitleEdit.Plugins.TypewriterEffect;
+
+///
+/// Port of the SE4 EffectTypewriter logic: each selected paragraph is replaced
+/// by N short paragraphs that progressively reveal the text character by
+/// character, optionally followed by a final paragraph holding the full line
+/// for before the original end time.
+///
+public static class TypewriterEngine
+{
+ public static (List Blocks, int ChangedCount) Apply(
+ List blocks,
+ HashSet selectedIndices,
+ double endDelaySeconds)
+ {
+ var output = new List(blocks.Count);
+ var changed = 0;
+ for (var i = 0; i < blocks.Count; i++)
+ {
+ var block = blocks[i];
+ // Empty SelectedIndices means "apply to every line" (matches plugin contract).
+ var isSelected = selectedIndices.Count == 0 || selectedIndices.Contains(i);
+ if (!isSelected)
+ {
+ output.Add(block);
+ continue;
+ }
+
+ var generated = GenerateForParagraph(block, endDelaySeconds);
+ if (generated.Count > 0)
+ {
+ output.AddRange(generated);
+ changed++;
+ }
+ else
+ {
+ output.Add(block);
+ }
+ }
+
+ return (output, changed);
+ }
+
+ private static List GenerateForParagraph(SrtBlock p, double endDelaySeconds)
+ {
+ var totalMs = p.EndMs - p.StartMs;
+ if (totalMs <= 500 || string.IsNullOrEmpty(p.Text))
+ {
+ return new List();
+ }
+
+ var endDelayMs = (long)(endDelaySeconds * 1000);
+ if (endDelayMs >= totalMs - 200)
+ {
+ endDelayMs = Math.Max(0, totalMs - 200);
+ }
+
+ var workDurationMs = totalMs - endDelayMs;
+ var visibleCharCount = CountVisibleChars(p.Text);
+ if (visibleCharCount == 0)
+ {
+ return new List();
+ }
+
+ var stepMs = workDurationMs / (double)visibleCharCount;
+
+ var result = new List();
+ var text = string.Empty;
+ var tag = string.Empty;
+ var beforeEndTag = string.Empty;
+ var alignment = string.Empty;
+ var tagOn = false;
+ var index = 0;
+ var i = 0;
+
+ // Eat any leading ASSA override block(s) into "alignment" so it prefixes every emitted paragraph.
+ if (p.Text.StartsWith("{\\", StringComparison.Ordinal))
+ {
+ var j = 0;
+ while (j < p.Text.Length && p.Text[j] == '{' && j + 1 < p.Text.Length && p.Text[j + 1] == '\\')
+ {
+ var close = p.Text.IndexOf('}', j);
+ if (close < 0)
+ {
+ break;
+ }
+ j = close + 1;
+ }
+ alignment = p.Text.Substring(0, j);
+ i = j;
+ }
+
+ while (i < p.Text.Length)
+ {
+ if (p.Text[i] == '{' && i + 1 < p.Text.Length && p.Text[i + 1] == '\\')
+ {
+ var endIndex = p.Text.IndexOf('}', i);
+ if (endIndex >= 0)
+ {
+ // Inline ASSA override - keep it attached to the current text but don't tick the clock.
+ text += p.Text.Substring(i, endIndex - i + 1);
+ i = endIndex + 1;
+ continue;
+ }
+ }
+
+ if (tagOn)
+ {
+ tag += p.Text[i];
+ if (p.Text[i] == '>')
+ {
+ tagOn = false;
+ var lowered = tag.ToLowerInvariant();
+ if (lowered.StartsWith("")
+ {
+ beforeEndTag = "";
+ }
+ else if (lowered == "")
+ {
+ beforeEndTag = "";
+ }
+ else if (lowered == "")
+ {
+ beforeEndTag = "";
+ }
+ else if (lowered.StartsWith("", StringComparison.Ordinal))
+ {
+ beforeEndTag = string.Empty;
+ }
+ }
+ }
+ else if (p.Text[i] == '<')
+ {
+ tagOn = true;
+ tag = "<";
+ }
+ else
+ {
+ text += tag + p.Text[i];
+ tag = string.Empty;
+
+ var startMs = p.StartMs + (long)(index * stepMs);
+ var endMs = p.StartMs + (long)((index + 1) * stepMs) - 1;
+ if (endMs < startMs)
+ {
+ endMs = startMs;
+ }
+ result.Add(new SrtBlock
+ {
+ StartMs = startMs,
+ EndMs = endMs,
+ Text = alignment + text + beforeEndTag,
+ });
+ index++;
+ }
+
+ i++;
+ }
+
+ if (endDelayMs > 0 && result.Count > 0)
+ {
+ var startMs = p.StartMs + workDurationMs;
+ result.Add(new SrtBlock { StartMs = startMs, EndMs = p.EndMs, Text = p.Text });
+ }
+ else if (result.Count > 0)
+ {
+ result[^1].EndMs = p.EndMs;
+ }
+
+ return result;
+ }
+
+ private static int CountVisibleChars(string text)
+ {
+ var count = 0;
+ var i = 0;
+ var inTag = false;
+ while (i < text.Length)
+ {
+ if (text[i] == '{' && i + 1 < text.Length && text[i + 1] == '\\')
+ {
+ var end = text.IndexOf('}', i);
+ if (end >= 0)
+ {
+ i = end + 1;
+ continue;
+ }
+ }
+
+ if (text[i] == '<')
+ {
+ inTag = true;
+ i++;
+ continue;
+ }
+
+ if (text[i] == '>')
+ {
+ inTag = false;
+ i++;
+ continue;
+ }
+
+ if (!inTag)
+ {
+ count++;
+ }
+
+ i++;
+ }
+ return count;
+ }
+}
diff --git a/se5/TypewriterEffect/app.manifest b/se5/TypewriterEffect/app.manifest
new file mode 100644
index 0000000..6473d20
--- /dev/null
+++ b/se5/TypewriterEffect/app.manifest
@@ -0,0 +1,10 @@
+
+
+
+
+
+ true/pm
+ PerMonitorV2
+
+
+
diff --git a/se5/TypewriterEffect/plugin.json b/se5/TypewriterEffect/plugin.json
new file mode 100644
index 0000000..8e630f6
--- /dev/null
+++ b/se5/TypewriterEffect/plugin.json
@@ -0,0 +1,12 @@
+{
+ "apiVersion": 1,
+ "name": "Typewriter effect",
+ "description": "Splits each subtitle line into short timed parts that progressively reveal the text, character by character.",
+ "version": "1.0.0",
+ "author": "Subtitle Edit",
+ "url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/TypewriterEffect",
+ "menu": "Tools",
+ "minSeVersion": "5.0.0",
+ "runtime": "dotnet",
+ "entry": "TypewriterEffect.dll"
+}