From 61ef8dc4f84669e35fc10ace30009c7109e581dd Mon Sep 17 00:00:00 2001 From: Nicolas Guillot Date: Tue, 30 Sep 2025 19:17:56 +0200 Subject: [PATCH] supports all standard LaTeX math delimiters for both inline and display modes --- README.md | 49 ++ .../MathRender/MTMathListBuilder.swift | 78 ++- .../MTMathListBuilderTests.swift | 447 ++++++++++++++++++ 3 files changed, 571 insertions(+), 3 deletions(-) mode change 100755 => 100644 README.md mode change 100755 => 100644 Tests/SwiftMathTests/MTMathListBuilderTests.swift diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 2ae3ec9..17510b1 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/SwiftMath/MathRender/MTMathListBuilder.swift b/Sources/SwiftMath/MathRender/MTMathListBuilder.swift index 95112a8..f662d4d 100644 --- a/Sources/SwiftMath/MathRender/MTMathListBuilder.swift +++ b/Sources/SwiftMath/MathRender/MTMathListBuilder.swift @@ -66,13 +66,22 @@ 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? @@ -191,11 +200,66 @@ public struct MTMathListBuilder { self.currentFontStyle = .defaultStyle 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 } diff --git a/Tests/SwiftMathTests/MTMathListBuilderTests.swift b/Tests/SwiftMathTests/MTMathListBuilderTests.swift old mode 100755 new mode 100644 index f1453cb..605af33 --- a/Tests/SwiftMathTests/MTMathListBuilderTests.swift +++ b/Tests/SwiftMathTests/MTMathListBuilderTests.swift @@ -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 {