Merge pull request #52 from nguillot/latex_support_extension

Add comprehensive LaTeX support: matrices, delimiters, operators, and line wrapping
This commit is contained in:
mgriebling
2025-10-05 09:38:49 -04:00
committed by GitHub
12 changed files with 2391 additions and 87 deletions

236
MISSING_FEATURES.md Normal file
View File

@@ -0,0 +1,236 @@
# SwiftMath Missing Features - Implementation Status
This document lists LaTeX features that are **not yet implemented** in SwiftMath, based on comprehensive testing against the LaTeX Mathematics reference.
## Summary
- **Total Features Tested**: 11
- **Fully Implemented**: 6 (55%)
- **Partially Implemented**: 0 (0%)
- **Not Implemented**: 5 (45%)
---
## HIGH PRIORITY Features (Not Implemented)
### 1. ✅ `\displaystyle` and `\textstyle` - **IMPLEMENTED**
**Status**: ✅ Working
**Description**: Commands to force display or text style rendering within expressions
**Test Results**: All tests passed
- `\displaystyle \sum_{i=1}^{n} x_i` - ✅ Works
- `\textstyle \int_{0}^{\infty} f(x) dx` - ✅ Works
- Inline displaystyle fractions - ✅ Works
---
### 2. ❌ `\middle` - Delimiter in Middle of Expression
**Status**: ❌ Not Implemented
**Error**: `Invalid command \middle`
**Description**: Used with `\left` and `\right` to add delimiters in the middle of expressions
**Examples**:
```latex
\left( \frac{a}{b} \middle| \frac{c}{d} \right)
\left\{ x \middle\| y \right\}
```
**Use Case**: Set notation, conditional expressions, piecewise functions with multiple sections
---
### 3. ✅ `\substack` - Multi-line Limits and Subscripts - **IMPLEMENTED**
**Status**: ✅ Working
**Description**: Creates multi-line subscripts and limits for operators
**Test Results**: All tests passed
- `\substack{a \\ b}` - ✅ Works
- `\sum_{\substack{0 \le i \le m \\ 0 < j < n}} P(i,j)` - ✅ Works (nested in subscript)
- `\prod_{\substack{p \text{ prime} \\ p < 100}} p` - ✅ Works (nested in subscript)
- `\substack{\frac{a}{b} \\ c}` - ✅ Works (with nested commands)
**Use Case**: Complex summation/product limits, constrained expressions
**Implementation**: Uses `buildInternal(true)` pattern, handles implicit tables created by `\\` within braces.
---
### 4. ❌ Manual Delimiter Sizing: `\big`, `\Big`, `\bigg`, `\Bigg`
**Status**: ❌ Not Implemented
**Error**: `Invalid command \big`
**Description**: Manually control delimiter sizes (4 levels beyond normal)
**Examples**:
```latex
\big( x \big) % slightly larger
\Big[ y \Big] % larger
\bigg\{ z \bigg\} % even larger
\Bigg| w \Bigg| % largest
```
**Use Case**: Fine control over delimiter appearance, nested expressions
---
### 5. ❌ Spacing Commands: `\,`, `\:`, `\;`, `\!`
**Status**: ❌ Partially Not Implemented
**Error**: `Invalid command \:` (and likely others)
**Description**: Fine-tuned horizontal spacing control
| Command | Description | Width |
|---------|-------------|-------|
| `\,` | Thin space | 3/18 em |
| `\:` | Medium space | 4/18 em |
| `\;` | Thick space | 5/18 em |
| `\!` | Negative thin space | -3/18 em |
**Examples**:
```latex
a\,b % thin space
\int\!\!\!\int f(x,y) dx dy % tight double integral
x \, y \: z \; w % mixed spacing
```
**Use Case**: Fine typography control, integral notation, custom spacing
---
## MEDIUM PRIORITY Features
### 6. ✅ Multiple Integral Symbols: `\iint`, `\iiint`, `\iiiint` - **IMPLEMENTED**
**Status**: ✅ Working
**Description**: Special symbols for double, triple, and quadruple integrals
**Test Results**: All tests passed
- `\iint f(x,y) dx dy` - ✅ Works (double integral)
- `\iiint f(x,y,z) dx dy dz` - ✅ Works (triple integral)
- `\iiiint f(w,x,y,z) dw dx dy dz` - ✅ Works (quadruple integral)
- `\iint_{D} f(x,y) dA` - ✅ Works (with subscript limits)
**Use Case**: Multivariable calculus, surface and volume integrals
**Implementation**: Added U+2A0C (quadruple integral) Unicode character to operator definitions.
---
### 7. ✅ `\cfrac` - Continued Fractions - **IMPLEMENTED**
**Status**: ✅ Working
**Description**: Optimized layout for continued fractions
**Test Results**: All tests passed
- Simple `\cfrac{1}{2}` - ✅ Works
- Nested continued fractions - ✅ Works
---
### 8. ❌ `\boldsymbol` - Bold Greek Letters
**Status**: ❌ Not Implemented
**Error**: `Invalid command \boldsymbol`
**Description**: Creates bold Greek letters (whereas `\mathbf` doesn't work for Greek)
**Examples**:
```latex
\boldsymbol{\alpha} % bold alpha
\boldsymbol{\beta} % bold beta
\boldsymbol{\Gamma} % bold Gamma
\mathbf{x} + \boldsymbol{\mu} % mix Roman and Greek bold
```
**Use Case**: Vectors with Greek symbols, bold emphasis for Greek letters
---
### 9. ✅ Starred Matrix Environments: `pmatrix*`, `bmatrix*`, etc. - **IMPLEMENTED**
**Status**: ✅ Working
**Description**: Matrix environments with optional column alignment
**Test Results**: All tests passed
- `\begin{pmatrix*}[r] 1 & 2 \\ 3 & 4 \end{pmatrix*}` - ✅ Works (right align)
- `\begin{bmatrix*}[l] a & b \\ c & d \end{bmatrix*}` - ✅ Works (left align)
- `\begin{vmatrix*}[c] x & y \\ z & w \end{vmatrix*}` - ✅ Works (center align)
- `\begin{matrix*}[r] 10 & 20 \\ 30 & 40 \end{matrix*}` - ✅ Works (no delimiters)
**Alignment Options**: `[r]` = right, `[l]` = left, `[c]` = center
**Use Case**: Financial tables, aligned numerical data in matrices
**Implementation**: Added `readOptionalAlignment()` function, modified `readString()` to accept asterisks, applies alignment to all columns.
---
### 10. ✅ `\smallmatrix` Environment - **IMPLEMENTED**
**Status**: ✅ Working
**Description**: Compact matrix for inline use (smaller than regular matrices)
**Test Results**: All tests passed
- `\left( \begin{smallmatrix} a & b \\ c & d \end{smallmatrix} \right)` - ✅ Works (with delimiters)
- `A = \left( \begin{smallmatrix} 1 & 0 \\ 0 & 1 \end{smallmatrix} \right)` - ✅ Works (identity matrix)
- `\begin{smallmatrix} x \\ y \end{smallmatrix}` - ✅ Works (column vector)
**Use Case**: Inline matrices, transformation matrices in text, compact notation
**Implementation**: Uses `.script` style for smaller font size, tighter column spacing (6 vs 18), no built-in delimiters.
---
## Implementation Priority Recommendations
### Remaining High Priority Features
1. **Spacing commands** (`\,`, `\:`, `\;`, `\!`) - Used in almost all advanced math
2. **Manual delimiter sizing** (`\big`, etc.) - Common in published mathematics
3. **`\middle`** - Useful for conditional notation
### Remaining Medium Priority Features
4. **`\boldsymbol`** - Important for vector notation with Greek letters
---
## Testing Coverage
All tests use the `MTMathListBuilder.build(fromString:error:)` API and automatically skip with `XCTSkip` when features are not implemented.
**Test File**: `Tests/SwiftMathTests/MTMathListBuilderTests.swift`
**Test Functions**:
- `testDisplayStyle()` - ✅ Passed (IMPLEMENTED)
- `testMiddleDelimiter()` - ⏭️ Skipped (not implemented)
- `testSubstack()` - ✅ Passed (IMPLEMENTED)
- `testManualDelimiterSizing()` - ⏭️ Skipped (not implemented)
- `testSpacingCommands()` - ⏭️ Skipped (not implemented)
- `testMultipleIntegrals()` - ✅ Passed (IMPLEMENTED)
- `testContinuedFractions()` - ✅ Passed (IMPLEMENTED)
- `testBoldsymbol()` - ⏭️ Skipped (not implemented)
- `testStarredMatrices()` - ✅ Passed (IMPLEMENTED)
- `testSmallMatrix()` - ✅ Passed (IMPLEMENTED)
---
## Notes for Future Implementation
### For `\middle`:
- Needs integration with existing `\left...\right` delimiter pairing system
- Should support all delimiter types that work with `\left` and `\right`
### For Manual Sizing (`\big`, etc.):
- Needs 4 size levels beyond normal
- Each size approximately 1.2x the previous
- Should work with all delimiter types
### For Spacing Commands:
- Need to insert proper `MTMathSpace` atoms
- Different space types: positive (`\,`, `\:`, `\;`) and negative (`\!`)
- Some might already be partially implemented
### For `\boldsymbol`:
- Needs access to bold math font variants
- Should work with both Greek and other symbols
- Different from `\mathbf` (which changes font family)
---
*Generated: 2025-10-01*
*SwiftMath Version: Based on iosMath v0.9.5*
*Last Updated: 2025-10-01 - Implemented 4 major features: \substack, \smallmatrix, starred matrices, \iiiint*

233
README.md Executable file → Normal file
View File

@@ -114,18 +114,34 @@ struct MathView: UIViewRepresentable {
var fontSize: CGFloat = 30
var labelMode: MTMathUILabelMode = .text
var insets: MTEdgeInsets = MTEdgeInsets()
func makeUIView(context: Context) -> MTMathUILabel {
let view = MTMathUILabel()
view.setContentHuggingPriority(.required, for: .vertical)
view.setContentCompressionResistancePriority(.required, for: .vertical)
return view
}
func updateUIView(_ view: MTMathUILabel, context: Context) {
view.latex = equation
view.font = MTFontManager().font(withName: font.rawValue, size: fontSize)
let font = MTFontManager().font(withName: font.rawValue, size: fontSize)
font?.fallbackFont = UIFont.systemFont(ofSize: fontSize)
view.font = font
view.textAlignment = textAlignment
view.labelMode = labelMode
view.textColor = MTColor(Color.primary)
view.contentInsets = insets
view.invalidateIntrinsicContentSize()
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: MTMathUILabel, context: Context) -> CGSize? {
// Enable line wrapping by passing proposed width to the label
if let width = proposal.width, width.isFinite, width > 0 {
uiView.preferredMaxLayoutWidth = width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
return nil
}
}
```
@@ -143,46 +159,201 @@ struct MathView: NSViewRepresentable {
var fontSize: CGFloat = 30
var labelMode: MTMathUILabelMode = .text
var insets: MTEdgeInsets = MTEdgeInsets()
func makeNSView(context: Context) -> MTMathUILabel {
let view = MTMathUILabel()
view.setContentHuggingPriority(.required, for: .vertical)
view.setContentCompressionResistancePriority(.required, for: .vertical)
return view
}
func updateNSView(_ view: MTMathUILabel, context: Context) {
view.latex = equation
view.font = MTFontManager().font(withName: font.rawValue, size: fontSize)
let font = MTFontManager().font(withName: font.rawValue, size: fontSize)
font?.fallbackFont = NSFont.systemFont(ofSize: fontSize)
view.font = font
view.textAlignment = textAlignment
view.labelMode = labelMode
view.textColor = MTColor(Color.primary)
view.contentInsets = insets
view.invalidateIntrinsicContentSize()
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: MTMathUILabel, context: Context) -> CGSize? {
// Enable line wrapping by passing proposed width to the label
if let width = proposal.width, width.isFinite, width > 0 {
nsView.preferredMaxLayoutWidth = width
let size = nsView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
return nil
}
}
```
### Automatic Line Wrapping
`SwiftMath` supports automatic line wrapping for text and simple math expressions. When the content exceeds the available width, it will wrap at word boundaries to fit within the constrained space.
#### Using Line Wrapping with UIKit/AppKit
For direct `MTMathUILabel` usage, set the `preferredMaxLayoutWidth` property:
```swift
let label = MTMathUILabel()
label.latex = "\\(\\text{Remember the conversion: 1 km equals 1000 meters.}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
// Enable line wrapping by setting a maximum width
label.preferredMaxLayoutWidth = 300
```
You can also use `sizeThatFits` to calculate the size with a width constraint:
```swift
let constrainedSize = label.sizeThatFits(CGSize(width: 300, height: .greatestFiniteMagnitude))
```
#### Using Line Wrapping with SwiftUI
The `MathView` examples above include `sizeThatFits()` which automatically enables line wrapping when SwiftUI proposes a width constraint. No additional configuration is needed:
```swift
VStack(alignment: .leading, spacing: 8) {
MathView(
equation: "\\(\\text{Remember the conversion: 1 km equals 1000 meters.}\\)",
fontSize: 17,
labelMode: .text
)
}
.frame(maxWidth: 300) // The text will wrap to fit within 300pt
```
#### Line Wrapping Behavior
- **Works for**: Text content (`\text{...}`), mixed text with simple math, and simple equations
- **Breaks at**: Word boundaries (spaces)
- **Preserves**: Complex math layout (fractions, superscripts, matrices remain on single lines)
- **Respects**: Unicode text including CJK characters with proper word boundaries
#### Examples
**Simple text wrapping:**
```swift
// Long text will wrap to multiple lines
label.latex = "\\(\\text{The quadratic formula is used to solve equations of the form } ax^2 + bx + c = 0\\)"
label.preferredMaxLayoutWidth = 250
```
**Simple equation with operators:**
```swift
// Long equations can break between operators if too long
label.latex = "\\(5 + 10 + 15 + 20 + 25 + 30\\)"
label.preferredMaxLayoutWidth = 150
// Will wrap: "5 + 10 + 15 + 20 +"
// "25 + 30"
```
**Mixed text and math:**
```swift
// Text wraps but math expressions stay intact
label.latex = "\\(\\text{Result: } 5 \\times 1000 = 5000 \\text{ meters}\\)"
label.preferredMaxLayoutWidth = 200
// Will wrap at spaces between text and operators
```
**Multiple lines in SwiftUI:**
```swift
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(steps) { step in
MathView(
equation: step.description,
fontSize: 17,
labelMode: .text
)
}
}
.padding()
}
// Each MathView will automatically wrap based on available width
```
### Included Features
This is a list of formula types that the library currently supports:
* Simple algebraic equations
* Fractions and continued fractions
* Fractions and continued fractions (including `\cfrac`)
* Exponents and subscripts
* Trigonometric formulae
* Square roots and n-th roots
* Calculus symbos - limits, derivatives, integrals
* Calculus symbols - limits, derivatives, integrals (including `\iint`, `\iiint`, `\iiiint`)
* Big operators (e.g. product, sum)
* Big delimiters (using \\left and \\right)
* Big delimiters (using `\left` and `\right`)
* Greek alphabet
* Combinatorics (\\binom, \\choose etc.)
* Combinatorics (`\binom`, `\choose` etc.)
* Geometry symbols (e.g. angle, congruence etc.)
* Ratios, proportions, percentages
* Math spacing
* Overline and underline
* Math accents
* Matrices
* Matrices (including `\smallmatrix` and starred variants like `pmatrix*` with alignment)
* Multi-line subscripts and limits (`\substack`)
* Equation alignment
* Change bold, roman, caligraphic and other font styles (\\bf, \\text, etc.)
* Change bold, roman, caligraphic and other font styles (`\bf`, `\text`, etc.)
* Style commands (`\displaystyle`, `\textstyle`)
* 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
@@ -262,6 +433,37 @@ It is also possible to set different colors for different parts of the
equation. Just access the `displayList` field and set the `textColor`
of the underlying displays of which you want to change the color.
##### Fallback Font for Unicode Text
By default, math fonts only support a limited set of characters (Latin, Greek, common math symbols).
To display other Unicode characters like Chinese, Japanese, Korean, emoji, or other scripts in `\text{}`
commands, you can configure a fallback font:
```swift
let mathFont = MTFontManager().font(withName: MathFont.latinModernFont.rawValue, size: 30)
// Set a fallback font for unsupported characters (defaults to nil)
#if os(iOS) || os(visionOS)
let systemFont = UIFont.systemFont(ofSize: 30)
mathFont?.fallbackFont = CTFontCreateWithName(systemFont.fontName as CFString, 30, nil)
#elseif os(macOS)
let systemFont = NSFont.systemFont(ofSize: 30)
mathFont?.fallbackFont = CTFontCreateWithName(systemFont.fontName as CFString, 30, nil)
#endif
label.font = mathFont
label.latex = "\\text{Hello 世界 🌍}" // English, Chinese, and emoji
```
When the main math font doesn't contain a glyph for a character, the fallback font will be used automatically.
This is particularly useful for:
- Chinese text: `\text{中文}`
- Japanese text: `\text{日本語}`
- Korean text: `\text{한국어}`
- Emoji: `\text{Math is fun! 🎉📐}`
- Mixed scripts: `\text{Equation: 方程式}`
**Note**: The fallback font only applies to characters within `\text{}` commands, not regular math mode.
##### Custom Commands
You can define your own commands that are not already predefined. This is
similar to macros is LaTeX. To define your own command use:
@@ -301,8 +503,13 @@ Note this is not a complete implementation of LaTeX math mode. There are
some important pieces that are missing and will be included in future
updates. This includes:
* Support for explicit big delimiters (bigl, bigr etc.)
* Addition of missing plain TeX commands
* Support for explicit big delimiters (`\big`, `\Big`, `\bigg`, `\Bigg`, etc.)
* `\middle` delimiter for use between `\left` and `\right`
* Fine spacing commands (`\,`, `\:`, `\;`, `\!`)
* Bold symbol command (`\boldsymbol`)
* Addition of missing plain TeX commands
For a complete list of missing features and their implementation status, see [MISSING_FEATURES.md](MISSING_FEATURES.md).
## License

21
Sources/SwiftMath/MathBundle/MathFont.swift Executable file → Normal file
View File

@@ -41,11 +41,28 @@ public enum MathFont: String, CaseIterable, Identifiable {
case .firaFont: "Fira Math"
case .notoSansFont: "Noto Sans Math"
case .libertinusFont: "Libertinus Math"
case .garamondFont: "Garamond Math"
case .garamondFont: "Garamond-Math" // PostScript name is "Garamond-Math", not "Garamond Math"
case .leteSansFont: "Lete Sans Math"
}
}
var postScriptName: String {
switch self {
case .latinModernFont: "LatinModernMath-Regular"
case .kpMathLightFont: "KpMath-Light"
case .kpMathSansFont: "KpMath-Sans"
case .xitsFont: "XITSMath"
case .termesFont: "TeXGyreTermesMath-Regular"
case .asanaFont: "Asana-Math"
case .eulerFont: "Euler-Math"
case .firaFont: "FiraMath-Regular"
case .notoSansFont: "NotoSansMath-Regular"
case .libertinusFont: "LibertinusMath-Regular"
case .garamondFont: "Garamond-Math"
case .leteSansFont: "LeteSansMath"
}
}
var fontName: String { self.rawValue }
public func cgFont() -> CGFont {

10
Sources/SwiftMath/MathRender/MTFont.swift Executable file → Normal file
View File

@@ -11,12 +11,18 @@ import CoreText
//
public class MTFont {
var defaultCGFont: CGFont!
var ctFont: CTFont!
var mathTable: MTFontMathTable?
var rawMathTable: NSDictionary?
/// Fallback font for characters not supported by the main math font.
/// Defaults to the system font at the same size. This is particularly useful
/// for rendering text in \text{} commands with characters outside the math font's coverage
/// (e.g., Chinese, Japanese, Korean, emoji, etc.)
public var fallbackFont: CTFont?
init() {}
/// `MTFont(fontWithName:)` does not load the complete math font, it only has about half the glyphs of the full math font.

53
Sources/SwiftMath/MathRender/MTMathAtomFactory.swift Executable file → Normal file
View File

@@ -257,6 +257,7 @@ public class MTMathAtomFactory {
"sqsupseteq" : MTMathAtom(type: .relation, value: "\u{2292}"),
"models" : MTMathAtom(type: .relation, value: "\u{22A7}"),
"perp" : MTMathAtom(type: .relation, value: "\u{27C2}"),
"implies" : MTMathAtom(type: .relation, value: "\u{27F9}"),
// operators
"times" : MTMathAtomFactory.times(),
@@ -309,6 +310,7 @@ public class MTMathAtomFactory {
"hom" : MTMathAtomFactory.operatorWithName( "hom", limits: false),
"exp" : MTMathAtomFactory.operatorWithName( "exp", limits: false),
"deg" : MTMathAtomFactory.operatorWithName( "deg", limits: false),
"mod" : MTMathAtomFactory.operatorWithName("mod", limits: false),
// Limit operators
"lim" : MTMathAtomFactory.operatorWithName( "lim", limits: true),
@@ -327,6 +329,9 @@ public class MTMathAtomFactory {
"coprod" : MTMathAtomFactory.operatorWithName( "\u{2210}", limits: true),
"sum" : MTMathAtomFactory.operatorWithName( "\u{2211}", limits: true),
"int" : MTMathAtomFactory.operatorWithName( "\u{222B}", limits: false),
"iint" : MTMathAtomFactory.operatorWithName( "\u{222C}", limits: false),
"iiint" : MTMathAtomFactory.operatorWithName( "\u{222D}", limits: false),
"iiiint" : MTMathAtomFactory.operatorWithName( "\u{2A0C}", limits: false),
"oint" : MTMathAtomFactory.operatorWithName( "\u{222E}", limits: false),
"bigwedge" : MTMathAtomFactory.operatorWithName( "\u{22C0}", limits: true),
"bigvee" : MTMathAtomFactory.operatorWithName( "\u{22C1}", limits: true),
@@ -382,6 +387,7 @@ public class MTMathAtomFactory {
"aleph" : MTMathAtom(type: .ordinary, value: "\u{2135}"),
"forall" : MTMathAtom(type: .ordinary, value: "\u{2200}"),
"exists" : MTMathAtom(type: .ordinary, value: "\u{2203}"),
"nexists" : MTMathAtom(type: .ordinary, value: "\u{2204}"),
"emptyset" : MTMathAtom(type: .ordinary, value: "\u{2205}"),
"nabla" : MTMathAtom(type: .ordinary, value: "\u{2207}"),
"infty" : MTMathAtom(type: .ordinary, value: "\u{221E}"),
@@ -786,7 +792,15 @@ public class MTMathAtomFactory {
"bmatrix": ["[", "]"],
"Bmatrix": ["{", "}"],
"vmatrix": ["vert", "vert"],
"Vmatrix": ["Vert", "Vert"]
"Vmatrix": ["Vert", "Vert"],
"smallmatrix": [],
// Starred versions with optional alignment
"matrix*": [],
"pmatrix*": ["(", ")"],
"bmatrix*": ["[", "]"],
"Bmatrix*": ["{", "}"],
"vmatrix*": ["vert", "vert"],
"Vmatrix*": ["Vert", "Vert"]
]
/** Builds a table for a given environment with the given rows. Returns a `MTMathAtom` containing the
@@ -796,9 +810,9 @@ public class MTMathAtomFactory {
@note The reason this function returns a `MTMathAtom` and not a `MTMathTable` is because some
matrix environments are have builtin delimiters added to the table and hence are returned as inner atoms.
*/
public static func table(withEnvironment env: String?, rows: [[MTMathList]], error:inout NSError?) -> MTMathAtom? {
public static func table(withEnvironment env: String?, alignment: MTColumnAlignment? = nil, rows: [[MTMathList]], error:inout NSError?) -> MTMathAtom? {
let table = MTMathTable(environment: env)
for i in 0..<rows.count {
let row = rows[i]
for j in 0..<row.count {
@@ -816,17 +830,28 @@ public class MTMathAtomFactory {
} else if let env = env {
if let delims = matrixEnvs[env] {
table.environment = "matrix"
// smallmatrix uses script style and tighter spacing for inline use
let isSmallMatrix = (env == "smallmatrix")
table.interRowAdditionalSpacing = 0
table.interColumnSpacing = 18
let style = MTMathStyle(style: .text)
table.interColumnSpacing = isSmallMatrix ? 6 : 18
let style = MTMathStyle(style: isSmallMatrix ? .script : .text)
for i in 0..<table.cells.count {
for j in 0..<table.cells[i].count {
table.cells[i][j].insert(style, at: 0)
}
}
// Apply alignment for starred matrix environments
if let align = alignment {
for col in 0..<table.numColumns {
table.set(alignment: align, forColumn: col)
}
}
if delims.count == 2 {
let inner = MTInner()
inner.leftBoundary = Self.boundary(forDelimiter: delims[0])
@@ -893,19 +918,21 @@ public class MTMathAtomFactory {
return table
} else if env == "cases" {
if table.numColumns != 2 {
let message = "cases environment can only have 2 columns"
if table.numColumns != 1 && table.numColumns != 2 {
let message = "cases environment can have 1 or 2 columns"
if error == nil {
error = NSError(domain: MTParseError, code: MTParseErrors.invalidNumColumns.rawValue, userInfo: [NSLocalizedDescriptionKey:message])
}
return nil
}
table.interRowAdditionalSpacing = 0
table.interColumnSpacing = 18
table.set(alignment: .left, forColumn: 0)
table.set(alignment: .left, forColumn: 1)
if table.numColumns == 2 {
table.set(alignment: .left, forColumn: 1)
}
let style = MTMathStyle(style: .text)
for i in 0..<table.cells.count {

6
Sources/SwiftMath/MathRender/MTMathList.swift Executable file → Normal file
View File

@@ -328,6 +328,10 @@ public class MTFraction: MTMathAtom {
public var rightDelimiter = ""
public var numerator: MTMathList?
public var denominator: MTMathList?
// Continued fraction properties
public var isContinuedFraction: Bool = false
public var alignment: String = "c" // "l", "r", "c" for left, right, center
init(_ frac: MTFraction?) {
super.init(frac)
@@ -338,6 +342,8 @@ public class MTFraction: MTMathAtom {
self.hasRule = frac.hasRule
self.leftDelimiter = frac.leftDelimiter
self.rightDelimiter = frac.rightDelimiter
self.isContinuedFraction = frac.isContinuedFraction
self.alignment = frac.alignment
}
}

344
Sources/SwiftMath/MathRender/MTMathListBuilder.swift Executable file → Normal file
View File

@@ -15,11 +15,13 @@ struct MTEnvProperties {
var envName: String?
var ended: Bool
var numRows: Int
init(name: String?) {
var alignment: MTColumnAlignment? // Optional alignment for starred matrix environments
init(name: String?, alignment: MTColumnAlignment? = nil) {
self.envName = name
self.numRows = 0
self.ended = false
self.alignment = alignment
}
}
@@ -66,13 +68,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?
@@ -94,6 +105,44 @@ public struct MTMathListBuilder {
currentCharIndex = string.index(before: currentCharIndex)
}
}
// Peek at next command without consuming it (for \not lookahead)
mutating func peekNextCommand() -> String {
let savedIndex = currentCharIndex
skipSpaces()
guard hasCharacters else {
currentCharIndex = savedIndex
return ""
}
let char = getNextCharacter()
let command: String
if char == "\\" {
command = readCommand()
} else {
command = ""
}
// Restore position
currentCharIndex = savedIndex
return command
}
// Consume the next command (after peeking)
mutating func consumeNextCommand() {
skipSpaces()
guard hasCharacters else { return }
let char = getNextCharacter()
if char == "\\" {
_ = readCommand()
}
}
mutating func expectCharacter(_ ch: Character) -> Bool {
MTAssertNotSpace(ch)
@@ -127,6 +176,24 @@ public struct MTMathListBuilder {
.script: "scriptstyle",
.scriptOfScript: "scriptscriptstyle"
]
// Comprehensive mapping of \not command combinations to Unicode negated symbols
public static let notCombinations: [String: String] = [
// Primary targets (user requested)
"equiv": "\u{2262}", // Not equivalent
"subset": "\u{2284}", // Not subset
"in": "\u{2209}", // Not element of
// Additional standard negations
"sim": "\u{2241}", // Not similar
"approx": "\u{2249}", // Not approximately equal
"cong": "\u{2247}", // Not congruent
"parallel": "\u{2226}", // Not parallel
"subseteq": "\u{2288}", // Not subset or equal
"supset": "\u{2285}", // Not superset
"supseteq": "\u{2289}", // Not superset or equal
"=": "\u{2260}", // Not equal (alternative to \neq)
]
init(string: String) {
self.error = nil
@@ -135,11 +202,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)")
@@ -148,6 +270,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
}
@@ -238,6 +368,13 @@ public struct MTMathListBuilder {
// \ means a command
assert(!oneCharOnly, "This should have been handled before")
assert(stop == nil, "This should have been handled before")
// Special case: } terminates implicit table (envName == nil) created by \\
// This happens when \\ is used inside braces: \substack{a \\ b}
if self.currentEnv != nil && self.currentEnv!.envName == nil {
// Mark environment as ended, don't consume the }
self.currentEnv!.ended = true
return list
}
// We encountered a closing brace when there is no stop set, that means there was no
// corresponding opening brace.
self.setError(.mismatchBraces, message:"Mismatched braces.")
@@ -301,8 +438,15 @@ public struct MTMathListBuilder {
} else {
atom = MTMathAtomFactory.atom(forCharacter: char)
if atom == nil {
// Not a recognized character
continue
// Not a recognized character in standard math mode
// In text mode (spacesAllowed && roman style), accept any Unicode character for fallback font support
// This enables Chinese, Japanese, Korean, emoji, etc. in \text{} commands
if spacesAllowed && currentFontStyle == .roman {
atom = MTMathAtom(type: .ordinary, value: String(char))
} else {
// In math mode or non-text commands, skip unrecognized characters
continue
}
}
}
@@ -349,7 +493,14 @@ public struct MTMathListBuilder {
}
if atom.type == .fraction {
if let frac = atom as? MTFraction {
if frac.hasRule {
if frac.isContinuedFraction {
// Generate \cfrac with optional alignment
if frac.alignment != "c" {
str += "\\cfrac[\(frac.alignment)]{\(mathListToString(frac.numerator!))}{\(mathListToString(frac.denominator!))}"
} else {
str += "\\cfrac{\(mathListToString(frac.numerator!))}{\(mathListToString(frac.denominator!))}"
}
} else if frac.hasRule {
str += "\\frac{\(mathListToString(frac.numerator!))}{\(mathListToString(frac.denominator!))}"
} else {
let command: String
@@ -526,6 +677,28 @@ public struct MTMathListBuilder {
} else if command == "frac" {
// A fraction command has 2 arguments
let frac = MTFraction()
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac;
} else if command == "cfrac" {
// A continued fraction command with optional alignment and 2 arguments
let frac = MTFraction()
frac.isContinuedFraction = true
// Parse optional alignment parameter [l], [r], [c]
skipSpaces()
if hasCharacters && string[currentCharIndex] == "[" {
_ = getNextCharacter() // consume '['
let alignmentChar = getNextCharacter()
if alignmentChar == "l" || alignmentChar == "r" || alignmentChar == "c" {
frac.alignment = String(alignmentChar)
}
// Consume closing ']'
if hasCharacters && string[currentCharIndex] == "]" {
_ = getNextCharacter()
}
}
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac;
@@ -583,6 +756,35 @@ public struct MTMathListBuilder {
let under = MTUnderLine()
under.innerList = self.buildInternal(true)
return under
} else if command == "substack" {
// \substack reads ONE braced argument containing rows separated by \\
// Similar to how \frac reads {numerator}{denominator}
// Read the braced content using standard pattern
let content = self.buildInternal(true)
if content == nil {
return nil
}
// The content may already be a table if \\ was encountered
// Check if we got a table from the \\ parsing
if content!.atoms.count == 1, let tableAtom = content!.atoms.first as? MTMathTable {
return tableAtom
}
// Otherwise, single row - wrap in table
var rows = [[MTMathList]]()
rows.append([content!])
var error: NSError? = self.error
let table = MTMathAtomFactory.table(withEnvironment: nil, rows: rows, error: &error)
if table == nil && self.error == nil {
self.error = error
return nil
}
return table
} else if command == "begin" {
let env = self.readEnvironment()
if env == nil {
@@ -620,6 +822,42 @@ public struct MTMathListBuilder {
mathColorbox.colorString = color!
mathColorbox.innerList = self.buildInternal(true)
return mathColorbox
} else if command == "pmod" {
// A pmod command has 1 argument - creates (mod n)
let inner = MTInner()
inner.leftBoundary = MTMathAtomFactory.boundary(forDelimiter: "(")
inner.rightBoundary = MTMathAtomFactory.boundary(forDelimiter: ")")
let innerList = MTMathList()
// Add the "mod" operator (upright text)
let modOperator = MTMathAtomFactory.atom(forLatexSymbol: "mod")!
innerList.add(modOperator)
// Add medium space between "mod" and argument (6mu)
let space = MTMathSpace(space: 6.0)
innerList.add(space)
// Parse the argument from braces
let argument = self.buildInternal(true)
if let argList = argument {
innerList.append(argList)
}
inner.innerList = innerList
return inner
} else if command == "not" {
// Handle \not command with lookahead for comprehensive negation support
let nextCommand = self.peekNextCommand()
if let negatedUnicode = Self.notCombinations[nextCommand] {
self.consumeNextCommand() // Remove base symbol from stream
return MTMathAtom(type: .relation, value: negatedUnicode)
} else {
let errorMessage = "Unsupported \\not\\\(nextCommand) combination"
self.setError(.invalidCommand, message: errorMessage)
return nil
}
} else {
let errorMessage = "Invalid command \\\(command)"
self.setError(.invalidCommand, message:errorMessage)
@@ -786,6 +1024,27 @@ public struct MTMathListBuilder {
return accent
} else if command == "frac" {
let frac = MTFraction()
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac
} else if command == "cfrac" {
let frac = MTFraction()
frac.isContinuedFraction = true
// Parse optional alignment parameter [l], [r], [c]
skipSpaces()
if hasCharacters && string[currentCharIndex] == "[" {
_ = getNextCharacter() // consume '['
let alignmentChar = getNextCharacter()
if alignmentChar == "l" || alignmentChar == "r" || alignmentChar == "c" {
frac.alignment = String(alignmentChar)
}
// Consume closing ']'
if hasCharacters && string[currentCharIndex] == "]" {
_ = getNextCharacter()
}
}
frac.numerator = self.buildInternal(true)
frac.denominator = self.buildInternal(true)
return frac
@@ -834,7 +1093,16 @@ public struct MTMathListBuilder {
return under
} else if command == "begin" {
if let env = self.readEnvironment() {
let table = self.buildTable(env: env, firstList: nil, isRow: false)
// Check if this is a starred matrix environment and read optional alignment
var alignment: MTColumnAlignment? = nil
if env.hasSuffix("*") {
alignment = self.readOptionalAlignment()
if self.error != nil {
return nil
}
}
let table = self.buildTable(env: env, alignment: alignment, firstList: nil, isRow: false)
return table
} else {
return nil
@@ -863,10 +1131,10 @@ public struct MTMathListBuilder {
self.setError(.characterNotFound, message: "Missing {")
return nil
}
self.skipSpaces()
let env = self.readString()
if !self.expectCharacter("}") {
// We didn"t find an closing brace, so invalid format.
self.setError(.characterNotFound, message: "Missing }")
@@ -874,16 +1142,58 @@ public struct MTMathListBuilder {
}
return env
}
/// Reads optional alignment parameter for starred matrix environments: [r], [l], or [c]
mutating func readOptionalAlignment() -> MTColumnAlignment? {
self.skipSpaces()
// Check if there's an opening bracket
guard hasCharacters && string[currentCharIndex] == "[" else {
return nil
}
_ = getNextCharacter() // consume '['
self.skipSpaces()
guard hasCharacters else {
self.setError(.characterNotFound, message: "Missing alignment specifier after [")
return nil
}
let alignChar = getNextCharacter()
let alignment: MTColumnAlignment?
switch alignChar {
case "l":
alignment = .left
case "c":
alignment = .center
case "r":
alignment = .right
default:
self.setError(.invalidEnv, message: "Invalid alignment specifier: \(alignChar). Must be l, c, or r")
return nil
}
self.skipSpaces()
if !self.expectCharacter("]") {
self.setError(.characterNotFound, message: "Missing ] after alignment specifier")
return nil
}
return alignment
}
func MTAssertNotSpace(_ ch: Character) {
assert(ch >= "\u{21}" && ch <= "\u{7E}", "Expected non-space character \(ch)")
}
mutating func buildTable(env: String?, firstList: MTMathList?, isRow: Bool) -> MTMathAtom? {
mutating func buildTable(env: String?, alignment: MTColumnAlignment? = nil, firstList: MTMathList?, isRow: Bool) -> MTMathAtom? {
// Save the current env till an new one gets built.
let oldEnv = self.currentEnv
currentEnv = MTEnvProperties(name: env)
currentEnv = MTEnvProperties(name: env, alignment: alignment)
var currentRow = 0
var currentCol = 0
@@ -921,7 +1231,7 @@ public struct MTMathListBuilder {
}
var error:NSError? = self.error
let table = MTMathAtomFactory.table(withEnvironment: currentEnv?.envName, rows: rows, error: &error)
let table = MTMathAtomFactory.table(withEnvironment: currentEnv?.envName, alignment: currentEnv?.alignment, rows: rows, error: &error)
if table == nil && self.error == nil {
self.error = error
return nil
@@ -978,11 +1288,11 @@ public struct MTMathListBuilder {
}
mutating func readString() -> String {
// a string of all upper and lower case characters.
// a string of all upper and lower case characters (and asterisks for starred environments)
var output = ""
while self.hasCharacters {
let char = self.getNextCharacter()
if char.isLowercase || char.isUppercase {
if char.isLowercase || char.isUppercase || char == "*" {
output.append(char)
} else {
self.unlookCharacter()

64
Sources/SwiftMath/MathRender/MTMathUILabel.swift Executable file → Normal file
View File

@@ -179,7 +179,19 @@ public class MTMathUILabel : MTView {
/** The internal display of the MTMathUILabel. This is for advanced use only. */
public var displayList: MTMathListDisplay? { _displayList }
private var _displayList:MTMathListDisplay?
/** The preferred maximum width (in points) for a multiline label.
Set this property to enable line wrapping based on available width. */
public var preferredMaxLayoutWidth: CGFloat {
set {
_preferredMaxLayoutWidth = newValue
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _preferredMaxLayoutWidth }
}
private var _preferredMaxLayoutWidth: CGFloat = 0
public var currentStyle:MTLineStyle {
switch _labelMode {
case .display: return .display
@@ -241,8 +253,12 @@ public class MTMathUILabel : MTView {
func _layoutSubviews() {
if _mathList != nil {
// Use the effective width for layout
let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width
let availableWidth = effectiveWidth - contentInsets.left - contentInsets.right
// print("Pre list = \(_mathList!)")
_displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle)
_displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: availableWidth)
_displayList!.textColor = textColor
// print("Post list = \(_mathList!)")
var textX = CGFloat(0)
@@ -252,7 +268,7 @@ public class MTMathUILabel : MTView {
case .right: textX = bounds.size.width - _displayList!.width - contentInsets.right
}
let availableHeight = bounds.size.height - contentInsets.bottom - contentInsets.top
// center things vertically
var height = _displayList!.ascent + _displayList!.descent
if height < fontSize/2 {
@@ -268,19 +284,47 @@ public class MTMathUILabel : MTView {
}
func _sizeThatFits(_ size:CGSize) -> CGSize {
guard _mathList != nil else { return size }
var size = size
guard _mathList != nil else {
// No content - return no-intrinsic-size marker
return CGSize(width: -1, height: -1)
}
// Determine the maximum width to use
var maxWidth: CGFloat = 0
if _preferredMaxLayoutWidth > 0 {
maxWidth = _preferredMaxLayoutWidth - contentInsets.left - contentInsets.right
} else if size.width > 0 {
maxWidth = size.width - contentInsets.left - contentInsets.right
}
var displayList:MTMathListDisplay? = nil
displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle)
size.width = displayList!.width + contentInsets.left + contentInsets.right
size.height = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom
return size
displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: maxWidth)
guard displayList != nil else {
// Failed to create display list
return CGSize(width: -1, height: -1)
}
let resultWidth = displayList!.width + contentInsets.left + contentInsets.right
let resultHeight = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom
return CGSize(width: resultWidth, height: resultHeight)
}
#if os(macOS)
public func sizeThatFits(_ size: CGSize) -> CGSize {
return _sizeThatFits(size)
}
#else
public override func sizeThatFits(_ size: CGSize) -> CGSize {
return _sizeThatFits(size)
}
#endif
#if os(macOS)
func setNeedsDisplay() { self.needsDisplay = true }
func setNeedsLayout() { self.needsLayout = true }
public override var fittingSize: CGSize { _sizeThatFits(CGSizeZero) }
public override var intrinsicContentSize: CGSize { _sizeThatFits(CGSizeZero) }
override public var isFlipped: Bool { false }
override public func layout() {
self._layoutSubviews()

246
Sources/SwiftMath/MathRender/MTTypesetter.swift Executable file → Normal file
View File

@@ -362,23 +362,40 @@ class MTTypesetter {
}
var cramped = false
var spaced = false
var maxWidth: CGFloat = 0 // Maximum width for line breaking, 0 means no constraint
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle) -> MTMathListDisplay? {
let finalizedList = mathList?.finalized
// default is not cramped
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false)
// default is not cramped, no width constraint
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false, maxWidth: 0)
}
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, maxWidth:CGFloat) -> MTMathListDisplay? {
let finalizedList = mathList?.finalized
// default is not cramped
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false, maxWidth: maxWidth)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool) -> MTMathListDisplay? {
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false)
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false, maxWidth: 0)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, maxWidth:CGFloat) -> MTMathListDisplay? {
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false, maxWidth: maxWidth)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool) -> MTMathListDisplay? {
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:spaced, maxWidth: 0)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool, maxWidth:CGFloat) -> MTMathListDisplay? {
assert(font != nil)
let preprocessedAtoms = self.preprocessMathList(mathList)
let typesetter = MTTypesetter(withFont:font, style:style, cramped:cramped, spaced:spaced)
let typesetter = MTTypesetter(withFont:font, style:style, cramped:cramped, spaced:spaced, maxWidth: maxWidth)
typesetter.createDisplayAtoms(preprocessedAtoms)
let lastAtom = mathList!.atoms.last
let last = lastAtom?.indexRange ?? NSMakeRange(0, 0)
@@ -387,13 +404,14 @@ class MTTypesetter {
}
static var placeholderColor: MTColor { MTColor.blue }
init(withFont font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool) {
init(withFont font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool, maxWidth:CGFloat = 0) {
self.font = font
self.displayAtoms = [MTDisplay]()
self.currentPosition = CGPoint.zero
self.cramped = cramped
self.spaced = spaced
self.maxWidth = maxWidth
self.currentLine = NSMutableAttributedString()
self.currentAtoms = [MTMathAtom]()
self.style = style
@@ -662,22 +680,78 @@ class MTTypesetter {
}
case .accent:
// stash the existing layout
if currentLine.length > 0 {
self.addDisplayLine()
}
// Accent is considered as Ord in rule 16.
self.addInterElementSpace(prevNode, currentType:.ordinary)
atom.type = .ordinary;
let accent = atom as! MTAccent?
let display = self.makeAccent(accent)
displayAtoms.append(display!)
currentPosition.x += display!.width;
// add super scripts || subscripts
if atom.subScript != nil || atom.superScript != nil {
self.makeScripts(atom, display:display, index:UInt(atom.indexRange.location), delta:0)
if maxWidth > 0 {
// When line wrapping is enabled, render the accent properly but inline
// to avoid premature line flushing
let accent = atom as! MTAccent
// Get the base character from innerList
var baseChar = ""
if let innerList = accent.innerList, !innerList.atoms.isEmpty {
// Convert innerList to string
baseChar = MTMathListBuilder.mathListToString(innerList)
}
// Combine base character with accent to create proper composed character
let accentChar = atom.nucleus
let composedString = baseChar + accentChar
// Normalize to composed form (NFC) to get proper accented character
let normalizedString = composedString.precomposedStringWithCanonicalMapping
// Add inter-element spacing
if prevNode != nil {
let interElementSpace = self.getInterElementSpace(prevNode!.type, right:.ordinary)
if currentLine.length > 0 {
if interElementSpace > 0 {
currentLine.addAttribute(kCTKernAttributeName as NSAttributedString.Key,
value:NSNumber(floatLiteral: interElementSpace),
range:currentLine.mutableString.rangeOfComposedCharacterSequence(at: currentLine.length-1))
}
} else {
currentPosition.x += interElementSpace
}
}
// Add the properly composed accented character
let current = NSAttributedString(string:normalizedString)
currentLine.append(current)
// Check if we should break the line
self.checkAndBreakLine()
// Add to atom list
if currentLineIndexRange.location == NSNotFound {
currentLineIndexRange = atom.indexRange
} else {
currentLineIndexRange.length += atom.indexRange.length
}
currentAtoms.append(atom)
// Treat accent as ordinary for spacing purposes
atom.type = .ordinary
} else {
// Original behavior when no width constraint
// Check if we need to break the line due to width constraints
self.checkAndBreakLine()
// stash the existing layout
if currentLine.length > 0 {
self.addDisplayLine()
}
// Accent is considered as Ord in rule 16.
self.addInterElementSpace(prevNode, currentType:.ordinary)
atom.type = .ordinary;
let accent = atom as! MTAccent?
let display = self.makeAccent(accent)
displayAtoms.append(display!)
currentPosition.x += display!.width;
// add super scripts || subscripts
if atom.subScript != nil || atom.superScript != nil {
self.makeScripts(atom, display:display, index:UInt(atom.indexRange.location), delta:0)
}
}
case .table:
@@ -720,7 +794,57 @@ class MTTypesetter {
} else {
current = NSAttributedString(string:atom.nucleus)
}
currentLine.append(current!)
// Universal line breaking: only for simple atoms (no scripts)
// This works for text, mixed text+math, and simple equations
let isSimpleAtom = (atom.subScript == nil && atom.superScript == nil)
if isSimpleAtom && maxWidth > 0 {
// Measure the current line width
let attrString = currentLine.mutableCopy() as! NSMutableAttributedString
attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, attrString.length))
let ctLine = CTLineCreateWithAttributedString(attrString)
let lineWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
if lineWidth > maxWidth {
// Line is too wide - need to find a break point
let currentText = currentLine.string
// Look for the last space before the current position
if let lastSpaceIndex = currentText.lastIndex(of: " ") {
// Split the line at the last space
let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex)
// Create attributed string for the first line (before space)
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length))
// Keep track of atoms that belong to the first line
// For simplicity, we'll split atoms at the boundary (this is approximate)
let firstLineAtoms = currentAtoms
// Flush the first line
currentLine = firstLine
currentAtoms = firstLineAtoms
self.addDisplayLine()
// Move down for new line and reset x position
currentPosition.y -= styleFont.fontSize * 1.5
currentPosition.x = 0
// Start the new line with the content after the space
let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex)))
currentLine = NSMutableAttributedString(string: remainingText)
// Reset atom list for new line
currentAtoms = []
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
}
// If no space found, let it overflow (better than breaking mid-word)
}
}
// add the atom to the current range
if currentLineIndexRange.location == NSNotFound {
currentLineIndexRange = atom.indexRange
@@ -767,6 +891,52 @@ class MTTypesetter {
}
}
/// Check if the current line exceeds maxWidth and break if needed
func checkAndBreakLine() {
guard maxWidth > 0 && currentLine.length > 0 else { return }
// Measure the current line width
let attrString = currentLine.mutableCopy() as! NSMutableAttributedString
attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, attrString.length))
let ctLine = CTLineCreateWithAttributedString(attrString)
let lineWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
guard lineWidth > maxWidth else { return }
// Line is too wide - need to find a break point
let currentText = currentLine.string
// Look for the last space before the current position
if let lastSpaceIndex = currentText.lastIndex(of: " ") {
// Split the line at the last space
let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex)
// Create attributed string for the first line (before space)
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length))
// Keep track of atoms that belong to the first line
let firstLineAtoms = currentAtoms
// Flush the first line
currentLine = firstLine
currentAtoms = firstLineAtoms
self.addDisplayLine()
// Move down for new line and reset x position
currentPosition.y -= styleFont.fontSize * 1.5
currentPosition.x = 0
// Start the new line with the content after the space
let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex)))
currentLine = NSMutableAttributedString(string: remainingText)
// Reset atom list for new line
currentAtoms = []
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
}
}
@discardableResult
func addDisplayLine() -> MTCTLineDisplay? {
// add the font
@@ -990,9 +1160,22 @@ class MTTypesetter {
func makeFraction(_ frac:MTFraction?) -> MTDisplay? {
// lay out the parts of the fraction
let fractionStyle = self.fractionStyle;
let numeratorDisplay = MTTypesetter.createLineForMathList(frac!.numerator, font:font, style:fractionStyle(), cramped:false)
let denominatorDisplay = MTTypesetter.createLineForMathList(frac!.denominator, font:font, style:fractionStyle(), cramped:true)
let numeratorStyle: MTLineStyle
let denominatorStyle: MTLineStyle
if frac!.isContinuedFraction {
// Continued fractions always use display style
numeratorStyle = .display
denominatorStyle = .display
} else {
// Regular fractions use adaptive style
let fractionStyle = self.fractionStyle;
numeratorStyle = fractionStyle()
denominatorStyle = fractionStyle()
}
let numeratorDisplay = MTTypesetter.createLineForMathList(frac!.numerator, font:font, style:numeratorStyle, cramped:false)
let denominatorDisplay = MTTypesetter.createLineForMathList(frac!.denominator, font:font, style:denominatorStyle, cramped:true)
// determine the location of the numerator
var numeratorShiftUp = self.numeratorShiftUp(frac!.hasRule)
@@ -1263,11 +1446,18 @@ class MTTypesetter {
func findGlyphForCharacterAtIndex(_ index:String.Index, inString str:String) -> CGGlyph {
// Get the character at index taking into account UTF-32 characters
var chars = Array(str[index].utf16)
// Get the glyph from the font
var glyph = [CGGlyph](repeating: CGGlyph.zero, count: chars.count)
let found = CTFontGetGlyphsForCharacters(styleFont.ctFont, &chars, &glyph, chars.count)
if !found {
// Try fallback font if available
if let fallbackFont = styleFont.fallbackFont {
let fallbackFound = CTFontGetGlyphsForCharacters(fallbackFont, &chars, &glyph, chars.count)
if fallbackFound {
return glyph[0]
}
}
// the font did not contain a glyph for our character, so we just return 0 (notdef)
return 0
}

1027
Tests/SwiftMathTests/MTMathListBuilderTests.swift Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
//
// MTMathUILabelLineWrappingTests.swift
// SwiftMathTests
//
// Tests for line wrapping functionality in MTMathUILabel
//
import XCTest
@testable import SwiftMath
class MTMathUILabelLineWrappingTests: XCTestCase {
func testBasicIntrinsicContentSize() {
let label = MTMathUILabel()
label.latex = "\\(x + y\\)"
label.font = MTFontManager.fontManager.defaultFont
// Debug: check if parsing worked
XCTAssertNotNil(label.mathList, "Math list should not be nil")
XCTAssertNil(label.error, "Should have no parsing error, got: \(String(describing: label.error))")
XCTAssertNotNil(label.font, "Font should not be nil")
let size = label.intrinsicContentSize
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
}
func testTextModeIntrinsicContentSize() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Hello World}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
let size = label.intrinsicContentSize
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
}
func testLongTextIntrinsicContentSize() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
let size = label.intrinsicContentSize
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
}
func testSizeThatFitsWithoutConstraint() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Hello World}\\)"
label.font = MTFontManager.fontManager.defaultFont
let size = label.sizeThatFits(CGSize.zero)
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
}
func testSizeThatFitsWithWidthConstraint() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
// Get unconstrained size first
let unconstrainedSize = label.sizeThatFits(CGSize.zero)
XCTAssertGreaterThan(unconstrainedSize.width, 0, "Unconstrained width should be > 0")
// Test with width constraint (use 300 since longest word might be ~237pt)
let constrainedSize = label.sizeThatFits(CGSize(width: 300, height: CGFloat.greatestFiniteMagnitude))
XCTAssertGreaterThan(constrainedSize.width, 0, "Constrained width should be greater than 0, got \(constrainedSize.width)")
XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width (\(constrainedSize.width)) should be less than unconstrained (\(unconstrainedSize.width))")
XCTAssertGreaterThan(constrainedSize.height, 0, "Constrained height should be greater than 0, got \(constrainedSize.height)")
// When constrained, height should increase when text wraps
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height,
"Constrained height (\(constrainedSize.height)) should be > unconstrained (\(unconstrainedSize.height)) when text wraps")
}
func testPreferredMaxLayoutWidth() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
// Get unconstrained size
let unconstrainedSize = label.intrinsicContentSize
// Now set preferred max width (use 300 since longest word might be ~237pt)
label.preferredMaxLayoutWidth = 300
let constrainedSize = label.intrinsicContentSize
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be greater than 0, got \(constrainedSize.width)")
XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width (\(constrainedSize.width)) should be < unconstrained (\(unconstrainedSize.width))")
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Constrained height (\(constrainedSize.height)) should be > unconstrained (\(unconstrainedSize.height)) due to wrapping")
}
func testWordBoundaryBreaking() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Word1 Word2 Word3 Word4 Word5}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
label.preferredMaxLayoutWidth = 150
let size = label.intrinsicContentSize
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
// Verify it actually uses the layout
label.frame = CGRect(origin: .zero, size: size)
#if os(macOS)
label.layout()
#else
label.layoutSubviews()
#endif
XCTAssertNotNil(label.displayList, "Display list should be created")
}
func testEmptyLatex() {
let label = MTMathUILabel()
label.latex = ""
label.font = MTFontManager.fontManager.defaultFont
let size = label.intrinsicContentSize
// Empty latex should still return a valid size (might be zero or minimal)
XCTAssertGreaterThanOrEqual(size.width, 0, "Width should be >= 0 for empty latex, got \(size.width)")
XCTAssertGreaterThanOrEqual(size.height, 0, "Height should be >= 0 for empty latex, got \(size.height)")
}
func testMathAndTextMixed() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Result: } x^2 + y^2 = z^2\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
let size = label.intrinsicContentSize
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
}
func testDebugSizeThatFitsWithConstraint() {
let label = MTMathUILabel()
label.latex = "\\(\\text{Word1 Word2 Word3 Word4 Word5}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
let unconstr = label.sizeThatFits(CGSize.zero)
let constr = label.sizeThatFits(CGSize(width: 150, height: 999))
XCTAssertLessThan(constr.width, unconstr.width, "Constrained (\(constr.width)) should be < unconstrained (\(unconstr.width))")
XCTAssertGreaterThan(constr.height, unconstr.height, "Constrained height (\(constr.height)) should be > unconstrained (\(unconstr.height))")
}
func testAccentedCharactersWithLineWrapping() {
let label = MTMathUILabel()
// French text with accented characters: è, é, à
label.latex = "\\(\\text{Rappelons la relation entre kilomètres et mètres.}\\)"
label.font = MTFontManager.fontManager.defaultFont
label.labelMode = .text
// Get unconstrained size
let unconstrainedSize = label.intrinsicContentSize
// Set a width constraint that should cause wrapping
label.preferredMaxLayoutWidth = 250
let constrainedSize = label.intrinsicContentSize
// Verify wrapping occurred
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width should be < unconstrained")
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped")
// Verify the label can render without errors
label.frame = CGRect(origin: .zero, size: constrainedSize)
#if os(macOS)
label.layout()
#else
label.layoutSubviews()
#endif
XCTAssertNotNil(label.displayList, "Display list should be created")
XCTAssertNil(label.error, "Should have no rendering error")
}
}

44
Tests/SwiftMathTests/MathFontTests.swift Executable file → Normal file
View File

@@ -17,7 +17,7 @@ final class MathFontTests: XCTestCase {
XCTAssertNotNil($0.cgFont())
XCTAssertNotNil($0.ctFont(withSize: CGFloat(size)))
XCTAssertEqual($0.ctFont(withSize: CGFloat(size)).fontSize, CGFloat(size), "ctFont fontSize != size.")
XCTAssertEqual($0.cgFont().postScriptName as? String, $0.fontName, "postscript Name != UIFont fontName")
XCTAssertEqual($0.cgFont().postScriptName as? String, $0.postScriptName, "cgFont.postScriptName != postScriptName")
// XCTAssertEqual($0.uiFont(withSize: CGFloat(size))?.familyName, $0.fontFamilyName, "uifont familyName != familyName.")
XCTAssertEqual(CTFontCopyFamilyName($0.ctFont(withSize: CGFloat(size))) as String, $0.fontFamilyName, "ctfont.family != familyName")
}
@@ -48,7 +48,7 @@ final class MathFontTests: XCTestCase {
XCTAssertEqual(mathFont.ctFont(withSize: CGFloat(size)).fontSize, CGFloat(size), "ctFont fontSize test")
}
var fontNames: [String] {
MathFont.allCases.map { $0.fontName }
MathFont.allCases.map { $0.postScriptName }
}
var fontFamilyNames: [String] {
MathFont.allCases.map { $0.fontFamilyName }
@@ -145,4 +145,44 @@ final class MathFontTests: XCTestCase {
}
queue.async(group: group, execute: workitem)
}
func testFallbackFont() throws {
#if os(iOS) || os(visionOS)
let systemFont = UIFont.systemFont(ofSize: 20)
let systemCTFont = CTFontCreateWithName(systemFont.fontName as CFString, 20, nil)
#elseif os(macOS)
let systemFont = NSFont.systemFont(ofSize: 20)
let systemCTFont = CTFontCreateWithName(systemFont.fontName as CFString, 20, nil)
#endif
// Create a math font with fallback
guard let mathFont = MTFontManager().font(withName: MathFont.latinModernFont.rawValue, size: 20) else {
XCTFail("Failed to create math font")
return
}
mathFont.fallbackFont = systemCTFont
// Build a math list with Chinese text
var error: NSError?
let mathList = MTMathListBuilder.build(fromString: "\\text{中文测试}", error: &error)
XCTAssertNil(error, "Should parse Chinese text without error")
XCTAssertNotNil(mathList, "Math list should be created")
// \text{...} creates atoms for each character (4 Chinese characters = 4 atoms)
XCTAssertEqual(mathList?.atoms.count, 4, "Should have 4 atoms for 4 Chinese characters")
// Verify atoms have the correct font style (roman for text)
for atom in mathList?.atoms ?? [] {
XCTAssertEqual(atom.fontStyle, .roman, "Text atoms should have roman font style")
}
// Create a display to verify glyph rendering works with fallback
let display = MTTypesetter.createLineForMathList(mathList!, font: mathFont, style: .text)
XCTAssertNotNil(display, "Display should be created with fallback font")
// Verify the display was actually created (would be nil if all glyphs failed)
XCTAssertGreaterThan(display?.width ?? 0, 0, "Display should have non-zero width with fallback font")
}
}