From cd300df0b8f15ec4e5e72588cfe98d0ea70b0915 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:03:35 +0530 Subject: [PATCH 1/4] [item 1] Move MTMacroDefinition to MTMathAtomFactory --- iosMath/lib/MTMathAtomFactory.h | 10 ++++++++++ iosMath/lib/MTMathAtomFactory.m | 13 +++++++++++++ iosMath/lib/MTMathListBuilder.m | 23 ----------------------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/iosMath/lib/MTMathAtomFactory.h b/iosMath/lib/MTMathAtomFactory.h index c6ce2d91..88e192d5 100644 --- a/iosMath/lib/MTMathAtomFactory.h +++ b/iosMath/lib/MTMathAtomFactory.h @@ -38,6 +38,16 @@ typedef NS_ENUM(NSUInteger, MTStackArgRole) { inheritsClass:(BOOL)inheritsClass; @end +/// Registry value: declared arity + the LaTeX template the expansion is parsed +/// from. Arity is declared rather than inferred from the template because a +/// future \newcommand declares [argc] and its body may ignore arguments. +@interface MTMacroDefinition : NSObject +@property (nonatomic, readonly) NSUInteger argumentCount; +@property (nonatomic, copy, readonly) NSString* templateString; +- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount + templateString:(NSString*)templateString; +@end + FOUNDATION_EXPORT NSString *const MTSymbolMultiplication; FOUNDATION_EXPORT NSString *const MTSymbolDivision; FOUNDATION_EXPORT NSString *const MTSymbolFractionSlash; diff --git a/iosMath/lib/MTMathAtomFactory.m b/iosMath/lib/MTMathAtomFactory.m index 0039a4c8..d46162fe 100644 --- a/iosMath/lib/MTMathAtomFactory.m +++ b/iosMath/lib/MTMathAtomFactory.m @@ -26,6 +26,19 @@ NSString *const MTSymbolAngle = @"\u2220"; // \angle NSString *const MTSymbolDegree = @"\u00B0"; // \circ +@implementation MTMacroDefinition +- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount + templateString:(NSString*)templateString +{ + self = [super init]; + if (self) { + _argumentCount = argumentCount; + _templateString = [templateString copy]; + } + return self; +} +@end + // Inter-column spacing for \begin{smallmatrix}, in mu. amsmath separates smallmatrix // columns with \thickspace = 5mu, measured under \scriptstyle (amsmath.dtx); KaTeX // mirrors this as 0.2778em = 5/18em (src/environments/array.ts). We store the honest diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 9a729565..5f454c56 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -47,29 +47,6 @@ - (instancetype)initWithName:(NSString*) name // far below the thousands of frames needed to overflow a 1 MB stack. static const NSInteger kMTMaxRecursionDepth = 150; -// Registry value: declared arity + the LaTeX template the expansion is parsed -// from. Arity is declared rather than inferred from the template because a -// future \newcommand declares [argc] and its body may ignore arguments. -@interface MTMacroDefinition : NSObject -@property (nonatomic, readonly) NSUInteger argumentCount; -@property (nonatomic, copy, readonly) NSString* templateString; -- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount - templateString:(NSString*)templateString; -@end - -@implementation MTMacroDefinition -- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount - templateString:(NSString*)templateString -{ - self = [super init]; - if (self) { - _argumentCount = argumentCount; - _templateString = [templateString copy]; - } - return self; -} -@end - // Not in the public header, so template mode does not appear in the Swift module // interface — only built-in macro templates use it. @interface MTMathListBuilder () From b7532a188ae61cd88f7bf0bb61b888a3733bfb74 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:05:59 +0530 Subject: [PATCH 2/4] [item 2] Move the macro table to MTMathAtomFactory and add +addMacro: --- iosMath/lib/MTMathAtomFactory.h | 14 ++++ iosMath/lib/MTMathAtomFactory.m | 88 ++++++++++++++++++++++++++ iosMath/lib/MTMathListBuilder.m | 53 +--------------- iosMathTests/MTModularArithmeticTest.m | 22 ++++--- 4 files changed, 115 insertions(+), 62 deletions(-) diff --git a/iosMath/lib/MTMathAtomFactory.h b/iosMath/lib/MTMathAtomFactory.h index 88e192d5..c7375f0f 100644 --- a/iosMath/lib/MTMathAtomFactory.h +++ b/iosMath/lib/MTMathAtomFactory.h @@ -127,6 +127,20 @@ FOUNDATION_EXPORT NSString *const MTSymbolDegree; `[MTMathAtomFactory addLatexSymbol:@"lcm" value:[MTMathAtomFactory operatorWithName:@"lcm" limits:NO]]` */ + (void) addLatexSymbol:(NSString*) name value:(MTMathAtom*) atom; +/** Define a macro: a command that expands to `templateString` with `#1`...`#9` replaced by + the arguments it is invoked with. Macros are looked up before every other command table, so + registering a name that already exists — a macro or a built-in command — shadows it. + e.g. `[MTMathAtomFactory addMacro:@"half" argumentCount:0 template:@"\\frac{1}{2}"]` + + Carries the same setup-time contract as `+addLatexSymbol:value:` — do not call this while + parsing on another thread. */ ++ (void) addMacro:(NSString*) name + argumentCount:(NSUInteger) argumentCount + template:(NSString*) templateString; + +/** The macro registered under `command`, or nil if it is not a macro. */ ++ (nullable MTMacroDefinition*) macroDefinitionForCommand:(NSString*) command; + /** Returns a list of all supported lated symbols names. */ + (NSArray*) supportedLatexSymbolNames; diff --git a/iosMath/lib/MTMathAtomFactory.m b/iosMath/lib/MTMathAtomFactory.m index d46162fe..154b02f1 100644 --- a/iosMath/lib/MTMathAtomFactory.m +++ b/iosMath/lib/MTMathAtomFactory.m @@ -1024,6 +1024,94 @@ + (nullable MTMathAtom*) arrayTableWithAlignments:(NSArray*) columnAl return commands; } +// Each entry is amsmath's exact inline expansion as a #N template. Not reproduced +// is amsmath's \if@display switch to an 18mu leading gap, because a macro expands +// at parse time, before the render style is known. +// +// This dispatch_once builds strings only. Parsing one here would re-enter +// -macroAtomForCommand: (every command reaches it) and deadlock, so templates are +// parsed per invocation instead. ++ (NSMutableDictionary*) macros +{ + static NSMutableDictionary* macros = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + macros = [NSMutableDictionary dictionaryWithDictionary:@{ + @"pmod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + templateString:@"\\mkern8mu(\\mathrm{mod}\\mkern6mu#1)"], + @"mod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + templateString:@"\\mkern12mu\\mathrm{mod}\\mkern6mu#1"], + @"pod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + templateString:@"\\mkern8mu(#1)"], + + // amsmath pads all three with \; on both sides. They were aliases of + // the bare arrow until now, which renders tighter than amsmath. + @"implies": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\;\\Longrightarrow\\;"], + @"impliedby": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\;\\Longleftarrow\\;"], + @"iff": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\;\\Longleftrightarrow\\;"], + + @"idotsint": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\int\\cdots\\int"], + + // amsmath builds these four out of \mathop, which iosMath has no + // command for. Without it the expansion is an Ord rather than an Op, + // which costs two things: a script lands to the right instead of + // centred underneath, and the 3mu an Op gets against the atom after + // it is missing. The symbol is right, the spacing around it is not. + // Known limitation; revisit if \mathop is ever added. + @"varliminf": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\underline{\\lim}"], + @"varlimsup": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\overline{\\lim}"], + @"varinjlim": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\underrightarrow{\\lim}"], + @"varprojlim": [[MTMacroDefinition alloc] initWithArgumentCount:0 + templateString:@"\\underleftarrow{\\lim}"], + }]; + }); + return macros; +} + ++ (BOOL) template:(NSString*) templateString referencesOnlyArgumentsUpTo:(NSUInteger) argumentCount +{ + for (NSUInteger i = 0; i + 1 < templateString.length; i++) { + if ([templateString characterAtIndex:i] != '#') { + continue; + } + unichar digit = [templateString characterAtIndex:i + 1]; + if (digit < '1' || digit > '9' || (NSUInteger)(digit - '0') > argumentCount) { + return NO; + } + i++; + } + return YES; +} + ++ (void) addMacro:(NSString*) name + argumentCount:(NSUInteger) argumentCount + template:(NSString*) templateString +{ + NSParameterAssert(name); + NSParameterAssert(templateString); + NSAssert(argumentCount <= 9, @"\\%@ declares %lu arguments; a macro can take at most 9", + name, (unsigned long)argumentCount); + NSAssert([self template:templateString referencesOnlyArgumentsUpTo:argumentCount], + @"Template for \\%@ references an argument beyond its %lu declared argument(s): %@", + name, (unsigned long)argumentCount, templateString); + // Same setup-time contract as +addLatexSymbol:value: — the table is read + // unguarded once initialized. Do not call this while parsing on another thread. + [self macros][name] = [[MTMacroDefinition alloc] initWithArgumentCount:argumentCount + templateString:templateString]; +} + ++ (nullable MTMacroDefinition*) macroDefinitionForCommand:(NSString*) command +{ + return [self macros][command]; +} + + (NSDictionary*) aliases { static NSDictionary* aliases = nil; diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 5f454c56..05151971 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -1145,7 +1145,7 @@ - (MTMathAtom*) getBoundaryAtom:(NSString*) delimiterType // it is a macro whose arguments failed to parse. - (nullable MTMacroAtom*) macroAtomForCommand:(NSString*) command { - MTMacroDefinition* def = [MTMathListBuilder builtinMacros][command]; + MTMacroDefinition* def = [MTMathAtomFactory macroDefinitionForCommand:command]; if (!def) { return nil; } @@ -1763,57 +1763,6 @@ + (NSDictionary*) spaceToCommands return fractionMacroCommands; } -// Each entry is amsmath's exact inline expansion as a #N template. Not reproduced -// is amsmath's \if@display switch to an 18mu leading gap, because a macro expands -// at parse time, before the render style is known. -// -// This dispatch_once builds strings only. Parsing one here would re-enter this -// method (every command reaches -macroAtomForCommand:) and deadlock, so templates -// are parsed per invocation instead — ~8 atoms, and MTMacroAtom copies them anyway. -+ (NSDictionary*) builtinMacros -{ - static NSDictionary* macros = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - macros = @{ - @"pmod": [[MTMacroDefinition alloc] initWithArgumentCount:1 - templateString:@"\\mkern8mu(\\mathrm{mod}\\mkern6mu#1)"], - @"mod": [[MTMacroDefinition alloc] initWithArgumentCount:1 - templateString:@"\\mkern12mu\\mathrm{mod}\\mkern6mu#1"], - @"pod": [[MTMacroDefinition alloc] initWithArgumentCount:1 - templateString:@"\\mkern8mu(#1)"], - - // amsmath pads all three with \; on both sides. They were aliases of - // the bare arrow until now, which renders tighter than amsmath. - @"implies": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\;\\Longrightarrow\\;"], - @"impliedby": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\;\\Longleftarrow\\;"], - @"iff": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\;\\Longleftrightarrow\\;"], - - @"idotsint": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\int\\cdots\\int"], - - // amsmath builds these four out of \mathop, which iosMath has no - // command for. Without it the expansion is an Ord rather than an Op, - // which costs two things: a script lands to the right instead of - // centred underneath, and the 3mu an Op gets against the atom after - // it is missing. The symbol is right, the spacing around it is not. - // Known limitation; revisit if \mathop is ever added. - @"varliminf": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\underline{\\lim}"], - @"varlimsup": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\overline{\\lim}"], - @"varinjlim": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\underrightarrow{\\lim}"], - @"varprojlim": [[MTMacroDefinition alloc] initWithArgumentCount:0 - templateString:@"\\underleftarrow{\\lim}"], - }; - }); - return macros; -} - + (NSDictionary*) styleToCommands { static NSDictionary* styleToCommands = nil; diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 09552cb0..1884eaad 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -35,14 +35,10 @@ @interface MTMathListBuilder (MTTemplateTesting) + (nullable MTMathList *)buildTemplate:(NSString *)str; @end -// Defined privately in MTMathListBuilder.m; redeclared for registry tests. -@interface MTMacroDefinition : NSObject -@property (nonatomic, readonly) NSUInteger argumentCount; -@property (nonatomic, copy, readonly) NSString* templateString; -@end - -@interface MTMathListBuilder (MTMacroRegistryTesting) -+ (NSDictionary*)builtinMacros; +// Private to MTMathAtomFactory.m; redeclared so the registry tests can enumerate +// the whole table. MTMacroDefinition itself is public now. +@interface MTMathAtomFactory (MTMacroRegistryTesting) ++ (NSMutableDictionary*)macros; @end // Defined under "Equivalence helpers" below. @@ -497,8 +493,14 @@ - (void)testTemplateSplicesMultipleArgumentsInOrder - (void)testEveryRegisteredMacroParses { - NSDictionary* macros = [MTMathListBuilder builtinMacros]; - XCTAssertEqual(macros.count, 11ul); + NSDictionary* macros = [MTMathAtomFactory macros]; + // A containment check, not an equality one: +addMacro: writes into this same + // global table and there is no unregister, so a test that registers a macro + // leaves it there for whatever runs next. + NSSet* builtins = [NSSet setWithArray:@[ + @"pmod", @"mod", @"pod", @"implies", @"impliedby", @"iff", @"idotsint", + @"varliminf", @"varlimsup", @"varinjlim", @"varprojlim" ]]; + XCTAssertTrue([builtins isSubsetOfSet:[NSSet setWithArray:macros.allKeys]]); for (NSString* command in macros) { MTMacroDefinition* def = macros[command]; MTMathList* templateExpression = [MTMathListBuilder buildTemplate:def.templateString]; From 693d0296a46e263cea307bdee6c708eb605e93aa Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:06:39 +0530 Subject: [PATCH 3/4] [item 3] Test macro registration and replacement --- iosMathTests/MTModularArithmeticTest.m | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 1884eaad..37194d62 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -523,6 +523,18 @@ - (void)testEveryRegisteredMacroParses } } +- (void)testAddMacroRegistersAndReplaces +{ + [MTMathAtomFactory addMacro:@"half" argumentCount:0 template:@"\\frac{1}{2}"]; + XCTAssertEqualObjects(ListSignature([MTMathListBuilder buildFromString:@"\\half"].finalized), + ListSignature([MTMathListBuilder buildFromString:@"\\frac{1}{2}"].finalized)); + + // Re-registering the same name replaces the definition, as +addLatexSymbol: does. + [MTMathAtomFactory addMacro:@"half" argumentCount:0 template:@"\\frac{1}{3}"]; + XCTAssertEqualObjects(ListSignature([MTMathListBuilder buildFromString:@"\\half"].finalized), + ListSignature([MTMathListBuilder buildFromString:@"\\frac{1}{3}"].finalized)); +} + #pragma mark - Parsing the three macros - (void)testPmodParsesToASingleMacroAtom From 15c71b802d26d18ec51c5b42a2806c9c5074b767 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Sat, 22 Aug 2026 02:44:01 +0530 Subject: [PATCH 4/4] Address review: report a bad macro template as a caller error +addMacro: makes templates caller-supplied, so a template that fails to parse is no longer a programming mistake. Drop the NSAssert that aborted on it and report MTParseErrorInvalidCommand instead of InternalError. testEveryRegisteredMacroParses iterated the whole registry, which +addMacro: writes into, so it validated whatever an earlier test left behind. Name the eleven built-ins instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMath/lib/MTMathAtomFactory.m | 2 +- iosMath/lib/MTMathListBuilder.m | 9 ++++----- iosMathTests/MTModularArithmeticTest.m | 24 ++++++++---------------- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/iosMath/lib/MTMathAtomFactory.m b/iosMath/lib/MTMathAtomFactory.m index 154b02f1..cae2af97 100644 --- a/iosMath/lib/MTMathAtomFactory.m +++ b/iosMath/lib/MTMathAtomFactory.m @@ -1131,7 +1131,7 @@ + (NSDictionary*) aliases @"to" : @"rightarrow", @"AA" : @"angstrom", @"restriction" : @"upharpoonright", - // \iff, \implies and \impliedby moved to +builtinMacros: amsmath + // \iff, \implies and \impliedby moved to +macros: amsmath // pads them with \;, which one symbol atom cannot express. @"dotsc" : @"ldots", @"dotsb" : @"cdots", diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 05151971..ddada1e3 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -1159,12 +1159,11 @@ - (nullable MTMacroAtom*) macroAtomForCommand:(NSString*) command } // A fresh builder, so the in-flight parse's state is never disturbed. MTMathList* templateExpression = [MTMathListBuilder buildTemplate:def.templateString]; - // Compile-time constants, so a parse failure here is a programming mistake. - NSAssert(templateExpression, @"Built-in template for \\%@ failed to parse: %@", - command, def.templateString); if (!templateExpression) { - [self setError:MTParseErrorInternalError - message:[NSString stringWithFormat:@"Built-in template for \\%@ failed to parse", command]]; + // Reachable from a template registered through +addMacro:, so this is the + // caller's error, not the library's. + [self setError:MTParseErrorInvalidCommand + message:[NSString stringWithFormat:@"Template for \\%@ failed to parse", command]]; return nil; } return [[MTMacroAtom alloc] initWithCommand:command arguments:arguments diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 37194d62..e210bf3e 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -35,12 +35,6 @@ @interface MTMathListBuilder (MTTemplateTesting) + (nullable MTMathList *)buildTemplate:(NSString *)str; @end -// Private to MTMathAtomFactory.m; redeclared so the registry tests can enumerate -// the whole table. MTMacroDefinition itself is public now. -@interface MTMathAtomFactory (MTMacroRegistryTesting) -+ (NSMutableDictionary*)macros; -@end - // Defined under "Equivalence helpers" below. static NSString* ListSignature(MTMathList* list); @@ -493,16 +487,14 @@ - (void)testTemplateSplicesMultipleArgumentsInOrder - (void)testEveryRegisteredMacroParses { - NSDictionary* macros = [MTMathAtomFactory macros]; - // A containment check, not an equality one: +addMacro: writes into this same - // global table and there is no unregister, so a test that registers a macro - // leaves it there for whatever runs next. - NSSet* builtins = [NSSet setWithArray:@[ - @"pmod", @"mod", @"pod", @"implies", @"impliedby", @"iff", @"idotsint", - @"varliminf", @"varlimsup", @"varinjlim", @"varprojlim" ]]; - XCTAssertTrue([builtins isSubsetOfSet:[NSSet setWithArray:macros.allKeys]]); - for (NSString* command in macros) { - MTMacroDefinition* def = macros[command]; + // Named rather than enumerated: +addMacro: writes into the same global table + // and there is no unregister, so enumerating it would validate whatever an + // earlier test left behind. + for (NSString* command in @[ @"pmod", @"mod", @"pod", @"implies", @"impliedby", @"iff", + @"idotsint", @"varliminf", @"varlimsup", @"varinjlim", + @"varprojlim" ]) { + MTMacroDefinition* def = [MTMathAtomFactory macroDefinitionForCommand:command]; + XCTAssertNotNil(def, @"\\%@ is not registered", command); MTMathList* templateExpression = [MTMathListBuilder buildTemplate:def.templateString]; XCTAssertNotNil(templateExpression, @"\\%@ template failed to parse", command); // Substitution does not descend into sub-lists, so every declared