Merge pull request #53 from nguillot/multiline-fixes
Add Unicode-aware line wrapping with conservative number protection
This commit is contained in:
@@ -216,6 +216,7 @@ public class MTMathUILabel : MTView {
|
|||||||
self.layer?.isGeometryFlipped = true
|
self.layer?.isGeometryFlipped = true
|
||||||
#else
|
#else
|
||||||
self.layer.isGeometryFlipped = true
|
self.layer.isGeometryFlipped = true
|
||||||
|
self.clipsToBounds = true
|
||||||
#endif
|
#endif
|
||||||
_fontSize = 20
|
_fontSize = 20
|
||||||
_contentInsets = MTEdgeInsetsZero
|
_contentInsets = MTEdgeInsetsZero
|
||||||
@@ -305,8 +306,16 @@ public class MTMathUILabel : MTView {
|
|||||||
return CGSize(width: -1, height: -1)
|
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
|
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)
|
return CGSize(width: resultWidth, height: resultHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -812,37 +812,67 @@ class MTTypesetter {
|
|||||||
// Line is too wide - need to find a break point
|
// Line is too wide - need to find a break point
|
||||||
let currentText = currentLine.string
|
let currentText = currentLine.string
|
||||||
|
|
||||||
// Look for the last space before the current position
|
// Use Unicode-aware line breaking with number protection
|
||||||
if let lastSpaceIndex = currentText.lastIndex(of: " ") {
|
if let breakIndex = findBestBreakPoint(in: currentText, font: styleFont.ctFont, maxWidth: maxWidth) {
|
||||||
// Split the line at the last space
|
// Split the line at the suggested break point
|
||||||
let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex)
|
let breakOffset = currentText.distance(from: currentText.startIndex, to: breakIndex)
|
||||||
|
|
||||||
// Create attributed string for the first line (before space)
|
// Create attributed string for the first line
|
||||||
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
|
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(breakOffset)))
|
||||||
firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length))
|
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
|
// Check if first line still exceeds maxWidth - need to find earlier break point
|
||||||
// For simplicity, we'll split atoms at the boundary (this is approximate)
|
let firstLineCT = CTLineCreateWithAttributedString(firstLine)
|
||||||
let firstLineAtoms = currentAtoms
|
let firstLineWidth = CGFloat(CTLineGetTypographicBounds(firstLineCT, nil, nil, nil))
|
||||||
|
|
||||||
// Flush the first line
|
if firstLineWidth > maxWidth {
|
||||||
currentLine = firstLine
|
// Need to break earlier - find previous break point
|
||||||
currentAtoms = firstLineAtoms
|
let firstLineText = firstLine.string
|
||||||
self.addDisplayLine()
|
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))
|
||||||
|
|
||||||
// Move down for new line and reset x position
|
// Flush the earlier line
|
||||||
currentPosition.y -= styleFont.fontSize * 1.5
|
currentLine = earlierLine
|
||||||
currentPosition.x = 0
|
currentAtoms = [] // Approximate - we're splitting
|
||||||
|
self.addDisplayLine()
|
||||||
|
|
||||||
// Start the new line with the content after the space
|
// Move down for new line
|
||||||
let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex)))
|
currentPosition.y -= styleFont.fontSize * 1.5
|
||||||
currentLine = NSMutableAttributedString(string: remainingText)
|
currentPosition.x = 0
|
||||||
|
|
||||||
// Reset atom list for new line
|
// Remaining text includes everything after the earlier break
|
||||||
currentAtoms = []
|
let remainingText = String(firstLineText.suffix(from: earlierBreakIndex)) +
|
||||||
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
|
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
|
// add the atom to the current range
|
||||||
@@ -890,7 +920,109 @@ class MTTypesetter {
|
|||||||
display?.width += interElementSpace
|
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
|
/// Check if the current line exceeds maxWidth and break if needed
|
||||||
func checkAndBreakLine() {
|
func checkAndBreakLine() {
|
||||||
guard maxWidth > 0 && currentLine.length > 0 else { return }
|
guard maxWidth > 0 && currentLine.length > 0 else { return }
|
||||||
@@ -906,15 +1038,46 @@ class MTTypesetter {
|
|||||||
// Line is too wide - need to find a break point
|
// Line is too wide - need to find a break point
|
||||||
let currentText = currentLine.string
|
let currentText = currentLine.string
|
||||||
|
|
||||||
// Look for the last space before the current position
|
// Use Unicode-aware line breaking with number protection
|
||||||
if let lastSpaceIndex = currentText.lastIndex(of: " ") {
|
if let breakIndex = findBestBreakPoint(in: currentText, font: styleFont.ctFont, maxWidth: maxWidth) {
|
||||||
// Split the line at the last space
|
// Split the line at the suggested break point
|
||||||
let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex)
|
let breakOffset = currentText.distance(from: currentText.startIndex, to: breakIndex)
|
||||||
|
|
||||||
// Create attributed string for the first line (before space)
|
// Create attributed string for the first line
|
||||||
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
|
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(breakOffset)))
|
||||||
firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length))
|
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
|
// Keep track of atoms that belong to the first line
|
||||||
let firstLineAtoms = currentAtoms
|
let firstLineAtoms = currentAtoms
|
||||||
|
|
||||||
@@ -927,8 +1090,8 @@ class MTTypesetter {
|
|||||||
currentPosition.y -= styleFont.fontSize * 1.5
|
currentPosition.y -= styleFont.fontSize * 1.5
|
||||||
currentPosition.x = 0
|
currentPosition.x = 0
|
||||||
|
|
||||||
// Start the new line with the content after the space
|
// Start the new line with the content after the break
|
||||||
let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex)))
|
let remainingText = String(currentText.suffix(from: breakIndex))
|
||||||
currentLine = NSMutableAttributedString(string: remainingText)
|
currentLine = NSMutableAttributedString(string: remainingText)
|
||||||
|
|
||||||
// Reset atom list for new line
|
// Reset atom list for new line
|
||||||
|
|||||||
@@ -191,4 +191,288 @@ class MTMathUILabelLineWrappingTests: XCTestCase {
|
|||||||
XCTAssertNotNil(label.displayList, "Display list should be created")
|
XCTAssertNotNil(label.displayList, "Display list should be created")
|
||||||
XCTAssertNil(label.error, "Should have no rendering error")
|
XCTAssertNil(label.error, "Should have no rendering error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testNumberProtection_FrenchDecimal() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// French decimal number should NOT be broken
|
||||||
|
label.latex = "\\(\\text{La valeur de pi est approximativement 3,14 dans ce calcul simple.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
// Constrain to force wrapping, but 3,14 should stay together
|
||||||
|
label.preferredMaxLayoutWidth = 200
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Verify it renders without error
|
||||||
|
label.frame = CGRect(origin: .zero, size: size)
|
||||||
|
#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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNumberProtection_ThousandsSeparator() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Number with comma separator should stay together
|
||||||
|
label.latex = "\\(\\text{The population is approximately 1,000,000 people in this city.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 200
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.frame = CGRect(origin: .zero, size: size)
|
||||||
|
#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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNumberProtection_MixedWithText() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Mixed numbers and text - numbers should be protected
|
||||||
|
label.latex = "\\(\\text{Results: 3.14, 2.71, and 1.41 are important constants.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 180
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.frame = CGRect(origin: .zero, size: size)
|
||||||
|
#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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - International Text Tests
|
||||||
|
|
||||||
|
func testChineseTextWrapping() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Chinese text: "Mathematical equations are an important tool for describing natural phenomena"
|
||||||
|
label.latex = "\\(\\text{数学方程式は自然現象を記述するための重要なツールです。}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
// Get unconstrained size
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Set constraint to force wrapping
|
||||||
|
label.preferredMaxLayoutWidth = 200
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Chinese should wrap (can break between characters)
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(constrainedSize.width, 200, "Width should not exceed constraint")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJapaneseTextWrapping() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Japanese text (Hiragana + Kanji): "This is a mathematics explanation"
|
||||||
|
label.latex = "\\(\\text{これは数学の説明です。計算式を使います。}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 180
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(constrainedSize.width, 180, "Width should not exceed constraint")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKoreanTextWrapping() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Korean text: "Mathematics is a very important subject"
|
||||||
|
label.latex = "\\(\\text{수학은 매우 중요한 과목입니다. 방정식을 배웁니다.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 200
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Korean uses spaces, should wrap at word boundaries
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(constrainedSize.width, 200, "Width should not exceed constraint")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMixedLatinCJKWrapping() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Mixed English and Chinese
|
||||||
|
label.latex = "\\(\\text{The equation is 方程式: } x^2 + y^2 = r^2 \\text{ です。}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 250
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(constrainedSize.width, 250, "Width should not exceed constraint")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmojiGraphemeClusters() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Emoji and complex grapheme clusters should not be broken
|
||||||
|
label.latex = "\\(\\text{Math is fun! 🎉📐📊 The formula is } E = mc^2 \\text{ 🚀✨}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 200
|
||||||
|
let size = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Should wrap but not break emoji
|
||||||
|
XCTAssertGreaterThan(size.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(size.width, 200, "Width should not exceed constraint")
|
||||||
|
|
||||||
|
label.frame = CGRect(origin: .zero, size: size)
|
||||||
|
#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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLongEnglishMultiSentence() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Standard English multi-sentence paragraph
|
||||||
|
label.latex = "\\(\\text{Mathematics is the study of numbers, shapes, and patterns. It is used in science, engineering, and everyday life. Equations help us solve problems.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 300
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// Should wrap at word boundaries (spaces)
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(constrainedSize.width, 300, "Width should not exceed constraint")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpanishAccentedText() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// Spanish with various accents
|
||||||
|
label.latex = "\\(\\text{La ecuación es muy útil para cálculos científicos y matemáticos.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 220
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(constrainedSize.width, 220, "Width should not exceed constraint")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGermanUmlautsWrapping() {
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
// German with umlauts
|
||||||
|
label.latex = "\\(\\text{Mathematische Gleichungen können für Berechnungen verwendet werden.}\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 250
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
XCTAssertLessThanOrEqual(constrainedSize.width, 250, "Width should not exceed constraint")
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped")
|
||||||
|
|
||||||
|
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