//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
/** `MTMathListBuilder` is a class for parsing LaTeX into an `MTMathList` that
can be rendered and processed mathematically.
*/
struct MTEnvProperties {
var envName: String?
var ended: Bool
var numRows: Int
var alignment: MTColumnAlignment? // Optional alignment for starred matrix environments
init(name: String?, alignment: MTColumnAlignment? = nil) {
self.envName = name
self.numRows = 0
self.ended = false
self.alignment = alignment
}
}
/**
The error encountered when parsing a LaTeX string.
The `code` in the `NSError` is one of the following indicating why the LaTeX string
could not be parsed.
*/
enum MTParseErrors:Int {
/// The braces { } do not match.
case mismatchBraces = 1
/// A command in the string is not recognized.
case invalidCommand
/// An expected character such as ] was not found.
case characterNotFound
/// The \left or \right command was not followed by a delimiter.
case missingDelimiter
/// The delimiter following \left or \right was not a valid delimiter.
case invalidDelimiter
/// There is no \right corresponding to the \left command.
case missingRight
/// There is no \left corresponding to the \right command.
case missingLeft
/// The environment given to the \begin command is not recognized
case invalidEnv
/// A command is used which is only valid inside a \begin,\end environment
case missingEnv
/// There is no \begin corresponding to the \end command.
case missingBegin
/// There is no \end corresponding to the \begin command.
case missingEnd
/// The number of columns do not match the environment
case invalidNumColumns
/// Internal error, due to a programming mistake.
case internalError
/// Limit control applied incorrectly
case invalidLimits
}
let MTParseError = "ParseError"
/** `MTMathListBuilder` is a class for parsing LaTeX into an `MTMathList` that
can be rendered and processed mathematically.
*/
public struct MTMathListBuilder {
/// The math mode determines rendering style (inline vs display)
enum MathMode {
/// Display style - larger operators, limits above/below (e.g., $$...$$, \[...\])
case display
/// Inline/text style - compact operators, limits to the side (e.g., $...$, \(...\))
case inline
/// Convert MathMode to MTLineStyle for rendering
func toLineStyle() -> MTLineStyle {
switch self {
case .display:
return .display
case .inline:
return .text
}
}
}
var string: String
var currentCharIndex: String.Index
var currentInnerAtom: MTInner?
var currentEnv: MTEnvProperties?
var currentFontStyle:MTFontStyle
var spacesAllowed:Bool
var mathMode: MathMode = .display
/** Contains any error that occurred during parsing. */
var error:NSError?
// MARK: - Character-handling routines
var hasCharacters: Bool { currentCharIndex < string.endIndex }
// gets the next character and increments the index
mutating func getNextCharacter() -> Character {
assert(self.hasCharacters, "Retrieving character at index \(self.currentCharIndex) beyond length \(self.string.count)")
let ch = string[currentCharIndex]
currentCharIndex = string.index(after: currentCharIndex)
return ch
}
mutating func unlookCharacter() {
assert(currentCharIndex > string.startIndex, "Unlooking when at the first character.")
if currentCharIndex > string.startIndex {
currentCharIndex = string.index(before: currentCharIndex)
}
}
// Peek at next command without consuming it (for \not lookahead)
mutating func peekNextCommand() -> String {
let savedIndex = currentCharIndex
skipSpaces()
guard hasCharacters else {
currentCharIndex = savedIndex
return ""
}
let char = getNextCharacter()
let command: String
if char == "\\" {
command = readCommand()
} else {
command = ""
}
// Restore position
currentCharIndex = savedIndex
return command
}
// Consume the next command (after peeking)
mutating func consumeNextCommand() {
skipSpaces()
guard hasCharacters else { return }
let char = getNextCharacter()
if char == "\\" {
_ = readCommand()
}
}
mutating func expectCharacter(_ ch: Character) -> Bool {
MTAssertNotSpace(ch)
self.skipSpaces()
if self.hasCharacters {
let nextChar = self.getNextCharacter()
MTAssertNotSpace(nextChar)
if nextChar == ch {
return true
} else {
self.unlookCharacter()
return false
}
}
return false
}
public static let spaceToCommands: [CGFloat: String] = [
3 : ",",
4 : ">",
5 : ";",
(-3) : "!",
18 : "quad",
36 : "qquad",
]
public static let styleToCommands: [MTLineStyle: String] = [
.display: "displaystyle",
.text: "textstyle",
.script: "scriptstyle",
.scriptOfScript: "scriptscriptstyle"
]
// Comprehensive mapping of \not command combinations to Unicode negated symbols
public static let notCombinations: [String: String] = [
// Primary targets (user requested)
"equiv": "\u{2262}", // ≢ Not equivalent
"subset": "\u{2284}", // ⊄ Not subset
"in": "\u{2209}", // ∉ Not element of
// Additional standard negations
"sim": "\u{2241}", // ≁ Not similar
"approx": "\u{2249}", // ≉ Not approximately equal
"cong": "\u{2247}", // ≇ Not congruent
"parallel": "\u{2226}", // ∦ Not parallel
"subseteq": "\u{2288}", // ⊈ Not subset or equal
"supset": "\u{2285}", // ⊅ Not superset
"supseteq": "\u{2289}", // ⊉ Not superset or equal
"=": "\u{2260}", // ≠ Not equal (alternative to \neq)
]
/// Delimiter sizing commands with their size multipliers (relative to font size).
/// Values based on standard TeX: at 10pt, \big=8.5pt, \Big=11.5pt, \bigg=14.5pt, \Bigg=17.5pt
/// These translate to approximately 0.85x, 1.15x, 1.45x, 1.75x of font size.
/// We use slightly larger values to ensure visible size differences.
public static let delimiterSizeCommands: [String: CGFloat] = [
// Basic sizing commands
"big": 1.0,
"Big": 1.4,
"bigg": 1.8,
"Bigg": 2.2,
// Left variants (same sizes, just semantic distinction in LaTeX)
"bigl": 1.0,
"Bigl": 1.4,
"biggl": 1.8,
"Biggl": 2.2,
// Right variants
"bigr": 1.0,
"Bigr": 1.4,
"biggr": 1.8,
"Biggr": 2.2,
// Middle variants (used between delimiters)
"bigm": 1.0,
"Bigm": 1.4,
"biggm": 1.8,
"Biggm": 2.2,
]
init(string: String) {
self.error = nil
self.string = string
self.currentCharIndex = string.startIndex
self.currentFontStyle = .defaultStyle
self.spacesAllowed = false
}
// MARK: - Delimiter Detection
/// Detects and strips LaTeX math delimiters from the input string.
/// Returns the cleaned content and the detected math mode.
/// Supports: $...$ \(...\) $$...$$ \[...\] and environments
func detectAndStripDelimiters(from str: String) -> (String, MathMode) {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
// Check display delimiters first (more specific patterns)
// \[...\] - LaTeX display math
if trimmed.hasPrefix("\\[") && trimmed.hasSuffix("\\]") && trimmed.count > 4 {
let content = String(trimmed.dropFirst(2).dropLast(2))
return (content, .display)
}
// $$...$$ - TeX display math (check before single $)
if trimmed.hasPrefix("$$") && trimmed.hasSuffix("$$") && trimmed.count > 4 {
let content = String(trimmed.dropFirst(2).dropLast(2))
return (content, .display)
}
// Check inline delimiters
// \(...\) - LaTeX inline math
if trimmed.hasPrefix("\\(") && trimmed.hasSuffix("\\)") && trimmed.count > 4 {
let content = String(trimmed.dropFirst(2).dropLast(2))
return (content, .inline)
}
// $...$ - TeX inline math (must check after $$)
if trimmed.hasPrefix("$") && trimmed.hasSuffix("$") && trimmed.count > 2 && !trimmed.hasPrefix("$$") {
let content = String(trimmed.dropFirst(1).dropLast(1))
return (content, .inline)
}
// Check if it's an environment (\begin{...}\end{...})
// These are handled by existing logic and are display mode by default
if trimmed.hasPrefix("\\begin{") {
return (str, .display)
}
// No delimiters found - default to display mode (current behavior for backward compatibility)
return (str, .display)
}
// MARK: - MTMathList builder functions
/// Builds a mathlist from the internal `string`. Returns nil if there is an error.
public mutating func build() -> MTMathList? {
// Detect and strip delimiters, updating the string and mode
let (cleanedString, mode) = detectAndStripDelimiters(from: self.string)
self.string = cleanedString
self.currentCharIndex = cleanedString.startIndex
self.mathMode = mode
// If inline mode, we could optionally prepend a \textstyle command
// to force inline rendering of operators. For now, just track the mode.
let list = self.buildInternal(false)
if self.hasCharacters && error == nil {
self.setError(.mismatchBraces, message: "Mismatched braces: \(self.string)")
return nil
}
if error != nil {
return nil
}
// Note: For inline mode, we insert \textstyle to match LaTeX behavior.
// However, fractionStyle() has been modified to keep fractions at the
// same font size in both display and text modes (not one level smaller).
// Large operators show limits above/below in text style due to the updated
// condition in makeLargeOp() that checks both .display and .text styles.
if mode == .inline && list != nil && !list!.atoms.isEmpty {
// Prepend \textstyle to force inline rendering
let styleAtom = MTMathStyle(style: .text)
list!.atoms.insert(styleAtom, at: 0)
}
return list
}
/** Construct a math list from a given string. If there is parse error, returns
nil. To retrieve the error use the function `MTMathListBuilder.build(fromString:error:)`.
*/
public static func build(fromString string: String) -> MTMathList? {
var builder = MTMathListBuilder(string: string)
return builder.build()
}
/** Construct a math list from a given string. If there is an error while
constructing the string, this returns nil. The error is returned in the
`error` parameter.
*/
public static func build(fromString string: String, error:inout NSError?) -> MTMathList? {
var builder = MTMathListBuilder(string: string)
let output = builder.build()
if builder.error != nil {
error = builder.error
return nil
}
return output
}
/** Construct a math list from a given string and return the detected style.
This method detects LaTeX delimiters like \[...\], $$...$$, $...$, \(...\)
and returns the appropriate rendering style (.display or .text).
If there is a parse error, returns nil for the MathList.
- Parameter string: The LaTeX string to parse
- Returns: A tuple containing the parsed MathList and the detected MTLineStyle
*/
public static func buildWithStyle(fromString string: String) -> (mathList: MTMathList?, style: MTLineStyle) {
var builder = MTMathListBuilder(string: string)
let mathList = builder.build()
let style = builder.mathMode.toLineStyle()
return (mathList, style)
}
/** Construct a math list from a given string and return the detected style.
This method detects LaTeX delimiters like \[...\], $$...$$, $...$, \(...\)
and returns the appropriate rendering style (.display or .text).
If there is an error while constructing the string, this returns nil for the MathList.
The error is returned in the `error` parameter.
- Parameters:
- string: The LaTeX string to parse
- error: An inout parameter that will contain any parse error
- Returns: A tuple containing the parsed MathList and the detected MTLineStyle
*/
public static func buildWithStyle(fromString string: String, error: inout NSError?) -> (mathList: MTMathList?, style: MTLineStyle) {
var builder = MTMathListBuilder(string: string)
let output = builder.build()
let style = builder.mathMode.toLineStyle()
if builder.error != nil {
error = builder.error
return (nil, style)
}
return (output, style)
}
public mutating func buildInternal(_ oneCharOnly: Bool) -> MTMathList? {
self.buildInternal(oneCharOnly, stopChar: nil)
}
public mutating func buildInternal(_ oneCharOnly: Bool, stopChar stop: Character?) -> MTMathList? {
let list = MTMathList()
assert(!(oneCharOnly && stop != nil), "Cannot set both oneCharOnly and stopChar.")
var prevAtom: MTMathAtom? = nil
while self.hasCharacters {
if error != nil { return nil } // If there is an error thus far then bail out.
var atom: MTMathAtom? = nil
let char = self.getNextCharacter()
if oneCharOnly {
if char == "^" || char == "}" || char == "_" || char == "&" {
// this is not the character we are looking for.
// They are meant for the caller to look at.
self.unlookCharacter()
return list
}
}
// If there is a stop character, keep scanning 'til we find it
if stop != nil && char == stop! {
return list
}
if char == "^" {
assert(!oneCharOnly, "This should have been handled before")
if (prevAtom == nil || prevAtom!.superScript != nil || !prevAtom!.isScriptAllowed()) {
// If there is no previous atom, or if it already has a superscript
// or if scripts are not allowed for it, then add an empty node.
prevAtom = MTMathAtom(type: .ordinary, value: "")
list.add(prevAtom!)
}
// this is a superscript for the previous atom
// note: if the next char is the stopChar it will be consumed by the ^ and so it doesn't count as stop
prevAtom!.superScript = self.buildInternal(true)
continue
} else if char == "_" {
assert(!oneCharOnly, "This should have been handled before")
if (prevAtom == nil || prevAtom!.subScript != nil || !prevAtom!.isScriptAllowed()) {
// If there is no previous atom, or if it already has a subcript
// or if scripts are not allowed for it, then add an empty node.
prevAtom = MTMathAtom(type: .ordinary, value: "")
list.add(prevAtom!)
}
// this is a subscript for the previous atom
// note: if the next char is the stopChar it will be consumed by the _ and so it doesn't count as stop
prevAtom!.subScript = self.buildInternal(true)
continue
} else if char == "{" {
// this puts us in a recursive routine, and sets oneCharOnly to false and no stop character
if let subList = self.buildInternal(false, stopChar: "}") {
prevAtom = subList.atoms.last
list.append(subList)
if oneCharOnly {
return list
}
}
continue
} else if char == "}" {
// \ means a command
assert(!oneCharOnly, "This should have been handled before")
assert(stop == nil, "This should have been handled before")
// Special case: } terminates implicit table (envName == nil) created by \\
// This happens when \\ is used inside braces: \substack{a \\ b}
if self.currentEnv != nil && self.currentEnv!.envName == nil {
// Mark environment as ended, don't consume the }
self.currentEnv!.ended = true
return list
}
// We encountered a closing brace when there is no stop set, that means there was no
// corresponding opening brace.
self.setError(.mismatchBraces, message:"Mismatched braces.")
return nil
} else if char == "\\" {
let command = readCommand()
let done = stopCommand(command, list:list, stopChar:stop)
if done != nil {
return done
} else if error != nil {
return nil
}
if self.applyModifier(command, atom:prevAtom) {
continue
}
if let fontStyle = MTMathAtomFactory.fontStyleWithName(command) {
let oldSpacesAllowed = spacesAllowed
// Text has special consideration where it allows spaces without escaping.
spacesAllowed = command == "text"
let oldFontStyle = currentFontStyle
currentFontStyle = fontStyle
if let sublist = self.buildInternal(true) {
// Restore the font style.
currentFontStyle = oldFontStyle
spacesAllowed = oldSpacesAllowed
prevAtom = sublist.atoms.last
list.append(sublist)
if oneCharOnly {
return list
}
}
continue
}
atom = self.atomForCommand(command)
if atom == nil {
// this was an unknown command,
// we flag an error and return
// (note setError will not set the error if there is already one, so we flag internal error
// in the odd case that an _error is not set.
self.setError(.internalError, message:"Internal error")
return nil
}
} else if char == "&" {
// used for column separation in tables
assert(!oneCharOnly, "This should have been handled before")
if self.currentEnv != nil {
return list
} else {
// Create a new table with the current list and a default env
if let table = self.buildTable(env: nil, firstList: list, isRow: false) {
return MTMathList(atom: table)
} else {
return nil
}
}
} else if spacesAllowed && char == " " {
// If spaces are allowed then spaces do not need escaping with a \ before being used.
atom = MTMathAtomFactory.atom(forLatexSymbol: " ")
} else {
atom = MTMathAtomFactory.atom(forCharacter: char)
if atom == nil {
// Not a recognized character in standard math mode
// In text mode (spacesAllowed && roman style), accept any Unicode character for fallback font support
// This enables Chinese, Japanese, Korean, emoji, etc. in \text{} commands
if spacesAllowed && currentFontStyle == .roman {
atom = MTMathAtom(type: .ordinary, value: String(char))
} else {
// In math mode or non-text commands, skip unrecognized characters
continue
}
}
}
assert(atom != nil, "Atom shouldn't be nil")
atom?.fontStyle = currentFontStyle
// If this is an accent atom (e.g., from an accented character like "é"),
// propagate the font style to the inner list atoms that don't already have
// an explicit font style. This handles Unicode accented characters which are
// converted to accents by atom(fromAccentedCharacter:) without font style context.
// We only set font style on atoms with .defaultStyle to avoid overriding
// explicit font style commands like \textbf inside accents.
if let accent = atom as? MTAccent, let innerList = accent.innerList {
for innerAtom in innerList.atoms {
if innerAtom.fontStyle == .defaultStyle {
innerAtom.fontStyle = currentFontStyle
}
}
}
list.add(atom)
prevAtom = atom
if oneCharOnly {
return list
}
}
if stop != nil {
if stop == "}" {
// We did not find a corresponding closing brace.
self.setError(.mismatchBraces, message:"Missing closing brace")
} else {
// we never found our stop character
let errorMessage = "Expected character not found: \(stop!)"
self.setError(.characterNotFound, message:errorMessage)
}
}
return list
}
// MARK: - MTMathList to LaTeX conversion
/// This converts the MTMathList to LaTeX.
public static func mathListToString(_ ml: MTMathList?) -> String {
var str = ""
var currentfontStyle = MTFontStyle.defaultStyle
if let atomList = ml {
for atom in atomList.atoms {
if currentfontStyle != atom.fontStyle {
if currentfontStyle != .defaultStyle {
str += "}"
}
if atom.fontStyle != .defaultStyle {
let fontStyleName = MTMathAtomFactory.fontNameForStyle(atom.fontStyle)
str += "\\\(fontStyleName){"
}
currentfontStyle = atom.fontStyle
}
if atom.type == .fraction {
if let frac = atom as? MTFraction {
if frac.isContinuedFraction {
// Generate \cfrac with optional alignment
if frac.alignment != "c" {
str += "\\cfrac[\(frac.alignment)]{\(mathListToString(frac.numerator!))}{\(mathListToString(frac.denominator!))}"
} else {
str += "\\cfrac{\(mathListToString(frac.numerator!))}{\(mathListToString(frac.denominator!))}"
}
} else if frac.hasRule {
str += "\\frac{\(mathListToString(frac.numerator!))}{\(mathListToString(frac.denominator!))}"
} else {
let command: String
if frac.leftDelimiter.isEmpty && frac.rightDelimiter.isEmpty {
command = "atop"
} else if frac.leftDelimiter == "(" && frac.rightDelimiter == ")" {
command = "choose"
} else if frac.leftDelimiter == "{" && frac.rightDelimiter == "}" {
command = "brace"
} else if frac.leftDelimiter == "[" && frac.rightDelimiter == "]" {
command = "brack"
} else {
command = "atopwithdelims\(frac.leftDelimiter)\(frac.rightDelimiter)"
}
str += "{\(mathListToString(frac.numerator!)) \\\(command) \(mathListToString(frac.denominator!))}"
}
}
} else if atom.type == .radical {
str += "\\sqrt"
if let rad = atom as? MTRadical {
if rad.degree != nil {
str += "[\(mathListToString(rad.degree!))]"
}
str += "{\(mathListToString(rad.radicand!))}"
}
} else if atom.type == .inner {
if let inner = atom as? MTInner {
if inner.leftBoundary != nil || inner.rightBoundary != nil {
if inner.leftBoundary != nil {
str += "\\left\(delimToString(delim: inner.leftBoundary!)) "
} else {
str += "\\left. "
}
str += mathListToString(inner.innerList!)
if inner.rightBoundary != nil {
str += "\\right\(delimToString(delim: inner.rightBoundary!)) "
} else {
str += "\\right. "
}
} else {
str += "{\(mathListToString(inner.innerList!))}"
}
}
} else if atom.type == .table {
if let table = atom as? MTMathTable {
if !table.environment.isEmpty {
str += "\\begin{\(table.environment)}"
}
for i in 0..
= 1 && cell.atoms[0].type == .style {
// remove first atom
cell.atoms.removeFirst()
}
}
if table.environment == "eqalign" || table.environment == "aligned" || table.environment == "split" {
if j == 1 && cell.atoms.count >= 1 && cell.atoms[0].type == .ordinary && cell.atoms[0].nucleus.count == 0 {
// remove empty nucleus added for spacing
cell.atoms.removeFirst()
}
}
str += mathListToString(cell)
if j < row.count - 1 {
str += "&"
}
}
if i < table.numRows - 1 {
str += "\\\\ "
}
}
if !table.environment.isEmpty {
str += "\\end{\(table.environment)}"
}
}
} else if atom.type == .overline {
if let overline = atom as? MTOverLine {
str += "\\overline"
str += "{\(mathListToString(overline.innerList!))}"
}
} else if atom.type == .underline {
if let underline = atom as? MTUnderLine {
str += "\\underline"
str += "{\(mathListToString(underline.innerList!))}"
}
} else if atom.type == .accent {
if let accent = atom as? MTAccent {
str += "\\\(MTMathAtomFactory.accentName(accent)!){\(mathListToString(accent.innerList!))}"
}
} else if atom.type == .largeOperator {
let op = atom as! MTLargeOperator
let command = MTMathAtomFactory.latexSymbolName(for: atom)
let originalOp = MTMathAtomFactory.atom(forLatexSymbol: command!) as! MTLargeOperator
str += "\\\(command!) "
if originalOp.limits != op.limits {
if op.limits {
str += "\\limits "
} else {
str += "\\nolimits "
}
}
} else if atom.type == .space {
if let space = atom as? MTMathSpace {
if let command = Self.spaceToCommands[space.space] {
str += "\\\(command) "
} else {
str += String(format: "\\mkern%.1fmu", space.space)
}
}
} else if atom.type == .style {
if let style = atom as? MTMathStyle {
if let command = Self.styleToCommands[style.style] {
str += "\\\(command) "
}
}
} else if atom.nucleus.isEmpty {
str += "{}"
} else if atom.nucleus == "\u{2236}" {
// math colon
str += ":"
} else if atom.nucleus == "\u{2212}" {
// math minus
str += "-"
} else {
if let command = MTMathAtomFactory.latexSymbolName(for: atom) {
str += "\\\(command) "
} else {
str += "\(atom.nucleus)"
}
}
if atom.superScript != nil {
str += "^{\(mathListToString(atom.superScript!))}"
}
if atom.subScript != nil {
str += "_{\(mathListToString(atom.subScript!))}"
}
}
}
if currentfontStyle != .defaultStyle {
str += "}"
}
return str
}
public static func delimToString(delim: MTMathAtom) -> String {
if let command = MTMathAtomFactory.getDelimiterName(of: delim) {
let singleChars = [ "(", ")", "[", "]", "<", ">", "|", ".", "/"]
if singleChars.contains(command) {
return command
} else if command == "||" {
return "\\|"
} else {
return "\\\(command)"
}
}
return ""
}
mutating func atomForCommand(_ command:String) -> MTMathAtom? {
if let atom = MTMathAtomFactory.atom(forLatexSymbol: command) {
return atom
}
if let accent = MTMathAtomFactory.accent(withName: command) {
// The command is an accent
accent.innerList = self.buildInternal(true)
return accent;
} else if command == "frac" {
// A fraction command has 2 arguments
let frac = MTFraction()
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac;
} else if command == "cfrac" {
// A continued fraction command with optional alignment and 2 arguments
let frac = MTFraction()
frac.isContinuedFraction = true
// Parse optional alignment parameter [l], [r], [c]
skipSpaces()
if hasCharacters && string[currentCharIndex] == "[" {
_ = getNextCharacter() // consume '['
if hasCharacters {
let alignmentChar = getNextCharacter()
if alignmentChar == "l" || alignmentChar == "r" || alignmentChar == "c" {
frac.alignment = String(alignmentChar)
}
}
// Consume closing ']'
if hasCharacters && string[currentCharIndex] == "]" {
_ = getNextCharacter()
}
}
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac;
} else if command == "dfrac" {
// Display-style fraction command has 2 arguments
let frac = MTFraction()
let numerator = self.buildInternal(true)
let denominator = self.buildInternal(true)
// Prepend \displaystyle to force display mode rendering
let displayStyle = MTMathStyle(style: .display)
numerator?.insert(displayStyle, at: 0)
denominator?.insert(displayStyle, at: 0)
frac.numerator = numerator
frac.denominator = denominator
return frac;
} else if command == "tfrac" {
// Text-style fraction command has 2 arguments
let frac = MTFraction()
let numerator = self.buildInternal(true)
let denominator = self.buildInternal(true)
// Prepend \textstyle to force text mode rendering
let textStyle = MTMathStyle(style: .text)
numerator?.insert(textStyle, at: 0)
denominator?.insert(textStyle, at: 0)
frac.numerator = numerator
frac.denominator = denominator
return frac;
} else if command == "binom" {
// A binom command has 2 arguments
let frac = MTFraction(hasRule: false)
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
frac.leftDelimiter = "(";
frac.rightDelimiter = ")";
return frac;
} else if command == "bra" {
// Dirac bra notation: \bra{psi} -> ⟨psi|
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "langle")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: "|")
inner.innerList = self.buildInternal(true)
return inner
} else if command == "ket" {
// Dirac ket notation: \ket{psi} -> |psi⟩
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "|")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: "rangle")
inner.innerList = self.buildInternal(true)
return inner
} else if command == "braket" {
// Dirac braket notation: \braket{phi}{psi} -> ⟨phi|psi⟩
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "langle")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: "rangle")
// Build the inner content: phi | psi
let phi = self.buildInternal(true)
let psi = self.buildInternal(true)
let content = MTMathList()
if let phiList = phi {
for atom in phiList.atoms {
content.add(atom)
}
}
// Add the | separator
content.add(MTMathAtom(type: .ordinary, value: "|"))
if let psiList = psi {
for atom in psiList.atoms {
content.add(atom)
}
}
inner.innerList = content
return inner
} else if command == "operatorname" || command == "operatorname*" {
// \operatorname{name} creates a custom operator with proper spacing
// \operatorname*{name} creates an operator with limits above/below
let hasLimits = command.hasSuffix("*")
// Parse the operator name
let content = self.buildInternal(true)
// Convert the parsed content to a string
var operatorName = ""
if let atoms = content?.atoms {
for atom in atoms {
operatorName += atom.nucleus
}
}
if operatorName.isEmpty {
let errorMessage = "Missing operator name for \\operatorname"
self.setError(.invalidCommand, message: errorMessage)
return nil
}
return MTLargeOperator(value: operatorName, limits: hasLimits)
} else if command == "sqrt" {
// A sqrt command with one argument
let rad = MTRadical()
guard self.hasCharacters else {
rad.radicand = self.buildInternal(true)
return rad
}
let ch = self.getNextCharacter()
if (ch == "[") {
// special handling for sqrt[degree]{radicand}
rad.degree = self.buildInternal(false, stopChar:"]")
rad.radicand = self.buildInternal(true)
} else {
self.unlookCharacter()
rad.radicand = self.buildInternal(true)
}
return rad;
} else if command == "left" {
// Save the current inner while a new one gets built.
let oldInner = currentInnerAtom
currentInnerAtom = MTInner()
currentInnerAtom!.leftBoundary = self.getBoundaryAtom("left")
if currentInnerAtom!.leftBoundary == nil {
return nil;
}
currentInnerAtom!.innerList = self.buildInternal(false)
if currentInnerAtom!.rightBoundary == nil {
// A right node would have set the right boundary so we must be missing the right node.
let errorMessage = "Missing \\right"
self.setError(.missingRight, message:errorMessage)
return nil
}
// reinstate the old inner atom.
let newInner = currentInnerAtom;
currentInnerAtom = oldInner;
return newInner;
} else if command == "overline" {
// The overline command has 1 arguments
let over = MTOverLine()
over.innerList = self.buildInternal(true)
return over
} else if command == "underline" {
// The underline command has 1 arguments
let under = MTUnderLine()
under.innerList = self.buildInternal(true)
return under
} else if command == "substack" {
// \substack reads ONE braced argument containing rows separated by \\
// Similar to how \frac reads {numerator}{denominator}
// Read the braced content using standard pattern
let content = self.buildInternal(true)
if content == nil {
return nil
}
// The content may already be a table if \\ was encountered
// Check if we got a table from the \\ parsing
if content!.atoms.count == 1, let tableAtom = content!.atoms.first as? MTMathTable {
return tableAtom
}
// Otherwise, single row - wrap in table
var rows = [[MTMathList]]()
rows.append([content!])
var error: NSError? = self.error
let table = MTMathAtomFactory.table(withEnvironment: nil, rows: rows, error: &error)
if table == nil && self.error == nil {
self.error = error
return nil
}
return table
} else if command == "begin" {
let env = self.readEnvironment()
if env == nil {
return nil;
}
let table = self.buildTable(env: env, firstList:nil, isRow:false)
return table
} else if command == "color" {
// A color command has 2 arguments
let mathColor = MTMathColor()
let color = self.readColor()
if color == nil {
return nil;
}
mathColor.colorString = color!
mathColor.innerList = self.buildInternal(true)
return mathColor
} else if command == "textcolor" {
// A textcolor command has 2 arguments
let mathColor = MTMathTextColor()
let color = self.readColor()
if color == nil {
return nil;
}
mathColor.colorString = color!
mathColor.innerList = self.buildInternal(true)
return mathColor
} else if command == "colorbox" {
// A color command has 2 arguments
let mathColorbox = MTMathColorbox()
let color = self.readColor()
if color == nil {
return nil;
}
mathColorbox.colorString = color!
mathColorbox.innerList = self.buildInternal(true)
return mathColorbox
} else if command == "pmod" {
// A pmod command has 1 argument - creates (mod n)
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "(")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: ")")
let innerList = MTMathList()
// Add the "mod" operator (upright text)
let modOperator = MTMathAtomFactory.atom(forLatexSymbol: "mod")!
innerList.add(modOperator)
// Add medium space between "mod" and argument (6mu)
let space = MTMathSpace(space: 6.0)
innerList.add(space)
// Parse the argument from braces
let argument = self.buildInternal(true)
if let argList = argument {
innerList.append(argList)
}
inner.innerList = innerList
return inner
} else if command == "not" {
// Handle \not command with lookahead for comprehensive negation support
let nextCommand = self.peekNextCommand()
if let negatedUnicode = Self.notCombinations[nextCommand] {
self.consumeNextCommand() // Remove base symbol from stream
return MTMathAtom(type: .relation, value: negatedUnicode)
} else {
let errorMessage = "Unsupported \\not\\\(nextCommand) combination"
self.setError(.invalidCommand, message: errorMessage)
return nil
}
} else if let sizeMultiplier = Self.delimiterSizeCommands[command] {
// Handle \big, \Big, \bigg, \Bigg and their variants
let delim = self.readDelimiter()
if delim == nil {
let errorMessage = "Missing delimiter for \\\(command)"
self.setError(.missingDelimiter, message: errorMessage)
return nil
}
let boundary = MTMathAtomFactory.boundary(forDelimiter: delim!)
if boundary == nil {
let errorMessage = "Invalid delimiter for \\\(command): \(delim!)"
self.setError(.invalidDelimiter, message: errorMessage)
return nil
}
// Create MTInner with explicit delimiter height
let inner = MTInner()
// Determine if this is a left, right, or middle delimiter based on command suffix
let isLeft = command.hasSuffix("l")
let isRight = command.hasSuffix("r")
// let isMiddle = command.hasSuffix("m") // For future use
if isLeft {
inner.leftBoundary = boundary
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: ".")
} else if isRight {
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: ".")
inner.rightBoundary = boundary
} else {
// For \big, \Big, \bigg, \Bigg and \bigm variants, use the delimiter on both sides
// but with empty inner content - it's just a sized delimiter
inner.leftBoundary = boundary
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: ".")
}
inner.innerList = MTMathList()
inner.delimiterHeight = sizeMultiplier // Store multiplier, typesetter will compute actual height
return inner
} else {
let errorMessage = "Invalid command \\\(command)"
self.setError(.invalidCommand, message:errorMessage)
return nil;
}
}
mutating func readColor() -> String? {
if !self.expectCharacter("{") {
// We didn't find an opening brace, so no env found.
self.setError(.characterNotFound, message:"Missing {")
return nil;
}
// Ignore spaces and nonascii.
self.skipSpaces()
// a string of all upper and lower case characters.
var mutable = ""
while self.hasCharacters {
let ch = self.getNextCharacter()
if ch == "#" || (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") {
mutable.append(ch) // appendString:[NSString stringWithCharacters:&ch length:1]];
} else {
// we went too far
self.unlookCharacter()
break;
}
}
if !self.expectCharacter("}") {
// We didn't find an closing brace, so invalid format.
self.setError(.characterNotFound, message:"Missing }")
return nil;
}
return mutable;
}
mutating func skipSpaces() {
while self.hasCharacters {
let ch = self.getNextCharacter().utf32Char
if ch < 0x21 || ch > 0x7E {
// skip non ascii characters and spaces
continue;
} else {
self.unlookCharacter()
return;
}
}
}
static var fractionCommands: [String:[Character]] {
[
"over": [],
"atop" : [],
"choose" : [ "(", ")"],
"brack" : [ "[", "]"],
"brace" : [ "{", "}"]
]
}
mutating func stopCommand(_ command: String, list:MTMathList, stopChar:Character?) -> MTMathList? {
if command == "right" {
if currentInnerAtom == nil {
let errorMessage = "Missing \\left";
self.setError(.missingLeft, message:errorMessage)
return nil;
}
currentInnerAtom!.rightBoundary = self.getBoundaryAtom("right")
if currentInnerAtom!.rightBoundary == nil {
return nil;
}
// return the list read so far.
return list
} else if let delims = Self.fractionCommands[command] {
var frac:MTFraction! = nil;
if command == "over" {
frac = MTFraction()
} else {
frac = MTFraction(hasRule: false)
}
if delims.count == 2 {
frac.leftDelimiter = String(delims[0])
frac.rightDelimiter = String(delims[1])
}
frac.numerator = list;
frac.denominator = self.buildInternal(false, stopChar: stopChar)
if error != nil {
return nil;
}
let fracList = MTMathList()
fracList.add(frac)
return fracList
} else if command == "\\" || command == "cr" {
if currentEnv != nil {
// Stop the current list and increment the row count
currentEnv!.numRows+=1
return list
} else {
// Create a new table with the current list and a default env
if let table = self.buildTable(env: nil, firstList:list, isRow:true) {
return MTMathList(atom: table)
}
}
} else if command == "end" {
if currentEnv == nil {
let errorMessage = "Missing \\begin";
self.setError(.missingBegin, message:errorMessage)
return nil
}
let env = self.readEnvironment()
if env == nil {
return nil
}
if env! != currentEnv!.envName {
let errorMessage = "Begin environment name \(currentEnv!.envName ?? "(none)") does not match end name: \(env!)"
self.setError(.invalidEnv, message:errorMessage)
return nil
}
// Finish the current environment.
currentEnv!.ended = true
return list
}
return nil
}
// Applies the modifier to the atom. Returns true if modifier applied.
mutating func applyModifier(_ modifier:String, atom:MTMathAtom?) -> Bool {
if modifier == "limits" {
if atom?.type != .largeOperator {
let errorMessage = "Limits can only be applied to an operator."
self.setError(.invalidLimits, message:errorMessage)
} else {
let op = atom as! MTLargeOperator
op.limits = true
}
return true
} else if modifier == "nolimits" {
if atom?.type != .largeOperator {
let errorMessage = "No limits can only be applied to an operator."
self.setError(.invalidLimits, message:errorMessage)
} else {
let op = atom as! MTLargeOperator
op.limits = false
}
return true
}
return false
}
mutating func setError(_ code:MTParseErrors, message:String) {
// Only record the first error.
if error == nil {
error = NSError(domain: MTParseError, code: code.rawValue, userInfo: [ NSLocalizedDescriptionKey : message ])
}
}
mutating func atom(forCommand command: String) -> MTMathAtom? {
if let atom = MTMathAtomFactory.atom(forLatexSymbol: command) {
return atom
}
if let accent = MTMathAtomFactory.accent(withName: command) {
accent.innerList = self.buildInternal(true)
return accent
} else if command == "frac" {
let frac = MTFraction()
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac
} else if command == "cfrac" {
let frac = MTFraction()
frac.isContinuedFraction = true
// Parse optional alignment parameter [l], [r], [c]
skipSpaces()
if hasCharacters && string[currentCharIndex] == "[" {
_ = getNextCharacter() // consume '['
if hasCharacters {
let alignmentChar = getNextCharacter()
if alignmentChar == "l" || alignmentChar == "r" || alignmentChar == "c" {
frac.alignment = String(alignmentChar)
}
}
// Consume closing ']'
if hasCharacters && string[currentCharIndex] == "]" {
_ = getNextCharacter()
}
}
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac
} else if command == "dfrac" {
// Display-style fraction command has 2 arguments
let frac = MTFraction()
let numerator = self.buildInternal(true)
let denominator = self.buildInternal(true)
// Prepend \displaystyle to force display mode rendering
let displayStyle = MTMathStyle(style: .display)
numerator?.insert(displayStyle, at: 0)
denominator?.insert(displayStyle, at: 0)
frac.numerator = numerator
frac.denominator = denominator
return frac
} else if command == "tfrac" {
// Text-style fraction command has 2 arguments
let frac = MTFraction()
let numerator = self.buildInternal(true)
let denominator = self.buildInternal(true)
// Prepend \textstyle to force text mode rendering
let textStyle = MTMathStyle(style: .text)
numerator?.insert(textStyle, at: 0)
denominator?.insert(textStyle, at: 0)
frac.numerator = numerator
frac.denominator = denominator
return frac
} else if command == "binom" {
let frac = MTFraction(hasRule: false)
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
frac.leftDelimiter = "("
frac.rightDelimiter = ")"
return frac
} else if command == "bra" {
// Dirac bra notation: \bra{psi} -> ⟨psi|
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "langle")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: "|")
inner.innerList = self.buildInternal(true)
return inner
} else if command == "ket" {
// Dirac ket notation: \ket{psi} -> |psi⟩
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "|")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: "rangle")
inner.innerList = self.buildInternal(true)
return inner
} else if command == "braket" {
// Dirac braket notation: \braket{phi}{psi} -> ⟨phi|psi⟩
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "langle")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: "rangle")
let phi = self.buildInternal(true)
let psi = self.buildInternal(true)
let content = MTMathList()
if let phiList = phi {
for atom in phiList.atoms {
content.add(atom)
}
}
content.add(MTMathAtom(type: .ordinary, value: "|"))
if let psiList = psi {
for atom in psiList.atoms {
content.add(atom)
}
}
inner.innerList = content
return inner
} else if command == "operatorname" || command == "operatorname*" {
// \operatorname{name} creates a custom operator with proper spacing
// \operatorname*{name} creates an operator with limits above/below
let hasLimits = command.hasSuffix("*")
let content = self.buildInternal(true)
var operatorName = ""
if let atoms = content?.atoms {
for atom in atoms {
operatorName += atom.nucleus
}
}
if operatorName.isEmpty {
self.setError(.invalidCommand, message: "Missing operator name for \\operatorname")
return nil
}
return MTLargeOperator(value: operatorName, limits: hasLimits)
} else if command == "sqrt" {
let rad = MTRadical()
guard self.hasCharacters else {
rad.radicand = self.buildInternal(true)
return rad
}
let char = self.getNextCharacter()
if char == "[" {
rad.degree = self.buildInternal(false, stopChar: "]")
rad.radicand = self.buildInternal(true)
} else {
self.unlookCharacter()
rad.radicand = self.buildInternal(true)
}
return rad
} else if command == "left" {
let oldInner = self.currentInnerAtom
self.currentInnerAtom = MTInner()
self.currentInnerAtom?.leftBoundary = self.getBoundaryAtom("left")
if self.currentInnerAtom?.leftBoundary == nil {
return nil
}
self.currentInnerAtom!.innerList = self.buildInternal(false)
if self.currentInnerAtom?.rightBoundary == nil {
self.setError(.missingRight, message: "Missing \\right")
return nil
}
let newInner = self.currentInnerAtom
currentInnerAtom = oldInner
return newInner
} else if command == "overline" {
let over = MTOverLine()
over.innerList = self.buildInternal(true)
return over
} else if command == "underline" {
let under = MTUnderLine()
under.innerList = self.buildInternal(true)
return under
} else if command == "begin" {
if let env = self.readEnvironment() {
// Check if this is a starred matrix environment and read optional alignment
var alignment: MTColumnAlignment? = nil
if env.hasSuffix("*") {
alignment = self.readOptionalAlignment()
if self.error != nil {
return nil
}
}
let table = self.buildTable(env: env, alignment: alignment, firstList: nil, isRow: false)
return table
} else {
return nil
}
} else if command == "color" {
// A color command has 2 arguments
let mathColor = MTMathColor()
mathColor.colorString = self.readColor()!
mathColor.innerList = self.buildInternal(true)
return mathColor
} else if command == "colorbox" {
// A color command has 2 arguments
let mathColorbox = MTMathColorbox()
mathColorbox.colorString = self.readColor()!
mathColorbox.innerList = self.buildInternal(true)
return mathColorbox
} else {
self.setError(.invalidCommand, message: "Invalid command \\\(command)")
return nil
}
}
mutating func readEnvironment() -> String? {
if !self.expectCharacter("{") {
// We didn't find an opening brace, so no env found.
self.setError(.characterNotFound, message: "Missing {")
return nil
}
self.skipSpaces()
let env = self.readString()
if !self.expectCharacter("}") {
// We didn"t find an closing brace, so invalid format.
self.setError(.characterNotFound, message: "Missing }")
return nil;
}
return env
}
/// Reads optional alignment parameter for starred matrix environments: [r], [l], or [c]
mutating func readOptionalAlignment() -> MTColumnAlignment? {
self.skipSpaces()
// Check if there's an opening bracket
guard hasCharacters && string[currentCharIndex] == "[" else {
return nil
}
_ = getNextCharacter() // consume '['
self.skipSpaces()
guard hasCharacters else {
self.setError(.characterNotFound, message: "Missing alignment specifier after [")
return nil
}
let alignChar = getNextCharacter()
let alignment: MTColumnAlignment?
switch alignChar {
case "l":
alignment = .left
case "c":
alignment = .center
case "r":
alignment = .right
default:
self.setError(.invalidEnv, message: "Invalid alignment specifier: \(alignChar). Must be l, c, or r")
return nil
}
self.skipSpaces()
if !self.expectCharacter("]") {
self.setError(.characterNotFound, message: "Missing ] after alignment specifier")
return nil
}
return alignment
}
func MTAssertNotSpace(_ ch: Character) {
assert(ch >= "\u{21}" && ch <= "\u{7E}", "Expected non-space character \(ch)")
}
mutating func buildTable(env: String?, alignment: MTColumnAlignment? = nil, firstList: MTMathList?, isRow: Bool) -> MTMathAtom? {
// Save the current env till an new one gets built.
let oldEnv = self.currentEnv
currentEnv = MTEnvProperties(name: env, alignment: alignment)
var currentRow = 0
var currentCol = 0
var rows = [[MTMathList]]()
rows.append([MTMathList]())
if firstList != nil {
rows[currentRow].append(firstList!)
if isRow {
currentEnv!.numRows+=1
currentRow+=1
rows.append([MTMathList]())
} else {
currentCol+=1
}
}
while !currentEnv!.ended && self.hasCharacters {
let list = self.buildInternal(false)
if list == nil {
// If there is an error building the list, bail out early.
return nil
}
rows[currentRow].append(list!)
currentCol+=1
if currentEnv!.numRows > currentRow {
currentRow = currentEnv!.numRows
rows.append([MTMathList]())
currentCol = 0
}
}
if !currentEnv!.ended && currentEnv!.envName != nil {
self.setError(.missingEnd, message: "Missing \\end")
return nil
}
var error:NSError? = self.error
let table = MTMathAtomFactory.table(withEnvironment: currentEnv?.envName, alignment: currentEnv?.alignment, rows: rows, error: &error)
if table == nil && self.error == nil {
self.error = error
return nil
}
self.currentEnv = oldEnv
return table
}
mutating func getBoundaryAtom(_ delimiterType: String) -> MTMathAtom? {
let delim = self.readDelimiter()
if delim == nil {
let errorMessage = "Missing delimiter for \\\(delimiterType)"
self.setError(.missingDelimiter, message:errorMessage)
return nil
}
let boundary = MTMathAtomFactory.boundary(forDelimiter: delim!)
if boundary == nil {
let errorMessage = "Invalid delimiter for \(delimiterType): \(delim!)"
self.setError(.invalidDelimiter, message:errorMessage)
return nil
}
return boundary
}
mutating func readDelimiter() -> String? {
self.skipSpaces()
while self.hasCharacters {
let char = self.getNextCharacter()
MTAssertNotSpace(char)
if char == "\\" {
let command = self.readCommand()
if command == "|" {
return "||"
}
return command
} else {
return String(char)
}
}
return nil
}
mutating func readCommand() -> String {
let singleChars = "{}$#%_| ,>;!\\"
if self.hasCharacters {
let char = self.getNextCharacter()
if let _ = singleChars.firstIndex(of: char) {
return String(char)
} else {
self.unlookCharacter()
}
}
return self.readString()
}
mutating func readString() -> String {
// a string of all upper and lower case characters (and asterisks for starred environments)
var output = ""
while self.hasCharacters {
let char = self.getNextCharacter()
if char.isLowercase || char.isUppercase || char == "*" {
output.append(char)
} else {
self.unlookCharacter()
break
}
}
return output
}
}