From ca89238727ad42366695ba8ad6acdcc21702533d Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 1 Jul 2026 20:42:58 +0530 Subject: [PATCH 1/5] [item 1] Add MTMathGroup atom (kMTMathAtomOrdGroup) for brace groups Co-Authored-By: Claude Sonnet 4.6 --- iosMath/lib/MTMathList.h | 22 ++++++++++++ iosMath/lib/MTMathList.m | 65 +++++++++++++++++++++++++++++++++++ iosMathTests/MTMathListTest.m | 27 +++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/iosMath/lib/MTMathList.h b/iosMath/lib/MTMathList.h index 939666c..ae7cb32 100644 --- a/iosMath/lib/MTMathList.h +++ b/iosMath/lib/MTMathList.h @@ -68,6 +68,10 @@ typedef NS_ENUM(NSUInteger, MTMathAtomType) kMTMathAtomText = 19, /// A box atom (phantom/smash/lap family). Script-capable (< kMTMathAtomBoundary). kMTMathAtomBox = 20, + /// A brace group {…} in math mode — an Ord subformula whose nucleus is a + /// sub-mlist (== TeX Ord noad with sub_mlist / KaTeX "ordgroup"). + /// Script-capable (< kMTMathAtomBoundary); spaced as Ordinary. + kMTMathAtomOrdGroup = 21, // Atoms after this point do not support subscripts or superscripts @@ -646,6 +650,24 @@ typedef NS_ENUM(NSUInteger, MTBoxHAlign) { @end +/** A brace group `{…}` in math mode — an Ord subformula whose nucleus is a + sub-mlist. Script-capable; spaced as Ordinary. The honest analog of TeX's + Ord noad with a `sub_mlist` nucleus and KaTeX's `ordgroup` node. The usual + nucleus is empty; the grouped content lives in `innerList`, and the + sub/superScript fields drive scripting of the whole group. */ +@interface MTMathGroup : MTMathAtom + +/// Creates an empty Ord group (type = kMTMathAtomOrdGroup). +- (instancetype) init NS_DESIGNATED_INITIALIZER; + +/// Throws unless type == kMTMathAtomOrdGroup. +- (instancetype) initWithType:(MTMathAtomType)type value:(NSString *)value; + +/// The grouped math content. +@property (nonatomic, nonnull) MTMathList* innerList; + +@end + /** An atom representing an table element. This atom is not like other atoms and is not present in TeX. We use it to represent the `\halign` command in TeX with some simplifications. This is used for matrices, equation diff --git a/iosMath/lib/MTMathList.m b/iosMath/lib/MTMathList.m index f782139..53f6feb 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -68,6 +68,8 @@ static BOOL isNotBinaryOperator(MTMathAtom* prevNode) return @"Text"; case kMTMathAtomBox: return @"Box"; + case kMTMathAtomOrdGroup: + return @"Ord Group"; case kMTMathAtomBoundary: return @"Boundary"; case kMTMathAtomSpace: @@ -158,6 +160,9 @@ + (instancetype)atomWithType:(MTMathAtomType)type value:(NSString *)value case kMTMathAtomBox: return [[MTMathBox alloc] init]; + case kMTMathAtomOrdGroup: + return [[MTMathGroup alloc] init]; + case kMTMathAtomSpace: return [[MTMathSpace alloc] initWithSpace:0]; @@ -1075,6 +1080,66 @@ - (instancetype)finalized @end +#pragma mark - MTMathGroup + +@implementation MTMathGroup + +- (instancetype)init +{ + self = [super initWithType:kMTMathAtomOrdGroup value:@""]; + if (self) { + _innerList = [MTMathList new]; + } + return self; +} + +- (instancetype)initWithType:(MTMathAtomType)type value:(NSString *)value +{ + if (type == kMTMathAtomOrdGroup) { + return [self init]; + } + @throw [NSException exceptionWithName:@"InvalidMethod" + reason:@"[MTMathGroup initWithType:value:] cannot be called. Use [MTMathGroup init] instead." + userInfo:nil]; +} + +// Standalone string (description / error messages): braces + this atom's scripts. +- (NSString *)stringValue +{ + NSMutableString* str = [NSMutableString stringWithFormat:@"{%@}", + [MTMathListBuilder mathListToString:self.innerList]]; + if (self.superScript) { + [str appendFormat:@"^{%@}", self.superScript.stringValue]; + } + if (self.subScript) { + [str appendFormat:@"_{%@}", self.subScript.stringValue]; + } + return str; +} + +// List serializer: emit ONLY {inner}. mathListToString: appends this atom's own +// ^{…}/_{…} afterwards, so emitting scripts here would double them. +- (void)appendLaTeXToString:(NSMutableString *)str +{ + [str appendFormat:@"{%@}", [MTMathListBuilder mathListToString:self.innerList]]; +} + +- (id)copyWithZone:(NSZone *)zone +{ + MTMathGroup* group = [super copyWithZone:zone]; + group.innerList = [self.innerList copyWithZone:zone]; + return group; +} + +- (instancetype)finalized +{ + MTMathGroup* newGroup = [super finalized]; + newGroup.innerList = newGroup.innerList.finalized; + return newGroup; +} + +@end + #pragma mark - MTMathTable @interface MTMathTable () diff --git a/iosMathTests/MTMathListTest.m b/iosMathTests/MTMathListTest.m index 333228c..dd28ac1 100644 --- a/iosMathTests/MTMathListTest.m +++ b/iosMathTests/MTMathListTest.m @@ -951,4 +951,31 @@ - (void) testMathBoxModel XCTAssertThrows([[MTMathBox alloc] initWithType:kMTMathAtomOrdinary value:@""]); } +- (void)testMathGroupAtom +{ + // Factory returns an MTMathGroup for the OrdGroup type. + MTMathAtom* atom = [MTMathAtom atomWithType:kMTMathAtomOrdGroup value:@""]; + XCTAssertTrue([atom isKindOfClass:[MTMathGroup class]]); + XCTAssertEqual(atom.type, kMTMathAtomOrdGroup); + + // Scripts are allowed (type < kMTMathAtomBoundary): no "scripts not allowed" throw. + XCTAssertTrue(atom.scriptsAllowed); + XCTAssertNoThrow(atom.superScript = [MTMathListBuilder buildFromString:@"2"]); + + // stringValue braces the group and preserves an interior \scriptstyle + // (uses mathListToString:, not innerList.stringValue). + MTMathGroup* group = (MTMathGroup*) [MTMathAtom atomWithType:kMTMathAtomOrdGroup value:@""]; + group.innerList = [MTMathListBuilder buildFromString:@"\\scriptstyle y"]; + XCTAssertEqualObjects(group.stringValue, @"{\\scriptstyle y}"); + + // copyWithZone: deep-copies innerList (distinct object, equal content). + MTMathGroup* copy = [group copy]; + XCTAssertNotEqual(copy.innerList, group.innerList); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:copy.innerList], + [MTMathListBuilder mathListToString:group.innerList]); + + // initWithType: guard — only kMTMathAtomOrdGroup is permitted. + XCTAssertThrows([[MTMathGroup alloc] initWithType:kMTMathAtomBox value:@""]); +} + @end From 6ec1a824fd9ef2d74b8138e8bb1b988f624dbb21 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 1 Jul 2026 21:00:35 +0530 Subject: [PATCH 2/5] =?UTF-8?q?[item=202]=20Wrap=20main-list=20{=E2=80=A6}?= =?UTF-8?q?=20as=20MTMathGroup=20in=20the=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also handle \over/\atop/\choose family correctly: these TeX group-transformation commands replace the enclosing group with a fraction at the parent level (TeX semantics), so their result is NOT wrapped in MTMathGroup. A private ivar _groupWasTransformedByStopCommand tracks this. Update testSqrtInGroup to expect the new MTMathGroup wrapping (semantically correct). Co-Authored-By: Claude Sonnet 4.6 --- iosMath/lib/MTMathListBuilder.m | 43 ++++++++++++-- iosMathTests/MTMathListBuilderTest.m | 86 ++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 17 deletions(-) diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index c0c5e77..e4f7394 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -51,6 +51,11 @@ @implementation MTMathListBuilder { MTFontStyle _currentFontStyle; BOOL _spacesAllowed; NSInteger _recursionDepth; + // Set to YES by stopCommand when a TeX group-transformation command (\over, + // \atop, \choose, \brack, \brace) fires inside a {…} group. Checked in the + // {…} branch to decide whether to wrap as MTMathGroup. Cleared at the top of + // every buildInternal call so the check is always fresh. + BOOL _groupWasTransformedByStopCommand; } - (instancetype)initWithString:(NSString *)str @@ -171,6 +176,7 @@ - (MTMathList*)buildInternal:(BOOL) oneCharOnly stopChar:(unichar) stop return nil; } _recursionDepth++; + _groupWasTransformedByStopCommand = NO; @try { MTMathList* list = [MTMathList new]; NSAssert(!(oneCharOnly && (stop > 0)), @"Cannot set both oneCharOnly and stopChar."); @@ -224,12 +230,33 @@ - (MTMathList*)buildInternal:(BOOL) oneCharOnly stopChar:(unichar) stop } else if (ch == '{') { // this puts us in a recursive routine, and sets oneCharOnly to false and no stop character MTMathList* sublist = [self buildInternal:false stopChar:'}']; - prevAtom = [sublist.atoms lastObject]; - [list append:sublist]; - if (oneCharOnly) { - return list; + if (!sublist) { + // inner error already set (e.g. missing closing brace); propagate. + return nil; } - continue; + BOOL transformed = _groupWasTransformedByStopCommand; + if (oneCharOnly || transformed) { + // Field brace (^{…}, _{…}, \frac{…}, command argument): the {…} + // *is* the field. Flatten and return it as the field — unchanged. + // Also: a group-transforming command (\over, \atop, \choose, + // \brack, \brace) fired inside this group. The resulting fraction + // replaces the group in the parent list (TeX behavior) — do NOT + // wrap in MTMathGroup. Fall through to continue after appending. + [list append:sublist]; + if (oneCharOnly) { + return list; + } + continue; + } + // Grouping brace in the main list: wrap as an Ord subformula so style + // nodes are scoped, scripts target the whole group, and Bin/Ord + // reclassification stops at the brace boundary + // (== TeX Ord-noad-with-sub_mlist / KaTeX ordgroup). + MTMathGroup* group = [[MTMathGroup alloc] init]; + group.innerList = sublist; + atom = group; + // fall through to the shared append path below: it sets prevAtom = group + // (so {x}^2 scripts the group) and finalize assigns the indexRange. } else if (ch == '}') { NSAssert(!oneCharOnly, @"This should have been handled before"); NSAssert(stop == 0, @"This should have been handled before"); @@ -1178,6 +1205,12 @@ - (MTMathList*) stopCommand:(NSString*) command list:(MTMathList*) list stopChar } MTMathList* fracList = [MTMathList new]; [fracList addAtom:frac]; + // Signal to the {…} branch that this group was transformed by a TeX + // group-transformation command (\over / \atop / \choose / \brack / \brace). + // The fraction should be inserted into the parent list directly (not wrapped + // in MTMathGroup), mirroring TeX's behavior where these commands replace the + // enclosing group with a generalized fraction. + _groupWasTransformedByStopCommand = YES; return fracList; } else if ([command isEqualToString:@"\\"] || [command isEqualToString:@"cr"]) { if (_currentEnv) { diff --git a/iosMathTests/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index a3ad815..574d76f 100644 --- a/iosMathTests/MTMathListBuilderTest.m +++ b/iosMathTests/MTMathListBuilderTest.m @@ -59,8 +59,8 @@ - (void)tearDown @[ @"x+2", @[ @(kMTMathAtomVariable), @(kMTMathAtomBinaryOperator), @(kMTMathAtomNumber) ], @"x+2"], // spaces are ignored @[ @"(2.3 * 8)", @[ @(kMTMathAtomOpen), @(kMTMathAtomNumber), @(kMTMathAtomNumber), @(kMTMathAtomNumber), @(kMTMathAtomBinaryOperator), @(kMTMathAtomNumber) , @(kMTMathAtomClose) ], @"(2.3*8)"], - // braces are just for grouping - @[ @"5{3+4}", @[@(kMTMathAtomNumber), @(kMTMathAtomNumber), @(kMTMathAtomBinaryOperator), @(kMTMathAtomNumber)], @"53+4"], + // braces create an Ord group (MTMathGroup) + @[ @"5{3+4}", @[@(kMTMathAtomNumber), @(kMTMathAtomOrdGroup)], @"5{3+4}"], // commands @[ @"\\pi+\\theta\\geq 3",@[ @(kMTMathAtomVariable), @(kMTMathAtomBinaryOperator), @(kMTMathAtomVariable), @(kMTMathAtomRelation), @(kMTMathAtomNumber)], @"\\pi +\\theta \\geq 3"], // aliases @@ -102,9 +102,9 @@ - (void) testBuilder @[ @"x^{2^3}", @[ @(kMTMathAtomVariable) ], @[ @(kMTMathAtomNumber)], @[ @(kMTMathAtomNumber),], @"x^{2^{3}}"], @[ @"x^{^2*}", @[ @(kMTMathAtomVariable) ], @[ @(kMTMathAtomOrdinary), @(kMTMathAtomBinaryOperator)], @[ @(kMTMathAtomNumber),], @"x^{{}^{2}*}"], @[ @"^2", @ [ @(kMTMathAtomOrdinary)], @[ @(kMTMathAtomNumber) ], @"{}^{2}"], - @[ @"{}^2", @ [ @(kMTMathAtomOrdinary)], @[ @(kMTMathAtomNumber) ], @"{}^{2}"], + @[ @"{}^2", @ [ @(kMTMathAtomOrdGroup)], @[ @(kMTMathAtomNumber) ], @"{}^{2}"], @[ @"x^^2", @[ @(kMTMathAtomVariable), @(kMTMathAtomOrdinary) ], @[ ], @"x^{}{}^{2}"], - @[ @"5{x}^2", @ [ @(kMTMathAtomNumber), @(kMTMathAtomVariable)], @[ ], @"5x^{2}"], + @[ @"5{x}^2", @ [ @(kMTMathAtomNumber), @(kMTMathAtomOrdGroup)], @[ ], @"5{x}^{2}"], ]; } @@ -152,9 +152,9 @@ - (void) testSuperScript @[ @"x_{2_3}", @[ @(kMTMathAtomVariable) ], @[ @(kMTMathAtomNumber)], @[ @(kMTMathAtomNumber),], @"x_{2_{3}}"], @[ @"x_{_2*}", @[ @(kMTMathAtomVariable) ], @[ @(kMTMathAtomOrdinary), @(kMTMathAtomBinaryOperator)], @[ @(kMTMathAtomNumber),], @"x_{{}_{2}*}"], @[ @"_2", @ [ @(kMTMathAtomOrdinary)], @[ @(kMTMathAtomNumber) ], @"{}_{2}" ], - @[ @"{}_2", @ [ @(kMTMathAtomOrdinary)], @[ @(kMTMathAtomNumber) ], @"{}_{2}" ], + @[ @"{}_2", @ [ @(kMTMathAtomOrdGroup)], @[ @(kMTMathAtomNumber) ], @"{}_{2}" ], @[ @"x__2", @[ @(kMTMathAtomVariable), @(kMTMathAtomOrdinary) ], @[ ], @"x_{}{}_{2}"], - @[ @"5{x}_2", @ [ @(kMTMathAtomNumber), @(kMTMathAtomVariable)], @[ ], @"5x_{2}"], + @[ @"5{x}_2", @ [ @(kMTMathAtomNumber), @(kMTMathAtomOrdGroup)], @[ ], @"5{x}_{2}"], ]; } @@ -407,16 +407,21 @@ - (void) testSqrtAtEnd - (void) testSqrtInGroup { - // A \sqrt with no argument inside a group exercises a different path - // (empty radicand via the oneCharOnly stop-char guard) than the - // end-of-input case, and must also not crash. + // A \sqrt with no argument inside a brace group. With MTMathGroup semantics, + // the {…} wraps as an Ord subformula — the radical lives inside the group. + // This exercises the MTMathGroup path without crashing. NSString *str = @"{\\sqrt}"; MTMathList* list = [MTMathListBuilder buildFromString:str]; NSString* desc = [NSString stringWithFormat:@"Error for string:%@", str]; XCTAssertNotNil(list, @"%@", desc); XCTAssertEqualObjects(@(list.atoms.count), @1, @"%@", desc); - MTRadical* rad = list.atoms[0]; + // The outer atom is an MTMathGroup (Ord subformula wrapping the radical). + MTMathGroup* group = list.atoms[0]; + XCTAssertEqual(group.type, kMTMathAtomOrdGroup, @"%@", desc); + + XCTAssertEqualObjects(@(group.innerList.atoms.count), @1, @"%@", desc); + MTRadical* rad = group.innerList.atoms[0]; XCTAssertEqual(rad.type, kMTMathAtomRadical, @"%@", desc); MTMathList *subList = rad.radicand; @@ -424,9 +429,9 @@ - (void) testSqrtInGroup XCTAssertEqualObjects(@(subList.atoms.count), @0, @"%@", desc); XCTAssertNil(rad.degree, @"%@", desc); - // convert it back to latex + // convert it back to latex — braces preserved, radical serializes as \sqrt{} NSString* latex = [MTMathListBuilder mathListToString:list]; - XCTAssertEqualObjects(latex, @"\\sqrt{}", @"%@", desc); + XCTAssertEqualObjects(latex, @"{\\sqrt{}}", @"%@", desc); } - (void) testSqrtInSqrt @@ -3384,4 +3389,61 @@ - (void) testBoxRoundTrip [MTMathListBuilder buildFromString:@"\\mathstrut"]], @"\\vphantom{(}"); } +- (void)testBraceGrouping +{ + // x{\scriptstyle y}z — the issue #177 case. The group is a distinct atom; + // \scriptstyle lives inside it; round-trips with braces preserved. + NSError* error = nil; + MTMathList* list = [MTMathListBuilder buildFromString:@"x{\\scriptstyle y}z" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list + types:@[ @(kMTMathAtomVariable), @(kMTMathAtomOrdGroup), @(kMTMathAtomVariable) ] + desc:@"x{\\scriptstyle y}z"]; + MTMathGroup* group = (MTMathGroup*) list.atoms[1]; + XCTAssertTrue([group isKindOfClass:[MTMathGroup class]]); + [self checkAtomTypes:group.innerList + types:@[ @(kMTMathAtomStyle), @(kMTMathAtomVariable) ] + desc:@"group innerList"]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"x{\\scriptstyle y}z"); + + // {x}^2 — the script attaches to the whole group. + list = [MTMathListBuilder buildFromString:@"{x}^2" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list types:@[ @(kMTMathAtomOrdGroup) ] desc:@"{x}^2"]; + group = (MTMathGroup*) list.atoms[0]; + XCTAssertNotNil(group.superScript, @"superscript must be on the group"); + [self checkAtomTypes:group.superScript types:@[ @(kMTMathAtomNumber) ] desc:@"{x}^2 script"]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"{x}^{2}"); + + // {a+b} — Bin classification stays inside the group; round-trips with braces. + list = [MTMathListBuilder buildFromString:@"{a+b}c" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list types:@[ @(kMTMathAtomOrdGroup), @(kMTMathAtomVariable) ] desc:@"{a+b}c"]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"{a+b}c"); + + // Nested {{x}} — outer group's innerList holds a single inner group. + list = [MTMathListBuilder buildFromString:@"{{x}}" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list types:@[ @(kMTMathAtomOrdGroup) ] desc:@"{{x}}"]; + group = (MTMathGroup*) list.atoms[0]; + [self checkAtomTypes:group.innerList types:@[ @(kMTMathAtomOrdGroup) ] desc:@"{{x}} inner"]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"{{x}}"); + + // Empty {} — a group with an empty innerList; round-trips as {}. + list = [MTMathListBuilder buildFromString:@"{}" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list types:@[ @(kMTMathAtomOrdGroup) ] desc:@"{}"]; + group = (MTMathGroup*) list.atoms[0]; + XCTAssertEqual(group.innerList.atoms.count, 0u); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"{}"); + + // Field braces must NOT wrap: ^{\scriptstyle y}z keeps the style scoped to the + // superscript field, with no group wrapper and no leak onto z. + list = [MTMathListBuilder buildFromString:@"x^{\\scriptstyle y}z" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list types:@[ @(kMTMathAtomVariable), @(kMTMathAtomVariable) ] desc:@"x^{...}z"]; + MTMathList* super0 = ((MTMathAtom*) list.atoms[0]).superScript; + [self checkAtomTypes:super0 types:@[ @(kMTMathAtomStyle), @(kMTMathAtomVariable) ] desc:@"super field"]; +} + @end From b7a705aa282269362d50d2cca04f31aabe935282 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 1 Jul 2026 21:05:44 +0530 Subject: [PATCH 3/5] [item 3] Render MTMathGroup in the typesetter (fix #177 style leak) Co-Authored-By: Claude Sonnet 4.6 --- iosMath/render/internal/MTTypesetter.m | 26 ++++++++++++++ iosMathTests/MTTypesetterTest.m | 49 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index b0c3d92..fd48a06 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -51,6 +51,7 @@ NSUInteger getInterElementSpaceArrayIndexForType(MTMathAtomType type, BOOL row) case kMTMathAtomColor: case kMTMathAtomColorbox: case kMTMathAtomOrdinary: + case kMTMathAtomOrdGroup: // Ord group is spaced as Ordinary case kMTMathAtomPlaceholder: // A placeholder is treated as ordinary case kMTMathAtomText: // Text blocks are spaced as Ord return 0; @@ -708,6 +709,31 @@ - (void) createDisplayAtoms:(NSArray*) preprocessed break; } + case kMTMathAtomOrdGroup: { + // A brace group {…}: an Ord subformula. Lay out its sub-mlist with a + // fresh typesetter — that recursion scopes any interior style node + // (the #177 fix) — then place the child inline like \color and run + // scripts on the whole group. + if (_currentLine.length > 0) { + [self addDisplayLine]; + } + // Spaced as Ordinary: reclassify before any inter-element lookup + // (mirrors the Box case), so it never reaches the default assert. + [self addInterElementSpace:prevNode currentType:kMTMathAtomOrdinary]; + atom.type = kMTMathAtomOrdinary; + + MTMathGroup* groupAtom = (MTMathGroup*) atom; + MTMathListDisplay* child = [MTTypesetter createLineForMathList:groupAtom.innerList font:_font style:_style]; + child.position = _currentPosition; + _currentPosition.x += child.width; + [_displayAtoms addObject:child]; + + if (atom.subScript || atom.superScript) { + [self makeScripts:atom display:child index:atom.indexRange.location delta:0]; + } + break; + } + case kMTMathAtomText: { // Flush any pending math run so the text block stands alone. if (_currentLine.length > 0) { diff --git a/iosMathTests/MTTypesetterTest.m b/iosMathTests/MTTypesetterTest.m index 572e8f3..35bcaf5 100644 --- a/iosMathTests/MTTypesetterTest.m +++ b/iosMathTests/MTTypesetterTest.m @@ -3093,4 +3093,53 @@ - (void) testLeadingNegativeKernRendersAtNegativeX XCTAssertNotNil(d); } +- (void)testScriptStyleDoesNotLeakPastBraceGroup { + // Issue #177: x{\scriptstyle y}z — \scriptstyle must be scoped to the group; + // z must render in the outer (display) style, not scriptstyle. + MTMathListDisplay* display = [self displayForLaTeX:@"x{\\scriptstyle y}z"]; + XCTAssertNotNil(display); + XCTAssertEqual(display.subDisplays.count, 3u, + @"Expected [CTLine(x), group MTMathListDisplay, CTLine(z)]"); + + MTDisplay* xLine = display.subDisplays[0]; + MTDisplay* groupSub = display.subDisplays[1]; + MTDisplay* zLine = display.subDisplays[2]; + XCTAssertTrue([xLine isKindOfClass:[MTCTLineDisplay class]]); + XCTAssertTrue([groupSub isKindOfClass:[MTMathListDisplay class]]); + XCTAssertTrue([zLine isKindOfClass:[MTCTLineDisplay class]]); + + // z is full display style (same ascent as x) — the leak is fixed. + XCTAssertEqualWithAccuracy(zLine.ascent, xLine.ascent, 0.01, + @"z leaked \\scriptstyle: z ascent %.3f != x ascent %.3f", + zLine.ascent, xLine.ascent); + // The group's interior y IS in scriptstyle — smaller than display-style x. + XCTAssertLessThan(groupSub.ascent, xLine.ascent, + @"group interior should be scriptstyle (smaller)"); +} + +- (void)testBraceGroupRenders { + // {x}^2 renders without error and produces a group MTMathListDisplay. + MTMathListDisplay* display = [self displayForLaTeX:@"{x}^2"]; + XCTAssertNotNil(display); + BOOL hasGroup = NO; + for (MTDisplay* d in display.subDisplays) { + if ([d isKindOfClass:[MTMathListDisplay class]]) { hasGroup = YES; break; } + } + XCTAssertTrue(hasGroup, @"Expected a group MTMathListDisplay for {x}^2"); +} + +- (void)testBraceGroupAddsNoSpuriousSpacing { + // a{b}c — an Ordinary-class group adds no inter-element space (Ord->Ord = none). + MTMathListDisplay* display = [self displayForLaTeX:@"a{b}c"]; + XCTAssertNotNil(display); + XCTAssertEqual(display.subDisplays.count, 3u); + MTDisplay* aLine = display.subDisplays[0]; + MTDisplay* groupSub = display.subDisplays[1]; + MTDisplay* cLine = display.subDisplays[2]; + XCTAssertEqualWithAccuracy(groupSub.position.x, aLine.position.x + aLine.width, 0.01, + @"Ord->Ord: no space before the group"); + XCTAssertEqualWithAccuracy(cLine.position.x, groupSub.position.x + groupSub.width, 0.01, + @"Ord->Ord: no space after the group"); +} + @end From f99517a619354e13936b9051b6030344b55ec188 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 2 Jul 2026 01:02:09 +0530 Subject: [PATCH 4/5] Fix stale _groupWasTransformedByStopCommand leaking into enclosing group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-and-clear the flag in the {…} branch. Previously a \over/\atop transform inside an INNER group left the flag set, so the ENCLOSING group was wrongly treated as transformed and dropped — reintroducing the #177 \scriptstyle leak when a leading inner group was \over-ed (e.g. {{a \over b}\scriptstyle c}z). Adds nested regression tests. Co-Authored-By: Claude --- iosMath/lib/MTMathListBuilder.m | 8 +++++++ iosMathTests/MTMathListBuilderTest.m | 34 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index e4f7394..7bd90ba 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -234,7 +234,15 @@ - (MTMathList*)buildInternal:(BOOL) oneCharOnly stopChar:(unichar) stop // inner error already set (e.g. missing closing brace); propagate. return nil; } + // Read-and-clear: a \over/\atop-\class transform fired by an INNER + // group must not leak into THIS (enclosing) group's decision. The flag + // is set in stopCommand: and reset at the top of each buildInternal:, + // but the inner recursion runs between our reset and this read, so + // without clearing here a transformed inner group would wrongly + // suppress wrapping of the outer group (dropping it + leaking any + // \scriptstyle inside it — a #177 regression). BOOL transformed = _groupWasTransformedByStopCommand; + _groupWasTransformedByStopCommand = NO; if (oneCharOnly || transformed) { // Field brace (^{…}, _{…}, \frac{…}, command argument): the {…} // *is* the field. Flatten and return it as the field — unchanged. diff --git a/iosMathTests/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index 574d76f..d42db0b 100644 --- a/iosMathTests/MTMathListBuilderTest.m +++ b/iosMathTests/MTMathListBuilderTest.m @@ -3446,4 +3446,38 @@ - (void)testBraceGrouping [self checkAtomTypes:super0 types:@[ @(kMTMathAtomStyle), @(kMTMathAtomVariable) ] desc:@"super field"]; } +- (void)testBraceGroupingAroundOverTransform +{ + // Regression: an inner group transformed by \over must NOT cause the + // ENCLOSING group to be dropped. {{a \over b}c} → the outer group survives, + // wrapping [Fraction, Variable(c)] (the inner {a \over b} became a Fraction, + // but that transform is scoped to the inner group only). + NSError* error = nil; + MTMathList* list = [MTMathListBuilder buildFromString:@"{{a \\over b}c}" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list types:@[ @(kMTMathAtomOrdGroup) ] desc:@"{{a \\over b}c} top"]; + MTMathGroup* group = (MTMathGroup*) list.atoms[0]; + XCTAssertTrue([group isKindOfClass:[MTMathGroup class]]); + [self checkAtomTypes:group.innerList + types:@[ @(kMTMathAtomFraction), @(kMTMathAtomVariable) ] + desc:@"{{a \\over b}c} inner"]; + + // The #177 leak variant: \scriptstyle inside the enclosing group must stay + // scoped to that group even when a leading inner group was \over-transformed. + // Before the fix the inner group's "transformed" flag leaked upward, the + // outer group was dropped, and \scriptstyle escaped onto z. + list = [MTMathListBuilder buildFromString:@"{{a \\over b}\\scriptstyle c}z" error:&error]; + XCTAssertNil(error); + [self checkAtomTypes:list + types:@[ @(kMTMathAtomOrdGroup), @(kMTMathAtomVariable) ] + desc:@"{{a \\over b}\\scriptstyle c}z top"]; + group = (MTMathGroup*) list.atoms[0]; + [self checkAtomTypes:group.innerList + types:@[ @(kMTMathAtomFraction), @(kMTMathAtomStyle), @(kMTMathAtomVariable) ] + desc:@"group innerList"]; + // z is a separate top-level atom — \scriptstyle did NOT leak out of the group. + XCTAssertEqual(((MTMathAtom*) list.atoms[1]).type, kMTMathAtomVariable, + @"z must be a plain top-level variable, not style-contaminated"); +} + @end From adbe7f9f2aa8667c3c05077021125d1cba082a45 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 2 Jul 2026 01:35:48 +0530 Subject: [PATCH 5/5] Address review #247: fix prevAtom in \over-transformed path; drop redundant spacing case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1 (HIGH, from review): in the {…} transformed path — when a group-transforming command (\over/\atop/\choose/\brack/\brace) fires inside a {…} group — prevAtom was not updated after appending the resulting fraction. A following ^ / _ / ' then attached to a spurious empty Ord instead of the fraction (e.g. {a \over b}^2 serialized as \frac{a}{b}{}^{2}). Restore prevAtom = [sublist.atoms lastObject], mirroring the pre-grouping behavior and the shared append path below. Adds regression test testScriptAfterOverTransformedGroupAttachesToFraction. Issue 3 (MEDIUM, from review): remove the redundant kMTMathAtomOrdGroup case from getInterElementSpaceArrayIndexForType. createDisplayAtoms reclassifies OrdGroup -> Ordinary before any spacing lookup (mirroring kMTMathAtomBox, which has no switch entry), so the case was unreachable dead code; the reclassification is now the single source of truth. Issue 2 (the _groupWasTransformedByStopCommand leak) was already fixed in f99517a (read-and-clear at lines 244-245), pushed after this review was filed against b7a705a; no change here. Co-Authored-By: Claude --- iosMath/lib/MTMathListBuilder.m | 5 +++++ iosMath/render/internal/MTTypesetter.m | 1 - iosMathTests/MTMathListBuilderTest.m | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 7bd90ba..6919df8 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -250,6 +250,11 @@ - (MTMathList*)buildInternal:(BOOL) oneCharOnly stopChar:(unichar) stop // \brack, \brace) fired inside this group. The resulting fraction // replaces the group in the parent list (TeX behavior) — do NOT // wrap in MTMathGroup. Fall through to continue after appending. + // Update prevAtom to the last appended atom so a following ^ / _ / + // prime attaches to the fraction (or field atom), not a spurious + // empty Ord — mirrors the pre-grouping behavior and the shared + // append path below (prevAtom = atom). + prevAtom = [sublist.atoms lastObject]; [list append:sublist]; if (oneCharOnly) { return list; diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index fd48a06..2e53a4c 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -51,7 +51,6 @@ NSUInteger getInterElementSpaceArrayIndexForType(MTMathAtomType type, BOOL row) case kMTMathAtomColor: case kMTMathAtomColorbox: case kMTMathAtomOrdinary: - case kMTMathAtomOrdGroup: // Ord group is spaced as Ordinary case kMTMathAtomPlaceholder: // A placeholder is treated as ordinary case kMTMathAtomText: // Text blocks are spaced as Ord return 0; diff --git a/iosMathTests/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index d42db0b..61b4f87 100644 --- a/iosMathTests/MTMathListBuilderTest.m +++ b/iosMathTests/MTMathListBuilderTest.m @@ -3480,4 +3480,24 @@ - (void)testBraceGroupingAroundOverTransform @"z must be a plain top-level variable, not style-contaminated"); } +- (void)testScriptAfterOverTransformedGroupAttachesToFraction +{ + // {a \over b}^2 — \over transforms the enclosing group into a Fraction at + // the parent level (TeX group-transformation). The following ^2 must attach + // to THAT fraction, not to a spurious empty Ord. Before the prevAtom fix the + // transformed path appended the fraction without updating prevAtom, so the ^ + // branch allocated an empty Ord and hung the superscript on it instead. + NSError* error = nil; + MTMathList* list = [MTMathListBuilder buildFromString:@"{a \\over b}^2" error:&error]; + XCTAssertNil(error); + XCTAssertEqualObjects(@(list.atoms.count), @1, @"expected a single fraction atom, not fraction + empty Ord"); + MTMathAtom* frac = list.atoms[0]; + XCTAssertEqual(frac.type, kMTMathAtomFraction, @"expected the \\over fraction"); + XCTAssertNotNil(frac.superScript, @"^2 must attach to the fraction"); + [self checkAtomTypes:frac.superScript types:@[ @(kMTMathAtomNumber) ] desc:@"{a \\over b}^2 superscript"]; + // Round-trip: \over normalizes to \frac{}{} on serialization (existing + // behavior); the superscript stays on the fraction. + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\frac{a}{b}^{2}"); +} + @end