From c7198ad9afd4f6fbe6eb380b7c1990fe07b57f38 Mon Sep 17 00:00:00 2001 From: Nicolas Guillot Date: Thu, 2 Oct 2025 12:05:37 +0200 Subject: [PATCH] [UI] add line wrapping functionality --- README.md | 130 ++++++++++- .../SwiftMath/MathRender/MTMathUILabel.swift | 64 ++++- .../SwiftMath/MathRender/MTTypesetter.swift | 218 ++++++++++++++++-- .../MTMathUILabelLineWrappingTests.swift | 194 ++++++++++++++++ 4 files changed, 567 insertions(+), 39 deletions(-) mode change 100755 => 100644 Sources/SwiftMath/MathRender/MTMathUILabel.swift create mode 100644 Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift diff --git a/README.md b/README.md index d4906d0..cb7f0fe 100644 --- a/README.md +++ b/README.md @@ -114,18 +114,34 @@ struct MathView: UIViewRepresentable { var fontSize: CGFloat = 30 var labelMode: MTMathUILabelMode = .text var insets: MTEdgeInsets = MTEdgeInsets() - + func makeUIView(context: Context) -> MTMathUILabel { let view = MTMathUILabel() + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentCompressionResistancePriority(.required, for: .vertical) return view } + func updateUIView(_ view: MTMathUILabel, context: Context) { view.latex = equation - view.font = MTFontManager().font(withName: font.rawValue, size: fontSize) + let font = MTFontManager().font(withName: font.rawValue, size: fontSize) + font?.fallbackFont = UIFont.systemFont(ofSize: fontSize) + view.font = font view.textAlignment = textAlignment view.labelMode = labelMode view.textColor = MTColor(Color.primary) view.contentInsets = insets + view.invalidateIntrinsicContentSize() + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: MTMathUILabel, context: Context) -> CGSize? { + // Enable line wrapping by passing proposed width to the label + if let width = proposal.width, width.isFinite, width > 0 { + uiView.preferredMaxLayoutWidth = width + let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + return size + } + return nil } } ``` @@ -143,23 +159,127 @@ struct MathView: NSViewRepresentable { var fontSize: CGFloat = 30 var labelMode: MTMathUILabelMode = .text var insets: MTEdgeInsets = MTEdgeInsets() - + func makeNSView(context: Context) -> MTMathUILabel { let view = MTMathUILabel() + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentCompressionResistancePriority(.required, for: .vertical) return view } - + func updateNSView(_ view: MTMathUILabel, context: Context) { view.latex = equation - view.font = MTFontManager().font(withName: font.rawValue, size: fontSize) + let font = MTFontManager().font(withName: font.rawValue, size: fontSize) + font?.fallbackFont = NSFont.systemFont(ofSize: fontSize) + view.font = font view.textAlignment = textAlignment view.labelMode = labelMode view.textColor = MTColor(Color.primary) view.contentInsets = insets + view.invalidateIntrinsicContentSize() + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: MTMathUILabel, context: Context) -> CGSize? { + // Enable line wrapping by passing proposed width to the label + if let width = proposal.width, width.isFinite, width > 0 { + nsView.preferredMaxLayoutWidth = width + let size = nsView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + return size + } + return nil } } ``` +### Automatic Line Wrapping + +`SwiftMath` supports automatic line wrapping for text and simple math expressions. When the content exceeds the available width, it will wrap at word boundaries to fit within the constrained space. + +#### Using Line Wrapping with UIKit/AppKit + +For direct `MTMathUILabel` usage, set the `preferredMaxLayoutWidth` property: + +```swift +let label = MTMathUILabel() +label.latex = "\\(\\text{Remember the conversion: 1 km equals 1000 meters.}\\)" +label.font = MTFontManager.fontManager.defaultFont +label.labelMode = .text + +// Enable line wrapping by setting a maximum width +label.preferredMaxLayoutWidth = 300 +``` + +You can also use `sizeThatFits` to calculate the size with a width constraint: + +```swift +let constrainedSize = label.sizeThatFits(CGSize(width: 300, height: .greatestFiniteMagnitude)) +``` + +#### Using Line Wrapping with SwiftUI + +The `MathView` examples above include `sizeThatFits()` which automatically enables line wrapping when SwiftUI proposes a width constraint. No additional configuration is needed: + +```swift +VStack(alignment: .leading, spacing: 8) { + MathView( + equation: "\\(\\text{Remember the conversion: 1 km equals 1000 meters.}\\)", + fontSize: 17, + labelMode: .text + ) +} +.frame(maxWidth: 300) // The text will wrap to fit within 300pt +``` + +#### Line Wrapping Behavior + +- **Works for**: Text content (`\text{...}`), mixed text with simple math, and simple equations +- **Breaks at**: Word boundaries (spaces) +- **Preserves**: Complex math layout (fractions, superscripts, matrices remain on single lines) +- **Respects**: Unicode text including CJK characters with proper word boundaries + +#### Examples + +**Simple text wrapping:** +```swift +// Long text will wrap to multiple lines +label.latex = "\\(\\text{The quadratic formula is used to solve equations of the form } ax^2 + bx + c = 0\\)" +label.preferredMaxLayoutWidth = 250 +``` + +**Simple equation with operators:** +```swift +// Long equations can break between operators if too long +label.latex = "\\(5 + 10 + 15 + 20 + 25 + 30\\)" +label.preferredMaxLayoutWidth = 150 +// Will wrap: "5 + 10 + 15 + 20 +" +// "25 + 30" +``` + +**Mixed text and math:** +```swift +// Text wraps but math expressions stay intact +label.latex = "\\(\\text{Result: } 5 \\times 1000 = 5000 \\text{ meters}\\)" +label.preferredMaxLayoutWidth = 200 +// Will wrap at spaces between text and operators +``` + +**Multiple lines in SwiftUI:** +```swift +ScrollView { + VStack(alignment: .leading, spacing: 12) { + ForEach(steps) { step in + MathView( + equation: step.description, + fontSize: 17, + labelMode: .text + ) + } + } + .padding() +} +// Each MathView will automatically wrap based on available width +``` + ### Included Features This is a list of formula types that the library currently supports: diff --git a/Sources/SwiftMath/MathRender/MTMathUILabel.swift b/Sources/SwiftMath/MathRender/MTMathUILabel.swift old mode 100755 new mode 100644 index ea4324f..1d131b4 --- a/Sources/SwiftMath/MathRender/MTMathUILabel.swift +++ b/Sources/SwiftMath/MathRender/MTMathUILabel.swift @@ -179,7 +179,19 @@ public class MTMathUILabel : MTView { /** The internal display of the MTMathUILabel. This is for advanced use only. */ public var displayList: MTMathListDisplay? { _displayList } private var _displayList:MTMathListDisplay? - + + /** The preferred maximum width (in points) for a multiline label. + Set this property to enable line wrapping based on available width. */ + public var preferredMaxLayoutWidth: CGFloat { + set { + _preferredMaxLayoutWidth = newValue + self.invalidateIntrinsicContentSize() + self.setNeedsLayout() + } + get { _preferredMaxLayoutWidth } + } + private var _preferredMaxLayoutWidth: CGFloat = 0 + public var currentStyle:MTLineStyle { switch _labelMode { case .display: return .display @@ -241,8 +253,12 @@ public class MTMathUILabel : MTView { func _layoutSubviews() { if _mathList != nil { + // Use the effective width for layout + let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width + let availableWidth = effectiveWidth - contentInsets.left - contentInsets.right + // print("Pre list = \(_mathList!)") - _displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle) + _displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: availableWidth) _displayList!.textColor = textColor // print("Post list = \(_mathList!)") var textX = CGFloat(0) @@ -252,7 +268,7 @@ public class MTMathUILabel : MTView { case .right: textX = bounds.size.width - _displayList!.width - contentInsets.right } let availableHeight = bounds.size.height - contentInsets.bottom - contentInsets.top - + // center things vertically var height = _displayList!.ascent + _displayList!.descent if height < fontSize/2 { @@ -268,19 +284,47 @@ public class MTMathUILabel : MTView { } func _sizeThatFits(_ size:CGSize) -> CGSize { - guard _mathList != nil else { return size } - var size = size + guard _mathList != nil else { + // No content - return no-intrinsic-size marker + return CGSize(width: -1, height: -1) + } + + // Determine the maximum width to use + var maxWidth: CGFloat = 0 + if _preferredMaxLayoutWidth > 0 { + maxWidth = _preferredMaxLayoutWidth - contentInsets.left - contentInsets.right + } else if size.width > 0 { + maxWidth = size.width - contentInsets.left - contentInsets.right + } + var displayList:MTMathListDisplay? = nil - displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle) - size.width = displayList!.width + contentInsets.left + contentInsets.right - size.height = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom - return size + displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: maxWidth) + + guard displayList != nil else { + // Failed to create display list + return CGSize(width: -1, height: -1) + } + + let resultWidth = displayList!.width + contentInsets.left + contentInsets.right + let resultHeight = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom + return CGSize(width: resultWidth, height: resultHeight) } - + + #if os(macOS) + public func sizeThatFits(_ size: CGSize) -> CGSize { + return _sizeThatFits(size) + } + #else + public override func sizeThatFits(_ size: CGSize) -> CGSize { + return _sizeThatFits(size) + } + #endif + #if os(macOS) func setNeedsDisplay() { self.needsDisplay = true } 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() diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index db86de6..ca45f12 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -362,23 +362,40 @@ class MTTypesetter { } var cramped = false var spaced = false + var maxWidth: CGFloat = 0 // Maximum width for line breaking, 0 means no constraint static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle) -> MTMathListDisplay? { let finalizedList = mathList?.finalized - // default is not cramped - return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false) + // default is not cramped, no width constraint + return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false, maxWidth: 0) } - + + static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, maxWidth:CGFloat) -> MTMathListDisplay? { + let finalizedList = mathList?.finalized + // default is not cramped + return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false, maxWidth: maxWidth) + } + // Internal static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool) -> MTMathListDisplay? { - return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false) + return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false, maxWidth: 0) } - + + // Internal + static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, maxWidth:CGFloat) -> MTMathListDisplay? { + return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false, maxWidth: maxWidth) + } + // Internal static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool) -> MTMathListDisplay? { + return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:spaced, maxWidth: 0) + } + + // Internal + static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool, maxWidth:CGFloat) -> MTMathListDisplay? { assert(font != nil) let preprocessedAtoms = self.preprocessMathList(mathList) - let typesetter = MTTypesetter(withFont:font, style:style, cramped:cramped, spaced:spaced) + let typesetter = MTTypesetter(withFont:font, style:style, cramped:cramped, spaced:spaced, maxWidth: maxWidth) typesetter.createDisplayAtoms(preprocessedAtoms) let lastAtom = mathList!.atoms.last let last = lastAtom?.indexRange ?? NSMakeRange(0, 0) @@ -387,13 +404,14 @@ class MTTypesetter { } static var placeholderColor: MTColor { MTColor.blue } - - init(withFont font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool) { + + init(withFont font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool, maxWidth:CGFloat = 0) { self.font = font self.displayAtoms = [MTDisplay]() self.currentPosition = CGPoint.zero self.cramped = cramped self.spaced = spaced + self.maxWidth = maxWidth self.currentLine = NSMutableAttributedString() self.currentAtoms = [MTMathAtom]() self.style = style @@ -662,22 +680,78 @@ class MTTypesetter { } case .accent: - // stash the existing layout - if currentLine.length > 0 { - self.addDisplayLine() - } - // Accent is considered as Ord in rule 16. - self.addInterElementSpace(prevNode, currentType:.ordinary) - atom.type = .ordinary; - - let accent = atom as! MTAccent? - let display = self.makeAccent(accent) - displayAtoms.append(display!) - currentPosition.x += display!.width; - - // add super scripts || subscripts - if atom.subScript != nil || atom.superScript != nil { - self.makeScripts(atom, display:display, index:UInt(atom.indexRange.location), delta:0) + if maxWidth > 0 { + // When line wrapping is enabled, render the accent properly but inline + // to avoid premature line flushing + + let accent = atom as! MTAccent + + // Get the base character from innerList + var baseChar = "" + if let innerList = accent.innerList, !innerList.atoms.isEmpty { + // Convert innerList to string + baseChar = MTMathListBuilder.mathListToString(innerList) + } + + // Combine base character with accent to create proper composed character + let accentChar = atom.nucleus + let composedString = baseChar + accentChar + + // Normalize to composed form (NFC) to get proper accented character + let normalizedString = composedString.precomposedStringWithCanonicalMapping + + // Add inter-element spacing + if prevNode != nil { + let interElementSpace = self.getInterElementSpace(prevNode!.type, right:.ordinary) + if currentLine.length > 0 { + if interElementSpace > 0 { + currentLine.addAttribute(kCTKernAttributeName as NSAttributedString.Key, + value:NSNumber(floatLiteral: interElementSpace), + range:currentLine.mutableString.rangeOfComposedCharacterSequence(at: currentLine.length-1)) + } + } else { + currentPosition.x += interElementSpace + } + } + + // Add the properly composed accented character + let current = NSAttributedString(string:normalizedString) + currentLine.append(current) + + // Check if we should break the line + self.checkAndBreakLine() + + // Add to atom list + if currentLineIndexRange.location == NSNotFound { + currentLineIndexRange = atom.indexRange + } else { + currentLineIndexRange.length += atom.indexRange.length + } + currentAtoms.append(atom) + + // Treat accent as ordinary for spacing purposes + atom.type = .ordinary + } else { + // Original behavior when no width constraint + // Check if we need to break the line due to width constraints + self.checkAndBreakLine() + // stash the existing layout + if currentLine.length > 0 { + self.addDisplayLine() + } + // Accent is considered as Ord in rule 16. + self.addInterElementSpace(prevNode, currentType:.ordinary) + atom.type = .ordinary; + + let accent = atom as! MTAccent? + let display = self.makeAccent(accent) + displayAtoms.append(display!) + currentPosition.x += display!.width; + + // add super scripts || subscripts + if atom.subScript != nil || atom.superScript != nil { + self.makeScripts(atom, display:display, index:UInt(atom.indexRange.location), delta:0) + } } case .table: @@ -720,7 +794,57 @@ class MTTypesetter { } else { current = NSAttributedString(string:atom.nucleus) } + currentLine.append(current!) + + // Universal line breaking: only for simple atoms (no scripts) + // This works for text, mixed text+math, and simple equations + let isSimpleAtom = (atom.subScript == nil && atom.superScript == nil) + + if isSimpleAtom && maxWidth > 0 { + // Measure the current line width + 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)) + + if lineWidth > maxWidth { + // Line is too wide - need to find a break point + let currentText = currentLine.string + + // Look for the last space before the current position + if let lastSpaceIndex = currentText.lastIndex(of: " ") { + // Split the line at the last space + let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex) + + // Create attributed string for the first line (before space) + let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset))) + firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length)) + + // Keep track of atoms that belong to the first line + // For simplicity, we'll split atoms at the boundary (this is approximate) + let firstLineAtoms = currentAtoms + + // Flush the first line + currentLine = firstLine + currentAtoms = firstLineAtoms + self.addDisplayLine() + + // Move down for new line and reset x position + currentPosition.y -= styleFont.fontSize * 1.5 + currentPosition.x = 0 + + // Start the new line with the content after the space + let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex))) + currentLine = NSMutableAttributedString(string: remainingText) + + // Reset atom list for new line + currentAtoms = [] + currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound) + } + // If no space found, let it overflow (better than breaking mid-word) + } + } // add the atom to the current range if currentLineIndexRange.location == NSNotFound { currentLineIndexRange = atom.indexRange @@ -767,6 +891,52 @@ class MTTypesetter { } } + /// Check if the current line exceeds maxWidth and break if needed + func checkAndBreakLine() { + guard maxWidth > 0 && currentLine.length > 0 else { return } + + // Measure the current line width + 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)) + + guard lineWidth > maxWidth else { return } + + // Line is too wide - need to find a break point + let currentText = currentLine.string + + // Look for the last space before the current position + if let lastSpaceIndex = currentText.lastIndex(of: " ") { + // Split the line at the last space + let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex) + + // Create attributed string for the first line (before space) + let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset))) + firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length)) + + // Keep track of atoms that belong to the first line + let firstLineAtoms = currentAtoms + + // Flush the first line + currentLine = firstLine + currentAtoms = firstLineAtoms + self.addDisplayLine() + + // Move down for new line and reset x position + currentPosition.y -= styleFont.fontSize * 1.5 + currentPosition.x = 0 + + // Start the new line with the content after the space + let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex))) + currentLine = NSMutableAttributedString(string: remainingText) + + // Reset atom list for new line + currentAtoms = [] + currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound) + } + } + @discardableResult func addDisplayLine() -> MTCTLineDisplay? { // add the font diff --git a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift new file mode 100644 index 0000000..37394cb --- /dev/null +++ b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift @@ -0,0 +1,194 @@ +// +// MTMathUILabelLineWrappingTests.swift +// SwiftMathTests +// +// Tests for line wrapping functionality in MTMathUILabel +// + +import XCTest +@testable import SwiftMath + +class MTMathUILabelLineWrappingTests: XCTestCase { + + func testBasicIntrinsicContentSize() { + let label = MTMathUILabel() + label.latex = "\\(x + y\\)" + label.font = MTFontManager.fontManager.defaultFont + + // Debug: check if parsing worked + XCTAssertNotNil(label.mathList, "Math list should not be nil") + XCTAssertNil(label.error, "Should have no parsing error, got: \(String(describing: label.error))") + XCTAssertNotNil(label.font, "Font should not be nil") + + let size = label.intrinsicContentSize + + XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)") + XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)") + } + + func testTextModeIntrinsicContentSize() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Hello World}\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + + let size = label.intrinsicContentSize + + XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)") + XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)") + } + + func testLongTextIntrinsicContentSize() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + + let size = label.intrinsicContentSize + + XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)") + XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)") + } + + func testSizeThatFitsWithoutConstraint() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Hello World}\\)" + label.font = MTFontManager.fontManager.defaultFont + + let size = label.sizeThatFits(CGSize.zero) + + XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)") + XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)") + } + + func testSizeThatFitsWithWidthConstraint() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + + // Get unconstrained size first + let unconstrainedSize = label.sizeThatFits(CGSize.zero) + XCTAssertGreaterThan(unconstrainedSize.width, 0, "Unconstrained width should be > 0") + + // Test with width constraint (use 300 since longest word might be ~237pt) + let constrainedSize = label.sizeThatFits(CGSize(width: 300, height: CGFloat.greatestFiniteMagnitude)) + + XCTAssertGreaterThan(constrainedSize.width, 0, "Constrained width should be greater than 0, got \(constrainedSize.width)") + XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width (\(constrainedSize.width)) should be less than unconstrained (\(unconstrainedSize.width))") + XCTAssertGreaterThan(constrainedSize.height, 0, "Constrained height should be greater than 0, got \(constrainedSize.height)") + + // When constrained, height should increase when text wraps + XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, + "Constrained height (\(constrainedSize.height)) should be > unconstrained (\(unconstrainedSize.height)) when text wraps") + } + + func testPreferredMaxLayoutWidth() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Rappelons la conversion : 1 km équivaut à 1000 m.}\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + + // Get unconstrained size + let unconstrainedSize = label.intrinsicContentSize + + // Now set preferred max width (use 300 since longest word might be ~237pt) + label.preferredMaxLayoutWidth = 300 + let constrainedSize = label.intrinsicContentSize + + XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be greater than 0, got \(constrainedSize.width)") + XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width (\(constrainedSize.width)) should be < unconstrained (\(unconstrainedSize.width))") + XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Constrained height (\(constrainedSize.height)) should be > unconstrained (\(unconstrainedSize.height)) due to wrapping") + } + + func testWordBoundaryBreaking() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Word1 Word2 Word3 Word4 Word5}\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + label.preferredMaxLayoutWidth = 150 + + let size = label.intrinsicContentSize + + XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)") + XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)") + + // Verify it actually uses the layout + label.frame = CGRect(origin: .zero, size: size) + #if os(macOS) + label.layout() + #else + label.layoutSubviews() + #endif + + XCTAssertNotNil(label.displayList, "Display list should be created") + } + + func testEmptyLatex() { + let label = MTMathUILabel() + label.latex = "" + label.font = MTFontManager.fontManager.defaultFont + + let size = label.intrinsicContentSize + + // Empty latex should still return a valid size (might be zero or minimal) + XCTAssertGreaterThanOrEqual(size.width, 0, "Width should be >= 0 for empty latex, got \(size.width)") + XCTAssertGreaterThanOrEqual(size.height, 0, "Height should be >= 0 for empty latex, got \(size.height)") + } + + func testMathAndTextMixed() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Result: } x^2 + y^2 = z^2\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + + let size = label.intrinsicContentSize + + XCTAssertGreaterThan(size.width, 0, "Width should be greater than 0, got \(size.width)") + XCTAssertGreaterThan(size.height, 0, "Height should be greater than 0, got \(size.height)") + } + + func testDebugSizeThatFitsWithConstraint() { + let label = MTMathUILabel() + label.latex = "\\(\\text{Word1 Word2 Word3 Word4 Word5}\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + + let unconstr = label.sizeThatFits(CGSize.zero) + let constr = label.sizeThatFits(CGSize(width: 150, height: 999)) + + XCTAssertLessThan(constr.width, unconstr.width, "Constrained (\(constr.width)) should be < unconstrained (\(unconstr.width))") + XCTAssertGreaterThan(constr.height, unconstr.height, "Constrained height (\(constr.height)) should be > unconstrained (\(unconstr.height))") + } + + func testAccentedCharactersWithLineWrapping() { + let label = MTMathUILabel() + // French text with accented characters: è, é, à + label.latex = "\\(\\text{Rappelons la relation entre kilomètres et mètres.}\\)" + label.font = MTFontManager.fontManager.defaultFont + label.labelMode = .text + + // Get unconstrained size + let unconstrainedSize = label.intrinsicContentSize + + // Set a width constraint that should cause wrapping + label.preferredMaxLayoutWidth = 250 + let constrainedSize = label.intrinsicContentSize + + // Verify wrapping occurred + XCTAssertGreaterThan(constrainedSize.width, 0, "Width should be > 0") + XCTAssertLessThan(constrainedSize.width, unconstrainedSize.width, "Constrained width should be < unconstrained") + XCTAssertGreaterThan(constrainedSize.height, unconstrainedSize.height, "Height should increase when wrapped") + + // 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") + } +}