Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions UnitsNet.Tests/CustomCode/LengthTests.FeetInches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public static IEnumerable<object[]> 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
Expand Down Expand Up @@ -80,6 +81,47 @@ 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' 1'000\"", 84.33333333)]
[InlineData("-1'000' 6\"", -1000.5)]
public void TryParseFeetInches_WhenGroupSeparatorIsFootAbbreviation_ParsesFeetAndInches(string str, double expectedFeet)
{
CultureInfo formatProvider = CreateCultureWithApostropheGroupSeparator();

Assert.True(Length.TryParseFeetInches(str, out Length result, formatProvider));
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()
{
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<object[]> InvalidData
{
get =>
Expand Down Expand Up @@ -111,4 +153,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;
}
}
68 changes: 52 additions & 16 deletions UnitsNet/CustomCode/Quantities/Length.extra.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,31 +77,40 @@ 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 = $@"^(?<negativeSign>\-?)(?<feet>{footRegex})\s?(?<inches>{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;
// 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<string> 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;
Expand All @@ -109,6 +119,32 @@ public static bool TryParseFeetInches(string? str, out Length result, IFormatPro
result = default;
return false;
}

private static IEnumerable<int> GetPossibleUnitSplitEndIndexes(string str, IReadOnlyList<string> 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);
}
}

/// <summary>
Expand Down
Loading