如何解析和捕获任何测量单位

How to parse and capture any measurement unit

在我的应用程序中,用户可以自定义测量单位,因此如果他们想使用分米而不是英寸或整圈而不是度,他们可以。但是,我需要一种方法来解析包含多个值和单位的字符串,例如 1' 2" 3/8。我在 SO 上看到了一些正则表达式,但没有找到任何匹配英制系统所有情况的正则表达式,更不用说允许任何类型的单位了。我的 objective 是尽可能有最宽松的输入框。

所以我的问题是:如何以最用户友好的方式从字符串中提取多个值单元对?


我想出了以下算法:

  1. 检查非法字符并在需要时抛出错误。
  2. Trim 前导和尾随空格。
  3. 每当有一个非数字字符后跟一个数字字符时,将字符串分成几部分,除了 .,/ 用于识别小数和分数。
  4. 删除部分中的所有空格,检查字符误用(多个小数点或分数线)并将 '' 替换为 "
  5. 每个部分的拆分值和单位字符串。如果零件没有单位:
    • 如果是第一部分,使用默认单位。
    • 否则,如果是分数,则认为它与前一部分相同。
    • 否则,如果不是,则根据前面部分的单位将其视为 in、cm 或 mm。
    • 如果不是第一部分且无法猜测单位,则抛出错误。
  6. 检查单位是否有意义,是否都属于同一个系统 (metric/imperial) 并遵循降序(ft > in > fraction 或 m > cm > mm > fraction),否则抛出错误.
  7. 对所有部分进行转换和求和,在此过程中进行除法。

我想我可以使用字符串操作函数来完成大部分工作,但我觉得必须有更简单的正则表达式方法。


