diff --git a/Sources/SwiftUIMath/Internal/Model/AtomFactory.swift b/Sources/SwiftUIMath/Internal/Model/AtomFactory.swift
index 10b5d0a..c9bf9c0 100644
--- a/Sources/SwiftUIMath/Internal/Model/AtomFactory.swift
+++ b/Sources/SwiftUIMath/Internal/Model/AtomFactory.swift
@@ -458,13 +458,6 @@ extension Math {
"Vmatrix*": ["Vert", "Vert"],
]
- private enum ParseErrorCode: Int {
- case invalidEnv = 8
- case invalidNumColumns = 12
- }
-
- private static let parseErrorDomain = "ParseError"
-
static func fontStyle(named fontName: String) -> Atom.FontStyle? {
fontStyles[fontName]
}
@@ -659,7 +652,7 @@ extension Math {
withEnvironment env: String?,
alignment: Table.ColumnAlignment? = nil,
rows: [[AtomList]],
- error: inout NSError?
+ error: inout ParserError?
) -> Atom? {
let table = Table(environment: env ?? "")
@@ -713,11 +706,7 @@ extension Math {
if table.numberOfColumns != 2 {
let message = "\(env) environment can only have 2 columns"
if error == nil {
- error = NSError(
- domain: parseErrorDomain,
- code: ParseErrorCode.invalidNumColumns.rawValue,
- userInfo: [NSLocalizedDescriptionKey: message]
- )
+ error = ParserError(code: .invalidNumberOfColumns, message: message)
}
return nil
}
@@ -741,11 +730,7 @@ extension Math {
if table.numberOfColumns != 1 {
let message = "\(env) environment can only have 1 column"
if error == nil {
- error = NSError(
- domain: parseErrorDomain,
- code: ParseErrorCode.invalidNumColumns.rawValue,
- userInfo: [NSLocalizedDescriptionKey: message]
- )
+ error = ParserError(code: .invalidNumberOfColumns, message: message)
}
return nil
}
@@ -760,11 +745,7 @@ extension Math {
if table.numberOfColumns != 3 {
let message = "\(env) environment can only have 3 columns"
if error == nil {
- error = NSError(
- domain: parseErrorDomain,
- code: ParseErrorCode.invalidNumColumns.rawValue,
- userInfo: [NSLocalizedDescriptionKey: message]
- )
+ error = ParserError(code: .invalidNumberOfColumns, message: message)
}
return nil
}
@@ -781,11 +762,7 @@ extension Math {
if table.numberOfColumns != 1 && table.numberOfColumns != 2 {
let message = "cases environment can have 1 or 2 columns"
if error == nil {
- error = NSError(
- domain: parseErrorDomain,
- code: ParseErrorCode.invalidNumColumns.rawValue,
- userInfo: [NSLocalizedDescriptionKey: message]
- )
+ error = ParserError(code: .invalidNumberOfColumns, message: message)
}
return nil
}
@@ -815,11 +792,7 @@ extension Math {
return inner
} else {
let message = "Unknown environment \(env)"
- error = NSError(
- domain: parseErrorDomain,
- code: ParseErrorCode.invalidEnv.rawValue,
- userInfo: [NSLocalizedDescriptionKey: message]
- )
+ error = ParserError(code: .invalidEnvironment, message: message)
return nil
}
}
diff --git a/Sources/SwiftUIMath/Internal/Parsing/Parser.swift b/Sources/SwiftUIMath/Internal/Parsing/Parser.swift
new file mode 100644
index 0000000..b9d1364
--- /dev/null
+++ b/Sources/SwiftUIMath/Internal/Parsing/Parser.swift
@@ -0,0 +1,1347 @@
+import Foundation
+
+extension Math {
+ struct ParserError: Error {
+ enum Code: Int {
+ case mismatchedBraces = 1
+ case invalidCommand
+ case characterNotFound
+ case missingDelimiter
+ case invalidDelimiter
+ case missingRight
+ case missingLeft
+ case invalidEnvironment
+ case missingEnvironment
+ case missingBegin
+ case missingEnd
+ case invalidNumberOfColumns
+ case internalError
+ case invalidLimits
+ }
+
+ let code: Code
+ let message: String
+ }
+}
+
+extension Math {
+ struct Parser {
+ struct Environment {
+ var name: String?
+ var ended: Bool
+ var numberOfRows: Int
+ var alignment: Table.ColumnAlignment? // Optional alignment for starred matrix environments
+
+ init(name: String?, alignment: Table.ColumnAlignment? = nil) {
+ self.name = name
+ self.numberOfRows = 0
+ self.ended = false
+ self.alignment = alignment
+ }
+ }
+
+ enum Mode {
+ case display
+ case inline
+ }
+
+ var string: String
+ var currentCharIndex: String.Index
+ var currentInnerAtom: Inner?
+ var currentEnvironment: Environment?
+ var currentFontStyle: Atom.FontStyle
+ var spacesAllowed: Bool
+ var mode: Mode = .display
+
+ var error: ParserError?
+
+ // MARK: - Character-handling routines
+
+ var hasCharacters: Bool { currentCharIndex < string.endIndex }
+
+ // gets the next character and increments the index
+ mutating func nextCharacter() -> 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 = nextCharacter()
+ 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 = nextCharacter()
+ if char == "\\" {
+ _ = readCommand()
+ }
+ }
+
+ mutating func expectCharacter(_ ch: Character) -> Bool {
+ assertNotSpace(ch)
+ self.skipSpaces()
+
+ if self.hasCharacters {
+ let nextChar = self.nextCharacter()
+ assertNotSpace(nextChar)
+ if nextChar == ch {
+ return true
+ } else {
+ self.unlookCharacter()
+ return false
+ }
+ }
+ return false
+ }
+
+ static let spaceToCommands: [CGFloat: String] = [
+ 3: ",",
+ 4: ">",
+ 5: ";",
+ (-3): "!",
+ 18: "quad",
+ 36: "qquad",
+ ]
+
+ static let styleToCommands: [Style.Level: String] = [
+ .display: "displaystyle",
+ .text: "textstyle",
+ .script: "scriptstyle",
+ .scriptOfScript: "scriptscriptstyle",
+ ]
+
+ // Comprehensive mapping of \not command combinations to Unicode negated symbols
+ 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)
+ ]
+
+ init(string: String) {
+ self.error = nil
+ self.string = string
+ self.currentCharIndex = string.startIndex
+ self.currentFontStyle = .default
+ self.spacesAllowed = false
+ }
+
+ // MARK: - Delimiter Detection
+
+ func detectAndStripDelimiters(from str: String) -> (String, Mode) {
+ 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: - AtomList builder functions
+
+ mutating func build() -> AtomList? {
+ // Detect and strip delimiters, updating the string and mode
+ let (cleanedString, mode) = detectAndStripDelimiters(from: self.string)
+ self.string = cleanedString
+ self.currentCharIndex = cleanedString.startIndex
+ self.mode = 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(.mismatchedBraces, 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 = Style(level: .text)
+ list!.atoms.insert(styleAtom, at: 0)
+ }
+
+ return list
+ }
+
+ static func build(fromString string: String) -> AtomList? {
+ var builder = Parser(string: string)
+ return builder.build()
+ }
+
+ static func build(fromString string: String, error: inout ParserError?) -> AtomList? {
+ var builder = Parser(string: string)
+ let output = builder.build()
+ if builder.error != nil {
+ error = builder.error
+ return nil
+ }
+ return output
+ }
+
+ mutating func buildInternal(_ oneCharOnly: Bool) -> AtomList? {
+ self.buildInternal(oneCharOnly, stopChar: nil)
+ }
+
+ mutating func buildInternal(_ oneCharOnly: Bool, stopChar stop: Character?) -> AtomList? {
+ let list = AtomList()
+ assert(!(oneCharOnly && stop != nil), "Cannot set both oneCharOnly and stopChar.")
+ var prevAtom: Atom? = nil
+ while self.hasCharacters {
+ if error != nil { return nil } // If there is an error thus far then bail out.
+
+ var atom: Atom? = nil
+ let char = self.nextCharacter()
+
+ 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!.allowsScripts {
+ // 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 = Atom(type: .ordinary, nucleus: "")
+ list.append(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!.allowsScripts {
+ // 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 = Atom(type: .ordinary, nucleus: "")
+ list.append(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(contentsOf: 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 (name == nil) created by \\
+ // This happens when \\ is used inside braces: \substack{a \\ b}
+ if self.currentEnvironment != nil && self.currentEnvironment!.name == nil {
+ // Mark environment as ended, don't consume the }
+ self.currentEnvironment!.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(.mismatchedBraces, 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 = AtomFactory.fontStyle(named: 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(contentsOf: 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.currentEnvironment != nil {
+ return list
+ } else {
+ // Create a new table with the current list and a default env
+ if let table = self.buildTable(environment: nil, firstList: list, isRow: false) {
+ return AtomList(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 = AtomFactory.atom(forLatexSymbol: " ")
+ } else {
+ atom = AtomFactory.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 = Atom(type: .ordinary, nucleus: String(char))
+ } else {
+ // In math mode or non-text commands, skip unrecognized characters
+ continue
+ }
+ }
+ }
+
+ guard let atom else {
+ assertionFailure("Atom shouldn't be nil")
+ continue
+ }
+ atom.fontStyle = currentFontStyle
+ list.append(atom)
+ prevAtom = atom
+
+ if oneCharOnly {
+ return list
+ }
+ }
+ if stop != nil {
+ if stop == "}" {
+ // We did not find a corresponding closing brace.
+ self.setError(.mismatchedBraces, 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: - AtomList to LaTeX conversion
+
+ static func atomListToString(_ atomList: AtomList?) -> String {
+ var str = ""
+ var currentFontStyle = Atom.FontStyle.default
+ if let atomList {
+ for atom in atomList.atoms {
+ if currentFontStyle != atom.fontStyle {
+ if currentFontStyle != .default {
+ str += "}"
+ }
+ if atom.fontStyle != .default {
+ let fontStyleName = AtomFactory.fontName(for: atom.fontStyle)
+ str += "\\\(fontStyleName){"
+ }
+ currentFontStyle = atom.fontStyle
+ }
+ if atom.type == .fraction {
+ if let frac = atom as? Fraction {
+ if frac.isContinuedFraction {
+ // Generate \cfrac with optional alignment
+ if frac.alignment != "c" {
+ str +=
+ "\\cfrac[\(frac.alignment)]{\(atomListToString(frac.numerator!))}{\(atomListToString(frac.denominator!))}"
+ } else {
+ str +=
+ "\\cfrac{\(atomListToString(frac.numerator!))}{\(atomListToString(frac.denominator!))}"
+ }
+ } else if frac.hasRule {
+ str +=
+ "\\frac{\(atomListToString(frac.numerator!))}{\(atomListToString(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 +=
+ "{\(atomListToString(frac.numerator!)) \\\(command) \(atomListToString(frac.denominator!))}"
+ }
+ }
+ } else if atom.type == .radical {
+ str += "\\sqrt"
+ if let rad = atom as? Radical {
+ if rad.degree != nil {
+ str += "[\(atomListToString(rad.degree!))]"
+ }
+ str += "{\(atomListToString(rad.radicand!))}"
+ }
+ } else if atom.type == .inner {
+ if let inner = atom as? Inner {
+ if inner.leftBoundary != nil || inner.rightBoundary != nil {
+ if inner.leftBoundary != nil {
+ str += "\\left\(delimiterToString(inner.leftBoundary!)) "
+ } else {
+ str += "\\left. "
+ }
+
+ str += atomListToString(inner.innerList!)
+
+ if inner.rightBoundary != nil {
+ str += "\\right\(delimiterToString(inner.rightBoundary!)) "
+ } else {
+ str += "\\right. "
+ }
+ } else {
+ str += "{\(atomListToString(inner.innerList!))}"
+ }
+ }
+ } else if atom.type == .table {
+ if let table = atom as? Table {
+ if !table.environment.isEmpty {
+ str += "\\begin{\(table.environment)}"
+ }
+
+ for i in 0..
= 1 && cell.atoms[0].type == Math.AtomType.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 == Math.AtomType.ordinary
+ && cell.atoms[0].nucleus.count == 0
+ {
+ // remove empty nucleus added for spacing
+ cell.atoms.removeFirst()
+ }
+ }
+ str += atomListToString(cell)
+ if j < row.count - 1 {
+ str += "&"
+ }
+ }
+ if i < table.numberOfRows - 1 {
+ str += "\\\\ "
+ }
+ }
+ if !table.environment.isEmpty {
+ str += "\\end{\(table.environment)}"
+ }
+ }
+ } else if atom.type == .overline {
+ if let overline = atom as? Overline {
+ str += "\\overline"
+ str += "{\(atomListToString(overline.innerList!))}"
+ }
+ } else if atom.type == .underline {
+ if let underline = atom as? Underline {
+ str += "\\underline"
+ str += "{\(atomListToString(underline.innerList!))}"
+ }
+ } else if atom.type == .accent {
+ if let accent = atom as? Accent {
+ str += "\\\(AtomFactory.accentName(accent)!){\(atomListToString(accent.innerList!))}"
+ }
+ } else if atom.type == .largeOperator {
+ let op = atom as! LargeOperator
+ let command = AtomFactory.latexSymbolName(for: atom)
+ let originalOp = AtomFactory.atom(forLatexSymbol: command!) as! LargeOperator
+ 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? Space {
+ if let command = Self.spaceToCommands[space.amount] {
+ str += "\\\(command) "
+ } else {
+ str += String(format: "\\mkern%.1fmu", space.amount)
+ }
+ }
+ } else if atom.type == .style {
+ if let style = atom as? Style {
+ if let command = Self.styleToCommands[style.level] {
+ 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 = AtomFactory.latexSymbolName(for: atom) {
+ str += "\\\(command) "
+ } else {
+ str += "\(atom.nucleus)"
+ }
+ }
+
+ if atom.superscript != nil {
+ str += "^{\(atomListToString(atom.superscript!))}"
+ }
+
+ if atom.`subscript` != nil {
+ str += "_{\(atomListToString(atom.`subscript`!))}"
+ }
+ }
+ }
+ if currentFontStyle != .default {
+ str += "}"
+ }
+ return str
+ }
+
+ static func delimiterToString(_ delimiter: Atom) -> String {
+ if let command = AtomFactory.delimiterName(of: delimiter) {
+ let singleChars = ["(", ")", "[", "]", "<", ">", "|", ".", "/"]
+ if singleChars.contains(command) {
+ return command
+ } else if command == "||" {
+ return "\\|"
+ } else {
+ return "\\\(command)"
+ }
+ }
+ return ""
+ }
+
+ mutating func atomForCommand(_ command: String) -> Atom? {
+ if let atom = AtomFactory.atom(forLatexSymbol: command) {
+ return atom
+ }
+ if let accent = AtomFactory.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 = Fraction()
+ 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 = Fraction()
+ frac.isContinuedFraction = true
+
+ // Parse optional alignment parameter [l], [r], [c]
+ skipSpaces()
+ if hasCharacters && string[currentCharIndex] == "[" {
+ _ = nextCharacter() // consume '['
+ let alignmentChar = nextCharacter()
+ if alignmentChar == "l" || alignmentChar == "r" || alignmentChar == "c" {
+ frac.alignment = String(alignmentChar)
+ }
+ // Consume closing ']'
+ if hasCharacters && string[currentCharIndex] == "]" {
+ _ = nextCharacter()
+ }
+ }
+
+ 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 = Fraction()
+ let numerator = self.buildInternal(true)
+ let denominator = self.buildInternal(true)
+
+ // Prepend \displaystyle to force display mode rendering
+ let displayStyle = Style(level: .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 = Fraction()
+ let numerator = self.buildInternal(true)
+ let denominator = self.buildInternal(true)
+
+ // Prepend \textstyle to force text mode rendering
+ let textStyle = Style(level: .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 = Fraction(hasRule: false)
+ frac.numerator = self.buildInternal(true)
+ frac.denominator = self.buildInternal(true)
+ frac.leftDelimiter = "("
+ frac.rightDelimiter = ")"
+ return frac
+ } else if command == "sqrt" {
+ // A sqrt command with one argument
+ let rad = Radical()
+ guard self.hasCharacters else {
+ rad.radicand = self.buildInternal(true)
+ return rad
+ }
+ let ch = self.nextCharacter()
+ 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 = Inner()
+ 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 = Overline()
+ over.innerList = self.buildInternal(true)
+ return over
+ } else if command == "underline" {
+ // The underline command has 1 arguments
+ let under = Underline()
+ 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? Table {
+ return tableAtom
+ }
+
+ // Otherwise, single row - wrap in table
+ var rows = [[AtomList]]()
+ rows.append([content!])
+
+ var error: ParserError? = self.error
+ let table = AtomFactory.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(environment: env, firstList: nil, isRow: false)
+ return table
+ } else if command == "color" {
+ // A color command has 2 arguments
+ let mathColor = Color()
+ 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 = TextColor()
+ 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 = ColorBox()
+ 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 = Inner()
+ inner.leftBoundary = AtomFactory.boundary(forDelimiter: "(")
+ inner.rightBoundary = AtomFactory.boundary(forDelimiter: ")")
+
+ let innerList = AtomList()
+
+ // Add the "mod" operator (upright text)
+ let modOperator = AtomFactory.atom(forLatexSymbol: "mod")!
+ innerList.append(modOperator)
+
+ // Add medium space between "mod" and argument (6mu)
+ let space = Space(amount: 6.0)
+ innerList.append(space)
+
+ // Parse the argument from braces
+ let argument = self.buildInternal(true)
+ if let argList = argument {
+ innerList.append(contentsOf: 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 Atom(type: .relation, nucleus: negatedUnicode)
+ } else {
+ let errorMessage = "Unsupported \\not\\\(nextCommand) combination"
+ self.setError(.invalidCommand, message: errorMessage)
+ return nil
+ }
+ } 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.nextCharacter()
+ 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.nextCharacter().utf32
+ 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: AtomList, stopChar: Character?) -> AtomList?
+ {
+ 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: Fraction! = nil
+ if command == "over" {
+ frac = Fraction()
+ } else {
+ frac = Fraction(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 = AtomList()
+ fracList.append(frac)
+ return fracList
+ } else if command == "\\" || command == "cr" {
+ if currentEnvironment != nil {
+ // Stop the current list and increment the row count
+ currentEnvironment!.numberOfRows += 1
+ return list
+ } else {
+ // Create a new table with the current list and a default env
+ if let table = self.buildTable(environment: nil, firstList: list, isRow: true) {
+ return AtomList(atom: table)
+ }
+ }
+ } else if command == "end" {
+ if currentEnvironment == nil {
+ let errorMessage = "Missing \\begin"
+ self.setError(.missingBegin, message: errorMessage)
+ return nil
+ }
+ let env = self.readEnvironment()
+ if env == nil {
+ return nil
+ }
+ if env! != currentEnvironment!.name {
+ let errorMessage =
+ "Begin environment name \(currentEnvironment!.name ?? "(none)") does not match end name: \(env!)"
+ self.setError(.invalidEnvironment, message: errorMessage)
+ return nil
+ }
+ // Finish the current environment.
+ currentEnvironment!.ended = true
+ return list
+ }
+ return nil
+ }
+
+ // Applies the modifier to the atom. Returns true if modifier applied.
+ mutating func applyModifier(_ modifier: String, atom: Atom?) -> 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! LargeOperator
+ 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! LargeOperator
+ op.limits = false
+ }
+ return true
+ }
+ return false
+ }
+
+ mutating func setError(_ code: ParserError.Code, message: String) {
+ // Only record the first error.
+ if error == nil {
+ error = ParserError(code: code, message: message)
+ }
+ }
+
+ mutating func atom(forCommand command: String) -> Atom? {
+ if let atom = AtomFactory.atom(forLatexSymbol: command) {
+ return atom
+ }
+ if let accent = AtomFactory.accent(withName: command) {
+ accent.innerList = self.buildInternal(true)
+ return accent
+ } else if command == "frac" {
+ let frac = Fraction()
+ frac.numerator = self.buildInternal(true)
+ frac.denominator = self.buildInternal(true)
+ return frac
+ } else if command == "cfrac" {
+ let frac = Fraction()
+ frac.isContinuedFraction = true
+
+ // Parse optional alignment parameter [l], [r], [c]
+ skipSpaces()
+ if hasCharacters && string[currentCharIndex] == "[" {
+ _ = nextCharacter() // consume '['
+ let alignmentChar = nextCharacter()
+ if alignmentChar == "l" || alignmentChar == "r" || alignmentChar == "c" {
+ frac.alignment = String(alignmentChar)
+ }
+ // Consume closing ']'
+ if hasCharacters && string[currentCharIndex] == "]" {
+ _ = nextCharacter()
+ }
+ }
+
+ 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 = Fraction()
+ let numerator = self.buildInternal(true)
+ let denominator = self.buildInternal(true)
+
+ // Prepend \displaystyle to force display mode rendering
+ let displayStyle = Style(level: .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 = Fraction()
+ let numerator = self.buildInternal(true)
+ let denominator = self.buildInternal(true)
+
+ // Prepend \textstyle to force text mode rendering
+ let textStyle = Style(level: .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 = Fraction(hasRule: false)
+ frac.numerator = self.buildInternal(true)
+ frac.denominator = self.buildInternal(true)
+ frac.leftDelimiter = "("
+ frac.rightDelimiter = ")"
+ return frac
+ } else if command == "sqrt" {
+ let rad = Radical()
+ let char = self.nextCharacter()
+ 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 = Inner()
+ 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 = Overline()
+ over.innerList = self.buildInternal(true)
+
+ return over
+ } else if command == "underline" {
+ let under = Underline()
+ 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: Table.ColumnAlignment? = nil
+ if env.hasSuffix("*") {
+ alignment = self.readOptionalAlignment()
+ if self.error != nil {
+ return nil
+ }
+ }
+
+ let table = self.buildTable(environment: env, alignment: alignment, firstList: nil, isRow: false)
+ return table
+ } else {
+ return nil
+ }
+ } else if command == "color" {
+ // A color command has 2 arguments
+ let mathColor = Color()
+ mathColor.colorString = self.readColor()!
+ mathColor.innerList = self.buildInternal(true)
+ return mathColor
+ } else if command == "colorbox" {
+ // A color command has 2 arguments
+ let mathColorbox = ColorBox()
+ 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() -> Table.ColumnAlignment? {
+ self.skipSpaces()
+
+ // Check if there's an opening bracket
+ guard hasCharacters && string[currentCharIndex] == "[" else {
+ return nil
+ }
+
+ _ = nextCharacter() // consume '['
+ self.skipSpaces()
+
+ guard hasCharacters else {
+ self.setError(.characterNotFound, message: "Missing alignment specifier after [")
+ return nil
+ }
+
+ let alignChar = nextCharacter()
+ let alignment: Table.ColumnAlignment?
+
+ switch alignChar {
+ case "l":
+ alignment = .left
+ case "c":
+ alignment = .center
+ case "r":
+ alignment = .right
+ default:
+ self.setError(
+ .invalidEnvironment, 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 assertNotSpace(_ ch: Character) {
+ assert(ch >= "\u{21}" && ch <= "\u{7E}", "Expected non-space character \(ch)")
+ }
+
+ mutating func buildTable(
+ environment: String?,
+ alignment: Table.ColumnAlignment? = nil,
+ firstList: AtomList?,
+ isRow: Bool
+ ) -> Atom? {
+ // Save the current env till an new one gets built.
+ let oldEnv = self.currentEnvironment
+
+ currentEnvironment = Environment(name: environment, alignment: alignment)
+
+ var currentRow = 0
+ var currentCol = 0
+
+ var rows = [[AtomList]]()
+ rows.append([AtomList]())
+ if firstList != nil {
+ rows[currentRow].append(firstList!)
+ if isRow {
+ currentEnvironment!.numberOfRows += 1
+ currentRow += 1
+ rows.append([AtomList]())
+ } else {
+ currentCol += 1
+ }
+ }
+ while !currentEnvironment!.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 currentEnvironment!.numberOfRows > currentRow {
+ currentRow = currentEnvironment!.numberOfRows
+ rows.append([AtomList]())
+ currentCol = 0
+ }
+ }
+
+ if !currentEnvironment!.ended && currentEnvironment!.name != nil {
+ self.setError(.missingEnd, message: "Missing \\end")
+ return nil
+ }
+
+ var error: ParserError? = self.error
+ let table = AtomFactory.table(
+ withEnvironment: currentEnvironment?.name, alignment: currentEnvironment?.alignment,
+ rows: rows, error: &error)
+ if table == nil && self.error == nil {
+ self.error = error
+ return nil
+ }
+ self.currentEnvironment = oldEnv
+ return table
+ }
+
+ mutating func getBoundaryAtom(_ delimiterType: String) -> Atom? {
+ let delim = self.readDelimiter()
+ if delim == nil {
+ let errorMessage = "Missing delimiter for \\\(delimiterType)"
+ self.setError(.missingDelimiter, message: errorMessage)
+ return nil
+ }
+ let boundary = AtomFactory.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.nextCharacter()
+ assertNotSpace(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.nextCharacter()
+ if singleChars.firstIndex(of: char) != nil {
+ 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.nextCharacter()
+ if char.isLowercase || char.isUppercase || char == "*" {
+ output.append(char)
+ } else {
+ self.unlookCharacter()
+ break
+ }
+ }
+ return output
+ }
+ }
+}