diff --git a/.github/workflows/arte-check.yml b/.github/workflows/arte-check.yml
new file mode 100644
index 0000000..4e79de9
--- /dev/null
+++ b/.github/workflows/arte-check.yml
@@ -0,0 +1,196 @@
+name: Build ArteCheck (SE5)
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'se5/ArteCheck/**'
+ - 'se5/ArteCheck.Tests/**'
+ - 'se5/Plugin-Shared/**'
+ - '.github/workflows/split-dialogs.yml'
+ pull_request:
+ paths:
+ - 'se5/ArteCheck/**'
+ - 'se5/ArteCheck.Tests/**'
+ - 'se5/Plugin-Shared/**'
+ - '.github/workflows/split-dialogs.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/ArteCheck/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/ArteCheck/plugin.json > "$tmp"
+ mv "$tmp" se5/ArteCheck/plugin.json
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add se5/ArteCheck/plugin.json
+ git commit -m "Bump ArteCheck 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-arte-check-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 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Test
+ run: dotnet test se5/ArteCheck.Tests/ArteCheck.Tests.csproj -c Release
+
+ build:
+ needs: [prepare, test]
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: win-x64
+ os: windows
+ exe: ArteCheck.exe
+ - rid: win-arm64
+ os: windows
+ exe: ArteCheck.exe
+ - rid: linux-x64
+ os: linux
+ exe: ArteCheck
+ - rid: linux-arm64
+ os: linux
+ exe: ArteCheck
+ - rid: osx-x64
+ os: macos
+ exe: ArteCheck
+ - rid: osx-arm64
+ os: macos
+ exe: ArteCheck
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare.outputs.ref }}
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Publish self-contained (${{ matrix.rid }})
+ working-directory: se5/ArteCheck
+ run: |
+ dotnet publish ArteCheck.csproj \
+ -c Release \
+ -r ${{ matrix.rid }} \
+ --self-contained true \
+ -p:DebugType=None \
+ -p:DebugSymbols=false \
+ -o staging/ArteCheck
+
+ - name: Rewrite plugin.json for ${{ matrix.os }}
+ working-directory: se5/ArteCheck
+ run: |
+ jq '
+ del(.runtime, .entry) |
+ .executables = { "${{ matrix.os }}": "${{ matrix.exe }}" }
+ ' plugin.json > staging/ArteCheck/plugin.json
+
+ - name: Package zip
+ working-directory: se5/ArteCheck/staging
+ run: zip -r "$GITHUB_WORKSPACE/ArteCheck-${{ matrix.rid }}.zip" ArteCheck
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ArteCheck-${{ matrix.rid }}
+ path: ArteCheck-${{ 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: ArteCheck-*
+ merge-multiple: true
+
+ - name: Create release and upload zips
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "${{ needs.prepare.outputs.tag }}" dist/ArteCheck-*.zip \
+ --repo "${{ github.repository }}" \
+ --target "${{ needs.prepare.outputs.ref }}" \
+ --title "ArteCheck v${{ needs.prepare.outputs.version }}" \
+ --notes "Self-contained ArteCheck plugin builds for win/linux/osx (x64 + arm64)."
diff --git a/se5/ArteCheck.Tests/ArteCheck.Tests.csproj b/se5/ArteCheck.Tests/ArteCheck.Tests.csproj
new file mode 100644
index 0000000..8553914
--- /dev/null
+++ b/se5/ArteCheck.Tests/ArteCheck.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/se5/ArteCheck.Tests/ArteCheckerTests.cs b/se5/ArteCheck.Tests/ArteCheckerTests.cs
new file mode 100644
index 0000000..ce92040
--- /dev/null
+++ b/se5/ArteCheck.Tests/ArteCheckerTests.cs
@@ -0,0 +1,244 @@
+using System.Text.RegularExpressions;
+using Projektanker.Icons.Avalonia.MaterialDesign;
+using SubtitleEdit.Plugins.ArteCheck;
+using SubtitleEdit.Plugins.Shared;
+
+namespace ArteCheck.Tests;
+
+public class ArteCheckerTests
+{
+ private const double Hour10 = 10 * 3_600_000.0;
+
+ private static PluginParagraph P(double startMs, double endMs, string text, string? row = "22") =>
+ new() { StartMs = startMs, EndMs = endMs, Text = text, MarginV = row };
+
+ private static ArteOptions Options(params ArteCheckType[] checks) => new()
+ {
+ EnabledChecks = checks.Length == 0 ? Enum.GetValues().ToHashSet() : checks.ToHashSet(),
+ };
+
+ private static string ArteHeader()
+ {
+ var header = GsiHeader.CreateDefault();
+ header.CodePage = "850";
+ header.DiskFormatCode = "STL25.01";
+ header.DisplayStandardCode = "2";
+ header.LanguageCode = "08";
+ return header.ToString();
+ }
+
+ [Fact]
+ public void GsiHeader_DefaultIs1024AndRoundTrips()
+ {
+ var header = GsiHeader.CreateDefault();
+ Assert.Equal(1024, header.ToString().Length);
+ Assert.True(GsiHeader.IsStlHeader(header.ToString()));
+ header.LanguageCode = "2D";
+ header.TimeCodeStartOfProgramme = "10000000";
+ var parsed = GsiHeader.Parse(header.ToString());
+ Assert.Equal("2D", parsed.LanguageCode);
+ Assert.Equal("10000000", parsed.TimeCodeStartOfProgramme);
+ Assert.Equal("STL25.01", parsed.DiskFormatCode);
+ }
+
+ [Fact]
+ public void Header_ProposesArteValues()
+ {
+ var fixes = ArteChecker.Analyze(new[] { P(Hour10, Hour10 + 200, "") }, GsiHeader.CreateDefault().ToString(), Options());
+ var fix = Assert.Single(fixes, f => f.Kind == ArteFixKind.Header);
+ var header = GsiHeader.Parse(fix.ProposedHeader!);
+ Assert.Equal("850", header.CodePage);
+ Assert.Equal("2", header.DisplayStandardCode);
+ Assert.Equal("08", header.LanguageCode);
+ }
+
+ [Fact]
+ public void BlankSubtitle_MissingIsInsertedAtTheHour()
+ {
+ var source = new[] { P(Hour10 + 5000, Hour10 + 8000, "Hallo") };
+ var fixes = ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.BlankSubtitle));
+ var fix = Assert.Single(fixes);
+ Assert.Equal(ArteFixKind.CreateBlankSubtitle, fix.Kind);
+
+ var result = ArteChecker.Apply(source, ArteHeader(), fixes, Options());
+ Assert.Equal(2, result.Paragraphs.Count);
+ Assert.Equal(Hour10, result.Paragraphs[0].StartMs);
+ Assert.Equal(Hour10 + 200, result.Paragraphs[0].EndMs);
+ Assert.Equal(string.Empty, result.Paragraphs[0].Text);
+ }
+
+ [Fact]
+ public void FrameRateConversion_KeepsTheProgrammeStart()
+ {
+ var source = new List { P(Hour10, Hour10 + 200, ""), P(Hour10 + 60_000, Hour10 + 62_000, "Hallo") };
+ var converted = ArteChecker.ConvertFrameRate(source.Select(p => p.Clone()).ToList(), 23.976);
+ Assert.Equal(Hour10, converted[0].StartMs);
+ Assert.Equal(Hour10 + 60_000 * (24000.0 / 1001.0) / 25.0, converted[1].StartMs, 3);
+ }
+
+ [Fact]
+ public void FrameAccurate_RoundsToWholeFrames()
+ {
+ var source = new[] { P(Hour10 + 1010, Hour10 + 3030, "Hallo") };
+ var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.FrameAccurateTimeCodes)));
+ Assert.Equal(Hour10 + 1000, fix.ProposedStartMs);
+ Assert.Equal(Hour10 + 3040, fix.ProposedEndMs);
+ }
+
+ [Fact]
+ public void MinimumGap_SharedBetweenOutAndInTime()
+ {
+ var source = new[] { P(Hour10, Hour10 + 4000, "Eins"), P(Hour10 + 4000, Hour10 + 8000, "Zwei") };
+ var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.MinimumGaps)));
+ Assert.True(fix.CanBeFixed);
+ var result = ArteChecker.Apply(source, ArteHeader(), new[] { fix }, Options());
+ Assert.Equal(Hour10 + 4000 - 2 * 40, result.Paragraphs[0].EndMs);
+ Assert.Equal(Hour10 + 4000 + 3 * 40, result.Paragraphs[1].StartMs);
+ }
+
+ [Fact]
+ public void MinimumGap_NoRoomIsAnAlarm()
+ {
+ var source = new[] { P(Hour10, Hour10 + 900, "Eins zwei drei vier"), P(Hour10 + 900, Hour10 + 1800, "Fünf sechs sieben acht") };
+ var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.MinimumGaps)));
+ Assert.True(fix.IsAlarm);
+ }
+
+ [Fact]
+ public void LinePosition_OneLineOnRow23MovesTo22()
+ {
+ var source = new[] { P(Hour10, Hour10 + 3000, "Hallo", "23"), P(Hour10 + 4000, Hour10 + 7000, "Eins\nZwei", "22") };
+ var fixes = ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.TeletextLinePosition));
+ Assert.Equal(new[] { "22", "20" }, fixes.Select(f => f.After));
+ }
+
+ [Fact]
+ public void Colors_NormalSubtitlesBecomeYellowEverywhere()
+ {
+ var source = new[] { P(Hour10, Hour10 + 3000, "Hallo"), P(Hour10 + 4000, Hour10 + 7000, "Welt") };
+ var fixes = ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.TeletextColors));
+ Assert.Equal(2, fixes.Count);
+ Assert.All(fixes, f => Assert.StartsWith("", f.After));
+ }
+
+ [Fact]
+ public void Colors_SdhKeepsTheNearestTeletextColor()
+ {
+ var options = Options(ArteCheckType.TeletextColors);
+ options.Profile = ArteProfile.All.Single(p => p.Code == "HG-DEU");
+ var source = new[] { P(Hour10, Hour10 + 3000, "Hallo") };
+ var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), options), f => f.Group == ArteChecker.GroupColors);
+ Assert.Equal("Hallo", fix.After);
+ }
+
+ [Fact]
+ public void LineLength_ColorCellCountsAgainstTheLimit()
+ {
+ var line = new string('a', 36);
+ Assert.True(TeletextText.Fits(line + "b", 37));
+ Assert.False(TeletextText.Fits("" + line + "b", 37));
+ Assert.True(TeletextText.Fits("" + line + "", 37));
+ }
+
+ [Fact]
+ public void LineLength_TooLongIsRebalanced()
+ {
+ var source = new[] { P(Hour10, Hour10 + 5000, "Das ist eine ziemlich lange Zeile, die nicht passt", "22") };
+ var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.TeletextLineLength)));
+ Assert.Equal(ArteFixKind.Rebalance, fix.Kind);
+ Assert.Equal("Das ist eine ziemlich lange Zeile,\ndie nicht passt", fix.After);
+
+ var result = ArteChecker.Apply(source, ArteHeader(), new[] { fix }, Options());
+ Assert.Equal("20", result.Paragraphs[0].MarginV); // two double-height lines keep the bottom edge
+ }
+
+ [Fact]
+ public void ThreeLines_SplitIntoTwoSubtitlesInsideTheTimeRange()
+ {
+ var text = "Wir müssen jetzt sofort gehen, sonst kommen wir zu spät.\nDer Zug wartet nicht auf uns.\nNiemand wartet auf uns.";
+ var source = new[] { P(Hour10, Hour10 + 9000, text, "18") };
+ var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.MaximumTwoLines)));
+ Assert.Equal(ArteFixKind.Split, fix.Kind);
+
+ var result = ArteChecker.Apply(source, ArteHeader(), new[] { fix }, Options());
+ Assert.True(result.Paragraphs.Count >= 2);
+ Assert.Equal(Hour10, result.Paragraphs[0].StartMs);
+ Assert.Equal(Hour10 + 9000, result.Paragraphs[^1].EndMs);
+ Assert.All(result.Paragraphs, p => Assert.True(TeletextText.Fits(p.Text, 37)));
+ for (var i = 1; i < result.Paragraphs.Count; i++)
+ {
+ Assert.True(result.Paragraphs[i].StartMs - result.Paragraphs[i - 1].EndMs >= 5 * 40);
+ }
+
+ Assert.Equal(TeletextText.Words(text), TeletextText.Words(string.Join(" ", result.Paragraphs.Select(p => p.Text))));
+ }
+
+ [Fact]
+ public void Italic_IsRemoved()
+ {
+ var fix = Assert.Single(ArteChecker.Analyze(new[] { P(Hour10, Hour10 + 3000, "Hallo") }, ArteHeader(), Options(ArteCheckType.Italic)));
+ Assert.Equal("Hallo", fix.After);
+ }
+
+ [Fact]
+ public void ShiftStartTimeCode_MovesEverythingAndUpdatesTcp()
+ {
+ var source = new[] { P(3_600_000, 3_600_200, ""), P(3_605_000, 3_608_000, "Hallo") };
+ var options = Options(ArteCheckType.BlankSubtitle);
+ options.ShiftToStartTimeCode = true;
+ options.TargetStartMs = Hour10;
+ var fixes = ArteChecker.Analyze(source, ArteHeader(), options);
+ var result = ArteChecker.Apply(source, ArteHeader(), fixes.Where(f => f.Kind == ArteFixKind.ShiftStartTimeCode).ToList(), options);
+ Assert.Equal(Hour10, result.Paragraphs[0].StartMs);
+ Assert.Equal(Hour10 + 5000, result.Paragraphs[1].StartMs);
+ Assert.Equal("10000000", GsiHeader.Parse(result.Header!).TimeCodeStartOfProgramme);
+ }
+
+ [Fact]
+ public void CleanFile_HasNoFindings()
+ {
+ var source = new[]
+ {
+ P(Hour10, Hour10 + 200, ""),
+ P(Hour10 + 1000, Hour10 + 4000, "Guten Tag."),
+ P(Hour10 + 4200, Hour10 + 8000, "Wie geht es Ihnen\nheute Abend?", "20"),
+ };
+ Assert.Empty(ArteChecker.Analyze(source, ArteHeader(), Options()));
+ }
+
+ [Fact]
+ public void EveryIconNameExists()
+ {
+ var files = Directory.GetFiles(FindSourceFolder(), "*.*", SearchOption.TopDirectoryOnly)
+ .Where(f => f.EndsWith(".cs") || f.EndsWith(".axaml"));
+ var names = files.SelectMany(f => Regex.Matches(File.ReadAllText(f), "\"(mdi-[a-z0-9-]+)\"").Select(m => m.Groups[1].Value)).Distinct().ToList();
+ Assert.NotEmpty(names);
+ var provider = new MaterialDesignIconProvider();
+ var getIcon = typeof(MaterialDesignIconProvider).GetMethods()
+ .First(m => m.ReturnType != typeof(void) && m.GetParameters() is [{ ParameterType: var t }] && t == typeof(string));
+ var missing = names.Where(n =>
+ {
+ try
+ {
+ getIcon.Invoke(provider, new object[] { n });
+ return false;
+ }
+ catch
+ {
+ return true;
+ }
+ }).ToList();
+ Assert.Empty(missing);
+ }
+
+ private static string FindSourceFolder()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "ArteCheck")))
+ {
+ dir = dir.Parent;
+ }
+
+ return Path.Combine(dir!.FullName, "ArteCheck");
+ }
+}
diff --git a/se5/ArteCheck/App.axaml b/se5/ArteCheck/App.axaml
new file mode 100644
index 0000000..fb25693
--- /dev/null
+++ b/se5/ArteCheck/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/se5/ArteCheck/App.axaml.cs b/se5/ArteCheck/App.axaml.cs
new file mode 100644
index 0000000..90fd69d
--- /dev/null
+++ b/se5/ArteCheck/App.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using SubtitleEdit.Plugins.Shared;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+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/ArteCheck/ArteCheck.csproj b/se5/ArteCheck/ArteCheck.csproj
new file mode 100644
index 0000000..809556d
--- /dev/null
+++ b/se5/ArteCheck/ArteCheck.csproj
@@ -0,0 +1,27 @@
+
+
+
+ WinExe
+ net8.0
+ enable
+ enable
+ ArteCheck
+ SubtitleEdit.Plugins.ArteCheck
+ true
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/se5/ArteCheck/ArteChecker.cs b/se5/ArteCheck/ArteChecker.cs
new file mode 100644
index 0000000..9e4f9bd
--- /dev/null
+++ b/se5/ArteCheck/ArteChecker.cs
@@ -0,0 +1,859 @@
+using SubtitleEdit.Plugins.Shared;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+///
+/// ARTE EBU STL / teletext delivery checks (ported from SubtitleEdit/subtitleedit#15185 by
+/// Triathlon-rally). ARTE delivers at 25 fps, so every time code is a multiple of 40 ms.
+///
+public sealed class ArteChecker
+{
+ public const double FrameMs = 40.0;
+ public const int BottomRow = 23;
+
+ public const string GroupHeader = "Header / language";
+ public const string GroupStartTimeCode = "Start time code";
+ public const string GroupBlank = "ARTE blank subtitle";
+ public const string GroupFrames = "Frame-accurate time codes";
+ public const string GroupDuration = "Display duration";
+ public const string GroupLayout = "Split / rebalance";
+ public const string GroupPosition = "Teletext line position";
+ public const string GroupColors = "Teletext colors";
+ public const string GroupItalic = "Italic formatting";
+ public const string GroupSpaces = "Unneeded spaces";
+ public const string GroupGaps = "Minimum gaps";
+
+ private readonly ArteOptions _options;
+ private readonly List _fixes = new();
+
+ private ArteChecker(ArteOptions options) => _options = options;
+
+ private double MinimumGapMs => _options.MinimumGapFrames * FrameMs;
+
+ public static List Analyze(IReadOnlyList source, string? header, ArteOptions options)
+ {
+ var checker = new ArteChecker(options);
+ var subtitle = ConvertFrameRate(source.Select(p => p.Clone()).ToList(), options.SourceFrameRate);
+ checker.Run(subtitle, header);
+ foreach (var fix in checker._fixes.Where(f => f.Index > 0 && f.Index <= subtitle.Count && string.IsNullOrEmpty(f.TimeRange)))
+ {
+ fix.TimeRange = FormatTimeRange(subtitle[fix.Index - 1]);
+ }
+
+ return checker._fixes;
+ }
+
+ private void Run(List subtitle, string? header)
+ {
+ var enabled = _options.EnabledChecks;
+ AnalyzeHeader(header);
+ if (_options.ShiftToStartTimeCode)
+ {
+ AnalyzeStartTimeCodeShift(subtitle);
+ }
+
+ if (enabled.Contains(ArteCheckType.BlankSubtitle)) AnalyzeBlankSubtitle(subtitle);
+ if (enabled.Contains(ArteCheckType.FrameAccurateTimeCodes)) AnalyzeFrameAccurateTimeCodes(subtitle);
+ if (enabled.Contains(ArteCheckType.DisplayDuration)) AnalyzeDisplayDurations(subtitle);
+ if (enabled.Contains(ArteCheckType.MaximumTwoLines)) AnalyzeMaximumTwoLines(subtitle);
+ if (enabled.Contains(ArteCheckType.TeletextLinePosition)) AnalyzeLinePositions(subtitle);
+
+ // A color code takes a teletext cell, so colors are normalized before line lengths are measured.
+ if (enabled.Contains(ArteCheckType.TeletextColors)) AnalyzeColors(subtitle);
+ if (enabled.Contains(ArteCheckType.TeletextLineLength)) AnalyzeLineLengths(subtitle);
+ if (enabled.Contains(ArteCheckType.Italic)) AnalyzeItalic(subtitle);
+ if (enabled.Contains(ArteCheckType.UnneededSpaces)) AnalyzeUnneededSpaces(subtitle);
+ if (enabled.Contains(ArteCheckType.MinimumGaps)) AnalyzeMinimumGaps(subtitle);
+ }
+
+ private void Add(ArteFix fix) => _fixes.Add(fix);
+
+ // ---- frame rate / time code helpers ------------------------------------------------------
+
+ public static double GetFrameForCalculation(double frameRate) =>
+ Math.Abs(frameRate - 23.976) < 0.001 ? 24000.0 / 1001.0 :
+ Math.Abs(frameRate - 29.97) < 0.001 ? 30000.0 / 1001.0 :
+ Math.Abs(frameRate - 59.94) < 0.001 ? 60000.0 / 1001.0 :
+ frameRate;
+
+ ///
+ /// Converts to 25 fps around the programme start (10:00:00:00, 01:00:00:00, ...): only the time
+ /// relative to it is scaled, so the start time code itself does not move.
+ ///
+ public static List ConvertFrameRate(List subtitle, double sourceFrameRate)
+ {
+ if (Math.Abs(sourceFrameRate - 25.0) < 0.001 || subtitle.Count == 0)
+ {
+ return subtitle;
+ }
+
+ var factor = GetFrameForCalculation(sourceFrameRate) / 25.0;
+ var reference = GetStartTimeCodeReference(subtitle);
+ foreach (var p in subtitle)
+ {
+ p.StartMs = reference + (p.StartMs - reference) * factor;
+ p.EndMs = reference + (p.EndMs - reference) * factor;
+ }
+
+ return subtitle;
+ }
+
+ /// The leading blank subtitle's start, or else the full hour the first subtitle is in.
+ public static double GetStartTimeCodeReference(IReadOnlyList subtitle)
+ {
+ if (subtitle.Count == 0)
+ {
+ return 0;
+ }
+
+ var first = subtitle[0];
+ return TeletextText.IsBlank(first.Text)
+ ? first.StartMs
+ : Math.Floor(first.StartMs / 3_600_000.0) * 3_600_000.0;
+ }
+
+ public static double RoundToFrame(double ms) => Math.Round(ms / FrameMs, MidpointRounding.AwayFromZero) * FrameMs;
+
+ public static string FormatTimeCode(double ms)
+ {
+ var frames = (long)Math.Round(ms / FrameMs, MidpointRounding.AwayFromZero);
+ return $"{frames / 90000:00}:{frames / 1500 % 60:00}:{frames / 25 % 60:00}:{frames % 25:00}";
+ }
+
+ /// HH:MM:SS.mmm - shows the off-frame milliseconds that frame rounding removes.
+ public static string FormatPrecise(double ms)
+ {
+ var t = TimeSpan.FromMilliseconds(Math.Round(ms));
+ return $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}.{t.Milliseconds:000}";
+ }
+
+ public static string FormatDuration(double ms)
+ {
+ var frames = (long)Math.Round(ms / FrameMs, MidpointRounding.AwayFromZero);
+ return $"{frames / 25} s {frames % 25:00} fr";
+ }
+
+ public static string FormatTimeRange(PluginParagraph p) =>
+ $"{FormatTimeCode(p.StartMs)} → {FormatTimeCode(p.EndMs)} · {FormatDuration(p.EndMs - p.StartMs)}";
+
+ public static bool TryParseTimeCode(string text, out double ms)
+ {
+ ms = 0;
+ var parts = text.Trim().Split(':', ';', '.', ',');
+ if (parts.Length != 4 || !parts.All(p => int.TryParse(p, out _)))
+ {
+ return false;
+ }
+
+ var v = parts.Select(int.Parse).ToArray();
+ if (v[1] > 59 || v[2] > 59 || v[3] > 24)
+ {
+ return false;
+ }
+
+ ms = ((v[0] * 3600.0 + v[1] * 60 + v[2]) * 25 + v[3]) * FrameMs;
+ return true;
+ }
+
+ // ---- checks ------------------------------------------------------------------------------
+
+ private void AnalyzeHeader(string? headerText)
+ {
+ var hasStlHeader = GsiHeader.IsStlHeader(headerText);
+ var header = hasStlHeader ? GsiHeader.Parse(headerText!) : GsiHeader.CreateDefault();
+ var original = header.Clone();
+ header.CodePage = "850";
+ header.DiskFormatCode = "STL25.01";
+ header.DisplayStandardCode = "2";
+ header.CharacterCodeTable = "00";
+ header.LanguageCode = _options.Profile.LanguageCode;
+ header.MaxCharactersPerRow = "40";
+ header.MaxRows = "23";
+ var after = header.DescribeDifferences(original);
+ if (after.Length > 0)
+ {
+ var before = hasStlHeader ? original.DescribeDifferences(header) : "No EBU STL header";
+ Add(new ArteFix(GroupHeader, true, 0, before, after,
+ hasStlHeader
+ ? $"Set the EBU header to the {_options.Profile.Name} delivery values (25 fps teletext, code page 850)."
+ : $"Create an EBU header with the {_options.Profile.Name} delivery values.",
+ ArteFixKind.Header)
+ {
+ ProposedHeader = header.ToString(),
+ });
+ }
+ }
+
+ private void AnalyzeStartTimeCodeShift(List subtitle)
+ {
+ var current = GetStartTimeCodeReference(subtitle);
+ var target = RoundToFrame(_options.TargetStartMs);
+ if (Math.Abs(target - current) < 0.01)
+ {
+ return;
+ }
+
+ Add(new ArteFix(GroupStartTimeCode, true, 0, FormatTimeCode(current), FormatTimeCode(target),
+ "Shift every subtitle so the programme starts at the requested time code.", ArteFixKind.ShiftStartTimeCode)
+ {
+ ProposedStartMs = target - current,
+ });
+ }
+
+ private void AnalyzeBlankSubtitle(List subtitle)
+ {
+ if (subtitle.Count == 0)
+ {
+ return;
+ }
+
+ var first = subtitle[0];
+ var startMs = GetStartTimeCodeReference(subtitle);
+ var expectedEndMs = startMs + 200.0;
+ if (!TeletextText.IsBlank(first.Text))
+ {
+ if (Math.Abs(first.StartMs - startMs) < 0.01)
+ {
+ Add(new ArteFix(GroupBlank, false, 1, first.Text, string.Empty,
+ "The first subtitle starts on the programme start time code, where ARTE needs the blank control subtitle. Move it manually."));
+ return;
+ }
+
+ var blank = new PluginParagraph { StartMs = startMs, EndMs = expectedEndMs, Text = string.Empty, MarginV = "22" };
+ Add(new ArteFix(GroupBlank, true, 0, "Missing", FormatTimeRange(blank),
+ "Insert the blank ARTE control subtitle (5 frames) at the programme start.", ArteFixKind.CreateBlankSubtitle)
+ {
+ ProposedParagraph = blank,
+ });
+ return;
+ }
+
+ if (Math.Abs(first.EndMs - expectedEndMs) >= 0.01)
+ {
+ var nextStart = subtitle.Count > 1 ? subtitle[1].StartMs : double.PositiveInfinity;
+ var canFix = expectedEndMs <= nextStart - MinimumGapMs;
+ var expected = new PluginParagraph { StartMs = startMs, EndMs = expectedEndMs };
+ Add(new ArteFix(GroupBlank, canFix, 1, FormatTimeRange(first), canFix ? FormatTimeRange(expected) : string.Empty,
+ canFix
+ ? "The blank control subtitle must last exactly 5 frames."
+ : "The blank control subtitle must last 5 frames, but the next subtitle starts too early.",
+ ArteFixKind.DisplayDuration)
+ {
+ ProposedEndMs = canFix ? expectedEndMs : null,
+ });
+ }
+ }
+
+ private void AnalyzeFrameAccurateTimeCodes(List subtitle)
+ {
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var p = subtitle[i];
+ var start = RoundToFrame(p.StartMs);
+ var end = RoundToFrame(p.EndMs);
+ if (Math.Abs(p.StartMs - start) < 0.01 && Math.Abs(p.EndMs - end) < 0.01)
+ {
+ continue;
+ }
+
+ if (end <= start)
+ {
+ Add(new ArteFix(GroupFrames, false, i + 1, FormatTimeRange(p), string.Empty,
+ "Rounding to whole 25 fps frames leaves no duration."));
+ continue;
+ }
+
+ Add(new ArteFix(GroupFrames, true, i + 1, $"{FormatPrecise(p.StartMs)} → {FormatPrecise(p.EndMs)}",
+ $"{FormatPrecise(start)} → {FormatPrecise(end)}",
+ "Round the time codes to whole 25 fps frames.", ArteFixKind.FrameAccurateTimeCode)
+ {
+ ProposedStartMs = start,
+ ProposedEndMs = end,
+ });
+ }
+ }
+
+ /// Reading time the text needs (minimum duration or CPS, whichever is longer).
+ private double RequiredDurationMs(string text)
+ {
+ var cps = _options.MaximumCharactersPerSecond;
+ var reading = cps > 0 ? TeletextText.VisibleCharacterCount(text) * 1000.0 / cps : 0;
+ return Math.Max(_options.MinimumDurationMs, reading);
+ }
+
+ private double AcceptedMinimumDurationMs(string text) =>
+ _options.AcceptShortDurations
+ ? _options.ShortMinimumFrames * FrameMs
+ : RequiredDurationMs(text) * Math.Max(0, 1.0 - _options.ReadingDurationTolerancePercent / 100.0);
+
+ private void AnalyzeDisplayDurations(List subtitle)
+ {
+ var maximumMs = _options.MaximumDurationMs;
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var p = subtitle[i];
+ if (TeletextText.IsBlank(p.Text))
+ {
+ continue;
+ }
+
+ var duration = p.EndMs - p.StartMs;
+ var required = RequiredDurationMs(p.Text);
+ var accepted = AcceptedMinimumDurationMs(p.Text);
+ var tooShort = duration < accepted;
+ var tooLong = maximumMs > 0 && duration > maximumMs;
+ if (!tooShort && !tooLong)
+ {
+ continue;
+ }
+
+ var desiredEnd = tooShort
+ ? RoundToFrame(p.StartMs + (_options.AcceptShortDurations ? _options.ShortMinimumFrames * FrameMs : required))
+ : RoundToFrame(p.StartMs + maximumMs);
+ var nextStart = i + 1 < subtitle.Count ? subtitle[i + 1].StartMs - MinimumGapMs : double.PositiveInfinity;
+ var canFix = desiredEnd > p.StartMs && desiredEnd <= nextStart && (!tooLong || desiredEnd >= required + p.StartMs);
+ var issue = tooShort
+ ? _options.AcceptShortDurations
+ ? $"Shown for {FormatDuration(duration)}, below the {_options.ShortMinimumFrames}-frame short minimum."
+ : $"Shown for {FormatDuration(duration)}, below the tolerated minimum of {FormatDuration(accepted)} " +
+ $"({_options.ReadingDurationTolerancePercent:0.#}% under the {FormatDuration(required)} reading time)."
+ : $"Shown for {FormatDuration(duration)}, above the maximum of {FormatDuration(maximumMs)}.";
+ Add(new ArteFix(GroupDuration, canFix, i + 1, FormatDuration(duration),
+ canFix ? FormatDuration(desiredEnd - p.StartMs) : string.Empty,
+ canFix ? issue + " Optional: move the out time." : issue + " No room to move the out time - edit manually.",
+ ArteFixKind.DisplayDuration, applyByDefault: false)
+ {
+ ProposedEndMs = canFix ? desiredEnd : null,
+ });
+ }
+ }
+
+ private void AnalyzeMaximumTwoLines(List subtitle)
+ {
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var lineCount = TeletextText.LineCount(subtitle[i].Text);
+ if (lineCount > 2)
+ {
+ AddLayoutProposal(subtitle[i], i + 1, $"{lineCount} lines - teletext allows two.");
+ }
+ }
+ }
+
+ private void AnalyzeLinePositions(List subtitle)
+ {
+ // Double height: the stored row is the first physical row, so one line belongs on 22 (22+23)
+ // and two lines on 20 (20+21, 22+23).
+ var correctBottom = 0;
+ var oneRowHigh = 0;
+ foreach (var p in subtitle)
+ {
+ if (string.IsNullOrWhiteSpace(p.Text) || !int.TryParse(p.MarginV, out var row))
+ {
+ continue;
+ }
+
+ var lines = TeletextText.LineCount(p.Text);
+ if (lines > 2)
+ {
+ continue;
+ }
+
+ var expected = lines == 1 ? 22 : 20;
+ if (row == expected)
+ {
+ correctBottom++;
+ }
+ else if (row == expected - 1)
+ {
+ oneRowHigh++;
+ }
+ }
+
+ var shiftWholeFile = oneRowHigh > 0 && oneRowHigh > correctBottom;
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var p = subtitle[i];
+ if (string.IsNullOrWhiteSpace(p.Text))
+ {
+ if (p.MarginV != "22")
+ {
+ Add(new ArteFix(GroupPosition, true, i + 1, string.IsNullOrWhiteSpace(p.MarginV) ? "Not set" : p.MarginV, "22",
+ "A blank subtitle sits on row 22.", ArteFixKind.TeletextLinePosition));
+ }
+
+ continue;
+ }
+
+ var lineCount = TeletextText.LineCount(p.Text);
+ if (lineCount > 2)
+ {
+ continue;
+ }
+
+ var expectedRow = lineCount == 1 ? 22 : 20;
+ var hasRow = int.TryParse(p.MarginV, out var currentRow);
+ if (shiftWholeFile && hasRow)
+ {
+ Add(new ArteFix(GroupPosition, true, i + 1, currentRow.ToString(), (currentRow + 1).ToString(),
+ "Most subtitles sit one row too high - move the whole file down one row.", ArteFixKind.TeletextLinePosition));
+ continue;
+ }
+
+ if (hasRow && ((lineCount == 1 && currentRow == BottomRow) || (lineCount == 2 && currentRow == BottomRow - 1)))
+ {
+ Add(new ArteFix(GroupPosition, true, i + 1, currentRow.ToString(), expectedRow.ToString(),
+ $"Double height: {lineCount} line(s) must start on row {expectedRow} to stay on the page.",
+ ArteFixKind.TeletextLinePosition));
+ continue;
+ }
+
+ // Higher rows are deliberate (e.g. a title at the top) and stay as they are.
+ if (!hasRow)
+ {
+ Add(new ArteFix(GroupPosition, true, i + 1, "Not set", expectedRow.ToString(),
+ $"No teletext row - place {lineCount} line(s) at the bottom (row {expectedRow}).",
+ ArteFixKind.TeletextLinePosition));
+ }
+ }
+ }
+
+ private static string NormalizeColors(string text, bool isSdh, out bool hasUnsupported)
+ {
+ var unsupported = false;
+ var normalized = TeletextText.ColorAttribute.Replace(text, match =>
+ {
+ var color = match.Groups["quoted"].Success ? match.Groups["quoted"].Value :
+ match.Groups["single"].Success ? match.Groups["single"].Value : match.Groups["bare"].Value;
+ var teletext = TeletextText.NearestTeletextColor(color);
+ if (teletext == null)
+ {
+ unsupported = true;
+ return match.Value;
+ }
+
+ return "color=\"" + (isSdh ? teletext : "Yellow") + "\"";
+ });
+ hasUnsupported = unsupported;
+ return normalized;
+ }
+
+ private void AnalyzeColors(List subtitle)
+ {
+ var isSdh = _options.Profile.IsSdh;
+
+ // A normal (non-SDH) file that uses color at all must use yellow for every subtitle;
+ // an uncolored file stays uncolored.
+ var yellowEverywhere = !isSdh && subtitle.Any(p => TeletextText.HasFontColor(p.Text));
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var text = subtitle[i].Text;
+ var withoutBox = isSdh ? text : TeletextText.RemoveBox(text);
+ var normalized = NormalizeColors(withoutBox, isSdh, out var unsupported);
+ if (yellowEverywhere && !string.IsNullOrWhiteSpace(text))
+ {
+ normalized = System.Text.RegularExpressions.Regex.Replace(normalized,
+ @"]*\bcolor\s*=[^>]*>(?.*?)", "${text}",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
+ normalized = "" + normalized + "";
+ }
+
+ if (normalized != text)
+ {
+ Add(new ArteFix(GroupColors, true, i + 1, text, normalized,
+ isSdh ? "Map the colors to the eight teletext colors." :
+ TeletextText.HasBox(text) ? "Boxing is for SDH only; normal subtitles are yellow or uncolored." :
+ "Normal subtitles are either all yellow or uncolored.",
+ ArteFixKind.TeletextColor));
+ }
+
+ if (unsupported)
+ {
+ Add(new ArteFix(GroupColors, false, i + 1, text, string.Empty,
+ "A color cannot be mapped to a teletext color - choose one manually."));
+ }
+ }
+ }
+
+ private void AnalyzeLineLengths(List subtitle)
+ {
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var p = subtitle[i];
+ if (string.IsNullOrWhiteSpace(p.Text))
+ {
+ continue;
+ }
+
+ // Measure the text as it will be written: after a proposed color normalization.
+ var text = _fixes.LastOrDefault(f => f.Index == i + 1 && f.Kind == ArteFixKind.TeletextColor)?.After ?? p.Text;
+ var lines = TeletextText.MeasureLines(text);
+ for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++)
+ {
+ var (visible, colorCells) = lines[lineIndex];
+ var maximum = Math.Max(1, _options.TeletextMaxCells - colorCells);
+ if (visible.Length > maximum)
+ {
+ var layout = text == p.Text ? p : new PluginParagraph { StartMs = p.StartMs, EndMs = p.EndMs, Text = text, MarginV = p.MarginV };
+ AddLayoutProposal(layout, i + 1,
+ $"Line {lineIndex + 1} has {visible.Length} characters" +
+ (colorCells > 0 ? $" + {colorCells} color cell(s)" : string.Empty) +
+ $" - the limit is {_options.TeletextMaxCells}.");
+ break;
+ }
+ }
+ }
+ }
+
+ private void AddLayoutProposal(PluginParagraph p, int index, string reason)
+ {
+ // A subtitle can fail both the line-count and the line-width check; offer one fix.
+ if (_fixes.Any(f => f.Index == index && f.Group == GroupLayout))
+ {
+ return;
+ }
+
+ var lines = TeletextText.SplitLines(p.Text);
+ var isDialog = lines.Length == 2 && lines.All(l => TeletextText.RemoveTags(l).TrimStart().StartsWith('-'));
+ var proposed = isDialog ? null : TeletextText.Rebalance(p.Text, _options.TeletextMaxCells);
+ if (proposed != null && proposed != p.Text)
+ {
+ Add(new ArteFix(GroupLayout, true, index, p.Text, proposed, reason + " Rebalance the line break.", ArteFixKind.Rebalance));
+ return;
+ }
+
+ if (!AddSplitProposal(p, index, reason))
+ {
+ Add(new ArteFix(GroupLayout, false, index, p.Text, string.Empty, reason + " No automatic split fits - edit manually."));
+ }
+ }
+
+ private bool AddSplitProposal(PluginParagraph p, int index, string reason)
+ {
+ bool Alarm(string message)
+ {
+ Add(new ArteFix(GroupLayout, false, index, p.Text, string.Empty, reason + " " + message));
+ return true;
+ }
+
+ if (TeletextText.HasFontColor(p.Text))
+ {
+ return Alarm("Colored text is not split automatically - edit manually.");
+ }
+
+ var texts = TeletextText.SplitToFit(TeletextText.RemoveItalic(p.Text), _options.TeletextMaxCells);
+ if (texts == null)
+ {
+ return false;
+ }
+
+ if (TeletextText.Words(string.Join(" ", texts)) != TeletextText.Words(TeletextText.RemoveItalic(p.Text)))
+ {
+ return Alarm("A split would change the text - edit manually.");
+ }
+
+ var parts = texts.Select(t => new PluginParagraph { Text = t }).ToList();
+ if (!TryFitSplitTiming(parts, p.StartMs, p.EndMs, out var timingError))
+ {
+ return Alarm(timingError);
+ }
+
+ var preview = string.Join("\n\n", parts.Select((part, n) => $"{n + 1}. {FormatTimeRange(part)}\n{part.Text}"));
+ Add(new ArteFix(GroupLayout, true, index, p.Text, preview,
+ reason + $" Split into {parts.Count} subtitles inside the original time range." +
+ (TeletextText.HasItalic(p.Text) ? " Removes the italic tags." : string.Empty),
+ ArteFixKind.Split)
+ {
+ SplitParagraphs = parts,
+ });
+ return true;
+ }
+
+ /// Shares the original time range out in whole frames, by text length, with the minimum gap between parts.
+ private bool TryFitSplitTiming(List parts, double startMs, double endMs, out string error)
+ {
+ error = string.Empty;
+ startMs = RoundToFrame(startMs);
+ endMs = RoundToFrame(endMs);
+ var available = (int)Math.Round((endMs - startMs) / FrameMs) - _options.MinimumGapFrames * (parts.Count - 1);
+ var weights = parts.Select(p => Math.Max(1, TeletextText.VisibleCharacterCount(p.Text))).ToArray();
+ var required = parts.Select(p => Math.Max(1, (int)Math.Ceiling(RequiredDurationMs(p.Text) / FrameMs))).ToArray();
+ var minimum = parts.Select(p => Math.Max(1, (int)Math.Ceiling(AcceptedMinimumDurationMs(p.Text) / FrameMs))).ToArray();
+ if (available < minimum.Sum())
+ {
+ error = $"Not enough time for {parts.Count} subtitles plus {_options.MinimumGapFrames}-frame gaps - edit manually.";
+ return false;
+ }
+
+ var maximum = _options.MaximumDurationMs > 0 ? Math.Max(1, (int)Math.Floor(_options.MaximumDurationMs / FrameMs)) : available;
+ var durations = minimum.ToArray();
+ for (var remaining = available - durations.Sum(); remaining > 0; remaining--)
+ {
+ // Fill the parts still short of their reading time first, then share the rest by length.
+ var best = -1;
+ for (var i = 0; i < parts.Count; i++)
+ {
+ if (durations[i] >= maximum)
+ {
+ continue;
+ }
+
+ if (best < 0)
+ {
+ best = i;
+ continue;
+ }
+
+ var shortI = durations[i] < required[i];
+ var shortBest = durations[best] < required[best];
+ if (shortI != shortBest ? shortI : (double)durations[i] / weights[i] < (double)durations[best] / weights[best])
+ {
+ best = i;
+ }
+ }
+
+ if (best < 0)
+ {
+ break;
+ }
+
+ durations[best]++;
+ }
+
+ var cursor = startMs;
+ for (var i = 0; i < parts.Count; i++)
+ {
+ parts[i].StartMs = cursor;
+ parts[i].EndMs = cursor + durations[i] * FrameMs;
+ cursor += (durations[i] + _options.MinimumGapFrames) * FrameMs;
+ }
+
+ return true;
+ }
+
+ private void AnalyzeItalic(List subtitle)
+ {
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var text = subtitle[i].Text;
+ if (TeletextText.HasItalic(text))
+ {
+ Add(new ArteFix(GroupItalic, true, i + 1, text, TeletextText.RemoveItalic(text),
+ "Teletext has no italics - remove the italic tags.", ArteFixKind.RemoveItalic));
+ }
+ }
+ }
+
+ private void AnalyzeUnneededSpaces(List subtitle)
+ {
+ for (var i = 0; i < subtitle.Count; i++)
+ {
+ var p = subtitle[i];
+ if (_fixes.Any(f => f.Index == i + 1 && f.Kind is ArteFixKind.Rebalance or ArteFixKind.Split))
+ {
+ continue;
+ }
+
+ var cleaned = TeletextText.TrimLines(p.Text);
+ if (cleaned != TeletextText.NormalizeNewLines(p.Text))
+ {
+ Add(new ArteFix(GroupSpaces, true, i + 1, p.Text, cleaned,
+ "Leading or trailing spaces take teletext cells.", ArteFixKind.UnneededSpaces));
+ }
+ }
+ }
+
+ private void AnalyzeMinimumGaps(List subtitle)
+ {
+ var minimumGapFrames = _options.MinimumGapFrames;
+ var starts = subtitle.Select(p => RoundToFrame(p.StartMs)).ToArray();
+ var ends = subtitle.Select(p => RoundToFrame(p.EndMs)).ToArray();
+ for (var i = 1; i < subtitle.Count; i++)
+ {
+ var gapFrames = (int)Math.Round((starts[i] - ends[i - 1]) / FrameMs, MidpointRounding.AwayFromZero);
+ if (gapFrames >= minimumGapFrames)
+ {
+ continue;
+ }
+
+ var missing = minimumGapFrames - gapFrames;
+ var split = _fixes.FirstOrDefault(f => f.Index == i && f.SplitParagraphs != null);
+ var previousStart = split?.SplitParagraphs![^1].StartMs ?? starts[i - 1];
+ var previousText = split?.SplitParagraphs![^1].Text ?? subtitle[i - 1].Text;
+ var previousCapacity = Math.Max(0, (int)Math.Floor((ends[i - 1] - previousStart - AcceptedMinimumDurationMs(previousText)) / FrameMs + 0.0001));
+ var currentCapacity = Math.Max(0, (int)Math.Floor((ends[i] - starts[i] - AcceptedMinimumDurationMs(subtitle[i].Text)) / FrameMs + 0.0001));
+ var gapText = $"{gapFrames} frame{(Math.Abs(gapFrames) == 1 ? string.Empty : "s")}";
+ if (previousCapacity + currentCapacity < missing)
+ {
+ Add(new ArteFix(GroupGaps, false, i, gapText, string.Empty,
+ $"No room for a {minimumGapFrames}-frame gap between subtitles {i} and {i + 1} without going under the minimum durations - edit manually.",
+ ArteFixKind.MinimumGap));
+ continue;
+ }
+
+ // Share the missing frames: half from the previous out time, half from the next in time.
+ var previousShift = Math.Min(previousCapacity, missing / 2);
+ var currentShift = Math.Min(currentCapacity, missing - previousShift);
+ var remaining = missing - previousShift - currentShift;
+ var addPrevious = Math.Min(previousCapacity - previousShift, remaining);
+ previousShift += addPrevious;
+ remaining -= addPrevious;
+ currentShift += Math.Min(currentCapacity - currentShift, remaining);
+
+ var newEnd = ends[i - 1] - previousShift * FrameMs;
+ var newStart = starts[i] + currentShift * FrameMs;
+ ends[i - 1] = newEnd;
+ starts[i] = newStart;
+ var how = previousShift > 0 && currentShift > 0
+ ? $"Out time of {i} {previousShift} frame(s) earlier, in time of {i + 1} {currentShift} frame(s) later."
+ : previousShift > 0
+ ? $"Out time of {i} {previousShift} frame(s) earlier."
+ : $"In time of {i + 1} {currentShift} frame(s) later.";
+ Add(new ArteFix(GroupGaps, true, i, gapText, $"{minimumGapFrames} frames", how, ArteFixKind.MinimumGap)
+ {
+ ProposedEndMs = newEnd,
+ ProposedStartMs = newStart,
+ });
+ }
+ }
+
+ // ---- apply -------------------------------------------------------------------------------
+
+ public sealed record ApplyResult(List Paragraphs, string? Header, int Applied);
+
+ public static ApplyResult Apply(IReadOnlyList source, string? header, IReadOnlyList fixes, ArteOptions options)
+ {
+ var subtitle = source.Select(p => p.Clone()).ToList();
+ var applied = 0;
+ if (Math.Abs(options.SourceFrameRate - 25.0) > 0.001)
+ {
+ subtitle = ConvertFrameRate(subtitle, options.SourceFrameRate);
+ applied++;
+ }
+
+ var selected = fixes.Where(f => f.CanBeFixed && f.Apply).ToList();
+
+ // Rows first (a rebalance keeps the corrected bottom edge), italic removal last.
+ foreach (var fix in selected
+ .Where(f => f.Kind is not (ArteFixKind.Split or ArteFixKind.CreateBlankSubtitle or ArteFixKind.ShiftStartTimeCode))
+ .OrderBy(f => f.Kind == ArteFixKind.TeletextLinePosition ? 0 : f.Kind == ArteFixKind.RemoveItalic ? 2 : 1))
+ {
+ if (fix.Kind == ArteFixKind.Header)
+ {
+ header = fix.ProposedHeader;
+ applied++;
+ continue;
+ }
+
+ if (fix.Index <= 0 || fix.Index > subtitle.Count)
+ {
+ continue;
+ }
+
+ var p = subtitle[fix.Index - 1];
+ switch (fix.Kind)
+ {
+ case ArteFixKind.FrameAccurateTimeCode:
+ p.StartMs = fix.ProposedStartMs!.Value;
+ p.EndMs = fix.ProposedEndMs!.Value;
+ break;
+ case ArteFixKind.DisplayDuration:
+ p.EndMs = fix.ProposedEndMs!.Value;
+ break;
+ case ArteFixKind.MinimumGap:
+ p.EndMs = fix.ProposedEndMs!.Value;
+ if (fix.ProposedStartMs.HasValue && fix.Index < subtitle.Count)
+ {
+ subtitle[fix.Index].StartMs = fix.ProposedStartMs.Value;
+ }
+
+ break;
+ case ArteFixKind.TeletextLinePosition:
+ p.MarginV = fix.After;
+ break;
+ case ArteFixKind.Rebalance:
+ p.MarginV = RowKeepingBottomEdge(p.MarginV, TeletextText.LineCount(p.Text), TeletextText.LineCount(fix.After), options.DoubleHeight) ?? p.MarginV;
+ p.Text = fix.After;
+ break;
+ case ArteFixKind.RemoveItalic:
+ p.Text = TeletextText.RemoveItalic(p.Text);
+ break;
+ case ArteFixKind.UnneededSpaces:
+ case ArteFixKind.TeletextColor:
+ p.Text = fix.After;
+ break;
+ default:
+ continue;
+ }
+
+ applied++;
+ }
+
+ // Splits after every index-based edit, from the end so earlier indices stay valid.
+ foreach (var fix in selected.Where(f => f.Kind == ArteFixKind.Split).OrderByDescending(f => f.Index))
+ {
+ var original = subtitle[fix.Index - 1];
+ var trim = selected.Any(f => f.Index == fix.Index && f.Kind == ArteFixKind.UnneededSpaces);
+ var replacements = fix.SplitParagraphs!.Select(part =>
+ {
+ var result = original.Clone();
+ result.Text = trim ? TeletextText.TrimLines(part.Text) : part.Text;
+ result.StartMs = part.StartMs;
+ result.EndMs = part.EndMs;
+ result.MarginV = RowKeepingBottomEdge(original.MarginV, TeletextText.LineCount(original.Text), TeletextText.LineCount(result.Text), options.DoubleHeight) ?? original.MarginV;
+ return result;
+ }).ToList();
+
+ // A gap fix may have moved the original start or end.
+ replacements[0].StartMs = original.StartMs;
+ replacements[^1].EndMs = original.EndMs;
+ subtitle.RemoveAt(fix.Index - 1);
+ subtitle.InsertRange(fix.Index - 1, replacements);
+ applied++;
+ }
+
+ foreach (var fix in selected.Where(f => f.Kind == ArteFixKind.CreateBlankSubtitle))
+ {
+ subtitle.Insert(0, fix.ProposedParagraph!.Clone());
+ applied++;
+ }
+
+ foreach (var fix in selected.Where(f => f.Kind == ArteFixKind.ShiftStartTimeCode))
+ {
+ var offset = fix.ProposedStartMs!.Value;
+ foreach (var p in subtitle)
+ {
+ p.StartMs += offset;
+ p.EndMs += offset;
+ }
+
+ if (GsiHeader.IsStlHeader(header))
+ {
+ var gsi = GsiHeader.Parse(header!);
+ var frames = Math.Max(0L, (long)Math.Round(GetStartTimeCodeReference(subtitle) / FrameMs));
+ gsi.TimeCodeStartOfProgramme = $"{frames / 90000:00}{frames / 1500 % 60:00}{frames / 25 % 60:00}{frames % 25:00}";
+ header = gsi.ToString();
+ }
+
+ applied++;
+ }
+
+ return new ApplyResult(subtitle, header, applied);
+ }
+
+ ///
+ /// The row that keeps a bottom-anchored subtitle's bottom edge when its line count changes
+ /// (double height: two rows per line). Null when the row must stay as it is.
+ ///
+ public static string? RowKeepingBottomEdge(string? marginV, int oldLineCount, int newLineCount, bool doubleHeight)
+ {
+ if (oldLineCount == newLineCount || oldLineCount < 1 || newLineCount < 1 ||
+ !int.TryParse(marginV, out var row) || row < 1 || row > BottomRow)
+ {
+ return null;
+ }
+
+ var newRow = row + (oldLineCount - newLineCount) * (doubleHeight ? 2 : 1);
+ return newRow is >= 1 and <= BottomRow ? newRow.ToString() : null;
+ }
+}
diff --git a/se5/ArteCheck/ArteFix.cs b/se5/ArteCheck/ArteFix.cs
new file mode 100644
index 0000000..9b019d0
--- /dev/null
+++ b/se5/ArteCheck/ArteFix.cs
@@ -0,0 +1,69 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using SubtitleEdit.Plugins.Shared;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+public enum ArteFixKind
+{
+ None,
+ Header,
+ ShiftStartTimeCode,
+ CreateBlankSubtitle,
+ FrameAccurateTimeCode,
+ DisplayDuration,
+ MinimumGap,
+ TeletextLinePosition,
+ Rebalance,
+ Split,
+ TeletextColor,
+ RemoveItalic,
+ UnneededSpaces,
+}
+
+/// One finding: a correction that can be applied, or an alarm that needs a manual edit.
+public sealed partial class ArteFix : ObservableObject
+{
+ [ObservableProperty] private bool _apply;
+
+ public ArteFix(string group, bool canBeFixed, int index, string before, string after, string reason,
+ ArteFixKind kind = ArteFixKind.None, bool applyByDefault = true)
+ {
+ Group = group;
+ CanBeFixed = canBeFixed;
+ Index = index;
+ Before = before;
+ After = after;
+ Reason = reason;
+ Kind = kind;
+ _apply = canBeFixed && applyByDefault;
+ }
+
+ public string Group { get; }
+ public bool CanBeFixed { get; }
+ public bool IsAlarm => !CanBeFixed;
+
+ /// 1-based subtitle number; 0 for file-level findings (header, start time code, ...).
+ public int Index { get; }
+
+ public string IndexDisplay => Index > 0 ? "#" + Index : "File";
+ public string Before { get; }
+ public string After { get; }
+ public bool HasAfter => !string.IsNullOrEmpty(After);
+ public string Reason { get; }
+ public ArteFixKind Kind { get; }
+ public string TimeRange { get; set; } = string.Empty;
+ public bool HasTimeRange => !string.IsNullOrEmpty(TimeRange);
+
+ /// Before/after are subtitle text (shown as a teletext preview) rather than time codes or header values.
+ public bool IsTextChange => Kind is ArteFixKind.Rebalance or ArteFixKind.TeletextColor or ArteFixKind.RemoveItalic or ArteFixKind.UnneededSpaces ||
+ (IsAlarm && Group is ArteChecker.GroupLayout or ArteChecker.GroupColors);
+
+ public bool IsPlainChange => !IsTextChange;
+ public string SeverityIcon => CanBeFixed ? "mdi-wrench-outline" : "mdi-alert-octagon-outline";
+
+ internal string? ProposedHeader { get; init; }
+ internal double? ProposedStartMs { get; init; }
+ internal double? ProposedEndMs { get; init; }
+ internal PluginParagraph? ProposedParagraph { get; init; }
+ internal List? SplitParagraphs { get; init; }
+}
diff --git a/se5/ArteCheck/ArteOptions.cs b/se5/ArteCheck/ArteOptions.cs
new file mode 100644
index 0000000..99081f9
--- /dev/null
+++ b/se5/ArteCheck/ArteOptions.cs
@@ -0,0 +1,62 @@
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+public enum ArteCheckType
+{
+ BlankSubtitle,
+ FrameAccurateTimeCodes,
+ DisplayDuration,
+ MaximumTwoLines,
+ TeletextLinePosition,
+ TeletextColors,
+ TeletextLineLength,
+ Italic,
+ UnneededSpaces,
+ MinimumGaps,
+}
+
+public sealed class ArteOptions
+{
+ public const int PresetTeletextMaxCells = 37;
+ public const int PresetMinimumGapFrames = 5;
+ public const double PresetReadingDurationTolerancePercent = 15.0;
+ public const int PresetShortMinimumFrames = 18;
+
+ public ArteProfile Profile { get; set; } = ArteProfile.All[0];
+
+ /// Frame rate the subtitle was timed for; ARTE always delivers 25 fps.
+ public double SourceFrameRate { get; set; } = 25.0;
+
+ public bool ShiftToStartTimeCode { get; set; }
+ public double TargetStartMs { get; set; }
+
+ /// Working limit of visible characters per row; the encoded row is always 40 cells.
+ public int TeletextMaxCells { get; set; } = PresetTeletextMaxCells;
+
+ public int MinimumGapFrames { get; set; } = PresetMinimumGapFrames;
+ public double ReadingDurationTolerancePercent { get; set; } = PresetReadingDurationTolerancePercent;
+ public bool AcceptShortDurations { get; set; }
+ public int ShortMinimumFrames { get; set; } = PresetShortMinimumFrames;
+
+ public double MinimumDurationMs { get; set; } = 1000;
+ public double MaximumDurationMs { get; set; } = 8000;
+ public double MaximumCharactersPerSecond { get; set; } = 25;
+ public bool DoubleHeight { get; set; } = true;
+
+ public HashSet EnabledChecks { get; set; } = Enum.GetValues().ToHashSet();
+
+ public bool IsPreset =>
+ TeletextMaxCells == PresetTeletextMaxCells &&
+ MinimumGapFrames == PresetMinimumGapFrames &&
+ Math.Abs(ReadingDurationTolerancePercent - PresetReadingDurationTolerancePercent) < 0.001 &&
+ !AcceptShortDurations &&
+ ShortMinimumFrames == PresetShortMinimumFrames;
+
+ public void ApplyPreset()
+ {
+ TeletextMaxCells = PresetTeletextMaxCells;
+ MinimumGapFrames = PresetMinimumGapFrames;
+ ReadingDurationTolerancePercent = PresetReadingDurationTolerancePercent;
+ AcceptShortDurations = false;
+ ShortMinimumFrames = PresetShortMinimumFrames;
+ }
+}
diff --git a/se5/ArteCheck/ArteProfile.cs b/se5/ArteCheck/ArteProfile.cs
new file mode 100644
index 0000000..88a8b36
--- /dev/null
+++ b/se5/ArteCheck/ArteProfile.cs
@@ -0,0 +1,18 @@
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+/// An ARTE delivery profile: the EBU language code it is delivered with, and whether it is SDH.
+public sealed record ArteProfile(string Code, string Name, string Description, string LanguageCode, bool IsSdh)
+{
+ public static readonly IReadOnlyList All = new[]
+ {
+ new ArteProfile("STA", "ARTE STA", "German subtitles", "08", false),
+ new ArteProfile("STF", "ARTE STF", "French subtitles", "0F", false),
+ new ArteProfile("HG-DEU", "ARTE HG DEU", "German SDH (VA-MAL)", "2D", true),
+ new ArteProfile("HG-FRA", "ARTE HG FRA", "French SDH (VF-MAL)", "2F", true),
+ };
+
+ public static ArteProfile FromLanguageCode(string? languageCode) =>
+ All.FirstOrDefault(p => p.LanguageCode == languageCode) ?? All[0];
+
+ public override string ToString() => $"{Name} - {Description}";
+}
diff --git a/se5/ArteCheck/FixGroup.cs b/se5/ArteCheck/FixGroup.cs
new file mode 100644
index 0000000..0aad0e1
--- /dev/null
+++ b/se5/ArteCheck/FixGroup.cs
@@ -0,0 +1,91 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+public sealed partial class FixGroup : ObservableObject
+{
+ [ObservableProperty] private bool _isExpanded;
+
+ public FixGroup(string name, IReadOnlyList items, bool isExpanded)
+ {
+ Name = name;
+ Items = items;
+ _isExpanded = isExpanded;
+ Icon = IconFor(name);
+ }
+
+ public string Name { get; }
+ public string Icon { get; }
+ public IReadOnlyList Items { get; }
+ public int FixCount => Items.Count(i => i.CanBeFixed);
+ public int AlarmCount => Items.Count(i => i.IsAlarm);
+ public bool HasFixes => FixCount > 0;
+ public bool HasAlarms => AlarmCount > 0;
+
+ [RelayCommand]
+ private void SelectAll()
+ {
+ foreach (var item in Items.Where(i => i.CanBeFixed))
+ {
+ item.Apply = true;
+ }
+ }
+
+ [RelayCommand]
+ private void SelectNone()
+ {
+ foreach (var item in Items)
+ {
+ item.Apply = false;
+ }
+ }
+
+ public static string IconFor(string group) => group switch
+ {
+ ArteChecker.GroupHeader => "mdi-file-cog-outline",
+ ArteChecker.GroupStartTimeCode => "mdi-clock-start",
+ ArteChecker.GroupBlank => "mdi-checkbox-blank-outline",
+ ArteChecker.GroupFrames => "mdi-filmstrip",
+ ArteChecker.GroupDuration => "mdi-timer-outline",
+ ArteChecker.GroupLayout => "mdi-call-split",
+ ArteChecker.GroupPosition => "mdi-format-vertical-align-bottom",
+ ArteChecker.GroupColors => "mdi-palette-outline",
+ ArteChecker.GroupItalic => "mdi-format-italic",
+ ArteChecker.GroupSpaces => "mdi-keyboard-space",
+ ArteChecker.GroupGaps => "mdi-arrow-expand-horizontal",
+ _ => "mdi-alert-circle-outline",
+ };
+}
+
+public sealed partial class CheckItem : ObservableObject
+{
+ [ObservableProperty] private bool _isEnabled = true;
+
+ public CheckItem(ArteCheckType type, string name, string description, string icon)
+ {
+ Type = type;
+ Name = name;
+ Description = description;
+ Icon = icon;
+ }
+
+ public ArteCheckType Type { get; }
+ public string Name { get; }
+ public string Description { get; }
+ public string Icon { get; }
+
+ public static IReadOnlyList CreateAll() => new[]
+ {
+ new CheckItem(ArteCheckType.BlankSubtitle, "Blank control subtitle", "5-frame blank subtitle at the programme start", FixGroup.IconFor(ArteChecker.GroupBlank)),
+ new CheckItem(ArteCheckType.FrameAccurateTimeCodes, "Frame-accurate time codes", "Whole 25 fps frames", FixGroup.IconFor(ArteChecker.GroupFrames)),
+ new CheckItem(ArteCheckType.DisplayDuration, "Display duration", "Reading time, minimum and maximum", FixGroup.IconFor(ArteChecker.GroupDuration)),
+ new CheckItem(ArteCheckType.MaximumTwoLines, "Maximum two lines", "Rebalance or split longer subtitles", "mdi-format-line-spacing"),
+ new CheckItem(ArteCheckType.TeletextLinePosition, "Teletext row", "Rows 22 / 20 with double height", FixGroup.IconFor(ArteChecker.GroupPosition)),
+ new CheckItem(ArteCheckType.TeletextColors, "Teletext colors", "Yellow or uncolored, SDH: 8 colors", FixGroup.IconFor(ArteChecker.GroupColors)),
+ new CheckItem(ArteCheckType.TeletextLineLength, "Characters per row", "Visible characters + color cells", "mdi-ruler"),
+ new CheckItem(ArteCheckType.Italic, "No italics", "Teletext cannot show italics", FixGroup.IconFor(ArteChecker.GroupItalic)),
+ new CheckItem(ArteCheckType.UnneededSpaces, "Unneeded spaces", "Leading and trailing spaces", FixGroup.IconFor(ArteChecker.GroupSpaces)),
+ new CheckItem(ArteCheckType.MinimumGaps, "Minimum gaps", "Frames between subtitles", FixGroup.IconFor(ArteChecker.GroupGaps)),
+ };
+}
diff --git a/se5/ArteCheck/GsiHeader.cs b/se5/ArteCheck/GsiHeader.cs
new file mode 100644
index 0000000..8c48bda
--- /dev/null
+++ b/se5/ArteCheck/GsiHeader.cs
@@ -0,0 +1,79 @@
+using System.Globalization;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+///
+/// The EBU STL GSI block as Subtitle Edit holds it: a 1024-character string in a single-byte
+/// code page, so every field sits at its fixed byte offset (EBU Tech 3264).
+///
+public sealed class GsiHeader
+{
+ public const int Length = 1024;
+
+ private char[] _chars;
+
+ private GsiHeader(string text) => _chars = text.ToCharArray();
+
+ public static bool IsStlHeader(string? header)
+ {
+ if (header is null || header.Length != Length)
+ {
+ return false;
+ }
+
+ return int.TryParse(header.AsSpan(0, 3), NumberStyles.None, CultureInfo.InvariantCulture, out _) &&
+ header.AsSpan(3, 3).SequenceEqual("STL");
+ }
+
+ public static GsiHeader Parse(string header) => new(header);
+
+ /// Subtitle Edit's default header (the one written for a file without a GSI block).
+ public static GsiHeader CreateDefault()
+ {
+ var text =
+ "437" + "STL25.01" + "0" + "00" + "0A" +
+ "No Title".PadRight(32) + new string(' ', 32) + new string(' ', 32) + new string(' ', 32) +
+ new string(' ', 32) + new string(' ', 32) + "0".PadRight(16) +
+ "101021" + "101021" + "01" + "00725" + "00725" + "001" + "40" + "23" + "1" +
+ "00000000" + "00000001" + "1" + "1" + "USA" +
+ new string(' ', 32) + new string(' ', 32) + new string(' ', 32) +
+ new string(' ', 75) + new string(' ', 576);
+ return new GsiHeader(text);
+ }
+
+ public string CodePage { get => Get(0, 3); set => Set(0, 3, value); }
+ public string DiskFormatCode { get => Get(3, 8); set => Set(3, 8, value); }
+ public string DisplayStandardCode { get => Get(11, 1); set => Set(11, 1, value); }
+ public string CharacterCodeTable { get => Get(12, 2); set => Set(12, 2, value); }
+ public string LanguageCode { get => Get(14, 2); set => Set(14, 2, value); }
+ public string MaxCharactersPerRow { get => Get(251, 2); set => Set(251, 2, value); }
+ public string MaxRows { get => Get(253, 2); set => Set(253, 2, value); }
+
+ /// TCP - time code start of programme, HHMMSSFF.
+ public string TimeCodeStartOfProgramme { get => Get(256, 8); set => Set(256, 8, value); }
+
+ /// The fields that differ from , one per line, e.g. "Language: 08".
+ public string DescribeDifferences(GsiHeader other)
+ {
+ var mine = Describe().Split('\n');
+ var theirs = other.Describe().Split('\n');
+ return string.Join("\n", mine.Where((line, i) => line != theirs[i]));
+ }
+
+ public string Describe() =>
+ $"Code page: {CodePage}\nDisk format: {DiskFormatCode}\nDisplay standard: {DisplayStandardCode}\n" +
+ $"Character table: {CharacterCodeTable}\nLanguage: {LanguageCode}\n" +
+ $"Characters per row: {MaxCharactersPerRow}\nRows: {MaxRows}";
+
+ public GsiHeader Clone() => new(ToString());
+
+ public override string ToString() => new(_chars);
+
+ private string Get(int offset, int length) => new(_chars, offset, length);
+
+ private void Set(int offset, int length, string value)
+ {
+ var padded = value.Length >= length ? value[..length] : value.PadRight(length);
+ padded.CopyTo(0, _chars, offset, length);
+ }
+}
diff --git a/se5/ArteCheck/MainViewModel.cs b/se5/ArteCheck/MainViewModel.cs
new file mode 100644
index 0000000..0c3fe59
--- /dev/null
+++ b/se5/ArteCheck/MainViewModel.cs
@@ -0,0 +1,502 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using SubtitleEdit.Plugins.Shared;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+public enum FindingFilter
+{
+ All,
+ Fixes,
+ Alarms,
+}
+
+public sealed partial class MainViewModel : ObservableObject
+{
+ private const int SettingsVersion = 1;
+ public const string EbuFormatName = "EBU STL";
+
+ private readonly PluginRequest _request;
+ private readonly bool _isEbu;
+ private readonly bool _legacyRequest;
+ private readonly long _videoOffsetMs;
+ private readonly Stack<(List Paragraphs, string? Header, double FrameRate, int Applied)> _undo = new();
+ private readonly Dictionary _expandedGroups = new();
+ private List _paragraphs;
+ private string? _header;
+ private List _fixes = new();
+ private bool _suspendAnalysis;
+
+ [ObservableProperty] private ArteProfile _selectedProfile;
+ [ObservableProperty] private double _selectedFrameRate = 25.0;
+ [ObservableProperty] private bool _shiftToStartTimeCode;
+ [ObservableProperty] private string _targetStartTimeCode = "10:00:00:00";
+ [ObservableProperty] private bool _isTargetStartTimeCodeValid = true;
+ [ObservableProperty] private decimal? _teletextMaxCells = ArteOptions.PresetTeletextMaxCells;
+ [ObservableProperty] private decimal? _minimumGapFrames = ArteOptions.PresetMinimumGapFrames;
+ [ObservableProperty] private decimal? _readingTolerancePercent = (decimal)ArteOptions.PresetReadingDurationTolerancePercent;
+ [ObservableProperty] private bool _acceptShortDurations;
+ [ObservableProperty] private decimal? _shortMinimumFrames = ArteOptions.PresetShortMinimumFrames;
+ [ObservableProperty] private FindingFilter _filter = FindingFilter.All;
+ [ObservableProperty] private int _fixCount;
+ [ObservableProperty] private int _alarmCount;
+ [ObservableProperty] private int _selectedCount;
+ [ObservableProperty] private int _appliedCount;
+ [ObservableProperty] private string _statusText = string.Empty;
+ [ObservableProperty] private bool _isPreset = true;
+ [ObservableProperty] private bool _canUndo;
+
+ public MainViewModel(PluginRequest request)
+ {
+ _request = request;
+ _isEbu = string.Equals(request.Subtitle.Format, EbuFormatName, StringComparison.OrdinalIgnoreCase);
+ _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();
+
+ // Rows hold video-relative times; ARTE time codes (10:00:00:00, ...) are what the file shows.
+ foreach (var p in _paragraphs)
+ {
+ p.StartMs += _videoOffsetMs;
+ p.EndMs += _videoOffsetMs;
+ }
+
+ _header = request.Subtitle.Header;
+ var headerLanguage = GsiHeader.IsStlHeader(_header) ? GsiHeader.Parse(_header!).LanguageCode : null;
+ _selectedProfile = ArteProfile.FromLanguageCode(headerLanguage);
+ if (_paragraphs.Count > 0)
+ {
+ _targetStartTimeCode = ArteChecker.FormatTimeCode(ArteChecker.GetStartTimeCodeReference(_paragraphs));
+ }
+
+ Checks = CheckItem.CreateAll();
+ foreach (var check in Checks)
+ {
+ check.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(CheckItem.IsEnabled))
+ {
+ Analyze();
+ }
+ };
+ }
+
+ _suspendAnalysis = true;
+ LoadSettings();
+ _suspendAnalysis = false;
+ Analyze();
+ }
+
+ public IReadOnlyList Profiles => ArteProfile.All;
+ public IReadOnlyList FrameRates { get; } = new[] { 23.976, 24.0, 25.0, 29.97, 30.0 };
+ public IReadOnlyList Checks { get; }
+ public ObservableCollection Groups { get; } = new();
+
+ public string SubtitleInfo
+ {
+ get
+ {
+ 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} subtitles";
+ }
+ }
+
+ public bool ShowFormatWarning => !_isEbu;
+ public string FormatWarning => _legacyRequest
+ ? "This Subtitle Edit version only sends SubRip to plugins, so teletext rows and the EBU header are not checked. Update Subtitle Edit for the full check."
+ : $"The subtitle is {(_request.Subtitle.Format is { Length: > 0 } f ? f : "not EBU STL")}. Header and teletext row checks need EBU STL - save as EBU STL for delivery.";
+
+ public bool HasFindings => FixCount + AlarmCount > 0;
+ public bool IsClean => !HasFindings;
+ public bool ShowAll { get => Filter == FindingFilter.All; set { if (value) Filter = FindingFilter.All; } }
+ public bool ShowFixes { get => Filter == FindingFilter.Fixes; set { if (value) Filter = FindingFilter.Fixes; } }
+ public bool ShowAlarms { get => Filter == FindingFilter.Alarms; set { if (value) Filter = FindingFilter.Alarms; } }
+ public string PresetLabel => IsPreset ? "ARTE preset" : "Custom";
+ public bool HasSelection => SelectedCount > 0;
+
+ partial void OnSelectedProfileChanged(ArteProfile value) => Analyze();
+ partial void OnSelectedFrameRateChanged(double value) => Analyze();
+ partial void OnShiftToStartTimeCodeChanged(bool value) => Analyze();
+ partial void OnTeletextMaxCellsChanged(decimal? value) => Analyze();
+ partial void OnMinimumGapFramesChanged(decimal? value) => Analyze();
+ partial void OnReadingTolerancePercentChanged(decimal? value) => Analyze();
+ partial void OnAcceptShortDurationsChanged(bool value) => Analyze();
+ partial void OnShortMinimumFramesChanged(decimal? value) => Analyze();
+
+ partial void OnTargetStartTimeCodeChanged(string value)
+ {
+ IsTargetStartTimeCodeValid = ArteChecker.TryParseTimeCode(value, out _);
+ if (ShiftToStartTimeCode)
+ {
+ Analyze();
+ }
+ }
+
+ partial void OnFilterChanged(FindingFilter value)
+ {
+ OnPropertyChanged(nameof(ShowAll));
+ OnPropertyChanged(nameof(ShowFixes));
+ OnPropertyChanged(nameof(ShowAlarms));
+ RebuildGroups();
+ }
+
+ partial void OnIsPresetChanged(bool value) => OnPropertyChanged(nameof(PresetLabel));
+ partial void OnSelectedCountChanged(int value) => OnPropertyChanged(nameof(HasSelection));
+ partial void OnAppliedCountChanged(int value) => OnPropertyChanged(nameof(HasApplied));
+
+ public bool HasApplied => AppliedCount > 0;
+
+ private ArteOptions BuildOptions()
+ {
+ var rules = _request.Rules;
+ var options = new ArteOptions
+ {
+ Profile = SelectedProfile,
+ SourceFrameRate = SelectedFrameRate,
+ ShiftToStartTimeCode = ShiftToStartTimeCode && IsTargetStartTimeCodeValid,
+ TeletextMaxCells = (int)(TeletextMaxCells ?? ArteOptions.PresetTeletextMaxCells),
+ MinimumGapFrames = (int)(MinimumGapFrames ?? ArteOptions.PresetMinimumGapFrames),
+ ReadingDurationTolerancePercent = (double)(ReadingTolerancePercent ?? 0),
+ AcceptShortDurations = AcceptShortDurations,
+ ShortMinimumFrames = (int)(ShortMinimumFrames ?? ArteOptions.PresetShortMinimumFrames),
+ EnabledChecks = Checks.Where(c => c.IsEnabled).Select(c => c.Type).ToHashSet(),
+ };
+
+ if (ArteChecker.TryParseTimeCode(TargetStartTimeCode, out var targetMs))
+ {
+ options.TargetStartMs = targetMs;
+ }
+
+ if (rules != null)
+ {
+ options.MinimumDurationMs = rules.SubtitleMinimumDisplayMilliseconds;
+ options.MaximumDurationMs = rules.SubtitleMaximumDisplayMilliseconds;
+ options.MaximumCharactersPerSecond = rules.SubtitleMaximumCharactersPerSeconds;
+ options.DoubleHeight = rules.EbuStlTeletextUseDoubleHeight;
+ }
+
+ if (!_isEbu)
+ {
+ options.EnabledChecks.Remove(ArteCheckType.TeletextLinePosition);
+ }
+
+ return options;
+ }
+
+ private void Analyze()
+ {
+ if (_suspendAnalysis)
+ {
+ return;
+ }
+
+ var options = BuildOptions();
+ IsPreset = options.IsPreset;
+ _fixes = ArteChecker.Analyze(_paragraphs, _header, options);
+ if (!_isEbu)
+ {
+ _fixes.RemoveAll(f => f.Kind == ArteFixKind.Header);
+ }
+
+ foreach (var fix in _fixes)
+ {
+ fix.PropertyChanged += OnFixChanged;
+ }
+
+ FixCount = _fixes.Count(f => f.CanBeFixed);
+ AlarmCount = _fixes.Count(f => f.IsAlarm);
+ OnPropertyChanged(nameof(HasFindings));
+ OnPropertyChanged(nameof(IsClean));
+ UpdateSelectedCount();
+ RebuildGroups();
+ }
+
+ private void RebuildGroups()
+ {
+ foreach (var group in Groups)
+ {
+ _expandedGroups[group.Name] = group.IsExpanded;
+ }
+
+ Groups.Clear();
+ var visible = _fixes.Where(f => Filter switch
+ {
+ FindingFilter.Fixes => f.CanBeFixed,
+ FindingFilter.Alarms => f.IsAlarm,
+ _ => true,
+ });
+
+ var first = true;
+ foreach (var group in visible.GroupBy(f => f.Group))
+ {
+ var expanded = _expandedGroups.TryGetValue(group.Key, out var wasExpanded) ? wasExpanded : first;
+ Groups.Add(new FixGroup(group.Key, group.ToList(), expanded));
+ first = false;
+ }
+ }
+
+ private void OnFixChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ArteFix.Apply))
+ {
+ UpdateSelectedCount();
+ }
+ }
+
+ private void UpdateSelectedCount()
+ {
+ SelectedCount = _fixes.Count(f => f.CanBeFixed && f.Apply);
+ var frameRateNote = Math.Abs(SelectedFrameRate - 25.0) > 0.001 ? $" + {SelectedFrameRate:0.###} → 25 fps conversion" : string.Empty;
+ StatusText = HasFindings
+ ? $"{SelectedCount} of {FixCount} correction(s) selected{frameRateNote}"
+ : $"No issues found{frameRateNote}";
+ }
+
+ [RelayCommand]
+ private void ApplyPreset()
+ {
+ _suspendAnalysis = true;
+ TeletextMaxCells = ArteOptions.PresetTeletextMaxCells;
+ MinimumGapFrames = ArteOptions.PresetMinimumGapFrames;
+ ReadingTolerancePercent = (decimal)ArteOptions.PresetReadingDurationTolerancePercent;
+ AcceptShortDurations = false;
+ ShortMinimumFrames = ArteOptions.PresetShortMinimumFrames;
+ _suspendAnalysis = false;
+ Analyze();
+ }
+
+ [RelayCommand]
+ private void SelectAll()
+ {
+ foreach (var fix in _fixes.Where(f => f.CanBeFixed))
+ {
+ fix.Apply = true;
+ }
+ }
+
+ [RelayCommand]
+ private void SelectNone()
+ {
+ foreach (var fix in _fixes)
+ {
+ fix.Apply = false;
+ }
+ }
+
+ [RelayCommand]
+ private void ExpandAll()
+ {
+ foreach (var group in Groups)
+ {
+ group.IsExpanded = true;
+ }
+ }
+
+ [RelayCommand]
+ private void CollapseAll()
+ {
+ foreach (var group in Groups)
+ {
+ group.IsExpanded = false;
+ }
+ }
+
+ /// Applies the selected corrections to the working copy and checks again (fixes can enable further fixes).
+ [RelayCommand]
+ private void ApplySelected()
+ {
+ if (!ApplyPending())
+ {
+ return;
+ }
+
+ Analyze();
+ }
+
+ private bool ApplyPending()
+ {
+ var options = BuildOptions();
+ var hasConversion = Math.Abs(options.SourceFrameRate - 25.0) > 0.001;
+ if (SelectedCount == 0 && !hasConversion)
+ {
+ return false;
+ }
+
+ var result = ArteChecker.Apply(_paragraphs, _header, _fixes, options);
+ _undo.Push((_paragraphs, _header, SelectedFrameRate, AppliedCount));
+ CanUndo = true;
+ _paragraphs = result.Paragraphs;
+ _header = result.Header;
+ AppliedCount += result.Applied;
+
+ // The working copy is on the 25 fps timeline now.
+ _suspendAnalysis = true;
+ SelectedFrameRate = 25.0;
+ _suspendAnalysis = false;
+ OnPropertyChanged(nameof(SubtitleInfo));
+ return true;
+ }
+
+ [RelayCommand]
+ private void Undo()
+ {
+ if (_undo.Count == 0)
+ {
+ return;
+ }
+
+ var (paragraphs, header, frameRate, applied) = _undo.Pop();
+ _paragraphs = paragraphs;
+ _header = header;
+ AppliedCount = applied;
+ CanUndo = _undo.Count > 0;
+ _suspendAnalysis = true;
+ SelectedFrameRate = frameRate;
+ _suspendAnalysis = false;
+ OnPropertyChanged(nameof(SubtitleInfo));
+ Analyze();
+ }
+
+ public string BuildReport()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"ARTE check - {SelectedProfile}");
+ sb.AppendLine(SubtitleInfo);
+ sb.AppendLine($"{FixCount} correction(s), {AlarmCount} alarm(s)");
+ foreach (var group in _fixes.GroupBy(f => f.Group))
+ {
+ sb.AppendLine();
+ sb.AppendLine($"{group.Key} ({group.Count()})");
+ sb.AppendLine(new string('-', group.Key.Length + 5));
+ foreach (var fix in group)
+ {
+ var location = fix.IndexDisplay;
+ if (fix.HasTimeRange)
+ {
+ location += $" {fix.TimeRange}";
+ }
+
+ sb.AppendLine($"{(fix.CanBeFixed ? "FIX " : "ALARM")} {location}");
+ sb.AppendLine($" {fix.Reason}");
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ /// The response for OK: applies what is still selected, then hands the working copy back.
+ public PluginResponse BuildResponse()
+ {
+ ApplyPending();
+ if (AppliedCount == 0)
+ {
+ return new PluginResponse { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion };
+ }
+
+ var paragraphs = _paragraphs.Select(p =>
+ {
+ var copy = p.Clone();
+ copy.StartMs -= _videoOffsetMs;
+ copy.EndMs -= _videoOffsetMs;
+ return copy;
+ }).ToList();
+
+ var subtitle = _legacyRequest
+ ? new PluginSubtitle
+ {
+ Format = "SubRip",
+ Native = SubRipParser.Serialize(paragraphs.Select(p => new SrtBlock
+ {
+ StartMs = (long)Math.Round(p.StartMs),
+ EndMs = (long)Math.Round(p.EndMs),
+ Text = p.Text,
+ }).ToList()),
+ }
+ : new PluginSubtitle
+ {
+ Format = _request.Subtitle.Format,
+ Header = _isEbu ? _header : null,
+ Paragraphs = paragraphs,
+ };
+
+ return new PluginResponse
+ {
+ Status = PluginStatus.Ok,
+ Message = $"ARTE check: {AppliedCount} correction(s) applied.",
+ UndoDescription = "ARTE check",
+ Subtitle = subtitle,
+ Settings = BuildSettings(),
+ SettingsVersion = SettingsVersion,
+ };
+ }
+
+ public PluginResponse BuildCancelResponse() =>
+ new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion };
+
+ private JsonElement BuildSettings()
+ {
+ var payload = new Dictionary
+ {
+ ["teletextMaxCells"] = (int)(TeletextMaxCells ?? ArteOptions.PresetTeletextMaxCells),
+ ["minimumGapFrames"] = (int)(MinimumGapFrames ?? ArteOptions.PresetMinimumGapFrames),
+ ["readingTolerancePercent"] = (double)(ReadingTolerancePercent ?? 0),
+ ["acceptShortDurations"] = AcceptShortDurations,
+ ["shortMinimumFrames"] = (int)(ShortMinimumFrames ?? ArteOptions.PresetShortMinimumFrames),
+ ["disabledChecks"] = Checks.Where(c => !c.IsEnabled).Select(c => c.Type.ToString()).ToArray(),
+ };
+ return JsonSerializer.SerializeToElement(payload);
+ }
+
+ private void LoadSettings()
+ {
+ if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings)
+ {
+ return;
+ }
+
+ if (settings.TryGetProperty("teletextMaxCells", out var cells) && cells.TryGetInt32(out var c) && c is >= 10 and <= 40)
+ {
+ TeletextMaxCells = c;
+ }
+
+ if (settings.TryGetProperty("minimumGapFrames", out var gap) && gap.TryGetInt32(out var g) && g is >= 0 and <= 50)
+ {
+ MinimumGapFrames = g;
+ }
+
+ if (settings.TryGetProperty("readingTolerancePercent", out var tolerance) && tolerance.TryGetDouble(out var t) && t is >= 0 and <= 100)
+ {
+ ReadingTolerancePercent = (decimal)t;
+ }
+
+ if (settings.TryGetProperty("acceptShortDurations", out var shortDurations) && shortDurations.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ AcceptShortDurations = shortDurations.GetBoolean();
+ }
+
+ if (settings.TryGetProperty("shortMinimumFrames", out var shortFrames) && shortFrames.TryGetInt32(out var s) && s is >= 1 and <= 250)
+ {
+ ShortMinimumFrames = s;
+ }
+
+ if (settings.TryGetProperty("disabledChecks", out var disabled) && disabled.ValueKind == JsonValueKind.Array)
+ {
+ var names = disabled.EnumerateArray().Select(e => e.GetString()).ToHashSet();
+ foreach (var check in Checks)
+ {
+ check.IsEnabled = !names.Contains(check.Type.ToString());
+ }
+ }
+ }
+
+ public static string FormatFrameRate(double fps) => fps.ToString("0.###", CultureInfo.InvariantCulture) + " fps";
+}
diff --git a/se5/ArteCheck/MainWindow.axaml b/se5/ArteCheck/MainWindow.axaml
new file mode 100644
index 0000000..4050431
--- /dev/null
+++ b/se5/ArteCheck/MainWindow.axaml
@@ -0,0 +1,446 @@
+
+
+
+
+
+
+
+
+
+
+
+ Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/se5/ArteCheck/MainWindow.axaml.cs b/se5/ArteCheck/MainWindow.axaml.cs
new file mode 100644
index 0000000..89dda55
--- /dev/null
+++ b/se5/ArteCheck/MainWindow.axaml.cs
@@ -0,0 +1,79 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.Platform.Storage;
+using SubtitleEdit.Plugins.Shared;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+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 = "ARTE check failed: " + exception.Message };
+ }
+
+ Close();
+ }
+
+ private async void OnSaveReport(object? sender, RoutedEventArgs e)
+ {
+ var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Save ARTE report",
+ SuggestedFileName = "arte_report.txt",
+ DefaultExtension = "txt",
+ FileTypeChoices = new[] { new FilePickerFileType("Text") { Patterns = new[] { "*.txt" } } },
+ });
+
+ if (file == null)
+ {
+ return;
+ }
+
+ await using var stream = await file.OpenWriteAsync();
+ await using var writer = new StreamWriter(stream);
+ await writer.WriteAsync(_vm.BuildReport());
+ }
+}
diff --git a/se5/ArteCheck/Program.cs b/se5/ArteCheck/Program.cs
new file mode 100644
index 0000000..38d7670
--- /dev/null
+++ b/se5/ArteCheck/Program.cs
@@ -0,0 +1,16 @@
+using Projektanker.Icons.Avalonia;
+using Projektanker.Icons.Avalonia.MaterialDesign;
+using SubtitleEdit.Plugins.Shared;
+using System;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+public static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ IconProvider.Current.Register();
+ return PluginBootstrap.Run(args);
+ }
+}
diff --git a/se5/ArteCheck/README.md b/se5/ArteCheck/README.md
new file mode 100644
index 0000000..8ab7722
--- /dev/null
+++ b/se5/ArteCheck/README.md
@@ -0,0 +1,41 @@
+# ARTE check (Subtitle Edit 5 plugin)
+
+Checks EBU STL teletext subtitles against ARTE's delivery rules and fixes what can be fixed safely. Things that need a person (no room for a gap, text that cannot be split, unmappable colors) are listed as **alarms**.
+
+The rules come from the *Check and Fix ARTE Errors* tool by Triathlon-rally in
+[subtitleedit#15185](https://github.com/SubtitleEdit/subtitleedit/pull/15185). Here they are packaged as a plugin.
+
+Needs a Subtitle Edit build whose plugin contract includes `subtitle.header` and `subtitle.paragraphs`. Only those carry the EBU header and the teletext rows of a binary EBU STL file. With an older build the plugin still runs on the SubRip text, but it skips the header and row checks.
+
+## Checks
+
+| Check | What it does |
+|---|---|
+| Header / language | Sets code page 850, `STL25.01`, teletext display standard, 40 characters × 23 rows, and the profile's language code (STA 08, STF 0F, HG DEU 2D, HG FRA 2F). Only the fields that change are shown. |
+| Start time code | Optional: shifts the whole file so the programme starts on a given time code (e.g. `10:00:00:00`) and updates the header's TCP. |
+| Blank control subtitle | Inserts or fixes the 5-frame blank subtitle at the programme start. |
+| Frame rate | *Timed for* converts 23.976/24/29.97/30 fps material to 25 fps. The programme start stays where it is; only the time after it is scaled. |
+| Frame-accurate time codes | Rounds in and out times to whole 25 fps frames. |
+| Display duration | Checks reading time (Subtitle Edit's minimum duration and max CPS, minus the reading tolerance), plus the maximum duration. Corrections are offered but not pre-selected. |
+| Maximum two lines / characters per row | Rebalances the line break, or splits the subtitle into several inside the original time range. Each color change uses a teletext cell. |
+| Teletext row | Double height: one line on row 22, two lines on row 20. Moves a whole file down one row if it sits one row too high. Rows higher up the screen are left alone. |
+| Teletext colors | Normal subtitles are all yellow or uncolored, with no boxing. SDH colors map to the eight teletext colors. |
+| No italics, unneeded spaces | Teletext has no italics; leading and trailing spaces use cells. |
+| Minimum gaps | Shares the missing frames between the previous out time and the next in time, without going under the minimum durations. |
+
+## UI
+
+- **Sidebar.** Delivery profile (read from the header language), source frame rate, programme start, limits, and which checks to run. *Reset to ARTE preset* sets 37 characters, 5-frame gaps, 15 % tolerance and strict durations.
+- **Findings.** Grouped by check, with corrections (green) and alarms (red). Text changes are previewed as teletext: a 40-cell row on black, in the real teletext colors. Header, row and time code changes are shown as before → after values.
+- **Apply and re-check** applies the selected corrections inside the plugin and checks again, because one fix can make room for another. **Undo** steps back one pass.
+- **OK** applies whatever is still selected and returns the result to Subtitle Edit as a single undo step. **Report** saves the findings as a text file.
+
+The limits and enabled checks are remembered between runs. Durations, CPS and double height come from Subtitle Edit's settings (`request.rules`).
+
+## Build
+
+See `.github/workflows/arte-check.yml`. Tests are in `../ArteCheck.Tests`:
+
+```
+dotnet test se5/ArteCheck.Tests
+```
diff --git a/se5/ArteCheck/TeletextPreview.cs b/se5/ArteCheck/TeletextPreview.cs
new file mode 100644
index 0000000..9554ff3
--- /dev/null
+++ b/se5/ArteCheck/TeletextPreview.cs
@@ -0,0 +1,105 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Media;
+using System.Text.RegularExpressions;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+///
+/// Shows subtitle text the way a teletext decoder draws it: monospaced, on black, in the eight
+/// teletext colors (white when uncolored), with the tags themselves hidden.
+///
+public sealed partial class TeletextPreview : TextBlock
+{
+ public static readonly StyledProperty MarkupProperty =
+ AvaloniaProperty.Register(nameof(Markup));
+
+ [GeneratedRegex(@"<(/?)(font|i|b|u|box)\b([^>]*)>|\{\\[^}]*\}", RegexOptions.IgnoreCase)]
+ private static partial Regex TokenRegex();
+
+ static TeletextPreview()
+ {
+ MarkupProperty.Changed.AddClassHandler((preview, _) => preview.Rebuild());
+ }
+
+ public string? Markup
+ {
+ get => GetValue(MarkupProperty);
+ set => SetValue(MarkupProperty, value);
+ }
+
+ protected override Type StyleKeyOverride => typeof(TextBlock);
+
+ private static IBrush BrushFor(string? color) => (TeletextText.NearestTeletextColor(color ?? "white") ?? "White") switch
+ {
+ "Black" => new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x40)),
+ "Red" => new SolidColorBrush(Color.FromRgb(0xFF, 0x4D, 0x4D)),
+ "Green" => new SolidColorBrush(Color.FromRgb(0x3D, 0xF5, 0x5A)),
+ "Yellow" => new SolidColorBrush(Color.FromRgb(0xFF, 0xF2, 0x3D)),
+ "Blue" => new SolidColorBrush(Color.FromRgb(0x5C, 0x8D, 0xFF)),
+ "Magenta" => new SolidColorBrush(Color.FromRgb(0xFF, 0x5C, 0xF0)),
+ "Cyan" => new SolidColorBrush(Color.FromRgb(0x3D, 0xF5, 0xF5)),
+ _ => Brushes.White,
+ };
+
+ private void Rebuild()
+ {
+ var inlines = new InlineCollection();
+ var text = TeletextText.NormalizeNewLines(Markup ?? string.Empty);
+ var colors = new Stack();
+ string? color = null;
+ var italic = false;
+ var position = 0;
+
+ void AddText(string value)
+ {
+ var lines = value.Split('\n');
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (i > 0)
+ {
+ inlines.Add(new LineBreak());
+ }
+
+ if (lines[i].Length > 0)
+ {
+ inlines.Add(new Run(lines[i])
+ {
+ Foreground = BrushFor(color),
+ FontStyle = italic ? FontStyle.Italic : FontStyle.Normal,
+ });
+ }
+ }
+ }
+
+ foreach (Match match in TokenRegex().Matches(text))
+ {
+ AddText(text[position..match.Index]);
+ position = match.Index + match.Length;
+ var closing = match.Groups[1].Value == "/";
+ switch (match.Groups[2].Value.ToLowerInvariant())
+ {
+ case "font" when closing:
+ color = colors.Count > 0 ? colors.Pop() : null;
+ break;
+ case "font":
+ colors.Push(color);
+ var attribute = TeletextText.ColorAttribute.Match(match.Groups[3].Value);
+ if (attribute.Success)
+ {
+ color = attribute.Groups["quoted"].Success ? attribute.Groups["quoted"].Value :
+ attribute.Groups["single"].Success ? attribute.Groups["single"].Value : attribute.Groups["bare"].Value;
+ }
+
+ break;
+ case "i":
+ italic = !closing;
+ break;
+ }
+ }
+
+ AddText(text[position..]);
+ Inlines = inlines;
+ }
+}
diff --git a/se5/ArteCheck/TeletextText.cs b/se5/ArteCheck/TeletextText.cs
new file mode 100644
index 0000000..5c8f2db
--- /dev/null
+++ b/se5/ArteCheck/TeletextText.cs
@@ -0,0 +1,290 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SubtitleEdit.Plugins.ArteCheck;
+
+///
+/// Text helpers for teletext subtitles as Subtitle Edit holds them: lines separated by newlines,
+/// colors as <font color="...">, boxing as <box>, alignment as {\anN}.
+///
+public static partial class TeletextText
+{
+ [GeneratedRegex(@"<[^>]*>|\{\\[^}]*\}")]
+ private static partial Regex TagRegex();
+
+ [GeneratedRegex(@"?i\s*>", RegexOptions.IgnoreCase)]
+ private static partial Regex ItalicTagRegex();
+
+ [GeneratedRegex(@"?box\b[^>]*>", RegexOptions.IgnoreCase)]
+ private static partial Regex BoxTagRegex();
+
+ [GeneratedRegex(@"]*\bcolor\s*=", RegexOptions.IgnoreCase)]
+ private static partial Regex FontColorRegex();
+
+ [GeneratedRegex(@"<(/?)(font|[a-z]+)\b([^>]*)>|\{\\[^}]*\}", RegexOptions.IgnoreCase)]
+ private static partial Regex TokenRegex();
+
+ [GeneratedRegex("""\bcolor\s*=\s*(?:"(?[^"]+)"|'(?[^']+)'|(?[^\s>]+))""", RegexOptions.IgnoreCase)]
+ private static partial Regex ColorAttributeRegex();
+
+ public static string NormalizeNewLines(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n');
+
+ public static string[] SplitLines(string text) => NormalizeNewLines(text).Split('\n');
+
+ public static int LineCount(string text) => SplitLines(text).Length;
+
+ public static string RemoveTags(string text) => TagRegex().Replace(text, string.Empty);
+
+ public static bool IsBlank(string? text) => string.IsNullOrWhiteSpace(text == null ? null : RemoveTags(text));
+
+ public static int VisibleCharacterCount(string text) => RemoveTags(text).Count(c => c is not '\r' and not '\n');
+
+ public static string RemoveItalic(string text) => ItalicTagRegex().Replace(text, string.Empty);
+
+ public static bool HasItalic(string text) => ItalicTagRegex().IsMatch(text);
+
+ public static string RemoveBox(string text) => BoxTagRegex().Replace(text, string.Empty);
+
+ public static bool HasBox(string text) => BoxTagRegex().IsMatch(text);
+
+ public static bool HasFontColor(string text) => FontColorRegex().IsMatch(text);
+
+ public static Regex ColorAttribute => ColorAttributeRegex();
+
+ public static string Words(string text) => Regex.Replace(RemoveTags(text), @"\s+", " ").Trim();
+
+ ///
+ /// Visible characters plus the teletext color control cells each line needs: every colored run
+ /// that touches a line costs one cell on that line (the color code written before it).
+ ///
+ public static IReadOnlyList<(string Visible, int ColorCells)> MeasureLines(string text)
+ {
+ var lines = new List<(string, int)>();
+ var colorStack = new Stack();
+ string? currentColor = null;
+ var visible = new StringBuilder();
+ var runs = 0;
+ string? lastRunColor = null;
+ var normalized = NormalizeNewLines(RemoveItalic(text));
+ var position = 0;
+
+ void CountChar(char c)
+ {
+ if (c == '\n')
+ {
+ lines.Add((visible.ToString(), runs));
+ visible.Clear();
+ runs = 0;
+ lastRunColor = null;
+ return;
+ }
+
+ if (currentColor != null && !string.Equals(currentColor, lastRunColor, StringComparison.OrdinalIgnoreCase))
+ {
+ runs++;
+ }
+
+ lastRunColor = currentColor;
+ visible.Append(c);
+ }
+
+ foreach (Match match in TokenRegex().Matches(normalized))
+ {
+ for (; position < match.Index; position++)
+ {
+ CountChar(normalized[position]);
+ }
+
+ position = match.Index + match.Length;
+ if (!match.Groups[2].Value.Equals("font", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (match.Groups[1].Value == "/")
+ {
+ currentColor = colorStack.Count > 0 ? colorStack.Pop() : null;
+ }
+ else
+ {
+ colorStack.Push(currentColor);
+ var color = ColorAttributeRegex().Match(match.Groups[3].Value);
+ if (color.Success)
+ {
+ currentColor = color.Groups["quoted"].Success ? color.Groups["quoted"].Value :
+ color.Groups["single"].Success ? color.Groups["single"].Value : color.Groups["bare"].Value;
+ }
+ }
+ }
+
+ for (; position < normalized.Length; position++)
+ {
+ CountChar(normalized[position]);
+ }
+
+ lines.Add((visible.ToString(), runs));
+ return lines;
+ }
+
+ public static bool Fits(string text, int maxCells, int maxLines = 2)
+ {
+ var lines = MeasureLines(text);
+ return lines.Count <= maxLines && lines.All(l => l.Visible.Length + l.ColorCells <= maxCells);
+ }
+
+ ///
+ /// Re-wraps into one or two lines that fit ,
+ /// preferring a balanced break after punctuation. Leading alignment tags stay in front.
+ /// Returns null when no break fits.
+ ///
+ public static string? Rebalance(string text, int maxCells)
+ {
+ var (prefix, body) = SplitLeadingAlignment(NormalizeNewLines(text));
+ var words = body.Replace('\n', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (words.Length == 0)
+ {
+ return null;
+ }
+
+ var single = prefix + string.Join(' ', words);
+ if (Fits(single, maxCells, 1))
+ {
+ return single;
+ }
+
+ string? best = null;
+ var bestScore = double.MaxValue;
+ for (var i = 1; i < words.Length; i++)
+ {
+ var first = string.Join(' ', words.Take(i));
+ var second = string.Join(' ', words.Skip(i));
+ var candidate = prefix + first + "\n" + second;
+ if (!Fits(candidate, maxCells))
+ {
+ continue;
+ }
+
+ var firstVisible = RemoveTags(first).TrimEnd();
+ double score = Math.Abs(RemoveTags(first).Length - RemoveTags(second).Length);
+ if (firstVisible.EndsWith('.') || firstVisible.EndsWith('!') || firstVisible.EndsWith('?') || firstVisible.EndsWith('…'))
+ {
+ score -= 25;
+ }
+ else if (firstVisible.EndsWith(',') || firstVisible.EndsWith(':') || firstVisible.EndsWith(';'))
+ {
+ score -= 15;
+ }
+
+ // Subtitlers break at clause ends when they can; otherwise as balanced as possible.
+ // A short first line reads worse than a short second line (pyramid shape).
+ if (RemoveTags(first).Length > RemoveTags(second).Length)
+ {
+ score += 0.5;
+ }
+
+ if (score < bestScore)
+ {
+ bestScore = score;
+ best = candidate;
+ }
+ }
+
+ return best;
+ }
+
+ ///
+ /// Splits text that cannot fit two lines into the fewest subtitles that each fit, cutting after
+ /// a sentence end where possible. Returns null when no split fits.
+ ///
+ public static List? SplitToFit(string text, int maxCells)
+ {
+ var (prefix, body) = SplitLeadingAlignment(NormalizeNewLines(text));
+ var words = body.Replace('\n', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var parts = new List();
+ var start = 0;
+ while (start < words.Length)
+ {
+ var end = start;
+ var lastSentenceEnd = -1;
+ while (end < words.Length && Rebalance(string.Join(' ', words[start..(end + 1)]), maxCells) != null)
+ {
+ var visible = RemoveTags(words[end]);
+ if (visible.EndsWith('.') || visible.EndsWith('!') || visible.EndsWith('?') || visible.EndsWith('…'))
+ {
+ lastSentenceEnd = end;
+ }
+
+ end++;
+ }
+
+ if (end == start)
+ {
+ return null; // a single word that does not fit
+ }
+
+ // Prefer ending the part on a sentence when that keeps at least 40% of what would fit.
+ if (end < words.Length && lastSentenceEnd >= start && lastSentenceEnd + 1 - start >= (end - start) * 0.4)
+ {
+ end = lastSentenceEnd + 1;
+ }
+
+ var part = Rebalance(string.Join(' ', words[start..end]), maxCells);
+ if (part == null)
+ {
+ return null;
+ }
+
+ parts.Add(prefix + part);
+ start = end;
+ }
+
+ return parts.Count >= 2 ? parts : null;
+ }
+
+ private static (string Prefix, string Body) SplitLeadingAlignment(string text)
+ {
+ var match = Regex.Match(text, @"^(\{\\an\d\})");
+ return match.Success ? (match.Value, text[match.Length..]) : (string.Empty, text);
+ }
+
+ public static string TrimLines(string text) =>
+ string.Join("\n", SplitLines(text).Select(line => line.Trim()));
+
+ /// Same mapping as Subtitle Edit's Ebu.GetNearestColorName: the teletext color a color is nearest to.
+ public static string? NearestTeletextColor(string color)
+ {
+ color = color.Trim().TrimStart('#').ToLowerInvariant();
+ switch (color)
+ {
+ case "black" or "000000": return "Black";
+ case "red" or "ff0000": return "Red";
+ case "green" or "00ff00": return "Green";
+ case "yellow" or "ffff00": return "Yellow";
+ case "blue" or "0000ff": return "Blue";
+ case "magenta" or "ff00ff": return "Magenta";
+ case "cyan" or "00ffff": return "Cyan";
+ case "white" or "ffffff": return "White";
+ }
+
+ if (color.Length != 6 || !Regex.IsMatch(color, "^[0-9a-f]{6}$"))
+ {
+ return null;
+ }
+
+ const int maxDiff = 130;
+ var r = Convert.ToInt32(color[..2], 16);
+ var g = Convert.ToInt32(color.Substring(2, 2), 16);
+ var b = Convert.ToInt32(color.Substring(4, 2), 16);
+ bool Low(int v) => v < maxDiff;
+ bool High(int v) => v > 255 - maxDiff;
+ if (Low(r) && Low(g) && Low(b)) return "Black";
+ if (High(r) && Low(g) && Low(b)) return "Red";
+ if (Low(r) && High(g) && Low(b)) return "Green";
+ if (High(r) && High(g) && Low(b)) return "Yellow";
+ if (Low(r) && Low(g) && High(b)) return "Blue";
+ if (High(r) && Low(g) && High(b)) return "Magenta";
+ if (Low(r) && High(g) && High(b)) return "Cyan";
+ if (High(r) && High(g) && High(b)) return "White";
+ return null;
+ }
+}
diff --git a/se5/ArteCheck/app.manifest b/se5/ArteCheck/app.manifest
new file mode 100644
index 0000000..c8e2cf5
--- /dev/null
+++ b/se5/ArteCheck/app.manifest
@@ -0,0 +1,10 @@
+
+
+
+
+
+ true/pm
+ PerMonitorV2
+
+
+
diff --git a/se5/ArteCheck/plugin.json b/se5/ArteCheck/plugin.json
new file mode 100644
index 0000000..5863837
--- /dev/null
+++ b/se5/ArteCheck/plugin.json
@@ -0,0 +1,12 @@
+{
+ "apiVersion": 1,
+ "name": "ARTE check",
+ "description": "Checks and fixes EBU STL teletext subtitles for ARTE delivery: header, blank control subtitle, 25 fps frames, durations, gaps, rows, colors and characters per row. Corrections are previewed and selectable; problems that need a manual edit are listed as alarms.",
+ "version": "1.0.0",
+ "author": "Subtitle Edit (rules by Triathlon-rally)",
+ "url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/ArteCheck",
+ "menu": "Tools",
+ "minSeVersion": "5.0.0",
+ "runtime": "dotnet",
+ "entry": "ArteCheck.dll"
+}
diff --git a/se5/Plugin-Shared/PluginContract.cs b/se5/Plugin-Shared/PluginContract.cs
index 061e791..72b6d04 100644
--- a/se5/Plugin-Shared/PluginContract.cs
+++ b/se5/Plugin-Shared/PluginContract.cs
@@ -33,6 +33,10 @@ public sealed class PluginRequest
public PluginThemeColors? ThemeColors { get; set; }
public string SeVersion { get; set; } = string.Empty;
+
+ /// Read-only snapshot of the user's subtitle rules. Null on older SE versions.
+ public PluginRules? Rules { get; set; }
+
public JsonElement? Settings { get; set; }
///
@@ -59,8 +63,69 @@ public sealed class PluginSubtitle
{
public string Format { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
+
+ /// The subtitle in its own format. Empty for binary formats (EBU STL, PAC, ...).
public string Native { get; set; } = string.Empty;
+
public string SubRip { get; set; } = string.Empty;
+
+ ///
+ /// In-memory header: the EBU STL GSI block (1024 characters), ASSA [Script Info]/styles, ...
+ /// Null on older SE versions. In a response, null keeps SE's current header.
+ ///
+ public string? Header { get; set; }
+
+ ///
+ /// The lines exactly as SE holds them. Null on older SE versions. In a response, non-empty
+ /// paragraphs win over : SE applies them as-is and keeps its format.
+ ///
+ public List? Paragraphs { get; set; }
+}
+
+/// One subtitle line with every field SE keeps (not only what survives SubRip).
+public sealed class PluginParagraph
+{
+ public double StartMs { get; set; }
+ public double EndMs { get; set; }
+ public string Text { get; set; } = string.Empty;
+ public string? Style { get; set; }
+ public string? Actor { get; set; }
+ public string? Language { get; set; }
+ public string? Region { get; set; }
+ public string? Effect { get; set; }
+ public string? Extra { get; set; }
+ public string? MarginL { get; set; }
+ public string? MarginR { get; set; }
+
+ /// For EBU STL / DVB Teletext: the teletext row (vertical position) the line starts on.
+ public string? MarginV { get; set; }
+
+ public int Layer { get; set; }
+ public bool IsComment { get; set; }
+ public bool Forced { get; set; }
+ public bool NewSection { get; set; }
+ public string? Bookmark { get; set; }
+
+ public PluginParagraph Clone() => (PluginParagraph)MemberwiseClone();
+}
+
+/// Read-only snapshot of the user's Subtitle Edit rules.
+public sealed class PluginRules
+{
+ public int SubtitleMinimumDisplayMilliseconds { get; set; }
+ public int SubtitleMaximumDisplayMilliseconds { get; set; }
+ public double SubtitleMaximumCharactersPerSeconds { get; set; }
+ public double SubtitleOptimalCharactersPerSeconds { get; set; }
+ public int SubtitleLineMaximumLength { get; set; }
+ public int MaxNumberOfLines { get; set; }
+ public int MinimumMillisecondsBetweenLines { get; set; }
+ public int MinimumFramesBetweenLines { get; set; }
+ public bool UseFrameMode { get; set; }
+ public bool EbuStlTeletextUseBox { get; set; }
+ public bool EbuStlTeletextUseDoubleHeight { get; set; }
+
+ /// Paragraph times are video-relative; the grid and the saved file show time + this offset.
+ public long VideoOffsetMs { get; set; }
}
public sealed class PluginResponse