A world-class math input for the web
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]);