[UI] add line wrapping functionality
This commit is contained in:
124
README.md
124
README.md
@@ -117,15 +117,31 @@ struct MathView: UIViewRepresentable {
|
|||||||
|
|
||||||
func makeUIView(context: Context) -> MTMathUILabel {
|
func makeUIView(context: Context) -> MTMathUILabel {
|
||||||
let view = MTMathUILabel()
|
let view = MTMathUILabel()
|
||||||
|
view.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
view.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: MTMathUILabel, context: Context) {
|
func updateUIView(_ view: MTMathUILabel, context: Context) {
|
||||||
view.latex = equation
|
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.textAlignment = textAlignment
|
||||||
view.labelMode = labelMode
|
view.labelMode = labelMode
|
||||||
view.textColor = MTColor(Color.primary)
|
view.textColor = MTColor(Color.primary)
|
||||||
view.contentInsets = insets
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -146,20 +162,124 @@ struct MathView: NSViewRepresentable {
|
|||||||
|
|
||||||
func makeNSView(context: Context) -> MTMathUILabel {
|
func makeNSView(context: Context) -> MTMathUILabel {
|
||||||
let view = MTMathUILabel()
|
let view = MTMathUILabel()
|
||||||
|
view.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
view.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ view: MTMathUILabel, context: Context) {
|
func updateNSView(_ view: MTMathUILabel, context: Context) {
|
||||||
view.latex = equation
|
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.textAlignment = textAlignment
|
||||||
view.labelMode = labelMode
|
view.labelMode = labelMode
|
||||||
view.textColor = MTColor(Color.primary)
|
view.textColor = MTColor(Color.primary)
|
||||||
view.contentInsets = insets
|
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
|
### Included Features
|
||||||
This is a list of formula types that the library currently supports:
|
This is a list of formula types that the library currently supports:
|
||||||
|
|
||||||
|
|||||||
58
Sources/SwiftMath/MathRender/MTMathUILabel.swift
Executable file → Normal file
58
Sources/SwiftMath/MathRender/MTMathUILabel.swift
Executable file → Normal file
@@ -180,6 +180,18 @@ public class MTMathUILabel : MTView {
|
|||||||
public var displayList: MTMathListDisplay? { _displayList }
|
public var displayList: MTMathListDisplay? { _displayList }
|
||||||
private var _displayList:MTMathListDisplay?
|
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 {
|
public var currentStyle:MTLineStyle {
|
||||||
switch _labelMode {
|
switch _labelMode {
|
||||||
case .display: return .display
|
case .display: return .display
|
||||||
@@ -241,8 +253,12 @@ public class MTMathUILabel : MTView {
|
|||||||
|
|
||||||
func _layoutSubviews() {
|
func _layoutSubviews() {
|
||||||
if _mathList != nil {
|
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!)")
|
// 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
|
_displayList!.textColor = textColor
|
||||||
// print("Post list = \(_mathList!)")
|
// print("Post list = \(_mathList!)")
|
||||||
var textX = CGFloat(0)
|
var textX = CGFloat(0)
|
||||||
@@ -268,19 +284,47 @@ public class MTMathUILabel : MTView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func _sizeThatFits(_ size:CGSize) -> CGSize {
|
func _sizeThatFits(_ size:CGSize) -> CGSize {
|
||||||
guard _mathList != nil else { return size }
|
guard _mathList != nil else {
|
||||||
var size = size
|
// 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
|
var displayList:MTMathListDisplay? = nil
|
||||||
displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle)
|
displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: maxWidth)
|
||||||
size.width = displayList!.width + contentInsets.left + contentInsets.right
|
|
||||||
size.height = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom
|
guard displayList != nil else {
|
||||||
return size
|
// 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)
|
#if os(macOS)
|
||||||
func setNeedsDisplay() { self.needsDisplay = true }
|
func setNeedsDisplay() { self.needsDisplay = true }
|
||||||
func setNeedsLayout() { self.needsLayout = true }
|
func setNeedsLayout() { self.needsLayout = true }
|
||||||
public override var fittingSize: CGSize { _sizeThatFits(CGSizeZero) }
|
public override var fittingSize: CGSize { _sizeThatFits(CGSizeZero) }
|
||||||
|
public override var intrinsicContentSize: CGSize { _sizeThatFits(CGSizeZero) }
|
||||||
override public var isFlipped: Bool { false }
|
override public var isFlipped: Bool { false }
|
||||||
override public func layout() {
|
override public func layout() {
|
||||||
self._layoutSubviews()
|
self._layoutSubviews()
|
||||||
|
|||||||
@@ -362,23 +362,40 @@ class MTTypesetter {
|
|||||||
}
|
}
|
||||||
var cramped = false
|
var cramped = false
|
||||||
var spaced = 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? {
|
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle) -> MTMathListDisplay? {
|
||||||
|
let finalizedList = mathList?.finalized
|
||||||
|
// 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
|
let finalizedList = mathList?.finalized
|
||||||
// default is not cramped
|
// default is not cramped
|
||||||
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false)
|
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false, maxWidth: maxWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool) -> MTMathListDisplay? {
|
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
|
// Internal
|
||||||
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool) -> MTMathListDisplay? {
|
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)
|
assert(font != nil)
|
||||||
let preprocessedAtoms = self.preprocessMathList(mathList)
|
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)
|
typesetter.createDisplayAtoms(preprocessedAtoms)
|
||||||
let lastAtom = mathList!.atoms.last
|
let lastAtom = mathList!.atoms.last
|
||||||
let last = lastAtom?.indexRange ?? NSMakeRange(0, 0)
|
let last = lastAtom?.indexRange ?? NSMakeRange(0, 0)
|
||||||
@@ -388,12 +405,13 @@ class MTTypesetter {
|
|||||||
|
|
||||||
static var placeholderColor: MTColor { MTColor.blue }
|
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.font = font
|
||||||
self.displayAtoms = [MTDisplay]()
|
self.displayAtoms = [MTDisplay]()
|
||||||
self.currentPosition = CGPoint.zero
|
self.currentPosition = CGPoint.zero
|
||||||
self.cramped = cramped
|
self.cramped = cramped
|
||||||
self.spaced = spaced
|
self.spaced = spaced
|
||||||
|
self.maxWidth = maxWidth
|
||||||
self.currentLine = NSMutableAttributedString()
|
self.currentLine = NSMutableAttributedString()
|
||||||
self.currentAtoms = [MTMathAtom]()
|
self.currentAtoms = [MTMathAtom]()
|
||||||
self.style = style
|
self.style = style
|
||||||
@@ -662,22 +680,78 @@ class MTTypesetter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .accent:
|
case .accent:
|
||||||
// stash the existing layout
|
if maxWidth > 0 {
|
||||||
if currentLine.length > 0 {
|
// When line wrapping is enabled, render the accent properly but inline
|
||||||
self.addDisplayLine()
|
// to avoid premature line flushing
|
||||||
}
|
|
||||||
// Accent is considered as Ord in rule 16.
|
|
||||||
self.addInterElementSpace(prevNode, currentType:.ordinary)
|
|
||||||
atom.type = .ordinary;
|
|
||||||
|
|
||||||
let accent = atom as! MTAccent?
|
let accent = atom as! MTAccent
|
||||||
let display = self.makeAccent(accent)
|
|
||||||
displayAtoms.append(display!)
|
|
||||||
currentPosition.x += display!.width;
|
|
||||||
|
|
||||||
// add super scripts || subscripts
|
// Get the base character from innerList
|
||||||
if atom.subScript != nil || atom.superScript != nil {
|
var baseChar = ""
|
||||||
self.makeScripts(atom, display:display, index:UInt(atom.indexRange.location), delta:0)
|
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:
|
case .table:
|
||||||
@@ -720,7 +794,57 @@ class MTTypesetter {
|
|||||||
} else {
|
} else {
|
||||||
current = NSAttributedString(string:atom.nucleus)
|
current = NSAttributedString(string:atom.nucleus)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentLine.append(current!)
|
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
|
// add the atom to the current range
|
||||||
if currentLineIndexRange.location == NSNotFound {
|
if currentLineIndexRange.location == NSNotFound {
|
||||||
currentLineIndexRange = atom.indexRange
|
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
|
@discardableResult
|
||||||
func addDisplayLine() -> MTCTLineDisplay? {
|
func addDisplayLine() -> MTCTLineDisplay? {
|
||||||
// add the font
|
// add the font
|
||||||
|
|||||||
194
Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift
Normal file
194
Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//
|
||||||
|
// MTMathUILabelLineWrappingTests.swift
|
||||||
|
// SwiftMathTests
|
||||||
|
//
|
||||||
|
// Tests for line wrapping functionality in MTMathUILabel
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import SwiftMath
|
||||||
|
|
||||||
|
class MTMathUILabelLineWrappingTests: XCTestCase {
|
||||||
|
|
||||||
|
func testBasicIntrinsicContentSize() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(x + y\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
|
||||||
|
// Debug: check if parsing worked
|
||||||
|
XCTAssertNotNil(label.mathList, "Math list should not be nil")
|
||||||
|
XCTAssertNil(label.error, "Should have no parsing error, got: \(String(describing: label.error))")
|
||||||
|
XCTAssertNotNil(label.font, "Font should not be nil")
|
||||||
|
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
|
||||||
|
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTextModeIntrinsicContentSize() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Hello World}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
|
||||||
|
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLongTextIntrinsicContentSize() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
|
||||||
|
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSizeThatFitsWithoutConstraint() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Hello World}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
|
||||||
|
let size = label.sizeThatFits(CGSize.zero)
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
|
||||||
|
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSizeThatFitsWithWidthConstraint() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
// Get unconstrained size first
|
||||||
|
let unconstrainedSize = label.sizeThatFits(CGSize.zero)
|
||||||
|
XCTAssertGreaterThan(unconstrainedSize.width, 0, "Unconstrained width should be > 0")
|
||||||
|
|
||||||
|
// Test with width constraint (use 300 since longest word might be ~237pt)
|
||||||
|
let constrainedSize = label.sizeThatFits(CGSize(width: 300, height: CGFloat.greatestFiniteMagnitude))
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Constrained width should be greater than 0, got \(constrainedSize.width)")
|
||||||
|
XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width (\(constrainedSize.width)) should be less than unconstrained (\(unconstrainedSize.width))")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, 0, "Constrained height should be greater than 0, got \(constrainedSize.height)")
|
||||||
|
|
||||||
|
// When constrained, height should increase when text wraps
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height,
|
||||||
|
"Constrained height (\(constrainedSize.height)) should be > unconstrained (\(unconstrainedSize.height)) when text wraps")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreferredMaxLayoutWidth() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
// Get unconstrained size
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Now set preferred max width (use 300 since longest word might be ~237pt)
|
||||||
|
label.preferredMaxLayoutWidth = 300
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be greater than 0, got \(constrainedSize.width)")
|
||||||
|
XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width (\(constrainedSize.width)) should be < unconstrained (\(unconstrainedSize.width))")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Constrained height (\(constrainedSize.height)) should be > unconstrained (\(unconstrainedSize.height)) due to wrapping")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWordBoundaryBreaking() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Word1 Word2 Word3 Word4 Word5}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
label.preferredMaxLayoutWidth = 150
|
||||||
|
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
|
||||||
|
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
|
||||||
|
|
||||||
|
// Verify it actually uses the layout
|
||||||
|
label.frame = CGRect(origin: .zero, size: size)
|
||||||
|
#if os(macOS)
|
||||||
|
label.layout()
|
||||||
|
#else
|
||||||
|
label.layoutSubviews()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
XCTAssertNotNil(label.displayList, "Display list should be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyLatex() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = ""
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Empty latex should still return a valid size (might be zero or minimal)
|
||||||
|
XCTAssertGreaterThanOrEqual(size.width, 0, "Width should be >= 0 for empty latex, got \(size.width)")
|
||||||
|
XCTAssertGreaterThanOrEqual(size.height, 0, "Height should be >= 0 for empty latex, got \(size.height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMathAndTextMixed() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Result: } x^2 + y^2 = z^2\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)")
|
||||||
|
XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDebugSizeThatFitsWithConstraint() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Word1 Word2 Word3 Word4 Word5}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let unconstr = label.sizeThatFits(CGSize.zero)
|
||||||
|
let constr = label.sizeThatFits(CGSize(width: 150, height: 999))
|
||||||
|
|
||||||
|
XCTAssertLessThan(constr.width, unconstr.width, "Constrained (\(constr.width)) should be < unconstrained (\(unconstr.width))")
|
||||||
|
XCTAssertGreaterThan(constr.height, unconstr.height, "Constrained height (\(constr.height)) should be > unconstrained (\(unconstr.height))")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAccentedCharactersWithLineWrapping() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// French text with accented characters: è, é, à
|
||||||
|
label.latex = "\\(\\text{Rappelons la relation entre kilomètres et mètres.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
// Get unconstrained size
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Set a width constraint that should cause wrapping
|
||||||
|
label.preferredMaxLayoutWidth = 250
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Verify wrapping occurred
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width should be < unconstrained")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped")
|
||||||
|
|
||||||
|
// Verify the label can render without errors
|
||||||
|
label.frame = CGRect(origin: .zero, size: constrainedSize)
|
||||||
|
#if os(macOS)
|
||||||
|
label.layout()
|
||||||
|
#else
|
||||||
|
label.layoutSubviews()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
XCTAssertNotNil(label.displayList, "Display list should be created")
|
||||||
|
XCTAssertNil(label.error, "Should have no rendering error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user