Skip to content
Open
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
73 changes: 63 additions & 10 deletions iosMath/lib/MTMathListBuilder.m
Original file line number Diff line number Diff line change
Expand Up @@ -545,28 +545,71 @@ - (NSString*) readColor
[self setError:MTParseErrorCharacterNotFound message:@"Missing {"];
return nil;
}

// Ignore spaces and nonascii.
[self skipSpaces];

// a string of all upper and lower case characters.
// Read the entire token up to the closing brace or whitespace.
// We deliberately do NOT restrict the charset here so that invalid
// inputs (e.g. named colors like "red") are captured whole and can
// produce a clear validation error instead of a confusing "Missing }".
NSMutableString* mutable = [NSMutableString string];
while([self hasCharacters]) {
unichar ch = [self getNextCharacter];
if (ch == '#' || (ch >= 'A' && ch <= 'F') || (ch >= 'a' && ch <= 'f') || (ch >= '0' && ch <= '9')) {
[mutable appendString:[NSString stringWithCharacters:&ch length:1]];
} else {
// we went too far
if (ch == '}') {
// Put the closing brace back; expectCharacter below will consume it.
[self unlookCharacter];
break;
}
if (ch < 0x21 || ch > 0x7E) {
// Treat whitespace / non-ASCII as end of token (skip over it).
break;
}
Comment on lines +564 to +567

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the current implementation, if a non-ASCII character (such as ) is present at the end of the color string (e.g., \color{#ff0000あ}), it triggers the ch > 0x7E condition and breaks the loop. However, because unlookCharacter is not called, the character is silently consumed. Since expectCharacter: then successfully matches the closing }, the validation is performed on #ff0000 and succeeds, meaning the invalid color is silently accepted.

Additionally, capturing non-ASCII characters in the mutable string rather than breaking on them allows the validation logic to fail with a clear error message (e.g., Invalid color: #ff0000あ) instead of silently ignoring them or failing with a confusing Missing } error.

We can improve this by only breaking on whitespace (ch < 0x21) and ensuring we call unlookCharacter before breaking so the character is not silently consumed.

        if (ch < 0x21) {
            // Treat whitespace as end of token.
            [self unlookCharacter];
            break;
        }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the behavior: for \color{#ff0000あ}, the non-ASCII char hits the ch > 0x7E branch and breaks without unlookCharacter, so it's consumed; expectCharacter:'}' then matches the trailing } and #ff0000 validates OK — the あ is silently dropped. The analysis is correct.

This is a pre-existing edge of the same class the EM review flagged as non-blocking, and tightening it (break only on ch < 0x21 with unlookCharacter, letting non-ASCII flow into the token so validation reports "Invalid color: #ff0000あ") is a clean, low-risk improvement. I'm leaving it out of this PR to keep REN-2 scoped to the three targeted parser bugs, and will fold the stricter token-termination into the follow-up rather than expand an already-approved change. Tracking it for the REN follow-up.

[mutable appendString:[NSString stringWithCharacters:&ch length:1]];
}

if (![self expectCharacter:'}']) {
// We didn't find an closing brace, so invalid format.
// We didn't find a closing brace, so invalid format.
[self setError:MTParseErrorCharacterNotFound message:@"Missing }"];
return nil;
}

// Validate: color must be '#' followed by exactly 3 or 6 hex digits.
// This keeps the grammar consistent with colorFromHexString: which requires
// a leading '#'. Named colors and bare hex strings are not supported.
//
// NOTE: 3-digit #RGB is accepted here at parse time, but correct *rendering*
// of #RGB depends on colorFromHexString: handling the 3-digit shorthand.
// The current decoder is 6-digit-only (scanHexInt on "f00" yields 0xF00 and
// is masked as if it were #000F00), so #RGB currently renders the wrong
// color until that decoder fix (REN-7) lands. We deliberately keep
// accepting #RGB rather than rejecting it: the previous parser also
// accepted and mis-rendered #RGB identically, so this is parse-correct /
// render-deferred, not a regression.
BOOL valid = NO;
NSUInteger len = mutable.length;
if (len == 4 || len == 7) {
unichar first = [mutable characterAtIndex:0];
if (first == '#') {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EM-REVIEW v1 (top-level summary posted as inline due to comment perms). Verdict: NOT BLOCKING. Validation accepts exactly #RGB/#RRGGBB and rejects named/missing-#/non-hex/wrong-length; nil propagated at both \color and \colorbox sites; EOF/missing-} yields a sensible 'Missing }' (MTParseErrorCharacterNotFound); error code reuse is additive (no enum change); 7 tests cover all three pre-existing bugs. COORDINATION NOTE (non-blocking): #f00 now parses-OK but colorFromHexString: is still 6-digit-only (scanHexInt('f00')=0xF00 masked as 6-digit), so #f00 renders ~black-green not red until REN-7 lands. This is NOT a regression vs master (old readColor already accepted #f00 and mis-rendered it) — it is parse-correct/render-deferred. RECOMMENDATION: keep accepting #RGB provided REN-7 is queued; ensure REN-7 lands before advertising 3-digit support.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the coordination note with technical rigor and concur.

colorFromHexString: (UIColor+HexString.m / NSColor+HexString.m) does scanHexInt on the post-# substring then masks with 0xFF0000/0xFF00/0xFF — strictly 6-digit. For #f00, scanHexInt("f00")=0xF00, masked as #000F00, so it renders ~black-green, not red. This is parse-correct / render-deferred and NOT a regression: the prior readColor accepted and mis-rendered #f00 identically.

Action taken: kept accepting #RGB (no grammar change), and added an in-code comment in readColor (commit d3fb07e) documenting that 3-digit #RGB rendering correctness depends on REN-7's colorFromHexString: fix. REN-7 must land before advertising 3-digit support.

valid = YES;
for (NSUInteger i = 1; i < len && valid; i++) {
unichar c = [mutable characterAtIndex:i];
BOOL isHex = ((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'));
if (!isHex) {
valid = NO;
}
}
}
}

if (!valid) {
NSString* msg = [NSString stringWithFormat:@"Invalid color: %@", mutable];
[self setError:MTParseErrorInvalidCommand message:msg];
return nil;
}

return mutable;
}

Expand Down Expand Up @@ -828,14 +871,24 @@ - (MTMathAtom*) atomForCommand:(NSString*) command
return table;
} else if ([command isEqualToString:@"color"]) {
// A color command has 2 arguments
NSString* colorStr = [self readColor];
if (!colorStr) {
// readColor already set the error.
return nil;
}
MTMathColor* mathColor = [[MTMathColor alloc] init];
mathColor.colorString = [self readColor];
mathColor.colorString = colorStr;
mathColor.innerList = [self buildInternal:true];
return mathColor;
} else if ([command isEqualToString:@"colorbox"]) {
// A color command has 2 arguments
// A colorbox command has 2 arguments
NSString* colorStr = [self readColor];
if (!colorStr) {
// readColor already set the error.
return nil;
}
MTMathColorbox* mathColorbox = [[MTMathColorbox alloc] init];
mathColorbox.colorString = [self readColor];
mathColorbox.colorString = colorStr;
mathColorbox.innerList = [self buildInternal:true];
return mathColorbox;
} else {
Expand Down
96 changes: 96 additions & 0 deletions iosMathTests/MTMathListBuilderTest.m
Original file line number Diff line number Diff line change
Expand Up @@ -2866,4 +2866,100 @@ - (void)testProgrammaticBothRowsSerializeNested
XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\underset{b}{\\overset{a}{X}}");
}

#pragma mark - \color and \colorbox tests

- (void)testColorValidHexSix
{
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\color{#ff0000}x" error:&error];
XCTAssertNil(error, @"Unexpected error: %@", error);
XCTAssertNotNil(list);
XCTAssertEqual(list.atoms.count, (NSUInteger)1);
MTMathColor* colorAtom = (MTMathColor*)list.atoms[0];
XCTAssertEqual(colorAtom.type, kMTMathAtomColor);
XCTAssertEqualObjects(colorAtom.colorString, @"#ff0000");
XCTAssertNotNil(colorAtom.innerList);
XCTAssertEqual(colorAtom.innerList.atoms.count, (NSUInteger)1);
// stringValue round-trip (mathListToString uses appendLaTeXToString: which MTMathColor
// inherits from the base class; stringValue is the color-specific round-trip method).
XCTAssertEqualObjects(colorAtom.stringValue, @"\\color{#ff0000}{x}");
}

- (void)testColorValidHexThree
{
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\color{#f00}x" error:&error];
XCTAssertNil(error, @"Unexpected error: %@", error);
XCTAssertNotNil(list);
XCTAssertEqual(list.atoms.count, (NSUInteger)1);
MTMathColor* colorAtom = (MTMathColor*)list.atoms[0];
XCTAssertEqual(colorAtom.type, kMTMathAtomColor);
XCTAssertEqualObjects(colorAtom.colorString, @"#f00");
}

- (void)testColorboxValidHexSix
{
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\colorbox{#00ff00}x" error:&error];
XCTAssertNil(error, @"Unexpected error: %@", error);
XCTAssertNotNil(list);
XCTAssertEqual(list.atoms.count, (NSUInteger)1);
MTMathColorbox* colorboxAtom = (MTMathColorbox*)list.atoms[0];
XCTAssertEqual(colorboxAtom.type, kMTMathAtomColorbox);
XCTAssertEqualObjects(colorboxAtom.colorString, @"#00ff00");
}

- (void)testColorInvalidNamedColorIsParseError
{
// Named colors like "red" must be a parse error (not a silent no-op).
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\color{red}x" error:&error];
XCTAssertNil(list, @"Expected nil list for invalid color");
XCTAssertNotNil(error);
XCTAssertEqual(error.domain, MTParseError);
XCTAssertEqual(error.code, MTParseErrorInvalidCommand);
}

- (void)testColorInvalidMissingHashIsParseError
{
// "ff0000" without leading # must be a parse error (silent failure bug).
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\color{ff0000}x" error:&error];
XCTAssertNil(list, @"Expected nil list for color missing #");
XCTAssertNotNil(error);
XCTAssertEqual(error.domain, MTParseError);
XCTAssertEqual(error.code, MTParseErrorInvalidCommand);
}

- (void)testColorInvalidNonHexDigitIsParseError
{
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\color{#gg0000}x" error:&error];
XCTAssertNil(list, @"Expected nil list for non-hex color");
XCTAssertNotNil(error);
XCTAssertEqual(error.domain, MTParseError);
XCTAssertEqual(error.code, MTParseErrorInvalidCommand);
}

- (void)testColorInvalidWrongLengthIsParseError
{
// 4-digit hex is neither #RGB nor #RRGGBB.
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\color{#ff00}x" error:&error];
XCTAssertNil(list, @"Expected nil list for wrong-length color");
XCTAssertNotNil(error);
XCTAssertEqual(error.domain, MTParseError);
XCTAssertEqual(error.code, MTParseErrorInvalidCommand);
}

- (void)testColorboxInvalidNamedColorIsParseError
{
NSError* error = nil;
MTMathList* list = [MTMathListBuilder buildFromString:@"\\colorbox{red}x" error:&error];
XCTAssertNil(list, @"Expected nil list for invalid colorbox color");
XCTAssertNotNil(error);
XCTAssertEqual(error.domain, MTParseError);
XCTAssertEqual(error.code, MTParseErrorInvalidCommand);
}

@end
Loading