From 9da5aba6b2e22a25c8639b61687f64569133d5f5 Mon Sep 17 00:00:00 2001 From: Nicolas Guillot Date: Fri, 21 Nov 2025 15:49:27 +0100 Subject: [PATCH] multiline fix with square root on second line --- .../SwiftMath/MathRender/MTMathUILabel.swift | 16 +- .../SwiftMath/MathRender/MTTypesetter.swift | 82 ++++++++ .../MTMathUILabelLineWrappingTests.swift | 198 ++++++++++++++++++ 3 files changed, 292 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftMath/MathRender/MTMathUILabel.swift b/Sources/SwiftMath/MathRender/MTMathUILabel.swift index 64a3f86..2f5117e 100644 --- a/Sources/SwiftMath/MathRender/MTMathUILabel.swift +++ b/Sources/SwiftMath/MathRender/MTMathUILabel.swift @@ -185,6 +185,7 @@ public class MTMathUILabel : MTView { public var preferredMaxLayoutWidth: CGFloat { set { _preferredMaxLayoutWidth = newValue + _displayList = nil // Clear cached display list when width constraint changes self.invalidateIntrinsicContentSize() self.setNeedsLayout() } @@ -246,10 +247,18 @@ public class MTMathUILabel : MTView { if self.mathList == 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 let context = MTGraphicsGetCurrentContext()! context.saveGState() - displayList!.draw(context) + + displayList.draw(context) context.restoreGState() } @@ -278,10 +287,9 @@ public class MTMathUILabel : MTView { let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width let availableWidth = effectiveWidth - contentInsets.left - contentInsets.right - // print("Pre list = \(_mathList!)") _displayList = MTTypesetter.createLineForMathList(_mathList, font: self.font, style: currentStyle, maxWidth: availableWidth) + _displayList!.textColor = textColor - // print("Post list = \(_mathList!)") var textX = CGFloat(0) switch self.textAlignment { case .left: textX = contentInsets.left @@ -296,6 +304,7 @@ public class MTMathUILabel : MTView { height = fontSize/2 // set height to half the font size } let textY = (availableHeight - height) / 2 + _displayList!.descent + contentInsets.bottom + _displayList!.position = CGPointMake(textX, textY) errorLabel?.frame = self.bounds self.setNeedsDisplay() @@ -362,7 +371,6 @@ public class MTMathUILabel : MTView { func setNeedsLayout() { self.needsLayout = true } public override var fittingSize: CGSize { _sizeThatFits(CGSizeZero) } public override var intrinsicContentSize: CGSize { _sizeThatFits(CGSizeZero) } - override public var isFlipped: Bool { false } override public func layout() { self._layoutSubviews() super.layout() diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index 2bc1665..c6e48ef 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -733,6 +733,76 @@ class MTTypesetter { 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..= 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.. 0 { @@ -1000,6 +1070,12 @@ class MTTypesetter { // Position and add the radical display displayRad!.position = currentPosition displayAtoms.append(displayRad!) + + // Check for overlap if we're not on the first line + if currentLineStartIndex > 0 { + adjustPositionToAvoidOverlap(displayRad!) + } + currentPosition.x += displayRad!.width // add super scripts || subscripts @@ -1032,6 +1108,12 @@ class MTTypesetter { // Position and add the fraction display display!.position = currentPosition displayAtoms.append(display!) + + // Check for overlap if we're not on the first line + if currentLineStartIndex > 0 { + adjustPositionToAvoidOverlap(display!) + } + currentPosition.x += display!.width // add super scripts || subscripts diff --git a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift index eb37984..33186c6 100644 --- a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift +++ b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift @@ -540,4 +540,202 @@ class MTMathUILabelLineWrappingTests: XCTestCase { XCTAssertNotNil(label.displayList, "Display list should be created") 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.. 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.. 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") + } }