supports all standard LaTeX math delimiters for both inline and display modes

This commit is contained in:
Nicolas Guillot
2025-09-30 19:17:56 +02:00
parent 6a5f64e402
commit 61ef8dc4f8
3 changed files with 571 additions and 3 deletions

49
README.md Executable file → Normal file
View File

@@ -183,6 +183,55 @@ This is a list of formula types that the library currently supports:
* Change bold, roman, caligraphic and other font styles (\\bf, \\text, etc.) * Change bold, roman, caligraphic and other font styles (\\bf, \\text, etc.)
* Most commonly used math symbols * Most commonly used math symbols
* Colors for both text and background * Colors for both text and background
* **Inline and display math mode delimiters** (see below)
### LaTeX Math Delimiters
`SwiftMath` now supports all standard LaTeX math delimiters for both inline and display modes. The parser automatically detects and handles these delimiters:
#### Inline Math (Text Style)
Use these delimiters for inline math within text, which renders more compactly:
```swift
// Dollar signs (TeX style)
label.latex = "$E = mc^2$"
// Parentheses (LaTeX style)
label.latex = "\\(\\sum_{i=1}^{n} x_i\\)"
// Cases environment in inline mode
label.latex = "\\(\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}\\)"
```
#### Display Math (Display Style)
Use these delimiters for standalone equations with larger operators and limits:
```swift
// Double dollar signs (TeX style)
label.latex = "$$\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$"
// Square brackets (LaTeX style)
label.latex = "\\[\\sum_{k=1}^{n} k^2 = \\frac{n(n+1)(2n+1)}{6}\\]"
// Equation environment
label.latex = "\\begin{equation} x^2 + y^2 = z^2 \\end{equation}"
// Cases environment in display mode
label.latex = "\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}"
```
**Note:** The difference between inline and display modes:
- **Inline mode** (`$...$` or `\(...\)`) renders compactly, suitable for math within text
- **Display mode** (`$$...$$`, `\[...\]`, or environments) renders with larger operators and limits positioned above/below
All delimiters are automatically stripped during parsing, and the math mode is set appropriately. No additional configuration is needed!
#### Backward Compatibility
Equations without explicit delimiters continue to work as before, defaulting to display mode:
```swift
label.latex = "x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}" // Works as always
```
Note: SwiftMath only supports the commands in LaTeX's math mode. There is Note: SwiftMath only supports the commands in LaTeX's math mode. There is
also no language support for other than west European langugages and some also no language support for other than west European langugages and some

View File

