[UI] add line wrapping functionality

This commit is contained in:
Nicolas Guillot
2025-10-02 12:05:37 +02:00
parent 11f57f7c6e
commit c7198ad9af
4 changed files with 567 additions and 39 deletions

130
README.md
View File

@@ -114,18 +114,34 @@ struct MathView: UIViewRepresentable {
var fontSize: CGFloat = 30
var labelMode: MTMathUILabelMode = .text
var insets: MTEdgeInsets = MTEdgeInsets()
func makeUIView(context: Context) -> MTMathUILabel {
let view = MTMathUILabel()
view.setContentHuggingPriority(.required, for: .vertical)
view.setContentCompressionResistancePriority(.required, for: .vertical)
return view
}
func updateUIView(_ view: MTMathUILabel, context: Context) {
view.latex = equation
view.font = MTFontManager().font(withName: font.rawValue, size: fontSize)
let font = MTFontManager().font(withName: font.rawValue, size: fontSize)
font?.fallbackFont = UIFont.systemFont(ofSize: fontSize)
view.font = font
view.textAlignment = textAlignment
view.labelMode = labelMode
view.textColor = MTColor(Color.primary)
view.contentInsets = insets
view.invalidateIntrinsicContentSize()
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: MTMathUILabel, context: Context) -> CGSize? {
// Enable line wrapping by passing proposed width to the label
if let width = proposal.width, width.isFinite, width > 0 {
uiView.preferredMaxLayoutWidth = width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
return nil
}
}
```
@@ -143,23 +159,127 @@ 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:

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

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

View File

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

View File

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