diff --git a/Sources/SwiftUIMath/Internal/Syntax/Parser.swift b/Sources/SwiftUIMath/Internal/Syntax/Parser.swift index 13f2c42..a442db7 100644 --- a/Sources/SwiftUIMath/Internal/Syntax/Parser.swift +++ b/Sources/SwiftUIMath/Internal/Syntax/Parser.swift @@ -571,15 +571,20 @@ extension Math { } } 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 " + if let command = AtomFactory.latexSymbolName(for: atom) { + // Known built-in operator (e.g. \sin, \sum) + let originalOp = AtomFactory.atom(forLatexSymbol: command) as! LargeOperator + str += "\\\(command) " + if originalOp.limits != op.limits { + if op.limits { + str += "\\limits " + } else { + str += "\\nolimits " + } } + } else { + // Custom operator defined via \operatorname + str += "\\operatorname{\(op.nucleus)}" } } else if atom.type == .space { if let space = atom as? Space { @@ -848,6 +853,28 @@ extension Math { inner.innerList = innerList return inner + } else if command == "operatorname" { + // \operatorname{name} — renders argument as an upright operator (no limits by default) + guard self.expectCharacter("{") else { + self.setError(.characterNotFound, message: "Missing { after \\operatorname") + return nil + } + self.skipSpaces() + var name = "" + while self.hasCharacters { + let ch = self.nextCharacter() + if ch == "}" { + break + } + name.append(ch) + } + name = name.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { + self.setError(.invalidCommand, message: "\\operatorname requires a non-empty argument") + return nil + } + // Create a LargeOperator with the given name, no limits (like \sin, \log) + return AtomFactory.operatorWithName(name, limits: false) } else if command == "not" { // Handle \not command with lookahead for comprehensive negation support let nextCommand = self.peekNextCommand() diff --git a/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift b/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift index a64c474..edee40e 100644 --- a/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift +++ b/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift @@ -1988,6 +1988,57 @@ struct ParserTests { } } + @Test + func operatorname() throws { + // Basic usage: \operatorname{Res} + var str = "\\operatorname{Res}" + var list = try #require(Math.Parser.build(fromString: str)) + #expect(list.atoms.count == 1) + var op = try #require(list.atoms[0] as? Math.LargeOperator) + #expect(op.type == .largeOperator) + #expect(op.nucleus == "Res") + #expect(!op.limits) + + // Serializes back as \operatorname{Res} + var latex = Math.Parser.atomListToString(list) + #expect(latex == "\\operatorname{Res}") + + // With argument: \operatorname{sgn}(x) + str = "\\operatorname{sgn}(x)" + list = try #require(Math.Parser.build(fromString: str)) + op = try #require(list.atoms.first(where: { $0.type == .largeOperator }) as? Math.LargeOperator) + #expect(op.nucleus == "sgn") + #expect(!op.limits) + + // Another common operator: \operatorname{tr}(A) + str = "\\operatorname{tr}(A)" + list = try #require(Math.Parser.build(fromString: str)) + op = try #require(list.atoms.first(where: { $0.type == .largeOperator }) as? Math.LargeOperator) + #expect(op.nucleus == "tr") + #expect(!op.limits) + + // With subscript: \operatorname{Res}_{z=0} f(z) + str = "\\operatorname{Res}_{z=0} f(z)" + list = try #require(Math.Parser.build(fromString: str)) + op = try #require(list.atoms.first(where: { $0.type == .largeOperator }) as? Math.LargeOperator) + #expect(op.nucleus == "Res") + #expect(op.subscript != nil) + + // With superscript: \operatorname{Res}^{2} + str = "\\operatorname{Res}^{2}" + list = try #require(Math.Parser.build(fromString: str)) + op = try #require(list.atoms.first(where: { $0.type == .largeOperator }) as? Math.LargeOperator) + #expect(op.nucleus == "Res") + #expect(op.superscript != nil) + + // Error case: empty argument \operatorname{} + str = "\\operatorname{}" + var error: Math.ParserError? = nil + let errorList = Math.Parser.build(fromString: str, error: &error) + #expect(errorList == nil) + #expect(error != nil) + } + @Test func arrows() throws { let arrows = [