diff --git a/se5/AmericanToBritish/README.md b/se5/AmericanToBritish/README.md
index b978244e..e3408da8 100644
--- a/se5/AmericanToBritish/README.md
+++ b/se5/AmericanToBritish/README.md
@@ -20,7 +20,9 @@ and format as the Subtitle Edit 4 plugin, so an existing list keeps working:
```
A `Word` with the same American spelling as a built-in pair replaces it; `Ignore`
-turns a built-in pair off.
+turns a built-in pair off. An `Ignore` can also be a phrase, which is then left
+untouched wherever it occurs (`` keeps "favor" there while
+it is still converted elsewhere). A few such phrases are built in, e.g. "por favor".
First Subtitle Edit 5 plugin built on top of the shared
[`Plugin-Shared`](../Plugin-Shared/) library — Avalonia app boot, theme passing,
diff --git a/se5/AmericanToBritish/WordListWindow.axaml b/se5/AmericanToBritish/WordListWindow.axaml
index e30c2569..aa747384 100644
--- a/se5/AmericanToBritish/WordListWindow.axaml
+++ b/se5/AmericanToBritish/WordListWindow.axaml
@@ -94,13 +94,13 @@
Opacity="0.7"
TextWrapping="Wrap"
Margin="0,0,0,8"
- Text="American words from the built-in list that should never be converted." />
+ Text="American words from the built-in list that should never be converted, or whole phrases to leave untouched (e.g. "por favor")." />
.
/// Each pair becomes three case-aware regexes: lowercase, UPPERCASE, and Titlecase,
/// all matched as whole words. Picks direction via .
+/// Keep phrases (built-in <Keep text="..."/> plus the local list's ignored entries) are left
+/// untouched, e.g. the Spanish "por favor" must not become "por favour".
///
public sealed class EnglishVariantConverter
{
@@ -29,6 +33,13 @@ public sealed class EnglishVariantConverter
private readonly EnglishVariantDirection _direction;
+ private readonly List _keepPhrases = new();
+ private Regex? _keepRegex;
+
+ /// Private use area chars stand in for kept phrases while the rules run.
+ private const char PlaceholderFirst = '\uE000';
+ private const char PlaceholderLast = '\uF8FF';
+
public EnglishVariantConverter(EnglishVariantDirection direction, LocalWordList? localWordList = null)
{
_direction = direction;
@@ -49,10 +60,21 @@ public EnglishVariantConverter(EnglishVariantDirection direction, LocalWordList?
foreach (var us in localWordList.Ignored)
{
ignored.Add(us);
+ _keepPhrases.Add(us);
}
}
LoadBuiltInWordList(ignored);
+
+ if (_keepPhrases.Count > 0)
+ {
+ // Longest first so the longer of two overlapping phrases wins.
+ var alternatives = _keepPhrases
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderByDescending(p => p.Length)
+ .Select(p => Regex.Escape(p).Replace("\\ ", "\\s+"));
+ _keepRegex = new Regex("\\b(?:" + string.Join("|", alternatives) + ")\\b", RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture);
+ }
}
public int RuleCount => _rules.Count;
@@ -64,6 +86,8 @@ public string Convert(string text)
return text;
}
+ var kept = MaskKeepPhrases(ref text);
+
var candidates = CollectCandidateRules(text);
foreach (var index in candidates)
{
@@ -71,9 +95,73 @@ public string Convert(string text)
text = pattern.Replace(text, replacement);
}
+ text = UnmaskKeepPhrases(text, kept);
return RevertFontColorAttribute(text);
}
+ ///
+ /// Replaces each kept phrase with one private use char (not a word char, so no rule can
+ /// match it or across it). Skipped for text that already contains such chars.
+ ///
+ private List? MaskKeepPhrases(ref string text)
+ {
+ if (_keepRegex == null || ContainsPlaceholderChar(text))
+ {
+ return null;
+ }
+
+ var kept = new List();
+ text = _keepRegex.Replace(text, m =>
+ {
+ if (kept.Count > PlaceholderLast - PlaceholderFirst)
+ {
+ return m.Value;
+ }
+
+ kept.Add(m.Value);
+ return ((char)(PlaceholderFirst + kept.Count - 1)).ToString();
+ });
+
+ return kept.Count > 0 ? kept : null;
+ }
+
+ private static string UnmaskKeepPhrases(string text, List? kept)
+ {
+ if (kept == null)
+ {
+ return text;
+ }
+
+ var sb = new StringBuilder(text.Length + 16);
+ foreach (var c in text)
+ {
+ var index = c - PlaceholderFirst;
+ if (index >= 0 && index < kept.Count)
+ {
+ sb.Append(kept[index]);
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static bool ContainsPlaceholderChar(string text)
+ {
+ foreach (var c in text)
+ {
+ if (c >= PlaceholderFirst && c <= PlaceholderLast)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public bool TryConvert(string text, out string converted)
{
converted = Convert(text);
@@ -92,6 +180,15 @@ private void LoadBuiltInWordList(HashSet ignored)
return;
}
+ foreach (var element in xml.Root.Elements("Keep"))
+ {
+ var keep = element.Attribute("text")?.Value.Trim();
+ if (!string.IsNullOrEmpty(keep))
+ {
+ _keepPhrases.Add(keep!);
+ }
+ }
+
foreach (var element in xml.Root.Elements("Word"))
{
var us = element.Attribute("us")?.Value;
diff --git a/se5/Plugin-Shared/WordList.xml b/se5/Plugin-Shared/WordList.xml
index f3474872..7c563c02 100644
--- a/se5/Plugin-Shared/WordList.xml
+++ b/se5/Plugin-Shared/WordList.xml
@@ -1,5 +1,8 @@
+
+
+