From 9f6f5a293436d3b23c1d7d8477c145d392e71f28 Mon Sep 17 00:00:00 2001 From: Nicolas Guillot Date: Thu, 13 Nov 2025 14:52:11 +0100 Subject: [PATCH] [multiple lines] inter atoms line breaking support --- .../SwiftMath/MathRender/MTTypesetter.swift | 73 ++++++++++ Tests/SwiftMathTests/MTTypesetterTests.swift | 137 ++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index ea5db9d..2c4eb73 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -479,6 +479,75 @@ class MTTypesetter { } self.currentPosition.x += interElementSpace } + + // MARK: - Interatom Line Breaking + + /// Calculate the width that would result from adding this atom to the current line + /// Returns the approximate width including inter-element spacing + func calculateAtomWidth(_ atom: MTMathAtom, prevNode: MTMathAtom?) -> CGFloat { + // Calculate inter-element spacing + var interElementSpace: CGFloat = 0 + if prevNode != nil { + interElementSpace = getInterElementSpace(prevNode!.type, right: atom.type) + } else if self.spaced { + interElementSpace = getInterElementSpace(.open, right: atom.type) + } + + // Calculate the width of the atom's nucleus + let atomString = NSAttributedString(string: atom.nucleus, attributes: [ + kCTFontAttributeName as NSAttributedString.Key: styleFont.ctFont as Any + ]) + let ctLine = CTLineCreateWithAttributedString(atomString as CFAttributedString) + let atomWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil)) + + return interElementSpace + atomWidth + } + + /// Calculate the current line width + func getCurrentLineWidth() -> CGFloat { + if currentLine.length == 0 { + return 0 + } + let attrString = currentLine.mutableCopy() as! NSMutableAttributedString + attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont as Any, range: NSMakeRange(0, attrString.length)) + let ctLine = CTLineCreateWithAttributedString(attrString) + return CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil)) + } + + /// Check if we should break to a new line before adding this atom + /// Returns true if a line break was performed + @discardableResult + func checkAndPerformInteratomLineBreak(_ atom: MTMathAtom, prevNode: MTMathAtom?) -> Bool { + // Only perform interatom breaking when maxWidth is set + guard maxWidth > 0 else { return false } + + // Don't break if current line is empty + guard currentLine.length > 0 else { return false } + + // Calculate what the width would be if we add this atom + let currentLineWidth = getCurrentLineWidth() + let atomWidth = calculateAtomWidth(atom, prevNode: prevNode) + let projectedWidth = currentLineWidth + atomWidth + + // If projected width exceeds max width, flush current line and start new one + if projectedWidth > maxWidth { + // Flush the current line + self.addDisplayLine() + + // Move down for new line + currentPosition.y -= styleFont.fontSize * 1.5 + currentPosition.x = 0 + + // Reset for new line + currentLine = NSMutableAttributedString() + currentAtoms = [] + currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound) + + return true + } + + return false + } func createDisplayAtoms(_ preprocessed:[MTMathAtom]) { // items should contain all the nodes that need to be layed out. @@ -772,6 +841,10 @@ class MTTypesetter { case .ordinary, .binaryOperator, .relation, .open, .close, .placeholder, .punctuation: // the rendering for all the rest is pretty similar // All we need is render the character and set the interelement space. + + // INTERATOM LINE BREAKING: Check if we need to break before adding this atom + checkAndPerformInteratomLineBreak(atom, prevNode: prevNode) + if prevNode != nil { let interElementSpace = self.getInterElementSpace(prevNode!.type, right:atom.type) if currentLine.length > 0 { diff --git a/Tests/SwiftMathTests/MTTypesetterTests.swift b/Tests/SwiftMathTests/MTTypesetterTests.swift index d33a22d..98929bd 100755 --- a/Tests/SwiftMathTests/MTTypesetterTests.swift +++ b/Tests/SwiftMathTests/MTTypesetterTests.swift @@ -1572,5 +1572,142 @@ final class MTTypesetterTests: XCTestCase { XCTAssertEqual(display.width, 44.86, accuracy: 0.01) } + // MARK: - Interatom Line Breaking Tests + + func testInteratomLineBreaking_SimpleEquation() throws { + // Simple equation that should break between atoms when width is constrained + let latex = "a=1, b=2, c=3, d=4" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Failed to parse LaTeX") + + // Create display with narrow width constraint (should force multiple lines) + let maxWidth: CGFloat = 100 + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + XCTAssertNotNil(display) + + // Should have multiple sub-displays (lines) + XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected multiple lines with width constraint of \(maxWidth)") + + // Verify that each line respects the width constraint + for (index, subDisplay) in display!.subDisplays.enumerated() { + XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1, "Line \(index) width \(subDisplay.width) exceeds maxWidth \(maxWidth)") + } + + // Verify vertical positioning - lines should be below each other + if display!.subDisplays.count > 1 { + let firstLine = display!.subDisplays[0] + let secondLine = display!.subDisplays[1] + XCTAssertLessThan(secondLine.position.y, firstLine.position.y, "Second line should be positioned below first line") + } + } + + func testInteratomLineBreaking_TextAndMath() throws { + // The user's specific example: text mixed with math + let latex = "\\text{Calculer le discriminant }\\Delta=b^{2}-4ac\\text{ avec }a=1\\text{, }b=-1\\text{, }c=-5" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Failed to parse LaTeX") + + // Create display with width constraint of 235 as specified by user + let maxWidth: CGFloat = 235 + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + XCTAssertNotNil(display) + + // Should have multiple lines + XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected multiple lines with width \(maxWidth) for the given LaTeX") + + // Verify each line respects width constraint + for (index, subDisplay) in display!.subDisplays.enumerated() { + // Allow 10% tolerance for spacing and rounding + XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1, + "Line \(index) width \(subDisplay.width) exceeds maxWidth \(maxWidth)") + } + + // Verify vertical spacing between lines + if display!.subDisplays.count >= 2 { + let firstLine = display!.subDisplays[0] + let secondLine = display!.subDisplays[1] + let verticalSpacing = abs(firstLine.position.y - secondLine.position.y) + XCTAssertGreaterThan(verticalSpacing, 0, "Lines should have vertical spacing") + // Typical line height is around 1.5 * font size + XCTAssertGreaterThan(verticalSpacing, self.font.fontSize * 0.5, "Vertical spacing seems too small") + } + } + + func testInteratomLineBreaking_BreaksAtAtomBoundaries() throws { + // Test that breaking happens between atoms, not within them + // Using mathematical atoms separated by operators + let latex = "a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Failed to parse LaTeX") + + // Create display with narrow width that should force breaking + let maxWidth: CGFloat = 120 + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + XCTAssertNotNil(display) + + // Should have multiple lines + XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected line breaking with narrow width") + + // Each line should respect the width constraint (with some tolerance) + // since we break at atom boundaries, not mid-atom + for (index, subDisplay) in display!.subDisplays.enumerated() { + XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.2, + "Line \(index) width \(subDisplay.width) exceeds maxWidth \(maxWidth) by too much") + } + } + + func testInteratomLineBreaking_WithSuperscripts() throws { + // Test breaking with atoms that have superscripts + let latex = "a^{2}+b^{2}+c^{2}+d^{2}+e^{2}" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Failed to parse LaTeX") + + let maxWidth: CGFloat = 100 + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + XCTAssertNotNil(display) + + // Should handle superscripts properly and create multiple lines if needed + for (index, subDisplay) in display!.subDisplays.enumerated() { + XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1, + "Line \(index) with superscripts exceeds width") + } + } + + func testInteratomLineBreaking_NoBreakingWhenNotNeeded() throws { + // Test that short content doesn't break unnecessarily + let latex = "a=b" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Failed to parse LaTeX") + + let maxWidth: CGFloat = 200 + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + XCTAssertNotNil(display) + + // Should stay on single line since content is short + // Note: The number of subDisplays might be 1 or more depending on internal structure, + // but the total width should be well under maxWidth + XCTAssertLessThan(display!.width, maxWidth, "Short content should fit without breaking") + } + + func testInteratomLineBreaking_BreaksAfterOperators() throws { + // Test that breaking prefers to happen after operators (good break points) + let latex = "a+b+c+d+e+f+g+h" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Failed to parse LaTeX") + + let maxWidth: CGFloat = 80 + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + XCTAssertNotNil(display) + + // Should break into multiple lines + XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected multiple lines") + + // Verify width constraints + for (index, subDisplay) in display!.subDisplays.enumerated() { + XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1, + "Line \(index) exceeds width") + } + } + }