diff --git a/README.md b/README.md index 4845787..2711b76 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,8 @@ Breaks equations **between atoms** (mathematical elements) when content exceeds ##### 2. Universal Line Breaking (Fallback) For very long text within single atoms, breaks at Unicode word boundaries using Core Text with number protection (prevents splitting numbers like "3.14"). +See `MULTILINE_IMPLEMENTATION_NOTES.md` for implementation details and recent changes. + #### Fully Supported Cases These atom types work perfectly with interatom line breaking: @@ -365,10 +367,12 @@ label.latex = "a + \\color{red}{b} + c" // Colored portion causes line break ``` -**⚠️ Math accents:** +**⚠️ Math accents (partial support):** ```swift label.latex = "\\hat{x} + \\tilde{y} + \\bar{z}" -// Accents may cause line breaks +// Common accents (\hat, \tilde, \bar) are positioned correctly in most cases. +// Some complex grapheme clusters or font-specific metrics may still need additional polishing. +// See MULTILINE_IMPLEMENTATION_NOTES.md for details and known edge cases. ``` #### Best Practices @@ -507,6 +511,25 @@ Equations without explicit delimiters continue to work as before, defaulting to label.latex = "x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}" // Works as always ``` +#### Programmatic API +For advanced use cases where you need to parse LaTeX and determine the detected style programmatically, use the `buildWithStyle` method: + +```swift +// Parse LaTeX and get both the math list and detected style +let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: "\\[x^2 + y^2 = z^2\\]") + +// style will be .display for \[...\] or $$...$$ +// style will be .text for \(...\) or $...$ + +// Create a display with the detected style +if let mathList = mathList { + let display = MTTypesetter.createLineForMathList(mathList, font: myFont, style: style) + // Use the display for rendering +} +``` + +This is particularly useful when building custom renderers or when you need to respect the user's choice of delimiter style. + Note: SwiftMath only supports the commands in LaTeX's math mode. There is also no language support for other than west European langugages and some Cyrillic characters. There would be two ways to support more languages: diff --git a/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift b/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift index 81f0d4c..1278735 100644 --- a/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift +++ b/Sources/SwiftMath/MathRender/MTMathAtomFactory.swift @@ -101,7 +101,10 @@ public class MTMathAtomFactory { "check" : "\u{030C}", "vec" : "\u{20D7}", "widehat" : "\u{0302}", - "widetilde" : "\u{0303}" + "widetilde" : "\u{0303}", + "overleftarrow" : "\u{20D6}", // Combining left arrow above + "overrightarrow" : "\u{20D7}", // Combining right arrow above (same as vec) + "overleftrightarrow" : "\u{20E1}" // Combining left right arrow above ] private static let accentValueLock = NSLock() @@ -525,6 +528,7 @@ public class MTMathAtomFactory { "mathbfit": .boldItalic, "bm": .boldItalic, "text": .roman, + "operatorname": .roman, ] public static func fontStyleWithName(_ fontName:String) -> MTFontStyle? { @@ -725,7 +729,20 @@ public class MTMathAtomFactory { */ public static func accent(withName name: String) -> MTAccent? { if let accentValue = accents[name] { - return MTAccent(value: accentValue) + let accent = MTAccent(value: accentValue) + // Mark stretchy arrow accents (\overleftarrow, \overrightarrow, \overleftrightarrow) + // These should stretch to match content width + // \vec is NOT stretchy - it should use a small fixed-size arrow + let stretchyAccents: Set = ["overleftarrow", "overrightarrow", "overleftrightarrow"] + accent.isStretchy = stretchyAccents.contains(name) + + // Mark wide accents (\widehat, \widetilde, \widecheck) + // These should stretch horizontally to cover content width + // \hat, \tilde, \check are NOT wide - they use fixed-size accents + let wideAccents: Set = ["widehat", "widetilde", "widecheck"] + accent.isWide = wideAccents.contains(name) + + return accent } return nil } diff --git a/Sources/SwiftMath/MathRender/MTMathList.swift b/Sources/SwiftMath/MathRender/MTMathList.swift index d4d8b90..b1cb8ec 100644 --- a/Sources/SwiftMath/MathRender/MTMathList.swift +++ b/Sources/SwiftMath/MathRender/MTMathList.swift @@ -565,19 +565,29 @@ public class MTUnderLine: MTMathAtom { public class MTAccent: MTMathAtom { public var innerList: MTMathList? - + /// Indicates if this accent should use stretchy arrow behavior (for \overrightarrow, etc.) + /// vs short accent behavior (for \vec). Only applies to arrow accents. + public var isStretchy: Bool = false + /// Indicates if this accent should use wide stretching behavior (for \widehat, \widetilde) + /// vs regular fixed-size accent behavior (for \hat, \tilde). + public var isWide: Bool = false + override public var finalized: MTMathAtom { let newAccent = super.finalized as! MTAccent newAccent.innerList = newAccent.innerList?.finalized + newAccent.isStretchy = self.isStretchy + newAccent.isWide = self.isWide return newAccent } - + init(_ accent: MTAccent?) { super.init(accent) self.type = .accent self.innerList = MTMathList(accent?.innerList) + self.isStretchy = accent?.isStretchy ?? false + self.isWide = accent?.isWide ?? false } - + init(value: String) { super.init() self.type = .accent diff --git a/Sources/SwiftMath/MathRender/MTMathListBuilder.swift b/Sources/SwiftMath/MathRender/MTMathListBuilder.swift index cdf52e0..7e9be07 100644 --- a/Sources/SwiftMath/MathRender/MTMathListBuilder.swift +++ b/Sources/SwiftMath/MathRender/MTMathListBuilder.swift @@ -74,6 +74,16 @@ public struct MTMathListBuilder { case display /// Inline/text style - compact operators, limits to the side (e.g., $...$, \(...\)) case inline + + /// Convert MathMode to MTLineStyle for rendering + func toLineStyle() -> MTLineStyle { + switch self { + case .display: + return .display + case .inline: + return .text + } + } } var string: String @@ -306,6 +316,45 @@ public struct MTMathListBuilder { } return output } + + /** Construct a math list from a given string and return the detected style. + This method detects LaTeX delimiters like \[...\], $$...$$, $...$, \(...\) + and returns the appropriate rendering style (.display or .text). + + If there is a parse error, returns nil for the MathList. + + - Parameter string: The LaTeX string to parse + - Returns: A tuple containing the parsed MathList and the detected MTLineStyle + */ + public static func buildWithStyle(fromString string: String) -> (mathList: MTMathList?, style: MTLineStyle) { + var builder = MTMathListBuilder(string: string) + let mathList = builder.build() + let style = builder.mathMode.toLineStyle() + return (mathList, style) + } + + /** Construct a math list from a given string and return the detected style. + This method detects LaTeX delimiters like \[...\], $$...$$, $...$, \(...\) + and returns the appropriate rendering style (.display or .text). + + If there is an error while constructing the string, this returns nil for the MathList. + The error is returned in the `error` parameter. + + - Parameters: + - string: The LaTeX string to parse + - error: An inout parameter that will contain any parse error + - Returns: A tuple containing the parsed MathList and the detected MTLineStyle + */ + public static func buildWithStyle(fromString string: String, error: inout NSError?) -> (mathList: MTMathList?, style: MTLineStyle) { + var builder = MTMathListBuilder(string: string) + let output = builder.build() + let style = builder.mathMode.toLineStyle() + if builder.error != nil { + error = builder.error + return (nil, style) + } + return (output, style) + } public mutating func buildInternal(_ oneCharOnly: Bool) -> MTMathList? { self.buildInternal(oneCharOnly, stopChar: nil) diff --git a/Sources/SwiftMath/MathRender/MTMathListDisplay.swift b/Sources/SwiftMath/MathRender/MTMathListDisplay.swift index 5ed7c16..7cf118f 100755 --- a/Sources/SwiftMath/MathRender/MTMathListDisplay.swift +++ b/Sources/SwiftMath/MathRender/MTMathListDisplay.swift @@ -503,10 +503,12 @@ class MTRadicalDisplay : MTDisplay { /// Rendering a glyph as a display class MTGlyphDisplay : MTDisplayDS { - + var glyph:CGGlyph! var font:MTFont? - + /// Horizontal scale factor for stretching glyphs (1.0 = no scaling) + var scaleX: CGFloat = 1.0 + init(withGlpyh glyph:CGGlyph, range:NSRange, font:MTFont?) { super.init() self.font = font @@ -521,10 +523,16 @@ class MTGlyphDisplay : MTDisplayDS { context.saveGState() self.textColor?.setFill() - + // Make the current position the origin as all the positions of the sub atoms are relative to the origin. - + context.translateBy(x: self.position.x, y: self.position.y - self.shiftDown); + + // Apply horizontal scaling if needed (for stretchy arrows) + if scaleX != 1.0 { + context.scaleBy(x: scaleX, y: 1.0) + } + context.textPosition = CGPoint.zero var pos = CGPoint.zero diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index c6e48ef..ef9d27b 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -1210,11 +1210,13 @@ class MTTypesetter { } case .accent: - if maxWidth > 0 { - // When line wrapping is enabled, render the accent properly but inline - // to avoid premature line flushing + let accent = atom as! MTAccent - let accent = atom as! MTAccent + // Check if we can use Unicode composition for inline rendering + // Unicode combining characters only work for single characters, not multi-character expressions + if maxWidth > 0 && canUseUnicodeComposition(accent) { + // When line wrapping is enabled and accent is simple, use Unicode composition + // to render inline without line breaks // Get the base character from innerList var baseChar = "" @@ -1263,7 +1265,12 @@ class MTTypesetter { // Treat accent as ordinary for spacing purposes atom.type = .ordinary } else { - // Original behavior when no width constraint + // Use font-based rendering for: + // - Multi-character expressions (e.g., \overrightarrow{DA}) + // - Arrow accents that need stretching + // - Complex expressions with scripts + // - When line wrapping is disabled + // Check if we need to break the line due to width constraints self.checkAndBreakLine() // stash the existing layout @@ -1274,7 +1281,6 @@ class MTTypesetter { 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; @@ -2439,7 +2445,7 @@ class MTTypesetter { } // Find the largest horizontal variant if exists, with width less than max width. - func findVariantGlyph(_ glyph:CGGlyph, withMaxWidth maxWidth:CGFloat, maxWidth glyphAscent:inout CGFloat, glyphDescent:inout CGFloat, glyphWidth:inout CGFloat) -> CGGlyph { + func findVariantGlyph(_ glyph:CGGlyph, withMaxWidth maxWidth:CGFloat, maxWidth glyphAscent:inout CGFloat, glyphDescent:inout CGFloat, glyphWidth:inout CGFloat, glyphMinY:inout CGFloat) -> CGGlyph { let variants = styleFont.mathTable!.getHorizontalVariantsForGlyph(glyph) let numVariants = variants.count assert(numVariants > 0, "A glyph is always it's own variant, so number of variants should be > 0"); @@ -2468,6 +2474,7 @@ class MTTypesetter { glyphWidth = advances[i].width; glyphAscent = ascent; glyphDescent = descent; + glyphMinY = bounds.minY; } return curGlyph; } else { @@ -2475,26 +2482,293 @@ class MTTypesetter { glyphWidth = advances[i].width; glyphAscent = ascent; glyphDescent = descent; + glyphMinY = bounds.minY; } } // We exhausted all the variants and none was larger than the width, so we return the largest return curGlyph; } + /// Gets the proper glyph name for arrow accents that have stretchy variants in the font. + /// Returns different glyphs based on the LaTeX command used: + /// - \vec: use combining character glyph (uni20D7) for small fixed-size arrow + /// - \overrightarrow: use non-combining arrow (arrowright) which can be stretched + func getArrowAccentGlyphName(_ accent: MTAccent) -> String? { + // Check if this is a stretchy arrow accent (set by the factory based on LaTeX command) + let useStretchy = accent.isStretchy + + // Map Unicode combining characters to appropriate glyph names + switch accent.nucleus { + case "\u{20D6}": // Combining left arrow above + return useStretchy ? "arrowleft" : "uni20D6" + case "\u{20D7}": // Combining right arrow above (\vec or \overrightarrow) + return useStretchy ? "arrowright" : "uni20D7" + case "\u{20E1}": // Combining left right arrow above + return useStretchy ? "arrowboth" : "uni20E1" + default: + return nil + } + } + + /// Gets the proper glyph name for wide accents that should stretch to cover content. + /// Returns different glyphs based on the LaTeX command used: + /// - \hat: use combining character for fixed-size accent + /// - \widehat: use non-combining circumflex which can be stretched + func getWideAccentGlyphName(_ accent: MTAccent) -> String? { + // Only apply to wide accents (set by factory based on LaTeX command) + guard accent.isWide else { return nil } + + // Map Unicode combining characters to non-combining glyph names with stretchy variants + switch accent.nucleus { + case "\u{0302}": // COMBINING CIRCUMFLEX ACCENT (\hat or \widehat) + return "circumflex" + case "\u{0303}": // COMBINING TILDE (\tilde or \widetilde) + return "tilde" + case "\u{030C}": // COMBINING CARON (\check or \widecheck) + return "caron" + default: + return nil + } + } + + /// Counts the approximate character length of the content under a wide accent. + /// This is used to select the appropriate glyph variant. + func getWideAccentContentLength(_ accent: MTAccent) -> Int { + guard let innerList = accent.innerList else { return 0 } + + var charCount = 0 + for atom in innerList.atoms { + switch atom.type { + case .variable, .number: + // Count actual characters + charCount += atom.nucleus.count + case .ordinary, .binaryOperator, .relation: + // Count as single character + charCount += 1 + case .fraction: + // Fractions count as 2 units + charCount += 2 + case .radical: + // Radicals count as 2 units + charCount += 2 + case .largeOperator: + // Large operators count as 2 units + charCount += 2 + default: + // Other types count as 1 unit + charCount += 1 + } + } + return charCount + } + + /// Determines which glyph variant to use for a wide accent based on content length. + /// Returns a multiplier for the requested width (1.0, 1.5, 2.0, or 2.5) + /// Similar to KaTeX's approach of selecting variants based on character count. + func getWideAccentVariantMultiplier(_ accent: MTAccent) -> CGFloat { + let charCount = getWideAccentContentLength(accent) + + // Map character count to variant width request multiplier + // This helps select larger glyph variants from the font's MATH table + // 1-2 chars: request 1.0x (smallest variant) + // 3-4 chars: request 1.5x (medium variant) + // 5-6 chars: request 2.0x (large variant) + // 7+ chars: request 2.5x (largest variant) + if charCount <= 2 { + return 1.0 + } else if charCount <= 4 { + return 1.5 + } else if charCount <= 6 { + return 2.0 + } else { + return 2.5 + } + } + func makeAccent(_ accent:MTAccent?) -> MTDisplay? { var accentee = MTTypesetter.createLineForMathList(accent!.innerList, font:font, style:style, cramped:true) if accent!.nucleus.isEmpty { // no accent! return accentee } - let end = accent!.nucleus.index(before: accent!.nucleus.endIndex) - var accentGlyph = self.findGlyphForCharacterAtIndex(end, inString:accent!.nucleus) + + var accentGlyph: CGGlyph + let isArrowAccent = getArrowAccentGlyphName(accent!) != nil + let isWideAccent = getWideAccentGlyphName(accent!) != nil + + // Check for special accent types that need non-combining glyphs + if let wideGlyphName = getWideAccentGlyphName(accent!) { + // For wide accents, use non-combining glyphs (e.g., "circumflex", "tilde") + // These have horizontal variants that can stretch + accentGlyph = styleFont.get(glyphWithName: wideGlyphName) + } else if let arrowGlyphName = getArrowAccentGlyphName(accent!) { + // For arrow accents, use non-combining arrow glyphs (e.g., "arrowright") + // These have larger horizontal variants than the combining versions + accentGlyph = styleFont.get(glyphWithName: arrowGlyphName) + } else { + // For regular accents, use Unicode character lookup + let end = accent!.nucleus.index(before: accent!.nucleus.endIndex) + accentGlyph = self.findGlyphForCharacterAtIndex(end, inString:accent!.nucleus) + } + let accenteeWidth = accentee!.width; - var glyphAscent=CGFloat(0), glyphDescent=CGFloat(0), glyphWidth=CGFloat(0) - accentGlyph = self.findVariantGlyph(accentGlyph, withMaxWidth:accenteeWidth, maxWidth:&glyphAscent, glyphDescent:&glyphDescent, glyphWidth:&glyphWidth) - let delta = min(accentee!.ascent, styleFont.mathTable!.accentBaseHeight); - let skew = self.getSkew(accent, accenteeWidth:accenteeWidth, accentGlyph:accentGlyph) - let height = accentee!.ascent - delta; // This is always positive since delta <= height. + var glyphAscent=CGFloat(0), glyphDescent=CGFloat(0), glyphWidth=CGFloat(0), glyphMinY=CGFloat(0) + + // Adjust requested width based on accent type: + // - Wide accents (\widehat): request width based on content length (variant selection) + // - Arrow accents (\overrightarrow): request extra width for stretching + // - Regular accents: request exact content width + let requestedWidth: CGFloat + if isWideAccent { + // For wide accents, request width based on content length to select appropriate variant + let multiplier = getWideAccentVariantMultiplier(accent!) + requestedWidth = accenteeWidth * multiplier + } else if isArrowAccent { + if accent!.isStretchy { + requestedWidth = accenteeWidth * 1.1 // Request extra width for stretching + } else { + requestedWidth = 1.0 // Get smallest non-zero variant (typically .h1) + } + } else { + requestedWidth = accenteeWidth + } + + accentGlyph = self.findVariantGlyph(accentGlyph, withMaxWidth:requestedWidth, maxWidth:&glyphAscent, glyphDescent:&glyphDescent, glyphWidth:&glyphWidth, glyphMinY:&glyphMinY) + + // For non-stretchy arrow accents (\vec): if we got a zero-width glyph (base combining char), + // manually select the first variant which is the proper accent size + if isArrowAccent && !accent!.isStretchy && glyphWidth == 0 { + let variants = styleFont.mathTable!.getHorizontalVariantsForGlyph(accentGlyph) + if variants.count > 1, let variantNum = variants[1] { + // Use the first variant (.h1) which has proper width + accentGlyph = CGGlyph(variantNum.uint16Value) + var glyph = accentGlyph + var advances = CGSize.zero + CTFontGetAdvancesForGlyphs(styleFont.ctFont, .horizontal, &glyph, &advances, 1) + glyphWidth = advances.width + // Recalculate ascent and descent for the variant glyph + var boundingRects = CGRect.zero + CTFontGetBoundingRectsForGlyphs(styleFont.ctFont, .horizontal, &glyph, &boundingRects, 1) + glyphMinY = boundingRects.minY + glyphAscent = boundingRects.maxY + glyphDescent = -boundingRects.minY + } + } + + // Special accents (arrows and wide accents) need more vertical space and different positioning + let delta: CGFloat + let height: CGFloat + let skew: CGFloat + + if isWideAccent { + // Wide accents (\widehat, \widetilde): use same vertical spacing as stretchy arrows + delta = 0 // No compression for wide accents + let wideAccentSpacing = styleFont.mathTable!.upperLimitGapMin // Same as stretchy arrows + // Compensate for internal glyph whitespace (minY > 0) + let minYCompensation = max(0, glyphMinY) + height = accentee!.ascent + wideAccentSpacing - minYCompensation + + // For wide accents: if the largest glyph variant is still smaller than content width, + // scale it horizontally to fully cover the content + if glyphWidth < accenteeWidth { + // Add padding to make accent extend slightly beyond content + // Use ~0.1em padding (less than arrows which use ~0.167em) + let widePadding = styleFont.fontSize / 10 // Approximately 0.1em + let targetWidth = accenteeWidth + widePadding + + let scaleX = targetWidth / glyphWidth + let accentGlyphDisplay = MTGlyphDisplay(withGlpyh: accentGlyph, range: accent!.indexRange, font: styleFont) + accentGlyphDisplay.scaleX = scaleX // Apply horizontal scaling + accentGlyphDisplay.ascent = glyphAscent + accentGlyphDisplay.descent = glyphDescent + accentGlyphDisplay.width = targetWidth // Set width to include padding + accentGlyphDisplay.position = CGPointMake(0, height) // Align to left edge + + if self.isSingleCharAccentee(accent) && (accent!.subScript != nil || accent!.superScript != nil) { + // Attach the super/subscripts to the accentee instead of the accent. + let innerAtom = accent!.innerList!.atoms[0] + innerAtom.superScript = accent!.superScript + innerAtom.subScript = accent!.subScript + accent?.superScript = nil + accent?.subScript = nil + accentee = MTTypesetter.createLineForMathList(accent!.innerList, font:font, style:style, cramped:cramped) + } + + let display = MTAccentDisplay(withAccent:accentGlyphDisplay, accentee:accentee, range:accent!.indexRange) + display.width = accentee!.width + display.descent = accentee!.descent + let ascent = height + glyphAscent + display.ascent = max(accentee!.ascent, ascent) + display.position = currentPosition + return display + } else { + // Wide accent glyph is wide enough: center it over the content + skew = (accenteeWidth - glyphWidth) / 2 + } + } else if isArrowAccent { + // Arrow accents spacing depends on whether they're stretchy or not + if accent!.isStretchy { + // Stretchy arrows (\overrightarrow): use full ascent + additional spacing + delta = 0 // No compression for stretchy arrows + let arrowSpacing = styleFont.mathTable!.upperLimitGapMin // Use standard gap + // Compensate for internal glyph whitespace (minY > 0) + let minYCompensation = max(0, glyphMinY) + height = accentee!.ascent + arrowSpacing - minYCompensation + } else { + // Non-stretchy arrows (\vec): use tight spacing like regular accents + // This gives a more compact appearance suitable for single-character vectors + delta = min(accentee!.ascent, styleFont.mathTable!.accentBaseHeight) + // Compensate for internal glyph whitespace (minY > 0) + let minYCompensation = max(0, glyphMinY) + height = accentee!.ascent - delta - minYCompensation + } + + // For stretchy arrow accents (\overrightarrow): if the largest glyph variant is still smaller than content width, + // scale it horizontally to fully cover the content + // Add small padding to make arrow tip extend slightly beyond content + // For non-stretchy accents (\vec): always center without scaling + if accent!.isStretchy && glyphWidth < accenteeWidth { + // Add padding to make arrow extend beyond content on the tip side + // Use approximately 0.15-0.2em extra width + let arrowPadding = styleFont.fontSize / 6 // Approximately 0.167em at typical font sizes + let targetWidth = accenteeWidth + arrowPadding + + let scaleX = targetWidth / glyphWidth + let accentGlyphDisplay = MTGlyphDisplay(withGlpyh: accentGlyph, range: accent!.indexRange, font: styleFont) + accentGlyphDisplay.scaleX = scaleX // Apply horizontal scaling + accentGlyphDisplay.ascent = glyphAscent + accentGlyphDisplay.descent = glyphDescent + accentGlyphDisplay.width = targetWidth // Set width to include padding + accentGlyphDisplay.position = CGPointMake(0, height) // Align to left edge + + if self.isSingleCharAccentee(accent) && (accent!.subScript != nil || accent!.superScript != nil) { + // Attach the super/subscripts to the accentee instead of the accent. + let innerAtom = accent!.innerList!.atoms[0] + innerAtom.superScript = accent!.superScript + innerAtom.subScript = accent!.subScript + accent?.superScript = nil + accent?.subScript = nil + accentee = MTTypesetter.createLineForMathList(accent!.innerList, font:font, style:style, cramped:cramped) + } + + let display = MTAccentDisplay(withAccent:accentGlyphDisplay, accentee:accentee, range:accent!.indexRange) + display.width = accentee!.width + display.descent = accentee!.descent + let ascent = height + glyphAscent + display.ascent = max(accentee!.ascent, ascent) + display.position = currentPosition + return display + } else { + // Arrow glyph is wide enough or is non-stretchy (\vec): center it over the content + skew = (accenteeWidth - glyphWidth) / 2 + } + } else { + // For regular accents: use traditional tight positioning + delta = min(accentee!.ascent, styleFont.mathTable!.accentBaseHeight) + skew = self.getSkew(accent, accenteeWidth:accenteeWidth, accentGlyph:accentGlyph) + height = accentee!.ascent - delta // This is always positive since delta <= height. + } + let accentPosition = CGPointMake(skew, height); let accentGlyphDisplay = MTGlyphDisplay(withGlpyh: accentGlyph, range: accent!.indexRange, font: styleFont) accentGlyphDisplay.ascent = glyphAscent; @@ -2518,13 +2792,53 @@ class MTTypesetter { let display = MTAccentDisplay(withAccent:accentGlyphDisplay, accentee:accentee, range:accent!.indexRange) display.width = accentee!.width; display.descent = accentee!.descent; - let ascent = accentee!.ascent - delta + glyphAscent; + + // Calculate total ascent based on positioning + // For arrows: height already includes spacing, so ascent = height + glyphAscent + // For regular accents: ascent = accentee.ascent - delta + glyphAscent (existing formula) + let ascent = height + glyphAscent; display.ascent = max(accentee!.ascent, ascent); display.position = currentPosition; return display; } - + + /// Determines if an accent can use Unicode composition for inline rendering. + /// Unicode combining characters only work correctly for single base characters. + /// Multi-character expressions and arrow accents need font-based rendering. + func canUseUnicodeComposition(_ accent: MTAccent) -> Bool { + // Check if innerList has exactly one simple character + guard let innerList = accent.innerList, + innerList.atoms.count == 1, + let firstAtom = innerList.atoms.first else { + return false + } + + // Only allow simple variable/number atoms + guard firstAtom.type == .variable || firstAtom.type == .number else { + return false + } + + // Check that the atom doesn't have subscripts/superscripts + guard firstAtom.subScript == nil && firstAtom.superScript == nil else { + return false + } + + // Exclude arrow accents - they need stretching from font glyphs + // These Unicode combining characters only apply to single preceding characters + let arrowAccents: Set = [ + "\u{20D6}", // overleftarrow + "\u{20D7}", // overrightarrow / vec + "\u{20E1}" // overleftrightarrow + ] + + if arrowAccents.contains(accent.nucleus) { + return false + } + + return true + } + // MARK: - Table let kBaseLineSkipMultiplier = CGFloat(1.2) // default base line stretch is 12 pt for 10pt font. diff --git a/Tests/SwiftMathTests/AccentSpacingComparisonTest.swift b/Tests/SwiftMathTests/AccentSpacingComparisonTest.swift new file mode 100644 index 0000000..11d9534 --- /dev/null +++ b/Tests/SwiftMathTests/AccentSpacingComparisonTest.swift @@ -0,0 +1,139 @@ +import XCTest +@testable import SwiftMath + +final class AccentSpacingComparisonTest: XCTestCase { + + var font: MTFont! + + override func setUp() { + super.setUp() + font = MTFontManager().termesFont(withSize: 20) + } + + func testCompareSpacing() throws { + // Test with same content for comparison + let content = "ABC" + + let widehatLatex = "\\widehat{\(content)}" + let overrightarrowLatex = "\\overrightarrow{\(content)}" + + let widehatMathList = MTMathListBuilder.build(fromString: widehatLatex) + let arrowMathList = MTMathListBuilder.build(fromString: overrightarrowLatex) + + let widehatDisplay = MTTypesetter.createLineForMathList(widehatMathList, font: font, style: .display) + let arrowDisplay = MTTypesetter.createLineForMathList(arrowMathList, font: font, style: .display) + + print("\n=== Spacing Comparison for '\(content)' ===\n") + + guard let widehatAccentDisp = widehatDisplay?.subDisplays.first as? MTAccentDisplay, + let widehatAccentee = widehatAccentDisp.accentee, + let widehatAccent = widehatAccentDisp.accent else { + XCTFail("Could not extract widehat display") + return + } + + print("\\widehat{\(content)}:") + print(" Accentee ascent: \(widehatAccentee.ascent)") + print(" Accent glyph ascent: \(widehatAccent.ascent)") + print(" Accent glyph descent: \(widehatAccent.descent)") + print(" Accent position.y: \(widehatAccent.position.y)") + print(" Display ascent: \(widehatAccentDisp.ascent)") + print(" Display descent: \(widehatAccentDisp.descent)") + print(" Total height: \(widehatAccentDisp.ascent + widehatAccentDisp.descent)") + + // Calculate the baseline gap + let widehatBaselineGap = widehatAccent.position.y - widehatAccentee.ascent + print(" Baseline gap (accent.y - accentee.ascent): \(widehatBaselineGap)") + + // Calculate the visual bounding box gap + // For glyphs with internal whitespace (minY > 0), we need to account for it + // The visual bottom of the glyph is at position.y + minY (not position.y - descent) + // We'll extract minY directly from the glyph's bounding rect + let widehatGlyphMinY: CGFloat + if let widehatGlyphDisp = widehatAccent as? MTGlyphDisplay, + let widehatGlyphOpt = widehatGlyphDisp.glyph { + var widehatGlyph = widehatGlyphOpt + var widehatBoundingRect = CGRect.zero + CTFontGetBoundingRectsForGlyphs(font.ctFont, .horizontal, &widehatGlyph, &widehatBoundingRect, 1) + widehatGlyphMinY = widehatBoundingRect.minY + } else { + widehatGlyphMinY = 0 + } + + let widehatAccentBottomEdge = widehatAccent.position.y + max(0, widehatGlyphMinY) + let widehatContentTopEdge = widehatAccentee.ascent + let widehatVisualGap = widehatAccentBottomEdge - widehatContentTopEdge + print(" Visual gap (bounding box): \(widehatVisualGap)") + print(" Content top edge (ascent): \(widehatContentTopEdge)") + print(" Accent visual bottom edge (y + minY): \(widehatAccentBottomEdge)") + print(" Accent glyph minY: \(widehatGlyphMinY)") + print() + + guard let arrowAccentDisp = arrowDisplay?.subDisplays.first as? MTAccentDisplay, + let arrowAccentee = arrowAccentDisp.accentee, + let arrowAccent = arrowAccentDisp.accent else { + XCTFail("Could not extract arrow display") + return + } + + print("\\overrightarrow{\(content)}:") + print(" Accentee ascent: \(arrowAccentee.ascent)") + print(" Accent glyph ascent: \(arrowAccent.ascent)") + print(" Accent glyph descent: \(arrowAccent.descent)") + print(" Accent position.y: \(arrowAccent.position.y)") + print(" Display ascent: \(arrowAccentDisp.ascent)") + print(" Display descent: \(arrowAccentDisp.descent)") + print(" Total height: \(arrowAccentDisp.ascent + arrowAccentDisp.descent)") + + // Calculate the baseline gap + let arrowBaselineGap = arrowAccent.position.y - arrowAccentee.ascent + print(" Baseline gap (accent.y - accentee.ascent): \(arrowBaselineGap)") + + // Calculate the visual bounding box gap + // Extract minY for the arrow glyph too + let arrowGlyphMinY: CGFloat + if let arrowGlyphDisp = arrowAccent as? MTGlyphDisplay, + let arrowGlyphOpt = arrowGlyphDisp.glyph { + var arrowGlyph = arrowGlyphOpt + var arrowBoundingRect = CGRect.zero + CTFontGetBoundingRectsForGlyphs(font.ctFont, .horizontal, &arrowGlyph, &arrowBoundingRect, 1) + arrowGlyphMinY = arrowBoundingRect.minY + } else { + arrowGlyphMinY = 0 + } + + let arrowAccentBottomEdge = arrowAccent.position.y + max(0, arrowGlyphMinY) + let arrowContentTopEdge = arrowAccentee.ascent + let arrowVisualGap = arrowAccentBottomEdge - arrowContentTopEdge + print(" Visual gap (bounding box): \(arrowVisualGap)") + print(" Content top edge (ascent): \(arrowContentTopEdge)") + print(" Accent visual bottom edge (y + minY): \(arrowAccentBottomEdge)") + print(" Accent glyph minY: \(arrowGlyphMinY)") + print() + + // Also check the math table value + if let mathTable = font.mathTable { + print("Font math table:") + print(" upperLimitGapMin: \(mathTable.upperLimitGapMin)") + print() + } + + // Compare the gaps + print("=== Comparison ===") + print("Baseline gaps:") + print(" Widehat: \(widehatBaselineGap)") + print(" Arrow: \(arrowBaselineGap)") + print(" Difference: \(abs(widehatBaselineGap - arrowBaselineGap))") + print() + print("Visual gaps (bounding box):") + print(" Widehat: \(widehatVisualGap)") + print(" Arrow: \(arrowVisualGap)") + print(" Difference: \(abs(widehatVisualGap - arrowVisualGap))") + print() + + // Baseline gaps will differ because we compensate for different minY values + // But visual gaps (which account for minY) should be approximately equal + XCTAssertEqual(widehatVisualGap, arrowVisualGap, accuracy: 0.5, + "Visual gaps should be approximately equal after minY compensation") + } +} diff --git a/Tests/SwiftMathTests/ArrowStretchingTest.swift b/Tests/SwiftMathTests/ArrowStretchingTest.swift new file mode 100644 index 0000000..6a6242a --- /dev/null +++ b/Tests/SwiftMathTests/ArrowStretchingTest.swift @@ -0,0 +1,153 @@ +import XCTest +@testable import SwiftMath + +final class ArrowStretchingTest: XCTestCase { + + var font: MTFont! + + override func setUp() { + super.setUp() + font = MTFontManager().termesFont(withSize: 20) + } + + func testVecSingleCharacter() throws { + // Test that \vec{v} produces an arrow (not a bar) + let mathList = MTMathList() + let vec = MTMathAtomFactory.accent(withName: "vec") + vec?.innerList = MTMathAtomFactory.mathListForCharacters("v") + mathList.add(vec) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + XCTAssertEqual(display.subDisplays.count, 1, "Should have 1 subdisplay") + let accentDisp = try XCTUnwrap(display.subDisplays[0] as? MTAccentDisplay) + + _ = try XCTUnwrap(accentDisp.accentee) + let accentGlyph = try XCTUnwrap(accentDisp.accent) + + // The arrow should have non-zero width + XCTAssertGreaterThan(accentGlyph.width, 0, "Arrow should have width > 0") + + // For single character, the arrow should be reasonably sized (not 0 like a bar) + // uni20D7.h1 has width ~12.14 + XCTAssertGreaterThan(accentGlyph.width, 10, "Arrow should be at least 10 points wide") + } + + func testVecMultipleCharacters() throws { + // Test that \vec{AB} uses small arrow (NOT stretchy like \overrightarrow{AB}) + let mathList = MTMathList() + let vec = MTMathAtomFactory.accent(withName: "vec") + vec?.innerList = MTMathAtomFactory.mathListForCharacters("AB") + mathList.add(vec) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + XCTAssertEqual(display.subDisplays.count, 1, "Should have 1 subdisplay") + let accentDisp = try XCTUnwrap(display.subDisplays[0] as? MTAccentDisplay) + + _ = try XCTUnwrap(accentDisp.accentee) + let accentGlyph = try XCTUnwrap(accentDisp.accent) + + // \vec should use small fixed arrow, NOT stretch to content width + // The arrow should be the small uni20D7.h1 variant (~12.14 wide) + XCTAssertLessThan(accentGlyph.width, 15, "\\vec should use small arrow, not stretch") + XCTAssertGreaterThan(accentGlyph.width, 10, "Arrow should be uni20D7.h1 variant") + } + + func testArrowStretchingForDA() throws { + // Test the reported issue: arrow should stretch to match "DA" width + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: "overrightarrow") + accent?.innerList = MTMathAtomFactory.mathListForCharacters("DA") + mathList.add(accent) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + XCTAssertEqual(display.subDisplays.count, 1, "Should have 1 subdisplay") + let accentDisp = try XCTUnwrap(display.subDisplays[0] as? MTAccentDisplay) + + let accentee = try XCTUnwrap(accentDisp.accentee) + let accentGlyph = try XCTUnwrap(accentDisp.accent) + + let ratio = accentGlyph.width / accentee.width + + // For proper rendering, the arrow should cover at least 90% of the content width + XCTAssertGreaterThan(ratio, 0.9, "Arrow should cover at least 90% of content width") + XCTAssertGreaterThan(accentee.width, 0, "Accentee should have width") + XCTAssertGreaterThan(accentGlyph.width, 0, "Arrow should have width") + } + + func testArrowStretchingComparison() throws { + // Compare arrow stretching for different content widths + let testCases = [ + ("A", "overrightarrow"), + ("DA", "overrightarrow"), + ("ABC", "overrightarrow"), + ("ABCD", "overrightarrow"), + ("velocity", "overleftrightarrow") + ] + + for (content, command) in testCases { + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: command) + accent?.innerList = MTMathAtomFactory.mathListForCharacters(content) + mathList.add(accent) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + let accentDisp = try XCTUnwrap(display.subDisplays[0] as? MTAccentDisplay) + let accentee = try XCTUnwrap(accentDisp.accentee) + let accentGlyph = try XCTUnwrap(accentDisp.accent) + + let ratio = accentGlyph.width / accentee.width + XCTAssertGreaterThan(ratio, 0.9, "\\\(command){\(content)} should have adequate arrow coverage") + } + } + + func testRegularAccentVsArrowAccent() throws { + // Compare how regular accents (bar, hat) behave vs arrow accents + + // Test \bar{DA} - regular accent + let barList = MTMathList() + let barAccent = MTMathAtomFactory.accent(withName: "bar") + barAccent?.innerList = MTMathAtomFactory.mathListForCharacters("DA") + barList.add(barAccent) + + let barDisplay = try XCTUnwrap( + MTTypesetter.createLineForMathList(barList, font: self.font, style: .display) + ) + + let barAccentDisp = try XCTUnwrap(barDisplay.subDisplays[0] as? MTAccentDisplay) + _ = try XCTUnwrap(barAccentDisp.accentee) + _ = try XCTUnwrap(barAccentDisp.accent) + + // Test \overrightarrow{DA} - arrow accent + let arrowList = MTMathList() + let arrowAccent = MTMathAtomFactory.accent(withName: "overrightarrow") + arrowAccent?.innerList = MTMathAtomFactory.mathListForCharacters("DA") + arrowList.add(arrowAccent) + + let arrowDisplay = try XCTUnwrap( + MTTypesetter.createLineForMathList(arrowList, font: self.font, style: .display) + ) + + let arrowAccentDisp = try XCTUnwrap(arrowDisplay.subDisplays[0] as? MTAccentDisplay) + let arrowAccentee = try XCTUnwrap(arrowAccentDisp.accentee) + let arrowGlyph = try XCTUnwrap(arrowAccentDisp.accent) + + let arrowRatio = arrowGlyph.width / arrowAccentee.width + + // Regular accents (bar) can be narrower than content + // Arrow accents should stretch to match content width + XCTAssertGreaterThan(arrowRatio, 0.9, "Arrow accents should stretch to match content") + } + +} diff --git a/Tests/SwiftMathTests/DebugOverlapTest.swift b/Tests/SwiftMathTests/DebugOverlapTest.swift new file mode 100644 index 0000000..8363398 --- /dev/null +++ b/Tests/SwiftMathTests/DebugOverlapTest.swift @@ -0,0 +1,47 @@ +// +// Test file to understand the overlap issue +// + +import XCTest +@testable import SwiftMath + +class DebugOverlapTest: XCTestCase { + func testDebugOverlap() { + let label = MTMathUILabel() + label.latex = "y=x^{2}+3x+4x+9x+8x+8+\\sqrt{\\dfrac{3x^{2}+5x}{\\cos x}}" + label.font = MTFontManager.fontManager.defaultFont + label.preferredMaxLayoutWidth = 200 + + let size = label.intrinsicContentSize + label.frame = CGRect(origin: .zero, size: size) + + #if os(macOS) + label.layout() + #else + label.layoutSubviews() + #endif + + if let displayList = label.displayList { + print("\n=== Display List Analysis ===") + print("Total displays: \(displayList.subDisplays.count)") + + for (index, display) in displayList.subDisplays.enumerated() { + let minY = display.position.y - display.ascent + let maxY = display.position.y + display.descent + print("\nDisplay \(index):") + print(" Type: \(type(of: display))") + print(" Position: (\(display.position.x), \(display.position.y))") + print(" Ascent: \(display.ascent), Descent: \(display.descent)") + print(" Width: \(display.width)") + print(" Y range: [\(minY), \(maxY)]") + + if index > 0 { + let prevDisplay = displayList.subDisplays[index - 1] + let prevMaxY = prevDisplay.position.y + prevDisplay.descent + let gap = prevMaxY - maxY + print(" Gap from previous: \(gap) (negative = overlap)") + } + } + } + } +} diff --git a/Tests/SwiftMathTests/GlyphBoundsTest.swift b/Tests/SwiftMathTests/GlyphBoundsTest.swift new file mode 100644 index 0000000..7ed3b03 --- /dev/null +++ b/Tests/SwiftMathTests/GlyphBoundsTest.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import SwiftMath +import CoreText +import CoreGraphics + +final class GlyphBoundsTest: XCTestCase { + + var font: MTFont! + + override func setUp() { + super.setUp() + font = MTFontManager().termesFont(withSize: 20) + } + + func testGlyphBounds() throws { + // Get the actual glyph objects + let circumflexGlyph = font.get(glyphWithName: "circumflex") + let arrowGlyph = font.get(glyphWithName: "arrowright") // rightarrow for stretchy overrightarrow + + print("\n=== Detailed Glyph Bounds Analysis ===\n") + + // Get bounding rects for both glyphs + var circumflexRect = CGRect.zero + var circumflexGlyphCopy = circumflexGlyph + CTFontGetBoundingRectsForGlyphs(font.ctFont, .horizontal, &circumflexGlyphCopy, &circumflexRect, 1) + + var arrowRect = CGRect.zero + var arrowGlyphCopy = arrowGlyph + CTFontGetBoundingRectsForGlyphs(font.ctFont, .horizontal, &arrowGlyphCopy, &arrowRect, 1) + + print("Circumflex glyph:") + print(" Bounding rect: \(circumflexRect)") + print(" minY (bottom): \(circumflexRect.minY)") + print(" maxY (top): \(circumflexRect.maxY)") + print(" height: \(circumflexRect.height)") + print(" Calculated ascent: \(circumflexRect.maxY)") + print(" Calculated descent: \(-circumflexRect.minY)") + print() + + print("Arrow glyph (arrowright):") + print(" Bounding rect: \(arrowRect)") + print(" minY (bottom): \(arrowRect.minY)") + print(" maxY (top): \(arrowRect.maxY)") + print(" height: \(arrowRect.height)") + print(" Calculated ascent: \(arrowRect.maxY)") + print(" Calculated descent: \(-arrowRect.minY)") + print() + + // Check if circumflex has significant space at the bottom + if -circumflexRect.minY < 1.0 && circumflexRect.maxY > 10.0 { + print("NOTE: Circumflex glyph sits on baseline with minimal descent") + print(" The visual 'peak' of the hat is at the top of the bounding box") + print(" Bottom whitespace in glyph: \(-circumflexRect.minY)") + } + + if -arrowRect.minY < 1.0 && arrowRect.maxY < 10.0 { + print("NOTE: Arrow glyph sits on baseline with minimal descent") + print(" The arrow is more compact vertically") + print(" Bottom whitespace in glyph: \(-arrowRect.minY)") + } + } +} diff --git a/Tests/SwiftMathTests/MTMathListBuilderTests.swift b/Tests/SwiftMathTests/MTMathListBuilderTests.swift index 576436f..9096a48 100644 --- a/Tests/SwiftMathTests/MTMathListBuilderTests.swift +++ b/Tests/SwiftMathTests/MTMathListBuilderTests.swift @@ -1356,6 +1356,33 @@ final class MTMathListBuilderTests: XCTestCase { XCTAssertEqual(latex, "\\mathrm{x\\ y}", desc) } + func testOperatorName() throws { + let str = "\\operatorname{dim}"; + let list = MTMathListBuilder.build(fromString: str)! + let desc = "Error for string:\(str)" + + XCTAssertNotNil(list, desc) + XCTAssertEqual((list.atoms.count), 3, desc) + var atom = list.atoms[0]; + XCTAssertEqual(atom.type, .variable, desc) + XCTAssertEqual(atom.nucleus, "d", desc) + XCTAssertEqual(atom.fontStyle, .roman, desc); + + atom = list.atoms[1]; + XCTAssertEqual(atom.type, .variable, desc) + XCTAssertEqual(atom.nucleus, "i", desc) + XCTAssertEqual(atom.fontStyle, .roman, desc); + + atom = list.atoms[2]; + XCTAssertEqual(atom.type, .variable, desc) + XCTAssertEqual(atom.nucleus, "m", desc) + XCTAssertEqual(atom.fontStyle, .roman, desc); + + // convert it back to latex + let latex = MTMathListBuilder.mathListToString(list) + XCTAssertEqual(latex, "\\mathrm{dim}", desc) + } + func testLimits() throws { // Int with no limits (default) var str = "\\int"; @@ -1972,6 +1999,180 @@ final class MTMathListBuilderTests: XCTestCase { } } + // MARK: - Vector Arrow Command Tests + + func testVectorArrowCommands() throws { + let commands = ["vec", "overleftarrow", "overrightarrow", "overleftrightarrow"] + + for cmd in commands { + let str = "\\\(cmd){x}" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(cmd)") + XCTAssertNil(error, "Should not error on \\\(cmd): \(error?.localizedDescription ?? "")") + + // Should create accent atom + XCTAssertEqual(unwrappedList.atoms.count, 1, "For \\\(cmd)") + let accent = try XCTUnwrap(unwrappedList.atoms[0] as? MTAccent, "For \\\(cmd)") + XCTAssertEqual(accent.type, .accent) + + // Should have innerList with variable 'x' + let innerList = try XCTUnwrap(accent.innerList) + XCTAssertEqual(innerList.atoms.count, 1) + let innerAtom = innerList.atoms[0] + XCTAssertEqual(innerAtom.type, .variable) + XCTAssertEqual(innerAtom.nucleus, "x") + } + } + + func testVectorArrowUnicodeValues() throws { + let expectedUnicode: [String: String] = [ + "vec": "\u{20D7}", + "overleftarrow": "\u{20D6}", + "overrightarrow": "\u{20D7}", + "overleftrightarrow": "\u{20E1}" + ] + + for (cmd, expectedValue) in expectedUnicode { + let str = "\\\(cmd){a}" + let list = try XCTUnwrap(MTMathListBuilder.build(fromString: str)) + let accent = try XCTUnwrap(list.atoms[0] as? MTAccent) + + XCTAssertEqual(accent.nucleus, expectedValue, + "\\\(cmd) should map to Unicode \(expectedValue)") + } + } + + func testVectorArrowMultiCharacter() throws { + let testCases = [ + ("vec", "AB"), + ("overleftarrow", "xyz"), + ("overrightarrow", "ABC"), + ("overleftrightarrow", "velocity") + ] + + for (cmd, content) in testCases { + let str = "\\\(cmd){\(content)}" + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: str, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \\\(cmd){\(content)}") + XCTAssertNil(error, "Should not error: \(error?.localizedDescription ?? "")") + + let accent = try XCTUnwrap(unwrappedList.atoms[0] as? MTAccent) + let innerList = try XCTUnwrap(accent.innerList) + XCTAssertEqual(innerList.atoms.count, content.count, + "Should parse all \(content.count) characters") + } + } + + func testVectorArrowLatexRoundTrip() throws { + let testCases = [ + ("\\vec{x}", "\\vec{x}"), + ("\\overleftarrow{AB}", "\\overleftarrow{AB}"), + // Note: \overrightarrow maps to same Unicode as \vec, so it converts to \vec + ("\\overrightarrow{v}", "\\vec{v}"), + ("\\overleftrightarrow{AC}", "\\overleftrightarrow{AC}") + ] + + for (input, expected) in testCases { + let list = try XCTUnwrap(MTMathListBuilder.build(fromString: input)) + let output = MTMathListBuilder.mathListToString(list) + XCTAssertEqual(output, expected, + "LaTeX round-trip failed for \(input)") + } + } + + func testVectorArrowWithScripts() throws { + let testCases = [ + "\\vec{v}_0", + "\\overrightarrow{AB}^2", + "\\overleftarrow{F}_x^y", + "\\overleftrightarrow{PQ}_{parallel}" + ] + + for input in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: input, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \(input)") + XCTAssertNil(error, "Should not error: \(error?.localizedDescription ?? "")") + + let accent = try XCTUnwrap(unwrappedList.atoms[0] as? MTAccent) + + // Check for scripts + let hasSubscript = accent.subScript != nil + let hasSuperscript = accent.superScript != nil + XCTAssertTrue(hasSubscript || hasSuperscript, + "Should have at least one script for \(input)") + } + } + + func testVectorArrowInExpressions() throws { + let testCases = [ + "$\\vec{a} \\cdot \\vec{b}$", // Dot product + "$\\overrightarrow{AB} + \\overrightarrow{BC}$", // Vector addition + "$\\overleftarrow{F} = m\\vec{a}$", // Newton's law + "$\\overleftrightarrow{AC} \\parallel \\overleftrightarrow{BD}$" // Parallel lines + ] + + for input in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: input, error: &error) + + XCTAssertNotNil(list, "Should parse: \(input)") + XCTAssertNil(error, "Should not error: \(error?.localizedDescription ?? "")") + + // Verify at least one accent exists + var foundAccent = false + for atom in list!.atoms { + if atom.type == .accent { + foundAccent = true + break + } + } + XCTAssertTrue(foundAccent, "Should have accent in: \(input)") + } + } + + func testMultiCharacterArrowAccentParsing() throws { + // Test the reported bug: \overrightarrow{DA} should parse correctly + let testCases = [ + ("\\overrightarrow{DA}", "DA", "\u{20D7}"), + ("\\overleftarrow{AB}", "AB", "\u{20D6}"), + ("\\overleftrightarrow{XY}", "XY", "\u{20E1}"), + ("\\vec{AB}", "AB", "\u{20D7}") + ] + + for (latex, expectedContent, expectedUnicode) in testCases { + var error: NSError? = nil + let list = MTMathListBuilder.build(fromString: latex, error: &error) + + let unwrappedList = try XCTUnwrap(list, "Should parse \(latex)") + XCTAssertNil(error, "Should not error: \(error?.localizedDescription ?? "")") + + // Should create single accent atom + XCTAssertEqual(unwrappedList.atoms.count, 1, "Should have 1 atom for \(latex)") + let accent = try XCTUnwrap(unwrappedList.atoms[0] as? MTAccent, "Should be MTAccent for \(latex)") + + // Check accent unicode value + XCTAssertEqual(accent.nucleus, expectedUnicode, "\(latex) should have correct Unicode") + + // Check innerList contains all characters + let innerList = try XCTUnwrap(accent.innerList, "\(latex) should have innerList") + XCTAssertEqual(innerList.atoms.count, expectedContent.count, + "\(latex) should have \(expectedContent.count) characters in innerList") + + // Verify each character + for (i, expectedChar) in expectedContent.enumerated() { + let atom = innerList.atoms[i] + XCTAssertEqual(atom.nucleus, String(expectedChar), + "\(latex) character \(i) should be \(expectedChar)") + } + } + } + func testDelimiterPairs() throws { let delimiterPairs = [ ("langle", "rangle"), diff --git a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift index 33186c6..b3394c6 100644 --- a/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift +++ b/Tests/SwiftMathTests/MTMathUILabelLineWrappingTests.swift @@ -192,6 +192,44 @@ class MTMathUILabelLineWrappingTests: XCTestCase { XCTAssertNil(label.error, "Should have no rendering error") } + func testVectorArrowsWithLineWrapping() { + let label = MTMathUILabel() + label.fontSize = 20 + #if os(macOS) + label.textColor = NSColor.black + #else + label.textColor = UIColor.black + #endif + label.textAlignment = .left + + // Test each arrow command + let testCases = [ + "\\vec{v} + \\vec{u}", + "\\overrightarrow{AB} + \\overrightarrow{CD}", + "\\overleftarrow{F_x} + \\overleftarrow{F_y}", + "\\overleftrightarrow{PQ} \\parallel \\overleftrightarrow{RS}" + ] + + for latex in testCases { + label.latex = "\\(\(latex)\\)" + + // Get size and verify layout + let size = label.intrinsicContentSize + label.frame = CGRect(origin: .zero, size: size) + #if os(macOS) + label.layout() + #else + label.layoutSubviews() + #endif + + // Verify label has content and no errors + XCTAssertGreaterThan(size.width, 0, "Should have width: \(latex)") + XCTAssertGreaterThan(size.height, 0, "Should have height: \(latex)") + XCTAssertNotNil(label.displayList, "Display list should be created for: \(latex)") + XCTAssertNil(label.error, "Should have no rendering error for: \(latex)") + } + } + func testUnicodeWordBreaking_EquivautCase() { // Specific test for the reported issue: "équivaut" should not break at "é" let label = MTMathUILabel() diff --git a/Tests/SwiftMathTests/MTTypesetterTests.swift b/Tests/SwiftMathTests/MTTypesetterTests.swift index 5052b70..0e6478b 100755 --- a/Tests/SwiftMathTests/MTTypesetterTests.swift +++ b/Tests/SwiftMathTests/MTTypesetterTests.swift @@ -1747,6 +1747,189 @@ final class MTTypesetterTests: XCTestCase { XCTAssertEqual(display.width, 44.86, accuracy: 0.01) } + // MARK: - Vector Arrow Rendering Tests + + func testVectorArrowRendering() throws { + let commands = ["vec", "overleftarrow", "overrightarrow", "overleftrightarrow"] + + for cmd in commands { + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: cmd) + let inner = MTMathList() + inner.add(MTMathAtomFactory.atom(forCharacter: "v")) + accent?.innerList = inner + mathList.add(accent) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + // Should have accent display + XCTAssertEqual(display.subDisplays.count, 1) + let accentDisp = try XCTUnwrap(display.subDisplays[0] as? MTAccentDisplay) + + // Should have accentee and accent glyph + XCTAssertNotNil(accentDisp.accentee, "\\\(cmd) should have accentee") + XCTAssertNotNil(accentDisp.accent, "\\\(cmd) should have accent glyph") + + // Accent should be positioned such that its visual bottom is at or above accentee + // With minY compensation, position.y can be negative, but visual bottom (position.y + minY) should be >= 0 + let accentVisualBottom: CGFloat + if let glyphDisp = accentDisp.accent as? MTGlyphDisplay, + let glyph = glyphDisp.glyph { + var glyphCopy = glyph + var boundingRect = CGRect.zero + CTFontGetBoundingRectsForGlyphs(self.font.ctFont, .horizontal, &glyphCopy, &boundingRect, 1) + accentVisualBottom = accentDisp.accent!.position.y + max(0, boundingRect.minY) + } else { + accentVisualBottom = accentDisp.accent!.position.y + } + XCTAssertGreaterThanOrEqual(accentVisualBottom, 0, + "\\\(cmd) accent visual bottom should be at or above accentee") + } + } + + func testWideVectorArrows() throws { + let commands = ["overleftarrow", "overrightarrow", "overleftrightarrow"] + + for cmd in commands { + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: cmd) + accent?.innerList = MTMathAtomFactory.mathListForCharacters("ABCDEF") + mathList.add(accent) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + let accentDisp = try XCTUnwrap(display.subDisplays[0] as? MTAccentDisplay) + let accentGlyph = try XCTUnwrap(accentDisp.accent) + let accentee = try XCTUnwrap(accentDisp.accentee) + + // Verify that the display is created correctly with both accent and accentee + XCTAssertGreaterThan(accentGlyph.width, 0, "\\\(cmd) accent should have width") + XCTAssertGreaterThan(accentee.width, 0, "\\\(cmd) accentee should have width") + + // Note: Arrow stretching behavior depends on font glyph variants available + // The implementation uses the font's Math table to select variants + // Some fonts may not stretch as much as others + } + } + + func testVectorArrowDimensions() throws { + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: "overrightarrow") + let inner = MTMathList() + inner.add(MTMathAtomFactory.atom(forCharacter: "x")) + accent?.innerList = inner + mathList.add(accent) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + // Should have positive dimensions + XCTAssertGreaterThan(display.ascent, 0, "Should have positive ascent") + XCTAssertGreaterThanOrEqual(display.descent, 0, "Should have non-negative descent") + XCTAssertGreaterThan(display.width, 0, "Should have positive width") + + // Ascent should be larger than normal 'x' due to arrow above + let normalX = MTTypesetter.createLineForMathList( + MTMathAtomFactory.mathListForCharacters("x"), + font: self.font, + style: .display + ) + XCTAssertGreaterThan(display.ascent, normalX!.ascent, + "Accent should increase ascent") + } + + func testMultiCharacterArrowAccents() throws { + // Test that multi-character arrow accents render correctly + // This is the reported bug: arrow should be above both characters, not after the last one + let testCases = [ + ("overrightarrow", "DA"), + ("overleftarrow", "AB"), + ("overleftrightarrow", "XY"), + ("vec", "AB") // vec with multi-char should also work + ] + + for (cmd, content) in testCases { + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: cmd) + accent?.innerList = MTMathAtomFactory.mathListForCharacters(content) + mathList.add(accent) + + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display) + ) + + // Should create MTAccentDisplay (not inline text) + XCTAssertEqual(display.subDisplays.count, 1, "\\\(cmd){\(content)}") + let accentDisp = try XCTUnwrap(display.subDisplays[0] as? MTAccentDisplay, + "\\\(cmd){\(content)} should create MTAccentDisplay") + + // Should have both accent and accentee + XCTAssertNotNil(accentDisp.accent, "\\\(cmd){\(content)} should have accent glyph") + XCTAssertNotNil(accentDisp.accentee, "\\\(cmd){\(content)} should have accentee") + + // The accentee should contain both characters + let accentee = try XCTUnwrap(accentDisp.accentee) + XCTAssertGreaterThan(accentee.width, 0, "\\\(cmd){\(content)} accentee should have width") + } + } + + func testSingleCharacterAccentsWithLineWrapping() throws { + // Test that single-character accents still work with Unicode composition when line wrapping + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: "bar") + accent?.innerList = MTMathAtomFactory.mathListForCharacters("x") + mathList.add(accent) + + // Create with line wrapping enabled + let maxWidth: CGFloat = 200 + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + ) + + // Should render successfully + XCTAssertGreaterThan(display.width, 0, "Should have width") + XCTAssertGreaterThan(display.ascent, 0, "Should have ascent") + } + + func testMultiCharacterAccentsWithLineWrapping() throws { + // Test that multi-character arrow accents work correctly with line wrapping enabled + let mathList = MTMathList() + let accent = MTMathAtomFactory.accent(withName: "overrightarrow") + accent?.innerList = MTMathAtomFactory.mathListForCharacters("DA") + mathList.add(accent) + + // Create with line wrapping enabled + let maxWidth: CGFloat = 200 + let display = try XCTUnwrap( + MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth) + ) + + // Should render successfully with MTAccentDisplay + XCTAssertGreaterThan(display.width, 0, "Should have width") + + // Should use MTAccentDisplay, not inline Unicode composition + // This verifies the fix: multi-char accents use font-based rendering + var foundAccentDisplay = false + func checkSubDisplays(_ disp: MTDisplay) { + if disp is MTAccentDisplay { + foundAccentDisplay = true + } + if let mathListDisplay = disp as? MTMathListDisplay { + for sub in mathListDisplay.subDisplays { + checkSubDisplays(sub) + } + } + } + checkSubDisplays(display) + + XCTAssertTrue(foundAccentDisplay, "Should use MTAccentDisplay for multi-character arrow accent") + } + // MARK: - Interatom Line Breaking Tests func testInteratomLineBreaking_SimpleEquation() throws { diff --git a/Tests/SwiftMathTests/MathDelimiterTests.swift b/Tests/SwiftMathTests/MathDelimiterTests.swift new file mode 100644 index 0000000..25e715d --- /dev/null +++ b/Tests/SwiftMathTests/MathDelimiterTests.swift @@ -0,0 +1,227 @@ +import XCTest +@testable import SwiftMath + +final class MathDelimiterTests: XCTestCase { + + // MARK: - Display Math Delimiters + + func testDisplayMathBrackets() throws { + // Test \[...\] delimiter for display math + let latex = "\\[x^2 + y^2 = z^2\\]" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "MathList should be parsed successfully") + XCTAssertEqual(style, .display, "\\[...\\] should produce display style") + + // Verify the content was parsed correctly (without the delimiters) + // Atoms: x (with ^2), +, y (with ^2), =, z (with ^2) = 5 atoms + XCTAssertEqual(mathList?.atoms.count, 5, "Should have 5 atoms: x^2 + y^2 = z^2") + } + + func testDoubleDollarDisplayMath() throws { + // Test $$...$$ delimiter for display math + let latex = "$$\\sum_{i=1}^{n} i$$" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "MathList should be parsed successfully") + XCTAssertEqual(style, .display, "$$...$$ should produce display style") + + // Verify content was parsed + XCTAssertGreaterThan(mathList?.atoms.count ?? 0, 0, "MathList should contain atoms") + } + + // MARK: - Inline Math Delimiters + + func testInlineMathParentheses() throws { + // Test \(...\) delimiter for inline math + let latex = "\\(a + b\\)" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "MathList should be parsed successfully") + XCTAssertEqual(style, .text, "\\(...\\) should produce text/inline style") + + // Verify content - includes style atom + XCTAssertGreaterThanOrEqual(mathList?.atoms.count ?? 0, 3, "Should have at least 3 atoms: a + b") + } + + func testSingleDollarInlineMath() throws { + // Test $...$ delimiter for inline math + let latex = "$\\frac{1}{2}$" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "MathList should be parsed successfully") + XCTAssertEqual(style, .text, "$...$ should produce text/inline style") + + // Verify fraction was parsed (may include style atom) + XCTAssertGreaterThanOrEqual(mathList?.atoms.count ?? 0, 1, "Should have at least 1 atom") + + // Find the fraction atom (might not be first due to style atoms) + let hasFraction = mathList?.atoms.contains(where: { $0.type == .fraction }) ?? false + XCTAssertTrue(hasFraction, "Should contain a fraction atom") + } + + // MARK: - No Delimiters (Default Behavior) + + func testNoDelimitersDefaultsToDisplay() throws { + // Test that content without delimiters defaults to display mode + let latex = "x + y = z" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "MathList should be parsed successfully") + XCTAssertEqual(style, .display, "Content without delimiters should default to display style") + + // Verify content + XCTAssertEqual(mathList?.atoms.count, 5, "Should have 5 atoms: x + y = z") + } + + // MARK: - Edge Cases + + func testEmptyBrackets() throws { + // Test empty \[...\] + // Note: \[\] is exactly 4 characters, so delimiter detection requires > 4 + // Empty delimiters are not detected as display math delimiters + let latex = "\\[ \\]" // Add space to make it > 4 characters + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "Empty display math with space should parse") + XCTAssertEqual(style, .display, "\\[ \\] should produce display style") + XCTAssertEqual(mathList?.atoms.count, 0, "Empty delimiters should produce empty list") + } + + func testEmptyDoubleDollar() throws { + // Test empty $$...$$ + let latex = "$$$$" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "Empty display math should still parse") + XCTAssertEqual(style, .display, "Empty $$$$ should produce display style") + XCTAssertEqual(mathList?.atoms.count, 0, "Empty delimiters should produce empty list") + } + + func testWhitespaceInBrackets() throws { + // Test \[...\] with whitespace + let latex = "\\[ x + y \\]" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "Whitespace should not affect parsing") + XCTAssertEqual(style, .display, "\\[...\\] with whitespace should produce display style") + XCTAssertEqual(mathList?.atoms.count, 3, "Should have 3 atoms: x + y") + } + + func testNestedBracesInDisplayMath() throws { + // Test \[...\] with nested braces + let latex = "\\[\\frac{a}{b}\\]" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "Nested structures should parse correctly") + XCTAssertEqual(style, .display, "\\[...\\] should produce display style") + XCTAssertEqual(mathList?.atoms.first?.type, .fraction, "Should contain a fraction") + } + + // MARK: - Complex Expressions + + func testComplexDisplayExpression() throws { + // Test a complex display math expression + let latex = "\\[\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}\\]" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "Complex expression should parse") + XCTAssertEqual(style, .display, "\\[...\\] should produce display style") + XCTAssertGreaterThan(mathList?.atoms.count ?? 0, 5, "Should have multiple atoms") + } + + func testComplexInlineExpression() throws { + // Test a complex inline math expression + let latex = "$\\sum_{i=1}^{n} x_i$" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + XCTAssertNotNil(mathList, "Complex inline expression should parse") + XCTAssertEqual(style, .text, "$...$ should produce text/inline style") + XCTAssertGreaterThan(mathList?.atoms.count ?? 0, 0, "Should have atoms") + } + + // MARK: - Error Handling + + func testInvalidLatexWithBrackets() throws { + // Test \[...\] with invalid LaTeX + let latex = "\\[\\invalidcommand\\]" + var error: NSError? + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex, error: &error) + + XCTAssertNil(mathList, "Invalid LaTeX should return nil") + XCTAssertNotNil(error, "Should return an error") + XCTAssertEqual(style, .display, "Style should still be detected even with error") + } + + func testMismatchedDelimiters() throws { + // Test mismatched delimiters - should not be recognized as delimited + let latex = "\\[x + y\\)" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + // The string doesn't match any delimiter pattern, so it's treated as raw content + // This should parse the raw string including the backslash-bracket + XCTAssertEqual(style, .display, "Mismatched delimiters default to display mode") + } + + // MARK: - Backward Compatibility + + func testBackwardCompatibilityWithOldAPI() throws { + // Ensure old API still works + let latex = "x + y" + let mathList = MTMathListBuilder.build(fromString: latex) + + XCTAssertNotNil(mathList, "Old API should still work") + XCTAssertEqual(mathList?.atoms.count, 3, "Should parse correctly") + } + + func testBackwardCompatibilityWithError() throws { + // Ensure old error API still works + let latex = "\\invalidcommand" + var error: NSError? + let mathList = MTMathListBuilder.build(fromString: latex, error: &error) + + XCTAssertNil(mathList, "Invalid LaTeX should return nil") + XCTAssertNotNil(error, "Should return an error") + } + + // MARK: - Multiple Delimiter Types + + func testAllDisplayDelimiters() throws { + // Test all display delimiter types produce display style + let testCases = [ + "\\[x^2\\]", + "$$x^2$$" + ] + + for latex in testCases { + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + XCTAssertNotNil(mathList, "Display math \(latex) should parse") + XCTAssertEqual(style, .display, "\(latex) should produce display style") + } + } + + func testAllInlineDelimiters() throws { + // Test all inline delimiter types produce text style + let testCases = [ + "\\(x^2\\)", + "$x^2$" + ] + + for latex in testCases { + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + XCTAssertNotNil(mathList, "Inline math \(latex) should parse") + XCTAssertEqual(style, .text, "\(latex) should produce text/inline style") + } + } + + // MARK: - Environment Testing + + func testEnvironmentDefaultsToDisplay() throws { + // Test that \begin{...}\end{...} environments default to display mode + let latex = "\\begin{align}x &= y\\end{align}" + let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: latex) + + // Note: This might fail depending on environment support in the codebase + XCTAssertEqual(style, .display, "Environments should default to display style") + } +} diff --git a/Tests/SwiftMathTests/WidehatGlyphTest.swift b/Tests/SwiftMathTests/WidehatGlyphTest.swift new file mode 100644 index 0000000..815fd03 --- /dev/null +++ b/Tests/SwiftMathTests/WidehatGlyphTest.swift @@ -0,0 +1,139 @@ +import XCTest +@testable import SwiftMath + +final class WidehatGlyphTest: XCTestCase { + + var font: MTFont! + + override func setUp() { + super.setUp() + font = MTFontManager().termesFont(withSize: 20) + } + + func testWidehatGlyphAvailability() throws { + // Test what glyphs are available for widehat (circumflex accent) + print("\n=== Widehat Glyph Analysis ===") + + let circumflexChar = "\u{0302}" // COMBINING CIRCUMFLEX ACCENT + let baseGlyph = font.get(glyphWithName: circumflexChar) + let glyphName = font.get(nameForGlyph: baseGlyph) + + print("Base circumflex character: U+0302") + print(" Glyph ID: \(baseGlyph)") + print(" Glyph name: \(glyphName)") + + // Check for horizontal variants + if let mathTable = font.mathTable { + let variants = mathTable.getHorizontalVariantsForGlyph(baseGlyph) + print(" Found \(variants.count) horizontal variant(s)") + + for (index, variantNum) in variants.enumerated() { + guard let variantNum = variantNum else { continue } + let variantGlyph = CGGlyph(variantNum.uint16Value) + let variantName = font.get(nameForGlyph: variantGlyph) + + var glyph = variantGlyph + var advances = CGSize.zero + CTFontGetAdvancesForGlyphs(font.ctFont, .horizontal, &glyph, &advances, 1) + + print(" [\(index)] \(variantName): width = \(String(format: "%.2f", advances.width))") + } + } + + // Try named glyphs + print("\nNamed glyph lookup:") + let namedGlyphs = [ + "uni0302", + "circumflex", + "asciicircum" + ] + + for name in namedGlyphs { + let glyph = font.get(glyphWithName: name) + if glyph != 0 { + let actualName = font.get(nameForGlyph: glyph) + print(" \(name) -> \(actualName) (glyph \(glyph))") + } else { + print(" \(name) -> NOT FOUND") + } + } + } + + func testWidetildeGlyphAvailability() throws { + // Test what glyphs are available for widetilde + print("\n=== Widetilde Glyph Analysis ===") + + let tildeChar = "\u{0303}" // COMBINING TILDE + let baseGlyph = font.get(glyphWithName: tildeChar) + let glyphName = font.get(nameForGlyph: baseGlyph) + + print("Base tilde character: U+0303") + print(" Glyph ID: \(baseGlyph)") + print(" Glyph name: \(glyphName)") + + // Check for horizontal variants + if let mathTable = font.mathTable { + let variants = mathTable.getHorizontalVariantsForGlyph(baseGlyph) + print(" Found \(variants.count) horizontal variant(s)") + + for (index, variantNum) in variants.enumerated() { + guard let variantNum = variantNum else { continue } + let variantGlyph = CGGlyph(variantNum.uint16Value) + let variantName = font.get(nameForGlyph: variantGlyph) + + var glyph = variantGlyph + var advances = CGSize.zero + CTFontGetAdvancesForGlyphs(font.ctFont, .horizontal, &glyph, &advances, 1) + + print(" [\(index)] \(variantName): width = \(String(format: "%.2f", advances.width))") + } + } + + // Try named glyphs + print("\nNamed glyph lookup:") + let namedGlyphs = [ + "uni0303", + "tilde", + "asciitilde" + ] + + for name in namedGlyphs { + let glyph = font.get(glyphWithName: name) + if glyph != 0 { + let actualName = font.get(nameForGlyph: glyph) + print(" \(name) -> \(actualName) (glyph \(glyph))") + } else { + print(" \(name) -> NOT FOUND") + } + } + } + + func testCurrentWidehatBehavior() throws { + // Test current behavior of \widehat vs \hat + print("\n=== Current Widehat Behavior ===") + + let testCases = [ + ("\\hat{x}", "Single char hat"), + ("\\widehat{x}", "Single char widehat"), + ("\\hat{ABC}", "Multi-char hat"), + ("\\widehat{ABC}", "Multi-char widehat") + ] + + for (latex, description) in testCases { + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + if let display = display, + let accentDisp = display.subDisplays.first as? MTAccentDisplay, + let accentee = accentDisp.accentee, + let accent = accentDisp.accent { + + let coverage = accent.width / accentee.width * 100 + print("\n\(description): \(latex)") + print(" Content width: \(String(format: "%.2f", accentee.width))") + print(" Accent width: \(String(format: "%.2f", accent.width))") + print(" Coverage: \(String(format: "%.1f", coverage))%") + } + } + } +} diff --git a/Tests/SwiftMathTests/WidehatTests.swift b/Tests/SwiftMathTests/WidehatTests.swift new file mode 100644 index 0000000..000183e --- /dev/null +++ b/Tests/SwiftMathTests/WidehatTests.swift @@ -0,0 +1,274 @@ +import XCTest +@testable import SwiftMath + +final class WidehatTests: XCTestCase { + + var font: MTFont! + + override func setUp() { + super.setUp() + font = MTFontManager().termesFont(withSize: 20) + } + + // MARK: - Basic Functionality Tests + + func testWidehatVsHat() throws { + // Test that \widehat and \hat produce different results + let hatLatex = "\\hat{ABC}" + let widehatLatex = "\\widehat{ABC}" + + let hatMathList = MTMathListBuilder.build(fromString: hatLatex) + let widehatMathList = MTMathListBuilder.build(fromString: widehatLatex) + + let hatDisplay = MTTypesetter.createLineForMathList(hatMathList, font: font, style: .display) + let widehatDisplay = MTTypesetter.createLineForMathList(widehatMathList, font: font, style: .display) + + XCTAssertNotNil(hatDisplay, "\\hat should render") + XCTAssertNotNil(widehatDisplay, "\\widehat should render") + + // Get the accent displays + guard let hatAccentDisp = hatDisplay?.subDisplays.first as? MTAccentDisplay, + let widehatAccentDisp = widehatDisplay?.subDisplays.first as? MTAccentDisplay, + let hatAccent = hatAccentDisp.accent, + let widehatAccent = widehatAccentDisp.accent else { + XCTFail("Could not extract accent displays") + return + } + + // Widehat should have greater width than hat for the same content + XCTAssertGreaterThan(widehatAccent.width, hatAccent.width, + "\\widehat should be wider than \\hat for multi-character content") + } + + func testWidetildeVsTilde() throws { + // Test that \widetilde and \tilde produce different results + let tildeLatex = "\\tilde{ABC}" + let widetildeLatex = "\\widetilde{ABC}" + + let tildeMathList = MTMathListBuilder.build(fromString: tildeLatex) + let widetildeMathList = MTMathListBuilder.build(fromString: widetildeLatex) + + let tildeDisplay = MTTypesetter.createLineForMathList(tildeMathList, font: font, style: .display) + let widetildeDisplay = MTTypesetter.createLineForMathList(widetildeMathList, font: font, style: .display) + + XCTAssertNotNil(tildeDisplay, "\\tilde should render") + XCTAssertNotNil(widetildeDisplay, "\\widetilde should render") + + guard let tildeAccentDisp = tildeDisplay?.subDisplays.first as? MTAccentDisplay, + let widetildeAccentDisp = widetildeDisplay?.subDisplays.first as? MTAccentDisplay, + let tildeAccent = tildeAccentDisp.accent, + let widetildeAccent = widetildeAccentDisp.accent else { + XCTFail("Could not extract accent displays") + return + } + + XCTAssertGreaterThan(widetildeAccent.width, tildeAccent.width, + "\\widetilde should be wider than \\tilde for multi-character content") + } + + // MARK: - Coverage Tests + + func testWidehatSingleCharCoverage() throws { + // Test that \widehat covers a single character + let latex = "\\widehat{x}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + guard let accentDisp = display?.subDisplays.first as? MTAccentDisplay, + let accentee = accentDisp.accentee, + let accent = accentDisp.accent else { + XCTFail("Could not extract accent display") + return + } + + let coverage = accent.width / accentee.width * 100 + + // Should cover at least 100% of content + XCTAssertGreaterThanOrEqual(coverage, 100, + "\\widehat should cover at least 100% of single character") + // Should not be excessively wide (less than 150%) + XCTAssertLessThan(coverage, 150, + "\\widehat should not be excessively wide for single character") + } + + func testWidehatMultiCharCoverage() throws { + // Test that \widehat covers multiple characters + let testCases = [ + ("\\widehat{AB}", "two characters"), + ("\\widehat{ABC}", "three characters"), + ("\\widehat{ABCD}", "four characters"), + ("\\widehat{ABCDEF}", "six characters") + ] + + for (latex, description) in testCases { + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + guard let accentDisp = display?.subDisplays.first as? MTAccentDisplay, + let accentee = accentDisp.accentee, + let accent = accentDisp.accent else { + XCTFail("Could not extract accent display for \(description)") + continue + } + + let coverage = accent.width / accentee.width * 100 + + // Should cover at least 100% of content (with padding) + XCTAssertGreaterThanOrEqual(coverage, 100, + "\\widehat should cover at least 100% for \(description)") + // Should not be excessively wide (less than 150%) + XCTAssertLessThan(coverage, 150, + "\\widehat should not be excessively wide for \(description)") + } + } + + func testWidetildeCoverage() throws { + // Test that \widetilde covers content properly + let latex = "\\widetilde{ABC}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + guard let accentDisp = display?.subDisplays.first as? MTAccentDisplay, + let accentee = accentDisp.accentee, + let accent = accentDisp.accent else { + XCTFail("Could not extract accent display") + return + } + + let coverage = accent.width / accentee.width * 100 + + XCTAssertGreaterThanOrEqual(coverage, 100, + "\\widetilde should cover at least 100% of content") + XCTAssertLessThan(coverage, 150, + "\\widetilde should not be excessively wide") + } + + // MARK: - Flag Tests + + func testIsWideFlagSet() throws { + // Test that isWide flag is set correctly by factory + let widehat = MTMathAtomFactory.accent(withName: "widehat") + let widetilde = MTMathAtomFactory.accent(withName: "widetilde") + let hat = MTMathAtomFactory.accent(withName: "hat") + let tilde = MTMathAtomFactory.accent(withName: "tilde") + + XCTAssertTrue(widehat?.isWide ?? false, "\\widehat should have isWide=true") + XCTAssertTrue(widetilde?.isWide ?? false, "\\widetilde should have isWide=true") + XCTAssertFalse(hat?.isWide ?? true, "\\hat should have isWide=false") + XCTAssertFalse(tilde?.isWide ?? true, "\\tilde should have isWide=false") + } + + // MARK: - Complex Content Tests + + func testWidehatWithFraction() throws { + // Test widehat over a fraction + let latex = "\\widehat{\\frac{a}{b}}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + XCTAssertNotNil(display, "\\widehat with fraction should render") + + guard let accentDisp = display?.subDisplays.first as? MTAccentDisplay, + let accentee = accentDisp.accentee, + let accent = accentDisp.accent else { + XCTFail("Could not extract accent display") + return + } + + let coverage = accent.width / accentee.width * 100 + + // Should cover the fraction + XCTAssertGreaterThanOrEqual(coverage, 90, + "\\widehat should adequately cover fraction") + } + + func testWidehatWithSubscript() throws { + // Test widehat with subscripted content + let latex = "\\widehat{x_i}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + XCTAssertNotNil(display, "\\widehat with subscript should render") + } + + func testWidehatWithSuperscript() throws { + // Test widehat with superscripted content + let latex = "\\widehat{x^2}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + XCTAssertNotNil(display, "\\widehat with superscript should render") + } + + // MARK: - Vertical Spacing Tests + + func testWidehatVerticalSpacing() throws { + // Test that widehat has proper vertical spacing + let latex = "\\widehat{ABC}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + guard let accentDisp = display?.subDisplays.first as? MTAccentDisplay else { + XCTFail("Could not extract accent display") + return + } + + // The overall display should be taller than just the content + XCTAssertGreaterThan(accentDisp.ascent, accentDisp.accentee?.ascent ?? 0, + "Accent display should be taller than content alone") + } + + // MARK: - Backward Compatibility Tests + + func testHatStillWorks() throws { + // Test that \hat still works as before + let latex = "\\hat{x}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + XCTAssertNotNil(display, "\\hat should still render") + } + + func testTildeStillWorks() throws { + // Test that \tilde still works as before + let latex = "\\tilde{x}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + XCTAssertNotNil(display, "\\tilde should still render") + } + + // MARK: - Edge Cases + + func testWidehatEmpty() throws { + // Test widehat with empty content + let latex = "\\widehat{}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + // Should handle empty content gracefully + XCTAssertNotNil(display, "\\widehat with empty content should not crash") + } + + func testWidehatVeryLongContent() throws { + // Test widehat with very long content + let latex = "\\widehat{abcdefghijk}" + let mathList = MTMathListBuilder.build(fromString: latex) + let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display) + + XCTAssertNotNil(display, "\\widehat with long content should render") + + guard let accentDisp = display?.subDisplays.first as? MTAccentDisplay, + let accentee = accentDisp.accentee, + let accent = accentDisp.accent else { + XCTFail("Could not extract accent display") + return + } + + let coverage = accent.width / accentee.width * 100 + + // Should still cover the content + XCTAssertGreaterThanOrEqual(coverage, 90, + "\\widehat should cover even very long content") + } +}