From a6e0cd4bed49a117b416b13ac9097e4ad5510616 Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Sun, 2 Aug 2026 22:16:40 +0200 Subject: [PATCH 1/4] Fix feet/inches parsing with apostrophe grouping --- .../CustomCode/LengthTests.FeetInches.cs | 15 +++++ .../CustomCode/Quantities/Length.extra.cs | 66 ++++++++++++++----- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs index c7b529d8fe..66dc085319 100644 --- a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs +++ b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs @@ -80,6 +80,21 @@ public void TryParseFeetInches(string str, double expectedFeet, string cultureNa AssertEx.EqualTolerance(expectedFeet, result.Feet, 1e-5); } + [Theory] + [InlineData("1'000'", 1000)] + [InlineData("1'000' 6\"", 1000.5)] + [InlineData("1'000'6\"", 1000.5)] + [InlineData("1'000'000' 2\"", 1000000.16666667)] + [InlineData("-1'000' 6\"", -1000.5)] + public void TryParseFeetInches_WhenGroupSeparatorIsFootAbbreviation_ParsesFeetAndInches(string str, double expectedFeet) + { + var formatProvider = new CultureInfo(GermanSwitzerland, false); + formatProvider.NumberFormat.NumberGroupSeparator = "'"; + + Assert.True(Length.TryParseFeetInches(str, out Length result, formatProvider)); + AssertEx.EqualTolerance(expectedFeet, result.Feet, 1e-5); + } + public static IEnumerable InvalidData { get => diff --git a/UnitsNet/CustomCode/Quantities/Length.extra.cs b/UnitsNet/CustomCode/Quantities/Length.extra.cs index 34c2e8bc8c..f80febc095 100644 --- a/UnitsNet/CustomCode/Quantities/Length.extra.cs +++ b/UnitsNet/CustomCode/Quantities/Length.extra.cs @@ -2,6 +2,7 @@ // Copyright 2013 Andreas Gullberg Larsen (andreas.larsen84@gmail.com). Maintained at https://github.com/angularsen/UnitsNet. using System; +using System.Collections.Generic; using System.Globalization; using System.Numerics; using System.Text.RegularExpressions; @@ -76,31 +77,38 @@ public static bool TryParseFeetInches(string? str, out Length result, IFormatPro str = str.Trim(); - // This succeeds if only feet or inches are given, not both - if (TryParse(str, formatProvider, out result)) + if (TryParseFeetInchesCombination(str, formatProvider, out result)) return true; + // This succeeds if only feet or inches are given, not both. + return TryParse(str, formatProvider, out result); + } + + private static bool TryParseFeetInchesCombination(string str, IFormatProvider? formatProvider, out Length result) + { QuantityParser quantityParser = QuantityParser.Default; - string footRegex = quantityParser.CreateRegexPatternForUnit(LengthUnit.Foot, formatProvider, matchEntireString: false); - string inchRegex = quantityParser.CreateRegexPatternForUnit(LengthUnit.Inch, formatProvider, matchEntireString: false); + var footRegex = new Regex(quantityParser.CreateRegexPatternForUnit(LengthUnit.Foot, formatProvider), RegexOptions.Singleline | RegexOptions.IgnoreCase); + var inchRegex = new Regex(quantityParser.CreateRegexPatternForUnit(LengthUnit.Inch, formatProvider), RegexOptions.Singleline | RegexOptions.IgnoreCase); - // Match entire string exactly - string pattern = $@"^(?\-?)(?{footRegex})\s?(?{inchRegex})$"; + bool isNegative = str.StartsWith("-", StringComparison.Ordinal); + if (isNegative) + str = str.Substring(1).TrimStart(); - var match = new Regex(pattern, RegexOptions.Singleline).Match(str); - if (!match.Success) - return false; + IReadOnlyList footAbbreviations = UnitAbbreviationsCache.Default.GetUnitAbbreviations(LengthUnit.Foot, formatProvider); + foreach (int splitEndIndex in GetPossibleUnitSplitEndIndexes(str, footAbbreviations)) + { + string feetPart = str.Substring(0, splitEndIndex).TrimEnd(); + string inchesPart = str.Substring(splitEndIndex).TrimStart(); + if (inchesPart.Length == 0) + continue; - var negativeSignGroup = match.Groups["negativeSign"]; - var feetGroup = match.Groups["feet"]; - var inchesGroup = match.Groups["inches"]; + if (!TryParseSpecificUnit(feetPart, footRegex, formatProvider, out Length feet) || + !TryParseSpecificUnit(inchesPart, inchRegex, formatProvider, out Length inches)) + continue; - if (TryParse(feetGroup.Value, formatProvider, out Length feet) && - TryParse(inchesGroup.Value, formatProvider, out Length inches)) - { result = feet + inches; - if (negativeSignGroup.Length > 0) + if (isNegative) result = -result; return true; @@ -109,6 +117,32 @@ public static bool TryParseFeetInches(string? str, out Length result, IFormatPro result = default; return false; } + + private static IEnumerable GetPossibleUnitSplitEndIndexes(string str, IReadOnlyList abbreviations) + { + for (int i = str.Length - 1; i >= 0; i--) + { + foreach (string abbreviation in abbreviations) + { + if (abbreviation.Length == 0 || i + abbreviation.Length > str.Length) + continue; + + if (string.Compare(str, i, abbreviation, 0, abbreviation.Length, StringComparison.OrdinalIgnoreCase) == 0) + yield return i + abbreviation.Length; + } + } + } + + private static bool TryParseSpecificUnit(string str, Regex unitRegex, IFormatProvider? formatProvider, out Length result) + { + if (!unitRegex.IsMatch(str)) + { + result = default; + return false; + } + + return TryParse(str, formatProvider, out result); + } } /// From 0b62cb537c83f392f757075266ece5c545cdc5fa Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Sun, 2 Aug 2026 23:01:33 +0200 Subject: [PATCH 2/4] Add feet/inches roundtrip parser coverage --- .../CustomCode/LengthTests.FeetInches.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs index 66dc085319..d2a8efb3eb 100644 --- a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs +++ b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs @@ -88,13 +88,25 @@ public void TryParseFeetInches(string str, double expectedFeet, string cultureNa [InlineData("-1'000' 6\"", -1000.5)] public void TryParseFeetInches_WhenGroupSeparatorIsFootAbbreviation_ParsesFeetAndInches(string str, double expectedFeet) { - var formatProvider = new CultureInfo(GermanSwitzerland, false); - formatProvider.NumberFormat.NumberGroupSeparator = "'"; + CultureInfo formatProvider = CreateCultureWithApostropheGroupSeparator(); Assert.True(Length.TryParseFeetInches(str, out Length result, formatProvider)); AssertEx.EqualTolerance(expectedFeet, result.Feet, 1e-5); } + [Fact] + public void ParseFeetInches_WithConflictingGroupSeparator_RoundTripsFeetInchesToString() + { + CultureInfo formatProvider = CreateCultureWithApostropheGroupSeparator(); + var length = Length.FromFeetInches(1000, 6); + string formatted = length.FeetInches.ToString(formatProvider); + + Length reparsed = Length.ParseFeetInches(formatted, formatProvider); + + Assert.Equal("1'000 ft 6 in", formatted); + AssertEx.EqualTolerance(length.Feet, reparsed.Feet, 1e-5); + } + public static IEnumerable InvalidData { get => @@ -126,4 +138,11 @@ public void TryParseFeetInches_GivenInvalidString_ReturnsFalseAndZeroOut(string Assert.False(Length.TryParseFeetInches(str, out Length result, formatProvider)); Assert.Equal(Length.Zero, result); } + + private static CultureInfo CreateCultureWithApostropheGroupSeparator() + { + var formatProvider = new CultureInfo(GermanSwitzerland, false); + formatProvider.NumberFormat.NumberGroupSeparator = "'"; + return formatProvider; + } } From eca5fc5367df201e672411d5281773a1daec5eec Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Sun, 2 Aug 2026 23:32:14 +0200 Subject: [PATCH 3/4] Address feet/inches parser review feedback --- UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs | 2 ++ UnitsNet/CustomCode/Quantities/Length.extra.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs index d2a8efb3eb..c89d8e1021 100644 --- a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs +++ b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs @@ -49,6 +49,7 @@ public static IEnumerable ValidData ["1′1″", 1.08333333, EnglishUs], // Without space ["1 ft 1 in", 1.08333333, EnglishUs], ["1ft 1in", 1.08333333, EnglishUs], + ["1 FT 6 IN", 1.5, EnglishUs], // Unit parsing is case-insensitive ["-1'", -1, EnglishUs], // Feet only ["-1′", -1, EnglishUs], // Feet only ["-1,000′", -1000, EnglishUs], // Feet only, with separator @@ -85,6 +86,7 @@ public void TryParseFeetInches(string str, double expectedFeet, string cultureNa [InlineData("1'000' 6\"", 1000.5)] [InlineData("1'000'6\"", 1000.5)] [InlineData("1'000'000' 2\"", 1000000.16666667)] + [InlineData("1' 1'000\"", 84.33333333)] [InlineData("-1'000' 6\"", -1000.5)] public void TryParseFeetInches_WhenGroupSeparatorIsFootAbbreviation_ParsesFeetAndInches(string str, double expectedFeet) { diff --git a/UnitsNet/CustomCode/Quantities/Length.extra.cs b/UnitsNet/CustomCode/Quantities/Length.extra.cs index f80febc095..9e31ab0667 100644 --- a/UnitsNet/CustomCode/Quantities/Length.extra.cs +++ b/UnitsNet/CustomCode/Quantities/Length.extra.cs @@ -94,6 +94,8 @@ private static bool TryParseFeetInchesCombination(string str, IFormatProvider? f if (isNegative) str = str.Substring(1).TrimStart(); + // Prefer the rightmost foot abbreviation so "1'000' 6\"" treats grouping apostrophes as part of + // the feet value, then keep walking left if that split does not leave valid feet and inches parts. IReadOnlyList footAbbreviations = UnitAbbreviationsCache.Default.GetUnitAbbreviations(LengthUnit.Foot, formatProvider); foreach (int splitEndIndex in GetPossibleUnitSplitEndIndexes(str, footAbbreviations)) { From a68c0ea519938c3aa18e1b38845e9011dd655ef8 Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Sun, 2 Aug 2026 23:39:48 +0200 Subject: [PATCH 4/4] Add invalid feet/inches parser conflict tests --- UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs index c89d8e1021..d53737d2dd 100644 --- a/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs +++ b/UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs @@ -96,6 +96,19 @@ public void TryParseFeetInches_WhenGroupSeparatorIsFootAbbreviation_ParsesFeetAn AssertEx.EqualTolerance(expectedFeet, result.Feet, 1e-5); } + [Theory] + [InlineData("1'000")] + [InlineData("1'000' 6")] + [InlineData("1' 1'")] + [InlineData("1'000' 6 ft")] + public void TryParseFeetInches_WhenGroupSeparatorIsFootAbbreviation_GivenInvalidString_ReturnsFalseAndZeroOut(string str) + { + CultureInfo formatProvider = CreateCultureWithApostropheGroupSeparator(); + + Assert.False(Length.TryParseFeetInches(str, out Length result, formatProvider)); + Assert.Equal(Length.Zero, result); + } + [Fact] public void ParseFeetInches_WithConflictingGroupSeparator_RoundTripsFeetInchesToString() {