diff --git a/iosMath/lib/MTMathList.h b/iosMath/lib/MTMathList.h index 49b45fa..939666c 100644 --- a/iosMath/lib/MTMathList.h +++ b/iosMath/lib/MTMathList.h @@ -66,6 +66,8 @@ typedef NS_ENUM(NSUInteger, MTMathAtomType) /// \textit, \textsf, \texttt. Captured raw at parse time; the body cannot /// contain math. kMTMathAtomText = 19, + /// A box atom (phantom/smash/lap family). Script-capable (< kMTMathAtomBoundary). + kMTMathAtomBox = 20, // Atoms after this point do not support subscripts or superscripts @@ -611,6 +613,39 @@ typedef NS_ENUM(NSUInteger, MTMathStackConstructionKind) { @end +/** Horizontal alignment of a box's child relative to the box origin (drives the lap draw offset). */ +typedef NS_ENUM(NSUInteger, MTBoxHAlign) { + kMTBoxHAlignLeft = 0, ///< \rlap: child left edge at origin, offset 0 + kMTBoxHAlignCenter, ///< \clap: offset -childWidth/2 + kMTBoxHAlignRight, ///< \llap: child right edge at origin, offset -childWidth +}; + +/** An atom representing a box element: the phantom/smash/lap family. + @note As with `MTMathColorbox`, the usual nucleus/script fields are unused; + the content lives in `innerList` and the flags below select the variant. */ +@interface MTMathBox : MTMathAtom + +/// Creates an empty box atom (type = kMTMathAtomBox). +- (instancetype) init NS_DESIGNATED_INITIALIZER; + +/// Throws unless type == kMTMathAtomBox. +- (instancetype) initWithType:(MTMathAtomType)type value:(NSString *)value; + +/// The wrapped math content. +@property (nonatomic, nullable) MTMathList* innerList; +/// Report the child's width (YES) or zero width (NO). +@property (nonatomic) BOOL keepWidth; +/// Report the child's ascent (YES) or zero ascent (NO). +@property (nonatomic) BOOL keepHeight; +/// Report the child's descent (YES) or zero descent (NO). +@property (nonatomic) BOOL keepDepth; +/// Draw the measured child (YES) or suppress drawing entirely (NO, phantom). +@property (nonatomic) BOOL drawChild; +/// Horizontal draw offset applied when keepWidth == NO (laps). +@property (nonatomic) MTBoxHAlign hAlign; + +@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 863fabe..f782139 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -66,6 +66,8 @@ static BOOL isNotBinaryOperator(MTMathAtom* prevNode) return @"Stack"; case kMTMathAtomText: return @"Text"; + case kMTMathAtomBox: + return @"Box"; case kMTMathAtomBoundary: return @"Boundary"; case kMTMathAtomSpace: @@ -153,9 +155,12 @@ + (instancetype)atomWithType:(MTMathAtomType)type value:(NSString *)value return [[MTTextAtom alloc] initWithText:value ?: @"" style:kMTTextStyleRoman]; + case kMTMathAtomBox: + return [[MTMathBox alloc] init]; + case kMTMathAtomSpace: return [[MTMathSpace alloc] initWithSpace:0]; - + case kMTMathAtomColor: return [[MTMathColor alloc] init]; @@ -997,6 +1002,79 @@ - (instancetype)finalized @end +#pragma mark - MTMathBox + +@implementation MTMathBox + +- (instancetype)init +{ + self = [super initWithType:kMTMathAtomBox value:@""]; + return self; +} + +- (instancetype)initWithType:(MTMathAtomType)type value:(NSString *)value +{ + if (type == kMTMathAtomBox) { + return [self init]; + } + @throw [NSException exceptionWithName:@"InvalidMethod" + reason:@"[MTMathBox initWithType:value:] cannot be called. Use [MTMathBox init] instead." + userInfo:nil]; +} + +// Lossy by design (LLD §3.4): pick the closest LaTeX command from the flag matrix. +- (NSString *)stringValue +{ + NSString* cmd; + NSString* inner = self.innerList.stringValue ?: @""; + if (!self.drawChild) { + // phantom family + if (self.keepWidth && self.keepHeight && self.keepDepth) cmd = @"\\phantom"; + else if (self.keepWidth) cmd = @"\\hphantom"; + else cmd = @"\\vphantom"; // covers \mathstrut -> \vphantom{(} + } else if (!self.keepWidth) { + // lap family + switch (self.hAlign) { + case kMTBoxHAlignRight: cmd = @"\\llap"; break; + case kMTBoxHAlignCenter: cmd = @"\\clap"; break; + case kMTBoxHAlignLeft: + default: cmd = @"\\rlap"; break; + } + } else { + // smash family + if (!self.keepHeight && !self.keepDepth) cmd = @"\\smash"; + else if (self.keepDepth && !self.keepHeight) cmd = @"\\smash[t]"; + else cmd = @"\\smash[b]"; + } + return [NSString stringWithFormat:@"%@{%@}", cmd, inner]; +} + +- (void)appendLaTeXToString:(NSMutableString *)str +{ + [str appendString:self.stringValue]; +} + +- (id)copyWithZone:(NSZone *)zone +{ + MTMathBox* op = [super copyWithZone:zone]; + op.innerList = [self.innerList copyWithZone:zone]; + op->_keepWidth = self.keepWidth; + op->_keepHeight = self.keepHeight; + op->_keepDepth = self.keepDepth; + op->_drawChild = self.drawChild; + op->_hAlign = self.hAlign; + return op; +} + +- (instancetype)finalized +{ + MTMathBox *newBox = [super finalized]; + newBox.innerList = newBox.innerList.finalized; + return newBox; +} + +@end + #pragma mark - MTMathTable @interface MTMathTable () diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index fb51fc4..634ef8f 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -762,6 +762,30 @@ - (MTMathAtom*) getBoundaryAtom:(NSString*) delimiterType return boundary; } +// Maps each phantom/smash/lap command to its MTMathBox flag set. +// keys: kW=keepWidth, kH=keepHeight, kD=keepDepth, draw=drawChild, hAlign, acceptsTB, synthParen ++ (NSDictionary*) boxCommands +{ + static NSDictionary* commands = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + commands = @{ + @"phantom": @{@"kW":@YES, @"kH":@YES, @"kD":@YES, @"draw":@NO}, + @"hphantom": @{@"kW":@YES, @"kH":@NO, @"kD":@NO, @"draw":@NO}, + @"vphantom": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@NO}, + @"mathstrut": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@NO, @"synthParen":@YES}, + @"smash": @{@"kW":@YES, @"kH":@NO, @"kD":@NO, @"draw":@YES, @"acceptsTB":@YES}, + @"llap": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@YES, @"hAlign":@(kMTBoxHAlignRight)}, + @"rlap": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@YES, @"hAlign":@(kMTBoxHAlignLeft)}, + @"clap": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@YES, @"hAlign":@(kMTBoxHAlignCenter)}, + @"mathllap": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@YES, @"hAlign":@(kMTBoxHAlignRight)}, + @"mathrlap": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@YES, @"hAlign":@(kMTBoxHAlignLeft)}, + @"mathclap": @{@"kW":@NO, @"kH":@YES, @"kD":@YES, @"draw":@YES, @"hAlign":@(kMTBoxHAlignCenter)}, + }; + }); + return commands; +} + - (MTMathAtom*) atomForCommand:(NSString*) command { MTMathAtom* atom = [MTMathAtomFactory atomForLatexSymbolName:command]; @@ -923,7 +947,56 @@ - (MTMathAtom*) atomForCommand:(NSString*) command mathColorbox.colorString = colorStr; mathColorbox.innerList = [self buildInternal:true]; return mathColorbox; - } else { + } + + NSDictionary* boxSpec = [MTMathListBuilder boxCommands][command]; + if (boxSpec) { + MTMathBox* box = [MTMathBox new]; + box.keepWidth = [boxSpec[@"kW"] boolValue]; + box.keepHeight = [boxSpec[@"kH"] boolValue]; + box.keepDepth = [boxSpec[@"kD"] boolValue]; + box.drawChild = [boxSpec[@"draw"] boolValue]; + box.hAlign = (MTBoxHAlign)[boxSpec[@"hAlign"] unsignedIntegerValue]; + + if ([boxSpec[@"synthParen"] boolValue]) { + // \mathstrut: no argument; synthetic inner list with a single open paren. + MTMathList* inner = [MTMathList new]; + MTMathAtom* paren = [MTMathAtomFactory atomForCharacter:'(']; + [inner addAtom:paren]; + box.innerList = inner; + return box; + } + + if ([boxSpec[@"acceptsTB"] boolValue] && [self hasCharacters]) { + // \smash[t]/[b]: optional [t]/[b] before the {X} argument (\sqrt[…] pattern). + unichar ch = [self getNextCharacter]; + if (ch == '[') { + NSMutableString* opt = [NSMutableString string]; + BOOL foundClose = NO; + while ([self hasCharacters]) { + unichar c = [self getNextCharacter]; + if (c == ']') { foundClose = YES; break; } + [opt appendString:[NSString stringWithCharacters:&c length:1]]; + } + if (!foundClose) { + // Mirror \sqrt[…]: a missing ']' is a parse error, not a silent recovery. + [self setError:MTParseErrorCharacterNotFound message:@"Expected character not found: ]"]; + return nil; + } + NSString* o = [opt stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([o isEqualToString:@"t"]) { box.keepHeight = NO; box.keepDepth = YES; } + else if ([o isEqualToString:@"b"]) { box.keepHeight = YES; box.keepDepth = NO; } + // any other value: ignore, leave smash-both flags (no crash). + } else { + [self unlookCharacter]; + } + } + + box.innerList = [self buildInternal:true]; + return box; + } + + { NSString* errorMessage = [NSString stringWithFormat:@"Invalid command \\%@", command]; [self setError:MTParseErrorInvalidCommand message:errorMessage]; return nil; diff --git a/iosMath/render/MTMathListDisplay.h b/iosMath/render/MTMathListDisplay.h index 7e2fb0d..8763c98 100644 --- a/iosMath/render/MTMathListDisplay.h +++ b/iosMath/render/MTMathListDisplay.h @@ -226,6 +226,11 @@ typedef NS_ENUM(unsigned int, MTLinePosition) { @end +/** Display for the box family (phantom/smash/lap). Reports geometry selected by + the keep* flags and either draws or suppresses its measured child. */ +@interface MTMathBoxDisplay : MTDisplay +@end + /// Rendering of an list with delimiters @interface MTInnerDisplay : MTDisplay diff --git a/iosMath/render/MTMathListDisplay.m b/iosMath/render/MTMathListDisplay.m index 7266fc3..b485e15 100644 --- a/iosMath/render/MTMathListDisplay.m +++ b/iosMath/render/MTMathListDisplay.m @@ -988,6 +988,73 @@ - (void)dealloc @end +#pragma mark - MTMathBoxDisplay + +@implementation MTMathBoxDisplay + +- (instancetype) initWithChild:(MTMathListDisplay*) child + keepWidth:(BOOL) keepWidth + keepHeight:(BOOL) keepHeight + keepDepth:(BOOL) keepDepth + drawChild:(BOOL) drawChild + hAlign:(MTBoxHAlign) hAlign + range:(NSRange) range +{ + self = [super init]; + if (self) { + _child = child; + _drawChild = drawChild; + _keepWidth = keepWidth; + _hAlign = hAlign; + self.width = keepWidth ? child.width : 0; + self.ascent = keepHeight ? child.ascent : 0; + self.descent = keepDepth ? child.descent : 0; + self.range = range; + } + return self; +} + +- (void)setTextColor:(MTColor *)textColor +{ + [super setTextColor:textColor]; + self.child.textColor = textColor; // forward so smash/lap inherit label color +} + +- (void) setPosition:(CGPoint)position +{ + super.position = position; + [self updateChildPosition]; +} + +- (void) updateChildPosition +{ + // Push an absolute position down to the child (mirrors MTRadicalDisplay / + // MTInnerDisplay) so draw: never has to mutate child state or juggle the CTM. + CGFloat offset = 0; + if (!self.keepWidth) { + switch (self.hAlign) { + case kMTBoxHAlignRight: offset = -self.child.width; break; // \llap + case kMTBoxHAlignCenter: offset = -self.child.width / 2; break; // \clap + case kMTBoxHAlignLeft: + default: offset = 0; break; // \rlap + } + } + self.child.position = CGPointMake(self.position.x + offset, self.position.y); +} + +- (void)draw:(CGContextRef)context +{ + [super draw:context]; // base draws only localBackgroundColor (a no-op here) + if (!self.drawChild) { + return; // phantom: geometry already flowed up at measure time + } + // Child holds its own absolute position (set in setPosition:); it translates + // the CTM by that position itself, so there is nothing to do here but draw it. + [self.child draw:context]; +} + +@end + #pragma mark - MTInnerDisplay @implementation MTInnerDisplay { diff --git a/iosMath/render/internal/MTMathListDisplayInternal.h b/iosMath/render/internal/MTMathListDisplayInternal.h index 76a09b5..9634aae 100644 --- a/iosMath/render/internal/MTMathListDisplayInternal.h +++ b/iosMath/render/internal/MTMathListDisplayInternal.h @@ -163,6 +163,23 @@ NS_ASSUME_NONNULL_BEGIN @end +@interface MTMathBoxDisplay () + +- (instancetype) initWithChild:(MTMathListDisplay*) child + keepWidth:(BOOL) keepWidth + keepHeight:(BOOL) keepHeight + keepDepth:(BOOL) keepDepth + drawChild:(BOOL) drawChild + hAlign:(MTBoxHAlign) hAlign + range:(NSRange) range NS_DESIGNATED_INITIALIZER; + +@property (nonatomic) MTMathListDisplay* child; +@property (nonatomic) BOOL drawChild; +@property (nonatomic) BOOL keepWidth; +@property (nonatomic) MTBoxHAlign hAlign; + +@end + @interface MTInnerDisplay () - (instancetype) initWithInner:(MTMathListDisplay*) inner leftDelimiter:(MTDisplay*) leftDelimiter rightDelimiter:(MTDisplay*) rightDelimiter atIndex:(NSUInteger) index NS_DESIGNATED_INITIALIZER; diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index 3cf72a1..b0c3d92 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -679,6 +679,35 @@ - (void) createDisplayAtoms:(NSArray*) preprocessed break; } + case kMTMathAtomBox: { + // stash the existing layout + if (_currentLine.length > 0) { + [self addDisplayLine]; + } + // Box spacing class is Ordinary: reclassify before any inter-element lookup + // (mirrors overline/accent), so it never reaches the getInterElementSpace default assert. + [self addInterElementSpace:prevNode currentType:kMTMathAtomOrdinary]; + atom.type = kMTMathAtomOrdinary; + + MTMathBox* boxAtom = (MTMathBox*) atom; + MTMathListDisplay* child = [MTTypesetter createLineForMathList:boxAtom.innerList font:_font style:_style]; + MTMathBoxDisplay* display = [[MTMathBoxDisplay alloc] initWithChild:child + keepWidth:boxAtom.keepWidth + keepHeight:boxAtom.keepHeight + keepDepth:boxAtom.keepDepth + drawChild:boxAtom.drawChild + hAlign:boxAtom.hAlign + range:atom.indexRange]; + display.position = _currentPosition; + _currentPosition.x += display.width; // 0 for vphantom/laps + [_displayAtoms addObject:display]; + + if (atom.subScript || atom.superScript) { + [self makeScripts:atom display:display 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/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index 24c2b95..4f87a62 100644 --- a/iosMathTests/MTMathListBuilderTest.m +++ b/iosMathTests/MTMathListBuilderTest.m @@ -1453,6 +1453,7 @@ - (void) testDisplayLines @[@"}a", @(MTParseErrorMismatchBraces)], @[@"\\notacommand", @(MTParseErrorInvalidCommand)], @[@"\\sqrt[5+3", @(MTParseErrorCharacterNotFound)], + @[@"\\smash[t", @(MTParseErrorCharacterNotFound)], // missing ] on smash optional arg @[@"{5+3", @(MTParseErrorMismatchBraces)], @[@"5+3}", @(MTParseErrorMismatchBraces)], @[@"{1+\\frac{3+2", @(MTParseErrorMismatchBraces)], @@ -3252,4 +3253,78 @@ - (void)testAngleBracketDelimiterConsistency XCTAssertEqualObjects(serialized, @"\\left< x\\right> ", @"serialized LaTeX unchanged"); } +- (void) testParsePhantomFamily +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"\\phantom{x}"]; + [self checkAtomTypes:list types:@[@(kMTMathAtomBox)] desc:@"phantom"]; + MTMathBox* box = list.atoms[0]; + XCTAssertTrue(box.keepWidth && box.keepHeight && box.keepDepth && !box.drawChild); + XCTAssertEqual(box.innerList.atoms.count, 1); + + // \phantom x : single-character argument (buildInternal:true), no braces + MTMathBox* box2 = [MTMathListBuilder buildFromString:@"\\phantom x"].atoms[0]; + XCTAssertEqual(box2.innerList.atoms.count, 1); + + MTMathBox* h = [MTMathListBuilder buildFromString:@"\\hphantom{x}"].atoms[0]; + XCTAssertTrue(h.keepWidth && !h.keepHeight && !h.keepDepth && !h.drawChild); + + MTMathBox* v = [MTMathListBuilder buildFromString:@"\\vphantom{x}"].atoms[0]; + XCTAssertTrue(!v.keepWidth && v.keepHeight && v.keepDepth && !v.drawChild); + + // \mathstrut: no argument, synthetic inner = open paren "(", vphantom flags + MTMathBox* strut = [MTMathListBuilder buildFromString:@"\\mathstrut"].atoms[0]; + XCTAssertTrue(!strut.keepWidth && strut.keepHeight && strut.keepDepth && !strut.drawChild); + XCTAssertEqual(strut.innerList.atoms.count, 1); + XCTAssertEqualObjects(((MTMathAtom*)strut.innerList.atoms[0]).nucleus, @"("); +} + +- (void) testParseSmash +{ + MTMathBox* s = [MTMathListBuilder buildFromString:@"\\smash{x}"].atoms[0]; + XCTAssertTrue(s.keepWidth && !s.keepHeight && !s.keepDepth && s.drawChild); + + MTMathBox* st = [MTMathListBuilder buildFromString:@"\\smash[t]{x}"].atoms[0]; + XCTAssertTrue(st.keepWidth && !st.keepHeight && st.keepDepth && st.drawChild); + + MTMathBox* sb = [MTMathListBuilder buildFromString:@"\\smash[b]{x}"].atoms[0]; + XCTAssertTrue(sb.keepWidth && sb.keepHeight && !sb.keepDepth && sb.drawChild); + + // bad optional value: ignore bracket, smash both, no crash (PRD §7.2.2) + MTMathBox* sx = [MTMathListBuilder buildFromString:@"\\smash[q]{x}"].atoms[0]; + XCTAssertTrue(!sx.keepHeight && !sx.keepDepth); +} + +- (void) testParseLaps +{ + NSDictionary* cases = @{ + @"\\llap{x}": @(kMTBoxHAlignRight), @"\\mathllap{x}": @(kMTBoxHAlignRight), + @"\\rlap{x}": @(kMTBoxHAlignLeft), @"\\mathrlap{x}": @(kMTBoxHAlignLeft), + @"\\clap{x}": @(kMTBoxHAlignCenter), @"\\mathclap{x}": @(kMTBoxHAlignCenter), + }; + for (NSString* latex in cases) { + MTMathBox* box = [MTMathListBuilder buildFromString:latex].atoms[0]; + XCTAssertEqual(box.type, kMTMathAtomBox, @"%@", latex); + XCTAssertTrue(!box.keepWidth && box.keepHeight && box.keepDepth && box.drawChild, @"%@", latex); + XCTAssertEqual(box.hAlign, (MTBoxHAlign)cases[latex].unsignedIntegerValue, @"%@", latex); + } +} + +- (void) testParseBoxAtEOF +{ + // \phantom with no argument at EOF: empty inner, no crash (LLD §6) + MTMathList* list = [MTMathListBuilder buildFromString:@"\\phantom"]; + XCTAssertNotNil(list); + MTMathBox* box = list.atoms[0]; + XCTAssertEqual(box.innerList.atoms.count, 0); +} + +- (void) testBoxRoundTrip +{ + XCTAssertEqualObjects([MTMathListBuilder mathListToString: + [MTMathListBuilder buildFromString:@"\\phantom{x}"]], @"\\phantom{x}"); + // \mathstrut serializes lossily to \vphantom{(} + XCTAssertEqualObjects([MTMathListBuilder mathListToString: + [MTMathListBuilder buildFromString:@"\\mathstrut"]], @"\\vphantom{(}"); +} + @end diff --git a/iosMathTests/MTMathListTest.m b/iosMathTests/MTMathListTest.m index c5bf771..333228c 100644 --- a/iosMathTests/MTMathListTest.m +++ b/iosMathTests/MTMathListTest.m @@ -922,4 +922,33 @@ - (void)testUnionRangesOrderIndependence XCTAssertEqual(fwd.length, (NSUInteger)6, @"length should be 6"); } +- (void) testMathBoxModel +{ + MTMathBox* box = [MTMathBox new]; + XCTAssertEqual(box.type, kMTMathAtomBox); + XCTAssertTrue(box.scriptsAllowed, @"kMTMathAtomBox (20) < kMTMathAtomBoundary (101) so scripts are allowed"); + + MTMathList* inner = [[MTMathList alloc] init]; + [inner addAtom:[MTMathAtomFactory atomForCharacter:'x']]; + box.innerList = inner; + box.keepWidth = YES; box.keepHeight = YES; box.keepDepth = YES; + box.drawChild = NO; box.hAlign = kMTBoxHAlignCenter; + + // typeToText + XCTAssertEqualObjects([MTMathAtom atomWithType:kMTMathAtomBox value:@""].class, MTMathBox.class); + + // copy is deep and preserves every flag + MTMathBox* copy = [box copy]; + XCTAssertNotEqual(copy.innerList, box.innerList); + XCTAssertEqual(copy.innerList.atoms.count, 1); + XCTAssertEqual(copy.keepWidth, box.keepWidth); + XCTAssertEqual(copy.keepHeight, box.keepHeight); + XCTAssertEqual(copy.keepDepth, box.keepDepth); + XCTAssertEqual(copy.drawChild, box.drawChild); + XCTAssertEqual(copy.hAlign, box.hAlign); + + // initWithType:value: guard throws for the wrong type + XCTAssertThrows([[MTMathBox alloc] initWithType:kMTMathAtomOrdinary value:@""]); +} + @end diff --git a/iosMathTests/MTTypesetterTest.m b/iosMathTests/MTTypesetterTest.m index 4c946f5..c177a2d 100644 --- a/iosMathTests/MTTypesetterTest.m +++ b/iosMathTests/MTTypesetterTest.m @@ -2980,4 +2980,100 @@ - (void)testSEC4_nonDigitNumberNucleusDoesNotCrash { @"Display must contain at least one sub-display"); } +- (void) testPhantomMetrics +{ + MTDisplay* x = [self singleDisplayForLaTeX:@"x"]; + MTMathListDisplay* phantomLine = [self displayForLaTeX:@"\\phantom{x}"]; + MTDisplay* box = phantomLine.subDisplays.firstObject; + XCTAssertEqualWithAccuracy(box.width, x.width, 0.01); + XCTAssertEqualWithAccuracy(box.ascent, x.ascent, 0.01); + XCTAssertEqualWithAccuracy(box.descent, x.descent, 0.01); +} + +- (void) testHPhantomMetrics +{ + MTDisplay* box = [self singleDisplayForLaTeX:@"\\hphantom{x}"]; + MTDisplay* x = [self singleDisplayForLaTeX:@"x"]; + XCTAssertEqualWithAccuracy(box.width, x.width, 0.01); + XCTAssertEqual(box.ascent, 0); + XCTAssertEqual(box.descent, 0); +} + +- (void) testVPhantomMetrics +{ + MTDisplay* box = [self singleDisplayForLaTeX:@"\\vphantom{x}"]; + MTDisplay* x = [self singleDisplayForLaTeX:@"x"]; + XCTAssertEqual(box.width, 0); + XCTAssertEqualWithAccuracy(box.ascent, x.ascent, 0.01); + XCTAssertEqualWithAccuracy(box.descent, x.descent, 0.01); +} + +- (void) testMathStrutMetrics +{ + MTDisplay* strut = [self singleDisplayForLaTeX:@"\\mathstrut"]; + MTDisplay* vparen = [self singleDisplayForLaTeX:@"\\vphantom{(}"]; + XCTAssertEqual(strut.width, 0); + XCTAssertEqualWithAccuracy(strut.ascent, vparen.ascent, 0.01); + XCTAssertEqualWithAccuracy(strut.descent, vparen.descent, 0.01); +} + +- (void) testSmashMetrics +{ + MTDisplay* box = [self singleDisplayForLaTeX:@"\\smash{x}"]; + MTDisplay* x = [self singleDisplayForLaTeX:@"x"]; + XCTAssertEqualWithAccuracy(box.width, x.width, 0.01); + XCTAssertEqual(box.ascent, 0); + XCTAssertEqual(box.descent, 0); + + MTDisplay* st = [self singleDisplayForLaTeX:@"\\smash[t]{x}"]; + XCTAssertEqual(st.ascent, 0); + XCTAssertTrue(st.descent > 0 || x.descent == 0); + + MTDisplay* sb = [self singleDisplayForLaTeX:@"\\smash[b]{x}"]; + XCTAssertEqual(sb.descent, 0); + XCTAssertTrue(sb.ascent > 0); +} + +- (void) testLapMetrics +{ + for (NSString* latex in @[@"\\llap{x}", @"\\rlap{x}", @"\\clap{x}"]) { + MTDisplay* box = [self singleDisplayForLaTeX:latex]; + XCTAssertEqual(box.width, 0, @"%@", latex); + MTDisplay* x = [self singleDisplayForLaTeX:@"x"]; + XCTAssertEqualWithAccuracy(box.ascent, x.ascent, 0.01, @"%@", latex); + } +} + +// Integration / composition (LLD §7) +- (void) testVPhantomDrivesDelimiterSize +{ + MTMathListDisplay* withPhantom = [self displayForLaTeX:@"\\left(\\vphantom{\\frac{1}{x}}x\\right)"]; + MTMathListDisplay* withoutPhantom = [self displayForLaTeX:@"\\left(x\\right)"]; + XCTAssertGreaterThan(withPhantom.ascent + withPhantom.descent, + withoutPhantom.ascent + withoutPhantom.descent); +} + +- (void) testScriptOnBox +{ + // \phantom{x}^2 : script attaches to the box display, no crash. + MTMathListDisplay* d = [self displayForLaTeX:@"\\phantom{x}^2"]; + XCTAssertNotNil(d); + XCTAssertGreaterThan(d.subDisplays.count, 0); +} + +- (void) testRlapDoesNotAdvance +{ + // a\rlap{+b}c : 'c' position matches the no-lap baseline "ac". + MTMathListDisplay* lapped = [self displayForLaTeX:@"a\\rlap{+b}c"]; + MTMathListDisplay* plain = [self displayForLaTeX:@"ac"]; + XCTAssertEqualWithAccuracy(lapped.width, plain.width, 0.01); +} + +- (void) testLeadingNegativeKernRendersAtNegativeX +{ + // Pin accepted-clipping behavior: a left \llap places ink at x<0 and still renders. + MTMathListDisplay* d = [self displayForLaTeX:@"\\llap{xy}z"]; + XCTAssertNotNil(d); +} + @end