// // MTDisplayGenerator.swift // SwiftMath // // Created by Claude Code on 2025-12-16. // This software may be modified and distributed under the terms of the // MIT license. See the LICENSE file for details. // import Foundation import CoreGraphics import CoreText /// Generates MTDisplay objects from fitted lines of breakable elements class MTDisplayGenerator { // MARK: - Properties let font: MTFont let style: MTLineStyle let widthCalculator: MTElementWidthCalculator // MARK: - Initialization init(font: MTFont, style: MTLineStyle) { self.font = font self.style = style self.widthCalculator = MTElementWidthCalculator(font: font, style: style) } // MARK: - Display Generation /// Generate displays from fitted lines func generateDisplays(from lines: [[MTBreakableElement]], startPosition: CGPoint) -> [MTDisplay] { var allDisplays: [MTDisplay] = [] var currentY = startPosition.y // Minimum spacing between lines (20% of font size for breathing room) let minimumLineSpacing = font.fontSize * 0.2 for (index, line) in lines.enumerated() { let (lineDisplays, currentLineMetrics) = generateLine(line, at: CGPoint(x: startPosition.x, y: currentY)) allDisplays.append(contentsOf: lineDisplays) // Calculate spacing for next line based on actual content heights if index < lines.count - 1 { let nextLine = lines[index + 1] let nextLineAscent = nextLine.map { $0.ascent }.max() ?? 0 // Space needed = current line's descent + minimum spacing + next line's ascent let spaceNeeded = currentLineMetrics.descent + minimumLineSpacing + nextLineAscent // Ensure minimum spacing of 1.2x font size for readability let minSpacing = font.fontSize * 1.2 currentY -= max(spaceNeeded, minSpacing) } } return allDisplays } /// Line metrics for spacing calculation struct LineMetrics { let ascent: CGFloat let descent: CGFloat var height: CGFloat { ascent + descent } } /// Generate displays for a single line private func generateLine(_ elements: [MTBreakableElement], at position: CGPoint) -> ([MTDisplay], LineMetrics) { var displays: [MTDisplay] = [] var xOffset: CGFloat = 0 // Calculate line metrics let lineAscent = elements.map { $0.ascent }.max() ?? 0 let lineDescent = elements.map { $0.descent }.max() ?? 0 // Baseline y position let baseline = position.y var i = 0 while i < elements.count { let element = elements[i] // Check if this is part of a group (base + scripts) if let groupId = element.groupId { // Collect all elements in this group var groupElements: [MTBreakableElement] = [] var j = i while j < elements.count && elements[j].groupId == groupId { groupElements.append(elements[j]) j += 1 } // Render the group let groupAdvance = renderGroup(groupElements, at: CGPoint(x: position.x + xOffset, y: baseline), displays: &displays) xOffset += groupAdvance i = j } else { // Regular element (not part of a group) // CRITICAL: For operators, spacing should be split evenly before and after // The element.width includes both spacing, but we need to position the operator // with half spacing before it var spacingBefore: CGFloat = 0 if case .operator(let op, _) = element.content { // Get the actual text width vs element width to calculate spacing let textWidth = widthCalculator.measureText(op) let totalSpacing = element.width - textWidth spacingBefore = totalSpacing / 2 } let elementPosition = CGPoint(x: position.x + xOffset + spacingBefore, y: baseline) switch element.content { case .text(let text): let display = createTextDisplay(text, at: elementPosition, element: element) displays.append(display) case .display(let preRenderedDisplay): // Use pre-rendered display (fraction, radical, etc.) let mutableDisplay = preRenderedDisplay mutableDisplay.position = elementPosition displays.append(mutableDisplay) case .operator(let op, _): let display = createTextDisplay(op, at: elementPosition, element: element) displays.append(display) case .script: // Standalone script (shouldn't happen, but handle gracefully) break case .space: // No display for space, just advance position break } xOffset += element.width i += 1 } } return (displays, LineMetrics(ascent: lineAscent, descent: lineDescent)) } /// Render a group of elements (base + scripts) and return the horizontal advance private func renderGroup(_ groupElements: [MTBreakableElement], at position: CGPoint, displays: inout [MTDisplay]) -> CGFloat { var baseWidth: CGFloat = 0 var superscriptWidth: CGFloat = 0 var subscriptWidth: CGFloat = 0 var baseXOffset: CGFloat = 0 // Check if this group has any scripts let hasScripts = groupElements.contains { element in if case .script = element.content { return true } return false } // Track the start index of base displays for dimension adjustment let baseDisplayStartIndex = displays.count // First pass: render base elements and collect script widths for element in groupElements { switch element.content { case .script: // Skip scripts in first pass break default: // Render base element let basePosition = CGPoint(x: position.x + baseXOffset, y: position.y) switch element.content { case .text(let text): let display = createTextDisplay(text, at: basePosition, element: element, hasScript: hasScripts) displays.append(display) case .display(let preRenderedDisplay): let mutableDisplay = preRenderedDisplay mutableDisplay.position = basePosition displays.append(mutableDisplay) case .operator(let op, _): let display = createTextDisplay(op, at: basePosition, element: element, hasScript: hasScripts) displays.append(display) default: break } baseWidth += element.width baseXOffset += element.width } } // Second pass: collect script information for joint positioning var superscriptDisplay: MTDisplay? = nil var subscriptDisplay: MTDisplay? = nil var hasBothScripts = false for element in groupElements { if case .script(let scriptDisplay, let isSuper) = element.content { if isSuper { superscriptDisplay = scriptDisplay } else { subscriptDisplay = scriptDisplay } } } hasBothScripts = superscriptDisplay != nil && subscriptDisplay != nil // Third pass: render scripts with proper positioning var superScriptShiftUp: CGFloat = 0 var subscriptShiftDown: CGFloat = 0 // Check if base is a glyph (not CTLineDisplay) for special positioning // For glyphs (like large operators), position scripts relative to glyph edges var isGlyphBase = false for disp in displays[baseDisplayStartIndex.. 0 { // Superscript is lower than the max allowed by the font with a subscript superScriptShiftUp += superscriptBottomDelta subscriptShiftDown -= superscriptBottomDelta } } } // Calculate italic correction (delta) for superscript positioning // Superscripts are positioned at baseWidth + delta, subscripts at baseWidth var delta: CGFloat = 0 if superscriptDisplay != nil { // Get italic correction from the base display if it's a glyph for disp in displays[baseDisplayStartIndex.. baseDisplayStartIndex { // Calculate the full extent of the group including scripts var maxAscent: CGFloat = 0 var maxDescent: CGFloat = 0 for i in baseDisplayStartIndex.. MTDisplay { let attrString = NSMutableAttributedString(string: text) attrString.addAttribute( NSAttributedString.Key(kCTFontAttributeName as String), value: font.ctFont as Any, range: NSMakeRange(0, attrString.length) ) // If the atom was fused (multiple ordinary chars combined), use fusedAtoms // Otherwise, use the original atom let atoms: [MTMathAtom] if !element.originalAtom.fusedAtoms.isEmpty { atoms = element.originalAtom.fusedAtoms } else { atoms = [element.originalAtom] } let display = MTCTLineDisplay( withString: attrString, position: position, range: element.indexRange, font: font, atoms: atoms ) // Mark if this base element has associated scripts display.hasScript = hasScript return display } }