From ca0e514505e2939a4f506444e74dfef9b8a8095d Mon Sep 17 00:00:00 2001 From: Nicolas Guillot Date: Mon, 27 Oct 2025 13:18:10 +0100 Subject: [PATCH] [multi line display] Fix line wrapping to respect width constraints and prevent text truncation --- .../SwiftMath/MathRender/MTMathUILabel.swift | 11 +- .../SwiftMath/MathRender/MTTypesetter.swift | 235 ++++++++++++--- .../MTMathUILabelLineWrappingTests.swift | 284 ++++++++++++++++++ 3 files changed, 493 insertions(+), 37 deletions(-) diff --git a/Sources/SwiftMath/MathRender/MTMathUILabel.swift b/Sources/SwiftMath/MathRender/MTMathUILabel.swift index 1d131b4..98c8375 100644 --- a/Sources/SwiftMath/MathRender/MTMathUILabel.swift +++ b/Sources/SwiftMath/MathRender/MTMathUILabel.swift @@ -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 @@ -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) } diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index ca45f12..ea5db9d 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -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 @@ -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 = [ + ".", ",", // 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 } @@ -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 @@ -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 diff --git a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift index 37394cb..748a41f 100644 --- a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift +++ b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift @@ -191,4 +191,288 @@ class MTMathUILabelLineWrappingTests: XCTestCase { XCTAssertNotNil(label.displayList, "Display list should be created") 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") + } }