A world-class math input for the web
at main 303 lines 11 kB view raw
1import { CaretNode, MissingNode } from "@caret-js/core"; 2import { 3 AddNode, 4 DecimalNode, 5 DivideNode, 6 EquationNode, 7 ExponentNode, 8 FunctionCallNode, 9 FunctionDefinitionNode, 10 MultiplicationStyleTag, 11 MultiplyNode, 12 NegativeNode, 13 PositiveNode, 14 RadicalNode, 15 SubtractNode, 16 VariableNode, 17} from "."; 18 19/* 20 TODO: Long-term, this needs to be split out into a more robust and modular system. Developers should be 21 able to add new languages and their own text conversions that work in combination with the pre-written 22 conversions. They should also be able to write their own overrides on top of the built-ins. 23 24 It might also be important to offer mathspeak vs natural style options. 25*/ 26 27// P.S. Some people (and AI chatbots) recommend having the screen reader just read out the MathML directly, 28// but when I tested using VoiceOver on MacOS it was a very terrible experience. So we at least need this 29// in some cases. 30 31export function toText(node: CaretNode): string { 32 return toTextWithMetadata(node).text; 33} 34 35interface TextWithMetadata { 36 text: string; 37 bindingPower: number; 38 hasAmbiguousEnd: boolean; 39} 40 41function toTextWithMetadata(node: CaretNode): TextWithMetadata { 42 if (node instanceof VariableNode) { 43 return { text: node.name, bindingPower: 0, hasAmbiguousEnd: false }; 44 } 45 46 if (node instanceof DecimalNode) { 47 return { text: node.value, bindingPower: 0, hasAmbiguousEnd: false }; 48 } 49 50 if (node instanceof AddNode) { 51 return { 52 text: node.addends 53 .map((node, i, arr) => 54 insertText(node, { allowTrailingComma: i !== arr.length - 1 }), 55 ) 56 .join(" plus "), 57 bindingPower: 1, 58 hasAmbiguousEnd: true, 59 }; 60 } 61 62 if (node instanceof MultiplyNode) { 63 const isImplicit = 64 node.getTag(MultiplicationStyleTag)?.style === "implicit"; 65 66 return { 67 text: node.factors 68 .map((node, i, arr) => 69 insertText(node, { allowTrailingComma: i !== arr.length - 1 }), 70 ) 71 .join(isImplicit ? " " : " times "), 72 bindingPower: isImplicit ? 2 : 3, 73 hasAmbiguousEnd: !isImplicit, 74 }; 75 } 76 77 if (node instanceof SubtractNode) { 78 return { 79 text: `${insertText(node.minuend, { 80 allowTrailingComma: true, 81 })} minus ${insertText(node.subtrahend)}`, 82 bindingPower: 1, 83 hasAmbiguousEnd: true, 84 }; 85 } 86 87 if (node instanceof DivideNode) { 88 if ( 89 node.dividend instanceof DecimalNode && 90 node.divisor instanceof DecimalNode 91 ) { 92 if (node.divisor.value === "2") { 93 return { 94 text: `${node.dividend.value} ${ 95 node.dividend.value === "1" ? "half" : "halves" 96 }`, 97 bindingPower: 0, 98 hasAmbiguousEnd: false, 99 }; 100 } 101 102 const denominatorWords = numberWords.get(node.divisor.value); 103 if (denominatorWords && node.divisor.value !== "1") { 104 const denominatorText = 105 node.dividend.value === "1" 106 ? denominatorWords.singularOrdinal 107 : denominatorWords.pluralOrdinal; 108 109 return { 110 text: `${node.dividend.value} ${denominatorText}`, 111 bindingPower: 0, 112 hasAmbiguousEnd: false, 113 }; 114 } 115 116 return { 117 text: `${insertText(node.dividend)} over ${insertText(node.divisor)}`, 118 bindingPower: 0, 119 hasAmbiguousEnd: false, 120 }; 121 } 122 123 return { 124 text: `${insertText(node.dividend)} divided by ${toText(node.divisor)}`, 125 bindingPower: 3, 126 hasAmbiguousEnd: true, 127 }; 128 } 129 130 if (node instanceof ExponentNode) { 131 if (node.power instanceof DecimalNode) { 132 if (node.power.value === "2") { 133 return { 134 text: `${insertText(node.base)} squared`, 135 bindingPower: 4, 136 hasAmbiguousEnd: false, 137 }; 138 } 139 if (node.power.value === "3") { 140 return { 141 text: `${insertText(node.base)} cubed`, 142 bindingPower: 4, 143 hasAmbiguousEnd: false, 144 }; 145 } 146 const words = numberWords.get(node.power.value); 147 if (words) { 148 const { singularOrdinal, pluralOrdinal } = words; 149 return { 150 text: `${insertText(node.base)} to the ${ 151 node.power.value === "1" ? singularOrdinal : pluralOrdinal 152 }`, 153 bindingPower: 4, 154 hasAmbiguousEnd: false, 155 }; 156 } 157 } 158 159 return { 160 text: `${insertText(node.base)} to the power of ${insertText( 161 node.power, 162 )}`, 163 bindingPower: 4, 164 hasAmbiguousEnd: true, 165 }; 166 } 167 168 if (node instanceof EquationNode) { 169 return { 170 text: node.expressions.map((expr) => insertText(expr)).join(" equals "), 171 bindingPower: 4, 172 hasAmbiguousEnd: true, 173 }; 174 } 175 176 if (node instanceof PositiveNode) { 177 return { 178 text: `positive ${insertText(node.child)}`, 179 bindingPower: 1, 180 hasAmbiguousEnd: true, 181 }; 182 } 183 184 if (node instanceof NegativeNode) { 185 return { 186 text: `negative ${insertText(node.child)}`, 187 bindingPower: 1, 188 hasAmbiguousEnd: true, 189 }; 190 } 191 192 if (node instanceof FunctionCallNode) { 193 return { 194 text: `${node.name} of ${node.args 195 .map((node) => insertText(node)) 196 .join(" ")}`, 197 bindingPower: 5, 198 hasAmbiguousEnd: true, 199 }; 200 } 201 202 if (node instanceof FunctionDefinitionNode) { 203 return { 204 text: `${node.name} of ${node.args 205 .map((node) => insertText(node)) 206 .join(" ")} equals ${insertText(node.body)}`, 207 bindingPower: 0, 208 hasAmbiguousEnd: true, 209 }; 210 } 211 212 if (node instanceof RadicalNode) { 213 if (node.index === null) { 214 return { 215 text: `square root of ${insertText(node.radicand)}`, 216 bindingPower: 4, 217 hasAmbiguousEnd: true, 218 }; 219 } 220 221 if (node.index instanceof DecimalNode) { 222 if (node.index.value === "2") { 223 return { 224 text: `square root of ${insertText(node.radicand)}`, 225 bindingPower: 4, 226 hasAmbiguousEnd: true, 227 }; 228 } 229 if (node.index.value === "3") { 230 return { 231 text: `cube root of ${insertText(node.radicand)}`, 232 bindingPower: 4, 233 hasAmbiguousEnd: true, 234 }; 235 } 236 237 const words = numberWords.get(node.index.value); 238 if (words) { 239 return { 240 text: `${words.singularOrdinal} root of ${insertText(node.radicand)}`, 241 bindingPower: 4, 242 hasAmbiguousEnd: true, 243 }; 244 } 245 } 246 247 return { 248 text: `${insertText(node.index)} root of ${insertText(node.radicand)}`, 249 bindingPower: 4, 250 hasAmbiguousEnd: true, 251 }; 252 } 253 254 if (node instanceof MissingNode) { 255 return { text: "", bindingPower: 0, hasAmbiguousEnd: false }; 256 } 257 258 return { text: "[Unknown]", bindingPower: 0, hasAmbiguousEnd: false }; 259} 260 261function insertText( 262 node: CaretNode, 263 options = { allowTrailingComma: false }, 264): string { 265 const textWithMetadata = toTextWithMetadata(node); 266 267 if (options.allowTrailingComma && textWithMetadata.hasAmbiguousEnd) { 268 return `${textWithMetadata.text},`; 269 } 270 271 return textWithMetadata.text; 272} 273 274// prettier-ignore 275const numberWords = new Map<string, { singularCardinal: string; pluralCardinal: string; singularOrdinal: string; pluralOrdinal: string }>([ 276 ["1", { singularCardinal: "one", pluralCardinal: "ones", singularOrdinal: "first", pluralOrdinal: "firsts" }], 277 ["2", { singularCardinal: "two", pluralCardinal: "twos", singularOrdinal: "second", pluralOrdinal: "seconds" }], 278 ["3", { singularCardinal: "three", pluralCardinal: "threes", singularOrdinal: "third", pluralOrdinal: "thirds" }], 279 ["4", { singularCardinal: "four", pluralCardinal: "fours", singularOrdinal: "fourth", pluralOrdinal: "fourths" }], 280 ["5", { singularCardinal: "five", pluralCardinal: "fives", singularOrdinal: "fifth", pluralOrdinal: "fifths" }], 281 ["6", { singularCardinal: "six", pluralCardinal: "sixes", singularOrdinal: "sixth", pluralOrdinal: "sixths" }], 282 ["7", { singularCardinal: "seven", pluralCardinal: "sevens", singularOrdinal: "seventh", pluralOrdinal: "sevenths" }], 283 ["8", { singularCardinal: "eight", pluralCardinal: "eights", singularOrdinal: "eighth", pluralOrdinal: "eighths" }], 284 ["9", { singularCardinal: "nine", pluralCardinal: "nines", singularOrdinal: "ninth", pluralOrdinal: "ninths" }], 285 ["10", { singularCardinal: "ten", pluralCardinal: "tens", singularOrdinal: "tenth", pluralOrdinal: "tenths" }], 286 ["11", { singularCardinal: "eleven", pluralCardinal: "elevens", singularOrdinal: "eleventh", pluralOrdinal: "elevenths" }], 287 ["12", { singularCardinal: "twelve", pluralCardinal: "twelves", singularOrdinal: "twelfth", pluralOrdinal: "twelfths" }], 288 ["13", { singularCardinal: "thirteen", pluralCardinal: "thirteens", singularOrdinal: "thirteenth", pluralOrdinal: "thirteenths" }], 289 ["14", { singularCardinal: "fourteen", pluralCardinal: "fourteens", singularOrdinal: "fourteenth", pluralOrdinal: "fourteenths" }], 290 ["15", { singularCardinal: "fifteen", pluralCardinal: "fifteens", singularOrdinal: "fifteenth", pluralOrdinal: "fifteenths" }], 291 ["16", { singularCardinal: "sixteen", pluralCardinal: "sixteens", singularOrdinal: "sixteenth", pluralOrdinal: "sixteenths" }], 292 ["17", { singularCardinal: "seventeen", pluralCardinal: "seventeens", singularOrdinal: "seventeenth", pluralOrdinal: "seventeenths" }], 293 ["18", { singularCardinal: "eighteen", pluralCardinal: "eighteens", singularOrdinal: "eighteenth", pluralOrdinal: "eighteenths" }], 294 ["19", { singularCardinal: "nineteen", pluralCardinal: "nineteens", singularOrdinal: "nineteenth", pluralOrdinal: "nineteenths" }], 295 ["20", { singularCardinal: "twenty", pluralCardinal: "twenties", singularOrdinal: "twentieth", pluralOrdinal: "twentieths" }], 296 ["30", { singularCardinal: "thirty", pluralCardinal: "thirties", singularOrdinal: "thirtieth", pluralOrdinal: "thirtieths" }], 297 ["40", { singularCardinal: "forty", pluralCardinal: "forties", singularOrdinal: "fortieth", pluralOrdinal: "fortieths" }], 298 ["50", { singularCardinal: "fifty", pluralCardinal: "fifties", singularOrdinal: "fiftieth", pluralOrdinal: "fiftieths" }], 299 ["60", { singularCardinal: "sixty", pluralCardinal: "sixties", singularOrdinal: "sixtieth", pluralOrdinal: "sixtieths" }], 300 ["70", { singularCardinal: "seventy", pluralCardinal: "seventies", singularOrdinal: "seventieth", pluralOrdinal: "seventieths" }], 301 ["80", { singularCardinal: "eighty", pluralCardinal: "eighties", singularOrdinal: "eightieth", pluralOrdinal: "eightieths" }], 302 ["90", { singularCardinal: "ninety", pluralCardinal: "nineties", singularOrdinal: "ninetieth", pluralOrdinal: "ninetieths" }], 303]);