Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Sources/SwiftMath/MathRender/MTMathUILabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ public class MTMathUILabel : MTView {
self.layer?.isGeometryFlipped = true
#else
self.layer.isGeometryFlipped = true
self.clipsToBounds = true
#endif
_fontSize = 20
_contentInsets = MTEdgeInsetsZero
Expand Down Expand Up @@ -305,8 +306,16 @@ public class MTMathUILabel : MTView {
return CGSize(width: -1, height: -1)
}

let resultWidth = displayList!.width + contentInsets.left + contentInsets.right
var resultWidth = displayList!.width + contentInsets.left + contentInsets.right
let resultHeight = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom

// Ensure we don't exceed the width constraints
if _preferredMaxLayoutWidth > 0 && resultWidth > _preferredMaxLayoutWidth {
resultWidth = _preferredMaxLayoutWidth
} else if _preferredMaxLayoutWidth == 0 && size.width > 0 && resultWidth > size.width {
resultWidth = size.width
}

return CGSize(width: resultWidth, height: resultHeight)
}

Expand Down
235 changes: 199 additions & 36 deletions Sources/SwiftMath/MathRender/MTTypesetter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -812,37 +812,67 @@ class MTTypesetter {
// 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)
// Use Unicode-aware line breaking with number protection
if let breakIndex = findBestBreakPoint(in: currentText, font: styleFont.ctFont, maxWidth: maxWidth) {
// Split the line at the suggested break point
let breakOffset = currentText.distance(from: currentText.startIndex, to: breakIndex)

// Create attributed string for the first line (before space)
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
// Create attributed string for the first line
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(breakOffset)))
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)
// Check if first line still exceeds maxWidth - need to find earlier break point
let firstLineCT = CTLineCreateWithAttributedString(firstLine)
let firstLineWidth = CGFloat(CTLineGetTypographicBounds(firstLineCT, nil, nil, nil))

if firstLineWidth > maxWidth {
// Need to break earlier - find previous break point
let firstLineText = firstLine.string
if let earlierBreakIndex = findBestBreakPoint(in: firstLineText, font: styleFont.ctFont, maxWidth: maxWidth) {
let earlierOffset = firstLineText.distance(from: firstLineText.startIndex, to: earlierBreakIndex)
let earlierLine = NSMutableAttributedString(string: String(firstLineText.prefix(earlierOffset)))
earlierLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, earlierLine.length))

// Flush the earlier line
currentLine = earlierLine
currentAtoms = [] // Approximate - we're splitting
self.addDisplayLine()

// Move down for new line
currentPosition.y -= styleFont.fontSize * 1.5
currentPosition.x = 0

// Remaining text includes everything after the earlier break
let remainingText = String(firstLineText.suffix(from: earlierBreakIndex)) +
String(currentText.suffix(from: breakIndex))
currentLine = NSMutableAttributedString(string: remainingText)
currentAtoms = []
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
}
} else {
// First line fits - proceed with normal wrapping
// 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 break
let remainingText = String(currentText.suffix(from: breakIndex))
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)
// If no break point found, let it overflow (better than breaking mid-word)
}
}
// add the atom to the current range
Expand Down Expand Up @@ -890,7 +920,109 @@ class MTTypesetter {
display?.width += interElementSpace
}
}


// MARK: - Unicode-aware Line Breaking

/// Find the best break point using Core Text, with conservative number protection
func findBestBreakPoint(in text: String, font: CTFont, maxWidth: CGFloat) -> String.Index? {
let attributes: [NSAttributedString.Key: Any] = [kCTFontAttributeName as NSAttributedString.Key: font]
let attrString = NSAttributedString(string: text, attributes: attributes)
let typesetter = CTTypesetterCreateWithAttributedString(attrString as CFAttributedString)
let suggestedBreak = CTTypesetterSuggestLineBreak(typesetter, 0, Double(maxWidth))

guard suggestedBreak > 0 && suggestedBreak < text.count else {
return nil
}

let breakIndex = text.index(text.startIndex, offsetBy: suggestedBreak)

// Conservative check: verify we're not breaking within a number
if isBreakingSafeForNumbers(text: text, breakIndex: breakIndex) {
return breakIndex
}

// If the suggested break would split a number, find the previous safe break point
return findPreviousSafeBreak(in: text, before: breakIndex)
}