@@ -66,13 +66,22 @@ let MTParseError = "ParseError"
can be rendered and processed mathematically. can be rendered and processed mathematically.
*/ */
public struct MTMathListBuilder { 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
}
var string: String var string: String
var currentCharIndex: String.Index var currentCharIndex: String.Index
var currentInnerAtom: MTInner? var currentInnerAtom: MTInner?
var currentEnv: MTEnvProperties? var currentEnv: MTEnvProperties?
var currentFontStyle:MTFontStyle var currentFontStyle:MTFontStyle
var spacesAllowed:Bool var spacesAllowed:Bool
var mathMode: MathMode = .display
/** Contains any error that occurred during parsing. */ /** Contains any error that occurred during parsing. */
var error:NSError? var error:NSError?
@@ -191,11 +200,66 @@ public struct MTMathListBuilder {
self.currentFontStyle = .defaultStyle self.currentFontStyle = .defaultStyle
self.spacesAllowed = false 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 // MARK: - MTMathList builder functions
/// Builds a mathlist from the internal `string`. Returns nil if there is an error. /// Builds a mathlist from the internal `string`. Returns nil if there is an error.
public mutating func build() -> MTMathList? { 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) let list = self.buildInternal(false)
if self.hasCharacters && error == nil { if self.hasCharacters && error == nil {
self.setError(.mismatchBraces, message: "Mismatched braces: \(self.string)") self.setError(.mismatchBraces, message: "Mismatched braces: \(self.string)")
@@ -204,6 +268,14 @@ public struct MTMathListBuilder {
if error != nil { if error != nil {
return nil return nil
} }
// Optionally: Add style hint for inline mode
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 return list
} }

447
Tests/SwiftMathTests/MTMathListBuilderTests.swift Executable file → Normal file
View File

@@ -1420,6 +1420,453 @@ final class MTMathListBuilderTests: XCTestCase {
XCTAssertEqual(latex, "\\sum \\nolimits ", desc) XCTAssertEqual(latex, "\\sum \\nolimits ", desc)
} }
// MARK: - Inline and Display Math Delimiter Tests
func testInlineMathDollar() throws {
let str = "$x^2$"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse inline math with $")
// Should have textstyle at start, then variable with superscript
XCTAssertTrue(list!.atoms.count >= 1, "Should have at least one atom")
// Find the variable atom (skip style atoms)
var foundVariable = false
for atom in list!.atoms {
if atom.type == .variable && atom.nucleus == "x" {
foundVariable = true
XCTAssertNotNil(atom.superScript, "Should have superscript")
break
}
}
XCTAssertTrue(foundVariable, "Should find variable x")
}
func testInlineMathParens() throws {
let str = "\\(E=mc^2\\)"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse inline math with \\(\\)")
XCTAssertTrue(list!.atoms.count >= 3, "Should have E, =, m, c atoms")
// Check for equals sign
var foundEquals = false
for atom in list!.atoms {
if atom.type == .relation && atom.nucleus == "=" {
foundEquals = true
break
}
}
XCTAssertTrue(foundEquals, "Should find equals sign")
}
func testInlineMathWithCases() throws {
let str = "\\(\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}\\)"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse inline cases")
// cases environment returns an Inner atom with table inside
var foundInner = false
for atom in list!.atoms {
if atom.type == .inner {
let inner = atom as! MTInner
// Look for table inside the inner list
if let innerList = inner.innerList {
for innerAtom in innerList.atoms {
if innerAtom.type == .table {
let table = innerAtom as! MTMathTable
XCTAssertEqual(table.environment, "cases", "Should be cases environment")
XCTAssertEqual(table.numRows, 2, "Should have 2 rows")
foundInner = true
break
}
}
}
if foundInner { break }
}
}
XCTAssertTrue(foundInner, "Should find cases table inside inner atom")
}
func testInlineMathVectorDot() throws {
let str = "$\\vec{a} \\cdot \\vec{b}$"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse inline vector dot product")
// Should contain accents (for vec) and cdot operator
var hasAccent = false
var hasCdot = false
for atom in list!.atoms {
if atom.type == .accent {
hasAccent = true
}
if atom.type == .binaryOperator && atom.nucleus.contains("\u{22C5}") {
hasCdot = true
}
}
XCTAssertTrue(hasAccent, "Should have accent for \\vec")
XCTAssertTrue(hasCdot, "Should have \\cdot operator")
}
func testDisplayMathDoubleDollar() throws {
let str = "$$x^2 + y^2 = z^2$$"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse display math with $$")
XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms for expression")
// Should NOT have textstyle at start (display mode)
let firstAtom = list!.atoms.first
XCTAssertNotEqual(firstAtom?.type, .style, "Display mode should not force textstyle")
}
func testDisplayMathBrackets() throws {
let str = "\\[\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}\\]"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse display math with \\[\\]")
// Find sum operator
var foundSum = false
for atom in list!.atoms {
if atom.type == .largeOperator && atom.nucleus.contains("") {
foundSum = true
XCTAssertNotNil(atom.subScript, "Sum should have subscript")
XCTAssertNotNil(atom.superScript, "Sum should have superscript")
break
}
}
XCTAssertTrue(foundSum, "Should find sum operator")
}
func testDisplayMathCasesWithoutDelimiters() throws {
// This should work as before (backward compatibility)
let str = "\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse display cases without outer delimiters")
XCTAssertTrue(list!.atoms.count >= 1, "Should have at least one atom")
// cases environment returns an Inner atom with table inside
var foundTable = false
for atom in list!.atoms {
if atom.type == .inner {
let inner = atom as! MTInner
if let innerList = inner.innerList {
for innerAtom in innerList.atoms {
if innerAtom.type == .table {
let table = innerAtom as! MTMathTable
XCTAssertEqual(table.environment, "cases", "Should be cases environment")
XCTAssertEqual(table.numRows, 2, "Should have 2 rows")
foundTable = true
break
}
}
}
if foundTable { break }
}
}
XCTAssertTrue(foundTable, "Should find cases table inside inner atom")
}
func testBackwardCompatibilityNoDelimiters() throws {
// Test that expressions without delimiters still work
let str = "x^2 + y^2 = z^2"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse expression without delimiters")
XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms")
}
func testEmptyInlineMath() throws {
let str = "$$$" // This is $$$ which should be treated as $$ + $
let list = MTMathListBuilder.build(fromString: str)
// Should handle gracefully
XCTAssertNotNil(list, "Should handle edge case")
}
func testEmptyDisplayMath() throws {
let str = "\\[\\]"
let list = MTMathListBuilder.build(fromString: str)
// Empty content may return nil or an empty list, both are acceptable
if list != nil {
XCTAssertTrue(list!.atoms.isEmpty || list!.atoms.count >= 0, "Should have empty or minimal atoms")
}
// It's ok if it returns nil for empty content
}
func testDollarInMath() throws {
// Test that delimiters are properly stripped
let str = "$a + b$"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse correctly")
// Should not contain $ in the parsed atoms
for atom in list!.atoms {
XCTAssertFalse(atom.nucleus.contains("$"), "Should not have $ in nucleus")
}
}
func testComplexInlineExpression() throws {
let str = "$\\frac{1}{2} + \\sqrt{3}$"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse complex inline expression")
// Should have fraction and radical
var hasFraction = false
var hasRadical = false
for atom in list!.atoms {
if atom.type == .fraction {
hasFraction = true
}
if atom.type == .radical {
hasRadical = true
}
}
XCTAssertTrue(hasFraction, "Should have fraction")
XCTAssertTrue(hasRadical, "Should have radical")
}
func testInlineMathStyleForcing() throws {
// Inline math should have textstyle prepended
let str = "$\\sum_{i=1}^{n} i$"
let list = MTMathListBuilder.build(fromString: str)
XCTAssertNotNil(list, "Should parse sum in inline mode")
// First atom should be style atom with text style
if let firstAtom = list!.atoms.first, firstAtom.type == .style {
let styleAtom = firstAtom as! MTMathStyle
XCTAssertEqual(styleAtom.style, .text, "Inline mode should force text style")
}
}
// MARK: - Tests for build(fromString:error:) API with delimiters
func testInlineMathDollarWithError() throws {
let str = "$x^2$"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse inline math with $")
XCTAssertNil(error, "Should not have error")
// Find the variable atom (skip style atoms)
var foundVariable = false
for atom in list!.atoms {
if atom.type == .variable && atom.nucleus == "x" {
foundVariable = true
XCTAssertNotNil(atom.superScript, "Should have superscript")
break
}
}
XCTAssertTrue(foundVariable, "Should find variable x")
}
func testInlineMathParensWithError() throws {
let str = "\\(E=mc^2\\)"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse inline math with \\(\\)")
XCTAssertNil(error, "Should not have error")
XCTAssertTrue(list!.atoms.count >= 3, "Should have E, =, m, c atoms")
// Check for equals sign
var foundEquals = false
for atom in list!.atoms {
if atom.type == .relation && atom.nucleus == "=" {
foundEquals = true
break
}
}
XCTAssertTrue(foundEquals, "Should find equals sign")
}
func testInlineMathWithCasesWithError() throws {
let str = "\\(\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}\\)"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse inline cases")
XCTAssertNil(error, "Should not have error")
// cases environment returns an Inner atom with table inside
var foundInner = false
for atom in list!.atoms {
if atom.type == .inner {
let inner = atom as! MTInner
if let innerList = inner.innerList {
for innerAtom in innerList.atoms {
if innerAtom.type == .table {
let table = innerAtom as! MTMathTable
XCTAssertEqual(table.environment, "cases", "Should be cases environment")
XCTAssertEqual(table.numRows, 2, "Should have 2 rows")
foundInner = true
break
}
}
}
if foundInner { break }
}
}
XCTAssertTrue(foundInner, "Should find cases table inside inner atom")
}
func testDisplayMathDoubleDollarWithError() throws {
let str = "$$x^2 + y^2 = z^2$$"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse display math with $$")
XCTAssertNil(error, "Should not have error")
XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms for expression")
}
func testDisplayMathBracketsWithError() throws {
let str = "\\[\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}\\]"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse display math with \\[\\]")
XCTAssertNil(error, "Should not have error")
// Find sum operator
var foundSum = false
for atom in list!.atoms {
if atom.type == .largeOperator && atom.nucleus.contains("") {
foundSum = true
XCTAssertNotNil(atom.subScript, "Sum should have subscript")
XCTAssertNotNil(atom.superScript, "Sum should have superscript")
break
}
}
XCTAssertTrue(foundSum, "Should find sum operator")
}
func testDisplayMathCasesWithoutDelimitersWithError() throws {
let str = "\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse display cases without outer delimiters")
XCTAssertNil(error, "Should not have error")
XCTAssertTrue(list!.atoms.count >= 1, "Should have at least one atom")
// cases environment returns an Inner atom with table inside
var foundTable = false
for atom in list!.atoms {
if atom.type == .inner {
let inner = atom as! MTInner
if let innerList = inner.innerList {
for innerAtom in innerList.atoms {
if innerAtom.type == .table {
let table = innerAtom as! MTMathTable
XCTAssertEqual(table.environment, "cases", "Should be cases environment")
XCTAssertEqual(table.numRows, 2, "Should have 2 rows")
foundTable = true
break
}
}
}
if foundTable { break }
}
}
XCTAssertTrue(foundTable, "Should find cases table inside inner atom")
}
func testBackwardCompatibilityNoDelimitersWithError() throws {
let str = "x^2 + y^2 = z^2"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse expression without delimiters")
XCTAssertNil(error, "Should not have error")
XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms")
}
func testInvalidLatexWithError() throws {
let str = "$\\notacommand$"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNil(list, "Should fail to parse invalid command")
XCTAssertNotNil(error, "Should have error")
XCTAssertEqual(error?.code, MTParseErrors.invalidCommand.rawValue, "Should be invalid command error")
}
func testMismatchedBracesWithError() throws {
let str = "${x+2$"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNil(list, "Should fail to parse mismatched braces")
XCTAssertNotNil(error, "Should have error")
XCTAssertEqual(error?.code, MTParseErrors.mismatchBraces.rawValue, "Should be mismatched braces error")
}
func testComplexInlineExpressionWithError() throws {
let str = "$\\frac{1}{2} + \\sqrt{3}$"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse complex inline expression")
XCTAssertNil(error, "Should not have error")
// Should have fraction and radical
var hasFraction = false
var hasRadical = false
for atom in list!.atoms {
if atom.type == .fraction {
hasFraction = true
}
if atom.type == .radical {
hasRadical = true
}
}
XCTAssertTrue(hasFraction, "Should have fraction")
XCTAssertTrue(hasRadical, "Should have radical")
}
func testInlineMathVectorDotWithError() throws {
let str = "$\\vec{a} \\cdot \\vec{b}$"
var error: NSError? = nil
let list = MTMathListBuilder.build(fromString: str, error: &error)
XCTAssertNotNil(list, "Should parse inline vector dot product")
XCTAssertNil(error, "Should not have error")
// Should contain accents (for vec) and cdot operator
var hasAccent = false
var hasCdot = false
for atom in list!.atoms {
if atom.type == .accent {
hasAccent = true
}
if atom.type == .binaryOperator && atom.nucleus.contains("\u{22C5}") {
hasCdot = true
}
}
XCTAssertTrue(hasAccent, "Should have accent for \\vec")
XCTAssertTrue(hasCdot, "Should have \\cdot operator")
}
// func testPerformanceExample() throws { // func testPerformanceExample() throws {
// // This is an example of a performance test case. // // This is an example of a performance test case.
// measure { // measure {