Merge pull request #3 from wesleyel/copilot/fix-rendering-issue-formula
Some checks failed
CI / Test All Platforms (push) Has been cancelled
CI / Backward Compatibility (Xcode 16.4) (push) Has been cancelled
Format / swift-format (push) Has been cancelled

Add `array` environment support for piecewise formulas
This commit is contained in:
2026-04-07 20:36:26 +08:00
committed by GitHub
5 changed files with 244 additions and 23 deletions

View File

@@ -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 {

View File

@@ -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..<rows.count {
let row = rows[i]
@@ -709,6 +711,32 @@ extension Math {
} else {
return table
}
} else if env == "array" {
let columnAlignments = columnAlignments ?? []
if table.numberOfColumns > 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..<table.cells.count {
for j in 0..<table.cells[i].count {
table.cells[i][j].insert(style, at: 0)
}
}
for (column, alignment) in columnAlignments.enumerated() {
table.setAlignment(alignment, forColumn: column)
}
return table
} else if env == "eqalign" || env == "split" || env == "aligned" {
if table.numberOfColumns != 2 {
let message = "\(env) environment can only have 2 columns"

View File

@@ -11,6 +11,7 @@ extension Math {
var alignments: [ColumnAlignment]
var cells: [[AtomList]]
var environment: String
var columnFormat: String?
var interColumnSpacing: CGFloat
var interRowAdditionalSpacing: CGFloat
@@ -32,6 +33,7 @@ extension Math {
row.map { AtomList($0) }
}
self.environment = table.environment
self.columnFormat = table.columnFormat
self.interColumnSpacing = table.interColumnSpacing
self.interRowAdditionalSpacing = table.interRowAdditionalSpacing
@@ -42,12 +44,14 @@ extension Math {
alignments: [ColumnAlignment] = [],
cells: [[AtomList]] = [],
environment: String = "",
columnFormat: String? = nil,
interColumnSpacing: CGFloat = 0,
interRowAdditionalSpacing: CGFloat = 0
) {
self.alignments = alignments
self.cells = cells
self.environment = environment
self.columnFormat = columnFormat
self.interColumnSpacing = interColumnSpacing
self.interRowAdditionalSpacing = interRowAdditionalSpacing
super.init(type: .table)

View File

@@ -31,12 +31,21 @@ extension Math {
var ended: Bool
var numberOfRows: Int
var alignment: Table.ColumnAlignment? // Optional alignment for starred matrix environments
var columnAlignments: [Table.ColumnAlignment]?
var columnFormat: String?
init(name: String?, alignment: Table.ColumnAlignment? = nil) {
init(
name: String?,
alignment: Table.ColumnAlignment? = nil,
columnAlignments: [Table.ColumnAlignment]? = nil,
columnFormat: String? = nil
) {
self.name = name
self.numberOfRows = 0
self.ended = false
self.alignment = alignment
self.columnAlignments = columnAlignments
self.columnFormat = columnFormat
}
}
@@ -519,13 +528,16 @@ extension Math {
if let table = atom as? Table {
if !table.environment.isEmpty {
str += "\\begin{\(table.environment)}"
if table.environment == "array" {
str += "{\(table.columnFormat ?? "")}"
}
}
for i in 0..<table.numberOfRows {
let row = table.cells[i]
for j in 0..<row.count {
let cell = row[j]
if table.environment == "matrix" {
if table.environment == "matrix" || table.environment == "array" {
if cell.atoms.count >= 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

View File

@@ -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..<table.numberOfRows {
for column in 0..<table.numberOfColumns {
let style = try #require(table.cells[row][column].atoms.first as? Math.Style)
#expect(style.level == .text)
}
}
let latex = Math.Parser.atomListToString(list)
#expect(latex.contains("\\begin{array}{ll}"))
#expect(latex.contains("\\right."))
}
@Test
func arrayRejectsInvalidColumnFormat() throws {
let str = "\\begin{array}{xyz}a\\end{array}"
var error: Math.ParserError? = nil
let list = Math.Parser.build(fromString: str, error: &error)
#expect(list == nil)
#expect(error?.code == .invalidEnvironment)
}
@Test
func arrayRejectsTooFewColumnSpecifiers() throws {
let str = "\\begin{array}{l}a&b\\end{array}"
var error: Math.ParserError? = nil
let list = Math.Parser.build(fromString: str, error: &error)
#expect(list == nil)
#expect(error?.code == .invalidNumberOfColumns)
}
@Test
func defaultTable() throws {
let str = "x \\\\ y"