import { CaretNode, MissingNode } from "@caret-js/core"; import { AddNode, DecimalNode, DivideNode, EquationNode, ExponentNode, FunctionCallNode, FunctionDefinitionNode, MultiplicationStyleTag, MultiplyNode, NegativeNode, PositiveNode, RadicalNode, SubtractNode, VariableNode, } from "."; /* TODO: Long-term, this needs to be split out into a more robust and modular system. Developers should be able to add new languages and their own text conversions that work in combination with the pre-written conversions. They should also be able to write their own overrides on top of the built-ins. It might also be important to offer mathspeak vs natural style options. */ // P.S. Some people (and AI chatbots) recommend having the screen reader just read out the MathML directly, // but when I tested using VoiceOver on MacOS it was a very terrible experience. So we at least need this // in some cases. export function toText(node: CaretNode): string { return toTextWithMetadata(node).text; } interface TextWithMetadata { text: string; bindingPower: number; hasAmbiguousEnd: boolean; } function toTextWithMetadata(node: CaretNode): TextWithMetadata { if (node instanceof VariableNode) { return { text: node.name, bindingPower: 0, hasAmbiguousEnd: false }; } if (node instanceof DecimalNode) { return { text: node.value, bindingPower: 0, hasAmbiguousEnd: false }; } if (node instanceof AddNode) { return { text: node.addends .map((node, i, arr) => insertText(node, { allowTrailingComma: i !== arr.length - 1 }), ) .join(" plus "), bindingPower: 1, hasAmbiguousEnd: true, }; } if (node instanceof MultiplyNode) { const isImplicit = node.getTag(MultiplicationStyleTag)?.style === "implicit"; return { text: node.factors .map((node, i, arr) => insertText(node, { allowTrailingComma: i !== arr.length - 1 }), ) .join(isImplicit ? " " : " times "), bindingPower: isImplicit ? 2 : 3, hasAmbiguousEnd: !isImplicit, }; } if (node instanceof SubtractNode) { return { text: `${insertText(node.minuend, { allowTrailingComma: true, })} minus ${insertText(node.subtrahend)}`, bindingPower: 1, hasAmbiguousEnd: true, }; } if (node instanceof DivideNode) { if ( node.dividend instanceof DecimalNode && node.divisor instanceof DecimalNode ) { if (node.divisor.value === "2") { return { text: `${node.dividend.value} ${ node.dividend.value === "1" ? "half" : "halves" }`, bindingPower: 0, hasAmbiguousEnd: false, }; } const denominatorWords = numberWords.get(node.divisor.value); if (denominatorWords && node.divisor.value !== "1") { const denominatorText = node.dividend.value === "1" ? denominatorWords.singularOrdinal : denominatorWords.pluralOrdinal; return { text: `${node.dividend.value} ${denominatorText}`, bindingPower: 0, hasAmbiguousEnd: false, }; } return { text: `${insertText(node.dividend)} over ${insertText(node.divisor)}`, bindingPower: 0, hasAmbiguousEnd: false, }; } return { text: `${insertText(node.dividend)} divided by ${toText(node.divisor)}`, bindingPower: 3, hasAmbiguousEnd: true, }; } if (node instanceof ExponentNode) { if (node.power instanceof DecimalNode) { if (node.power.value === "2") { return { text: `${insertText(node.base)} squared`, bindingPower: 4, hasAmbiguousEnd: false, }; } if (node.power.value === "3") { return { text: `${insertText(node.base)} cubed`, bindingPower: 4, hasAmbiguousEnd: false, }; } const words = numberWords.get(node.power.value); if (words) { const { singularOrdinal, pluralOrdinal } = words; return { text: `${insertText(node.base)} to the ${ node.power.value === "1" ? singularOrdinal : pluralOrdinal }`, bindingPower: 4, hasAmbiguousEnd: false, }; } } return { text: `${insertText(node.base)} to the power of ${insertText( node.power, )}`, bindingPower: 4, hasAmbiguousEnd: true, }; } if (node instanceof EquationNode) { return { text: node.expressions.map((expr) => insertText(expr)).join(" equals "), bindingPower: 4, hasAmbiguousEnd: true, }; } if (node instanceof PositiveNode) { return { text: `positive ${insertText(node.child)}`, bindingPower: 1, hasAmbiguousEnd: true, }; } if (node instanceof NegativeNode) { return { text: `negative ${insertText(node.child)}`, bindingPower: 1, hasAmbiguousEnd: true, }; } if (node instanceof FunctionCallNode) { return { text: `${node.name} of ${node.args .map((node) => insertText(node)) .join(" ")}`, bindingPower: 5, hasAmbiguousEnd: true, }; } if (node instanceof FunctionDefinitionNode) { return { text: `${node.name} of ${node.args .map((node) => insertText(node)) .join(" ")} equals ${insertText(node.body)}`, bindingPower: 0, hasAmbiguousEnd: true, }; } if (node instanceof RadicalNode) { if (node.index === null) { return { text: `square root of ${insertText(node.radicand)}`, bindingPower: 4, hasAmbiguousEnd: true, }; } if (node.index instanceof DecimalNode) { if (node.index.value === "2") { return { text: `square root of ${insertText(node.radicand)}`, bindingPower: 4, hasAmbiguousEnd: true, }; } if (node.index.value === "3") { return { text: `cube root of ${insertText(node.radicand)}`, bindingPower: 4, hasAmbiguousEnd: true, }; } const words = numberWords.get(node.index.value); if (words) { return { text: `${words.singularOrdinal} root of ${insertText(node.radicand)}`, bindingPower: 4, hasAmbiguousEnd: true, }; } } return { text: `${insertText(node.index)} root of ${insertText(node.radicand)}`, bindingPower: 4, hasAmbiguousEnd: true, }; } if (node instanceof MissingNode) { return { text: "", bindingPower: 0, hasAmbiguousEnd: false }; } return { text: "[Unknown]", bindingPower: 0, hasAmbiguousEnd: false }; } function insertText( node: CaretNode, options = { allowTrailingComma: false }, ): string { const textWithMetadata = toTextWithMetadata(node); if (options.allowTrailingComma && textWithMetadata.hasAmbiguousEnd) { return `${textWithMetadata.text},`; } return textWithMetadata.text; } // prettier-ignore const numberWords = new Map([ ["1", { singularCardinal: "one", pluralCardinal: "ones", singularOrdinal: "first", pluralOrdinal: "firsts" }], ["2", { singularCardinal: "two", pluralCardinal: "twos", singularOrdinal: "second", pluralOrdinal: "seconds" }], ["3", { singularCardinal: "three", pluralCardinal: "threes", singularOrdinal: "third", pluralOrdinal: "thirds" }], ["4", { singularCardinal: "four", pluralCardinal: "fours", singularOrdinal: "fourth", pluralOrdinal: "fourths" }], ["5", { singularCardinal: "five", pluralCardinal: "fives", singularOrdinal: "fifth", pluralOrdinal: "fifths" }], ["6", { singularCardinal: "six", pluralCardinal: "sixes", singularOrdinal: "sixth", pluralOrdinal: "sixths" }], ["7", { singularCardinal: "seven", pluralCardinal: "sevens", singularOrdinal: "seventh", pluralOrdinal: "sevenths" }], ["8", { singularCardinal: "eight", pluralCardinal: "eights", singularOrdinal: "eighth", pluralOrdinal: "eighths" }], ["9", { singularCardinal: "nine", pluralCardinal: "nines", singularOrdinal: "ninth", pluralOrdinal: "ninths" }], ["10", { singularCardinal: "ten", pluralCardinal: "tens", singularOrdinal: "tenth", pluralOrdinal: "tenths" }], ["11", { singularCardinal: "eleven", pluralCardinal: "elevens", singularOrdinal: "eleventh", pluralOrdinal: "elevenths" }], ["12", { singularCardinal: "twelve", pluralCardinal: "twelves", singularOrdinal: "twelfth", pluralOrdinal: "twelfths" }], ["13", { singularCardinal: "thirteen", pluralCardinal: "thirteens", singularOrdinal: "thirteenth", pluralOrdinal: "thirteenths" }], ["14", { singularCardinal: "fourteen", pluralCardinal: "fourteens", singularOrdinal: "fourteenth", pluralOrdinal: "fourteenths" }], ["15", { singularCardinal: "fifteen", pluralCardinal: "fifteens", singularOrdinal: "fifteenth", pluralOrdinal: "fifteenths" }], ["16", { singularCardinal: "sixteen", pluralCardinal: "sixteens", singularOrdinal: "sixteenth", pluralOrdinal: "sixteenths" }], ["17", { singularCardinal: "seventeen", pluralCardinal: "seventeens", singularOrdinal: "seventeenth", pluralOrdinal: "seventeenths" }], ["18", { singularCardinal: "eighteen", pluralCardinal: "eighteens", singularOrdinal: "eighteenth", pluralOrdinal: "eighteenths" }], ["19", { singularCardinal: "nineteen", pluralCardinal: "nineteens", singularOrdinal: "nineteenth", pluralOrdinal: "nineteenths" }], ["20", { singularCardinal: "twenty", pluralCardinal: "twenties", singularOrdinal: "twentieth", pluralOrdinal: "twentieths" }], ["30", { singularCardinal: "thirty", pluralCardinal: "thirties", singularOrdinal: "thirtieth", pluralOrdinal: "thirtieths" }], ["40", { singularCardinal: "forty", pluralCardinal: "forties", singularOrdinal: "fortieth", pluralOrdinal: "fortieths" }], ["50", { singularCardinal: "fifty", pluralCardinal: "fifties", singularOrdinal: "fiftieth", pluralOrdinal: "fiftieths" }], ["60", { singularCardinal: "sixty", pluralCardinal: "sixties", singularOrdinal: "sixtieth", pluralOrdinal: "sixtieths" }], ["70", { singularCardinal: "seventy", pluralCardinal: "seventies", singularOrdinal: "seventieth", pluralOrdinal: "seventieths" }], ["80", { singularCardinal: "eighty", pluralCardinal: "eighties", singularOrdinal: "eightieth", pluralOrdinal: "eightieths" }], ["90", { singularCardinal: "ninety", pluralCardinal: "nineties", singularOrdinal: "ninetieth", pluralOrdinal: "ninetieths" }], ]);