diff --git a/iosMath/lib/MTMathAtomFactory.h b/iosMath/lib/MTMathAtomFactory.h index c6ce2d91..c7375f0f 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; @@ -117,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 0039a4c8..cae2af97 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 @@ -1011,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; @@ -1030,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 9a729565..ddada1e3 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 () @@ -1168,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; } @@ -1182,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 @@ -1786,57 +1762,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..e210bf3e 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -35,16 +35,6 @@ @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; -@end - // Defined under "Equivalence helpers" below. static NSString* ListSignature(MTMathList* list); @@ -497,10 +487,14 @@ - (void)testTemplateSplicesMultipleArgumentsInOrder - (void)testEveryRegisteredMacroParses { - NSDictionary* macros = [MTMathListBuilder builtinMacros]; - XCTAssertEqual(macros.count, 11ul); - 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 @@ -521,6 +515,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