Engineering Journal
Pdf Processor
Pdf Processor

When your list-marker regex eats decimal numbers

2026-07-03

TLDR: A regex that strips ordered-list markers ("1.", "a)", "iv.") silently corrupted technical text: "0.5 amp. maximum" became "5 amp. maximum" because "0." matched the marker pattern. The bug class is prefix regexes that do not assert what comes after the match. One negative lookahead fixes it.

The bug class

Any pipeline that converts visual lines into semantic lists needs two operations: detect that a line starts a list item, and strip the marker from the output text.

Both are usually regexes like this:

const ORDERED_STRIP_RE = /^(?:\d{1,3}[.)]\s|[a-zA-Z][.)]\s)/;

The pattern reads "digits, then a dot or paren, then optional whitespace." The \s* is the trap. Optional whitespace means the pattern matches even when the next character is another digit. "0.5 amp" begins with 0., which satisfies \d{1,3}[.)] followed by zero spaces.

Why the language produces it

Two forces create this bug. First, \s* instead of \s+ is usually deliberate: markers arrive as isolated tokens ("3." with nothing after it) when the PDF emits the marker as its own text item, so requiring trailing whitespace breaks legitimate cases.

Second, regexes fail open. A prefix pattern that overmatches does not throw, it just deletes characters. The corrupted string looks plausible: "5 amp. maximum humidifier" reads fine unless you know the spec said 0.5.

This is worse than a crash. It is data corruption in a document tool, the one category of error users cannot forgive.

The fix

Assert that the character after the marker punctuation is not a digit:

// (?!\d) prevents decimals ("0.5 amp") from being read as marker "0."
const ORDERED_STRIP_RE =
  /^(?:\d{1,3}.)\s*|[a-zA-Z].)|[ivxIVX]+.))/;

Three details:

Preventing the class

Treat every prefix-stripping regex as a two-sided contract: it must match the prefix AND assert the boundary after it. ^\d+[.)] is half a pattern. ^\d+.) is a whole one.

A cheap audit: grep your codebase for replace(/^ and check each pattern for a boundary assertion. Any pattern ending in \s deserves suspicion, because \s matches the empty string and therefore asserts nothing.

The lesson

Regexes that delete text should be held to a higher standard than regexes that detect text. Detection errors show up as misclassification you can see. Deletion errors show up as subtly wrong numbers in someone's furnace manual.

Read this post in the full Engineering Journal →