/** * Unicode text styling using Mathematical Alphanumeric Symbols (U+1D400–U+1D7FF). * * Toggle styles: bold, italic, underline, strikethrough * Font families: script (fancy), fraktur, doubleStruck, sansSerif, monospace * (mutually exclusive — enabling one replaces the previous font family) * * Underline uses U+0332 COMBINING LOW LINE appended after each character. * Strikethrough uses U+0336 COMBINING LONG STROKE OVERLAY appended after each character. * * Backwards-compatible: toggling a style off restores the original ASCII characters. */ // ── Public types ──────────────────────────────────────────────────────────── export type FontFamily = | 'none' | 'script' | 'fraktur' | 'doubleStruck' | 'sansSerif' | 'monospace'; export type TextStyle = | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'stroke' | 'fancy' // alias for script | 'fraktur' | 'doubleStruck' | 'sansSerif' | 'monospace'; export interface DetectedStyles { bold: boolean; italic: boolean; underline: boolean; strikethrough: boolean; stroke: boolean; fontFamily: FontFamily; } // ── Constants ─────────────────────────────────────────────────────────────── const COMBINING_LOW_LINE = '\u0332'; const COMBINING_LONG_STROKE = '\u0336'; const COMBINING_ENCLOSING_SQUARE = '\u20DE'; // ── Internal style keys (one per contiguous Unicode block) ────────────────── type StyleKey = | 'bold' | 'italic' | 'boldItalic' | 'script' | 'boldScript' | 'fraktur' | 'boldFraktur' | 'doubleStruck' | 'sansSerif' | 'sansSerifBold' | 'sansSerifItalic' | 'sansSerifBoldItalic' | 'monospace'; interface Range { upper: number; lower: number; digits?: number; } const RANGES: Record = { bold: { upper: 0x1D400, lower: 0x1D41A, digits: 0x1D7CE }, italic: { upper: 0x1D434, lower: 0x1D44E }, boldItalic: { upper: 0x1D468, lower: 0x1D482 }, script: { upper: 0, lower: 0x1D4B6 }, boldScript: { upper: 0x1D4D0, lower: 0x1D4EA }, fraktur: { upper: 0, lower: 0x1D51E }, boldFraktur: { upper: 0x1D56C, lower: 0x1D586 }, doubleStruck: { upper: 0, lower: 0x1D552, digits: 0x1D7D8 }, sansSerif: { upper: 0x1D5A0, lower: 0x1D5BA, digits: 0x1D7E2 }, sansSerifBold: { upper: 0x1D5D4, lower: 0x1D5EE, digits: 0x1D7EC }, sansSerifItalic: { upper: 0x1D608, lower: 0x1D622 }, sansSerifBoldItalic: { upper: 0x1D63C, lower: 0x1D656 }, monospace: { upper: 0x1D670, lower: 0x1D68A }, }; // ── Exception arrays (uppercase / lowercase letters with holes) ───────────── // Script uppercase — some letters live in the Letterlike Symbols block const SCRIPT_UPPER: readonly number[] = [ 0x1D49C, // A 𝒜 0x212C, // B ℬ 0x1D49E, // C 𝒞 0x1D49F, // D 𝒟 0x2130, // E ℰ 0x2131, // F ℱ 0x1D4A2, // G 𝒢 0x210B, // H ℋ 0x2110, // I ℐ 0x1D4A5, // J 𝒥 0x1D4A6, // K 𝒦 0x2112, // L ℒ 0x2133, // M ℳ 0x1D4A9, // N 𝒩 0x1D4AA, // O 𝒪 0x1D4AB, // P 𝒫 0x1D4AC, // Q 𝒬 0x211B, // R ℛ 0x1D4AE, // S 𝒮 0x1D4AF, // T 𝒯 0x1D4B0, // U 𝒰 0x1D4B1, // V 𝒱 0x1D4B2, // W 𝒲 0x1D4B3, // X 𝒳 0x1D4B4, // Y 𝒴 0x1D4B5, // Z 𝒵 ]; // Fraktur uppercase — some letters live in the Letterlike Symbols block const FRAKTUR_UPPER: readonly number[] = [ 0x1D504, // A 𝔄 0x1D505, // B 𝔅 0x212D, // C ℭ 0x1D507, // D 𝔇 0x1D508, // E 𝔈 0x1D509, // F 𝔉 0x1D50A, // G 𝔊 0x210C, // H ℌ 0x2111, // I ℑ 0x1D50D, // J 𝔍 0x1D50E, // K 𝔎 0x1D50F, // L 𝔏 0x1D510, // M 𝔐 0x1D511, // N 𝔑 0x1D512, // O 𝔒 0x1D513, // P 𝔓 0x1D514, // Q 𝔔 0x211C, // R ℜ 0x1D516, // S 𝔖 0x1D517, // T 𝔗 0x1D518, // U 𝔘 0x1D519, // V 𝔙 0x1D51A, // W 𝔚 0x1D51B, // X 𝔛 0x1D51C, // Y 𝔜 0x2128, // Z ℨ ]; // Double-struck uppercase — several letters in the Letterlike Symbols block const DOUBLE_STRUCK_UPPER: readonly number[] = [ 0x1D538, // A 𝔸 0x1D539, // B 𝔹 0x2102, // C ℂ 0x1D53B, // D 𝔻 0x1D53C, // E 𝔼 0x1D53D, // F 𝔽 0x1D53E, // G 𝔾 0x210D, // H ℍ 0x1D540, // I 𝕀 0x1D541, // J 𝕁 0x1D542, // K 𝕂 0x1D543, // L 𝕃 0x1D544, // M 𝕄 0x2115, // N ℕ 0x1D546, // O 𝕆 0x2119, // P ℙ 0x211A, // Q ℚ 0x211D, // R ℝ 0x1D54A, // S 𝕊 0x1D54B, // T 𝕋 0x1D54C, // U 𝕌 0x1D54D, // V 𝕍 0x1D54E, // W 𝕎 0x1D54F, // X 𝕏 0x1D550, // Y 𝕐 0x2124, // Z ℤ ]; // Italic lowercase — h is U+210E (ℎ PLANCK CONSTANT), U+1D455 is a hole const ITALIC_LOWER: readonly number[] = [ 0x1D44E, // a 0x1D44F, // b 0x1D450, // c 0x1D451, // d 0x1D452, // e 0x1D453, // f 0x1D454, // g 0x210E, // h ℎ 0x1D456, // i 0x1D457, // j 0x1D458, // k 0x1D459, // l 0x1D45A, // m 0x1D45B, // n 0x1D45C, // o 0x1D45D, // p 0x1D45E, // q 0x1D45F, // r 0x1D460, // s 0x1D461, // t 0x1D462, // u 0x1D463, // v 0x1D464, // w 0x1D465, // x 0x1D466, // y 0x1D467, // z ]; // ── Resolve (bold, italic, fontFamily) → StyleKey ────────────────────────── function resolveStyleKey( bold: boolean, italic: boolean, fontFamily: FontFamily, ): StyleKey | null { switch (fontFamily) { case 'none': if (bold && italic) return 'boldItalic'; if (bold) return 'bold'; if (italic) return 'italic'; return null; case 'script': // No script+italic or boldScript+italic blocks exist return bold ? 'boldScript' : 'script'; case 'fraktur': // No fraktur+italic block exists return bold ? 'boldFraktur' : 'fraktur'; case 'doubleStruck': // No bold/italic variants return 'doubleStruck'; case 'sansSerif': if (bold && italic) return 'sansSerifBoldItalic'; if (bold) return 'sansSerifBold'; if (italic) return 'sansSerifItalic'; return 'sansSerif'; case 'monospace': // No bold/italic variants return 'monospace'; } } // ── Forward mapping (regular code point → styled code point) ──────────────── function toStyledCp(regularCp: number, key: StyleKey): number { const range = RANGES[key]; // Uppercase A–Z if (regularCp >= 65 && regularCp <= 90) { const idx = regularCp - 65; if (key === 'script') return SCRIPT_UPPER[idx]; if (key === 'fraktur') return FRAKTUR_UPPER[idx]; if (key === 'doubleStruck') return DOUBLE_STRUCK_UPPER[idx]; return range.upper + idx; } // Lowercase a–z if (regularCp >= 97 && regularCp <= 122) { const idx = regularCp - 97; if (key === 'italic') return ITALIC_LOWER[idx]; return range.lower + idx; } // Digits 0–9 if (regularCp >= 48 && regularCp <= 57 && range.digits !== undefined) { return range.digits + (regularCp - 48); } return regularCp; // not convertible } // ── Reverse mapping (styled code point → { regular, key }) ───────────────── interface ReverseEntry { regular: number; key: StyleKey; } let reverseMap: Map | null = null; function buildReverseMap(): Map { const m = new Map(); const addForward = (key: StyleKey) => { const range = RANGES[key]; // Uppercase if (key === 'script') { for (let i = 0; i < 26; i++) m.set(SCRIPT_UPPER[i], { regular: 65 + i, key }); } else if (key === 'fraktur') { for (let i = 0; i < 26; i++) m.set(FRAKTUR_UPPER[i], { regular: 65 + i, key }); } else if (key === 'doubleStruck') { for (let i = 0; i < 26; i++) m.set(DOUBLE_STRUCK_UPPER[i], { regular: 65 + i, key }); } else { for (let i = 0; i < 26; i++) m.set(range.upper + i, { regular: 65 + i, key }); } // Lowercase if (key === 'italic') { for (let i = 0; i < 26; i++) m.set(ITALIC_LOWER[i], { regular: 97 + i, key }); } else { for (let i = 0; i < 26; i++) m.set(range.lower + i, { regular: 97 + i, key }); } // Digits if (range.digits !== undefined) { for (let i = 0; i < 10; i++) m.set(range.digits + i, { regular: 48 + i, key }); } }; (Object.keys(RANGES) as StyleKey[]).forEach(addForward); return m; } function getReverseMap(): Map { if (!reverseMap) reverseMap = buildReverseMap(); return reverseMap; } // ── StyleKey → { bold, italic, fontFamily } flags ─────────────────────────── interface StyleFlags { bold: boolean; italic: boolean; fontFamily: FontFamily; } const STYLE_KEY_FLAGS: Record = { bold: { bold: true, italic: false, fontFamily: 'none' }, italic: { bold: false, italic: true, fontFamily: 'none' }, boldItalic: { bold: true, italic: true, fontFamily: 'none' }, script: { bold: false, italic: false, fontFamily: 'script' }, boldScript: { bold: true, italic: false, fontFamily: 'script' }, fraktur: { bold: false, italic: false, fontFamily: 'fraktur' }, boldFraktur: { bold: true, italic: false, fontFamily: 'fraktur' }, doubleStruck: { bold: false, italic: false, fontFamily: 'doubleStruck' }, sansSerif: { bold: false, italic: false, fontFamily: 'sansSerif' }, sansSerifBold: { bold: true, italic: false, fontFamily: 'sansSerif' }, sansSerifItalic: { bold: false, italic: true, fontFamily: 'sansSerif' }, sansSerifBoldItalic: { bold: true, italic: true, fontFamily: 'sansSerif' }, monospace: { bold: false, italic: false, fontFamily: 'monospace' }, }; // ── Character-level helpers ───────────────────────────────────────────────── function isCombining(cp: number): boolean { return (cp >= 0x0300 && cp <= 0x036F) || (cp >= 0x20DD && cp <= 0x20E0); } /** Yield each Unicode code point with its string index. */ function* eachChar(text: string): Generator<{ cp: number; idx: number; len: number }> { let i = 0; while (i < text.length) { const cp = text.codePointAt(i)!; const len = cp > 0xFFFF ? 2 : 1; yield { cp, idx: i, len }; i += len; } } interface CharStyles { regular: number; bold: boolean; italic: boolean; fontFamily: FontFamily; } /** Detect the styles of a single (non-combining) character. Returns null if not convertible. */ function charStyles(cp: number): CharStyles | null { // Regular ASCII letter/digit if ((cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122) || (cp >= 48 && cp <= 57)) { return { regular: cp, bold: false, italic: false, fontFamily: 'none' }; } const entry = getReverseMap().get(cp); if (!entry) return null; const flags = STYLE_KEY_FLAGS[entry.key]; return { regular: entry.regular, ...flags }; } // ── Underline helpers ─────────────────────────────────────────────────────── function stripCombining(text: string): { cleaned: string; hadUnderline: boolean; hadStrikethrough: boolean; hadStroke: boolean } { let hadUnderline = false; let hadStrikethrough = false; let hadStroke = false; let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0332) { hadUnderline = true; } else if (cp === 0x0336) { hadStrikethrough = true; } else if (cp === 0x20DE) { hadStroke = true; } else { result += String.fromCodePoint(cp); } } // Restore normal spaces where we used no-break spaces for continuous underline/strikethrough if (hadUnderline || hadStrikethrough || hadStroke) { result = result.replace(/\u00A0/g, ' '); } return { cleaned: result, hadUnderline, hadStrikethrough, hadStroke }; } // @ts-ignore function addUnderlines(text: string): string { let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0020) { // Replace space with no-break space + combining low line for continuous underline result += '\u00A0' + COMBINING_LOW_LINE; } else { result += String.fromCodePoint(cp); if (!isCombining(cp)) result += COMBINING_LOW_LINE; } } return result; } // @ts-ignore function addStrikethroughs(text: string): string { let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0020) { // Replace space with no-break space + combining long stroke for continuous strikethrough result += '\u00A0' + COMBINING_LONG_STROKE; } else { result += String.fromCodePoint(cp); if (!isCombining(cp)) result += COMBINING_LONG_STROKE; } } return result; } /** Re-apply underline, strikethrough, and stroke combining marks to text. */ function addCombiningMarks(text: string, underline: boolean, strikethrough: boolean, stroke: boolean): string { if (!underline && !strikethrough && !stroke) return text; let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0020) { result += '\u00A0'; if (underline) result += COMBINING_LOW_LINE; if (strikethrough) result += COMBINING_LONG_STROKE; if (stroke) result += COMBINING_ENCLOSING_SQUARE; } else { result += String.fromCodePoint(cp); if (!isCombining(cp)) { if (underline) result += COMBINING_LOW_LINE; if (strikethrough) result += COMBINING_LONG_STROKE; if (stroke) result += COMBINING_ENCLOSING_SQUARE; } } } return result; } // ── Core: process every character, applying a style toggle ────────────────── type FontFamilyStyle = 'fancy' | 'fraktur' | 'doubleStruck' | 'sansSerif' | 'monospace'; function processChars( text: string, targetStyle: 'bold' | 'italic' | FontFamilyStyle, enable: boolean, ): string { const out: string[] = []; for (const { cp } of eachChar(text)) { const s = charStyles(cp); if (!s) { // Non-convertible character (space, punctuation, emoji…) — pass through out.push(String.fromCodePoint(cp)); continue; } let { bold, italic, fontFamily } = s; switch (targetStyle) { case 'bold': bold = enable; break; case 'italic': italic = enable; break; case 'fancy': fontFamily = enable ? 'script' : 'none'; break; case 'fraktur': fontFamily = enable ? 'fraktur' : 'none'; break; case 'doubleStruck': fontFamily = enable ? 'doubleStruck' : 'none'; break; case 'sansSerif': fontFamily = enable ? 'sansSerif' : 'none'; break; case 'monospace': fontFamily = enable ? 'monospace' : 'none'; break; } const key = resolveStyleKey(bold, italic, fontFamily); const newCp = key ? toStyledCp(s.regular, key) : s.regular; out.push(String.fromCodePoint(newCp)); } return out.join(''); } // ── Helpers ───────────────────────────────────────────────────────────────── /** Map a public TextStyle to the internal FontFamily name. */ export function styleToFontFamily(style: TextStyle): FontFamily { switch (style) { case 'fancy': return 'script'; case 'fraktur': return 'fraktur'; case 'doubleStruck': return 'doubleStruck'; case 'sansSerif': return 'sansSerif'; case 'monospace': return 'monospace'; default: return 'none'; } } // ── Public API ────────────────────────────────────────────────────────────── /** * Toggle a text style on the given string. * * Behaviour: * - Examines the **first convertible character** to decide whether to enable * or disable the style. * - Applies the decision uniformly to every character. * - Font families are mutually exclusive: enabling one replaces the previous. * - Underline is handled separately via combining low lines. */ export function toggleStyle(text: string, style: TextStyle): string { // 1. Separate combining marks from the text const { cleaned, hadUnderline, hadStrikethrough, hadStroke } = stripCombining(text); if (style === 'underline') { const newUnderline = !hadUnderline; return addCombiningMarks(cleaned, newUnderline, hadStrikethrough, hadStroke); } if (style === 'strikethrough') { const newStrikethrough = !hadStrikethrough; return addCombiningMarks(cleaned, hadUnderline, newStrikethrough, hadStroke); } if (style === 'stroke') { const newStroke = !hadStroke; return addCombiningMarks(cleaned, hadUnderline, hadStrikethrough, newStroke); } // 2. Determine toggle direction from the first convertible char let enable = true; for (const { cp } of eachChar(cleaned)) { const s = charStyles(cp); if (s) { if (style === 'bold') { enable = !s.bold; } else if (style === 'italic') { enable = !s.italic; } else { // Font family style: enable if current family is different enable = s.fontFamily !== styleToFontFamily(style); } break; } } // 3. Process every character const converted = processChars(cleaned, style as 'bold' | 'italic' | FontFamilyStyle, enable); // 4. Restore combining marks if they were present return addCombiningMarks(converted, hadUnderline, hadStrikethrough, hadStroke); } /** * Detect the styles present in the text based on the **first convertible * character** and whether any combining low lines are present. */ export function detectStyles(text: string): DetectedStyles { const { cleaned, hadUnderline, hadStrikethrough, hadStroke } = stripCombining(text); let bold = false; let italic = false; let fontFamily: FontFamily = 'none'; for (const { cp } of eachChar(cleaned)) { const s = charStyles(cp); if (s) { bold = s.bold; italic = s.italic; fontFamily = s.fontFamily; break; } } return { bold, italic, underline: hadUnderline, strikethrough: hadStrikethrough, stroke: hadStroke, fontFamily }; }