From 3aa6c6c98be942fe6d72dd5da415390f0aa2a7b4 Mon Sep 17 00:00:00 2001 From: Nicolas Guillot Date: Mon, 17 Nov 2025 17:51:30 +0100 Subject: [PATCH] Fix line width calculation for expressions with superscripts/subscripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwiftMath/MathRender/MTMathUILabel.swift | 15 --------- .../SwiftMath/MathRender/MTTypesetter.swift | 32 +++++++++++++----- .../MTMathUILabelLineWrappingTests.swift | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/Sources/SwiftMath/MathRender/MTMathUILabel.swift b/Sources/SwiftMath/MathRender/MTMathUILabel.swift index cb3a276..64a3f86 100644 --- a/Sources/SwiftMath/MathRender/MTMathUILabel.swift +++ b/Sources/SwiftMath/MathRender/MTMathUILabel.swift @@ -278,24 +278,9 @@ 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: self.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 4957689..0509c0f 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -557,7 +557,10 @@ class MTTypesetter { // starts with a letter, they're part of the same word - don't break! // Example: "...é" + "quivaut" should not break // 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 return false } @@ -565,9 +568,14 @@ class MTTypesetter { } // 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 visualLineWidth = currentPosition.x + currentLineWidth 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 projectedWidth <= maxWidth { @@ -1213,14 +1221,22 @@ class MTTypesetter { 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) - 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 let currentText = currentLine.string // 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 let breakOffset = currentText.distance(from: currentText.startIndex, to: breakIndex) @@ -1228,14 +1244,14 @@ class MTTypesetter { let firstLine = NSMutableAttributedString(string: String(currentText.prefix(breakOffset))) 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 firstLineWidth = CGFloat(CTLineGetTypographicBounds(firstLineCT, nil, nil, nil)) - if firstLineWidth > maxWidth { + if firstLineWidth > remainingWidth { // Need to break earlier - find previous break point 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 earlierLine = NSMutableAttributedString(string: String(firstLineText.prefix(earlierOffset))) earlierLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, earlierLine.length)) diff --git a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift index 34f1385..ef5de3f 100644 --- a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift +++ b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift @@ -225,6 +225,39 @@ class MTMathUILabelLineWrappingTests: XCTestCase { 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() { let label = MTMathUILabel() // French decimal number should NOT be broken