| 1 | export type InlineMathDecision = "math" | "currency" | "literal"; |
| 2 | |
| 3 | export interface InlineMathContext { |
| 4 | before?: string; |
| 5 | after?: string; |
| 6 | } |
| 7 | |
| 8 | const PURE_NUMBER = /^\d+(?:,\d{3})*(?:\.\d+)?$/; |
| 9 | |
| 10 | const CURRENCY_BEFORE = |
| 11 | /(?:\b(?:costs?|prices?|priced|pay|paid|worth|fees?|budget|salary|total|amount|spend|spent|save|saved|charge|charged|buy|bought|sell|sold|usd|dollars?)\b|(?:价格|售价|花费|成本|预算|金额|合计|总计|总价|单价|支付|付款|收费|价值|卖价|买价|薪资|工资|优惠|节省|美元|美金))\s*(?:(?:is|was|are|were|of|at)\s+|(?:是|为|达|约|共|了)\s*|[::=]\s*)?$/i; |
| 12 | |
| 13 | const CURRENCY_AFTER = |
| 14 | /^\s*(?:usd\b|dollars?\b|(?:in\s+)?cash\b|each\b|per\b|\/\s*(?:day|week|month|year)\b|美元|美金|人民币|元|块钱|现金|每(?:个|天|周|月|年))/i; |
| 15 | |
| 16 | const NUMERIC_MATH_BEFORE = |
| 17 | /(?:\d(?:,\d{3})*(?:\.\d+)?\s*[-–—−]\s*|(?:[=<>≤≥≈≃~+*/^(,[{]|\\(?:approx|sim|le|ge|lt|gt))\s*)$/; |
| 18 | |
| 19 | const SCIENTIFIC_UNIT_PREFIX = String.raw`(?:da|[qryzafpnumcdhkMGTPEZYRQµμ])?`; |
| 20 | const SCIENTIFIC_UNIT_SYMBOL = String.raw`(?:${SCIENTIFIC_UNIT_PREFIX}(?:eV|Hz|mol|cd|Pa|Wb|lm|lx|Bq|Gy|Sv|kat|Wh|Da|bar|m|g|s|A|K|N|J|W|C|V|F|S|T|H|L)|dB|atm|Torr|rpm|bps|Ω|ohms?|bits?|bytes?|minutes?|hours?|min|hr)`; |
| 21 | const SCIENTIFIC_UNIT_BOUNDARY = String.raw`(?=$|[\s,.;:!?()[\]{}\/·⋅×^²³⁴⁵⁶⁷⁸⁹⁰-])`; |
| 22 | const NUMERIC_MATH_AFTER = new RegExp( |
| 23 | String.raw`^\s*(?:[-–—−]\s*\d|${SCIENTIFIC_UNIT_SYMBOL}${SCIENTIFIC_UNIT_BOUNDARY}|°[CFK]${SCIENTIFIC_UNIT_BOUNDARY})`, |
| 24 | "i", |
| 25 | ); |
| 26 | |
| 27 | function currencyContext(context: InlineMathContext): Required<InlineMathContext> { |
| 28 | return { |
| 29 | before: (context.before ?? "").replace(/[\s()[\]{}"'“”‘’()【】《》〈〉「」『』]+$/u, ""), |
| 30 | after: (context.after ?? "").replace(/^[\s()[\]{}"'“”‘’()【】《》〈〉「」『』,;:,;:。、—–-]+/u, ""), |
| 31 | }; |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * Decide how an `inlineMath` node produced by remark-math should render. |
| 36 | * |
| 37 | * Pure-number spans remain literal unless their surrounding source provides |
| 38 | * positive mathematical context (for example a range endpoint or scientific |
| 39 | * unit). High-confidence currency context repairs paired input such as |
| 40 | * `costs $5$ today` to the prose token `$5`. Other non-math content is |
| 41 | * restored verbatim as text. |
| 42 | */ |
| 43 | export function classifyInlineMath( |
| 44 | math: string, |
| 45 | context: InlineMathContext = {}, |
| 46 | ): InlineMathDecision { |
| 47 | if (!math || math !== math.trim() || math.includes("\n")) return "literal"; |
| 48 | if (math.includes("://") || math.includes("](")) return "literal"; |
| 49 | |
| 50 | if (PURE_NUMBER.test(math)) { |
| 51 | const currency = currencyContext(context); |
| 52 | if (CURRENCY_BEFORE.test(currency.before) || CURRENCY_AFTER.test(currency.after)) { |
| 53 | return "currency"; |
| 54 | } |
| 55 | if (NUMERIC_MATH_BEFORE.test(context.before ?? "") || NUMERIC_MATH_AFTER.test(context.after ?? "")) { |
| 56 | return "math"; |
| 57 | } |
| 58 | return "literal"; |
| 59 | } |
| 60 | |
| 61 | // A percentage enclosed in explicit delimiters is mathematical notation, |
| 62 | // not a currency amount. latexNormalizeForKatex escapes its `%` for KaTeX. |
| 63 | if (/^\d+(?:,\d{3})*(?:\.\d+)?%$/.test(math)) return "math"; |
| 64 | |
| 65 | // Number followed by variable: implicit multiplication (2.5x, 3y^2). |
| 66 | if (/^\d+(?:\.\d+)?[A-Za-z](?:[A-Za-z0-9^_{}]*)?$/.test(math)) return "math"; |
| 67 | // Number with LaTeX escape: 10\%, 5\cdot3. |
| 68 | if (/^\d+(?:\.\d+)?\\(?:%|[A-Za-z]+)(?:\{[^{}]*\})?(?:[A-Za-z0-9\\{}^_+\-*/=<>.()]*)$/.test(math)) return "math"; |
| 69 | |
| 70 | // Unary plus/minus: +2, -x, +\alpha, - 3.14. |
| 71 | if (/^[+\-]\s*(?:\d+(?:\.\d+)?|[A-Za-z\\])/.test(math)) return "math"; |
| 72 | |
| 73 | // LaTeX command (\alpha, \frac{x}{y}, \tfrac12, ...). |
| 74 | if (/\\[A-Za-z]+/.test(math)) return "math"; |
| 75 | if (/[\^_{}|]/.test(math)) return "math"; |
| 76 | if (/^[+\-=<>±∓]$/.test(math)) return "math"; |
| 77 | if (/\b(?:alpha|beta|gamma|sum|int|prod|lim|infty|sqrt|frac|sin|cos|tan|log|ln|max|min|partial|nabla|left|right)\b/.test(math)) return "math"; |
| 78 | if (/^[A-Za-z]{1,6}\s*\([^)]{1,80}\)$/.test(math)) return "math"; |
| 79 | if (/^[A-Za-z]'{1,}\s*\([^)]{1,80}\)$/.test(math)) return "math"; |
| 80 | if (/^(?:\(\d+\))+$/.test(math)) return "math"; |
| 81 | if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[+\-]?\s*[A-Za-z0-9([{\\]/.test(math)) return "math"; |
| 82 | |
| 83 | // One-sided relation/equality against an implicit operand. The complete |
| 84 | // anchors are important: `=1 dollar` must not be accepted by a prefix match. |
| 85 | const operand = String.raw`(?:[+\-]?\s*(?:\d+(?:\.\d+)?|[A-Za-z]|\\[A-Za-z]+))`; |
| 86 | const relation = String.raw`(?:<=?|>=?|=|≠|≤|≥)`; |
| 87 | if (new RegExp(`^(?:${relation}\\s*${operand}|${operand}\\s*${relation})$`).test(math)) return "math"; |
| 88 | |
| 89 | if (/^\(?(?:[A-Za-z0-9]|\\[A-Za-z]+)(?:\s*,\s*(?:[A-Za-z0-9]|\\[A-Za-z]+)){1,10}\)?$/.test(math)) return "math"; |
| 90 | if (/^\[[A-Za-z0-9^_+\-,.\\\s{}]+\]$/.test(math)) return "math"; |
| 91 | |
| 92 | if (/[A-Za-z]\s+[A-Za-z]/.test(math)) return "literal"; |
| 93 | if (/^[A-Z][A-Z0-9_]{1,}$/.test(math)) return "literal"; |
| 94 | if (/^v\d+(?:\.\d+)*$/i.test(math)) return "literal"; |
| 95 | if (/^[A-Za-z]{2,}$/.test(math)) return "literal"; |
| 96 | |
| 97 | if (/^(?:[A-Za-z]|\\[A-Za-z]+)'{1,}$/.test(math)) return "math"; |
| 98 | return /^[A-Za-z]$/.test(math) ? "math" : "literal"; |
| 99 | } |
| 100 | |
| 101 | export function isLikelyInlineMath(math: string): boolean { |
| 102 | return classifyInlineMath(math) === "math"; |
| 103 | } |
| 104 |