Merge pull request #52 from nguillot/latex_support_extension
Add comprehensive LaTeX support: matrices, delimiters, operators, and line wrapping
This commit is contained in:
236
MISSING_FEATURES.md
Normal file
236
MISSING_FEATURES.md
Normal 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
233
README.md
Executable file → Normal 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
21
Sources/SwiftMath/MathBundle/MathFont.swift
Executable file → Normal 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
10
Sources/SwiftMath/MathRender/MTFont.swift
Executable file → Normal 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
53
Sources/SwiftMath/MathRender/MTMathAtomFactory.swift
Executable file → Normal 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
6
Sources/SwiftMath/MathRender/MTMathList.swift
Executable file → Normal 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
344
Sources/SwiftMath/MathRender/MTMathListBuilder.swift
Executable file → Normal 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
64
Sources/SwiftMath/MathRender/MTMathUILabel.swift
Executable file → Normal 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
246
Sources/SwiftMath/MathRender/MTTypesetter.swift
Executable file → Normal 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
1027
Tests/SwiftMathTests/MTMathListBuilderTests.swift
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
194
Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift
Normal file
194
Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift
Normal 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
44
Tests/SwiftMathTests/MathFontTests.swift
Executable file → Normal 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user