/// Check if breaking at this index would split a number
func isBreakingSafeForNumbers(text: String, breakIndex: String.Index) -> Bool {
guard breakIndex > text.startIndex && breakIndex < text.endIndex else {
return true
}

// Check a small window around the break point
let beforeIndex = text.index(before: breakIndex)
let charBefore = text[beforeIndex]
let charAfter = text[breakIndex]

// Number separators in various locales
let numberSeparators: Set<Character> = [
".", ",", // Decimal/thousands (EN/FR)
"'", // Thousands (CH)
"\u{00A0}", // Non-breaking space (FR thousands)
"\u{2009}", // Thin space (sometimes used)
"\u{202F}" // Narrow no-break space (FR)
]

// Pattern 1: digit + separator + digit (e.g., "3.14" or "3,14")
if charBefore.isNumber && numberSeparators.contains(charAfter) {
// Check if there's a digit after the separator
let nextIndex = text.index(after: breakIndex)
if nextIndex < text.endIndex && text[nextIndex].isNumber {
return false // Don't break: this looks like "3.|14"
}
}

// Pattern 2: separator + digit, check if previous is digit
if numberSeparators.contains(charBefore) && charAfter.isNumber {
// Check if there's a digit before the separator
if beforeIndex > text.startIndex {
let prevIndex = text.index(before: beforeIndex)
if text[prevIndex].isNumber {
return false // Don't break: this looks like "3,|14"
}
}
}

// Pattern 3: digit + digit (shouldn't happen with CTTypesetter, but be safe)
if charBefore.isNumber && charAfter.isNumber {
return false // Don't break within consecutive digits
}

// Pattern 4: digit + space + digit (French: "1 000 000")
if charBefore.isNumber && charAfter.isWhitespace {
let nextIndex = text.index(after: breakIndex)
if nextIndex < text.endIndex && text[nextIndex].isNumber {
return false // Don't break: this looks like "1 |000"
}
}

return true // Safe to break
}

/// Find previous safe break point before the given index
func findPreviousSafeBreak(in text: String, before breakIndex: String.Index) -> String.Index? {
var currentIndex = breakIndex

// Walk backwards to find a space or safe break
while currentIndex > text.startIndex {
currentIndex = text.index(before: currentIndex)

// Prefer breaking at whitespace (safest option)
if text[currentIndex].isWhitespace {
return text.index(after: currentIndex) // Break after the space
}

// Check if this would be safe
if isBreakingSafeForNumbers(text: text, breakIndex: currentIndex) {
return currentIndex
}
}

return nil
}

/// Check if the current line exceeds maxWidth and break if needed
func checkAndBreakLine() {
guard maxWidth > 0 && currentLine.length > 0 else { return }
Expand All @@ -906,15 +1038,46 @@ class MTTypesetter {
// 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)
// Use Unicode-aware line breaking with number protection
if let breakIndex = findBestBreakPoint(in: currentText, font: styleFont.ctFont, maxWidth: maxWidth) {
// Split the line at the suggested break point
let breakOffset = currentText.distance(from: currentText.startIndex, to: breakIndex)

// Create attributed string for the first line (before space)
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
// Create attributed string for the first line
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(breakOffset)))
firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length))

// Check if first line still exceeds maxWidth - need to find earlier break point
let firstLineCT = CTLineCreateWithAttributedString(firstLine)
let firstLineWidth = CGFloat(CTLineGetTypographicBounds(firstLineCT, nil, nil, nil))

if firstLineWidth > maxWidth {
// Need to break earlier - find previous break point
let firstLineText = firstLine.string
if let earlierBreakIndex = findBestBreakPoint(in: firstLineText, font: styleFont.ctFont, maxWidth: maxWidth) {
let earlierOffset = firstLineText.distance(from: firstLineText.startIndex, to: earlierBreakIndex)
let earlierLine = NSMutableAttributedString(string: String(firstLineText.prefix(earlierOffset)))
earlierLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, earlierLine.length))

// Flush the earlier line
currentLine = earlierLine
currentAtoms = []
self.addDisplayLine()

// Move down for new line
currentPosition.y -= styleFont.fontSize * 1.5
currentPosition.x = 0

// Remaining text includes everything after the earlier break
let remainingText = String(firstLineText.suffix(from: earlierBreakIndex)) +
String(currentText.suffix(from: breakIndex))
currentLine = NSMutableAttributedString(string: remainingText)
currentAtoms = []
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
return
}
}

// Keep track of atoms that belong to the first line
let firstLineAtoms = currentAtoms

Expand All @@ -927,8 +1090,8 @@ class MTTypesetter {
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)))
// Start the new line with the content after the break
let remainingText = String(currentText.suffix(from: breakIndex))
currentLine = NSMutableAttributedString(string: remainingText)

// Reset atom list for new line
Expand Down
Loading