Fix inline mode rendering for large operators and fractions

This commit addresses three issues with math rendering:

  1. Large operator limits positioning (continued from previous commit)
  Modified makeLargeOp() and addLimitsToDisplay() to show limits above/below
  in both display and text (inline) modes:
    - Changed: op.limits && style == .display
    - To: op.limits && (style == .display || style == .text)

  This enables operators like \lim, \sum, and \prod to show subscripts/
  superscripts above and below even in inline mode \(...\), not just in
  display mode \[...\].

  2. Fraction font size issue
  Fixed fractions appearing too small in inline mode. Previously, fractions
  used one style level smaller than their parent (standard LaTeX behavior):
    - Display mode → fractions use text style (acceptable)
    - Text mode →

  Root cause:
  Inline delimiters \(...\) insert \textstyle, forcing text mode. In text
  mode, fractionStyle() returned style.inc(), making numerator/denominator
  use script style (two levels smaller than display). This made fraction
  numbers tiny compared to surrounding text in expressions like:
    \(\frac{a}{b} = c\) - a, b were script-sized while c was text-sized

  Solution:
  Modified fractionStyle() to return the SAME style instead of incrementing:
    func fractionStyle() -> MTLineStyle {
        return style  // Was: return style.inc()
    }

  This keeps fraction numerators/denominators at the same font size as
  regular text, preventing them from becoming too small. Spacing and
  positioning (numeratorShiftUp, etc.) still vary by parent style.

  3. Non-regression fixes
  Updated test expectations to match new fraction sizing behavior
This commit is contained in:
Nicolas Guillot
2025-11-17 10:12:45 +01:00
parent cb890fb787
commit cc1a7b8023
3 changed files with 187 additions and 18 deletions

View File

