From a38d00b25882e2e57f95c9166331398040a5ee13 Mon Sep 17 00:00:00 2001 From: Samuel Xu Date: Thu, 11 Jun 2026 23:58:33 -0400 Subject: [PATCH 1/3] =?UTF-8?q?[MTTypesetter]=20Fix=20\left=E2=80=A6\right?= =?UTF-8?q?=20duplicating=20inner=20content=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeLeftRight typeset the inner math list twice — once to size the delimiters and once to place the content. Typesetting an MTMathList is not idempotent: preprocessMathList fuses adjacent ordinary atoms in place via MTMathAtom.fuse (nucleus += atom.nucleus), so the second pass re-fused already-fused runs and duplicated any multi-atom content inside a \left…\right group. Nesting compounded the effect (one extra pass per level). Typeset the inner list once, cache the resulting MTMathListDisplay, and reuse it for both the delimiter-height calculation and placement. Adds LeftRightDuplicationTests covering the issue #66 reporter expression plus fraction, bracket, nested, and \left. variants. --- .../SwiftMath/MathRender/MTTypesetter.swift | 24 +++-- .../LeftRightDuplicationTests.swift | 101 ++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 Tests/SwiftMathTests/LeftRightDuplicationTests.swift diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index 3435205..d41bc19 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -1230,6 +1230,15 @@ 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. + // Typesetting the inner list twice mutates its atoms in place + // (preprocessMathList fuses ordinary atoms via MTMathAtom.fuse, which is + // non-idempotent), which previously duplicated the rendered content of + // any \left...\right group containing a fraction or multi-atom run + // (GitHub issue #66). + 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 +1246,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 +1274,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..8084bf5 --- /dev/null +++ b/Tests/SwiftMathTests/LeftRightDuplicationTests.swift @@ -0,0 +1,101 @@ +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 testIssue66ReproDoesNotDuplicate() throws { + // The exact reporter expression from issue #66, compared against the + // delimiter-free variant the reporter confirmed renders correctly. + let rendered = renderedText("\\phi = \\arctan\\left(\\frac{-C R \\omega}{1 - C L \\omega^2}\\right)") + // \left( renders the paren as a glyph (not CTLine text); the literal '(' + // in the baseline renders as CTLine text. Strip the literal delimiters so + // the comparison is apples-to-apples on the inner content. + let baseline = renderedText("\\phi = \\arctan(\\frac{-C R \\omega}{1 - C L \\omega^2})") + .replacingOccurrences(of: "(", with: "") + .replacingOccurrences(of: ")", with: "") + XCTAssertEqual(rendered, baseline, + "Issue #66 expression duplicated inner content.\n baseline: \(baseline)\n 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 + } + + func testNestedLeftRightDoesNotDuplicate() throws { + // Nested \left\right around an inner \left\right around a multi-atom fraction. + let rendered = renderedText("\\left(\\left[\\frac{C R}{L}\\right]\\right)") + XCTAssertEqual(rendered.filter { String($0) == "\u{1D445}" }.count, 1, "Nested inner 'R' duplicated. Rendered: \(rendered)") + XCTAssertEqual(rendered.filter { String($0) == "\u{1D43F}" }.count, 1, "Nested inner 'L' duplicated. Rendered: \(rendered)") + } + + func testLeftDotRightDoesNotDuplicate() throws { + let rendered = renderedText("\\left.\\frac{C R}{L}\\right)") + XCTAssertEqual(rendered.filter { String($0) == "\u{1D445}" }.count, 1, "Inner 'R' duplicated. Rendered: \(rendered)") + XCTAssertEqual(rendered.filter { String($0) == "\u{1D43F}" }.count, 1, "Inner 'L' duplicated. Rendered: \(rendered)") + } +} From 43f4757c4bede9dedd8755d588f4ba1b4b5f4abd Mon Sep 17 00:00:00 2001 From: Samuel Xu Date: Fri, 12 Jun 2026 14:30:11 -0400 Subject: [PATCH 2/3] Touch up comments --- Sources/SwiftMath/MathRender/MTTypesetter.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/SwiftMath/MathRender/MTTypesetter.swift b/Sources/SwiftMath/MathRender/MTTypesetter.swift index d41bc19..f6286d3 100644 --- a/Sources/SwiftMath/MathRender/MTTypesetter.swift +++ b/Sources/SwiftMath/MathRender/MTTypesetter.swift @@ -1232,11 +1232,6 @@ class MTTypesetter { // Typeset the inner content at most once. The resulting display is reused // for both the delimiter height calculation and the placement below. - // Typesetting the inner list twice mutates its atoms in place - // (preprocessMathList fuses ordinary atoms via MTMathAtom.fuse, which is - // non-idempotent), which previously duplicated the rendered content of - // any \left...\right group containing a fraction or multi-atom run - // (GitHub issue #66). var innerListDisplay: MTMathListDisplay? = nil // Check if we have an explicit delimiter height (from \big, \Big, etc.) From 8e4a1570f140c8e2bd6957f322377d4a4ea94ab9 Mon Sep 17 00:00:00 2001 From: Samuel Xu Date: Fri, 12 Jun 2026 14:33:11 -0400 Subject: [PATCH 3/3] Removing some unnecessary duplication tests --- .../LeftRightDuplicationTests.swift | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/Tests/SwiftMathTests/LeftRightDuplicationTests.swift b/Tests/SwiftMathTests/LeftRightDuplicationTests.swift index 8084bf5..4452187 100644 --- a/Tests/SwiftMathTests/LeftRightDuplicationTests.swift +++ b/Tests/SwiftMathTests/LeftRightDuplicationTests.swift @@ -53,20 +53,6 @@ final class LeftRightDuplicationTests: XCTestCase { "Inner 'x' duplicated. Rendered: \(rendered)") } - func testIssue66ReproDoesNotDuplicate() throws { - // The exact reporter expression from issue #66, compared against the - // delimiter-free variant the reporter confirmed renders correctly. - let rendered = renderedText("\\phi = \\arctan\\left(\\frac{-C R \\omega}{1 - C L \\omega^2}\\right)") - // \left( renders the paren as a glyph (not CTLine text); the literal '(' - // in the baseline renders as CTLine text. Strip the literal delimiters so - // the comparison is apples-to-apples on the inner content. - let baseline = renderedText("\\phi = \\arctan(\\frac{-C R \\omega}{1 - C L \\omega^2})") - .replacingOccurrences(of: "(", with: "") - .replacingOccurrences(of: ")", with: "") - XCTAssertEqual(rendered, baseline, - "Issue #66 expression duplicated inner content.\n baseline: \(baseline)\n 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. @@ -85,17 +71,4 @@ final class LeftRightDuplicationTests: XCTestCase { 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 } - - func testNestedLeftRightDoesNotDuplicate() throws { - // Nested \left\right around an inner \left\right around a multi-atom fraction. - let rendered = renderedText("\\left(\\left[\\frac{C R}{L}\\right]\\right)") - XCTAssertEqual(rendered.filter { String($0) == "\u{1D445}" }.count, 1, "Nested inner 'R' duplicated. Rendered: \(rendered)") - XCTAssertEqual(rendered.filter { String($0) == "\u{1D43F}" }.count, 1, "Nested inner 'L' duplicated. Rendered: \(rendered)") - } - - func testLeftDotRightDoesNotDuplicate() throws { - let rendered = renderedText("\\left.\\frac{C R}{L}\\right)") - XCTAssertEqual(rendered.filter { String($0) == "\u{1D445}" }.count, 1, "Inner 'R' duplicated. Rendered: \(rendered)") - XCTAssertEqual(rendered.filter { String($0) == "\u{1D43F}" }.count, 1, "Inner 'L' duplicated. Rendered: \(rendered)") - } }