feat: modernize to .NET 8, split into Core/Cli/Gui (#3)

* feat: modernize to .NET 8, split into Core/Cli/Gui projects

Core: shared converter library (cross-platform)

Cli: console app with CLI args (cross-platform)

Gui: WinForms app (Windows only, uses Core library)

- Split into 3 projects sharing ConvertSRTto3DASS.Core

- Rewrite SrtConverter with clean public API

- Add CLI entry point with stdlib argument parsing

- Redesign GUI with GroupBox layout, flat buttons, modern fonts

- Update README with publish commands

* feat: add drag-and-drop to GUI

* refactor: rename GUI controls to proper PascalCase identifiers

---------

Co-authored-by: imrayya <imrayya@users.noreply.github.com>
This commit is contained in:
2026-06-24 18:02:05 +07:00
committed by GitHub
parent 743900f2f7
commit 40ce11d068
27 changed files with 961 additions and 1839 deletions
@@ -0,0 +1,21 @@
namespace ConvertSRTto3DASS;
public class ConversionOptions
{
public string InputPath { get; set; } = "";
public string OutputPath { get; set; } = "";
public string Mode { get; set; } = "sbs"; // sbs, ou, rg
public int ResX { get; set; } = 1280;
public int ResY { get; set; } = 720;
public int BaseResX { get; set; } = 1280;
public int BaseResY { get; set; } = 720;
public int FontSize { get; set; } = 16;
public int OffsetX { get; set; } = 4;
public int BottomOffset { get; set; } = 18;
public int SbsSideMargin { get; set; } = 640;
public int OuTopMargin { get; set; } = 385;
public int VerticalMargin { get; set; } = 25;
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>imrayya</Company>
<Copyright>Copyright © imrayya 2021-2026</Copyright>
<RootNamespace>ConvertSRTto3DASS</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<Optimize>true</Optimize>
</PropertyGroup>
</Project>
+273
View File
@@ -0,0 +1,273 @@
using System.Text;
using System.Text.RegularExpressions;
namespace ConvertSRTto3DASS;
/// <summary>
/// Core SRT-to-ASS conversion logic for 3D subtitle formats.
/// </summary>
public static class SrtConverter
{
public class ConversionResult
{
public string OutputText { get; set; } = "";
public int SubtitleCount { get; set; }
}
public static ConversionResult Convert(ConversionOptions options)
{
var srtText = ReadSrt(options.InputPath);
var subtitles = ParseSrt(srtText);
var header = CreateHeader(options);
var styles = CreateStyles(options);
var events = GenerateEvents(subtitles, options);
var output =
"[Script Info]\n" +
header +
"\n\n[V4+ Styles]\n" +
styles +
"\n\n[Events]\n" +
events;
File.WriteAllText(options.OutputPath, output, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
return new ConversionResult
{
OutputText = output,
SubtitleCount = subtitles.Count
};
}
#region SRT Parsing
private static string ReadSrt(string path)
{
using var reader = new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var text = reader.ReadToEnd();
// Fallback to Windows-1252 if we see replacement characters
if (text.Contains('\uFFFD'))
text = File.ReadAllText(path, Encoding.GetEncoding(1252));
return text;
}
private static List<SrtSubtitle> ParseSrt(string srtText)
{
var results = new List<SrtSubtitle>();
srtText = srtText.Replace("\r\n", "\n").Replace("\r", "\n").Trim();
var blocks = Regex.Split(srtText, @"\n\s*\n");
int expectedNumber = 1;
foreach (var block in blocks)
{
var lines = block.Split('\n', StringSplitOptions.TrimEntries)
.Where(l => l.Length > 0)
.ToList();
if (lines.Count < 3) continue;
if (!int.TryParse(lines[0], out var number)) continue;
if (number != expectedNumber)
{
Console.Error.WriteLine($"Warning: expected subtitle number {expectedNumber}, but found {number}.");
expectedNumber = number;
}
expectedNumber++;
var timeMatch = Regex.Match(lines[1],
@"^(?<start>\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(?<end>\d{2}:\d{2}:\d{2},\d{3})$");
if (!timeMatch.Success)
{
Console.Error.WriteLine($"Skipping malformed timestamp: [{lines[1]}]");
continue;
}
var text = string.Join("\\N", lines.Skip(2));
text = NormalizeFormatting(text);
results.Add(new SrtSubtitle
{
Start = timeMatch.Groups["start"].Value,
End = timeMatch.Groups["end"].Value,
Text = text
});
}
return results;
}
#endregion
#region Formatting
private static readonly Dictionary<Regex, string> _tagReplacements = new()
{
{ new Regex("<b>", RegexOptions.IgnoreCase), "{\\b1}" },
{ new Regex("</b>", RegexOptions.IgnoreCase), "{\\b0}" },
{ new Regex("<i>", RegexOptions.IgnoreCase), "{\\i1}" },
{ new Regex("</i>", RegexOptions.IgnoreCase), "{\\i0}" },
{ new Regex("<u>", RegexOptions.IgnoreCase), "{\\u1}" },
{ new Regex("</u>", RegexOptions.IgnoreCase), "{\\u0}" },
{ new Regex("</font>", RegexOptions.IgnoreCase), "{\\c&HFFFFFF&}" }
};
private static readonly Regex _colorRegex =
new Regex("<font color=\"#([0-9A-Fa-f]{6})\">", RegexOptions.IgnoreCase);
private static readonly Regex _markupRegex =
new Regex("<.+?>|(\\r)", RegexOptions.IgnoreCase);
private static string NormalizeFormatting(string text)
{
foreach (var (pattern, replacement) in _tagReplacements)
text = pattern.Replace(text, replacement);
// Convert HTML color to ASS BBGGRR format
while (_colorRegex.IsMatch(text))
{
var match = _colorRegex.Match(text);
var rgb = match.Groups[1].Value;
var assColor = rgb.Substring(4, 2) + rgb.Substring(2, 2) + rgb.Substring(0, 2);
text = _colorRegex.Replace(text, "{\\c&H" + assColor + "&}", 1);
}
return _markupRegex.Replace(text, "");
}
#endregion
#region Time & Scaling
private static string ConvertTimestamp(string timestamp)
{
var tmp = timestamp.Substring(1);
tmp = tmp.Substring(0, tmp.Length - 1);
return tmp.Replace(",", ".");
}
private static int ScaleX(int value, ConversionOptions o) =>
(int)Math.Round(value * (o.ResX / (double)o.BaseResX));
private static int ScaleY(int value, ConversionOptions o) =>
(int)Math.Round(value * (o.ResY / (double)o.BaseResY));
private static int ScaleFont(int fontSize, ConversionOptions o) =>
Math.Max(1, ScaleY(fontSize, o));
#endregion
#region ASS Output Generation
private static string CreateHeader(ConversionOptions o) =>
$"; Generated by SRTto3Dsubtitles\n" +
$"Title: {Path.GetFileNameWithoutExtension(o.InputPath)}\n" +
"ScriptType: v4.00+\n" +
"Collisions: Normal\n" +
$"PlayResX: {o.ResX}\n" +
$"PlayResY: {o.ResY}\n" +
"ScaledBorderAndShadow: yes";
private static string CreateStyles(ConversionOptions o)
{
var fontSize = ScaleFont(o.FontSize, o);
return o.Mode.ToLowerInvariant() switch
{
"ou" => CreateOuStyles(fontSize, o),
"rg" => CreateRgStyles(fontSize, o),
_ => CreateSbsStyles(fontSize, o)
};
}
private static string CreateSbsStyles(int fontSize, ConversionOptions o)
{
var sideMargin = ScaleX(o.SbsSideMargin, o);
var verticalMargin = ScaleY(o.VerticalMargin, o);
return FormatStyle("Right", fontSize, sideMargin, 0, verticalMargin) + "\n" +
FormatStyle("Left", fontSize, 0, sideMargin, verticalMargin);
}
private static string CreateOuStyles(int fontSize, ConversionOptions o)
{
var topMargin = ScaleY(o.OuTopMargin, o);
var bottomMargin = ScaleY(o.VerticalMargin, o);
return FormatStyle("Top", fontSize, 0, 0, topMargin) + "\n" +
FormatStyle("Bottom", fontSize, 0, 0, bottomMargin);
}
private static string CreateRgStyles(int fontSize, ConversionOptions o)
{
var verticalMargin = ScaleY(o.VerticalMargin, o);
// Red = &H0000FF, Green = &H00FF00 (ASS uses BBGGRR)
return FormatStyle("RedEye", fontSize, 0, 0, verticalMargin, primary: "&H0000FF") + "\n" +
FormatStyle("GreenEye", fontSize, 0, 0, verticalMargin, primary: "&H00FF00");
}
private static string FormatStyle(string name, int fontSize, int marginL, int marginR, int marginV,
string primary = "&HFFFFFF")
{
return $"Style: {name},Arial,{fontSize},{primary},{primary},&H000000,&H000000," +
$"0,0,0,0,100,100,0,0,1,1,0,2," +
$"{marginL},{marginR},{marginV},0";
}
private static string GenerateEvents(List<SrtSubtitle> subs, ConversionOptions o)
{
var sb = new StringBuilder();
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
foreach (var sub in subs)
{
var start = ConvertTimestamp(sub.Start);
var end = ConvertTimestamp(sub.End);
var text = sub.Text;
switch (o.Mode.ToLowerInvariant())
{
case "sbs":
sb.AppendLine($"Dialogue: 0,{start},{end},Right,,0,0,0,,{text}");
sb.AppendLine($"Dialogue: 1,{start},{end},Left,,0,0,0,,{text}");
break;
case "ou":
sb.AppendLine($"Dialogue: 0,{start},{end},Top,,0,0,0,,{text}");
sb.AppendLine($"Dialogue: 1,{start},{end},Bottom,,0,0,0,,{text}");
break;
case "rg":
var centerX = o.ResX / 2;
var scaledBottomOffset = ScaleY(o.BottomOffset, o);
var y = o.ResY - scaledBottomOffset;
var scaledOffsetX = ScaleX(o.OffsetX, o);
var redX = centerX - scaledOffsetX;
var greenX = centerX + scaledOffsetX;
sb.AppendLine($"Dialogue: 0,{start},{end},RedEye,,0,0,0,,{{\\an2\\pos({redX},{y})}}{text}");
sb.AppendLine($"Dialogue: 1,{start},{end},GreenEye,,0,0,0,,{{\\an2\\pos({greenX},{y})}}{text}");
break;
}
}
return sb.ToString();
}
#endregion
#region Data Classes
private class SrtSubtitle
{
public string Start { get; set; } = "";
public string End { get; set; } = "";
public string Text { get; set; } = "";
}
#endregion
}