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 + } + } +}