Skip to content

Commit 8a17a5b

Browse files
authored
Merge pull request #11764 from SubtitleEdit/feat/dutch-casing-rules-11747
Dutch casing: 's/'t contractions + IJ digraph (#11747)
2 parents bdf4ab0 + 3b1af61 commit 8a17a5b

3 files changed

Lines changed: 165 additions & 10 deletions

File tree

src/libse/Common/DutchCasing.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System;
2+
3+
namespace Nikse.SubtitleEdit.Core.Common
4+
{
5+
/// <summary>
6+
/// Dutch-specific capitalization rules:
7+
/// - The "IJ" digraph is capitalized as two letters: "ijsland" -> "IJsland" (not "Ijsland").
8+
/// - Sentence-initial contractions ('s, 't, 'n, 'k, 'm, 'r) keep their lowercase letter and the
9+
/// following word takes the capital instead: "'s morgens" -> "'s Morgens".
10+
/// Callers must gate on <see cref="IsDutch"/>; the methods themselves do not check the language.
11+
/// </summary>
12+
public static class DutchCasing
13+
{
14+
public const string LanguageCode = "nl";
15+
16+
private static readonly char[] Apostrophes = { '\'', '’' }; // straight + typographic
17+
private const string ContractionLetters = "stnkmr"; // 's 't 'n 'k 'm 'r
18+
19+
public static bool IsDutch(string language)
20+
=> string.Equals(language, LanguageCode, StringComparison.OrdinalIgnoreCase);
21+
22+
/// <summary>
23+
/// Capitalizes the first letter of <paramref name="word"/> (which must begin with the letter
24+
/// to capitalize), applying the Dutch IJ-digraph rule. Idempotent and safe on empty input.
25+
/// </summary>
26+
public static string CapitalizeFirstLetter(string word)
27+
{
28+
if (string.IsNullOrEmpty(word))
29+
{
30+
return word;
31+
}
32+
33+
// Word-initial "ij" is the IJ digraph - capitalize both letters. Skip when already "IJ".
34+
if (word.Length >= 2 &&
35+
(word[0] == 'i' || word[0] == 'I') &&
36+
(word[1] == 'j' || word[1] == 'J') &&
37+
!(word[0] == 'I' && word[1] == 'J'))
38+
{
39+
return "IJ" + word.Substring(2);
40+
}
41+
42+
return char.ToUpper(word[0]) + word.Substring(1);
43+
}
44+
45+
/// <summary>
46+
/// Applies Dutch sentence-start casing to <paramref name="text"/>, which must begin at the
47+
/// first character to consider (a leading apostrophe contraction is allowed; leading tags and
48+
/// other punctuation must already be stripped by the caller). Handles 's/'t/etc. contractions
49+
/// (the contraction stays lowercase and the next word is capitalized) and the IJ digraph for
50+
/// the word that ends up capitalized. Returns the input unchanged when nothing applies.
51+
/// </summary>
52+
public static string FixSentenceStart(string text)
53+
{
54+
if (string.IsNullOrEmpty(text))
55+
{
56+
return text;
57+
}
58+
59+
// "'s morgens" / "'S morgens" -> "'s Morgens": keep the contraction lowercase and move
60+
// the capital to the following word. A leading apostrophe that is NOT one of these
61+
// contractions (e.g. an opening quote) falls through to normal capitalization.
62+
if (text.Length > 3 &&
63+
Array.IndexOf(Apostrophes, text[0]) >= 0 &&
64+
ContractionLetters.IndexOf(char.ToLowerInvariant(text[1])) >= 0 &&
65+
text[2] == ' ')
66+
{
67+
var contraction = text[0] + char.ToLowerInvariant(text[1]).ToString();
68+
return contraction + " " + CapitalizeFirstLetter(text.Substring(3));
69+
}
70+
71+
return CapitalizeFirstLetter(text);
72+
}
73+
}
74+
}

src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterParagraph.cs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,11 @@ private static string DoFix(Paragraph p, Paragraph prev, Encoding encoding, stri
9595
isPrevEndOfLine = true;
9696
}
9797

98-
if ((!text.StartsWith("www.", StringComparison.Ordinal) && !text.StartsWith("http:", StringComparison.Ordinal) && !text.StartsWith("https:", StringComparison.Ordinal)) &&
99-
(char.IsLower(firstLetter) || Helper.IsTurkishLittleI(firstLetter, encoding, language)) &&
100-
!char.IsDigit(firstLetter) &&
101-
isPrevEndOfLine)
98+
var isUrl = text.StartsWith("www.", StringComparison.Ordinal) || text.StartsWith("http:", StringComparison.Ordinal) || text.StartsWith("https:", StringComparison.Ordinal);
99+
// Dutch additionally acts on a leading apostrophe contraction ('s morgens), where
100+
// 'firstLetter' is an apostrophe rather than a lowercase letter.
101+
if (!isUrl && !char.IsDigit(firstLetter) && isPrevEndOfLine &&
102+
(char.IsLower(firstLetter) || Helper.IsTurkishLittleI(firstLetter, encoding, language) || DutchCasing.IsDutch(language)))
102103
{
103104
bool isMatchInKnowAbbreviations = language == "en" &&
104105
(prevText.EndsWith(" o.r.", StringComparison.Ordinal) ||
@@ -107,7 +108,12 @@ private static string DoFix(Paragraph p, Paragraph prev, Encoding encoding, stri
107108

108109
if (!isMatchInKnowAbbreviations)
109110
{
110-
if (Helper.IsTurkishLittleI(firstLetter, encoding, language))
111+
if (DutchCasing.IsDutch(language))
112+
{
113+
// Handles 's/'t/etc. contractions (capitalize the next word) and the IJ digraph.
114+
p.Text = pre + DutchCasing.FixSentenceStart(text);
115+
}
116+
else if (Helper.IsTurkishLittleI(firstLetter, encoding, language))
111117
{
112118
p.Text = pre + Helper.GetTurkishUppercaseLetter(firstLetter, encoding) + text.Substring(1);
113119
}
@@ -174,10 +180,11 @@ private static string DoFix(Paragraph p, Paragraph prev, Encoding encoding, stri
174180
char firstLetter = text[0];
175181
string prevText = HtmlUtil.RemoveHtmlTags(arr[0]);
176182
bool isPrevEndOfLine = Helper.IsPreviousTextEndOfParagraph(prevText);
177-
if ((!text.StartsWith("www.", StringComparison.Ordinal) && !text.StartsWith("http:", StringComparison.Ordinal) && !text.StartsWith("https:", StringComparison.Ordinal)) &&
178-
(char.IsLower(firstLetter) || Helper.IsTurkishLittleI(firstLetter, encoding, language)) &&
183+
var isUrl = text.StartsWith("www.", StringComparison.Ordinal) || text.StartsWith("http:", StringComparison.Ordinal) || text.StartsWith("https:", StringComparison.Ordinal);
184+
if (!isUrl &&
179185
!prevText.EndsWith("...", StringComparison.Ordinal) &&
180-
isPrevEndOfLine)
186+
isPrevEndOfLine &&
187+
(char.IsLower(firstLetter) || Helper.IsTurkishLittleI(firstLetter, encoding, language) || DutchCasing.IsDutch(language)))
181188
{
182189
bool isMatchInKnowAbbreviations = language == "en" &&
183190
(prevText.EndsWith(" o.r.", StringComparison.Ordinal) ||
@@ -186,7 +193,11 @@ private static string DoFix(Paragraph p, Paragraph prev, Encoding encoding, stri
186193

187194
if (!isMatchInKnowAbbreviations)
188195
{
189-
if (Helper.IsTurkishLittleI(firstLetter, encoding, language))
196+
if (DutchCasing.IsDutch(language))
197+
{
198+
text = pre + DutchCasing.FixSentenceStart(text);
199+
}
200+
else if (Helper.IsTurkishLittleI(firstLetter, encoding, language))
190201
{
191202
text = pre + Helper.GetTurkishUppercaseLetter(firstLetter, encoding) + text.Substring(1);
192203
}
@@ -302,7 +313,12 @@ private static string DoFix(Paragraph p, Paragraph prev, Encoding encoding, stri
302313
}
303314
else if (st.StrippedText.Length > 0 && st.StrippedText[0] != char.ToUpper(st.StrippedText[0]) && !st.Pre.EndsWith('[') && !st.Pre.Contains("..."))
304315
{
305-
text = st.Pre + char.ToUpper(st.StrippedText[0]) + st.StrippedText.Substring(1) + st.Post;
316+
// DutchCasing.CapitalizeFirstLetter applies the IJ digraph rule for "nl"; for
317+
// other languages it is equivalent to char.ToUpper on the first letter.
318+
var capitalized = DutchCasing.IsDutch(language)
319+
? DutchCasing.CapitalizeFirstLetter(st.StrippedText)
320+
: char.ToUpper(st.StrippedText[0]) + st.StrippedText.Substring(1);
321+
text = st.Pre + capitalized + st.Post;
306322
p.Text = p.Text.Remove(indexOfNewLine + len).Insert(indexOfNewLine + len, text);
307323
}
308324
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using Nikse.SubtitleEdit.Core.Common;
2+
3+
namespace LibSETests.Core;
4+
5+
public class DutchCasingTest
6+
{
7+
// --- IJ digraph (CapitalizeFirstLetter) ---
8+
9+
[Theory]
10+
[InlineData("ijsland", "IJsland")]
11+
[InlineData("ijs", "IJs")]
12+
[InlineData("ijzer", "IJzer")]
13+
[InlineData("Ijsland", "IJsland")] // wrongly single-capitalized -> corrected
14+
[InlineData("IJsland", "IJsland")] // already correct -> idempotent
15+
[InlineData("morgens", "Morgens")] // ordinary word
16+
[InlineData("a", "A")]
17+
[InlineData("", "")]
18+
public void CapitalizeFirstLetter_HandlesIjDigraph(string input, string expected)
19+
{
20+
Assert.Equal(expected, DutchCasing.CapitalizeFirstLetter(input));
21+
}
22+
23+
// --- Sentence start: apostrophe contractions + IJ (FixSentenceStart) ---
24+
25+
[Theory]
26+
[InlineData("'s morgens is te laat", "'s Morgens is te laat")] // the issue's "Not Good" case
27+
[InlineData("'S morgens is te laat", "'s Morgens is te laat")] // the issue's "WRONG" case
28+
[InlineData("'s Morgens is te laat", "'s Morgens is te laat")] // the issue's "Good" case -> unchanged
29+
[InlineData("'t kind", "'t Kind")]
30+
[InlineData("'n appel", "'n Appel")]
31+
[InlineData("'k weet het", "'k Weet het")]
32+
[InlineData("'m geven", "'m Geven")]
33+
[InlineData("'t ijs smelt", "'t IJs smelt")] // contraction + IJ digraph on next word
34+
public void FixSentenceStart_HandlesContractions(string input, string expected)
35+
{
36+
Assert.Equal(expected, DutchCasing.FixSentenceStart(input));
37+
}
38+
39+
[Theory]
40+
[InlineData("ijsland is koud", "IJsland is koud")] // plain sentence start with IJ
41+
[InlineData("morgen is het laat", "Morgen is het laat")]
42+
public void FixSentenceStart_CapitalizesFirstWord(string input, string expected)
43+
{
44+
Assert.Equal(expected, DutchCasing.FixSentenceStart(input));
45+
}
46+
47+
[Fact]
48+
public void FixSentenceStart_LeavesNonContractionApostropheAlone()
49+
{
50+
// A leading apostrophe that is not a Dutch contraction (e.g. an opening quote) must not be
51+
// treated as one - we don't move/force casing there (the quote/contraction ambiguity).
52+
Assert.Equal("'Hallo,' zei hij", DutchCasing.FixSentenceStart("'Hallo,' zei hij"));
53+
}
54+
55+
[Theory]
56+
[InlineData("nl", true)]
57+
[InlineData("NL", true)]
58+
[InlineData("en", false)]
59+
[InlineData("", false)]
60+
[InlineData(null, false)]
61+
public void IsDutch(string? language, bool expected)
62+
{
63+
Assert.Equal(expected, DutchCasing.IsDutch(language!));
64+
}
65+
}

0 commit comments

Comments
 (0)