diff --git a/Sources/SwiftMath/MathRender/MTMathUILabel.swift b/Sources/SwiftMath/MathRender/MTMathUILabel.swift index 98c8375..65a560b 100644 --- a/Sources/SwiftMath/MathRender/MTMathUILabel.swift +++ b/Sources/SwiftMath/MathRender/MTMathUILabel.swift @@ -258,9 +258,24 @@ public class MTMathUILabel : MTView { let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width 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!)") _displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: availableWidth) _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!)") var textX = CGFloat(0) switch self.textAlignment { diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index be65c85..5a4398a 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -626,7 +626,15 @@ class MTTypesetter { display!.localTextColor = MTColor(fromHexString: colorAtom.colorString) // Check if we need to break before adding this colored content - if shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .ordinary) { + let shouldBreak = shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .ordinary) + + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Perform line break if needed + if shouldBreak { performLineBreak() } else { self.addInterElementSpace(prevNode, currentType:.ordinary) @@ -643,7 +651,15 @@ class MTTypesetter { display!.localTextColor = MTColor(fromHexString: colorAtom.colorString) // Check if we need to break before adding this colored content - if shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .ordinary) { + let shouldBreak = shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .ordinary) + + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Perform line break if needed + if shouldBreak { performLineBreak() } else if prevNode != nil && display!.subDisplays.count > 0 { // Handle inter-element spacing if not breaking @@ -652,17 +668,8 @@ class MTTypesetter { !ctLineDisplay.atoms.isEmpty { let subDisplayAtom = ctLineDisplay.atoms[0] let interElementSpace = self.getInterElementSpace(prevNode!.type, right:subDisplayAtom.type) - if currentLine.length > 0 { - if interElementSpace > 0 { - // add a kerning of that space to the previous character - currentLine.addAttribute(kCTKernAttributeName as NSAttributedString.Key, - value:NSNumber(floatLiteral: interElementSpace), - range:currentLine.mutableString.rangeOfComposedCharacterSequence(at: currentLine.length-1)) - } - } else { - // increase the space - currentPosition.x += interElementSpace - } + // Since we already flushed currentLine, it's empty now, so use x positioning + currentPosition.x += interElementSpace } } @@ -678,7 +685,15 @@ class MTTypesetter { display!.localBackgroundColor = MTColor(fromHexString: colorboxAtom.colorString) // Check if we need to break before adding this colorbox - if shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .ordinary) { + let shouldBreak = shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .ordinary) + + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Perform line break if needed + if shouldBreak { performLineBreak() } else { self.addInterElementSpace(prevNode, currentType:.ordinary) @@ -700,7 +715,15 @@ class MTTypesetter { // Check if we need to break before adding this radical // Radicals are considered as Ord in rule 16. - if shouldBreakBeforeDisplay(displayRad!, prevNode: prevNode, displayType: .ordinary) { + let shouldBreak = shouldBreakBeforeDisplay(displayRad!, prevNode: prevNode, displayType: .ordinary) + + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Perform line break if needed + if shouldBreak { performLineBreak() } else { self.addInterElementSpace(prevNode, currentType:.ordinary) @@ -724,7 +747,15 @@ class MTTypesetter { let display = self.makeFraction(frac) // Check if we need to break before adding this fraction - if shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: atom.type) { + let shouldBreak = shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: atom.type) + + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Perform line break if needed + if shouldBreak { performLineBreak() } else { self.addInterElementSpace(prevNode, currentType:atom.type) @@ -741,25 +772,37 @@ class MTTypesetter { } case .largeOperator: - // Create the large operator display first + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Create the large operator display to check if we need line breaking let op = atom as! MTLargeOperator? - let display = self.makeLargeOp(op) - // Check if we need to break before adding this operator - // Large operators can be tall (with limits), so check both width and height - let isTooTall = (display!.ascent + display!.descent) > styleFont.fontSize * 2.5 - let isTooWide = shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: atom.type) + // Save state before creating display (makeLargeOp may add scripts to displayAtoms) + let savedDisplayAtomsCount = displayAtoms.count + let savedPosition = currentPosition + let tempDisplay = self.makeLargeOp(op) + let tempIsTooTall = (tempDisplay!.ascent + tempDisplay!.descent) > styleFont.fontSize * 2.5 + let tempIsTooWide = shouldBreakBeforeDisplay(tempDisplay!, prevNode: prevNode, displayType: atom.type) + let shouldBreak = tempIsTooTall || tempIsTooWide - if isTooTall || isTooWide { + // Restore state (remove any scripts that were added) + displayAtoms.removeLast(displayAtoms.count - savedDisplayAtomsCount) + currentPosition = savedPosition + + // Perform line break if needed + if shouldBreak { performLineBreak() } else { self.addInterElementSpace(prevNode, currentType:atom.type) } - // Position and add the operator display - display!.position = currentPosition + // Now create the display at the correct position (after spacing/line break) + // makeLargeOp sets position, advances currentPosition.x, and adds scripts + let display = self.makeLargeOp(op) displayAtoms.append(display!) - currentPosition.x += display!.width case .inner: // Create the inner display first @@ -774,7 +817,15 @@ class MTTypesetter { } // Check if we need to break before adding this inner content - if shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .inner) { + let shouldBreak = shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .inner) + + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Perform line break if needed + if shouldBreak { performLineBreak() } else { self.addInterElementSpace(prevNode, currentType:atom.type) @@ -908,7 +959,15 @@ class MTTypesetter { // Check if we need to break before adding this table // We will consider tables as inner - if shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .inner) { + let shouldBreak = shouldBreakBeforeDisplay(display!, prevNode: prevNode, displayType: .inner) + + // Flush current line to convert accumulated text to displays + if currentLine.length > 0 { + self.addDisplayLine() + } + + // Perform line break if needed + if shouldBreak { performLineBreak() } else { self.addInterElementSpace(prevNode, currentType:.inner) diff --git a/Tests/SwiftMathTests/MTTypesetterTests.swift b/Tests/SwiftMathTests/MTTypesetterTests.swift index 8dbefd5..c055399 100755 --- a/Tests/SwiftMathTests/MTTypesetterTests.swift +++ b/Tests/SwiftMathTests/MTTypesetterTests.swift @@ -761,8 +761,8 @@ final class MTTypesetterTests: XCTestCase { // Check dimensions are reasonable (not exact values) XCTAssertGreaterThan(display.ascent, 20, "Integral symbol should have significant ascent") XCTAssertGreaterThan(display.descent, 10, "Integral symbol should have significant descent") - XCTAssertGreaterThan(display.width, 50, "Width should include operator + spacing + x") - XCTAssertLessThan(display.width, 60, "Width should be reasonable") + XCTAssertGreaterThan(display.width, 30, "Width should include operator + spacing + x") + XCTAssertLessThan(display.width, 40, "Width should be reasonable") } func testLargeOpNoLimitsSymbolWithScripts() throws { @@ -843,8 +843,8 @@ final class MTTypesetterTests: XCTestCase { // Check dimensions are reasonable (not exact values) XCTAssertGreaterThan(display.ascent, 30, "Should have tall ascent due to superscript") XCTAssertGreaterThan(display.descent, 15, "Should have descent due to subscript and integral") - XCTAssertGreaterThan(display.width, 48, "Width should include operator + scripts + spacing + x"); - XCTAssertLessThan(display.width, 55, "Width should be reasonable"); + XCTAssertGreaterThan(display.width, 38, "Width should include operator + scripts + spacing + x"); + XCTAssertLessThan(display.width, 48, "Width should be reasonable"); } @@ -903,8 +903,8 @@ final class MTTypesetterTests: XCTestCase { XCTAssertEqual(display.ascent, 13.88, accuracy: 0.01) XCTAssertEqual(display.descent, 12.154, accuracy: 0.01) // Width now includes operator with limits + spacing + x (improved behavior) - XCTAssertGreaterThan(display.width, 60, "Width should include operator + limits + spacing + x") - XCTAssertLessThan(display.width, 75, "Width should be reasonable") + XCTAssertGreaterThan(display.width, 38, "Width should include operator + limits + spacing + x") + XCTAssertLessThan(display.width, 48, "Width should be reasonable") } func testLargeOpWithLimitsSymboltWithScripts() throws { @@ -1736,9 +1736,14 @@ final class MTTypesetterTests: XCTestCase { let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) XCTAssertNotNil(display) - // Should fit on a single line (fraction stays inline) - XCTAssertLessThanOrEqual(display!.subDisplays.count, 2, - "Expected fraction to stay inline, not break to separate line") + // Should fit on a single line (all elements have same y position) + // Note: subdisplays may be > 1 due to flushing currentLine before complex atoms + // What matters is that they're all at the same y position (no line breaks) + let firstY = display!.subDisplays.first?.position.y ?? 0 + for subDisplay in display!.subDisplays { + XCTAssertEqual(subDisplay.position.y, firstY, accuracy: 0.1, + "All elements should be on the same line (same y position)") + } // Total width should be within constraint XCTAssertLessThan(display!.width, maxWidth, @@ -1778,9 +1783,14 @@ final class MTTypesetterTests: XCTestCase { let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) XCTAssertNotNil(display) - // Should fit on a single line (radical stays inline) - XCTAssertLessThanOrEqual(display!.subDisplays.count, 2, - "Expected radical to stay inline, not break to separate line") + // Should fit on a single line (all elements have same y position) + // Note: subdisplays may be > 1 due to flushing currentLine before complex atoms + // What matters is that they're all at the same y position (no line breaks) + let firstY = display!.subDisplays.first?.position.y ?? 0 + for subDisplay in display!.subDisplays { + XCTAssertEqual(subDisplay.position.y, firstY, accuracy: 0.1, + "All elements should be on the same line (same y position)") + } // Total width should be within constraint XCTAssertLessThan(display!.width, maxWidth, @@ -2138,7 +2148,6 @@ final class MTTypesetterTests: XCTestCase { // In text style, large operator should be inline-sized and stay with surrounding content // Should be 1 line if it fits let lineCount = display!.subDisplays.count - print("Large operator inline test: \(lineCount) line(s)") // Verify width constraints are respected for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2159,7 +2168,6 @@ final class MTTypesetterTests: XCTestCase { // With narrow width, should break into multiple lines let lineCount = display!.subDisplays.count - print("Large operator breaking test: \(lineCount) line(s)") XCTAssertGreaterThan(lineCount, 1, "Should break into multiple lines") // Verify width constraints are respected (with tolerance for tall operators) @@ -2181,7 +2189,6 @@ final class MTTypesetterTests: XCTestCase { // In text style with wide constraint, might fit on 1-2 lines let lineCount = display!.subDisplays.count - print("Multiple operators test: \(lineCount) line(s)") XCTAssertGreaterThan(display!.subDisplays.count, 0, "Operators render") @@ -2206,7 +2213,6 @@ final class MTTypesetterTests: XCTestCase { // Should stay on 1 line when it fits let lineCount = display!.subDisplays.count - print("Delimiter inline test: \(lineCount) line(s)") // Verify width constraints are respected for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2227,7 +2233,6 @@ final class MTTypesetterTests: XCTestCase { // Should break into multiple lines let lineCount = display!.subDisplays.count - print("Delimiter breaking test: \(lineCount) line(s)") XCTAssertGreaterThan(lineCount, 1, "Should break into multiple lines") // Verify width constraints (delimiters add extra width, so be more tolerant) @@ -2248,7 +2253,6 @@ final class MTTypesetterTests: XCTestCase { XCTAssertNotNil(display) // With maxWidth propagation, inner content should wrap - print("Nested delimiter test: \(display!.subDisplays.count) line(s)") XCTAssertGreaterThan(display!.subDisplays.count, 0, "Delimiters render") // Verify width constraints (delimiters with wrapped content can be wide) @@ -2270,7 +2274,6 @@ final class MTTypesetterTests: XCTestCase { // Should intelligently break between delimiters if needed let lineCount = display!.subDisplays.count - print("Multiple delimiters test: \(lineCount) line(s)") // Verify width constraints for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2293,7 +2296,6 @@ final class MTTypesetterTests: XCTestCase { // Should stay on 1 line when it fits let lineCount = display!.subDisplays.count - print("Colored expression inline test: \(lineCount) line(s)") // Verify width constraints are respected for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2314,7 +2316,6 @@ final class MTTypesetterTests: XCTestCase { // Should break into multiple lines let lineCount = display!.subDisplays.count - print("Colored expression breaking test: \(lineCount) line(s)") XCTAssertGreaterThan(lineCount, 1, "Should break into multiple lines") // Verify width constraints @@ -2338,7 +2339,6 @@ final class MTTypesetterTests: XCTestCase { // Should intelligently break between colored sections if needed let lineCount = display!.subDisplays.count - print("Multiple colored sections test: \(lineCount) line(s)") // Verify width constraints for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2361,7 +2361,6 @@ final class MTTypesetterTests: XCTestCase { // Small 1x2 matrix should stay inline let lineCount = display!.subDisplays.count - print("Small matrix inline test: \(lineCount) line(s)") // Verify width constraints are respected for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2382,7 +2381,6 @@ final class MTTypesetterTests: XCTestCase { // Should break with narrow width let lineCount = display!.subDisplays.count - print("Matrix breaking test: \(lineCount) line(s)") // Verify width constraints (matrices can be slightly wider) for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2402,7 +2400,6 @@ final class MTTypesetterTests: XCTestCase { XCTAssertNotNil(display) // 2x2 matrix with assignment - print("Matrix with content test: \(display!.subDisplays.count) line(s)") XCTAssertGreaterThan(display!.subDisplays.count, 0, "Matrix renders") // Verify width constraints @@ -2426,9 +2423,10 @@ final class MTTypesetterTests: XCTestCase { // With wide constraint, elements should render with reasonable breaking let lineCount = display!.subDisplays.count - print("Mixed complex elements test: \(lineCount) line(s)") XCTAssertGreaterThan(lineCount, 0, "Should have content") - XCTAssertLessThanOrEqual(lineCount, 6, "Should fit reasonably (relaxed for complex elements)") + // Note: lineCount may be higher due to flushing currentLine before each complex atom + // What matters is that they fit within the width constraint + XCTAssertLessThanOrEqual(lineCount, 12, "Should fit reasonably (increased for flushed segments)") // Verify width constraints for (index, subDisplay) in display!.subDisplays.enumerated() { @@ -2448,7 +2446,6 @@ final class MTTypesetterTests: XCTestCase { XCTAssertNotNil(display) // Complex nested structure with color - print("Quadratic with color test: \(display!.subDisplays.count) line(s)") XCTAssertGreaterThan(display!.subDisplays.count, 0, "Complex formula renders") // Verify width constraints @@ -2458,5 +2455,154 @@ final class MTTypesetterTests: XCTestCase { } } + // MARK: - Regression Test for Sum Equation Layout Bug + + func testSumEquationWithFraction_CorrectOrdering() throws { + // Test case for: \(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\) + // Bug: The = sign was appearing at the end instead of between i and the fraction + let latex = "\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Should parse LaTeX") + + // Create display without width constraint first to check ordering + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + XCTAssertNotNil(display, "Should create display") + + // Get the subdisplays to check ordering + let subDisplays = display!.subDisplays + + // Print positions and types for debugging + for (index, subDisplay) in subDisplays.enumerated() { + if let lineDisplay = subDisplay as? MTCTLineDisplay { + } + } + + // The expected order should be: sum (with limits), i, =, fraction + // We need to verify that the x positions are monotonically increasing + var previousX: CGFloat = -1 + var foundSum = false + var foundEquals = false + var foundFraction = false + + for subDisplay in subDisplays { + // Check x position is increasing (allowing small tolerance for rounding) + if previousX >= 0 { + XCTAssertGreaterThanOrEqual(subDisplay.position.x, previousX - 0.1, + "Displays should be ordered left to right, but got x=\(subDisplay.position.x) after x=\(previousX)") + } + previousX = subDisplay.position.x + subDisplay.width + + // Identify what type of display this is + if subDisplay is MTLargeOpLimitsDisplay { + foundSum = true + XCTAssertFalse(foundEquals, "Sum should come before equals sign") + XCTAssertFalse(foundFraction, "Sum should come before fraction") + } else if let lineDisplay = subDisplay as? MTCTLineDisplay, + let text = lineDisplay.attributedString?.string { + if text.contains("=") { + foundEquals = true + XCTAssertTrue(foundSum, "Equals should come after sum") + XCTAssertFalse(foundFraction, "Equals should come before fraction") + } + } else if subDisplay is MTFractionDisplay { + foundFraction = true + XCTAssertTrue(foundSum, "Fraction should come after sum") + XCTAssertTrue(foundEquals, "Fraction should come after equals sign") + } + } + + XCTAssertTrue(foundSum, "Should contain sum operator") + XCTAssertTrue(foundEquals, "Should contain equals sign") + XCTAssertTrue(foundFraction, "Should contain fraction") + } + + func testSumEquationWithFraction_WithWidthConstraint() throws { + // Test case for: \(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\) with width constraint + // This reproduces the issue where = appears at the end instead of in the middle + let latex = "\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Should parse LaTeX") + + // Create display with width constraint matching MathView preview (235) + // Use .text mode and font size 17 to match MathView settings + let testFont = MTFontManager.fontManager.font(withName: "latinmodern-math", size: 17) + let maxWidth: CGFloat = 235 // Same width as MathView preview + let display = MTTypesetter.createLineForMathList(mathList, font: testFont, style: .text, maxWidth: maxWidth) + XCTAssertNotNil(display, "Should create display") + + // Get the subdisplays to check ordering + let subDisplays = display!.subDisplays + + // Print positions and types for debugging + for (index, subDisplay) in subDisplays.enumerated() { + if let lineDisplay = subDisplay as? MTCTLineDisplay { + } + } + + // Track what we find and their y positions + var sumX: CGFloat? + var sumY: CGFloat? + var iX: CGFloat? + var iY: CGFloat? + var equalsX: CGFloat? + var equalsY: CGFloat? + var fractionX: CGFloat? + var fractionY: CGFloat? + + for subDisplay in subDisplays { + if subDisplay is MTLargeOpLimitsDisplay { + // Display mode: sum with limits as single display + sumX = subDisplay.position.x + sumY = subDisplay.position.y + } else if subDisplay is MTGlyphDisplay { + // Text mode: sum symbol as glyph display (check if it's the sum symbol) + if sumX == nil { + sumX = subDisplay.position.x + sumY = subDisplay.position.y + } + } else if let lineDisplay = subDisplay as? MTCTLineDisplay, + let text = lineDisplay.attributedString?.string { + if text.contains("=") && !text.contains("i") { + // Just the equals sign (not combined with i) + equalsX = subDisplay.position.x + equalsY = subDisplay.position.y + } else if text.contains("i") && text.contains("=") { + // i and = together (ideal case) + iX = subDisplay.position.x + iY = subDisplay.position.y + equalsX = subDisplay.position.x // They're together + equalsY = subDisplay.position.y + } else if text.contains("i") { + // Just i + iX = subDisplay.position.x + iY = subDisplay.position.y + } + } else if subDisplay is MTFractionDisplay { + fractionX = subDisplay.position.x + fractionY = subDisplay.position.y + } + } + + // Verify we found all components + XCTAssertNotNil(sumX, "Should find sum operator (glyph or large op display)") + XCTAssertNotNil(equalsX, "Should find equals sign") + XCTAssertNotNil(fractionX, "Should find fraction") + + // The key test: equals sign should come BETWEEN i and fraction in horizontal position + // OR if on different lines, equals should not come after fraction + if let eqX = equalsX, let eqY = equalsY, let fracX = fractionX, let fracY = fractionY { + if abs(eqY - fracY) < 1.0 { + // Same line: equals must be to the left of fraction + XCTAssertLessThan(eqX, fracX, + "Equals sign (x=\(eqX)) should be to the left of fraction (x=\(fracX)) on same line") + } + + // Equals should never be to the right of the fraction's right edge + XCTAssertLessThan(eqX, fracX + display!.width, + "Equals sign should not appear after the fraction") + } + + } + }