diff --git a/Sources/SwiftUIMath/Internal/Display/Typesetter.swift b/Sources/SwiftUIMath/Internal/Display/Typesetter.swift index d87eec6..b13f502 100644 --- a/Sources/SwiftUIMath/Internal/Display/Typesetter.swift +++ b/Sources/SwiftUIMath/Internal/Display/Typesetter.swift @@ -1846,7 +1846,11 @@ extension Math { let spaceArray = getInterElementSpaces()[Int(leftIndex)] let spaceTypeObj = spaceArray[Int(rightIndex)] let spaceType = spaceTypeObj - assert(spaceType != .invalid, "Invalid space between \(left) and \(right)") + // Keep malformed LaTeX from crashing debug builds. Propagating NaN lets callers + // detect the failed layout and surface it as a validation issue instead. + if spaceType == .invalid { + return .nan + } let spaceMultipler = self.getSpacingInMu(spaceType) if spaceMultipler > 0 { diff --git a/Sources/SwiftUIMath/Internal/Syntax/AtomFactory.swift b/Sources/SwiftUIMath/Internal/Syntax/AtomFactory.swift index 4561ffc..9b21291 100644 --- a/Sources/SwiftUIMath/Internal/Syntax/AtomFactory.swift +++ b/Sources/SwiftUIMath/Internal/Syntax/AtomFactory.swift @@ -658,10 +658,12 @@ extension Math { static func table( withEnvironment env: String?, alignment: Table.ColumnAlignment? = nil, + columnAlignments: [Table.ColumnAlignment]? = nil, + columnFormat: String? = nil, rows: [[AtomList]], error: inout ParserError? ) -> Atom? { - let table = Table(environment: env ?? "") + let table = Table(environment: env ?? "", columnFormat: columnFormat) for i in 0.. columnAlignments.count { + let message = "array environment has more columns than alignment specifiers" + if error == nil { + error = ParserError(code: .invalidNumberOfColumns, message: message) + } + return nil + } + + table.interRowAdditionalSpacing = 0 + table.interColumnSpacing = 18 + + let style = Style(level: .text) + for i in 0..= 1 && cell.atoms[0].type == Math.AtomType.style { // remove first atom cell.atoms.removeFirst() @@ -752,6 +764,12 @@ extension Math { // reinstate the old inner atom. let newInner = currentInnerAtom currentInnerAtom = oldInner + if newInner?.leftBoundary?.nucleus.isEmpty == true { + newInner?.leftBoundary = nil + } + if newInner?.rightBoundary?.nucleus.isEmpty == true { + newInner?.rightBoundary = nil + } return newInner } else if command == "overline" { // The overline command has 1 arguments @@ -793,11 +811,20 @@ extension Math { return table } else if command == "begin" { - let env = self.readEnvironment() - if env == nil { + guard let env = self.readEnvironment() else { return nil } - let table = self.buildTable(environment: env, firstList: nil, isRow: false) + guard let environmentOptions = self.readEnvironmentOptions(for: env) else { + return nil + } + let table = self.buildTable( + environment: env, + alignment: environmentOptions.alignment, + columnAlignments: environmentOptions.columnAlignments, + columnFormat: environmentOptions.columnFormat, + firstList: nil, + isRow: false + ) return table } else if command == "color" { // A color command has 2 arguments @@ -1141,6 +1168,12 @@ extension Math { } let newInner = self.currentInnerAtom currentInnerAtom = oldInner + if newInner?.leftBoundary?.nucleus.isEmpty == true { + newInner?.leftBoundary = nil + } + if newInner?.rightBoundary?.nucleus.isEmpty == true { + newInner?.rightBoundary = nil + } return newInner } else if command == "overline" { let over = Overline() @@ -1154,18 +1187,15 @@ extension Math { 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 - } + guard let environmentOptions = self.readEnvironmentOptions(for: env) else { + return nil } let table = self.buildTable( environment: env, - alignment: alignment, + alignment: environmentOptions.alignment, + columnAlignments: environmentOptions.columnAlignments, + columnFormat: environmentOptions.columnFormat, firstList: nil, isRow: false ) @@ -1254,6 +1284,84 @@ extension Math { return alignment } + mutating func readEnvironmentOptions(for env: String) -> ( + alignment: Table.ColumnAlignment?, + columnAlignments: [Table.ColumnAlignment]?, + columnFormat: String? + )? { + if env.hasSuffix("*") { + let alignment = self.readOptionalAlignment() + return self.error == nil ? (alignment, nil, nil) : nil + } + + if env == "array" { + let (columnAlignments, columnFormat) = self.readArrayColumnFormat() + return self.error == nil ? (nil, columnAlignments, columnFormat) : nil + } + + return (nil, nil, nil) + } + + mutating func readArrayColumnFormat() -> ([Table.ColumnAlignment]?, String?) { + guard self.expectCharacter("{") else { + self.setError(.characterNotFound, message: "Missing { after \\begin{array}") + return (nil, nil) + } + + self.skipSpaces() + + var columnAlignments = [Table.ColumnAlignment]() + var format = "" + var foundClosingBrace = false + + while self.hasCharacters { + let char = self.nextCharacter() + if char == "}" { + foundClosingBrace = true + break + } + + switch char { + case "l": + columnAlignments.append(.left) + format.append(char) + case "c": + columnAlignments.append(.center) + format.append(char) + case "r": + columnAlignments.append(.right) + format.append(char) + case "|": + format.append(char) + case _ where char.isWhitespace: + continue + default: + self.setError( + .invalidEnvironment, + message: "Unsupported array column format specifier: \(char)" + ) + return (nil, nil) + } + } + + if !foundClosingBrace { + self.setError(.characterNotFound, message: "Missing } after array column format") + } + + if columnAlignments.isEmpty { + self.setError( + .invalidEnvironment, + message: "array environment requires at least one column alignment specifier (l, c, or r)" + ) + } + + if self.error != nil { + return (nil, nil) + } + + return (columnAlignments, format) + } + func assertNotSpace(_ ch: Character) { assert(ch >= "\u{21}" && ch <= "\u{7E}", "Expected non-space character \(ch)") } @@ -1261,13 +1369,20 @@ extension Math { mutating func buildTable( environment: String?, alignment: Table.ColumnAlignment? = nil, + columnAlignments: [Table.ColumnAlignment]? = nil, + columnFormat: String? = 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) + currentEnvironment = Environment( + name: environment, + alignment: alignment, + columnAlignments: columnAlignments, + columnFormat: columnFormat + ) var currentRow = 0 var currentCol = 0 @@ -1306,7 +1421,10 @@ extension Math { var error: ParserError? = self.error let table = AtomFactory.table( - withEnvironment: currentEnvironment?.name, alignment: currentEnvironment?.alignment, + withEnvironment: currentEnvironment?.name, + alignment: currentEnvironment?.alignment, + columnAlignments: currentEnvironment?.columnAlignments, + columnFormat: currentEnvironment?.columnFormat, rows: rows, error: &error) if table == nil && self.error == nil { self.error = error diff --git a/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift b/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift index c9dfb58..2aa782a 100644 --- a/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift +++ b/Tests/SwiftUIMathTests/Internal/Syntax/ParserTests.swift @@ -594,13 +594,21 @@ struct ParserTests { let innerList = try #require(inner.innerList) checkAtomTypes(innerList, types: testCase.type2) - let leftBoundary = try #require(inner.leftBoundary) - #expect(leftBoundary.type == .boundary) - #expect(leftBoundary.nucleus == testCase.left) + if testCase.left.isEmpty { + #expect(inner.leftBoundary == nil) + } else { + let leftBoundary = try #require(inner.leftBoundary) + #expect(leftBoundary.type == .boundary) + #expect(leftBoundary.nucleus == testCase.left) + } - let rightBoundary = try #require(inner.rightBoundary) - #expect(rightBoundary.type == .boundary) - #expect(rightBoundary.nucleus == testCase.right) + if testCase.right.isEmpty { + #expect(inner.rightBoundary == nil) + } else { + let rightBoundary = try #require(inner.rightBoundary) + #expect(rightBoundary.type == .boundary) + #expect(rightBoundary.nucleus == testCase.right) + } // convert it back to latex let latex = Math.Parser.atomListToString(list) @@ -1052,6 +1060,65 @@ struct ParserTests { #expect(latex == "\\left( \\begin{matrix}x&y\\\\ z&w\\end{matrix}\\right) ") } + @Test + func array() throws { + let str = "\\left\\{\\begin{array}{ll}1,&|x|\\leq1,\\\\0,&|x|>1,\\end{array}\\right." + let list = try #require(Math.Parser.build(fromString: str)) + #expect(list.atoms.count == 1) + + let inner = try #require(list.atoms[0] as? Math.Inner) + let leftBoundary = try #require(inner.leftBoundary) + #expect(leftBoundary.type == .boundary) + #expect(leftBoundary.nucleus == "{") + #expect(inner.rightBoundary == nil) + + let innerList = try #require(inner.innerList) + #expect(innerList.atoms.count == 1) + + let table = try #require(innerList.atoms[0] as? Math.Table) + #expect(table.environment == "array") + #expect(table.columnFormat == "ll") + #expect(table.interRowAdditionalSpacing == 0) + #expect(table.interColumnSpacing == 18) + #expect(table.numberOfRows == 2) + #expect(table.numberOfColumns == 2) + #expect(table.alignment(forColumn: 0) == .left) + #expect(table.alignment(forColumn: 1) == .left) + + for row in 0..