Fix line width calculation for expressions with superscripts/subscripts
The typesetter was incorrectly measuring line width when expressions contained
superscripts or subscripts (e.g., b²). After rendering a superscript, the line
is split into multiple display segments, but the width checking code was only
measuring the current segment, not the total visual line width.
Key changes:
- Use currentPosition.x to track actual horizontal position across all segments
- Calculate visualLineWidth = currentPosition.x + currentSegmentWidth
- Pass remainingWidth (maxWidth - currentPosition.x) to findBestBreakPoint
- Apply fix to both interatom breaking and inline text breaking
This fixes truncation issues where content like "Δ=b²-4ac avec a=1..." was
being clipped instead of wrapped to a new line.
Before: Each segment checked in isolation → segments appeared to fit individually
but total visual width exceeded maxWidth → content truncated/clipped
After: Total visual width tracked correctly → line breaking triggered when
actual visual width exceeds maxWidth → content wraps properly
This commit is contained in:
@@ -278,24 +278,9 @@ public class MTMathUILabel : MTView {
|
|||||||
let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width
|
let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width
|
||||||
let availableWidth = effectiveWidth - contentInsets.left - contentInsets.right
|
let availableWidth = effectiveWidth - contentInsets.left - contentInsets.right
|
||||||
|
|
||||||
print("🔧 MTMathUILabel _layoutSubviews:")
|
|
||||||
print(" preferredMaxLayoutWidth: \(_preferredMaxLayoutWidth)")
|
|
||||||
print(" bounds.size.width: \(bounds.size.width)")
|
|
||||||
print(" effectiveWidth: \(effectiveWidth)")
|
|
||||||
print(" availableWidth: \(availableWidth)")
|
|
||||||
print(" LaTeX: \(_latex.prefix(60))...")
|
|
||||||
|
|
||||||
// print("Pre list = \(_mathList!)")
|
// print("Pre list = \(_mathList!)")
|
||||||
_displayList = MTTypesetter.createLineForMathList(_mathList, font: self.font, style: currentStyle, maxWidth: availableWidth)
|
_displayList = MTTypesetter.createLineForMathList(_mathList, font: self.font, style: currentStyle, maxWidth: availableWidth)
|
||||||
_displayList!.textColor = textColor
|
_displayList!.textColor = textColor
|
||||||
|
|
||||||
print(" Display subDisplays count: \(_displayList!.subDisplays.count)")
|
|
||||||
for (index, subDisplay) in _displayList!.subDisplays.enumerated() {
|
|
||||||
print(" Display \(index): type=\(type(of: subDisplay)), x=\(subDisplay.position.x), width=\(subDisplay.width)")
|
|
||||||
if let lineDisplay = subDisplay as? MTCTLineDisplay {
|
|
||||||
print(" Content: '\(lineDisplay.attributedString?.string ?? "")'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// print("Post list = \(_mathList!)")
|
// print("Post list = \(_mathList!)")
|
||||||
var textX = CGFloat(0)
|
var textX = CGFloat(0)
|
||||||
switch self.textAlignment {
|
switch self.textAlignment {
|
||||||
|
|||||||
@@ -557,7 +557,10 @@ class MTTypesetter {
|
|||||||
// starts with a letter, they're part of the same word - don't break!
|
// starts with a letter, they're part of the same word - don't break!
|
||||||
// Example: "...é" + "quivaut" should not break
|
// Example: "...é" + "quivaut" should not break
|
||||||
// But "...km " + "équivaut" can break (has space)
|
// But "...km " + "équivaut" can break (has space)
|
||||||
if lastChar.isLetter && firstChar.isLetter {
|
// IMPORTANT: Only apply this to multi-character atoms (text words), not single
|
||||||
|
// letters (math variables). In math "4ac" splits as "4","a","c" - these are
|
||||||
|
// separate and CAN be broken between.
|
||||||
|
if lastChar.isLetter && firstChar.isLetter && atom.nucleus.count > 1 {
|
||||||
// Don't break - this would split a word
|
// Don't break - this would split a word
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -565,9 +568,14 @@ class MTTypesetter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate what the width would be if we add this atom
|
// Calculate what the width would be if we add this atom
|
||||||
|
// IMPORTANT: Use currentPosition.x instead of getCurrentLineWidth()
|
||||||
|
// because currentLine only measures the current text segment, but after
|
||||||
|
// superscripts/subscripts, the line may be split into multiple segments.
|
||||||
|
// currentPosition.x tracks the actual visual horizontal position.
|
||||||
let currentLineWidth = getCurrentLineWidth()
|
let currentLineWidth = getCurrentLineWidth()
|
||||||
|
let visualLineWidth = currentPosition.x + currentLineWidth
|
||||||
let atomWidth = calculateAtomWidth(atom, prevNode: prevNode)
|
let atomWidth = calculateAtomWidth(atom, prevNode: prevNode)
|
||||||
let projectedWidth = currentLineWidth + atomWidth
|
let projectedWidth = visualLineWidth + atomWidth
|
||||||
|
|
||||||
// If we're well within the limit, no need to break
|
// If we're well within the limit, no need to break
|
||||||
if projectedWidth <= maxWidth {
|
if projectedWidth <= maxWidth {
|
||||||
@@ -1213,14 +1221,22 @@ class MTTypesetter {
|
|||||||
let attrString = currentLine.mutableCopy() as! NSMutableAttributedString
|
let attrString = currentLine.mutableCopy() as! NSMutableAttributedString
|
||||||
attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, attrString.length))
|
attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, attrString.length))
|
||||||
let ctLine = CTLineCreateWithAttributedString(attrString)
|
let ctLine = CTLineCreateWithAttributedString(attrString)
|
||||||
let lineWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
|
let segmentWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
|
||||||
|
|
||||||
if lineWidth > maxWidth {
|
// IMPORTANT: Account for currentPosition.x to get the true visual line width
|
||||||
|
// After superscripts/subscripts, currentPosition.x > 0 because previous segments
|
||||||
|
// have been rendered and flushed
|
||||||
|
let visualLineWidth = currentPosition.x + segmentWidth
|
||||||
|
|
||||||
|
if visualLineWidth > maxWidth {
|
||||||
// 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
|
||||||
|
|
||||||
// Use Unicode-aware line breaking with number protection
|
// Use Unicode-aware line breaking with number protection
|
||||||
if let breakIndex = findBestBreakPoint(in: currentText, font: styleFont.ctFont, maxWidth: maxWidth) {
|
// IMPORTANT: Use remaining width, not full maxWidth, because currentPosition.x
|
||||||
|
// may be > 0 if we've already rendered segments on this visual line
|
||||||
|
let remainingWidth = max(0, maxWidth - currentPosition.x)
|
||||||
|
if let breakIndex = findBestBreakPoint(in: currentText, font: styleFont.ctFont, maxWidth: remainingWidth) {
|
||||||
// Split the line at the suggested break point
|
// Split the line at the suggested break point
|
||||||
let breakOffset = currentText.distance(from: currentText.startIndex, to: breakIndex)
|
let breakOffset = currentText.distance(from: currentText.startIndex, to: breakIndex)
|
||||||
|
|
||||||
@@ -1228,14 +1244,14 @@ class MTTypesetter {
|
|||||||
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(breakOffset)))
|
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
|
// Check if first line still exceeds remaining width - need to find earlier break point
|
||||||
let firstLineCT = CTLineCreateWithAttributedString(firstLine)
|
let firstLineCT = CTLineCreateWithAttributedString(firstLine)
|
||||||
let firstLineWidth = CGFloat(CTLineGetTypographicBounds(firstLineCT, nil, nil, nil))
|
let firstLineWidth = CGFloat(CTLineGetTypographicBounds(firstLineCT, nil, nil, nil))
|
||||||
|
|
||||||
if firstLineWidth > maxWidth {
|
if firstLineWidth > remainingWidth {
|
||||||
// Need to break earlier - find previous break point
|
// Need to break earlier - find previous break point
|
||||||
let firstLineText = firstLine.string
|
let firstLineText = firstLine.string
|
||||||
if let earlierBreakIndex = findBestBreakPoint(in: firstLineText, font: styleFont.ctFont, maxWidth: maxWidth) {
|
if let earlierBreakIndex = findBestBreakPoint(in: firstLineText, font: styleFont.ctFont, maxWidth: remainingWidth) {
|
||||||
let earlierOffset = firstLineText.distance(from: firstLineText.startIndex, to: earlierBreakIndex)
|
let earlierOffset = firstLineText.distance(from: firstLineText.startIndex, to: earlierBreakIndex)
|
||||||
let earlierLine = NSMutableAttributedString(string: String(firstLineText.prefix(earlierOffset)))
|
let earlierLine = NSMutableAttributedString(string: String(firstLineText.prefix(earlierOffset)))
|
||||||
earlierLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, earlierLine.length))
|
earlierLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, earlierLine.length))
|
||||||
|
|||||||
@@ -225,6 +225,39 @@ class MTMathUILabelLineWrappingTests: XCTestCase {
|
|||||||
XCTAssertLessThan(constrainedSize.width, 250, "Width should respect constraint")
|
XCTAssertLessThan(constrainedSize.width, 250, "Width should respect constraint")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMixedTextMathNoTruncation() {
|
||||||
|
// Test for truncation bug: content should wrap, not be lost
|
||||||
|
// Input: \(\text{Calculer le discriminant }\Delta=b^{2}-4ac\text{ avec }a=1\text{, }b=-1\text{, }c=-5\)
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "\\(\\text{Calculer le discriminant }\\Delta=b^{2}-4ac\\text{ avec }a=1\\text{, }b=-1\\text{, }c=-5\\)"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
label.labelMode = .text
|
||||||
|
|
||||||
|
// Set width constraint that should cause wrapping
|
||||||
|
label.preferredMaxLayoutWidth = 235
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// Verify content is not truncated - should wrap to multiple lines
|
||||||
|
XCTAssertGreaterThan(constrainedSize.height, 30, "Should wrap to multiple lines (not truncate)")
|
||||||
|
|
||||||
|
// Check that we have multiple display elements (wrapped content)
|
||||||
|
if let displayList = label.displayList {
|
||||||
|
print("Display has \(displayList.subDisplays.count) subdisplays")
|
||||||
|
XCTAssertGreaterThan(displayList.subDisplays.count, 1, "Should have multiple display elements from wrapping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testNumberProtection_FrenchDecimal() {
|
func testNumberProtection_FrenchDecimal() {
|
||||||
let label = MTMathUILabel()
|
let label = MTMathUILabel()
|
||||||
// French decimal number should NOT be broken
|
// French decimal number should NOT be broken
|
||||||
|
|||||||
Reference in New Issue
Block a user