@@ -271,7 +271,11 @@ public struct MTMathListBuilder {
return nil
}
// Optionally: Add style hint for inline mode
// Note: For inline mode, we insert \textstyle to match LaTeX behavior.
// However, fractionStyle() has been modified to keep fractions at the
// same font size in both display and text modes (not one level smaller).
// Large operators show limits above/below in text style due to the updated
// condition in makeLargeOp() that checks both .display and .text styles.
if mode == .inline && list != nil && !list!.atoms.isEmpty {
// Prepend \textstyle to force inline rendering
let styleAtom = MTMathStyle(style: .text)

View File

@@ -1726,10 +1726,11 @@ class MTTypesetter {
}
func fractionStyle() -> MTLineStyle {
if style == .scriptOfScript {
return .scriptOfScript
}
return style.inc()
// Keep fractions at the same style level instead of incrementing.
// This ensures that fraction numerators/denominators have the same
// font size as regular text, preventing them from appearing too small
// in inline mode or when nested.
return style
}
func makeFraction(_ frac:MTFraction?) -> MTDisplay? {
@@ -2041,7 +2042,9 @@ class MTTypesetter {
// MARK: - Large Operators
func makeLargeOp(_ op:MTLargeOperator!) -> MTDisplay? {
let limits = op.limits && style == .display
// Show limits above/below in both display and text (inline) modes
// Only show limits to the side in script modes to keep them compact
let limits = op.limits && (style == .display || style == .text)
var delta = CGFloat(0)
if op.nucleus.count == 1 {
var glyph = self.findGlyphForCharacterAtIndex(op.nucleus.startIndex, inString:op.nucleus)
@@ -2086,7 +2089,8 @@ class MTTypesetter {
currentPosition.x += display!.width
return display;
}
if op.limits && style == .display {
// Show limits above/below in both display and text (inline) modes
if op.limits && (style == .display || style == .text) {
// make limits
var superScript:MTMathListDisplay? = nil, subScript:MTMathListDisplay? = nil
if op.superScript != nil {

View File

@@ -982,6 +982,166 @@ final class MTTypesetterTests: XCTestCase {
XCTAssertGreaterThan(display.width, 40, "Width should include operator + limits + spacing + x");
}
func testLargeOpWithLimitsInlineMode_Limit() throws {
// Test that \lim in inline/text mode shows limits above/below (not to the side)
// This tests the fix for: \(\lim_{n \to \infty} \frac{1}{n} = 0\)
let latex = "\\lim_{n\\to\\infty}\\frac{1}{n}"
let mathList = MTMathListBuilder.build(fromString: latex)
XCTAssertNotNil(mathList, "Should parse LaTeX")
// Use .text style to simulate inline mode \(...\)
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .text)!
XCTAssertNotNil(display)
XCTAssertEqual(display.type, .regular)
// Should have at least 2 subdisplays: lim with limits, and fraction
XCTAssertGreaterThanOrEqual(display.subDisplays.count, 2)
// First subdisplay should be the limit operator with limits display
let limDisplay = display.subDisplays[0]
XCTAssertTrue(limDisplay is MTLargeOpLimitsDisplay, "Limit should use MTLargeOpLimitsDisplay in inline mode")
if let limitsDisplay = limDisplay as? MTLargeOpLimitsDisplay {
XCTAssertNotNil(limitsDisplay.lowerLimit, "Should have lower limit (n→∞)")
XCTAssertNil(limitsDisplay.upperLimit, "Should not have upper limit")
XCTAssertLessThan(limitsDisplay.lowerLimit!.position.y, 0, "Lower limit should be below baseline")
}
}
func testLargeOpWithLimitsInlineMode_Sum() throws {
// Test that \sum in inline/text mode shows limits above/below (not to the side)
// This tests the fix for: \(\sum_{i=1}^{n} i\)
let latex = "\\sum_{i=1}^{n}i"
let mathList = MTMathListBuilder.build(fromString: latex)
XCTAssertNotNil(mathList, "Should parse LaTeX")
// Use .text style to simulate inline mode \(...\)
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .text)!
XCTAssertNotNil(display)
XCTAssertEqual(display.type, .regular)
// Should have at least 2 subdisplays: sum with limits, and variable i
XCTAssertGreaterThanOrEqual(display.subDisplays.count, 2)
// First subdisplay should be the sum operator with limits display
let sumDisplay = display.subDisplays[0]
XCTAssertTrue(sumDisplay is MTLargeOpLimitsDisplay, "Sum should use MTLargeOpLimitsDisplay in inline mode")
if let limitsDisplay = sumDisplay as? MTLargeOpLimitsDisplay {
XCTAssertNotNil(limitsDisplay.upperLimit, "Should have upper limit (n)")
XCTAssertNotNil(limitsDisplay.lowerLimit, "Should have lower limit (i=1)")
XCTAssertGreaterThan(limitsDisplay.upperLimit!.position.y, 0, "Upper limit should be above baseline")
XCTAssertLessThan(limitsDisplay.lowerLimit!.position.y, 0, "Lower limit should be below baseline")
}
}
func testLargeOpWithLimitsInlineMode_Product() throws {
// Test that \prod in inline/text mode shows limits above/below (not to the side)
// This tests the fix for: \(\prod_{k=1}^{\infty} (1 + x^k)\)
let latex = "\\prod_{k=1}^{\\infty}x"
let mathList = MTMathListBuilder.build(fromString: latex)
XCTAssertNotNil(mathList, "Should parse LaTeX")
// Use .text style to simulate inline mode \(...\)
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .text)!
XCTAssertNotNil(display)
XCTAssertEqual(display.type, .regular)
// Should have at least 2 subdisplays: prod with limits, and variable x
XCTAssertGreaterThanOrEqual(display.subDisplays.count, 2)
// First subdisplay should be the product operator with limits display
let prodDisplay = display.subDisplays[0]
XCTAssertTrue(prodDisplay is MTLargeOpLimitsDisplay, "Product should use MTLargeOpLimitsDisplay in inline mode")
if let limitsDisplay = prodDisplay as? MTLargeOpLimitsDisplay {
XCTAssertNotNil(limitsDisplay.upperLimit, "Should have upper limit (∞)")
XCTAssertNotNil(limitsDisplay.lowerLimit, "Should have lower limit (k=1)")
XCTAssertGreaterThan(limitsDisplay.upperLimit!.position.y, 0, "Upper limit should be above baseline")
XCTAssertLessThan(limitsDisplay.lowerLimit!.position.y, 0, "Lower limit should be below baseline")
}
}
func testFractionInlineMode_NormalFontSize() throws {
// Test that \(...\) delimiter doesn't make fractions too small
// This tests the fix for: \(\frac{a}{b} = c\)
let latex = "\\frac{a}{b}"
let mathList = MTMathListBuilder.build(fromString: latex)
XCTAssertNotNil(mathList, "Should parse LaTeX")
// Create display without any style forcing
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display)!
XCTAssertNotNil(display)
XCTAssertEqual(display.type, .regular)
// Should have 1 subdisplay: the fraction
XCTAssertEqual(display.subDisplays.count, 1)
// First subdisplay should be the fraction
let fracDisplay = display.subDisplays[0]
XCTAssertTrue(fracDisplay is MTFractionDisplay, "Should be a fraction display")
if let fractionDisplay = fracDisplay as? MTFractionDisplay {
XCTAssertNotNil(fractionDisplay.numerator, "Should have numerator")
XCTAssertNotNil(fractionDisplay.denominator, "Should have denominator")
// The numerator and denominator should use text style (not script style)
// In display mode, fractions use text style for numerator/denominator
// Check that the font size is reasonable (not script-sized)
let numDisplay = fractionDisplay.numerator!
XCTAssertGreaterThan(numDisplay.width, 5, "Numerator should have reasonable size, not script-sized")
XCTAssertGreaterThan(numDisplay.ascent, 5, "Numerator should have reasonable ascent, not script-sized")
}
}
func testFractionInlineDelimiters_NormalSize() throws {
// Test that \(\frac{a}{b}\) has full-sized numerator/denominator
// Inline delimiters insert \textstyle, but fractions maintain same font size
let latex1 = "\\(\\frac{a}{b}\\)"
let mathList1 = MTMathListBuilder.build(fromString: latex1)
XCTAssertNotNil(mathList1, "Should parse LaTeX with delimiters")
let display1 = MTTypesetter.createLineForMathList(mathList1, font: self.font, style: .display)!
// Should have subdisplays (style atom + fraction)
XCTAssertGreaterThanOrEqual(display1.subDisplays.count, 1)
// Find the fraction display (it might be after a style atom)
let fracDisplay = display1.subDisplays.first(where: { $0 is MTFractionDisplay }) as? MTFractionDisplay
XCTAssertNotNil(fracDisplay, "Should have fraction display")
// The numerator should have reasonable size (not script-sized)
XCTAssertGreaterThan(fracDisplay!.numerator!.width, 8, "Numerator should have reasonable width")
XCTAssertGreaterThan(fracDisplay!.numerator!.ascent, 6, "Numerator should have reasonable ascent")
}
func testComplexFractionInlineMode() throws {
// Test that complex fractions in inline mode render at normal size
// This tests: \(\frac{x^2 + 1}{y - 3}\)
let latex = "\\frac{x^2+1}{y-3}"
let mathList = MTMathListBuilder.build(fromString: latex)
XCTAssertNotNil(mathList, "Should parse LaTeX")
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display)!
XCTAssertNotNil(display)
// Should have a fraction display
XCTAssertEqual(display.subDisplays.count, 1)
let fracDisplay = display.subDisplays[0]
XCTAssertTrue(fracDisplay is MTFractionDisplay)
if let fractionDisplay = fracDisplay as? MTFractionDisplay {
// Numerator should contain multiple atoms (x^2 + 1)
let numDisplay = fractionDisplay.numerator!
XCTAssertGreaterThanOrEqual(numDisplay.subDisplays.count, 1, "Numerator should have content")
// Check that the numerator has reasonable size (not script-sized)
XCTAssertGreaterThan(numDisplay.width, 20, "Complex numerator should have reasonable width")
XCTAssertGreaterThan(numDisplay.ascent, 5, "Numerator with superscript should have reasonable height")
}
}
func testInner() throws {
let innerList = MTMathList()
innerList.add(MTMathAtomFactory.atom(forCharacter: "x"))
@@ -1202,11 +1362,11 @@ final class MTTypesetterTests: XCTestCase {
func testLargeRadicalDescent() throws {
let list = MTMathListBuilder.build(fromString: "\\sqrt{\\frac{\\sqrt{\\frac{1}{2}} + 3}{\\sqrt{5}^x}}")
let display = MTTypesetter.createLineForMathList(list, font:self.font, style:.display)!
// dimensions
XCTAssertEqual(display.ascent, 49.16, accuracy: 0.01)
// dimensions (updated for new fraction sizing where fractions maintain same size as parent style)
XCTAssertEqual(display.ascent, 61.16, accuracy: 0.01)
XCTAssertEqual(display.descent, 21.288, accuracy: 0.01)
XCTAssertEqual(display.width, 82.569, accuracy: 0.01)
XCTAssertEqual(display.width, 85.569, accuracy: 0.01)
}
func testMathTable() throws {
@@ -1420,21 +1580,22 @@ final class MTTypesetterTests: XCTestCase {
let list = MTMathList(atoms: [frac])
let style = MTMathStyle(style: .text)
let textList = MTMathList(atoms: [style, frac])
// This should make the display same as text.
let display = MTTypesetter.createLineForMathList(textList, font:self.font, style:.display)!
let textDisplay = MTTypesetter.createLineForMathList(list, font:self.font, style:.text)!
let originalDisplay = MTTypesetter.createLineForMathList(list, font:self.font, style:.display)!
// Display should be the same as rendering the fraction in text style.
XCTAssertEqual(display.ascent, textDisplay.ascent);
XCTAssertEqual(display.descent, textDisplay.descent);
XCTAssertEqual(display.width, textDisplay.width);
// Original display should be larger than display since it is greater.
XCTAssertGreaterThan(originalDisplay.ascent, display.ascent);
XCTAssertGreaterThan(originalDisplay.descent, display.descent);
XCTAssertGreaterThan(originalDisplay.width, display.width);
// With updated fractionStyle(), fractions use the same font size in display and text modes,
// but spacing/positioning is still different (numeratorShiftUp, etc. check parent style).
// So originalDisplay (display mode) will be larger than display (text mode).
XCTAssertGreaterThan(originalDisplay.ascent, display.ascent, "Display mode fractions have more vertical spacing");
XCTAssertGreaterThan(originalDisplay.descent, display.descent, "Display mode fractions have more vertical spacing");
}
func testStyleMiddle() throws {