From 5d5e0ffc5cbf74ef2e550626c30c3019fe2b549d Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:01:45 +0530 Subject: [PATCH 1/9] [item 1] Read italic correction from the face that drew the glyph Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMath/render/internal/MTTypesetter.m | 63 ++++++++++++-- iosMathTests/MTItalicCorrectionTest.m | 111 +++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 iosMathTests/MTItalicCorrectionTest.m diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index da0032e..b755e32 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -1033,15 +1033,21 @@ - (void) createDisplayAtoms:(NSArray*) preprocessed // add super scripts || subscripts if (atom.subScript || atom.superScript) { - // stash the existing line - // We don't check _currentLine.length here since we want to allow empty lines with super/sub scripts. - MTCTLineDisplay* line = [self addDisplayLine]; CGFloat delta = 0; if (atom.nucleus.length > 0) { - // Use the italic correction of the last character. - CGGlyph glyph = [self findGlyphForCharacterAtIndex:atom.nucleus.length - 1 inString:atom.nucleus]; - delta = [_styleFont.mathTable getItalicCorrection:glyph]; + // Read before the flush clears _currentLine. A non-empty + // nucleus was just appended, so the line's last composed + // sequence is this atom's last character. Keying on the + // atom rather than on _currentLine.length matters: for an + // empty nucleus the line's last character belongs to the + // previous atom, whose correction addDisplayLine has + // already carried into the pen. + NSRange last = [_currentLine.string rangeOfComposedCharacterSequenceAtIndex:_currentLine.length - 1]; + delta = [self italicCorrectionInCurrentLineAtIndex:last.location]; } + // stash the existing line + // We don't check _currentLine.length here since we want to allow empty lines with super/sub scripts. + MTCTLineDisplay* line = [self addDisplayLine]; if (delta > 0 && !atom.subScript) { // Add a kern of delta _currentPosition.x += delta; @@ -1088,6 +1094,51 @@ - (void) applyMathitFontToRoutableCharactersInRange:(NSRange) range } } +// The face stamped at `index`. Every appended range is stamped before anything +// reads it back, so a missing attribute is a broken invariant rather than +// something LaTeX input can produce. Defaulting to _styleFont would read the +// math table for a companion glyph and return a plausible wrong number, which +// is the defect this path exists to remove. +- (CTFontRef) faceInCurrentLineAtIndex:(NSUInteger) index +{ + CTFontRef face = (__bridge CTFontRef) [_currentLine attribute:(NSString*) kCTFontAttributeName + atIndex:index + effectiveRange:NULL]; + NSAssert(face != NULL, @"No font stamped at index %lu of '%@'", + (unsigned long) index, _currentLine.string); + return face; +} + +// The italic correction of the composed character sequence at `index`, from the +// face that drew it. _currentLine can carry two faces — \mathit routes some +// characters to the companion — and a CoreText glyph id means nothing without +// its font, so the glyph is resolved against the same face the metric comes +// from. This must stay the only way to ask for this number. +- (CGFloat) italicCorrectionInCurrentLineAtIndex:(NSUInteger) index +{ + CTFontRef face = [self faceInCurrentLineAtIndex:index]; + NSString* string = _currentLine.string; + NSRange range = [string rangeOfComposedCharacterSequenceAtIndex:index]; + unichar chars[range.length]; + [string getCharacters:chars range:range]; + CGGlyph glyphs[range.length]; + if (!CTFontGetGlyphsForCharacters(face, chars, glyphs, range.length)) { + // Same convention as findGlyphForCharacterAtIndex:inString:. Measuring + // notdef would return a correction for a box that is never drawn. + return 0; + } + if (CFEqual(face, _styleFont.ctFont)) { + return [_styleFont.mathTable getItalicCorrection:glyphs[0]]; + } + // No text-italic face available to us carries a MATH table, so the + // correction is the ink overhanging the advance — the metric MathJax bakes + // into its -tex-mathit table. + CGRect bounds = CTFontGetBoundingRectsForGlyphs(face, kCTFontOrientationDefault, glyphs, NULL, 1); + CGSize advance; + CTFontGetAdvancesForGlyphs(face, kCTFontOrientationDefault, glyphs, &advance, 1); + return MAX(0, CGRectGetMaxX(bounds) - advance.width); +} + - (MTCTLineDisplay*) addDisplayLine { /*NSAssert(_currentLineIndexRange.length == numCodePoints(_currentLine.string), diff --git a/iosMathTests/MTItalicCorrectionTest.m b/iosMathTests/MTItalicCorrectionTest.m new file mode 100644 index 0000000..056e440 --- /dev/null +++ b/iosMathTests/MTItalicCorrectionTest.m @@ -0,0 +1,111 @@ +// +// MTItalicCorrectionTest.m +// iosMath +// + +#import +#import + +#import "MTTypesetter.h" +#import "MTFont+Internal.h" +#import "MTFontMathTable.h" +#import "MTFontManager.h" +#import "MTMathListDisplay.h" +#import "MTMathListDisplayInternal.h" +#import "MTMathListBuilder.h" + +@interface MTItalicCorrectionTest : XCTestCase + +@property (nonatomic) MTFont* font; + +@end + +@implementation MTItalicCorrectionTest + +- (void) setUp +{ + [super setUp]; + self.font = [MTFontManager.fontManager fontWithName:MTFontNameLatinModern size:20]; +} + +- (MTMathListDisplay*) displayForLaTeX:(NSString*) latex withFont:(MTFont*) font +{ + MTMathList* list = [MTMathListBuilder buildFromString:latex]; + XCTAssertNotNil(list, @"%@", latex); + return [MTTypesetter createLineForMathList:list font:font style:kMTLineStyleDisplay]; +} + +- (MTMathListDisplay*) displayForLaTeX:(NSString*) latex +{ + return [self displayForLaTeX:latex withFont:self.font]; +} + +// The one CTLine of an expression expected to typeset as a single run. +- (MTCTLineDisplay*) lineForLaTeX:(NSString*) latex +{ + MTMathListDisplay* display = [self displayForLaTeX:latex]; + XCTAssertEqual(display.subDisplays.count, 1, @"%@", latex); + XCTAssertTrue([display.subDisplays[0] isKindOfClass:[MTCTLineDisplay class]], @"%@", latex); + return display.subDisplays[0]; +} + +// The kern attached to the character at `index`, 0 when there is none. +- (CGFloat) kernOf:(MTCTLineDisplay*) line atIndex:(NSUInteger) index +{ + NSNumber* kern = [line.attributedString attribute:(NSString*) kCTKernAttributeName + atIndex:index + effectiveRange:NULL]; + return kern.floatValue; +} + +// The math font's own correction for the first character of `str`, read the +// way the typesetter reads it, so the expectation is font-parameterised. +- (CGFloat) mathItalicCorrectionOf:(NSString*) str inFont:(MTFont*) font +{ + unichar chars[str.length]; + [str getCharacters:chars range:NSMakeRange(0, str.length)]; + CGGlyph glyphs[str.length]; + XCTAssertTrue(CTFontGetGlyphsForCharacters(font.ctFont, chars, glyphs, str.length), @"%@", str); + return [font.mathTable getItalicCorrection:glyphs[0]]; +} + +- (CGFloat) mathItalicCorrectionOf:(NSString*) str +{ + return [self mathItalicCorrectionOf:str inFont:self.font]; +} + +- (CGFloat) mathAdvanceOf:(NSString*) str +{ + unichar chars[str.length]; + [str getCharacters:chars range:NSMakeRange(0, str.length)]; + CGGlyph glyphs[str.length]; + XCTAssertTrue(CTFontGetGlyphsForCharacters(self.font.ctFont, chars, glyphs, str.length), @"%@", str); + CGSize advance; + CTFontGetAdvancesForGlyphs(self.font.ctFont, kCTFontOrientationDefault, glyphs, &advance, 1); + return advance.width; +} + +// master read the MATH table's upright f (0.079 em) while the companion drew +// the glyph, leaving 0.066 em of its ink under the superscript. +- (void) testSuperscriptShiftReadsTheFaceThatDrewTheGlyph +{ + CGFloat em = self.font.fontSize; + MTMathListDisplay* display = [self displayForLaTeX:@"\\mathit{f}^2"]; + XCTAssertEqual(display.subDisplays.count, 2); + MTCTLineDisplay* base = display.subDisplays[0]; + MTDisplay* script = display.subDisplays[1]; + XCTAssertEqualWithAccuracy(script.position.x - (base.position.x + base.width), + 0.145 * em, 0.001 * em); +} + +// The math-font side of the same helper, unchanged from master. +- (void) testSuperscriptShiftOnAMathFontGlyphIsUnchanged +{ + MTMathListDisplay* display = [self displayForLaTeX:@"V^a"]; + MTCTLineDisplay* base = display.subDisplays[0]; + MTDisplay* script = display.subDisplays[1]; + XCTAssertEqualWithAccuracy(script.position.x - (base.position.x + base.width), + [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); +} + +@end From 4f773b4dad1353b8886edd1f0ed91e16d364c893 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:02:09 +0530 Subject: [PATCH 2/9] [item 2] Make the inter-element kern additive Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMath/render/internal/MTTypesetter.m | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index b755e32..084fa17 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -990,9 +990,15 @@ - (void) createDisplayAtoms:(NSArray*) preprocessed if (_currentLine.length > 0) { if (interElementSpace > 0) { // add a kerning of that space to the previous character + NSRange prev = [_currentLine.string rangeOfComposedCharacterSequenceAtIndex:_currentLine.length - 1]; + // Additive: that character may already carry an italic + // correction, and assigning would drop it. + NSNumber* kern = [_currentLine attribute:(NSString*) kCTKernAttributeName + atIndex:prev.location + effectiveRange:NULL]; [_currentLine addAttribute:(NSString*) kCTKernAttributeName - value:[NSNumber numberWithFloat:interElementSpace] - range:[_currentLine.string rangeOfComposedCharacterSequenceAtIndex:_currentLine.length - 1]]; + value:@(kern.floatValue + interElementSpace) + range:prev]; } } else { // increase the space From 4eb83920ca57e1c14956ea8eb6fba5a5a48ed6bb Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:04:28 +0530 Subject: [PATCH 3/9] [item 3] Apply italic corrections at the nucleus append site Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMath/render/internal/MTTypesetter.m | 72 ++++++++++++++ iosMathTests/MTItalicCorrectionTest.m | 126 +++++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index 084fa17..225606b 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -1024,6 +1024,10 @@ - (void) createDisplayAtoms:(NSArray*) preprocessed if (atom.fontStyle == kMTFontStyleItalic && atom.type == kMTMathAtomOrdinary) { [self applyMathitFontToRoutableCharactersInRange:appendedRange]; } + // Deliberately not gated on atom type, unlike the \mathit stamp + // above: TeX82 §749 routes all seven noad classes through the same + // nucleus conversion, so §755's correction fires for all of them. + [self applyItalicCorrectionsInRange:appendedRange forAtom:atom]; // add the atom to the current range if (_currentLineIndexRange.location == NSNotFound) { _currentLineIndexRange = atom.indexRange; @@ -1077,6 +1081,32 @@ - (void) createDisplayAtoms:(NSArray*) preprocessed } } +// TeX82 §755 drops the correction on an interior character only when the run is +// set in a text font — a TFM whose FONTDIMEN 2 (SPACE) is nonzero. \math* +// selects a family, and LaTeX binds most of those families to text TFMs; only +// these three sit on a math TFM. A style added later must be classified against +// that table rather than inherit a branch, which is why there is no default:. +static BOOL MTStyleSuppressesInteriorItalicCorrection(MTFontStyle style) +{ + switch (style) { + case kMTFontStyleDefault: // cmmi10 + case kMTFontStyleCaligraphic: // cmsy10 + case kMTFontStyleBoldItalic: // cmmib10 + // \mathit re-families class-7 mathchars only, so anything still drawn in + // the math font here is cmmi10. Its companion half is suppressed by the + // face test in applyItalicCorrectionsInRange:forAtom:. + case kMTFontStyleItalic: + return NO; + case kMTFontStyleRoman: // cmr10 + case kMTFontStyleBold: // cmbx10 + case kMTFontStyleSansSerif: // cmss10 + case kMTFontStyleTypewriter: // cmtt10 + case kMTFontStyleFraktur: // eufm10 + case kMTFontStyleBlackboard: // msbm10 + return YES; + } +} + // Gives maximal runs of routable characters the \mathit companion face. // Evaluated per character, not per atom: fusion merges a whole \mathit group // into one atom, which can mix routable and non-routable characters @@ -1145,6 +1175,48 @@ - (CGFloat) italicCorrectionInCurrentLineAtIndex:(NSUInteger) index return MAX(0, CGRectGetMaxX(bounds) - advance.width); } +// TeX Rule 17: a kern of the glyph's italic correction after each character of +// `range` whose subscript is empty, with Rule 14's interior suppression. +- (void) applyItalicCorrectionsInRange:(NSRange) range forAtom:(MTMathAtom*) atom +{ + NSString* string = _currentLine.string; + NSUInteger i = range.location; + while (i < NSMaxRange(range)) { + NSRange sequence = [string rangeOfComposedCharacterSequenceAtIndex:i]; + NSUInteger next = NSMaxRange(sequence); + BOOL apply; + if (next >= NSMaxRange(range)) { + // Last character of the atom, and nothing ever suppresses the + // correction there. When it carries a script the script path applies + // it instead, so the two paths own this character exclusively. + apply = !atom.subScript && !atom.superScript; + } else { + CTFontRef face = [self faceInCurrentLineAtIndex:i]; + if (!CFEqual(face, [self faceInCurrentLineAtIndex:next])) { + // A face change is a family change, so TeX never marks this + // character math_text_char and §755's AND cannot fire. + apply = YES; + } else { + // Interior of a single-face run: kept only for a math font. + apply = CFEqual(face, _styleFont.ctFont) + && !MTStyleSuppressesInteriorItalicCorrection(atom.fontStyle); + } + } + // The MATH-table metric is signed and TeX applies it with its sign, so + // the test is != 0 rather than > 0. + CGFloat correction = apply ? [self italicCorrectionInCurrentLineAtIndex:i] : 0; + if (correction != 0) { + NSNumber* kern = [_currentLine attribute:(NSString*) kCTKernAttributeName + atIndex:i + effectiveRange:NULL]; + [_currentLine addAttribute:(NSString*) kCTKernAttributeName + value:@(kern.floatValue + correction) + range:sequence]; + } + i = next; + } +} + - (MTCTLineDisplay*) addDisplayLine { /*NSAssert(_currentLineIndexRange.length == numCodePoints(_currentLine.string), diff --git a/iosMathTests/MTItalicCorrectionTest.m b/iosMathTests/MTItalicCorrectionTest.m index 056e440..38d9d77 100644 --- a/iosMathTests/MTItalicCorrectionTest.m +++ b/iosMathTests/MTItalicCorrectionTest.m @@ -108,4 +108,130 @@ - (void) testSuperscriptShiftOnAMathFontGlyphIsUnchanged [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); } +// Default style is cmmi10 (SPACE = 0), so every character of a fused run is +// corrected, interior included. +- (void) testDefaultStyleCorrectsEveryCharacter +{ + MTCTLineDisplay* line = [self lineForLaTeX:@"fVf"]; + CGFloat f = [self mathItalicCorrectionOf:@"\U0001D453"]; + CGFloat V = [self mathItalicCorrectionOf:@"\U0001D449"]; + XCTAssertGreaterThan(f, 0); + XCTAssertGreaterThan(V, 0); + // fVf fuses to one atom of three surrogate pairs. + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], f, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], V, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:4], f, 0.001); +} + +// A run whose last glyph has no correction still corrects the interior. +- (void) testDefaultStyleInteriorCorrectionWithZeroTrailing +{ + MTCTLineDisplay* line = [self lineForLaTeX:@"Vx"]; + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], + [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], 0, 0.001); +} + +// Text-font styles keep only the trailing correction. \mathrm is cmr10 and +// \mathbf is cmbx10, both SPACE != 0. +- (void) testTextFontStylesAreTrailingOnly +{ + MTCTLineDisplay* roman = [self lineForLaTeX:@"\\mathrm{fVf}"]; + XCTAssertEqualWithAccuracy([self kernOf:roman atIndex:0], 0, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:roman atIndex:1], 0, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:roman atIndex:2], + [self mathItalicCorrectionOf:@"f"], 0.001); + + MTCTLineDisplay* bold = [self lineForLaTeX:@"\\mathbf{fVf}"]; + XCTAssertEqualWithAccuracy([self kernOf:bold atIndex:0], 0, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:bold atIndex:2], 0, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:bold atIndex:4], + [self mathItalicCorrectionOf:@"\U0001D41F"], 0.001); +} + +// The two non-default styles that sit on a SPACE = 0 TFM. These fail if the +// gate is ever keyed on "style != default", or if a default: branch swallows +// kMTFontStyleBoldItalic. \mathcal maps lowercase onto the default math-italic +// code points, so \mathcal{ff} and \mathnormal{ff} must agree exactly. +- (void) testMathFontStylesCorrectTheInterior +{ + CGFloat mathItalicF = [self mathItalicCorrectionOf:@"\U0001D453"]; + for (NSString* latex in @[ @"\\mathcal{ff}", @"\\mathnormal{ff}" ]) { + MTCTLineDisplay* line = [self lineForLaTeX:latex]; + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], mathItalicF, 0.001, @"%@", latex); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], mathItalicF, 0.001, @"%@", latex); + } + + MTCTLineDisplay* bm = [self lineForLaTeX:@"\\bm{ff}"]; + CGFloat boldItalicF = [self mathItalicCorrectionOf:@"\U0001D487"]; + XCTAssertGreaterThan(boldItalicF, 0); + XCTAssertEqualWithAccuracy([self kernOf:bm atIndex:0], boldItalicF, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:bm atIndex:2], boldItalicF, 0.001); +} + +// A style change ends a run, so the correction survives on the left glyph even +// for a suppressed style — pdfTeX's $V\mathrm{l}$ -> V ·2.22223· l. Fusion +// never merges across styles, so this proves the gate is per-atom. +- (void) testCorrectionAppliesAtAStyleSeam +{ + MTCTLineDisplay* seam = [self lineForLaTeX:@"V\\mathrm{l}"]; + XCTAssertEqualWithAccuracy([self kernOf:seam atIndex:0], + [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); + + MTCTLineDisplay* suppressed = [self lineForLaTeX:@"\\mathrm{a}\\mathbf{b}"]; + XCTAssertEqualWithAccuracy([self kernOf:suppressed atIndex:0], + [self mathItalicCorrectionOf:@"a"], 0.001); + + MTCTLineDisplay* unGated = [self lineForLaTeX:@"\\mathnormal{f}\\mathrm{x}"]; + XCTAssertEqualWithAccuracy([self kernOf:unGated atIndex:0], + [self mathItalicCorrectionOf:@"\U0001D453"], 0.001); +} + +// A cross-atom boundary inside one line: the Close atom appends after the +// corrected V. +- (void) testCorrectionAppliesBeforeAClosingDelimiter +{ + MTCTLineDisplay* line = [self lineForLaTeX:@"V]"]; + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], + [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); +} + +// Companion glyphs are cmti10 (SPACE != 0), so the interior is suppressed and +// only the last f carries the measured overhang. Asserting kern placement +// rather than run width distinguishes "suppressed interior" from "a smaller +// correction everywhere". +- (void) testCompanionRunIsTrailingOnly +{ + CGFloat em = self.font.fontSize; + MTCTLineDisplay* line = [self lineForLaTeX:@"\\mathit{fVf}"]; + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], 0, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:1], 0, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], 0.145 * em, 0.001 * em); +} + +// The face seam inside one nucleus: f is drawn by the companion, alpha by the +// math font, so the run ends at f and its correction is applied. The only case +// where an interior companion glyph is corrected, and the only one that fails +// if the seam check is dropped. +- (void) testCorrectionAppliesAtAFaceSeamInsideOneNucleus +{ + CGFloat em = self.font.fontSize; + MTCTLineDisplay* line = [self lineForLaTeX:@"\\mathit{f\\alpha}"]; + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], 0.145 * em, 0.001 * em); +} + +// Neither beta nor gamma is routable, so both stay in the math font and the +// interior is corrected. This is the only test that pins kMTFontStyleItalic in +// the gate's NO branch. Beta, not alpha: alpha has no italic entry in Latin +// Modern, so an alpha-first case would pass whichever branch the style took. +- (void) testMathitInteriorIsCorrectedInTheMathFont +{ + MTCTLineDisplay* line = [self lineForLaTeX:@"\\mathit{\\beta\\gamma}"]; + CGFloat beta = [self mathItalicCorrectionOf:@"\U0001D6FD"]; + XCTAssertGreaterThan(beta, 0); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], beta, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], + [self mathItalicCorrectionOf:@"\U0001D6FE"], 0.001); +} + @end From f11fdec5cb33576bf7a5e77402c561611852bf92 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:07:05 +0530 Subject: [PATCH 4/9] [item 4] Pin flush-boundary and script behaviour Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMathTests/MTItalicCorrectionTest.m | 80 +++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/iosMathTests/MTItalicCorrectionTest.m b/iosMathTests/MTItalicCorrectionTest.m index 38d9d77..cb56df5 100644 --- a/iosMathTests/MTItalicCorrectionTest.m +++ b/iosMathTests/MTItalicCorrectionTest.m @@ -234,4 +234,84 @@ - (void) testMathitInteriorIsCorrectedInTheMathFont [self mathItalicCorrectionOf:@"\U0001D6FE"], 0.001); } +// The trailing kern reaches MTCTLineDisplay.width — the measured CoreText +// property the whole flush story rests on. A lone f is also the end-of-list case. +- (void) testTrailingCorrectionReachesLineWidth +{ + MTCTLineDisplay* line = [self lineForLaTeX:@"f"]; + XCTAssertEqualWithAccuracy(line.width, + [self mathAdvanceOf:@"\U0001D453"] + [self mathItalicCorrectionOf:@"\U0001D453"], + 0.001); +} + +// ...and every flush site inherits it through the display's width, with no +// pending-advance state anywhere. +- (void) testTrailingCorrectionSurvivesEveryFlushSite +{ + CGFloat expected = [self mathAdvanceOf:@"\U0001D453"] + [self mathItalicCorrectionOf:@"\U0001D453"]; + for (NSString* latex in @[ @"f\\sqrt{x}", @"f\\sum x", @"f\\,x", @"f\\frac{1}{2}", + @"f\\left(x\\right)", @"f\\color{#ff0000}{x}" ]) { + MTMathListDisplay* display = [self displayForLaTeX:latex]; + MTCTLineDisplay* line = display.subDisplays[0]; + XCTAssertTrue([line isKindOfClass:[MTCTLineDisplay class]], @"%@", latex); + XCTAssertEqualWithAccuracy(line.width, expected, 0.001, @"%@", latex); + } + + // Ordinary -> Radical takes no inter-element space, so the next display + // starts exactly where the corrected line ends. + MTMathListDisplay* radical = [self displayForLaTeX:@"f\\sqrt{x}"]; + MTCTLineDisplay* line = radical.subDisplays[0]; + MTDisplay* next = radical.subDisplays[1]; + XCTAssertEqualWithAccuracy(next.position.x, line.position.x + line.width, 0.001); +} + +// The final character is corrected by exactly one path, never both, and a +// subscript still blocks the base from advancing. +- (void) testScriptedAndScriptlessGlyphsAgree +{ + CGFloat f = [self mathItalicCorrectionOf:@"\U0001D453"]; + CGFloat advance = [self mathAdvanceOf:@"\U0001D453"]; + + // Scriptless: the correction is in the line width. + XCTAssertEqualWithAccuracy([self lineForLaTeX:@"f"].width, advance + f, 0.001); + + // Superscript: the correction shifts the script instead, and the base line + // keeps its bare advance — applied once, not twice. + MTMathListDisplay* sup = [self displayForLaTeX:@"f^a"]; + MTCTLineDisplay* supBase = sup.subDisplays[0]; + XCTAssertEqualWithAccuracy(supBase.width, advance, 0.001); + XCTAssertEqualWithAccuracy(sup.subDisplays[1].position.x - supBase.width, f, 0.001); + + // Subscript: the base does not advance by the correction. + MTMathListDisplay* sub = [self displayForLaTeX:@"f_a"]; + MTCTLineDisplay* subBase = sub.subDisplays[0]; + XCTAssertEqualWithAccuracy(subBase.width, advance, 0.001); + XCTAssertEqualWithAccuracy(sub.subDisplays[1].position.x, subBase.width, 0.001); + + // Both scripts: the superscript carries the correction, the subscript does not. + MTMathListDisplay* both = [self displayForLaTeX:@"f_a^b"]; + MTCTLineDisplay* bothBase = both.subDisplays[0]; + MTDisplay* superscript = nil; + MTDisplay* subscript = nil; + for (MTMathListDisplay* d in both.subDisplays) { + if (![d isKindOfClass:[MTMathListDisplay class]]) { continue; } + if (d.type == kMTLinePositionSuperscript) { superscript = d; } + if (d.type == kMTLinePositionSubscript) { subscript = d; } + } + XCTAssertEqualWithAccuracy(superscript.position.x - subscript.position.x, f, 0.001); + XCTAssertEqualWithAccuracy(subscript.position.x, bothBase.width, 0.001); +} + +// A fused atom whose last character carries the script: the interior is +// corrected here, the last character by the script path. +- (void) testFusedAtomWithAScriptOnItsLastCharacter +{ + MTMathListDisplay* display = [self displayForLaTeX:@"Vt^2"]; + MTCTLineDisplay* line = display.subDisplays[0]; + // Vt fuses to one atom; V is interior and corrected, t has no correction. + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], + [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], 0, 0.001); +} + @end From 4bbd0cb81bd016b4bf1aef68cc63e797c7f49e57 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:07:41 +0530 Subject: [PATCH 5/9] [item 5] Pin companion metric and cross-font behaviour Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMathTests/MTItalicCorrectionTest.m | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/iosMathTests/MTItalicCorrectionTest.m b/iosMathTests/MTItalicCorrectionTest.m index cb56df5..4c9ee57 100644 --- a/iosMathTests/MTItalicCorrectionTest.m +++ b/iosMathTests/MTItalicCorrectionTest.m @@ -314,4 +314,88 @@ - (void) testFusedAtomWithAScriptOnItsLastCharacter XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], 0, 0.001); } +// Digits and capital Greek are routable too, so they are measured in the +// companion. \mathit{7} doubles as a face check: the math font's own correction +// for upright 7 is 0.013 em, so reading the wrong face gives a plausible wrong +// number rather than zero. +- (void) testCompanionDigitsAndCapitalGreek +{ + CGFloat em = self.font.fontSize; + XCTAssertEqualWithAccuracy([self kernOf:[self lineForLaTeX:@"\\mathit{7}"] atIndex:0], + 0.114 * em, 0.001 * em); + XCTAssertEqualWithAccuracy([self kernOf:[self lineForLaTeX:@"\\mathit{\\Pi}"] atIndex:0], + 0.108 * em, 0.001 * em); + + // No ink past the advance: nothing to clear, no kern, and no floor. + XCTAssertEqualWithAccuracy([self kernOf:[self lineForLaTeX:@"\\mathit{1}"] atIndex:0], 0, 0.001); + XCTAssertEqualWithAccuracy([self kernOf:[self lineForLaTeX:@"\\mathit{\\Delta}"] atIndex:0], 0, 0.001); +} + +// Latin Modern and New CM both fall through to the bundled companion, so their +// values are ours to pin. The other six resolve to OS faces: assert where the +// kern landed and that it is bounded, not what Apple's outlines measure. +- (void) testCompanionCorrectionAcrossAllBundledFonts +{ + NSArray* bundledCompanion = @[ MTFontNameLatinModern, MTFontNameNewComputerModern ]; + NSArray* names = @[ MTFontNameLatinModern, MTFontNameXITS, MTFontNameTermes, + MTFontNameNewComputerModern, MTFontNamePagella, + MTFontNameSTIXTwo, MTFontNameFiraMath, MTFontNameNotoSansMath ]; + for (NSString* name in names) { + MTFont* font = [MTFontManager.fontManager fontWithName:name size:20]; + MTMathListDisplay* display = [self displayForLaTeX:@"\\mathit{fVf}" withFont:font]; + XCTAssertEqual(display.subDisplays.count, 1, @"%@", name); + MTCTLineDisplay* line = display.subDisplays[0]; + + // Trailing-only, whatever the face measures. + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], 0, 0.001, @"%@", name); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:1], 0, 0.001, @"%@", name); + CGFloat trailing = [self kernOf:line atIndex:2]; + XCTAssertGreaterThanOrEqual(trailing, 0, @"%@", name); + XCTAssertLessThanOrEqual(trailing, 0.4 * font.fontSize, @"%@", name); + + if ([bundledCompanion containsObject:name]) { + XCTAssertEqualWithAccuracy(trailing, 0.145 * font.fontSize, 0.001 * font.fontSize, @"%@", name); + } + } +} + +// The math-font correction is a font parameter, so the same assertion runs +// across all eight bundled fonts by reading its own expectation. +- (void) testMathFontCorrectionIsFontParameterised +{ + for (NSString* name in @[ MTFontNameLatinModern, MTFontNameXITS, MTFontNameTermes, + MTFontNameNewComputerModern, MTFontNamePagella, + MTFontNameSTIXTwo, MTFontNameFiraMath, MTFontNameNotoSansMath ]) { + MTFont* font = [MTFontManager.fontManager fontWithName:name size:20]; + MTMathListDisplay* display = [self displayForLaTeX:@"fVf" withFont:font]; + MTCTLineDisplay* line = display.subDisplays[0]; + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], + [self mathItalicCorrectionOf:@"\U0001D453" inFont:font], 0.001, @"%@", name); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], + [self mathItalicCorrectionOf:@"\U0001D449" inFont:font], 0.001, @"%@", name); + XCTAssertEqualWithAccuracy([self kernOf:line atIndex:4], + [self mathItalicCorrectionOf:@"\U0001D453" inFont:font], 0.001, @"%@", name); + } +} + +// New CM is the one bundled font with GPOS pair kerning on math-italic glyphs. +// The correction must stack on the shaped position, not replace it. +- (void) testCorrectionStacksOnNativePairKerning +{ + MTFont* newcm = [MTFontManager.fontManager fontWithName:MTFontNameNewComputerModern size:20]; + MTMathListDisplay* display = [self displayForLaTeX:@"B." withFont:newcm]; + MTCTLineDisplay* line = display.subDisplays[0]; + + NSMutableAttributedString* unkerned = [line.attributedString mutableCopy]; + [unkerned removeAttribute:(NSString*) kCTKernAttributeName range:NSMakeRange(0, unkerned.length)]; + CTLineRef shaped = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef) unkerned); + CGFloat shapedWidth = CTLineGetTypographicBounds(shaped, NULL, NULL, NULL); + CFRelease(shaped); + + CGFloat correction = [self mathItalicCorrectionOf:@"\U0001D435" inFont:newcm]; + XCTAssertGreaterThan(correction, 0); + // shaped + correction, not rawAdvance + correction. + XCTAssertEqualWithAccuracy(line.width, shapedWidth + correction, 0.001); +} + @end From c5bf7c54b9745ee8d4cf89b047d7382d4d7a7446 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:08:08 +0530 Subject: [PATCH 6/9] [item 6] Pin kern composition and zero-correction cases Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMathTests/MTItalicCorrectionTest.m | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/iosMathTests/MTItalicCorrectionTest.m b/iosMathTests/MTItalicCorrectionTest.m index 4c9ee57..917815a 100644 --- a/iosMathTests/MTItalicCorrectionTest.m +++ b/iosMathTests/MTItalicCorrectionTest.m @@ -398,4 +398,36 @@ - (void) testCorrectionStacksOnNativePairKerning XCTAssertEqualWithAccuracy(line.width, shapedWidth + correction, 0.001); } +// f+1 carries both a correction and a binary-operator space on the f. Each is +// isolated by a control that has only one of them. +- (void) testCorrectionAndInterElementSpaceCompose +{ + CGFloat correctionOnly = [self kernOf:[self lineForLaTeX:@"f1"] atIndex:0]; + CGFloat spaceOnly = [self kernOf:[self lineForLaTeX:@"x+1"] atIndex:0]; + CGFloat both = [self kernOf:[self lineForLaTeX:@"f+1"] atIndex:0]; + XCTAssertGreaterThan(correctionOnly, 0); // x has no correction, f does + XCTAssertGreaterThan(spaceOnly, 0); + XCTAssertEqualWithAccuracy(both, correctionOnly + spaceOnly, 0.001); + + CGFloat relationSpace = [self kernOf:[self lineForLaTeX:@"x="] atIndex:0]; + XCTAssertGreaterThan(relationSpace, 0); + XCTAssertEqualWithAccuracy([self kernOf:[self lineForLaTeX:@"V="] atIndex:0], + [self mathItalicCorrectionOf:@"\U0001D449"] + relationSpace, 0.001); +} + +// Nothing is attached where the font reports no correction, so these are +// byte-identical to master. +- (void) testZeroCorrectionGlyphsAreUntouched +{ + for (NSString* latex in @[ @"\\mathrm{abc}", @"123" ]) { + MTCTLineDisplay* line = [self lineForLaTeX:latex]; + [line.attributedString enumerateAttribute:(NSString*) kCTKernAttributeName + inRange:NSMakeRange(0, line.attributedString.length) + options:0 + usingBlock:^(NSNumber* kern, NSRange range, BOOL* stop) { + XCTAssertNil(kern, @"%@ has a kern at %@", latex, NSStringFromRange(range)); + }]; + } +} + @end From f41e8def5880c6b430733383ad59c77d19fe223f Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:10:13 +0530 Subject: [PATCH 7/9] [item 7] Pin that the text path takes no correction --- iosMathTests/MTItalicCorrectionTest.m | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/iosMathTests/MTItalicCorrectionTest.m b/iosMathTests/MTItalicCorrectionTest.m index 917815a..e710553 100644 --- a/iosMathTests/MTItalicCorrectionTest.m +++ b/iosMathTests/MTItalicCorrectionTest.m @@ -430,4 +430,30 @@ - (void) testZeroCorrectionGlyphsAreUntouched } } +// \textit{fVf} renders through MTTextDisplay, which the correction path never +// sees. The property is structural, so one smoke case is enough. +- (void) testTextPathTakesNoCorrection +{ + MTMathListDisplay* display = [self displayForLaTeX:@"\\textit{fVf}"]; + XCTAssertEqual(display.subDisplays.count, 1); + XCTAssertFalse([display.subDisplays[0] isKindOfClass:[MTCTLineDisplay class]]); +} + +// Two properties of \math* vs \text* that are already correct and must stay +// that way: math mode discards an interword space, and \text shrinks in scripts. +- (void) testMathAndTextModeDifferencesAreUnchanged +{ + MTCTLineDisplay* math = [self lineForLaTeX:@"\\mathrm{a b}"]; + XCTAssertEqualObjects(math.attributedString.string, @"ab"); + + MTMathListDisplay* text = [self displayForLaTeX:@"\\text{a b}"]; + MTTextDisplay* textDisplay = text.subDisplays[0]; + XCTAssertEqualObjects(textDisplay.text, @"a b"); + + MTMathListDisplay* scripted = [self displayForLaTeX:@"x^{\\text{ab}}"]; + MTMathListDisplay* superscript = scripted.subDisplays[1]; + MTTextDisplay* scriptedText = superscript.subDisplays[0]; + XCTAssertLessThan(scriptedText.ascent, textDisplay.ascent); +} + @end From c3bcfabb942d7fef703b643e048a5002adcdaff6 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 03:45:43 +0530 Subject: [PATCH 8/9] [item 8] Update geometry baselines for italic correction Every failing assertion is reconciled to a sum of the italic corrections introduced by items 1-6, propagated through unchanged composition/layout logic (composite ink rollup, accent centering, table column width, the mathit companion-overhang correction, and CoreText ligature shaping) - never blind-rebaselined. Each updated constant carries a comment naming its source. Also registers MTItalicCorrectionTest.m with the iosMathTests Xcode target (4 entries, following MTInkWidthTest.m's pattern). --- iosMath.xcodeproj/project.pbxproj | 4 +++ iosMathTests/MTInkWidthTest.m | 11 +++++-- iosMathTests/MTMathUILabelSizingTest.m | 12 +++++-- iosMathTests/MTTypesetterTest.m | 45 ++++++++++++++++++++------ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/iosMath.xcodeproj/project.pbxproj b/iosMath.xcodeproj/project.pbxproj index 9f0cb1f..574a965 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 490465BF1D23DA8400F82033 /* MTTypesetterTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 490465BE1D23DA8400F82033 /* MTTypesetterTest.m */; }; 49A1B2C41D23DA8400F82033 /* MTInkWidthTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49A1B2C31D23DA8400F82033 /* MTInkWidthTest.m */; }; + B5F0B5C103FA9EF7992D11F9 /* MTItalicCorrectionTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA6FC9DE3C76B9C2E7DF10 /* MTItalicCorrectionTest.m */; }; 490465C11D23DA8400F82033 /* MTFontManagerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 490465C01D23DA8400F82033 /* MTFontManagerTest.m */; }; 492EED0817DAEDD200939107 /* MTFontManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 492EECFA17DAED9000939107 /* MTFontManager.m */; }; 492EED0917DAEDD200939107 /* MTFontMathTable.m in Sources */ = {isa = PBXBuildFile; fileRef = 492EECF817DAED9000939107 /* MTFontMathTable.m */; }; @@ -78,6 +79,7 @@ /* Begin PBXFileReference section */ 490465BE1D23DA8400F82033 /* MTTypesetterTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTTypesetterTest.m; sourceTree = ""; }; 49A1B2C31D23DA8400F82033 /* MTInkWidthTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTInkWidthTest.m; sourceTree = ""; }; + 4EEA6FC9DE3C76B9C2E7DF10 /* MTItalicCorrectionTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTItalicCorrectionTest.m; sourceTree = ""; }; 490465C01D23DA8400F82033 /* MTFontManagerTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTFontManagerTest.m; sourceTree = ""; }; 492EECF317DAED9000939107 /* MTMathListDisplay.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMathListDisplay.h; sourceTree = ""; }; 492EECF417DAED9000939107 /* MTMathUILabel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMathUILabel.h; sourceTree = ""; }; @@ -259,6 +261,7 @@ 49B83EF517CF046A0014B739 /* MTMathListTest.m */, 490465BE1D23DA8400F82033 /* MTTypesetterTest.m */, 49A1B2C31D23DA8400F82033 /* MTInkWidthTest.m */, + 4EEA6FC9DE3C76B9C2E7DF10 /* MTItalicCorrectionTest.m */, 490465C01D23DA8400F82033 /* MTFontManagerTest.m */, 49965F2417CBBA2700A555C5 /* Supporting Files */, ); @@ -538,6 +541,7 @@ 498730A817D548190041B02B /* MTMathListTest.m in Sources */, 490465BF1D23DA8400F82033 /* MTTypesetterTest.m in Sources */, 49A1B2C41D23DA8400F82033 /* MTInkWidthTest.m in Sources */, + B5F0B5C103FA9EF7992D11F9 /* MTItalicCorrectionTest.m in Sources */, 490465C11D23DA8400F82033 /* MTFontManagerTest.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iosMathTests/MTInkWidthTest.m b/iosMathTests/MTInkWidthTest.m index 26d46b8..82897b7 100644 --- a/iosMathTests/MTInkWidthTest.m +++ b/iosMathTests/MTInkWidthTest.m @@ -42,7 +42,9 @@ - (void)testCTLineLeafInk { MTCTLineDisplay* lineP = (MTCTLineDisplay*)dP.subDisplays.firstObject; XCTAssertTrue([lineP isKindOfClass:[MTCTLineDisplay class]]); XCTAssertGreaterThanOrEqual(lineP.inkWidth, 15.08 - 0.01); // P ink right = 15.08 - XCTAssertGreaterThan(lineP.inkWidth, lineP.width); // 15.08 > advance 12.84 + // ε (2.80) now exceeds P's 2.24 of protruding ink, so the advance alone + // covers the ink extent and inkWidth collapses onto width (LLD §5). + XCTAssertEqualWithAccuracy(lineP.inkWidth, lineP.width, 0.01); // Control: x ink (10.54) < advance (11.44) → inkWidth stays the advance. MTMathListDisplay* dx = [self displayFor:@"x"]; @@ -244,7 +246,12 @@ - (void)assertComposite:(Class)cls bare:(NSString*)bare shifted:(NSString*)shift XCTAssertNotNil(s, @"no %@ in %@", NSStringFromClass(cls), shifted); XCTAssertGreaterThanOrEqual(b.inkWidth, [self composedInkRightOf:b] - 0.01); XCTAssertGreaterThanOrEqual(s.inkWidth, [self composedInkRightOf:s] - 0.01); - XCTAssertGreaterThan(b.inkWidth, b.width); // trailing child overhangs + // Every bare/shifted pair here ends in a trailing V. V's correction (4.28) now + // exceeds V's own protruding ink (the same LLD §5 mechanism as the P case in + // testCTLineLeafInk), so V no longer overhangs its own advance -- and since V is + // always the composite's rightmost child, the composite doesn't overhang either. + // "Trailing child overhangs" no longer holds; assert the collapse instead. + XCTAssertEqualWithAccuracy(b.inkWidth, b.width, 0.01); XCTAssertGreaterThan(s.position.x, b.position.x); // shifted variant is further right XCTAssertEqualWithAccuracy(s.inkWidth - s.width, b.inkWidth - b.width, 0.02); // basis-invariant } diff --git a/iosMathTests/MTMathUILabelSizingTest.m b/iosMathTests/MTMathUILabelSizingTest.m index 14f517b..a9be6a1 100644 --- a/iosMathTests/MTMathUILabelSizingTest.m +++ b/iosMathTests/MTMathUILabelSizingTest.m @@ -78,7 +78,10 @@ - (void)testNilLatexReportsRoundedInsets { } - (void)testAlignmentUsesInkWidth { - NSString* latex = @"V"; // heavy right overhang (advance 11.66, ink 15.38) + // V's italic correction (4.28) now covers its ink overhang (advance grows to + // 15.94, matching its 15.94 ink; see LLD §5), so the bound below holds with + // equality rather than slack -- it still guards against clipping either way. + NSString* latex = @"V"; for (NSNumber* alignN in @[@(kMTTextAlignmentLeft), @(kMTTextAlignmentCenter), @(kMTTextAlignmentRight)]) { MTMathUILabel* label = [[MTMathUILabel alloc] init]; label.latex = latex; @@ -105,7 +108,12 @@ - (void)testAlignmentUsesInkWidth { - (void)testScaleLifecycleInvalidates { MTSpyLabel* label = [[MTSpyLabel alloc] init]; label.forcedScale = 1; - label.latex = @"V"; // ink overhang; its 1x and 3x rounded widths differ + // V's italic correction (4.28) now exceeds its own ink overhang (LLD §5), so its + // ink width (15.94) rounds to the same 16pt at 1x and 3x (ceil(15.94)==ceil(47.82)/3) + // and no longer demonstrates the scale-dependent rounding this test needs. P's + // corrected ink width (15.64) still straddles a 1x/3x rounding boundary + // (ceil(15.64)=16 vs ceil(46.92)/3=15.67), so swap to P for that property. + label.latex = @"P"; CGSize sizeAt1x = label.intrinsicContentSize; XCTAssertEqualWithAccuracy(sizeAt1x.width, round(sizeAt1x.width), 0.001, @"1x width off grid"); diff --git a/iosMathTests/MTTypesetterTest.m b/iosMathTests/MTTypesetterTest.m index 442c265..1883f25 100644 --- a/iosMathTests/MTTypesetterTest.m +++ b/iosMathTests/MTTypesetterTest.m @@ -113,7 +113,9 @@ - (void)testMultipleVariables { XCTAssertEqualWithAccuracy(display.ascent, 8.834, 0.01); XCTAssertEqualWithAccuracy(display.descent, 4.1, 0.01); - XCTAssertEqualWithAccuracy(display.width, 44.86, 0.01); + // +1.22: trailing ε on y (0.56) and z (0.60), each its own atom so each takes its + // own correction, plus w (0.06); x's correction is 0. + XCTAssertEqualWithAccuracy(display.width, 46.08, 0.01); } - (void)testVariablesAndNumbers { @@ -144,7 +146,8 @@ - (void)testVariablesAndNumbers { XCTAssertEqualWithAccuracy(display.ascent, 13.32, 0.01); XCTAssertEqualWithAccuracy(display.descent, 4.1, 0.01); - XCTAssertEqualWithAccuracy(display.width, 45.56, 0.01); + // +0.62: trailing ε on y (0.56) and w (0.06); x and 2 have zero correction. + XCTAssertEqualWithAccuracy(display.width, 46.18, 0.01); } - (void)testEquationWithOperatorsAndRelations { @@ -175,7 +178,10 @@ - (void)testEquationWithOperatorsAndRelations { XCTAssertEqualWithAccuracy(display.ascent, 13.32, 0.01); XCTAssertEqualWithAccuracy(display.descent, 4.1, 0.01); - XCTAssertEqualWithAccuracy(display.width, 92.36, 0.01); + // +0.56: trailing ε on the final y; 2, x, +, 3, = all have zero correction. The + // 4.44/5.56pt kerns visible in the run are pre-existing operator inter-element + // spacing (unrelated to italic correction) already baked into the old baseline. + XCTAssertEqualWithAccuracy(display.width, 92.92, 0.01); } #define XCTAssertEqualsCGPoint(p1, p2, accuracy, ...) \ @@ -1540,7 +1546,13 @@ - (void) testMathTable XCTAssertEqual(display2.subDisplays.count, 3); CGFloat rowPos[3] = { 30.28, -2.68, -31.95}; // alignment is right, center, left. - CGFloat cellPos[3][3] = { { 35.89, 65.89, 129.438 }, { 45.89, 76.94, 129.438}, { 0, 87.66, 129.438}}; + // Column 1 (center) holds "y+z" (row 0) and the fraction (row 1); "y+z" gains + // +1.16 (trailing ε on y=0.56 and z=0.60, each its own atom) and becomes the + // widest cell in the column, growing column 1's width by that same +1.16 and + // shifting rows 1-2's column-1 cells right by half of it (+0.58) under center + // alignment. Column 2 (left-aligned) starts right after column 1, so every row's + // column-2 cell shifts right by the full +1.16 column-1 growth. + CGFloat cellPos[3][3] = { { 35.89, 65.89, 130.598 }, { 45.89, 77.52, 130.598}, { 0, 88.24, 130.598}}; // check the 3 rows of the matrix for (int i = 0; i < 3; i++) { MTDisplay* sub0i = display2.subDisplays[i]; @@ -1944,14 +1956,18 @@ - (void)testWideAccent { XCTAssertFalse(line2.hasScript); MTGlyphDisplay* glyph = accentDisp.accent; - XCTAssertEqualsCGPoint(glyph.position, CGPointMake(3.47, 0), 0.01); + // +0.61: the accentee "xyzw" grows by +1.22 (trailing ε on y and z, plus w; see + // testMultipleVariables), and the accent glyph is centered over the accentee, so + // it shifts by half that growth. + XCTAssertEqualsCGPoint(glyph.position, CGPointMake(4.08, 0), 0.01); XCTAssertEqualNSRange(glyph.range, NSMakeRange(0, 1)); XCTAssertFalse(glyph.hasScript); // dimensions XCTAssertEqualWithAccuracy(display.ascent, 14.98, 0.01); XCTAssertEqualWithAccuracy(display.descent, 4.1, 0.01); - XCTAssertEqualWithAccuracy(display.width, 44.86, 0.01); + // +1.22: same accentee growth as testMultipleVariables's "xyzw". + XCTAssertEqualWithAccuracy(display.width, 46.08, 0.01); } - (void)testLargeDelimiterHeightsIncreaseBySize @@ -3799,7 +3815,10 @@ - (void) testMathitChangesWidthOfRoutableCharacters CGFloat em = self.font.fontSize; MTMathListDisplay* mathitF = [self displayForLaTeX:@"\\mathit{f}"]; MTMathListDisplay* plainF = [self displayForLaTeX:@"f"]; - XCTAssertEqualWithAccuracy(mathitF.width, 0.307 * em, 0.01 * em); + // +0.145em: the companion glyph's own ink overhangs its advance by 2.90pt + // (lmroman10-italic f: advance 6.14, ink right 9.04), and items 1-6 now add + // that overhang as f's trailing italic correction (LLD §3 companion contract). + XCTAssertEqualWithAccuracy(mathitF.width, (0.307 + 0.145) * em, 0.01 * em); XCTAssertGreaterThan(fabs(plainF.width - mathitF.width), 0.05 * em); MTMathListDisplay* mathitOne = [self displayForLaTeX:@"\\mathit{1}"]; @@ -3839,14 +3858,20 @@ - (void) testMathitAppliesCompanionKerning - (void) testMathitAppliesCompanionLigature { - // One shaped run, one glyph: the f_i ligature. Asserted on glyph count, - // not width, since a ligature can be advance-neutral. + // Previously one shaped run, one glyph: the f_i ligature. Items 1-6 now stamp + // a trailing italic-correction kern on the atom's last character (i's 1.02pt + // companion overhang; f is interior so gets none, per the text-font + // interior-suppression rule), which is an attribute boundary between f and i + // that CoreText's shaper won't ligature across. GSUB-aware correction + // placement is out of scope for this PR (LLD §6), so the ligature no longer + // forms: two runs of one (unshaped) glyph each, not one run of a ligature glyph. MTMathListDisplay* display = [self displayForLaTeX:@"\\mathit{fi}"]; MTCTLineDisplay* line = (MTCTLineDisplay*) display.subDisplays[0]; XCTAssertTrue([line isKindOfClass:[MTCTLineDisplay class]]); NSArray* runs = (__bridge NSArray*) CTLineGetGlyphRuns(line.line); - XCTAssertEqual(runs.count, 1); + XCTAssertEqual(runs.count, 2); XCTAssertEqual(CTRunGetGlyphCount((__bridge CTRunRef) runs[0]), 1); + XCTAssertEqual(CTRunGetGlyphCount((__bridge CTRunRef) runs[1]), 1); } - (void) testMathitShapingDoesNotCrossStyleBoundary From 96059d1189c171069f79cf41a63757e454edbf59 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 12 Aug 2026 11:49:49 +0530 Subject: [PATCH 9/9] Address review: cast subDisplays, restore composite ink overhang coverage Cast subDisplays elements at their 16 use sites in MTItalicCorrectionTest. subDisplays is NSArray*, so assigning an element straight into a subclass local is -Wincompatible-pointer-types; MTTypesetterTest and MTInkWidthTest already either declare MTDisplay* or cast explicitly. Add testCompositeInkTracksOverhangingChild. Every assertComposite: case trails a V, which no longer overhangs its corrected advance, so the composed-ink assertion holds trivially and deleting the MAX over children from a composite inkWidth getter would fail nothing. No plain glyph overhangs any more -- a sweep of ~150 characters across the four bundled fonts finds none -- so the collapse assertion stays and the new test nests \vec{f}, whose accent glyph still overhangs, to force the child to drive the getter. \sum and \overrightarrow are excluded: their own glyph always covers the base, so they cannot overhang at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMathTests/MTInkWidthTest.m | 18 +++++++++++++ iosMathTests/MTItalicCorrectionTest.m | 37 ++++++++++++++------------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/iosMathTests/MTInkWidthTest.m b/iosMathTests/MTInkWidthTest.m index 82897b7..e7cd84c 100644 --- a/iosMathTests/MTInkWidthTest.m +++ b/iosMathTests/MTInkWidthTest.m @@ -171,6 +171,24 @@ - (void)testInnerInk { [self assertComposite:[MTInnerDisplay class] bare:@"\\left( V \\right." shifted:@"a\\left( V \\right."]; } +// The composites above all trail a V, which no longer overhangs, so their getters +// would still pass with the MAX over children deleted. No plain glyph overhangs +// its corrected advance any more, but \vec{f}'s accent glyph does (see +// testAccentGlyphInk), so nesting it is what still forces the child to drive the +// composite's inkWidth. \sum and \overrightarrow are absent because their own +// glyph is always wide enough to cover the base -- they can't overhang at all. +- (void)testCompositeInkTracksOverhangingChild { + for (NSArray* c in @[ @[ [MTFractionDisplay class], @"\\frac{1}{\\vec{f}}" ], + @[ [MTRadicalDisplay class], @"\\sqrt{\\vec{f}}" ], + @[ [MTLineDisplay class], @"\\overline{\\vec{f}}" ], + @[ [MTInnerDisplay class], @"\\left( \\vec{f} \\right." ] ]) { + MTDisplay* d = [self findDisplayOfClass:c[0] in:[self displayFor:c[1]]]; + XCTAssertNotNil(d, @"%@", c[1]); + XCTAssertGreaterThan(d.inkWidth, d.width, @"%@", c[1]); + XCTAssertGreaterThanOrEqual(d.inkWidth, [self composedInkRightOf:d] - 0.01, @"%@", c[1]); + } +} + // Depth-first: the first display of the given class, or nil. - (MTDisplay*)findDisplayOfClass:(Class)cls in:(MTDisplay*)d { if ([d isKindOfClass:cls]) return d; diff --git a/iosMathTests/MTItalicCorrectionTest.m b/iosMathTests/MTItalicCorrectionTest.m index e710553..4c2a568 100644 --- a/iosMathTests/MTItalicCorrectionTest.m +++ b/iosMathTests/MTItalicCorrectionTest.m @@ -46,7 +46,7 @@ - (MTCTLineDisplay*) lineForLaTeX:(NSString*) latex MTMathListDisplay* display = [self displayForLaTeX:latex]; XCTAssertEqual(display.subDisplays.count, 1, @"%@", latex); XCTAssertTrue([display.subDisplays[0] isKindOfClass:[MTCTLineDisplay class]], @"%@", latex); - return display.subDisplays[0]; + return (MTCTLineDisplay*) display.subDisplays[0]; } // The kern attached to the character at `index`, 0 when there is none. @@ -92,7 +92,7 @@ - (void) testSuperscriptShiftReadsTheFaceThatDrewTheGlyph CGFloat em = self.font.fontSize; MTMathListDisplay* display = [self displayForLaTeX:@"\\mathit{f}^2"]; XCTAssertEqual(display.subDisplays.count, 2); - MTCTLineDisplay* base = display.subDisplays[0]; + MTCTLineDisplay* base = (MTCTLineDisplay*) display.subDisplays[0]; MTDisplay* script = display.subDisplays[1]; XCTAssertEqualWithAccuracy(script.position.x - (base.position.x + base.width), 0.145 * em, 0.001 * em); @@ -102,7 +102,7 @@ - (void) testSuperscriptShiftReadsTheFaceThatDrewTheGlyph - (void) testSuperscriptShiftOnAMathFontGlyphIsUnchanged { MTMathListDisplay* display = [self displayForLaTeX:@"V^a"]; - MTCTLineDisplay* base = display.subDisplays[0]; + MTCTLineDisplay* base = (MTCTLineDisplay*) display.subDisplays[0]; MTDisplay* script = display.subDisplays[1]; XCTAssertEqualWithAccuracy(script.position.x - (base.position.x + base.width), [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); @@ -252,7 +252,7 @@ - (void) testTrailingCorrectionSurvivesEveryFlushSite for (NSString* latex in @[ @"f\\sqrt{x}", @"f\\sum x", @"f\\,x", @"f\\frac{1}{2}", @"f\\left(x\\right)", @"f\\color{#ff0000}{x}" ]) { MTMathListDisplay* display = [self displayForLaTeX:latex]; - MTCTLineDisplay* line = display.subDisplays[0]; + MTCTLineDisplay* line = (MTCTLineDisplay*) display.subDisplays[0]; XCTAssertTrue([line isKindOfClass:[MTCTLineDisplay class]], @"%@", latex); XCTAssertEqualWithAccuracy(line.width, expected, 0.001, @"%@", latex); } @@ -260,7 +260,7 @@ - (void) testTrailingCorrectionSurvivesEveryFlushSite // Ordinary -> Radical takes no inter-element space, so the next display // starts exactly where the corrected line ends. MTMathListDisplay* radical = [self displayForLaTeX:@"f\\sqrt{x}"]; - MTCTLineDisplay* line = radical.subDisplays[0]; + MTCTLineDisplay* line = (MTCTLineDisplay*) radical.subDisplays[0]; MTDisplay* next = radical.subDisplays[1]; XCTAssertEqualWithAccuracy(next.position.x, line.position.x + line.width, 0.001); } @@ -278,25 +278,26 @@ - (void) testScriptedAndScriptlessGlyphsAgree // Superscript: the correction shifts the script instead, and the base line // keeps its bare advance — applied once, not twice. MTMathListDisplay* sup = [self displayForLaTeX:@"f^a"]; - MTCTLineDisplay* supBase = sup.subDisplays[0]; + MTCTLineDisplay* supBase = (MTCTLineDisplay*) sup.subDisplays[0]; XCTAssertEqualWithAccuracy(supBase.width, advance, 0.001); XCTAssertEqualWithAccuracy(sup.subDisplays[1].position.x - supBase.width, f, 0.001); // Subscript: the base does not advance by the correction. MTMathListDisplay* sub = [self displayForLaTeX:@"f_a"]; - MTCTLineDisplay* subBase = sub.subDisplays[0]; + MTCTLineDisplay* subBase = (MTCTLineDisplay*) sub.subDisplays[0]; XCTAssertEqualWithAccuracy(subBase.width, advance, 0.001); XCTAssertEqualWithAccuracy(sub.subDisplays[1].position.x, subBase.width, 0.001); // Both scripts: the superscript carries the correction, the subscript does not. MTMathListDisplay* both = [self displayForLaTeX:@"f_a^b"]; - MTCTLineDisplay* bothBase = both.subDisplays[0]; + MTCTLineDisplay* bothBase = (MTCTLineDisplay*) both.subDisplays[0]; MTDisplay* superscript = nil; MTDisplay* subscript = nil; - for (MTMathListDisplay* d in both.subDisplays) { + for (MTDisplay* d in both.subDisplays) { if (![d isKindOfClass:[MTMathListDisplay class]]) { continue; } - if (d.type == kMTLinePositionSuperscript) { superscript = d; } - if (d.type == kMTLinePositionSubscript) { subscript = d; } + MTMathListDisplay* script = (MTMathListDisplay*) d; + if (script.type == kMTLinePositionSuperscript) { superscript = script; } + if (script.type == kMTLinePositionSubscript) { subscript = script; } } XCTAssertEqualWithAccuracy(superscript.position.x - subscript.position.x, f, 0.001); XCTAssertEqualWithAccuracy(subscript.position.x, bothBase.width, 0.001); @@ -307,7 +308,7 @@ - (void) testScriptedAndScriptlessGlyphsAgree - (void) testFusedAtomWithAScriptOnItsLastCharacter { MTMathListDisplay* display = [self displayForLaTeX:@"Vt^2"]; - MTCTLineDisplay* line = display.subDisplays[0]; + MTCTLineDisplay* line = (MTCTLineDisplay*) display.subDisplays[0]; // Vt fuses to one atom; V is interior and corrected, t has no correction. XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], [self mathItalicCorrectionOf:@"\U0001D449"], 0.001); @@ -344,7 +345,7 @@ - (void) testCompanionCorrectionAcrossAllBundledFonts MTFont* font = [MTFontManager.fontManager fontWithName:name size:20]; MTMathListDisplay* display = [self displayForLaTeX:@"\\mathit{fVf}" withFont:font]; XCTAssertEqual(display.subDisplays.count, 1, @"%@", name); - MTCTLineDisplay* line = display.subDisplays[0]; + MTCTLineDisplay* line = (MTCTLineDisplay*) display.subDisplays[0]; // Trailing-only, whatever the face measures. XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], 0, 0.001, @"%@", name); @@ -368,7 +369,7 @@ - (void) testMathFontCorrectionIsFontParameterised MTFontNameSTIXTwo, MTFontNameFiraMath, MTFontNameNotoSansMath ]) { MTFont* font = [MTFontManager.fontManager fontWithName:name size:20]; MTMathListDisplay* display = [self displayForLaTeX:@"fVf" withFont:font]; - MTCTLineDisplay* line = display.subDisplays[0]; + MTCTLineDisplay* line = (MTCTLineDisplay*) display.subDisplays[0]; XCTAssertEqualWithAccuracy([self kernOf:line atIndex:0], [self mathItalicCorrectionOf:@"\U0001D453" inFont:font], 0.001, @"%@", name); XCTAssertEqualWithAccuracy([self kernOf:line atIndex:2], @@ -384,7 +385,7 @@ - (void) testCorrectionStacksOnNativePairKerning { MTFont* newcm = [MTFontManager.fontManager fontWithName:MTFontNameNewComputerModern size:20]; MTMathListDisplay* display = [self displayForLaTeX:@"B." withFont:newcm]; - MTCTLineDisplay* line = display.subDisplays[0]; + MTCTLineDisplay* line = (MTCTLineDisplay*) display.subDisplays[0]; NSMutableAttributedString* unkerned = [line.attributedString mutableCopy]; [unkerned removeAttribute:(NSString*) kCTKernAttributeName range:NSMakeRange(0, unkerned.length)]; @@ -447,12 +448,12 @@ - (void) testMathAndTextModeDifferencesAreUnchanged XCTAssertEqualObjects(math.attributedString.string, @"ab"); MTMathListDisplay* text = [self displayForLaTeX:@"\\text{a b}"]; - MTTextDisplay* textDisplay = text.subDisplays[0]; + MTTextDisplay* textDisplay = (MTTextDisplay*) text.subDisplays[0]; XCTAssertEqualObjects(textDisplay.text, @"a b"); MTMathListDisplay* scripted = [self displayForLaTeX:@"x^{\\text{ab}}"]; - MTMathListDisplay* superscript = scripted.subDisplays[1]; - MTTextDisplay* scriptedText = superscript.subDisplays[0]; + MTMathListDisplay* superscript = (MTMathListDisplay*) scripted.subDisplays[1]; + MTTextDisplay* scriptedText = (MTTextDisplay*) superscript.subDisplays[0]; XCTAssertLessThan(scriptedText.ascent, textDisplay.ascent); }