From b014be12b4c3300fc618cf9475a0ab0faa661a59 Mon Sep 17 00:00:00 2001 From: Nicolas Guillot Date: Fri, 14 Nov 2025 12:31:54 +0100 Subject: [PATCH] Add dynamic line height adjustment for multiline math display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fixed fontSize × 1.5 spacing with adaptive height calculation based on actual line content (ascent + descent), providing better visual spacing for expressions with varying content heights. --- MULTILINE_IMPLEMENTATION_NOTES.md | 67 ++++-- .../SwiftMath/MathRender/MTTypesetter.swift | 80 +++++-- Tests/SwiftMathTests/MTTypesetterTests.swift | 212 ++++++++++++++++++ 3 files changed, 326 insertions(+), 33 deletions(-) diff --git a/MULTILINE_IMPLEMENTATION_NOTES.md b/MULTILINE_IMPLEMENTATION_NOTES.md index 33bca0e..e4b0a04 100644 --- a/MULTILINE_IMPLEMENTATION_NOTES.md +++ b/MULTILINE_IMPLEMENTATION_NOTES.md @@ -357,19 +357,31 @@ The following cases that previously forced line breaks now work perfectly: **Progress**: COMPLETED with 8 comprehensive tests! -### Priority 2: Dynamic Line Height -**Goal**: Adjust vertical spacing based on actual line content height. +### ✅ Dynamic Line Height (NEWLY COMPLETED!) +**Goal**: Adjust vertical spacing based on actual line content height rather than fixed fontSize × 1.5. -**Approach**: -1. Track maximum ascent/descent for each line -2. Use actual measurements for vertical positioning -3. Add configurable minimum line spacing +**Implementation**: Lines 366-367, 421-423, 654-689 in MTTypesetter.swift +- Added `currentLineStartIndex: Int` to track where each line's displays begin in displayAtoms array +- Added `minimumLineSpacing: CGFloat` set to 20% of fontSize for breathing room between lines +- Created `calculateCurrentLineHeight()` function that: + * Iterates through all displays added for the current line (from currentLineStartIndex to displayAtoms.count) + * Finds maximum ascent and maximum descent across all displays + * Returns total height = maxAscent + maxDescent + minimumLineSpacing + * Ensures at least fontSize × 1.2 spacing for readability +- Modified all line breaking functions to use dynamic height: + * `performInteratomLineBreak()` - for interatom breaks (lines 606-624) + * `performLineBreak()` - for complex display breaks (lines 649-664) + * Universal line breaking - two locations (lines 1237-1241, 1260-1264) + * Scripted atom breaking (lines 1290-1293) + * `checkAndBreakLine()` helper - two locations (lines 1486-1490, 1510-1514) +- All break locations now: + * Calculate line height based on actual content + * Update currentPosition.y using dynamic height + * Update currentLineStartIndex for next line -**Implementation**: Modify `addDisplayLine()` to calculate and store line height. +**Impact**: ⭐⭐⭐⭐ SIGNIFICANT improvement! Lines with tall content (fractions, large operators) get appropriate spacing, while regular lines don't have excessive gaps! -**Impact**: ⭐⭐ (Better vertical spacing) - -**Difficulty**: Low-Medium (straightforward calculation change) +**Progress**: COMPLETED with 8 comprehensive tests! ## Testing Strategy @@ -401,9 +413,10 @@ The following cases that previously forced line breaks now work perfectly: ✅ **Edge cases** (NEW - 2 tests in lines 2494-2534) ✅ **Scripted atoms inline** (NEW - 8 tests in lines 2609-2780) ✅ **Break quality scoring** (NEW - 8 tests in lines 2797-3006) +✅ **Dynamic line height** (NEW - 8 tests in lines 3007-3218) -**Total: 89 tests in MTTypesetterTests.swift, all passing on iOS** -**Overall: 240 tests across entire test suite, all passing** +**Total: 97 tests in MTTypesetterTests.swift, all passing on iOS** +**Overall: 248 tests across entire test suite, all passing** ### Coverage Summary by Category @@ -426,7 +439,7 @@ The following cases that previously forced line breaks now work perfectly: - No breaking without width constraint - Complex expressions mixing fractions and scripts -**Break Quality Scoring:** (8 NEW tests) +**Break Quality Scoring:** (8 tests) - Prefer breaking after binary operators (+, -, ×, ÷) - Prefer breaking after relation operators (=, <, >) - Avoid breaking after open brackets @@ -436,6 +449,16 @@ The following cases that previously forced line breaks now work perfectly: - No unnecessary breaks when content fits - Penalty ordering validates break preferences +**Dynamic Line Height:** (8 NEW tests) +- Tall content (fractions) gets more spacing +- Regular content has reasonable spacing (not excessive) +- Mixed content varies spacing appropriately per line +- Large operators with limits get adequate vertical space +- Similar content gets consistent spacing +- No regression on single-line expressions +- Deep/nested fractions get extra space +- Radicals with indices (cube roots) get adequate spacing + **Edge Cases & Stress Tests:** (4 tests) - Very narrow widths (30pt) - Very wide atoms (overflow) @@ -496,14 +519,18 @@ The implementation now provides **excellent support** for: ### ⚠️ Remaining Limitations (Minor Cases Only) **Still need work** for: -- ⚠️ Very long text atoms - break within atom rather than between atoms -- ⚠️ Dynamic line height - fixed spacing regardless of content height +- ⚠️ Very long text atoms - break within atom rather than between atoms (already implemented via universal line breaking but could be further optimized) -**Note**: These are minor aesthetic improvements rather than fundamental limitations! +**Note**: This is a minor edge case rather than a fundamental limitation! -### 🎯 Next Priority +### 🎯 Future Enhancements (All Core Features Complete!) -The most impactful remaining improvement: -1. **Dynamic line height** (Priority 1) - adjust vertical spacing based on actual content height rather than fixed fontSize × 1.5 +All major priorities have been completed! Possible future enhancements: +1. **Further optimize very long text atom breaking** - fine-tune Unicode-aware breaking for edge cases +2. **Configurable line spacing multiplier** - allow users to adjust minimum spacing +3. **Alignment options** - left/center/right alignment for multiline expressions -**Progress**: 🎉 **100% complete for all atom types + intelligent break point selection!** All major atom types (simple, complex, and scripted) now support intelligent inline layout with width-based breaking AND aesthetically-pleasing break point selection! +**Progress**: 🎉 **100% COMPLETE for all major features!** All atom types (simple, complex, and scripted) now support: +- ✅ Intelligent inline layout with width-based breaking +- ✅ Aesthetically-pleasing break point selection (after operators, avoiding bad breaks) +- ✅ Dynamic line height based on actual content (tall fractions get more space, regular content stays compact) diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index ac12be0..8bc4e5e 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -363,7 +363,9 @@ class MTTypesetter { var cramped = false var spaced = false var maxWidth: CGFloat = 0 // Maximum width for line breaking, 0 means no constraint - + var currentLineStartIndex: Int = 0 // Index in displayAtoms where current line starts + var minimumLineSpacing: CGFloat = 0 // Minimum spacing between lines (will be set based on fontSize) + static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle) -> MTMathListDisplay? { let finalizedList = mathList?.finalized // default is not cramped, no width constraint @@ -416,6 +418,9 @@ class MTTypesetter { self.currentAtoms = [MTMathAtom]() self.style = style self.currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound); + self.currentLineStartIndex = 0 + // Set minimum line spacing to 20% of fontSize for some breathing room + self.minimumLineSpacing = (font?.fontSize ?? 0) * 0.2 } static func preprocessMathList(_ ml:MTMathList?) -> [MTMathAtom] { @@ -602,10 +607,16 @@ class MTTypesetter { // Flush the current line self.addDisplayLine() - // Move down for new line - currentPosition.y -= styleFont.fontSize * 1.5 + // Calculate dynamic line height based on actual content + let lineHeight = calculateCurrentLineHeight() + + // Move down for new line using dynamic height + currentPosition.y -= lineHeight currentPosition.x = 0 + // Update line start index for next line + currentLineStartIndex = displayAtoms.count + // Reset for new line currentLine = NSMutableAttributedString() currentAtoms = [] @@ -640,8 +651,41 @@ class MTTypesetter { if currentLine.length > 0 { self.addDisplayLine() } - currentPosition.y -= styleFont.fontSize * 1.5 + + // Calculate dynamic line height based on actual content + let lineHeight = calculateCurrentLineHeight() + + // Move down for new line using dynamic height + currentPosition.y -= lineHeight currentPosition.x = 0 + + // Update line start index for next line + currentLineStartIndex = displayAtoms.count + } + + /// Calculate the height of the current line based on actual display heights + /// Returns the total height (max ascent + max descent) plus minimum spacing + func calculateCurrentLineHeight() -> CGFloat { + // If no displays added for current line, use default spacing + guard currentLineStartIndex < displayAtoms.count else { + return styleFont.fontSize * 1.5 + } + + var maxAscent: CGFloat = 0 + var maxDescent: CGFloat = 0 + + // Iterate through all displays added for the current line + for i in currentLineStartIndex.. maxWidth { self.addDisplayLine() - currentPosition.y -= styleFont.fontSize * 1.5 + let lineHeight = calculateCurrentLineHeight() + currentPosition.y -= lineHeight currentPosition.x = 0 + currentLineStartIndex = displayAtoms.count } } @@ -1433,9 +1483,11 @@ class MTTypesetter { currentAtoms = [] self.addDisplayLine() - // Move down for new line - currentPosition.y -= styleFont.fontSize * 1.5 + // Calculate dynamic line height and move down for new line + let lineHeight = calculateCurrentLineHeight() + currentPosition.y -= lineHeight currentPosition.x = 0 + currentLineStartIndex = displayAtoms.count // Remaining text includes everything after the earlier break let remainingText = String(firstLineText.suffix(from: earlierBreakIndex)) + @@ -1455,9 +1507,11 @@ class MTTypesetter { currentAtoms = firstLineAtoms self.addDisplayLine() - // Move down for new line and reset x position - currentPosition.y -= styleFont.fontSize * 1.5 + // Calculate dynamic line height and move down for new line + let lineHeight = calculateCurrentLineHeight() + currentPosition.y -= lineHeight currentPosition.x = 0 + currentLineStartIndex = displayAtoms.count // Start the new line with the content after the break let remainingText = String(currentText.suffix(from: breakIndex)) diff --git a/Tests/SwiftMathTests/MTTypesetterTests.swift b/Tests/SwiftMathTests/MTTypesetterTests.swift index 5394be6..122de61 100755 --- a/Tests/SwiftMathTests/MTTypesetterTests.swift +++ b/Tests/SwiftMathTests/MTTypesetterTests.swift @@ -3004,5 +3004,217 @@ final class MTTypesetterTests: XCTestCase { "Should prefer breaking after + operator or fit on one line, found lines: \(lineContents)") } + // MARK: - Dynamic Line Height Tests + + func testDynamicLineHeight_TallContentHasMoreSpacing() throws { + // Test that lines with tall content (fractions) have appropriate spacing + let latex = "a+b+c+\\frac{x^{2}}{y^{2}}+d+e+f" + let mathList = MTMathListBuilder.build(fromString: latex) + XCTAssertNotNil(mathList, "Should parse LaTeX") + + // Force multiple lines + let maxWidth: CGFloat = 80 + let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + XCTAssertNotNil(display) + + // Collect unique y positions (representing different lines) + let yPositions = Set(display!.subDisplays.map { $0.position.y }).sorted(by: >) + + // Should have multiple lines + XCTAssertGreaterThan(yPositions.count, 1, "Should have multiple lines") + + // Calculate spacing between lines + var spacings: [CGFloat] = [] + for i in 1..) + + // Should have multiple lines + XCTAssertGreaterThan(yPositions.count, 1, "Should have multiple lines") + + // Calculate spacing between lines + var spacings: [CGFloat] = [] + for i in 1..) + + if yPositions.count > 1 { + // Calculate spacing + var spacings: [CGFloat] = [] + for i in 1..) + + if yPositions.count >= 3 { + // Calculate all spacings + var spacings: [CGFloat] = [] + for i in 1..) + if yPositions.count > 1 { + for i in 1..