diff --git a/MISSING_FEATURES.md b/MISSING_FEATURES.md new file mode 100644 index 0000000..9ae210a --- /dev/null +++ b/MISSING_FEATURES.md @@ -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* diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 2ae3ec9..cb7f0fe --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/SwiftMath/MathBundle/MathFont.swift b/Sources/SwiftMath/MathBundle/MathFont.swift old mode 100755 new mode 100644 index 74e31a4..bc14e35 --- a/Sources/SwiftMath/MathBundle/MathFont.swift +++ b/Sources/SwiftMath/MathBundle/MathFont.swift @@ -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 { diff --git a/Sources/SwiftMath/MathRender/MTFont.swift b/Sources/SwiftMath/MathRender/MTFont.swift old mode 100755 new mode 100644 index 3dd3042..24a2d2a --- a/Sources/SwiftMath/MathRender/MTFont.swift +++ b/Sources/SwiftMath/MathRender/MTFont.swift @@ -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. diff --git a/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift b/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift old mode 100755 new mode 100644 index 5109fca..81f0d4c --- a/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift +++ b/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift @@ -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.. 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() diff --git a/Sources/SwiftMath/MathRender/MTMathUILabel.swift b/Sources/SwiftMath/MathRender/MTMathUILabel.swift old mode 100755 new mode 100644 index ea4324f..1d131b4 --- a/Sources/SwiftMath/MathRender/MTMathUILabel.swift +++ b/Sources/SwiftMath/MathRender/MTMathUILabel.swift @@ -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() diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift old mode 100755 new mode 100644 index 609767d..ca45f12 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -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 } diff --git a/Tests/SwiftMathTests/MTMathListBuilderTests.swift b/Tests/SwiftMathTests/MTMathListBuilderTests.swift old mode 100755 new mode 100644 index f1453cb..b051a38 --- a/Tests/SwiftMathTests/MTMathListBuilderTests.swift +++ b/Tests/SwiftMathTests/MTMathListBuilderTests.swift @@ -1420,6 +1420,1033 @@ final class MTMathListBuilderTests: XCTestCase { XCTAssertEqual(latex, "\\sum \\nolimits ", desc) } + // MARK: - Inline and Display Math Delimiter Tests + + func testInlineMathDollar() throws { + let str = "$x^2$" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse inline math with $") + // Should have textstyle at start, then variable with superscript + XCTAssertTrue(list!.atoms.count >= 1, "Should have at least one atom") + + // Find the variable atom (skip style atoms) + var foundVariable = false + for atom in list!.atoms { + if atom.type == .variable && atom.nucleus == "x" { + foundVariable = true + XCTAssertNotNil(atom.superScript, "Should have superscript") + break + } + } + XCTAssertTrue(foundVariable, "Should find variable x") + } + + func testInlineMathParens() throws { + let str = "\\(E=mc^2\\)" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse inline math with \\(\\)") + XCTAssertTrue(list!.atoms.count >= 3, "Should have E, =, m, c atoms") + + // Check for equals sign + var foundEquals = false + for atom in list!.atoms { + if atom.type == .relation && atom.nucleus == "=" { + foundEquals = true + break + } + } + XCTAssertTrue(foundEquals, "Should find equals sign") + } + + func testInlineMathWithCases() throws { + let str = "\\(\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}\\)" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse inline cases") + + // cases environment returns an Inner atom with table inside + var foundInner = false + for atom in list!.atoms { + if atom.type == .inner { + let inner = atom as! MTInner + // Look for table inside the inner list + if let innerList = inner.innerList { + for innerAtom in innerList.atoms { + if innerAtom.type == .table { + let table = innerAtom as! MTMathTable + XCTAssertEqual(table.environment, "cases", "Should be cases environment") + XCTAssertEqual(table.numRows, 2, "Should have 2 rows") + foundInner = true + break + } + } + } + if foundInner { break } + } + } + XCTAssertTrue(foundInner, "Should find cases table inside inner atom") + } + + func testInlineMathVectorDot() throws { + let str = "$\\vec{a} \\cdot \\vec{b}$" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse inline vector dot product") + + // Should contain accents (for vec) and cdot operator + var hasAccent = false + var hasCdot = false + + for atom in list!.atoms { + if atom.type == .accent { + hasAccent = true + } + if atom.type == .binaryOperator && atom.nucleus.contains("\u{22C5}") { + hasCdot = true + } + } + + XCTAssertTrue(hasAccent, "Should have accent for \\vec") + XCTAssertTrue(hasCdot, "Should have \\cdot operator") + } + + func testDisplayMathDoubleDollar() throws { + let str = "$$x^2 + y^2 = z^2$$" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse display math with $$") + XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms for expression") + + // Should NOT have textstyle at start (display mode) + let firstAtom = list!.atoms.first + XCTAssertNotEqual(firstAtom?.type, .style, "Display mode should not force textstyle") + } + + func testDisplayMathBrackets() throws { + let str = "\\[\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}\\]" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse display math with \\[\\]") + + // Find sum operator + var foundSum = false + for atom in list!.atoms { + if atom.type == .largeOperator && atom.nucleus.contains("∑") { + foundSum = true + XCTAssertNotNil(atom.subScript, "Sum should have subscript") + XCTAssertNotNil(atom.superScript, "Sum should have superscript") + break + } + } + XCTAssertTrue(foundSum, "Should find sum operator") + } + + func testDisplayMathCasesWithoutDelimiters() throws { + // This should work as before (backward compatibility) + let str = "\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse display cases without outer delimiters") + XCTAssertTrue(list!.atoms.count >= 1, "Should have at least one atom") + + // cases environment returns an Inner atom with table inside + var foundTable = false + for atom in list!.atoms { + if atom.type == .inner { + let inner = atom as! MTInner + if let innerList = inner.innerList { + for innerAtom in innerList.atoms { + if innerAtom.type == .table { + let table = innerAtom as! MTMathTable + XCTAssertEqual(table.environment, "cases", "Should be cases environment") + XCTAssertEqual(table.numRows, 2, "Should have 2 rows") + foundTable = true + break + } + } + } + if foundTable { break } + } + } + + XCTAssertTrue(foundTable, "Should find cases table inside inner atom") + } + + func testBackwardCompatibilityNoDelimiters() throws { + // Test that expressions without delimiters still work + let str = "x^2 + y^2 = z^2" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse expression without delimiters") + XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms") + } + + func testEmptyInlineMath() throws { + let str = "$$$" // This is $$$ which should be treated as $$ + $ + let list = MTMathListBuilder.build(fromString: str) + + // Should handle gracefully + XCTAssertNotNil(list, "Should handle edge case") + } + + func testEmptyDisplayMath() throws { + let str = "\\[\\]" + let list = MTMathListBuilder.build(fromString: str) + + // Empty content may return nil or an empty list, both are acceptable + if list != nil { + XCTAssertTrue(list!.atoms.isEmpty || list!.atoms.count >= 0, "Should have empty or minimal atoms") + } + // It's ok if it returns nil for empty content + } + + func testDollarInMath() throws { + // Test that delimiters are properly stripped + let str = "$a + b$" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse correctly") + + // Should not contain $ in the parsed atoms + for atom in list!.atoms { + XCTAssertFalse(atom.nucleus.contains("$"), "Should not have $ in nucleus") + } + } + + func testComplexInlineExpression() throws { + let str = "$\\frac{1}{2} + \\sqrt{3}$" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse complex inline expression") + + // Should have fraction and radical + var hasFraction = false + var hasRadical = false + + for atom in list!.atoms { + if atom.type == .fraction { + hasFraction = true + } + if atom.type == .radical { + hasRadical = true + } + } + + XCTAssertTrue(hasFraction, "Should have fraction") + XCTAssertTrue(hasRadical, "Should have radical") + } + + func testInlineMathStyleForcing() throws { + // Inline math should have textstyle prepended + let str = "$\\sum_{i=1}^{n} i$" + let list = MTMathListBuilder.build(fromString: str) + + XCTAssertNotNil(list, "Should parse sum in inline mode") + + // First atom should be style atom with text style + if let firstAtom = list!.atoms.first, firstAtom.type == .style { + let styleAtom = firstAtom as! MTMathStyle + XCTAssertEqual(styleAtom.style, .text, "Inline mode should force text style") + } + } + + // MARK: - Tests for build(fromString:error:) API with delimiters + + func testInlineMathDollarWithError() throws { + let str = "$x^2$" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse inline math with $") + XCTAssertNil(error, "Should not have error") + + // Find the variable atom (skip style atoms) + var foundVariable = false + for atom in list!.atoms { + if atom.type == .variable && atom.nucleus == "x" { + foundVariable = true + XCTAssertNotNil(atom.superScript, "Should have superscript") + break + } + } + XCTAssertTrue(foundVariable, "Should find variable x") + } + + func testInlineMathParensWithError() throws { + let str = "\\(E=mc^2\\)" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse inline math with \\(\\)") + XCTAssertNil(error, "Should not have error") + XCTAssertTrue(list!.atoms.count >= 3, "Should have E, =, m, c atoms") + + // Check for equals sign + var foundEquals = false + for atom in list!.atoms { + if atom.type == .relation && atom.nucleus == "=" { + foundEquals = true + break + } + } + XCTAssertTrue(foundEquals, "Should find equals sign") + } + + func testInlineMathWithCasesWithError() throws { + let str = "\\(\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}\\)" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse inline cases") + XCTAssertNil(error, "Should not have error") + + // cases environment returns an Inner atom with table inside + var foundInner = false + for atom in list!.atoms { + if atom.type == .inner { + let inner = atom as! MTInner + if let innerList = inner.innerList { + for innerAtom in innerList.atoms { + if innerAtom.type == .table { + let table = innerAtom as! MTMathTable + XCTAssertEqual(table.environment, "cases", "Should be cases environment") + XCTAssertEqual(table.numRows, 2, "Should have 2 rows") + foundInner = true + break + } + } + } + if foundInner { break } + } + } + XCTAssertTrue(foundInner, "Should find cases table inside inner atom") + } + + func testDisplayMathDoubleDollarWithError() throws { + let str = "$$x^2 + y^2 = z^2$$" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse display math with $$") + XCTAssertNil(error, "Should not have error") + XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms for expression") + } + + func testDisplayMathBracketsWithError() throws { + let str = "\\[\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}\\]" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse display math with \\[\\]") + XCTAssertNil(error, "Should not have error") + + // Find sum operator + var foundSum = false + for atom in list!.atoms { + if atom.type == .largeOperator && atom.nucleus.contains("∑") { + foundSum = true + XCTAssertNotNil(atom.subScript, "Sum should have subscript") + XCTAssertNotNil(atom.superScript, "Sum should have superscript") + break + } + } + XCTAssertTrue(foundSum, "Should find sum operator") + } + + func testDisplayMathCasesWithoutDelimitersWithError() throws { + let str = "\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse display cases without outer delimiters") + XCTAssertNil(error, "Should not have error") + XCTAssertTrue(list!.atoms.count >= 1, "Should have at least one atom") + + // cases environment returns an Inner atom with table inside + var foundTable = false + for atom in list!.atoms { + if atom.type == .inner { + let inner = atom as! MTInner + if let innerList = inner.innerList { + for innerAtom in innerList.atoms { + if innerAtom.type == .table { + let table = innerAtom as! MTMathTable + XCTAssertEqual(table.environment, "cases", "Should be cases environment") + XCTAssertEqual(table.numRows, 2, "Should have 2 rows") + foundTable = true + break + } + } + } + if foundTable { break } + } + } + + XCTAssertTrue(foundTable, "Should find cases table inside inner atom") + } + + func testBackwardCompatibilityNoDelimitersWithError() throws { + let str = "x^2 + y^2 = z^2" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse expression without delimiters") + XCTAssertNil(error, "Should not have error") + XCTAssertTrue(list!.atoms.count >= 5, "Should have multiple atoms") + } + + func testInvalidLatexWithError() throws { + let str = "$\\notacommand$" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNil(list, "Should fail to parse invalid command") + XCTAssertNotNil(error, "Should have error") + XCTAssertEqual(error?.code, MTParseErrors.invalidCommand.rawValue, "Should be invalid command error") + } + + func testMismatchedBracesWithError() throws { + let str = "${x+2$" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNil(list, "Should fail to parse mismatched braces") + XCTAssertNotNil(error, "Should have error") + XCTAssertEqual(error?.code, MTParseErrors.mismatchBraces.rawValue, "Should be mismatched braces error") + } + + func testComplexInlineExpressionWithError() throws { + let str = "$\\frac{1}{2} + \\sqrt{3}$" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse complex inline expression") + XCTAssertNil(error, "Should not have error") + + // Should have fraction and radical + var hasFraction = false + var hasRadical = false + + for atom in list!.atoms { + if atom.type == .fraction { + hasFraction = true + } + if atom.type == .radical { + hasRadical = true + } + } + + XCTAssertTrue(hasFraction, "Should have fraction") + XCTAssertTrue(hasRadical, "Should have radical") + } + + func testInlineMathVectorDotWithError() throws { + let str = "$\\vec{a} \\cdot \\vec{b}$" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse inline vector dot product") + XCTAssertNil(error, "Should not have error") + + // Should contain accents (for vec) and cdot operator + var hasAccent = false + var hasCdot = false + + for atom in list!.atoms { + if atom.type == .accent { + hasAccent = true + } + if atom.type == .binaryOperator && atom.nucleus.contains("\u{22C5}") { + hasCdot = true + } + } + + XCTAssertTrue(hasAccent, "Should have accent for \\vec") + XCTAssertTrue(hasCdot, "Should have \\cdot operator") + } + + // MARK: - Comprehensive Command Coverage Tests + + func testGreekLettersLowercase() throws { + let commands = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", + "iota", "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", + "rho", "sigma", "tau", "upsilon", "phi", "chi", "psi", "omega"] + + for cmd in commands { + var error: NSError? = nil + let str = "$\\\(cmd)$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(cmd)") + XCTAssertNil(error, "Should not error on \\\(cmd): \(error?.localizedDescription ?? "")") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\\\(cmd) should have at least one atom") + } + } + + func testGreekLettersUppercase() throws { + let commands = ["Gamma", "Delta", "Theta", "Lambda", "Xi", "Pi", "Sigma", "Upsilon", "Phi", "Psi", "Omega"] + + for cmd in commands { + var error: NSError? = nil + let str = "$\\\(cmd)$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(cmd)") + XCTAssertNil(error, "Should not error on \\\(cmd): \(error?.localizedDescription ?? "")") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\\\(cmd) should have at least one atom") + } + } + + func testBinaryOperators() throws { + let operators = ["times", "div", "pm", "mp", "ast", "star", "circ", "bullet", + "cdot", "cap", "cup", "uplus", "sqcap", "sqcup", + "oplus", "ominus", "otimes", "oslash", "odot", "wedge", "vee"] + + for op in operators { + var error: NSError? = nil + let str = "$a \\\(op) b$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(op)") + XCTAssertNil(error, "Should not error on \\\(op): \(error?.localizedDescription ?? "")") + + // Should find the operator + var foundOp = false + for atom in unwrappedList.atoms { + if atom.type == .binaryOperator { + foundOp = true + break + } + } + XCTAssertTrue(foundOp, "Should find binary operator for \\\(op)") + } + } + + func testRelations() throws { + let relations = ["leq", "geq", "neq", "equiv", "approx", "sim", "simeq", "cong", + "prec", "succ", "subset", "supset", "subseteq", "supseteq", + "in", "notin", "ni", "propto", "perp", "parallel"] + + for rel in relations { + var error: NSError? = nil + let str = "$a \\\(rel) b$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(rel)") + XCTAssertNil(error, "Should not error on \\\(rel): \(error?.localizedDescription ?? "")") + + // Should find the relation + var foundRel = false + for atom in unwrappedList.atoms { + if atom.type == .relation { + foundRel = true + break + } + } + XCTAssertTrue(foundRel, "Should find relation for \\\(rel)") + } + } + + func testAllAccents() throws { + let accents = ["hat", "tilde", "bar", "dot", "ddot", "check", "grave", "acute", "breve", "vec"] + + for acc in accents { + var error: NSError? = nil + let str = "$\\\(acc){x}$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(acc)") + XCTAssertNil(error, "Should not error on \\\(acc): \(error?.localizedDescription ?? "")") + + // Should find the accent + var foundAccent = false + for atom in unwrappedList.atoms { + if atom.type == .accent { + foundAccent = true + break + } + } + XCTAssertTrue(foundAccent, "Should find accent for \\\(acc)") + } + } + + func testDelimiterPairs() throws { + let delimiterPairs = [ + ("langle", "rangle"), + ("lfloor", "rfloor"), + ("lceil", "rceil"), + ("lgroup", "rgroup"), + ("{", "}") + ] + + for (left, right) in delimiterPairs { + var error: NSError? = nil + let str = "$\\left\\\(left) x \\right\\\(right)$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\left\\\(left) ... \\right\\\(right)") + XCTAssertNil(error, "Should not error on delimiters \\\(left)/\\\(right): \(error?.localizedDescription ?? "")") + + // Should have an inner atom + var foundInner = false + for atom in unwrappedList.atoms { + if atom.type == .inner { + foundInner = true + break + } + } + XCTAssertTrue(foundInner, "Should create inner atom for \\left\\\(left)...\\right\\\(right)") + } + } + + func testLargeOperators() throws { + let operators = ["sum", "prod", "coprod", "int", "iint", "iiint", "oint", + "bigcap", "bigcup", "bigvee", "bigwedge", "bigodot", "bigoplus", "bigotimes"] + + for op in operators { + var error: NSError? = nil + let str = "$\\\(op)_{i=1}^{n} x_i$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(op)") + XCTAssertNil(error, "Should not error on \\\(op): \(error?.localizedDescription ?? "")") + + // Should find large operator + var foundOp = false + for atom in unwrappedList.atoms { + if atom.type == .largeOperator { + foundOp = true + break + } + } + XCTAssertTrue(foundOp, "Should find large operator for \\\(op)") + } + } + + func testArrows() throws { + let arrows = ["leftarrow", "rightarrow", "uparrow", "downarrow", "leftrightarrow", + "Leftarrow", "Rightarrow", "Uparrow", "Downarrow", "Leftrightarrow", + "longleftarrow", "longrightarrow", "Longleftarrow", "Longrightarrow", + "mapsto", "nearrow", "searrow", "swarrow", "nwarrow"] + + for arrow in arrows { + var error: NSError? = nil + let str = "$a \\\(arrow) b$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(arrow)") + XCTAssertNil(error, "Should not error on \\\(arrow): \(error?.localizedDescription ?? "")") + + // Arrows are typically relations + var foundArrow = false + for atom in unwrappedList.atoms { + if atom.type == .relation { + foundArrow = true + break + } + } + XCTAssertTrue(foundArrow, "Should find arrow relation for \\\(arrow)") + } + } + + func testTrigonometricFunctions() throws { + let functions = ["sin", "cos", "tan", "cot", "sec", "csc", + "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "coth"] + + for funcName in functions { + var error: NSError? = nil + let str = "$\\\(funcName) x$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(funcName)") + XCTAssertNil(error, "Should not error on \\\(funcName): \(error?.localizedDescription ?? "")") + + // Should find the function operator + var foundFunc = false + for atom in unwrappedList.atoms { + if atom.type == .largeOperator { + foundFunc = true + break + } + } + XCTAssertTrue(foundFunc, "Should find function operator for \\\(funcName)") + } + } + + func testLimitOperators() throws { + let operators = ["lim", "limsup", "liminf", "max", "min", "sup", "inf", "det", "gcd"] + + for op in operators { + var error: NSError? = nil + let str = "$\\\(op)_{x \\to 0} f(x)$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(op)") + XCTAssertNil(error, "Should not error on \\\(op): \(error?.localizedDescription ?? "")") + + // Should find the operator + var foundOp = false + for atom in unwrappedList.atoms { + if atom.type == .largeOperator { + foundOp = true + break + } + } + XCTAssertTrue(foundOp, "Should find limit operator for \\\(op)") + } + } + + func testSpecialSymbols() throws { + let symbols = ["infty", "partial", "nabla", "prime", "hbar", "ell", "wp", + "Re", "Im", "top", "bot", "emptyset", "exists", "forall", + "neg", "angle", "triangle", "ldots", "cdots", "vdots", "ddots"] + + for sym in symbols { + var error: NSError? = nil + let str = "$\\\(sym)$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(sym)") + XCTAssertNil(error, "Should not error on \\\(sym): \(error?.localizedDescription ?? "")") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\\\(sym) should have at least one atom") + } + } + + func testLogFunctions() throws { + let logFuncs = ["log", "ln", "lg"] + + for funcName in logFuncs { + var error: NSError? = nil + let str = "$\\\(funcName) x$" + let list = MTMathListBuilder.build(fromString: str, error: &error) + + XCTAssertNotNil(list, "Should parse \\\(funcName)") + XCTAssertNil(error, "Should not error on \\\(funcName): \(error?.localizedDescription ?? "")") + } + } + + // MARK: - High Priority Missing Features Tests + + func testDisplayStyle() throws { + // Test \displaystyle and \textstyle commands + let testCases = [ + ("\\displaystyle \\sum_{i=1}^{n} x_i", "displaystyle with sum"), + ("\\textstyle \\int_{0}^{\\infty} f(x) dx", "textstyle with integral"), + ("x + \\displaystyle\\frac{a}{b} + y", "inline displaystyle fraction"), + ("\\displaystyle x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}", "displaystyle equation") + ] + + for (latex, desc) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if list == nil || error != nil { + throw XCTSkip("\\displaystyle/\\textstyle not implemented: \(desc). Error: \(error?.localizedDescription ?? "nil result")") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + } + } + + func testMiddleDelimiter() throws { + // Test \middle command for delimiters in the middle of expressions + let testCases = [ + ("\\left( \\frac{a}{b} \\middle| \\frac{c}{d} \\right)", "middle pipe"), + ("\\left\\{ x \\middle\\| y \\right\\}", "middle double pipe"), + ("\\left[ a \\middle\\\\ b \\right]", "middle backslash") + ] + + for (latex, desc) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if list == nil || error != nil { + throw XCTSkip("\\middle not implemented: \(desc). Error: \(error?.localizedDescription ?? "nil result")") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + } + } + + func testSubstack() throws { + // Test \substack for multi-line subscripts and limits + + let testCases = [ + ("\\substack{a \\\\ b}", "simple substack"), + ("x_{\\substack{a \\\\ b}}", "substack in subscript"), + ("\\sum_{\\substack{0 \\le i \\le m \\\\ 0 < j < n}} P(i,j)", "substack in sum limits"), + ("\\prod_{\\substack{p \\text{ prime} \\\\ p < 100}} p", "substack with text"), + ("A_{\\substack{n \\\\ k}}", "subscript with substack"), + ("\\substack{\\frac{a}{b} \\\\ c}", "substack with frac"), + ("\\substack{a}", "single row substack"), + ("\\substack{a \\\\ b \\\\ c \\\\ d}", "multi-row substack") + ] + + for (latex, desc) in testCases { + print("Testing: \(desc)") + print(" LaTeX: \(latex)") + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if let err = error { + print(" ERROR: \(err.localizedDescription)") + } else if list == nil { + print(" List is nil but no error") + } else { + print(" SUCCESS: Got \(list!.atoms.count) atoms") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertNil(error, "Should not error on \(desc): \(error?.localizedDescription ?? "")") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + + // Verify we have a table structure (either directly or in subscript) + var foundTable = false + for atom in unwrappedList.atoms { + if atom.type == .table { + foundTable = true + break + } + if let subScript = atom.subScript { + for subAtom in subScript.atoms { + if subAtom.type == .table { + foundTable = true + break + } + } + } + } + XCTAssertTrue(foundTable, "\(desc) should contain a table structure") + } + } + + func testManualDelimiterSizing() throws { + // Test \big, \Big, \bigg, \Bigg sizing commands + let testCases = [ + ("\\big( x \\big)", "big parentheses"), + ("\\Big[ y \\Big]", "Big brackets"), + ("\\bigg\\{ z \\bigg\\}", "bigg braces"), + ("\\Bigg| w \\Bigg|", "Bigg pipes"), + ("\\big< a \\big>", "big angle brackets") + ] + + for (latex, desc) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if list == nil || error != nil { + throw XCTSkip("Manual delimiter sizing (\\big, \\Big, \\bigg, \\Bigg) not implemented: \(desc). Error: \(error?.localizedDescription ?? "nil result")") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + } + } + + func testSpacingCommands() throws { + // Test fine-tuned spacing commands + let testCases = [ + ("a\\,b", "thin space \\,"), + ("a\\:b", "medium space \\:"), + ("a\\;b", "thick space \\;"), + ("a\\!b", "negative space \\!"), + ("\\int\\!\\!\\!\\int f(x,y) dx dy", "multiple negative spaces"), + ("x \\, y \\: z \\; w", "mixed spacing") + ] + + for (latex, desc) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if list == nil || error != nil { + throw XCTSkip("Spacing commands (\\,, \\:, \\;, \\!) not implemented: \(desc). Error: \(error?.localizedDescription ?? "nil result")") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + } + } + + // MARK: - Medium Priority Missing Features Tests + + func testMultipleIntegrals() throws { + // Test \iint, \iiint, \iiiint for multiple integrals + let testCases = [ + ("\\iint f(x,y) dx dy", "double integral"), + ("\\iiint f(x,y,z) dx dy dz", "triple integral"), + ("\\iiiint f(w,x,y,z) dw dx dy dz", "quadruple integral"), + ("\\iint_{D} f(x,y) dA", "double integral with limits") + ] + + for (latex, desc) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if let err = error { + XCTFail("ERROR: \(err.localizedDescription)") + } else if list == nil { + XCTFail("List is nil but no error") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertNil(error, "Should not error on \(desc): \(error?.localizedDescription ?? "")") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + + // Verify we have a large operator (integral) in the list + var foundOperator = false + for atom in unwrappedList.atoms { + if atom.type == .largeOperator { + foundOperator = true + break + } + } + XCTAssertTrue(foundOperator, "\(desc) should contain a large operator (integral)") + } + } + + func testContinuedFractions() throws { + // Test \cfrac for continued fractions (already added but verify) + let testCases = [ + ("\\cfrac{1}{2}", "simple cfrac"), + ("a_0 + \\cfrac{1}{a_1 + \\cfrac{1}{a_2}}", "nested cfrac"), + ("\\cfrac{x^2}{y + \\cfrac{1}{z}}", "cfrac with expressions") + ] + + for (latex, desc) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + // cfrac might be implemented, let's check + if list != nil && error == nil { + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + } else { + throw XCTSkip("\\cfrac may have issues: \(desc). Error: \(error?.localizedDescription ?? "nil result")") + } + } + } + + func testBoldsymbol() throws { + // Test \boldsymbol for bold Greek letters + let testCases = [ + ("\\boldsymbol{\\alpha}", "bold alpha"), + ("\\boldsymbol{\\beta}", "bold beta"), + ("\\boldsymbol{\\Gamma}", "bold Gamma"), + ("\\mathbf{x} + \\boldsymbol{\\mu}", "mixed bold") + ] + + for (latex, desc) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if list == nil || error != nil { + throw XCTSkip("\\boldsymbol not implemented: \(desc). Error: \(error?.localizedDescription ?? "nil result")") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + } + } + + func testStarredMatrices() throws { + // Test starred matrix environments with alignment + let testCases = [ + ("\\begin{pmatrix*}[r] 1 & 2 \\\\ 3 & 4 \\end{pmatrix*}", "pmatrix* right align"), + ("\\begin{bmatrix*}[l] a & b \\\\ c & d \\end{bmatrix*}", "bmatrix* left align"), + ("\\begin{vmatrix*}[c] x & y \\\\ z & w \\end{vmatrix*}", "vmatrix* center align"), + ("\\begin{matrix*}[r] 10 & 20 \\\\ 30 & 40 \\end{matrix*}", "matrix* right align (no delimiters)") + ] + + for (latex, desc) in testCases { + print("Testing: \(desc)") + print(" LaTeX: \(latex)") + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if let err = error { + print(" ERROR: \(err.localizedDescription)") + } else if list == nil { + print(" List is nil but no error") + } else { + print(" SUCCESS: Got \(list!.atoms.count) atoms") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertNil(error, "Should not error on \(desc): \(error?.localizedDescription ?? "")") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + + // Verify we have a table structure + var foundTable = false + for atom in unwrappedList.atoms { + if atom.type == .table { + foundTable = true + break + } + // Check inside inner atoms (for matrices with delimiters) + if atom.type == .inner, let inner = atom as? MTInner, let innerList = inner.innerList { + for innerAtom in innerList.atoms { + if innerAtom.type == .table { + foundTable = true + break + } + } + } + } + XCTAssertTrue(foundTable, "\(desc) should contain a table structure") + } + } + + func testSmallMatrix() throws { + // Test \smallmatrix for inline matrices + let testCases = [ + ("\\left( \\begin{smallmatrix} a & b \\\\ c & d \\end{smallmatrix} \\right)", "smallmatrix with delimiters"), + ("A = \\left( \\begin{smallmatrix} 1 & 0 \\\\ 0 & 1 \\end{smallmatrix} \\right)", "identity in smallmatrix"), + ("\\begin{smallmatrix} x \\\\ y \\end{smallmatrix}", "column vector in smallmatrix") + ] + + for (latex, desc) in testCases { + print("Testing: \(desc)") + print(" LaTeX: \(latex)") + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + if let err = error { + print(" ERROR: \(err.localizedDescription)") + } else if list == nil { + print(" List is nil but no error") + } else { + print(" SUCCESS: Got \(list!.atoms.count) atoms") + } + + let unwrappedList = try XCTUnwrap(list, "Should parse: \(desc)") + XCTAssertNil(error, "Should not error on \(desc): \(error?.localizedDescription ?? "")") + XCTAssertTrue(unwrappedList.atoms.count >= 1, "\(desc) should have atoms") + + // Verify we have a table structure + var foundTable = false + for atom in unwrappedList.atoms { + if atom.type == .table { + foundTable = true + break + } + // Check inside inner atoms (for matrices with delimiters) + if atom.type == .inner, let inner = atom as? MTInner, let innerList = inner.innerList { + for innerAtom in innerList.atoms { + if innerAtom.type == .table { + foundTable = true + break + } + } + } + } + XCTAssertTrue(foundTable, "\(desc) should contain a table structure") + } + } + // func testPerformanceExample() throws { // // This is an example of a performance test case. // measure { diff --git a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift new file mode 100644 index 0000000..37394cb --- /dev/null +++ b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift @@ -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") + } +} diff --git a/Tests/SwiftMathTests/MathFontTests.swift b/Tests/SwiftMathTests/MathFontTests.swift old mode 100755 new mode 100644 index 75fa277..b690156 --- a/Tests/SwiftMathTests/MathFontTests.swift +++ b/Tests/SwiftMathTests/MathFontTests.swift @@ -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") + } }