我想出了一个正则表达式:
((\d+('|''|"|m|cm|mm|\s|$) *)+(\d+(\/\d+)?('|''|"|m|cm|mm|\s|$) *)?)|((\d+('|''|"|m|cm|mm|\s) *)*(\d+(\/\d+)?('|''|"|m|cm|mm|\s|$) *))

它只允许末尾有分数,并允许在值之间放置空格。不过我从来没有使用过正则表达式捕获,所以我不太确定我将如何设法从这个混乱中提取值。我明天再做这个。

My objective is to have the most permissive input box possible.

细心、宽容并不总是意味着更直观。模棱两可的输入 应该 警告用户,而不是悄悄地通过,因为这可能会导致他们在意识到他们的输入没有像他们希望的那样被解释之前犯下多个错误。

How can I extract multiple value-unit pairs from a string? I guess I could use string manipulation functions to do most of this, but I feel like there must be a simpler way through regex.

正则表达式是一个强大的工具,特别是因为它们适用于多种编程语言,但请注意。当你拿着锤子时,一切都开始看起来像钉子。不要仅仅因为您最近了解了它们的工作原理就试图使用正则表达式来解决所有问题。

看看你写的伪代码,你试图同时解决两个问题:拆分字符串(我们称之为标记化)和根据语法解释输入(我们称之为解析)。您应该首先尝试将输入拆分为一个标记列表,或者可能是单位值对。完成字符串操作后,您就可以开始理解这些对了。关注点分离会让你不再头疼,结果你的代码将更容易维护。

I've never used regex capturing though, so I'm not so sure how I'll manage to extract the values out of this mess.

如果正则表达式具有全局 (g) 标志,则可用于在同一字符串中查找多个匹配项。如果您有一个查找单个单位值对的正则表达式,那将很有用。在 JavaScript 中,您可以使用 string.match(regex) 检索匹配列表。但是,该函数会忽略全局正则表达式上的捕获组。

如果你想使用捕获组,你需要在循环中调用regex.exec(string)。对于每个成功的匹配项,exec 函数将 return 一个数组,其中项 0 是整个匹配项,项 1 及以后的项是捕获的组。

例如,/(\d+) ([a-z]+)/g 将查找一个整数,后跟一个 space 和一个单词。如果您连续调用 regex.exec("1 hour 30 minutes"),您将得到:

  • ["1 hour", "1", "hour"]
  • ["30 minutes", "30", "minutes"]
  • null

连续调用是这样工作的,因为正则表达式对象保留了一个内部光标,您可以使用 regex.lastIndex 获取或设置它。在使用不同的输入再次使用正则表达式之前,您应该将其设置回 0。

您一直在使用括号来隔离 OR 子句,例如 a|b,并将量词应用于字符序列,例如 (abc)+。如果您想在不创建捕获组的情况下执行此操作,则可以改用 (?: )。这称为非捕获组。它与正则表达式中的常规括号做同样的事情,但它里面的内容不会在 returned 数组中创建条目。

Is there a better way to approach this?

这个答案的前一个版本以一个比问题中发布的正则表达式更难以理解的正则表达式结尾,因为当时我并不了解,但今天这将是我的建议。这是一个正则表达式,一次只从输入字符串中提取一个标记。

/ (\s+)                             // 1 whitespace
| (\d+)\/(\d+)                      // 2,3 fraction
| (\d*)([.,])(\d+)                  // 4,5,6 decimal
| (\d+)                             // 7 integer
| (km|cm|mm|m|ft|in|pi|po|'|")      // 8 unit
/gi

抱歉奇怪的语法突出显示。我使用 whitespace 使它更具可读性,但正确格式化后它变成了:

/(\s+)|(\d+)\/(\d+)|(\d*)([.,])(\d+)|(\d+)|(km|cm|mm|m|ft|in|pi|po|'|")/gi

这个正则表达式巧妙地使用了由 OR 子句分隔的捕获组。只有一种令牌的捕获组才会包含任何内容。例如,在字符串 "10 ft" 上,对 exec 的连续调用将 return:

  • ["10", "", "", "", "", "", "", "10", ""](因为“10”是整数)
  • [" ", " ", "", "", "", "", "", "", ""](因为“ ”是白色的space)
  • ["ft", "", "", "", "", "", "", "", "ft"](因为“ft”是一个单位)
  • null

分词器函数然后可以执行类似这样的操作来处理每个单独的分词:

function tokenize (input) {
    const localTokenRx = new RegExp(tokenRx);

    return function next () {
        const startIndex = localTokenRx.lastIndex;
        if (startIndex >= input.length) {
            // end of input reached
            return undefined;
        }

        const match = localTokenRx.exec(input);

        if (!match) {
            localTokenRx.lastIndex = input.length;
            // there is leftover garbage at the end of the input
            return ["garbage", input.slice(startIndex)];
        }

        if (match.index !== startIndex) {
            localTokenRx.lastIndex = match.index;
            // the regex skipped over some garbage
            return ["garbage", input.slice(startIndex, match.index)];
        }

        const [
            text,
            whitespace,
            numerator, denominator,
            integralPart, decimalSeparator, fractionalPart,
            integer,
            unit
        ] = match;

        if (whitespace) {
            return ["whitespace", undefined];
            // or return next(); if we want to ignore it
        }

        if (denominator) {
            return ["fraction", Number(numerator) / Number(denominator)];
        }

        if (decimalSeparator) {
            return ["decimal", Number(integralPart + "." + fractionalPart)];
        }

        if (integer) {
            return ["integer", Number(integer)];
        }

        if (unit) {
            return ["unit", unit];
        }
    };
}

这个函数可以在一个地方完成所有必要的字符串操作和类型转换,让另一段代码对标记序列进行适当的分析。但这超出了这个 Stack Overflow 答案的范围,特别是因为这个问题没有指定我们愿意接受的语法规则。

但如果您只想接受英制长度和公制长度,那么这很可能是过于通用和复杂的解决方案。为此,我可能只会为每种可接受的格式编写一个不同的正则表达式,然后测试用户的输入以查看哪个匹配。如果两个不同的表达式匹配,则输入有歧义,我们应该警告用户。