import { cachedRegExp, LATIN_LETTER_RE, LATIN_OR_DIGIT, MAX_BOUNDARY_SEPARATORS, SPACE_CHAR_RE, UNICODE_SYMBOLS, WORD_RE } from "./constants.js" import { boundaryCountAt, exceedsSingleBoundary, firstInteriorBoundary, interiorBoundariesWithin, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js" import { convertPrimeMarks } from "./quote-classifier.js" export interface SymbolOptions { /** Include arrow transforms (-> → →). Default: true */ includeArrows?: boolean } const { ELLIPSIS, MULTIPLICATION, NOT_EQUAL, PLUS_MINUS, COPYRIGHT, REGISTERED, TRADEMARK, DEGREE, ARROW_RIGHT, ARROW_LEFT, ARROW_LEFT_RIGHT, APPROXIMATE, LESS_EQUAL, GREATER_EQUAL, NBSP, PRIME, DOUBLE_PRIME, SUPERSCRIPT_ST, SUPERSCRIPT_ND, SUPERSCRIPT_RD, SUPERSCRIPT_TH, DOUBLE_QUESTION, QUESTION_EXCLAMATION, EXCLAMATION_QUESTION, } = UNICODE_SYMBOLS // The boundary-tolerance positions throughout this module reproduce the // element-boundary semantics of the pre-v5 sentinel-marked pipeline; they are // pinned by the HTML regression corpus and the migration's differential fuzz. /** Convert "..." or ". . ." to "…". */ export const ellipsis = makeProsePass(ellipsisOverView) function ellipsisOverView(view: ProseView): void { ellipsisFoldDots(view) view.commit() // A space is inserted only when a letter or digit directly follows the // ellipsis. A node boundary between the ellipsis and the letter is a non-letter // for the `(?=[A-Za-z…\d])` lookahead, so it blocks the space; re-check the // boundary at the lookahead position. const trailing = cachedRegExp(`${ELLIPSIS}(?=[${LATIN_OR_DIGIT}])`, "gu") replaceAllInView(view, trailing, (match, v) => { if (v.hasBoundary(match.index + match[0].length)) return null return `${ELLIPSIS} ` }) view.commit() } /** * Offset of the dot following one inter-dot gap that begins at `afterDot`, or * -1 when no dot is reachable. The gap is one of: nothing, one space character, * or one node boundary — never two and never a space-plus-boundary mix. A dot * is reachable only when no unconsumed boundary shadows it (the `\.` cannot * cross a boundary). */ function ellipsisNextDot(view: ProseView, text: string, afterDot: number): number { const boundariesHere = boundaryCountAt(view, afterDot) if (text[afterDot] === ".") { // Empty gap (no boundary) or a single-boundary gap consuming exactly one. return boundariesHere <= 1 ? afterDot : -1 } // Space gap: one space character with no boundary, then a non-shadowed dot. if (boundariesHere === 0 && text[afterDot] !== undefined && SPACE_CHAR_RE.test(text[afterDot])) { const dot = afterDot + 1 if (text[dot] === "." && boundaryCountAt(view, dot) === 0) return dot } return -1 } /** * Fold `...`, `. . .`, and the cross-boundary variants into `…`, scanning * left-to-right so a blocked leftmost triple still lets the next valid triple * fold. Each fold replaces the whole `. gap . gap .` span with `…`; consumed * interior boundaries collapse to just after the ellipsis. */ function ellipsisFoldDots(view: ProseView): void { const text = view.text let i = 0 while (i < text.length) { if (text[i] !== ".") { i++; continue } const secondDot = ellipsisNextDot(view, text, i + 1) if (secondDot < 0) { i++; continue } const thirdDot = ellipsisNextDot(view, text, secondDot + 1) if (thirdDot < 0) { i++; continue } view.replace(i, thirdDot + 1, ELLIPSIS) i = thirdDot + 1 } } /** Convert "5x5" to "5×5". Skips hex (0x5F). */ export const multiplication = makeProsePass(multiplicationOverView) // A curly single-quote glyph directly after a trailing multiplier means the // quotes pass (which runs earlier in the pipeline) already read the `x` as a // letter — a word-final elision keyed on the letter. Were this pass to rewrite // that `x` to `×`, a re-run would re-read the quote as a closing quote after a // symbol and relocate a trailing comma/period, so the transform would never // reach a fixed point. Leave such an `x` alone. A straight `'` is not blocked: // it only reaches this pass on a standalone `multiplication()` call, where no // later quote pass can flip its classification. const CURLY_APOSTROPHE_FOLLOWERS = new Set([ UNICODE_SYMBOLS.RIGHT_SINGLE_QUOTE, UNICODE_SYMBOLS.LEFT_SINGLE_QUOTE, UNICODE_SYMBOLS.MODIFIER_LETTER_APOSTROPHE, ]) // After a digit run, either a prime mark (10′) or a length/size unit may // attach before the multiplication operator. Allowing both lets dimensions // like "5m × 5m", "210mm × 297mm", or "1920px × 1080px" convert, matching // Chicago §9.17's preference for × in dimension notation. // // Only length/size units that plausibly appear in two-dimensional dimensions // are listed — mass/time/electrical units (kg, min, V, …) don't participate in // "N × N" constructions and excluding them avoids false matches on words // ending in those letters. const DIMENSION_UNITS = "rem|vh|vw|km|cm|mm|nm|pm|mi|ft|yd|in|px|pt|em|m" // Word-boundary lookahead: the unit must be followed by whitespace, the // multiplication operator, end-of-string, or sentence punctuation — never // another letter (which would mean the "unit" is actually a word prefix like // "mold"). A node boundary directly after the unit also satisfies this position // and is permitted by allowBoundaries, so it is dropped from the literal // lookahead. const UNIT_BOUNDARY = `(?=\\s|[xX*.,;!?)}]|$)` const UNIT_ALT = `\\s?(?:${DIMENSION_UNITS})${UNIT_BOUNDARY}` const PRIME_ALT = `[${PRIME}${DOUBLE_PRIME}]` const DIGIT_SUFFIX = `(?:${PRIME_ALT}|${UNIT_ALT})?` // Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5". // Pattern matches: digit(s), then one or more (operator, digit(s)) groups. // The pattern carries no leading lookbehinds — they would leave the nested // `\d+(…\d+)+` quantifier ReDoS-prone — so the scientific-notation and // model-name guards run separately in `chainGuardOk` (see // multiplicationOverView), which rejects an operand whose preceding character // is a Latin letter or digit. That covers unsigned scientific notation // (1e5x3 — the `e` is a letter) and model SKUs (Surface5x3, RTX3060x2); // signed exponents (1e-5x3, 3.5E+10x2) are unambiguously scientific, their // operand is preceded by a sign, and they convert. const MULTIPLICATION_CHAIN = `(?\\d+${DIGIT_SUFFIX})(?(?:\\s*[xX*]\\s*\\d+${DIGIT_SUFFIX})+)` const MULTIPLICATION_SEGMENT = `(?\\s*)[xX*](?\\s*)(?\\d+${DIGIT_SUFFIX})` /** A `\s*[xX*]\s*\d+suffix` segment located by absolute clean-text offsets. */ interface ChainSegment { /** Absolute offset of the operator character. */ operatorOffset: number /** Absolute offset where the right operand's digits begin. */ operandStart: number /** Absolute offset just past the right operand (including any suffix). */ operandEnd: number spaceBefore: string spaceAfter: string } /** Locate each operator segment of `rest` (absolute offsets via `chainStart`). */ function chainSegments(chainStart: number, firstNum: string, rest: string): ChainSegment[] { const segments: ChainSegment[] = [] const segmentRe = cachedRegExp(MULTIPLICATION_SEGMENT, "gy") segmentRe.lastIndex = 0 let m: RegExpExecArray | null while ((m = segmentRe.exec(rest)) !== null) { const groups = m.groups as { spaceBefore: string; spaceAfter: string; num: string } const operatorAbs = chainStart + firstNum.length + m.index + groups.spaceBefore.length segments.push({ operatorOffset: operatorAbs, operandStart: operatorAbs + 1 + groups.spaceAfter.length, operandEnd: chainStart + firstNum.length + m.index + m[0].length, spaceBefore: groups.spaceBefore, spaceAfter: groups.spaceAfter, }) } return segments } /** * Each operator's spacing slot (`\s*[xX*]\s*`) tolerates one node boundary at * each outer edge and none inside the spacing; a second adjacent boundary at an * edge breaks the segment. The operator's slot is the span [slotStart, slotEnd) * covering its surrounding spaces and the operator itself. */ function operatorSlotClean(view: ProseView, slotStart: number, slotEnd: number): boolean { // No boundary strictly inside the spacing-plus-operator slot. if (firstInteriorBoundary(view, slotStart, slotEnd) >= 0) return false // At most one boundary hugging each outer edge (duplicates at an offset come // from empty nodes and each counts as one boundary). return boundaryCountAt(view, slotStart) <= 1 && boundaryCountAt(view, slotEnd) <= 1 } /** * The chain's leading lookbehinds at `operandStart` * (`(? lowerBound && /\d/.test(text[start - 1]) && (start === digitsEnd || !view.hasBoundary(start))) start-- return start } /** * End offset of the digit run of the left operand whose operator spacing slot * begins at `slotStart`: walk back over the operand's optional suffix (a prime, * or a unit's letters, possibly with one space) to the last digit. */ function leftDigitsEnd(text: string, slotStart: number): number { let i = slotStart while (i > 0 && !/\d/.test(text[i - 1])) i-- return i } /** * True iff the operand's unit suffix between `digitsEnd` and `slotStart` is not * split by a node boundary. One boundary is tolerated before the unit, but the * unit's letters must be contiguous, so a boundary between two unit letters * detaches the operator from the operand. */ function leftSuffixUnsplit(view: ProseView, text: string, digitsEnd: number, slotStart: number): boolean { // A prime or unit suffix begins after one optional space-or-boundary slot; // a second boundary in that slot (before the suffix) leaves `\d+suffix` // matching only the digits, detaching the operator. if (slotStart > digitsEnd && exceedsSingleBoundary(view, digitsEnd)) return false for (let i = digitsEnd + 1; i < slotStart; i++) { if (LATIN_LETTER_RE.test(text[i - 1]) && LATIN_LETTER_RE.test(text[i]) && view.hasBoundary(i)) return false } return true } /** * True iff a node boundary falls strictly inside the leading digit run of the * operand starting at `operandStart` (`\d+` cannot cross a boundary, so such a * boundary truncates the operand and ends the chain there). */ function runHasInteriorBoundary(view: ProseView, operandStart: number, operandEnd: number): boolean { const text = view.text let i = operandStart + 1 while (i < operandEnd && /\d/.test(text[i])) { if (view.hasBoundary(i)) return true i++ } return false } const ASCII_DIGIT_RE = /\d/ /** * True when a character following an operator/unit is word-like and so should * block conversion: an ASCII word char (`\w`, keeping digit/`_` coverage) or an * accented Latin letter. The leading guards (`chainGuardOk`, * `degreeLeadingGuardOk`) are already accent-aware via {@link LATIN_LETTER_RE}; * the trailing checks must match, or `5xé`/`5Cé` convert while `é5x3`/`é5C` do * not — an asymmetry against the library's European-accent support. */ function isWordLikeFollower(ch: string | undefined): boolean { return ch !== undefined && (WORD_RE.test(ch) || LATIN_LETTER_RE.test(ch)) } /** * Next offset at which a sticky `\d`-anchored pattern could match after a miss * at `scan`. A miss at a digit rules out every start in the rest of its run: * from a later start the pattern's leading `\d+` reaches a strict subset of * the split positions the failed attempt already backtracked through. So the * scan jumps past the current digit run (if any), then past non-digits to the * next digit. Advancing one character at a time here instead re-reads the run * at every offset — O(n²) on a long digit run, a denial-of-service vector. */ function nextDigitAnchorAfterMiss(text: string, scan: number): number { let next = scan while (next < text.length && ASCII_DIGIT_RE.test(text[next])) next++ while (next < text.length && !ASCII_DIGIT_RE.test(text[next])) next++ return next } function multiplicationOverView(view: ProseView): void { // The chain is matched with a sticky regex driven by a manual left-to-right // scan. Sticky anchoring keeps the nested `\d+(…\d+)+` quantifier ReDoS-safe // without leading lookbehinds; dropping those lookbehinds lets a chain anchor // on an operand whose preceding letter/digit is shadowed by a node boundary. // Boundary-aware guard/hex/operand checks below make the per-operand // decisions. const chainPattern = cachedRegExp(MULTIPLICATION_CHAIN, "y") const chainText = view.text let scan = 0 while (scan < chainText.length) { chainPattern.lastIndex = scan const match = chainPattern.exec(chainText) if (match === null) { scan = nextDigitAnchorAfterMiss(chainText, scan); continue } const { firstNum, rest } = match.groups as { firstNum: string; rest: string } const text = chainText const chainStart = match.index const segments = chainSegments(chainStart, firstNum, rest) // Edit each operator's spacing slot in place, leaving the digit operands // (and any node boundaries inside them) untouched so boundary positions are // preserved. // // The chain matches contiguously, then the hex skip is applied. Two // outcomes propagate differently: // - Leading-guard failure does not anchor here, so the match is re-tried // at the next operand: keep retrying each operator until one anchors. // - Hex skip and a successful anchor consume the matched chain, so no // operator inside it converts or re-anchors until a STRUCTURAL break (a // boundary inside an operator's spacing slot or splitting an operand's // digit run) lets the chain re-match at a later operand. let chainActive = false let anchored = false segments.forEach((segment, index) => { const slotStart = segment.operatorOffset - segment.spaceBefore.length const slotEnd = segment.operatorOffset + 1 + segment.spaceAfter.length const digitsEnd = leftDigitsEnd(text, slotStart) // The left operand's unit suffix must be a contiguous match: a boundary // splitting the unit's letters breaks `\d+suffix`, detaching the operator // from any operand (its left side is no longer `\d+unit`). const slotClean = operatorSlotClean(view, slotStart, slotEnd) && leftSuffixUnsplit(view, text, digitsEnd, slotStart) if (!anchored) { const lowerBound = index === 0 ? chainStart : segments[index - 1].operandStart const runStart = leftDigitRunStart(view, text, digitsEnd, lowerBound) const operand = text.slice(runStart, digitsEnd) if (!chainGuardOk(view, runStart)) { // Did not anchor here; re-try at the next operand. chainActive = false } else { anchored = true chainActive = !chainHexBlocked(view, runStart, operand) } } if (!slotClean) { // Structural break: the matched chain ends; the next operand re-anchors. chainActive = false anchored = false return } if (chainActive) { const space = segment.spaceBefore || segment.spaceAfter ? " " : "" view.replace(slotStart, slotEnd, `${space}${MULTIPLICATION}${space}`) } // A boundary inside this operand's digit run truncates it (`\d+` stops // there): a structural break, so the next operand re-anchors. if (runHasInteriorBoundary(view, segment.operandStart, segment.operandEnd)) { chainActive = false anchored = false } }) scan = chainStart + match[0].length } view.commit() // Trailing multiplier: 5x (followed by a word boundary). The rule is // `(?\\d+)(?[xX*])`, "y") const trailingText = view.text let trailingScan = 0 while (trailingScan < trailingText.length) { trailingPattern.lastIndex = trailingScan const match = trailingPattern.exec(trailingText) if (match === null) { trailingScan = nextDigitAnchorAfterMiss(trailingText, trailingScan); continue } const num = match.groups!.num const op = match.groups!.op const operatorOffset = match.index + num.length trailingScan = match.index + match[0].length // One optional boundary may sit between the digits and the operator. if (exceedsSingleBoundary(view, operatorOffset)) continue // Leading guard and hex skip, on the boundary-free digit run ending at the // operator (a boundary inside the digits truncates the operand). const runStart = leftDigitRunStart(view, trailingText, operatorOffset, 0) const operand = trailingText.slice(runStart, operatorOffset) if (!chainGuardOk(view, runStart) || chainHexBlocked(view, runStart, operand)) continue // Trailing word boundary: `*` is not a word character so it never anchors a // trailing `\b`; otherwise reject when a word-like character (ASCII word or // accented Latin letter) follows the operator through at most three boundaries. if (op === "*") continue // An uppercase `X` directly attached to the digits is a model/SKU suffix // (Ryzen 9 5900X), not a multiplier — prose multipliers write a lowercase // x ("by 4x."). Chains ("16X16") convert above regardless of case. if (op === "X") continue const afterOp = operatorOffset + 1 const followChar = trailingText[afterOp] if (followChar !== undefined && CURLY_APOSTROPHE_FOLLOWERS.has(followChar)) continue if (boundaryCountAt(view, afterOp) <= MAX_BOUNDARY_SEPARATORS && isWordLikeFollower(followChar)) continue view.replace(operatorOffset, afterOp, MULTIPLICATION) } view.commit() } /** `[left, right, forbiddenFollow, replacement]`; forbiddenFollow is "" when none. */ type MathSymbolRule = [string, string, string, string] const MATH_SYMBOL_MAP: MathSymbolRule[] = [ ["!", "=", "=", NOT_EQUAL], ["\\+/", "-", "", PLUS_MINUS], ["\\+", "-", "", PLUS_MINUS], ["<", "=", "=", LESS_EQUAL], [">", "=", "=", GREATER_EQUAL], ["~", "=", "", APPROXIMATE], ["=", "~", "", APPROXIMATE], ] /** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */ export const mathSymbols = makeProsePass(mathSymbolsOverView) function mathSymbolsOverView(view: ProseView): void { for (const [left, right, forbiddenFollow, replacement] of MATH_SYMBOL_MAP) { // Re-read after each rule's commit: prior rules mutate the clean text. const text = view.text // One boundary is tolerated at the left/right junction (e.g. `!=`). // Position editing keeps the surrounding fragments (and so the boundary) // intact; allowBoundaries permits that one boundary. The negative lookahead // `(?!=)` is checked manually because it tolerates one boundary before the // forbidden `=`: with two or more boundaries the `=` is out of reach, so the // match is not blocked. const pattern = cachedRegExp(`${left}${right}`, "g") replaceAllInView(view, pattern, (match, v) => { if (forbiddenFollow && mathLookaheadBlocks(text, v, match.index + match[0].length, forbiddenFollow)) { return null } return replacement }, { allowBoundaries: (m, v) => mathOperatorAllowBoundary(m, v), }) view.commit() } } /** * The `(?!=)` guard: the match is blocked when the forbidden character follows * through at most one boundary. Two or more boundaries put the character out of * reach (one boundary tolerated), so the match proceeds. `≈` blocks where `=` * does: a later rule in this same pass folds `=~` to `≈`, consuming the `=` * this guard keys on (`!==~` must stay blocked once it reads `!=≈`). */ function mathLookaheadBlocks(text: string, view: ProseView, end: number, forbidden: string): boolean { const next = text[end] const matches = next === forbidden || (forbidden === "=" && next === APPROXIMATE) return matches && boundaryCountAt(view, end) <= 1 } /** * Only the junction between the `left` operator string and the single-character * `right` tolerates one interior boundary (the `right` char ends the match). A * boundary inside a multi-character `left` like `+/` breaks the operator. */ function mathOperatorAllowBoundary(match: RegExpExecArray, view: ProseView): boolean { const junction = match.index + match[0].length - 1 if (!interiorBoundariesWithin(view, match.index, match.index + match[0].length, [junction])) return false return boundaryCountAt(view, junction) <= 1 } type ContextPredicate = (before: string, after: string) => boolean // 25 chars: fits "copyright " or a 4-digit year with padding, without making slicing expensive. const LEGAL_SYMBOL_CONTEXT_WINDOW = 25 // Window cost charged per node boundary: an element edge stands in for two // characters of context, so boundary-dense markup exposes less of it. const BOUNDARY_CONTEXT_COST = 2 // Vulgar fraction glyphs (½, ¾, …). A fraction folds from `n/m`, which the path // heuristic below reads as a path context — so the glyph must read the same way, // or `1/2(tm)` (blocked) would convert once fractions strips the slash to `½(tm)`. const FRACTION_GLYPH_RE = new RegExp( `[${Object.entries(UNICODE_SYMBOLS) .filter(([key]) => key.startsWith("FRACTION_")) .map(([, glyph]) => glyph) .join("")}]`, ) const isPathContext = (before: string): boolean => { const parts = before.split(/\s+/) const trailing = parts[parts.length - 1] // A slash anywhere in the token that leads up to the legal marker signals a // path/URL. The marker itself is excluded from `before`, so a slash sitting // right before it (`foo/(tm)`, `example.com/(c)`) leaves the slash as the // token's final character — still a path, so match on any slash, not only an // interior one. return FRACTION_GLYPH_RE.test(trailing) || trailing.includes("/") } function contextAwareLegalReplace( view: ProseView, pattern: RegExp, replacement: string, shouldConvert: ContextPredicate, ): void { const text = view.text replaceAllInView(view, pattern, (match, v) => { const offset = match.index const before = legalContextBefore(text, v, offset) const after = legalContextAfter(text, v, offset + match[0].length) return shouldConvert(before, after) ? replacement : null }, { // The `(c)`/`(r)`/`(tm)` token itself never spans a boundary (no interior // slot), so a match containing an interior boundary is skipped, exactly as // the default behavior. allowBoundaries: undefined, }) } /** * Context preceding `offset`, spanning the 25-character window. Each node * boundary costs {@link BOUNDARY_CONTEXT_COST} characters of the window, so a * region dense with boundaries exposes fewer characters of context. */ function legalContextBefore(text: string, view: ProseView, offset: number): string { let cost = 0 let i = offset while (i > 0) { // Cost of stepping back over this clean char plus the boundaries hugging it. cost += boundaryCountAt(view, i) * BOUNDARY_CONTEXT_COST + 1 if (cost > LEGAL_SYMBOL_CONTEXT_WINDOW) break i-- } return text.slice(i, offset) } /** Mirror of {@link legalContextBefore} for the text following `end`. */ function legalContextAfter(text: string, view: ProseView, end: number): string { let cost = 0 let i = end while (i < text.length) { cost += boundaryCountAt(view, i) * BOUNDARY_CONTEXT_COST + 1 if (cost > LEGAL_SYMBOL_CONTEXT_WINDOW) break i++ } return text.slice(end, i) } const LEGAL_COPYRIGHT_RE = "\\(c\\)" const LEGAL_REGISTERED_RE = "\\(r\\)" const LEGAL_TRADEMARK_RE = "\\(tm\\)" /** Convert (c), (r), (tm) to ©, ®, ™. */ export const legalSymbols = makeProsePass(legalSymbolsOverView) function legalSymbolsOverView(view: ProseView): void { // (c) → © only with positive copyright evidence (year or "copyright" // keyword) and not in a path context (e.g. example.com/path(c)). contextAwareLegalReplace(view, cachedRegExp(LEGAL_COPYRIGHT_RE, "gi"), COPYRIGHT, (before, after) => !isPathContext(before) && (/^\s*(?:19|20)\d{2}\b/.test(after) || /\bcopyright\s*$/i.test(before)), ) view.commit() // (r) → ® unless in enumeration "(q), (r)", legal citation "(r)(1)", or path context. contextAwareLegalReplace(view, cachedRegExp(LEGAL_REGISTERED_RE, "gi"), REGISTERED, (before, after) => !/\([a-z]\)[,;]\s*$/i.test(before) && !/^\(\d/.test(after) && !isPathContext(before), ) view.commit() // (tm) → ™ unless in a path context. contextAwareLegalReplace(view, cachedRegExp(LEGAL_TRADEMARK_RE, "gi"), TRADEMARK, (before) => !isPathContext(before), ) view.commit() } /** Matches one arrow shape starting at `start`; returns its end offset or -1. */ type ArrowMatcher = (view: ProseView, text: string, start: number) => number const WHITESPACE_RE = /\s/ /** * One boundary is tolerated after `<`, between dash runs, and before `>`, and a * boundary cannot split a dash run (`-+`). Each helper walks the clean text and * consults boundaries at exactly those positions, tolerating at most one * boundary per slot. */ const ARROW_MATCHERS: readonly [ArrowMatcher, string][] = [ [matchLeftRightArrow, ARROW_LEFT_RIGHT], [matchRightArrow, ARROW_RIGHT], [matchLeftArrow, ARROW_LEFT], ] /** Length of a `-+` dash run at `pos` (no boundary may split it); 0 if none. */ function dashRunLength(view: ProseView, text: string, pos: number): number { let i = pos while (text[i] === "-" && (i === pos || boundaryCountAt(view, i) === 0)) i++ return i - pos } /** Skip at most one boundary slot at `pos`; returns the offset after it. -1 on a double boundary. */ function arrowSepSlot(view: ProseView, pos: number): number { const count = boundaryCountAt(view, pos) return count <= 1 ? pos : -1 } /** `-+${sep}?>`: a dash run, an optional boundary, then `>`. */ function matchRightArrow(view: ProseView, text: string, start: number): number { const run = dashRunLength(view, text, start) if (run === 0) return -1 const beforeGt = start + run if (arrowSepSlot(view, beforeGt) < 0) return -1 return text[beforeGt] === ">" ? beforeGt + 1 : -1 } /** `<${sep}?-+`: `<`, an optional boundary, then a dash run. */ function matchLeftArrow(view: ProseView, text: string, start: number): number { if (text[start] !== "<") return -1 const afterLt = start + 1 if (arrowSepSlot(view, afterLt) < 0) return -1 const run = dashRunLength(view, text, afterLt) return run === 0 ? -1 : afterLt + run } /** `<${sep}?-+(?:${sep}-+)*${sep}?>`: bidirectional arrow with dash runs. */ function matchLeftRightArrow(view: ProseView, text: string, start: number): number { if (text[start] !== "<") return -1 let i = start + 1 if (arrowSepSlot(view, i) < 0) return -1 let run = dashRunLength(view, text, i) if (run === 0) return -1 i += run // Additional `${sep}-+` runs: each separated by exactly one boundary. while (boundaryCountAt(view, i) === 1) { run = dashRunLength(view, text, i) if (run === 0) break i += run } if (arrowSepSlot(view, i) < 0) return -1 return text[i] === ">" ? i + 1 : -1 } /** Left context `(^|\s|${chr})`: start of text, a space, or a node boundary. */ function arrowLeftContextOk(view: ProseView, text: string, start: number): boolean { if (start === 0 || view.hasBoundary(start)) return true return WHITESPACE_RE.test(text[start - 1]) } /** Right context `(?=\s|${chr}|$)`: end of text, a space, or a node boundary. */ function arrowRightContextOk(view: ProseView, text: string, end: number): boolean { if (end >= text.length || view.hasBoundary(end)) return true return WHITESPACE_RE.test(text[end]) } /** Convert -> and <-> to arrows. */ export const arrows = makeProsePass(arrowsOverView) function arrowsOverView(view: ProseView): void { // Each arrow shape is matched by a left-to-right scan so a boundary that // splits a dash run still lets the valid suffix match. The whole shape span is // replaced with the arrow; consumed boundaries collapse to just after it. for (const [matcher, replacement] of ARROW_MATCHERS) { const text = view.text let i = 0 while (i < text.length) { // Every arrow shape starts with `-` or `<`; skip other offsets before // the comparatively expensive left-context/boundary check. if (text[i] !== "-" && text[i] !== "<") { i++; continue } if (!arrowLeftContextOk(view, text, i)) { i++; continue } const end = matcher(view, text, i) if (end < 0 || !arrowRightContextOk(view, text, end)) { i++; continue } view.replace(i, end, replacement) i = end } view.commit() } } /** * A temperature's digit run starts a word: a letter directly before the run * makes it an identifier ("W3C", "HTML5C"), and a `%` makes it a URL-encoded * octet ("%2C"). Mirrors {@link chainGuardOk}'s boundary handling — a node * boundary at the run start shadows the prior character and the guard passes. */ function degreeLeadingGuardOk(text: string, view: ProseView, digitIndex: number): boolean { let start = digitIndex while (start > 0 && /\d/.test(text[start - 1]) && !view.hasBoundary(start)) start-- if (view.hasBoundary(start)) return true const prior = text[start - 1] return prior === undefined || (!LATIN_LETTER_RE.test(prior) && prior !== "%") } export const degrees = makeProsePass(degreesOverView) function degreesOverView(view: ProseView): void { // Temperature with optional space before C or F (uppercase only). One boundary // is tolerated between the digit and the unit, and the unit is followed by // `(?!-[A-Za-z…]|[+#])` (reject "C-compiler", "C++", "F#") plus a word // boundary. Those trailing assertions are checked manually so a node boundary // right after the unit shadows the compound character, and the word-boundary // arm sees through up to three boundaries. const pattern = cachedRegExp(`\\d ?(?[CF])`, "g") const text = view.text replaceAllInView(view, pattern, (match, v) => { const unitEnd = match.index + match[0].length if (!degreeLeadingGuardOk(text, v, match.index)) return null if (!degreeUnitFollowOk(text, v, unitEnd)) return null const { unit } = match.groups! // Replace everything after the leading digit (the optional space and the // unit) with ` °C`/` °F`, leaving the digit and any boundary right after it // in place so the boundary keeps its position. v.replace(match.index + 1, unitEnd, ` ${DEGREE}${unit}`) return null }, { allowBoundaries: (match, v) => digitSuffixBoundaryOk(match, v), }) view.commit() } /** * The `(?!-[A-Za-z…]|[+#])` compound guard plus `\b(?!${chr}{0,3}\w)` word * boundary that follow the unit. A node boundary directly after the unit * shadows a `+`/`#`/`-`, satisfying the compound guard; the word boundary * blocks only when a word character follows the unit through at most three * boundaries. */ function degreeUnitFollowOk(text: string, view: ProseView, unitEnd: number): boolean { const boundaryAfterUnit = view.hasBoundary(unitEnd) // Compound guard: `+`/`#` directly after the unit (no boundary shadowing it), // or `-` directly followed by a Latin letter (boundary-aware). if (!boundaryAfterUnit) { const next = text[unitEnd] if (next === "+" || next === "#") return false if (next === "-" && !view.hasBoundary(unitEnd + 1) && LATIN_LETTER_RE.test(text[unitEnd + 1] ?? "")) return false } // Word boundary: reject when a word-like character (ASCII word or accented // Latin letter) follows the unit across up to three boundaries; with no // boundary such a character directly after the unit also removes the `\b`. // Consecutive boundaries pile at the same clean offset, so count them there. const followChar = text[unitEnd] if (boundaryCountAt(view, unitEnd) <= MAX_BOUNDARY_SEPARATORS && isWordLikeFollower(followChar)) return false return true } /** * Tolerate at most one boundary directly after the leading digit (the single * slot between `\d` and the rest); a second adjacent boundary breaks the slot. * The digit is the first match character, so the only legal interior-boundary * offset is match.index + 1. */ function digitSuffixBoundaryOk(match: RegExpExecArray, view: ProseView): boolean { const digitEnd = match.index + 1 const matchEnd = match.index + match[0].length // Reject any interior boundary that is not the single tolerated slot after // the leading digit. if (!interiorBoundariesWithin(view, match.index, matchEnd, [digitEnd])) return false return boundaryCountAt(view, digitEnd) <= 1 } /** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */ export const primeMarks = makeProsePass(convertPrimeMarks) type FractionRule = [string, string, string] const FRACTION_TUPLES: FractionRule[] = [ ["1", "4", UNICODE_SYMBOLS.FRACTION_1_4], ["1", "2", UNICODE_SYMBOLS.FRACTION_1_2], ["3", "4", UNICODE_SYMBOLS.FRACTION_3_4], ["1", "3", UNICODE_SYMBOLS.FRACTION_1_3], ["2", "3", UNICODE_SYMBOLS.FRACTION_2_3], ["1", "5", UNICODE_SYMBOLS.FRACTION_1_5], ["2", "5", UNICODE_SYMBOLS.FRACTION_2_5], ["3", "5", UNICODE_SYMBOLS.FRACTION_3_5], ["4", "5", UNICODE_SYMBOLS.FRACTION_4_5], ["1", "6", UNICODE_SYMBOLS.FRACTION_1_6], ["5", "6", UNICODE_SYMBOLS.FRACTION_5_6], ["1", "8", UNICODE_SYMBOLS.FRACTION_1_8], ["3", "8", UNICODE_SYMBOLS.FRACTION_3_8], ["5", "8", UNICODE_SYMBOLS.FRACTION_5_8], ["7", "8", UNICODE_SYMBOLS.FRACTION_7_8], ] const FRACTION_MAP = Object.fromEntries(FRACTION_TUPLES.map(([n, d, u]) => [`${n}/${d}`, u])) /** Convert 1/2, 1/4, etc. to ½, ¼, etc. Single-pass using alternation. */ export const fractions = makeProsePass(fractionsOverView) function fractionsOverView(view: ProseView): void { // Build alternation of exact valid pairs: `1/4|1/2|...`. Only exact pairs // from FRACTION_TUPLES match — no cross-product. One boundary is tolerated on // each side of the slash, and the pair is surrounded by the lookarounds // `(? `${n}/${d}`).join("|") const pattern = cachedRegExp(`(?:${pairAlternation})`, "g") const text = view.text replaceAllInView(view, pattern, (match, v) => { const start = match.index const end = start + match[0].length if (!fractionLookbehindOk(text, v, start)) return null if (!fractionLookaheadOk(text, v, end)) return null // Distribute interior boundaries: the first lands before the unicode char, // any remaining after it. const interior = firstInteriorBoundary(v, start, end) const firstBoundary = interior >= 0 ? interior : end if (firstBoundary > start) v.replace(start, firstBoundary, "") v.replace(firstBoundary, end, FRACTION_MAP[match[0]]) return null }, { allowBoundaries: (match, v) => fractionSlotBoundaryOk(match, v), }) view.commit() } /** * `(? = { st: SUPERSCRIPT_ST, nd: SUPERSCRIPT_ND, rd: SUPERSCRIPT_RD, th: SUPERSCRIPT_TH, } /** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */ export const superscriptOrdinal = makeProsePass(superscriptOrdinalOverView) function superscriptOrdinalOverView(view: ProseView): void { // Match number + ordinal suffix at word boundary, case-insensitively. One // boundary is tolerated between the digit and the suffix, with a word boundary // after the suffix. Only the suffix is replaced, leaving the digit and any // boundary between them untouched so the boundary keeps its position. const pattern = cachedRegExp(`(?\\d)(?st|nd|rd|th)\\b(?![${LATIN_OR_DIGIT}])`, "gi") replaceAllInView(view, pattern, (match, v) => { const { num, suffix } = match.groups! const suffixStart = match.index + num.length v.replace(suffixStart, match.index + match[0].length, ORDINAL_MAP[suffix.toLowerCase()]) return null }, { allowBoundaries: (match, v) => digitSuffixBoundaryOk(match, v), }) view.commit() } // Preserves highest-priority space type (NBSP > NNBSP > regular) and leading indentation. export const collapseSpaces = makeProsePass(collapseSpacesOverView) /** * Collapses each run of two or more space characters to its highest-priority * space. A run never crosses a node boundary, and the anchor requiring a * non-newline, non-space character before the run is satisfied by a boundary * (so a run opening a node still collapses); runs at line or text start are * preserved so indented blocks survive. */ function collapseSpacesOverView(view: ProseView): void { const text = view.text let i = 0 while (i < text.length) { if (!SPACE_CHAR_RE.test(text[i])) { i++ continue } let end = i + 1 while (end < text.length && SPACE_CHAR_RE.test(text[end]) && !view.hasBoundary(end)) end++ const anchored = view.hasBoundary(i) || (i > 0 && text[i - 1] !== "\n" && !SPACE_CHAR_RE.test(text[i - 1])) if (anchored && end - i >= 2) { const run = text.slice(i, end) const kept = run.includes(NBSP) ? NBSP : run.includes(UNICODE_SYMBOLS.NNBSP) ? UNICODE_SYMBOLS.NNBSP : " " view.replace(i, end, kept) } i = end } view.commit() } /** `[first, repeated, replacement]` with literal punctuation characters. */ type LigatureRule = [string, string, string] // Order matters: mixed punctuation first, then repeated. const PUNCTUATION_LIGATURE_MAP: LigatureRule[] = [ ["?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈ ["!", "?", EXCLAMATION_QUESTION], // !?+ → ⁉ ["?", "?", DOUBLE_QUESTION], // ??+ → ⁇ ["!", "!", "!"], // !!+ → ! (normalize) ] /** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Disabled by default (poor font support). */ export const punctuationLigatures = makeProsePass(punctuationLigaturesOverView) function punctuationLigaturesOverView(view: ProseView): void { for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) { // The rule is `first (repeated)+`: the leading char, then one or more // repeated chars each preceded by at most one boundary (a second adjacent // boundary breaks the run). A left-to-right scan replaces the whole run with // the ligature; consumed boundaries collapse to just after it, and a broken // run re-anchors at the next leading char. const text = view.text let i = 0 while (i < text.length) { if (text[i] !== first) { i++; continue } let end = i + 1 let consumed = 0 for (;;) { const afterSep = arrowSepSlot(view, end) if (afterSep < 0 || text[afterSep] !== repeated) break end = afterSep + 1 consumed++ } if (consumed === 0) { i++; continue } view.replace(i, end, replacement) i = end } view.commit() } } export function symbolTransform(input: string, options?: SymbolOptions): string export function symbolTransform(input: ProseView, options?: SymbolOptions): void export function symbolTransform(input: string | ProseView, options: SymbolOptions = {}): string | void { return overInput(input, (view) => { ellipsisOverView(view) multiplicationOverView(view) mathSymbolsOverView(view) legalSymbolsOverView(view) if (options.includeArrows !== false) { arrowsOverView(view) } }) }