diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index 3435205..f6286d3 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -1230,6 +1230,10 @@ 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) @@ -1237,7 +1241,7 @@ class MTTypesetter { 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); @@ -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 { diff --git a/Tests/SwiftMathTests/LeftRightDuplicationTests.swift b/Tests/SwiftMathTests/LeftRightDuplicationTests.swift new file mode 100644 index 0000000..4452187 --- /dev/null +++ b/Tests/SwiftMathTests/LeftRightDuplicationTests.swift @@ -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 + } +}