DEV Community

Cover image for Parsing 12' 6" and 2.4 m: measurement input that doesn't annoy people
Jeremiah Say
Jeremiah Say

Posted on

Parsing 12' 6" and 2.4 m: measurement input that doesn't annoy people

Most online calculators give you a number box and a unit dropdown. Then someone measures their patio with a tape, gets 12 feet 6 inches, and has to work out that that's 12.5 before they can type anything.

I build Toggle9, a set of free calculators, and I wanted the input box to accept what people actually write down. This post walks through the parser behind it: under 200 lines of plain JavaScript, no dependencies.

What it has to accept

These are real inputs and the values the parser returns (field default unit in brackets):

Typed Result
12' 6" 12.5 ft
12'-6 1/2" 12.5417 ft
12 1/2 or 12½ 12.5 ft
2.4 m (in a feet field) 7.874 ft
2,5 (in a metres field) 2.5 m — decimal comma
1,250 1250 — thousands separator
12' + 3' 6" 15.5 ft
(12 + 4) * 2 32 ft
12' x 10' (area field) 120 sq ft
150 sq ft (square metres field) 150 sq ft, stored as m²

And it has to refuse some things, with a message a person can act on:

Typed Message
12 sq ft in a length field That looks like an area; this field needs a length.
12 apples I couldn't read "apples". Try 12.5, 12 1/2 or 12' 6".

Step 1: normalise the mess

Before parsing anything, fold the many ways of writing the same thing into one:

const VULGAR = { '½': '1/2', '¼': '1/4', '¾': '3/4', /* … */ };

export function normalise(raw) {
  let s = String(raw ?? '').trim();
  for (const [k, v] of Object.entries(VULGAR))
    s = s.replace(new RegExp('(\\d)?' + k, 'g'), (m, d) => (d ? d + ' ' : '') + v); // 12½ -> 12 1/2
  s = s.replace(/[×xX](?=\s*[\d(.])/g, '*').replace(/÷/g, '/').replace(/[−–—]/g, '-');
  s = s.replace(/(\d),(\d{3})(?!\d)/g, '$1$2');   // 1,250 -> 1250
  s = s.replace(/(\d),(\d)/g, '$1.$2');            // 2,5 -> 2.5
  s = s.replace(/(\d)-(\d+\/\d+)/g, '$1 $2');      // 3-1/2 -> 3 1/2
  s = s.replace(/(['’′]|\bft|\bfeet|\bfoot)\s*-\s*(?=\d)/gi, '$1 '); // 12'-6" -> 12' 6"
  return s;
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • x only becomes multiply when a number follows it, so 12 x 10 is a product but the x inside a unit word is left alone.
  • The comma rule runs in a fixed order. 1,250 (comma before exactly three digits) is a thousands separator; any other digit,digit is a decimal comma. That reads 2,5 correctly for the many people who write decimals that way, and it's predictable.

Step 2: lex numbers, fractions and units

The lexer produces three kinds of token: numbers, operators and units. Fractions are folded into the number while lexing, because 1/2 next to a number is part of the number, not a division:

const num = /^(\d+(\.\d+)?|\.\d+)/.exec(s.slice(i));
if (num) {
  let v = parseFloat(num[0]); i += num[0].length;
  const frac = /^\/(\d+(\.\d+)?)/.exec(s.slice(i));        // tight a/b is a fraction
  if (frac) { v = v / parseFloat(frac[1]); i += frac[0].length; }
  else {
    const mixed = /^\s+(\d+)\/(\d+)/.exec(s.slice(i));     // 3 1/2
    if (mixed && Number.isInteger(v)) { v += parseInt(mixed[1]) / parseInt(mixed[2]); i += mixed[0].length; }
  }
  t.push({ k: 'num', v });
}
Enter fullscreen mode Exit fullscreen mode

So 48 in / 2 (spaces around the slash) is division, while 1/2 is a fraction.

Units are matched longest-first so sq ft wins over ft, and volume and area words are tried before length words. Each unit token carries its dimension: the power of length (1 = length, 2 = area, 3 = volume). Quote marks (', ", ′, ″, and their curly versions) count as feet and inches, which matters on phones that autocorrect ' into ’.

Step 3: evaluate with dimensions

The evaluator is a small recursive-descent parser (sum → product → quantity) where every value is { v, dim }, always in SI base units (metres, m², m³). Multiplying adds dimensions, dividing subtracts them:

if (op === '*') a = { v: a.v * b.v, dim: a.dim + b.dim };
else            a = { v: a.v / b.v, dim: a.dim - b.dim };
Enter fullscreen mode Exit fullscreen mode

That is how 12' x 10' becomes an area: length × length = dimension 2, so the area field accepts it and a length field rejects it.

Two rules make it feel natural rather than strict:

Adjacent pieces add up. 5' 10" is two quantities side by side with no operator, and people mean "plus". Inside quantity(), while the next token is a number and we're holding a length, keep adding. If the previous unit was feet and the next number has no unit, assume inches:

while (peek()?.k === 'num' && q.dim === 1) {
  const n = take(); let add;
  if (peek()?.k === 'unit' && peek().dim === 1) { const u = take(); add = n.v * LENGTH[u.u]; }
  else if (q.units.at(-1) === 'ft') add = n.v * LENGTH.in;   // 5' 10 -> 5' 10"
  else throw new Error('Add a unit to the second number, e.g. 5 ft 10 in.');
  q = { ...q, v: q.v + add };
}
Enter fullscreen mode Exit fullscreen mode

Bare numbers borrow the field's unit, but only when they need to. In 12' + 4, the 4 has no unit. Adding a length to a plain number would be a dimension error, so plain numbers in a sum are "lifted" to the field's default unit first. In (12 + 4) * 2 nothing has a unit, so the whole thing is evaluated as a plain number and lifted once at the end: 32 ft.

Step 4: errors a person can fix

Every failure returns { ok: false, error } with a sentence the UI shows under the box. Three kinds of error cover nearly everything:

  • Can't read it: "I couldn't read "apples". Try 12.5, 12 1/2 or 12' 6"." The examples matter more than the error itself.
  • Wrong kind of quantity: "That looks like an area; this field needs a length." This catches 12 sq ft typed into a length box, which a plain parseFloat would happily turn into 12.
  • Out of range: "Use a value of 10 or less." Range checks run on the value in the field's unit, not in metres, so the numbers in the message match what the person sees.

Testing it

The parser has its own test file (Node's built-in node:test, no framework), with cases taken from what people actually type: 12' 6 1/2", 12 x 10 into an area field, 2 cu yd into a cubic-feet field. Any input that turns out to be read wrongly gets added as a test case first, then fixed.

Where it's used

The same parser sits behind every measurement box on Toggle9, for example the concrete calculator (slabs, footings and post holes) and the square footage calculator. Try typing 12' 6" x 10' into an area field.

If you've built something similar, I'd like to hear how you handled the ambiguous cases. 5 10 is my current favourite: is that 5 feet 10, or a typo?

Top comments (0)