Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions Sources/SwiftMath/MathRender/MTTypesetter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1230,14 +1230,18 @@ class MTTypesetter {

let glyphHeight: CGFloat

// Typeset the inner content at most once. The resulting display is reused
// for both the delimiter height calculation and the placement below.
var innerListDisplay: MTMathListDisplay? = nil

// Check if we have an explicit delimiter height (from \big, \Big, etc.)
if let delimiterMultiplier = inner!.delimiterHeight {
// delimiterHeight is a multiplier (e.g., 1.2, 1.8, 2.4, 3.0)
// Multiply by font size to get actual height
glyphHeight = styleFont.fontSize * delimiterMultiplier
} else {
// Calculate height based on inner content (for \left...\right)
let innerListDisplay = MTTypesetter.createLineForMathList(inner!.innerList, font:font, style:style, cramped:cramped, spaced:true, maxWidth:maxWidth)
innerListDisplay = MTTypesetter.createLineForMathList(inner!.innerList, font:font, style:style, cramped:cramped, spaced:true, maxWidth:maxWidth)
let axisHeight = styleFont.mathTable!.axisHeight
// delta is the max distance from the axis
let delta = max(innerListDisplay!.ascent - axisHeight, innerListDisplay!.descent + axisHeight);
Expand Down Expand Up @@ -1265,12 +1269,13 @@ class MTTypesetter {
}

// Only include inner content if not using explicit delimiter height
// (explicit height commands like \big produce standalone delimiters)
if inner!.delimiterHeight == nil {
let innerListDisplay = MTTypesetter.createLineForMathList(inner!.innerList, font:font, style:style, cramped:cramped, spaced:true, maxWidth:maxWidth)
innerListDisplay!.position = position;
position.x += innerListDisplay!.width;
innerElements.append(innerListDisplay!)
// (explicit height commands like \big produce standalone delimiters).
// Reuse the display already typeset for the height calculation above so the
// inner list is never typeset twice (see issue #66).
if inner!.delimiterHeight == nil, let innerListDisplay {
innerListDisplay.position = position;
position.x += innerListDisplay.width;
innerElements.append(innerListDisplay)
}

if inner!.rightBoundary != nil && !inner!.rightBoundary!.nucleus.isEmpty {
Expand Down
74 changes: 74 additions & 0 deletions Tests/SwiftMathTests/LeftRightDuplicationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import XCTest
@testable import SwiftMath

// Regression tests for GitHub issue #66:
// "Duplicate characters are rendered when \left and \right are used"
// https://github.com/mgriebling/SwiftMath/issues/66
//
// Root cause: makeLeftRight typeset the inner list twice (once for the
// delimiter height, once for placement). Typesetting an MTMathList twice is
// non-idempotent because preprocessMathList fuses ordinary atoms in place
// (MTMathAtom.fuse does `nucleus += atom.nucleus`), so the second pass
// duplicated any multi-atom run inside the \left...\right group.
final class LeftRightDuplicationTests: XCTestCase {

/// Concatenate the text rendered by every MTCTLineDisplay in the tree.
private func collectGlyphRuns(_ display: MTDisplay, into runs: inout [String]) {
if let line = display as? MTCTLineDisplay {
if let s = line.attributedString?.string, !s.isEmpty {
runs.append(s)
}
}
if let listDisplay = display as? MTMathListDisplay {
for sub in listDisplay.subDisplays {
collectGlyphRuns(sub, into: &runs)
}
}
if let frac = display as? MTFractionDisplay {
if let n = frac.numerator { collectGlyphRuns(n, into: &runs) }
if let d = frac.denominator { collectGlyphRuns(d, into: &runs) }
}
if let radical = display as? MTRadicalDisplay, let radicand = radical.radicand {
collectGlyphRuns(radicand, into: &runs)
}
}

private func renderedText(_ latex: String) -> String {
let mathList = MTMathListBuilder.build(fromString: latex)!
let font = MTFontManager.fontManager.defaultFont!
let display = MTTypesetter.createLineForMathList(mathList, font: font, style: .display)!
var runs = [String]()
collectGlyphRuns(display, into: &runs)
return runs.joined()
}

func testSimpleLeftRightDoesNotDuplicate() throws {
let rendered = renderedText("\\left(x^3\\right)")
// \left(...\right) renders the parentheses as glyphs, so the collected
// CTLine text is the pure inner content. "x" and exponent "3" exactly once.
XCTAssertEqual(rendered.filter { $0 == "3" }.count, 1,
"Exponent '3' duplicated. Rendered: \(rendered)")
// The italic-math 'x' is U+1D465.
XCTAssertEqual(rendered.filter { String($0) == "\u{1D465}" }.count, 1,
"Inner 'x' duplicated. Rendered: \(rendered)")
}

func testFractionInLeftRightDoesNotDuplicate() throws {
let rendered = renderedText("\\left(\\frac{-C R \\omega}{1 - C L \\omega^2}\\right)")
// 'C' (U+1D436) appears once in the numerator and once in the denominator.
let cCount = rendered.filter { String($0) == "\u{1D436}" }.count
XCTAssertEqual(cCount, 2, "C should appear exactly twice (num + denom). Rendered: \(rendered)")
// 'R' (U+1D445) only in the numerator -> exactly once.
let rCount = rendered.filter { String($0) == "\u{1D445}" }.count
XCTAssertEqual(rCount, 1, "R duplicated. Rendered: \(rendered)")
// 'L' (U+1D43F) only in the denominator -> exactly once.
let lCount = rendered.filter { String($0) == "\u{1D43F}" }.count
XCTAssertEqual(lCount, 1, "L duplicated. Rendered: \(rendered)")
}

func testLeftBracketRightBracketDoesNotDuplicate() throws {
let rendered = renderedText("\\left[a+b\\right]")
XCTAssertEqual(rendered.filter { String($0) == "\u{1D44E}" }.count, 1, "Inner 'a' duplicated. Rendered: \(rendered)") // a = U+1D44E
XCTAssertEqual(rendered.filter { String($0) == "\u{1D44F}" }.count, 1, "Inner 'b' duplicated. Rendered: \(rendered)") // b = U+1D44F
}
}