multiline fix with square root on second line
This commit is contained in:
@@ -185,6 +185,7 @@ public class MTMathUILabel : MTView {
|
|||||||
public var preferredMaxLayoutWidth: CGFloat {
|
public var preferredMaxLayoutWidth: CGFloat {
|
||||||
set {
|
set {
|
||||||
_preferredMaxLayoutWidth = newValue
|
_preferredMaxLayoutWidth = newValue
|
||||||
|
_displayList = nil // Clear cached display list when width constraint changes
|
||||||
self.invalidateIntrinsicContentSize()
|
self.invalidateIntrinsicContentSize()
|
||||||
self.setNeedsLayout()
|
self.setNeedsLayout()
|
||||||
}
|
}
|
||||||
@@ -246,10 +247,18 @@ public class MTMathUILabel : MTView {
|
|||||||
if self.mathList == nil { return }
|
if self.mathList == nil { return }
|
||||||
if self.font == nil { return }
|
if self.font == nil { return }
|
||||||
|
|
||||||
|
// Ensure display list is created before drawing
|
||||||
|
if _displayList == nil {
|
||||||
|
_layoutSubviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let displayList = _displayList else { return }
|
||||||
|
|
||||||
// drawing code
|
// drawing code
|
||||||
let context = MTGraphicsGetCurrentContext()!
|
let context = MTGraphicsGetCurrentContext()!
|
||||||
context.saveGState()
|
context.saveGState()
|
||||||
displayList!.draw(context)
|
|
||||||
|
displayList.draw(context)
|
||||||
context.restoreGState()
|
context.restoreGState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,10 +287,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("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("Post list = \(_mathList!)")
|
|
||||||
var textX = CGFloat(0)
|
var textX = CGFloat(0)
|
||||||
switch self.textAlignment {
|
switch self.textAlignment {
|
||||||
case .left: textX = contentInsets.left
|
case .left: textX = contentInsets.left
|
||||||
@@ -296,6 +304,7 @@ public class MTMathUILabel : MTView {
|
|||||||
height = fontSize/2 // set height to half the font size
|
height = fontSize/2 // set height to half the font size
|
||||||
}
|
}
|
||||||
let textY = (availableHeight - height) / 2 + _displayList!.descent + contentInsets.bottom
|
let textY = (availableHeight - height) / 2 + _displayList!.descent + contentInsets.bottom
|
||||||
|
|
||||||
_displayList!.position = CGPointMake(textX, textY)
|
_displayList!.position = CGPointMake(textX, textY)
|
||||||
errorLabel?.frame = self.bounds
|
errorLabel?.frame = self.bounds
|
||||||
self.setNeedsDisplay()
|
self.setNeedsDisplay()
|
||||||
@@ -362,7 +371,6 @@ public class MTMathUILabel : MTView {
|
|||||||
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) }
|
public override var intrinsicContentSize: CGSize { _sizeThatFits(CGSizeZero) }
|
||||||
override public var isFlipped: Bool { false }
|
|
||||||
override public func layout() {
|
override public func layout() {
|
||||||
self._layoutSubviews()
|
self._layoutSubviews()
|
||||||
super.layout()
|
super.layout()
|
||||||
|
|||||||
@@ -733,6 +733,76 @@ class MTTypesetter {
|
|||||||
return projectedWidth > maxWidth
|
return projectedWidth > maxWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adjust the current position to avoid overlap between the new display and previous line's displays
|
||||||
|
/// This is called when adding displays to a line below the first line
|
||||||
|
///
|
||||||
|
/// Coordinate formulas (from test expectations):
|
||||||
|
/// - Bottom of display = position.y + descent
|
||||||
|
/// - Top of display = position.y - ascent
|
||||||
|
/// - No overlap when: prevBottom <= currTop + spacing
|
||||||
|
/// - Which means: prevBottom <= (currPosition - currAscent) + spacing
|
||||||
|
/// - Rearranging: currPosition >= prevBottom + currAscent - spacing
|
||||||
|
///
|
||||||
|
/// Recursively adjust positions of a display and all its nested sub-displays
|
||||||
|
/// Note: For MTRadicalDisplay and MTFractionDisplay, their position setters automatically
|
||||||
|
/// update child positions (radicand/degree, numerator/denominator), so we don't need
|
||||||
|
/// to manually adjust those. We only need to adjust subdisplays within MTMathListDisplay.
|
||||||
|
private func adjustDisplayPosition(_ display: MTDisplay, by delta: CGFloat) {
|
||||||
|
display.position.y += delta
|
||||||
|
|
||||||
|
// If it's a MTMathListDisplay, adjust all its subdisplays too
|
||||||
|
if let mathListDisplay = display as? MTMathListDisplay {
|
||||||
|
for subDisplay in mathListDisplay.subDisplays {
|
||||||
|
adjustDisplayPosition(subDisplay, by: delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: No special handling needed for MTRadicalDisplay or MTFractionDisplay
|
||||||
|
// Their position setters handle updating child positions automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust position to avoid overlap with previous line
|
||||||
|
/// In CoreText's Y-up coordinate system:
|
||||||
|
/// - Positive Y = upward, Negative Y = downward
|
||||||
|
/// - Top of display = position + ascent (higher Y)
|
||||||
|
/// - Bottom of display = position - descent (lower Y)
|
||||||
|
/// - No overlap when: prevBottom >= currTop (with spacing)
|
||||||
|
private func adjustPositionToAvoidOverlap(_ display: MTDisplay) {
|
||||||
|
// Find all displays on previous lines and calculate their minimum bottom edge
|
||||||
|
// In Y-up: Bottom = position - descent (lower Y value)
|
||||||
|
var minBottomEdge: CGFloat = CGFloat.greatestFiniteMagnitude
|
||||||
|
|
||||||
|
for i in 0..<currentLineStartIndex {
|
||||||
|
let prevDisplay = displayAtoms[i]
|
||||||
|
let bottomEdge = prevDisplay.position.y - prevDisplay.descent
|
||||||
|
minBottomEdge = min(minBottomEdge, bottomEdge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate where current top would be
|
||||||
|
// In Y-up: Top = position + ascent (higher Y value)
|
||||||
|
let currentTop = currentPosition.y + display.ascent
|
||||||
|
|
||||||
|
// Check for overlap: prevBottom should be <= currTop (with spacing)
|
||||||
|
// We need prevBottom - spacing >= currTop for no overlap
|
||||||
|
let tolerance: CGFloat = 0.5
|
||||||
|
let maxAllowedTop = minBottomEdge - tolerance
|
||||||
|
|
||||||
|
if currentTop > maxAllowedTop {
|
||||||
|
// Current top is too high, adjust position downward (more negative)
|
||||||
|
// We need: position + ascent = maxAllowedTop
|
||||||
|
// So: position = maxAllowedTop - ascent
|
||||||
|
let requiredPosition = maxAllowedTop - display.ascent
|
||||||
|
let delta = requiredPosition - currentPosition.y
|
||||||
|
|
||||||
|
currentPosition.y = requiredPosition
|
||||||
|
|
||||||
|
// Update all displays on this line, including nested subdisplays
|
||||||
|
for i in currentLineStartIndex..<displayAtoms.count {
|
||||||
|
adjustDisplayPosition(displayAtoms[i], by: delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Perform line break for complex displays
|
/// Perform line break for complex displays
|
||||||
func performLineBreak() {
|
func performLineBreak() {
|
||||||
if currentLine.length > 0 {
|
if currentLine.length > 0 {
|
||||||
@@ -1000,6 +1070,12 @@ class MTTypesetter {
|
|||||||
// Position and add the radical display
|
// Position and add the radical display
|
||||||
displayRad!.position = currentPosition
|
displayRad!.position = currentPosition
|
||||||
displayAtoms.append(displayRad!)
|
displayAtoms.append(displayRad!)
|
||||||
|
|
||||||
|
// Check for overlap if we're not on the first line
|
||||||
|
if currentLineStartIndex > 0 {
|
||||||
|
adjustPositionToAvoidOverlap(displayRad!)
|
||||||
|
}
|
||||||
|
|
||||||
currentPosition.x += displayRad!.width
|
currentPosition.x += displayRad!.width
|
||||||
|
|
||||||
// add super scripts || subscripts
|
// add super scripts || subscripts
|
||||||
@@ -1032,6 +1108,12 @@ class MTTypesetter {
|
|||||||
// Position and add the fraction display
|
// Position and add the fraction display
|
||||||
display!.position = currentPosition
|
display!.position = currentPosition
|
||||||
displayAtoms.append(display!)
|
displayAtoms.append(display!)
|
||||||
|
|
||||||
|
// Check for overlap if we're not on the first line
|
||||||
|
if currentLineStartIndex > 0 {
|
||||||
|
adjustPositionToAvoidOverlap(display!)
|
||||||
|
}
|
||||||
|
|
||||||
currentPosition.x += display!.width
|
currentPosition.x += display!.width
|
||||||
|
|
||||||
// add super scripts || subscripts
|
// add super scripts || subscripts
|
||||||
|
|||||||
@@ -540,4 +540,202 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests for Complex Math Expressions with Line Breaking
|
||||||
|
|
||||||
|
func testComplexExpressionWithRadicalWrapping() {
|
||||||
|
// This is the reported issue: y=x^{2}+3x+4x+9x+8x+8+\sqrt{\dfrac{3x^{2}+5x}{\cos x}}
|
||||||
|
// The sqrt part is displayed on the second line and overlaps the first line
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "y=x^{2}+3x+4x+9x+8x+8+\\sqrt{\\dfrac{3x^{2}+5x}{\\cos x}}"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
|
||||||
|
// Get unconstrained size first
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
XCTAssertGreaterThan(unconstrainedSize.width, 0, "Unconstrained width should be > 0")
|
||||||
|
XCTAssertGreaterThan(unconstrainedSize.height, 0, "Unconstrained height should be > 0")
|
||||||
|
|
||||||
|
// Now constrain the width to force wrapping
|
||||||
|
label.preferredMaxLayoutWidth = 200
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Layout and check for overlapping
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Check that displays don't overlap by examining positions
|
||||||
|
// Group displays by line (similar y positions) and check for overlap between lines
|
||||||
|
if let displayList = label.displayList {
|
||||||
|
// Group displays by line based on their y position
|
||||||
|
var lineGroups: [[MTDisplay]] = []
|
||||||
|
var currentLineDisplays: [MTDisplay] = []
|
||||||
|
var currentLineY: CGFloat? = nil
|
||||||
|
let yTolerance: CGFloat = 15.0 // Displays within 15 units are considered on same line (accounts for superscripts/subscripts)
|
||||||
|
|
||||||
|
for display in displayList.subDisplays {
|
||||||
|
if let lineY = currentLineY {
|
||||||
|
if abs(display.position.y - lineY) < yTolerance {
|
||||||
|
// Same line
|
||||||
|
currentLineDisplays.append(display)
|
||||||
|
} else {
|
||||||
|
// New line
|
||||||
|
lineGroups.append(currentLineDisplays)
|
||||||
|
currentLineDisplays = [display]
|
||||||
|
currentLineY = display.position.y
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First display
|
||||||
|
currentLineDisplays = [display]
|
||||||
|
currentLineY = display.position.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !currentLineDisplays.isEmpty {
|
||||||
|
lineGroups.append(currentLineDisplays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlap between consecutive lines
|
||||||
|
for i in 1..<lineGroups.count {
|
||||||
|
let previousLine = lineGroups[i-1]
|
||||||
|
let currentLine = lineGroups[i]
|
||||||
|
|
||||||
|
// Find the minimum bottom edge of previous line (Y-up: bottom = pos - desc, smaller Y)
|
||||||
|
let previousLineMinBottom = previousLine.map { $0.position.y - $0.descent }.min() ?? 0
|
||||||
|
|
||||||
|
// Find the maximum top edge of current line (Y-up: top = pos + asc, larger Y)
|
||||||
|
let currentLineMaxTop = currentLine.map { $0.position.y + $0.ascent }.max() ?? 0
|
||||||
|
|
||||||
|
// Check for overlap: if current line's top > previous line's bottom, they overlap
|
||||||
|
// (In Y-up coordinate system: positive Y is upward, negative Y is downward)
|
||||||
|
// Allow 0.5 points tolerance for floating-point precision and small adjustments
|
||||||
|
XCTAssertLessThanOrEqual(currentLineMaxTop, previousLineMinBottom + 0.5,
|
||||||
|
"Line \(i) (top at \(currentLineMaxTop)) overlaps with line \(i-1) (bottom at \(previousLineMinBottom))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRadicalWithFractionInsideWrapping() {
|
||||||
|
// Simplified version: just a radical with a fraction inside
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "x+y+z+\\sqrt{\\dfrac{a}{b}}"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 100
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
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 testTallElementsOnSecondLine() {
|
||||||
|
// Test case with tall fractions and radicals breaking to second line
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "a+b+c+\\dfrac{x^2+y^2}{z^2}+\\sqrt{\\dfrac{p}{q}}"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 150
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Verify no overlapping displays between lines
|
||||||
|
if let displayList = label.displayList {
|
||||||
|
// Group displays by line
|
||||||
|
var lineGroups: [[MTDisplay]] = []
|
||||||
|
var currentLineDisplays: [MTDisplay] = []
|
||||||
|
var currentLineY: CGFloat? = nil
|
||||||
|
let yTolerance: CGFloat = 15.0
|
||||||
|
|
||||||
|
for display in displayList.subDisplays {
|
||||||
|
if let lineY = currentLineY {
|
||||||
|
if abs(display.position.y - lineY) < yTolerance {
|
||||||
|
currentLineDisplays.append(display)
|
||||||
|
} else {
|
||||||
|
lineGroups.append(currentLineDisplays)
|
||||||
|
currentLineDisplays = [display]
|
||||||
|
currentLineY = display.position.y
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentLineDisplays = [display]
|
||||||
|
currentLineY = display.position.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !currentLineDisplays.isEmpty {
|
||||||
|
lineGroups.append(currentLineDisplays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlap between consecutive lines
|
||||||
|
for i in 1..<lineGroups.count {
|
||||||
|
let previousLine = lineGroups[i-1]
|
||||||
|
let currentLine = lineGroups[i]
|
||||||
|
|
||||||
|
let previousLineMinBottom = previousLine.map { $0.position.y - $0.descent }.min() ?? 0
|
||||||
|
let currentLineMaxTop = currentLine.map { $0.position.y + $0.ascent }.max() ?? 0
|
||||||
|
|
||||||
|
// Allow 0.5 points tolerance for floating-point precision
|
||||||
|
XCTAssertLessThanOrEqual(currentLineMaxTop, previousLineMinBottom + 0.5,
|
||||||
|
"Line \(i) overlaps with line \(i-1)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleLinesWithVaryingHeights() {
|
||||||
|
// Test expression that should wrap to multiple lines with different heights
|
||||||
|
let label = MTMathUILabel()
|
||||||
|
label.latex = "x+y+z+a+b+c+\\sqrt{d}+e+f+g+h+\\dfrac{i}{j}+k"
|
||||||
|
label.font = MTFontManager.fontManager.defaultFont
|
||||||
|
|
||||||
|
let unconstrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
label.preferredMaxLayoutWidth = 120
|
||||||
|
let constrainedSize = label.intrinsicContentSize
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0")
|
||||||
|
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