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:
@@ -271,7 +271,11 @@ public struct MTMathListBuilder {
|
|||||||
return nil
|
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 {
|
if mode == .inline && list != nil && !list!.atoms.isEmpty {
|
||||||
// Prepend \textstyle to force inline rendering
|
// Prepend \textstyle to force inline rendering
|
||||||
let styleAtom = MTMathStyle(style: .text)
|
let styleAtom = MTMathStyle(style: .text)
|
||||||
|
|||||||
@@ -1726,10 +1726,11 @@ class MTTypesetter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fractionStyle() -> MTLineStyle {
|
func fractionStyle() -> MTLineStyle {
|
||||||
if style == .scriptOfScript {
|
// Keep fractions at the same style level instead of incrementing.
|
||||||
return .scriptOfScript
|
// This ensures that fraction numerators/denominators have the same
|
||||||
}
|
// font size as regular text, preventing them from appearing too small
|
||||||
return style.inc()
|
// in inline mode or when nested.
|
||||||
|
return style
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeFraction(_ frac:MTFraction?) -> MTDisplay? {
|
func makeFraction(_ frac:MTFraction?) -> MTDisplay? {
|
||||||
@@ -2041,7 +2042,9 @@ class MTTypesetter {
|
|||||||
// MARK: - Large Operators
|
// MARK: - Large Operators
|
||||||
|
|
||||||
func makeLargeOp(_ op:MTLargeOperator!) -> MTDisplay? {
|
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)
|
var delta = CGFloat(0)
|
||||||
if op.nucleus.count == 1 {
|
if op.nucleus.count == 1 {
|
||||||
var glyph = self.findGlyphForCharacterAtIndex(op.nucleus.startIndex, inString:op.nucleus)
|
var glyph = self.findGlyphForCharacterAtIndex(op.nucleus.startIndex, inString:op.nucleus)
|
||||||
@@ -2086,7 +2089,8 @@ class MTTypesetter {
|
|||||||
currentPosition.x += display!.width
|
currentPosition.x += display!.width
|
||||||
return display;
|
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
|
// make limits
|
||||||
var superScript:MTMathListDisplay? = nil, subScript:MTMathListDisplay? = nil
|
var superScript:MTMathListDisplay? = nil, subScript:MTMathListDisplay? = nil
|
||||||
if op.superScript != nil {
|
if op.superScript != nil {
|
||||||
|
|||||||
@@ -982,6 +982,166 @@ final class MTTypesetterTests: XCTestCase {
|
|||||||
XCTAssertGreaterThan(display.width, 40, "Width should include operator + limits + spacing + x");
|
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 {
|
func testInner() throws {
|
||||||
let innerList = MTMathList()
|
let innerList = MTMathList()
|
||||||
innerList.add(MTMathAtomFactory.atom(forCharacter: "x"))
|
innerList.add(MTMathAtomFactory.atom(forCharacter: "x"))
|
||||||
@@ -1202,11 +1362,11 @@ final class MTTypesetterTests: XCTestCase {
|
|||||||
func testLargeRadicalDescent() throws {
|
func testLargeRadicalDescent() throws {
|
||||||
let list = MTMathListBuilder.build(fromString: "\\sqrt{\\frac{\\sqrt{\\frac{1}{2}} + 3}{\\sqrt{5}^x}}")
|
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)!
|
let display = MTTypesetter.createLineForMathList(list, font:self.font, style:.display)!
|
||||||
|
|
||||||
// dimensions
|
// dimensions (updated for new fraction sizing where fractions maintain same size as parent style)
|
||||||
XCTAssertEqual(display.ascent, 49.16, accuracy: 0.01)
|
XCTAssertEqual(display.ascent, 61.16, accuracy: 0.01)
|
||||||
XCTAssertEqual(display.descent, 21.288, 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 {
|
func testMathTable() throws {
|
||||||
@@ -1420,21 +1580,22 @@ final class MTTypesetterTests: XCTestCase {
|
|||||||
let list = MTMathList(atoms: [frac])
|
let list = MTMathList(atoms: [frac])
|
||||||
let style = MTMathStyle(style: .text)
|
let style = MTMathStyle(style: .text)
|
||||||
let textList = MTMathList(atoms: [style, frac])
|
let textList = MTMathList(atoms: [style, frac])
|
||||||
|
|
||||||
// This should make the display same as text.
|
// This should make the display same as text.
|
||||||
let display = MTTypesetter.createLineForMathList(textList, font:self.font, style:.display)!
|
let display = MTTypesetter.createLineForMathList(textList, font:self.font, style:.display)!
|
||||||
let textDisplay = MTTypesetter.createLineForMathList(list, font:self.font, style:.text)!
|
let textDisplay = MTTypesetter.createLineForMathList(list, font:self.font, style:.text)!
|
||||||
let originalDisplay = MTTypesetter.createLineForMathList(list, font:self.font, style:.display)!
|
let originalDisplay = MTTypesetter.createLineForMathList(list, font:self.font, style:.display)!
|
||||||
|
|
||||||
// Display should be the same as rendering the fraction in text style.
|
// Display should be the same as rendering the fraction in text style.
|
||||||
XCTAssertEqual(display.ascent, textDisplay.ascent);
|
XCTAssertEqual(display.ascent, textDisplay.ascent);
|
||||||
XCTAssertEqual(display.descent, textDisplay.descent);
|
XCTAssertEqual(display.descent, textDisplay.descent);
|
||||||
XCTAssertEqual(display.width, textDisplay.width);
|
XCTAssertEqual(display.width, textDisplay.width);
|
||||||
|
|
||||||
// Original display should be larger than display since it is greater.
|
// With updated fractionStyle(), fractions use the same font size in display and text modes,
|
||||||
XCTAssertGreaterThan(originalDisplay.ascent, display.ascent);
|
// but spacing/positioning is still different (numeratorShiftUp, etc. check parent style).
|
||||||
XCTAssertGreaterThan(originalDisplay.descent, display.descent);
|
// So originalDisplay (display mode) will be larger than display (text mode).
|
||||||
XCTAssertGreaterThan(originalDisplay.width, display.width);
|
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 {
|
func testStyleMiddle() throws {
|
||||||
|
|||||||
Reference in New Issue
Block a user