From 11811aa078f59f9ff7b2e2b7249b2d65c8fde59c Mon Sep 17 00:00:00 2001 From: pdjdev Date: Fri, 10 Jul 2026 22:13:09 +0900 Subject: [PATCH] Fix WebVTT cue setting preservation --- src/libse/ContainerFormats/Mp4/Mp4Parser.cs | 10 +- src/libse/SubtitleFormats/WebVTT.cs | 128 +++++++----------- .../WebVTTFileWithLineNumber.cs | 18 +-- tests/libse/SubtitleFormats/WebVttTest.cs | 68 ++++++++++ 4 files changed, 131 insertions(+), 93 deletions(-) diff --git a/src/libse/ContainerFormats/Mp4/Mp4Parser.cs b/src/libse/ContainerFormats/Mp4/Mp4Parser.cs index 41fe50dbffd..b971153f935 100644 --- a/src/libse/ContainerFormats/Mp4/Mp4Parser.cs +++ b/src/libse/ContainerFormats/Mp4/Mp4Parser.cs @@ -706,11 +706,17 @@ private void ReadVttWithSize(Mdat mdat, List trunSamples, ref doubl private void AddVttParagraph(double timeTotalMs, string payload, double before, string style) { var p = new Paragraph(payload, before, timeTotalMs); - var positionInfo = WebVTT.GetPositionInfo(style); + var positionInfo = WebVTT.GetAssAlignmentTagFromCueSettings(style); if (!string.IsNullOrEmpty(positionInfo)) { p.Text = positionInfo + p.Text; - p.Extra = style; + } + + p.Style = string.IsNullOrEmpty(positionInfo) + ? WebVTT.GetPositionInfoRaw(style) + : string.Empty; + if (!string.IsNullOrEmpty(p.Style) || !string.IsNullOrEmpty(positionInfo)) + { VttcSubtitle.Header = "WEBVTT"; } VttcSubtitle.Paragraphs.Add(p); diff --git a/src/libse/SubtitleFormats/WebVTT.cs b/src/libse/SubtitleFormats/WebVTT.cs index abd775a53e8..95bc8c6b1bc 100644 --- a/src/libse/SubtitleFormats/WebVTT.cs +++ b/src/libse/SubtitleFormats/WebVTT.cs @@ -2,7 +2,6 @@ using SkiaSharp; using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -97,7 +96,7 @@ public override string ToText(Subtitle subtitle, string title) return sb.ToString().Trim(); } - internal static string GetPositionInfoFromAssTag(Paragraph p) + internal static string GetPositionInfoFromAssTag(Paragraph p, string cueSettings = null) { string positionInfo; if (p.Text.StartsWith("{\\an1", StringComparison.Ordinal)) @@ -134,7 +133,12 @@ internal static string GetPositionInfoFromAssTag(Paragraph p) } else { - positionInfo = Configuration.Settings.SubtitleSettings.WebVttCueAn2; + // Treat \an2 as the default alignment and preserve any raw cue settings. + positionInfo = !string.IsNullOrEmpty(cueSettings) + ? cueSettings + : string.IsNullOrEmpty(p.Style) + ? Configuration.Settings.SubtitleSettings.WebVttCueAn2 + : p.Style; } return (" " + positionInfo).TrimEnd(); @@ -292,8 +296,10 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string p.StartTime.TotalMilliseconds += addSeconds * 1000; p.EndTime.TotalMilliseconds += addSeconds * 1000; - positionInfo = GetPositionInfo(s); - p.Style = GetPositionInfoRaw(s); + positionInfo = GetAssAlignmentTagFromCueSettings(s); + p.Style = string.IsNullOrEmpty(positionInfo) + ? GetPositionInfoRaw(s) + : string.Empty; p.Region = GetRegion(s); } catch (Exception exception) @@ -475,110 +481,72 @@ private static double GetXTimeStampSeconds(string input) return 0; } - internal static string GetPositionInfo(string s) + internal static string GetAssAlignmentTagFromCueSettings(string s) { - //position: x --- 0% = left, 100% = right (horizontal) - //line: x --- 0 or -16 or 0% = top, 16 or -1 or 100% = bottom (vertical) - var pos = GetTag(s, "position:"); - var line = GetTag(s, "line:"); - var positionInfo = string.Empty; - var hAlignLeft = false; - var hAlignRight = false; - var vAlignTop = false; - var vAlignMiddle = false; - double number; - - if (!string.IsNullOrEmpty(pos) && pos.EndsWith('%') && double.TryParse(pos.TrimEnd('%'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out number)) + var cueSettings = GetPositionInfoRaw(s).Trim(); + if (string.IsNullOrEmpty(cueSettings)) { - if (number < 25) - { - hAlignLeft = true; - } - else if (number > 75) - { - hAlignRight = true; - } + return string.Empty; } - if (!string.IsNullOrEmpty(line)) + var subtitleSettings = Configuration.Settings.SubtitleSettings; + + if (cueSettings == subtitleSettings.WebVttCueAn1) { - line = line.Trim(); - if (line.EndsWith('%')) - { - if (double.TryParse(line.TrimEnd('%'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out number)) - { - if (number < 25) - { - vAlignTop = true; - } - else if (number < 75) - { - vAlignMiddle = true; - } - } - } - else - { - if (double.TryParse(line, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out number)) - { - if (number >= 0 && number <= 7) - { - vAlignTop = true; // Positive numbers indicate top down - } - else if (number > 7 && number < 11) - { - vAlignMiddle = true; - } - } - } + return "{\\an1}"; } - if (hAlignLeft) + if (cueSettings == subtitleSettings.WebVttCueAn2) { - if (vAlignTop) - { - return "{\\an7}"; - } + return "{\\an2}"; + } - if (vAlignMiddle) - { - return "{\\an4}"; - } + if (cueSettings == subtitleSettings.WebVttCueAn3) + { + return "{\\an3}"; + } - return "{\\an1}"; + if (cueSettings == subtitleSettings.WebVttCueAn4) + { + return "{\\an4}"; } - if (hAlignRight) + if (cueSettings == subtitleSettings.WebVttCueAn5) { - if (vAlignTop) - { - return "{\\an9}"; - } + return "{\\an5}"; + } - if (vAlignMiddle) - { - return "{\\an6}"; - } + if (cueSettings == subtitleSettings.WebVttCueAn6) + { + return "{\\an6}"; + } - return "{\\an3}"; + if (cueSettings == subtitleSettings.WebVttCueAn7) + { + return "{\\an7}"; } - if (vAlignTop) + if (cueSettings == subtitleSettings.WebVttCueAn8) { return "{\\an8}"; } - if (vAlignMiddle) + if (cueSettings == subtitleSettings.WebVttCueAn9) { - return "{\\an5}"; + return "{\\an9}"; } - return positionInfo; + return string.Empty; } internal static string GetPositionInfoRaw(string s) { //line: 72.69 % align:left position:44.90 % size:10.21 % + if (string.IsNullOrEmpty(s)) + { + return string.Empty; + } + var list = new List(); var idx = s.IndexOf("line:", StringComparison.Ordinal); diff --git a/src/libse/SubtitleFormats/WebVTTFileWithLineNumber.cs b/src/libse/SubtitleFormats/WebVTTFileWithLineNumber.cs index 9015021485c..579ebda30df 100644 --- a/src/libse/SubtitleFormats/WebVTTFileWithLineNumber.cs +++ b/src/libse/SubtitleFormats/WebVTTFileWithLineNumber.cs @@ -24,7 +24,7 @@ public override string ToText(Subtitle subtitle, string title) { const string timeCodeFormatNoHours = "{0:00}:{1:00}.{2:000}"; // mm:ss.cc const string timeCodeFormatHours = "{0:00}:{1:00}:{2:00}.{3:000}"; // hh:mm:ss.cc - const string paragraphWriteFormat = "{0} --> {1}{2}{5}{3}{4}{5}"; + const string paragraphWriteFormat = "{0} --> {1}{2}{4}{3}{4}"; var sb = new StringBuilder(); sb.AppendLine("WEBVTT FILE"); @@ -34,7 +34,7 @@ public override string ToText(Subtitle subtitle, string title) { var start = string.Format(timeCodeFormatNoHours, p.StartTime.Minutes, p.StartTime.Seconds, p.StartTime.Milliseconds); var end = string.Format(timeCodeFormatNoHours, p.EndTime.Minutes, p.EndTime.Seconds, p.EndTime.Milliseconds); - var positionInfo = WebVTT.GetPositionInfoFromAssTag(p); + var positionInfo = WebVTT.GetPositionInfoFromAssTag(p, p.Extra); if (p.StartTime.Hours > 0 || p.EndTime.Hours > 0) { @@ -42,15 +42,9 @@ public override string ToText(Subtitle subtitle, string title) end = string.Format(timeCodeFormatHours, p.EndTime.Hours, p.EndTime.Minutes, p.EndTime.Seconds, p.EndTime.Milliseconds); } - var style = string.Empty; - if (!string.IsNullOrEmpty(p.Extra) && subtitle.Header == "WEBVTT FILE") - { - style = p.Extra; - } - sb.Append(count); sb.AppendLine(); - sb.AppendLine(string.Format(paragraphWriteFormat, start, end, positionInfo, WebVTT.FormatText(p), style, Environment.NewLine)); + sb.AppendLine(string.Format(paragraphWriteFormat, start, end, positionInfo, WebVTT.FormatText(p), Environment.NewLine)); count++; } @@ -112,8 +106,10 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string StartTime = WebVTT.GetTimeCodeFromString(parts[0]), EndTime = WebVTT.GetTimeCodeFromString(parts[1]) }; - positionInfo = WebVTT.GetPositionInfo(s); - p.Extra = WebVTT.GetPositionInfoRaw(s); + positionInfo = WebVTT.GetAssAlignmentTagFromCueSettings(s); + p.Extra = string.IsNullOrEmpty(positionInfo) + ? WebVTT.GetPositionInfoRaw(s) + : string.Empty; p.Region = WebVTT.GetRegion(s); } catch (Exception exception) diff --git a/tests/libse/SubtitleFormats/WebVttTest.cs b/tests/libse/SubtitleFormats/WebVttTest.cs index 82f45c0a843..24e6ec17214 100644 --- a/tests/libse/SubtitleFormats/WebVttTest.cs +++ b/tests/libse/SubtitleFormats/WebVttTest.cs @@ -61,6 +61,74 @@ public void LoadSubtitleDoesNotMergeCuesWithSameTimeCodesButDifferentRegions() Assert.Equal("World", subtitle.Paragraphs[1].Text); } + [Fact] + public void LoadAndSave_PreservesCueSettingsThatDoNotMatchAnPreset() + { + var vtt = "WEBVTT\r\n\r\n00:00:21.731 --> 00:00:23.356 position:3% line:5% align:left\r\nHello"; + var subtitle = LoadWebVttSubtitle(vtt); + + Assert.Equal("position:3% line:5% align:left", subtitle.Paragraphs[0].Style); + Assert.Equal("Hello", subtitle.Paragraphs[0].Text); + + var saved = new WebVTT().ToText(subtitle, null); + + Assert.Contains("00:00:21.731 --> 00:00:23.356 position:3% line:5% align:left", saved); + } + + [Fact] + public void LoadSubtitle_ConvertsCueSettingsMatchingAnPresetToAssTag() + { + var vtt = "WEBVTT\r\n\r\n00:00:21.731 --> 00:00:23.356 position:20% line:20%\r\nHello"; + var subtitle = LoadWebVttSubtitle(vtt); + + Assert.Equal("{\\an7}Hello", subtitle.Paragraphs[0].Text); + Assert.Equal(string.Empty, subtitle.Paragraphs[0].Style); + } + + [Fact] + public void Save_RemovingLoadedAnTagDoesNotKeepItsOriginalCueSettings() + { + var vtt = "WEBVTT\r\n\r\n00:00:21.731 --> 00:00:23.356 position:20% line:20%\r\nHello"; + var subtitle = LoadWebVttSubtitle(vtt); + subtitle.Paragraphs[0].Text = "Hello"; + + var saved = new WebVTT().ToText(subtitle, null); + + Assert.DoesNotContain("position:20% line:20%", saved); + } + + [Fact] + public void FileWithLineNumber_LoadAndSave_PreservesCueSettingsThatDoNotMatchAnPreset() + { + var vtt = "WEBVTT FILE\r\n\r\n1\r\n00:00.000 --> 00:02.000 position:3% line:5% align:left\r\nHello"; + var subtitle = new Subtitle(); + var format = new WebVTTFileWithLineNumber(); + format.LoadSubtitle(subtitle, new List(vtt.Split(new[] { "\r\n" }, StringSplitOptions.None)), null); + + Assert.Equal("position:3% line:5% align:left", subtitle.Paragraphs[0].Extra); + + var saved = format.ToText(subtitle, null); + + Assert.Contains("00:00.000 --> 00:02.000 position:3% line:5% align:left", saved); + } + + [Fact] + public void Save_AnTagOverridesExistingCueSettings() + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph + { + StartTime = new TimeCode(0, 0, 21, 731), + EndTime = new TimeCode(0, 0, 23, 356), + Style = "position:10% line:3%", + Text = "{\\an7}Hello" + }); + + var saved = new WebVTT().ToText(subtitle, null); + + Assert.Contains("00:00:21.731 --> 00:00:23.356 position:20% line:20%", saved); + } + // Regression coverage for https://github.com/SubtitleEdit/subtitleedit/issues/10676 // Apple TV WebVTT files carry `X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000` (HLS segment metadata) // and a STYLE block using class selectors like `.styledotAB9216dotitalic` for italic/bold/color.