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.)
* Most commonly used math symbols
* 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
also no language support for other than west European langugages and some

View File

@@ -66,12 +66,21 @@ let MTParseError = "ParseError"
can be rendered and processed mathematically.
*/
public struct MTMathListBuilder {
/// The math mode determines rendering style (inline vs display)
enum MathMode {
/// Display style - larger operators, limits above/below (e.g., $$...$$, \[...\])
case display
/// Inline/text style - compact operators, limits to the side (e.g., $...$, \(...\))
case inline
}
var string: String
var currentCharIndex: String.Index
var currentInnerAtom: MTInner?
var currentEnv: MTEnvProperties?
var currentFontStyle:MTFontStyle
var spacesAllowed:Bool
var mathMode: MathMode = .display
/** Contains any error that occurred during parsing. */
var error:NSError?
@@ -192,10 +201,65 @@ public struct MTMathListBuilder {
self.spacesAllowed = false
}
// MARK: - Delimiter Detection
/// Detects and strips LaTeX math delimiters from the input string.
/// Returns the cleaned content and the detected math mode.
/// Supports: $...$ \(...\) $$...$$ \[...\] and environments
func detectAndStripDelimiters(from str: String) -> (String, MathMode) {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
// Check display delimiters first (more specific patterns)
// \[...\] - LaTeX display math
if trimmed.hasPrefix("\\[") && trimmed.hasSuffix("\\]") && trimmed.count > 4 {
let content = String(trimmed.dropFirst(2).dropLast(2))
return (content, .display)
}
// $$...$$ - TeX display math (check before single $)
if trimmed.hasPrefix("$$") && trimmed.hasSuffix("$$") && trimmed.count > 4 {
let content = String(trimmed.dropFirst(2).dropLast(2))
return (content, .display)
}
// Check inline delimiters
// \(...\) - LaTeX inline math
if trimmed.hasPrefix("\\(") && trimmed.hasSuffix("\\)") && trimmed.count > 4 {
let content = String(trimmed.dropFirst(2).dropLast(2))
return (content, .inline)
}
// $...$ - TeX inline math (must check after $$)
if trimmed.hasPrefix("$") && trimmed.hasSuffix("$") && trimmed.count > 2 && !trimmed.hasPrefix("$$") {
let content = String(trimmed.dropFirst(1).dropLast(1))
return (content, .inline)
}
// Check if it's an environment (\begin{...}\end{...})
// These are handled by existing logic and are display mode by default
if trimmed.hasPrefix("\\begin{") {
return (str, .display)
}
// No delimiters found - default to display mode (current behavior for backward compatibility)
return (str, .display)
}
// MARK: - MTMathList builder functions
/// Builds a mathlist from the internal `string`. Returns nil if there is an error.
public mutating func build() -> MTMathList? {
// Detect and strip delimiters, updating the string and mode
let (cleanedString, mode) = detectAndStripDelimiters(from: self.string)
self.string = cleanedString
self.currentCharIndex = cleanedString.startIndex
self.mathMode = mode
// If inline mode, we could optionally prepend a \textstyle command
// to force inline rendering of operators. For now, just track the mode.
let list = self.buildInternal(false)
if self.hasCharacters && error == nil {
self.setError(.mismatchBraces, message: "Mismatched braces: \(self.string)")
@@ -204,6 +268,14 @@ public struct MTMathListBuilder {
if error != 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
}

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

@@ -1420,6 +1420,453 @@ final class MTMathListBuilderTests: XCTestCase {
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 {
// // This is an example of a performance test case.
// measure {