using System.Text;
using System.Text.RegularExpressions;
namespace ConvertSRTto3DASS;
///
/// Core SRT-to-ASS conversion logic for 3D subtitle formats.
///
public static class SrtConverter
{
public class ConversionResult
{
public string OutputText { get; set; } = "";
public int SubtitleCount { get; set; }
}
public static ConversionResult Convert(ConversionOptions options)
{
// Register legacy codepage support (needed for CP1252, etc.)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
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 ParseSrt(string srtText)
{
var results = new List();
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],
@"^(?\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(?\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 _tagReplacements = new()
{
{ new Regex("", RegexOptions.IgnoreCase), "{\\b1}" },
{ new Regex("", RegexOptions.IgnoreCase), "{\\b0}" },
{ new Regex("", RegexOptions.IgnoreCase), "{\\i1}" },
{ new Regex("", RegexOptions.IgnoreCase), "{\\i0}" },
{ new Regex("", RegexOptions.IgnoreCase), "{\\u1}" },
{ new Regex("", RegexOptions.IgnoreCase), "{\\u0}" },
{ new Regex("", RegexOptions.IgnoreCase), "{\\c&HFFFFFF&}" }
};
private static readonly Regex _colorRegex =
new Regex("", 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 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
}