The text string to search for a formula
The formula with <sub>/<sup> tags converted to glyphs, or undefined if none is found
findFormulaInText("C₃₃H₂₅N₃O₁₂S • ₄K") // "C₃₃H₂₅N₃O₁₂S • ₄K"
findFormulaInText("K<sub>2</sub>SO<sub>4</sub>") // "K₂SO₄"
findFormulaInText("Here is a formula: C₁₀H₇KN₆O·xH₂O") // "C₁₀H₇KN₆O·xH₂O"
findFormulaInText("Na") // "Na" (lone element, whole input)
findFormulaInText("I love Nature") // undefined (not pulled from prose)
https://regex101.com/r/h3ZnXX/4 - Regex pattern explanation
export const findFormulaInText = (text: string): string | undefined => {
// A sub/superscript "number" (no leading zero), in each accepted representation.
// Characters are enumerated rather than ranged so engines that don't compute
// Unicode ranges (byte-oriented / non-Unicode modes) still match them.
const glyphSub = '[₁₂₃₄₅₆₇₈₉][₀₁₂₃₄₅₆₇₈₉]*';
const glyphSup = '[¹²³⁴⁵⁶⁷⁸⁹][⁰¹²³⁴⁵⁶⁷⁸⁹]*';
// \u escape text (literal backslash-u-XXXX, e.g. before JSON.parse).
const escSub = String.raw`\\u208[1-9](?:\\u208[0-9])*`;
const escSup = String.raw`(?:\\u00[bB][239]|\\u207[4-9])(?:\\u2070|\\u00[bB][239]|\\u207[4-9])*`;
// HTML numeric entities, decimal or hex, with optional leading zeros.
const entSub = '&#(?:0*832[1-9]|[xX]0*208[1-9]);(?:&#(?:0*832[0-9]|[xX]0*208[0-9]);)*';
const entSup =
'&#(?:0*(?:178|179|185|830[89]|831[0-3])|[xX]0*(?:[bB][239]|207[4-9]));(?:&#(?:0*(?:178|179|185|8304|830[89]|831[0-3])|[xX]0*(?:2070|[bB][239]|207[4-9]));)*';
// <sub>2</sub> / <sup>2</sup> tags.
const tag = '<su[bp]>[1-9][0-9]*</su[bp]>';
const subSup = `(?:${glyphSub}|${glyphSup}|${escSub}|${escSup}|${entSub}|${entSup}|${tag})`;
// Polymer / repeat-unit index: a variable subscript (n, m, x), not a number — as in "(C₃H₃NaO₂)ₙ".
// Glyph form (ₙ ₘ ₓ) or the <sub>n</sub> HTML form. Unambiguous → no prose-word risk.
const repeatIndex = '(?:[ₙₘₓ]|<su[bp]>[nmx]</su[bp]>)';
// Everything that can trail a unit as a "count": a real sub/sup number, or a repeat index.
const trailingCount = `(?:${subSup}|${repeatIndex})`;
// A lone element (e.g. "Na", "K+") is a valid formula, but only when it is the entire trimmed
// input — anchoring keeps a bare symbol from being pulled out of prose (e.g. "I" from "I love …").
const trimmed = text.trim();
if (new RegExp(`^${FORMULA_ELEMENT_PATTERN}(?:[+-])?$`).test(trimmed)) {
return trimmed;
}
// Collect every candidate and keep the most likely one, so a real formula isn't shadowed by a
// two-letter coincidence earlier in the text (e.g. "IN"/"CS" inside "EINECS 243-669-6").
const best = pickBestFormula(
[...text.matchAll(buildFormulaPattern(trailingCount))].map((m) => m[0]),
);
if (best === undefined) {
return;
}
return (
best
.replace(/<sub>(\d+)<\/sub>/g, (_match, p1) => subscript(p1 || ''))
.replace(/<sup>(\d+)<\/sup>/g, (_match, p1) => superscript(p1 || ''))
// Repeat-index tag → glyph, mirroring the digit-tag handling above.
.replace(/<su[bp]>([nmx])<\/su[bp]>/g, (_match, p1: string) => REPEAT_INDEX_GLYPHS[p1] ?? p1)
.replace(/\\u208[0-9](?:\\u208[0-9])*/g, (match) => subscriptGlyph(match))
.replace(/(?:\\u00[bB][239]|\\u207[4-9])(?:\\u2070|\\u00[bB][239]|\\u207[4-9])*/g, (match) =>
superscriptGlyph(match),
)
);
};
Finds the first chemical-formula-like substring in
textand returns it with<sub>/<sup>tags converted to Unicode sub/superscript glyphs, orundefinedif none is found. Unlike findFormulaInHtml (which only understands<sub>/<sup>markup), this recognizes a subscript/superscript number written in any of four representations, so it works on raw scraped text regardless of how the source encoded it:H₂O,x²;\uescape text —H₂O(e.g. unparsed JSON);H₂O,H₂O(decimal or hex);<sub>/<sup>tags —H<sub>2</sub>O.All four forms are matched so the formula is found; for the returned string only
<sub>/<sup>tags are rewritten to glyphs. Glyph input already carries glyphs and passes through unchanged; entity- and\u-escape-encoded numbers are returned in their source encoding. Untagged inline digits (e.g. a salt/hydrate coefficient like·12H₂O, or2Kafter a separator) are matched but left as regular digits.Also handles salts/hydrates joined by
·/•/*(or a tight.) with optional integer, fraction, or variable (x/n) coefficients, and trailing ionic charge signs. Element symbols gate the match — within a longer string it needs at least two element/bracket "units" (soKBrcounts asK+Br) or a single element carrying a subscript/superscript, so ordinary prose isn't mistaken for a formula. A lone element (e.g.Na,K+) is accepted only when it is the entire trimmed input, so a bare symbol is never pulled out of a sentence.